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
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
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.
|
data/benchmark/parser.rb
ADDED
|
@@ -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,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
|