Spaces:
Configuration error
Configuration error
File size: 9,451 Bytes
9f21d0a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 | 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);
});
});
|