three-rb 0.1.0 → 0.2.1

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -1
  3. data/README.md +66 -3
  4. data/docs/browser-runtime.md +92 -24
  5. data/docs/loaded-assets-design.md +1 -1
  6. data/docs/next-work.md +9 -5
  7. data/docs/publishing.md +119 -23
  8. data/docs/release-readiness.md +5 -3
  9. data/docs/standalone-browser-app.md +106 -0
  10. data/examples/browser/README.md +8 -0
  11. data/examples/browser/composition/main.rb +44 -64
  12. data/examples/browser/cube/main.rb +4 -34
  13. data/examples/browser/cubemap/assets/checker.svg +11 -0
  14. data/examples/browser/cubemap/main.rb +17 -40
  15. data/examples/browser/gltf/main.rb +30 -50
  16. data/examples/browser/picking/main.rb +27 -53
  17. data/examples/browser/postprocessing/main.rb +23 -42
  18. data/examples/browser/primitives/assets/checker.svg +11 -0
  19. data/examples/browser/primitives/main.rb +19 -42
  20. data/examples/browser/ruby/README.md +24 -0
  21. data/examples/browser/ruby/boot.mjs +6 -0
  22. data/examples/browser/ruby/index.html +142 -0
  23. data/examples/browser/ruby/main.rb +313 -0
  24. data/examples/browser/ruby/smoke_test.mjs +126 -0
  25. data/examples/browser/serialization/assets/checker.svg +11 -0
  26. data/examples/browser/serialization/main.rb +20 -42
  27. data/examples/browser/shared/boot.mjs +37 -5
  28. data/examples/browser/textures/assets/checker.svg +11 -0
  29. data/examples/browser/textures/assets/studio.hdr +5 -0
  30. data/examples/browser/textures/main.rb +23 -41
  31. data/exe/three-rb +56 -0
  32. data/lib/three/backends/threejs/materialization.rb +6 -0
  33. data/lib/three/backends/threejs/parameters.rb +17 -0
  34. data/lib/three/backends/threejs/ruby_wasm_adapter.rb +166 -59
  35. data/lib/three/backends/threejs/synchronization.rb +38 -4
  36. data/lib/three/backends/threejs.rb +24 -0
  37. data/lib/three/browser.rb +389 -0
  38. data/lib/three/constants.rb +6 -0
  39. data/lib/three/core/buffer_attribute.rb +5 -1
  40. data/lib/three/core/buffer_geometry.rb +29 -1
  41. data/lib/three/core/object3d.rb +39 -1
  42. data/lib/three/exporters/three_json_exporter.rb +3 -0
  43. data/lib/three/generators/browser_example.rb +396 -0
  44. data/lib/three/geometries/text_geometry.rb +41 -0
  45. data/lib/three/loaders/font_loader.rb +29 -0
  46. data/lib/three/loaders/three_json_loader.rb +92 -46
  47. data/lib/three/materials/material.rb +2 -1
  48. data/lib/three/math/matrix4.rb +27 -0
  49. data/lib/three/renderers/threejs_renderer.rb +19 -0
  50. data/lib/three/scenes/fog.rb +86 -0
  51. data/lib/three/scenes/scene.rb +19 -1
  52. data/lib/three/textures/texture.rb +2 -1
  53. data/lib/three/version.rb +1 -1
  54. data/lib/three.rb +4 -0
  55. data/package.json +2 -1
  56. metadata +26 -8
  57. /data/examples/browser/{assets → composition/assets}/checker.svg +0 -0
  58. /data/examples/browser/{assets → gltf/assets}/animated_triangle.gltf +0 -0
  59. /data/examples/browser/{assets → gltf/assets}/compressed_triangle.gltf +0 -0
  60. /data/examples/browser/{assets → gltf/assets}/triangle.gltf +0 -0
  61. /data/examples/browser/{assets → ruby/assets}/studio.hdr +0 -0
