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,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