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,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebXR
4
+ # XRSystem - Entry point for WebXR API
5
+ # Accessed via navigator.xr in JavaScript
6
+ class System < JSWrapper
7
+ @instance = nil
8
+
9
+ class << self
10
+ # Get the singleton XRSystem instance
11
+ # @return [System, nil] The XRSystem instance, or nil if WebXR is not available
12
+ def instance
13
+ @instance ||= begin
14
+ js_xr = JS.global[:navigator][:xr]
15
+ js_xr.nil? ? nil : new(js_xr)
16
+ end
17
+ end
18
+
19
+ # Check if WebXR is available in the current environment
20
+ # @return [Boolean]
21
+ def available?
22
+ !instance.nil?
23
+ end
24
+
25
+ # Reset the singleton instance (useful for testing)
26
+ # @api private
27
+ def reset_instance!
28
+ @instance = nil
29
+ end
30
+ end
31
+
32
+ # @param js_xr [JS::Object] The navigator.xr JavaScript object
33
+ def initialize(js_xr)
34
+ super(js_xr)
35
+ @device_change_callbacks = []
36
+ end
37
+
38
+ # Check if a session mode is supported
39
+ # @param mode [String] The session mode ("inline", "immersive-vr", "immersive-ar")
40
+ # @return [Boolean]
41
+ def session_supported?(mode)
42
+ promise = js_call(:isSessionSupported, mode)
43
+ result = js_await(promise)
44
+ !!result
45
+ end
46
+
47
+ # Request a new XR session
48
+ # @param mode [String] The session mode
49
+ # @param required_features [Array<String>] Required features for the session
50
+ # @param optional_features [Array<String>] Optional features for the session
51
+ # @return [Session] The created XR session
52
+ # @raise [NotSupportedError] If the session mode is not supported
53
+ # @raise [NotAllowedError] If the user denies the session request
54
+ def request_session(mode, required_features: [], optional_features: [])
55
+ js_options = build_session_options(
56
+ required_features: required_features,
57
+ optional_features: optional_features
58
+ )
59
+
60
+ promise = js_call(:requestSession, mode, js_options)
61
+ js_session = js_await(promise)
62
+ Session.new(js_session)
63
+ rescue JS::Error => e
64
+ handle_js_error(e)
65
+ end
66
+
67
+ # Register a callback for device change events
68
+ # @yield Called when XR devices are connected or disconnected
69
+ # @return [void]
70
+ def on_device_change(&block)
71
+ return unless block_given?
72
+
73
+ @device_change_callbacks << block
74
+ setup_device_change_listener if @device_change_callbacks.size == 1
75
+ end
76
+
77
+ private
78
+
79
+ def build_session_options(required_features:, optional_features:)
80
+ js_options = JS.eval("({})")
81
+
82
+ unless required_features.empty?
83
+ js_required = to_js_array(required_features)
84
+ js_options[:requiredFeatures] = js_required
85
+ end
86
+
87
+ unless optional_features.empty?
88
+ js_optional = to_js_array(optional_features)
89
+ js_options[:optionalFeatures] = js_optional
90
+ end
91
+
92
+ js_options
93
+ end
94
+
95
+ def setup_device_change_listener
96
+ callback = ->(event) { dispatch_device_change(event) }
97
+ js_callback = JS.eval("(function(event) { return $callback.call(event); })")
98
+ js_call(:addEventListener, "devicechange", js_callback)
99
+ end
100
+
101
+ def dispatch_device_change(event)
102
+ @device_change_callbacks.each { |cb| cb.call(event) }
103
+ end
104
+
105
+ def handle_js_error(error)
106
+ message = error.message.to_s
107
+
108
+ case message
109
+ when /NotSupportedError/
110
+ raise NotSupportedError, message
111
+ when /SecurityError/
112
+ raise SecurityError, message
113
+ when /NotAllowedError/
114
+ raise NotAllowedError, message
115
+ when /InvalidStateError/
116
+ raise InvalidStateError, message
117
+ else
118
+ raise Error, message
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebXR
4
+ # Base error class for all WebXR-related errors
5
+ class Error < StandardError; end
6
+
7
+ # Raised when the requested feature is not supported by the browser or device
8
+ class NotSupportedError < Error; end
9
+
10
+ # Raised when a security policy prevents the operation
11
+ class SecurityError < Error; end
12
+
13
+ # Raised when an operation is performed in an invalid state
14
+ class InvalidStateError < Error; end
15
+
16
+ # Raised when the user or system denies the operation
17
+ class NotAllowedError < Error; end
18
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebXR
4
+ # XRInputSourceEvent - Event for input source actions (select, squeeze)
5
+ class InputSourceEvent < SessionEvent
6
+ # Get the input source that triggered this event
7
+ # @return [InputSource]
8
+ def input_source
9
+ InputSource.new(js_prop(:inputSource))
10
+ end
11
+
12
+ # Get the frame at the time of the event
13
+ # @return [Frame]
14
+ def frame
15
+ Frame.new(js_prop(:frame))
16
+ end
17
+
18
+ # Convenience method to get the handedness
19
+ # @return [String] "none", "left", or "right"
20
+ def handedness
21
+ input_source.handedness
22
+ end
23
+
24
+ # Check if this event is from the left controller
25
+ # @return [Boolean]
26
+ def left?
27
+ input_source.left?
28
+ end
29
+
30
+ # Check if this event is from the right controller
31
+ # @return [Boolean]
32
+ def right?
33
+ input_source.right?
34
+ end
35
+
36
+ # Get the pose of the input source at the time of the event
37
+ # @param reference_space [ReferenceSpace] The reference space
38
+ # @return [Pose, nil]
39
+ def pose(reference_space)
40
+ frame.pose(input_source.target_ray_space, reference_space)
41
+ end
42
+
43
+ # Get the grip pose of the input source at the time of the event
44
+ # @param reference_space [ReferenceSpace] The reference space
45
+ # @return [Pose, nil]
46
+ def grip_pose(reference_space)
47
+ grip = input_source.grip_space
48
+ return nil if grip.nil?
49
+
50
+ frame.pose(grip, reference_space)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebXR
4
+ # XRReferenceSpaceEvent - Event fired when a reference space is reset
5
+ class ReferenceSpaceEvent < JSWrapper
6
+ # @param js_event [JS::Object] The JavaScript event object
7
+ def initialize(js_event)
8
+ super(js_event)
9
+ end
10
+
11
+ # Get the event type
12
+ # @return [String]
13
+ def type
14
+ js_string(:type) || ""
15
+ end
16
+
17
+ # Get the reference space that was reset
18
+ # @return [ReferenceSpace]
19
+ def reference_space
20
+ ReferenceSpace.new(js_prop(:referenceSpace))
21
+ end
22
+
23
+ # Get the transform representing the change in origin
24
+ # @return [RigidTransform, nil]
25
+ def transform
26
+ js_transform = js_prop(:transform)
27
+ return nil if js_transform.nil?
28
+
29
+ RigidTransform.new(js_transform)
30
+ end
31
+
32
+ # Get the event timestamp
33
+ # @return [Float]
34
+ def time_stamp
35
+ js_float(:timeStamp) || 0.0
36
+ end
37
+
38
+ # Check if the event is trusted
39
+ # @return [Boolean]
40
+ def trusted?
41
+ js_bool(:isTrusted)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebXR
4
+ # XRSessionEvent - Base event for session-related events
5
+ class SessionEvent < JSWrapper
6
+ # @param js_event [JS::Object] The JavaScript event object
7
+ def initialize(js_event)
8
+ super(js_event)
9
+ end
10
+
11
+ # Get the event type
12
+ # @return [String]
13
+ def type
14
+ js_string(:type) || ""
15
+ end
16
+
17
+ # Get the session associated with this event
18
+ # @return [Session]
19
+ def session
20
+ Session.new(js_prop(:session))
21
+ end
22
+
23
+ # Get the event timestamp
24
+ # @return [Float]
25
+ def time_stamp
26
+ js_float(:timeStamp) || 0.0
27
+ end
28
+
29
+ # Check if the event is trusted
30
+ # @return [Boolean]
31
+ def trusted?
32
+ js_bool(:isTrusted)
33
+ end
34
+ end
35
+
36
+ # XRInputSourcesChangeEvent - Event fired when input sources change
37
+ class InputSourcesChangeEvent < SessionEvent
38
+ # Get the added input sources
39
+ # @return [Array<InputSource>]
40
+ def added
41
+ js_added = js_prop(:added)
42
+ return [] if js_added.nil?
43
+
44
+ js_array_to_a(js_added).map { |s| InputSource.new(s) }
45
+ end
46
+
47
+ # Get the removed input sources
48
+ # @return [Array<InputSource>]
49
+ def removed
50
+ js_removed = js_prop(:removed)
51
+ return [] if js_removed.nil?
52
+
53
+ js_array_to_a(js_removed).map { |s| InputSource.new(s) }
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebXR
4
+ # XRPose - Represents the position and orientation of a tracked object
5
+ class Pose < JSWrapper
6
+ # @param js_pose [JS::Object] The XRPose JavaScript object
7
+ def initialize(js_pose)
8
+ super(js_pose)
9
+ end
10
+
11
+ # Get the rigid transform for this pose
12
+ # @return [RigidTransform]
13
+ def transform
14
+ RigidTransform.new(js_prop(:transform))
15
+ end
16
+
17
+ # Check if the position is emulated (not directly tracked)
18
+ # @return [Boolean]
19
+ def emulated_position?
20
+ js_bool(:emulatedPosition)
21
+ end
22
+
23
+ # Get the linear velocity in m/s
24
+ # @return [Hash, nil] Hash with :x, :y, :z keys, or nil if not available
25
+ def linear_velocity
26
+ v = js_prop(:linearVelocity)
27
+ return nil if v.nil?
28
+
29
+ {
30
+ x: v[:x].to_f,
31
+ y: v[:y].to_f,
32
+ z: v[:z].to_f
33
+ }
34
+ end
35
+
36
+ # Get the angular velocity in rad/s
37
+ # @return [Hash, nil] Hash with :x, :y, :z keys, or nil if not available
38
+ def angular_velocity
39
+ v = js_prop(:angularVelocity)
40
+ return nil if v.nil?
41
+
42
+ {
43
+ x: v[:x].to_f,
44
+ y: v[:y].to_f,
45
+ z: v[:z].to_f
46
+ }
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebXR
4
+ # XRRigidTransform - Represents a position and orientation in 3D space
5
+ # Immutable transform consisting of a position and quaternion orientation
6
+ class RigidTransform < JSWrapper
7
+ # Create a new rigid transform
8
+ # @param js_transform [JS::Object, nil] Existing JS transform to wrap
9
+ # @param position [Hash, nil] Position hash with :x, :y, :z, :w keys
10
+ # @param orientation [Hash, nil] Orientation quaternion hash with :x, :y, :z, :w keys
11
+ def initialize(js_transform = nil, position: nil, orientation: nil)
12
+ if js_transform
13
+ super(js_transform)
14
+ else
15
+ js_position = position ? create_dom_point(position) : nil
16
+ js_orientation = orientation ? create_dom_point(orientation) : nil
17
+ js_obj = JS.global[:XRRigidTransform].new(js_position, js_orientation)
18
+ super(js_obj)
19
+ end
20
+ end
21
+
22
+ # Get the position as a hash
23
+ # @return [Hash] Hash with :x, :y, :z, :w keys
24
+ def position
25
+ p = js_prop(:position)
26
+ {
27
+ x: p[:x].to_f,
28
+ y: p[:y].to_f,
29
+ z: p[:z].to_f,
30
+ w: p[:w].to_f
31
+ }
32
+ end
33
+
34
+ # Get the orientation quaternion as a hash
35
+ # @return [Hash] Hash with :x, :y, :z, :w keys
36
+ def orientation
37
+ o = js_prop(:orientation)
38
+ {
39
+ x: o[:x].to_f,
40
+ y: o[:y].to_f,
41
+ z: o[:z].to_f,
42
+ w: o[:w].to_f
43
+ }
44
+ end
45
+
46
+ # Get the 4x4 transformation matrix
47
+ # @return [Array<Float>] 16-element array in column-major order
48
+ def matrix
49
+ js_matrix = js_prop(:matrix)
50
+ js_array_to_a(js_matrix).map(&:to_f)
51
+ end
52
+
53
+ # Get the inverse transform
54
+ # @return [RigidTransform]
55
+ def inverse
56
+ RigidTransform.new(js_prop(:inverse))
57
+ end
58
+
59
+ # Get the position as a 3-element array [x, y, z]
60
+ # @return [Array<Float>]
61
+ def position_array
62
+ p = position
63
+ [p[:x], p[:y], p[:z]]
64
+ end
65
+
66
+ # Get the orientation as a 4-element array [x, y, z, w]
67
+ # @return [Array<Float>]
68
+ def orientation_array
69
+ o = orientation
70
+ [o[:x], o[:y], o[:z], o[:w]]
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebXR
4
+ # XRView - Represents a single view (eye) for rendering
5
+ class View < JSWrapper
6
+ # @param js_view [JS::Object] The XRView JavaScript object
7
+ def initialize(js_view)
8
+ super(js_view)
9
+ end
10
+
11
+ # Get the eye this view is for
12
+ # @return [String] "left", "right", or "none"
13
+ def eye
14
+ js_string(:eye) || Eye::NONE
15
+ end
16
+
17
+ # Get the 4x4 projection matrix
18
+ # @return [Array<Float>] 16-element array in column-major order
19
+ def projection_matrix
20
+ js_matrix = js_prop(:projectionMatrix)
21
+ js_array_to_a(js_matrix).map(&:to_f)
22
+ end
23
+
24
+ # Get the view transform
25
+ # @return [RigidTransform]
26
+ def transform
27
+ RigidTransform.new(js_prop(:transform))
28
+ end
29
+
30
+ # Get the recommended viewport scale
31
+ # @return [Float, nil]
32
+ def recommended_viewport_scale
33
+ js_float(:recommendedViewportScale)
34
+ end
35
+
36
+ # Request a specific viewport scale
37
+ # @param scale [Float] Scale factor (0.0 to 1.0)
38
+ # @return [void]
39
+ def request_viewport_scale(scale)
40
+ js_call(:requestViewportScale, scale)
41
+ end
42
+
43
+ # Check if this is the left eye
44
+ # @return [Boolean]
45
+ def left?
46
+ eye == Eye::LEFT
47
+ end
48
+
49
+ # Check if this is the right eye
50
+ # @return [Boolean]
51
+ def right?
52
+ eye == Eye::RIGHT
53
+ end
54
+
55
+ # Check if this is a mono view (no stereo)
56
+ # @return [Boolean]
57
+ def mono?
58
+ eye == Eye::NONE
59
+ end
60
+
61
+ # Get the view matrix (inverse of transform)
62
+ # Useful for rendering calculations
63
+ # @return [Array<Float>] 16-element array in column-major order
64
+ def view_matrix
65
+ transform.inverse.matrix
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebXR
4
+ # XRViewerPose - The pose of the user's head/viewer
5
+ # Contains one or more views (eyes) for rendering
6
+ class ViewerPose < Pose
7
+ # @param js_viewer_pose [JS::Object] The XRViewerPose JavaScript object
8
+ def initialize(js_viewer_pose)
9
+ super(js_viewer_pose)
10
+ end
11
+
12
+ # Get all views (typically one per eye)
13
+ # @return [Array<View>]
14
+ def views
15
+ js_views = js_prop(:views)
16
+ return [] if js_views.nil?
17
+
18
+ js_array_to_a(js_views).map { |v| View.new(v) }
19
+ end
20
+
21
+ # Get the left eye view
22
+ # @return [View, nil]
23
+ def left_view
24
+ views.find { |v| v.eye == Eye::LEFT }
25
+ end
26
+
27
+ # Get the right eye view
28
+ # @return [View, nil]
29
+ def right_view
30
+ views.find { |v| v.eye == Eye::RIGHT }
31
+ end
32
+
33
+ # Iterate over each view
34
+ # @yield [View] Each view
35
+ # @return [void]
36
+ def each_view(&block)
37
+ views.each(&block)
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebXR
4
+ # XRViewport - Defines a rectangular region for rendering
5
+ class Viewport < JSWrapper
6
+ # @param js_viewport [JS::Object] The XRViewport JavaScript object
7
+ def initialize(js_viewport)
8
+ super(js_viewport)
9
+ end
10
+
11
+ # Get the X offset in pixels
12
+ # @return [Integer]
13
+ def x
14
+ js_int(:x) || 0
15
+ end
16
+
17
+ # Get the Y offset in pixels
18
+ # @return [Integer]
19
+ def y
20
+ js_int(:y) || 0
21
+ end
22
+
23
+ # Get the width in pixels
24
+ # @return [Integer]
25
+ def width
26
+ js_int(:width) || 0
27
+ end
28
+
29
+ # Get the height in pixels
30
+ # @return [Integer]
31
+ def height
32
+ js_int(:height) || 0
33
+ end
34
+
35
+ # Convert to a hash
36
+ # @return [Hash] Hash with :x, :y, :width, :height keys
37
+ def to_h
38
+ { x: x, y: y, width: width, height: height }
39
+ end
40
+
41
+ # Convert to an array [x, y, width, height]
42
+ # @return [Array<Integer>]
43
+ def to_a
44
+ [x, y, width, height]
45
+ end
46
+
47
+ # Calculate the aspect ratio
48
+ # @return [Float]
49
+ def aspect_ratio
50
+ return 0.0 if height.zero?
51
+
52
+ width.to_f / height.to_f
53
+ end
54
+ end
55
+ end