@@ -0,0 +1,389 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Three
4
+ module Browser
5
+ class Error < RuntimeError; end
6
+
7
+ class Element
8
+ attr_reader :handle
9
+
10
+ def initialize(handle)
11
+ @handle = handle
12
+ end
13
+
14
+ def self.wrap(value)
15
+ value.is_a?(self) ? value : new(value)
16
+ end
17
+
18
+ def client_width
19
+ [@handle[:clientWidth].to_i, 1].max
20
+ end
21
+
22
+ def client_height
23
+ [@handle[:clientHeight].to_i, 1].max
24
+ end
25
+
26
+ def size
27
+ [client_width, client_height]
28
+ end
29
+
30
+ def add_event_listener(name, &block)
31
+ @handle.call(:addEventListener, name.to_s, block)
32
+ self
33
+ end
34
+
35
+ alias on add_event_listener
36
+
37
+ def bounding_client_rect
38
+ rect = @handle.call(:getBoundingClientRect)
39
+ {
40
+ left: rect[:left].to_f,
41
+ top: rect[:top].to_f,
42
+ width: rect[:width].to_f,
43
+ height: rect[:height].to_f
44
+ }
45
+ end
46
+
47
+ def pointer_ndc(event)
48
+ rect = bounding_client_rect
49
+ x = ((event[:clientX].to_f - rect[:left]) / rect[:width]) * 2 - 1
50
+ y = -(((event[:clientY].to_f - rect[:top]) / rect[:height]) * 2 - 1)
51
+ [x, y]
52
+ end
53
+ end
54
+
55
+ class Storage
56
+ attr_reader :handle
57
+
58
+ def initialize(handle)
59
+ @handle = handle
60
+ end
61
+
62
+ def set(key, value)
63
+ @handle.call(:setItem, key.to_s, value)
64
+ self
65
+ end
66
+
67
+ def get(key)
68
+ @handle.call(:getItem, key.to_s)
69
+ end
70
+
71
+ def delete(key)
72
+ @handle.call(:removeItem, key.to_s)
73
+ self
74
+ end
75
+
76
+ def clear
77
+ @handle.call(:clear)
78
+ self
79
+ end
80
+
81
+ def length
82
+ @handle[:length].to_i
83
+ end
84
+
85
+ def key(index)
86
+ @handle.call(:key, index)
87
+ end
88
+ end
89
+
90
+ class Application
91
+ attr_reader :document, :window
92
+
93
+ def initialize(starting: nil, status_selector: "#status", status_dot_selector: "#status-dot")
94
+ Browser.ready!
95
+ @document = Browser.document
96
+ @window = Browser.window
97
+ @status = status_selector ? query(status_selector) : nil
98
+ @status_dot = status_dot_selector ? query(status_dot_selector) : nil
99
+ status(starting, state: "loading") if starting
100
+ end
101
+
102
+ def query(selector)
103
+ Element.new(@document.call(:querySelector, selector))
104
+ end
105
+
106
+ def element(handle)
107
+ Element.wrap(handle)
108
+ end
109
+
110
+ def status(message, state: nil)
111
+ setter = Browser.global[:__threeRbSetStatus]
112
+ if js_function?(setter)
113
+ Browser.global.call(:__threeRbSetStatus, message, state)
114
+ else
115
+ @status.handle[:textContent] = message if @status
116
+ @status_dot.handle[:dataset][:state] = state if @status_dot && state
117
+ end
118
+ self
119
+ end
120
+
121
+ def running!
122
+ status("Running", state: "running")
123
+ end
124
+
125
+ def boot_failed(message)
126
+ failure = Browser.global[:__threeRbBootFailed]
127
+ if js_function?(failure)
128
+ Browser.global.call(:__threeRbBootFailed, message)
129
+ else
130
+ status(message, state: "error")
131
+ end
132
+ end
133
+
134
+ def on_resize(viewport: "#viewport", &block)
135
+ raise ArgumentError, "block is required" unless block
136
+
137
+ viewport_element = viewport.is_a?(Element) ? viewport : query(viewport)
138
+ callback = proc do
139
+ width, height = viewport_element.size
140
+ block.call(width, height, width.to_f / height)
141
+ end
142
+
143
+ callback.call
144
+ @window.call(:addEventListener, "resize", callback)
145
+ callback
146
+ end
147
+
148
+ def on_key(name = "keydown", key: nil, target: nil, &block)
149
+ raise ArgumentError, "block is required" unless block
150
+
151
+ element = event_target(target || @window)
152
+ callback = proc do |event|
153
+ next if key && event[:key].to_s != key.to_s
154
+
155
+ block.call(event)
156
+ end
157
+ element.on(name, &callback)
158
+ callback
159
+ end
160
+
161
+ def on_pointer(name = "pointermove", target: "#viewport", &block)
162
+ raise ArgumentError, "block is required" unless block
163
+
164
+ element = event_target(target)
165
+ callback = proc do |event|
166
+ block.call(event, *element.pointer_ndc(event))
167
+ end
168
+ element.on(name, &callback)
169
+ callback
170
+ end
171
+
172
+ def pointer_ndc(event, target: "#viewport")
173
+ event_target(target).pointer_ndc(event)
174
+ end
175
+
176
+ def storage(kind = :local)
177
+ handle =
178
+ case kind
179
+ when :local, "local" then Browser.global[:localStorage]
180
+ when :session, "session" then Browser.global[:sessionStorage]
181
+ else kind
182
+ end
183
+
184
+ Storage.new(handle)
185
+ end
186
+
187
+ def animation_loop(renderer = nil, &block)
188
+ raise ArgumentError, "block is required" unless block
189
+
190
+ return renderer.animation_loop(&block) if renderer
191
+
192
+ callback = nil
193
+ callback = proc do |time|
194
+ block.call(time)
195
+ @window.call(:requestAnimationFrame, callback)
196
+ end
197
+ @window.call(:requestAnimationFrame, callback)
198
+ callback
199
+ end
200
+
201
+ def resize_renderer(renderer, camera, viewport: "#viewport")
202
+ on_resize(viewport: viewport) do |width, height, aspect|
203
+ if block_given?
204
+ yield(width, height, aspect)
205
+ elsif camera.respond_to?(:aspect=)
206
+ camera.aspect = aspect
207
+ camera.update_projection_matrix if camera.respond_to?(:update_projection_matrix)
208
+ end
209
+ renderer.set_size(width, height)
210
+ end
211
+ end
212
+
213
+ def expose(values, renderer: nil, prefix: "__threeRb")
214
+ values.each do |name, value|
215
+ set(name, value, renderer: renderer, prefix: prefix)
216
+ end
217
+ self
218
+ end
219
+
220
+ def set(name, value, renderer: nil, prefix: "__threeRb")
221
+ Browser.global[global_name(name, prefix)] = exposed_value(value, renderer)
222
+ self
223
+ end
224
+
225
+ def get(name, prefix: "__threeRb")
226
+ Browser.global[global_name(name, prefix)]
227
+ end
228
+
229
+ def increment(name, by: 1, prefix: "__threeRb")
230
+ value = get(name, prefix: prefix).to_i + by
231
+ set(name, value, prefix: prefix)
232
+ value
233
+ end
234
+
235
+ private
236
+
237
+ def event_target(target)
238
+ case target
239
+ when Element then target
240
+ when String, Symbol then query(target.to_s)
241
+ else Element.wrap(target)
242
+ end
243
+ end
244
+
245
+ def js_function?(value)
246
+ value.respond_to?(:typeof) && value.typeof == "function"
247
+ end
248
+
249
+ def camelize(value)
250
+ value.to_s.split("_").map(&:capitalize).join
251
+ end
252
+
253
+ def global_name(name, prefix)
254
+ :"#{prefix}#{camelize(name)}"
255
+ end
256
+
257
+ def exposed_value(value, renderer)
258
+ if value.is_a?(Array)
259
+ js_array(value, renderer)
260
+ elsif renderer && materializable?(value)
261
+ renderer.backend.materialize(value)
262
+ elsif value.respond_to?(:handle)
263
+ value.handle
264
+ else
265
+ value
266
+ end
267
+ end
268
+
269
+ def js_array(values, renderer)
270
+ array = Browser.global[:Array].new
271
+ values.each { |value| array.call(:push, exposed_value(value, renderer)) }
272
+ array
273
+ end
274
+
275
+ def materializable?(value)
276
+ value.respond_to?(:uuid) || value.respond_to?(:source) || value.respond_to?(:sources)
277
+ end
278
+ end
279
+
280
+ class << self
281
+ def run(**options)
282
+ app = nil
283
+ begin
284
+ install_runtime_extensions
285
+ app = Application.new(**options)
286
+ result = yield app
287
+ app.running!
288
+ result
289
+ rescue StandardError => error
290
+ app ? app.boot_failed(error.message) : notify_boot_failed(error.message)
291
+ raise
292
+ end
293
+ end
294
+
295
+ def ready!
296
+ ready = global[:__threeReady]
297
+ ready.await if ready.respond_to?(:await)
298
+ self
299
+ end
300
+
301
+ def document
302
+ global[:document]
303
+ end
304
+
305
+ def window
306
+ global[:window]
307
+ end
308
+
309
+ def js
310
+ require "js"
311
+ JS.global
312
+ rescue LoadError
313
+ raise Error, "Three::Browser.js requires ruby.wasm's js gem"
314
+ end
315
+
316
+ def global
317
+ js
318
+ end
319
+
320
+ private
321
+
322
+ def install_runtime_extensions
323
+ install_renderer_extensions
324
+ install_backend_extensions
325
+ install_adapter_extensions
326
+ end
327
+
328
+ def install_renderer_extensions
329
+ renderer = Three::Renderers::ThreeJSRenderer
330
+ unless renderer.method_defined?(:on_dispose)
331
+ renderer.class_eval do
332
+ def on_dispose(object, &block)
333
+ @backend.add_event_listener(object, :dispose, block)
334
+ self
335
+ end
336
+ end
337
+ end
338
+
339
+ return if renderer.method_defined?(:cached?)
340
+
341
+ renderer.class_eval do
342
+ def cached?(object)
343
+ @backend.cached?(object)
344
+ end
345
+ end
346
+ end
347
+
348
+ def install_backend_extensions
349
+ backend = Three::Backends::ThreeJS
350
+ unless backend.method_defined?(:add_event_listener)
351
+ backend.class_eval do
352
+ def add_event_listener(object, type, callback)
353
+ raise ArgumentError, "callback is required" unless callback
354
+
355
+ @adapter.add_event_listener(materialize(object), type, callback)
356
+ end
357
+ end
358
+ end
359
+
360
+ return if backend.method_defined?(:cached?)
361
+
362
+ backend.class_eval do
363
+ def cached?(object)
364
+ key = cache_key(object)
365
+ key ? @handles.key?(key) : false
366
+ end
367
+ end
368
+ end
369
+
370
+ def install_adapter_extensions
371
+ adapter = Three::Backends::ThreeJS::RubyWasmAdapter
372
+ return if adapter.method_defined?(:add_event_listener)
373
+
374
+ adapter.class_eval do
375
+ def add_event_listener(handle, type, callback)
376
+ handle.call(:addEventListener, type.to_s, callback)
377
+ end
378
+ end
379
+ end
380
+
381
+ def notify_boot_failed(message)
382
+ failure = global[:__threeRbBootFailed]
383
+ global.call(:__threeRbBootFailed, message) if failure.respond_to?(:typeof) && failure.typeof == "function"
384
+ rescue Error
385
+ nil
386
+ end
387
+ end
388
+ end
389
+ end
@@ -37,4 +37,10 @@ module Three
37
37
  PCFShadowMap = 1
