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,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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rgltf
4
+ VERSION = '1.0.0'
5
+ 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