webxr 0.1.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 (54) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/CHANGELOG.md +7 -0
  4. data/README.md +138 -0
  5. data/Rakefile +21 -0
  6. data/examples/ar_demo.html +238 -0
  7. data/examples/ar_hit_test.rb +157 -0
  8. data/examples/basic_vr.rb +110 -0
  9. data/examples/controller_input.rb +91 -0
  10. data/examples/hand_tracking.rb +124 -0
  11. data/examples/hello_webxr.html +288 -0
  12. data/examples/inline_demo.html +261 -0
  13. data/examples/server.rb +34 -0
  14. data/examples/vr_scene_demo.html +330 -0
  15. data/lib/webxr/ar/anchor.rb +83 -0
  16. data/lib/webxr/ar/hit_test_result.rb +54 -0
  17. data/lib/webxr/ar/hit_test_source.rb +34 -0
  18. data/lib/webxr/ar/ray.rb +90 -0
  19. data/lib/webxr/constants.rb +61 -0
  20. data/lib/webxr/core/frame.rb +155 -0
  21. data/lib/webxr/core/render_state.rb +47 -0
  22. data/lib/webxr/core/session.rb +212 -0
  23. data/lib/webxr/core/system.rb +122 -0
  24. data/lib/webxr/errors.rb +18 -0
  25. data/lib/webxr/events/input_source_event.rb +53 -0
  26. data/lib/webxr/events/reference_space_event.rb +44 -0
  27. data/lib/webxr/events/session_event.rb +56 -0
  28. data/lib/webxr/geometry/pose.rb +49 -0
  29. data/lib/webxr/geometry/rigid_transform.rb +73 -0
  30. data/lib/webxr/geometry/view.rb +68 -0
  31. data/lib/webxr/geometry/viewer_pose.rb +40 -0
  32. data/lib/webxr/geometry/viewport.rb +55 -0
  33. data/lib/webxr/hand/hand.rb +197 -0
  34. data/lib/webxr/hand/joint_pose.rb +33 -0
  35. data/lib/webxr/hand/joint_space.rb +74 -0
  36. data/lib/webxr/helpers/input_helper.rb +142 -0
  37. data/lib/webxr/helpers/rendering_helper.rb +94 -0
  38. data/lib/webxr/helpers/session_manager.rb +105 -0
  39. data/lib/webxr/input/gamepad.rb +115 -0
  40. data/lib/webxr/input/gamepad_button.rb +36 -0
  41. data/lib/webxr/input/input_source.rb +101 -0
  42. data/lib/webxr/input/input_source_array.rb +86 -0
  43. data/lib/webxr/js_wrapper.rb +116 -0
  44. data/lib/webxr/layers/layer.rb +28 -0
  45. data/lib/webxr/layers/webgl_binding.rb +69 -0
  46. data/lib/webxr/layers/webgl_layer.rb +102 -0
  47. data/lib/webxr/layers/webgl_sub_image.rb +59 -0
  48. data/lib/webxr/spaces/bounded_reference_space.rb +43 -0
  49. data/lib/webxr/spaces/reference_space.rb +51 -0
  50. data/lib/webxr/spaces/space.rb +18 -0
  51. data/lib/webxr/version.rb +5 -0
  52. data/lib/webxr.rb +73 -0
  53. data/webxr.gemspec +33 -0
  54. metadata +111 -0
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebXR
4
+ module AR
5
+ # XRHitTestResult - Result of a hit test
6
+ class HitTestResult < JSWrapper
7
+ # @param js_result [JS::Object] The XRHitTestResult JavaScript object
8
+ def initialize(js_result)
9
+ super(js_result)
10
+ end
11
+
12
+ # Get the pose of the hit point relative to a base space
13
+ # @param base_space [Space] The base reference space
14
+ # @return [Pose, nil]
15
+ def pose(base_space)
16
+ js_pose = js_call(:getPose, base_space.js)
17
+ return nil if js_pose.nil?
18
+
19
+ Pose.new(js_pose)
20
+ end
21
+
22
+ # Create an anchor at this hit test result
23
+ # @return [Anchor]
24
+ def create_anchor
25
+ promise = js_call(:createAnchor)
26
+ js_anchor = js_await(promise)
27
+ Anchor.new(js_anchor)
28
+ end
29
+ end
30
+
31
+ # XRTransientInputHitTestResult - Hit test result for transient input
32
+ class TransientInputHitTestResult < JSWrapper
33
+ # @param js_result [JS::Object] The XRTransientInputHitTestResult JavaScript object
34
+ def initialize(js_result)
35
+ super(js_result)
36
+ end
37
+
38
+ # Get the input source that triggered the hit test
39
+ # @return [InputSource]
40
+ def input_source
41
+ InputSource.new(js_prop(:inputSource))
42
+ end
43
+
44
+ # Get all hit test results for this input
45
+ # @return [Array<HitTestResult>]
46
+ def results
47
+ js_results = js_prop(:results)
48
+ return [] if js_results.nil?
49
+
50
+ js_array_to_a(js_results).map { |r| HitTestResult.new(r) }
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebXR
4
+ module AR
5
+ # XRHitTestSource - Source for performing hit tests
6
+ class HitTestSource < JSWrapper
7
+ # @param js_hit_test_source [JS::Object] The XRHitTestSource JavaScript object
8
+ def initialize(js_hit_test_source)
9
+ super(js_hit_test_source)
10
+ end
11
+
12
+ # Cancel the hit test source
13
+ # Once cancelled, the source cannot be used for further hit tests
14
+ # @return [void]
15
+ def cancel
16
+ js_call(:cancel)
17
+ end
18
+ end
19
+
20
+ # XRTransientInputHitTestSource - Hit test source for transient input
21
+ class TransientInputHitTestSource < JSWrapper
22
+ # @param js_source [JS::Object] The XRTransientInputHitTestSource JavaScript object
23
+ def initialize(js_source)
24
+ super(js_source)
25
+ end
26
+
27
+ # Cancel the hit test source
28
+ # @return [void]
29
+ def cancel
30
+ js_call(:cancel)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebXR
4
+ module AR
5
+ # XRRay - Represents a ray in 3D space for hit testing
6
+ class Ray < JSWrapper
7
+ # Create a new ray
8
+ # @param js_ray [JS::Object, nil] Existing JS ray to wrap
9
+ # @param origin [Hash, nil] Origin point with :x, :y, :z, :w keys
10
+ # @param direction [Hash, nil] Direction vector with :x, :y, :z, :w keys
11
+ # @param transform [RigidTransform, nil] Transform to apply
12
+ def initialize(js_ray = nil, origin: nil, direction: nil, transform: nil)
13
+ if js_ray
14
+ super(js_ray)
15
+ else
16
+ js_origin = origin ? create_dom_point(origin) : nil
17
+ js_direction = direction ? create_dom_point(direction) : nil
18
+
19
+ if transform
20
+ js_obj = JS.global[:XRRay].new(transform.js)
21
+ elsif js_origin && js_direction
22
+ js_obj = JS.global[:XRRay].new(js_origin, js_direction)
23
+ elsif js_origin
24
+ js_obj = JS.global[:XRRay].new(js_origin)
25
+ else
26
+ js_obj = JS.global[:XRRay].new
27
+ end
28
+
29
+ super(js_obj)
30
+ end
31
+ end
32
+
33
+ # Get the origin point of the ray
34
+ # @return [Hash] Hash with :x, :y, :z, :w keys
35
+ def origin
36
+ o = js_prop(:origin)
37
+ {
38
+ x: o[:x].to_f,
39
+ y: o[:y].to_f,
40
+ z: o[:z].to_f,
41
+ w: o[:w].to_f
42
+ }
43
+ end
44
+
45
+ # Get the direction vector of the ray (normalized)
46
+ # @return [Hash] Hash with :x, :y, :z, :w keys
47
+ def direction
48
+ d = js_prop(:direction)
49
+ {
50
+ x: d[:x].to_f,
51
+ y: d[:y].to_f,
52
+ z: d[:z].to_f,
53
+ w: d[:w].to_f
54
+ }
55
+ end
56
+
57
+ # Get the 4x4 transformation matrix
58
+ # @return [Array<Float>] 16-element array in column-major order
59
+ def matrix
60
+ js_matrix = js_prop(:matrix)
61
+ js_array_to_a(js_matrix).map(&:to_f)
62
+ end
63
+
64
+ # Get the origin as a 3-element array [x, y, z]
65
+ # @return [Array<Float>]
66
+ def origin_array
67
+ o = origin
68
+ [o[:x], o[:y], o[:z]]
69
+ end
70
+
71
+ # Get the direction as a 3-element array [x, y, z]
72
+ # @return [Array<Float>]
73
+ def direction_array
74
+ d = direction
75
+ [d[:x], d[:y], d[:z]]
76
+ end
77
+
78
+ private
79
+
80
+ def create_dom_point(hash)
81
+ JS.global[:DOMPointReadOnly].new(
82
+ hash[:x] || 0,
83
+ hash[:y] || 0,
84
+ hash[:z] || (hash.key?(:z) ? hash[:z] : -1), # Default direction is -Z
85
+ hash[:w] || 1
86
+ )
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebXR
4
+ # XR session mode types
5
+ module SessionMode
6
+ INLINE = "inline"
7
+ IMMERSIVE_VR = "immersive-vr"
8
+ IMMERSIVE_AR = "immersive-ar"
9
+ end
10
+
11
+ # Reference space types
12
+ module ReferenceSpaceType
13
+ VIEWER = "viewer"
14
+ LOCAL = "local"
15
+ LOCAL_FLOOR = "local-floor"
16
+ BOUNDED_FLOOR = "bounded-floor"
17
+ UNBOUNDED = "unbounded"
18
+ end
19
+
20
+ # Session visibility states
21
+ module VisibilityState
22
+ VISIBLE = "visible"
23
+ VISIBLE_BLURRED = "visible-blurred"
24
+ HIDDEN = "hidden"
25
+ end
26
+
27
+ # Controller handedness
28
+ module Handedness
29
+ NONE = "none"
30
+ LEFT = "left"
31
+ RIGHT = "right"
32
+ end
33
+
34
+ # Input source target ray modes
35
+ module TargetRayMode
36
+ GAZE = "gaze"
37
+ TRACKED_POINTER = "tracked-pointer"
38
+ SCREEN = "screen"
39
+ TRANSIENT_POINTER = "transient-pointer"
40
+ end
41
+
42
+ # Environment blend modes (for AR)
43
+ module EnvironmentBlendMode
44
+ OPAQUE = "opaque"
45
+ ALPHA_BLEND = "alpha-blend"
46
+ ADDITIVE = "additive"
47
+ end
48
+
49
+ # Interaction modes
50
+ module InteractionMode
51
+ SCREEN_SPACE = "screen-space"
52
+ WORLD_SPACE = "world-space"
53
+ end
54
+
55
+ # Eye types for XRView
56
+ module Eye
57
+ NONE = "none"
58
+ LEFT = "left"
59
+ RIGHT = "right"
60
+ end
61
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebXR
4
+ # XRFrame - Represents a single frame of an XR session
5
+ # Contains pose and timing information for rendering
6
+ class Frame < JSWrapper
7
+ # @param js_frame [JS::Object] The XRFrame JavaScript object
8
+ def initialize(js_frame)
9
+ super(js_frame)
10
+ @active = true
11
+ @animation_frame = true
12
+ end
13
+
14
+ # Get the session this frame belongs to
15
+ # @return [Session]
16
+ def session
17
+ Session.new(js_prop(:session))
18
+ end
19
+
20
+ # Get the predicted display time
21
+ # @return [Float] Time in milliseconds
22
+ def predicted_display_time
23
+ js_float(:predictedDisplayTime) || 0.0
24
+ end
25
+
26
+ # Get the viewer pose relative to a reference space
27
+ # @param reference_space [ReferenceSpace] The reference space
28
+ # @return [ViewerPose, nil]
29
+ def viewer_pose(reference_space)
30
+ js_pose = js_call(:getViewerPose, reference_space.js)
31
+ return nil if js_pose.nil?
32
+
33
+ ViewerPose.new(js_pose)
34
+ end
35
+
36
+ # Get a pose relative to a base space
37
+ # @param space [Space] The space to get the pose for
38
+ # @param base_space [Space] The base reference space
39
+ # @return [Pose, nil]
40
+ def pose(space, base_space)
41
+ js_pose = js_call(:getPose, space.js, base_space.js)
42
+ return nil if js_pose.nil?
43
+
44
+ Pose.new(js_pose)
45
+ end
46
+
47
+ # Get a joint pose (for hand tracking)
48
+ # @param joint [Hand::JointSpace] The joint space
49
+ # @param base_space [Space] The base reference space
50
+ # @return [Hand::JointPose, nil]
51
+ def joint_pose(joint, base_space)
52
+ js_pose = js_call(:getJointPose, joint.js, base_space.js)
53
+ return nil if js_pose.nil?
54
+
55
+ Hand::JointPose.new(js_pose)
56
+ end
57
+
58
+ # Fill joint poses for an entire hand
59
+ # @param hand [Hand::Hand] The hand object
60
+ # @param base_space [Space] The base reference space
61
+ # @param joint_spaces [Array<Hand::JointSpace>] Array of joint spaces
62
+ # @param float32_array [JS::Object] Float32Array to fill
63
+ # @return [Boolean] True if all poses were filled
64
+ def fill_joint_poses(hand, base_space, joint_spaces, float32_array)
65
+ js_call(:fillJointPoses, joint_spaces.map(&:js), base_space.js, float32_array)
66
+ end
67
+
68
+ # Fill a transform array from poses
69
+ # @param poses [Array<Pose>] Array of poses
70
+ # @param float32_array [JS::Object] Float32Array to fill
71
+ # @return [Boolean] True if all transforms were filled
72
+ def fill_poses(poses, float32_array)
73
+ js_call(:fillPoses, poses.map(&:js), float32_array)
74
+ end
75
+
76
+ # Get hit test results
77
+ # @param hit_test_source [AR::HitTestSource] The hit test source
78
+ # @return [Array<AR::HitTestResult>]
79
+ def hit_test_results(hit_test_source)
80
+ js_results = js_call(:getHitTestResults, hit_test_source.js)
81
+ return [] if js_results.nil?
82
+
83
+ js_array_to_a(js_results).map { |r| AR::HitTestResult.new(r) }
84
+ end
85
+
86
+ # Get hit test results for transient input
87
+ # @param hit_test_source [AR::TransientInputHitTestSource] The hit test source
88
+ # @return [Array<AR::TransientInputHitTestResult>]
89
+ def hit_test_results_for_transient_input(hit_test_source)
90
+ js_results = js_call(:getHitTestResultsForTransientInput, hit_test_source.js)
91
+ return [] if js_results.nil?
92
+
93
+ js_array_to_a(js_results).map { |r| AR::TransientInputHitTestResult.new(r) }
94
+ end
95
+
96
+ # Create an anchor at a pose
97
+ # @param pose [RigidTransform] The transform for the anchor
98
+ # @param space [Space] The space for the anchor
99
+ # @return [AR::Anchor]
100
+ def create_anchor(pose, space)
101
+ promise = js_call(:createAnchor, pose.js, space.js)
102
+ js_anchor = js_await(promise)
103
+ AR::Anchor.new(js_anchor)
104
+ end
105
+
106
+ # Get tracked anchors
107
+ # @return [Set<AR::Anchor>]
108
+ def tracked_anchors
109
+ js_anchors = js_prop(:trackedAnchors)
110
+ return Set.new if js_anchors.nil?
111
+
112
+ anchors = Set.new
113
+ js_anchors.call(:forEach, ->(js_anchor) { anchors << AR::Anchor.new(js_anchor) })
114
+ anchors
115
+ end
116
+
117
+ # Get the light estimate
118
+ # @param light_probe [Lighting::Probe] The light probe
119
+ # @return [Lighting::Estimate, nil]
120
+ def light_estimate(light_probe)
121
+ js_estimate = js_call(:getLightEstimate, light_probe.js)
122
+ return nil if js_estimate.nil?
123
+
124
+ Lighting::Estimate.new(js_estimate)
125
+ end
126
+
127
+ # Get depth information
128
+ # @param view [View] The view to get depth for
129
+ # @return [Depth::Information, nil]
130
+ def depth_information(view)
131
+ js_info = js_call(:getDepthInformation, view.js)
132
+ return nil if js_info.nil?
133
+
134
+ Depth::Information.new(js_info)
135
+ end
136
+
137
+ # Check if this frame is still active
138
+ # @return [Boolean]
139
+ def active?
140
+ @active
141
+ end
142
+
143
+ # Check if this is an animation frame
144
+ # @return [Boolean]
145
+ def animation_frame?
146
+ @animation_frame
147
+ end
148
+
149
+ # Mark the frame as inactive (called after callback completes)
150
+ # @api private
151
+ def mark_inactive!
152
+ @active = false
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebXR
4
+ # XRRenderState - Contains render configuration for a session
5
+ class RenderState < JSWrapper
6
+ # @param js_render_state [JS::Object] The XRRenderState JavaScript object
7
+ def initialize(js_render_state)
8
+ super(js_render_state)
9
+ end
10
+
11
+ # Get the near depth plane distance
12
+ # @return [Float]
13
+ def depth_near
14
+ js_float(:depthNear) || 0.1
15
+ end
16
+
17
+ # Get the far depth plane distance
18
+ # @return [Float]
19
+ def depth_far
20
+ js_float(:depthFar) || 1000.0
21
+ end
22
+
23
+ # Get the inline vertical field of view (for inline sessions)
24
+ # @return [Float, nil] Field of view in radians
25
+ def inline_vertical_field_of_view
26
+ js_float(:inlineVerticalFieldOfView)
27
+ end
28
+
29
+ # Get the base XR layer
30
+ # @return [WebGLLayer, nil]
31
+ def base_layer
32
+ js_layer = js_prop(:baseLayer)
33
+ return nil if js_layer.nil?
34
+
35
+ WebGLLayer.wrap(js_layer)
36
+ end
37
+
38
+ # Get all layers (for multi-layer rendering)
39
+ # @return [Array<Layer>]
40
+ def layers
41
+ js_layers = js_prop(:layers)
42
+ return [] if js_layers.nil?
43
+
44
+ js_array_to_a(js_layers).map { |l| Layer.wrap(l) }
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebXR
4
+ # XRSession - Represents an XR session
5
+ # Manages the rendering loop and input sources
6
+ class Session < JSWrapper
7
+ # @return [String] The session mode ("inline", "immersive-vr", "immersive-ar")
8
+ attr_reader :mode
9
+
10
+ # Session event types
11
+ EVENTS = %i[
12
+ end
13
+ select
14
+ selectstart
15
+ selectend
16
+ squeeze
17
+ squeezestart
18
+ squeezeend
19
+ inputsourceschange
20
+ visibilitychange
21
+ frameratechange
22
+ ].freeze
23
+
24
+ # @param js_session [JS::Object] The XRSession JavaScript object
25
+ def initialize(js_session)
26
+ super(js_session)
27
+ @mode = js_string(:mode)
28
+ @render_state = nil
29
+ @callbacks = EVENTS.each_with_object({}) { |event, h| h[event] = [] }
30
+ @animation_frame_id = nil
31
+ end
32
+
33
+ # Get the current visibility state
34
+ # @return [String] "visible", "visible-blurred", or "hidden"
35
+ def visibility_state
36
+ js_string(:visibilityState)
37
+ end
38
+
39
+ # Get the current frame rate
40
+ # @return [Float, nil]
41
+ def frame_rate
42
+ js_float(:frameRate)
43
+ end
44
+
45
+ # Get the supported frame rates
46
+ # @return [Array<Float>, nil]
47
+ def supported_frame_rates
48
+ js_array = js_prop(:supportedFrameRates)
49
+ return nil if js_array.nil?
50
+
51
+ js_array_to_a(js_array).map(&:to_f)
52
+ end
53
+
54
+ # Get the current render state
55
+ # @return [RenderState]
56
+ def render_state
57
+ @render_state ||= RenderState.new(js_prop(:renderState))
58
+ end
59
+
60
+ # Get the current input sources
61
+ # @return [InputSourceArray]
62
+ def input_sources
63
+ InputSourceArray.new(js_prop(:inputSources))
64
+ end
65
+
66
+ # Get the enabled features for this session
67
+ # @return [Array<String>]
68
+ def enabled_features
69
+ js_array = js_prop(:enabledFeatures)
70
+ return [] if js_array.nil?
71
+
72
+ js_array_to_a(js_array).map(&:to_s)
73
+ end
74
+
75
+ # Check if system keyboard is supported
76
+ # @return [Boolean]
77
+ def system_keyboard_supported?
78
+ js_bool(:isSystemKeyboardSupported)
79
+ end
80
+
81
+ # Get the environment blend mode
82
+ # @return [String] "opaque", "alpha-blend", or "additive"
83
+ def environment_blend_mode
84
+ js_string(:environmentBlendMode)
85
+ end
86
+
87
+ # Get the interaction mode
88
+ # @return [String] "screen-space" or "world-space"
89
+ def interaction_mode
90
+ js_string(:interactionMode)
91
+ end
92
+
93
+ # Check if the session has ended
94
+ # @return [Boolean]
95
+ def ended?
96
+ js_bool(:ended)
97
+ end
98
+
99
+ # Update the render state
100
+ # @param depth_near [Float, nil] Near depth plane
101
+ # @param depth_far [Float, nil] Far depth plane
102
+ # @param inline_vertical_field_of_view [Float, nil] Field of view for inline sessions
103
+ # @param base_layer [WebGLLayer, nil] The base WebGL layer
104
+ # @return [void]
105
+ def update_render_state(depth_near: nil, depth_far: nil, inline_vertical_field_of_view: nil, base_layer: nil)
106
+ js_options = JS.eval("({})")
107
+
108
+ js_options[:depthNear] = depth_near if depth_near
109
+ js_options[:depthFar] = depth_far if depth_far
110
+ js_options[:inlineVerticalFieldOfView] = inline_vertical_field_of_view if inline_vertical_field_of_view
111
+ js_options[:baseLayer] = base_layer.js if base_layer
112
+
113
+ js_call(:updateRenderState, js_options)
114
+ @render_state = nil # Clear cache
115
+ end
116
+
117
+ # Update the target frame rate
118
+ # @param rate [Float] The target frame rate in Hz
119
+ # @return [void]
120
+ def update_target_frame_rate(rate)
121
+ promise = js_call(:updateTargetFrameRate, rate)
122
+ js_await(promise)
123
+ end
124
+
125
+ # Request a reference space
126
+ # @param type [String] The reference space type
127
+ # @return [ReferenceSpace, BoundedReferenceSpace]
128
+ def request_reference_space(type)
129
+ promise = js_call(:requestReferenceSpace, type)
130
+ js_space = js_await(promise)
131
+
132
+ case type
133
+ when ReferenceSpaceType::BOUNDED_FLOOR
134
+ BoundedReferenceSpace.new(js_space)
135
+ else
136
+ ReferenceSpace.new(js_space)
137
+ end
138
+ end
139
+
140
+ # Request an animation frame
141
+ # @yield [Float, Frame] The callback receives time and frame
142
+ # @return [Integer] The animation frame handle
143
+ def request_animation_frame(&block)
144
+ return nil unless block_given?
145
+
146
+ js_callback = create_animation_frame_callback(block)
147
+ @animation_frame_id = js_call(:requestAnimationFrame, js_callback)
148
+ @animation_frame_id.to_i
149
+ end
150
+
151
+ # Cancel a pending animation frame
152
+ # @param handle [Integer, nil] The animation frame handle (uses last handle if nil)
153
+ # @return [void]
154
+ def cancel_animation_frame(handle = nil)
155
+ handle ||= @animation_frame_id
156
+ js_call(:cancelAnimationFrame, handle) if handle
157
+ end
158
+
159
+ # End the XR session
160
+ # @return [void]
161
+ def end_session
162
+ promise = js_call(:end)
163
+ js_await(promise)
164
+ end
165
+
166
+ # Register an event handler
167
+ # @param event [Symbol, String] The event type
168
+ # @yield [Event] The event callback
169
+ # @return [void]
170
+ def on(event, &block)
171
+ event_sym = event.to_sym
172
+ return unless @callbacks.key?(event_sym) && block_given?
173
+
174
+ @callbacks[event_sym] << block
175
+ setup_event_listener(event_sym) if @callbacks[event_sym].size == 1
176
+ end
177
+
178
+ private
179
+
180
+ def create_animation_frame_callback(block)
181
+ # Create a JS function that will invoke the Ruby block
182
+ JS.eval("(function(time, frame) {
183
+ return $callback.call(time, frame);
184
+ })")
185
+ end
186
+
187
+ def setup_event_listener(event_sym)
188
+ js_event = event_sym.to_s
189
+
190
+ callback = ->(js_event_obj) { dispatch_event(event_sym, js_event_obj) }
191
+ js_callback = JS.eval("(function(event) { return $callback.call(event); })")
192
+
193
+ js_call(:addEventListener, js_event, js_callback)
194
+ end
195
+
196
+ def dispatch_event(event_sym, js_event_obj)
197
+ event = wrap_event(event_sym, js_event_obj)
198
+ @callbacks[event_sym].each { |cb| cb.call(event) }
199
+ end
200
+
201
+ def wrap_event(event_sym, js_event_obj)
202
+ case event_sym
203
+ when :select, :selectstart, :selectend, :squeeze, :squeezestart, :squeezeend
204
+ InputSourceEvent.new(js_event_obj)
205
+ when :inputsourceschange
206
+ InputSourcesChangeEvent.new(js_event_obj)
207
+ else
208
+ SessionEvent.new(js_event_obj)
209
+ end
210
+ end
211
+ end
212
+ end