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,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'builder/accessor_data'
|
|
4
|
+
require_relative 'builder/accessors'
|
|
5
|
+
require_relative 'builder/materials'
|
|
6
|
+
require_relative 'builder/component_builders'
|
|
7
|
+
require_relative 'builder/document_properties'
|
|
8
|
+
|
|
9
|
+
module Rgltf
|
|
10
|
+
class Builder
|
|
11
|
+
include AccessorData
|
|
12
|
+
include Accessors
|
|
13
|
+
include Materials
|
|
14
|
+
include DocumentProperties
|
|
15
|
+
|
|
16
|
+
Handle = Struct.new(:kind, :index, :buffer_view_index, :owner, keyword_init: true)
|
|
17
|
+
TARGETS = { array_buffer: 34_962, array: 34_962, element_array: 34_963, element_array_buffer: 34_963 }.freeze
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def build(&block)
|
|
21
|
+
builder = new
|
|
22
|
+
block.call(builder)
|
|
23
|
+
builder.document
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize
|
|
28
|
+
@handle_owner = Object.new.freeze
|
|
29
|
+
@json = { 'asset' => { 'version' => '2.0' } }
|
|
30
|
+
@binary = String.new(encoding: Encoding::BINARY)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def asset(generator: nil, copyright: nil, **properties)
|
|
34
|
+
values = { 'version' => '2.0' }
|
|
35
|
+
values['generator'] = generator if generator
|
|
36
|
+
values['copyright'] = copyright if copyright
|
|
37
|
+
properties.each { |key, value| values[camelize(key)] = value }
|
|
38
|
+
@json['asset'] = values
|
|
39
|
+
self
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def mesh(name: nil, weights: nil, extensions: nil, extras: nil)
|
|
43
|
+
mesh_builder = MeshBuilder.new(self)
|
|
44
|
+
yield mesh_builder
|
|
45
|
+
value = { 'primitives' => mesh_builder.primitives }
|
|
46
|
+
add_common_properties(value, name:, extensions:, extras:)
|
|
47
|
+
value['weights'] = weights if weights
|
|
48
|
+
handle(:mesh, append('meshes', value))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def node(name: nil, mesh: nil, skin: nil, camera: nil, children: nil,
|
|
52
|
+
translation: nil, rotation: nil, scale: nil, matrix: nil, weights: nil, **properties)
|
|
53
|
+
value = {}
|
|
54
|
+
value['name'] = name if name
|
|
55
|
+
value['mesh'] = reference(mesh, :mesh) if mesh
|
|
56
|
+
value['skin'] = reference(skin, :skin) if skin
|
|
57
|
+
value['camera'] = reference(camera, :camera) if camera
|
|
58
|
+
value['children'] = children.map { |child| reference(child, :node) } if children
|
|
59
|
+
value['translation'] = translation if translation
|
|
60
|
+
value['rotation'] = rotation if rotation
|
|
61
|
+
value['scale'] = scale if scale
|
|
62
|
+
value['matrix'] = matrix if matrix
|
|
63
|
+
value['weights'] = weights if weights
|
|
64
|
+
value.merge!(stringify_properties(properties))
|
|
65
|
+
handle(:node, append('nodes', value))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def camera(type:, name: nil, extensions: nil, extras: nil,
|
|
69
|
+
projection_extensions: nil, projection_extras: nil, **projection)
|
|
70
|
+
camera_type = type.to_sym
|
|
71
|
+
raise ArgumentError, 'camera type must be perspective or orthographic' unless %i[perspective
|
|
72
|
+
orthographic].include?(camera_type)
|
|
73
|
+
|
|
74
|
+
projection_value = stringify_properties(projection)
|
|
75
|
+
projection_value['extensions'] = projection_extensions if projection_extensions
|
|
76
|
+
projection_value['extras'] = projection_extras unless projection_extras.nil?
|
|
77
|
+
value = { 'type' => camera_type.to_s, camera_type.to_s => projection_value }
|
|
78
|
+
add_common_properties(value, name:, extensions:, extras:)
|
|
79
|
+
handle(:camera, append('cameras', value))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def skin(joints:, inverse_bind_matrices: nil, skeleton: nil, name: nil, extensions: nil, extras: nil)
|
|
83
|
+
value = { 'joints' => joints.map { |joint| reference(joint, :node) } }
|
|
84
|
+
value['inverseBindMatrices'] = reference(inverse_bind_matrices, :accessor) if inverse_bind_matrices
|
|
85
|
+
value['skeleton'] = reference(skeleton, :node) if skeleton
|
|
86
|
+
add_common_properties(value, name:, extensions:, extras:)
|
|
87
|
+
handle(:skin, append('skins', value))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def attach_skin(node, skin)
|
|
91
|
+
node_handle = verify_handle(node, :node)
|
|
92
|
+
@json.fetch('nodes').fetch(node_handle.index)['skin'] = reference(skin, :skin)
|
|
93
|
+
node
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def animation(name: nil, extensions: nil, extras: nil)
|
|
97
|
+
animation_builder = AnimationBuilder.new(self)
|
|
98
|
+
yield animation_builder
|
|
99
|
+
value = { 'samplers' => animation_builder.samplers, 'channels' => animation_builder.channels }
|
|
100
|
+
add_common_properties(value, name:, extensions:, extras:)
|
|
101
|
+
handle(:animation, append('animations', value))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def scene(nodes:, name: nil, default: false, **properties)
|
|
105
|
+
value = { 'nodes' => nodes.map { |node| reference(node, :node) } }
|
|
106
|
+
value['name'] = name if name
|
|
107
|
+
value.merge!(stringify_properties(properties))
|
|
108
|
+
scene_handle = handle(:scene, append('scenes', value))
|
|
109
|
+
@json['scene'] = scene_handle.index if default
|
|
110
|
+
scene_handle
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def document
|
|
114
|
+
@json['buffers'] = [{ 'byteLength' => @binary.bytesize }] unless @binary.empty?
|
|
115
|
+
Rgltf.load_json(@json, buffers: @binary.empty? ? nil : [@binary], strict_extensions: false)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def mark_accessor_target(handle, target)
|
|
119
|
+
verify_handle(handle, :accessor)
|
|
120
|
+
return handle unless handle.buffer_view_index
|
|
121
|
+
|
|
122
|
+
@json.fetch('bufferViews').fetch(handle.buffer_view_index)['target'] = TARGETS.fetch(target)
|
|
123
|
+
handle
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def reference(handle, kind)
|
|
127
|
+
verify_handle(handle, kind).index
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def append(collection, value)
|
|
133
|
+
(@json[collection] ||= []) << value
|
|
134
|
+
@json.fetch(collection).length - 1
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def append_buffer_view(bytes, target:, byte_stride: nil, name: nil, extensions: nil, extras: nil)
|
|
138
|
+
@binary << ("\0" * ((4 - (@binary.bytesize % 4)) % 4))
|
|
139
|
+
value = { 'buffer' => 0, 'byteOffset' => @binary.bytesize, 'byteLength' => bytes.bytesize }
|
|
140
|
+
value.delete('byteOffset') if value['byteOffset'].zero?
|
|
141
|
+
value['target'] = target if target
|
|
142
|
+
value['byteStride'] = byte_stride if byte_stride
|
|
143
|
+
add_common_properties(value, name:, extensions:, extras:)
|
|
144
|
+
@binary << bytes
|
|
145
|
+
append('bufferViews', value)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def verify_handle(handle, kind)
|
|
149
|
+
unless handle.is_a?(Handle) && handle.kind == kind && handle.owner.equal?(@handle_owner)
|
|
150
|
+
raise ArgumentError, "expected a #{kind} builder handle"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
handle
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def stringify_properties(properties)
|
|
157
|
+
properties.to_h { |key, value| [camelize(key), value] }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def add_common_properties(value, name: nil, extensions: nil, extras: nil)
|
|
161
|
+
value['name'] = name if name
|
|
162
|
+
value['extensions'] = extensions if extensions
|
|
163
|
+
value['extras'] = extras unless extras.nil?
|
|
164
|
+
value
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def handle(kind, index, buffer_view_index: nil)
|
|
168
|
+
Handle.new(kind:, index:, buffer_view_index:, owner: @handle_owner).freeze
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def camelize(key)
|
|
172
|
+
parts = key.to_s.split('_')
|
|
173
|
+
parts.first + parts.drop(1).map(&:capitalize).join
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rgltf
|
|
4
|
+
class Document
|
|
5
|
+
COLLECTIONS = {
|
|
6
|
+
'buffers' => Buffer,
|
|
7
|
+
'bufferViews' => BufferView,
|
|
8
|
+
'accessors' => Accessor,
|
|
9
|
+
'images' => Image,
|
|
10
|
+
'samplers' => Sampler,
|
|
11
|
+
'textures' => Texture,
|
|
12
|
+
'materials' => Material,
|
|
13
|
+
'meshes' => Mesh,
|
|
14
|
+
'cameras' => Camera,
|
|
15
|
+
'nodes' => Node,
|
|
16
|
+
'skins' => Skin,
|
|
17
|
+
'animations' => Animation,
|
|
18
|
+
'scenes' => Scene
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
attr_reader :asset, :scenes, :nodes, :meshes, :accessors, :buffer_views,
|
|
22
|
+
:buffers, :materials, :textures, :images, :samplers, :skins,
|
|
23
|
+
:animations, :cameras, :extensions_used, :extensions_required,
|
|
24
|
+
:extensions, :extras, :extension_registry, :buffer_resolver,
|
|
25
|
+
:raw_json, :validation_result
|
|
26
|
+
|
|
27
|
+
def initialize(json, base_dir:, supplied_buffers:, glb_bin:, lazy:,
|
|
28
|
+
extension_registry:, validate:, strict_extensions:)
|
|
29
|
+
@raw_json = json
|
|
30
|
+
@extension_registry = extension_registry
|
|
31
|
+
@extensions_used = json.fetch('extensionsUsed', []).freeze
|
|
32
|
+
@extensions_required = json.fetch('extensionsRequired', []).freeze
|
|
33
|
+
@extras = json['extras']
|
|
34
|
+
reject_unsupported_extensions! if strict_extensions
|
|
35
|
+
@buffer_resolver = BufferResolver.new(base_dir:, glb_bin:, supplied_buffers:)
|
|
36
|
+
@asset = Asset.new(json.fetch('asset'), document: self)
|
|
37
|
+
reject_unsupported_version!
|
|
38
|
+
|
|
39
|
+
build_collections(json)
|
|
40
|
+
validate_default_scene_reference!
|
|
41
|
+
@extensions = parse_extensions(json.fetch('extensions', {}), :document, self).freeze
|
|
42
|
+
nodes.each(&:resolve_references!)
|
|
43
|
+
parse_property_extensions!
|
|
44
|
+
decode_primitives!
|
|
45
|
+
buffers.each(&:bytes) unless lazy
|
|
46
|
+
|
|
47
|
+
@validation_result = Validator.validate(self) if validate || !strict_extensions
|
|
48
|
+
if validate && !validation_result.errors.empty?
|
|
49
|
+
raise ValidationError, validation_result.errors.map(&:message).join('; ')
|
|
50
|
+
end
|
|
51
|
+
rescue KeyError => e
|
|
52
|
+
raise FormatError, "missing required glTF property: #{e.message}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def fetch_reference(collection, index, path)
|
|
56
|
+
objects = public_send(collection)
|
|
57
|
+
return objects[index] if index.is_a?(Integer) && index >= 0 && index < objects.length
|
|
58
|
+
|
|
59
|
+
raise FormatError, "#{path} references missing #{collection.to_s.tr('_', ' ')} index #{index.inspect}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def default_scene
|
|
63
|
+
@default_scene_index.nil? ? nil : fetch_reference(:scenes, @default_scene_index, 'scene')
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def extension(name)
|
|
67
|
+
extensions[name]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def each_mesh_instance
|
|
71
|
+
return enum_for(:each_mesh_instance) unless block_given?
|
|
72
|
+
|
|
73
|
+
scenes.each do |scene|
|
|
74
|
+
scene.nodes.each { |node| visit_mesh_instances(node, Matrix4::IDENTITY, {}) { |*values| yield(*values) } }
|
|
75
|
+
end
|
|
76
|
+
self
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def release_buffers!
|
|
80
|
+
accessors.each(&:release!)
|
|
81
|
+
images.each(&:release!)
|
|
82
|
+
buffer_resolver.release!
|
|
83
|
+
self
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def to_glb(validate: true)
|
|
87
|
+
Writer.new(self, validate:).to_glb
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def write_glb(path, validate: true)
|
|
91
|
+
Writer.new(self, validate:).write_glb(path)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def write_gltf(path, embed: false, validate: true)
|
|
95
|
+
Writer.new(self, validate:).write_gltf(path, embed:)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def build_collections(json)
|
|
101
|
+
COLLECTIONS.each do |json_key, klass|
|
|
102
|
+
ruby_name = json_key == 'bufferViews' ? :buffer_views : json_key.to_sym
|
|
103
|
+
values = json.fetch(json_key, []).each_with_index.map do |properties, index|
|
|
104
|
+
klass.new(properties, document: self, index:)
|
|
105
|
+
end.freeze
|
|
106
|
+
instance_variable_set("@#{ruby_name}", values)
|
|
107
|
+
end
|
|
108
|
+
@default_scene_index = json['scene']
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def parse_property_extensions!
|
|
112
|
+
objects = [asset, *COLLECTIONS.keys.flat_map do |json_key|
|
|
113
|
+
public_send(json_key == 'bufferViews' ? :buffer_views : json_key)
|
|
114
|
+
end]
|
|
115
|
+
objects.each(&:parse_extensions!)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def decode_primitives!
|
|
119
|
+
meshes.each do |mesh|
|
|
120
|
+
decoded = mesh.primitives.map { |primitive| extension_registry.decode_primitive(primitive, doc: self) }
|
|
121
|
+
mesh.replace_primitives!(decoded)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def validate_default_scene_reference!
|
|
126
|
+
return if @default_scene_index.nil?
|
|
127
|
+
|
|
128
|
+
fetch_reference(:scenes, @default_scene_index, 'scene')
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def parse_extensions(values, owner_type, owner)
|
|
132
|
+
values.to_h do |name, value|
|
|
133
|
+
[name, extension_registry.parse(name, value, on: owner_type, owner:, doc: self)]
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def reject_unsupported_extensions!
|
|
138
|
+
missing = extensions_required.reject { |name| extension_registry.known?(name) }
|
|
139
|
+
return if missing.empty?
|
|
140
|
+
|
|
141
|
+
raise UnsupportedExtensionError, "required extension is not registered: #{missing.join(', ')}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def reject_unsupported_version!
|
|
145
|
+
version = asset.version.to_s
|
|
146
|
+
raise UnsupportedError, "glTF version #{version}" unless version.split('.').first == '2'
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def visit_mesh_instances(node, parent_matrix, visiting, &block)
|
|
150
|
+
raise FormatError, "node hierarchy contains a cycle at node #{node.index}" if visiting[node.object_id]
|
|
151
|
+
|
|
152
|
+
visiting[node.object_id] = true
|
|
153
|
+
world = node.world_matrix(parent_matrix)
|
|
154
|
+
block.call(node, node.mesh, world) if node.mesh
|
|
155
|
+
node.children.each { |child| visit_mesh_instances(child, world, visiting, &block) }
|
|
156
|
+
visiting.delete(node.object_id)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
data/lib/rgltf/errors.rb
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rgltf
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
class FormatError < Error; end
|
|
6
|
+
class UnsupportedError < Error; end
|
|
7
|
+
class UnsupportedExtensionError < UnsupportedError; end
|
|
8
|
+
class MissingResourceError < Error; end
|
|
9
|
+
class ValidationError < Error; end
|
|
10
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rgltf
|
|
4
|
+
class ExtensionRegistry
|
|
5
|
+
def initialize
|
|
6
|
+
@entries = {}
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def add(name, handler, on:)
|
|
10
|
+
Array(on).each { |owner_type| @entries[[name, owner_type.to_sym]] = handler }
|
|
11
|
+
self
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def known?(name)
|
|
15
|
+
@entries.any? { |(registered_name, _), _| registered_name == name }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def parse(name, json, on:, owner:, doc:)
|
|
19
|
+
handler = @entries[[name, on.to_sym]]
|
|
20
|
+
handler ? handler.parse(json, owner:, doc:) : json
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def serialize(name, object, on:, doc:)
|
|
24
|
+
handler = @entries[[name, on.to_sym]]
|
|
25
|
+
handler ? handler.serialize(object, doc:) : object
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def registered_on?(name, on:)
|
|
29
|
+
@entries.key?([name, on.to_sym])
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def names
|
|
33
|
+
@entries.keys.map(&:first).uniq.freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def decode_primitive(primitive, doc:)
|
|
37
|
+
primitive.extensions.reduce(primitive) do |current, (name, _value)|
|
|
38
|
+
handler = @entries[[name, :primitive]]
|
|
39
|
+
if handler.respond_to?(:decode_primitive)
|
|
40
|
+
handler.decode_primitive(current, doc:) || current
|
|
41
|
+
else
|
|
42
|
+
current
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class Extension
|
|
49
|
+
SerializedProperties = Struct.new(:value, :handled_keys, keyword_init: true)
|
|
50
|
+
|
|
51
|
+
class << self
|
|
52
|
+
def register(name, on:)
|
|
53
|
+
Rgltf.default_extensions.add(name, self, on:)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def parse(json, owner:, doc:)
|
|
57
|
+
json
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def serialize(object, doc:)
|
|
61
|
+
object
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def serialize_properties(value, handled_keys:)
|
|
65
|
+
SerializedProperties.new(value:, handled_keys: Array(handled_keys).map(&:to_s).freeze).freeze
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def decode_primitive(primitive, doc:)
|
|
69
|
+
primitive
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
class << self
|
|
75
|
+
def default_extensions
|
|
76
|
+
@default_extensions ||= ExtensionRegistry.new
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rgltf
|
|
4
|
+
module Extensions
|
|
5
|
+
class KHRLightsPunctual < Extension
|
|
6
|
+
class Collection
|
|
7
|
+
attr_reader :lights
|
|
8
|
+
|
|
9
|
+
def initialize(json)
|
|
10
|
+
@lights = json.fetch('lights', []).each_with_index.map { |light, index| Light.new(light, index:) }.freeze
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class NodeLight
|
|
15
|
+
attr_reader :light
|
|
16
|
+
|
|
17
|
+
def initialize(light)
|
|
18
|
+
@light = light
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class Light
|
|
23
|
+
attr_reader :index, :name, :type, :color, :intensity, :range, :spot, :source_json
|
|
24
|
+
|
|
25
|
+
def initialize(json, index:)
|
|
26
|
+
@source_json = json
|
|
27
|
+
@index = index
|
|
28
|
+
@name = json['name']
|
|
29
|
+
@type = json.fetch('type').to_sym
|
|
30
|
+
raise FormatError, "invalid punctual light type #{@type.inspect}" unless %i[directional point
|
|
31
|
+
spot].include?(@type)
|
|
32
|
+
|
|
33
|
+
@color = json.fetch('color', [1.0, 1.0, 1.0]).freeze
|
|
34
|
+
@intensity = json.fetch('intensity', 1.0)
|
|
35
|
+
@range = json['range']
|
|
36
|
+
@spot = Spot.new(json.fetch('spot', {})) if @type == :spot
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class Spot
|
|
41
|
+
attr_reader :inner_cone_angle, :outer_cone_angle, :source_json
|
|
42
|
+
|
|
43
|
+
def initialize(json)
|
|
44
|
+
@source_json = json
|
|
45
|
+
@inner_cone_angle = json.fetch('innerConeAngle', 0.0)
|
|
46
|
+
@outer_cone_angle = json.fetch('outerConeAngle', Math::PI / 4.0)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.parse(json, owner:, doc:)
|
|
51
|
+
return Collection.new(json) if owner.is_a?(Document)
|
|
52
|
+
|
|
53
|
+
collection = doc.extension('KHR_lights_punctual')
|
|
54
|
+
index = json.fetch('light')
|
|
55
|
+
light = collection&.lights&.fetch(index)
|
|
56
|
+
raise FormatError, "node references missing punctual light #{index}" unless light
|
|
57
|
+
|
|
58
|
+
NodeLight.new(light)
|
|
59
|
+
rescue IndexError, KeyError => e
|
|
60
|
+
raise FormatError, "invalid KHR_lights_punctual: #{e.message}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.serialize(object, doc:)
|
|
64
|
+
if object.is_a?(Collection)
|
|
65
|
+
value = { 'lights' => object.lights.map { |light| serialize_light(light) } }
|
|
66
|
+
return serialize_properties(value, handled_keys: %w[lights])
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
serialize_properties({ 'light' => object.light.index }, handled_keys: %w[light])
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.serialize_light(light)
|
|
73
|
+
copy_hash(light.source_json).tap do |json|
|
|
74
|
+
%w[name type color intensity range spot].each { |key| json.delete(key) }
|
|
75
|
+
json['type'] = light.type.to_s
|
|
76
|
+
json['name'] = light.name if light.name
|
|
77
|
+
json['color'] = light.color unless light.color == [1.0, 1.0, 1.0]
|
|
78
|
+
json['intensity'] = light.intensity unless light.intensity == 1.0
|
|
79
|
+
json['range'] = light.range if light.range
|
|
80
|
+
if light.spot
|
|
81
|
+
spot = copy_hash(light.spot.source_json)
|
|
82
|
+
%w[innerConeAngle outerConeAngle].each { |key| spot.delete(key) }
|
|
83
|
+
spot['innerConeAngle'] = light.spot.inner_cone_angle unless light.spot.inner_cone_angle == 0.0
|
|
84
|
+
spot['outerConeAngle'] = light.spot.outer_cone_angle unless light.spot.outer_cone_angle == Math::PI / 4.0
|
|
85
|
+
json['spot'] = spot unless spot.empty?
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.copy_hash(value)
|
|
91
|
+
value.to_h { |key, child| [key, child] }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
Rgltf.default_extensions.add(
|
|
98
|
+
'KHR_lights_punctual',
|
|
99
|
+
Rgltf::Extensions::KHRLightsPunctual,
|
|
100
|
+
on: %i[document node]
|
|
101
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rgltf
|
|
4
|
+
module Extensions
|
|
5
|
+
class KHRMaterialsEmissiveStrength < Extension
|
|
6
|
+
attr_reader :emissive_strength
|
|
7
|
+
|
|
8
|
+
def initialize(json)
|
|
9
|
+
@emissive_strength = json.fetch('emissiveStrength', 1.0)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.parse(json, owner:, doc:)
|
|
13
|
+
new(json)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.serialize(object, doc:)
|
|
17
|
+
value = object.emissive_strength == 1.0 ? {} : { 'emissiveStrength' => object.emissive_strength }
|
|
18
|
+
|
|
19
|
+
serialize_properties(value, handled_keys: %w[emissiveStrength])
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
Rgltf.default_extensions.add(
|
|
26
|
+
'KHR_materials_emissive_strength',
|
|
27
|
+
Rgltf::Extensions::KHRMaterialsEmissiveStrength,
|
|
28
|
+
on: :material
|
|
29
|
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rgltf
|
|
4
|
+
module Extensions
|
|
5
|
+
class KHRMaterialsUnlit < Extension
|
|
6
|
+
INSTANCE = new.freeze
|
|
7
|
+
|
|
8
|
+
def self.parse(_json, owner:, doc:)
|
|
9
|
+
INSTANCE
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.serialize(_object, doc:)
|
|
13
|
+
serialize_properties({}, handled_keys: [])
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
Rgltf.default_extensions.add('KHR_materials_unlit', Rgltf::Extensions::KHRMaterialsUnlit, on: :material)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rgltf
|
|
4
|
+
module Extensions
|
|
5
|
+
class KHRTextureTransform < Extension
|
|
6
|
+
attr_reader :offset, :rotation, :scale, :tex_coord
|
|
7
|
+
|
|
8
|
+
def initialize(json)
|
|
9
|
+
@offset = json.fetch('offset', [0.0, 0.0]).freeze
|
|
10
|
+
@rotation = json.fetch('rotation', 0.0)
|
|
11
|
+
@scale = json.fetch('scale', [1.0, 1.0]).freeze
|
|
12
|
+
@tex_coord = json['texCoord']
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def uv_matrix
|
|
16
|
+
cosine = Math.cos(rotation)
|
|
17
|
+
sine = Math.sin(rotation)
|
|
18
|
+
[
|
|
19
|
+
cosine * scale[0], sine * scale[0], 0.0,
|
|
20
|
+
-sine * scale[1], cosine * scale[1], 0.0,
|
|
21
|
+
offset[0], offset[1], 1.0
|
|
22
|
+
].freeze
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.parse(json, owner:, doc:)
|
|
26
|
+
new(json)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.serialize(object, doc:)
|
|
30
|
+
value = {}.tap do |json|
|
|
31
|
+
json['offset'] = object.offset unless object.offset == [0.0, 0.0]
|
|
32
|
+
json['rotation'] = object.rotation unless object.rotation == 0.0
|
|
33
|
+
json['scale'] = object.scale unless object.scale == [1.0, 1.0]
|
|
34
|
+
json['texCoord'] = object.tex_coord unless object.tex_coord.nil?
|
|
35
|
+
end
|
|
36
|
+
serialize_properties(value, handled_keys: %w[offset rotation scale texCoord])
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
Rgltf.default_extensions.add('KHR_texture_transform', Rgltf::Extensions::KHRTextureTransform, on: :texture_info)
|
data/lib/rgltf/glb.rb
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rgltf
|
|
4
|
+
module GLB
|
|
5
|
+
MAGIC = 0x46546C67
|
|
6
|
+
CHUNK_JSON = 0x4E4F534A
|
|
7
|
+
CHUNK_BIN = 0x004E4942
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def read(data)
|
|
12
|
+
bytes = data.b
|
|
13
|
+
raise FormatError, 'truncated GLB header' if bytes.bytesize < 12
|
|
14
|
+
|
|
15
|
+
magic, version, length = bytes.unpack('L<L<L<')
|
|
16
|
+
raise FormatError, 'not a GLB (bad magic)' unless magic == MAGIC
|
|
17
|
+
raise UnsupportedError, "GLB version #{version}" unless version == 2
|
|
18
|
+
raise FormatError, 'invalid GLB length' if length < 20
|
|
19
|
+
raise FormatError, 'GLB length must be 4-byte aligned' unless (length % 4).zero?
|
|
20
|
+
raise FormatError, 'GLB length does not match input' unless bytes.bytesize == length
|
|
21
|
+
|
|
22
|
+
offset = 12
|
|
23
|
+
json = nil
|
|
24
|
+
bin = nil
|
|
25
|
+
first_chunk = true
|
|
26
|
+
while offset < length
|
|
27
|
+
raise FormatError, 'truncated GLB chunk header' if offset + 8 > length
|
|
28
|
+
|
|
29
|
+
chunk_length, chunk_type = bytes.byteslice(offset, 8).unpack('L<L<')
|
|
30
|
+
raise FormatError, 'GLB chunk length must be 4-byte aligned' unless (chunk_length % 4).zero?
|
|
31
|
+
|
|
32
|
+
chunk_end = offset + 8 + chunk_length
|
|
33
|
+
raise FormatError, 'truncated GLB chunk' if chunk_end > length
|
|
34
|
+
raise FormatError, 'GLB JSON must be the first chunk' if first_chunk && chunk_type != CHUNK_JSON
|
|
35
|
+
|
|
36
|
+
body = bytes.byteslice(offset + 8, chunk_length)
|
|
37
|
+
case chunk_type
|
|
38
|
+
when CHUNK_JSON
|
|
39
|
+
raise FormatError, 'duplicate JSON chunk' if json
|
|
40
|
+
|
|
41
|
+
json = body
|
|
42
|
+
when CHUNK_BIN
|
|
43
|
+
raise FormatError, 'duplicate BIN chunk' if bin
|
|
44
|
+
|
|
45
|
+
bin = body
|
|
46
|
+
end
|
|
47
|
+
first_chunk = false
|
|
48
|
+
offset = chunk_end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
raise FormatError, 'GLB chunks do not match declared length' unless offset == length
|
|
52
|
+
raise FormatError, 'GLB has no JSON chunk' unless json
|
|
53
|
+
|
|
54
|
+
[json, bin]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def write(json_string, bin_string = nil)
|
|
58
|
+
json = pad(json_string.b, 0x20)
|
|
59
|
+
chunks = [[CHUNK_JSON, json]]
|
|
60
|
+
chunks << [CHUNK_BIN, pad(bin_string.b, 0x00)] if bin_string
|
|
61
|
+
total = 12 + chunks.sum { |_, chunk| 8 + chunk.bytesize }
|
|
62
|
+
output = [MAGIC, 2, total].pack('L<L<L<')
|
|
63
|
+
chunks.each { |type, chunk| output << [chunk.bytesize, type].pack('L<L<') << chunk }
|
|
64
|
+
output
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def pad(string, byte)
|
|
68
|
+
string + (byte.chr * ((4 - (string.bytesize % 4)) % 4))
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|