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