Source code

Revision control

Copy as Markdown

Other Tools

import * as vrMojom from '/gen/device/vr/public/mojom/vr_service.mojom.m.js';
import * as xrSessionMojom from '/gen/device/vr/public/mojom/xr_session.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:
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.
//
// 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: xrSessionMojom.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': xrSessionMojom.XRSessionFeature.REF_SPACE_VIEWER,
'local': xrSessionMojom.XRSessionFeature.REF_SPACE_LOCAL,
'local-floor': xrSessionMojom.XRSessionFeature.REF_SPACE_LOCAL_FLOOR,
'bounded-floor': xrSessionMojom.XRSessionFeature.REF_SPACE_BOUNDED_FLOOR,
'unbounded': xrSessionMojom.XRSessionFeature.REF_SPACE_UNBOUNDED,
'hit-test': xrSessionMojom.XRSessionFeature.HIT_TEST,
'dom-overlay': xrSessionMojom.XRSessionFeature.DOM_OVERLAY,
'light-estimation': xrSessionMojom.XRSessionFeature.LIGHT_ESTIMATION,
'anchors': xrSessionMojom.XRSessionFeature.ANCHORS,
'depth-sensing': xrSessionMojom.XRSessionFeature.DEPTH,
'secondary-views': xrSessionMojom.XRSessionFeature.SECONDARY_VIEWS,
'camera-access': xrSessionMojom.XRSessionFeature.CAMERA_ACCESS,
'layers': xrSessionMojom.XRSessionFeature.LAYERS,
};
static _sessionModeToMojoMap = {
"inline": xrSessionMojom.XRSessionMode.kInline,
"immersive-vr": xrSessionMojom.XRSessionMode.kImmersiveVr,
"immersive-ar": xrSessionMojom.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(xrSessionMojom.XRSessionMode.kImmersiveAr)) {
return vrMojom.XREnvironmentBlendMode.kAdditive;
} else if (this.supportedModes_.includes(
xrSessionMojom.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 xrSessionMojom.XRSessionFeature.INVALID;
}
}
this.supportedFeatures_ = [];
for (let i = 0; i < supportedFeatures.length; i++) {
const feature = convertFeatureToMojom(supportedFeatures[i]);
if (feature !== xrSessionMojom.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(xrSessionMojom.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 == xrSessionMojom.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);
}
// XREnvironmentIntegrationProvider implementation:
subscribeToHitTest(nativeOriginInformation, entityTypes, ray) {
if (!this.supportedModes_.includes(xrSessionMojom.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(xrSessionMojom.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: {
defaultFramebufferScale: this.defaultFramebufferScale_,
supportsViewportScaling: true,
depthConfiguration: enabled_features.includes(
xrSessionMojom.XRSessionFeature.DEPTH) ?
{
depthUsage: xrSessionMojom.XRDepthUsage.kCPUOptimized,
depthDataFormat:
xrSessionMojom.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(xrSessionMojom.XRSessionFeature.DEPTH)
|| options.optionalFeatures.includes(xrSessionMojom.XRSessionFeature.DEPTH)) {
result &= options.depthOptions.usagePreferences.includes(
xrSessionMojom.XRDepthUsage.kCPUOptimized);
result &= options.depthOptions.dataFormatPreferences.includes(
xrSessionMojom.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(xrSessionMojom.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(xrSessionMojom.XRSessionMode.kImmersiveAr)) {
return;
}
if (!this.enabledFeatures_.includes(xrSessionMojom.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(xrSessionMojom.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: [],
touchEvents: [],
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();