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,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rgltf
4
+ module Matrix4
5
+ IDENTITY = [
6
+ 1.0, 0.0, 0.0, 0.0,
7
+ 0.0, 1.0, 0.0, 0.0,
8
+ 0.0, 0.0, 1.0, 0.0,
9
+ 0.0, 0.0, 0.0, 1.0
10
+ ].freeze
11
+
12
+ module_function
13
+
14
+ def multiply(left, right)
15
+ Array.new(16) do |index|
16
+ row = index % 4
17
+ column = index / 4
18
+ 4.times.sum { |inner| left[(inner * 4) + row] * right[(column * 4) + inner] }
19
+ end.freeze
20
+ end
21
+
22
+ def from_trs(translation, rotation, scale)
23
+ x, y, z, w = rotation
24
+ sx, sy, sz = scale
25
+ xx = x * x
26
+ yy = y * y
27
+ zz = z * z
28
+ xy = x * y
29
+ xz = x * z
30
+ yz = y * z
31
+ xw = x * w
32
+ yw = y * w
33
+ zw = z * w
34
+ [
35
+ (1.0 - (2.0 * (yy + zz))) * sx, (2.0 * (xy + zw)) * sx, (2.0 * (xz - yw)) * sx, 0.0,
36
+ (2.0 * (xy - zw)) * sy, (1.0 - (2.0 * (xx + zz))) * sy, (2.0 * (yz + xw)) * sy, 0.0,
37
+ (2.0 * (xz + yw)) * sz, (2.0 * (yz - xw)) * sz, (1.0 - (2.0 * (xx + yy))) * sz, 0.0,
38
+ translation[0], translation[1], translation[2], 1.0
39
+ ].freeze
40
+ end
41
+ end
42
+
43
+ class Node < Properties::Base
44
+ IDENTITY = Matrix4::IDENTITY
45
+
46
+ attr_reader :children, :mesh, :skin, :camera, :translation, :rotation,
47
+ :scale, :weights
48
+
49
+ def initialize(json, document:, index:)
50
+ super(json, document:, index:, owner_type: :node)
51
+ @child_indices = json.fetch('children', [])
52
+ @mesh_index = json['mesh']
53
+ @skin_index = json['skin']
54
+ @camera_index = json['camera']
55
+ @translation = json.fetch('translation', [0.0, 0.0, 0.0]).freeze
56
+ @rotation = json.fetch('rotation', [0.0, 0.0, 0.0, 1.0]).freeze
57
+ @scale = json.fetch('scale', [1.0, 1.0, 1.0]).freeze
58
+ @weights = json['weights']&.freeze
59
+ @matrix = json['matrix']&.freeze
60
+ return unless @matrix && (json.key?('translation') || json.key?('rotation') || json.key?('scale'))
61
+
62
+ raise FormatError, "node #{index} cannot define both matrix and TRS"
63
+ end
64
+
65
+ def resolve_references!
66
+ @children = @child_indices.each_with_index.map do |child_index, child_position|
67
+ @document.fetch_reference(:nodes, child_index, "nodes/#{index}/children/#{child_position}")
68
+ end.freeze
69
+ @mesh = optional_reference(:meshes, @mesh_index, "nodes/#{index}/mesh")
70
+ @skin = optional_reference(:skins, @skin_index, "nodes/#{index}/skin")
71
+ @camera = optional_reference(:cameras, @camera_index, "nodes/#{index}/camera")
72
+ self
73
+ end
74
+
75
+ def matrix
76
+ @matrix || (@computed_matrix ||= Matrix4.from_trs(translation, rotation, scale))
77
+ end
78
+
79
+ def trs?
80
+ @matrix.nil?
81
+ end
82
+
83
+ def traverse(&block)
84
+ return enum_for(:traverse) unless block
85
+
86
+ traverse_guarded({}, &block)
87
+ self
88
+ end
89
+
90
+ def world_matrix(parent_matrix = IDENTITY)
91
+ Matrix4.multiply(parent_matrix, matrix)
92
+ end
93
+
94
+ class << self
95
+ def install_larb_conversion!
96
+ return false unless defined?(::Larb::Mat4)
97
+ return true if public_method_defined?(:to_larb, false)
98
+
99
+ define_method(:to_larb) { ::Larb::Mat4.new(matrix) }
100
+ public :to_larb
101
+ true
102
+ end
103
+ end
104
+
105
+ private_class_method :install_larb_conversion!
106
+ install_larb_conversion!
107
+
108
+ private
109
+
110
+ def method_missing(name, ...)
111
+ return public_send(name, ...) if name == :to_larb && self.class.send(:install_larb_conversion!)
112
+
113
+ super
114
+ end
115
+
116
+ def respond_to_missing?(name, include_private = false)
117
+ return true if name == :to_larb && self.class.send(:install_larb_conversion!)
118
+
119
+ super
120
+ end
121
+
122
+ def traverse_guarded(visiting, &block)
123
+ raise FormatError, "node hierarchy contains a cycle at node #{index}" if visiting[object_id]
124
+
125
+ visiting[object_id] = true
126
+ block.call(self)
127
+ children.each { |child| child.send(:traverse_guarded, visiting, &block) }
128
+ visiting.delete(object_id)
129
+ end
130
+
131
+ def optional_reference(collection, value, path)
132
+ value.nil? ? nil : @document.fetch_reference(collection, value, path)
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rgltf
4
+ class Sampler < Properties::Base
5
+ FILTERS = {
6
+ 9728 => :nearest,
7
+ 9729 => :linear,
8
+ 9984 => :nearest_mipmap_nearest,
9
+ 9985 => :linear_mipmap_nearest,
10
+ 9986 => :nearest_mipmap_linear,
11
+ 9987 => :linear_mipmap_linear
12
+ }.freeze
13
+ WRAPS = { 33_071 => :clamp_to_edge, 33_648 => :mirrored_repeat, 10_497 => :repeat }.freeze
14
+
15
+ attr_reader :mag_filter, :min_filter, :wrap_s, :wrap_t
16
+
17
+ def initialize(json, document:, index:)
18
+ super(json, document:, index:, owner_type: :sampler)
19
+ @mag_filter = convert(FILTERS, json['magFilter'], 'magFilter')
20
+ @min_filter = convert(FILTERS, json['minFilter'], 'minFilter')
21
+ @wrap_s = convert(WRAPS, json.fetch('wrapS', 10_497), 'wrapS')
22
+ @wrap_t = convert(WRAPS, json.fetch('wrapT', 10_497), 'wrapT')
23
+ end
24
+
25
+ private
26
+
27
+ def convert(table, value, property)
28
+ return nil if value.nil?
29
+
30
+ table.fetch(value)
31
+ rescue KeyError
32
+ raise FormatError, "sampler #{index} has invalid #{property} #{value.inspect}"
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rgltf
4
+ class Scene < Properties::Base
5
+ attr_reader :nodes
6
+
7
+ def initialize(json, document:, index:)
8
+ super(json, document:, index:, owner_type: :scene)
9
+ @nodes = json.fetch('nodes', []).each_with_index.map do |node_index, node_position|
10
+ document.fetch_reference(:nodes, node_index, "scenes/#{index}/nodes/#{node_position}")
11
+ end.freeze
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rgltf
4
+ class Skin < Properties::Base
5
+ attr_reader :joints, :inverse_bind_matrices, :skeleton
6
+
7
+ def initialize(json, document:, index:)
8
+ super(json, document:, index:, owner_type: :skin)
9
+ @joints = json.fetch('joints').each_with_index.map do |node_index, joint_index|
10
+ document.fetch_reference(:nodes, node_index, "skins/#{index}/joints/#{joint_index}")
11
+ end.freeze
12
+ @inverse_bind_matrices = optional_reference(document, :accessors, json['inverseBindMatrices'],
13
+ "skins/#{index}/inverseBindMatrices")
14
+ @skeleton = optional_reference(document, :nodes, json['skeleton'], "skins/#{index}/skeleton")
15
+ rescue KeyError => e
16
+ raise FormatError, "invalid skin #{index}: #{e.message}"
17
+ end
18
+
19
+ private
20
+
21
+ def optional_reference(document, collection, value, path)
22
+ value.nil? ? nil : document.fetch_reference(collection, value, path)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rgltf
4
+ class Texture < Properties::Base
5
+ attr_reader :sampler, :source
6
+
7
+ def initialize(json, document:, index:)
8
+ super(json, document:, index:, owner_type: :texture)
9
+ @sampler = optional_reference(document, :samplers, json['sampler'], "textures/#{index}/sampler")
10
+ @source = optional_reference(document, :images, json['source'], "textures/#{index}/source")
11
+ end
12
+
13
+ private
14
+
15
+ def optional_reference(document, collection, value, path)
16
+ value.nil? ? nil : document.fetch_reference(collection, value, path)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'properties/base'
4
+ require_relative 'properties/asset'
5
+ require_relative 'properties/buffer'
6
+ require_relative 'properties/buffer_view'
7
+ require_relative 'properties/accessor'
8
+ require_relative 'properties/image'
9
+ require_relative 'properties/sampler'
10
+ require_relative 'properties/texture'
11
+ require_relative 'properties/material'
12
+ require_relative 'properties/mesh'
13
+ require_relative 'properties/camera'
14
+ require_relative 'properties/node'
15
+ require_relative 'properties/skin'
16
+ require_relative 'properties/animation'
17
+ require_relative 'properties/scene'
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rgltf
4
+ module Validation
5
+ class Accessors < Rule
6
+ COMPONENTS = AccessorReader::COMPONENT_TYPES.freeze
7
+ TYPES = AccessorReader::ELEMENT_COUNTS.freeze
8
+ UNSIGNED = [5121, 5123, 5125].freeze
9
+
10
+ def validate
11
+ array('accessors').each_with_index { |raw, index| validate_accessor(object(raw, "accessors/#{index}"), index) }
12
+ end
13
+
14
+ private
15
+
16
+ def validate_accessor(accessor, index)
17
+ path = "accessors/#{index}"
18
+ component = accessor['componentType']
19
+ type = accessor['type']
20
+ count = accessor['count']
21
+ validate_enum(COMPONENTS, component, "#{path}/componentType", :invalid_component_type)
22
+ validate_enum(TYPES, type, "#{path}/type", :invalid_accessor_type)
23
+ context.non_negative_integer(count, "#{path}/count", minimum: 1)
24
+ offset = accessor.fetch('byteOffset', 0)
25
+ context.non_negative_integer(offset, "#{path}/byteOffset")
26
+ if !accessor.key?('bufferView') && accessor.key?('byteOffset') && offset != 0
27
+ error(:accessor_offset_without_buffer_view, "#{path}/byteOffset", 'must be zero without bufferView')
28
+ end
29
+ validate_normalized(accessor, path, component)
30
+ validate_min_max(accessor, path, type)
31
+ has_view = reference('bufferViews', accessor['bufferView'], "#{path}/bufferView") if accessor.key?('bufferView')
32
+ unless accessor.key?('bufferView') || accessor.key?('sparse') || draco_accessor?(index)
33
+ error(:accessor_data_missing, path, 'must define bufferView or sparse')
34
+ end
35
+ validate_view(accessor, path, component, type, count, offset) if has_view
36
+ validate_sparse(accessor['sparse'], path, component, type, count) if accessor.key?('sparse')
37
+ end
38
+
39
+ def validate_enum(table, value, path, code)
40
+ error(code, path, 'contains an unsupported value') unless table.key?(value)
41
+ end
42
+
43
+ def validate_normalized(accessor, path, component)
44
+ value = accessor['normalized']
45
+ error(:invalid_normalized, "#{path}/normalized", 'must be boolean') if accessor.key?('normalized') && ![true,
46
+ false].include?(value)
47
+ return unless value && ![5120, 5121, 5122, 5123].include?(component)
48
+
49
+ error(:invalid_normalized_type, "#{path}/normalized", 'is only valid for 8-bit and 16-bit integers')
50
+ end
51
+
52
+ def validate_min_max(accessor, path, type)
53
+ size = TYPES[type]
54
+ %w[min max].each do |key|
55
+ next unless accessor.key?(key) && size
56
+
57
+ context.numeric_array(accessor[key], size, "#{path}/#{key}")
58
+ end
59
+ return unless size && accessor['min'].is_a?(Array) && accessor['max'].is_a?(Array)
60
+ return unless accessor['min'].length == size && accessor['max'].length == size
61
+
62
+ accessor['min'].zip(accessor['max']).each_with_index do |(minimum, maximum), component_index|
63
+ next unless finite?(minimum) && finite?(maximum) && minimum > maximum
64
+
65
+ error(:invalid_accessor_bounds, "#{path}/min/#{component_index}", 'must not exceed max')
66
+ end
67
+ end
68
+
69
+ def validate_view(accessor, path, component, type, count, offset)
70
+ unless COMPONENTS.key?(component) && TYPES.key?(type) && count.is_a?(Integer) && count >= 1 && offset.is_a?(Integer) && offset >= 0
71
+ return
72
+ end
73
+
74
+ view = array('bufferViews')[accessor['bufferView']]
75
+ return unless view.is_a?(Hash)
76
+
77
+ info = COMPONENTS[component]
78
+ absolute = view.fetch('byteOffset', 0).to_i + offset
79
+ unless (absolute % info[:size]).zero?
80
+ error(:accessor_misaligned, "#{path}/byteOffset",
81
+ 'absolute offset must align to component size')
82
+ end
83
+ storage_size = AccessorReader.storage_element_size(type, info[:size])
84
+ stride = view['byteStride'] || storage_size
85
+ if stride.is_a?(Integer) && stride < storage_size
86
+ error(:invalid_byte_stride, "bufferViews/#{accessor['bufferView']}/byteStride",
87
+ 'must not be smaller than an accessor element')
88
+ end
89
+ return unless view['byteLength'].is_a?(Integer) && stride.is_a?(Integer)
90
+
91
+ required = offset + (stride * (count - 1)) + storage_size
92
+ error(:accessor_out_of_bounds, path, 'data exceeds bufferView byteLength') if required > view['byteLength']
93
+ end
94
+
95
+ def validate_sparse(raw, path, component, type, accessor_count)
96
+ sparse = object(raw, "#{path}/sparse")
97
+ count = sparse['count']
98
+ context.non_negative_integer(count, "#{path}/sparse/count", minimum: 1)
99
+ if count.is_a?(Integer) && accessor_count.is_a?(Integer) && count > accessor_count
100
+ error(:invalid_sparse_count, "#{path}/sparse/count", 'must not exceed accessor count')
101
+ end
102
+ indices = object(sparse['indices'], "#{path}/sparse/indices")
103
+ values = object(sparse['values'], "#{path}/sparse/values")
104
+ validate_sparse_indices(indices, path, count, accessor_count)
105
+ validate_sparse_values(values, path, count, component, type)
106
+ end
107
+
108
+ def validate_sparse_indices(indices, path, count, accessor_count)
109
+ view_ok = reference('bufferViews', indices['bufferView'], "#{path}/sparse/indices/bufferView", required: true)
110
+ type = indices['componentType']
111
+ unless UNSIGNED.include?(type)
112
+ error(:invalid_sparse_index_type, "#{path}/sparse/indices/componentType",
113
+ 'must be an unsigned integer type')
114
+ end
115
+ offset = indices.fetch('byteOffset', 0)
116
+ context.non_negative_integer(offset, "#{path}/sparse/indices/byteOffset")
117
+ unless view_ok && UNSIGNED.include?(type) && offset.is_a?(Integer) && offset >= 0 && count.is_a?(Integer) && count >= 1
118
+ return
119
+ end
120
+
121
+ view = array('bufferViews')[indices['bufferView']]
122
+ if view.is_a?(Hash) && view.key?('byteStride')
123
+ error(:sparse_buffer_view_stride, "#{path}/sparse/indices/bufferView",
124
+ 'bufferView must not define byteStride')
125
+ end
126
+ size = COMPONENTS[type][:size]
127
+ check_sparse_range(view, offset, count * size, "#{path}/sparse/indices")
128
+ absolute = view.fetch('byteOffset', 0).to_i + offset
129
+ unless (absolute % size).zero?
130
+ error(:sparse_indices_misaligned, "#{path}/sparse/indices/byteOffset",
131
+ 'absolute offset must align to component size')
132
+ end
133
+ validate_sparse_order(indices['bufferView'], view, offset, count, type, accessor_count, path)
134
+ end
135
+
136
+ def validate_sparse_values(values, path, count, component, type)
137
+ view_ok = reference('bufferViews', values['bufferView'], "#{path}/sparse/values/bufferView", required: true)
138
+ offset = values.fetch('byteOffset', 0)
139
+ context.non_negative_integer(offset, "#{path}/sparse/values/byteOffset")
140
+ unless view_ok && COMPONENTS.key?(component) && TYPES.key?(type) && offset.is_a?(Integer) && offset >= 0 && count.is_a?(Integer) && count >= 1
141
+ return
142
+ end
143
+
144
+ view = array('bufferViews')[values['bufferView']]
145
+ if view.is_a?(Hash) && view.key?('byteStride')
146
+ error(:sparse_buffer_view_stride, "#{path}/sparse/values/bufferView",
147
+ 'bufferView must not define byteStride')
148
+ end
149
+ size = COMPONENTS[component][:size]
150
+ element_size = AccessorReader.storage_element_size(type, size)
151
+ check_sparse_range(view, offset, count * element_size, "#{path}/sparse/values")
152
+ absolute = view.fetch('byteOffset', 0).to_i + offset
153
+ return if (absolute % size).zero?
154
+
155
+ error(:sparse_values_misaligned, "#{path}/sparse/values/byteOffset",
156
+ 'absolute offset must align to component size')
157
+ end
158
+
159
+ def check_sparse_range(view, offset, length, path)
160
+ return unless view.is_a?(Hash) && view['byteLength'].is_a?(Integer)
161
+
162
+ error(:sparse_out_of_bounds, path, 'data exceeds bufferView byteLength') if offset + length > view['byteLength']
163
+ end
164
+
165
+ def validate_sparse_order(view_index, view, offset, count, type, accessor_count, path)
166
+ bytes = buffer_view_bytes(view_index, view)
167
+ return unless bytes && bytes.bytesize >= offset + (count * COMPONENTS[type][:size])
168
+
169
+ values = bytes.byteslice(offset, count * COMPONENTS[type][:size]).unpack("#{COMPONENTS[type][:pack]}*")
170
+ values.each_with_index do |value, index|
171
+ if accessor_count.is_a?(Integer) && value >= accessor_count
172
+ error(:sparse_index_out_of_range, "#{path}/sparse/indices/#{index}",
173
+ 'must be less than accessor count')
174
+ end
175
+ next if index.zero? || value > values[index - 1]
176
+
177
+ error(:sparse_indices_not_strictly_increasing, "#{path}/sparse/indices/#{index}",
178
+ 'indices must be strictly increasing')
179
+ end
180
+ end
181
+
182
+ def buffer_view_bytes(_view_index, view)
183
+ buffer_index = view['buffer']
184
+ bytes = context.bytes_for_buffer(buffer_index) if buffer_index.is_a?(Integer)
185
+ bytes&.byteslice(view.fetch('byteOffset', 0), view['byteLength'])
186
+ rescue TypeError
187
+ nil
188
+ end
189
+
190
+ def draco_accessor?(accessor_index)
191
+ return false unless array('extensionsUsed').include?('KHR_draco_mesh_compression')
192
+
193
+ array('meshes').any? do |mesh|
194
+ next false unless mesh.is_a?(Hash) && mesh['primitives'].is_a?(Array)
195
+
196
+ mesh['primitives'].any? do |primitive|
197
+ next false unless primitive.is_a?(Hash) && primitive.dig('extensions', 'KHR_draco_mesh_compression')
198
+
199
+ primitive['indices'] == accessor_index || primitive.fetch('attributes', {}).value?(accessor_index)
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rgltf
4
+ module Validation
5
+ class Animations < Rule
6
+ PATHS = %w[translation rotation scale weights].freeze
7
+ INTERPOLATIONS = %w[LINEAR STEP CUBICSPLINE].freeze
8
+
9
+ def validate
10
+ array('animations').each_with_index do |raw, index|
11
+ validate_animation(object(raw, "animations/#{index}"), index)
12
+ end
13
+ end
14
+
15
+ private
16
+
17
+ def validate_animation(animation, animation_index)
18
+ path = "animations/#{animation_index}"
19
+ samplers = animation['samplers']
20
+ channels = animation['channels']
21
+ validate_non_empty_array(samplers, "#{path}/samplers")
22
+ validate_non_empty_array(channels, "#{path}/channels")
23
+ samplers = [] unless samplers.is_a?(Array)
24
+ channels = [] unless channels.is_a?(Array)
25
+ samplers.each_with_index do |raw, index|
26
+ validate_sampler(object(raw, "#{path}/samplers/#{index}"), animation_index, index)
27
+ end
28
+ targets = channels.each_with_index.map do |raw, index|
29
+ validate_channel(object(raw, "#{path}/channels/#{index}"), samplers, animation_index, index)
30
+ end.compact
31
+ duplicates = targets.select { |target| targets.count(target) > 1 }.uniq
32
+ duplicates.each do |target|
33
+ error(:duplicate_animation_target, "#{path}/channels", "contains duplicate target #{target.join('/')}")
34
+ end
35
+ end
36
+
37
+ def validate_non_empty_array(value, path)
38
+ return if value.is_a?(Array) && !value.empty?
39
+
40
+ error(:invalid_animation_collection, path,
41
+ 'must be a non-empty array')
42
+ end
43
+
44
+ def validate_sampler(sampler, animation_index, sampler_index)
45
+ path = "animations/#{animation_index}/samplers/#{sampler_index}"
46
+ input_ok = reference('accessors', sampler['input'], "#{path}/input", required: true)
47
+ reference('accessors', sampler['output'], "#{path}/output", required: true)
48
+ interpolation = sampler.fetch('interpolation', 'LINEAR')
49
+ unless INTERPOLATIONS.include?(interpolation)
50
+ error(:invalid_interpolation, "#{path}/interpolation",
51
+ 'must be LINEAR, STEP, or CUBICSPLINE')
52
+ end
53
+ validate_input(sampler['input'], path) if input_ok
54
+ end
55
+
56
+ def validate_input(index, path)
57
+ accessor = array('accessors')[index]
58
+ return unless accessor.is_a?(Hash)
59
+
60
+ valid = accessor['componentType'] == 5126 && accessor['type'] == 'SCALAR' && !accessor.fetch('normalized',
61
+ false)
62
+ error(:invalid_animation_input, "#{path}/input", 'must be a non-normalized SCALAR FLOAT accessor') unless valid
63
+ unless accessor['min'] && accessor['max']
64
+ warning(:animation_input_bounds_missing, "#{path}/input",
65
+ 'input accessor should define min and max')
66
+ end
67
+ if accessor['min'].is_a?(Array) && finite?(accessor['min'].first) && accessor['min'].first.negative?
68
+ error(:negative_animation_time, "#{path}/input", 'time values must be non-negative')
69
+ end
70
+ validate_input_order(index, path)
71
+ end
72
+
73
+ def validate_input_order(index, path)
74
+ document = context.document
75
+ return unless document && document.accessors[index]
76
+ return if context.send(:meshopt_decode_target_accessor?, index)
77
+
78
+ values = document.accessors[index].to_a
79
+ if values.first&.negative?
80
+ error(:negative_animation_time, "#{path}/input",
81
+ 'time values must be non-negative')
82
+ end
83
+ values.each_cons(2) do |previous, current|
84
+ unless current > previous
85
+ error(:animation_input_not_increasing, "#{path}/input", 'time values must be strictly increasing')
86
+ break
87
+ end
88
+ end
89
+ rescue Error => e
90
+ error(:animation_input_unreadable, "#{path}/input", e.message)
91
+ end
92
+
93
+ def validate_channel(channel, samplers, animation_index, channel_index)
94
+ path = "animations/#{animation_index}/channels/#{channel_index}"
95
+ sampler_index = channel['sampler']
96
+ unless sampler_index.is_a?(Integer) && sampler_index >= 0 && sampler_index < samplers.length
97
+ error(:invalid_animation_sampler_reference, "#{path}/sampler", 'must reference an animation sampler')
98
+ return
99
+ end
100
+ target = object(channel['target'], "#{path}/target")
101
+ target_path = target['path']
102
+ if animation_pointer?(target)
103
+ validate_pointer(target, path)
104
+ validate_output(samplers[sampler_index], 'pointer', nil, path)
105
+ return nil
106
+ end
107
+
108
+ node_ok = reference('nodes', target['node'], "#{path}/target/node", required: true)
109
+ unless PATHS.include?(target_path)
110
+ error(:invalid_animation_path, "#{path}/target/path",
111
+ 'contains an unsupported path')
112
+ end
113
+ validate_output(samplers[sampler_index], target_path, target['node'], path) if PATHS.include?(target_path)
114
+ [target['node'], target_path] if node_ok && PATHS.include?(target_path)
115
+ end
116
+
117
+ def validate_output(sampler_raw, target_path, node_index, path)
118
+ sampler = sampler_raw.is_a?(Hash) ? sampler_raw : {}
119
+ output = array('accessors')[sampler['output']] if sampler['output'].is_a?(Integer)
120
+ input = array('accessors')[sampler['input']] if sampler['input'].is_a?(Integer)
121
+ return unless output.is_a?(Hash)
122
+
123
+ valid = animation_component_valid?(output, target_path) && output_type_valid?(output['type'], target_path)
124
+ unless valid
125
+ error(:invalid_animation_output, "#{path}/sampler/output",
126
+ "accessor type is invalid for #{target_path}")
127
+ end
128
+ return unless input.is_a?(Hash) && input['count'].is_a?(Integer) && output['count'].is_a?(Integer)
129
+
130
+ multiplier = sampler.fetch('interpolation', 'LINEAR') == 'CUBICSPLINE' ? 3 : 1
131
+ if target_path == 'weights'
132
+ target_count = morph_target_count(node_index)
133
+ unless target_count
134
+ error(:animation_weights_without_morph_targets, "#{path}/target/path",
135
+ 'weights require a mesh with morph targets')
136
+ end
137
+ multiplier *= target_count || 1
138
+ end
139
+ expected = input['count'] * multiplier
140
+ return unless output['count'] != expected
141
+
142
+ error(:animation_output_count_mismatch, "#{path}/sampler/output", "count must be #{expected}")
143
+ end
144
+
145
+ def output_type_valid?(type, target_path)
146
+ case target_path
147
+ when 'translation', 'scale' then type == 'VEC3'
148
+ when 'rotation' then type == 'VEC4'
149
+ when 'weights' then type == 'SCALAR'
150
+ when 'pointer' then AccessorReader::ELEMENT_COUNTS.key?(type)
151
+ end
152
+ end
153
+
154
+ def animation_component_valid?(accessor, target_path)
155
+ return AccessorReader::COMPONENT_TYPES.key?(accessor['componentType']) if target_path == 'pointer'
156
+ return true if accessor['componentType'] == 5126 && !accessor.fetch('normalized', false)
157
+ return false unless array('extensionsUsed').include?('KHR_mesh_quantization')
158
+
159
+ [5120, 5121, 5122, 5123].include?(accessor['componentType']) && accessor['normalized'] == true
160
+ end
161
+
162
+ def animation_pointer?(target)
163
+ target['path'] == 'pointer' &&
164
+ array('extensionsUsed').include?('KHR_animation_pointer') &&
165
+ target.dig('extensions', 'KHR_animation_pointer').is_a?(Hash)
166
+ end
167
+
168
+ def validate_pointer(target, path)
169
+ pointer = target.dig('extensions', 'KHR_animation_pointer', 'pointer')
170
+ return if pointer.is_a?(String) && pointer.start_with?('/') && pointer.length > 1
171
+
172
+ error(:invalid_animation_pointer, "#{path}/target/extensions/KHR_animation_pointer/pointer",
173
+ 'must be a non-empty JSON pointer')
174
+ end
175
+
176
+ def morph_target_count(node_index)
177
+ node = array('nodes')[node_index] if node_index.is_a?(Integer)
178
+ mesh = array('meshes')[node['mesh']] if node.is_a?(Hash) && node['mesh'].is_a?(Integer)
179
+ targets = mesh&.dig('primitives', 0, 'targets')
180
+ targets.length if targets.is_a?(Array) && !targets.empty?
181
+ end
182
+ end
183
+ end
184
+ end