38
38
  PCFSoftShadowMap = 2
39
39
  VSMShadowMap = 3
40
+
41
+ NoToneMapping = 0
42
+ LinearToneMapping = 1
43
+ ReinhardToneMapping = 2
44
+ CineonToneMapping = 3
45
+ ACESFilmicToneMapping = 4
40
46
  end
@@ -84,7 +84,11 @@ module Three
84
84
  end
85
85
 
86
86
  def clone
87
- self.class.new(@array, @item_size, @normalized)
87
+ if instance_of?(BufferAttribute)
88
+ self.class.new(@array, @item_size, @normalized, component_type: @component_type)
89
+ else
90
+ self.class.new(@array, @item_size, @normalized)
91
+ end
88
92
  end
89
93
 
90
94
  def get_component(index, component)
@@ -99,6 +99,18 @@ module Three
99
99
  self
100
100
  end
101
101
 
102
+ def center
103
+ @centered = true
104
+ mark_dirty!(:geometry_operations)
105
+ self
106
+ end
107
+
108
+ alias center! center
109
+
110
+ def centered?
111
+ @centered == true
112
+ end
113
+
102
114
  def compute_bounding_box
103
115
  position = get_attribute(:position)
104
116
  @bounding_box = nil
@@ -153,8 +165,10 @@ module Three
153
165
  index: @index&.to_h,
