riggle 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4b90c1955e3c3ecfa989314c851c4688e7550a3a4d4857ffbc089451e7b9ae81
4
+ data.tar.gz: a1994a3aff3a2af70319525238422d3b81084354720db60559fa723b8615ffb7
5
+ SHA512:
6
+ metadata.gz: 23897f9ebde01dbe1b8588316ba80ce9f76ece400a81ed4debadee88b5da1073272fc3959be7dc1ab773cf9b5e5281c17a221036862a369226de67c5082f4370
7
+ data.tar.gz: cab2c1fc089b281a5ddb5a0c9e55e04cf6bfb337fb97b454a094df518993c761af157d94ac67983fed2b4bf7536c90c34aede4778dc2d469e39c7b0a4c12d72b
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.1.0] - 2026-09-23
6
+
7
+ - Initial release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 ydah
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # Riggle
2
+
3
+ [![Gem version](https://badge.fury.io/rb/riggle.svg)](https://rubygems.org/gems/riggle)
4
+ [![Downloads](https://img.shields.io/gem/dt/riggle?label=downloads)](https://rubygems.org/gems/riggle)
5
+ [![CI](https://github.com/rbgfx/riggle/actions/workflows/ci.yml/badge.svg)](https://github.com/rbgfx/riggle/actions/workflows/ci.yml)
6
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D3.1-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org/)
7
+ [![License](https://img.shields.io/badge/license-MIT-750014.svg)](LICENSE.txt)
8
+
9
+ > OBJ and glTF loading with CPU skinning for Ruby graphics.
10
+
11
+ Riggle turns common 3D asset files into small Ruby scene objects: meshes,
12
+ primitives, nodes, materials, animation channels, and skinning data.
13
+
14
+ **[Features](#features) · [Installation](#installation) · [Quick start](#quick-start) · [Development](#development)**
15
+
16
+ ## Features
17
+
18
+ - OBJ loading with fan triangulation and negative indices.
19
+ - glTF JSON and GLB loading, including data URI buffers and accessors.
20
+ - Node transforms, materials, animation channels, and scene traversal.
21
+ - Linear blend skinning (LBS) and dual quaternion skinning (DQS).
22
+ - An optional RBGL adapter for vertex and index buffers.
23
+ - Defaults for missing normals, UVs, and colors when adapting to RBGL.
24
+
25
+ ## Installation
26
+
27
+ Add Riggle to your Gemfile:
28
+
29
+ ~~~ruby
30
+ gem "riggle"
31
+ ~~~
32
+
33
+ Then run:
34
+
35
+ ~~~sh
36
+ bundle install
37
+ ~~~
38
+
39
+ Or install the released gem:
40
+
41
+ ~~~sh
42
+ gem install riggle
43
+ ~~~
44
+
45
+ ## Quick start
46
+
47
+ ~~~ruby
48
+ require "riggle"
49
+
50
+ scene = Riggle.load("model.obj", normals: :smooth)
51
+ primitive = scene.meshes.first.primitives.first
52
+ puts primitive.positions.length
53
+ ~~~
54
+
55
+ When RBGL is available, convert a primitive with:
56
+
57
+ ~~~ruby
58
+ vertex_buffer, index_buffer = primitive.to_rbgl
59
+ ~~~
60
+
61
+ Pass an <code>RBGL::Engine::VertexLayout</code> to select an explicit layout.
62
+
63
+ ## Development
64
+
65
+ ~~~sh
66
+ bundle install
67
+ bundle exec rake verify
68
+ ~~~
69
+
70
+ ## License
71
+
72
+ [MIT](LICENSE.txt)
data/Rakefile ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec) { |task| task.ruby_opts = ["-I../rbgl/lib", "-I../larb/lib"] }
7
+
8
+ task default: :spec
9
+ task verify: :spec
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbgl"
4
+
5
+ module Riggle
6
+ module RBGL
7
+ module_function
8
+
9
+ def to_rbgl(primitive, layout: :auto, warnings: true)
10
+ engine = ::RBGL::Engine
11
+ layout = build_layout(primitive) if layout == :auto
12
+ layout = { position_only: engine::VertexLayout.method(:position_only), position_color: engine::VertexLayout.method(:position_color), position_normal_uv: engine::VertexLayout.method(:position_normal_uv), position_normal_uv_color: engine::VertexLayout.method(:position_normal_uv_color) }.fetch(layout).call if layout.is_a?(Symbol)
13
+ raise ArgumentError, "layout must be an RBGL vertex layout" unless layout.is_a?(engine::VertexLayout)
14
+
15
+ missing = layout.attributes.keys.filter { |name| %i[normal uv color].include?(name) && primitive.public_send(attribute_source(name)).nil? }
16
+ warn "Riggle::RBGL: filling missing attributes: #{missing.join(', ')}" if warnings && missing.any?
17
+ vertices = primitive.positions.each_index.map do |index|
18
+ layout.attributes.keys.to_h { |name| [name, attribute_value(primitive, name, index)] }
19
+ end
20
+ [engine::VertexBuffer.from_array(layout, vertices), engine::IndexBuffer.new(primitive.indices || (0...primitive.positions.length).to_a)]
21
+ end
22
+
23
+ def build_layout(primitive)
24
+ engine = ::RBGL::Engine
25
+ engine::VertexLayout.new do
26
+ attribute :position, 3
27
+ attribute :normal, 3 if primitive.normals
28
+ attribute :uv, 2 if primitive.uvs
29
+ attribute :color, 4 if primitive.colors
30
+ end
31
+ end
32
+
33
+ def attribute_source(name)
34
+ { normal: :normals, uv: :uvs, color: :colors }.fetch(name)
35
+ end
36
+ private_class_method :attribute_source
37
+
38
+ def attribute_value(primitive, name, index)
39
+ case name
40
+ when :position then primitive.positions.fetch(index).to_a
41
+ when :normal then primitive.normals&.[](index)&.to_a || [0.0, 0.0, 1.0]
42
+ when :uv then primitive.uvs&.[](index)&.to_a || [0.0, 0.0]
43
+ when :color then primitive.colors&.[](index)&.to_a || [1.0, 1.0, 1.0, 1.0]
44
+ else raise ArgumentError, "unsupported RBGL vertex attribute: #{name}"
45
+ end
46
+ end
47
+ private_class_method :attribute_value
48
+ end
49
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Riggle
4
+ VERSION = "0.1.0"
5
+ end
data/lib/riggle.rb ADDED
@@ -0,0 +1,759 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "riggle/version"
6
+
7
+ module Riggle
8
+ class Error < StandardError; end
9
+ class UnsupportedError < ArgumentError; end
10
+
11
+ Vec2 = Struct.new(:x, :y, keyword_init: true) do
12
+ def to_a = [x, y]
13
+ end
14
+ Vec3 = Struct.new(:x, :y, :z, keyword_init: true) do
15
+ def to_a = [x, y, z]
16
+ def +(other) = Vec3.new(x: x + other.x, y: y + other.y, z: z + other.z)
17
+ def -(other) = Vec3.new(x: x - other.x, y: y - other.y, z: z - other.z)
18
+ def *(scalar) = Vec3.new(x: x * scalar, y: y * scalar, z: z * scalar)
19
+ def dot(other) = x * other.x + y * other.y + z * other.z
20
+ def cross(other) = Vec3.new(x: y * other.z - z * other.y, y: z * other.x - x * other.z, z: x * other.y - y * other.x)
21
+ def normalize
22
+ length = Math.sqrt(dot(self))
23
+ length.zero? ? self : self * (1.0 / length)
24
+ end
25
+ end
26
+ Color = Struct.new(:r, :g, :b, :a, keyword_init: true) do
27
+ def to_a = [r, g, b, a]
28
+ end
29
+ Quat = Struct.new(:x, :y, :z, :w, keyword_init: true) do
30
+ def to_a = [x, y, z, w]
31
+ def normalize
32
+ length = Math.sqrt(x * x + y * y + z * z + w * w)
33
+ length.zero? ? self : Quat.new(x: x / length, y: y / length, z: z / length, w: w / length)
34
+ end
35
+
36
+ def self.slerp(first, second, amount)
37
+ left = first.normalize
38
+ right = second.normalize
39
+ dot = left.to_a.zip(right.to_a).sum { |a, b| a * b }
40
+ if dot.negative?
41
+ right = new(x: -right.x, y: -right.y, z: -right.z, w: -right.w)
42
+ dot = -dot
43
+ end
44
+ if dot > 0.9995
45
+ return new(
46
+ x: left.x + (right.x - left.x) * amount,
47
+ y: left.y + (right.y - left.y) * amount,
48
+ z: left.z + (right.z - left.z) * amount,
49
+ w: left.w + (right.w - left.w) * amount
50
+ ).normalize
51
+ end
52
+
53
+ angle = Math.acos(dot.clamp(-1.0, 1.0))
54
+ scale = Math.sin((1.0 - amount) * angle) / Math.sin(angle)
55
+ other_scale = Math.sin(amount * angle) / Math.sin(angle)
56
+ new(
57
+ x: left.x * scale + right.x * other_scale,
58
+ y: left.y * scale + right.y * other_scale,
59
+ z: left.z * scale + right.z * other_scale,
60
+ w: left.w * scale + right.w * other_scale
61
+ )
62
+ end
63
+ end
64
+
65
+ class Mat4
66
+ attr_reader :values
67
+
68
+ def initialize(values = nil)
69
+ @values = (values || identity_values).map(&:to_f)
70
+ raise ArgumentError, "Mat4 needs 16 values" unless @values.length == 16
71
+ end
72
+
73
+ def [](row, column)
74
+ @values[row * 4 + column]
75
+ end
76
+
77
+ def *(other)
78
+ Mat4.new(Array.new(16) { |index| row = index / 4; column = index % 4; (0...4).sum { |k| self[row, k] * other[k, column] } })
79
+ end
80
+
81
+ def transform(point)
82
+ values = [point.x, point.y, point.z, 1.0]
83
+ result = (0...4).map { |row| (0...4).sum { |column| self[row, column] * values[column] } }
84
+ Vec3.new(x: result[0] / result[3], y: result[1] / result[3], z: result[2] / result[3])
85
+ end
86
+
87
+ def self.trs(translation, rotation, scale)
88
+ x, y, z, w = rotation.to_a
89
+ sx, sy, sz = scale.to_a
90
+ Mat4.new([
91
+ (1 - 2 * (y * y + z * z)) * sx, (2 * (x * y - z * w)) * sy, (2 * (x * z + y * w)) * sz, translation.x,
92
+ (2 * (x * y + z * w)) * sx, (1 - 2 * (x * x + z * z)) * sy, (2 * (y * z - x * w)) * sz, translation.y,
93
+ (2 * (x * z - y * w)) * sx, (2 * (y * z + x * w)) * sy, (1 - 2 * (x * x + y * y)) * sz, translation.z,
94
+ 0, 0, 0, 1
95
+ ])
96
+ end
97
+
98
+ def self.identity = new
99
+
100
+ private
101
+
102
+ def identity_values = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]
103
+ end
104
+
105
+ Material = Struct.new(:name, :base_color_factor, :base_color_texture, :emissive, :alpha_mode, keyword_init: true)
106
+ TextureRef = Struct.new(:path, :image, keyword_init: true)
107
+ Primitive = Struct.new(:positions, :normals, :uvs, :colors, :indices, :material, :joints, :weights, keyword_init: true) do
108
+ def to_rbgl(**options)
109
+ require_relative "riggle/rbgl"
110
+ Riggle::RBGL.to_rbgl(self, **options)
111
+ end
112
+ end
113
+ Mesh = Struct.new(:name, :primitives, keyword_init: true)
114
+ Node = Struct.new(:name, :children, :mesh, :translation, :rotation, :scale, :matrix, :skin, keyword_init: true)
115
+ Skin = Struct.new(:joints, :inverse_bind_matrices, :skeleton, keyword_init: true)
116
+
117
+ class Channel
118
+ attr_reader :node_index, :path, :times, :values, :interpolation
119
+
120
+ def initialize(node_index:, path:, times:, values:, interpolation: "LINEAR")
121
+ @node_index, @path, @times, @values, @interpolation = node_index, path.to_sym, times, values, interpolation
122
+ end
123
+
124
+ def sample(time, loop: false)
125
+ return value_at(0) if @times.empty?
126
+ duration = @times.last
127
+ time %= duration if loop && duration.positive?
128
+ return value_at(0) if time <= @times.first
129
+ return value_at(@times.length - 1) if time >= @times.last
130
+ index = @times.each_index.find { |candidate| @times[candidate + 1] && time < @times[candidate + 1] } || @times.length - 2
131
+ span = @times[index + 1] - @times[index]
132
+ amount = span.zero? ? 0.0 : (time - @times[index]) / span
133
+ return value_at(index) if @interpolation == "STEP"
134
+ raise UnsupportedError, "unsupported interpolation: #{@interpolation}" unless %w[LINEAR CUBICSPLINE].include?(@interpolation)
135
+ interpolate(value_at(index), value_at(index + 1), amount, index: index, span: span)
136
+ end
137
+
138
+ private
139
+
140
+ def value_at(index)
141
+ @interpolation == "CUBICSPLINE" ? @values[index * 3 + 1] : @values[index]
142
+ end
143
+
144
+ def interpolate(first, second, amount, index:, span:)
145
+ if @interpolation == "CUBICSPLINE"
146
+ outgoing = @values[index * 3 + 2]
147
+ incoming = @values[(index + 1) * 3]
148
+ t2 = amount * amount
149
+ t3 = t2 * amount
150
+ result = first.zip(second, outgoing, incoming).map do |left, right, out, incoming_value|
151
+ (2 * t3 - 3 * t2 + 1) * left + (t3 - 2 * t2 + amount) * out * span + (-2 * t3 + 3 * t2) * right + (t3 - t2) * incoming_value * span
152
+ end
153
+ return normalize_rotation(result) if @path == :rotation && result.length == 4
154
+ return result
155
+ end
156
+ if @path == :rotation && first.length == 4
157
+ return Quat.slerp(
158
+ Quat.new(x: first[0], y: first[1], z: first[2], w: first[3]),
159
+ Quat.new(x: second[0], y: second[1], z: second[2], w: second[3]),
160
+ amount
161
+ ).to_a
162
+ end
163
+ first.zip(second).map { |left, right| left + (right - left) * amount }
164
+ end
165
+
166
+ def normalize_rotation(value)
167
+ length = Math.sqrt(value.sum { |component| component * component })
168
+ length.zero? ? value : value.map { |component| component / length }
169
+ end
170
+ end
171
+
172
+ class Animation
173
+ attr_reader :name, :channels, :duration
174
+
175
+ def initialize(name:, channels:)
176
+ @name, @channels = name, channels
177
+ @duration = channels.flat_map(&:times).max.to_f
178
+ end
179
+
180
+ def sample(time, loop: false)
181
+ @channels.each_with_object({}) do |channel, pose|
182
+ (pose[channel.node_index] ||= {})[channel.path] = channel.sample(time, loop: loop)
183
+ end
184
+ end
185
+ end
186
+
187
+ module Skinning
188
+ module_function
189
+
190
+ def apply(primitive, skin, scene, method: :lbs)
191
+ raise ArgumentError, "unknown skinning method: #{method}" unless %i[lbs dqs].include?(method.to_sym)
192
+ return primitive.positions.dup unless skin
193
+ matrices = skin.joints.map.with_index do |joint, index|
194
+ scene.world_matrix(joint) * (skin.inverse_bind_matrices&.[](index) || Mat4.identity)
195
+ end
196
+ return apply_dual_quaternions(primitive, matrices) if method.to_sym == :dqs && matrices.all? { |matrix| rigid_transform?(matrix) }
197
+ return apply_linear_blend(primitive, matrices)
198
+ end
199
+
200
+ def apply_normals(primitive, skin, scene, method: :lbs)
201
+ raise ArgumentError, "unknown skinning method: #{method}" unless %i[lbs dqs].include?(method.to_sym)
202
+ return nil unless primitive.normals
203
+ return primitive.normals.map(&:dup) unless skin
204
+
205
+ matrices = skin.joints.map.with_index do |joint, index|
206
+ scene.world_matrix(joint) * (skin.inverse_bind_matrices&.[](index) || Mat4.identity)
207
+ end
208
+ if method.to_sym == :dqs && matrices.all? { |matrix| rigid_transform?(matrix) }
209
+ return apply_dual_quaternion_normals(primitive, matrices)
210
+ end
211
+ apply_linear_blend_normals(primitive, matrices)
212
+ end
213
+
214
+ def apply_linear_blend(primitive, matrices)
215
+ primitive.positions.each_with_index.map do |position, index|
216
+ joints = primitive.joints&.[](index) || [0]
217
+ weights = primitive.weights&.[](index) || [1.0]
218
+ total = weights.sum
219
+ weights = weights.map { |weight| weight / total } if total.positive? && total != 1.0
220
+ result = Vec3.new(x: 0.0, y: 0.0, z: 0.0)
221
+ joints.each_with_index { |joint, weight_index| result += matrices[joint].transform(position) * (weights[weight_index] || 0) if matrices[joint] }
222
+ result
223
+ end
224
+ end
225
+ private_class_method :apply_linear_blend
226
+
227
+ def apply_linear_blend_normals(primitive, matrices)
228
+ primitive.normals.each_with_index.map do |normal, index|
229
+ joints = primitive.joints&.[](index) || [0]
230
+ weights = primitive.weights&.[](index) || [1.0]
231
+ raise ArgumentError, "skin weights do not match joints" if joints.length != weights.length
232
+ total = weights.sum
233
+ weights = weights.map { |weight| weight / total } if total.positive? && total != 1.0
234
+ result = Vec3.new(x: 0.0, y: 0.0, z: 0.0)
235
+ joints.each_with_index do |joint, weight_index|
236
+ matrix = matrices[joint]
237
+ result += inverse_transpose_direction(matrix, normal) * (weights[weight_index] || 0) if matrix
238
+ end
239
+ result.normalize
240
+ end
241
+ end
242
+ private_class_method :apply_linear_blend_normals
243
+
244
+ def inverse_transpose_direction(matrix, direction)
245
+ a, b, c = matrix[0, 0], matrix[0, 1], matrix[0, 2]
246
+ d, e, f = matrix[1, 0], matrix[1, 1], matrix[1, 2]
247
+ g, h, i = matrix[2, 0], matrix[2, 1], matrix[2, 2]
248
+ determinant = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g)
249
+ return direction_from_matrix(matrix, direction) if determinant.abs < 1e-12
250
+
251
+ inverse = [
252
+ (e * i - f * h) / determinant, (c * h - b * i) / determinant, (b * f - c * e) / determinant,
253
+ (f * g - d * i) / determinant, (a * i - c * g) / determinant, (c * d - a * f) / determinant,
254
+ (d * h - e * g) / determinant, (b * g - a * h) / determinant, (a * e - b * d) / determinant
255
+ ]
256
+ Vec3.new(
257
+ x: inverse[0] * direction.x + inverse[3] * direction.y + inverse[6] * direction.z,
258
+ y: inverse[1] * direction.x + inverse[4] * direction.y + inverse[7] * direction.z,
259
+ z: inverse[2] * direction.x + inverse[5] * direction.y + inverse[8] * direction.z
260
+ )
261
+ end
262
+ private_class_method :inverse_transpose_direction
263
+
264
+ def direction_from_matrix(matrix, direction)
265
+ Vec3.new(
266
+ x: matrix[0, 0] * direction.x + matrix[0, 1] * direction.y + matrix[0, 2] * direction.z,
267
+ y: matrix[1, 0] * direction.x + matrix[1, 1] * direction.y + matrix[1, 2] * direction.z,
268
+ z: matrix[2, 0] * direction.x + matrix[2, 1] * direction.y + matrix[2, 2] * direction.z
269
+ )
270
+ end
271
+ private_class_method :direction_from_matrix
272
+
273
+ def rigid_transform?(matrix)
274
+ basis = [
275
+ [matrix[0, 0], matrix[1, 0], matrix[2, 0]],
276
+ [matrix[0, 1], matrix[1, 1], matrix[2, 1]],
277
+ [matrix[0, 2], matrix[1, 2], matrix[2, 2]]
278
+ ]
279
+ lengths = basis.map { |axis| Math.sqrt(axis.sum { |value| value * value }) }
280
+ return false unless lengths.all? { |length| (length - 1.0).abs < 1e-5 }
281
+
282
+ dot = ->(first, second) { first.zip(second).sum { |left, right| left * right } }
283
+ dot.call(basis[0], basis[1]).abs < 1e-5 && dot.call(basis[0], basis[2]).abs < 1e-5 && dot.call(basis[1], basis[2]).abs < 1e-5
284
+ end
285
+ private_class_method :rigid_transform?
286
+
287
+ def apply_dual_quaternions(primitive, matrices)
288
+ dual_quaternions = matrices.map { |matrix| dual_quaternion(matrix) }
289
+ primitive.positions.each_with_index.map do |position, index|
290
+ real, dual = blended_dual_quaternion(primitive, index, dual_quaternions)
291
+ transform_dual_quaternion(position, real, dual)
292
+ end
293
+ end
294
+ private_class_method :apply_dual_quaternions
295
+
296
+ def apply_dual_quaternion_normals(primitive, matrices)
297
+ dual_quaternions = matrices.map { |matrix| dual_quaternion(matrix) }
298
+ primitive.normals.each_index.map do |index|
299
+ real, = blended_dual_quaternion(primitive, index, dual_quaternions)
300
+ transform_dual_quaternion_vector(primitive.normals[index], real)
301
+ end
302
+ end
303
+ private_class_method :apply_dual_quaternion_normals
304
+
305
+ def blended_dual_quaternion(primitive, index, dual_quaternions)
306
+ joints = primitive.joints&.[](index) || [0]
307
+ weights = primitive.weights&.[](index) || [1.0]
308
+ raise ArgumentError, "skin weights do not match joints" if joints.length != weights.length
309
+ reference = nil
310
+ real = Array.new(4, 0.0)
311
+ dual = Array.new(4, 0.0)
312
+ joints.each_with_index do |joint, weight_index|
313
+ pair = dual_quaternions[joint]
314
+ next unless pair
315
+ weight = weights[weight_index].to_f
316
+ reference ||= pair[0]
317
+ sign = quaternion_dot(reference, pair[0]).negative? ? -1.0 : 1.0
318
+ 4.times do |component|
319
+ real[component] += pair[0][component] * weight * sign
320
+ dual[component] += pair[1][component] * weight * sign
321
+ end
322
+ end
323
+ [real, dual]
324
+ end
325
+ private_class_method :blended_dual_quaternion
326
+
327
+ def dual_quaternion(matrix)
328
+ basis = [
329
+ [matrix[0, 0], matrix[1, 0], matrix[2, 0]],
330
+ [matrix[0, 1], matrix[1, 1], matrix[2, 1]],
331
+ [matrix[0, 2], matrix[1, 2], matrix[2, 2]]
332
+ ]
333
+ lengths = basis.map { |axis| Math.sqrt(axis.sum { |value| value * value }) }
334
+ raise UnsupportedError, "DQS requires rigid skin transforms" unless lengths.all? { |length| (length - 1.0).abs < 1e-5 }
335
+ dot = ->(first, second) { first.zip(second).sum { |left, right| left * right } }
336
+ raise UnsupportedError, "DQS requires orthogonal skin transforms" unless dot.call(basis[0], basis[1]).abs < 1e-5 && dot.call(basis[0], basis[2]).abs < 1e-5 && dot.call(basis[1], basis[2]).abs < 1e-5
337
+
338
+ trace = matrix[0, 0] + matrix[1, 1] + matrix[2, 2]
339
+ rotation = if trace.positive?
340
+ root = Math.sqrt(trace + 1.0) * 2
341
+ [
342
+ (matrix[2, 1] - matrix[1, 2]) / root,
343
+ (matrix[0, 2] - matrix[2, 0]) / root,
344
+ (matrix[1, 0] - matrix[0, 1]) / root,
345
+ 0.25 * root
346
+ ]
347
+ elsif matrix[0, 0] > matrix[1, 1] && matrix[0, 0] > matrix[2, 2]
348
+ root = Math.sqrt(1.0 + matrix[0, 0] - matrix[1, 1] - matrix[2, 2]) * 2
349
+ [0.25 * root, (matrix[0, 1] + matrix[1, 0]) / root, (matrix[0, 2] + matrix[2, 0]) / root, (matrix[2, 1] - matrix[1, 2]) / root]
350
+ elsif matrix[1, 1] > matrix[2, 2]
351
+ root = Math.sqrt(1.0 + matrix[1, 1] - matrix[0, 0] - matrix[2, 2]) * 2
352
+ [(matrix[0, 1] + matrix[1, 0]) / root, 0.25 * root, (matrix[1, 2] + matrix[2, 1]) / root, (matrix[0, 2] - matrix[2, 0]) / root]
353
+ else
354
+ root = Math.sqrt(1.0 + matrix[2, 2] - matrix[0, 0] - matrix[1, 1]) * 2
355
+ [(matrix[0, 2] + matrix[2, 0]) / root, (matrix[1, 2] + matrix[2, 1]) / root, 0.25 * root, (matrix[1, 0] - matrix[0, 1]) / root]
356
+ end
357
+ rotation = quaternion_normalize(rotation)
358
+ translation = [matrix[0, 3], matrix[1, 3], matrix[2, 3], 0.0]
359
+ [rotation, quaternion_scale(quaternion_multiply(translation, rotation), 0.5)]
360
+ end
361
+ private_class_method :dual_quaternion
362
+
363
+ def transform_dual_quaternion(point, real, dual)
364
+ real = quaternion_normalize(real)
365
+ dual = quaternion_scale(dual, 1.0 / Math.sqrt(real.sum { |value| value * value }))
366
+ rotated = quaternion_multiply(quaternion_multiply(real, [point.x, point.y, point.z, 0.0]), quaternion_conjugate(real))
367
+ translation = quaternion_multiply(dual, quaternion_conjugate(real)).first(3).map { |value| value * 2 }
368
+ Vec3.new(x: rotated[0] + translation[0], y: rotated[1] + translation[1], z: rotated[2] + translation[2])
369
+ end
370
+ private_class_method :transform_dual_quaternion
371
+
372
+ def transform_dual_quaternion_vector(vector, real)
373
+ real = quaternion_normalize(real)
374
+ rotated = quaternion_multiply(quaternion_multiply(real, [vector.x, vector.y, vector.z, 0.0]), quaternion_conjugate(real))
375
+ Vec3.new(x: rotated[0], y: rotated[1], z: rotated[2]).normalize
376
+ end
377
+ private_class_method :transform_dual_quaternion_vector
378
+
379
+ def quaternion_multiply(first, second)
380
+ ax, ay, az, aw = first
381
+ bx, by, bz, bw = second
382
+ [aw * bx + ax * bw + ay * bz - az * by, aw * by - ax * bz + ay * bw + az * bx, aw * bz + ax * by - ay * bx + az * bw, aw * bw - ax * bx - ay * by - az * bz]
383
+ end
384
+ private_class_method :quaternion_multiply
385
+
386
+ def quaternion_conjugate(quaternion)
387
+ [-quaternion[0], -quaternion[1], -quaternion[2], quaternion[3]]
388
+ end
389
+ private_class_method :quaternion_conjugate
390
+
391
+ def quaternion_scale(quaternion, scale)
392
+ quaternion.map { |value| value * scale }
393
+ end
394
+ private_class_method :quaternion_scale
395
+
396
+ def quaternion_normalize(quaternion)
397
+ length = Math.sqrt(quaternion.sum { |value| value * value })
398
+ length.zero? ? [0.0, 0.0, 0.0, 1.0] : quaternion_scale(quaternion, 1.0 / length)
399
+ end
400
+ private_class_method :quaternion_normalize
401
+
402
+ def quaternion_dot(first, second)
403
+ first.zip(second).sum { |left, right| left * right }
404
+ end
405
+ private_class_method :quaternion_dot
406
+ end
407
+
408
+ class Scene
409
+ attr_reader :meshes, :nodes, :materials, :animations, :skins
410
+
411
+ def initialize(meshes: [], nodes: [], materials: [], animations: [], skins: [])
412
+ @meshes = meshes
413
+ @nodes = nodes
414
+ @materials = materials
415
+ @animations = animations
416
+ @skins = skins
417
+ end
418
+
419
+ def world_matrix(node)
420
+ node = @nodes[node] if node.is_a?(Integer)
421
+ index = @nodes.index(node)
422
+ raise ArgumentError, "node is not part of the scene" unless index
423
+ parent = @nodes.find { |candidate| candidate.children.to_a.include?(index) }
424
+ local = node.matrix || Mat4.trs(node.translation, node.rotation, node.scale)
425
+ parent ? world_matrix(parent) * local : local
426
+ end
427
+
428
+ def apply_pose!(pose)
429
+ pose.each do |index, values|
430
+ node = @nodes[index]
431
+ raise ArgumentError, "invalid node index: #{index}" unless node
432
+ values.each do |key, value|
433
+ node[key] = case key.to_sym
434
+ when :translation, :scale then Vec3.new(x: value[0], y: value[1], z: value[2])
435
+ when :rotation then Quat.new(x: value[0], y: value[1], z: value[2], w: value[3]).normalize
436
+ else raise ArgumentError, "unsupported animation path: #{key}"
437
+ end
438
+ end
439
+ end
440
+ self
441
+ end
442
+ end
443
+
444
+ module OBJ
445
+ module_function
446
+
447
+ def load(path, normals: :keep, triangulate: true, base_dir: File.dirname(path))
448
+ lines = File.read(path).gsub(/\\\r?\n/, "").lines
449
+ positions = []
450
+ uvs = []
451
+ normal_values = []
452
+ groups = Hash.new { |hash, key| hash[key] = { vertices: {}, positions: [], uvs: [], normals: [], indices: [] } }
453
+ material_name = "default"
454
+ materials = {}
455
+ lines.each do |line|
456
+ line = line.strip
457
+ next if line.empty? || line.start_with?("#")
458
+ fields = line.split
459
+ case fields.shift
460
+ when "v" then positions << Vec3.new(x: fields[0].to_f, y: fields[1].to_f, z: fields[2].to_f)
461
+ when "vt" then uvs << Vec2.new(x: fields[0].to_f, y: fields[1].to_f)
462
+ when "vn" then normal_values << Vec3.new(x: fields[0].to_f, y: fields[1].to_f, z: fields[2].to_f)
463
+ when "usemtl" then material_name = fields.join(" ")
464
+ when "mtllib"
465
+ file = File.expand_path(fields.join(" "), base_dir)
466
+ materials.merge!(MTL.load(file)) if File.file?(file)
467
+ when "f"
468
+ raise ArgumentError, "OBJ face needs at least three vertices" if fields.length < 3
469
+ face = fields.map { |token| parse_vertex(token, positions.length, uvs.length, normal_values.length) }
470
+ triangles = if triangulate && face.length > 3
471
+ (1...face.length - 1).map { |index| [face[0], face[index], face[index + 1]] }
472
+ else
473
+ [face]
474
+ end
475
+ triangles.each do |triangle|
476
+ triangle.each do |key|
477
+ group = groups[material_name]
478
+ index = group[:vertices][key]
479
+ unless index
480
+ index = group[:vertices].length
481
+ group[:vertices][key] = index
482
+ group[:positions] << positions[key[0]]
483
+ group[:uvs] << (key[1] && uvs[key[1]])
484
+ group[:normals] << (key[2] && normal_values[key[2]])
485
+ end
486
+ group[:indices] << index
487
+ end
488
+ end
489
+ end
490
+ end
491
+ primitives = groups.map do |name, group|
492
+ primitive = Primitive.new(positions: group[:positions], uvs: group[:uvs].compact.empty? ? nil : group[:uvs], normals: group[:normals].compact.empty? ? nil : group[:normals], indices: group[:indices], material: materials[name] || Material.new(name: name, base_color_factor: [1, 1, 1, 1]))
493
+ primitive.normals = generate_normals(primitive) if normals == :flat || (normals == :smooth && primitive.normals.nil?) || normals == :force_smooth
494
+ primitive
495
+ end
496
+ Scene.new(meshes: [Mesh.new(name: File.basename(path), primitives: primitives)], materials: materials.values)
497
+ end
498
+
499
+ def parse_vertex(token, position_count, uv_count, normal_count)
500
+ values = token.split("/", -1)
501
+ raise ArgumentError, "invalid OBJ vertex: #{token}" unless values.length.between?(1, 3)
502
+ values.map!.with_index do |text, index|
503
+ next nil if text.nil? || text.empty?
504
+ raise ArgumentError, "invalid OBJ vertex: #{token}" unless text.match?(/\A-?\d+\z/)
505
+ value = text.to_i
506
+ count = [position_count, uv_count, normal_count][index]
507
+ resolved = value.negative? ? count + value : value - 1
508
+ raise ArgumentError, "OBJ index out of range: #{token}" unless resolved.between?(0, count - 1)
509
+ resolved
510
+ end
511
+ raise ArgumentError, "OBJ position index is missing" if values[0].nil?
512
+ [values[0], values[1], values[2]]
513
+ end
514
+ private_class_method :parse_vertex
515
+
516
+ def generate_normals(primitive)
517
+ normals = Array.new(primitive.positions.length) { Vec3.new(x: 0, y: 0, z: 0) }
518
+ primitive.indices.each_slice(3) do |a, b, c|
519
+ normal = (primitive.positions[b] - primitive.positions[a]).cross(primitive.positions[c] - primitive.positions[a])
520
+ [a, b, c].each { |index| normals[index] = normals[index] + normal }
521
+ end
522
+ normals.map(&:normalize)
523
+ end
524
+ private_class_method :generate_normals
525
+ end
526
+
527
+ module MTL
528
+ module_function
529
+
530
+ def load(path)
531
+ current = nil
532
+ result = {}
533
+ File.read(path).each_line do |line|
534
+ fields = line.split
535
+ next if fields.empty? || fields.first.start_with?("#")
536
+ case fields.shift
537
+ when "newmtl"
538
+ current = Material.new(name: fields.join(" "), base_color_factor: [1, 1, 1, 1])
539
+ result[current.name] = current
540
+ when "Kd" then current.base_color_factor = fields.map(&:to_f) + [1]
541
+ when "d" then current.base_color_factor[3] = fields.first.to_f
542
+ when "map_Kd" then current.base_color_texture = TextureRef.new(path: File.expand_path(fields.join(" "), File.dirname(path)))
543
+ end
544
+ end
545
+ result
546
+ end
547
+ end
548
+
549
+ module GLTF
550
+ module_function
551
+
552
+ COMPONENTS = { "SCALAR" => 1, "VEC2" => 2, "VEC3" => 3, "VEC4" => 4, "MAT2" => 4, "MAT3" => 9, "MAT4" => 16 }.freeze
553
+ FORMATS = { 5120 => "c", 5121 => "C", 5122 => "s<", 5123 => "S<", 5125 => "L<", 5126 => "e" }.freeze
554
+
555
+ def load(path, load_images: true, base_dir: File.dirname(path))
556
+ json, binary = document(path)
557
+ raise UnsupportedError, "glTF 2.x is required" unless json.dig("asset", "version").to_s.match?(/\A2\./)
558
+ raise UnsupportedError, "required glTF extensions are not supported" unless json.fetch("extensionsRequired", []).empty?
559
+ buffers = json.fetch("buffers", []).map { |buffer| buffer_data(buffer, binary, base_dir) }
560
+ views = json.fetch("bufferViews", [])
561
+ access = lambda do |index|
562
+ definition = json.fetch("accessors")[index]
563
+ view = definition["bufferView"] && views[definition["bufferView"]]
564
+ count = definition["count"]
565
+ components = COMPONENTS.fetch(definition["type"])
566
+ format = FORMATS.fetch(definition["componentType"])
567
+ item_size = format_size(format) * components
568
+ read_values = lambda do |buffer_view, byte_offset, value_count, value_format, value_size, value_components|
569
+ raw = buffers.fetch(buffer_view.fetch("buffer"))
570
+ view_start = buffer_view.fetch("byteOffset", 0)
571
+ start = view_start + byte_offset
572
+ view_end = view_start + buffer_view.fetch("byteLength")
573
+ stride = buffer_view["byteStride"] || value_size
574
+ raise ArgumentError, "invalid glTF accessor stride" if stride < value_size
575
+ needed = value_count.zero? ? 0 : (value_count - 1) * stride + value_size
576
+ raise ArgumentError, "glTF accessor exceeds buffer view" if start + needed > view_end || start + needed > raw.bytesize
577
+ Array.new(value_count) { |item| raw.byteslice(start + item * stride, value_size).unpack(value_format * value_components) }
578
+ end
579
+ values = if view
580
+ read_values.call(view, definition.fetch("byteOffset", 0), count, format, item_size, components)
581
+ else
582
+ raise ArgumentError, "glTF accessor count is missing" unless count
583
+ Array.new(count) { Array.new(components, 0) }
584
+ end
585
+ values.map! { |value| normalize(value, definition["componentType"]) } if definition["normalized"]
586
+ sparse = definition["sparse"]
587
+ if sparse
588
+ sparse_count = sparse.fetch("count")
589
+ raise ArgumentError, "sparse accessor count exceeds accessor count" if sparse_count > count
590
+ index_view = views.fetch(sparse.fetch("indices").fetch("bufferView"))
591
+ index_component = sparse.fetch("indices").fetch("componentType")
592
+ index_format = { 5121 => "C", 5123 => "S<", 5125 => "L<" }.fetch(index_component) { raise ArgumentError, "invalid sparse index component type" }
593
+ index_values = read_values.call(index_view, sparse["indices"].fetch("byteOffset", 0), sparse_count, index_format, format_size(index_format), 1).map(&:first)
594
+ value_view = views.fetch(sparse.fetch("values").fetch("bufferView"))
595
+ sparse_values = read_values.call(value_view, sparse["values"].fetch("byteOffset", 0), sparse_count, format, item_size, components)
596
+ index_values.each_with_index do |target, sparse_index|
597
+ raise ArgumentError, "sparse accessor index is out of range" if target >= count
598
+ value = sparse_values[sparse_index]
599
+ value = normalize(value, definition["componentType"]) if definition["normalized"]
600
+ values[target] = value
601
+ end
602
+ end
603
+ values.map { |value| definition["type"] == "SCALAR" ? value.first : value }
604
+ end
605
+ image_refs = json.fetch("images", []).map do |image|
606
+ uri = image["uri"]
607
+ path = if uri && !uri.start_with?("data:")
608
+ expanded = File.expand_path(uri, base_dir)
609
+ root = File.realpath(base_dir) + File::SEPARATOR
610
+ raise ArgumentError, "image escapes base_dir" unless expanded.start_with?(root)
611
+ raise ArgumentError, "image escapes base_dir" unless File.realpath(expanded).start_with?(root)
612
+ expanded
613
+ end
614
+ bytes = if load_images && uri&.start_with?("data:")
615
+ raise UnsupportedError, "unsupported glTF image URI" unless uri.match?(/\Adata:[^,]*;base64,/)
616
+ uri.split(",", 2).last.unpack1("m0")
617
+ elsif load_images && path
618
+ File.binread(path)
619
+ end
620
+ if load_images && bytes
621
+ require "tessel"
622
+ TextureRef.new(path: path, image: Tessel.decode(bytes))
623
+ else
624
+ TextureRef.new(path: path, image: nil)
625
+ end
626
+ end
627
+ image_refs = json.fetch("images", []).each_with_index.map do |image, index|
628
+ next image_refs[index] if image["uri"]
629
+ view = views.fetch(image.fetch("bufferView"))
630
+ raw = buffers.fetch(view.fetch("buffer")).byteslice(view.fetch("byteOffset", 0), view.fetch("byteLength"))
631
+ image_refs[index].tap do |reference|
632
+ if load_images
633
+ require "tessel"
634
+ reference.image = Tessel.decode(raw)
635
+ end
636
+ end
637
+ end
638
+ textures = json.fetch("textures", []).map { |texture| image_refs[texture["source"]] }
639
+ materials = json.fetch("materials", []).map do |material|
640
+ pbr = material["pbrMetallicRoughness"] || {}
641
+ texture = pbr["baseColorTexture"] && textures[pbr["baseColorTexture"]["index"]]
642
+ Material.new(name: material["name"], base_color_factor: pbr["baseColorFactor"] || [1, 1, 1, 1], base_color_texture: texture, emissive: material["emissiveFactor"] || [0, 0, 0], alpha_mode: material["alphaMode"] || "OPAQUE")
643
+ end
644
+ meshes = json.fetch("meshes", []).map do |mesh|
645
+ primitives = mesh.fetch("primitives").map do |primitive|
646
+ raise UnsupportedError, "only TRIANGLES glTF primitives are supported" unless (primitive["mode"] || 4) == 4
647
+ attributes = primitive.fetch("attributes")
648
+ position_values = access.call(attributes.fetch("POSITION"))
649
+ colors = attributes["COLOR_0"] && access.call(attributes["COLOR_0"]).map do |value|
650
+ value = [value] unless value.is_a?(Array)
651
+ Color.new(r: value[0], g: value[1], b: value[2], a: value.fetch(3, 1.0))
652
+ end
653
+ Primitive.new(
654
+ positions: position_values.map { |v| Vec3.new(x: v[0], y: v[1], z: v[2]) },
655
+ normals: attributes["NORMAL"] && access.call(attributes["NORMAL"]).map { |v| Vec3.new(x: v[0], y: v[1], z: v[2]) },
656
+ uvs: attributes["TEXCOORD_0"] && access.call(attributes["TEXCOORD_0"]).map { |v| Vec2.new(x: v[0], y: v[1]) },
657
+ colors: colors,
658
+ joints: attributes["JOINTS_0"] && access.call(attributes["JOINTS_0"]),
659
+ weights: attributes["WEIGHTS_0"] && access.call(attributes["WEIGHTS_0"]),
660
+ indices: primitive["indices"] ? access.call(primitive["indices"]) : (0...position_values.length).to_a,
661
+ material: materials[primitive["material"] || 0]
662
+ )
663
+ end
664
+ Mesh.new(name: mesh["name"], primitives: primitives)
665
+ end
666
+ nodes = json.fetch("nodes", []).map do |node|
667
+ translation = node["translation"] || [0, 0, 0]
668
+ rotation = node["rotation"] || [0, 0, 0, 1]
669
+ scale = node["scale"] || [1, 1, 1]
670
+ matrix = node["matrix"]&.each_slice(4)&.to_a
671
+ Node.new(name: node["name"], children: node["children"] || [], mesh: node["mesh"] && meshes[node["mesh"]], translation: Vec3.new(x: translation[0], y: translation[1], z: translation[2]), rotation: Quat.new(x: rotation[0], y: rotation[1], z: rotation[2], w: rotation[3]), scale: Vec3.new(x: scale[0], y: scale[1], z: scale[2]), matrix: matrix && Mat4.new(matrix.transpose.flatten), skin: node["skin"])
672
+ end
673
+ skins = json.fetch("skins", []).map do |skin|
674
+ inverse_bind_matrices = if skin["inverseBindMatrices"]
675
+ access.call(skin["inverseBindMatrices"]).map { |value| Mat4.new(value.each_slice(4).to_a.transpose.flatten) }
676
+ end
677
+ Skin.new(joints: skin.fetch("joints"), inverse_bind_matrices: inverse_bind_matrices, skeleton: skin["skeleton"])
678
+ end
679
+ animations = json.fetch("animations", []).map do |animation|
680
+ samplers = animation.fetch("samplers")
681
+ channels = animation.fetch("channels").map do |channel|
682
+ sampler = samplers.fetch(channel.fetch("sampler"))
683
+ target = channel.fetch("target")
684
+ Channel.new(node_index: target.fetch("node"), path: target.fetch("path"), times: access.call(sampler.fetch("input")), values: access.call(sampler.fetch("output")), interpolation: sampler.fetch("interpolation", "LINEAR"))
685
+ end
686
+ Animation.new(name: animation["name"], channels: channels)
687
+ end
688
+ Scene.new(meshes: meshes, nodes: nodes, materials: materials, animations: animations, skins: skins)
689
+ end
690
+
691
+ def document(path)
692
+ bytes = File.binread(path)
693
+ return [JSON.parse(bytes), nil] unless bytes.start_with?("glTF")
694
+ raise ArgumentError, "invalid GLB" unless bytes.bytesize >= 12 && bytes.byteslice(4, 8).unpack("L<2") == [2, bytes.bytesize]
695
+ cursor = 12
696
+ json = nil
697
+ binary = nil
698
+ while cursor < bytes.bytesize
699
+ raise ArgumentError, "truncated GLB chunk" if cursor + 8 > bytes.bytesize
700
+ length, type = bytes.byteslice(cursor, 8).unpack("L<2")
701
+ raise ArgumentError, "truncated GLB chunk" if cursor + 8 + length > bytes.bytesize
702
+ chunk = bytes.byteslice(cursor + 8, length)
703
+ if type == 0x4E4F534A
704
+ json = JSON.parse(chunk)
705
+ elsif type == 0x004E4942
706
+ binary = chunk
707
+ end
708
+ cursor += 8 + length
709
+ end
710
+ raise ArgumentError, "GLB is missing JSON" unless json
711
+ [json, binary]
712
+ end
713
+ private_class_method :document
714
+
715
+ def buffer_data(definition, binary, base_dir)
716
+ uri = definition["uri"]
717
+ data = if uri.nil?
718
+ binary || (raise ArgumentError, "glTF buffer has no data")
719
+ elsif uri.start_with?("data:")
720
+ raise UnsupportedError, "unsupported glTF data URI" unless uri.match?(/\Adata:[^,]*;base64,/)
721
+ uri.split(",", 2).last.unpack1("m0")
722
+ else
723
+ path = File.expand_path(uri, base_dir)
724
+ raise ArgumentError, "buffer escapes base_dir" unless path.start_with?(File.expand_path(base_dir) + File::SEPARATOR)
725
+ raise ArgumentError, "buffer escapes base_dir" unless File.realpath(path).start_with?(File.realpath(base_dir) + File::SEPARATOR)
726
+ File.binread(path)
727
+ end
728
+ raise ArgumentError, "glTF buffer is truncated" if data.bytesize < definition.fetch("byteLength")
729
+ data
730
+ end
731
+ private_class_method :buffer_data
732
+
733
+ def format_size(format)
734
+ { "c" => 1, "C" => 1, "s<" => 2, "S<" => 2, "L<" => 4, "e" => 4 }.fetch(format)
735
+ end
736
+ private_class_method :format_size
737
+
738
+ def normalize(values, component)
739
+ values.map do |value|
740
+ case component
741
+ when 5120 then [value / 127.0, -1.0].max
742
+ when 5122 then [value / 32_767.0, -1.0].max
743
+ when 5121 then value / 255.0
744
+ when 5123 then value / 65_535.0
745
+ when 5125 then value / 4_294_967_295.0
746
+ else value
747
+ end
748
+ end
749
+ end
750
+ private_class_method :normalize
751
+ end
752
+
753
+ module_function
754
+
755
+ def load(path, **options)
756
+ extension = File.extname(path).downcase
757
+ extension == ".obj" ? OBJ.load(path, **options) : GLTF.load(path, **options)
758
+ end
759
+ end
data/sig/riggle.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Riggle
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,50 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: riggle
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - ydah
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Small scene models, materials, animation data, and mesh accessors.
13
+ email:
14
+ - t.yudai92@gmail.com
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - CHANGELOG.md
20
+ - LICENSE.txt
21
+ - README.md
22
+ - Rakefile
23
+ - lib/riggle.rb
24
+ - lib/riggle/rbgl.rb
25
+ - lib/riggle/version.rb
26
+ - sig/riggle.rbs
27
+ homepage: https://github.com/rbgfx/riggle
28
+ licenses:
29
+ - MIT
30
+ metadata:
31
+ homepage_uri: https://github.com/rbgfx/riggle
32
+ source_code_uri: https://github.com/rbgfx/riggle/tree/main
33
+ rdoc_options: []
34
+ require_paths:
35
+ - lib
36
+ required_ruby_version: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 3.1.0
41
+ required_rubygems_version: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ requirements: []
47
+ rubygems_version: 4.0.16
48
+ specification_version: 4
49
+ summary: OBJ and glTF loading for Ruby graphics
50
+ test_files: []