summaryrefslogtreecommitdiffstats
path: root/testing/web-platform/tests/webxr/hit-test
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /testing/web-platform/tests/webxr/hit-test
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'testing/web-platform/tests/webxr/hit-test')
-rw-r--r--testing/web-platform/tests/webxr/hit-test/META.yml1
-rw-r--r--testing/web-platform/tests/webxr/hit-test/ar_hittest_source_cancel.https.html100
-rw-r--r--testing/web-platform/tests/webxr/hit-test/ar_hittest_subscription_inputSources.https.html170
-rw-r--r--testing/web-platform/tests/webxr/hit-test/ar_hittest_subscription_refSpaces.https.html158
-rw-r--r--testing/web-platform/tests/webxr/hit-test/ar_hittest_subscription_states_regular.https.html69
-rw-r--r--testing/web-platform/tests/webxr/hit-test/ar_hittest_subscription_states_transient.https.html66
-rw-r--r--testing/web-platform/tests/webxr/hit-test/ar_hittest_subscription_transientInputSources.https.html177
-rw-r--r--testing/web-platform/tests/webxr/hit-test/ar_hittest_subscription_unlocalizable.https.html108
-rw-r--r--testing/web-platform/tests/webxr/hit-test/idlharness.https.html29
-rw-r--r--testing/web-platform/tests/webxr/hit-test/xrRay_constructor.https.html159
-rw-r--r--testing/web-platform/tests/webxr/hit-test/xrRay_matrix.https.html101
11 files changed, 1138 insertions, 0 deletions
diff --git a/testing/web-platform/tests/webxr/hit-test/META.yml b/testing/web-platform/tests/webxr/hit-test/META.yml
new file mode 100644
index 0000000000..e3f94f05a1
--- /dev/null
+++ b/testing/web-platform/tests/webxr/hit-test/META.yml
@@ -0,0 +1 @@
+spec: https://immersive-web.github.io/hit-test/
diff --git a/testing/web-platform/tests/webxr/hit-test/ar_hittest_source_cancel.https.html b/testing/web-platform/tests/webxr/hit-test/ar_hittest_source_cancel.https.html
new file mode 100644
index 0000000000..5e7381df02
--- /dev/null
+++ b/testing/web-platform/tests/webxr/hit-test/ar_hittest_source_cancel.https.html
@@ -0,0 +1,100 @@
+<!DOCTYPE html>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="../resources/webxr_util.js"></script>
+<script src="../resources/webxr_test_asserts.js"></script>
+<script src="../resources/webxr_test_constants.js"></script>
+<script src="../resources/webxr_test_constants_fake_world.js"></script>
+
+<script>
+
+// at world origin.
+const VIEWER_ORIGIN_TRANSFORM = {
+ position: [0, 0, 0],
+ orientation: [0, 0, 0, 1],
+};
+
+// at world origin.
+const FLOOR_ORIGIN_TRANSFORM = {
+ position: [0, 0, 0],
+ orientation: [0, 0, 0, 1],
+};
+
+const fakeDeviceInitParams = {
+ supportedModes: ["immersive-ar"],
+ views: VALID_VIEWS,
+ floorOrigin: FLOOR_ORIGIN_TRANSFORM, // aka mojo_from_floor
+ viewerOrigin: VIEWER_ORIGIN_TRANSFORM, // aka mojo_from_viewer
+ supportedFeatures: ALL_FEATURES,
+ world: createFakeWorld(5.0, 2.0, 5.0), // webxr_test_constants_fake_world.js has detailed description of the fake world
+};
+
+const test_function_generator = function(isTransientTest, isSessionEndedTest) {
+ return function(session, fakeDeviceController, t) {
+
+ let done = false;
+
+ return session.requestReferenceSpace('local').then((localSpace) => {
+ const validation_function = (hitTestSource) => {
+
+ const rAFcb = function(time, frame) {
+ if(isSessionEndedTest) {
+ // Session is marked as "ended" synchronously, there is no need to
+ // wait for the promise it returns to settle.
+ session.end();
+
+ // Currently, the specification does not say what happen
+ // when a hit test source gets cancelled post-session-end.
+ hitTestSource.cancel();
+ done = true;
+ } else {
+ hitTestSource.cancel();
+ t.step(() => {
+ assert_throws_dom("InvalidStateError", () => hitTestSource.cancel());
+ });
+ done = true;
+ }
+ };
+
+ session.requestAnimationFrame(rAFcb);
+
+ return t.step_wait(() => done);
+ };
+
+ // Same validation will happen both in transient and non-transient variant
+ if(isTransientTest) {
+ return session.requestHitTestSourceForTransientInput({
+ profile: "generic-touchscreen",
+ offsetRay: new XRRay(),
+ }).then(validation_function);
+ } else {
+ return session.requestHitTestSource({
+ space: localSpace,
+ offsetRay: new XRRay(),
+ }).then(validation_function);
+ }
+ }); // return session.requestReferenceSpace('local').then((localSpace) => { ...
+ }; // return function(session, fakeDeviceController, t) { ...
+}
+
+xr_session_promise_test("Ensures hit test source cancellation works when the session has not ended.",
+ test_function_generator(/*isTransientTest=*/false, /*isSessionEndedTest=*/false),
+ fakeDeviceInitParams,
+ 'immersive-ar', { 'requiredFeatures': ['hit-test'] });
+
+xr_session_promise_test("Ensures transient input hit test source cancellation works when the session has not ended.",
+ test_function_generator(/*isTransientTest=*/true, /*isSessionEndedTest=*/false),
+ fakeDeviceInitParams,
+ 'immersive-ar', { 'requiredFeatures': ['hit-test'] });
+
+xr_session_promise_test("Ensures hit test source cancellation works when the session has ended",
+ test_function_generator(/*isTransientTest=*/false, /*isSessionEndedTest=*/true),
+ fakeDeviceInitParams,
+ 'immersive-ar', { 'requiredFeatures': ['hit-test'] });
+
+xr_session_promise_test("Ensures transient input hit test source cancellation works when the session has ended",
+ test_function_generator(/*isTransientTest=*/true, /*isSessionEndedTest=*/true),
+ fakeDeviceInitParams,
+ 'immersive-ar', { 'requiredFeatures': ['hit-test'] });
+
+</script>
diff --git a/testing/web-platform/tests/webxr/hit-test/ar_hittest_subscription_inputSources.https.html b/testing/web-platform/tests/webxr/hit-test/ar_hittest_subscription_inputSources.https.html
new file mode 100644
index 0000000000..ca92d390fc
--- /dev/null
+++ b/testing/web-platform/tests/webxr/hit-test/ar_hittest_subscription_inputSources.https.html
@@ -0,0 +1,170 @@
+<!DOCTYPE html>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="../resources/webxr_util.js"></script>
+<script src="../resources/webxr_math_utils.js"></script>
+<script src="../resources/webxr_test_asserts.js"></script>
+<script src="../resources/webxr_test_constants.js"></script>
+<script src="../resources/webxr_test_constants_fake_world.js"></script>
+
+<script>
+
+// 1m above world origin.
+const VIEWER_ORIGIN_TRANSFORM = {
+ position: [0, 1, 0],
+ orientation: [0, 0, 0, 1],
+};
+
+// 0.25m above world origin.
+const FLOOR_ORIGIN_TRANSFORM = {
+ position: [0, -0.25, 0],
+ orientation: [0, 0, 0, 1],
+};
+
+// Start the screen pointer at the same place as the viewer, so it's essentially
+// coming straight forward from the middle of the screen.
+const SCREEN_POINTER_TRANSFORM = VIEWER_ORIGIN_TRANSFORM;
+
+const screen_controller_init = {
+ handedness: "none",
+ targetRayMode: "screen",
+ pointerOrigin: SCREEN_POINTER_TRANSFORM, // aka mojo_from_pointer
+ profiles: ["generic-touchscreen",]
+};
+
+const fakeDeviceInitParams = {
+ supportedModes: ["immersive-ar"],
+ views: VALID_VIEWS,
+ floorOrigin: FLOOR_ORIGIN_TRANSFORM, // aka floor_from_mojo
+ viewerOrigin: VIEWER_ORIGIN_TRANSFORM, // aka mojo_from_viewer
+ supportedFeatures: ALL_FEATURES,
+ world: createFakeWorld(5.0, 2.0, 5.0), // see webxr_test_constants_fake_world.js for details
+};
+
+// Generates a test function given the parameters for the hit test.
+// |ray| - ray that will be used to subscribe to hit test.
+// |expectedPoses| - array of expected pose objects. The poses should be expressed in local space.
+// Null entries in the array mean that the given entry will not be validated.
+// |inputFromPointer| - input from pointer transform that will be used as the input source's
+// inputFromPointer (aka pointer origin) in subsequent rAF.
+// |nextFrameExpectedPoses| - array of expected pose objects. The poses should be expressed in local space.
+// Null entries in the array mean that the given entry will not be validated.
+let testFunctionGenerator = function(ray, expectedPoses, inputFromPointer, nextFrameExpectedPoses) {
+ const testFunction = function(session, fakeDeviceController, t) {
+ return session.requestReferenceSpace('local').then((localRefSpace) => new Promise((resolve, reject) => {
+
+ const input_source_controller = fakeDeviceController.simulateInputSourceConnection(screen_controller_init);
+
+ requestSkipAnimationFrame(session, (time, frame) => {
+ t.step(() => {
+ assert_equals(session.inputSources.length, 1);
+ });
+
+ const input_source = session.inputSources[0];
+ const hitTestOptionsInit = {
+ space: input_source.targetRaySpace,
+ offsetRay: ray,
+ };
+
+ session.requestHitTestSource(hitTestOptionsInit).then((hitTestSource) => {
+ t.step(() => {
+ assert_not_equals(hitTestSource, null);
+ });
+
+ // We got a hit test source, now get the results in subsequent rAFcb:
+ session.requestAnimationFrame((time, frame) => {
+ const results = frame.getHitTestResults(hitTestSource);
+
+ t.step(() => {
+ assert_equals(results.length, expectedPoses.length);
+ for(const [index, expectedPose] of expectedPoses.entries()) {
+ const pose = results[index].getPose(localRefSpace);
+ assert_true(pose != null, "Each hit test result should have a pose in local space");
+ if(expectedPose != null) {
+ assert_transform_approx_equals(pose.transform, expectedPose, FLOAT_EPSILON, "before-move-pose: ");
+ }
+ }
+ });
+
+ input_source_controller.setPointerOrigin(inputFromPointer, false);
+
+ session.requestAnimationFrame((time, frame) => {
+ const results = frame.getHitTestResults(hitTestSource);
+
+ t.step(() => {
+ assert_equals(results.length, nextFrameExpectedPoses.length);
+ for(const [index, expectedPose] of nextFrameExpectedPoses.entries()) {
+ const pose = results[index].getPose(localRefSpace);
+ assert_true(pose != null, "Each hit test result should have a pose in local space");
+ if(expectedPose != null) {
+ assert_transform_approx_equals(pose.transform, expectedPose, FLOAT_EPSILON, "after-move-pose: ");
+ }
+ }
+ });
+
+ resolve();
+ });
+ });
+ });
+ });
+ }));
+ };
+
+ return testFunction;
+};
+
+
+// Pose of the first expected hit test result - straight ahead of the input source, viewer-facing.
+const pose_1 = {
+ position: {x: 0.0, y: 1.0, z: -2.5, w: 1.0},
+ orientation: {x: 0.0, y: -0.707, z: -0.707, w: 0.0},
+ // Hit test API will set Y axis to the surface normal at the intersection point,
+ // Z axis towards the ray origin and X axis to cross product of Y axis & Z axis.
+ // If the surface normal and Z axis would be parallel, the hit test API
+ // will attempt to use `up` vector ([0, 1, 0]) as the Z axis, and if it so happens that Z axis
+ // and the surface normal would still be parallel, it will use the `right` vector ([1, 0, 0]) as the Z axis.
+ // In this particular case, `up` vector will work so the resulting pose.orientation
+ // becomes a rotation around [0, 1, 1] vector by 180 degrees.
+};
+
+xr_session_promise_test("Ensures subscription to hit test works with an XRSpace from input source - no move",
+ testFunctionGenerator(new XRRay(), [pose_1], SCREEN_POINTER_TRANSFORM, [pose_1]),
+ fakeDeviceInitParams,
+ 'immersive-ar', { 'requiredFeatures': ['hit-test'] });
+
+const moved_pointer_transform_1 = {
+ position: [0, 1, 0],
+ orientation: [ 0.707, 0, 0, 0.707 ] // 90 degrees around X axis = facing up
+};
+
+xr_session_promise_test("Ensures subscription to hit test works with an XRSpace from input source - after move - no results",
+ testFunctionGenerator(new XRRay(), [pose_1], moved_pointer_transform_1, []),
+ fakeDeviceInitParams,
+ 'immersive-ar', { 'requiredFeatures': ['hit-test'] });
+
+const pose_2 = {
+ position: {x: -1.443, y: 1.0, z: -2.5, w: 1.0},
+ // Intersection point will be on the same height as the viewer, on the front
+ // wall. Distance from the front wall to viewer is 2.5m, and we are rotating
+ // to the left, so X coordinate of the intersection point will be negative
+ // & equal to -2.5 * tan(30 deg) ~= 1.443m.
+ orientation: {x: 0.5, y: 0.5, z: 0.5, w: 0.5 },
+ // See comment for pose_1.orientation for details.
+ // In this case, the hit test pose will have Y axis facing towards world's
+ // positive Z axis ([0,0,1]), Z axis to the right ([1,0,0]) and X axis
+ // towards world's Y axis ([0,1,0]).
+ // This is equivalent to the rotation around [1, 1, 1] vector by 120 degrees.
+};
+
+const moved_pointer_transform_2 = {
+ position: [0, 1, 0],
+ orientation: [ 0, 0.2588, 0, 0.9659 ] // 30 degrees around Y axis = to the left,
+ // creating 30-60-90 triangle with the front wall
+};
+
+xr_session_promise_test("Ensures subscription to hit test works with an XRSpace from input source - after move - 1 result",
+ testFunctionGenerator(new XRRay(), [pose_1], moved_pointer_transform_2, [pose_2]),
+ fakeDeviceInitParams,
+ 'immersive-ar', { 'requiredFeatures': ['hit-test'] });
+
+</script>
diff --git a/testing/web-platform/tests/webxr/hit-test/ar_hittest_subscription_refSpaces.https.html b/testing/web-platform/tests/webxr/hit-test/ar_hittest_subscription_refSpaces.https.html
new file mode 100644
index 0000000000..a30e71949c
--- /dev/null
+++ b/testing/web-platform/tests/webxr/hit-test/ar_hittest_subscription_refSpaces.https.html
@@ -0,0 +1,158 @@
+<!DOCTYPE html>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="../resources/webxr_util.js"></script>
+<script src="../resources/webxr_math_utils.js"></script>
+<script src="../resources/webxr_test_asserts.js"></script>
+<script src="../resources/webxr_test_constants.js"></script>
+<script src="../resources/webxr_test_constants_fake_world.js"></script>
+
+<script>
+
+// 1m above world origin.
+const VIEWER_ORIGIN_TRANSFORM = {
+ position: [0, 1, 0],
+ orientation: [0, 0, 0, 1],
+};
+
+// 0.25m above world origin.
+const FLOOR_ORIGIN_TRANSFORM = {
+ position: [0, 0.25, 0],
+ orientation: [0, 0, 0, 1],
+};
+
+const fakeDeviceInitParams = {
+ supportedModes: ["immersive-ar"],
+ views: VALID_VIEWS,
+ floorOrigin: FLOOR_ORIGIN_TRANSFORM, // aka mojo_from_floor
+ viewerOrigin: VIEWER_ORIGIN_TRANSFORM, // aka mojo_from_viewer
+ supportedFeatures: ALL_FEATURES,
+ world: createFakeWorld(5.0, 2.0, 5.0), // webxr_test_constants_fake_world.js has detailed description of the fake world
+};
+
+// Generates a test function given the parameters for the hit test.
+// |ray| - ray that will be used to subscribe to hit test.
+// |expectedPoses| - array of expected pose objects. The poses are expected to be expressed in local space.
+// Null entries in the array mean that the given entry will not be validated.
+// |refSpaceName| - XRReferenceSpaceType - either 'local', 'local-floor' or 'viewer'.
+let testFunctionGenerator = function(ray, expectedPoses, refSpaceName) {
+ const testFunction = function(session, fakeDeviceController, t) {
+ return Promise.all([
+ session.requestReferenceSpace('local'),
+ session.requestReferenceSpace('viewer'),
+ session.requestReferenceSpace('local-floor'),
+ ]).then(([localRefSpace, viewerRefSpace, localFloorRefSpace]) => {
+
+ const refSpaceNameToSpace = {
+ 'local' : localRefSpace,
+ 'viewer' : viewerRefSpace,
+ 'local-floor' : localFloorRefSpace
+ };
+
+ const hitTestOptionsInit = {
+ space: refSpaceNameToSpace[refSpaceName],
+ offsetRay: ray,
+ };
+
+ return session.requestHitTestSource(hitTestOptionsInit).then(
+ (hitTestSource) => new Promise((resolve, reject) => {
+
+ const requestAnimationFrameCallback = function(time, frame) {
+ const hitTestResults = frame.getHitTestResults(hitTestSource);
+
+ t.step(() => {
+ assert_equals(hitTestResults.length, expectedPoses.length, "Results length should match expected results length");
+ for(const [index, expectedPose] of expectedPoses.entries()) {
+ const pose = hitTestResults[index].getPose(localRefSpace);
+ assert_true(pose != null, "Each hit test result should have a pose in local space");
+ if(expectedPose != null) {
+ assert_transform_approx_equals(pose.transform, expectedPose);
+ }
+ }
+ });
+
+ resolve();
+ };
+
+ t.step(() => {
+ assert_true(hitTestSource != null, "Hit test source should not be null");
+ });
+
+ session.requestAnimationFrame(requestAnimationFrameCallback);
+ }));
+ });
+ };
+
+ return testFunction;
+};
+
+// Generates a test function that will use local space for hit test subscription.
+// See testFunctionGenerator for explanation of other parameters.
+const localBasedTestFunctionGenerator = function(ray, expectedPoses) {
+ return testFunctionGenerator(ray, expectedPoses, 'local');
+};
+
+// Generates a test function that will use viewer space for hit test subscription.
+// See testFunctionGenerator for explanation of other parameters.
+const viewerBasedTestFunctionGenerator = function(ray, expectedPoses) {
+ return testFunctionGenerator(ray, expectedPoses, 'viewer');
+};
+
+// Generates a test function that will use local-floor space for hit test subscription.
+// See testFunctionGenerator for explanation of other parameters.
+const localFloorBasedTestFunctionGenerator = function(ray, expectedPoses) {
+ return testFunctionGenerator(ray, expectedPoses, 'local-floor');
+};
+
+// All test cases require local-floor and hit-test.
+const sessionInit = { 'requiredFeatures': ['local-floor', 'hit-test'] };
+
+// Pose of the first expected hit test result - straight ahead of the viewer, viewer-facing.
+const pose_1 = {
+ position: {x: 0.0, y: 1.0, z: -2.5, w: 1.0},
+ orientation: {x: 0.0, y: -0.707, z: -0.707, w: 0.0},
+ // Hit test API will set Y axis to the surface normal at the intersection point,
+ // Z axis towards the ray origin and X axis to cross product of Y axis & Z axis.
+ // If the surface normal and Z axis would be parallel, the hit test API
+ // will attempt to use `up` vector ([0, 1, 0]) as the Z axis, and if it so happens that Z axis
+ // and the surface normal would still be parallel, it will use the `right` vector ([1, 0, 0]) as the Z axis.
+ // In this particular case, `up` vector will work so the resulting pose.orientation
+ // becomes a rotation around [0, 1, 1] vector by 180 degrees.
+};
+
+xr_session_promise_test(
+ "Ensures subscription to hit test works with viewer space - straight ahead - plane",
+ viewerBasedTestFunctionGenerator(new XRRay(), [pose_1]),
+ fakeDeviceInitParams, 'immersive-ar', sessionInit);
+
+xr_session_promise_test("Ensures subscription to hit test works with viewer space - straight up - no results",
+ viewerBasedTestFunctionGenerator(new XRRay({}, {x: 0.0, y: 1.0, z : 0.0}), []),
+ fakeDeviceInitParams, 'immersive-ar', sessionInit);
+
+const pose_2 = {
+ position: {x: 0.0, y: 0.0, z: -2.5, w: 1.0},
+ orientation: {x: 0.0, y: -0.707, z: -0.707, w: 0.0},
+ // See comment for pose_1.orientation for details.
+ // In this case, the hit test pose will have Y and Z axis towards the ray origin so it won't be used,
+ // but `up` vector will work so the resulting pose.orientation
+ // becomes a rotation around [0, 1, 1] vector by 180 degrees.
+};
+
+xr_session_promise_test("Ensures subscription to hit test works with local space",
+ localBasedTestFunctionGenerator(new XRRay(), [pose_2]),
+ fakeDeviceInitParams, 'immersive-ar', sessionInit);
+
+const pose_3 = {
+ position: {x: 0.0, y: 0.25, z: -2.5, w: 1.0},
+ orientation: {x: 0.0, y: -0.707, z: -0.707, w: 0.0},
+ // See comment for pose_1.orientation for details.
+ // In this case, the hit test pose will have Y and Z axis towards the ray origin so it won't be used,
+ // but `up` vector will work so the resulting pose.orientation
+ // becomes a rotation around [0, 1, 1] vector by 180 degrees.
+};
+
+xr_session_promise_test("Ensures subscription to hit test works with local-floor space",
+ localFloorBasedTestFunctionGenerator(new XRRay(), [pose_3]),
+ fakeDeviceInitParams, 'immersive-ar', sessionInit);
+
+</script>
diff --git a/testing/web-platform/tests/webxr/hit-test/ar_hittest_subscription_states_regular.https.html b/testing/web-platform/tests/webxr/hit-test/ar_hittest_subscription_states_regular.https.html
new file mode 100644
index 0000000000..9f5513fd1e
--- /dev/null
+++ b/testing/web-platform/tests/webxr/hit-test/ar_hittest_subscription_states_regular.https.html
@@ -0,0 +1,69 @@
+<!DOCTYPE html>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="../resources/webxr_util.js"></script>
+<script src="../resources/webxr_test_asserts.js"></script>
+<script src="../resources/webxr_test_constants.js"></script>
+<script src="../resources/webxr_test_constants_fake_world.js"></script>
+
+<script>
+
+const fakeDeviceInitParams = {
+ supportedModes: ["immersive-ar"],
+ views: VALID_VIEWS,
+ supportedFeatures: ALL_FEATURES,
+};
+
+// Creates a test method that leverages regular hit test API (as opposed to hit
+// test for transient input).
+// |shouldSucceed| - true if the hit test request is expected to succeed, false otherwise
+// |endSession| - true if the test case should call session.end() prior to requesting hit test
+// |expectedError| - expected error name that should be returned in case shouldSucceed is false
+const testFunctionGeneratorRegular = function(shouldSucceed, endSession, expectedError) {
+ const testFunction = function(session, fakeDeviceController, t) {
+ session.requestReferenceSpace('viewer').then((viewerRefSpace) => {
+
+ const hitTestOptionsInit = {
+ space: viewerRefSpace,
+ offsetRay: new XRRay(),
+ };
+
+ if(endSession) {
+ session.end();
+ }
+
+ return session.requestHitTestSource(hitTestOptionsInit).then((hitTestSource) => {
+ t.step(() => {
+ assert_true(shouldSucceed,
+ "`requestHitTestSource` succeeded when it was expected to fail");
+ });
+ }).catch((error) => {
+ t.step(() => {
+ assert_false(shouldSucceed,
+ "`requestHitTestSource` failed when it was expected to succeed, error: " + error);
+ assert_equals(error.name, expectedError,
+ "`requestHitTestSource` failed with unexpected error name");
+ });
+ });
+ });
+ };
+
+ return testFunction;
+};
+
+xr_session_promise_test("Hit test subscription succeeds if the feature was requested",
+ testFunctionGeneratorRegular(/*shouldSucceed=*/true, /*endSession=*/false),
+ fakeDeviceInitParams,
+ 'immersive-ar', { 'requiredFeatures': ['hit-test'] });
+
+xr_session_promise_test("Hit test subscription fails if the feature was not requested",
+ testFunctionGeneratorRegular(/*shouldSucceed=*/false, /*endSession=*/false, "NotSupportedError"),
+ fakeDeviceInitParams,
+ 'immersive-ar', {});
+
+xr_session_promise_test("Hit test subscription fails if the feature was requested but the session already ended",
+ testFunctionGeneratorRegular(/*shouldSucceed=*/false, /*endSession=*/true, "InvalidStateError"),
+ fakeDeviceInitParams,
+ 'immersive-ar', { 'requiredFeatures': ['hit-test'] });
+
+</script>
diff --git a/testing/web-platform/tests/webxr/hit-test/ar_hittest_subscription_states_transient.https.html b/testing/web-platform/tests/webxr/hit-test/ar_hittest_subscription_states_transient.https.html
new file mode 100644
index 0000000000..e8a83a62a6
--- /dev/null
+++ b/testing/web-platform/tests/webxr/hit-test/ar_hittest_subscription_states_transient.https.html
@@ -0,0 +1,66 @@
+<!DOCTYPE html>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="../resources/webxr_util.js"></script>
+<script src="../resources/webxr_test_asserts.js"></script>
+<script src="../resources/webxr_test_constants.js"></script>
+<script src="../resources/webxr_test_constants_fake_world.js"></script>
+
+<script>
+
+const fakeDeviceInitParams = {
+ supportedModes: ["immersive-ar"],
+ views: VALID_VIEWS,
+ supportedFeatures: ALL_FEATURES,
+};
+
+// Creates a test method that leverages hit test API for transient input.
+// |shouldSucceed| - true if the hit test request is expected to succeed, false otherwise
+// |endSession| - true if the test case should call session.end() prior to requesting hit test
+// |expectedError| - expected error name that should be returned in case shouldSucceed is false
+const testFunctionGeneratorTransient = function(shouldSucceed, endSession, expectedError) {
+ const testFunction = function(session, fakeDeviceController, t) {
+ const hitTestOptionsInit = {
+ profile: "generic-touchscreen",
+ offsetRay: new XRRay(),
+ };
+
+ if(endSession) {
+ session.end();
+ }
+
+ return session.requestHitTestSourceForTransientInput(hitTestOptionsInit)
+ .then((hitTestSource) => {
+ t.step(() => {
+ assert_true(shouldSucceed,
+ "`requestHitTestSourceForTransientInput` succeeded when it was expected to fail");
+ });
+ }).catch((error) => {
+ t.step(() => {
+ assert_false(shouldSucceed,
+ "`requestHitTestSourceForTransientInput` failed when it was expected to succeed, error: " + error);
+ assert_equals(error.name, expectedError,
+ "`requestHitTestSourceForTransientInput` failed with unexpected error name");
+ });
+ });
+ };
+
+ return testFunction;
+};
+
+xr_session_promise_test("Transient hit test subscription succeeds if the feature was requested",
+ testFunctionGeneratorTransient(/*shouldSucceed=*/true, /*endSession=*/false),
+ fakeDeviceInitParams,
+ 'immersive-ar', { 'requiredFeatures': ['hit-test'] });
+
+xr_session_promise_test("Transient hit test subscription fails if the feature was not requested",
+ testFunctionGeneratorTransient(/*shouldSucceed=*/false, /*endSession=*/false, "NotSupportedError"),
+ fakeDeviceInitParams,
+ 'immersive-ar', {});
+
+xr_session_promise_test("Transient test subscription fails if the feature was requested but the session already ended",
+ testFunctionGeneratorTransient(/*shouldSucceed=*/false, /*endSession=*/true, "InvalidStateError"),
+ fakeDeviceInitParams,
+ 'immersive-ar', { 'requiredFeatures': ['hit-test'] });
+
+</script>
diff --git a/testing/web-platform/tests/webxr/hit-test/ar_hittest_subscription_transientInputSources.https.html b/testing/web-platform/tests/webxr/hit-test/ar_hittest_subscription_transientInputSources.https.html
new file mode 100644
index 0000000000..9e0347963c
--- /dev/null
+++ b/testing/web-platform/tests/webxr/hit-test/ar_hittest_subscription_transientInputSources.https.html
@@ -0,0 +1,177 @@
+<!DOCTYPE html>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="../resources/webxr_util.js"></script>
+<script src="../resources/webxr_math_utils.js"></script>
+<script src="../resources/webxr_test_asserts.js"></script>
+<script src="../resources/webxr_test_constants.js"></script>
+<script src="../resources/webxr_test_constants_fake_world.js"></script>
+
+<script>
+
+// 1m above world origin.
+const VIEWER_ORIGIN_TRANSFORM = {
+ position: [0, 1, 0],
+ orientation: [0, 0, 0, 1],
+};
+
+// 0.25m above world origin.
+const FLOOR_ORIGIN_TRANSFORM = {
+ position: [0, -0.25, 0],
+ orientation: [0, 0, 0, 1],
+};
+
+// Start the screen pointer at the same place as the viewer, so it's essentially
+// coming straight forward from the middle of the screen.
+const SCREEN_POINTER_TRANSFORM = VIEWER_ORIGIN_TRANSFORM;
+
+const screen_controller_init = {
+ handedness: "none",
+ targetRayMode: "screen",
+ pointerOrigin: SCREEN_POINTER_TRANSFORM, // aka mojo_from_pointer
+ profiles: ["generic-touchscreen",]
+};
+
+const fakeDeviceInitParams = {
+ supportedModes: ["immersive-ar"],
+ views: VALID_VIEWS,
+ floorOrigin: FLOOR_ORIGIN_TRANSFORM, // aka floor_from_mojo
+ viewerOrigin: VIEWER_ORIGIN_TRANSFORM, // aka mojo_from_viewer
+ supportedFeatures: ALL_FEATURES,
+ world: createFakeWorld(5.0, 2.0, 5.0), // see webxr_test_constants_fake_world.js for details
+};
+
+// Generates a test function given the parameters for the transient hit test.
+// |ray| - ray that will be used to subscribe to hit test.
+// |expectedPoses| - array of expected pose objects. The poses should be expressed in local space.
+// Null entries in the array mean that the given entry will not be validated.
+// |inputFromPointer| - input from pointer transform that will be used as the input source's
+// inputFromPointer (aka pointer origin) in subsequent rAF.
+// |nextFrameExpectedPoses| - array of expected pose objects. The poses should be expressed in local space.
+// Null entries in the array mean that the given entry will not be validated.
+let testFunctionGenerator = function(ray, expectedPoses, inputFromPointer, nextFrameExpectedPoses) {
+ const testFunction = function(session, fakeDeviceController, t) {
+ let debug = xr_debug.bind(this, 'testFunction');
+ return session.requestReferenceSpace('local').then((localRefSpace) => new Promise((resolve, reject) => {
+
+ const input_source_controller = fakeDeviceController.simulateInputSourceConnection(screen_controller_init);
+
+ requestSkipAnimationFrame(session, (time, frame) => {
+ debug('rAF 1');
+ t.step(() => {
+ assert_equals(session.inputSources.length, 1);
+ });
+
+ const hitTestOptionsInit = {
+ profile: "generic-touchscreen",
+ offsetRay: ray,
+ };
+
+ session.requestHitTestSourceForTransientInput(hitTestOptionsInit)
+ .then((hitTestSource) => {
+ t.step(() => {
+ assert_not_equals(hitTestSource, null);
+ });
+
+ // We got a hit test source, now get the results in subsequent rAFcb:
+ session.requestAnimationFrame((time, frame) => {
+ debug('rAF 2');
+ const results = frame.getHitTestResultsForTransientInput(hitTestSource);
+
+ t.step(() => {
+ assert_true(results != null, "Transient input hit tests should not be null");
+ assert_equals(results.length, 1, "There should be exactly one group of transient hit test results!");
+ assert_equals(results[0].results.length, expectedPoses.length);
+ for(const [index, expectedPose] of expectedPoses.entries()) {
+ const pose = results[0].results[index].getPose(localRefSpace);
+ assert_true(pose != null, "Each hit test result should have a pose in local space");
+ if(expectedPose != null) {
+ assert_transform_approx_equals(pose.transform, expectedPose, FLOAT_EPSILON, "before-move-pose: ");
+ }
+ }
+ });
+
+ input_source_controller.setPointerOrigin(inputFromPointer, false);
+
+ session.requestAnimationFrame((time, frame) => {
+ debug('rAF 3');
+ const results = frame.getHitTestResultsForTransientInput(hitTestSource);
+
+ t.step(() => {
+ assert_equals(results[0].results.length, nextFrameExpectedPoses.length);
+ for(const [index, expectedPose] of nextFrameExpectedPoses.entries()) {
+ const pose = results[0].results[index].getPose(localRefSpace);
+ assert_true(pose != null, "Each hit test result should have a pose in local space");
+ if(expectedPose != null) {
+ assert_transform_approx_equals(pose.transform, expectedPose, FLOAT_EPSILON, "after-move-pose: ");
+ }
+ }
+ });
+
+ debug('resolving');
+ resolve();
+ });
+ });
+ });
+ });
+ }));
+ };
+
+ return testFunction;
+};
+
+
+// Pose of the first expected hit test result - straight ahead of the input source, viewer-facing.
+const pose_1 = {
+ position: {x: 0.0, y: 1.0, z: -2.5, w: 1.0},
+ orientation: {x: 0.0, y: -0.707, z: -0.707, w: 0.0},
+ // Hit test API will set Y axis to the surface normal at the intersection point,
+ // Z axis towards the ray origin and X axis to cross product of Y axis & Z axis.
+ // If the surface normal and Z axis would be parallel, the hit test API
+ // will attempt to use `up` vector ([0, 1, 0]) as the Z axis, and if it so happens that Z axis
+ // and the surface normal would still be parallel, it will use the `right` vector ([1, 0, 0]) as the Z axis.
+ // In this particular case, `up` vector will work so the resulting pose.orientation
+ // becomes a rotation around [0, 1, 1] vector by 180 degrees.
+};
+
+xr_session_promise_test("Ensures subscription to transient hit test works with an XRSpace from input source - no move",
+ testFunctionGenerator(new XRRay(), [pose_1], SCREEN_POINTER_TRANSFORM, [pose_1]),
+ fakeDeviceInitParams,
+ 'immersive-ar', { 'requiredFeatures': ['hit-test'] });
+
+const moved_pointer_transform_1 = {
+ position: [0, 1, 0],
+ orientation: [ 0.707, 0, 0, 0.707 ] // 90 degrees around X axis = facing up
+};
+
+xr_session_promise_test("Ensures subscription to transient hit test works with an XRSpace from input source - after move - no results",
+ testFunctionGenerator(new XRRay(), [pose_1], moved_pointer_transform_1, []),
+ fakeDeviceInitParams,
+ 'immersive-ar', { 'requiredFeatures': ['hit-test'] });
+
+const pose_2 = {
+ position: {x: -1.443, y: 1.0, z: -2.5, w: 1.0},
+ // Intersection point will be on the same height as the viewer, on the front
+ // wall. Distance from the front wall to viewer is 2.5m, and we are rotating
+ // to the left, so X coordinate of the intersection point will be negative
+ // & equal to -2.5 * tan(30 deg) ~= 1.443m.
+ orientation: {x: 0.5, y: 0.5, z: 0.5, w: 0.5 },
+ // See comment for pose_1.orientation for details.
+ // In this case, the hit test pose will have Y axis facing towards world's
+ // positive Z axis ([0,0,1]), Z axis to the right ([1,0,0]) and X axis
+ // towards world's Y axis ([0,1,0]).
+ // This is equivalent to the rotation around [1, 1, 1] vector by 120 degrees.
+};
+
+const moved_pointer_transform_2 = {
+ position: [0, 1, 0],
+ orientation: [ 0, 0.2588, 0, 0.9659 ] // 30 degrees around Y axis = to the left,
+ // creating 30-60-90 triangle with the front wall
+};
+
+xr_session_promise_test("Ensures subscription to transient hit test works with an XRSpace from input source - after move - 1 result",
+ testFunctionGenerator(new XRRay(), [pose_1], moved_pointer_transform_2, [pose_2]),
+ fakeDeviceInitParams,
+ 'immersive-ar', { 'requiredFeatures': ['hit-test'] });
+
+</script>
diff --git a/testing/web-platform/tests/webxr/hit-test/ar_hittest_subscription_unlocalizable.https.html b/testing/web-platform/tests/webxr/hit-test/ar_hittest_subscription_unlocalizable.https.html
new file mode 100644
index 0000000000..d1cb1a5a5c
--- /dev/null
+++ b/testing/web-platform/tests/webxr/hit-test/ar_hittest_subscription_unlocalizable.https.html
@@ -0,0 +1,108 @@
+<!DOCTYPE html>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="../resources/webxr_util.js"></script>
+<script src="../resources/webxr_math_utils.js"></script>
+<script src="../resources/webxr_test_asserts.js"></script>
+<script src="../resources/webxr_test_constants.js"></script>
+<script src="../resources/webxr_test_constants_fake_world.js"></script>
+
+<script>
+
+// 1m above world origin.
+const VIEWER_ORIGIN_TRANSFORM = {
+ position: [0, 1, 0],
+ orientation: [0, 0, 0, 1],
+};
+
+// 0.25m above world origin.
+const FLOOR_ORIGIN_TRANSFORM = {
+ position: [0, 0.25, 0],
+ orientation: [0, 0, 0, 1],
+};
+
+const fakeDeviceInitParams = {
+ supportedModes: ["immersive-ar"],
+ views: VALID_VIEWS,
+ floorOrigin: FLOOR_ORIGIN_TRANSFORM, // aka mojo_from_floor
+ viewerOrigin: VIEWER_ORIGIN_TRANSFORM, // aka mojo_from_viewer
+ supportedFeatures: ALL_FEATURES,
+ world: createFakeWorld(5.0, 2.0, 5.0), // webxr_test_constants_fake_world.js has detailed description of the fake world
+};
+
+// Generates a test function given the parameters for the hit test. It will subscribe
+// to a hit test using |refSpaceName| and |ray|, and attempt to obtain poses from returned hit
+// test results using a space that is known to be unlocalizable, with the expectation of
+// obtaining a null pose for each hit test result.
+// |ray| - ray that will be used to subscribe to hit test.
+// |refSpaceName| - XRReferenceSpaceType - either 'local', 'local-floor' or 'viewer'.
+let testFunctionGenerator = function(ray, refSpaceName) {
+ const testFunction = function(session, fakeDeviceController, t) {
+
+ const input_source_controller = fakeDeviceController.simulateInputSourceConnection({
+ handedness: "right",
+ targetRayMode: "tracked-pointer",
+ pointerOrigin: IDENTITY_TRANSFORM,
+ profiles: []
+ });
+
+ return Promise.all([
+ session.requestReferenceSpace('local'),
+ session.requestReferenceSpace('viewer'),
+ session.requestReferenceSpace('local-floor'),
+ ]).then(([localRefSpace, viewerRefSpace, localFloorRefSpace]) => {
+
+ const refSpaceNameToSpace = {
+ 'local' : localRefSpace,
+ 'viewer' : viewerRefSpace,
+ 'local-floor' : localFloorRefSpace
+ };
+
+ const hitTestOptionsInit = {
+ space: refSpaceNameToSpace[refSpaceName],
+ offsetRay: ray,
+ };
+
+ return session.requestHitTestSource(hitTestOptionsInit).then(
+ (hitTestSource) => new Promise((resolve, reject) => {
+
+ const requestAnimationFrameCallback = function(time, frame) {
+
+ const hitTestResults = frame.getHitTestResults(hitTestSource);
+
+ t.step(() => {
+ assert_true(session.inputSources.length > 0, "session.inputSources should not be empty!");
+ assert_true(hitTestResults.length > 0, "Results should not be empty!");
+
+ const input_source = session.inputSources[0];
+
+ for(const [index, hitTestResult] of hitTestResults.entries()) {
+ const pose = hitTestResult.getPose(input_source.targetRaySpace);
+ assert_true(pose == null, "Pose should be null since input source is not localizable");
+ }
+ });
+
+ resolve();
+ };
+
+ t.step(() => {
+ assert_true(hitTestSource != null, "Hit test source should not be null");
+ });
+
+ session.requestAnimationFrame(requestAnimationFrameCallback);
+ }));
+ });
+ };
+
+ return testFunction;
+};
+
+// All test cases require local-floor and hit-test.
+const sessionInit = { 'requiredFeatures': ['local-floor', 'hit-test'] };
+
+xr_session_promise_test(
+ "Ensures hit test result returns null pose w/unlocalizable space - viewer space",
+ testFunctionGenerator(new XRRay(), 'viewer'),
+ fakeDeviceInitParams, 'immersive-ar', sessionInit);
+
+</script>
diff --git a/testing/web-platform/tests/webxr/hit-test/idlharness.https.html b/testing/web-platform/tests/webxr/hit-test/idlharness.https.html
new file mode 100644
index 0000000000..f61959b9ed
--- /dev/null
+++ b/testing/web-platform/tests/webxr/hit-test/idlharness.https.html
@@ -0,0 +1,29 @@
+<!doctype html>
+<title>WebXR hit-test IDL tests</title>
+<link rel="help" href="https://immersive-web.github.io/hit-test/">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/WebIDLParser.js"></script>
+<script src="/resources/idlharness.js"></script>
+
+<script>
+ 'use strict';
+
+ idl_test(
+ ['webxr-hit-test'],
+ ['webxr', 'geometry', 'dom'],
+ async idl_array => {
+ idl_array.add_objects({
+ // TODO: XRHitTestSource
+ // TODO: XRTransientInputHitTestSource
+ // TODO: XRHitTestResult
+ // TODO: XRTransientInputHitTestResult
+ XRSession: ['xrSession'],
+ // TODO: XRFrame
+ XRRay: ['new XRRay()'],
+ });
+
+ self.xrSession = await navigator.xr.requestSession("inline");
+ }
+ );
+</script>
diff --git a/testing/web-platform/tests/webxr/hit-test/xrRay_constructor.https.html b/testing/web-platform/tests/webxr/hit-test/xrRay_constructor.https.html
new file mode 100644
index 0000000000..56b3931341
--- /dev/null
+++ b/testing/web-platform/tests/webxr/hit-test/xrRay_constructor.https.html
@@ -0,0 +1,159 @@
+<!DOCTYPE html>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="../resources/webxr_test_constants.js"></script>
+<script src="../resources/webxr_test_asserts.js"></script>
+<script>
+
+let constructor_test_name = "XRRay constructors work";
+
+let constructor_tests = function() {
+ // Constructor tests for XRRay.
+ // Spec: https://immersive-web.github.io/webxr/#xrray-interface
+
+ //
+ // Constructor 1 - from origin and direction
+ //
+
+ {
+ // Check defaults - should be 0,0,0,1 for origin and 0,0,-1,0 for direction,
+ // identity matrix for the transform:
+ let xrRay1 = new XRRay();
+ let xrRay2 = new XRRay({});
+ let xrRay3 = new XRRay({}, {});
+
+ assert_point_approx_equals(
+ xrRay1.origin, {x : 0.0, y : 0.0, z : 0.0, w : 1.0},
+ FLOAT_EPSILON, "origin-default:");
+ assert_point_approx_equals(
+ xrRay1.direction, {x : 0.0, y : 0.0, z : -1.0, w : 0.0},
+ FLOAT_EPSILON, "direction-default:");
+ assert_matrix_approx_equals(
+ xrRay1.matrix, IDENTITY_MATRIX,
+ FLOAT_EPSILON, "matrix-default:");
+
+ assert_ray_approx_equals(xrRay1, xrRay2, FLOAT_EPSILON, "ray1-ray2-default:");
+ assert_ray_approx_equals(xrRay2, xrRay3, FLOAT_EPSILON, "ray2-ray3-default:");
+ }
+
+ {
+ // Check custom value for origin, default for direction:
+ let originDict = {x : 11.0, y : 12.0, z : 13.0, w : 1.0};
+ let xrRay1 = new XRRay(originDict);
+ let xrRay2 = new XRRay(DOMPoint.fromPoint(originDict));
+ let xrRay3 = new XRRay(DOMPointReadOnly.fromPoint(originDict));
+ let matrix1 = [ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 11, 12, 13, 1];
+
+ assert_point_approx_equals(
+ xrRay1.origin, originDict,
+ FLOAT_EPSILON, "origin-custom-direction-default:");
+ assert_point_approx_equals(
+ xrRay1.direction, {x : 0.0, y : 0.0, z : -1.0, w : 0.0},
+ FLOAT_EPSILON, "direction-custom-direction-default:");
+ assert_matrix_approx_equals(
+ xrRay1.matrix, matrix1,
+ FLOAT_EPSILON, "matrix-custom-direction-default:");
+
+ assert_ray_approx_equals(xrRay1, xrRay2, FLOAT_EPSILON, "ray1-ray2-direction-default:");
+ assert_ray_approx_equals(xrRay2, xrRay3, FLOAT_EPSILON, "ray2-ray3-direction-default:");
+ }
+
+ {
+ // Check custom values - ray rotated from -Z onto +X,
+ // not placed at origin:
+ // - from DOMPoint
+ // - from DOMPointReadOnly
+ let originDict = {x : 10.0, y : 10.0, z : 10.0, w : 1.0};
+ let directionDict = {x : 10.0, y : 0.0, z : 0.0, w : 0.0};
+ let directionNorm = {x : 1.0, y : 0.0, z : 0.0, w : 0.0};
+ // column-major
+ let matrix1 = [ 0, 0, 1, 0,
+ 0, 1, 0, 0,
+ -1, 0, 0, 0,
+ 10, 10, 10, 1];
+
+ let xrRay1 = new XRRay(
+ originDict,
+ directionDict);
+
+ let xrRay2 = new XRRay(
+ DOMPoint.fromPoint(originDict),
+ DOMPoint.fromPoint(directionDict));
+
+ let xrRay3 = new XRRay(
+ DOMPointReadOnly.fromPoint(originDict),
+ DOMPointReadOnly.fromPoint(directionDict));
+
+ assert_point_approx_equals(
+ xrRay1.origin, originDict,
+ FLOAT_EPSILON, "origin-custom:");
+ assert_point_approx_equals(
+ xrRay1.direction, directionNorm,
+ FLOAT_EPSILON, "direction-custom:");
+ assert_matrix_approx_equals(
+ xrRay1.matrix, matrix1,
+ FLOAT_EPSILON, "matrix-custom:");
+
+ assert_ray_approx_equals(xrRay1, xrRay2, FLOAT_EPSILON, "ray1-ray2:");
+ assert_ray_approx_equals(xrRay2, xrRay3, FLOAT_EPSILON, "ray2-ray3:");
+ }
+
+ {
+ // Check that we throw exception on direction too close to 0,0,0:
+ let originDict = {x : 10.0, y : 10.0, z : 10.0, w : 1.0};
+ let directionDict = {x : 1.0, y : 0.0, z : 0.0, w : 0.0};
+
+ assert_throws_js(TypeError, () => new XRRay(
+ DOMPoint.fromPoint(originDict),
+ DOMPoint.fromPoint({x : 0.0, y : 0.0, z : 0.0, w : 0.0})
+ ), "Constructor should throw for zero direction");
+
+ assert_throws_js(TypeError, () => new XRRay(
+ DOMPoint.fromPoint(originDict),
+ DOMPoint.fromPoint({x : 1.0, y : 0.0, z : 0.0, w : 0.5})
+ ), "Constructor should throw for nonzero direction w coordinate");
+
+ assert_throws_js(TypeError, () => new XRRay(
+ DOMPoint.fromPoint({x : 10.0, y : 10.0, z : 10.0, w : 0.5}),
+ DOMPoint.fromPoint(directionDict)
+ ), "Constructor should throw for non-1 origin w coordinate");
+ }
+
+ //
+ // Constructor 2 - from rigid transform.
+ //
+
+ {
+ // Not placed at origin, ray rotated by 135 degrees around Y:
+ let originDict = {x : 10.0, y : 10.0, z : 10.0, w : 1.0};
+ let directionQuaternionDict = { x : 0, y : 0.9239, z : 0, w : 0.3827 };
+ let directionNorm2 = { x : -0.707, y : 0.0, z : 0.707, w : 0.0 };
+ let matrix2 = [-0.707, 0, -0.707, 0,
+ 0., 1, 0, 0,
+ 0.707, 0, -0.707, 0,
+ 10., 10, 10., 1];
+
+ let xrRay = new XRRay(
+ new XRRigidTransform(
+ DOMPoint.fromPoint(originDict),
+ DOMPoint.fromPoint(directionQuaternionDict)));
+
+ assert_point_approx_equals(
+ xrRay.origin, originDict,
+ FLOAT_EPSILON, "origin-custom-rigid:");
+ assert_point_approx_equals(
+ xrRay.direction, directionNorm2,
+ FLOAT_EPSILON, "direction-custom-rigid:");
+
+ assert_matrix_approx_equals(
+ xrRay.matrix, matrix2,
+ FLOAT_EPSILON, "matrix-custom-rigid:");
+ }
+};
+
+test(constructor_tests, constructor_test_name);
+
+</script>
diff --git a/testing/web-platform/tests/webxr/hit-test/xrRay_matrix.https.html b/testing/web-platform/tests/webxr/hit-test/xrRay_matrix.https.html
new file mode 100644
index 0000000000..77f134a75f
--- /dev/null
+++ b/testing/web-platform/tests/webxr/hit-test/xrRay_matrix.https.html
@@ -0,0 +1,101 @@
+<!DOCTYPE html>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="../resources/webxr_test_constants.js"></script>
+<script src="../resources/webxr_test_asserts.js"></script>
+<script src="../resources/webxr_math_utils.js"></script>
+<script>
+
+let matrix_tests_name = "XRRay matrix works";
+
+let matrix_tests = function() {
+ // Matrix tests for XRRay.
+ // Spec: https://immersive-web.github.io/webxr/#xrray-interface
+
+ const initialOrigin = {x : 0, y : 0, z : 0, w : 1};
+ const initialDirection = {x : 0, y : 0, z : -1, w : 0};
+
+ // Test 1. Simple translation and rotation.
+ {
+ let originDict = {x : 10.0, y : 10.0, z : 10.0, w : 1.0};
+ let directionDict = {x : 10.0, y : 0.0, z : 0.0, w : 0.0};
+ let directionNorm = {x : 1.0, y : 0.0, z : 0.0, w : 0.0};
+ let xrRay = new XRRay(
+ DOMPoint.fromPoint(originDict),
+ DOMPoint.fromPoint(directionDict));
+
+ let transformedOrigin = normalize_perspective(transform_point_by_matrix(xrRay.matrix, initialOrigin));
+ let transformedDirection = normalize_perspective(transform_point_by_matrix(xrRay.matrix, initialDirection));
+
+ assert_point_approx_equals(
+ originDict, transformedOrigin,
+ FLOAT_EPSILON, "origin-test1:");
+ assert_point_approx_equals(
+ directionNorm, transformedDirection,
+ FLOAT_EPSILON, "direction-test1:");
+ }
+
+ // Test 2. Co-linear direction - rotation by 180 deg.
+ {
+ let originDict = {x : 10.0, y : 10.0, z : 10.0, w : 1.0};
+ let directionDict = {x : 0.0, y : 0.0, z : 1.0, w : 0.0};
+ let directionNorm = {x : 0.0, y : 0.0, z : 1.0, w : 0.0};
+ let xrRay = new XRRay(
+ DOMPoint.fromPoint(originDict),
+ DOMPoint.fromPoint(directionDict));
+
+ let transformedOrigin = normalize_perspective(transform_point_by_matrix(xrRay.matrix, initialOrigin));
+ let transformedDirection = normalize_perspective(transform_point_by_matrix(xrRay.matrix, initialDirection));
+
+ assert_point_approx_equals(
+ originDict, transformedOrigin,
+ FLOAT_EPSILON, "origin-test2:");
+ assert_point_approx_equals(
+ directionNorm, transformedDirection,
+ FLOAT_EPSILON, "direction-test2:");
+ }
+
+ // Test 3. No translation.
+ {
+ let originDict = {x : 0.0, y : 0.0, z : 0.0, w : 1.0};
+ let directionDict = {x : 10.0, y : 0.0, z : 0.0, w : 0.0};
+ let directionNorm = {x : 1.0, y : 0.0, z : 0.0, w : 0.0};
+ let xrRay = new XRRay(
+ DOMPoint.fromPoint(originDict),
+ DOMPoint.fromPoint(directionDict));
+
+ let transformedOrigin = normalize_perspective(transform_point_by_matrix(xrRay.matrix, initialOrigin));
+ let transformedDirection = normalize_perspective(transform_point_by_matrix(xrRay.matrix, initialDirection));
+
+ assert_point_approx_equals(
+ originDict, transformedOrigin,
+ FLOAT_EPSILON, "origin-test3:");
+ assert_point_approx_equals(
+ directionNorm, transformedDirection,
+ FLOAT_EPSILON, "direction-test3:");
+ }
+
+ // Test 4. No rotation.
+ {
+ let originDict = {x : 10.0, y : 10.0, z : 10.0, w : 1.0};
+ let directionDict = {x : 0.0, y : 0.0, z : -1.0, w : 0.0};
+ let directionNorm = {x : 0.0, y : 0.0, z : -1.0, w : 0.0};
+ let xrRay = new XRRay(
+ DOMPoint.fromPoint(originDict),
+ DOMPoint.fromPoint(directionDict));
+
+ let transformedOrigin = normalize_perspective(transform_point_by_matrix(xrRay.matrix, initialOrigin));
+ let transformedDirection = normalize_perspective(transform_point_by_matrix(xrRay.matrix, initialDirection));
+
+ assert_point_approx_equals(
+ originDict, transformedOrigin,
+ FLOAT_EPSILON, "origin-test4:");
+ assert_point_approx_equals(
+ directionNorm, transformedDirection,
+ FLOAT_EPSILON, "direction-test4:");
+ }
+};
+
+test(matrix_tests, matrix_tests_name);
+
+</script>