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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 18f311892663fd11d0b18ef824c6ad9b62265b06b4699f9c2e591f0d40024813
4
+ data.tar.gz: 565f39580b706c5fa4f0486ac256784fe8a9069a783ce052526b0463ffcc363f
5
+ SHA512:
6
+ metadata.gz: '082ed57cb32bc359f49a9de3c5b5723f0b914de8bdf68932981fd26436b12451e21692546f12d1551d1a656e7fffb8e59db5274362c380869efd709034591257'
7
+ data.tar.gz: e78c9b71ee3d6a99cb27070a3cd1b53b31aee1804285d943f302993355f4537f96bc6feea33d5ee7c371af93a25200134d68b0e321a1a8fe78b0196957ba3893
data/.rubocop.yml ADDED
@@ -0,0 +1,47 @@
1
+ AllCops:
2
+ CacheRootDirectory: tmp/rubocop_cache
3
+ NewCops: enable
4
+ TargetRubyVersion: 3.2
5
+ SuggestExtensions: false
6
+ Exclude:
7
+ - "vendor/**/*"
8
+ - "tmp/**/*"
9
+
10
+ Metrics/BlockLength:
11
+ Exclude:
12
+ - "spec/**/*"
13
+ - "openusd.gemspec"
14
+
15
+ Metrics/ClassLength:
16
+ Max: 260
17
+
18
+ Metrics/MethodLength:
19
+ Max: 30
20
+
21
+ Metrics/ParameterLists:
22
+ Max: 6
23
+
24
+ Metrics/AbcSize:
25
+ Max: 30
26
+
27
+ Metrics/CyclomaticComplexity:
28
+ Max: 12
29
+
30
+ Metrics/PerceivedComplexity:
31
+ Max: 12
32
+
33
+ Metrics/ModuleLength:
34
+ Max: 220
35
+
36
+ Naming/AccessorMethodName:
37
+ Exclude:
38
+ - "lib/openusd/relationship.rb"
39
+
40
+ Style/Documentation:
41
+ Enabled: false
42
+
43
+ Style/StringLiterals:
44
+ EnforcedStyle: double_quotes
45
+
46
+ Style/StringLiteralsInInterpolation:
47
+ EnforcedStyle: double_quotes
data/.yardopts ADDED
@@ -0,0 +1,5 @@
1
+ --markup markdown
2
+ --output-dir doc
3
+ --protected
4
+ -
5
+ README.md
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,163 @@
1
+ # openusd
2
+
3
+ `openusd` is a Pure Ruby library for reading, composing, editing, and writing
4
+ OpenUSD ASCII (`.usda`) and package (`.usdz`) assets. It requires Ruby 3.2 or
5
+ newer and has no runtime gem or native-library dependencies.
6
+
7
+ Version 1.0 focuses on pipeline automation, validation, conversion, and scene
8
+ generation. It supports sublayers and references, but it is not a replacement
9
+ for the complete C++ OpenUSD composition engine.
10
+
11
+ ## Installation
12
+
13
+ Add the gem to your bundle:
14
+
15
+ ```bash
16
+ bundle add openusd
17
+ ```
18
+
19
+ Or install it directly:
20
+
21
+ ```bash
22
+ gem install openusd
23
+ ```
24
+
25
+ ## Create and save a stage
26
+
27
+ ```ruby
28
+ require "openusd"
29
+
30
+ stage = OpenUSD::Stage.create("scene.usda")
31
+ stage.define_prim("/World", "Xform")
32
+ cube = stage.define_prim("/World/Cube", "Cube")
33
+ cube.create_attribute("size", "double").set(2.0)
34
+ cube.create_attribute("xformOp:translate", "double3").set([0, 1, 0])
35
+ cube.create_relationship("target").set_targets(["/World"])
36
+
37
+ stage.root_layer.metadata.merge!(
38
+ "defaultPrim" => "World",
39
+ "metersPerUnit" => 1.0,
40
+ "upAxis" => "Y"
41
+ )
42
+ stage.save
43
+ ```
44
+
45
+ ## Open and traverse a composed stage
46
+
47
+ ```ruby
48
+ stage = OpenUSD::Stage.open("scene.usda")
49
+
50
+ stage.traverse do |prim|
51
+ puts "#{prim.path} <#{prim.type_name}>"
52
+ end
53
+
54
+ size = stage.prim_at("/World/Cube").attribute("size").get
55
+ ```
56
+
57
+ `Stage.open(path, missing_assets: :warn)` warns and skips unresolved sublayers
58
+ or references. The default policy, `:error`, raises
59
+ `OpenUSD::CompositionError`; `:ignore` silently skips them.
60
+
61
+ ## Time samples and metadata
62
+
63
+ ```ruby
64
+ prim = stage.prim_at("/World/Cube")
65
+ attribute = prim.create_attribute("animatedSize", "double")
66
+ attribute.set(1.0, time: 0)
67
+ attribute.set(3.0, time: 24)
68
+
69
+ attribute.get(time: 12) # => 2.0
70
+ prim.metadata["kind"] = "component"
71
+ attribute.metadata["documentation"] = "Animated cube size"
72
+ ```
73
+
74
+ ## Schema helpers
75
+
76
+ ```ruby
77
+ xform = OpenUSD::Schema::Xform.define(stage, "/World/Model")
78
+ xform.translate = [0, 2, 0]
79
+ xform.scale = [2, 2, 2]
80
+
81
+ mesh = OpenUSD::Schema::Mesh.define(stage, "/World/Model/Mesh")
82
+ mesh.points = [[0, 0, 0], [1, 0, 0], [0, 1, 0]]
83
+ mesh.face_vertex_counts = [3]
84
+ mesh.face_vertex_indices = [0, 1, 2]
85
+ ```
86
+
87
+ ## USDZ
88
+
89
+ Package an existing root layer and its assets:
90
+
91
+ ```ruby
92
+ OpenUSD::Format::Usdz::Writer.pack(
93
+ "scene.usdz",
94
+ root: "scene.usda",
95
+ assets: ["textures/albedo.png"]
96
+ )
97
+ ```
98
+
99
+ Every entry is stored without compression and begins at a 64-byte boundary.
100
+ The reader validates bounds, CRC values, alignment, encryption/compression
101
+ flags, and extraction paths.
102
+
103
+ ```ruby
104
+ stage = OpenUSD::Stage.open("scene.usdz")
105
+ OpenUSD::Format::Usdz::Reader.unpack("scene.usdz", destination: "unpacked")
106
+ ```
107
+
108
+ ## Command line
109
+
110
+ ```text
111
+ openusd cat scene.usda
112
+ openusd cat --output formatted.usda scene.usda
113
+ openusd tree scene.usdz
114
+ openusd zip scene.usdz scene.usda textures/albedo.png
115
+ ```
116
+
117
+ The repository includes a reproducible
118
+ [CLI smoke-test procedure](docs/CLI_SMOKE_TEST.md) covering all three
119
+ subcommands and official validation of their outputs.
120
+
121
+ ## Supported scope
122
+
123
+ Version 1.0 supports:
124
+
125
+ - USDA layer metadata, prims, typed attributes, relationships, time samples,
126
+ dictionaries, connections, references, and variant selections
127
+ - deterministic USDA output and semantic round trips
128
+ - root layer → authored sublayers → references composition strength
129
+ - stored ZIP32 USDZ packages with internal USDA references
130
+ - Xform, Mesh, Camera, Material, and Scope conveniences
131
+
132
+ Version 1.0 does not support USDC (Crate), payloads, inherits, specializes, full
133
+ list-edit semantics, Hydra/imaging, or concurrent writes to one Stage. Separate
134
+ stages and concurrent read-only access are safe as long as application code
135
+ does not mutate their underlying Layers.
136
+
137
+ Unknown USDA value types and metadata are retained where the parser can
138
+ represent their syntax, which allows newer authored data to survive a
139
+ parse/write round trip.
140
+
141
+ ## Development
142
+
143
+ ```bash
144
+ bundle install
145
+ bundle exec rake spec
146
+ bundle exec rake lint
147
+ bundle exec rake doc
148
+ bundle exec rake compatibility # requires usdchecker and usdcat on PATH
149
+ bundle exec rake golden:update # intentionally refresh exact writer baselines
150
+ bundle exec rake bench
151
+ ```
152
+
153
+ The compatibility task validates generated USDA and USDZ files with the
154
+ official `usdchecker`, then parses every golden USDA output with `usdcat`.
155
+ In CI, where the `usd-core` wheel does not install those executables, the task
156
+ uses the equivalent official `UsdValidation` and `Sdf` Python APIs.
157
+ The benchmark defaults to 100,000 generated prims and a 1,000,000-vertex Mesh,
158
+ reports elapsed time and resident-memory growth, and enforces a five-second
159
+ 100,000-Prim budget. Set `PRIMS`, `VERTICES`, or `PRIM_BUDGET` to adjust it.
160
+
161
+ ## License
162
+
163
+ The gem is available under the [MIT License](LICENSE.txt).
data/Rakefile ADDED
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "fileutils"
5
+ require "rspec/core/rake_task"
6
+ require "rubocop/rake_task"
7
+ require "tmpdir"
8
+ require "yard"
9
+ require "yard/rake/yardoc_task"
10
+
11
+ RSpec::Core::RakeTask.new(:spec)
12
+
13
+ RuboCop::RakeTask.new(:lint)
14
+
15
+ YARD::Rake::YardocTask.new(:doc) do |task|
16
+ task.files = ["lib/**/*.rb", "exe/openusd"]
17
+ end
18
+
19
+ desc "Run the USDA parser benchmark"
20
+ task :bench do
21
+ ruby "-Ilib", "benchmark/parser.rb"
22
+ end
23
+
24
+ namespace :golden do
25
+ desc "Regenerate deterministic USDA golden files"
26
+ task :update do
27
+ require_relative "lib/openusd"
28
+
29
+ source_root = File.expand_path("spec/fixtures", __dir__)
30
+ destination = File.join(source_root, "golden")
31
+ FileUtils.mkdir_p(destination)
32
+ Dir[File.join(source_root, "{,upstream/}*.usda")].each do |source|
33
+ name = source.delete_prefix("#{source_root}/").tr("/", "__")
34
+ File.binwrite(File.join(destination, name), OpenUSD::Layer.open(source).to_usda)
35
+ end
36
+ end
37
+ end
38
+
39
+ module CompatibilityTask
40
+ extend Rake::DSL
41
+
42
+ module_function
43
+
44
+ def write_assets(directory)
45
+ usda_path = File.join(directory, "scene.usda")
46
+ usdz_path = File.join(directory, "scene.usdz")
47
+ stage = OpenUSD::Stage.create(usda_path)
48
+ stage.define_prim("/World", "Xform")
49
+ mesh = OpenUSD::Schema::Mesh.define(stage, "/World/Mesh")
50
+ mesh.points = [[0, 0, 0], [1, 0, 0], [0, 1, 0]]
51
+ mesh.face_vertex_counts = [3]
52
+ mesh.face_vertex_indices = [0, 1, 2]
53
+ mesh.extent = [[0, 0, 0], [1, 1, 0]]
54
+ stage.root_layer.metadata.merge!(
55
+ "defaultPrim" => "World", "metersPerUnit" => 1.0, "upAxis" => "Y"
56
+ )
57
+ stage.save
58
+ stage.export(usdz_path)
59
+ [usda_path, usdz_path]
60
+ end
61
+
62
+ def validate(assets)
63
+ python = ENV.fetch("USD_CORE_PYTHON", nil)
64
+ return validate_with_python(python, assets) if python
65
+
66
+ assets.each { |path| sh ENV.fetch("USDCHECKER", "usdchecker"), path }
67
+ usdcat = ENV.fetch("USDCAT", "usdcat")
68
+ golden_paths.each { |path| sh usdcat, path, out: File::NULL }
69
+ end
70
+
71
+ def validate_with_python(python, assets)
72
+ validator = File.expand_path("spec/support/usd_core_check.py", __dir__)
73
+ sh python, validator, "validate", *assets
74
+ sh python, validator, "parse", *golden_paths
75
+ end
76
+
77
+ def golden_paths
78
+ Dir[File.expand_path("spec/fixtures/golden/*.usda", __dir__)]
79
+ end
80
+ end
81
+
82
+ desc "Validate generated USDA and USDZ with the official usdchecker"
83
+ task :compatibility do
84
+ require_relative "lib/openusd"
85
+
86
+ Dir.mktmpdir("openusd-compatibility") do |directory|
87
+ CompatibilityTask.validate(CompatibilityTask.write_assets(directory))
88
+ end
89
+ end
90
+
91
+ task default: %i[spec lint]
@@ -0,0 +1,16 @@
1
+ # Parser benchmark results
2
+
3
+ The benchmark is generated in memory and can be reproduced with:
4
+
5
+ ```bash
6
+ bundle exec rake bench
7
+ ```
8
+
9
+ ## 2026-07-28
10
+
11
+ - Environment: Ruby 4.0.0, arm64 Darwin 25.3.0
12
+ - 100,000 Prim layer: 1.351 seconds, resident memory +147.1 MiB
13
+ - 1,000,000-vertex Mesh: 2.093 seconds, resident memory +208.5 MiB
14
+
15
+ Resident-memory growth is sampled immediately before and after each parse.
16
+ Results vary by Ruby version, allocator, hardware, and concurrent system load.
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openusd"
4
+
5
+ def resident_megabytes
6
+ if File.file?("/proc/self/status")
7
+ kilobytes = File.read("/proc/self/status")[/^VmRSS:\s+(\d+)/, 1]
8
+ return kilobytes.to_i / 1024.0 if kilobytes
9
+ end
10
+
11
+ output = IO.popen(["ps", "-o", "rss=", "-p", Process.pid.to_s], &:read)
12
+ Integer(output, 10) / 1024.0
13
+ rescue Errno::ENOENT, ArgumentError
14
+ nil
15
+ end
16
+
17
+ def measure(label)
18
+ GC.start
19
+ memory_before = resident_megabytes
20
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
21
+ result = yield
22
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
23
+ memory_after = resident_megabytes
24
+ memory = memory_before && memory_after ? format(", RSS +%.1f MiB", memory_after - memory_before) : ""
25
+ puts "#{label} in #{format("%.3f", elapsed)} seconds#{memory}"
26
+ [result, elapsed]
27
+ end
28
+
29
+ def run_prim_benchmark
30
+ prim_count = Integer(ENV.fetch("PRIMS", "100000"))
31
+ body = String.new(capacity: prim_count * 22)
32
+ prim_count.times { |index| body << "def Xform \"P#{index}\" {}\n" }
33
+ source = "#usda 1.0\n#{body}"
34
+
35
+ layer, elapsed = measure("Parsed #{prim_count} prims") { OpenUSD::Format::Usda::Parser.parse(source) }
36
+ raise "prim benchmark parsed the wrong count" unless layer.root_prims.length == prim_count
37
+
38
+ budget = Float(ENV.fetch("PRIM_BUDGET", "5"))
39
+ raise "prim benchmark exceeded #{budget} seconds" if elapsed > budget
40
+ end
41
+
42
+ def run_mesh_benchmark
43
+ vertex_count = Integer(ENV.fetch("VERTICES", "1000000"))
44
+ points = "(0, 0, 0)," * vertex_count
45
+ source = "#usda 1.0\ndef Mesh \"Mesh\" { point3f[] points = [#{points}] }"
46
+ layer, = measure("Parsed #{vertex_count} Mesh vertices") { OpenUSD::Format::Usda::Parser.parse(source) }
47
+ parsed_points = layer.prim_at("/Mesh").property_named("points").default
48
+ raise "mesh benchmark parsed the wrong vertex count" unless parsed_points.length == vertex_count
49
+ end
50
+
51
+ run_prim_benchmark
52
+ GC.start
53
+ run_mesh_benchmark
@@ -0,0 +1,29 @@
1
+ # CLI smoke test
2
+
3
+ Run these commands from the repository root after `bundle install`.
4
+
5
+ ```bash
6
+ work_dir="$(mktemp -d)"
7
+
8
+ bundle exec exe/openusd cat \
9
+ --output "$work_dir/formatted.usda" \
10
+ spec/fixtures/layer_metadata.usda
11
+
12
+ bundle exec exe/openusd tree spec/fixtures/nested_prims.usda
13
+
14
+ bundle exec exe/openusd zip \
15
+ "$work_dir/scene.usdz" \
16
+ spec/fixtures/layer_metadata.usda
17
+
18
+ usdchecker "$work_dir/formatted.usda"
19
+ usdchecker "$work_dir/scene.usdz"
20
+ ```
21
+
22
+ Expected results:
23
+
24
+ - `cat` exits successfully and writes a deterministic USDA layer.
25
+ - `tree` prints `World`, its `Geometry` child, and the nested `Cube`.
26
+ - `zip` exits successfully and creates an uncompressed, aligned USDZ package.
27
+ - Both `usdchecker` commands report `Success!`.
28
+
29
+ The temporary directory can be deleted after inspection.
data/exe/openusd ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "openusd"
5
+
6
+ exit OpenUSD::CLI.new.run(ARGV)
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module OpenUSD
6
+ # Resolves authored asset paths relative to their containing layer.
7
+ class AssetResolver
8
+ # Supported unresolved-asset behaviors.
9
+ MISSING_POLICIES = %i[error warn ignore].freeze
10
+
11
+ attr_reader :search_paths, :missing_assets
12
+
13
+ def initialize(search_paths: [], missing_assets: :error)
14
+ @search_paths = search_paths.map { |path| File.expand_path(path) }.freeze
15
+ @missing_assets = missing_assets.to_sym
16
+ return if MISSING_POLICIES.include?(@missing_assets)
17
+
18
+ raise ArgumentError, "invalid missing-assets policy: #{missing_assets.inspect}"
19
+ end
20
+
21
+ # Resolve an asset path, optionally anchored to a layer identifier.
22
+ # @return [String, nil]
23
+ def resolve(asset_path, anchor: nil)
24
+ authored = asset_path.is_a?(AssetPath) ? asset_path.path : asset_path.to_s
25
+ return resolve_package_asset(authored, anchor) if package_uri?(anchor)
26
+ return File.expand_path(anchor) if authored.empty? && anchor
27
+
28
+ resolved = candidates(authored, anchor).find { |candidate| File.file?(candidate) }
29
+ return resolved if resolved
30
+
31
+ missing(authored, anchor)
32
+ end
33
+
34
+ private
35
+
36
+ def package_uri?(value)
37
+ value&.match?(/\A.+\.usdz\[[^\]]+\]\z/i)
38
+ end
39
+
40
+ def resolve_package_asset(authored, anchor)
41
+ package, entry_name = Format::Usdz::Reader.parse_uri(anchor)
42
+ normalized = File.expand_path(authored, "/#{File.dirname(entry_name)}").delete_prefix("/")
43
+ unsafe = normalized.start_with?("../") || authored.start_with?("/")
44
+ return missing(authored, anchor) if unsafe
45
+
46
+ return "#{package}[#{normalized}]" if Format::Usdz::Reader.new(package).entry(normalized)
47
+
48
+ missing(authored, anchor)
49
+ end
50
+
51
+ def candidates(authored, anchor)
52
+ return [File.expand_path(authored)] if Pathname.new(authored).absolute?
53
+
54
+ roots = []
55
+ roots << File.dirname(File.expand_path(anchor)) if anchor && !anchor.start_with?("anonymous:")
56
+ roots.concat(search_paths)
57
+ roots << Dir.pwd if roots.empty?
58
+ roots.uniq.map { |root| File.expand_path(authored, root) }
59
+ end
60
+
61
+ def missing(authored, anchor)
62
+ message = "asset not found: #{authored.inspect}"
63
+ message += " (relative to #{anchor})" if anchor
64
+ raise CompositionError, message if missing_assets == :error
65
+
66
+ warn(message) if missing_assets == :warn
67
+ nil
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUSD
4
+ # Composed attribute view on a Stage.
5
+ class Attribute
6
+ attr_reader :prim, :name
7
+
8
+ def initialize(prim, name)
9
+ @prim = prim
10
+ @name = name.to_s
11
+ end
12
+
13
+ # @return [String, nil] strongest authored USD type name
14
+ def type_name
15
+ opinions.first&.type_name
16
+ end
17
+
18
+ # Get the default or a time-sampled value.
19
+ # @param time [Numeric, nil]
20
+ # @return [Object, nil]
21
+ def get(time: nil)
22
+ return default_value if time.nil?
23
+
24
+ sampled_value(Float(time))
25
+ rescue ArgumentError, ::TypeError
26
+ raise OpenUSD::TypeError, "time must be numeric"
27
+ end
28
+
29
+ # Author a default or time-sampled value in the edit target.
30
+ # @return [Object] input value
31
+ def set(value, time: nil)
32
+ authored_spec.set(value, time: time)
33
+ prim.stage.invalidate!
34
+ value
35
+ end
36
+
37
+ # @return [Hash{Float => Object}] composed samples in time order
38
+ def time_samples
39
+ opinions.find(&:time_samples_authored?)&.time_samples || {}
40
+ end
41
+
42
+ # @return [Array<Path>] strongest authored connections
43
+ def connections
44
+ opinions.find(&:connections_authored?)&.connections || []
45
+ end
46
+
47
+ # @return [MetadataView] composed, writable metadata
48
+ def metadata
49
+ values = opinions.reverse_each.with_object({}) { |opinion, result| result.merge!(opinion.metadata) }
50
+ MetadataView.new(values, writer: method(:write_metadata))
51
+ end
52
+
53
+ private
54
+
55
+ def opinions
56
+ prim.property_opinions(name).grep(AttributeSpec)
57
+ end
58
+
59
+ def default_value
60
+ opinions.find(&:default_authored?)&.default
61
+ end
62
+
63
+ def sampled_value(time)
64
+ opinion = opinions.find(&:time_samples_authored?)
65
+ return default_value unless opinion
66
+
67
+ samples = opinion.time_samples
68
+ return default_value if samples.empty?
69
+ return samples[time] if samples.key?(time)
70
+
71
+ interpolate_samples(samples, time)
72
+ end
73
+
74
+ def interpolate_samples(samples, time)
75
+ times = samples.keys
76
+ return samples[times.first] if time <= times.first
77
+ return samples[times.last] if time >= times.last
78
+
79
+ upper = times.find { |sample_time| sample_time > time }
80
+ lower = times[times.index(upper) - 1]
81
+ interpolate(samples[lower], samples[upper], (time - lower) / (upper - lower))
82
+ end
83
+
84
+ def interpolate(left, right, alpha)
85
+ return left + ((right - left) * alpha) if left.is_a?(Numeric) && right.is_a?(Numeric)
86
+ if left.is_a?(Array) && right.is_a?(Array) && left.length == right.length
87
+ return left.zip(right).map { |a, b| interpolate(a, b, alpha) }
88
+ end
89
+
90
+ left
91
+ end
92
+
93
+ def authored_spec
94
+ prim.stage.author_attribute(prim.path, name, type_name)
95
+ end
96
+
97
+ def write_metadata(operation, key, value)
98
+ authored = authored_spec.metadata
99
+ operation == :set ? authored[key] = value : authored.delete(key)
100
+ prim.stage.invalidate!
101
+ end
102
+ end
103
+ end