orbit-studio / src /modules /DeviceAim.ts
moncefem's picture
Deploy Orbit Studio propagator
9f21d0a
Raw
History Blame Contribute Delete
6.8 kB
// Turning a phone's orientation into an aim for the sky view.
//
// VERIFIED ON iOS: the sign of the screen-orientation correction and the
// `360 - webkitCompassHeading` substitution are both right, and the sky lines up
// with what the phone is pointed at. The manual trim that existed to recover from
// either being wrong is gone.
//
// NOT VERIFIED ON ANDROID, where `webkitCompassHeading` does not exist and
// `deviceorientation`'s alpha is referenced to an arbitrary zero. North comes from
// `deviceorientationabsolute` there instead. Where neither source is available the
// sky view refuses to aim by compass rather than aiming at an arbitrary azimuth —
// see docs/adr/0004-compass-aiming.md.
//
// Note this needs a secure context, so it cannot be exercised over
// `pnpm dev:host` on a LAN address; it wants a tunnel or a preview deploy.
//
// The device frame is the one `deviceorientation` defines: with the phone flat,
// screen up and its top edge pointing north, the device's X is east, Y is north
// and Z is up — the same axes as the observer's east-north-up frame, so at
// alpha = beta = gamma = 0 the rotation is the identity.
import { Cartesian3, Math as CesiumMath, Matrix3 } from "@cesium/engine";
import { type Aim, normalizeAzimuth, rollOf } from "./skyGeometry";
export { normalizeAzimuth } from "./skyGeometry";
export interface DeviceOrientationSample {
/** Rotation about the vertical, 0-360. Relative to an arbitrary zero on iOS. */
alpha: number;
/** Front-to-back tilt, -180 to 180. */
beta: number;
/** Left-to-right tilt, -90 to 90. */
gamma: number;
/** `screen.orientation.angle` — how far the content is rotated from natural. */
screenAngle: number;
}
/**
* How near horizontal the screen must be before iOS's compass heading is worth
* believing. The `360 - webkitCompassHeading` workaround is only valid with the
* phone flat; as it tilts the two diverge, and with the phone pointed near the
* zenith the projected heading of the top edge swings by ~180°, which would
* spin the whole sky.
*/
const COMPASS_POSTURE_TOLERANCE = 35;
const BACK_CAMERA: Cartesian3 = new Cartesian3(0, 0, -1);
const SCREEN_UP: Cartesian3 = new Cartesian3(0, 1, 0);
const SCREEN_NORMAL: Cartesian3 = new Cartesian3(0, 0, 1);
/**
* The device's rotation, taking device-frame vectors into east-north-up.
*
* Intrinsic Z-X'-Y'', the order `deviceorientation` specifies. The screen
* rotation is about the device's own Z and comes last, because it turns the
* *display* without turning the hardware.
*/
function deviceRotation({ alpha, beta, gamma, screenAngle }: DeviceOrientationSample): Matrix3 {
const rotation = Matrix3.multiply(
Matrix3.multiply(Matrix3.fromRotationZ(CesiumMath.toRadians(alpha)), Matrix3.fromRotationX(CesiumMath.toRadians(beta)), new Matrix3()),
Matrix3.fromRotationY(CesiumMath.toRadians(gamma)),
new Matrix3(),
);
return Matrix3.multiply(rotation, Matrix3.fromRotationZ(CesiumMath.toRadians(-screenAngle)), rotation);
}
/**
* The aim a device orientation implies, before any compass correction: the
* azimuth is measured from `alpha`'s zero, which on iOS drifts and is arbitrary.
*/
export function aimFromDeviceOrientation(sample: DeviceOrientationSample): Aim {
const rotation = deviceRotation(sample);
// The rear camera looks out of the back of the screen, along -Z; the top of
// the display is +Y. Both are device-frame vectors rotated into the world.
const direction = Matrix3.multiplyByVector(rotation, BACK_CAMERA, new Cartesian3());
const screenUp = Matrix3.multiplyByVector(rotation, SCREEN_UP, new Cartesian3());
const pitch = CesiumMath.toDegrees(Math.asin(CesiumMath.clamp(direction.z, -1, 1)));
const azimuth = normalizeAzimuth(CesiumMath.toDegrees(Math.atan2(direction.x, direction.y)));
// Decomposed against the same level pair `skyBasis` composes with, so the two
// are exact inverses — see skyGeometry. Projecting rather than reading an
// Euler angle is what keeps this defined with the phone pointed straight up.
return { azimuth, pitch, roll: rollOf(azimuth, pitch, screenUp) };
}
/** Whether the screen is flat enough for iOS's compass heading to mean anything. */
export function compassIsMeaningful(sample: DeviceOrientationSample): boolean {
// Screen normal is +Z in the device frame; flat means it is near vertical,
// either face up or face down. The screen angle cannot change that, so it is
// left out rather than cancelled.
const rotation = deviceRotation({ ...sample, screenAngle: 0 });
const screenNormal = Matrix3.multiplyByVector(rotation, SCREEN_NORMAL, new Cartesian3());
const tiltFromHorizontal = CesiumMath.toDegrees(Math.acos(CesiumMath.clamp(Math.abs(screenNormal.z), -1, 1)));
return tiltFromHorizontal <= COMPASS_POSTURE_TOLERANCE;
}
/**
* The yaw offset that carries an alpha-relative azimuth onto true north.
*
* Applied about world up and held between refreshes, rather than folded into
* `alpha`: the usual `360 - webkitCompassHeading` substitution is a statement
* about the phone lying flat, and applying it continuously is what makes the sky
* spin as the device tilts.
*/
export function compassYawOffset(sample: DeviceOrientationSample, compassHeading: number): number {
return normalizeAzimuth(360 - compassHeading - sample.alpha);
}
/** Where a sample's idea of north came from, if anywhere. */
export interface HeadingReading {
/** Safari's compass heading. Absent everywhere else. */
compassHeading?: number | undefined;
/** Whether the sample's own alpha is already referenced to true north. */
absolute?: boolean;
}
/** Whether a reading can establish north at all, whatever the current posture. */
export const hasHeadingSource = (reading: HeadingReading): boolean => reading.absolute === true || reading.compassHeading !== undefined;
/** Tracks the yaw offset, refreshing it only from readings that justify it. */
export class CompassCalibration {
#offset = 0;
#calibrated = false;
get calibrated(): boolean {
return this.#calibrated;
}
update(sample: DeviceOrientationSample, reading: HeadingReading): void {
// An absolute reading needs no correction and no particular posture: alpha is
// already measured from north, so the offset is zero and known to be right.
if (reading.absolute) {
this.#offset = 0;
this.#calibrated = true;
return;
}
if (reading.compassHeading === undefined || !compassIsMeaningful(sample)) {
return;
}
this.#offset = compassYawOffset(sample, reading.compassHeading);
this.#calibrated = true;
}
/** Correct a device-relative aim onto true north. */
correct(aim: Aim): Aim {
return { ...aim, azimuth: normalizeAzimuth(aim.azimuth + this.#offset) };
}
}