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
+ class Writer
5
+ def initialize(document, validate: true)
6
+ @document = document
7
+ @validate = validate
8
+ return unless validate
9
+
10
+ result = Validator.validate(document)
11
+ return if result.errors.empty?
12
+
13
+ raise ValidationError, result.errors.map { |issue| "#{issue.path}: #{issue.message}" }.join('; ')
14
+ end
15
+
16
+ def to_glb
17
+ json, binary = serialized_payload
18
+ json.fetch('buffers', []).each { |buffer| buffer.delete('uri') }
19
+ GLB.write(JSON.generate(json), binary.empty? ? nil : binary)
20
+ end
21
+
22
+ def write_glb(path)
23
+ File.binwrite(path, to_glb)
24
+ path
25
+ rescue Errno::EACCES, Errno::ENOENT => e
26
+ raise MissingResourceError, e.message
27
+ end
28
+
29
+ def write_gltf(path, embed: false)
30
+ json, binary = serialized_payload
31
+ unless binary.empty?
32
+ if embed
33
+ json.fetch('buffers').first['uri'] = data_uri(binary)
34
+ else
35
+ extension = File.extname(path)
36
+ basename = File.basename(path, extension)
37
+ bin_path = File.join(File.dirname(path), "#{basename}.bin")
38
+ File.binwrite(bin_path, binary)
39
+ json.fetch('buffers').first['uri'] = File.basename(bin_path)
40
+ end
41
+ end
42
+ File.write(path, JSON.pretty_generate(json) << "\n")
43
+ path
44
+ rescue Errno::EACCES, Errno::ENOENT => e
45
+ raise MissingResourceError, e.message
46
+ end
47
+
48
+ private
49
+
50
+ def serialized_payload
51
+ copies = {}
52
+ json = deep_copy(@document.raw_json, copies)
53
+ owners = property_owners
54
+ ExtensionSerializer.new(@document, copies).apply!(owners)
55
+ DefaultOmitter.apply!(owners, copies)
56
+ binary = BufferMerger.new(@document).merge!(json)
57
+ validate_serialized!(json) if @validate
58
+ [json, binary]
59
+ end
60
+
61
+ def property_owners
62
+ roots = [@document.asset, *@document.class::COLLECTIONS.keys.flat_map do |key|
63
+ @document.public_send(key == 'bufferViews' ? :buffer_views : key)
64
+ end]
65
+ roots.flat_map { |owner| collect_owners(owner) }
66
+ end
67
+
68
+ def collect_owners(owner)
69
+ [owner, *owner.nested_properties.flat_map { |child| collect_owners(child) }]
70
+ end
71
+
72
+ def deep_copy(value, copies)
73
+ copy = case value
74
+ when Hash then value.to_h { |key, child| [key, deep_copy(child, copies)] }
75
+ when Array then value.map { |child| deep_copy(child, copies) }
76
+ when String then value.dup
77
+ else value
78
+ end
79
+ copies[value.object_id] = copy if value.is_a?(Hash)
80
+ copy
81
+ end
82
+
83
+ def data_uri(binary)
84
+ "data:application/octet-stream;base64,#{[binary].pack('m0')}"
85
+ end
86
+
87
+ def validate_serialized!(json)
88
+ result = Validator.validate(json)
89
+ return if result.errors.empty?
90
+
91
+ message = result.errors.map { |issue| "#{issue.path}: #{issue.message}" }.join('; ')
92
+ raise ValidationError, message
93
+ end
94
+ end
95
+ end
data/lib/rgltf.rb ADDED
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ require_relative 'rgltf/version'
6
+ require_relative 'rgltf/errors'
7
+ require_relative 'rgltf/glb'
8
+ require_relative 'rgltf/extension'
9
+ require_relative 'rgltf/buffer_resolver'
10
+ require_relative 'rgltf/accessor_reader'
11
+ require_relative 'rgltf/properties'
12
+ require_relative 'rgltf/document'
13
+ require_relative 'rgltf/extensions/khr_materials_unlit'
14
+ require_relative 'rgltf/extensions/khr_texture_transform'
15
+ require_relative 'rgltf/extensions/khr_lights_punctual'
16
+ require_relative 'rgltf/extensions/khr_materials_emissive_strength'
17
+ require_relative 'rgltf/validator'
18
+ require_relative 'rgltf/writer/default_omitter'
19
+ require_relative 'rgltf/writer/extension_serializer'
20
+ require_relative 'rgltf/writer/buffer_merger'
21
+ require_relative 'rgltf/writer'
22
+ require_relative 'rgltf/builder'
23
+
24
+ module Rgltf
25
+ class << self
26
+ def load(source, base_dir: nil, **options)
27
+ data, inferred_base_dir, inferred_format = read_source(source)
28
+ base_dir ||= inferred_base_dir
29
+
30
+ if inferred_format == :glb
31
+ raise FormatError, 'not a GLB (bad magic)' unless glb?(data)
32
+
33
+ load_glb(data, base_dir:, **options)
34
+ elsif glb?(data)
35
+ load_glb(data, base_dir:, **options)
36
+ else
37
+ load_json(data, base_dir:, **options)
38
+ end
39
+ rescue Errno::ENOENT, Errno::EACCES => e
40
+ raise MissingResourceError, e.message
41
+ rescue Error
42
+ raise
43
+ rescue StandardError => e
44
+ raise FormatError, "could not load glTF: #{e.message}"
45
+ end
46
+
47
+ def load_glb(data, base_dir: nil, **options)
48
+ json, bin = GLB.read(binary_string(data))
49
+ load_json(json, base_dir:, glb_bin: bin, **options)
50
+ rescue Error
51
+ raise
52
+ rescue StandardError => e
53
+ raise FormatError, "could not load GLB: #{e.message}"
54
+ end
55
+
56
+ def load_json(json, base_dir: nil, buffers: nil, glb_bin: nil, lazy: true,
57
+ extensions: default_extensions, validate: false,
58
+ strict_extensions: true)
59
+ parsed = json.is_a?(Hash) ? deep_copy(json) : JSON.parse(string_data(json))
60
+ validate_before_build!(parsed, supplied_buffers: buffers) if validate
61
+ Document.new(
62
+ parsed,
63
+ base_dir:,
64
+ supplied_buffers: buffers,
65
+ glb_bin:,
66
+ lazy:,
67
+ extension_registry: extensions,
68
+ validate:,
69
+ strict_extensions:
70
+ )
71
+ rescue JSON::ParserError => e
72
+ raise FormatError, "invalid glTF JSON: #{e.message}"
73
+ rescue FormatError => e
74
+ raise ValidationError, e.message if validate
75
+
76
+ raise
77
+ rescue Error
78
+ raise
79
+ rescue StandardError => e
80
+ raise FormatError, "could not load glTF JSON: #{e.message}"
81
+ end
82
+
83
+ private
84
+
85
+ def read_source(source)
86
+ return [source.read, nil, nil] if source.respond_to?(:read)
87
+
88
+ value = string_data(source)
89
+ stripped = value.lstrip
90
+ return [value, nil, nil] if glb?(value.b) || stripped.start_with?('{', '[')
91
+
92
+ format = :glb if File.extname(value).casecmp?('.glb')
93
+ [File.binread(value), File.dirname(File.expand_path(value)), format]
94
+ end
95
+
96
+ def string_data(value)
97
+ return value.to_str if value.respond_to?(:to_str)
98
+
99
+ raise ArgumentError, 'expected a path, String, or IO-like object'
100
+ end
101
+
102
+ def binary_string(value)
103
+ string_data(value).b
104
+ end
105
+
106
+ def glb?(data)
107
+ data.bytesize >= 4 && data.byteslice(0, 4).unpack1('L<') == GLB::MAGIC
108
+ end
109
+
110
+ def deep_copy(value)
111
+ Marshal.load(Marshal.dump(value))
112
+ end
113
+
114
+ def validate_before_build!(json, supplied_buffers: nil)
115
+ result = Validator.validate(json)
116
+ errors = filter_supplied_buffer_errors(result.errors, supplied_buffers)
117
+ return if errors.empty?
118
+
119
+ message = errors.map { |issue| "#{issue.path}: #{issue.message}" }.join('; ')
120
+ raise ValidationError, message
121
+ end
122
+
123
+ def filter_supplied_buffer_errors(errors, supplied_buffers)
124
+ return errors unless supplied_buffers
125
+
126
+ resolver = BufferResolver.new(base_dir: nil, supplied_buffers:)
127
+ errors.reject do |issue|
128
+ match = %r{\Abuffers/(\d+)\z}.match(issue.path) if issue.code == :missing_buffer
129
+ match && resolver.send(:supplied?, match[1].to_i)
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'rake'
5
+
6
+ namespace :sample_assets do
7
+ SHA = '2bac6f8c57bf471df0d2a1e8a8ec023c7801dddf'
8
+ URL = 'https://github.com/KhronosGroup/glTF-Sample-Assets.git'
9
+ DEFAULT_PATH = File.expand_path('../tmp/gltf-sample-assets', __dir__)
10
+
11
+ desc 'Clone and verify the pinned Khronos glTF-Sample-Assets revision'
12
+ task :fetch do
13
+ destination = ENV.fetch('GLTF_SAMPLE_ASSETS', DEFAULT_PATH)
14
+ managed = File.expand_path(destination) == DEFAULT_PATH
15
+ checkout = SampleAssetsCheckout.new(destination, URL, SHA)
16
+ managed ? checkout.prepare_managed : checkout.verify_custom
17
+ puts "glTF-Sample-Assets ready at #{destination} (#{SHA})"
18
+ end
19
+
20
+ desc 'Run the explicit and full pinned sample-asset acceptance suites'
21
+ task :verify do
22
+ root = ENV.fetch('GLTF_SAMPLE_ASSETS', nil)
23
+ abort 'GLTF_SAMPLE_ASSETS must point to a pinned checkout' if root.nil? || root.empty?
24
+
25
+ sh({ 'GLTF_SAMPLE_ASSETS' => File.expand_path(root) }, 'bundle', 'exec', 'rspec',
26
+ 'spec/sample_assets_spec.rb', 'spec/sample_assets_full_spec.rb')
27
+ end
28
+
29
+ class SampleAssetsCheckout
30
+ def initialize(destination, url, sha)
31
+ @destination = destination
32
+ @url = url
33
+ @sha = sha
34
+ end
35
+
36
+ def prepare_managed
37
+ clone_fresh unless usable_repository?
38
+ refresh unless commit_available?
39
+ return checkout if checkout_successful?
40
+
41
+ clone_fresh
42
+ checkout
43
+ end
44
+
45
+ def verify_custom
46
+ raise "custom sample-assets path is not a git repository: #{@destination}" unless usable_repository?
47
+
48
+ dirty = capture('git', '-C', @destination, 'status', '--porcelain').strip
49
+ raise "custom sample-assets checkout must be clean: #{@destination}" unless dirty.empty?
50
+
51
+ actual = capture('git', '-C', @destination, 'rev-parse', 'HEAD').strip
52
+ raise "custom sample-assets checkout must be at #{@sha}, got #{actual}" unless actual == @sha
53
+ end
54
+
55
+ private
56
+
57
+ def usable_repository?
58
+ return false unless File.directory?(File.join(@destination, '.git'))
59
+
60
+ system('git', '-C', @destination, 'rev-parse', '--is-inside-work-tree',
61
+ out: File::NULL, err: File::NULL)
62
+ end
63
+
64
+ def commit_available?
65
+ system('git', '-C', @destination, 'cat-file', '-e', "#{@sha}^{commit}",
66
+ out: File::NULL, err: File::NULL)
67
+ end
68
+
69
+ def refresh
70
+ remove_stale_locks
71
+ run 'git', '-C', @destination, 'fetch', '--depth', '1', '--force', 'origin', @sha
72
+ end
73
+
74
+ def checkout
75
+ run 'git', '-C', @destination, 'checkout', '--detach', '--force', @sha
76
+ actual = capture('git', '-C', @destination, 'rev-parse', 'HEAD').strip
77
+ raise "sample-assets revision mismatch: #{actual}" unless actual == @sha
78
+
79
+ run 'git', '-C', @destination, 'submodule', 'update', '--init', '--recursive', '--depth', '1'
80
+ end
81
+
82
+ def checkout_successful?
83
+ remove_stale_locks
84
+ system('git', '-C', @destination, 'checkout', '--detach', '--force', @sha,
85
+ out: File::NULL, err: File::NULL)
86
+ end
87
+
88
+ def clone_fresh
89
+ temporary = "#{@destination}.new-#{Process.pid}"
90
+ FileUtils.rm_rf(temporary)
91
+ FileUtils.mkdir_p(File.dirname(@destination))
92
+ run 'git', 'clone', '--filter=blob:none', '--no-checkout', @url, temporary
93
+ run 'git', '-C', temporary, 'fetch', '--depth', '1', '--force', 'origin', @sha
94
+ FileUtils.rm_rf(@destination)
95
+ FileUtils.mv(temporary, @destination)
96
+ ensure
97
+ FileUtils.rm_rf(temporary) if temporary && File.exist?(temporary)
98
+ end
99
+
100
+ def remove_stale_locks
101
+ Dir[File.join(@destination, '.git', '**', '*.lock')].each { |path| FileUtils.rm_f(path) }
102
+ end
103
+
104
+ def run(*command)
105
+ raise "command failed: #{command.join(' ')}" unless system(*command)
106
+ end
107
+
108
+ def capture(*command)
109
+ IO.popen(command, &:read)
110
+ end
111
+ end
112
+ end
113
+
114
+ desc 'Clone and verify the pinned Khronos glTF sample assets'
115
+ task sample_assets: 'sample_assets:fetch'
metadata ADDED
@@ -0,0 +1,104 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rgltf
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Yudai Takada
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Loads and writes glTF 2.0 and GLB with GPU-ready packed binary accessors.
13
+ email:
14
+ - t.yudai92@gmail.com
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - LICENSE.txt
20
+ - README.md
21
+ - Rakefile
22
+ - benchmark/accessors.rb
23
+ - benchmark/compare.rb
24
+ - benchmark/sample_assets.rb
25
+ - examples/README.md
26
+ - examples/build_triangle.rb
27
+ - examples/convert.rb
28
+ - examples/inspect.rb
29
+ - lib/rgltf.rb
30
+ - lib/rgltf/accessor_reader.rb
31
+ - lib/rgltf/buffer_resolver.rb
32
+ - lib/rgltf/builder.rb
33
+ - lib/rgltf/builder/accessor_data.rb
34
+ - lib/rgltf/builder/accessors.rb
35
+ - lib/rgltf/builder/component_builders.rb
36
+ - lib/rgltf/builder/document_properties.rb
37
+ - lib/rgltf/builder/materials.rb
38
+ - lib/rgltf/document.rb
39
+ - lib/rgltf/errors.rb
40
+ - lib/rgltf/extension.rb
41
+ - lib/rgltf/extensions/khr_lights_punctual.rb
42
+ - lib/rgltf/extensions/khr_materials_emissive_strength.rb
43
+ - lib/rgltf/extensions/khr_materials_unlit.rb
44
+ - lib/rgltf/extensions/khr_texture_transform.rb
45
+ - lib/rgltf/glb.rb
46
+ - lib/rgltf/properties.rb
47
+ - lib/rgltf/properties/accessor.rb
48
+ - lib/rgltf/properties/animation.rb
49
+ - lib/rgltf/properties/asset.rb
50
+ - lib/rgltf/properties/base.rb
51
+ - lib/rgltf/properties/buffer.rb
52
+ - lib/rgltf/properties/buffer_view.rb
53
+ - lib/rgltf/properties/camera.rb
54
+ - lib/rgltf/properties/image.rb
55
+ - lib/rgltf/properties/material.rb
56
+ - lib/rgltf/properties/mesh.rb
57
+ - lib/rgltf/properties/node.rb
58
+ - lib/rgltf/properties/sampler.rb
59
+ - lib/rgltf/properties/scene.rb
60
+ - lib/rgltf/properties/skin.rb
61
+ - lib/rgltf/properties/texture.rb
62
+ - lib/rgltf/validation/accessors.rb
63
+ - lib/rgltf/validation/animations.rb
64
+ - lib/rgltf/validation/buffers.rb
65
+ - lib/rgltf/validation/cameras.rb
66
+ - lib/rgltf/validation/context.rb
67
+ - lib/rgltf/validation/images_materials.rb
68
+ - lib/rgltf/validation/meshes.rb
69
+ - lib/rgltf/validation/root_asset_extensions.rb
70
+ - lib/rgltf/validation/scenes_nodes.rb
71
+ - lib/rgltf/validation/skins.rb
72
+ - lib/rgltf/validator.rb
73
+ - lib/rgltf/version.rb
74
+ - lib/rgltf/writer.rb
75
+ - lib/rgltf/writer/buffer_merger.rb
76
+ - lib/rgltf/writer/default_omitter.rb
77
+ - lib/rgltf/writer/extension_serializer.rb
78
+ - tasks/sample_assets.rake
79
+ homepage: https://github.com/ydah/rgltf
80
+ licenses:
81
+ - MIT
82
+ metadata:
83
+ homepage_uri: https://github.com/ydah/rgltf
84
+ source_code_uri: https://github.com/ydah/rgltf
85
+ changelog_uri: https://github.com/ydah/rgltf/releases
86
+ rubygems_mfa_required: 'true'
87
+ rdoc_options: []
88
+ require_paths:
89
+ - lib
90
+ required_ruby_version: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - ">="
93
+ - !ruby/object:Gem::Version
94
+ version: 3.1.0
95
+ required_rubygems_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: '0'
100
+ requirements: []
101
+ rubygems_version: 4.0.6
102
+ specification_version: 4
103
+ summary: Pure Ruby glTF 2.0 loader and writer
104
+ test_files: []