/**
* Class to handle device orientation.
* IMPORTANT - this code is a modified version the former official three.js
* DeviceOrientationControls class, which was formerly provided with the
* three.js repo
*
* Changes:
*
* - use "deviceorientationabsolute" rather than "deviceorientation"
* where available
* - many others (see comments and GitHub commit history)
*
* Original authors (prior to AR.js and locar.js modifications):
*
* @author richt / http://richt.me
* @author WestLangley / http://github.com/WestLangley
*
* W3C Device Orientation control (http://w3c.github.io/deviceorientation/spec-source-orientation.html)
*/
import { Euler, EventDispatcher, MathUtils, Quaternion, Vector3 } from "three";
import EventEmitter from "./event-emitter.js";
import {
LOCAR_DEVICE_ORIENTATION_PERMISSION_MODAL,
LOCAR_DEVICE_ORIENTATION_PERMISSION_BUTTON,
LOCAR_DEVICE_ORIENTATION_PERMISSION_MESSAGE,
LOCAR_DEVICE_ORIENTATION_PERMISSION_INNER,
LOCAR_DEVICE_ORIENTATION_PERMISSION_BUTTON_INNER,
} from "../constants/classes.js";
const LOCAR_DEVICE_ORIENTATION_MESSAGE =
"This immersive website requires access to your device motion sensors.";
const isIOS =
navigator.userAgent.match(/iPhone|iPad|iPod/i) ||
(/Macintosh/i.test(navigator.userAgent) &&
navigator.maxTouchPoints != null &&
navigator.maxTouchPoints > 1); // for iPad Safari - see #660 in main repo
const _zee = new Vector3(0, 0, 1);
const _euler = new Euler();
const _q0 = new Quaternion();
const _q1 = new Quaternion(-Math.sqrt(0.5), 0, 0, Math.sqrt(0.5)); // - PI/2 around the x-axis
class DeviceOrientationControls extends EventDispatcher {
/**
* Create an instance of DeviceOrientationControls.
* @param {Object} object - the object to attach the controls to
* (usually your Three.js camera)
* @param {Object} options - options for DeviceOrientationControls:
* currently accepts smoothingFactor (< 1), enablePermissionDialog, orientationChangeThreshold (radians)
*/
constructor(object, options = {}) {
super();
this.eventEmitter = new EventEmitter();
const scope = this;
this.object = object;
this.object.rotation.reorder("YXZ");
this.enabled = true;
this.deviceOrientation = null;
this.screenOrientation = 0;
this.alphaOffset = 0; // radians
this.orientationOffset = 0; // iOS orientation offset
this.initialOffset = null; // used in fix provided in issue #466 on main AR.js repo, iOS related
this.lastCompassY = undefined;
this.lastOrientation = null;
this.TWO_PI = 2 * Math.PI;
this.HALF_PI = 0.5 * Math.PI;
this.orientationChangeEventName =
"ondeviceorientationabsolute" in window
? "deviceorientationabsolute"
: "deviceorientation";
this.smoothingFactor = options.smoothingFactor || 1;
this.enablePermissionDialog = options.enablePermissionDialog ?? true;
this.enableInlineStyling = options.enableStyling ?? true;
this.preferConfirmDialog = options.preferConfirmDialog ?? false;
this.orientationChangeThreshold = options.orientationChangeThreshold ?? 0; // radians
const onDeviceOrientationChangeEvent = function ({
alpha,
beta,
gamma,
webkitCompassHeading,
}) {
if (isIOS) {
const ccwNorthHeading = 360 - webkitCompassHeading;
scope.alphaOffset = MathUtils.degToRad(ccwNorthHeading - alpha);
scope.deviceOrientation = { alpha, beta, gamma, webkitCompassHeading };
} else {
if (alpha < 0) alpha += 360;
scope.deviceOrientation = { alpha, beta, gamma };
}
window.dispatchEvent(
new CustomEvent("camera-rotation-change", {
detail: { cameraRotation: object.rotation },
})
);
};
const onScreenOrientationChangeEvent = function () {
scope.screenOrientation = window.orientation || 0;
if (isIOS) {
if (scope.screenOrientation === 90) {
scope.orientationOffset = -scope.HALF_PI;
} else if (scope.screenOrientation === -90) {
scope.orientationOffset = scope.HALF_PI;
} else {
scope.orientationOffset = 0;
}
}
};
const setObjectQuaternion = function (
quaternion,
alpha,
beta,
gamma,
orient
) {
_euler.set(beta, alpha, -gamma, "YXZ"); // 'ZXY' for the device, but 'YXZ' for us
quaternion.setFromEuler(_euler); // orient the device
quaternion.multiply(_q1); // camera looks out the back of the device, not the top
quaternion.multiply(_q0.setFromAxisAngle(_zee, -orient)); // adjust for screen orientation
};
/**
* Update the device orientation controls.
* Should be called from your three.js rendering/animation function.
* Permission handling now factored out into requestOrientationPermissions()
* Therefore, connect() should now only be called once permission has been
* granted, ie after the "deviceorientationgranted" event has been emitted.
* This is a breaking change and now means that you need to call connect()
* yourself - previously it was done automatically.
*/
this.connect = function () {
onScreenOrientationChangeEvent(); // run once on load
window.addEventListener(
"orientationchange",
onScreenOrientationChangeEvent
);
window.addEventListener(
scope.orientationChangeEventName,
onDeviceOrientationChangeEvent
);
scope.enabled = true;
};
this.disconnect = function () {
window.removeEventListener(
"orientationchange",
onScreenOrientationChangeEvent
);
window.removeEventListener(
scope.orientationChangeEventName,
onDeviceOrientationChangeEvent
);
scope.enabled = false;
scope.initialOffset = false;
scope.deviceOrientation = null;
};
// On iOS 13+ devices, request orientation permissions
// MUST come after a user gesture (e.g. pressing button):
// see obtainPermissionGesture() below.
// This is therefore tightly coupled to obtainPermissionGesture(), thus is
// called directly from it.
this.requestOrientationPermissions = function () {
if (
window.DeviceOrientationEvent !== undefined &&
typeof window.DeviceOrientationEvent.requestPermission === "function"
) {
window.DeviceOrientationEvent.requestPermission()
.then((response) => {
if (response === "granted") {
// Emit the "deviceorientationgranted" event: calls to connect()
// would typically be done from this event's handler
/**
* Device orientation granted event.
* @event DeviceOrientationControls#deviceorientationgranted
* @param {Object} event object with 'target' field, referencing the DeviceOrientationControls object.
*/
this.eventEmitter.emit("deviceorientationgranted", {
target: this,
});
} else {
/**
* Device orientation error event.
* @event DeviceOrientationControls#deviceorientationerror
* @param {Object} error object with 'code' and 'message' fields.
*/
this.eventEmitter.emit("deviceorientationerror", {
code: "LOCAR_DEVICE_ORIENTATION_PERMISSION_DENIED",
message:
"Permission for device orientation denied - AR will not work correctly",
});
}
})
.catch(function (error) {
this.eventEmitter.emit("deviceorientationerror", {
code: "LOCAR_DEVICE_ORIENTATION_PERMISSION_FAILED",
message:
"Permission request for device orientation failed - AR will not work correctly",
error: JSON.stringify(error, null, 2),
});
});
} else {
// Should never go here, emit an error if we do
this.eventEmitter.emit("deviceorientationerror", {
code: "LOCAR_DEVICE_ORIENTATION_INTERNAL_ERROR",
message:
"Internal error: no requestPermission() found although requestOrientationPermissions() was called - please raise an issue on GitHub",
});
}
};
// eslint-disable-next-line no-unused-vars
this.update = function ({ theta: s = 0 } = { theta: 0 }) {
if (scope.enabled === false) return;
const device = scope.deviceOrientation;
if (device) {
let alpha = device.alpha
? MathUtils.degToRad(device.alpha) + scope.alphaOffset
: 0; // Z
let beta = device.beta ? MathUtils.degToRad(device.beta) : 0; // X'
let gamma = device.gamma ? MathUtils.degToRad(device.gamma) : 0; // Y''
const orient = scope.screenOrientation
? MathUtils.degToRad(scope.screenOrientation)
: 0; // O
if (scope.smoothingFactor < 1) {
if (scope.lastOrientation) {
const k = scope.smoothingFactor;
alpha = scope._getSmoothedAngle(
alpha,
scope.lastOrientation.alpha,
k
);
beta = scope._getSmoothedAngle(
beta + Math.PI,
scope.lastOrientation.beta,
k
);
gamma = scope._getSmoothedAngle(
gamma + scope.HALF_PI,
scope.lastOrientation.gamma,
k,
Math.PI
);
} else {
beta += Math.PI;
gamma += scope.HALF_PI;
}
}
if (scope.lastOrientation) {
alpha = scope._calcAngleWithThreshold(alpha, scope.lastOrientation.alpha);
beta = scope._calcAngleWithThreshold(beta, scope.lastOrientation.beta);
gamma = scope._calcAngleWithThreshold(gamma, scope.lastOrientation.gamma);
}
if (isIOS) {
const currentQuaternion = new Quaternion();
setObjectQuaternion(
currentQuaternion,
alpha,
scope.smoothingFactor < 1 ? beta - Math.PI : beta,
scope.smoothingFactor < 1 ? gamma - Math.PI / 2 : gamma,
orient
);
const currentEuler = new Euler().setFromQuaternion(
currentQuaternion,
"YXZ"
);
let compassY = MathUtils.degToRad(360 - device.webkitCompassHeading);
if (scope.smoothingFactor < 1 && scope.lastCompassY !== void 0) {
compassY = scope._getSmoothedAngle(
compassY,
scope.lastCompassY,
scope.smoothingFactor
);
}
scope.lastCompassY = compassY;
currentEuler.y = compassY + (scope.orientationOffset || 0);
currentQuaternion.setFromEuler(currentEuler);
scope.object.quaternion.copy(currentQuaternion);
} else {
setObjectQuaternion(
scope.object.quaternion,
isIOS ? alpha + scope.alphaOffset : alpha,
scope.smoothingFactor < 1 ? beta - Math.PI : beta,
scope.smoothingFactor < 1 ? gamma - Math.PI / 2 : gamma,
orient
);
}
scope.lastOrientation = {
alpha,
beta,
gamma,
}
}
};
this.getCorrectedHeading = function () {
const { deviceOrientation: device } = scope;
if (!device) return 0;
let heading = 0;
if (isIOS) {
// iOS always uses webkitCompassHeading
heading = 360 - device.webkitCompassHeading;
if (scope.orientationOffset) {
heading += scope.orientationOffset * (180 / Math.PI);
heading = (heading + 360) % 360;
}
} else {
// Android: Check if we have absolute values
const isAbsolute =
device.absolute === true ||
scope.orientationChangeEventName === "deviceorientationabsolute";
heading = device.alpha ? device.alpha : 0;
if (isAbsolute) {
// With absolute values, we can use the alpha value directly as compass heading
// but possibly with a system-specific offset
// Reverse like iOS if alpha increases clockwise
//heading = 360 - heading;
}
// Reverse direction for Android
heading = (360 - heading) % 360;
if (heading < 0) heading += 360;
}
return heading;
};
this._calcAngleWithThreshold = function (a, b) {
return Math.abs(a - b) < scope.orientationChangeThreshold ? b : a;
};
// NW Added
this._orderAngle = function (a, b, range = scope.TWO_PI) {
if (
(b > a && Math.abs(b - a) < range / 2) ||
(a > b && Math.abs(b - a) > range / 2)
) {
return { left: a, right: b };
} else {
return { left: b, right: a };
}
};
// NW Added
this._getSmoothedAngle = function (a, b, k, range = scope.TWO_PI) {
const angles = scope._orderAngle(a, b, range);
const angleshift = angles.left;
const origAnglesRight = angles.right;
angles.left = 0;
angles.right -= angleshift;
if (angles.right < 0) angles.right += range;
let newangle =
origAnglesRight == b
? (1 - k) * angles.right + k * angles.left
: k * angles.right + (1 - k) * angles.left;
newangle += angleshift;
if (newangle >= range) newangle -= range;
return newangle;
};
// Provided in fix on issue #466 (main AR.js) - iOS related
this.updateAlphaOffset = function () {
scope.initialOffset = false;
};
this.dispose = function () {
scope.disconnect();
};
// provided with fix on issue #466 (main AR.js)
this.getAlpha = function () {
const { deviceOrientation: device } = scope;
return device && device.alpha
? MathUtils.degToRad(device.alpha) + scope.alphaOffset
: 0;
};
// provided with fix on issue #466 (main AR.js)
this.getBeta = function () {
const { deviceOrientation: device } = scope;
return device && device.beta ? MathUtils.degToRad(device.beta) : 0;
};
this.getGamma = function () {
const { deviceOrientation: device } = scope;
return device && device.gamma ? MathUtils.degToRad(device.gamma) : 0;
};
// Provide gesture before initialising device orientation controls
// From PR #659 on the main AR.js repo
// Thanks to @ma2yama
this.createObtainPermissionGestureDialog = function () {
// Add all the elements and a common class names to all elements
// to allow external styling
const startModal = document.createElement("div");
startModal.classList.add(LOCAR_DEVICE_ORIENTATION_PERMISSION_MODAL);
const innerDiv = document.createElement("div");
innerDiv.classList.add(LOCAR_DEVICE_ORIENTATION_PERMISSION_INNER);
const msgDiv = document.createElement("div");
msgDiv.classList.add(LOCAR_DEVICE_ORIENTATION_PERMISSION_MESSAGE);
const btnDiv = document.createElement("div");
btnDiv.classList.add(LOCAR_DEVICE_ORIENTATION_PERMISSION_BUTTON_INNER);
const btn = document.createElement("button");
btn.classList.add(LOCAR_DEVICE_ORIENTATION_PERMISSION_BUTTON);
document.body.appendChild(startModal);
// Apply inline styling if required, the styling tries to resemble
// a native ios dialog as closely as possible
if (this.enableInlineStyling === true) {
const startModalStyles = {
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'",
display: "flex",
position: "fixed",
zIndex: 100000,
justifyContent: "center",
alignItems: "center",
backgroundColor: "rgba(0,0,0,0.2)",
inset: 0,
padding: "20px",
};
const innerDivStyles = {
backgroundColor: "rgba(220, 220, 220, 0.85)",
padding: "6px 0",
borderRadius: "10px",
width: "100%",
maxWidth: "400px",
};
const msgDivStyles = {
padding: "10px 12px",
textAlign: "center",
fontWeight: 400,
fontSize: "13px",
display: "flex",
justifyContent: "center",
alignItems: "center",
};
const btnDivStyles = {
display: "block",
textAlign: "center",
textDecoration: "none",
borderTop: "rgb(180,180,180) solid 1px",
};
const btnStyles = {
display: "block",
width: "100%",
textAlign: "center",
appearance: "none",
background: "none",
border: "none",
outline: "none",
padding: "10px",
fontWeight: 400,
fontSize: "16px",
color: "#2e7cf1",
cursor: "pointer",
};
for (let key in startModalStyles) {
startModal.style[key] = startModalStyles[key];
}
for (let key in innerDivStyles) {
innerDiv.style[key] = innerDivStyles[key];
}
for (let key in msgDivStyles) {
msgDiv.style[key] = msgDivStyles[key];
}
for (let key in btnDivStyles) {
btnDiv.style[key] = btnDivStyles[key];
}
for (let key in btnStyles) {
btn.style[key] = btnStyles[key];
}
}
startModal.appendChild(innerDiv);
innerDiv.appendChild(msgDiv);
innerDiv.appendChild(btnDiv);
msgDiv.appendChild(
document.createTextNode(LOCAR_DEVICE_ORIENTATION_MESSAGE)
);
const onStartClick = () => {
this.requestOrientationPermissions();
startModal.style.display = "none";
};
btn.addEventListener("click", onStartClick);
btn.appendChild(document.createTextNode("OK"));
btnDiv.appendChild(btn);
document.body.appendChild(startModal);
};
this.obtainPermissionGesture = function () {
// Create a simple ok/cancel confirm() dialog instead of creating an html one
// if defined in the options, otherwise create the html dialog as above
if (this.preferConfirmDialog === true) {
if (window.confirm(LOCAR_DEVICE_ORIENTATION_MESSAGE)) {
this.requestOrientationPermissions();
}
} else {
this.createObtainPermissionGestureDialog();
}
};
}
on(eventName, eventHandler) {
this.eventEmitter.on(eventName, eventHandler);
}
/**
* Initialise device orientation controls.
* Should be called after you have created the DeviceOrientationControls
* object and set up the deviceorientationgranted and deviceorientationerror
* event handlers.
*/
init() {
if (window.DeviceOrientationEvent === undefined) {
this.eventEmitter.emit("deviceorientationerror", {
code: "LOCAR_DEVICE_ORIENTATION_NOT_SUPPORTED",
message: "Device orientation API not supported",
});
} else if (window.isSecureContext === false) {
this.eventEmitter.emit("deviceorientationerror", {
code: "LOCAR_DEVICE_ORIENTATION_NO_HTTPS",
message:
"DeviceOrientationEvent is only available in secure contexts (https)",
});
} else {
// Option to handle iOS permissions and connecting elsewhere
const isiOSWithReq =
typeof window.DeviceOrientationEvent.requestPermission === "function";
if (isiOSWithReq && this.enablePermissionDialog) {
this.obtainPermissionGesture();
} else {
// if not iOS we don't have to request permission so emit the
// "deviceorientationgranted" event straight away
this.eventEmitter.emit("deviceorientationgranted", { target: this });
}
}
}
}
export default DeviceOrientationControls;