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,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rgltf
|
|
4
|
+
module Validation
|
|
5
|
+
class Buffers < Rule
|
|
6
|
+
TARGETS = [34_962, 34_963].freeze
|
|
7
|
+
|
|
8
|
+
def validate
|
|
9
|
+
validate_buffers
|
|
10
|
+
validate_views
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def validate_buffers
|
|
16
|
+
array('buffers').each_with_index do |raw, index|
|
|
17
|
+
buffer = object(raw, "buffers/#{index}")
|
|
18
|
+
if buffer.key?('uri') && (!buffer['uri'].is_a?(String) || buffer['uri'].empty?)
|
|
19
|
+
error(:invalid_buffer_uri, "buffers/#{index}/uri", 'must be a non-empty string')
|
|
20
|
+
end
|
|
21
|
+
length = buffer['byteLength']
|
|
22
|
+
context.non_negative_integer(length, "buffers/#{index}/byteLength", minimum: 1)
|
|
23
|
+
validate_raw_resource(buffer, index)
|
|
24
|
+
next unless length.is_a?(Integer) && length.positive?
|
|
25
|
+
|
|
26
|
+
bytes = context.bytes_for_buffer(index)
|
|
27
|
+
if bytes && bytes.bytesize < length
|
|
28
|
+
error(:buffer_too_short, "buffers/#{index}", 'resolved bytes are shorter than byteLength')
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def validate_raw_resource(buffer, index)
|
|
34
|
+
return if context.document || index.zero? || buffer['uri'].is_a?(String)
|
|
35
|
+
return if required_meshopt_target?(buffer, index)
|
|
36
|
+
|
|
37
|
+
error(:missing_buffer, "buffers/#{index}", "buffer #{index} has no URI or GLB BIN chunk")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def required_meshopt_target?(buffer, index)
|
|
41
|
+
names = Buffer.const_get(:MESHOPT_EXTENSIONS, false)
|
|
42
|
+
required = @json['extensionsRequired']
|
|
43
|
+
return false unless required.is_a?(Array)
|
|
44
|
+
|
|
45
|
+
required_names = names & required
|
|
46
|
+
return true if required_names.any? { |name| buffer.dig('extensions', name, 'fallback') == true }
|
|
47
|
+
|
|
48
|
+
array('bufferViews').any? do |view|
|
|
49
|
+
next false unless view.is_a?(Hash) && view['buffer'] == index
|
|
50
|
+
|
|
51
|
+
extensions = view['extensions']
|
|
52
|
+
extensions.is_a?(Hash) && required_names.any? { |name| extensions[name].is_a?(Hash) }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def validate_views
|
|
57
|
+
array('bufferViews').each_with_index do |raw, index|
|
|
58
|
+
view = object(raw, "bufferViews/#{index}")
|
|
59
|
+
path = "bufferViews/#{index}"
|
|
60
|
+
reference('buffers', view['buffer'], "#{path}/buffer", required: true)
|
|
61
|
+
offset = view.fetch('byteOffset', 0)
|
|
62
|
+
length = view['byteLength']
|
|
63
|
+
context.non_negative_integer(offset, "#{path}/byteOffset")
|
|
64
|
+
context.non_negative_integer(length, "#{path}/byteLength", minimum: 1)
|
|
65
|
+
validate_stride(view['byteStride'], "#{path}/byteStride") if view.key?('byteStride')
|
|
66
|
+
validate_target(view['target'], "#{path}/target") if view.key?('target')
|
|
67
|
+
validate_range(view, offset, length, path)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def validate_stride(stride, path)
|
|
72
|
+
return if stride.is_a?(Integer) && stride.between?(4, 252) && (stride % 4).zero?
|
|
73
|
+
|
|
74
|
+
error(:invalid_byte_stride, path, 'must be 4..252 and divisible by 4')
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def validate_target(target, path)
|
|
78
|
+
return if TARGETS.include?(target)
|
|
79
|
+
|
|
80
|
+
error(:invalid_buffer_view_target, path, 'must be ARRAY_BUFFER or ELEMENT_ARRAY_BUFFER')
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def validate_range(view, offset, length, path)
|
|
84
|
+
return unless offset.is_a?(Integer) && offset >= 0 && length.is_a?(Integer) && length >= 1
|
|
85
|
+
return unless view['buffer'].is_a?(Integer)
|
|
86
|
+
|
|
87
|
+
buffer = array('buffers')[view['buffer']]
|
|
88
|
+
return unless buffer.is_a?(Hash) && buffer['byteLength'].is_a?(Integer)
|
|
89
|
+
return if offset + length <= buffer['byteLength']
|
|
90
|
+
|
|
91
|
+
error(:buffer_view_out_of_bounds, path, 'byte range exceeds buffer byteLength')
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rgltf
|
|
4
|
+
module Validation
|
|
5
|
+
class Cameras < Rule
|
|
6
|
+
def validate
|
|
7
|
+
array('cameras').each_with_index { |raw, index| validate_camera(object(raw, "cameras/#{index}"), index) }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def validate_camera(camera, index)
|
|
13
|
+
path = "cameras/#{index}"
|
|
14
|
+
case camera['type']
|
|
15
|
+
when 'perspective'
|
|
16
|
+
if camera.key?('orthographic')
|
|
17
|
+
error(:camera_projection_conflict, path,
|
|
18
|
+
'must not define orthographic for a perspective camera')
|
|
19
|
+
end
|
|
20
|
+
validate_perspective(object(camera['perspective'], "#{path}/perspective"), path)
|
|
21
|
+
when 'orthographic'
|
|
22
|
+
if camera.key?('perspective')
|
|
23
|
+
error(:camera_projection_conflict, path,
|
|
24
|
+
'must not define perspective for an orthographic camera')
|
|
25
|
+
end
|
|
26
|
+
validate_orthographic(object(camera['orthographic'], "#{path}/orthographic"), path)
|
|
27
|
+
else error(:invalid_camera_type, "#{path}/type", 'must be perspective or orthographic')
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def validate_perspective(camera, path)
|
|
32
|
+
positive(camera['yfov'], "#{path}/perspective/yfov")
|
|
33
|
+
if finite?(camera['yfov']) && camera['yfov'] >= Math::PI
|
|
34
|
+
warning(:unusual_camera_yfov, "#{path}/perspective/yfov", 'is at least PI radians')
|
|
35
|
+
end
|
|
36
|
+
positive(camera['znear'], "#{path}/perspective/znear")
|
|
37
|
+
positive(camera['aspectRatio'], "#{path}/perspective/aspectRatio") if camera.key?('aspectRatio')
|
|
38
|
+
return unless camera.key?('zfar')
|
|
39
|
+
|
|
40
|
+
positive(camera['zfar'], "#{path}/perspective/zfar")
|
|
41
|
+
return unless finite?(camera['zfar']) && finite?(camera['znear']) && camera['zfar'] <= camera['znear']
|
|
42
|
+
|
|
43
|
+
error(:invalid_camera_range, "#{path}/perspective/zfar", 'must be greater than znear')
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def validate_orthographic(camera, path)
|
|
47
|
+
%w[xmag ymag].each { |key| nonzero_magnitude(camera[key], "#{path}/orthographic/#{key}") }
|
|
48
|
+
%w[znear zfar].each do |key|
|
|
49
|
+
unless finite?(camera[key])
|
|
50
|
+
error(:invalid_camera_value, "#{path}/orthographic/#{key}",
|
|
51
|
+
'must be a finite number')
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
return unless finite?(camera['zfar']) && finite?(camera['znear']) && camera['zfar'] <= camera['znear']
|
|
55
|
+
|
|
56
|
+
error(:invalid_camera_range, "#{path}/orthographic/zfar", 'must be greater than znear')
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def positive(value, path, upper: nil)
|
|
60
|
+
valid = finite?(value) && value.positive? && (!upper || value < upper)
|
|
61
|
+
return if valid
|
|
62
|
+
|
|
63
|
+
error(:invalid_camera_value, path,
|
|
64
|
+
upper ? "must be greater than zero and less than #{upper}" : 'must be greater than zero')
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def nonzero_magnitude(value, path)
|
|
68
|
+
unless finite?(value) && !value.zero?
|
|
69
|
+
error(:invalid_camera_value, path, 'must be a non-zero finite number')
|
|
70
|
+
return
|
|
71
|
+
end
|
|
72
|
+
warning(:negative_camera_magnitude, path, 'negative magnification mirrors the camera axis') if value.negative?
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rgltf
|
|
4
|
+
module Validation
|
|
5
|
+
class Context
|
|
6
|
+
attr_reader :json, :document, :errors, :warnings
|
|
7
|
+
|
|
8
|
+
def initialize(json, document: nil)
|
|
9
|
+
@json = json.is_a?(Hash) ? json : {}
|
|
10
|
+
@document = document
|
|
11
|
+
@errors = []
|
|
12
|
+
@warnings = []
|
|
13
|
+
error(:invalid_document, '', 'glTF root must be an object') unless json.is_a?(Hash)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def array(key)
|
|
17
|
+
value = json[key]
|
|
18
|
+
return [] if value.nil?
|
|
19
|
+
return value if value.is_a?(Array)
|
|
20
|
+
|
|
21
|
+
error(:invalid_type, key, 'must be an array')
|
|
22
|
+
[]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def object(value, path)
|
|
26
|
+
return value if value.is_a?(Hash)
|
|
27
|
+
|
|
28
|
+
error(:invalid_type, path, 'must be an object')
|
|
29
|
+
{}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def reference(collection, value, path, required: false)
|
|
33
|
+
if value.nil?
|
|
34
|
+
error(:missing_reference, path, 'is required') if required
|
|
35
|
+
return false
|
|
36
|
+
end
|
|
37
|
+
return true if value.is_a?(Integer) && value >= 0 && value < array(collection).length
|
|
38
|
+
|
|
39
|
+
error(:invalid_reference, path, "must reference an existing #{collection} entry")
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def non_negative_integer(value, path, minimum: 0)
|
|
44
|
+
return true if value.is_a?(Integer) && value >= minimum
|
|
45
|
+
|
|
46
|
+
error(:invalid_integer, path, "must be an integer greater than or equal to #{minimum}")
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def finite_number?(value)
|
|
51
|
+
value.is_a?(Numeric) && (!value.respond_to?(:finite?) || value.finite?)
|
|
52
|
+
rescue StandardError
|
|
53
|
+
false
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def numeric_array(value, length, path)
|
|
57
|
+
unless value.is_a?(Array) && value.length == length && value.all? { |entry| finite_number?(entry) }
|
|
58
|
+
error(:invalid_numeric_array, path, "must contain exactly #{length} finite numbers")
|
|
59
|
+
return false
|
|
60
|
+
end
|
|
61
|
+
true
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def error(code, path, message)
|
|
65
|
+
issue = Validator::Issue.new(code:, path:, message:)
|
|
66
|
+
errors << issue unless errors.include?(issue)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def warning(code, path, message)
|
|
70
|
+
issue = Validator::Issue.new(code:, path:, message:)
|
|
71
|
+
warnings << issue unless warnings.include?(issue)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def bytes_for_buffer(index)
|
|
75
|
+
return unless document
|
|
76
|
+
|
|
77
|
+
buffer = document.buffers[index]
|
|
78
|
+
return if buffer&.send(:meshopt_decode_target?)
|
|
79
|
+
|
|
80
|
+
buffer&.bytes
|
|
81
|
+
rescue Error => e
|
|
82
|
+
error(:missing_buffer, "buffers/#{index}", e.message)
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def bytes_for_image(index)
|
|
87
|
+
return unless document
|
|
88
|
+
|
|
89
|
+
document.images[index]&.bytes
|
|
90
|
+
rescue Error => e
|
|
91
|
+
error(:missing_image, "images/#{index}", e.message)
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def meshopt_decode_target_accessor?(index)
|
|
96
|
+
accessors = json['accessors']
|
|
97
|
+
accessor = accessors[index] if accessors.is_a?(Array) && index.is_a?(Integer)
|
|
98
|
+
return false unless accessor.is_a?(Hash)
|
|
99
|
+
|
|
100
|
+
views = json['bufferViews']
|
|
101
|
+
view = views[accessor['bufferView']] if views.is_a?(Array) && accessor['bufferView'].is_a?(Integer)
|
|
102
|
+
return false unless view.is_a?(Hash) && view['buffer'].is_a?(Integer)
|
|
103
|
+
|
|
104
|
+
extensions = view['extensions']
|
|
105
|
+
meshopt_extensions = Buffer.const_get(:MESHOPT_EXTENSIONS, false)
|
|
106
|
+
required_extensions = document ? document.extensions_required : json.fetch('extensionsRequired', [])
|
|
107
|
+
required_extensions = [] unless required_extensions.is_a?(Array)
|
|
108
|
+
required = meshopt_extensions & required_extensions
|
|
109
|
+
extensions.is_a?(Hash) && required.any? { |name| extensions[name].is_a?(Hash) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private :meshopt_decode_target_accessor?
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
class Rule
|
|
116
|
+
def initialize(context)
|
|
117
|
+
@context = context
|
|
118
|
+
@json = context.json
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
attr_reader :context
|
|
124
|
+
|
|
125
|
+
def array(key) = context.array(key)
|
|
126
|
+
def object(value, path) = context.object(value, path)
|
|
127
|
+
def error(code, path, message) = context.error(code, path, message)
|
|
128
|
+
def warning(code, path, message) = context.warning(code, path, message)
|
|
129
|
+
def reference(collection, value, path, required: false) = context.reference(collection, value, path, required:)
|
|
130
|
+
def finite?(value) = context.finite_number?(value)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rgltf
|
|
4
|
+
module Validation
|
|
5
|
+
class ImagesMaterials < Rule
|
|
6
|
+
IMAGE_SIGNATURES = {
|
|
7
|
+
'image/png' => "\x89PNG\r\n\x1A\n".b,
|
|
8
|
+
'image/jpeg' => "\xFF\xD8\xFF".b,
|
|
9
|
+
'image/webp' => 'RIFF'.b,
|
|
10
|
+
'image/ktx2' => "\xABKTX 20\xBB\r\n\x1A\n".b
|
|
11
|
+
}.freeze
|
|
12
|
+
FILTERS = [9728, 9729, 9984, 9985, 9986, 9987].freeze
|
|
13
|
+
WRAPS = [33_071, 33_648, 10_497].freeze
|
|
14
|
+
|
|
15
|
+
def validate
|
|
16
|
+
validate_images
|
|
17
|
+
validate_samplers
|
|
18
|
+
validate_textures
|
|
19
|
+
array('materials').each_with_index { |raw, index| validate_material(object(raw, "materials/#{index}"), index) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def validate_images
|
|
25
|
+
array('images').each_with_index do |raw, index|
|
|
26
|
+
image = object(raw, "images/#{index}")
|
|
27
|
+
path = "images/#{index}"
|
|
28
|
+
uri = image.key?('uri')
|
|
29
|
+
view = image.key?('bufferView')
|
|
30
|
+
error(:image_source_missing, path, 'must define exactly one of uri or bufferView') unless uri ^ view
|
|
31
|
+
if uri && (!image['uri'].is_a?(String) || image['uri'].empty?)
|
|
32
|
+
error(:invalid_type, "#{path}/uri",
|
|
33
|
+
'must be a non-empty string')
|
|
34
|
+
end
|
|
35
|
+
reference('bufferViews', image['bufferView'], "#{path}/bufferView") if view
|
|
36
|
+
buffer_view = array('bufferViews')[image['bufferView']] if image['bufferView'].is_a?(Integer)
|
|
37
|
+
if buffer_view.is_a?(Hash) && buffer_view.key?('target')
|
|
38
|
+
error(:image_buffer_view_target, "#{path}/bufferView", 'image bufferView must not define target')
|
|
39
|
+
end
|
|
40
|
+
mime = image['mimeType']
|
|
41
|
+
error(:image_mime_missing, "#{path}/mimeType", 'is required with bufferView images') if view && mime.nil?
|
|
42
|
+
if mime && !allowed_mimes.include?(mime)
|
|
43
|
+
error(:invalid_image_mime, "#{path}/mimeType",
|
|
44
|
+
'contains a MIME type unavailable to this document')
|
|
45
|
+
end
|
|
46
|
+
validate_image_signature(index, mime, path) if allowed_mimes.include?(mime)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def validate_image_signature(index, mime, path)
|
|
51
|
+
bytes = context.bytes_for_image(index)
|
|
52
|
+
return unless bytes
|
|
53
|
+
return if bytes.start_with?(IMAGE_SIGNATURES[mime])
|
|
54
|
+
|
|
55
|
+
error(:image_mime_mismatch, path, "bytes do not match #{mime}")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def allowed_mimes
|
|
59
|
+
@allowed_mimes ||= begin
|
|
60
|
+
values = %w[image/png image/jpeg]
|
|
61
|
+
values << 'image/webp' if array('extensionsUsed').include?('EXT_texture_webp')
|
|
62
|
+
values << 'image/ktx2' if array('extensionsUsed').include?('KHR_texture_basisu')
|
|
63
|
+
values
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def validate_samplers
|
|
68
|
+
array('samplers').each_with_index do |raw, index|
|
|
69
|
+
sampler = object(raw, "samplers/#{index}")
|
|
70
|
+
check_enum(sampler, 'magFilter', [9728, 9729], "samplers/#{index}")
|
|
71
|
+
check_enum(sampler, 'minFilter', FILTERS, "samplers/#{index}")
|
|
72
|
+
check_enum(sampler, 'wrapS', WRAPS, "samplers/#{index}")
|
|
73
|
+
check_enum(sampler, 'wrapT', WRAPS, "samplers/#{index}")
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def validate_textures
|
|
78
|
+
array('textures').each_with_index do |raw, index|
|
|
79
|
+
texture = object(raw, "textures/#{index}")
|
|
80
|
+
reference('samplers', texture['sampler'], "textures/#{index}/sampler") if texture.key?('sampler')
|
|
81
|
+
reference('images', texture['source'], "textures/#{index}/source") if texture.key?('source')
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def validate_material(material, index)
|
|
86
|
+
path = "materials/#{index}"
|
|
87
|
+
pbr = object(material.fetch('pbrMetallicRoughness', {}), "#{path}/pbrMetallicRoughness")
|
|
88
|
+
factor(pbr, 'baseColorFactor', 4, "#{path}/pbrMetallicRoughness")
|
|
89
|
+
unit(pbr, 'metallicFactor', "#{path}/pbrMetallicRoughness")
|
|
90
|
+
unit(pbr, 'roughnessFactor', "#{path}/pbrMetallicRoughness")
|
|
91
|
+
factor(material, 'emissiveFactor', 3, path)
|
|
92
|
+
%w[baseColorTexture metallicRoughnessTexture].each do |key|
|
|
93
|
+
texture_info(pbr[key], "#{path}/pbrMetallicRoughness/#{key}") if pbr[key]
|
|
94
|
+
end
|
|
95
|
+
texture_info(material['normalTexture'], "#{path}/normalTexture") if material['normalTexture']
|
|
96
|
+
texture_info(material['occlusionTexture'], "#{path}/occlusionTexture") if material['occlusionTexture']
|
|
97
|
+
texture_info(material['emissiveTexture'], "#{path}/emissiveTexture") if material['emissiveTexture']
|
|
98
|
+
validate_material_options(material, path)
|
|
99
|
+
scale = material.dig('normalTexture', 'scale') if material['normalTexture'].is_a?(Hash)
|
|
100
|
+
return unless !scale.nil? && !finite?(scale)
|
|
101
|
+
|
|
102
|
+
error(:invalid_normal_scale, "#{path}/normalTexture/scale",
|
|
103
|
+
'must be a finite number')
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def validate_material_options(material, path)
|
|
107
|
+
alpha = material.fetch('alphaMode', 'OPAQUE')
|
|
108
|
+
error(:invalid_alpha_mode, "#{path}/alphaMode", 'must be OPAQUE, MASK, or BLEND') unless %w[OPAQUE MASK
|
|
109
|
+
BLEND].include?(alpha)
|
|
110
|
+
cutoff = material['alphaCutoff']
|
|
111
|
+
if cutoff && (!finite?(cutoff) || cutoff.negative?)
|
|
112
|
+
error(:invalid_alpha_cutoff, "#{path}/alphaCutoff",
|
|
113
|
+
'must be non-negative')
|
|
114
|
+
end
|
|
115
|
+
if cutoff && alpha != 'MASK'
|
|
116
|
+
warning(:unused_alpha_cutoff, "#{path}/alphaCutoff",
|
|
117
|
+
'has no effect unless alphaMode is MASK')
|
|
118
|
+
end
|
|
119
|
+
sided = material['doubleSided']
|
|
120
|
+
error(:invalid_boolean, "#{path}/doubleSided", 'must be boolean') if material.key?('doubleSided') && ![true,
|
|
121
|
+
false].include?(sided)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def texture_info(raw, path)
|
|
125
|
+
info = object(raw, path)
|
|
126
|
+
reference('textures', info['index'], "#{path}/index", required: true)
|
|
127
|
+
tex_coord = info.fetch('texCoord', 0)
|
|
128
|
+
context.non_negative_integer(tex_coord, "#{path}/texCoord")
|
|
129
|
+
unit(info, 'strength', path) if info.key?('strength')
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def factor(owner, key, length, path)
|
|
133
|
+
return unless owner.key?(key)
|
|
134
|
+
return unless context.numeric_array(owner[key], length, "#{path}/#{key}")
|
|
135
|
+
return if owner[key].all? { |value| value.between?(0, 1) }
|
|
136
|
+
|
|
137
|
+
error(:factor_out_of_range, "#{path}/#{key}", 'components must be between 0 and 1')
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def unit(owner, key, path)
|
|
141
|
+
return unless owner.key?(key)
|
|
142
|
+
|
|
143
|
+
value = owner[key]
|
|
144
|
+
return if finite?(value) && value.between?(0, 1)
|
|
145
|
+
|
|
146
|
+
error(:factor_out_of_range, "#{path}/#{key}", 'must be between 0 and 1')
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def check_enum(owner, key, values, path)
|
|
150
|
+
return unless owner.key?(key)
|
|
151
|
+
|
|
152
|
+
return if values.include?(owner[key])
|
|
153
|
+
|
|
154
|
+
error(:invalid_sampler_value, "#{path}/#{key}",
|
|
155
|
+
'contains an unsupported value')
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rgltf
|
|
4
|
+
module Validation
|
|
5
|
+
class Meshes < Rule
|
|
6
|
+
BUILTIN = /\A(?:POSITION|NORMAL|TANGENT|TEXCOORD_\d+|COLOR_\d+|JOINTS_\d+|WEIGHTS_\d+)\z/
|
|
7
|
+
CUSTOM = /\A_[A-Z0-9_]+\z/
|
|
8
|
+
MORPH = %w[POSITION NORMAL TANGENT].freeze
|
|
9
|
+
|
|
10
|
+
def validate
|
|
11
|
+
array('meshes').each_with_index { |raw, index| validate_mesh(object(raw, "meshes/#{index}"), index) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def validate_mesh(mesh, mesh_index)
|
|
17
|
+
primitives = mesh['primitives']
|
|
18
|
+
unless primitives.is_a?(Array) && !primitives.empty?
|
|
19
|
+
error(:mesh_primitives_missing, "meshes/#{mesh_index}/primitives", 'must be a non-empty array')
|
|
20
|
+
return
|
|
21
|
+
end
|
|
22
|
+
target_counts = primitives.each_with_index.map do |raw, primitive_index|
|
|
23
|
+
validate_primitive(object(raw, "meshes/#{mesh_index}/primitives/#{primitive_index}"), mesh_index,
|
|
24
|
+
primitive_index)
|
|
25
|
+
end
|
|
26
|
+
if target_counts.uniq.length > 1
|
|
27
|
+
error(:inconsistent_morph_targets, "meshes/#{mesh_index}/primitives",
|
|
28
|
+
'all primitives must have the same target count')
|
|
29
|
+
end
|
|
30
|
+
validate_weights(mesh['weights'], target_counts.first, "meshes/#{mesh_index}/weights") if mesh.key?('weights')
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def validate_primitive(primitive, mesh_index, primitive_index)
|
|
34
|
+
path = "meshes/#{mesh_index}/primitives/#{primitive_index}"
|
|
35
|
+
mode = primitive.fetch('mode', 4)
|
|
36
|
+
unless mode.is_a?(Integer) && mode.between?(
|
|
37
|
+
0, 6
|
|
38
|
+
)
|
|
39
|
+
error(:invalid_primitive_mode, "#{path}/mode",
|
|
40
|
+
'must be between 0 and 6')
|
|
41
|
+
end
|
|
42
|
+
reference('materials', primitive['material'], "#{path}/material") if primitive.key?('material')
|
|
43
|
+
attributes = primitive['attributes']
|
|
44
|
+
unless attributes.is_a?(Hash) && !attributes.empty?
|
|
45
|
+
error(:primitive_attributes_missing, "#{path}/attributes", 'must be a non-empty object')
|
|
46
|
+
attributes = {}
|
|
47
|
+
end
|
|
48
|
+
counts = attributes.filter_map do |semantic, accessor_index|
|
|
49
|
+
validate_attribute(semantic, accessor_index, "#{path}/attributes/#{semantic}")
|
|
50
|
+
end
|
|
51
|
+
if counts.uniq.length > 1
|
|
52
|
+
error(:attribute_count_mismatch, "#{path}/attributes",
|
|
53
|
+
'all attribute accessors must have equal count')
|
|
54
|
+
end
|
|
55
|
+
vertex_count = counts.first
|
|
56
|
+
if primitive.key?('indices')
|
|
57
|
+
compressed = primitive.dig('extensions', 'KHR_draco_mesh_compression').is_a?(Hash)
|
|
58
|
+
validate_indices(primitive['indices'], vertex_count, "#{path}/indices", compressed:)
|
|
59
|
+
end
|
|
60
|
+
targets = primitive.fetch('targets', [])
|
|
61
|
+
unless targets.is_a?(Array)
|
|
62
|
+
error(:invalid_type, "#{path}/targets", 'must be an array')
|
|
63
|
+
return 0
|
|
64
|
+
end
|
|
65
|
+
targets.each_with_index do |target, index|
|
|
66
|
+
validate_target(object(target, "#{path}/targets/#{index}"), vertex_count, "#{path}/targets/#{index}")
|
|
67
|
+
end
|
|
68
|
+
targets.length
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def validate_attribute(semantic, accessor_index, path)
|
|
72
|
+
unless semantic.is_a?(String) && (BUILTIN.match?(semantic) || CUSTOM.match?(semantic))
|
|
73
|
+
error(:invalid_attribute_semantic, path, 'is not a valid attribute semantic')
|
|
74
|
+
end
|
|
75
|
+
return unless reference('accessors', accessor_index, path, required: true)
|
|
76
|
+
|
|
77
|
+
accessor = array('accessors')[accessor_index]
|
|
78
|
+
validate_semantic_type(semantic, accessor, path) if accessor.is_a?(Hash) && BUILTIN.match?(semantic.to_s)
|
|
79
|
+
validate_view_target(accessor, 34_962, path) if accessor.is_a?(Hash)
|
|
80
|
+
validate_vertex_alignment(accessor, path) if accessor.is_a?(Hash)
|
|
81
|
+
accessor['count'] if accessor.is_a?(Hash)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def validate_semantic_type(semantic, accessor, path)
|
|
85
|
+
type = accessor['type']
|
|
86
|
+
component = accessor['componentType']
|
|
87
|
+
normalized = accessor.fetch('normalized', false)
|
|
88
|
+
valid = case semantic
|
|
89
|
+
when 'POSITION', 'NORMAL' then type == 'VEC3' && component == 5126
|
|
90
|
+
when 'TANGENT' then type == 'VEC4' && component == 5126
|
|
91
|
+
when /\ATEXCOORD_/ then type == 'VEC2' && (component == 5126 || ([5121,
|
|
92
|
+
5123].include?(component) && normalized))
|
|
93
|
+
when /\ACOLOR_/ then %w[VEC3
|
|
94
|
+
VEC4].include?(type) && (component == 5126 || ([5121,
|
|
95
|
+
5123].include?(component) && normalized))
|
|
96
|
+
when /\AJOINTS_/ then type == 'VEC4' && [5121, 5123].include?(component) && !normalized
|
|
97
|
+
when /\AWEIGHTS_/ then type == 'VEC4' && (component == 5126 || ([5121,
|
|
98
|
+
5123].include?(component) && normalized))
|
|
99
|
+
end
|
|
100
|
+
valid ||= quantized_attribute?(semantic, type, component, normalized)
|
|
101
|
+
unless valid
|
|
102
|
+
error(:invalid_attribute_accessor, path,
|
|
103
|
+
"accessor type or componentType is invalid for #{semantic}")
|
|
104
|
+
end
|
|
105
|
+
return unless semantic == 'POSITION' && (!accessor['min'] || !accessor['max'])
|
|
106
|
+
|
|
107
|
+
warning(:position_min_max_missing, path, 'POSITION accessor should define min and max')
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def validate_indices(accessor_index, vertex_count, path, compressed: false)
|
|
111
|
+
return unless reference('accessors', accessor_index, path, required: true)
|
|
112
|
+
|
|
113
|
+
accessor = array('accessors')[accessor_index]
|
|
114
|
+
return unless accessor.is_a?(Hash)
|
|
115
|
+
|
|
116
|
+
unless accessor['type'] == 'SCALAR' && [5121, 5123,
|
|
117
|
+
5125].include?(accessor['componentType']) && !accessor.fetch(
|
|
118
|
+
'normalized', false
|
|
119
|
+
)
|
|
120
|
+
error(:invalid_indices_accessor, path, 'must be a SCALAR unsigned integer accessor')
|
|
121
|
+
end
|
|
122
|
+
validate_view_target(accessor, 34_963, path)
|
|
123
|
+
return if compressed
|
|
124
|
+
|
|
125
|
+
maximum = accessor['max']&.first
|
|
126
|
+
maximum ||= decoded_index_max(accessor_index, path)
|
|
127
|
+
return unless vertex_count.is_a?(Integer) && finite?(maximum) && maximum >= vertex_count
|
|
128
|
+
|
|
129
|
+
error(:index_out_of_range, path, 'index maximum must be less than vertex count')
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def decoded_index_max(accessor_index, path)
|
|
133
|
+
document = context.document
|
|
134
|
+
return unless document && document.accessors[accessor_index]
|
|
135
|
+
return if context.send(:meshopt_decode_target_accessor?, accessor_index)
|
|
136
|
+
|
|
137
|
+
document.accessors[accessor_index].to_a.max
|
|
138
|
+
rescue Error => e
|
|
139
|
+
error(:indices_unreadable, path, e.message)
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def validate_target(target, vertex_count, path)
|
|
144
|
+
error(:empty_morph_target, path, 'must contain at least one attribute') if target.empty?
|
|
145
|
+
target.each do |semantic, accessor_index|
|
|
146
|
+
unless MORPH.include?(semantic)
|
|
147
|
+
error(:invalid_morph_semantic, "#{path}/#{semantic}",
|
|
148
|
+
'must be POSITION, NORMAL, or TANGENT')
|
|
149
|
+
end
|
|
150
|
+
next unless reference('accessors', accessor_index, "#{path}/#{semantic}", required: true)
|
|
151
|
+
|
|
152
|
+
accessor = array('accessors')[accessor_index]
|
|
153
|
+
next unless accessor.is_a?(Hash)
|
|
154
|
+
|
|
155
|
+
valid = accessor['type'] == 'VEC3' && accessor['componentType'] == 5126
|
|
156
|
+
valid ||= quantized_attribute?(
|
|
157
|
+
semantic,
|
|
158
|
+
accessor['type'],
|
|
159
|
+
accessor['componentType'],
|
|
160
|
+
accessor.fetch('normalized', false),
|
|
161
|
+
morph: true
|
|
162
|
+
)
|
|
163
|
+
error(:invalid_morph_accessor, "#{path}/#{semantic}", 'must be a VEC3 FLOAT accessor') unless valid
|
|
164
|
+
if vertex_count.is_a?(Integer) && accessor['count'] != vertex_count
|
|
165
|
+
error(:morph_count_mismatch, "#{path}/#{semantic}",
|
|
166
|
+
'count must match primitive attributes')
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def validate_weights(weights, target_count, path)
|
|
172
|
+
unless weights.is_a?(Array) && weights.all? { |weight| finite?(weight) }
|
|
173
|
+
error(:invalid_mesh_weights, path, 'must be an array of finite numbers')
|
|
174
|
+
return
|
|
175
|
+
end
|
|
176
|
+
return unless target_count && weights.length != target_count
|
|
177
|
+
|
|
178
|
+
error(:mesh_weights_count_mismatch, path,
|
|
179
|
+
'count must equal morph target count')
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def validate_view_target(accessor, expected, path)
|
|
183
|
+
view = array('bufferViews')[accessor['bufferView']] if accessor['bufferView'].is_a?(Integer)
|
|
184
|
+
return unless view.is_a?(Hash) && view.key?('target') && view['target'] != expected
|
|
185
|
+
|
|
186
|
+
error(:buffer_view_target_mismatch, path, 'bufferView target is incompatible with accessor use')
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def validate_vertex_alignment(accessor, path)
|
|
190
|
+
view = array('bufferViews')[accessor['bufferView']] if accessor['bufferView'].is_a?(Integer)
|
|
191
|
+
return unless view.is_a?(Hash)
|
|
192
|
+
|
|
193
|
+
absolute = view.fetch('byteOffset', 0)
|
|
194
|
+
offset = accessor.fetch('byteOffset', 0)
|
|
195
|
+
return unless absolute.is_a?(Integer) && offset.is_a?(Integer)
|
|
196
|
+
return if ((absolute + offset) % 4).zero?
|
|
197
|
+
|
|
198
|
+
error(:vertex_attribute_misaligned, path, 'vertex attribute absolute offset must be divisible by 4')
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def quantized_attribute?(semantic, type, component, normalized, morph: false)
|
|
202
|
+
return false unless array('extensionsUsed').include?('KHR_mesh_quantization')
|
|
203
|
+
|
|
204
|
+
case semantic
|
|
205
|
+
when 'POSITION' then type == 'VEC3' && [5120, 5121, 5122, 5123].include?(component)
|
|
206
|
+
when 'NORMAL' then type == 'VEC3' && [5120, 5122].include?(component) && normalized
|
|
207
|
+
when 'TANGENT' then type == (morph ? 'VEC3' : 'VEC4') && [5120, 5122].include?(component) && normalized
|
|
208
|
+
when /\ATEXCOORD_/ then type == 'VEC2' && [5120, 5121, 5122, 5123].include?(component)
|
|
209
|
+
else false
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|