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,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUSD
4
+ # An authored attribute in a Layer.
5
+ class AttributeSpec
6
+ # Sentinel distinguishing an absent default from an authored value block.
7
+ UNAUTHORED = Object.new.freeze
8
+ # Valid attribute variability values.
9
+ VARIABILITIES = %i[varying uniform].freeze
10
+
11
+ attr_reader :name, :type_name, :time_samples, :connections, :metadata, :variability, :default
12
+ attr_accessor :custom
13
+
14
+ def initialize(name, type_name, default: UNAUTHORED, variability: :varying, custom: false, metadata: {})
15
+ @name = validate_name(name)
16
+ @type_name = String(type_name).dup.freeze
17
+ self.variability = variability
18
+ @custom = custom == true
19
+ @metadata = metadata.dup
20
+ @connections = []
21
+ @connections_authored = false
22
+ @time_samples = {}
23
+ @time_samples_authored = false
24
+ @default_authored = false
25
+ set(default) unless default.equal?(UNAUTHORED)
26
+ end
27
+
28
+ def type_name=(value)
29
+ normalized = String(value).dup.freeze
30
+ Types.validate!(normalized, @default) if default_authored? && !@default.nil?
31
+ @time_samples.each_value { |sample| Types.validate!(normalized, sample) unless sample.nil? }
32
+ @type_name = normalized
33
+ end
34
+
35
+ def variability=(value)
36
+ normalized = value.to_sym
37
+ raise OpenUSD::TypeError, "invalid variability: #{value.inspect}" unless VARIABILITIES.include?(normalized)
38
+
39
+ @variability = normalized
40
+ end
41
+
42
+ # Whether a default opinion, including a value block, is authored.
43
+ def default_authored?
44
+ @default_authored
45
+ end
46
+
47
+ # Whether a timeSamples dictionary, including an empty one, is authored.
48
+ def time_samples_authored?
49
+ @time_samples_authored
50
+ end
51
+
52
+ # Whether connections, including an empty list, are authored.
53
+ def connections_authored?
54
+ @connections_authored
55
+ end
56
+
57
+ # Set a default or time-sampled value.
58
+ def set(value, time: nil)
59
+ normalized = value.nil? ? nil : Types.coerce(type_name, value)
60
+ return set_time_sample(time, normalized) unless time.nil?
61
+
62
+ @default = normalized
63
+ @default_authored = true
64
+ normalized
65
+ end
66
+
67
+ # Remove the default opinion.
68
+ def clear_default
69
+ @default = nil
70
+ @default_authored = false
71
+ self
72
+ end
73
+
74
+ # Replace time samples, normalizing their keys and values.
75
+ def time_samples=(samples)
76
+ @time_samples = samples.each_with_object({}) do |(time, value), result|
77
+ result[Float(time)] = value.nil? ? nil : Types.coerce(type_name, value)
78
+ end.sort.to_h
79
+ @time_samples_authored = true
80
+ rescue ArgumentError, ::TypeError
81
+ raise OpenUSD::TypeError, "time sample keys must be numeric"
82
+ end
83
+
84
+ # Replace all connection paths.
85
+ def connections=(paths)
86
+ @connections = Array(paths).map { |path| Path.parse(path) }
87
+ @connections_authored = true
88
+ end
89
+
90
+ # @return [Hash] semantic representation used for equality
91
+ def to_h
92
+ {
93
+ name: name, type_name: type_name, default: default,
94
+ default_authored: default_authored?, time_samples: time_samples,
95
+ time_samples_authored: time_samples_authored?,
96
+ variability: variability, custom: custom, connections: connections,
97
+ connections_authored: connections_authored?,
98
+ metadata: metadata
99
+ }
100
+ end
101
+
102
+ private
103
+
104
+ def validate_name(value)
105
+ name = String(value)
106
+ return name.dup.freeze if Path::PROPERTY_NAME.match?(name)
107
+
108
+ raise PathError, "invalid attribute name: #{name.inspect}"
109
+ end
110
+
111
+ def set_time_sample(time, value)
112
+ @time_samples[Float(time)] = value
113
+ @time_samples = @time_samples.sort.to_h
114
+ @time_samples_authored = true
115
+ value
116
+ rescue ArgumentError, ::TypeError
117
+ raise OpenUSD::TypeError, "time must be numeric"
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module OpenUSD
6
+ # Command-line interface used by the `openusd` executable.
7
+ class CLI
8
+ # Supported subcommand names.
9
+ COMMANDS = %w[cat tree zip].freeze
10
+
11
+ def initialize(stdout: $stdout, stderr: $stderr)
12
+ @stdout = stdout
13
+ @stderr = stderr
14
+ end
15
+
16
+ # Run a command and return a process exit status.
17
+ # @param arguments [Array<String>]
18
+ # @return [Integer]
19
+ def run(arguments)
20
+ argv = arguments.dup
21
+ command = argv.shift
22
+ return print_help(0) if command.nil? || %w[-h --help help].include?(command)
23
+ return print_version if %w[-v --version version].include?(command)
24
+ return unknown_command(command) unless COMMANDS.include?(command)
25
+
26
+ send("run_#{command}", argv)
27
+ 0
28
+ rescue OptionParser::ParseError, ArgumentError, OpenUSD::Error, SystemCallError => e
29
+ @stderr.puts("openusd: #{e.message}")
30
+ 1
31
+ end
32
+
33
+ private
34
+
35
+ def run_cat(argv)
36
+ output = nil
37
+ show_help = false
38
+ parser = OptionParser.new do |options|
39
+ options.banner = "Usage: openusd cat [options] FILE"
40
+ options.on("-o", "--output PATH", "Write formatted USDA to PATH") { |path| output = path }
41
+ options.on("-h", "--help", "Show this help") { show_help = true }
42
+ end
43
+ parser.parse!(argv)
44
+ return @stdout.puts(parser) if show_help
45
+
46
+ input = required_argument(argv, parser)
47
+ ensure_no_arguments!(argv, parser)
48
+ text = Layer.open(input).to_usda
49
+ output ? File.binwrite(output, text) : @stdout.write(text)
50
+ end
51
+
52
+ def run_tree(argv)
53
+ show_help = false
54
+ parser = OptionParser.new
55
+ parser.banner = "Usage: openusd tree FILE"
56
+ parser.on("-h", "--help", "Show this help") { show_help = true }
57
+ parser.parse!(argv)
58
+ return @stdout.puts(parser) if show_help
59
+
60
+ input = required_argument(argv, parser)
61
+ ensure_no_arguments!(argv, parser)
62
+ Stage.open(input).traverse do |prim|
63
+ depth = prim.path.to_s.count("/") - 1
64
+ type = prim.type_name ? " <#{prim.type_name}>" : ""
65
+ @stdout.puts("#{" " * depth}#{prim.name}#{type}")
66
+ end
67
+ end
68
+
69
+ def run_zip(argv)
70
+ show_help = false
71
+ parser = OptionParser.new
72
+ parser.banner = "Usage: openusd zip OUTPUT.usdz ROOT.usd[a|c] [ASSET ...]"
73
+ parser.on("-h", "--help", "Show this help") { show_help = true }
74
+ parser.parse!(argv)
75
+ return @stdout.puts(parser) if show_help
76
+
77
+ output = required_argument(argv, parser)
78
+ root = required_argument(argv, parser)
79
+ Format::Usdz::Writer.pack(output, root: root, assets: argv)
80
+ @stdout.puts(output)
81
+ end
82
+
83
+ def required_argument(argv, parser)
84
+ argv.shift || raise(OptionParser::MissingArgument, parser.banner)
85
+ end
86
+
87
+ def ensure_no_arguments!(argv, parser)
88
+ raise OptionParser::InvalidArgument, "#{parser.banner}: #{argv.join(" ")}" unless argv.empty?
89
+ end
90
+
91
+ def print_help(status)
92
+ @stdout.puts <<~HELP
93
+ Usage: openusd COMMAND [options]
94
+
95
+ Commands:
96
+ cat FILE Parse and format a USDA or USDZ root layer
97
+ tree FILE Print the composed prim hierarchy
98
+ zip OUTPUT ROOT [ASSET...] Create an aligned, uncompressed USDZ package
99
+
100
+ Run `openusd COMMAND --help` for command-specific options.
101
+ HELP
102
+ status
103
+ end
104
+
105
+ def print_version
106
+ @stdout.puts("openusd #{VERSION}")
107
+ 0
108
+ end
109
+
110
+ def unknown_command(command)
111
+ @stderr.puts("openusd: unknown command #{command.inspect}")
112
+ print_help(1)
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module OpenUSD
6
+ # Builds the v1 root/sublayer/reference composition index.
7
+ class Composition
8
+ # Internal prim-like opinion for a selected variant.
9
+ VariantOpinion = Data.define(:properties, :metadata, :type_name) do
10
+ # Find a property in the selected variant.
11
+ # @api private
12
+ def property_named(name)
13
+ properties.find { |property| property.name == name.to_s }
14
+ end
15
+ end
16
+
17
+ attr_reader :root_layer, :resolver, :layers, :layer_cache
18
+
19
+ def initialize(root_layer, resolver:, layer_cache: {})
20
+ @root_layer = root_layer
21
+ @resolver = resolver
22
+ @layer_cache = layer_cache
23
+ @layer_cache[layer_key(root_layer)] = root_layer
24
+ @layers = []
25
+ end
26
+
27
+ # Map absolute prim paths to opinions in strongest-to-weakest order.
28
+ def build
29
+ @layers = []
30
+ compose_layer(root_layer, [])
31
+ end
32
+
33
+ private
34
+
35
+ def compose_layer(layer, stack)
36
+ key = layer_key(layer)
37
+ cycle!(stack, key) if stack.include?(key)
38
+ next_stack = stack + [key]
39
+ layer_stack = collect_layer_stack(layer, next_stack)
40
+ index = index_layer_stack(layer_stack)
41
+ compose_references(index, next_stack)
42
+ index
43
+ end
44
+
45
+ def collect_layer_stack(layer, stack)
46
+ @layers << layer unless @layers.include?(layer)
47
+ result = [layer]
48
+ layer.sub_layer_paths.each do |authored_path|
49
+ resolved = resolver.resolve(authored_path, anchor: layer.identifier)
50
+ next unless resolved
51
+
52
+ key = File.expand_path(resolved)
53
+ cycle!(stack, key) if stack.include?(key)
54
+ child = load_layer(resolved)
55
+ result.concat(collect_layer_stack(child, stack + [key]))
56
+ end
57
+ result
58
+ end
59
+
60
+ def index_layer_stack(layer_stack)
61
+ index = Hash.new { |hash, key| hash[key] = [] }
62
+ layer_stack.each do |layer|
63
+ layer.root_prims.each { |prim| index_prim(index, prim, "/#{prim.name}") }
64
+ end
65
+ index
66
+ end
67
+
68
+ def index_prim(index, prim, path)
69
+ selected_variant_opinions(prim).each { |opinion| index[path] << opinion }
70
+ index[path] << prim
71
+ prim.children.each { |child| index_prim(index, child, "#{path}/#{child.name}") }
72
+ index_variant_children(index, prim, path)
73
+ end
74
+
75
+ def selected_variant_opinions(prim)
76
+ selections = prim.metadata["variants"]
77
+ return [] unless selections.is_a?(Hash)
78
+
79
+ selections.filter_map do |set_name, choice|
80
+ variant = prim.variant_sets.dig(set_name.to_s, choice.to_s)
81
+ VariantOpinion.new(variant.properties, {}, nil) if variant
82
+ end
83
+ end
84
+
85
+ def index_variant_children(index, prim, path)
86
+ selections = prim.metadata["variants"]
87
+ return unless selections.is_a?(Hash)
88
+
89
+ selections.each do |set_name, choice|
90
+ variant = prim.variant_sets.dig(set_name.to_s, choice.to_s)
91
+ next unless variant
92
+
93
+ variant.children.each { |child| index_prim(index, child, "#{path}/#{child.name}") }
94
+ end
95
+ end
96
+
97
+ def compose_references(index, stack)
98
+ authored = index.to_a
99
+ authored.each do |destination, opinions|
100
+ effective_references(opinions).each do |reference, owner|
101
+ map_reference(index, destination, owner, reference, stack)
102
+ end
103
+ end
104
+ index
105
+ end
106
+
107
+ def map_reference(index, destination, owner, reference, stack)
108
+ return map_internal_reference(index, destination, reference) if reference.internal?
109
+
110
+ resolved = resolver.resolve(reference.asset_path, anchor: layer_identifier(owner))
111
+ return unless resolved
112
+
113
+ referenced_layer = load_layer(resolved)
114
+ source_index = compose_layer(referenced_layer, stack)
115
+ source_root = reference.prim_path || referenced_layer.default_prim&.path
116
+ raise CompositionError, "reference #{resolved} has no target or defaultPrim" unless source_root
117
+
118
+ map_reference_subtree(index, destination, source_index, source_root.to_s)
119
+ end
120
+
121
+ def map_internal_reference(index, destination, reference)
122
+ source_prefix = reference.prim_path.to_s
123
+ raise CompositionError, "internal reference cycle detected at #{destination}" if source_prefix == destination
124
+
125
+ map_reference_subtree(index, destination, index, source_prefix)
126
+ end
127
+
128
+ def map_reference_subtree(index, destination, source_index, source_prefix)
129
+ source_index.to_a.each do |source_path, source_opinions|
130
+ next unless source_path == source_prefix || source_path.start_with?("#{source_prefix}/")
131
+
132
+ suffix = source_path.delete_prefix(source_prefix)
133
+ index["#{destination}#{suffix}"].concat(source_opinions)
134
+ end
135
+ end
136
+
137
+ def effective_references(opinions)
138
+ opinions.grep(PrimSpec).reverse_each.with_object([]) do |opinion, result|
139
+ next unless opinion.references_authored?
140
+
141
+ entries = opinion.references.map { |reference| [reference, opinion] }
142
+ apply_reference_list_op(result, entries, opinion.reference_list_op)
143
+ end
144
+ end
145
+
146
+ def apply_reference_list_op(result, entries, operation)
147
+ case operation
148
+ when :prepend then result.replace(unique_references(entries + result))
149
+ when :append, :add then result.replace(unique_references(result + entries))
150
+ when :delete then result.reject! { |reference, _owner| entries.any? { |entry| entry.first == reference } }
151
+ when :reorder then reorder_references(result, entries)
152
+ else result.replace(entries)
153
+ end
154
+ end
155
+
156
+ def unique_references(entries)
157
+ entries.each_with_object([]) do |entry, result|
158
+ result << entry unless result.any? { |candidate| candidate.first == entry.first }
159
+ end
160
+ end
161
+
162
+ def reorder_references(result, entries)
163
+ ordered = entries.filter_map do |entry|
164
+ result.find { |candidate| candidate.first == entry.first }
165
+ end
166
+ result.replace(ordered + result.reject { |candidate| ordered.include?(candidate) })
167
+ end
168
+
169
+ def layer_identifier(owner)
170
+ layers.find { |layer| layer.prim_at(owner.path) == owner }&.identifier || root_layer.identifier
171
+ end
172
+
173
+ def load_layer(identifier)
174
+ key = identifier.match?(/\.usdz\[[^\]]+\]\z/i) ? identifier : File.expand_path(identifier)
175
+ layer_cache[key] ||= Layer.open(identifier)
176
+ end
177
+
178
+ def layer_key(layer)
179
+ identifier = layer.identifier
180
+ return "anonymous:#{layer.object_id}" if identifier.nil? || identifier.start_with?("anonymous:")
181
+
182
+ File.expand_path(identifier)
183
+ end
184
+
185
+ def cycle!(stack, key)
186
+ chain = (stack + [key]).join(" -> ")
187
+ raise CompositionError, "composition cycle detected: #{chain}"
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUSD
4
+ # Base class for errors raised by this library.
5
+ class Error < StandardError; end
6
+
7
+ # Raised when USDA text cannot be parsed.
8
+ class ParseError < Error
9
+ attr_reader :file, :line, :column
10
+
11
+ def initialize(message, file: nil, line: nil, column: nil)
12
+ @file = file
13
+ @line = line
14
+ @column = column
15
+ super(location ? "#{location}: #{message}" : message)
16
+ end
17
+
18
+ private
19
+
20
+ def location
21
+ return unless line && column
22
+
23
+ [file, line, column].compact.join(":")
24
+ end
25
+ end
26
+
27
+ # Raised when a Ruby value does not conform to a USD value type.
28
+ class TypeError < Error; end
29
+
30
+ # Raised when an Sdf-style path is malformed or invalid for an operation.
31
+ class PathError < Error; end
32
+
33
+ # Raised when layers cannot be composed.
34
+ class CompositionError < Error; end
35
+
36
+ # Raised when a USDZ package is malformed or violates package constraints.
37
+ class PackageError < Error; end
38
+
39
+ # Raised for recognized operations that are outside this implementation.
40
+ class NotSupportedError < Error; end
41
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUSD
4
+ # File-format adapters and the extension registry.
5
+ module Format
6
+ # Extension-based registry for layer and package formats.
7
+ module Registry
8
+ # Registered reader/writer pair.
9
+ Entry = Data.define(:reader, :writer)
10
+ @entries = {}
11
+
12
+ module_function
13
+
14
+ # Register one or both adapters for an extension.
15
+ def register(extension, reader: nil, writer: nil)
16
+ key = normalize(extension)
17
+ current = @entries[key]
18
+ @entries[key] = Entry.new(
19
+ reader: reader || current&.reader,
20
+ writer: writer || current&.writer
21
+ )
22
+ end
23
+
24
+ # @return [Entry] adapter pair for a path or extension
25
+ def fetch(path_or_extension)
26
+ extension = File.extname(path_or_extension.to_s)
27
+ key = normalize(extension.empty? ? path_or_extension : extension)
28
+ @entries.fetch(key) { raise NotSupportedError, "unsupported file format: #{key}" }
29
+ end
30
+
31
+ # @return [#read] reader adapter for a path
32
+ def reader_for(path)
33
+ fetch(path).reader || raise(NotSupportedError, "reading #{File.extname(path)} is not supported")
34
+ end
35
+
36
+ # @return [#write] writer adapter for a path
37
+ def writer_for(path)
38
+ fetch(path).writer || raise(NotSupportedError, "writing #{File.extname(path)} is not supported")
39
+ end
40
+
41
+ # @return [Array<String>] registered extensions without leading dots
42
+ def extensions
43
+ @entries.keys.freeze
44
+ end
45
+
46
+ def normalize(extension)
47
+ extension.to_s.downcase.delete_prefix(".")
48
+ end
49
+ private_class_method :normalize
50
+ end
51
+ end
52
+ end