rgltf 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 (62) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +270 -0
  4. data/Rakefile +10 -0
  5. data/benchmark/accessors.rb +26 -0
  6. data/benchmark/compare.rb +28 -0
  7. data/benchmark/sample_assets.rb +61 -0
  8. data/examples/README.md +23 -0
  9. data/examples/build_triangle.rb +40 -0
  10. data/examples/convert.rb +24 -0
  11. data/examples/inspect.rb +27 -0
  12. data/lib/rgltf/accessor_reader.rb +142 -0
  13. data/lib/rgltf/buffer_resolver.rb +95 -0
  14. data/lib/rgltf/builder/accessor_data.rb +40 -0
  15. data/lib/rgltf/builder/accessors.rb +130 -0
  16. data/lib/rgltf/builder/component_builders.rb +89 -0
  17. data/lib/rgltf/builder/document_properties.rb +32 -0
  18. data/lib/rgltf/builder/materials.rb +132 -0
  19. data/lib/rgltf/builder.rb +176 -0
  20. data/lib/rgltf/document.rb +159 -0
  21. data/lib/rgltf/errors.rb +10 -0
  22. data/lib/rgltf/extension.rb +79 -0
  23. data/lib/rgltf/extensions/khr_lights_punctual.rb +101 -0
  24. data/lib/rgltf/extensions/khr_materials_emissive_strength.rb +29 -0
  25. data/lib/rgltf/extensions/khr_materials_unlit.rb +19 -0
  26. data/lib/rgltf/extensions/khr_texture_transform.rb +42 -0
  27. data/lib/rgltf/glb.rb +71 -0
  28. data/lib/rgltf/properties/accessor.rb +142 -0
  29. data/lib/rgltf/properties/animation.rb +96 -0
  30. data/lib/rgltf/properties/asset.rb +15 -0
  31. data/lib/rgltf/properties/base.rb +45 -0
  32. data/lib/rgltf/properties/buffer.rb +50 -0
  33. data/lib/rgltf/properties/buffer_view.rb +18 -0
  34. data/lib/rgltf/properties/camera.rb +50 -0
  35. data/lib/rgltf/properties/image.rb +48 -0
  36. data/lib/rgltf/properties/material.rb +95 -0
  37. data/lib/rgltf/properties/mesh.rb +67 -0
  38. data/lib/rgltf/properties/node.rb +135 -0
  39. data/lib/rgltf/properties/sampler.rb +35 -0
  40. data/lib/rgltf/properties/scene.rb +14 -0
  41. data/lib/rgltf/properties/skin.rb +25 -0
  42. data/lib/rgltf/properties/texture.rb +19 -0
  43. data/lib/rgltf/properties.rb +17 -0
  44. data/lib/rgltf/validation/accessors.rb +205 -0
  45. data/lib/rgltf/validation/animations.rb +184 -0
  46. data/lib/rgltf/validation/buffers.rb +95 -0
  47. data/lib/rgltf/validation/cameras.rb +76 -0
  48. data/lib/rgltf/validation/context.rb +133 -0
  49. data/lib/rgltf/validation/images_materials.rb +159 -0
  50. data/lib/rgltf/validation/meshes.rb +214 -0
  51. data/lib/rgltf/validation/root_asset_extensions.rb +134 -0
  52. data/lib/rgltf/validation/scenes_nodes.rb +141 -0
  53. data/lib/rgltf/validation/skins.rb +94 -0
  54. data/lib/rgltf/validator.rb +53 -0
  55. data/lib/rgltf/version.rb +5 -0
  56. data/lib/rgltf/writer/buffer_merger.rb +72 -0
  57. data/lib/rgltf/writer/default_omitter.rb +51 -0
  58. data/lib/rgltf/writer/extension_serializer.rb +64 -0
  59. data/lib/rgltf/writer.rb +95 -0
  60. data/lib/rgltf.rb +133 -0
  61. data/tasks/sample_assets.rake +115 -0
  62. metadata +104 -0
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module Rgltf
6
+ class BufferResolver
7
+ DATA_URI_PATTERN = /\Adata:([^,]*?),(.*)\z/m
8
+
9
+ def initialize(base_dir:, glb_bin: nil, supplied_buffers: nil)
10
+ @base_dir = base_dir && File.expand_path(base_dir)
11
+ @glb_bin = glb_bin&.b
12
+ @supplied_buffers = supplied_buffers
13
+ @cache = {}
14
+ end
15
+
16
+ def resolve(buffer)
17
+ return @cache[buffer.index] if @cache.key?(buffer.index)
18
+
19
+ bytes = supplied(buffer.index) || resolve_uri(buffer)
20
+ if bytes.bytesize < buffer.byte_length
21
+ raise FormatError,
22
+ "buffer #{buffer.index} is shorter than byteLength (#{bytes.bytesize} < #{buffer.byte_length})"
23
+ end
24
+
25
+ @cache[buffer.index] = bytes.b.freeze
26
+ end
27
+
28
+ def release!
29
+ @cache.clear
30
+ @glb_bin = nil
31
+ @supplied_buffers = nil
32
+ end
33
+
34
+ def resolve_resource(uri)
35
+ return decode_data_uri(uri) if uri.start_with?('data:')
36
+
37
+ resolve_external(uri)
38
+ end
39
+
40
+ def supplied?(index)
41
+ return index.zero? if @supplied_buffers.is_a?(String)
42
+ return false unless @supplied_buffers.respond_to?(:[])
43
+ return false if @supplied_buffers.respond_to?(:key?) && !@supplied_buffers.key?(index)
44
+
45
+ !@supplied_buffers[index].nil?
46
+ rescue StandardError
47
+ false
48
+ end
49
+
50
+ private :supplied?
51
+
52
+ private
53
+
54
+ def supplied(index)
55
+ return unless supplied?(index)
56
+
57
+ value = @supplied_buffers.is_a?(String) ? @supplied_buffers : @supplied_buffers[index]
58
+ value&.b
59
+ end
60
+
61
+ def resolve_uri(buffer)
62
+ return @glb_bin if buffer.uri.nil? && buffer.index.zero? && @glb_bin
63
+ return decode_data_uri(buffer.uri) if buffer.uri&.start_with?('data:')
64
+ return resolve_external(buffer.uri) if buffer.uri
65
+
66
+ raise MissingResourceError, "buffer #{buffer.index} has no URI or GLB BIN chunk"
67
+ end
68
+
69
+ def decode_data_uri(uri)
70
+ match = DATA_URI_PATTERN.match(uri)
71
+ raise FormatError, 'invalid data URI' unless match
72
+
73
+ metadata, payload = match.captures
74
+ decoded_payload = URI::DEFAULT_PARSER.unescape(payload)
75
+ metadata.split(';').include?('base64') ? decoded_payload.unpack1('m0') : decoded_payload
76
+ rescue ArgumentError => e
77
+ raise FormatError, "invalid data URI: #{e.message}"
78
+ end
79
+
80
+ def resolve_external(uri)
81
+ raise MissingResourceError, 'cannot resolve external resource without base_dir' unless @base_dir
82
+
83
+ decoded = URI.decode_www_form_component(uri)
84
+ path = File.expand_path(decoded, @base_dir)
85
+ base_prefix = @base_dir.end_with?(File::SEPARATOR) ? @base_dir : "#{@base_dir}#{File::SEPARATOR}"
86
+ unless path == @base_dir || path.start_with?(base_prefix)
87
+ raise MissingResourceError, "external resource escapes base_dir: #{uri}"
88
+ end
89
+
90
+ File.binread(path)
91
+ rescue Errno::ENOENT, Errno::EACCES => e
92
+ raise MissingResourceError, e.message
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rgltf
4
+ class Builder
5
+ module AccessorData
6
+ private
7
+
8
+ def add_matrix_padding(source, type, component_size, count)
9
+ dimension = AccessorReader::MATRIX_DIMENSIONS[type]
10
+ return source unless dimension && component_size < 4
11
+
12
+ column_size = dimension * component_size
13
+ column_padding = (4 - (column_size % 4)) % 4
14
+ output = String.new(encoding: Encoding::BINARY)
15
+ offset = 0
16
+ count.times do
17
+ dimension.times do
18
+ output << source.byteslice(offset, column_size) << ("\0" * column_padding)
19
+ offset += column_size
20
+ end
21
+ end
22
+ output
23
+ end
24
+
25
+ def add_min_max(value, source, info, component_count)
26
+ elements = source.unpack("#{info.fetch(:pack)}*").each_slice(component_count).to_a
27
+ return if elements.empty?
28
+
29
+ value['min'] = extrema(elements, component_count, &:min)
30
+ value['max'] = extrema(elements, component_count, &:max)
31
+ end
32
+
33
+ def extrema(elements, component_count)
34
+ Array.new(component_count) do |component|
35
+ yield elements.map { |element| element[component] }
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rgltf
4
+ class Builder
5
+ module Accessors
6
+ def accessor(type, component_type, packed, min_max: false, target: :array_buffer, **properties)
7
+ type_name = type.to_s.upcase
8
+ info = AccessorReader::COMPONENTS_BY_SYMBOL.fetch(component_type.to_sym)
9
+ component_count = AccessorReader::ELEMENT_COUNTS.fetch(type_name)
10
+ element_size = info[:size] * component_count
11
+ source = packed.b
12
+ unless (source.bytesize % element_size).zero?
13
+ raise ArgumentError,
14
+ 'accessor byte length must be divisible by its element size'
15
+ end
16
+
17
+ count = source.bytesize / element_size
18
+ stored = add_matrix_padding(source, type_name, info[:size], count)
19
+ view_index = append_buffer_view(stored, target: target && TARGETS.fetch(target.to_sym))
20
+ value = accessor_properties(type, component_type, count:, **properties)
21
+ value['bufferView'] = view_index
22
+ add_min_max(value, source, info, component_count) if min_max
23
+ handle(:accessor, append('accessors', value), buffer_view_index: view_index)
24
+ rescue KeyError => e
25
+ raise ArgumentError, "unsupported accessor type: #{e.message}"
26
+ end
27
+
28
+ def buffer_view(bytes, byte_stride: nil, target: nil, name: nil, extensions: nil, extras: nil)
29
+ target_number = target && TARGETS.fetch(target.to_sym)
30
+ index = append_buffer_view(bytes.b, target: target_number, byte_stride:, name:, extensions:, extras:)
31
+ handle(:buffer_view, index)
32
+ rescue KeyError
33
+ raise ArgumentError, "unsupported buffer view target #{target.inspect}"
34
+ end
35
+
36
+ def accessor_from_view(type, component_type, buffer_view:, count:, byte_offset: 0,
37
+ normalized: false, min: nil, max: nil, **properties)
38
+ value = accessor_properties(type, component_type, count:, byte_offset:, normalized:, min:, max:, **properties)
39
+ value['bufferView'] = reference(buffer_view, :buffer_view)
40
+ handle(:accessor, append('accessors', value), buffer_view_index: buffer_view.index)
41
+ end
42
+
43
+ def sparse_accessor(type, component_type, count:, indices:, values:, index_component_type: :u16,
44
+ base: nil, normalized: false, sparse_extensions: nil, sparse_extras: nil,
45
+ indices_extensions: nil, indices_extras: nil,
46
+ values_extensions: nil, values_extras: nil, **properties)
47
+ index_info = sparse_index_info(index_component_type)
48
+ index_bytes = indices.is_a?(String) ? indices.b : Array(indices).pack("#{index_info.fetch(:pack)}*")
49
+ unless (index_bytes.bytesize % index_info.fetch(:size)).zero?
50
+ raise ArgumentError, 'sparse index byte length must be divisible by its component size'
51
+ end
52
+
53
+ sparse_count = index_bytes.bytesize / index_info.fetch(:size)
54
+ value_info = AccessorReader::COMPONENTS_BY_SYMBOL.fetch(component_type.to_sym)
55
+ type_name = type.to_s.upcase
56
+ component_count = AccessorReader::ELEMENT_COUNTS.fetch(type_name)
57
+ expected_size = sparse_count * component_count * value_info.fetch(:size)
58
+ unless values.bytesize == expected_size
59
+ raise ArgumentError,
60
+ 'sparse value byte length does not match its indices'
61
+ end
62
+
63
+ value_bytes = add_matrix_padding(values.b, type_name, value_info.fetch(:size), sparse_count)
64
+ sparse = sparse_properties(index_bytes, value_bytes, index_info, sparse_count,
65
+ sparse_extensions:, sparse_extras:, indices_extensions:,
66
+ indices_extras:, values_extensions:, values_extras:)
67
+ value = accessor_properties(type, component_type, count:, normalized:, **properties)
68
+ view_index = append_sparse_base(value, base, type_name, value_info, count)
69
+ value['sparse'] = sparse
70
+ handle(:accessor, append('accessors', value), buffer_view_index: view_index)
71
+ rescue KeyError => e
72
+ raise ArgumentError, "unsupported sparse accessor type: #{e.message}"
73
+ end
74
+
75
+ private
76
+
77
+ def sparse_index_info(component_type)
78
+ symbol = component_type.to_sym
79
+ raise ArgumentError, 'sparse indices must use u8, u16, or u32' unless %i[u8 u16 u32].include?(symbol)
80
+
81
+ AccessorReader::COMPONENTS_BY_SYMBOL.fetch(symbol)
82
+ end
83
+
84
+ def sparse_properties(index_bytes, value_bytes, info, count, sparse_extensions:, sparse_extras:,
85
+ indices_extensions:, indices_extras:, values_extensions:, values_extras:)
86
+ indices = { 'bufferView' => append_buffer_view(index_bytes, target: nil),
87
+ 'componentType' => info.fetch(:number) }
88
+ add_extension_properties(indices, indices_extensions, indices_extras)
89
+ values = { 'bufferView' => append_buffer_view(value_bytes, target: nil) }
90
+ add_extension_properties(values, values_extensions, values_extras)
91
+ { 'count' => count, 'indices' => indices, 'values' => values }.tap do |sparse|
92
+ add_extension_properties(sparse, sparse_extensions, sparse_extras)
93
+ end
94
+ end
95
+
96
+ def add_extension_properties(value, extensions, extras)
97
+ value['extensions'] = extensions if extensions
98
+ value['extras'] = extras unless extras.nil?
99
+ end
100
+
101
+ def append_sparse_base(value, base, type_name, info, count)
102
+ return unless base
103
+
104
+ component_count = AccessorReader::ELEMENT_COUNTS.fetch(type_name)
105
+ expected_size = count * component_count * info.fetch(:size)
106
+ unless base.bytesize == expected_size
107
+ raise ArgumentError,
108
+ 'sparse base byte length does not match accessor count'
109
+ end
110
+
111
+ view_index = append_buffer_view(add_matrix_padding(base.b, type_name, info.fetch(:size), count), target: nil)
112
+ value['bufferView'] = view_index
113
+ view_index
114
+ end
115
+
116
+ def accessor_properties(type, component_type, count:, byte_offset: 0, normalized: false,
117
+ min: nil, max: nil, **properties)
118
+ info = AccessorReader::COMPONENTS_BY_SYMBOL.fetch(component_type.to_sym)
119
+ type_name = type.to_s.upcase
120
+ AccessorReader::ELEMENT_COUNTS.fetch(type_name)
121
+ value = { 'componentType' => info.fetch(:number), 'count' => count, 'type' => type_name }
122
+ value['byteOffset'] = byte_offset unless byte_offset.zero?
123
+ value['normalized'] = true if normalized
124
+ value['min'] = min if min
125
+ value['max'] = max if max
126
+ value.merge!(stringify_properties(properties))
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rgltf
4
+ class Builder
5
+ class MeshBuilder
6
+ attr_reader :primitives
7
+
8
+ def initialize(builder)
9
+ @builder = builder
10
+ @primitives = []
11
+ end
12
+
13
+ def primitive(attributes:, indices: nil, material: nil, mode: :triangles, targets: nil,
14
+ extensions: nil, extras: nil, **properties)
15
+ value = { 'attributes' => attributes_json(attributes) }
16
+ if indices
17
+ @builder.mark_accessor_target(indices, :element_array)
18
+ value['indices'] = @builder.reference(indices, :accessor)
19
+ end
20
+ value['material'] = @builder.reference(material, :material) if material
21
+ mode_number = Primitive::MODES.key(mode.to_sym)
22
+ raise ArgumentError, "unsupported primitive mode #{mode.inspect}" unless mode_number
23
+
24
+ value['mode'] = mode_number unless mode_number == 4
25
+ value['targets'] = targets.map { |target| attributes_json(target) } if targets
26
+ value['extensions'] = extensions if extensions
27
+ value['extras'] = extras unless extras.nil?
28
+ properties.each { |key, property| value[@builder.send(:camelize, key)] = property }
29
+ @primitives << value
30
+ self
31
+ end
32
+
33
+ private
34
+
35
+ def attributes_json(attributes)
36
+ attributes.to_h do |semantic, accessor|
37
+ @builder.mark_accessor_target(accessor, :array_buffer)
38
+ [semantic.to_s, @builder.reference(accessor, :accessor)]
39
+ end
40
+ end
41
+ end
42
+
43
+ class AnimationBuilder
44
+ attr_reader :samplers, :channels
45
+
46
+ def initialize(builder)
47
+ @builder = builder
48
+ @samplers = []
49
+ @channels = []
50
+ end
51
+
52
+ def sampler(input:, output:, interpolation: :linear, extensions: nil, extras: nil)
53
+ mode = interpolation.to_s.upcase
54
+ raise ArgumentError, "unsupported interpolation #{interpolation.inspect}" unless %w[LINEAR STEP
55
+ CUBICSPLINE].include?(mode)
56
+
57
+ value = {
58
+ 'input' => @builder.reference(input, :accessor),
59
+ 'output' => @builder.reference(output, :accessor)
60
+ }
61
+ value['interpolation'] = mode unless mode == 'LINEAR'
62
+ value['extensions'] = extensions if extensions
63
+ value['extras'] = extras unless extras.nil?
64
+ @samplers << value
65
+ @samplers.length - 1
66
+ end
67
+
68
+ def channel(sampler:, node:, path:, extensions: nil, extras: nil,
69
+ target_extensions: nil, target_extras: nil)
70
+ target_path = path.to_s
71
+ unless Animation::Channel::TARGET_PATHS.include?(target_path)
72
+ raise ArgumentError, "unsupported animation target path #{path.inspect}"
73
+ end
74
+
75
+ target = { 'node' => @builder.reference(node, :node), 'path' => target_path }
76
+ target['extensions'] = target_extensions if target_extensions
77
+ target['extras'] = target_extras unless target_extras.nil?
78
+ value = {
79
+ 'sampler' => sampler,
80
+ 'target' => target
81
+ }
82
+ value['extensions'] = extensions if extensions
83
+ value['extras'] = extras unless extras.nil?
84
+ @channels << value
85
+ self
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rgltf
4
+ class Builder
5
+ module DocumentProperties
6
+ def extensions_used(*names)
7
+ @json['extensionsUsed'] = names.flatten.map(&:to_s).uniq
8
+ self
9
+ end
10
+
11
+ def extensions_required(*names)
12
+ values = names.flatten.map(&:to_s).uniq
13
+ @json['extensionsRequired'] = values
14
+ @json['extensionsUsed'] = (@json.fetch('extensionsUsed', []) | values)
15
+ self
16
+ end
17
+
18
+ def extension(name, value, required: false)
19
+ string_name = name.to_s
20
+ (@json['extensions'] ||= {})[string_name] = value
21
+ @json['extensionsUsed'] = (@json.fetch('extensionsUsed', []) | [string_name])
22
+ extensions_required(string_name) if required
23
+ self
24
+ end
25
+
26
+ def extras(value)
27
+ @json['extras'] = value
28
+ self
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rgltf
4
+ class Builder
5
+ module Materials
6
+ TextureInfoValue = Struct.new(:json, :owner, keyword_init: true)
7
+
8
+ def image(bytes: nil, uri: nil, buffer_view: nil, mime_type: nil, name: nil, extensions: nil, extras: nil)
9
+ sources = [bytes, uri, buffer_view].count { |source| !source.nil? }
10
+ raise ArgumentError, 'provide exactly one of image bytes, uri, or buffer_view' unless sources == 1
11
+
12
+ value = image_properties(bytes:, uri:, buffer_view:, mime_type:)
13
+ add_common_properties(value, name:, extensions:, extras:)
14
+ handle(:image, append('images', value))
15
+ end
16
+
17
+ def sampler(mag_filter: nil, min_filter: nil, wrap_s: nil, wrap_t: nil,
18
+ name: nil, extensions: nil, extras: nil)
19
+ value = sampler_properties(mag_filter:, min_filter:, wrap_s:, wrap_t:)
20
+ add_common_properties(value, name:, extensions:, extras:)
21
+ handle(:sampler, append('samplers', value))
22
+ end
23
+
24
+ def texture(image: nil, source: nil, mime_type: nil, sampler: nil, name: nil,
25
+ extensions: nil, extras: nil)
26
+ image ||= source
27
+ raise ArgumentError, 'texture requires an image or source' unless image
28
+
29
+ image_handle = image.is_a?(Handle) ? verify_handle(image, :image) : self.image(bytes: image, mime_type:, name:)
30
+ texture_json = { 'source' => image_handle.index }
31
+ texture_json['sampler'] = build_sampler(sampler) if sampler
32
+ add_common_properties(texture_json, name:, extensions:, extras:)
33
+ handle(:texture, append('textures', texture_json))
34
+ end
35
+
36
+ def material(name: nil, base_color_factor: nil, base_color_texture: nil,
37
+ metallic_factor: nil, roughness_factor: nil, metallic_roughness_texture: nil,
38
+ normal_texture: nil, occlusion_texture: nil, emissive_texture: nil,
39
+ emissive_factor: nil, alpha_mode: nil, alpha_cutoff: nil,
40
+ double_sided: nil, pbr_extensions: nil, pbr_extras: nil,
41
+ extensions: nil, extras: nil, **properties)
42
+ pbr = {}
43
+ pbr['baseColorFactor'] = base_color_factor if base_color_factor
44
+ pbr['baseColorTexture'] = texture_info_json(base_color_texture) if base_color_texture
45
+ pbr['metallicFactor'] = metallic_factor unless metallic_factor.nil?
46
+ pbr['roughnessFactor'] = roughness_factor unless roughness_factor.nil?
47
+ pbr['metallicRoughnessTexture'] = texture_info_json(metallic_roughness_texture) if metallic_roughness_texture
48
+ pbr['extensions'] = pbr_extensions if pbr_extensions
49
+ pbr['extras'] = pbr_extras unless pbr_extras.nil?
50
+ value = {}
51
+ add_common_properties(value, name:, extensions:, extras:)
52
+ value['pbrMetallicRoughness'] = pbr unless pbr.empty?
53
+ value['normalTexture'] = texture_info_json(normal_texture) if normal_texture
54
+ value['occlusionTexture'] = texture_info_json(occlusion_texture) if occlusion_texture
55
+ value['emissiveTexture'] = texture_info_json(emissive_texture) if emissive_texture
56
+ value['emissiveFactor'] = emissive_factor if emissive_factor
57
+ value['alphaMode'] = alpha_mode.to_s.upcase if alpha_mode
58
+ value['alphaCutoff'] = alpha_cutoff unless alpha_cutoff.nil?
59
+ value['doubleSided'] = double_sided unless double_sided.nil?
60
+ value.merge!(stringify_properties(properties))
61
+ handle(:material, append('materials', value))
62
+ end
63
+
64
+ def texture_info(texture, tex_coord: nil, extensions: nil, extras: nil)
65
+ build_texture_info(texture, tex_coord:, extensions:, extras:)
66
+ end
67
+
68
+ def normal_texture_info(texture, tex_coord: nil, scale: nil, extensions: nil, extras: nil)
69
+ build_texture_info(texture, tex_coord:, scale:, extensions:, extras:)
70
+ end
71
+
72
+ def occlusion_texture_info(texture, tex_coord: nil, strength: nil, extensions: nil, extras: nil)
73
+ build_texture_info(texture, tex_coord:, strength:, extensions:, extras:)
74
+ end
75
+
76
+ private
77
+
78
+ def image_properties(bytes:, uri:, buffer_view:, mime_type:)
79
+ return { 'uri' => uri }.tap { |value| value['mimeType'] = mime_type if mime_type } if uri
80
+ if buffer_view
81
+ return {
82
+ 'bufferView' => reference(buffer_view, :buffer_view),
83
+ 'mimeType' => mime_type || raise(ArgumentError, 'mime_type is required for buffer view images')
84
+ }
85
+ end
86
+
87
+ {
88
+ 'bufferView' => append_buffer_view(bytes.b, target: nil),
89
+ 'mimeType' => mime_type || raise(ArgumentError, 'mime_type is required for image bytes')
90
+ }
91
+ end
92
+
93
+ def build_sampler(properties)
94
+ return reference(properties, :sampler) if properties.is_a?(Handle)
95
+
96
+ reference(sampler(**properties), :sampler)
97
+ end
98
+
99
+ def build_texture_info(texture, **properties)
100
+ json = { 'index' => reference(texture, :texture) }
101
+ properties.each do |key, value|
102
+ next if value.nil?
103
+
104
+ json[camelize(key)] = value
105
+ end
106
+ TextureInfoValue.new(json:, owner: @handle_owner).freeze
107
+ end
108
+
109
+ def texture_info_json(value)
110
+ return { 'index' => reference(value, :texture) } if value.is_a?(Handle)
111
+ unless value.is_a?(TextureInfoValue) && value.owner.equal?(@handle_owner)
112
+ raise ArgumentError, 'expected a texture or texture-info builder handle'
113
+ end
114
+
115
+ value.json.dup
116
+ end
117
+
118
+ def sampler_properties(mag_filter: nil, min_filter: nil, wrap_s: nil, wrap_t: nil, **unknown)
119
+ raise ArgumentError, "unknown sampler properties: #{unknown.keys.join(', ')}" unless unknown.empty?
120
+
121
+ filters = Sampler::FILTERS.invert
122
+ wraps = Sampler::WRAPS.invert
123
+ value = {}
124
+ value['magFilter'] = filters.fetch(mag_filter) if mag_filter
125
+ value['minFilter'] = filters.fetch(min_filter) if min_filter
126
+ value['wrapS'] = wraps.fetch(wrap_s) if wrap_s && wrap_s != :repeat
127
+ value['wrapT'] = wraps.fetch(wrap_t) if wrap_t && wrap_t != :repeat
128
+ value
129
+ end
130
+ end
131
+ end
132
+ end