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.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +47 -0
  3. data/.yardopts +5 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +163 -0
  6. data/Rakefile +91 -0
  7. data/benchmark/RESULTS.md +16 -0
  8. data/benchmark/parser.rb +53 -0
  9. data/docs/CLI_SMOKE_TEST.md +29 -0
  10. data/exe/openusd +6 -0
  11. data/lib/openusd/asset_resolver.rb +70 -0
  12. data/lib/openusd/attribute.rb +103 -0
  13. data/lib/openusd/attribute_spec.rb +120 -0
  14. data/lib/openusd/cli.rb +115 -0
  15. data/lib/openusd/composition.rb +190 -0
  16. data/lib/openusd/errors.rb +41 -0
  17. data/lib/openusd/format/registry.rb +52 -0
  18. data/lib/openusd/format/usda/lexer.rb +258 -0
  19. data/lib/openusd/format/usda/parser.rb +338 -0
  20. data/lib/openusd/format/usda/property_merger.rb +47 -0
  21. data/lib/openusd/format/usda/reference_metadata.rb +37 -0
  22. data/lib/openusd/format/usda/writer.rb +296 -0
  23. data/lib/openusd/format/usdz/reader.rb +195 -0
  24. data/lib/openusd/format/usdz/writer.rb +145 -0
  25. data/lib/openusd/layer.rb +126 -0
  26. data/lib/openusd/metadata_view.rb +26 -0
  27. data/lib/openusd/path.rb +158 -0
  28. data/lib/openusd/prim.rb +151 -0
  29. data/lib/openusd/prim_spec.rb +179 -0
  30. data/lib/openusd/relationship.rb +56 -0
  31. data/lib/openusd/relationship_spec.rb +57 -0
  32. data/lib/openusd/schema/base.rb +61 -0
  33. data/lib/openusd/schema/camera.rb +30 -0
  34. data/lib/openusd/schema/material.rb +17 -0
  35. data/lib/openusd/schema/mesh.rb +31 -0
  36. data/lib/openusd/schema/scope.rb +10 -0
  37. data/lib/openusd/schema/xform.rb +49 -0
  38. data/lib/openusd/stage.rb +262 -0
  39. data/lib/openusd/types.rb +168 -0
  40. data/lib/openusd/value.rb +100 -0
  41. data/lib/openusd/version.rb +6 -0
  42. data/lib/openusd.rb +40 -0
  43. metadata +85 -0
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
5
+ module OpenUSD
6
+ module Format
7
+ # USDA text format support.
8
+ module Usda
9
+ # Converts USDA text into location-aware tokens.
10
+ class Lexer
11
+ # One lexeme and its source location.
12
+ Token = Data.define(:type, :value, :line, :column, :raw)
13
+ # Single-character grammar symbols.
14
+ SYMBOLS = "(){}[]=,:."
15
+ # Numeric literal pattern shared by ordinary and fast-path scans.
16
+ NUMERIC_LITERAL = /[+-]?(?:(?:\d+\.\d*|\.\d+|\d+)(?:[eE][+-]?\d+)?|inf|nan)/
17
+ # Supported string escape translations.
18
+ ESCAPES = {
19
+ "n" => "\n", "r" => "\r", "t" => "\t", "b" => "\b",
20
+ "f" => "\f", "\\" => "\\", "\"" => "\""
21
+ }.freeze
22
+
23
+ attr_reader :file
24
+
25
+ def initialize(source, file: nil)
26
+ @scanner = StringScanner.new(String(source).delete_prefix("\uFEFF"))
27
+ @file = file
28
+ @line = 1
29
+ @column = 1
30
+ end
31
+
32
+ # Iterate through all tokens, including the final EOF token.
33
+ def each_token
34
+ return enum_for(__method__) unless block_given?
35
+
36
+ loop do
37
+ token = next_token
38
+ yield token
39
+ break if token.type == :eof
40
+ end
41
+ end
42
+
43
+ # @return [Token] next token from the source
44
+ def next_token
45
+ skip_ignored
46
+ return token(:eof, nil, "") if @scanner.eos?
47
+
48
+ return scan_magic if @scanner.match?(/#usda\b/)
49
+ return scan_quoted("\"\"\"", :string) if @scanner.peek(3) == "\"\"\""
50
+ return scan_quoted("\"", :string) if @scanner.peek(1) == "\""
51
+ return scan_quoted("@@@", :asset) if @scanner.peek(3) == "@@@"
52
+ return scan_quoted("@", :asset) if @scanner.peek(1) == "@"
53
+ return scan_path if @scanner.peek(1) == "<"
54
+
55
+ scan_bare_token
56
+ end
57
+
58
+ # Scan a flat or tuple-grouped numeric array without allocating tokens.
59
+ # @api private
60
+ # @return [Array, nil] values, or nil when the input needs normal lexing
61
+ def scan_numeric_array(component_count: nil)
62
+ rest = @scanner.rest
63
+ closing = rest.index("]")
64
+ return unless closing
65
+
66
+ body = rest[0...closing]
67
+ literals = body.scan(NUMERIC_LITERAL)
68
+ residue = body.gsub(NUMERIC_LITERAL, "").gsub(/[\s,()]/, "")
69
+ return unless residue.empty?
70
+ return if component_count && (literals.length % component_count).nonzero?
71
+
72
+ raw = rest[0..closing]
73
+ @scanner.pos += raw.bytesize
74
+ advance(raw)
75
+ values = literals.map { |literal| numeric_value(literal) }
76
+ component_count ? values.each_slice(component_count).to_a : values
77
+ end
78
+
79
+ private
80
+
81
+ def scan_bare_token
82
+ return scan_special_number if @scanner.match?(/[+-]?(?:inf|nan)\b/)
83
+ return scan_number if @scanner.match?(/[+-]?(?:\d|\.\d)/)
84
+ return scan_identifier if @scanner.match?(/[\p{L}_]/u)
85
+ return scan_symbol if SYMBOLS.include?(@scanner.peek(1))
86
+
87
+ lexical_error!("unexpected character #{@scanner.peek(1).inspect}")
88
+ end
89
+
90
+ def skip_ignored
91
+ loop do
92
+ skipped = scan_ignored_pattern?(/[ \t\r\n]+/)
93
+ skipped ||= scan_line_comment?("#") unless @scanner.match?(/#usda\b/)
94
+ skipped ||= scan_line_comment?("//")
95
+ skipped ||= scan_block_comment?
96
+ break unless skipped
97
+ end
98
+ end
99
+
100
+ def scan_ignored_pattern?(pattern)
101
+ raw = @scanner.scan(pattern)
102
+ return false unless raw
103
+
104
+ advance(raw)
105
+ true
106
+ end
107
+
108
+ def scan_line_comment?(prefix)
109
+ return false unless @scanner.peek(prefix.length) == prefix
110
+
111
+ raw = @scanner.scan(/.*(?:\n|\z)/)
112
+ advance(raw)
113
+ true
114
+ end
115
+
116
+ def scan_block_comment?
117
+ return false unless @scanner.peek(2) == "/*"
118
+
119
+ start_line = @line
120
+ start_column = @column
121
+ raw = @scanner.scan(%r{/\*.*?\*/}m)
122
+ lexical_error!("unterminated block comment", start_line, start_column) unless raw
123
+ advance(raw)
124
+ true
125
+ end
126
+
127
+ def scan_magic
128
+ line = @line
129
+ column = @column
130
+ raw = @scanner.scan(/#usda[ \t]+([0-9]+(?:\.[0-9]+)?)/)
131
+ lexical_error!("invalid USDA header", line, column) unless raw
132
+ version = @scanner[1]
133
+ advance(raw)
134
+ Token.new(:magic, version, line, column, raw)
135
+ end
136
+
137
+ def scan_quoted(delimiter, type)
138
+ line = @line
139
+ column = @column
140
+ raw = consume(delimiter)
141
+ value = +""
142
+
143
+ until @scanner.eos?
144
+ if @scanner.peek(delimiter.length) == delimiter
145
+ raw << consume(delimiter)
146
+ return Token.new(type, value.freeze, line, column, raw.freeze)
147
+ end
148
+
149
+ character = consume_character
150
+ raw << character
151
+ if character == "\\" && type == :string
152
+ escaped = consume_character
153
+ raw << escaped
154
+ value << ESCAPES.fetch(escaped, escaped)
155
+ else
156
+ value << character
157
+ end
158
+ end
159
+
160
+ lexical_error!("unterminated #{type}", line, column)
161
+ end
162
+
163
+ def scan_path
164
+ line = @line
165
+ column = @column
166
+ raw = consume("<")
167
+ value = +""
168
+
169
+ until @scanner.eos?
170
+ character = consume_character
171
+ raw << character
172
+ return Token.new(:path, value.freeze, line, column, raw.freeze) if character == ">"
173
+
174
+ value << character
175
+ end
176
+
177
+ lexical_error!("unterminated path", line, column)
178
+ end
179
+
180
+ def scan_number
181
+ line = @line
182
+ column = @column
183
+ raw = @scanner.scan(/[+-]?(?:(?:\d+\.\d*|\.\d+|\d+)(?:[eE][+-]?\d+)?)/)
184
+ value = raw.match?(/[.eE]/) ? Float(raw) : Integer(raw, 10)
185
+ advance(raw)
186
+ Token.new(:number, value, line, column, raw.freeze)
187
+ end
188
+
189
+ def scan_special_number
190
+ line = @line
191
+ column = @column
192
+ raw = @scanner.scan(/[+-]?(?:inf|nan)\b/)
193
+ value = if raw.include?("nan")
194
+ Float::NAN
195
+ elsif raw.start_with?("-")
196
+ -Float::INFINITY
197
+ else
198
+ Float::INFINITY
199
+ end
200
+ advance(raw)
201
+ Token.new(:number, value, line, column, raw.freeze)
202
+ end
203
+
204
+ def numeric_value(raw)
205
+ return Float::NAN if raw.include?("nan")
206
+ return raw.start_with?("-") ? -Float::INFINITY : Float::INFINITY if raw.include?("inf")
207
+
208
+ raw.match?(/[.eE]/) ? Float(raw) : Integer(raw, 10)
209
+ end
210
+
211
+ def scan_identifier
212
+ line = @line
213
+ column = @column
214
+ raw = @scanner.scan(/[\p{L}_][\p{L}\p{N}_:]*(?:\[\])?/u)
215
+ advance(raw)
216
+ Token.new(:identifier, raw.freeze, line, column, raw.freeze)
217
+ end
218
+
219
+ def scan_symbol
220
+ line = @line
221
+ column = @column
222
+ raw = consume_character
223
+ Token.new(:symbol, raw, line, column, raw)
224
+ end
225
+
226
+ def consume(expected)
227
+ raw = @scanner.scan(Regexp.new(Regexp.escape(expected)))
228
+ advance(raw)
229
+ raw
230
+ end
231
+
232
+ def consume_character
233
+ raw = @scanner.getch
234
+ advance(raw)
235
+ raw
236
+ end
237
+
238
+ def advance(raw)
239
+ lines = raw.count("\n")
240
+ if lines.zero?
241
+ @column += raw.length
242
+ else
243
+ @line += lines
244
+ @column = raw.length - raw.rindex("\n")
245
+ end
246
+ end
247
+
248
+ def token(type, value, raw)
249
+ Token.new(type, value, @line, @column, raw)
250
+ end
251
+
252
+ def lexical_error!(message, line = @line, column = @column)
253
+ raise ParseError.new(message, file: file, line: line, column: column)
254
+ end
255
+ end
256
+ end
257
+ end
258
+ end
@@ -0,0 +1,338 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUSD
4
+ module Format
5
+ module Usda
6
+ # Recursive-descent parser for USDA layers.
7
+ class Parser
8
+ # Authored prim specifier keywords.
9
+ SPECIFIERS = %w[def over class].freeze
10
+ # Recognized list-edit operation keywords.
11
+ LIST_OPERATIONS = %w[prepend append delete reorder add explicit].freeze
12
+ # Recognized property qualifiers.
13
+ QUALIFIERS = %w[custom uniform varying].freeze
14
+
15
+ class << self
16
+ # Parse a USDA string.
17
+ # @return [Layer]
18
+ def parse(source, file: nil)
19
+ new(source, file: file).parse
20
+ end
21
+ end
22
+
23
+ attr_reader :file
24
+
25
+ def initialize(source, file: nil)
26
+ @lexer = Lexer.new(source, file: file)
27
+ @file = file
28
+ advance
29
+ end
30
+
31
+ # Parse the configured source.
32
+ # @return [Layer]
33
+ def parse
34
+ header = expect(:magic, description: "USDA header")
35
+ error!("unsupported USDA version #{header.value}") unless header.value == "1.0"
36
+
37
+ layer = Layer.new(file)
38
+ layer.metadata.merge!(parse_metadata_block(layer_comment: true)) if symbol?("(")
39
+ layer.add_root_prim(parse_prim) until current.type == :eof
40
+ layer
41
+ rescue OpenUSD::PathError, OpenUSD::TypeError => e
42
+ error!(e.message)
43
+ end
44
+
45
+ private
46
+
47
+ attr_reader :current
48
+
49
+ def parse_prim
50
+ specifier = expect_identifier
51
+ error!("expected prim specifier") unless SPECIFIERS.include?(specifier)
52
+
53
+ type_name = expect_identifier unless current.type == :string
54
+ name = expect(:string, description: "prim name").value
55
+ metadata = symbol?("(") ? parse_metadata_block : {}
56
+ references, reference_list_op, references_authored = ReferenceMetadata.extract(metadata)
57
+ prim = PrimSpec.new(
58
+ name,
59
+ type_name: type_name,
60
+ specifier: specifier,
61
+ metadata: metadata,
62
+ references: references_authored ? references : PrimSpec::REFERENCES_UNAUTHORED,
63
+ reference_list_op: reference_list_op
64
+ )
65
+ parse_prim_body(prim)
66
+ prim
67
+ end
68
+
69
+ def parse_prim_body(prim)
70
+ expect_symbol("{")
71
+ until accept_symbol?("}")
72
+ if specifier?
73
+ prim.add_child(parse_prim)
74
+ elsif identifier?("variantSet")
75
+ parse_variant_set(prim)
76
+ else
77
+ PropertyMerger.merge(prim, parse_property)
78
+ end
79
+ end
80
+ end
81
+
82
+ def parse_variant_set(prim)
83
+ advance
84
+ name = expect(:string, description: "variant set name").value
85
+ expect_symbol("=")
86
+ expect_symbol("{")
87
+ choices = {}
88
+ until accept_symbol?("}")
89
+ choice = expect(:string, description: "variant name").value
90
+ holder = PrimSpec.new("_variant")
91
+ parse_prim_body(holder)
92
+ choices[choice] = Variant.new(holder.properties.freeze, holder.children.freeze)
93
+ end
94
+ prim.variant_sets[name] = choices
95
+ end
96
+
97
+ def parse_property
98
+ qualifiers = []
99
+ qualifiers << consume_identifier while current.type == :identifier && QUALIFIERS.include?(current.value)
100
+
101
+ return parse_relationship(qualifiers) if identifier?("rel")
102
+
103
+ type_name = expect_identifier
104
+ name = expect_identifier
105
+ suffix = parse_property_suffix
106
+ value = parse_assigned_value(type_name)
107
+ metadata = symbol?("(") ? parse_metadata_block : {}
108
+ build_attribute(name, type_name, suffix, value, qualifiers, metadata)
109
+ end
110
+
111
+ def parse_relationship(qualifiers)
112
+ advance
113
+ name = expect_identifier
114
+ value = accept_symbol?("=") ? parse_value : RelationshipSpec::UNAUTHORED
115
+ metadata = symbol?("(") ? parse_metadata_block : {}
116
+ targets = value.equal?(RelationshipSpec::UNAUTHORED) ? value : unwrap_list(value).compact
117
+ RelationshipSpec.new(name, targets: targets, custom: qualifiers.include?("custom"), metadata: metadata)
118
+ end
119
+
120
+ def parse_property_suffix
121
+ return unless accept_symbol?(".")
122
+
123
+ expect_identifier
124
+ end
125
+
126
+ def parse_assigned_value(type_name)
127
+ return AttributeSpec::UNAUTHORED unless accept_symbol?("=")
128
+
129
+ parse_value(type_name)
130
+ end
131
+
132
+ def build_attribute(name, type_name, suffix, value, qualifiers, metadata)
133
+ attribute = AttributeSpec.new(
134
+ name, type_name,
135
+ variability: qualifiers.include?("uniform") ? :uniform : :varying,
136
+ custom: qualifiers.include?("custom"),
137
+ metadata: metadata
138
+ )
139
+ apply_attribute_value(attribute, suffix, value)
140
+ attribute
141
+ end
142
+
143
+ def apply_attribute_value(attribute, suffix, value)
144
+ case suffix
145
+ when "timeSamples"
146
+ attribute.time_samples = value
147
+ when "connect"
148
+ attribute.connections = unwrap_list(value)
149
+ when nil
150
+ attribute.set(value) unless value.equal?(AttributeSpec::UNAUTHORED)
151
+ else
152
+ attribute.metadata[suffix] = value
153
+ end
154
+ end
155
+
156
+ def parse_metadata_block(layer_comment: false)
157
+ expect_symbol("(")
158
+ metadata = {}
159
+ until accept_symbol?(")")
160
+ if layer_comment && current.type == :string
161
+ error!("duplicate layer comment") if metadata.key?("comment")
162
+
163
+ metadata["comment"] = consume.value
164
+ else
165
+ metadata.merge!(parse_metadata_entry)
166
+ end
167
+ end
168
+ metadata
169
+ end
170
+
171
+ def parse_metadata_entry
172
+ operation = consume_identifier if current.type == :identifier && LIST_OPERATIONS.include?(current.value)
173
+ first = expect_identifier
174
+ type_name, key = metadata_type_and_key(first)
175
+ expect_symbol("=")
176
+ value = parse_value(type_name)
177
+ value = ListOp.new(operation, value) if operation
178
+ { key => value }
179
+ end
180
+
181
+ def metadata_type_and_key(first)
182
+ return [nil, first] unless current.type == :identifier
183
+
184
+ [first, consume_identifier]
185
+ end
186
+
187
+ def parse_value(expected_type = nil)
188
+ case current.type
189
+ when :string, :number then consume.value
190
+ when :asset then parse_asset
191
+ when :path then Path.parse(consume.value)
192
+ when :identifier then parse_identifier_value
193
+ when :symbol then parse_compound_value(expected_type)
194
+ else error!("expected a value")
195
+ end
196
+ end
197
+
198
+ def parse_asset
199
+ asset = AssetPath.new(consume.value)
200
+ return asset unless current.type == :path
201
+
202
+ Reference.new(asset, Path.parse(consume.value))
203
+ end
204
+
205
+ def parse_identifier_value
206
+ value = consume.value
207
+ return true if value == "true"
208
+ return false if value == "false"
209
+ return nil if value == "None"
210
+ return Float::INFINITY if value == "inf"
211
+ return -Float::INFINITY if value == "-inf"
212
+ return Float::NAN if value == "nan"
213
+
214
+ Token.new(value)
215
+ end
216
+
217
+ def parse_compound_value(expected_type)
218
+ return parse_sequence("(", ")", expected_type) if symbol?("(")
219
+ return parse_array(expected_type) if symbol?("[")
220
+ return parse_dictionary if symbol?("{")
221
+
222
+ error!("expected a value")
223
+ end
224
+
225
+ def parse_array(expected_type)
226
+ base_type = Types.base_type(expected_type.to_s)
227
+ component_count = Types::VECTOR_TYPES[base_type]
228
+ numeric = component_count || Types::FLOAT_TYPES.include?(base_type) ||
229
+ Types::INTEGER_RANGES.key?(base_type)
230
+ return parse_sequence("[", "]", base_type) unless numeric
231
+
232
+ values = @lexer.scan_numeric_array(component_count: component_count)
233
+ return parse_sequence("[", "]", base_type) unless values
234
+
235
+ advance
236
+ values
237
+ end
238
+
239
+ def parse_sequence(opening, closing, expected_type)
240
+ expect_symbol(opening)
241
+ values = []
242
+ until accept_symbol?(closing)
243
+ values << parse_value(expected_type)
244
+ accept_symbol?(",")
245
+ end
246
+ values
247
+ end
248
+
249
+ def parse_dictionary
250
+ expect_symbol("{")
251
+ values = {}
252
+ until accept_symbol?("}")
253
+ key, type_name, separator = parse_dictionary_key
254
+ expect_symbol(separator)
255
+ values[key] = parse_value(type_name)
256
+ accept_symbol?(",")
257
+ end
258
+ values
259
+ end
260
+
261
+ def parse_dictionary_key
262
+ return [consume.value, nil, ":"] if current.type == :number
263
+
264
+ first = consume
265
+ error!("expected dictionary key") unless %i[string identifier].include?(first.type)
266
+ return [first.value, nil, current.value] if symbol?(":") || symbol?("=")
267
+
268
+ [expect_identifier, first.value, "="]
269
+ end
270
+
271
+ def unwrap_list(value)
272
+ value = value.value if value.is_a?(ListOp)
273
+ value.is_a?(Array) ? value : [value]
274
+ end
275
+
276
+ def specifier? = current.type == :identifier && SPECIFIERS.include?(current.value)
277
+
278
+ def identifier?(value) = current.type == :identifier && current.value == value
279
+
280
+ def symbol?(value) = current.type == :symbol && current.value == value
281
+
282
+ def accept_symbol?(value)
283
+ return false unless symbol?(value)
284
+
285
+ advance
286
+ true
287
+ end
288
+
289
+ def expect_symbol(value)
290
+ expect(:symbol, value: value, description: value)
291
+ end
292
+
293
+ def expect_identifier
294
+ expect(:identifier, description: "identifier").value
295
+ end
296
+
297
+ def consume_identifier
298
+ expect_identifier
299
+ end
300
+
301
+ def expect(type, value: nil, description: type)
302
+ token = current
303
+ matches = token.type == type && (value.nil? || token.value == value)
304
+ error!("expected #{description}, got #{token.raw.inspect}") unless matches
305
+ advance
306
+ token
307
+ end
308
+
309
+ def consume
310
+ token = current
311
+ advance
312
+ token
313
+ end
314
+
315
+ def advance
316
+ @current = @lexer.next_token
317
+ end
318
+
319
+ def error!(message)
320
+ raise ParseError.new(message, file: file, line: current.line, column: current.column)
321
+ end
322
+ end
323
+
324
+ # File reader adapter for the format registry.
325
+ module Reader
326
+ module_function
327
+
328
+ # Read a USDA layer from a filesystem path.
329
+ # @return [Layer]
330
+ def read(path)
331
+ Parser.parse(File.binread(path).force_encoding(Encoding::UTF_8), file: path)
332
+ rescue Errno::ENOENT
333
+ raise CompositionError, "layer not found: #{path}"
334
+ end
335
+ end
336
+ end
337
+ end
338
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUSD
4
+ module Format
5
+ module Usda
6
+ # Combines fields from repeated USDA declarations into one property spec.
7
+ module PropertyMerger
8
+ module_function
9
+
10
+ # Merge a parsed property into its owning prim.
11
+ # @return [AttributeSpec, RelationshipSpec]
12
+ def merge(prim, property)
13
+ existing = prim.property_named(property.name)
14
+ return prim.add_property(property) unless existing
15
+
16
+ unless existing.instance_of?(property.class)
17
+ raise OpenUSD::TypeError, "property #{property.name} is declared with conflicting kinds"
18
+ end
19
+
20
+ existing.is_a?(AttributeSpec) ? merge_attribute(existing, property) : merge_relationship(existing, property)
21
+ existing
22
+ end
23
+
24
+ def merge_attribute(existing, property)
25
+ unless existing.type_name == property.type_name
26
+ raise OpenUSD::TypeError, "attribute #{property.name} is declared with conflicting types"
27
+ end
28
+
29
+ existing.custom ||= property.custom
30
+ existing.variability = :uniform if property.variability == :uniform
31
+ existing.metadata.merge!(property.metadata)
32
+ existing.set(property.default) if property.default_authored?
33
+ existing.time_samples = property.time_samples if property.time_samples_authored?
34
+ existing.connections = property.connections if property.connections_authored?
35
+ end
36
+ private_class_method :merge_attribute
37
+
38
+ def merge_relationship(existing, property)
39
+ existing.custom ||= property.custom
40
+ existing.metadata.merge!(property.metadata)
41
+ existing.targets = property.targets if property.targets_authored?
42
+ end
43
+ private_class_method :merge_relationship
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUSD
4
+ module Format
5
+ module Usda
6
+ # Converts a USDA references metadata field to model references.
7
+ module ReferenceMetadata
8
+ module_function
9
+
10
+ # Remove and normalize references metadata.
11
+ # @return [Array(Array<Reference>, Symbol, Boolean)]
12
+ def extract(metadata)
13
+ return [[], nil, false] unless metadata.key?("references")
14
+
15
+ value = metadata.delete("references")
16
+ operation = value.operation if value.is_a?(ListOp)
17
+ value = value.value if value.is_a?(ListOp)
18
+ references = Array(unwrap_list(value)).map { |reference| normalize(reference) }
19
+ [references, operation, true]
20
+ end
21
+
22
+ def normalize(reference)
23
+ return Reference.internal(reference) if reference.is_a?(Path)
24
+ return reference if reference.is_a?(Reference)
25
+
26
+ Reference.new(reference)
27
+ end
28
+ private_class_method :normalize
29
+
30
+ def unwrap_list(value)
31
+ value.is_a?(Array) ? value : [value]
32
+ end
33
+ private_class_method :unwrap_list
34
+ end
35
+ end
36
+ end
37
+ end