154
166
  attributes: @attributes.transform_values(&:to_h),
155
167
  groups: @groups.map(&:dup),
168
+ draw_range: serialize_draw_range(@draw_range),
156
169
  bounding_box: serialize_bounds(@bounding_box),
157
- bounding_sphere: serialize_sphere(@bounding_sphere)
170
+ bounding_sphere: serialize_sphere(@bounding_sphere),
171
+ user_data: @user_data
158
172
  }
159
173
  end
160
174
 
@@ -172,6 +186,20 @@ module Three
172
186
  { center: sphere[:center].to_a, radius: sphere[:radius] }
173
187
  end
174
188
 
189
+ def serialize_draw_range(draw_range)
190
+ {
191
+ start: serialize_number(draw_range[:start]),
192
+ count: serialize_number(draw_range[:count])
193
+ }
194
+ end
195
+
196
+ def serialize_number(value)
197
+ return "Infinity" if value == Float::INFINITY
198
+ return "-Infinity" if value == -Float::INFINITY
199
+
200
+ value
201
+ end
202
+
175
203
  def bind_attribute_change(attribute)
176
204
  return unless attribute.respond_to?(:on)
177
205
 
@@ -27,8 +27,9 @@ module Three
27
27
  attr_reader :layers
28
28
  attr_reader :position, :rotation, :quaternion, :scale
