Spaces:
Configuration error
Configuration error
| // 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) }; | |
| } | |
| } | |