diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 09:22:09 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 09:22:09 +0000 |
commit | 43a97878ce14b72f0981164f87f2e35e14151312 (patch) | |
tree | 620249daf56c0258faa40cbdcf9cfba06de2a846 /testing/web-platform/tests/resources/chromium/webxr-test.js | |
parent | Initial commit. (diff) | |
download | firefox-43a97878ce14b72f0981164f87f2e35e14151312.tar.xz firefox-43a97878ce14b72f0981164f87f2e35e14151312.zip |
Adding upstream version 110.0.1.upstream/110.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'testing/web-platform/tests/resources/chromium/webxr-test.js')
-rw-r--r-- | testing/web-platform/tests/resources/chromium/webxr-test.js | 2125 |
1 files changed, 2125 insertions, 0 deletions
diff --git a/testing/web-platform/tests/resources/chromium/webxr-test.js b/testing/web-platform/tests/resources/chromium/webxr-test.js new file mode 100644 index 0000000000..452cdfa5e3 --- /dev/null +++ b/testing/web-platform/tests/resources/chromium/webxr-test.js @@ -0,0 +1,2125 @@ +import * as vrMojom from '/gen/device/vr/public/mojom/vr_service.mojom.m.js'; +import {GamepadHand, GamepadMapping} from '/gen/device/gamepad/public/mojom/gamepad.mojom.m.js'; + +// This polyfill library implements the WebXR Test API as specified here: +// https://github.com/immersive-web/webxr-test-api + +const defaultMojoFromFloor = { + matrix: [1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, -1.65, 0, 1] +}; +const default_stage_parameters = { + mojoFromFloor: defaultMojoFromFloor, + bounds: null +}; + +const default_framebuffer_scale = 0.7; + +function getMatrixFromTransform(transform) { + const x = transform.orientation[0]; + const y = transform.orientation[1]; + const z = transform.orientation[2]; + const w = transform.orientation[3]; + + const m11 = 1.0 - 2.0 * (y * y + z * z); + const m21 = 2.0 * (x * y + z * w); + const m31 = 2.0 * (x * z - y * w); + + const m12 = 2.0 * (x * y - z * w); + const m22 = 1.0 - 2.0 * (x * x + z * z); + const m32 = 2.0 * (y * z + x * w); + + const m13 = 2.0 * (x * z + y * w); + const m23 = 2.0 * (y * z - x * w); + const m33 = 1.0 - 2.0 * (x * x + y * y); + + const m14 = transform.position[0]; + const m24 = transform.position[1]; + const m34 = transform.position[2]; + + // Column-major linearized order is expected. + return [m11, m21, m31, 0, + m12, m22, m32, 0, + m13, m23, m33, 0, + m14, m24, m34, 1]; +} + +function getPoseFromTransform(transform) { + const [px, py, pz] = transform.position; + const [ox, oy, oz, ow] = transform.orientation; + return { + position: {x: px, y: py, z: pz}, + orientation: {x: ox, y: oy, z: oz, w: ow}, + }; +} + +function composeGFXTransform(fakeTransformInit) { + return {matrix: getMatrixFromTransform(fakeTransformInit)}; +} + +// Value equality for camera image init objects - they must contain `width` & +// `height` properties and may contain `pixels` property. +function isSameCameraImageInit(rhs, lhs) { + return lhs.width === rhs.width && lhs.height === rhs.height && lhs.pixels === rhs.pixels; +} + +class ChromeXRTest { + constructor() { + this.mockVRService_ = new MockVRService(); + } + + // WebXR Test API + simulateDeviceConnection(init_params) { + return Promise.resolve(this.mockVRService_._addRuntime(init_params)); + } + + disconnectAllDevices() { + this.mockVRService_._removeAllRuntimes(); + return Promise.resolve(); + } + + simulateUserActivation(callback) { + if (window.top !== window) { + // test_driver.click only works for the toplevel frame. This alternate + // Chrome-specific method is sufficient for starting an XR session in an + // iframe, and is used in platform-specific tests. + // + // TODO(https://github.com/web-platform-tests/wpt/issues/20282): use + // a cross-platform method if available. + xr_debug('simulateUserActivation', 'use eventSender'); + document.addEventListener('click', callback); + eventSender.mouseMoveTo(0, 0); + eventSender.mouseDown(); + eventSender.mouseUp(); + document.removeEventListener('click', callback); + return; + } + const button = document.createElement('button'); + button.textContent = 'click to continue test'; + button.style.display = 'block'; + button.style.fontSize = '20px'; + button.style.padding = '10px'; + button.onclick = () => { + callback(); + document.body.removeChild(button); + }; + document.body.appendChild(button); + test_driver.click(button); + } + + // Helper method leveraged by chrome-specific setups. + Debug(name, msg) { + console.log(new Date().toISOString() + ' DEBUG[' + name + '] ' + msg); + } +} + +// Mocking class definitions + +// Mock service implements the VRService mojo interface. +class MockVRService { + constructor() { + this.receiver_ = new vrMojom.VRServiceReceiver(this); + this.runtimes_ = []; + + this.interceptor_ = + new MojoInterfaceInterceptor(vrMojom.VRService.$interfaceName); + this.interceptor_.oninterfacerequest = + e => this.receiver_.$.bindHandle(e.handle); + this.interceptor_.start(); + } + + // WebXR Test API Implementation Helpers + _addRuntime(fakeDeviceInit) { + const runtime = new MockRuntime(fakeDeviceInit, this); + this.runtimes_.push(runtime); + + if (this.client_) { + this.client_.onDeviceChanged(); + } + + return runtime; + } + + _removeAllRuntimes() { + if (this.client_) { + this.client_.onDeviceChanged(); + } + + this.runtimes_ = []; + } + + _removeRuntime(device) { + const index = this.runtimes_.indexOf(device); + if (index >= 0) { + this.runtimes_.splice(index, 1); + if (this.client_) { + this.client_.onDeviceChanged(); + } + } + } + + // VRService overrides + setClient(client) { + if (this.client_) { + throw new Error("setClient should only be called once"); + } + + this.client_ = client; + } + + requestSession(sessionOptions) { + const requests = []; + // Request a session from all the runtimes. + for (let i = 0; i < this.runtimes_.length; i++) { + requests[i] = this.runtimes_[i]._requestRuntimeSession(sessionOptions); + } + + return Promise.all(requests).then((results) => { + // Find and return the first successful result. + for (let i = 0; i < results.length; i++) { + if (results[i].session) { + // Construct a dummy metrics recorder + const metricsRecorderPtr = new vrMojom.XRSessionMetricsRecorderRemote(); + metricsRecorderPtr.$.bindNewPipeAndPassReceiver().handle.close(); + + const success = { + session: results[i].session, + metricsRecorder: metricsRecorderPtr, + }; + + return {result: {success}}; + } + } + + // If there were no successful results, returns a null session. + return { + result: {failureReason: vrMojom.RequestSessionError.NO_RUNTIME_FOUND} + }; + }); + } + + supportsSession(sessionOptions) { + const requests = []; + // Check supports on all the runtimes. + for (let i = 0; i < this.runtimes_.length; i++) { + requests[i] = this.runtimes_[i]._runtimeSupportsSession(sessionOptions); + } + + return Promise.all(requests).then((results) => { + // Find and return the first successful result. + for (let i = 0; i < results.length; i++) { + if (results[i].supportsSession) { + return results[i]; + } + } + + // If there were no successful results, returns false. + return {supportsSession: false}; + }); + } + + exitPresent() { + return Promise.resolve(); + } + + setFramesThrottled(throttled) { + this.setFramesThrottledImpl(throttled); + } + + // We cannot override the mojom interceptors via the prototype; so this method + // and the above indirection exist to allow overrides by internal code. + setFramesThrottledImpl(throttled) {} + + // Only handles asynchronous calls to makeXrCompatible. Synchronous calls are + // not supported in Javascript. + makeXrCompatible() { + if (this.runtimes_.length == 0) { + return { + xrCompatibleResult: vrMojom.XrCompatibleResult.kNoDeviceAvailable + }; + } + return {xrCompatibleResult: vrMojom.XrCompatibleResult.kAlreadyCompatible}; + } +} + +class FakeXRAnchorController { + constructor() { + // Private properties. + this.device_ = null; + this.id_ = null; + this.dirty_ = true; + + // Properties backing up public attributes / methods. + this.deleted_ = false; + this.paused_ = false; + this.anchorOrigin_ = XRMathHelper.identity(); + } + + // WebXR Test API (Anchors Extension) + get deleted() { + return this.deleted_; + } + + pauseTracking() { + if(!this.paused_) { + this.paused_ = true; + this.dirty_ = true; + } + } + + resumeTracking() { + if(this.paused_) { + this.paused_ = false; + this.dirty_ = true; + } + } + + stopTracking() { + if(!this.deleted_) { + this.device_._deleteAnchorController(this.id_); + + this.deleted_ = true; + this.dirty_ = true; + } + } + + setAnchorOrigin(anchorOrigin) { + this.anchorOrigin_ = getMatrixFromTransform(anchorOrigin); + this.dirty_ = true; + } + + // Internal implementation: + set id(value) { + this.id_ = value; + } + + set device(value) { + this.device_ = value; + } + + get dirty() { + return this.dirty_; + } + + get paused() { + return this.paused_; + } + + _markProcessed() { + this.dirty_ = false; + } + + _getAnchorOrigin() { + return this.anchorOrigin_; + } +} + +// Implements XRFrameDataProvider and XRPresentationProvider. Maintains a mock +// for XRPresentationProvider. Implements FakeXRDevice test API. +class MockRuntime { + // Mapping from string feature names to the corresponding mojo types. + // This is exposed as a member for extensibility. + static _featureToMojoMap = { + 'viewer': vrMojom.XRSessionFeature.REF_SPACE_VIEWER, + 'local': vrMojom.XRSessionFeature.REF_SPACE_LOCAL, + 'local-floor': vrMojom.XRSessionFeature.REF_SPACE_LOCAL_FLOOR, + 'bounded-floor': vrMojom.XRSessionFeature.REF_SPACE_BOUNDED_FLOOR, + 'unbounded': vrMojom.XRSessionFeature.REF_SPACE_UNBOUNDED, + 'hit-test': vrMojom.XRSessionFeature.HIT_TEST, + 'dom-overlay': vrMojom.XRSessionFeature.DOM_OVERLAY, + 'light-estimation': vrMojom.XRSessionFeature.LIGHT_ESTIMATION, + 'anchors': vrMojom.XRSessionFeature.ANCHORS, + 'depth-sensing': vrMojom.XRSessionFeature.DEPTH, + 'secondary-views': vrMojom.XRSessionFeature.SECONDARY_VIEWS, + 'camera-access': vrMojom.XRSessionFeature.CAMERA_ACCESS, + }; + + static _sessionModeToMojoMap = { + "inline": vrMojom.XRSessionMode.kInline, + "immersive-vr": vrMojom.XRSessionMode.kImmersiveVr, + "immersive-ar": vrMojom.XRSessionMode.kImmersiveAr, + }; + + static _environmentBlendModeToMojoMap = { + "opaque": vrMojom.XREnvironmentBlendMode.kOpaque, + "alpha-blend": vrMojom.XREnvironmentBlendMode.kAlphaBlend, + "additive": vrMojom.XREnvironmentBlendMode.kAdditive, + }; + + static _interactionModeToMojoMap = { + "screen-space": vrMojom.XRInteractionMode.kScreenSpace, + "world-space": vrMojom.XRInteractionMode.kWorldSpace, + }; + + constructor(fakeDeviceInit, service) { + this.sessionClient_ = null; + this.presentation_provider_ = new MockXRPresentationProvider(); + + this.pose_ = null; + this.next_frame_id_ = 0; + this.bounds_ = null; + this.send_mojo_space_reset_ = false; + this.stageParameters_ = null; + this.stageParametersId_ = 1; + + this.service_ = service; + + this.framesOfReference = {}; + + this.input_sources_ = new Map(); + this.next_input_source_index_ = 1; + + // Currently active hit test subscriptons. + this.hitTestSubscriptions_ = new Map(); + // Currently active transient hit test subscriptions. + this.transientHitTestSubscriptions_ = new Map(); + // ID of the next subscription to be assigned. + this.next_hit_test_id_ = 1n; + + this.anchor_controllers_ = new Map(); + // ID of the next anchor to be assigned. + this.next_anchor_id_ = 1n; + // Anchor creation callback (initially null, can be set by tests). + this.anchor_creation_callback_ = null; + + this.depthSensingData_ = null; + this.depthSensingDataDirty_ = false; + + let supportedModes = []; + if (fakeDeviceInit.supportedModes) { + supportedModes = fakeDeviceInit.supportedModes.slice(); + if (fakeDeviceInit.supportedModes.length === 0) { + supportedModes = ["inline"]; + } + } else { + // Back-compat mode. + console.warn("Please use `supportedModes` to signal which modes are supported by this device."); + if (fakeDeviceInit.supportsImmersive == null) { + throw new TypeError("'supportsImmersive' must be set"); + } + + supportedModes = ["inline"]; + if (fakeDeviceInit.supportsImmersive) { + supportedModes.push("immersive-vr"); + } + } + + this.supportedModes_ = this._convertModesToEnum(supportedModes); + if (this.supportedModes_.length == 0) { + console.error("Device has empty supported modes array!"); + throw new InvalidStateError(); + } + + if (fakeDeviceInit.viewerOrigin != null) { + this.setViewerOrigin(fakeDeviceInit.viewerOrigin); + } + + if (fakeDeviceInit.floorOrigin != null) { + this.setFloorOrigin(fakeDeviceInit.floorOrigin); + } + + if (fakeDeviceInit.world) { + this.setWorld(fakeDeviceInit.world); + } + + if (fakeDeviceInit.depthSensingData) { + this.setDepthSensingData(fakeDeviceInit.depthSensingData); + } + + this.defaultFramebufferScale_ = default_framebuffer_scale; + this.enviromentBlendMode_ = this._convertBlendModeToEnum(fakeDeviceInit.environmentBlendMode); + this.interactionMode_ = this._convertInteractionModeToEnum(fakeDeviceInit.interactionMode); + + // This appropriately handles if the coordinates are null + this.setBoundsGeometry(fakeDeviceInit.boundsCoordinates); + + this.setViews(fakeDeviceInit.views, fakeDeviceInit.secondaryViews); + + // Need to support webVR which doesn't have a notion of features + this._setFeatures(fakeDeviceInit.supportedFeatures || []); + } + + // WebXR Test API + setViews(primaryViews, secondaryViews) { + this.cameraImage_ = null; + this.primaryViews_ = []; + this.secondaryViews_ = []; + let xOffset = 0; + if (primaryViews) { + this.primaryViews_ = []; + xOffset = this._setViews(primaryViews, xOffset, this.primaryViews_); + const cameraImage = this._findCameraImage(primaryViews); + + if (cameraImage) { + this.cameraImage_ = cameraImage; + } + } + + if (secondaryViews) { + this.secondaryViews_ = []; + this._setViews(secondaryViews, xOffset, this.secondaryViews_); + const cameraImage = this._findCameraImage(secondaryViews); + + if (cameraImage) { + if (!isSameCameraImageInit(this.cameraImage_, cameraImage)) { + throw new Error("If present, camera resolutions on each view must match each other!" + + " Secondary views' camera doesn't match primary views."); + } + + this.cameraImage_ = cameraImage; + } + } + } + + disconnect() { + this.service_._removeRuntime(this); + this.presentation_provider_._close(); + if (this.sessionClient_) { + this.sessionClient_.$.close(); + this.sessionClient_ = null; + } + + return Promise.resolve(); + } + + setViewerOrigin(origin, emulatedPosition = false) { + const p = origin.position; + const q = origin.orientation; + this.pose_ = { + orientation: { x: q[0], y: q[1], z: q[2], w: q[3] }, + position: { x: p[0], y: p[1], z: p[2] }, + emulatedPosition: emulatedPosition, + angularVelocity: null, + linearVelocity: null, + angularAcceleration: null, + linearAcceleration: null, + inputState: null, + poseIndex: 0 + }; + } + + clearViewerOrigin() { + this.pose_ = null; + } + + setFloorOrigin(floorOrigin) { + if (!this.stageParameters_) { + this.stageParameters_ = default_stage_parameters; + this.stageParameters_.bounds = this.bounds_; + } + + // floorOrigin is passed in as mojoFromFloor. + this.stageParameters_.mojoFromFloor = + {matrix: getMatrixFromTransform(floorOrigin)}; + + this._onStageParametersUpdated(); + } + + clearFloorOrigin() { + if (this.stageParameters_) { + this.stageParameters_ = null; + this._onStageParametersUpdated(); + } + } + + setBoundsGeometry(bounds) { + if (bounds == null) { + this.bounds_ = null; + } else if (bounds.length < 3) { + throw new Error("Bounds must have a length of at least 3"); + } else { + this.bounds_ = bounds; + } + + // We can only set bounds if we have stageParameters set; otherwise, we + // don't know the transform from local space to bounds space. + // We'll cache the bounds so that they can be set in the future if the + // floorLevel transform is set, but we won't update them just yet. + if (this.stageParameters_) { + this.stageParameters_.bounds = this.bounds_; + this._onStageParametersUpdated(); + } + } + + simulateResetPose() { + this.send_mojo_space_reset_ = true; + } + + simulateVisibilityChange(visibilityState) { + let mojoState = null; + switch (visibilityState) { + case "visible": + mojoState = vrMojom.XRVisibilityState.VISIBLE; + break; + case "visible-blurred": + mojoState = vrMojom.XRVisibilityState.VISIBLE_BLURRED; + break; + case "hidden": + mojoState = vrMojom.XRVisibilityState.HIDDEN; + break; + } + if (mojoState && this.sessionClient_) { + this.sessionClient_.onVisibilityStateChanged(mojoState); + } + } + + simulateInputSourceConnection(fakeInputSourceInit) { + const index = this.next_input_source_index_; + this.next_input_source_index_++; + + const source = new MockXRInputSource(fakeInputSourceInit, index, this); + this.input_sources_.set(index, source); + return source; + } + + // WebXR Test API Hit Test extensions + setWorld(world) { + this.world_ = world; + } + + clearWorld() { + this.world_ = null; + } + + // WebXR Test API Anchor extensions + setAnchorCreationCallback(callback) { + this.anchor_creation_callback_ = callback; + } + + setHitTestSourceCreationCallback(callback) { + this.hit_test_source_creation_callback_ = callback; + } + + // WebXR Test API Lighting estimation extensions + setLightEstimate(fakeXrLightEstimateInit) { + if (!fakeXrLightEstimateInit.sphericalHarmonicsCoefficients) { + throw new TypeError("sphericalHarmonicsCoefficients must be set"); + } + + if (fakeXrLightEstimateInit.sphericalHarmonicsCoefficients.length != 27) { + throw new TypeError("Must supply all 27 sphericalHarmonicsCoefficients"); + } + + if (fakeXrLightEstimateInit.primaryLightDirection && fakeXrLightEstimateInit.primaryLightDirection.w != 0) { + throw new TypeError("W component of primaryLightDirection must be 0"); + } + + if (fakeXrLightEstimateInit.primaryLightIntensity && fakeXrLightEstimateInit.primaryLightIntensity.w != 1) { + throw new TypeError("W component of primaryLightIntensity must be 1"); + } + + // If the primaryLightDirection or primaryLightIntensity aren't set, we need to set them + // to the defaults that the spec expects. ArCore will either give us everything or nothing, + // so these aren't nullable on the mojom. + if (!fakeXrLightEstimateInit.primaryLightDirection) { + fakeXrLightEstimateInit.primaryLightDirection = { x: 0.0, y: 1.0, z: 0.0, w: 0.0 }; + } + + if (!fakeXrLightEstimateInit.primaryLightIntensity) { + fakeXrLightEstimateInit.primaryLightIntensity = { x: 0.0, y: 0.0, z: 0.0, w: 1.0 }; + } + + let c = fakeXrLightEstimateInit.sphericalHarmonicsCoefficients; + + this.light_estimate_ = { + lightProbe: { + // XRSphereicalHarmonics + sphericalHarmonics: { + coefficients: [ + { red: c[0], green: c[1], blue: c[2] }, + { red: c[3], green: c[4], blue: c[5] }, + { red: c[6], green: c[7], blue: c[8] }, + { red: c[9], green: c[10], blue: c[11] }, + { red: c[12], green: c[13], blue: c[14] }, + { red: c[15], green: c[16], blue: c[17] }, + { red: c[18], green: c[19], blue: c[20] }, + { red: c[21], green: c[22], blue: c[23] }, + { red: c[24], green: c[25], blue: c[26] } + ] + }, + // Vector3dF + mainLightDirection: { + x: fakeXrLightEstimateInit.primaryLightDirection.x, + y: fakeXrLightEstimateInit.primaryLightDirection.y, + z: fakeXrLightEstimateInit.primaryLightDirection.z + }, + // RgbTupleF32 + mainLightIntensity: { + red: fakeXrLightEstimateInit.primaryLightIntensity.x, + green: fakeXrLightEstimateInit.primaryLightIntensity.y, + blue: fakeXrLightEstimateInit.primaryLightIntensity.z + } + } + } + } + + // WebXR Test API depth Sensing Extensions + setDepthSensingData(depthSensingData) { + for(const key of ["depthData", "normDepthBufferFromNormView", "rawValueToMeters", "width", "height"]) { + if(!(key in depthSensingData)) { + throw new TypeError("Required key not present. Key: " + key); + } + } + + if(depthSensingData.depthData != null) { + // Create new object w/ properties based on the depthSensingData, but + // convert the FakeXRRigidTransformInit into a transformation matrix object. + this.depthSensingData_ = Object.assign({}, + depthSensingData, { + normDepthBufferFromNormView: composeGFXTransform(depthSensingData.normDepthBufferFromNormView), + }); + } else { + throw new TypeError("`depthData` is not set"); + } + + this.depthSensingDataDirty_ = true; + } + + clearDepthSensingData() { + this.depthSensingData_ = null; + this.depthSensingDataDirty_ = true; + } + + // Internal Implementation/Helper Methods + _convertModeToEnum(sessionMode) { + if (sessionMode in MockRuntime._sessionModeToMojoMap) { + return MockRuntime._sessionModeToMojoMap[sessionMode]; + } + + throw new TypeError("Unrecognized value for XRSessionMode enum: " + sessionMode); + } + + _convertModesToEnum(sessionModes) { + return sessionModes.map(mode => this._convertModeToEnum(mode)); + } + + _convertBlendModeToEnum(blendMode) { + if (blendMode in MockRuntime._environmentBlendModeToMojoMap) { + return MockRuntime._environmentBlendModeToMojoMap[blendMode]; + } else { + if (this.supportedModes_.includes(vrMojom.XRSessionMode.kImmersiveAr)) { + return vrMojom.XREnvironmentBlendMode.kAdditive; + } else if (this.supportedModes_.includes( + vrMojom.XRSessionMode.kImmersiveVr)) { + return vrMojom.XREnvironmentBlendMode.kOpaque; + } + } + } + + _convertInteractionModeToEnum(interactionMode) { + if (interactionMode in MockRuntime._interactionModeToMojoMap) { + return MockRuntime._interactionModeToMojoMap[interactionMode]; + } else { + return vrMojom.XRInteractionMode.kWorldSpace; + } + } + + _setViews(deviceViews, xOffset, views) { + for (let i = 0; i < deviceViews.length; i++) { + views[i] = this._getView(deviceViews[i], xOffset); + xOffset += deviceViews[i].resolution.width; + } + + return xOffset; + } + + _findCameraImage(views) { + const viewWithCamera = views.find(view => view.cameraImageInit); + if (viewWithCamera) { + //If we have one view with a camera resolution, all views should have the same camera resolution. + const allViewsHaveSameCamera = views.every( + view => isSameCameraImageInit(view.cameraImageInit, viewWithCamera.cameraImageInit)); + + if (!allViewsHaveSameCamera) { + throw new Error("If present, camera resolutions on each view must match each other!"); + } + + return viewWithCamera.cameraImageInit; + } + + return null; + } + + _onStageParametersUpdated() { + // Indicate for the frame loop that the stage parameters have been updated. + this.stageParametersId_++; + } + + _getDefaultViews() { + if (this.primaryViews_) { + return this.primaryViews_; + } + + const viewport_size = 20; + return [{ + eye: vrMojom.XREye.kLeft, + fieldOfView: { + upDegrees: 48.316, + downDegrees: 50.099, + leftDegrees: 50.899, + rightDegrees: 35.197 + }, + mojoFromView: this._getMojoFromViewerWithOffset(composeGFXTransform({ + position: [-0.032, 0, 0], + orientation: [0, 0, 0, 1] + })), + viewport: { x: 0, y: 0, width: viewport_size, height: viewport_size } + }, + { + eye: vrMojom.XREye.kRight, + fieldOfView: { + upDegrees: 48.316, + downDegrees: 50.099, + leftDegrees: 50.899, + rightDegrees: 35.197 + }, + mojoFromView: this._getMojoFromViewerWithOffset(composeGFXTransform({ + position: [0.032, 0, 0], + orientation: [0, 0, 0, 1] + })), + viewport: { x: viewport_size, y: 0, width: viewport_size, height: viewport_size } + }]; + } + + // This function converts between the matrix provided by the WebXR test API + // and the internal data representation. + _getView(fakeXRViewInit, xOffset) { + let fov = null; + + if (fakeXRViewInit.fieldOfView) { + fov = { + upDegrees: fakeXRViewInit.fieldOfView.upDegrees, + downDegrees: fakeXRViewInit.fieldOfView.downDegrees, + leftDegrees: fakeXRViewInit.fieldOfView.leftDegrees, + rightDegrees: fakeXRViewInit.fieldOfView.rightDegrees + }; + } else { + const m = fakeXRViewInit.projectionMatrix; + + function toDegrees(tan) { + return Math.atan(tan) * 180 / Math.PI; + } + + const leftTan = (1 - m[8]) / m[0]; + const rightTan = (1 + m[8]) / m[0]; + const upTan = (1 + m[9]) / m[5]; + const downTan = (1 - m[9]) / m[5]; + + fov = { + upDegrees: toDegrees(upTan), + downDegrees: toDegrees(downTan), + leftDegrees: toDegrees(leftTan), + rightDegrees: toDegrees(rightTan) + }; + } + + let viewEye = vrMojom.XREye.kNone; + // The eye passed in corresponds to the values in the WebXR spec, which are + // the strings "none", "left", and "right". They should be converted to the + // corresponding values of XREye in vr_service.mojom. + switch(fakeXRViewInit.eye) { + case "none": + viewEye = vrMojom.XREye.kNone; + break; + case "left": + viewEye = vrMojom.XREye.kLeft; + break; + case "right": + viewEye = vrMojom.XREye.kRight; + break; + } + + return { + eye: viewEye, + fieldOfView: fov, + mojoFromView: this._getMojoFromViewerWithOffset(composeGFXTransform(fakeXRViewInit.viewOffset)), + viewport: { + x: xOffset, + y: 0, + width: fakeXRViewInit.resolution.width, + height: fakeXRViewInit.resolution.height + }, + isFirstPersonObserver: fakeXRViewInit.isFirstPersonObserver ? true : false, + viewOffset: composeGFXTransform(fakeXRViewInit.viewOffset) + }; + } + + _setFeatures(supportedFeatures) { + function convertFeatureToMojom(feature) { + if (feature in MockRuntime._featureToMojoMap) { + return MockRuntime._featureToMojoMap[feature]; + } else { + return vrMojom.XRSessionFeature.INVALID; + } + } + + this.supportedFeatures_ = []; + + for (let i = 0; i < supportedFeatures.length; i++) { + const feature = convertFeatureToMojom(supportedFeatures[i]); + if (feature !== vrMojom.XRSessionFeature.INVALID) { + this.supportedFeatures_.push(feature); + } + } + } + + // These methods are intended to be used by MockXRInputSource only. + _addInputSource(source) { + if (!this.input_sources_.has(source.source_id_)) { + this.input_sources_.set(source.source_id_, source); + } + } + + _removeInputSource(source) { + this.input_sources_.delete(source.source_id_); + } + + // These methods are intended to be used by FakeXRAnchorController only. + _deleteAnchorController(controllerId) { + this.anchor_controllers_.delete(controllerId); + } + + // Extension point for non-standard modules. + _injectAdditionalFrameData(options, frameData) { + } + + // Mojo function implementations. + + // XRFrameDataProvider implementation. + getFrameData(options) { + return new Promise((resolve) => { + + const populatePose = () => { + const mojo_space_reset = this.send_mojo_space_reset_; + this.send_mojo_space_reset_ = false; + + if (this.pose_) { + this.pose_.poseIndex++; + } + + // Setting the input_state to null tests a slightly different path than + // the browser tests where if the last input source is removed, the device + // code always sends up an empty array, but it's also valid mojom to send + // up a null array. + let input_state = null; + if (this.input_sources_.size > 0) { + input_state = []; + for (const input_source of this.input_sources_.values()) { + input_state.push(input_source._getInputSourceState()); + } + } + + let frame_views = this.primaryViews_; + for (let i = 0; i < this.primaryViews_.length; i++) { + this.primaryViews_[i].mojoFromView = + this._getMojoFromViewerWithOffset(this.primaryViews_[i].viewOffset); + } + if (this.enabledFeatures_.includes(vrMojom.XRSessionFeature.SECONDARY_VIEWS)) { + for (let i = 0; i < this.secondaryViews_.length; i++) { + this.secondaryViews_[i].mojoFromView = + this._getMojoFromViewerWithOffset(this.secondaryViews_[i].viewOffset); + } + + frame_views = frame_views.concat(this.secondaryViews_); + } + + const frameData = { + mojoFromViewer: this.pose_, + views: frame_views, + mojoSpaceReset: mojo_space_reset, + inputState: input_state, + timeDelta: { + // window.performance.now() is in milliseconds, so convert to microseconds. + microseconds: BigInt(Math.floor(window.performance.now() * 1000)), + }, + frameId: this.next_frame_id_, + bufferHolder: null, + cameraImageSize: this.cameraImage_ ? { + width: this.cameraImage_.width, + height: this.cameraImage_.height + } : null, + renderingTimeRatio: 0, + stageParameters: this.stageParameters_, + stageParametersId: this.stageParametersId_, + lightEstimationData: this.light_estimate_ + }; + + this.next_frame_id_++; + + this._calculateHitTestResults(frameData); + + this._calculateAnchorInformation(frameData); + + this._calculateDepthInformation(frameData); + + this._injectAdditionalFrameData(options, frameData); + + resolve({frameData}); + }; + + if(this.sessionOptions_.mode == vrMojom.XRSessionMode.kInline) { + // Inline sessions should not have a delay introduced since it causes them + // to miss a vsync blink-side and delays propagation of changes that happened + // within a rAFcb by one frame (e.g. setViewerOrigin() calls would take 2 frames + // to propagate). + populatePose(); + } else { + // For immerive sessions, add additional delay to allow for anchor creation + // promises to run. + setTimeout(populatePose, 3); // note: according to MDN, the timeout is not exact + } + }); + } + + getEnvironmentIntegrationProvider(environmentProviderRequest) { + if (this.environmentProviderReceiver_) { + this.environmentProviderReceiver_.$.close(); + } + this.environmentProviderReceiver_ = + new vrMojom.XREnvironmentIntegrationProviderReceiver(this); + this.environmentProviderReceiver_.$.bindHandle( + environmentProviderRequest.handle); + } + + setInputSourceButtonListener(listener) { listener.$.close(); } + + // XREnvironmentIntegrationProvider implementation: + subscribeToHitTest(nativeOriginInformation, entityTypes, ray) { + if (!this.supportedModes_.includes(vrMojom.XRSessionMode.kImmersiveAr)) { + // Reject outside of AR. + return Promise.resolve({ + result : vrMojom.SubscribeToHitTestResult.FAILURE_GENERIC, + subscriptionId : 0n + }); + } + + if (!this._nativeOriginKnown(nativeOriginInformation)) { + return Promise.resolve({ + result : vrMojom.SubscribeToHitTestResult.FAILURE_GENERIC, + subscriptionId : 0n + }); + } + + // Reserve the id for hit test source: + const id = this.next_hit_test_id_++; + const hitTestParameters = { isTransient: false, profileName: null }; + const controller = new FakeXRHitTestSourceController(id); + + + return this._shouldHitTestSourceCreationSucceed(hitTestParameters, controller) + .then((succeeded) => { + if(succeeded) { + // Store the subscription information as-is (including controller): + this.hitTestSubscriptions_.set(id, { nativeOriginInformation, entityTypes, ray, controller }); + + return Promise.resolve({ + result : vrMojom.SubscribeToHitTestResult.SUCCESS, + subscriptionId : id + }); + } else { + return Promise.resolve({ + result : vrMojom.SubscribeToHitTestResult.FAILURE_GENERIC, + subscriptionId : 0n + }); + } + }); + } + + subscribeToHitTestForTransientInput(profileName, entityTypes, ray){ + if (!this.supportedModes_.includes(vrMojom.XRSessionMode.kImmersiveAr)) { + // Reject outside of AR. + return Promise.resolve({ + result : vrMojom.SubscribeToHitTestResult.FAILURE_GENERIC, + subscriptionId : 0n + }); + } + + const id = this.next_hit_test_id_++; + const hitTestParameters = { isTransient: true, profileName: profileName }; + const controller = new FakeXRHitTestSourceController(id); + + // Check if we have hit test source creation callback. + // If yes, ask it if the hit test source creation should succeed. + // If no, for back-compat, assume the hit test source creation succeeded. + return this._shouldHitTestSourceCreationSucceed(hitTestParameters, controller) + .then((succeeded) => { + if(succeeded) { + // Store the subscription information as-is (including controller): + this.transientHitTestSubscriptions_.set(id, { profileName, entityTypes, ray, controller }); + + return Promise.resolve({ + result : vrMojom.SubscribeToHitTestResult.SUCCESS, + subscriptionId : id + }); + } else { + return Promise.resolve({ + result : vrMojom.SubscribeToHitTestResult.FAILURE_GENERIC, + subscriptionId : 0n + }); + } + }); + } + + unsubscribeFromHitTest(subscriptionId) { + let controller = null; + if(this.transientHitTestSubscriptions_.has(subscriptionId)){ + controller = this.transientHitTestSubscriptions_.get(subscriptionId).controller; + this.transientHitTestSubscriptions_.delete(subscriptionId); + } else if(this.hitTestSubscriptions_.has(subscriptionId)){ + controller = this.hitTestSubscriptions_.get(subscriptionId).controller; + this.hitTestSubscriptions_.delete(subscriptionId); + } + + if(controller) { + controller.deleted = true; + } + } + + createAnchor(nativeOriginInformation, nativeOriginFromAnchor) { + return new Promise((resolve) => { + if(this.anchor_creation_callback_ == null) { + resolve({ + result : vrMojom.CreateAnchorResult.FAILURE, + anchorId : 0n + }); + + return; + } + + const mojoFromNativeOrigin = this._getMojoFromNativeOrigin(nativeOriginInformation); + if(mojoFromNativeOrigin == null) { + resolve({ + result : vrMojom.CreateAnchorResult.FAILURE, + anchorId : 0n + }); + + return; + } + + const mojoFromAnchor = XRMathHelper.mul4x4(mojoFromNativeOrigin, nativeOriginFromAnchor); + + const anchorCreationParameters = { + requestedAnchorOrigin: mojoFromAnchor, + isAttachedToEntity: false, + }; + + const anchorController = new FakeXRAnchorController(); + + this.anchor_creation_callback_(anchorCreationParameters, anchorController) + .then((result) => { + if(result) { + // If the test allowed the anchor creation, + // store the anchor controller & return success. + + const anchor_id = this.next_anchor_id_; + this.next_anchor_id_++; + + this.anchor_controllers_.set(anchor_id, anchorController); + anchorController.device = this; + anchorController.id = anchor_id; + + resolve({ + result : vrMojom.CreateAnchorResult.SUCCESS, + anchorId : anchor_id + }); + } else { + // The test has rejected anchor creation. + resolve({ + result : vrMojom.CreateAnchorResult.FAILURE, + anchorId : 0n + }); + } + }) + .catch(() => { + // The test threw an error, treat anchor creation as failed. + resolve({ + result : vrMojom.CreateAnchorResult.FAILURE, + anchorId : 0n + }); + }); + }); + } + + createPlaneAnchor(planeFromAnchor, planeId) { + return new Promise((resolve) => { + + // Not supported yet. + + resolve({ + result : vrMojom.CreateAnchorResult.FAILURE, + anchorId : 0n, + }); + }); + } + + detachAnchor(anchorId) {} + + // Utility function + _requestRuntimeSession(sessionOptions) { + return this._runtimeSupportsSession(sessionOptions).then((result) => { + // The JavaScript bindings convert c_style_names to camelCase names. + const options = { + transportMethod: + vrMojom.XRPresentationTransportMethod.SUBMIT_AS_MAILBOX_HOLDER, + waitForTransferNotification: true, + waitForRenderNotification: true, + waitForGpuFence: false, + }; + + let submit_frame_sink; + if (result.supportsSession) { + submit_frame_sink = { + clientReceiver: this.presentation_provider_._getClientReceiver(), + provider: this.presentation_provider_._bindProvider(sessionOptions), + transportOptions: options + }; + + const dataProviderPtr = new vrMojom.XRFrameDataProviderRemote(); + this.dataProviderReceiver_ = + new vrMojom.XRFrameDataProviderReceiver(this); + this.dataProviderReceiver_.$.bindHandle( + dataProviderPtr.$.bindNewPipeAndPassReceiver().handle); + this.sessionOptions_ = sessionOptions; + + this.sessionClient_ = new vrMojom.XRSessionClientRemote(); + const clientReceiver = this.sessionClient_.$.bindNewPipeAndPassReceiver(); + + const enabled_features = []; + for (let i = 0; i < sessionOptions.requiredFeatures.length; i++) { + if (this.supportedFeatures_.indexOf(sessionOptions.requiredFeatures[i]) !== -1) { + enabled_features.push(sessionOptions.requiredFeatures[i]); + } else { + return Promise.resolve({session: null}); + } + } + + for (let i =0; i < sessionOptions.optionalFeatures.length; i++) { + if (this.supportedFeatures_.indexOf(sessionOptions.optionalFeatures[i]) !== -1) { + enabled_features.push(sessionOptions.optionalFeatures[i]); + } + } + + this.enabledFeatures_ = enabled_features; + + return Promise.resolve({ + session: { + submitFrameSink: submit_frame_sink, + dataProvider: dataProviderPtr, + clientReceiver: clientReceiver, + enabledFeatures: enabled_features, + deviceConfig: { + usesInputEventing: false, + defaultFramebufferScale: this.defaultFramebufferScale_, + supportsViewportScaling: true, + depthConfiguration: + enabled_features.includes(vrMojom.XRSessionFeature.DEPTH) ? { + depthUsage: vrMojom.XRDepthUsage.kCPUOptimized, + depthDataFormat: vrMojom.XRDepthDataFormat.kLuminanceAlpha, + } : null, + views: this._getDefaultViews(), + }, + enviromentBlendMode: this.enviromentBlendMode_, + interactionMode: this.interactionMode_ + } + }); + } else { + return Promise.resolve({session: null}); + } + }); + } + + _runtimeSupportsSession(options) { + let result = this.supportedModes_.includes(options.mode); + + if (options.requiredFeatures.includes(vrMojom.XRSessionFeature.DEPTH) + || options.optionalFeatures.includes(vrMojom.XRSessionFeature.DEPTH)) { + result &= options.depthOptions.usagePreferences.includes(vrMojom.XRDepthUsage.kCPUOptimized); + result &= options.depthOptions.dataFormatPreferences.includes(vrMojom.XRDepthDataFormat.kLuminanceAlpha); + } + + return Promise.resolve({ + supportsSession: result, + }); + } + + // Private functions - utilities: + _nativeOriginKnown(nativeOriginInformation){ + + if (nativeOriginInformation.inputSourceSpaceInfo !== undefined) { + if (!this.input_sources_.has(nativeOriginInformation.inputSourceSpaceInfo.inputSourceId)) { + // Unknown input source. + return false; + } + + return true; + } else if (nativeOriginInformation.referenceSpaceType !== undefined) { + // Bounded_floor & unbounded ref spaces are not yet supported for AR: + if (nativeOriginInformation.referenceSpaceType == vrMojom.XRReferenceSpaceType.kUnbounded + || nativeOriginInformation.referenceSpaceType == vrMojom.XRReferenceSpaceType.kBoundedFloor) { + return false; + } + + return true; + } else { + // Planes and anchors are not yet supported by the mock interface. + return false; + } + } + + // Private functions - anchors implementation: + + // Modifies passed in frameData to add anchor information. + _calculateAnchorInformation(frameData) { + if (!this.supportedModes_.includes(vrMojom.XRSessionMode.kImmersiveAr)) { + return; + } + + frameData.anchorsData = {allAnchorsIds: [], updatedAnchorsData: []}; + for(const [id, controller] of this.anchor_controllers_) { + frameData.anchorsData.allAnchorsIds.push(id); + + // Send the entire anchor data over if there was a change since last GetFrameData(). + if(controller.dirty) { + const anchorData = {id}; + if(!controller.paused) { + anchorData.mojoFromAnchor = getPoseFromTransform( + XRMathHelper.decomposeRigidTransform( + controller._getAnchorOrigin())); + } + + controller._markProcessed(); + + frameData.anchorsData.updatedAnchorsData.push(anchorData); + } + } + } + + // Private functions - depth sensing implementation: + + // Modifies passed in frameData to add anchor information. + _calculateDepthInformation(frameData) { + if (!this.supportedModes_.includes(vrMojom.XRSessionMode.kImmersiveAr)) { + return; + } + + if (!this.enabledFeatures_.includes(vrMojom.XRSessionFeature.DEPTH)) { + return; + } + + // If we don't have a current depth data, we'll return null + // (i.e. no data is not a valid data, so it cannot be "StillValid"). + if (this.depthSensingData_ == null) { + frameData.depthData = null; + return; + } + + if(!this.depthSensingDataDirty_) { + frameData.depthData = { dataStillValid: {}}; + return; + } + + frameData.depthData = { + updatedDepthData: { + timeDelta: frameData.timeDelta, + normTextureFromNormView: this.depthSensingData_.normDepthBufferFromNormView, + rawValueToMeters: this.depthSensingData_.rawValueToMeters, + size: { width: this.depthSensingData_.width, height: this.depthSensingData_.height }, + pixelData: { bytes: this.depthSensingData_.depthData } + } + }; + + this.depthSensingDataDirty_ = false; + } + + // Private functions - hit test implementation: + + // Returns a Promise<bool> that signifies whether hit test source creation should succeed. + // If we have a hit test source creation callback installed, invoke it and return its result. + // If it's not installed, for back-compat just return a promise that resolves to true. + _shouldHitTestSourceCreationSucceed(hitTestParameters, controller) { + if(this.hit_test_source_creation_callback_) { + return this.hit_test_source_creation_callback_(hitTestParameters, controller); + } else { + return Promise.resolve(true); + } + } + + // Modifies passed in frameData to add hit test results. + _calculateHitTestResults(frameData) { + if (!this.supportedModes_.includes(vrMojom.XRSessionMode.kImmersiveAr)) { + return; + } + + frameData.hitTestSubscriptionResults = {results: [], + transientInputResults: []}; + if (!this.world_) { + return; + } + + // Non-transient hit test: + for (const [id, subscription] of this.hitTestSubscriptions_) { + const mojo_from_native_origin = this._getMojoFromNativeOrigin(subscription.nativeOriginInformation); + if (!mojo_from_native_origin) continue; + + const [mojo_ray_origin, mojo_ray_direction] = this._transformRayToMojoSpace( + subscription.ray, + mojo_from_native_origin + ); + + const results = this._hitTestWorld(mojo_ray_origin, mojo_ray_direction, subscription.entityTypes); + frameData.hitTestSubscriptionResults.results.push( + {subscriptionId: id, hitTestResults: results}); + } + + // Transient hit test: + const mojo_from_viewer = this._getMojoFromViewer(); + + for (const [id, subscription] of this.transientHitTestSubscriptions_) { + const result = {subscriptionId: id, + inputSourceIdToHitTestResults: new Map()}; + + // Find all input sources that match the profile name: + const matching_input_sources = Array.from(this.input_sources_.values()) + .filter(input_source => input_source.profiles_.includes(subscription.profileName)); + + for (const input_source of matching_input_sources) { + const mojo_from_native_origin = input_source._getMojoFromInputSource(mojo_from_viewer); + + const [mojo_ray_origin, mojo_ray_direction] = this._transformRayToMojoSpace( + subscription.ray, + mojo_from_native_origin + ); + + const results = this._hitTestWorld(mojo_ray_origin, mojo_ray_direction, subscription.entityTypes); + + result.inputSourceIdToHitTestResults.set(input_source.source_id_, results); + } + + frameData.hitTestSubscriptionResults.transientInputResults.push(result); + } + } + + // Returns 2-element array [origin, direction] of a ray in mojo space. + // |ray| is expressed relative to native origin. + _transformRayToMojoSpace(ray, mojo_from_native_origin) { + const ray_origin = { + x: ray.origin.x, + y: ray.origin.y, + z: ray.origin.z, + w: 1 + }; + const ray_direction = { + x: ray.direction.x, + y: ray.direction.y, + z: ray.direction.z, + w: 0 + }; + + const mojo_ray_origin = XRMathHelper.transform_by_matrix( + mojo_from_native_origin, + ray_origin); + const mojo_ray_direction = XRMathHelper.transform_by_matrix( + mojo_from_native_origin, + ray_direction); + + return [mojo_ray_origin, mojo_ray_direction]; + } + + // Hit tests the passed in ray (expressed as origin and direction) against the mocked world data. + _hitTestWorld(origin, direction, entityTypes) { + let result = []; + + for (const region of this.world_.hitTestRegions) { + const partial_result = this._hitTestRegion( + region, + origin, direction, + entityTypes); + + result = result.concat(partial_result); + } + + return result.sort((lhs, rhs) => lhs.distance - rhs.distance).map((hitTest) => { + delete hitTest.distance; + return hitTest; + }); + } + + // Hit tests the passed in ray (expressed as origin and direction) against world region. + // |entityTypes| is a set of FakeXRRegionTypes. + // |region| is FakeXRRegion. + // Returns array of XRHitResults, each entry will be decorated with the distance from the ray origin (along the ray). + _hitTestRegion(region, origin, direction, entityTypes) { + const regionNameToMojoEnum = { + "point": vrMojom.EntityTypeForHitTest.POINT, + "plane": vrMojom.EntityTypeForHitTest.PLANE, + "mesh":null + }; + + if (!entityTypes.includes(regionNameToMojoEnum[region.type])) { + return []; + } + + const result = []; + for (const face of region.faces) { + const maybe_hit = this._hitTestFace(face, origin, direction); + if (maybe_hit) { + result.push(maybe_hit); + } + } + + // The results should be sorted by distance and there should be no 2 entries with + // the same distance from ray origin - that would mean they are the same point. + // This situation is possible when a ray intersects the region through an edge shared + // by 2 faces. + return result.sort((lhs, rhs) => lhs.distance - rhs.distance) + .filter((val, index, array) => index === 0 || val.distance !== array[index - 1].distance); + } + + // Hit tests the passed in ray (expressed as origin and direction) against a single face. + // |face|, |origin|, and |direction| are specified in world (aka mojo) coordinates. + // |face| is an array of DOMPointInits. + // Returns null if the face does not intersect with the ray, otherwise the result is + // an XRHitResult with matrix describing the pose of the intersection point. + _hitTestFace(face, origin, direction) { + const add = XRMathHelper.add; + const sub = XRMathHelper.sub; + const mul = XRMathHelper.mul; + const normalize = XRMathHelper.normalize; + const dot = XRMathHelper.dot; + const cross = XRMathHelper.cross; + const neg = XRMathHelper.neg; + + //1. Calculate plane normal in world coordinates. + const point_A = face.vertices[0]; + const point_B = face.vertices[1]; + const point_C = face.vertices[2]; + + const edge_AB = sub(point_B, point_A); + const edge_AC = sub(point_C, point_A); + + const normal = normalize(cross(edge_AB, edge_AC)); + + const numerator = dot(sub(point_A, origin), normal); + const denominator = dot(direction, normal); + + if (Math.abs(denominator) < XRMathHelper.EPSILON) { + // Planes are nearly parallel - there's either infinitely many intersection points or 0. + // Both cases signify a "no hit" for us. + return null; + } else { + // Single intersection point between the infinite plane and the line (*not* ray). + // Need to calculate the hit test matrix taking into account the face vertices. + const distance = numerator / denominator; + if (distance < 0) { + // Line - plane intersection exists, but not the half-line - plane does not. + return null; + } else { + const intersection_point = add(origin, mul(distance, direction)); + // Since we are treating the face as a solid, flip the normal so that its + // half-space will contain the ray origin. + const y_axis = denominator > 0 ? neg(normal) : normal; + + let z_axis = null; + const cos_direction_and_y_axis = dot(direction, y_axis); + if (Math.abs(cos_direction_and_y_axis) > (1 - XRMathHelper.EPSILON)) { + // Ray and the hit test normal are co-linear - try using the 'up' or 'right' vector's projection on the face plane as the Z axis. + // Note: this edge case is currently not covered by the spec. + const up = {x: 0.0, y: 1.0, z: 0.0, w: 0.0}; + const right = {x: 1.0, y: 0.0, z: 0.0, w: 0.0}; + + z_axis = Math.abs(dot(up, y_axis)) > (1 - XRMathHelper.EPSILON) + ? sub(up, mul(dot(right, y_axis), y_axis)) // `up is also co-linear with hit test normal, use `right` + : sub(up, mul(dot(up, y_axis), y_axis)); // `up` is not co-linear with hit test normal, use it + } else { + // Project the ray direction onto the plane, negate it and use as a Z axis. + z_axis = neg(sub(direction, mul(cos_direction_and_y_axis, y_axis))); // Z should point towards the ray origin, not away. + } + + z_axis = normalize(z_axis); + const x_axis = normalize(cross(y_axis, z_axis)); + + // Filter out the points not in polygon. + if (!XRMathHelper.pointInFace(intersection_point, face)) { + return null; + } + + const hitResult = {planeId: 0n}; + hitResult.distance = distance; // Extend the object with additional information used by higher layers. + // It will not be serialized over mojom. + + const matrix = new Array(16); + + matrix[0] = x_axis.x; + matrix[1] = x_axis.y; + matrix[2] = x_axis.z; + matrix[3] = 0; + + matrix[4] = y_axis.x; + matrix[5] = y_axis.y; + matrix[6] = y_axis.z; + matrix[7] = 0; + + matrix[8] = z_axis.x; + matrix[9] = z_axis.y; + matrix[10] = z_axis.z; + matrix[11] = 0; + + matrix[12] = intersection_point.x; + matrix[13] = intersection_point.y; + matrix[14] = intersection_point.z; + matrix[15] = 1; + + hitResult.mojoFromResult = getPoseFromTransform( + XRMathHelper.decomposeRigidTransform(matrix)); + return hitResult; + } + } + } + + _getMojoFromViewer() { + if (!this.pose_) { + return XRMathHelper.identity(); + } + const transform = { + position: [ + this.pose_.position.x, + this.pose_.position.y, + this.pose_.position.z], + orientation: [ + this.pose_.orientation.x, + this.pose_.orientation.y, + this.pose_.orientation.z, + this.pose_.orientation.w], + }; + + return getMatrixFromTransform(transform); + } + + _getMojoFromViewerWithOffset(viewOffset) { + return { matrix: XRMathHelper.mul4x4(this._getMojoFromViewer(), viewOffset.matrix) }; + } + + _getMojoFromNativeOrigin(nativeOriginInformation) { + const mojo_from_viewer = this._getMojoFromViewer(); + + if (nativeOriginInformation.inputSourceSpaceInfo !== undefined) { + if (!this.input_sources_.has(nativeOriginInformation.inputSourceSpaceInfo.inputSourceId)) { + return null; + } else { + const inputSource = this.input_sources_.get(nativeOriginInformation.inputSourceSpaceInfo.inputSourceId); + return inputSource._getMojoFromInputSource(mojo_from_viewer); + } + } else if (nativeOriginInformation.referenceSpaceType !== undefined) { + switch (nativeOriginInformation.referenceSpaceType) { + case vrMojom.XRReferenceSpaceType.kLocal: + return XRMathHelper.identity(); + case vrMojom.XRReferenceSpaceType.kLocalFloor: + if (this.stageParameters_ == null || this.stageParameters_.mojoFromFloor == null) { + console.warn("Standing transform not available."); + return null; + } + return this.stageParameters_.mojoFromFloor.matrix; + case vrMojom.XRReferenceSpaceType.kViewer: + return mojo_from_viewer; + case vrMojom.XRReferenceSpaceType.kBoundedFloor: + return null; + case vrMojom.XRReferenceSpaceType.kUnbounded: + return null; + default: + throw new TypeError("Unrecognized XRReferenceSpaceType!"); + } + } else { + // Anchors & planes are not yet supported for hit test. + return null; + } + } +} + +class MockXRInputSource { + constructor(fakeInputSourceInit, id, pairedDevice) { + this.source_id_ = id; + this.pairedDevice_ = pairedDevice; + this.handedness_ = fakeInputSourceInit.handedness; + this.target_ray_mode_ = fakeInputSourceInit.targetRayMode; + + if (fakeInputSourceInit.pointerOrigin == null) { + throw new TypeError("FakeXRInputSourceInit.pointerOrigin is required."); + } + + this.setPointerOrigin(fakeInputSourceInit.pointerOrigin); + this.setProfiles(fakeInputSourceInit.profiles); + + this.primary_input_pressed_ = false; + if (fakeInputSourceInit.selectionStarted != null) { + this.primary_input_pressed_ = fakeInputSourceInit.selectionStarted; + } + + this.primary_input_clicked_ = false; + if (fakeInputSourceInit.selectionClicked != null) { + this.primary_input_clicked_ = fakeInputSourceInit.selectionClicked; + } + + this.primary_squeeze_pressed_ = false; + this.primary_squeeze_clicked_ = false; + + this.mojo_from_input_ = null; + if (fakeInputSourceInit.gripOrigin != null) { + this.setGripOrigin(fakeInputSourceInit.gripOrigin); + } + + // This properly handles if supportedButtons were not specified. + this.setSupportedButtons(fakeInputSourceInit.supportedButtons); + + this.emulated_position_ = false; + this.desc_dirty_ = true; + } + + // WebXR Test API + setHandedness(handedness) { + if (this.handedness_ != handedness) { + this.desc_dirty_ = true; + this.handedness_ = handedness; + } + } + + setTargetRayMode(targetRayMode) { + if (this.target_ray_mode_ != targetRayMode) { + this.desc_dirty_ = true; + this.target_ray_mode_ = targetRayMode; + } + } + + setProfiles(profiles) { + this.desc_dirty_ = true; + this.profiles_ = profiles; + } + + setGripOrigin(transform, emulatedPosition = false) { + // grip_origin was renamed to mojo_from_input in mojo + this.mojo_from_input_ = composeGFXTransform(transform); + this.emulated_position_ = emulatedPosition; + + // Technically, setting the grip shouldn't make the description dirty, but + // the webxr-test-api sets our pointer as mojoFromPointer; however, we only + // support it across mojom as inputFromPointer, so we need to recalculate it + // whenever the grip moves. + this.desc_dirty_ = true; + } + + clearGripOrigin() { + // grip_origin was renamed to mojo_from_input in mojo + if (this.mojo_from_input_ != null) { + this.mojo_from_input_ = null; + this.emulated_position_ = false; + this.desc_dirty_ = true; + } + } + + setPointerOrigin(transform, emulatedPosition = false) { + // pointer_origin is mojo_from_pointer. + this.desc_dirty_ = true; + this.mojo_from_pointer_ = composeGFXTransform(transform); + this.emulated_position_ = emulatedPosition; + } + + disconnect() { + this.pairedDevice_._removeInputSource(this); + } + + reconnect() { + this.pairedDevice_._addInputSource(this); + } + + startSelection() { + this.primary_input_pressed_ = true; + if (this.gamepad_) { + this.gamepad_.buttons[0].pressed = true; + this.gamepad_.buttons[0].touched = true; + } + } + + endSelection() { + if (!this.primary_input_pressed_) { + throw new Error("Attempted to end selection which was not started"); + } + + this.primary_input_pressed_ = false; + this.primary_input_clicked_ = true; + + if (this.gamepad_) { + this.gamepad_.buttons[0].pressed = false; + this.gamepad_.buttons[0].touched = false; + } + } + + simulateSelect() { + this.primary_input_clicked_ = true; + } + + setSupportedButtons(supportedButtons) { + this.gamepad_ = null; + this.supported_buttons_ = []; + + // If there are no supported buttons, we can stop now. + if (supportedButtons == null || supportedButtons.length < 1) { + return; + } + + const supported_button_map = {}; + this.gamepad_ = this._getEmptyGamepad(); + for (let i = 0; i < supportedButtons.length; i++) { + const buttonType = supportedButtons[i].buttonType; + this.supported_buttons_.push(buttonType); + supported_button_map[buttonType] = supportedButtons[i]; + } + + // Let's start by building the button state in order of priority: + // Primary button is index 0. + this.gamepad_.buttons.push({ + pressed: this.primary_input_pressed_, + touched: this.primary_input_pressed_, + value: this.primary_input_pressed_ ? 1.0 : 0.0 + }); + + // Now add the rest of our buttons + this._addGamepadButton(supported_button_map['grip']); + this._addGamepadButton(supported_button_map['touchpad']); + this._addGamepadButton(supported_button_map['thumbstick']); + this._addGamepadButton(supported_button_map['optional-button']); + this._addGamepadButton(supported_button_map['optional-thumbstick']); + + // Finally, back-fill placeholder buttons/axes + for (let i = 0; i < this.gamepad_.buttons.length; i++) { + if (this.gamepad_.buttons[i] == null) { + this.gamepad_.buttons[i] = { + pressed: false, + touched: false, + value: 0 + }; + } + } + + for (let i=0; i < this.gamepad_.axes.length; i++) { + if (this.gamepad_.axes[i] == null) { + this.gamepad_.axes[i] = 0; + } + } + } + + updateButtonState(buttonState) { + if (this.supported_buttons_.indexOf(buttonState.buttonType) == -1) { + throw new Error("Tried to update state on an unsupported button"); + } + + const buttonIndex = this._getButtonIndex(buttonState.buttonType); + const axesStartIndex = this._getAxesStartIndex(buttonState.buttonType); + + if (buttonIndex == -1) { + throw new Error("Unknown Button Type!"); + } + + // is this a 'squeeze' button? + if (buttonIndex === this._getButtonIndex('grip')) { + // squeeze + if (buttonState.pressed) { + this.primary_squeeze_pressed_ = true; + } else if (this.gamepad_.buttons[buttonIndex].pressed) { + this.primary_squeeze_clicked_ = true; + this.primary_squeeze_pressed_ = false; + } else { + this.primary_squeeze_clicked_ = false; + this.primary_squeeze_pressed_ = false; + } + } + + this.gamepad_.buttons[buttonIndex].pressed = buttonState.pressed; + this.gamepad_.buttons[buttonIndex].touched = buttonState.touched; + this.gamepad_.buttons[buttonIndex].value = buttonState.pressedValue; + + if (axesStartIndex != -1) { + this.gamepad_.axes[axesStartIndex] = buttonState.xValue == null ? 0.0 : buttonState.xValue; + this.gamepad_.axes[axesStartIndex + 1] = buttonState.yValue == null ? 0.0 : buttonState.yValue; + } + } + + // DOM Overlay Extensions + setOverlayPointerPosition(x, y) { + this.overlay_pointer_position_ = {x: x, y: y}; + } + + // Helpers for Mojom + _getInputSourceState() { + const input_state = {}; + + input_state.sourceId = this.source_id_; + input_state.isAuxiliary = false; + + input_state.primaryInputPressed = this.primary_input_pressed_; + input_state.primaryInputClicked = this.primary_input_clicked_; + + input_state.primarySqueezePressed = this.primary_squeeze_pressed_; + input_state.primarySqueezeClicked = this.primary_squeeze_clicked_; + // Setting the input source's "clicked" state should generate one "select" + // event. Reset the input value to prevent it from continuously generating + // events. + this.primary_input_clicked_ = false; + // Setting the input source's "clicked" state should generate one "squeeze" + // event. Reset the input value to prevent it from continuously generating + // events. + this.primary_squeeze_clicked_ = false; + + input_state.mojoFromInput = this.mojo_from_input_; + + input_state.gamepad = this.gamepad_; + + input_state.emulatedPosition = this.emulated_position_; + + if (this.desc_dirty_) { + const input_desc = {}; + + switch (this.target_ray_mode_) { + case 'gaze': + input_desc.targetRayMode = vrMojom.XRTargetRayMode.GAZING; + break; + case 'tracked-pointer': + input_desc.targetRayMode = vrMojom.XRTargetRayMode.POINTING; + break; + case 'screen': + input_desc.targetRayMode = vrMojom.XRTargetRayMode.TAPPING; + break; + default: + throw new Error('Unhandled target ray mode ' + this.target_ray_mode_); + } + + switch (this.handedness_) { + case 'left': + input_desc.handedness = vrMojom.XRHandedness.LEFT; + break; + case 'right': + input_desc.handedness = vrMojom.XRHandedness.RIGHT; + break; + default: + input_desc.handedness = vrMojom.XRHandedness.NONE; + break; + } + + // Mojo requires us to send the pointerOrigin as relative to the grip + // space. If we don't have a grip space, we'll just assume that there + // is a grip at identity. This allows tests to simulate controllers that + // are really just a pointer with no tracked grip, though we will end up + // exposing that grip space. + let mojo_from_input = XRMathHelper.identity(); + switch (this.target_ray_mode_) { + case 'gaze': + case 'screen': + // For gaze and screen space, we won't have a mojo_from_input; however + // the "input" position is just the viewer, so use mojo_from_viewer. + mojo_from_input = this.pairedDevice_._getMojoFromViewer(); + break; + case 'tracked-pointer': + // If we have a tracked grip position (e.g. mojo_from_input), then use + // that. If we don't, then we'll just set the pointer offset directly, + // using identity as set above. + if (this.mojo_from_input_) { + mojo_from_input = this.mojo_from_input_.matrix; + } + break; + default: + throw new Error('Unhandled target ray mode ' + this.target_ray_mode_); + } + + // To convert mojo_from_pointer to input_from_pointer, we need: + // input_from_pointer = input_from_mojo * mojo_from_pointer + // Since we store mojo_from_input, we need to invert it here before + // multiplying. + let input_from_mojo = XRMathHelper.inverse(mojo_from_input); + input_desc.inputFromPointer = {}; + input_desc.inputFromPointer.matrix = + XRMathHelper.mul4x4(input_from_mojo, this.mojo_from_pointer_.matrix); + + input_desc.profiles = this.profiles_; + + input_state.description = input_desc; + + this.desc_dirty_ = false; + } + + // Pointer data for DOM Overlay, set by setOverlayPointerPosition() + if (this.overlay_pointer_position_) { + input_state.overlayPointerPosition = this.overlay_pointer_position_; + this.overlay_pointer_position_ = null; + } + + return input_state; + } + + _getEmptyGamepad() { + // Mojo complains if some of the properties on Gamepad are null, so set + // everything to reasonable defaults that tests can override. + const gamepad = { + connected: true, + id: [], + timestamp: 0n, + axes: [], + buttons: [], + mapping: GamepadMapping.GamepadMappingStandard, + displayId: 0, + }; + + switch (this.handedness_) { + case 'left': + gamepad.hand = GamepadHand.GamepadHandLeft; + break; + case 'right': + gamepad.hand = GamepadHand.GamepadHandRight; + break; + default: + gamepad.hand = GamepadHand.GamepadHandNone; + break; + } + + return gamepad; + } + + _addGamepadButton(buttonState) { + if (buttonState == null) { + return; + } + + const buttonIndex = this._getButtonIndex(buttonState.buttonType); + const axesStartIndex = this._getAxesStartIndex(buttonState.buttonType); + + if (buttonIndex == -1) { + throw new Error("Unknown Button Type!"); + } + + this.gamepad_.buttons[buttonIndex] = { + pressed: buttonState.pressed, + touched: buttonState.touched, + value: buttonState.pressedValue + }; + + // Add x/y value if supported. + if (axesStartIndex != -1) { + this.gamepad_.axes[axesStartIndex] = (buttonState.xValue == null ? 0.0 : buttonSate.xValue); + this.gamepad_.axes[axesStartIndex + 1] = (buttonState.yValue == null ? 0.0 : buttonSate.yValue); + } + } + + // General Helper methods + _getButtonIndex(buttonType) { + switch (buttonType) { + case 'grip': + return 1; + case 'touchpad': + return 2; + case 'thumbstick': + return 3; + case 'optional-button': + return 4; + case 'optional-thumbstick': + return 5; + default: + return -1; + } + } + + _getAxesStartIndex(buttonType) { + switch (buttonType) { + case 'touchpad': + return 0; + case 'thumbstick': + return 2; + case 'optional-thumbstick': + return 4; + default: + return -1; + } + } + + _getMojoFromInputSource(mojo_from_viewer) { + return this.mojo_from_pointer_.matrix; + } +} + +// Mojo helper classes +class FakeXRHitTestSourceController { + constructor(id) { + this.id_ = id; + this.deleted_ = false; + } + + get deleted() { + return this.deleted_; + } + + // Internal setter: + set deleted(value) { + this.deleted_ = value; + } +} + +class MockXRPresentationProvider { + constructor() { + this.receiver_ = null; + this.submit_frame_count_ = 0; + this.missing_frame_count_ = 0; + } + + _bindProvider() { + const provider = new vrMojom.XRPresentationProviderRemote(); + + if (this.receiver_) { + this.receiver_.$.close(); + } + this.receiver_ = new vrMojom.XRPresentationProviderReceiver(this); + this.receiver_.$.bindHandle(provider.$.bindNewPipeAndPassReceiver().handle); + return provider; + } + + _getClientReceiver() { + this.submitFrameClient_ = new vrMojom.XRPresentationClientRemote(); + return this.submitFrameClient_.$.bindNewPipeAndPassReceiver(); + } + + // XRPresentationProvider mojo implementation + updateLayerBounds(frameId, leftBounds, rightBounds, sourceSize) {} + + submitFrameMissing(frameId, mailboxHolder, timeWaited) { + this.missing_frame_count_++; + } + + submitFrame(frameId, mailboxHolder, timeWaited) { + this.submit_frame_count_++; + + // Trigger the submit completion callbacks here. WARNING: The + // Javascript-based mojo mocks are *not* re-entrant. It's OK to + // wait for these notifications on the next frame, but waiting + // within the current frame would never finish since the incoming + // calls would be queued until the current execution context finishes. + this.submitFrameClient_.onSubmitFrameTransferred(true); + this.submitFrameClient_.onSubmitFrameRendered(); + } + + submitFrameWithTextureHandle(frameId, texture, syncToken) {} + + submitFrameDrawnIntoTexture(frameId, syncToken, timeWaited) {} + + // Utility methods + _close() { + if (this.receiver_) { + this.receiver_.$.close(); + } + } +} + +// Export these into the global object as a side effect of importing this +// module. +self.ChromeXRTest = ChromeXRTest; +self.MockRuntime = MockRuntime; +self.MockVRService = MockVRService; +self.SubscribeToHitTestResult = vrMojom.SubscribeToHitTestResult; + +navigator.xr.test = new ChromeXRTest(); |