'use strict'; // This polyfill library implements the WebXR Test API as specified here: // https://github.com/immersive-web/webxr-test-api const default_standing = new gfx.mojom.Transform(); default_standing.matrix = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 1.65, 0, 1]; const default_stage_parameters = { standingTransform: default_standing, bounds: null }; 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 composeGFXTransform(fakeTransformInit) { const transform = new gfx.mojom.Transform(); transform.matrix = getMatrixFromTransform(fakeTransformInit); return transform; } class ChromeXRTest { constructor() { this.mockVRService_ = new MockVRService(mojo.frameInterfaces); } simulateDeviceConnection(init_params) { return Promise.resolve(this.mockVRService_.addRuntime(init_params)); } disconnectAllDevices() { this.mockVRService_.removeAllRuntimes(device); return Promise.resolve(); } simulateUserActivation(callback) { 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); } } // Mocking class definitions // Mock service implements the VRService mojo interface. class MockVRService { constructor() { this.bindingSet_ = new mojo.BindingSet(device.mojom.VRService); this.runtimes_ = []; this.interceptor_ = new MojoInterfaceInterceptor(device.mojom.VRService.name, "context", true); this.interceptor_.oninterfacerequest = e => this.bindingSet_.addBinding(this, e.handle); this.interceptor_.start(); } // Test methods 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(); } } } setClient(client) { if (this.client_) { throw new Error("setClient should only be called once"); } this.client_ = client; } requestSession(sessionOptions, was_activation) { 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 device.mojom.XRSessionMetricsRecorderPtr(); const metricsRecorderRequest = mojo.makeRequest(metricsRecorderPtr); const metricsRecorderBinding = new mojo.Binding( device.mojom.XRSessionMetricsRecorder, new MockXRSessionMetricsRecorder(), metricsRecorderRequest); const success = { session: results[i].session, metricsRecorder: metricsRecorderPtr, }; return { result: { success : success, $tag : 0 } }; } } // If there were no successful results, returns a null session. return { result: { failureReason : device.mojom.RequestSessionError.NO_RUNTIME_FOUND, $tag : 1 } }; }); } exitPresent() { return Promise.resolve(); } 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}; }); } } // Implements XRFrameDataProvider and XRPresentationProvider. Maintains a mock // for XRPresentationProvider. class MockRuntime { // Mapping from string feature names to the corresponding mojo types. // This is exposed as a member for extensibility. static featureToMojoMap = { "viewer": device.mojom.XRSessionFeature.REF_SPACE_VIEWER, "local": device.mojom.XRSessionFeature.REF_SPACE_LOCAL, "local-floor": device.mojom.XRSessionFeature.REF_SPACE_LOCAL_FLOOR, "bounded-floor": device.mojom.XRSessionFeature.REF_SPACE_BOUNDED_FLOOR, "unbounded": device.mojom.XRSessionFeature.REF_SPACE_UNBOUNDED, }; static sessionModeToMojoMap = { "inline": device.mojom.XRSessionMode.kInline, "immersive-vr": device.mojom.XRSessionMode.kImmersiveVr, "immersive-ar": device.mojom.XRSessionMode.kImmersiveAr, }; constructor(fakeDeviceInit, service) { this.sessionClient_ = new device.mojom.XRSessionClientPtr(); this.presentation_provider_ = new MockXRPresentationProvider(); this.pose_ = null; this.next_frame_id_ = 0; this.bounds_ = null; this.send_mojo_space_reset_ = false; 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(); // ID of the next subscription to be assigned. this.next_hit_test_id_ = 1; 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); // Initialize DisplayInfo first to set the defaults, then override with // anything from the deviceInit if (this.supportedModes_.includes(device.mojom.XRSessionMode.kImmersiveVr) || this.supportedModes_.includes(device.mojom.XRSessionMode.kImmersiveAr)) { this.displayInfo_ = this.getImmersiveDisplayInfo(); } else if (this.supportedModes_.includes(device.mojom.XRSessionMode.kInline)) { this.displayInfo_ = this.getNonImmersiveDisplayInfo(); } else { // This should never happen! 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.world_ = fakeDeviceInit.world; } // This appropriately handles if the coordinates are null this.setBoundsGeometry(fakeDeviceInit.boundsCoordinates); this.setViews(fakeDeviceInit.views); // Need to support webVR which doesn't have a notion of features this.setFeatures(fakeDeviceInit.supportedFeatures || []); } _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)); } // Test API methods. disconnect() { this.service_.removeRuntime(this); this.presentation_provider_.Close(); if (this.sessionClient_.ptr.isBound()) { this.sessionClient_.ptr.reset(); } return Promise.resolve(); } setViews(views) { if (views) { let changed = false; for (let i = 0; i < views.length; i++) { if (views[i].eye == 'left') { this.displayInfo_.leftEye = this.getEye(views[i]); changed = true; } else if (views[i].eye == 'right') { this.displayInfo_.rightEye = this.getEye(views[i]); changed = true; } } if (changed && this.sessionClient_.ptr.isBound()) { this.sessionClient_.onChanged(this.displayInfo_); } } } 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; } simulateVisibilityChange(visibilityState) { let mojoState = null; switch (visibilityState) { case "visible": mojoState = device.mojom.XRVisibilityState.VISIBLE; break; case "visible-blurred": mojoState = device.mojom.XRVisibilityState.VISIBLE_BLURRED; break; case "hidden": mojoState = device.mojom.XRVisibilityState.HIDDEN; break; } if (mojoState) { this.sessionClient_.onVisibilityStateChanged(mojoState); } } 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.displayInfo_.stageParameters) { this.displayInfo_.stageParameters.bounds = this.bounds_; if (this.sessionClient_.ptr.isBound()) { this.sessionClient_.onChanged(this.displayInfo_); } } } setFloorOrigin(floorOrigin) { if (!this.displayInfo_.stageParameters) { this.displayInfo_.stageParameters = default_stage_parameters; this.displayInfo_.stageParameters.bounds = this.bounds_; } this.displayInfo_.stageParameters.standingTransform = new gfx.mojom.Transform(); this.displayInfo_.stageParameters.standingTransform.matrix = getMatrixFromTransform(floorOrigin); if (this.sessionClient_.ptr.isBound()) { this.sessionClient_.onChanged(this.displayInfo_); } } clearFloorOrigin() { if (this.displayInfo_.stageParameters) { this.displayInfo_.stageParameters = null; if (this.sessionClient_.ptr.isBound()) { this.sessionClient_.onChanged(this.displayInfo_); } } } simulateResetPose() { this.send_mojo_space_reset_ = true; } 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; } // Helper methods getNonImmersiveDisplayInfo() { const displayInfo = this.getImmersiveDisplayInfo(); displayInfo.capabilities.canPresent = false; displayInfo.leftEye = null; displayInfo.rightEye = null; return displayInfo; } // Function to generate some valid display information for the device. getImmersiveDisplayInfo() { return { displayName: 'FakeDevice', capabilities: { hasPosition: false, hasExternalDisplay: false, canPresent: true, maxLayers: 1 }, stageParameters: null, leftEye: { fieldOfView: { upDegrees: 48.316, downDegrees: 50.099, leftDegrees: 50.899, rightDegrees: 35.197 }, headFromEye: composeGFXTransform({ position: [-0.032, 0, 0], orientation: [0, 0, 0, 1] }), renderWidth: 20, renderHeight: 20 }, rightEye: { fieldOfView: { upDegrees: 48.316, downDegrees: 50.099, leftDegrees: 50.899, rightDegrees: 35.197 }, headFromEye: composeGFXTransform({ position: [0.032, 0, 0], orientation: [0, 0, 0, 1] }), renderWidth: 20, renderHeight: 20 }, webxrDefaultFramebufferScale: 0.7, }; } // This function converts between the matrix provided by the WebXR test API // and the internal data representation. getEye(fakeXRViewInit) { 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) }; } return { fieldOfView: fov, headFromEye: composeGFXTransform(fakeXRViewInit.viewOffset), renderWidth: fakeXRViewInit.resolution.width, renderHeight: fakeXRViewInit.resolution.height }; } setFeatures(supportedFeatures) { function convertFeatureToMojom(feature) { if (feature in MockRuntime.featureToMojoMap) { return MockRuntime.featureToMojoMap[feature]; } else { return device.mojom.XRSessionFeature.INVALID; } } this.supportedFeatures_ = []; for (let i = 0; i < supportedFeatures.length; i++) { const feature = convertFeatureToMojom(supportedFeatures[i]); if (feature !== device.mojom.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_); } // Extension point for non-standard modules. _injectAdditionalFrameData(options, frameData) { } // Mojo function implementations. // XRFrameDataProvider implementation. getFrameData(options) { 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()); } } // Convert current document time to monotonic time. let now = window.performance.now() / 1000.0; const diff = now - internals.monotonicTimeToZeroBasedDocumentTime(now); now += diff; now *= 1000000; const frameData = { pose: this.pose_, mojoSpaceReset: mojo_space_reset, inputState: input_state, timeDelta: { microseconds: now, }, frameId: this.next_frame_id_++, bufferHolder: null, bufferSize: {}, }; this._calculateHitTestResults(frameData); this._injectAdditionalFrameData(options, frameData); return Promise.resolve({ frameData: frameData, }); } getEnvironmentIntegrationProvider(environmentProviderRequest) { this.environmentProviderBinding_ = new mojo.AssociatedBinding( device.mojom.XREnvironmentIntegrationProvider, this, environmentProviderRequest); } // Note that if getEnvironmentProvider hasn't finished running yet this will // be undefined. It's recommended that you allow a successful task to post // first before attempting to close. closeEnvironmentIntegrationProvider() { this.environmentProviderBinding_.close(); } closeDataProvider() { this.dataProviderBinding_.close(); } updateSessionGeometry(frame_size, display_rotation) { // This function must exist to ensure that calls to it do not crash, but we // do not have any use for this data at present. } // XREnvironmentIntegrationProvider implementation: subscribeToHitTest(nativeOriginInformation, entityTypes, ray) { if (!this.supportedModes_.includes(device.mojom.XRSessionMode.kImmersiveAr)) { // Reject outside of AR. return Promise.resolve({ result : device.mojom.SubscribeToHitTestResult.FAILED, subscriptionId : 0 }); } if (nativeOriginInformation.$tag == device.mojom.XRNativeOriginInformation.Tags.inputSourceId) { if (!this.input_sources_.has(nativeOriginInformation.inputSourceId)) { // Reject - unknown input source ID. return Promise.resolve({ result : device.mojom.SubscribeToHitTestResult.FAILED, subscriptionId : 0 }); } } else if (nativeOriginInformation.$tag == device.mojom.XRNativeOriginInformation.Tags.referenceSpaceCategory) { // Bounded_floor & unbounded ref spaces are not yet supported for AR: if (nativeOriginInformation.referenceSpaceCategory == device.mojom.XRReferenceSpaceCategory.UNBOUNDED || nativeOriginInformation.referenceSpaceCategory == device.mojom.XRReferenceSpaceCategory.BOUNDED_FLOOR) { return Promise.resolve({ result : device.mojom.SubscribeToHitTestResult.FAILED, subscriptionId : 0 }); } } else { // Planes and anchors are not yet supported by the mock interface. return Promise.resolve({ result : device.mojom.SubscribeToHitTestResult.FAILED, subscriptionId : 0 }); } // Store the subscription information as-is: const id = this.next_hit_test_id_++; this.hitTestSubscriptions_.set(id, { nativeOriginInformation, entityTypes, ray }); return Promise.resolve({ result : device.mojom.SubscribeToHitTestResult.SUCCESS, subscriptionId : id }); } // Utility function requestRuntimeSession(sessionOptions) { return this.runtimeSupportsSession(sessionOptions).then((result) => { // The JavaScript bindings convert c_style_names to camelCase names. const options = new device.mojom.XRPresentationTransportOptions(); options.transportMethod = device.mojom.XRPresentationTransportMethod.SUBMIT_AS_MAILBOX_HOLDER; options.waitForTransferNotification = true; options.waitForRenderNotification = true; 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 device.mojom.XRFrameDataProviderPtr(); const dataProviderRequest = mojo.makeRequest(dataProviderPtr); this.dataProviderBinding_ = new mojo.Binding( device.mojom.XRFrameDataProvider, this, dataProviderRequest); const clientReceiver = mojo.makeRequest(this.sessionClient_); 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]); } } return Promise.resolve({ session: { submitFrameSink: submit_frame_sink, dataProvider: dataProviderPtr, clientReceiver: clientReceiver, displayInfo: this.displayInfo_, enabledFeatures: enabled_features, } }); } else { return Promise.resolve({session: null}); } }); } runtimeSupportsSession(options) { return Promise.resolve({ supportsSession: this.supportedModes_.includes(options.mode) }); } // Private functions - hit test implementation: // Modifies passed in frameData to add hit test results. _calculateHitTestResults(frameData) { if (!this.supportedModes_.includes(device.mojom.XRSessionMode.kImmersiveAr)) { return; } frameData.hitTestSubscriptionResults = new device.mojom.XRHitTestSubscriptionResultsData(); frameData.hitTestSubscriptionResults.results = []; frameData.hitTestSubscriptionResults.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 ray_origin = {x: subscription.ray.origin.x, y: subscription.ray.origin.y, z: subscription.ray.origin.z, w: 1}; const ray_direction = {x: subscription.ray.direction.x, y: subscription.ray.direction.y, z: subscription.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); const results = this._hitTestWorld(mojo_ray_origin, mojo_ray_direction, subscription.entityTypes); const result = new device.mojom.XRHitTestSubscriptionResultData(); result.subscriptionId = id; result.hitTestResults = results; frameData.hitTestSubscriptionResults.results.push(result); } } // 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); } // 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":device.mojom.EntityTypeForHitTest.POINT, "plane":device.mojom.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[0]; const point_B = face[1]; const point_C = face[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) < 0.0001) { // 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) > 0.9999) { // 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)) > 0.9999 ? 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. } 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 = new device.mojom.XRHitResult(); hitResult.hitMatrix = new gfx.mojom.Transform(); hitResult.distance = distance; // Extend the object with additional information used by higher layers. // It will not be serialized over mojom. hitResult.hitMatrix.matrix = new Array(16); hitResult.hitMatrix.matrix[0] = x_axis.x; hitResult.hitMatrix.matrix[1] = x_axis.y; hitResult.hitMatrix.matrix[2] = x_axis.z; hitResult.hitMatrix.matrix[3] = 0; hitResult.hitMatrix.matrix[4] = y_axis.x; hitResult.hitMatrix.matrix[5] = y_axis.y; hitResult.hitMatrix.matrix[6] = y_axis.z; hitResult.hitMatrix.matrix[7] = 0; hitResult.hitMatrix.matrix[8] = z_axis.x; hitResult.hitMatrix.matrix[9] = z_axis.y; hitResult.hitMatrix.matrix[10] = z_axis.z; hitResult.hitMatrix.matrix[11] = 0; hitResult.hitMatrix.matrix[12] = intersection_point.x; hitResult.hitMatrix.matrix[13] = intersection_point.y; hitResult.hitMatrix.matrix[14] = intersection_point.z; hitResult.hitMatrix.matrix[15] = 1; return hitResult; } } } _getMojoFromNativeOrigin(nativeOriginInformation) { const identity = function() { return [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ]; }; if (nativeOriginInformation.$tag == device.mojom.XRNativeOriginInformation.Tags.inputSourceId) { if (!this.input_sources_.has(nativeOriginInformation.inputSourceId)) { return null; } else { const inputSource = this.input_sources_.get(nativeOriginInformation.inputSourceId); return inputSource.mojo_from_input_.matrix; } } else if (nativeOriginInformation.$tag == device.mojom.XRNativeOriginInformation.Tags.referenceSpaceCategory) { switch (nativeOriginInformation.referenceSpaceCategory) { case device.mojom.XRReferenceSpaceCategory.LOCAL: return identity(); case device.mojom.XRReferenceSpaceCategory.LOCAL_FLOOR: if (this.displayInfo_ == null || this.displayInfo_.stageParameters == null || this.displayInfo_.stageParameters.standingTransform == null) { console.warn("Standing transform not available."); return null; } // this.displayInfo_.stageParameters.standingTransform = floor_from_mojo aka native_origin_from_mojo return XRMathHelper.inverse(this.displayInfo_.stageParameters.standingTransform.matrix); case device.mojom.XRReferenceSpaceCategory.VIEWER: 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); // this.pose_ = mojo_from_viewer case device.mojom.XRReferenceSpaceCategory.BOUNDED_FLOOR: return null; case device.mojom.XRReferenceSpaceCategory.UNBOUNDED: return null; default: throw new TypeError("Unrecognized XRReferenceSpaceCategory!"); } } else { // Anchors & planes are not yet supported for hit test. return null; } } } class MockXRSessionMetricsRecorder { reportFeatureUsed(feature) { // Do nothing } } class MockXRInputSource { constructor(fakeInputSourceInit, id, pairedDevice) { this.source_id_ = id; this.pairedDevice_ = pairedDevice; this.handedness_ = fakeInputSourceInit.handedness; this.target_ray_mode_ = fakeInputSourceInit.targetRayMode; 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.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_ = new gfx.mojom.Transform(); this.mojo_from_input_.matrix = getMatrixFromTransform(transform); this.emulated_position_ = emulatedPosition; } 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; } } setPointerOrigin(transform, emulatedPosition = false) { // pointer_origin was renamed to input_from_pointer in mojo this.desc_dirty_ = true; this.input_from_pointer_ = new gfx.mojom.Transform(); this.input_from_pointer_.matrix = getMatrixFromTransform(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!"); } 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; } } // Helpers for Mojom getInputSourceState() { const input_state = new device.mojom.XRInputSourceState(); input_state.sourceId = this.source_id_; input_state.primaryInputPressed = this.primary_input_pressed_; input_state.primaryInputClicked = this.primary_input_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; 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 = new device.mojom.XRInputSourceDescription(); switch (this.target_ray_mode_) { case 'gaze': input_desc.targetRayMode = device.mojom.XRTargetRayMode.GAZING; break; case 'tracked-pointer': input_desc.targetRayMode = device.mojom.XRTargetRayMode.POINTING; break; case 'screen': input_desc.targetRayMode = device.mojom.XRTargetRayMode.TAPPING; break; default: throw new Error('Unhandled target ray mode ' + this.target_ray_mode_); } switch (this.handedness_) { case 'left': input_desc.handedness = device.mojom.XRHandedness.LEFT; break; case 'right': input_desc.handedness = device.mojom.XRHandedness.RIGHT; break; default: input_desc.handedness = device.mojom.XRHandedness.NONE; break; } input_desc.inputFromPointer = this.input_from_pointer_; input_desc.profiles = this.profiles_; input_state.description = input_desc; this.desc_dirty_ = false; } 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 = new device.mojom.Gamepad(); gamepad.connected = true; gamepad.id = ""; gamepad.timestamp = 0; gamepad.axes = []; gamepad.buttons = []; gamepad.mapping = "xr-standard"; gamepad.display_id = 0; switch (this.handedness_) { case 'left': gamepad.hand = device.mojom.GamepadHand.GamepadHandLeft; break; case 'right': gamepad.hand = device.mojom.GamepadHand.GamepadHandRight; break; default: gamepad.hand = device.mojom.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; } } } // Mojo helper classes class MockXRPresentationProvider { constructor() { this.binding_ = new mojo.Binding(device.mojom.XRPresentationProvider, this); this.submit_frame_count_ = 0; this.missing_frame_count_ = 0; } bindProvider(request) { const providerPtr = new device.mojom.XRPresentationProviderPtr(); const providerRequest = mojo.makeRequest(providerPtr); this.binding_.close(); this.binding_ = new mojo.Binding( device.mojom.XRPresentationProvider, this, providerRequest); return providerPtr; } getClientReceiver() { this.submitFrameClient_ = new device.mojom.XRPresentationClientPtr(); return mojo.makeRequest(this.submitFrameClient_); } // XRPresentationProvider mojo implementation 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(); } // Utility methods Close() { this.binding_.close(); } } // This is a temporary workaround for the fact that spinning up webxr before // the mojo interceptors are created will cause the interceptors to not get // registered, so we have to create this before we query xr; const XRTest = new ChromeXRTest(); // This test API is also used to run Chrome's internal legacy VR tests; however, // those fail if navigator.xr has been used. Those tests will set a bool telling // us not to try to check navigator.xr if ((typeof legacy_vr_test === 'undefined') || !legacy_vr_test) { // Some tests may run in the http context where navigator.xr isn't exposed // This should just be to test that it isn't exposed, but don't try to set up // the test framework in this case. if (navigator.xr) { navigator.xr.test = XRTest; } } else { navigator.vr = { test: XRTest }; }