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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +270 -0
- data/Rakefile +10 -0
- data/benchmark/accessors.rb +26 -0
- data/benchmark/compare.rb +28 -0
- data/benchmark/sample_assets.rb +61 -0
- data/examples/README.md +23 -0
- data/examples/build_triangle.rb +40 -0
- data/examples/convert.rb +24 -0
- data/examples/inspect.rb +27 -0
- data/lib/rgltf/accessor_reader.rb +142 -0
- data/lib/rgltf/buffer_resolver.rb +95 -0
- data/lib/rgltf/builder/accessor_data.rb +40 -0
- data/lib/rgltf/builder/accessors.rb +130 -0
- data/lib/rgltf/builder/component_builders.rb +89 -0
- data/lib/rgltf/builder/document_properties.rb +32 -0
- data/lib/rgltf/builder/materials.rb +132 -0
- data/lib/rgltf/builder.rb +176 -0
- data/lib/rgltf/document.rb +159 -0
- data/lib/rgltf/errors.rb +10 -0
- data/lib/rgltf/extension.rb +79 -0
- data/lib/rgltf/extensions/khr_lights_punctual.rb +101 -0
- data/lib/rgltf/extensions/khr_materials_emissive_strength.rb +29 -0
- data/lib/rgltf/extensions/khr_materials_unlit.rb +19 -0
- data/lib/rgltf/extensions/khr_texture_transform.rb +42 -0
- data/lib/rgltf/glb.rb +71 -0
- data/lib/rgltf/properties/accessor.rb +142 -0
- data/lib/rgltf/properties/animation.rb +96 -0
- data/lib/rgltf/properties/asset.rb +15 -0
- data/lib/rgltf/properties/base.rb +45 -0
- data/lib/rgltf/properties/buffer.rb +50 -0
- data/lib/rgltf/properties/buffer_view.rb +18 -0
- data/lib/rgltf/properties/camera.rb +50 -0
- data/lib/rgltf/properties/image.rb +48 -0
- data/lib/rgltf/properties/material.rb +95 -0
- data/lib/rgltf/properties/mesh.rb +67 -0
- data/lib/rgltf/properties/node.rb +135 -0
- data/lib/rgltf/properties/sampler.rb +35 -0
- data/lib/rgltf/properties/scene.rb +14 -0
- data/lib/rgltf/properties/skin.rb +25 -0
- data/lib/rgltf/properties/texture.rb +19 -0
- data/lib/rgltf/properties.rb +17 -0
- data/lib/rgltf/validation/accessors.rb +205 -0
- data/lib/rgltf/validation/animations.rb +184 -0
- data/lib/rgltf/validation/buffers.rb +95 -0
- data/lib/rgltf/validation/cameras.rb +76 -0
- data/lib/rgltf/validation/context.rb +133 -0
- data/lib/rgltf/validation/images_materials.rb +159 -0
- data/lib/rgltf/validation/meshes.rb +214 -0
- data/lib/rgltf/validation/root_asset_extensions.rb +134 -0
- data/lib/rgltf/validation/scenes_nodes.rb +141 -0
- data/lib/rgltf/validation/skins.rb +94 -0
- data/lib/rgltf/validator.rb +53 -0
- data/lib/rgltf/version.rb +5 -0
- data/lib/rgltf/writer/buffer_merger.rb +72 -0
- data/lib/rgltf/writer/default_omitter.rb +51 -0
- data/lib/rgltf/writer/extension_serializer.rb +64 -0
- data/lib/rgltf/writer.rb +95 -0
- data/lib/rgltf.rb +133 -0
- data/tasks/sample_assets.rake +115 -0
- 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
|