29
29
  attr_reader :matrix, :matrix_world
30
+ attr_reader :matrix_auto_update
30
31
  attr_reader :name, :type, :up, :visible, :cast_shadow, :receive_shadow
31
- attr_accessor :matrix_auto_update, :matrix_world_auto_update, :matrix_world_needs_update
32
+ attr_accessor :matrix_world_auto_update, :matrix_world_needs_update
32
33
  attr_accessor :user_data
33
34
 
34
35
  def initialize
@@ -52,6 +53,7 @@ module Three
52
53
  @matrix_auto_update = DEFAULT_MATRIX_AUTO_UPDATE
53
54
  @matrix_world_auto_update = DEFAULT_MATRIX_WORLD_AUTO_UPDATE
54
55
  @matrix_world_needs_update = false
56
+ @suppress_matrix_change = false
55
57
  @visible = true
56
58
  @cast_shadow = false
57
59
  @receive_shadow = false
@@ -59,6 +61,7 @@ module Three
59
61
 
60
62
  bind_rotation_and_quaternion
61
63
  bind_transform_changes
64
+ bind_matrix_changes
62
65
  bind_layer_changes
63
66
  mark_dirty!
64
67
  end
@@ -93,6 +96,18 @@ module Three
93
96
  mark_dirty!(:properties)
94
97
  end
95
98
 
99
+ def matrix=(value)
100
+ @matrix = coerce_matrix4(value)
101
+ bind_matrix_changes
102
+ @matrix_world_needs_update = true
103
+ mark_dirty!(:transform)
104
+ end
105
+
106
+ def matrix_auto_update=(value)
107
+ @matrix_auto_update = value
108
+ mark_dirty!(:transform)
109
+ end
110
+
96
111
  def mark_dirty!(field = :all)
97
112
  super
98
113
  @parent&.mark_descendant_dirty!
@@ -188,9 +203,13 @@ module Three
188
203
  end
189
204
 
190
205
  def update_matrix
206
+ @suppress_matrix_change = true
191
207
  @matrix.compose(@position, @quaternion, @scale)
208
+ @suppress_matrix_change = false
192
209
  @matrix_world_needs_update = true
193
210
  self
211
+ ensure
212
+ @suppress_matrix_change = false
194
213
  end
195
214
 
196
215
  def update_matrix_world(force = false)
@@ -324,8 +343,27 @@ module Three
324
343
  end
325
344
  end
326
345
 
346
+ def bind_matrix_changes
347
+ @matrix.on_change do
348
+ next if @suppress_matrix_change
349
+
350
+ @matrix_world_needs_update = true
351
+ mark_dirty!(:transform)
352
+ end
353
+ end
354
+
327
355
  def bind_layer_changes
328
356
  @layers.on_change { mark_dirty!(:properties) }
329
357
  end
358
+
359
+ def coerce_matrix4(value)
360
+ return value if value.is_a?(Matrix4)
361
+
362
+ array = value.to_ary if value.respond_to?(:to_ary)
363
+ array ||= value.to_a if value.respond_to?(:to_a)
364
+ return Matrix4.new.from_array(array) if array && array.length >= 16
365
+
366
+ raise TypeError, "matrix must be a Three::Matrix4 or an array-like with 16 elements"
367
+ end
330
368
  end
331
369
  end
@@ -58,6 +58,7 @@ module Three
58
58
  user_data: object.user_data,
59
59
  children: object.children.map { |child| serialize_object(child) }
60
60
  }
61
+ data[:matrix] = object.matrix.to_a unless object.matrix_auto_update
61
62
 
62
63
  serialize_scene(object, data) if object.is_a?(Scene)
63
64
  serialize_camera(object, data) if object.is_a?(Camera)
@@ -71,6 +72,8 @@ module Three
71
72
  def serialize_scene(scene, data)
72
73
  data[:background] = serialize_texture_reference(scene.background)
73
74
  data[:environment] = serialize_texture_reference(scene.environment)
75
+ data[:fog] = scene.fog.to_h if scene.fog
76
+ data[:override_material] = register_material(scene.override_material) if scene.override_material
74
77
  end
75
78
 
76
79
  def serialize_camera(camera, data)