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,296 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenUSD
|
|
4
|
+
module Format
|
|
5
|
+
module Usda
|
|
6
|
+
# Deterministic USDA serializer.
|
|
7
|
+
class Writer
|
|
8
|
+
# Indentation used for nested USDA constructs.
|
|
9
|
+
INDENT = " " * 4
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
# Serialize a layer to a filesystem path.
|
|
13
|
+
# @return [String] destination path
|
|
14
|
+
def write(layer, path)
|
|
15
|
+
File.binwrite(path, new.write_to_string(layer))
|
|
16
|
+
path
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Serialize a layer to USDA text.
|
|
21
|
+
# @return [String]
|
|
22
|
+
def write_to_string(layer)
|
|
23
|
+
@lines = ["#usda 1.0"]
|
|
24
|
+
write_metadata_block(layer.metadata, 0, layer_comment: true) unless layer.metadata.empty?
|
|
25
|
+
layer.root_prims.each do |prim|
|
|
26
|
+
line
|
|
27
|
+
write_prim(prim, 0)
|
|
28
|
+
end
|
|
29
|
+
"#{@lines.join("\n")}\n"
|
|
30
|
+
ensure
|
|
31
|
+
@lines = nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def write_prim(prim, depth)
|
|
37
|
+
declaration = "#{prim.specifier}#{type_declaration(prim.type_name)} #{quote(prim.name)}"
|
|
38
|
+
metadata = prim.metadata.dup
|
|
39
|
+
write_references_metadata(metadata, prim) if prim.references_authored?
|
|
40
|
+
append(declaration, depth)
|
|
41
|
+
write_metadata_block(metadata, depth) unless metadata.empty?
|
|
42
|
+
append("{", depth)
|
|
43
|
+
write_prim_contents(prim, depth + 1)
|
|
44
|
+
append("}", depth)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def write_prim_contents(prim, depth)
|
|
48
|
+
ordered_properties(prim).each { |property| write_property(property, depth) }
|
|
49
|
+
write_variants(prim, depth)
|
|
50
|
+
prim.children.each do |child|
|
|
51
|
+
line
|
|
52
|
+
write_prim(child, depth)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def ordered_properties(prim)
|
|
57
|
+
prim.properties.sort_by do |property|
|
|
58
|
+
if property.is_a?(RelationshipSpec)
|
|
59
|
+
2
|
|
60
|
+
elsif property.variability == :uniform
|
|
61
|
+
0
|
|
62
|
+
else
|
|
63
|
+
1
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def write_property(property, depth)
|
|
69
|
+
if property.is_a?(RelationshipSpec)
|
|
70
|
+
write_relationship(property, depth)
|
|
71
|
+
else
|
|
72
|
+
write_attribute(property, depth)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def write_attribute(attribute, depth)
|
|
77
|
+
prefix = attribute_prefix(attribute)
|
|
78
|
+
wrote_declaration = false
|
|
79
|
+
if attribute.default_authored?
|
|
80
|
+
write_property_line("#{prefix} = #{format_value(attribute.default, attribute.type_name, depth)}",
|
|
81
|
+
attribute.metadata, depth)
|
|
82
|
+
wrote_declaration = true
|
|
83
|
+
elsif !attribute.metadata.empty?
|
|
84
|
+
write_property_line(prefix, attribute.metadata, depth)
|
|
85
|
+
wrote_declaration = true
|
|
86
|
+
end
|
|
87
|
+
write_time_samples(attribute, prefix, depth) if attribute.time_samples_authored?
|
|
88
|
+
write_connections(attribute, prefix, depth) if attribute.connections_authored?
|
|
89
|
+
authored = wrote_declaration || attribute.time_samples_authored? || attribute.connections_authored?
|
|
90
|
+
write_property_line(prefix, attribute.metadata, depth) unless authored
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def write_time_samples(attribute, prefix, depth)
|
|
94
|
+
append("#{prefix}.timeSamples = {", depth)
|
|
95
|
+
attribute.time_samples.each do |time, value|
|
|
96
|
+
formatted = format_value(value, attribute.type_name, depth + 1)
|
|
97
|
+
append("#{format_number(time)}: #{formatted},", depth + 1)
|
|
98
|
+
end
|
|
99
|
+
append("}", depth)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def write_connections(attribute, prefix, depth)
|
|
103
|
+
value = attribute.connections.length == 1 ? attribute.connections.first : attribute.connections
|
|
104
|
+
append("#{prefix}.connect = #{format_value(value, nil, depth)}", depth)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def write_relationship(relationship, depth)
|
|
108
|
+
prefix = relationship.custom ? "custom rel #{relationship.name}" : "rel #{relationship.name}"
|
|
109
|
+
value = relationship.targets.length == 1 ? relationship.targets.first : relationship.targets
|
|
110
|
+
text = if relationship.targets_authored?
|
|
111
|
+
"#{prefix} = #{format_value(value, nil, depth)}"
|
|
112
|
+
else
|
|
113
|
+
prefix
|
|
114
|
+
end
|
|
115
|
+
write_property_line(text, relationship.metadata, depth)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def write_property_line(text, metadata, depth)
|
|
119
|
+
if metadata.empty?
|
|
120
|
+
append(text, depth)
|
|
121
|
+
else
|
|
122
|
+
append("#{text} (", depth)
|
|
123
|
+
write_metadata_entries(metadata, depth + 1)
|
|
124
|
+
append(")", depth)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def write_metadata_block(metadata, depth, layer_comment: false)
|
|
129
|
+
append("(", depth)
|
|
130
|
+
write_metadata_entries(metadata, depth + 1, layer_comment: layer_comment)
|
|
131
|
+
append(")", depth)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def write_metadata_entries(metadata, depth, layer_comment: false)
|
|
135
|
+
metadata.each do |key, value|
|
|
136
|
+
if layer_comment && key == "comment" && value.is_a?(String)
|
|
137
|
+
append(quote(value), depth)
|
|
138
|
+
next
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
operation, unwrapped = unwrap_list_op(value)
|
|
142
|
+
prefix = operation ? "#{operation} " : ""
|
|
143
|
+
write_metadata_entry("#{prefix}#{key}", unwrapped, depth)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def write_metadata_entry(key, value, depth)
|
|
148
|
+
if value.is_a?(Hash)
|
|
149
|
+
append("#{key} = {", depth)
|
|
150
|
+
write_dictionary(value, depth + 1)
|
|
151
|
+
append("}", depth)
|
|
152
|
+
else
|
|
153
|
+
append("#{key} = #{format_value(value, nil, depth)}", depth)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def write_dictionary(dictionary, depth)
|
|
158
|
+
dictionary.each do |key, value|
|
|
159
|
+
type_name = infer_type(value)
|
|
160
|
+
if value.is_a?(Hash)
|
|
161
|
+
append("dictionary #{key} = {", depth)
|
|
162
|
+
write_dictionary(value, depth + 1)
|
|
163
|
+
append("}", depth)
|
|
164
|
+
else
|
|
165
|
+
append("#{type_name} #{key} = #{format_value(value, type_name, depth)}", depth)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def write_variants(prim, depth)
|
|
171
|
+
prim.variant_sets.each do |name, choices|
|
|
172
|
+
line
|
|
173
|
+
append("variantSet #{quote(name)} = {", depth)
|
|
174
|
+
choices.each do |choice, variant|
|
|
175
|
+
append("#{quote(choice)} {", depth + 1)
|
|
176
|
+
variant.properties.each { |property| write_property(property, depth + 2) }
|
|
177
|
+
variant.children.each { |child| write_prim(child, depth + 2) }
|
|
178
|
+
append("}", depth + 1)
|
|
179
|
+
end
|
|
180
|
+
append("}", depth)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def format_value(value, expected_type, depth)
|
|
185
|
+
return "None" if value.nil?
|
|
186
|
+
return format_reference(value) if value.is_a?(Reference)
|
|
187
|
+
return format_asset(value) if value.is_a?(AssetPath)
|
|
188
|
+
return "<#{value}>" if value.is_a?(Path)
|
|
189
|
+
return quote(value) if value.is_a?(String)
|
|
190
|
+
return value ? "true" : "false" if [true, false].include?(value)
|
|
191
|
+
return format_number(value) if value.is_a?(Numeric)
|
|
192
|
+
return format_array(value, expected_type, depth) if value.is_a?(Array)
|
|
193
|
+
return format_inline_dictionary(value, depth) if value.is_a?(Hash)
|
|
194
|
+
|
|
195
|
+
value.to_s
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def format_array(values, expected_type, depth)
|
|
199
|
+
scalar_value = expected_type && !Types.array?(expected_type)
|
|
200
|
+
vector = scalar_value && Types::VECTOR_TYPES.key?(Types.base_type(expected_type))
|
|
201
|
+
matrix = scalar_value && Types::MATRIX_TYPES.key?(Types.base_type(expected_type))
|
|
202
|
+
opening, closing = vector || matrix ? ["(", ")"] : ["[", "]"]
|
|
203
|
+
element_type = Types.array?(expected_type.to_s) ? Types.base_type(expected_type) : nil
|
|
204
|
+
contents = if matrix
|
|
205
|
+
values.map { |row| format_array(row, "double4", depth) }.join(", ")
|
|
206
|
+
else
|
|
207
|
+
values.map { |value| format_value(value, element_type, depth) }.join(", ")
|
|
208
|
+
end
|
|
209
|
+
"#{opening}#{contents}#{closing}"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def format_inline_dictionary(dictionary, depth)
|
|
213
|
+
return "{}" if dictionary.empty?
|
|
214
|
+
|
|
215
|
+
parts = dictionary.map { |key, value| "#{key}: #{format_value(value, nil, depth + 1)}" }
|
|
216
|
+
"{ #{parts.join(", ")} }"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def format_reference(reference)
|
|
220
|
+
return "<#{reference.prim_path}>" if reference.internal?
|
|
221
|
+
|
|
222
|
+
asset = format_asset(reference.asset_path)
|
|
223
|
+
reference.prim_path ? "#{asset}<#{reference.prim_path}>" : asset
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def format_asset(asset)
|
|
227
|
+
delimiter = asset.path.include?("@") ? "@@@" : "@"
|
|
228
|
+
"#{delimiter}#{asset.path}#{delimiter}"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def format_number(number)
|
|
232
|
+
return "nan" if number.respond_to?(:nan?) && number.nan?
|
|
233
|
+
return number.negative? ? "-inf" : "inf" if number.respond_to?(:infinite?) && number.infinite?
|
|
234
|
+
return number.to_s unless number.is_a?(Float)
|
|
235
|
+
|
|
236
|
+
number.to_s
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def infer_type(value)
|
|
240
|
+
return "bool" if [true, false].include?(value)
|
|
241
|
+
return "int" if value.is_a?(Integer)
|
|
242
|
+
return "double" if value.is_a?(Float)
|
|
243
|
+
return "asset" if value.is_a?(AssetPath)
|
|
244
|
+
return "token" if value.is_a?(Token)
|
|
245
|
+
return "string" if value.is_a?(String)
|
|
246
|
+
|
|
247
|
+
"string"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def quote(value)
|
|
251
|
+
escaped = value.to_s.gsub("\\", "\\\\").gsub("\"", "\\\"")
|
|
252
|
+
.gsub("\n", "\\n").gsub("\r", "\\r").gsub("\t", "\\t")
|
|
253
|
+
"\"#{escaped}\""
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def type_declaration(type_name)
|
|
257
|
+
type_name.nil? || type_name.empty? ? "" : " #{type_name}"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def attribute_prefix(attribute)
|
|
261
|
+
qualifiers = []
|
|
262
|
+
qualifiers << "custom" if attribute.custom
|
|
263
|
+
qualifiers << "uniform" if attribute.variability == :uniform
|
|
264
|
+
[*qualifiers, attribute.type_name, attribute.name].join(" ")
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def reference_value(references)
|
|
268
|
+
references.length == 1 ? references.first : references
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def write_references_metadata(metadata, prim)
|
|
272
|
+
value = reference_value(prim.references)
|
|
273
|
+
value = ListOp.new(prim.reference_list_op, value) if prim.reference_list_op
|
|
274
|
+
metadata["references"] = value
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def unwrap_list_op(value)
|
|
278
|
+
return [value.operation, value.value] if value.is_a?(ListOp)
|
|
279
|
+
|
|
280
|
+
[nil, value]
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def append(text, depth)
|
|
284
|
+
@lines << "#{INDENT * depth}#{text}"
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def line
|
|
288
|
+
@lines << ""
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
Format::Registry.register("usda", reader: Reader, writer: Writer)
|
|
293
|
+
Format::Registry.register("usd", reader: Reader, writer: Writer)
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "zlib"
|
|
5
|
+
|
|
6
|
+
module OpenUSD
|
|
7
|
+
module Format
|
|
8
|
+
# USDZ package format support.
|
|
9
|
+
module Usdz
|
|
10
|
+
# Reads and validates the constrained ZIP representation used by USDZ.
|
|
11
|
+
class Reader
|
|
12
|
+
# Validated package entry.
|
|
13
|
+
Entry = Data.define(:name, :data, :data_offset)
|
|
14
|
+
# Virtual identifier for an entry inside a package.
|
|
15
|
+
PACKAGE_URI = /\A(.+\.usdz)\[([^\]]+)\]\z/i
|
|
16
|
+
# ZIP local-file-header signature.
|
|
17
|
+
LOCAL_SIGNATURE = 0x04034B50
|
|
18
|
+
# ZIP central-directory-entry signature.
|
|
19
|
+
CENTRAL_SIGNATURE = 0x02014B50
|
|
20
|
+
# ZIP end-of-central-directory signature.
|
|
21
|
+
END_SIGNATURE = 0x06054B50
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
# Read the default layer from a package.
|
|
25
|
+
# @return [Layer]
|
|
26
|
+
def read(path)
|
|
27
|
+
package = new(path)
|
|
28
|
+
root = package.entries.first
|
|
29
|
+
raise PackageError, "USDZ package is empty" unless root
|
|
30
|
+
|
|
31
|
+
parse_layer(package.path, root)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Read a layer identified by a virtual package URI.
|
|
35
|
+
# @return [Layer]
|
|
36
|
+
def read_uri(uri)
|
|
37
|
+
package_path, entry_name = parse_uri(uri)
|
|
38
|
+
package = new(package_path)
|
|
39
|
+
entry = package.entry(entry_name)
|
|
40
|
+
raise PackageError, "package entry not found: #{entry_name}" unless entry
|
|
41
|
+
|
|
42
|
+
parse_layer(package.path, entry)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Split a virtual package URI.
|
|
46
|
+
# @return [Array(String, String)] package path and entry name
|
|
47
|
+
def parse_uri(uri)
|
|
48
|
+
match = PACKAGE_URI.match(uri.to_s)
|
|
49
|
+
raise PackageError, "invalid package URI: #{uri}" unless match
|
|
50
|
+
|
|
51
|
+
[File.expand_path(match[1]), match[2]]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Parse a validated package entry as USDA.
|
|
55
|
+
# @return [Layer]
|
|
56
|
+
def parse_layer(package_path, root)
|
|
57
|
+
extension = File.extname(root.name).downcase
|
|
58
|
+
raise NotSupportedError, "USDC root layers are not supported" if extension == ".usdc"
|
|
59
|
+
raise PackageError, "first USDZ entry must be a native USD layer" unless %w[.usd .usda].include?(extension)
|
|
60
|
+
|
|
61
|
+
layer = Format::Usda::Parser.parse(root.data.dup.force_encoding(Encoding::UTF_8),
|
|
62
|
+
file: "#{package_path}[#{root.name}]")
|
|
63
|
+
layer.identifier = "#{File.expand_path(package_path)}[#{root.name}]"
|
|
64
|
+
layer
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Extract a validated package.
|
|
68
|
+
# @return [Array<String>] extracted entry names
|
|
69
|
+
def unpack(path, destination:)
|
|
70
|
+
new(path).extract(destination)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
attr_reader :path
|
|
75
|
+
|
|
76
|
+
def initialize(path)
|
|
77
|
+
@path = File.expand_path(path)
|
|
78
|
+
@archive = File.binread(@path)
|
|
79
|
+
@entries = nil
|
|
80
|
+
rescue Errno::ENOENT
|
|
81
|
+
raise PackageError, "package not found: #{path}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @return [Array<Entry>] validated entries in archive order
|
|
85
|
+
def entries
|
|
86
|
+
@entries ||= parse_entries.freeze
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Find an entry by its exact package path.
|
|
90
|
+
# @return [Entry, nil]
|
|
91
|
+
def entry(name)
|
|
92
|
+
entries.find { |candidate| candidate.name == name.to_s }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Securely extract all entries below a destination.
|
|
96
|
+
# @return [Array<String>] extracted entry names
|
|
97
|
+
def extract(destination)
|
|
98
|
+
root = File.expand_path(destination)
|
|
99
|
+
entries.each do |entry|
|
|
100
|
+
output = File.expand_path(entry.name, root)
|
|
101
|
+
unless output.start_with?("#{root}#{File::SEPARATOR}")
|
|
102
|
+
raise PackageError, "unsafe package entry: #{entry.name.inspect}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
FileUtils.mkdir_p(File.dirname(output))
|
|
106
|
+
File.binwrite(output, entry.data)
|
|
107
|
+
end
|
|
108
|
+
entries.map(&:name)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def parse_entries
|
|
114
|
+
count, central_offset = end_directory
|
|
115
|
+
offset = central_offset
|
|
116
|
+
Array.new(count) do
|
|
117
|
+
entry, offset = parse_central_entry(offset)
|
|
118
|
+
entry
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def end_directory
|
|
123
|
+
signature = [END_SIGNATURE].pack("V")
|
|
124
|
+
offset = @archive.rindex(signature, [@archive.bytesize - 22, 0].max)
|
|
125
|
+
raise PackageError, "missing ZIP end-of-directory record" unless offset
|
|
126
|
+
|
|
127
|
+
fields = bytes(offset, 22).unpack("VvvvvVVv")
|
|
128
|
+
_signature, disk, central_disk, disk_count, total_count, size, central_offset, comment_size = fields
|
|
129
|
+
unsupported = disk != 0 || central_disk != 0 || disk_count != total_count || comment_size != 0
|
|
130
|
+
raise PackageError, "multi-disk and commented ZIP files are not supported" if unsupported
|
|
131
|
+
raise PackageError, "invalid central directory bounds" if central_offset + size > offset
|
|
132
|
+
|
|
133
|
+
[total_count, central_offset]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def parse_central_entry(offset)
|
|
137
|
+
fields = bytes(offset, 46).unpack("VvvvvvvVVVvvvvvVV")
|
|
138
|
+
signature, _made, _needed, flags, method, _time, _date, crc, compressed_size,
|
|
139
|
+
size, name_size, extra_size, comment_size, _disk, _internal, _external, local_offset = fields
|
|
140
|
+
raise PackageError, "invalid central directory entry" unless signature == CENTRAL_SIGNATURE
|
|
141
|
+
|
|
142
|
+
validate_storage!(flags, method, compressed_size, size)
|
|
143
|
+
|
|
144
|
+
name = bytes(offset + 46, name_size).force_encoding(Encoding::UTF_8)
|
|
145
|
+
validate_name!(name)
|
|
146
|
+
data, data_offset = read_local_entry(local_offset, name, size, crc)
|
|
147
|
+
next_offset = offset + 46 + name_size + extra_size + comment_size
|
|
148
|
+
[Entry.new(name.freeze, data.freeze, data_offset), next_offset]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def read_local_entry(offset, expected_name, size, expected_crc)
|
|
152
|
+
fields = bytes(offset, 30).unpack("VvvvvvVVVvv")
|
|
153
|
+
signature, _needed, flags, method, _time, _date, crc, compressed_size,
|
|
154
|
+
local_size, name_size, extra_size = fields
|
|
155
|
+
raise PackageError, "invalid local ZIP header" unless signature == LOCAL_SIGNATURE
|
|
156
|
+
|
|
157
|
+
validate_storage!(flags, method, compressed_size, local_size)
|
|
158
|
+
raise PackageError, "central and local sizes differ" unless size == local_size
|
|
159
|
+
|
|
160
|
+
name = bytes(offset + 30, name_size).force_encoding(Encoding::UTF_8)
|
|
161
|
+
raise PackageError, "central and local names differ" unless name == expected_name
|
|
162
|
+
|
|
163
|
+
data_offset = offset + 30 + name_size + extra_size
|
|
164
|
+
raise PackageError, "USDZ entry is not 64-byte aligned" unless (data_offset % 64).zero?
|
|
165
|
+
|
|
166
|
+
data = bytes(data_offset, size)
|
|
167
|
+
actual_crc = Zlib.crc32(data)
|
|
168
|
+
raise PackageError, "CRC mismatch for #{name}" unless crc == expected_crc && crc == actual_crc
|
|
169
|
+
|
|
170
|
+
[data, data_offset]
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def validate_storage!(flags, method, compressed_size, size)
|
|
174
|
+
raise PackageError, "encrypted ZIP entries are not supported" unless flags.nobits?(1)
|
|
175
|
+
raise PackageError, "USDZ entries must be uncompressed" unless method.zero? && compressed_size == size
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def validate_name!(name)
|
|
179
|
+
parts = name.tr("\\", "/").split("/")
|
|
180
|
+
invalid = name.empty? || name.start_with?("/", "\\") || parts.include?("..")
|
|
181
|
+
raise PackageError, "unsafe package entry: #{name.inspect}" if invalid
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def bytes(offset, length)
|
|
185
|
+
value = @archive.byteslice(offset, length)
|
|
186
|
+
raise PackageError, "truncated ZIP structure" unless value&.bytesize == length
|
|
187
|
+
|
|
188
|
+
value
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
Format::Registry.register("usdz", reader: Reader)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "zlib"
|
|
5
|
+
|
|
6
|
+
module OpenUSD
|
|
7
|
+
module Format
|
|
8
|
+
module Usdz
|
|
9
|
+
# Writes uncompressed, 64-byte-aligned USDZ packages.
|
|
10
|
+
module Writer
|
|
11
|
+
# ZIP local-file-header signature.
|
|
12
|
+
LOCAL_SIGNATURE = 0x04034B50
|
|
13
|
+
# ZIP central-directory-entry signature.
|
|
14
|
+
CENTRAL_SIGNATURE = 0x02014B50
|
|
15
|
+
# ZIP end-of-central-directory signature.
|
|
16
|
+
END_SIGNATURE = 0x06054B50
|
|
17
|
+
# OpenUSD alignment extra-field identifier.
|
|
18
|
+
ALIGNMENT_EXTRA_ID = 0x1986
|
|
19
|
+
# Largest value representable by non-ZIP64 records.
|
|
20
|
+
UINT32_MAX = (2**32) - 1
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# Package one in-memory layer as a USDZ file.
|
|
25
|
+
# @return [String] destination path
|
|
26
|
+
def write(layer, path)
|
|
27
|
+
root_name = "#{File.basename(path, File.extname(path))}.usda"
|
|
28
|
+
write_entries(path, [[root_name, layer.to_usda]])
|
|
29
|
+
path
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Package a root USD file and additional assets.
|
|
33
|
+
# @return [String] destination path
|
|
34
|
+
def pack(path, root:, assets: [])
|
|
35
|
+
root_path = File.expand_path(root)
|
|
36
|
+
validate_root!(root_path)
|
|
37
|
+
entries = [[File.basename(root_path), File.binread(root_path)]]
|
|
38
|
+
entries.concat(Array(assets).map { |asset| asset_entry(asset) })
|
|
39
|
+
write_entries(path, entries)
|
|
40
|
+
path
|
|
41
|
+
rescue Errno::ENOENT => e
|
|
42
|
+
raise PackageError, "package input not found: #{e.message}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Write preloaded `[name, bytes]` entries.
|
|
46
|
+
# @return [Integer] bytes written
|
|
47
|
+
def write_entries(path, entries)
|
|
48
|
+
validate_entries!(entries)
|
|
49
|
+
archive = String.new(encoding: Encoding::BINARY)
|
|
50
|
+
central_records = entries.map { |name, data| write_local_entry(archive, name, data.b) }
|
|
51
|
+
central_offset = archive.bytesize
|
|
52
|
+
central_records.each { |record| archive << central_header(record) }
|
|
53
|
+
central_size = archive.bytesize - central_offset
|
|
54
|
+
archive << end_record(entries.length, central_size, central_offset)
|
|
55
|
+
File.binwrite(path, archive)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def write_local_entry(archive, name, data)
|
|
59
|
+
name = name.encode(Encoding::UTF_8)
|
|
60
|
+
flags = name.ascii_only? ? 0 : (1 << 11)
|
|
61
|
+
crc = Zlib.crc32(data)
|
|
62
|
+
local_offset = archive.bytesize
|
|
63
|
+
extra = alignment_extra(local_offset + 30 + name.bytesize)
|
|
64
|
+
header = [
|
|
65
|
+
LOCAL_SIGNATURE, 10, flags, 0, 0, 0, crc,
|
|
66
|
+
data.bytesize, data.bytesize, name.bytesize, extra.bytesize
|
|
67
|
+
].pack("VvvvvvVVVvv")
|
|
68
|
+
archive << header << name.b << extra << data
|
|
69
|
+
{
|
|
70
|
+
name: name, flags: flags, crc: crc, size: data.bytesize,
|
|
71
|
+
local_offset: local_offset
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
private_class_method :write_local_entry
|
|
75
|
+
|
|
76
|
+
def alignment_extra(base_offset)
|
|
77
|
+
extra_size = (-base_offset) % 64
|
|
78
|
+
extra_size += 64 if extra_size.positive? && extra_size < 4
|
|
79
|
+
return "".b if extra_size.zero?
|
|
80
|
+
|
|
81
|
+
[ALIGNMENT_EXTRA_ID, extra_size - 4].pack("vv") + ("\0".b * (extra_size - 4))
|
|
82
|
+
end
|
|
83
|
+
private_class_method :alignment_extra
|
|
84
|
+
|
|
85
|
+
def central_header(record)
|
|
86
|
+
name = record.fetch(:name)
|
|
87
|
+
[
|
|
88
|
+
CENTRAL_SIGNATURE, 20, 10, record.fetch(:flags), 0, 0, 0,
|
|
89
|
+
record.fetch(:crc), record.fetch(:size), record.fetch(:size),
|
|
90
|
+
name.bytesize, 0, 0, 0, 0, 0, record.fetch(:local_offset)
|
|
91
|
+
].pack("VvvvvvvVVVvvvvvVV") + name.b
|
|
92
|
+
end
|
|
93
|
+
private_class_method :central_header
|
|
94
|
+
|
|
95
|
+
def end_record(count, central_size, central_offset)
|
|
96
|
+
[END_SIGNATURE, 0, 0, count, count, central_size, central_offset, 0].pack("VvvvvVVv")
|
|
97
|
+
end
|
|
98
|
+
private_class_method :end_record
|
|
99
|
+
|
|
100
|
+
def asset_entry(asset)
|
|
101
|
+
source, authored_name = asset.is_a?(Hash) ? asset.values_at(:source, :path) : [asset, asset]
|
|
102
|
+
source = File.expand_path(source)
|
|
103
|
+
name = Pathname.new(authored_name.to_s).absolute? ? File.basename(authored_name) : authored_name.to_s
|
|
104
|
+
[normalize_entry_name(name), File.binread(source)]
|
|
105
|
+
end
|
|
106
|
+
private_class_method :asset_entry
|
|
107
|
+
|
|
108
|
+
def validate_root!(path)
|
|
109
|
+
extension = File.extname(path).downcase
|
|
110
|
+
return if %w[.usd .usda .usdc].include?(extension)
|
|
111
|
+
|
|
112
|
+
raise PackageError, "USDZ root must be a .usd, .usda, or .usdc file"
|
|
113
|
+
end
|
|
114
|
+
private_class_method :validate_root!
|
|
115
|
+
|
|
116
|
+
def validate_entries!(entries)
|
|
117
|
+
raise PackageError, "USDZ package requires a root layer" if entries.empty?
|
|
118
|
+
raise PackageError, "too many ZIP entries" if entries.length > 65_535
|
|
119
|
+
|
|
120
|
+
names = entries.map do |name, data|
|
|
121
|
+
normalized = normalize_entry_name(name)
|
|
122
|
+
raise PackageError, "ZIP64 packages are not supported" if data.bytesize > UINT32_MAX
|
|
123
|
+
|
|
124
|
+
normalized
|
|
125
|
+
end
|
|
126
|
+
raise PackageError, "duplicate package entry" unless names.uniq.length == names.length
|
|
127
|
+
end
|
|
128
|
+
private_class_method :validate_entries!
|
|
129
|
+
|
|
130
|
+
def normalize_entry_name(name)
|
|
131
|
+
normalized = name.to_s.tr("\\", "/").delete_prefix("./")
|
|
132
|
+
parts = normalized.split("/")
|
|
133
|
+
invalid = normalized.empty? || normalized.start_with?("/") || parts.include?("..")
|
|
134
|
+
raise PackageError, "unsafe package entry: #{name.inspect}" if invalid
|
|
135
|
+
raise PackageError, "package entry name is too long" if normalized.bytesize > 65_535
|
|
136
|
+
|
|
137
|
+
normalized
|
|
138
|
+
end
|
|
139
|
+
private_class_method :normalize_entry_name
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
Format::Registry.register("usdz", writer: Writer)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|