orbit-studio / src /modules /DeviceAim.test.ts
moncefem's picture
Deploy Orbit Studio propagator
9f21d0a
Raw
History Blame Contribute Delete
9.45 kB
import { Cartesian3, Math as CesiumMath, Matrix3 } from "@cesium/engine";
import { describe, expect, test } from "vitest";
import { aimFromDeviceOrientation, CompassCalibration, compassIsMeaningful, compassYawOffset, type DeviceOrientationSample, hasHeadingSource, normalizeAzimuth } from "./DeviceAim";
import { skyBasis } from "./SkyView";
const sample = (alpha: number, beta: number, gamma: number, screenAngle = 0): DeviceOrientationSample => ({ alpha, beta, gamma, screenAngle });
const azimuthError = (actual: number, expected: number): number => Math.abs(((((actual - expected) % 360) + 540) % 360) - 180);
/**
* The device's own axes in east-north-up, built here from the `deviceorientation`
* Euler order rather than borrowed from the module, so the round-trip test below
* checks the implementation against the specification and not against itself.
*/
function deviceRotationForTest({ alpha, beta, gamma, screenAngle }: DeviceOrientationSample): { backCamera: Cartesian3; screenUp: Cartesian3 } {
const radians = (degrees: number) => (degrees * Math.PI) / 180;
const rotation = [
Matrix3.fromRotationZ(radians(alpha)),
Matrix3.fromRotationX(radians(beta)),
Matrix3.fromRotationY(radians(gamma)),
Matrix3.fromRotationZ(radians(-screenAngle)),
].reduce((accumulated, next) => Matrix3.multiply(accumulated, next, new Matrix3()));
return {
backCamera: Matrix3.multiplyByVector(rotation, new Cartesian3(0, 0, -1), new Cartesian3()),
screenUp: Matrix3.multiplyByVector(rotation, new Cartesian3(0, 1, 0), new Cartesian3()),
};
}
describe("aimFromDeviceOrientation", () => {
test("looks straight down when the phone lies flat, screen up", () => {
// The rear camera faces the table.
expect(aimFromDeviceOrientation(sample(0, 0, 0)).pitch).toBeCloseTo(-90, 9);
});
test("looks straight up when the phone lies face down", () => {
// Rear camera to the sky — the posture the whole feature is for.
expect(aimFromDeviceOrientation(sample(0, 180, 0)).pitch).toBeCloseTo(90, 9);
});
test("looks level at the horizon when the phone is held upright", () => {
const aim = aimFromDeviceOrientation(sample(0, 90, 0));
expect(aim.pitch).toBeCloseTo(0, 9);
expect(azimuthError(aim.azimuth, 0)).toBeLessThan(1e-9);
expect(aim.roll).toBeCloseTo(0, 9);
});
test("carries alpha into the azimuth while upright", () => {
for (const alpha of [0, 45, 90, 200, 350]) {
const aim = aimFromDeviceOrientation(sample(alpha, 90, 0));
// Alpha turns the device anticlockwise seen from above, so the view
// heading runs the other way.
expect(azimuthError(aim.azimuth, -alpha), `alpha=${alpha}`).toBeLessThan(1e-6);
}
});
test("tilts pitch with beta between upright and the zenith", () => {
expect(aimFromDeviceOrientation(sample(0, 135, 0)).pitch).toBeCloseTo(45, 6);
expect(aimFromDeviceOrientation(sample(0, 45, 0)).pitch).toBeCloseTo(-45, 6);
});
test("swings the azimuth, not the roll, when an upright phone tips sideways", () => {
// Gamma turns the device about its own top-to-bottom axis. Held upright
// that axis is vertical, so tipping sideways points the camera somewhere
// else along the horizon and leaves the horizon level on screen.
const aim = aimFromDeviceOrientation(sample(0, 90, 30));
expect(aim.pitch).toBeCloseTo(0, 6);
expect(azimuthError(aim.azimuth, -30)).toBeLessThan(1e-6);
expect(aim.roll).toBeCloseTo(0, 6);
});
test("takes the roll out again when the screen rotates to match", () => {
// A landscape screen on a device turned 90° should read level, not rolled:
// the display turned with the hardware.
const upright = aimFromDeviceOrientation(sample(0, 90, 0, 0));
const landscape = aimFromDeviceOrientation(sample(0, 90, 0, 90));
expect(upright.roll).toBeCloseTo(0, 6);
expect(Math.abs(landscape.roll)).toBeCloseTo(90, 6);
});
test("hands the camera back the orientation the device reported", () => {
// The end-to-end invariant: decomposing a device orientation into an aim and
// recomposing that aim into a camera basis must reproduce the device's own
// axes. Roll used to come back negated here — the decomposition and the
// composition were written separately from the same formula and disagreed in
// sign, which mirrored the sky about the view axis. Nothing caught it because
// the tests only asserted the magnitude of the roll.
for (const posture of [sample(0, 90, 0, 90), sample(40, 120, 20, 0), sample(200, 150, -35, 270)]) {
const basis = skyBasis(aimFromDeviceOrientation(posture));
const rotation = deviceRotationForTest(posture);
const label = `a=${posture.alpha} b=${posture.beta} g=${posture.gamma} s=${posture.screenAngle}`;
expect(CesiumMath.toDegrees(Cartesian3.angleBetween(basis.direction, rotation.backCamera)), label).toBeCloseTo(0, 6);
expect(CesiumMath.toDegrees(Cartesian3.angleBetween(basis.up, rotation.screenUp)), label).toBeCloseTo(0, 6);
}
});
test("stays finite and level-consistent pointing at the zenith", () => {
// Where `setView` would have mirrored the sky, and where an Euler-derived
// roll is undefined.
const aim = aimFromDeviceOrientation(sample(217, 180, 0));
expect(aim.pitch).toBeCloseTo(90, 9);
expect(Number.isFinite(aim.azimuth)).toBe(true);
expect(Number.isFinite(aim.roll)).toBe(true);
});
});
describe("compassIsMeaningful", () => {
test("accepts a phone lying flat, either face", () => {
expect(compassIsMeaningful(sample(0, 0, 0))).toBe(true);
expect(compassIsMeaningful(sample(0, 180, 0))).toBe(true);
expect(compassIsMeaningful(sample(0, 20, 0))).toBe(true);
});
test("rejects the posture the sky view is actually used in", () => {
// Held up toward the sky, where `360 - webkitCompassHeading` stops holding
// and using it anyway would spin the view.
expect(compassIsMeaningful(sample(0, 90, 0))).toBe(false);
expect(compassIsMeaningful(sample(0, 120, 0))).toBe(false);
expect(compassIsMeaningful(sample(0, 90, 60))).toBe(false);
});
});
describe("compassYawOffset", () => {
test("cancels alpha so the corrected azimuth is the compass heading", () => {
for (const [alpha, heading] of [
[0, 0],
[90, 30],
[200, 145],
[350, 359],
]) {
const offset = compassYawOffset(sample(alpha as number, 0, 0), heading as number);
expect(normalizeAzimuth((alpha as number) + offset)).toBeCloseTo(normalizeAzimuth(360 - (heading as number)), 6);
}
});
});
describe("CompassCalibration", () => {
test("starts uncalibrated and leaves the aim alone", () => {
const calibration = new CompassCalibration();
expect(calibration.calibrated).toBe(false);
expect(calibration.correct({ azimuth: 123, pitch: 10, roll: 0 }).azimuth).toBe(123);
});
test("refuses to calibrate from a posture that cannot support it", () => {
const calibration = new CompassCalibration();
calibration.update(sample(0, 90, 0), { compassHeading: 90 });
expect(calibration.calibrated).toBe(false);
});
test("calibrates from a flat posture and then holds through the tilt", () => {
const calibration = new CompassCalibration();
calibration.update(sample(10, 0, 0), { compassHeading: 40 });
expect(calibration.calibrated).toBe(true);
const afterFlat = calibration.correct({ azimuth: 10, pitch: 0, roll: 0 }).azimuth;
// Tilting up must not move the offset, even with a wildly different heading.
calibration.update(sample(10, 140, 0), { compassHeading: 300 });
expect(calibration.correct({ azimuth: 10, pitch: 50, roll: 0 }).azimuth).toBeCloseTo(afterFlat, 9);
});
test("ignores a device with no compass at all", () => {
const calibration = new CompassCalibration();
calibration.update(sample(0, 0, 0), {});
expect(calibration.calibrated).toBe(false);
});
test("an absolute reading calibrates at any posture and corrects by nothing", () => {
const calibration = new CompassCalibration();
// Screen pointed at the zenith, where iOS's heading would be meaningless.
calibration.update(sample(37, 90, 0), { absolute: true });
expect(calibration.calibrated).toBe(true);
expect(calibration.correct({ azimuth: 123, pitch: 60, roll: 0 }).azimuth).toBeCloseTo(123, 9);
});
test("an absolute reading wins over a heading measured in the same event", () => {
const calibration = new CompassCalibration();
calibration.update(sample(10, 0, 0), { compassHeading: 40, absolute: true });
expect(calibration.correct({ azimuth: 10, pitch: 0, roll: 0 }).azimuth).toBeCloseTo(10, 9);
});
test("wraps rather than running past a full turn", () => {
const calibration = new CompassCalibration();
// alpha 0 with the phone flat facing 60° puts the offset at 300.
calibration.update(sample(0, 0, 0), { compassHeading: 60 });
expect(calibration.correct({ azimuth: 100, pitch: 0, roll: 0 }).azimuth).toBeCloseTo(40, 9);
});
});
describe("hasHeadingSource", () => {
test("a bare relative reading cannot establish north", () => {
expect(hasHeadingSource({})).toBe(false);
expect(hasHeadingSource({ absolute: false })).toBe(false);
});
test("either source will do", () => {
expect(hasHeadingSource({ compassHeading: 0 })).toBe(true);
expect(hasHeadingSource({ absolute: true })).toBe(true);
});
});