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,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUSD
4
+ # An authored relationship in a Layer.
5
+ class RelationshipSpec
6
+ # Sentinel distinguishing no target opinion from an authored empty list.
7
+ UNAUTHORED = Object.new.freeze
8
+
9
+ attr_reader :name, :targets, :metadata
10
+ attr_accessor :custom
11
+
12
+ def initialize(name, targets: UNAUTHORED, custom: false, metadata: {})
13
+ @name = validate_name(name)
14
+ @custom = custom == true
15
+ @metadata = metadata.dup
16
+ @targets = []
17
+ @targets_authored = false
18
+ self.targets = targets unless targets.equal?(UNAUTHORED)
19
+ end
20
+
21
+ def targets=(paths)
22
+ @targets = Array(paths).map { |path| Path.parse(path) }
23
+ @targets_authored = true
24
+ end
25
+
26
+ # Whether targets, including an empty list, are authored.
27
+ def targets_authored?
28
+ @targets_authored
29
+ end
30
+
31
+ # Add a target unless it is already present.
32
+ # @return [Path]
33
+ def add_target(path)
34
+ target = Path.parse(path)
35
+ @targets << target unless @targets.include?(target)
36
+ @targets_authored = true
37
+ target
38
+ end
39
+
40
+ # @return [Hash] semantic representation used for equality
41
+ def to_h
42
+ {
43
+ name: name, targets: targets, targets_authored: targets_authored?,
44
+ custom: custom, metadata: metadata
45
+ }
46
+ end
47
+
48
+ private
49
+
50
+ def validate_name(value)
51
+ name = String(value)
52
+ return name.dup.freeze if Path::PROPERTY_NAME.match?(name)
53
+
54
+ raise PathError, "invalid relationship name: #{name.inspect}"
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUSD
4
+ # Typed convenience wrappers for commonly authored prim schemas.
5
+ module Schema
6
+ # Base for typed convenience wrappers around Stage prims.
7
+ class Base
8
+ class << self
9
+ attr_reader :type_name
10
+
11
+ # Declare the prim type represented by a wrapper.
12
+ # @api private
13
+ def schema_type(name)
14
+ @type_name = name.freeze
15
+ end
16
+
17
+ # Define and wrap a prim.
18
+ # @return [Base]
19
+ def define(stage, path)
20
+ new(stage.define_prim(path, type_name))
21
+ end
22
+
23
+ # Wrap a prim only when its type matches.
24
+ # @return [Base, nil]
25
+ def get(stage, path)
26
+ prim = stage.prim_at(path)
27
+ return unless prim&.type_name == type_name
28
+
29
+ new(prim)
30
+ end
31
+ end
32
+
33
+ attr_reader :prim
34
+
35
+ def initialize(prim)
36
+ @prim = prim
37
+ end
38
+
39
+ # @return [Stage] owning stage
40
+ def stage
41
+ prim.stage
42
+ end
43
+
44
+ # @return [Path] wrapped prim path
45
+ def path
46
+ prim.path
47
+ end
48
+
49
+ private
50
+
51
+ def get(name)
52
+ prim.attribute(name)&.get
53
+ end
54
+
55
+ def set(name, type_name, value)
56
+ prim.create_attribute(name, type_name).set(value)
57
+ self
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUSD
4
+ module Schema
5
+ # Convenience API for common Camera attributes.
6
+ class Camera < Base
7
+ schema_type "Camera"
8
+
9
+ # @return [Float, nil] focal length in tenths of a scene unit
10
+ def focal_length
11
+ get("focalLength")
12
+ end
13
+
14
+ # Author the focal length.
15
+ def focal_length=(value)
16
+ set("focalLength", "float", value)
17
+ end
18
+
19
+ # @return [Array<Float>, nil] near and far clipping distances
20
+ def clipping_range
21
+ get("clippingRange")
22
+ end
23
+
24
+ # Author near and far clipping distances.
25
+ def clipping_range=(value)
26
+ set("clippingRange", "float2", value)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUSD
4
+ module Schema
5
+ # Convenience API for Material prims.
6
+ class Material < Base
7
+ schema_type "Material"
8
+
9
+ # Bind this material to another prim.
10
+ # @return [Material]
11
+ def bind(geometry_prim)
12
+ geometry_prim.create_relationship("material:binding").set_targets([path])
13
+ self
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUSD
4
+ module Schema
5
+ # Convenience API for core Mesh topology and point attributes.
6
+ class Mesh < Base
7
+ schema_type "Mesh"
8
+
9
+ {
10
+ points: ["points", "point3f[]"],
11
+ normals: ["normals", "normal3f[]"],
12
+ face_vertex_counts: ["faceVertexCounts", "int[]"],
13
+ face_vertex_indices: ["faceVertexIndices", "int[]"],
14
+ extent: ["extent", "float3[]"]
15
+ }.each do |method_name, (attribute_name, type_name)|
16
+ define_method(method_name) { get(attribute_name) }
17
+ define_method("#{method_name}=") { |value| set(attribute_name, type_name, value) }
18
+ end
19
+
20
+ # @return [Token, nil] authored subdivision scheme
21
+ def subdivision_scheme
22
+ get("subdivisionScheme")
23
+ end
24
+
25
+ # Author the subdivision scheme token.
26
+ def subdivision_scheme=(value)
27
+ set("subdivisionScheme", "token", value)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUSD
4
+ module Schema
5
+ # Convenience wrapper for organizational Scope prims.
6
+ class Scope < Base
7
+ schema_type "Scope"
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUSD
4
+ module Schema
5
+ # Convenience API for Xform prims and common transform operations.
6
+ class Xform < Base
7
+ schema_type "Xform"
8
+
9
+ # @return [Array<Float>, nil] translation operation
10
+ def translate
11
+ get("xformOp:translate")
12
+ end
13
+
14
+ # Author the translation operation.
15
+ def translate=(value)
16
+ set_operation("xformOp:translate", "double3", value)
17
+ end
18
+
19
+ # @return [Array<Float>, nil] XYZ rotation operation
20
+ def rotate_xyz
21
+ get("xformOp:rotateXYZ")
22
+ end
23
+
24
+ # Author the XYZ rotation operation.
25
+ def rotate_xyz=(value)
26
+ set_operation("xformOp:rotateXYZ", "float3", value)
27
+ end
28
+
29
+ # @return [Array<Float>, nil] scale operation
30
+ def scale
31
+ get("xformOp:scale")
32
+ end
33
+
34
+ # Author the scale operation.
35
+ def scale=(value)
36
+ set_operation("xformOp:scale", "float3", value)
37
+ end
38
+
39
+ private
40
+
41
+ def set_operation(name, type_name, value)
42
+ set(name, type_name, value)
43
+ order = prim.attribute("xformOpOrder")&.get || []
44
+ prim.create_attribute("xformOpOrder", "token[]").set(order + [name]) unless order.include?(name)
45
+ value
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUSD
4
+ # Composed, editable view of one root layer and its composition arcs.
5
+ class Stage
6
+ attr_reader :root_layer, :resolver, :pseudo_root, :edit_target
7
+
8
+ class << self
9
+ # Open a composed stage.
10
+ # @return [Stage]
11
+ def open(path, missing_assets: :error)
12
+ expanded = File.expand_path(path)
13
+ resolver = AssetResolver.new(missing_assets: missing_assets)
14
+ new(Layer.open(expanded), resolver: resolver)
15
+ end
16
+
17
+ # Create an empty file-backed stage.
18
+ # @return [Stage]
19
+ def create(identifier)
20
+ new(Layer.create(identifier))
21
+ end
22
+
23
+ # Create an anonymous in-memory stage.
24
+ # @return [Stage]
25
+ def create_in_memory
26
+ new(Layer.create("anonymous:#{object_id}:#{Process.clock_gettime(Process::CLOCK_MONOTONIC)}"))
27
+ end
28
+ end
29
+
30
+ def initialize(root_layer, resolver: AssetResolver.new)
31
+ @root_layer = root_layer
32
+ @resolver = resolver
33
+ @edit_target = root_layer
34
+ @pseudo_root = PseudoRoot.new(self)
35
+ @composition = nil
36
+ @index = nil
37
+ @layer_cache = {}
38
+ end
39
+
40
+ def edit_target=(layer)
41
+ raise OpenUSD::TypeError, "edit target must be a Layer" unless layer.is_a?(Layer)
42
+ raise CompositionError, "edit target is not in this stage's layer stack" unless layer_stack.include?(layer)
43
+
44
+ @edit_target = layer
45
+ end
46
+
47
+ # Find an active composed prim.
48
+ # @return [Prim, PseudoRoot, nil]
49
+ def prim_at(path)
50
+ parsed = Path.parse(path)
51
+ return pseudo_root if parsed.to_s == "/"
52
+ return nil if parsed.property?
53
+ return nil unless composed_index.key?(parsed.to_s)
54
+
55
+ prim = Prim.new(self, parsed)
56
+ prim.active? ? prim : nil
57
+ end
58
+
59
+ # @return [Prim, nil] composed default prim
60
+ def default_prim
61
+ name = root_layer.metadata["defaultPrim"]
62
+ prim_at("/#{name}") if name
63
+ end
64
+
65
+ # Define a prim and any missing ancestors in the edit target.
66
+ # @return [Prim]
67
+ def define_prim(path, type_name = nil)
68
+ parsed = validate_prim_path(path)
69
+ spec = ensure_spec(edit_target, parsed)
70
+ spec.specifier = :def
71
+ spec.type_name = type_name.to_s if type_name
72
+ invalidate!
73
+ Prim.new(self, parsed)
74
+ end
75
+
76
+ # Remove or deactivate a prim in the edit target.
77
+ # @return [Stage]
78
+ def remove_prim(path)
79
+ parsed = validate_prim_path(path)
80
+ remove_spec(edit_target, parsed)
81
+ invalidate!
82
+ if composed_index.key?(parsed.to_s)
83
+ blocker = ensure_spec(edit_target, parsed)
84
+ blocker.specifier = :over
85
+ blocker.metadata["active"] = false
86
+ end
87
+ invalidate!
88
+ self
89
+ end
90
+
91
+ # Traverse active prims depth-first.
92
+ # @return [Enumerator, Stage]
93
+ def traverse
94
+ return enum_for(__method__) unless block_given?
95
+
96
+ walk = lambda do |prim|
97
+ yield prim
98
+ prim.children.each { |child| walk.call(child) }
99
+ end
100
+ pseudo_root.children.each { |prim| walk.call(prim) }
101
+ self
102
+ end
103
+
104
+ # Save the root layer to its identifier.
105
+ # @return [Stage]
106
+ def save
107
+ root_layer.save
108
+ self
109
+ end
110
+
111
+ # Export the root layer by destination extension.
112
+ # @return [Stage]
113
+ def export(path)
114
+ root_layer.export(path)
115
+ self
116
+ end
117
+
118
+ # Author a variant choice for a prim.
119
+ # @return [Stage]
120
+ def set_variant_selection(path, set_name, choice)
121
+ prim = prim_at(path)
122
+ raise CompositionError, "prim not found: #{path}" unless prim
123
+
124
+ choices = prim.variant_sets[set_name.to_s]
125
+ raise CompositionError, "variant set not found: #{set_name}" unless choices
126
+ raise CompositionError, "variant not found: #{choice}" unless choices.key?(choice.to_s)
127
+
128
+ spec = ensure_spec(edit_target, Path.parse(path))
129
+ spec.metadata["variants"] ||= {}
130
+ spec.metadata["variants"][set_name.to_s] = choice.to_s
131
+ invalidate!
132
+ self
133
+ end
134
+
135
+ # @api private
136
+ # @return [Array<PrimSpec>] strongest-to-weakest opinions
137
+ def opinions_for(path)
138
+ composed_index.fetch(Path.parse(path).to_s, [])
139
+ end
140
+
141
+ # @api private
142
+ # @return [Array<Path>] direct composed child paths
143
+ def child_paths(path)
144
+ prefix = "#{Path.parse(path)}/"
145
+ paths = composed_index.keys.select do |candidate|
146
+ candidate.start_with?(prefix) && !candidate.delete_prefix(prefix).include?("/")
147
+ end
148
+ paths.sort.map { |candidate| Path.parse(candidate) }
149
+ end
150
+
151
+ # @api private
152
+ # @return [Array<Path>] composed root paths
153
+ def root_paths
154
+ composed_index.keys.select { |path| path.count("/") == 1 }.sort.map { |path| Path.parse(path) }
155
+ end
156
+
157
+ # Find or author an attribute spec in the edit target.
158
+ # @api private
159
+ def author_attribute(path, name, type_name)
160
+ spec = ensure_spec(edit_target, Path.parse(path))
161
+ property = spec.property_named(name)
162
+ validate_property_kind!(property, AttributeSpec, "#{name} is authored as a relationship")
163
+ property ||= spec.add_property(AttributeSpec.new(name, type_name || "token"))
164
+ property.type_name = type_name if type_name
165
+ invalidate!
166
+ property
167
+ end
168
+
169
+ # Find or author a relationship spec in the edit target.
170
+ # @api private
171
+ def author_relationship(path, name)
172
+ spec = ensure_spec(edit_target, Path.parse(path))
173
+ property = spec.property_named(name)
174
+ validate_property_kind!(property, RelationshipSpec, "#{name} is authored as an attribute")
175
+ property ||= spec.add_property(RelationshipSpec.new(name))
176
+ invalidate!
177
+ property
178
+ end
179
+
180
+ # Author a prim type in the edit target.
181
+ # @api private
182
+ def set_prim_type(path, type_name)
183
+ spec = ensure_spec(edit_target, Path.parse(path))
184
+ spec.type_name = type_name&.to_s
185
+ invalidate!
186
+ type_name
187
+ end
188
+
189
+ # Apply one metadata mutation to an edit-target prim spec.
190
+ # @api private
191
+ def author_prim_metadata(path, operation, key, value)
192
+ metadata = ensure_spec(edit_target, Path.parse(path)).metadata
193
+ operation == :set ? metadata[key] = value : metadata.delete(key)
194
+ invalidate!
195
+ end
196
+
197
+ # @return [Array<Layer>] layers participating in composition
198
+ def layer_stack
199
+ composed_index
200
+ @composition.layers
201
+ end
202
+
203
+ # @api private
204
+ # Invalidate the cached composition index after an edit.
205
+ def invalidate!
206
+ @index = nil
207
+ @composition = nil
208
+ self
209
+ end
210
+
211
+ private
212
+
213
+ def composed_index
214
+ return @index if @index
215
+
216
+ @composition = Composition.new(root_layer, resolver: resolver, layer_cache: @layer_cache)
217
+ @index = @composition.build
218
+ end
219
+
220
+ def validate_prim_path(path)
221
+ parsed = Path.parse(path)
222
+ invalid = !parsed.absolute? || parsed.property? || parsed.to_s == "/"
223
+ raise PathError, "absolute non-root prim path required" if invalid
224
+
225
+ parsed
226
+ end
227
+
228
+ def ensure_spec(layer, path)
229
+ existing = layer.prim_at(path)
230
+ return existing if existing
231
+
232
+ names = path.to_s.delete_prefix("/").split("/")
233
+ parent = nil
234
+ names.each do |name|
235
+ child = parent ? parent.child_named(name) : layer.root_prim_named(name)
236
+ unless child
237
+ child = PrimSpec.new(name, specifier: :over)
238
+ parent ? parent.add_child(child) : layer.add_root_prim(child)
239
+ end
240
+ parent = child
241
+ end
242
+ parent
243
+ end
244
+
245
+ def remove_spec(layer, path)
246
+ spec = layer.prim_at(path)
247
+ return unless spec
248
+
249
+ if spec.parent
250
+ spec.parent.remove_child(spec.name)
251
+ else
252
+ layer.remove_root_prim(spec.name)
253
+ end
254
+ end
255
+
256
+ def validate_property_kind!(property, expected_class, message)
257
+ return if property.nil? || property.is_a?(expected_class)
258
+
259
+ raise OpenUSD::TypeError, message
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUSD
4
+ # Registry and validation for built-in USD value types.
5
+ module Types
6
+ # Integer ranges enforced when authoring.
7
+ INTEGER_RANGES = {
8
+ "int" => (-(2**31)...(2**31)),
9
+ "uint" => (0...(2**32)),
10
+ "int64" => (-(2**63)...(2**63)),
11
+ "uint64" => (0...(2**64))
12
+ }.freeze
13
+ # Floating-point scalar type names.
14
+ FLOAT_TYPES = %w[half float double].freeze
15
+ # Vector and quaternion type names mapped to component counts.
16
+ VECTOR_TYPES = {
17
+ "float2" => 2, "float3" => 3, "float4" => 4,
18
+ "double2" => 2, "double3" => 3, "double4" => 4,
19
+ "half2" => 2, "half3" => 3, "half4" => 4,
20
+ "int2" => 2, "int3" => 3, "int4" => 4,
21
+ "point3f" => 3, "normal3f" => 3, "color3f" => 3,
22
+ "color4f" => 4, "texCoord2f" => 2, "texCoord3f" => 3,
23
+ "quatf" => 4, "quatd" => 4, "quath" => 4
24
+ }.freeze
25
+ # Matrix type names mapped to dimensions.
26
+ MATRIX_TYPES = { "matrix2d" => 2, "matrix3d" => 3, "matrix4d" => 4 }.freeze
27
+ # Built-in scalar type names.
28
+ SCALAR_TYPES = %w[bool int uint int64 uint64 half float double string token asset].freeze
29
+ # Internal dispatch table for scalar normalization.
30
+ SIMPLE_COERCERS = {
31
+ "bool" => :coerce_bool,
32
+ "half" => :coerce_float,
33
+ "float" => :coerce_float,
34
+ "double" => :coerce_float,
35
+ "string" => :coerce_string,
36
+ "token" => :coerce_token,
37
+ "asset" => :coerce_asset
38
+ }.freeze
39
+
40
+ module_function
41
+
42
+ # Whether the registry recognizes a type name.
43
+ def known?(type_name)
44
+ name = base_type(type_name)
45
+ SCALAR_TYPES.include?(name) || VECTOR_TYPES.key?(name) || MATRIX_TYPES.key?(name)
46
+ end
47
+
48
+ # Whether the type is an array type.
49
+ def array?(type_name)
50
+ String(type_name).end_with?("[]")
51
+ end
52
+
53
+ # Remove the array suffix from a type name.
54
+ def base_type(type_name)
55
+ String(type_name).delete_suffix("[]")
56
+ end
57
+
58
+ # Validate and normalize a Ruby value for a USD type.
59
+ # Unknown types are intentionally preserved for forward compatibility.
60
+ def coerce(type_name, value)
61
+ name = String(type_name)
62
+ return coerce_array(base_type(name), value) if array?(name)
63
+ return value unless known?(name)
64
+
65
+ coerce_scalar(name, value)
66
+ rescue OpenUSD::TypeError
67
+ raise
68
+ rescue StandardError
69
+ invalid!(name, value)
70
+ end
71
+
72
+ # Validate a Ruby value and return its normalized representation.
73
+ def validate!(type_name, value)
74
+ coerce(type_name, value)
75
+ end
76
+
77
+ def coerce_array(type_name, value)
78
+ invalid!("#{type_name}[]", value) unless value.is_a?(Array)
79
+
80
+ value.map { |element| coerce_scalar(type_name, element) }.freeze
81
+ end
82
+ private_class_method :coerce_array
83
+
84
+ def coerce_scalar(type_name, value)
85
+ coercer = SIMPLE_COERCERS[type_name]
86
+ return send(coercer, value) if coercer
87
+ return coerce_integer(type_name, value) if INTEGER_RANGES.key?(type_name)
88
+ return coerce_vector(type_name, value) if VECTOR_TYPES.key?(type_name)
89
+ return coerce_matrix(type_name, value) if MATRIX_TYPES.key?(type_name)
90
+ return value unless known?(type_name)
91
+
92
+ invalid!(type_name, value)
93
+ end
94
+ private_class_method :coerce_scalar
95
+
96
+ def coerce_bool(value)
97
+ return value if [true, false].include?(value)
98
+
99
+ invalid!("bool", value)
100
+ end
101
+ private_class_method :coerce_bool
102
+
103
+ def coerce_integer(type_name, value)
104
+ range = INTEGER_RANGES.fetch(type_name)
105
+ return value if value.is_a?(Integer) && range.cover?(value)
106
+
107
+ invalid!(type_name, value)
108
+ end
109
+ private_class_method :coerce_integer
110
+
111
+ def coerce_float(value)
112
+ return value.to_f if value.is_a?(Numeric)
113
+
114
+ invalid!("floating-point", value)
115
+ end
116
+ private_class_method :coerce_float
117
+
118
+ def coerce_string(value)
119
+ return value.dup.freeze if value.instance_of?(String)
120
+
121
+ invalid!("string", value)
122
+ end
123
+ private_class_method :coerce_string
124
+
125
+ def coerce_token(value)
126
+ return Token.new(value) if value.is_a?(String)
127
+
128
+ invalid!("token", value)
129
+ end
130
+ private_class_method :coerce_token
131
+
132
+ def coerce_asset(value)
133
+ return value if value.is_a?(AssetPath)
134
+ return AssetPath.new(value) if value.is_a?(String)
135
+
136
+ invalid!("asset", value)
137
+ end
138
+ private_class_method :coerce_asset
139
+
140
+ def coerce_vector(type_name, value)
141
+ size = VECTOR_TYPES.fetch(type_name)
142
+ invalid!(type_name, value) unless value.is_a?(Array) && value.length == size
143
+
144
+ scalar = type_name.start_with?("int") ? "int" : "double"
145
+ value.map { |element| coerce_scalar(scalar, element) }.freeze
146
+ end
147
+ private_class_method :coerce_vector
148
+
149
+ def coerce_matrix(type_name, value)
150
+ size = MATRIX_TYPES.fetch(type_name)
151
+ invalid!(type_name, value) unless matrix_shape?(value, size)
152
+
153
+ value.map { |row| row.map { |element| coerce_float(element) }.freeze }.freeze
154
+ end
155
+ private_class_method :coerce_matrix
156
+
157
+ def matrix_shape?(value, size)
158
+ value.is_a?(Array) && value.length == size &&
159
+ value.all? { |row| row.is_a?(Array) && row.length == size }
160
+ end
161
+ private_class_method :matrix_shape?
162
+
163
+ def invalid!(type_name, value)
164
+ raise OpenUSD::TypeError, "#{value.inspect} is not a valid #{type_name} value"
165
+ end
166
+ private_class_method :invalid!
167
+ end
168
+ end