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,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rgltf
|
|
4
|
+
module Validation
|
|
5
|
+
class RootAssetExtensions < Rule
|
|
6
|
+
ROOT_ARRAYS = %w[accessors animations buffers bufferViews cameras images materials meshes nodes
|
|
7
|
+
samplers scenes skins textures extensionsUsed extensionsRequired].freeze
|
|
8
|
+
|
|
9
|
+
def validate
|
|
10
|
+
validate_root_arrays
|
|
11
|
+
validate_asset
|
|
12
|
+
validate_extensions
|
|
13
|
+
validate_extension_shapes(@json)
|
|
14
|
+
reference('scenes', @json['scene'], 'scene') if @json.key?('scene')
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def validate_root_arrays
|
|
20
|
+
ROOT_ARRAYS.each do |key|
|
|
21
|
+
next unless @json.key?(key)
|
|
22
|
+
|
|
23
|
+
if @json[key].is_a?(Array)
|
|
24
|
+
error(:empty_root_collection, key, 'must not be empty when present') if @json[key].empty?
|
|
25
|
+
next
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
error(:invalid_type, key, 'must be an array')
|
|
29
|
+
end
|
|
30
|
+
return unless @json.key?('extensions') && !@json['extensions'].is_a?(Hash)
|
|
31
|
+
|
|
32
|
+
error(:invalid_type, 'extensions',
|
|
33
|
+
'must be an object')
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def validate_asset
|
|
37
|
+
unless @json.key?('asset')
|
|
38
|
+
error(:asset_missing, 'asset', 'is required')
|
|
39
|
+
return
|
|
40
|
+
end
|
|
41
|
+
asset = object(@json['asset'], 'asset')
|
|
42
|
+
version = asset['version']
|
|
43
|
+
unless version.is_a?(String) && version.match?(/\A2\.\d+\z/)
|
|
44
|
+
error(:invalid_asset_version, 'asset/version', 'must identify glTF 2.x')
|
|
45
|
+
end
|
|
46
|
+
min = asset['minVersion']
|
|
47
|
+
%w[copyright generator].each do |key|
|
|
48
|
+
error(:invalid_type, "asset/#{key}", 'must be a string') if asset.key?(key) && !asset[key].is_a?(String)
|
|
49
|
+
end
|
|
50
|
+
return if min.nil?
|
|
51
|
+
|
|
52
|
+
unless min.is_a?(String) && min.match?(/\A2\.\d+\z/)
|
|
53
|
+
error(:invalid_min_version, 'asset/minVersion', 'must identify glTF 2.x')
|
|
54
|
+
return
|
|
55
|
+
end
|
|
56
|
+
return unless version.is_a?(String) && version.match?(/\A2\.\d+\z/)
|
|
57
|
+
|
|
58
|
+
comparison = min.split('.').map(&:to_i) <=> version.split('.').map(&:to_i)
|
|
59
|
+
return if comparison && comparison <= 0
|
|
60
|
+
|
|
61
|
+
error(:invalid_min_version, 'asset/minVersion', 'must not exceed asset version')
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def validate_extensions
|
|
65
|
+
used = string_set('extensionsUsed')
|
|
66
|
+
required = string_set('extensionsRequired')
|
|
67
|
+
(required - used).each do |name|
|
|
68
|
+
error(:required_extension_not_used, 'extensionsRequired', "#{name} must also appear in extensionsUsed")
|
|
69
|
+
end
|
|
70
|
+
collect_extension_names(@json).uniq.each do |name|
|
|
71
|
+
unless used.include?(name)
|
|
72
|
+
warning(:undeclared_extension, 'extensionsUsed',
|
|
73
|
+
"#{name} is used but not declared")
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
registry = context.document&.extension_registry
|
|
77
|
+
return unless registry
|
|
78
|
+
|
|
79
|
+
required.reject { |name| registry.known?(name) }.each do |name|
|
|
80
|
+
warning(:unsupported_required_extension, 'extensionsRequired', "#{name} is not registered")
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def string_set(key)
|
|
85
|
+
values = array(key)
|
|
86
|
+
values.each_with_index do |value, index|
|
|
87
|
+
error(:invalid_extension_name, "#{key}/#{index}", 'must be a string') unless value.is_a?(String)
|
|
88
|
+
end
|
|
89
|
+
duplicates = values.select { |value| values.count(value) > 1 }.uniq
|
|
90
|
+
duplicates.each { |value| error(:duplicate_extension, key, "contains duplicate #{value.inspect}") }
|
|
91
|
+
values.grep(String)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def collect_extension_names(value, names = [])
|
|
95
|
+
case value
|
|
96
|
+
when Hash
|
|
97
|
+
names.concat(value['extensions'].keys.grep(String)) if value['extensions'].is_a?(Hash)
|
|
98
|
+
value.each_value { |child| collect_extension_names(child, names) }
|
|
99
|
+
when Array
|
|
100
|
+
value.each { |child| collect_extension_names(child, names) }
|
|
101
|
+
end
|
|
102
|
+
names
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def validate_extension_shapes(value, path = '')
|
|
106
|
+
case value
|
|
107
|
+
when Hash
|
|
108
|
+
extensions = value['extensions']
|
|
109
|
+
if value.key?('extensions') && !extensions.is_a?(Hash)
|
|
110
|
+
error(:invalid_extensions, join(path, 'extensions'), 'must be an object')
|
|
111
|
+
elsif extensions
|
|
112
|
+
extensions.each do |name, payload|
|
|
113
|
+
unless name.is_a?(String) && !name.empty?
|
|
114
|
+
error(:invalid_extension_name, join(path, 'extensions'),
|
|
115
|
+
'extension names must be non-empty strings')
|
|
116
|
+
end
|
|
117
|
+
unless payload.is_a?(Hash)
|
|
118
|
+
error(:invalid_extension_payload, join(path, "extensions/#{name}"),
|
|
119
|
+
'must be an object')
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
value.each { |key, child| validate_extension_shapes(child, join(path, key)) }
|
|
124
|
+
when Array
|
|
125
|
+
value.each_with_index { |child, index| validate_extension_shapes(child, join(path, index)) }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def join(path, segment)
|
|
130
|
+
path.empty? ? segment.to_s : "#{path}/#{segment}"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rgltf
|
|
4
|
+
module Validation
|
|
5
|
+
class ScenesNodes < Rule
|
|
6
|
+
def validate
|
|
7
|
+
validate_scenes
|
|
8
|
+
validate_nodes
|
|
9
|
+
validate_hierarchy
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def validate_scenes
|
|
15
|
+
array('scenes').each_with_index do |raw, scene_index|
|
|
16
|
+
scene = object(raw, "scenes/#{scene_index}")
|
|
17
|
+
nodes = scene.fetch('nodes', [])
|
|
18
|
+
unless nodes.is_a?(Array)
|
|
19
|
+
error(:invalid_type, "scenes/#{scene_index}/nodes", 'must be an array')
|
|
20
|
+
next
|
|
21
|
+
end
|
|
22
|
+
validate_unique(nodes, "scenes/#{scene_index}/nodes", :duplicate_scene_node)
|
|
23
|
+
nodes.each_with_index do |node, index|
|
|
24
|
+
reference('nodes', node, "scenes/#{scene_index}/nodes/#{index}", required: true)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def validate_nodes
|
|
30
|
+
array('nodes').each_with_index do |raw, node_index|
|
|
31
|
+
node = object(raw, "nodes/#{node_index}")
|
|
32
|
+
path = "nodes/#{node_index}"
|
|
33
|
+
validate_transform(node, path)
|
|
34
|
+
%w[mesh skin camera].each do |key|
|
|
35
|
+
reference((key == 'mesh' ? 'meshes' : "#{key}s").to_s, node[key], "#{path}/#{key}") if node.key?(key)
|
|
36
|
+
end
|
|
37
|
+
if node.key?('skin') && !node.key?('mesh')
|
|
38
|
+
warning(:skin_without_mesh, "#{path}/skin",
|
|
39
|
+
'skin has no effect without a mesh on the same node')
|
|
40
|
+
end
|
|
41
|
+
validate_children(node.fetch('children', []), node_index, path)
|
|
42
|
+
validate_node_weights(node, path)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def validate_transform(node, path)
|
|
47
|
+
trs = %w[translation rotation scale].any? { |key| node.key?(key) }
|
|
48
|
+
error(:matrix_trs_conflict, path, 'cannot define both matrix and TRS') if node.key?('matrix') && trs
|
|
49
|
+
context.numeric_array(node['matrix'], 16, "#{path}/matrix") if node.key?('matrix')
|
|
50
|
+
context.numeric_array(node['translation'], 3, "#{path}/translation") if node.key?('translation')
|
|
51
|
+
context.numeric_array(node['scale'], 3, "#{path}/scale") if node.key?('scale')
|
|
52
|
+
return unless node.key?('rotation') && context.numeric_array(node['rotation'], 4, "#{path}/rotation")
|
|
53
|
+
|
|
54
|
+
magnitude = Math.sqrt(node['rotation'].sum { |value| value * value })
|
|
55
|
+
return if (magnitude - 1.0).abs <= 1e-3
|
|
56
|
+
|
|
57
|
+
error(:rotation_not_normalized, "#{path}/rotation",
|
|
58
|
+
'quaternion must have unit length')
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def validate_children(children, node_index, path)
|
|
62
|
+
unless children.is_a?(Array)
|
|
63
|
+
error(:invalid_type, "#{path}/children", 'must be an array')
|
|
64
|
+
return
|
|
65
|
+
end
|
|
66
|
+
validate_unique(children, "#{path}/children", :duplicate_child)
|
|
67
|
+
children.each_with_index do |child, index|
|
|
68
|
+
reference('nodes', child, "#{path}/children/#{index}", required: true)
|
|
69
|
+
if child == node_index
|
|
70
|
+
error(:node_self_reference, "#{path}/children/#{index}",
|
|
71
|
+
'node cannot be its own child')
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def validate_node_weights(node, path)
|
|
77
|
+
return unless node.key?('weights')
|
|
78
|
+
|
|
79
|
+
weights = node['weights']
|
|
80
|
+
unless weights.is_a?(Array) && weights.all? { |weight| finite?(weight) }
|
|
81
|
+
error(:invalid_node_weights, "#{path}/weights", 'must be an array of finite numbers')
|
|
82
|
+
return
|
|
83
|
+
end
|
|
84
|
+
mesh = array('meshes')[node['mesh']] if node['mesh'].is_a?(Integer)
|
|
85
|
+
targets = mesh&.dig('primitives', 0, 'targets')
|
|
86
|
+
return unless targets.is_a?(Array) && weights.length != targets.length
|
|
87
|
+
|
|
88
|
+
error(:node_weights_count_mismatch, "#{path}/weights", 'count must equal morph target count')
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def validate_hierarchy
|
|
92
|
+
parents = Hash.new { |hash, key| hash[key] = [] }
|
|
93
|
+
array('nodes').each_with_index do |node, parent|
|
|
94
|
+
next unless node.is_a?(Hash) && node['children'].is_a?(Array)
|
|
95
|
+
|
|
96
|
+
node['children'].grep(Integer).each do |child|
|
|
97
|
+
parents[child] << parent if child.between?(0, array('nodes').length - 1)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
parents.each do |child, values|
|
|
101
|
+
error(:node_multiple_parents, "nodes/#{child}", 'node has more than one parent') if values.uniq.length > 1
|
|
102
|
+
end
|
|
103
|
+
array('scenes').each_with_index do |scene, scene_index|
|
|
104
|
+
next unless scene.is_a?(Hash) && scene['nodes'].is_a?(Array)
|
|
105
|
+
|
|
106
|
+
scene['nodes'].each_with_index do |root, root_index|
|
|
107
|
+
next unless root.is_a?(Integer) && parents[root].any?
|
|
108
|
+
|
|
109
|
+
error(:scene_node_has_parent, "scenes/#{scene_index}/nodes/#{root_index}",
|
|
110
|
+
'scene root node must not have a parent')
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
visiting = {}
|
|
114
|
+
visited = {}
|
|
115
|
+
array('nodes').each_index { |index| visit(index, visiting, visited) }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def visit(index, visiting, visited)
|
|
119
|
+
return if visited[index]
|
|
120
|
+
|
|
121
|
+
if visiting[index]
|
|
122
|
+
error(:node_cycle, "nodes/#{index}/children", 'node hierarchy contains a cycle')
|
|
123
|
+
return
|
|
124
|
+
end
|
|
125
|
+
visiting[index] = true
|
|
126
|
+
node = array('nodes')[index]
|
|
127
|
+
children = node.is_a?(Hash) && node['children'].is_a?(Array) ? node['children'] : []
|
|
128
|
+
children.grep(Integer).each do |child|
|
|
129
|
+
visit(child, visiting, visited) if child.between?(0, array('nodes').length - 1)
|
|
130
|
+
end
|
|
131
|
+
visiting.delete(index)
|
|
132
|
+
visited[index] = true
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def validate_unique(values, path, code)
|
|
136
|
+
duplicates = values.select { |value| values.count(value) > 1 }.uniq
|
|
137
|
+
duplicates.each { |value| error(code, path, "contains duplicate #{value.inspect}") }
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rgltf
|
|
4
|
+
module Validation
|
|
5
|
+
class Skins < Rule
|
|
6
|
+
def validate
|
|
7
|
+
array('skins').each_with_index { |raw, index| validate_skin(object(raw, "skins/#{index}"), index) }
|
|
8
|
+
validate_skinned_meshes
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def validate_skin(skin, index)
|
|
14
|
+
path = "skins/#{index}"
|
|
15
|
+
joints = skin['joints']
|
|
16
|
+
unless joints.is_a?(Array) && !joints.empty?
|
|
17
|
+
error(:skin_joints_missing, "#{path}/joints", 'must be a non-empty array')
|
|
18
|
+
joints = []
|
|
19
|
+
end
|
|
20
|
+
duplicates = joints.select { |joint| joints.count(joint) > 1 }.uniq
|
|
21
|
+
duplicates.each do |joint|
|
|
22
|
+
error(:duplicate_skin_joint, "#{path}/joints", "contains duplicate #{joint.inspect}")
|
|
23
|
+
end
|
|
24
|
+
joints.each_with_index do |joint, position|
|
|
25
|
+
reference('nodes', joint, "#{path}/joints/#{position}", required: true)
|
|
26
|
+
end
|
|
27
|
+
reference('nodes', skin['skeleton'], "#{path}/skeleton") if skin.key?('skeleton')
|
|
28
|
+
validate_skeleton(skin['skeleton'], joints, path) if skin['skeleton'].is_a?(Integer)
|
|
29
|
+
return unless skin.key?('inverseBindMatrices')
|
|
30
|
+
|
|
31
|
+
validate_inverse_bind_matrices(skin['inverseBindMatrices'], joints.length,
|
|
32
|
+
path)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def validate_inverse_bind_matrices(index, joint_count, path)
|
|
36
|
+
return unless reference('accessors', index, "#{path}/inverseBindMatrices", required: true)
|
|
37
|
+
|
|
38
|
+
accessor = array('accessors')[index]
|
|
39
|
+
return unless accessor.is_a?(Hash)
|
|
40
|
+
|
|
41
|
+
valid = accessor['componentType'] == 5126 && accessor['type'] == 'MAT4' && !accessor.fetch('normalized', false)
|
|
42
|
+
unless valid
|
|
43
|
+
error(:invalid_inverse_bind_matrices, "#{path}/inverseBindMatrices",
|
|
44
|
+
'must be a non-normalized MAT4 FLOAT accessor')
|
|
45
|
+
end
|
|
46
|
+
return unless accessor['count'].is_a?(Integer) && accessor['count'] < joint_count
|
|
47
|
+
|
|
48
|
+
error(:inverse_bind_count_mismatch, "#{path}/inverseBindMatrices", 'count must be at least joint count')
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def validate_skeleton(skeleton, joints, path)
|
|
52
|
+
return unless skeleton.between?(0, array('nodes').length - 1)
|
|
53
|
+
|
|
54
|
+
descendants = collect_descendants(skeleton, {})
|
|
55
|
+
joints.grep(Integer).each do |joint|
|
|
56
|
+
unless descendants[joint]
|
|
57
|
+
error(:joint_outside_skeleton, "#{path}/joints",
|
|
58
|
+
"joint #{joint} is not below skeleton")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def collect_descendants(index, found)
|
|
64
|
+
return found if found[index]
|
|
65
|
+
|
|
66
|
+
found[index] = true
|
|
67
|
+
node = array('nodes')[index]
|
|
68
|
+
children = node.is_a?(Hash) && node['children'].is_a?(Array) ? node['children'] : []
|
|
69
|
+
children.grep(Integer).each do |child|
|
|
70
|
+
collect_descendants(child, found) if child.between?(0, array('nodes').length - 1)
|
|
71
|
+
end
|
|
72
|
+
found
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def validate_skinned_meshes
|
|
76
|
+
array('nodes').each_with_index do |node, node_index|
|
|
77
|
+
next unless node.is_a?(Hash) && node.key?('skin') && node['mesh'].is_a?(Integer)
|
|
78
|
+
|
|
79
|
+
mesh = array('meshes')[node['mesh']]
|
|
80
|
+
next unless mesh.is_a?(Hash) && mesh['primitives'].is_a?(Array)
|
|
81
|
+
|
|
82
|
+
mesh['primitives'].each_with_index do |primitive, primitive_index|
|
|
83
|
+
attributes = primitive.is_a?(Hash) ? primitive['attributes'] : nil
|
|
84
|
+
next if attributes.is_a?(Hash) && attributes.key?('JOINTS_0') && attributes.key?('WEIGHTS_0')
|
|
85
|
+
|
|
86
|
+
error(:skinned_primitive_attributes_missing,
|
|
87
|
+
"nodes/#{node_index}/mesh/primitives/#{primitive_index}/attributes",
|
|
88
|
+
'skinned primitives must define JOINTS_0 and WEIGHTS_0')
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'validation/context'
|
|
4
|
+
require_relative 'validation/root_asset_extensions'
|
|
5
|
+
require_relative 'validation/buffers'
|
|
6
|
+
require_relative 'validation/accessors'
|
|
7
|
+
require_relative 'validation/images_materials'
|
|
8
|
+
require_relative 'validation/meshes'
|
|
9
|
+
require_relative 'validation/scenes_nodes'
|
|
10
|
+
require_relative 'validation/skins'
|
|
11
|
+
require_relative 'validation/animations'
|
|
12
|
+
require_relative 'validation/cameras'
|
|
13
|
+
|
|
14
|
+
module Rgltf
|
|
15
|
+
class Validator
|
|
16
|
+
Issue = Struct.new(:code, :path, :message, keyword_init: true)
|
|
17
|
+
Result = Struct.new(:errors, :warnings, keyword_init: true) do
|
|
18
|
+
def valid?
|
|
19
|
+
errors.empty?
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
RULES = [
|
|
24
|
+
Validation::RootAssetExtensions,
|
|
25
|
+
Validation::Buffers,
|
|
26
|
+
Validation::Accessors,
|
|
27
|
+
Validation::ImagesMaterials,
|
|
28
|
+
Validation::Meshes,
|
|
29
|
+
Validation::ScenesNodes,
|
|
30
|
+
Validation::Skins,
|
|
31
|
+
Validation::Animations,
|
|
32
|
+
Validation::Cameras
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
def validate(value)
|
|
37
|
+
document = value if value.respond_to?(:raw_json)
|
|
38
|
+
json = document ? document.raw_json : value
|
|
39
|
+
context = Validation::Context.new(json, document:)
|
|
40
|
+
RULES.each { |rule| run_rule(rule, context) }
|
|
41
|
+
Result.new(errors: context.errors.freeze, warnings: context.warnings.freeze)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def run_rule(rule, context)
|
|
47
|
+
rule.new(context).validate
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
context.error(:validation_rule_failure, '', "#{rule.name.split('::').last} could not validate: #{e.message}")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rgltf
|
|
4
|
+
class Writer
|
|
5
|
+
class BufferMerger
|
|
6
|
+
attr_reader :binary, :offsets
|
|
7
|
+
|
|
8
|
+
def initialize(document)
|
|
9
|
+
@document = document
|
|
10
|
+
@binary = String.new(encoding: Encoding::BINARY)
|
|
11
|
+
@offsets = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def merge!(json)
|
|
15
|
+
append_buffers
|
|
16
|
+
remap_buffer_views!(json)
|
|
17
|
+
remap_meshopt!(json)
|
|
18
|
+
replace_buffers!(json)
|
|
19
|
+
binary
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def append_buffers
|
|
25
|
+
@document.buffers.each do |buffer|
|
|
26
|
+
binary << ("\0" * ((4 - (binary.bytesize % 4)) % 4))
|
|
27
|
+
offsets << binary.bytesize
|
|
28
|
+
binary << buffer_payload(buffer)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def buffer_payload(buffer)
|
|
33
|
+
return "\0" * buffer.byte_length if buffer.send(:meshopt_decode_target?)
|
|
34
|
+
|
|
35
|
+
buffer.bytes.byteslice(0, buffer.byte_length)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def remap_buffer_views!(json)
|
|
39
|
+
json.fetch('bufferViews', []).each do |view|
|
|
40
|
+
source = view.fetch('buffer')
|
|
41
|
+
view['byteOffset'] = view.fetch('byteOffset', 0) + offsets.fetch(source)
|
|
42
|
+
view['buffer'] = 0
|
|
43
|
+
view.delete('byteOffset') if view['byteOffset'].zero?
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def remap_meshopt!(json)
|
|
48
|
+
json.fetch('bufferViews', []).each do |view|
|
|
49
|
+
meshopt_extensions.each { |name| remap_meshopt_extension!(view.dig('extensions', name)) }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def meshopt_extensions
|
|
54
|
+
Buffer.const_get(:MESHOPT_EXTENSIONS, false)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def remap_meshopt_extension!(meshopt)
|
|
58
|
+
return unless meshopt.is_a?(Hash) && meshopt['buffer'].is_a?(Integer)
|
|
59
|
+
|
|
60
|
+
source = meshopt.fetch('buffer')
|
|
61
|
+
meshopt['byteOffset'] = meshopt.fetch('byteOffset', 0) + offsets.fetch(source)
|
|
62
|
+
meshopt['buffer'] = 0
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def replace_buffers!(json)
|
|
66
|
+
return json.delete('buffers') if binary.empty?
|
|
67
|
+
|
|
68
|
+
json['buffers'] = [{ 'byteLength' => binary.bytesize }]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rgltf
|
|
4
|
+
class Writer
|
|
5
|
+
class DefaultOmitter
|
|
6
|
+
DEFAULTS = {
|
|
7
|
+
accessor: { 'byteOffset' => 0, 'normalized' => false },
|
|
8
|
+
buffer_view: { 'byteOffset' => 0 },
|
|
9
|
+
primitive: { 'mode' => 4 },
|
|
10
|
+
sampler: { 'wrapS' => 10_497, 'wrapT' => 10_497 },
|
|
11
|
+
texture_info: { 'texCoord' => 0 },
|
|
12
|
+
pbr_metallic_roughness: {
|
|
13
|
+
'baseColorFactor' => [1.0, 1.0, 1.0, 1.0],
|
|
14
|
+
'metallicFactor' => 1.0,
|
|
15
|
+
'roughnessFactor' => 1.0
|
|
16
|
+
},
|
|
17
|
+
material: {
|
|
18
|
+
'emissiveFactor' => [0.0, 0.0, 0.0],
|
|
19
|
+
'alphaMode' => 'OPAQUE',
|
|
20
|
+
'alphaCutoff' => 0.5,
|
|
21
|
+
'doubleSided' => false
|
|
22
|
+
},
|
|
23
|
+
node: {
|
|
24
|
+
'translation' => [0.0, 0.0, 0.0],
|
|
25
|
+
'rotation' => [0.0, 0.0, 0.0, 1.0],
|
|
26
|
+
'scale' => [1.0, 1.0, 1.0]
|
|
27
|
+
},
|
|
28
|
+
animation_sampler: { 'interpolation' => 'LINEAR' }
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
def self.apply!(owners, copies)
|
|
32
|
+
owners.each do |owner|
|
|
33
|
+
json = copies[owner.source_json.object_id]
|
|
34
|
+
next unless json
|
|
35
|
+
|
|
36
|
+
DEFAULTS.fetch(owner.owner_type, {}).each do |key, default|
|
|
37
|
+
json.delete(key) if json[key] == default
|
|
38
|
+
end
|
|
39
|
+
omit_texture_info_defaults!(owner, json)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.omit_texture_info_defaults!(owner, json)
|
|
44
|
+
json.delete('scale') if owner.is_a?(NormalTextureInfo) && json['scale'] == 1.0
|
|
45
|
+
json.delete('strength') if owner.is_a?(OcclusionTextureInfo) && json['strength'] == 1.0
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private_class_method :omit_texture_info_defaults!
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rgltf
|
|
4
|
+
class Writer
|
|
5
|
+
class ExtensionSerializer
|
|
6
|
+
def initialize(document, copies)
|
|
7
|
+
@document = document
|
|
8
|
+
@copies = copies
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def apply!(owners)
|
|
12
|
+
serialize_extensions!(@copies.fetch(@document.raw_json.object_id), :document, @document.extensions)
|
|
13
|
+
owners.each do |owner|
|
|
14
|
+
json = @copies[owner.source_json.object_id]
|
|
15
|
+
next unless json
|
|
16
|
+
|
|
17
|
+
serialize_extensions!(json, owner.owner_type, owner.extensions)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def serialize_extensions!(json, owner_type, extensions)
|
|
24
|
+
return unless json['extensions']
|
|
25
|
+
|
|
26
|
+
extensions.each do |name, object|
|
|
27
|
+
next unless @document.extension_registry.registered_on?(name, on: owner_type)
|
|
28
|
+
|
|
29
|
+
original = json['extensions'][name]
|
|
30
|
+
serialized = @document.extension_registry.serialize(
|
|
31
|
+
name, object, on: owner_type, doc: @document
|
|
32
|
+
)
|
|
33
|
+
json['extensions'][name] = merge_serialized(original, serialized)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def merge_serialized(original, serialized)
|
|
38
|
+
if serialized.is_a?(Extension::SerializedProperties)
|
|
39
|
+
return replace_handled_properties(original, serialized.value, serialized.handled_keys)
|
|
40
|
+
end
|
|
41
|
+
return deep_copy(serialized) unless original.is_a?(Hash) && serialized.is_a?(Hash)
|
|
42
|
+
|
|
43
|
+
deep_copy(original).merge(deep_copy(serialized))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def replace_handled_properties(original, replacement, handled_keys)
|
|
47
|
+
return deep_copy(replacement) unless original.is_a?(Hash) && replacement.is_a?(Hash)
|
|
48
|
+
|
|
49
|
+
output = deep_copy(original)
|
|
50
|
+
handled_keys.each { |key| output.delete(key) }
|
|
51
|
+
output.merge!(deep_copy(replacement))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def deep_copy(value)
|
|
55
|
+
case value
|
|
56
|
+
when Hash then value.to_h { |key, child| [key, deep_copy(child)] }
|
|
57
|
+
when Array then value.map { |child| deep_copy(child) }
|
|
58
|
+
when String then value.dup
|
|
59
|
+
else value
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|