assimp-ruby 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a5462e51936b49a449b8092dbc3245eae7470f526941d28adf38edacc724c8d8
4
+ data.tar.gz: 4198ab6d851dfc478bd72af3db20acec391cb93c0f394ca9c42a01d842796f84
5
+ SHA512:
6
+ metadata.gz: 7c4fae0a2dc08713fce49dc09b071f69d5ccd8db526af3974082fc7da1ccb111d9ecffbf0c87daf7579578a78267d36784f6242f82ab1812ae7e05907e1b6900
7
+ data.tar.gz: 26d77fd5e810ffad4d6a17ba50626fd53867c08c4b8f4854dc977f8008f2dcb77ad9277538ede0ea73cbe9a368da34b399c694bdad6c83b3e5ca5ec1635528e5
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,170 @@
1
+ # assimp-ruby
2
+
3
+ Ruby bindings for [Assimp](https://github.com/assimp/assimp), providing one
4
+ import API for OBJ, FBX, STL, PLY, DAE, 3DS, glTF, and the other formats
5
+ supported by Assimp.
6
+
7
+ `Assimp.import` copies native scene data into packed Ruby strings and immutable
8
+ Ruby objects, then immediately calls `aiReleaseImport`. Returned scenes do not
9
+ retain pointers or other native resources.
10
+
11
+ ## Requirements
12
+
13
+ - Ruby 3.2 or newer
14
+ - `ffi` 1.17
15
+
16
+ Install the gem:
17
+
18
+ ```sh
19
+ bundle add assimp-ruby
20
+ ```
21
+
22
+ Platform gems bundle Assimp 5.4.3 for x86-64 and ARM64 Linux, Intel and Apple
23
+ Silicon macOS, and x64 Windows. The source gem remains available for other
24
+ platforms and searches the system for `assimp`, `libassimp.so.5`, or
25
+ `libassimp.5.dylib`. Set an explicit path when Assimp is installed elsewhere:
26
+
27
+ ```sh
28
+ export ASSIMP_LIBRARY_PATH=/path/to/libassimp.so.5
29
+ ```
30
+
31
+ The major version is checked when the first import runs. Assimp 6.x is rejected
32
+ because its ABI differs from the 5.x ABI.
33
+
34
+ ## Importing
35
+
36
+ ```ruby
37
+ require "assimp"
38
+
39
+ scene = Assimp.import("model.fbx")
40
+ scene = Assimp.import(
41
+ "model.obj",
42
+ process: %i[triangulate gen_smooth_normals flip_uvs]
43
+ )
44
+
45
+ bytes = File.binread("mesh.stl")
46
+ scene = Assimp.import(bytes, hint: "stl")
47
+ ```
48
+
49
+ Memory and IO inputs require an extension hint. File imports should be used
50
+ when a format needs to resolve external resources such as MTL files or images.
51
+
52
+ The post-process presets are:
53
+
54
+ - `:realtime` (default): triangulation, vertex joining, smooth normals,
55
+ tangents, bone-weight limiting, cache optimization, and primitive sorting
56
+ - `:fast`: triangulation, vertex joining, and generated normals
57
+ - `:none`: no post-processing
58
+
59
+ Every key in `Assimp::POST_PROCESS` can also be passed in an explicit array.
60
+
61
+ ## Scene data
62
+
63
+ ```ruby
64
+ scene.meshes
65
+ scene.materials
66
+ scene.root
67
+ scene.animations
68
+ scene.textures
69
+ scene.incomplete?
70
+
71
+ scene.each_mesh_instance do |mesh, world_matrix|
72
+ # world_matrix is a 16-element, column-major array
73
+ end
74
+ ```
75
+
76
+ Mesh attributes are little-endian packed strings:
77
+
78
+ - `positions`, `normals`: `f32x3`
79
+ - `tangents`: `f32x4`, including handedness in `w`
80
+ - `uv_sets`: `f32x2`
81
+ - `colors`: `f32x4`
82
+ - `indices`: `u32`
83
+
84
+ ```ruby
85
+ mesh = scene.meshes.first
86
+ positions = mesh.positions.unpack("e*")
87
+ indices = mesh.indices.unpack("L<*")
88
+ mesh.aabb
89
+ mesh.bones
90
+ ```
91
+
92
+ Materials expose normalized common values and retain every native key-value
93
+ property as an escape hatch:
94
+
95
+ ```ruby
96
+ material = scene.materials.first
97
+ material.name
98
+ material.base_color
99
+ material.metallic
100
+ material.roughness
101
+ material.opacity
102
+ material.two_sided?
103
+
104
+ texture = material.texture(:diffuse)
105
+ texture.path
106
+ texture.embedded_index
107
+
108
+ material["$clr.diffuse"]
109
+ material["$tex.file", semantic: 1, index: 0]
110
+ ```
111
+
112
+ Animation values use `Assimp::Vec3` and `Assimp::Quat` (`x, y, z, w`):
113
+
114
+ ```ruby
115
+ animation = scene.animations.first
116
+ animation.duration_ticks
117
+ animation.effective_ticks_per_second
118
+ animation.channels.first.rotation_keys
119
+ ```
120
+
121
+ ## Embedded images
122
+
123
+ Compressed embedded images retain their encoded bytes and format hint. Raw
124
+ images retain Assimp's BGRA bytes together with their width and height.
125
+ Applications can pass this neutral data to an image library of their choice.
126
+
127
+ ## Raw mode
128
+
129
+ For very large files, block-scoped raw access avoids the Ruby copy:
130
+
131
+ ```ruby
132
+ Assimp.open("large.fbx") do |raw|
133
+ raw.native # guarded Assimp::Native::Scene view
134
+ raw.meshes # guarded non-owning FFI struct views
135
+ end
136
+ ```
137
+
138
+ All top-level raw views raise `Assimp::ReleasedError` when used after the block
139
+ exits. Nested native structs and pointers must not be retained beyond the block.
140
+
141
+ ## Development
142
+
143
+ ```sh
144
+ bundle install
145
+ bundle exec rake
146
+
147
+ ASSIMP_LIBRARY_PATH=/path/to/libassimp.so.5 bundle exec rspec
148
+ ASSIMP_LIBRARY_PATH=/path/to/libassimp.so.5 \
149
+ ASSIMP_STRESS=1 bundle exec rspec spec/stress_spec.rb
150
+ ```
151
+
152
+ The stress suite performs 1,000 imports under `GC.stress`. Test fixtures cover
153
+ OBJ, ASCII and binary STL, PLY, and FBX. Regenerate deterministic text/binary
154
+ fixtures with `bundle exec rake fixtures:generate`. ABI checks verify all
155
+ generated Assimp 5.4 struct layouts against a C compiler.
156
+
157
+ Build and smoke-test a native platform gem from Assimp 5.4.3:
158
+
159
+ ```sh
160
+ bundle exec rake native:smoke_gem
161
+ ```
162
+
163
+ Set `ASSIMP_SOURCE_DIR` to reuse an Assimp source checkout, or
164
+ `ASSIMP_LIBRARY_PATH` to package an already-built compatible shared library.
165
+ Tagged releases build and verify source plus platform gems for all five
166
+ supported platform/architecture combinations before publishing.
167
+
168
+ ## License
169
+
170
+ The gem is available under the [MIT License](LICENSE.txt).
data/Rakefile ADDED
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "fileutils"
5
+ require "open3"
6
+ require "rbconfig"
7
+ require "rspec/core/rake_task"
8
+ require "shellwords"
9
+
10
+ RSpec::Core::RakeTask.new(:spec)
11
+
12
+ namespace :fixtures do
13
+ desc "Generate deterministic OBJ, STL, and PLY fixtures"
14
+ task :generate do
15
+ require_relative "spec/support/fixture_builder"
16
+ FixtureBuilder.generate
17
+ end
18
+
19
+ desc "Fail if generated fixtures are stale"
20
+ task :check do
21
+ require_relative "spec/support/fixture_builder"
22
+ stale = FixtureBuilder.stale
23
+ abort "generated fixtures are stale: #{stale.join(", ")}" unless stale.empty?
24
+ end
25
+ end
26
+
27
+ namespace :generator do
28
+ desc "Regenerate Assimp FFI declarations and the native layout probe"
29
+ task :generate do
30
+ sh(RbConfig.ruby, File.join(__dir__, "generator", "generate.rb"))
31
+ end
32
+
33
+ desc "Fail if generated FFI declarations are stale"
34
+ task :check do
35
+ sh(RbConfig.ruby, File.join(__dir__, "generator", "generate.rb"), "--check")
36
+ end
37
+ end
38
+
39
+ namespace :native do
40
+ desc "Verify every generated FFI struct against the C compiler"
41
+ task verify_layout: "generator:check" do
42
+ include_dir = ENV.fetch("ASSIMP_INCLUDE_DIR")
43
+ config_include_dir = ENV.fetch("ASSIMP_CONFIG_INCLUDE_DIR", include_dir)
44
+ directory = File.join(__dir__, "tmp", "layout")
45
+ FileUtils.mkdir_p(directory)
46
+ executable = File.join(directory, "layout_probe#{RbConfig::CONFIG.fetch("EXEEXT")}")
47
+ compiler = Shellwords.split(ENV.fetch("CC", RbConfig::CONFIG.fetch("CC")))
48
+ sh(
49
+ *compiler,
50
+ "-std=c11",
51
+ "-I#{config_include_dir}",
52
+ "-I#{include_dir}",
53
+ File.join(__dir__, "generator", "layout_probe.c"),
54
+ "-o", executable
55
+ )
56
+ output, status = Open3.capture2e(executable)
57
+ raise "layout probe failed:\n#{output}" unless status.success?
58
+
59
+ output_path = File.join(directory, "layouts.tsv")
60
+ File.binwrite(output_path, output)
61
+ sh(RbConfig.ruby, File.join(__dir__, "generator", "verify_layout.rb"), output_path)
62
+ end
63
+ end
64
+
65
+ task spec: "fixtures:check"
66
+ task default: :spec
@@ -0,0 +1,297 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ffi/clang"
4
+ require "open3"
5
+
6
+ class BindingGenerator
7
+ ROOT = File.expand_path("..", __dir__)
8
+ RUBY_OUTPUT = File.join(ROOT, "lib", "assimp", "native", "generated.rb")
9
+ PROBE_OUTPUT = File.join(__dir__, "layout_probe.c")
10
+ HEADERS = %w[scene.h cimport.h material.h postprocess.h version.h].freeze
11
+ INTEGER_TYPES = {
12
+ type_char_u: :char,
13
+ type_char_s: :char,
14
+ type_schar: :int8,
15
+ type_uchar: :uchar,
16
+ type_short: :int16,
17
+ type_ushort: :uint16,
18
+ type_int: :int,
19
+ type_uint: :uint,
20
+ type_long: :long,
21
+ type_ulong: :ulong,
22
+ type_longlong: :int64,
23
+ type_ulonglong: :uint64
24
+ }.freeze
25
+ CLASS_NAMES = { "aiString" => "StringValue" }.freeze
26
+ FIELD_NAMES = {
27
+ ["aiTexture", "achFormatHint"] => "format_hint",
28
+ ["aiTexture", "pcData"] => "data"
29
+ }.freeze
30
+
31
+ def initialize
32
+ @include_dir = locate_include_dir
33
+ @config_include_dir = locate_config_include_dir
34
+ @translation_units = HEADERS.map { |header| parse(header) }
35
+ verify_supported_headers!
36
+ end
37
+
38
+ def run(check: false)
39
+ outputs = { RUBY_OUTPUT => render_ruby, PROBE_OUTPUT => render_probe }
40
+ if check
41
+ stale = outputs.filter_map { |path, content| path unless File.binread(path) == content }
42
+ abort "generated bindings are stale: #{stale.join(", ")}" unless stale.empty?
43
+ return
44
+ end
45
+
46
+ outputs.each { |path, content| File.binwrite(path, content) }
47
+ end
48
+
49
+ private
50
+
51
+ def locate_include_dir
52
+ explicit = ENV["ASSIMP_INCLUDE_DIR"]
53
+ candidates = [
54
+ explicit,
55
+ pkg_config_include_dir,
56
+ "/opt/homebrew/include",
57
+ "/usr/local/include",
58
+ "/usr/include"
59
+ ].compact
60
+ candidates.find { |directory| File.file?(File.join(directory, "assimp", "scene.h")) } ||
61
+ abort("Assimp headers were not found; set ASSIMP_INCLUDE_DIR")
62
+ end
63
+
64
+ def locate_config_include_dir
65
+ explicit = ENV["ASSIMP_CONFIG_INCLUDE_DIR"]
66
+ candidates = [explicit, @include_dir].compact
67
+ candidates.find { |directory| File.file?(File.join(directory, "assimp", "config.h")) } ||
68
+ abort("Assimp config.h was not found; set ASSIMP_CONFIG_INCLUDE_DIR")
69
+ end
70
+
71
+ def pkg_config_include_dir
72
+ output, status = Open3.capture2("pkg-config", "--variable=includedir", "assimp")
73
+ output.strip if status.success?
74
+ rescue Errno::ENOENT
75
+ nil
76
+ end
77
+
78
+ def parse(header)
79
+ path = File.join(@include_dir, "assimp", header)
80
+ unit = FFI::Clang::Index.new.parse_translation_unit(
81
+ path, ["-x", "c", "-I#{@config_include_dir}", "-I#{@include_dir}"]
82
+ )
83
+ errors = unit.diagnostics.select { |diagnostic| %i[error fatal].include?(diagnostic.severity) }
84
+ abort errors.map(&:spelling).join("\n") unless errors.empty?
85
+
86
+ unit
87
+ end
88
+
89
+ def declarations(kind)
90
+ @translation_units.flat_map do |unit|
91
+ unit.cursor.each.filter_map do |cursor, _parent|
92
+ next unless cursor.kind == kind
93
+ next unless cursor.location.file.to_s.start_with?(File.join(@include_dir, "assimp"))
94
+
95
+ cursor
96
+ end
97
+ end
98
+ end
99
+
100
+ def records
101
+ declarations(:cursor_struct)
102
+ .select { |cursor| cursor.definition? && cursor.spelling.start_with?("ai") }
103
+ .uniq(&:spelling)
104
+ end
105
+
106
+ def enums
107
+ declarations(:cursor_enum_decl)
108
+ .select { |cursor| cursor.definition? && cursor.spelling.start_with?("ai") }
109
+ .uniq(&:spelling)
110
+ end
111
+
112
+ def fields(record)
113
+ record.each(false).filter_map do |cursor, _parent|
114
+ cursor if cursor.kind == :cursor_field_decl
115
+ end
116
+ end
117
+
118
+ def enum_values(enum)
119
+ enum.each(false).filter_map do |cursor, _parent|
120
+ [cursor.spelling, cursor.enum_value] if cursor.kind == :cursor_enum_constant_decl
121
+ end
122
+ end
123
+
124
+ def sorted_records
125
+ remaining = records.to_h { |record| [record.spelling, record] }
126
+ sorted = []
127
+ until remaining.empty?
128
+ ready = remaining.values.select do |record|
129
+ record_dependencies(record).none? { |name| remaining.key?(name) }
130
+ end
131
+ abort "cyclic struct dependency: #{remaining.keys.join(", ")}" if ready.empty?
132
+
133
+ ready.sort_by(&:spelling).each do |record|
134
+ sorted << record
135
+ remaining.delete(record.spelling)
136
+ end
137
+ end
138
+ sorted
139
+ end
140
+
141
+ def record_dependencies(record)
142
+ fields(record).filter_map do |field|
143
+ type = field.type.canonical
144
+ type = type.element_type.canonical if type.kind == :type_constant_array
145
+ record_type_name(type) if type.kind == :type_record && record_type_name(type) != record.spelling
146
+ end.uniq
147
+ end
148
+
149
+ def verify_supported_headers!
150
+ record_names = records.map(&:spelling)
151
+ required_records = %w[aiScene aiMesh aiBone aiNode aiAnimation aiTexture]
152
+ missing = required_records - record_names
153
+ abort "incomplete Assimp headers: #{missing.join(", ")}" unless missing.empty?
154
+
155
+ quat_key = records.find { |record| record.spelling == "aiQuatKey" }
156
+ scene = records.find { |record| record.spelling == "aiScene" }
157
+ unless fields(quat_key).any? { |field| field.spelling == "mInterpolation" } &&
158
+ fields(scene).any? { |field| field.spelling == "mNumSkeletons" }
159
+ abort "Assimp 5.4 headers are required to regenerate bindings"
160
+ end
161
+ end
162
+
163
+ def render_ruby
164
+ lines = [
165
+ "# frozen_string_literal: true",
166
+ "",
167
+ "# Generated from Assimp 5.4 headers by generator/generate.rb.",
168
+ "# Do not edit this file directly.",
169
+ "",
170
+ "require \"ffi\"",
171
+ "",
172
+ "module Assimp",
173
+ " module Native"
174
+ ]
175
+ sorted_records.each { |record| render_record(lines, record) }
176
+ render_enums(lines)
177
+ render_layouts(lines)
178
+ lines.concat [" end", "end", ""]
179
+ lines.join("\n")
180
+ end
181
+
182
+ def render_record(lines, record)
183
+ lines << ""
184
+ lines << " class #{ruby_class_name(record.spelling)} < FFI::Struct"
185
+ layout = fields(record).flat_map do |field|
186
+ [":#{ruby_field_name(record.spelling, field.spelling)}", ruby_type(field.type, field: true)]
187
+ end
188
+ if layout.empty?
189
+ lines << " end"
190
+ return
191
+ end
192
+
193
+ lines << " layout #{layout.each_slice(2).map { |pair| pair.join(", ") }.join(",\n ")}"
194
+ lines << " end"
195
+ end
196
+
197
+ def render_enums(lines)
198
+ lines << ""
199
+ lines << " ENUMS = {"
200
+ enums.sort_by(&:spelling).each do |enum|
201
+ values = enum_values(enum).map { |name, value| "#{name.inspect} => #{value}" }
202
+ lines << " #{enum.spelling.inspect} => { #{values.join(", ")} }.freeze,"
203
+ end
204
+ lines << " }.freeze"
205
+ lines << ""
206
+ lines << " POST_PROCESS = {"
207
+ post_process = enums.find { |enum| enum.spelling == "aiPostProcessSteps" }
208
+ enum_values(post_process).each do |name, value|
209
+ ruby_name = snake_case(name.delete_prefix("aiProcess_"))
210
+ lines << " #{ruby_name}: #{format_hex(value & 0xffff_ffff)},"
211
+ end
212
+ lines << " }.freeze"
213
+ end
214
+
215
+ def render_layouts(lines)
216
+ lines << ""
217
+ lines << " HEADER_LAYOUTS = {"
218
+ sorted_records.each do |record|
219
+ offsets = fields(record).map do |field|
220
+ "#{ruby_field_name(record.spelling, field.spelling)}: #{field.offset_of_field / 8}"
221
+ end
222
+ lines << " #{record.spelling.inspect} => [#{record.type.sizeof}, { #{offsets.join(", ")} }.freeze],"
223
+ end
224
+ lines << " }.freeze"
225
+ end
226
+
227
+ def render_probe
228
+ lines = [
229
+ "/* Generated from Assimp 5.4 headers by generator/generate.rb. */",
230
+ "/* Do not edit this file directly. */",
231
+ "#include <stddef.h>",
232
+ "#include <stdio.h>",
233
+ "#include <assimp/cimport.h>",
234
+ "#include <assimp/scene.h>",
235
+ "",
236
+ "int main(void) {"
237
+ ]
238
+ sorted_records.each do |record|
239
+ lines << %( printf("S\\t#{record.spelling}\\t%zu\\n", sizeof(struct #{record.spelling}));)
240
+ fields(record).each do |field|
241
+ ruby_name = ruby_field_name(record.spelling, field.spelling)
242
+ lines << %( printf("F\\t#{record.spelling}\\t#{ruby_name}\\t%zu\\n", offsetof(struct #{record.spelling}, #{field.spelling}));)
243
+ end
244
+ end
245
+ lines.concat [" return 0;", "}", ""]
246
+ lines.join("\n")
247
+ end
248
+
249
+ def ruby_type(type, field: false)
250
+ canonical = type.canonical
251
+ kind = canonical.kind
252
+ return ":pointer" if kind == :type_pointer
253
+ return ":float" if kind == :type_float
254
+ return ":double" if kind == :type_double
255
+ return ":int" if kind == :type_enum
256
+ return INTEGER_TYPES.fetch(kind).inspect if INTEGER_TYPES.key?(kind)
257
+ return "#{ruby_class_name(record_type_name(canonical))}.by_value" if kind == :type_record
258
+ if kind == :type_constant_array
259
+ return "[#{ruby_type(canonical.element_type, field: true)}, #{canonical.size}]"
260
+ end
261
+
262
+ abort "unsupported C type: #{type.spelling} (#{kind}, field=#{field})"
263
+ end
264
+
265
+ def ruby_class_name(c_name)
266
+ CLASS_NAMES.fetch(c_name, c_name.delete_prefix("ai"))
267
+ end
268
+
269
+ def record_type_name(type)
270
+ type.spelling.delete_prefix("struct ")
271
+ end
272
+
273
+ def ruby_field_name(record_name, field_name)
274
+ special = FIELD_NAMES[[record_name, field_name]]
275
+ return special if special
276
+
277
+ normalized = field_name.sub(/\Am(?=[A-Z])/, "")
278
+ snake_case(normalized)
279
+ end
280
+
281
+ def snake_case(name)
282
+ name
283
+ .gsub("UV", "Uv")
284
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
285
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
286
+ .downcase
287
+ end
288
+
289
+ def format_hex(value)
290
+ return value.to_s if value < 16
291
+
292
+ groups = value.to_s(16).reverse.scan(/.{1,4}/).map(&:reverse).reverse
293
+ "0x#{groups.join("_")}"
294
+ end
295
+ end
296
+
297
+ BindingGenerator.new.run(check: ARGV.delete("--check"))