openusd 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rubocop.yml +47 -0
- data/.yardopts +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +163 -0
- data/Rakefile +91 -0
- data/benchmark/RESULTS.md +16 -0
- data/benchmark/parser.rb +53 -0
- data/docs/CLI_SMOKE_TEST.md +29 -0
- data/exe/openusd +6 -0
- data/lib/openusd/asset_resolver.rb +70 -0
- data/lib/openusd/attribute.rb +103 -0
- data/lib/openusd/attribute_spec.rb +120 -0
- data/lib/openusd/cli.rb +115 -0
- data/lib/openusd/composition.rb +190 -0
- data/lib/openusd/errors.rb +41 -0
- data/lib/openusd/format/registry.rb +52 -0
- data/lib/openusd/format/usda/lexer.rb +258 -0
- data/lib/openusd/format/usda/parser.rb +338 -0
- data/lib/openusd/format/usda/property_merger.rb +47 -0
- data/lib/openusd/format/usda/reference_metadata.rb +37 -0
- data/lib/openusd/format/usda/writer.rb +296 -0
- data/lib/openusd/format/usdz/reader.rb +195 -0
- data/lib/openusd/format/usdz/writer.rb +145 -0
- data/lib/openusd/layer.rb +126 -0
- data/lib/openusd/metadata_view.rb +26 -0
- data/lib/openusd/path.rb +158 -0
- data/lib/openusd/prim.rb +151 -0
- data/lib/openusd/prim_spec.rb +179 -0
- data/lib/openusd/relationship.rb +56 -0
- data/lib/openusd/relationship_spec.rb +57 -0
- data/lib/openusd/schema/base.rb +61 -0
- data/lib/openusd/schema/camera.rb +30 -0
- data/lib/openusd/schema/material.rb +17 -0
- data/lib/openusd/schema/mesh.rb +31 -0
- data/lib/openusd/schema/scope.rb +10 -0
- data/lib/openusd/schema/xform.rb +49 -0
- data/lib/openusd/stage.rb +262 -0
- data/lib/openusd/types.rb +168 -0
- data/lib/openusd/value.rb +100 -0
- data/lib/openusd/version.rb +6 -0
- data/lib/openusd.rb +40 -0
- metadata +85 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenUSD
|
|
4
|
+
# A single authored USD layer.
|
|
5
|
+
class Layer
|
|
6
|
+
attr_reader :identifier, :root_prims, :metadata
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
# Create an empty layer.
|
|
10
|
+
# @return [Layer]
|
|
11
|
+
def create(identifier)
|
|
12
|
+
new(identifier)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Open a USDA, USD, or USDZ layer.
|
|
16
|
+
# @return [Layer]
|
|
17
|
+
def open(path)
|
|
18
|
+
if path.to_s.match?(/\.usdz\[[^\]]+\]\z/i)
|
|
19
|
+
require_relative "format/usdz/reader"
|
|
20
|
+
return Format::Usdz::Reader.read_uri(path.to_s)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
expanded = File.expand_path(path)
|
|
24
|
+
Format::Registry.reader_for(expanded).read(expanded)
|
|
25
|
+
rescue Errno::ENOENT
|
|
26
|
+
raise CompositionError, "layer not found: #{expanded}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(identifier = nil, metadata: {}, root_prims: [])
|
|
31
|
+
@identifier = identifier&.to_s
|
|
32
|
+
@metadata = metadata.dup
|
|
33
|
+
@root_prims = []
|
|
34
|
+
@root_prim_index = {}
|
|
35
|
+
root_prims.each { |prim| add_root_prim(prim) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def identifier=(value)
|
|
39
|
+
@identifier = value&.to_s
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def add_root_prim(prim)
|
|
43
|
+
raise OpenUSD::TypeError, "root prim must be a PrimSpec" unless prim.is_a?(PrimSpec)
|
|
44
|
+
raise PathError, "duplicate root prim: #{prim.name}" if @root_prim_index.key?(prim.name)
|
|
45
|
+
|
|
46
|
+
prim.parent = nil
|
|
47
|
+
root_prims << prim
|
|
48
|
+
@root_prim_index[prim.name] = prim
|
|
49
|
+
prim
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Remove and return a root prim by name.
|
|
53
|
+
# @return [PrimSpec, nil]
|
|
54
|
+
def remove_root_prim(name)
|
|
55
|
+
prim = root_prim_named(name)
|
|
56
|
+
root_prims.delete(prim)
|
|
57
|
+
@root_prim_index.delete(name.to_s)
|
|
58
|
+
prim
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @return [PrimSpec, nil] root prim with the requested name
|
|
62
|
+
def root_prim_named(name)
|
|
63
|
+
@root_prim_index[name.to_s]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def prim_at(path)
|
|
67
|
+
parsed = Path.parse(path)
|
|
68
|
+
raise PathError, "prim path required" if parsed.property?
|
|
69
|
+
return nil unless parsed.absolute?
|
|
70
|
+
return nil if parsed.to_s == "/"
|
|
71
|
+
|
|
72
|
+
names = parsed.to_s.delete_prefix("/").split("/")
|
|
73
|
+
names.drop(1).reduce(root_prim_named(names.first)) { |prim, name| prim&.child_named(name) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Traverse authored specs depth-first.
|
|
77
|
+
# @return [Enumerator, Layer]
|
|
78
|
+
def each_prim(&block)
|
|
79
|
+
return enum_for(__method__) unless block
|
|
80
|
+
|
|
81
|
+
root_prims.each do |prim|
|
|
82
|
+
yield prim
|
|
83
|
+
prim.each_descendant(&block)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @return [Array<String>] authored sublayer asset paths
|
|
88
|
+
def sub_layer_paths
|
|
89
|
+
value = metadata["subLayers"]
|
|
90
|
+
value = value.value if value.is_a?(ListOp)
|
|
91
|
+
Array(value).map { |path| path.is_a?(AssetPath) ? path.path : path.to_s }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @return [PrimSpec, nil] authored default prim
|
|
95
|
+
def default_prim
|
|
96
|
+
name = metadata["defaultPrim"]
|
|
97
|
+
name = name.to_s if name
|
|
98
|
+
root_prim_named(name)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def save
|
|
102
|
+
raise Error, "in-memory layer has no identifier" unless identifier
|
|
103
|
+
|
|
104
|
+
export(identifier)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Export using the format selected by the destination extension.
|
|
108
|
+
# @return [Layer]
|
|
109
|
+
def export(path)
|
|
110
|
+
Format::Registry.writer_for(path).write(self, path)
|
|
111
|
+
self
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @return [String] deterministic USDA representation
|
|
115
|
+
def to_usda
|
|
116
|
+
require_relative "format/usda/writer"
|
|
117
|
+
Format::Usda::Writer.new.write_to_string(self)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Compare authored semantic content, ignoring identifiers.
|
|
121
|
+
def ==(other)
|
|
122
|
+
other.is_a?(self.class) && metadata == other.metadata &&
|
|
123
|
+
root_prims.map(&:to_h) == other.root_prims.map(&:to_h)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenUSD
|
|
4
|
+
# Hash-compatible composed metadata that authors mutations through a callback.
|
|
5
|
+
class MetadataView < Hash
|
|
6
|
+
def initialize(values, writer:)
|
|
7
|
+
super()
|
|
8
|
+
values.each { |key, value| store(key.to_s, value) }
|
|
9
|
+
@writer = writer
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Author a metadata value through the owning view.
|
|
13
|
+
def []=(key, value)
|
|
14
|
+
normalized = key.to_s
|
|
15
|
+
@writer.call(:set, normalized, value)
|
|
16
|
+
store(normalized, value)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Delete an authored metadata value.
|
|
20
|
+
def delete(key)
|
|
21
|
+
normalized = key.to_s
|
|
22
|
+
@writer.call(:delete, normalized, nil)
|
|
23
|
+
super(normalized)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/openusd/path.rb
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenUSD
|
|
4
|
+
# Immutable SdfPath-like value object for prim and property paths.
|
|
5
|
+
class Path
|
|
6
|
+
include Comparable
|
|
7
|
+
|
|
8
|
+
# Reusable USD identifier fragment.
|
|
9
|
+
IDENTIFIER = /[\p{L}_][\p{L}\p{N}_]*/u
|
|
10
|
+
# Validation expression for prim names.
|
|
11
|
+
PRIM_NAME = /\A#{IDENTIFIER}\z/u
|
|
12
|
+
# Validation expression for namespaced property names.
|
|
13
|
+
PROPERTY_NAME = /\A#{IDENTIFIER}(?::#{IDENTIFIER})*\z/u
|
|
14
|
+
|
|
15
|
+
attr_reader :property_name
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
# Parse a path string.
|
|
19
|
+
# @param value [String, Path]
|
|
20
|
+
# @return [Path]
|
|
21
|
+
# @raise [PathError] if the path is malformed
|
|
22
|
+
def parse(value)
|
|
23
|
+
return value if value.is_a?(self)
|
|
24
|
+
|
|
25
|
+
new(String(value))
|
|
26
|
+
rescue ArgumentError, TypeError
|
|
27
|
+
raise PathError, "path must be a string or Path"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(value)
|
|
32
|
+
@value = value.dup.freeze
|
|
33
|
+
@property_name = split_property
|
|
34
|
+
validate!
|
|
35
|
+
freeze
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Whether this path begins at the pseudo-root.
|
|
39
|
+
def absolute?
|
|
40
|
+
@value.start_with?("/")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Whether this identifies a property rather than a prim.
|
|
44
|
+
def property?
|
|
45
|
+
!property_name.nil?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Return the prim portion of this path.
|
|
49
|
+
# @return [Path]
|
|
50
|
+
def prim_path
|
|
51
|
+
return self unless property?
|
|
52
|
+
|
|
53
|
+
self.class.parse(@value.delete_suffix(".#{property_name}"))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Return the namespace parent, or nil when no parent exists.
|
|
57
|
+
# @return [Path, nil]
|
|
58
|
+
def parent
|
|
59
|
+
return prim_path if property?
|
|
60
|
+
return nil if @value == "/"
|
|
61
|
+
|
|
62
|
+
separator = @value.rindex("/")
|
|
63
|
+
return nil unless separator
|
|
64
|
+
return self.class.parse("/") if separator.zero?
|
|
65
|
+
|
|
66
|
+
self.class.parse(@value[0...separator])
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Append a child prim name.
|
|
70
|
+
# @param name [String]
|
|
71
|
+
# @return [Path]
|
|
72
|
+
def child(name)
|
|
73
|
+
raise PathError, "a property path cannot have prim children" if property?
|
|
74
|
+
|
|
75
|
+
child_name = String(name)
|
|
76
|
+
raise PathError, "invalid prim name: #{child_name.inspect}" unless PRIM_NAME.match?(child_name)
|
|
77
|
+
|
|
78
|
+
separator = @value == "/" ? "" : "/"
|
|
79
|
+
self.class.parse("#{@value}#{separator}#{child_name}")
|
|
80
|
+
rescue ArgumentError, TypeError
|
|
81
|
+
raise PathError, "child name must be a string"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Append a property name.
|
|
85
|
+
# @param name [String]
|
|
86
|
+
# @return [Path]
|
|
87
|
+
def property(name)
|
|
88
|
+
raise PathError, "a property path cannot have another property" if property?
|
|
89
|
+
|
|
90
|
+
property = String(name)
|
|
91
|
+
raise PathError, "invalid property name: #{property.inspect}" unless PROPERTY_NAME.match?(property)
|
|
92
|
+
|
|
93
|
+
self.class.parse("#{@value}.#{property}")
|
|
94
|
+
rescue ArgumentError, TypeError
|
|
95
|
+
raise PathError, "property name must be a string"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Compare paths by their canonical string form.
|
|
99
|
+
def <=>(other)
|
|
100
|
+
@value <=> self.class.parse(other).to_s
|
|
101
|
+
rescue PathError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def eql?(other)
|
|
106
|
+
other.is_a?(self.class) && @value == other.to_s
|
|
107
|
+
end
|
|
108
|
+
alias == eql?
|
|
109
|
+
|
|
110
|
+
# @return [Integer] value hash compatible with {#eql?}
|
|
111
|
+
def hash
|
|
112
|
+
@value.hash
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @return [String] canonical path text
|
|
116
|
+
def to_s
|
|
117
|
+
@value
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# @return [String] developer representation
|
|
121
|
+
def inspect
|
|
122
|
+
"#<#{self.class} #{@value.inspect}>"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def split_property
|
|
128
|
+
index = @value.index(".")
|
|
129
|
+
return unless index
|
|
130
|
+
|
|
131
|
+
@value[(index + 1)..]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def validate!
|
|
135
|
+
raise PathError, "path cannot be empty" if @value.empty?
|
|
136
|
+
return if @value == "/"
|
|
137
|
+
|
|
138
|
+
validate_prim_path!
|
|
139
|
+
return unless property?
|
|
140
|
+
raise PathError, "property path requires a prim" if raw_prim_path.empty?
|
|
141
|
+
raise PathError, "invalid property name: #{property_name.inspect}" unless PROPERTY_NAME.match?(property_name)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def validate_prim_path!
|
|
145
|
+
prim = raw_prim_path
|
|
146
|
+
raise PathError, "path cannot end with /" if prim.end_with?("/")
|
|
147
|
+
raise PathError, "path cannot contain an empty component" if prim.include?("//")
|
|
148
|
+
|
|
149
|
+
components = prim.delete_prefix("/").split("/")
|
|
150
|
+
invalid = components.find { |part| !PRIM_NAME.match?(part) }
|
|
151
|
+
raise PathError, "invalid prim name: #{invalid.inspect}" if invalid
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def raw_prim_path
|
|
155
|
+
property? ? @value[0...@value.index(".")] : @value
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
data/lib/openusd/prim.rb
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenUSD
|
|
4
|
+
# Composed prim view on a Stage.
|
|
5
|
+
class Prim
|
|
6
|
+
attr_reader :stage, :path
|
|
7
|
+
|
|
8
|
+
def initialize(stage, path)
|
|
9
|
+
@stage = stage
|
|
10
|
+
@path = Path.parse(path)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# @return [String] final path component
|
|
14
|
+
def name
|
|
15
|
+
path.to_s.split("/").last
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @return [String, nil] strongest authored type name
|
|
19
|
+
def type_name
|
|
20
|
+
opinions.filter_map(&:type_name).find { |value| !value.empty? }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Author a type name in the edit target.
|
|
24
|
+
def type_name=(value)
|
|
25
|
+
stage.set_prim_type(path, value)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [Prim, PseudoRoot, nil] composed namespace parent
|
|
29
|
+
def parent
|
|
30
|
+
parent_path = path.parent
|
|
31
|
+
return stage.pseudo_root if parent_path&.to_s == "/"
|
|
32
|
+
return unless parent_path
|
|
33
|
+
|
|
34
|
+
stage.prim_at(parent_path)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Array<Prim>] active direct children
|
|
38
|
+
def children
|
|
39
|
+
stage.child_paths(path).filter_map { |child_path| stage.prim_at(child_path) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [Attribute, nil] composed attribute by name
|
|
43
|
+
def attribute(name)
|
|
44
|
+
properties = property_opinions(name)
|
|
45
|
+
return unless properties.first.is_a?(AttributeSpec)
|
|
46
|
+
|
|
47
|
+
Attribute.new(self, name)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Create or return an attribute in the edit target.
|
|
51
|
+
# @return [Attribute]
|
|
52
|
+
def create_attribute(name, type_name)
|
|
53
|
+
stage.author_attribute(path, name, type_name)
|
|
54
|
+
Attribute.new(self, name)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [Relationship, nil] composed relationship by name
|
|
58
|
+
def relationship(name)
|
|
59
|
+
properties = property_opinions(name)
|
|
60
|
+
return unless properties.first.is_a?(RelationshipSpec)
|
|
61
|
+
|
|
62
|
+
Relationship.new(self, name)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Create or return a relationship in the edit target.
|
|
66
|
+
# @return [Relationship]
|
|
67
|
+
def create_relationship(name)
|
|
68
|
+
stage.author_relationship(path, name)
|
|
69
|
+
Relationship.new(self, name)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def active?
|
|
73
|
+
authored = opinions.find { |opinion| opinion.metadata.key?("active") }
|
|
74
|
+
authored ? authored.metadata["active"] != false : true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @return [MetadataView] composed, writable metadata
|
|
78
|
+
def metadata
|
|
79
|
+
values = opinions.reverse_each.with_object({}) { |opinion, result| result.merge!(opinion.metadata) }
|
|
80
|
+
MetadataView.new(values, writer: method(:write_metadata))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [Hash] composed variant-set definitions
|
|
84
|
+
def variant_sets
|
|
85
|
+
opinions.reverse_each.with_object({}) do |opinion, result|
|
|
86
|
+
result.merge!(opinion.variant_sets) if opinion.respond_to?(:variant_sets)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Author a variant selection.
|
|
91
|
+
# @return [Prim]
|
|
92
|
+
def set_variant_selection(set_name, choice)
|
|
93
|
+
stage.set_variant_selection(path, set_name, choice)
|
|
94
|
+
self
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @api private
|
|
98
|
+
# @return [Array<AttributeSpec, RelationshipSpec>]
|
|
99
|
+
def property_opinions(name)
|
|
100
|
+
opinions.filter_map { |opinion| opinion.property_named(name) }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
protected
|
|
104
|
+
|
|
105
|
+
# Return all composed opinions for this prim.
|
|
106
|
+
# @api private
|
|
107
|
+
def opinions
|
|
108
|
+
stage.opinions_for(path)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def write_metadata(operation, key, value)
|
|
114
|
+
stage.author_prim_metadata(path, operation, key, value)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Synthetic root for a Stage's root prims.
|
|
119
|
+
class PseudoRoot
|
|
120
|
+
attr_reader :stage
|
|
121
|
+
|
|
122
|
+
def initialize(stage)
|
|
123
|
+
@stage = stage
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# @return [Path] pseudo-root path
|
|
127
|
+
def path
|
|
128
|
+
Path.parse("/")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @return [String] empty pseudo-root name
|
|
132
|
+
def name
|
|
133
|
+
""
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# @return [nil]
|
|
137
|
+
def type_name
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# @return [nil]
|
|
142
|
+
def parent
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# @return [Array<Prim>] active root prims
|
|
147
|
+
def children
|
|
148
|
+
stage.root_paths.filter_map { |root_path| stage.prim_at(root_path) }
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenUSD
|
|
4
|
+
# An authored prim specification in a Layer.
|
|
5
|
+
class PrimSpec
|
|
6
|
+
# Valid authored specifier values.
|
|
7
|
+
SPECIFIERS = %i[def over class].freeze
|
|
8
|
+
# Sentinel distinguishing no reference opinion from an authored empty list.
|
|
9
|
+
REFERENCES_UNAUTHORED = Object.new.freeze
|
|
10
|
+
# Supported reference list-edit operators.
|
|
11
|
+
REFERENCE_LIST_OPERATIONS = %i[prepend append delete reorder add explicit].freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :name, :children, :properties, :metadata, :references, :variant_sets, :specifier,
|
|
14
|
+
:reference_list_op
|
|
15
|
+
attr_accessor :type_name, :parent
|
|
16
|
+
|
|
17
|
+
def initialize(name, type_name: nil, specifier: :def, metadata: {}, references: REFERENCES_UNAUTHORED,
|
|
18
|
+
reference_list_op: :prepend)
|
|
19
|
+
@name = validate_name(name)
|
|
20
|
+
@type_name = type_name&.to_s
|
|
21
|
+
self.specifier = specifier
|
|
22
|
+
@metadata = metadata.dup
|
|
23
|
+
@references = []
|
|
24
|
+
@references_authored = false
|
|
25
|
+
set_references(references, operation: reference_list_op) unless references.equal?(REFERENCES_UNAUTHORED)
|
|
26
|
+
@variant_sets = {}
|
|
27
|
+
@children = []
|
|
28
|
+
@child_index = {}
|
|
29
|
+
@properties = []
|
|
30
|
+
@property_index = {}
|
|
31
|
+
@parent = nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def specifier=(value)
|
|
35
|
+
normalized = value.to_sym
|
|
36
|
+
raise OpenUSD::TypeError, "invalid specifier: #{value.inspect}" unless SPECIFIERS.include?(normalized)
|
|
37
|
+
|
|
38
|
+
@specifier = normalized
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Absolute path within the containing layer.
|
|
42
|
+
def path
|
|
43
|
+
return Path.parse("/#{name}") unless parent
|
|
44
|
+
|
|
45
|
+
parent.path.child(name)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def add_child(child)
|
|
49
|
+
raise OpenUSD::TypeError, "child must be a PrimSpec" unless child.is_a?(PrimSpec)
|
|
50
|
+
raise PathError, "duplicate child prim: #{child.name}" if @child_index.key?(child.name)
|
|
51
|
+
|
|
52
|
+
child.parent = self
|
|
53
|
+
children << child
|
|
54
|
+
@child_index[child.name] = child
|
|
55
|
+
child
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Remove a direct child by name.
|
|
59
|
+
# @return [PrimSpec, nil]
|
|
60
|
+
def remove_child(name)
|
|
61
|
+
child = child_named(name)
|
|
62
|
+
return unless child
|
|
63
|
+
|
|
64
|
+
children.delete(child)
|
|
65
|
+
@child_index.delete(name.to_s)
|
|
66
|
+
child.parent = nil
|
|
67
|
+
child
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [PrimSpec, nil] direct child by name
|
|
71
|
+
def child_named(name)
|
|
72
|
+
@child_index[name.to_s]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def add_property(property)
|
|
76
|
+
valid = property.is_a?(AttributeSpec) || property.is_a?(RelationshipSpec)
|
|
77
|
+
raise OpenUSD::TypeError, "property must be an AttributeSpec or RelationshipSpec" unless valid
|
|
78
|
+
raise PathError, "duplicate property: #{property.name}" if @property_index.key?(property.name)
|
|
79
|
+
|
|
80
|
+
properties << property
|
|
81
|
+
@property_index[property.name] = property
|
|
82
|
+
property
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @return [AttributeSpec, RelationshipSpec, nil] authored property by name
|
|
86
|
+
def property_named(name)
|
|
87
|
+
@property_index[name.to_s]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Remove an authored property by name.
|
|
91
|
+
# @return [AttributeSpec, RelationshipSpec, nil]
|
|
92
|
+
def remove_property(name)
|
|
93
|
+
property = property_named(name)
|
|
94
|
+
properties.delete(property)
|
|
95
|
+
@property_index.delete(name.to_s)
|
|
96
|
+
property
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Add a reference composition arc.
|
|
100
|
+
# @return [Reference]
|
|
101
|
+
def add_reference(asset_path = nil, prim_path = nil)
|
|
102
|
+
reference = Reference.new(asset_path, prim_path)
|
|
103
|
+
references << reference
|
|
104
|
+
@references_authored = true
|
|
105
|
+
@reference_list_op = :prepend if @reference_list_op.nil?
|
|
106
|
+
reference
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Replace the authored references and list-edit operation.
|
|
110
|
+
# @return [Array<Reference>]
|
|
111
|
+
def set_references(values, operation: nil)
|
|
112
|
+
normalized_operation = operation&.to_sym
|
|
113
|
+
unless normalized_operation.nil? || REFERENCE_LIST_OPERATIONS.include?(normalized_operation)
|
|
114
|
+
raise OpenUSD::TypeError, "invalid reference list operation: #{operation.inspect}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
@references = Array(values).map { |reference| normalize_reference(reference) }
|
|
118
|
+
@reference_list_op = normalized_operation
|
|
119
|
+
@references_authored = true
|
|
120
|
+
references
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Whether a reference opinion, including an empty list, is authored.
|
|
124
|
+
def references_authored?
|
|
125
|
+
@references_authored
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Traverse authored descendants depth-first.
|
|
129
|
+
# @return [Enumerator, nil]
|
|
130
|
+
def each_descendant(&block)
|
|
131
|
+
return enum_for(__method__) unless block
|
|
132
|
+
|
|
133
|
+
children.each do |child|
|
|
134
|
+
yield child
|
|
135
|
+
child.each_descendant(&block)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# @return [Hash] semantic representation used for equality
|
|
140
|
+
def to_h
|
|
141
|
+
{
|
|
142
|
+
name: name, type_name: type_name, specifier: specifier,
|
|
143
|
+
metadata: metadata, references: references, references_authored: references_authored?,
|
|
144
|
+
reference_list_op: reference_list_op, variant_sets: variant_sets_to_h,
|
|
145
|
+
properties: semantic_properties(properties), children: children.map(&:to_h)
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
def validate_name(value)
|
|
152
|
+
name = String(value)
|
|
153
|
+
return name.dup.freeze if Path::PRIM_NAME.match?(name)
|
|
154
|
+
|
|
155
|
+
raise PathError, "invalid prim name: #{name.inspect}"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def normalize_reference(value)
|
|
159
|
+
return value if value.is_a?(Reference)
|
|
160
|
+
return Reference.new(*value) if value.is_a?(Array)
|
|
161
|
+
|
|
162
|
+
Reference.new(value)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def variant_sets_to_h
|
|
166
|
+
variant_sets.transform_values do |choices|
|
|
167
|
+
choices.transform_values do |variant|
|
|
168
|
+
next variant unless variant.respond_to?(:properties)
|
|
169
|
+
|
|
170
|
+
{ properties: semantic_properties(variant.properties), children: variant.children.map(&:to_h) }
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def semantic_properties(values)
|
|
176
|
+
values.sort_by(&:name).map(&:to_h)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenUSD
|
|
4
|
+
# Composed relationship view on a Stage.
|
|
5
|
+
class Relationship
|
|
6
|
+
attr_reader :prim, :name
|
|
7
|
+
|
|
8
|
+
def initialize(prim, name)
|
|
9
|
+
@prim = prim
|
|
10
|
+
@name = name.to_s
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# @return [Array<Path>] strongest authored targets
|
|
14
|
+
def targets
|
|
15
|
+
opinions.find(&:targets_authored?)&.targets || []
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Add one target in the edit target.
|
|
19
|
+
# @return [Relationship]
|
|
20
|
+
def add_target(path)
|
|
21
|
+
authored_spec.add_target(path)
|
|
22
|
+
prim.stage.invalidate!
|
|
23
|
+
self
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Replace targets in the edit target.
|
|
27
|
+
# @return [Relationship]
|
|
28
|
+
def set_targets(paths)
|
|
29
|
+
authored_spec.targets = paths
|
|
30
|
+
prim.stage.invalidate!
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [MetadataView] composed, writable metadata
|
|
35
|
+
def metadata
|
|
36
|
+
values = opinions.reverse_each.with_object({}) { |opinion, result| result.merge!(opinion.metadata) }
|
|
37
|
+
MetadataView.new(values, writer: method(:write_metadata))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def opinions
|
|
43
|
+
prim.property_opinions(name).grep(RelationshipSpec)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def authored_spec
|
|
47
|
+
prim.stage.author_relationship(prim.path, name)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def write_metadata(operation, key, value)
|
|
51
|
+
authored = authored_spec.metadata
|
|
52
|
+
operation == :set ? authored[key] = value : authored.delete(key)
|
|
53
|
+
prim.stage.invalidate!
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|