rgltf 1.0.0

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