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
|
@@ -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
|
data/lib/openusd/cli.rb
ADDED
|
@@ -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
|