rgltf 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +270 -0
- data/Rakefile +10 -0
- data/benchmark/accessors.rb +26 -0
- data/benchmark/compare.rb +28 -0
- data/benchmark/sample_assets.rb +61 -0
- data/examples/README.md +23 -0
- data/examples/build_triangle.rb +40 -0
- data/examples/convert.rb +24 -0
- data/examples/inspect.rb +27 -0
- data/lib/rgltf/accessor_reader.rb +142 -0
- data/lib/rgltf/buffer_resolver.rb +95 -0
- data/lib/rgltf/builder/accessor_data.rb +40 -0
- data/lib/rgltf/builder/accessors.rb +130 -0
- data/lib/rgltf/builder/component_builders.rb +89 -0
- data/lib/rgltf/builder/document_properties.rb +32 -0
- data/lib/rgltf/builder/materials.rb +132 -0
- data/lib/rgltf/builder.rb +176 -0
- data/lib/rgltf/document.rb +159 -0
- data/lib/rgltf/errors.rb +10 -0
- data/lib/rgltf/extension.rb +79 -0
- data/lib/rgltf/extensions/khr_lights_punctual.rb +101 -0
- data/lib/rgltf/extensions/khr_materials_emissive_strength.rb +29 -0
- data/lib/rgltf/extensions/khr_materials_unlit.rb +19 -0
- data/lib/rgltf/extensions/khr_texture_transform.rb +42 -0
- data/lib/rgltf/glb.rb +71 -0
- data/lib/rgltf/properties/accessor.rb +142 -0
- data/lib/rgltf/properties/animation.rb +96 -0
- data/lib/rgltf/properties/asset.rb +15 -0
- data/lib/rgltf/properties/base.rb +45 -0
- data/lib/rgltf/properties/buffer.rb +50 -0
- data/lib/rgltf/properties/buffer_view.rb +18 -0
- data/lib/rgltf/properties/camera.rb +50 -0
- data/lib/rgltf/properties/image.rb +48 -0
- data/lib/rgltf/properties/material.rb +95 -0
- data/lib/rgltf/properties/mesh.rb +67 -0
- data/lib/rgltf/properties/node.rb +135 -0
- data/lib/rgltf/properties/sampler.rb +35 -0
- data/lib/rgltf/properties/scene.rb +14 -0
- data/lib/rgltf/properties/skin.rb +25 -0
- data/lib/rgltf/properties/texture.rb +19 -0
- data/lib/rgltf/properties.rb +17 -0
- data/lib/rgltf/validation/accessors.rb +205 -0
- data/lib/rgltf/validation/animations.rb +184 -0
- data/lib/rgltf/validation/buffers.rb +95 -0
- data/lib/rgltf/validation/cameras.rb +76 -0
- data/lib/rgltf/validation/context.rb +133 -0
- data/lib/rgltf/validation/images_materials.rb +159 -0
- data/lib/rgltf/validation/meshes.rb +214 -0
- data/lib/rgltf/validation/root_asset_extensions.rb +134 -0
- data/lib/rgltf/validation/scenes_nodes.rb +141 -0
- data/lib/rgltf/validation/skins.rb +94 -0
- data/lib/rgltf/validator.rb +53 -0
- data/lib/rgltf/version.rb +5 -0
- data/lib/rgltf/writer/buffer_merger.rb +72 -0
- data/lib/rgltf/writer/default_omitter.rb +51 -0
- data/lib/rgltf/writer/extension_serializer.rb +64 -0
- data/lib/rgltf/writer.rb +95 -0
- data/lib/rgltf.rb +133 -0
- data/tasks/sample_assets.rake +115 -0
- metadata +104 -0
|
@@ -0,0 +1,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
|