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);
  });
});