mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Supersedes #219 by @spcpza, ported onto the current component layout with fixes from review. Verified before merge: format, package boundaries, unit suite, build, and the browser tracking suite; a browser harness for the change where one applies; adversarial review applied. Co-authored-by: spcpza <[email protected]>
1081 lines
49 KiB
JavaScript
1081 lines
49 KiB
JavaScript
/**
|
||
* fly_route cinematic pins.
|
||
*
|
||
* The dolly is watched, not read, so these lock the qualities an owner can see
|
||
* in a screen recording: it starts and stops from rest, it never steps speed,
|
||
* it rolls INTO turns by a bounded amount and rolls back out, it never clips
|
||
* the mesh, it flattens under prefers-reduced-motion, and it stops the instant
|
||
* anything interrupts it.
|
||
*/
|
||
import assert from 'node:assert/strict';
|
||
import test from 'node:test';
|
||
import * as Cesium from 'cesium';
|
||
|
||
import {
|
||
ROUTE_CINEMA,
|
||
advanceRouteFlight,
|
||
approachValue,
|
||
createRouteFlight,
|
||
flyRoute,
|
||
getActiveCameraMotion,
|
||
activeCameraMotionId,
|
||
initCameraVerbs,
|
||
interruptCameraMotion,
|
||
interruptCameraMotionIfActive,
|
||
moveCamera,
|
||
prefersReducedMotion,
|
||
routeAltitudeOffsetM,
|
||
routeColdSeedFloorM,
|
||
routeCorridorCells,
|
||
routeEyeHeightM,
|
||
routeFloorHoldM,
|
||
routeRampFraction,
|
||
probeMeshFloorM,
|
||
routeSpeedProfile,
|
||
signedTurnRad,
|
||
} from './cameraVerbs.js';
|
||
|
||
const FRAME_S = 1 / 60;
|
||
const CRUISE_M_S = { slow: 20, normal: 40, fast: 90 };
|
||
|
||
/**
|
||
* Roll of an applied camera frame, in degrees, + = right wing down. This is
|
||
* what Cesium derives from a direction/up pair, so pins that read it are
|
||
* reading the orientation a viewer would actually see.
|
||
*/
|
||
function rollDegOf(eye, direction, up) {
|
||
const surfaceUp = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(eye, new Cesium.Cartesian3());
|
||
const right = Cesium.Cartesian3.normalize(
|
||
Cesium.Cartesian3.cross(direction, surfaceUp, new Cesium.Cartesian3()), new Cesium.Cartesian3(),
|
||
);
|
||
const levelUp = Cesium.Cartesian3.normalize(
|
||
Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3()), new Cesium.Cartesian3(),
|
||
);
|
||
const sin = Cesium.Cartesian3.dot(
|
||
Cesium.Cartesian3.cross(levelUp, up, new Cesium.Cartesian3()), direction,
|
||
);
|
||
return Cesium.Math.toDegrees(Math.atan2(sin, Cesium.Cartesian3.dot(levelUp, up)));
|
||
}
|
||
|
||
/**
|
||
* A viewer whose camera actually MODELS orientation: setView with a
|
||
* direction/up pair updates heading/pitch/roll the way Cesium's own
|
||
* decomposition does, and setView with an explicit roll writes it back. Without
|
||
* that, a pin can only prove the code called setView — not that the horizon
|
||
* the user is left holding is level.
|
||
*/
|
||
function createTickableViewer({ heightM = 700 } = {}) {
|
||
const listeners = [];
|
||
const setViews = [];
|
||
// The tick derives dt from the wall clock, so a synchronous test loop would
|
||
// advance the flight by ~0 no matter how many times it ticked. Stub the
|
||
// clock and step it explicitly.
|
||
const realPerformance = globalThis.performance;
|
||
let nowMs = 0;
|
||
globalThis.performance = { now: () => nowMs };
|
||
const position = Cesium.Cartesian3.fromDegrees(-97.76, 30.26, heightM);
|
||
const camera = {
|
||
positionWC: position,
|
||
positionCartographic: Cesium.Cartographic.fromCartesian(position),
|
||
heading: 0,
|
||
pitch: Cesium.Math.toRadians(-45),
|
||
roll: 0,
|
||
cancelFlight() {},
|
||
lookAtTransform() {},
|
||
setView(options) {
|
||
setViews.push(options);
|
||
const orientation = options?.orientation;
|
||
// A destination ALWAYS moves the camera, whatever orientation form came
|
||
// with it. Branching on the orientation shape instead would let a
|
||
// levelling call that also passed a destination slip past the pose pin.
|
||
if (options?.destination) camera.positionWC = options.destination;
|
||
if (orientation?.direction) {
|
||
camera.roll = Cesium.Math.toRadians(rollDegOf(
|
||
options?.destination || camera.positionWC, orientation.direction, orientation.up,
|
||
));
|
||
}
|
||
if (orientation && Number.isFinite(orientation.roll)) camera.roll = orientation.roll;
|
||
if (orientation && Number.isFinite(orientation.heading)) camera.heading = orientation.heading;
|
||
if (orientation && Number.isFinite(orientation.pitch)) camera.pitch = orientation.pitch;
|
||
},
|
||
};
|
||
const viewer = {
|
||
trackedEntity: undefined,
|
||
clock: { onTick: { addEventListener: (fn) => { listeners.push(fn); return () => {}; } } },
|
||
scene: {
|
||
canvas: { addEventListener() {}, removeEventListener() {} },
|
||
tweens: [],
|
||
requestRender() {},
|
||
// A live app has a rendered surface under the route; the mesh probe reads
|
||
// it, so these flights start immediately rather than arming.
|
||
sampleHeight: () => 0,
|
||
},
|
||
camera,
|
||
};
|
||
return {
|
||
viewer,
|
||
setViews,
|
||
tick: (dtS = FRAME_S) => {
|
||
nowMs += dtS * 1000;
|
||
for (const fn of listeners) fn();
|
||
},
|
||
rollDeg: () => Cesium.Math.toDegrees(camera.roll),
|
||
restore: () => { globalThis.performance = realPerformance; },
|
||
};
|
||
}
|
||
|
||
/** The floor a flight is holding, for assertions that care about the value. */
|
||
function flight_floorOf(flight) {
|
||
return flight.floorM;
|
||
}
|
||
|
||
/** Unit horizontal component of a pitched camera direction at `eye`. */
|
||
function horizontalOf(eye, direction) {
|
||
const surfaceUp = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(eye, new Cesium.Cartesian3());
|
||
const vertical = Cesium.Cartesian3.multiplyByScalar(
|
||
surfaceUp, Cesium.Cartesian3.dot(direction, surfaceUp), new Cesium.Cartesian3(),
|
||
);
|
||
return Cesium.Cartesian3.normalize(
|
||
Cesium.Cartesian3.subtract(direction, vertical, new Cesium.Cartesian3()), new Cesium.Cartesian3(),
|
||
);
|
||
}
|
||
|
||
/** Build a flight the way flyRoute() does, from [lon, lat] degrees. */
|
||
function flightFrom(lonLat, options = {}) {
|
||
const pts = lonLat.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat, 0));
|
||
const cumM = [0];
|
||
for (let i = 1; i < pts.length; i += 1) {
|
||
cumM.push(cumM[i - 1] + Cesium.Cartesian3.distance(pts[i - 1], pts[i]));
|
||
}
|
||
return createRouteFlight({ pts, cumM, ...options });
|
||
}
|
||
|
||
/** Fly the whole route at a fixed frame rate and record every frame. */
|
||
function record(flight, { maxFrames = 20000 } = {}) {
|
||
const frames = [];
|
||
for (let i = 0; i < maxFrames; i += 1) {
|
||
const frame = advanceRouteFlight(flight, FRAME_S);
|
||
// Arming frames carry no pose — the dolly is waiting for terrain, and the
|
||
// camera is deliberately untouched.
|
||
if (frame.arming) continue;
|
||
frames.push({
|
||
progress: frame.progress,
|
||
speed: frame.groundSpeedMps,
|
||
bankDeg: frame.bankDeg,
|
||
heightM: frame.heightM,
|
||
aglM: frame.aglM,
|
||
traveledM: flight.traveled,
|
||
eye: Cesium.Cartesian3.clone(frame.eye),
|
||
direction: Cesium.Cartesian3.clone(frame.direction),
|
||
up: Cesium.Cartesian3.clone(frame.up),
|
||
});
|
||
if (frame.finished) break;
|
||
}
|
||
assert.ok(frames.at(-1).progress >= 1, 'the flight must terminate');
|
||
return frames;
|
||
}
|
||
|
||
function maxStep(values) {
|
||
let worst = 0;
|
||
for (let i = 1; i < values.length; i += 1) worst = Math.max(worst, Math.abs(values[i] - values[i - 1]));
|
||
return worst;
|
||
}
|
||
|
||
// Austin: a 6-waypoint route with two sharp turns (a right, then a left).
|
||
const TWO_TURN_ROUTE = [
|
||
[-97.7600, 30.2600],
|
||
[-97.7480, 30.2600],
|
||
[-97.7480, 30.2700], // sharp LEFT (east → north)
|
||
[-97.7350, 30.2700], // sharp RIGHT (north → east)
|
||
[-97.7350, 30.2800],
|
||
[-97.7220, 30.2800],
|
||
];
|
||
const STRAIGHT_ROUTE = [[-97.76, 30.26], [-97.70, 30.26], [-97.64, 30.26]];
|
||
|
||
test('the speed profile eases in and out with no step anywhere', () => {
|
||
const flight = flightFrom(TWO_TURN_ROUTE);
|
||
const frames = record(flight);
|
||
const speeds = frames.map((f) => f.speed);
|
||
const cruise = CRUISE_M_S.normal;
|
||
|
||
// Starts and ends from rest — no instant velocity step at either end.
|
||
assert.ok(speeds[0] < cruise * 0.05, `start speed ${speeds[0].toFixed(2)} must be ~0`);
|
||
assert.ok(speeds.at(-1) < cruise * 0.05, `end speed ${speeds.at(-1).toFixed(2)} must be ~0`);
|
||
|
||
// Bounded acceleration for the whole flight: the profile is continuous, and
|
||
// no waypoint crossing perturbs it (segment-constant speed is gone).
|
||
assert.ok(maxStep(speeds) < 0.6, `per-frame speed step ${maxStep(speeds).toFixed(3)} m/s too large`);
|
||
|
||
// Cruise plateau is exactly flat — waypoints are invisible to the profile.
|
||
const plateau = frames.filter((f) => f.progress > 0.4 && f.progress < 0.6).map((f) => f.speed);
|
||
assert.ok(plateau.length > 10);
|
||
assert.ok(maxStep(plateau) < 1e-9, 'the cruise plateau must not wobble at waypoints');
|
||
|
||
// Distance advances monotonically and lands exactly on the route end.
|
||
const traveled = frames.map((f) => f.traveledM);
|
||
for (let i = 1; i < traveled.length; i += 1) {
|
||
assert.ok(traveled[i] >= traveled[i - 1] - 1e-9, 'the dolly never runs backwards');
|
||
}
|
||
assert.ok(Math.abs(traveled.at(-1) - flight.totalM) < 1e-6, 'the dolly reaches the final waypoint');
|
||
});
|
||
|
||
test('easing preserves the shipped pace — mean speed is still the speed word', () => {
|
||
for (const speed of ['slow', 'normal', 'fast']) {
|
||
const flight = flightFrom(TWO_TURN_ROUTE, { speed });
|
||
const expectedS = flight.totalM / CRUISE_M_S[speed];
|
||
assert.ok(
|
||
Math.abs(flight.durationS - expectedS) < 1e-6,
|
||
`${speed}: duration ${flight.durationS} should equal totalM / ${CRUISE_M_S[speed]}`,
|
||
);
|
||
const frames = record(flight);
|
||
const elapsedS = frames.length * FRAME_S;
|
||
assert.ok(
|
||
Math.abs(elapsedS - expectedS) < 0.5,
|
||
`${speed}: flew for ${elapsedS.toFixed(2)}s, expected ${expectedS.toFixed(2)}s`,
|
||
);
|
||
}
|
||
});
|
||
|
||
test('routeSpeedProfile is continuous, normalized, and zero at both ends', () => {
|
||
const r = routeRampFraction(30);
|
||
assert.ok(r > 0 && r <= 0.35);
|
||
assert.equal(routeSpeedProfile(0, r).distance, 0);
|
||
assert.equal(routeSpeedProfile(0, r).speed, 0);
|
||
assert.ok(Math.abs(routeSpeedProfile(1, r).distance - 1) < 1e-12);
|
||
assert.equal(routeSpeedProfile(1, r).speed, 0);
|
||
// Sampled derivative matches the reported speed everywhere (the closed-form
|
||
// distance really is the integral of the speed curve).
|
||
const cruiseShare = 1 / (1 - r);
|
||
for (let u = 0.001; u < 1; u += 0.001) {
|
||
const h = 1e-5;
|
||
const derivative = (routeSpeedProfile(u + h, r).distance - routeSpeedProfile(u - h, r).distance) / (2 * h);
|
||
const reported = routeSpeedProfile(u, r).speed * cruiseShare;
|
||
assert.ok(Math.abs(derivative - reported) < 1e-3, `derivative mismatch at u=${u.toFixed(3)}`);
|
||
}
|
||
// Short flights cap the ramp share rather than easing for the whole runtime.
|
||
assert.equal(routeRampFraction(1), 0.35);
|
||
assert.equal(routeRampFraction(0), 0);
|
||
});
|
||
|
||
test('turns bank into the corner, capped, and roll in and out smoothly', () => {
|
||
const frames = record(flightFrom(TWO_TURN_ROUTE));
|
||
const banks = frames.map((f) => f.bankDeg);
|
||
|
||
// Level at both ends.
|
||
assert.ok(Math.abs(banks[0]) < 0.01, 'the dolly starts wings level');
|
||
assert.ok(Math.abs(banks.at(-1)) < 1.5, 'the dolly finishes near wings level');
|
||
|
||
// Capped — this is a map, not a flight sim.
|
||
const peak = Math.max(...banks.map(Math.abs));
|
||
assert.ok(peak <= ROUTE_CINEMA.maxBankDeg + 1e-9, `bank ${peak.toFixed(2)}° exceeded the cap`);
|
||
assert.ok(peak > 3, `a 90° corner should read as a real bank, got ${peak.toFixed(2)}°`);
|
||
|
||
// Both directions appear: this route alternates left and right corners.
|
||
assert.ok(Math.min(...banks) < -3, 'the left turns bank left');
|
||
assert.ok(Math.max(...banks) > 3, 'the right turns bank right');
|
||
// The FIRST corner is a left, and the roll follows the path, not a clock.
|
||
const firstRoll = banks.find((deg) => Math.abs(deg) > 3);
|
||
assert.ok(firstRoll < 0, `the first corner is a left turn, rolled ${firstRoll?.toFixed(2)}°`);
|
||
|
||
// Smooth entry/exit: no roll step a viewer could see as a snap.
|
||
assert.ok(maxStep(banks) < 0.25, `per-frame roll step ${maxStep(banks).toFixed(3)}° too large`);
|
||
});
|
||
|
||
test('a straight route never banks', () => {
|
||
const banks = record(flightFrom(STRAIGHT_ROUTE)).map((f) => f.bankDeg);
|
||
assert.ok(Math.max(...banks.map(Math.abs)) < 0.05, 'a straight run must stay wings level');
|
||
});
|
||
|
||
test('a hairpin saturates the cap without exceeding it', () => {
|
||
const hairpin = [[-97.76, 30.26], [-97.74, 30.26], [-97.76, 30.2605]];
|
||
const banks = record(flightFrom(hairpin, { speed: 'slow' })).map((f) => f.bankDeg);
|
||
const peak = Math.max(...banks.map(Math.abs));
|
||
assert.ok(peak <= ROUTE_CINEMA.maxBankDeg + 1e-9, `hairpin bank ${peak.toFixed(2)}° exceeded the cap`);
|
||
assert.ok(peak > ROUTE_CINEMA.maxBankDeg - 1.5, `a hairpin should reach the cap, got ${peak.toFixed(2)}°`);
|
||
});
|
||
|
||
test('the banked up vector is a roll about the camera forward axis, not a skew', () => {
|
||
const frames = record(flightFrom(TWO_TURN_ROUTE));
|
||
for (const frame of frames) {
|
||
// Orthonormal frame: an up vector that is not perpendicular to direction
|
||
// would shear the view when Cesium converts it to heading/pitch/roll.
|
||
assert.ok(Math.abs(Cesium.Cartesian3.magnitude(frame.up) - 1) < 1e-9);
|
||
assert.ok(Math.abs(Cesium.Cartesian3.magnitude(frame.direction) - 1) < 1e-9);
|
||
assert.ok(Math.abs(Cesium.Cartesian3.dot(frame.up, frame.direction)) < 1e-9);
|
||
}
|
||
// Pitch stays locked at the designed look-down angle for the whole flight.
|
||
for (const frame of frames) {
|
||
const surfaceUp = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(frame.eye, new Cesium.Cartesian3());
|
||
const pitchDeg = -Cesium.Math.toDegrees(
|
||
Math.asin(-Cesium.Cartesian3.dot(frame.direction, surfaceUp)),
|
||
);
|
||
assert.ok(
|
||
Math.abs(pitchDeg - ROUTE_CINEMA.pitchDeg) < 0.5,
|
||
`pitch drifted to ${pitchDeg.toFixed(2)}°`,
|
||
);
|
||
}
|
||
});
|
||
|
||
test('altitude breathes around the requested mean and rises into turns', () => {
|
||
const flight = flightFrom(TWO_TURN_ROUTE, { floorFn: () => 0 });
|
||
const heights = record(flight).map((f) => f.aglM);
|
||
const min = Math.min(...heights);
|
||
const max = Math.max(...heights);
|
||
assert.ok(min >= ROUTE_CINEMA.meanHeightM - ROUTE_CINEMA.breathM - 1e-6, `dipped to ${min.toFixed(1)} m`);
|
||
assert.ok(
|
||
max <= ROUTE_CINEMA.meanHeightM + ROUTE_CINEMA.breathM + ROUTE_CINEMA.turnLiftM + 1e-6,
|
||
`climbed to ${max.toFixed(1)} m`,
|
||
);
|
||
assert.ok(max - min > 5, 'the altitude should actually breathe');
|
||
// Vertical rate stays gentle. The peak is the climb INTO a corner (against
|
||
// 40 m/s of ground speed — a shallow swell); rolling back out sinks far
|
||
// slower, because the lift tracks the roll on a deliberately lazy filter.
|
||
const verticalMps = maxStep(heights) / FRAME_S;
|
||
assert.ok(verticalMps < 7, `vertical rate reached ${verticalMps.toFixed(2)} m/s`);
|
||
|
||
// Breathing alone is zero-mean: one wavelength integrates to nothing, so the
|
||
// requested altitude stays the mean of a straight run.
|
||
let sum = 0;
|
||
const samples = 2000;
|
||
for (let i = 0; i < samples; i += 1) sum += routeAltitudeOffsetM((i / samples) * 2200, 0);
|
||
assert.ok(Math.abs(sum / samples) < 0.2, 'breathing must be zero-mean');
|
||
// Turning lifts, and only lifts.
|
||
assert.ok(routeAltitudeOffsetM(0, 1) > routeAltitudeOffsetM(0, 0));
|
||
assert.ok(routeAltitudeOffsetM(0, -1) > routeAltitudeOffsetM(0, 0));
|
||
});
|
||
|
||
test('a rise is cleared BEFORE the camera reaches it, not as it arrives', () => {
|
||
// A 900 m ridge across the middle of the route. Clearance under the camera
|
||
// alone cannot prove the lookahead read exists — the floor hold adopts a rise
|
||
// instantly, so reading only the current cell still clears the cell you are
|
||
// standing on. What the lookahead buys is clearing the ridge while you are
|
||
// still SHORT of it, which is what this measures.
|
||
const ridgeFloor = (lat, lon) => (lon > -97.745 && lon < -97.730 ? 900 : 0);
|
||
const flight = flightFrom(TWO_TURN_ROUTE, { floorFn: ridgeFloor });
|
||
const frames = record(flight);
|
||
const floorAtFrame = (frame) => {
|
||
const carto = Cesium.Cartographic.fromCartesian(frame.eye);
|
||
return ridgeFloor(
|
||
Cesium.Math.toDegrees(carto.latitude), Cesium.Math.toDegrees(carto.longitude),
|
||
);
|
||
};
|
||
let approachSamples = 0;
|
||
for (let i = 0, ahead = 0; i < frames.length; i += 1) {
|
||
const carto = Cesium.Cartographic.fromCartesian(frames[i].eye);
|
||
assert.ok(
|
||
carto.height - floorAtFrame(frames[i]) >= ROUTE_CINEMA.minClearanceM - 1e-6,
|
||
`eye at ${carto.height.toFixed(1)} m cleared the floor under it by too little`,
|
||
);
|
||
// Where the camera will BE in ~130 m of travel — half the lookahead at
|
||
// "normal", roughly three seconds out. Reading only the current cell still
|
||
// clears the cell you stand on (the floor hold adopts a rise instantly);
|
||
// what the ahead read buys is being high enough BEFORE you get there.
|
||
while (ahead < frames.length - 1 && frames[ahead].traveledM < frames[i].traveledM + 130) ahead += 1;
|
||
const floorAhead = floorAtFrame(frames[ahead]);
|
||
if (floorAhead > floorAtFrame(frames[i])) {
|
||
approachSamples += 1;
|
||
assert.ok(
|
||
carto.height - floorAhead >= ROUTE_CINEMA.minClearanceM - 1e-6,
|
||
`on approach the eye was ${carto.height.toFixed(1)} m against a ${floorAhead} m ridge 130 m ahead`,
|
||
);
|
||
}
|
||
}
|
||
assert.ok(approachSamples > 10, `the route must actually approach the ridge (${approachSamples} samples)`);
|
||
// The clamp is the last word even if the shaping constants were retuned into
|
||
// the ground.
|
||
assert.equal(routeEyeHeightM(500, -100000), 500 + ROUTE_CINEMA.minClearanceM);
|
||
assert.equal(routeEyeHeightM(Number.NaN, 0), ROUTE_CINEMA.meanHeightM);
|
||
});
|
||
|
||
test('a COLD corridor never descends blind — the seed holds until terrain lands', () => {
|
||
// The failure this exists for: OSRM route vertices carry height 0, and an
|
||
// unwarmed floor cell reads null. Trusting the vertex put the eye 1.3 km
|
||
// UNDER Albuquerque. Cold is missing data, not flat ground.
|
||
const ALBUQUERQUE = [[-106.61, 35.08], [-106.55, 35.08]];
|
||
const REAL_FLOOR_M = 1600;
|
||
const cold = flightFrom(ALBUQUERQUE, { floorFn: () => null, cameraHeightM: 2400 });
|
||
let first = advanceRouteFlight(cold, FRAME_S);
|
||
while (first.arming) first = advanceRouteFlight(cold, FRAME_S); // outlast the arm
|
||
assert.ok(
|
||
first.heightM > REAL_FLOOR_M,
|
||
`a cold corridor put the eye at ${first.heightM.toFixed(0)} m, under ${REAL_FLOOR_M} m of terrain`,
|
||
);
|
||
|
||
// And when the DEM lands a second in, the eye eases down to cruise without
|
||
// ever dropping below the clamp.
|
||
const warming = flightFrom(ALBUQUERQUE, { floorFn: () => null, cameraHeightM: 2400 });
|
||
let minClearance = Infinity;
|
||
for (let i = 0; ; i += 1) {
|
||
if (i === 60) warming.floorFn = () => REAL_FLOOR_M;
|
||
const frame = advanceRouteFlight(warming, FRAME_S);
|
||
if (frame.arming) continue;
|
||
minClearance = Math.min(minClearance, frame.heightM - REAL_FLOOR_M);
|
||
if (frame.finished) break;
|
||
}
|
||
assert.ok(
|
||
minClearance >= ROUTE_CINEMA.minClearanceM - 1e-6,
|
||
`the descent onto a late floor dipped to ${minClearance.toFixed(1)} m of clearance`,
|
||
);
|
||
assert.ok(warming.armS > 0, 'the flight armed rather than racing the warm');
|
||
assert.equal(warming.floorKnown, true, 'and it picked the floor up when it landed');
|
||
|
||
// The first real floor is an ACQUISITION, taken at once. Easing onto it would
|
||
// turn the gap between the safety seed and the ground into a several-hundred-
|
||
// metre plummet (measured: 358 m/s). After it lands, ordinary descents are
|
||
// eased as usual.
|
||
const acquiring = flightFrom(ALBUQUERQUE, { floorFn: () => null, cameraHeightM: 2400 });
|
||
assert.equal(acquiring.floorKnown, false, 'a cold construction has no floor yet');
|
||
// The warm lands DURING the arm — the ordinary case, and the one where the
|
||
// floor may be taken whole because nothing has moved yet.
|
||
advanceRouteFlight(acquiring, FRAME_S);
|
||
assert.equal(acquiring.floorM, Number.NaN === acquiring.floorM ? Number.NaN : acquiring.floorM);
|
||
acquiring.floorFn = () => REAL_FLOOR_M;
|
||
advanceRouteFlight(acquiring, FRAME_S);
|
||
assert.equal(acquiring.floorM, REAL_FLOOR_M, 'a pre-departure floor is adopted whole, not eased');
|
||
assert.equal(acquiring.floorKnown, true);
|
||
let peakVerticalMps = 0;
|
||
let previous = advanceRouteFlight(acquiring, FRAME_S).heightM;
|
||
for (let i = 0; i < 600; i += 1) {
|
||
const height = advanceRouteFlight(acquiring, FRAME_S).heightM;
|
||
peakVerticalMps = Math.max(peakVerticalMps, Math.abs(height - previous) / FRAME_S);
|
||
previous = height;
|
||
}
|
||
assert.ok(peakVerticalMps < 7, `after acquisition the eye moved at ${peakVerticalMps.toFixed(1)} m/s`);
|
||
|
||
// A corridor that is already warm at construction never needs the seed.
|
||
const prewarmed = flightFrom(ALBUQUERQUE, { floorFn: () => REAL_FLOOR_M, cameraHeightM: 2400 });
|
||
assert.equal(prewarmed.floorKnown, true, 'a warm cache is adopted before the first frame');
|
||
assert.equal(prewarmed.floorM, REAL_FLOOR_M);
|
||
});
|
||
|
||
test('a cold route ARMS for terrain rather than racing the warm', () => {
|
||
// The corridor warm is fire-and-forget, so the alternative to waiting is
|
||
// racing it. Losing that race used to put the eye at an altitude derived from
|
||
// the camera — 774 m underground from a LOW starting camera, which the old
|
||
// pin missed only because it supplied a conveniently safe 2,400 m one.
|
||
const ALBUQUERQUE = [[-106.61, 35.08], [-106.55, 35.08]];
|
||
const REAL_FLOOR_M = 1600;
|
||
const LOW_CAMERA_M = 826;
|
||
let cold = true;
|
||
const flight = flightFrom(ALBUQUERQUE, {
|
||
floorFn: () => (cold ? null : REAL_FLOOR_M),
|
||
cameraHeightM: LOW_CAMERA_M,
|
||
});
|
||
assert.equal(flight.floorKnown, false);
|
||
|
||
// While armed the camera is not touched AT ALL — no teleport onto the route,
|
||
// no descent, no frame to apply.
|
||
for (let i = 0; i < 20; i += 1) {
|
||
const frame = advanceRouteFlight(flight, FRAME_S);
|
||
assert.equal(frame.arming, true, 'a cold dolly must not move before it has terrain');
|
||
assert.equal(frame.eye, undefined, 'an arming frame carries no pose to apply');
|
||
}
|
||
assert.equal(flight.traveled, 0, 'and it has not advanced along the route');
|
||
assert.ok(flight.armS > 0.3, 'the arm actually elapsed');
|
||
|
||
// The warm lands. From here the flight is ordinary, and never underground.
|
||
cold = false;
|
||
let minHeight = Infinity;
|
||
for (let i = 0; i < 20000; i += 1) {
|
||
const frame = advanceRouteFlight(flight, FRAME_S);
|
||
if (frame.arming) continue;
|
||
minHeight = Math.min(minHeight, frame.heightM);
|
||
if (frame.finished) break;
|
||
}
|
||
assert.ok(
|
||
minHeight >= REAL_FLOOR_M + ROUTE_CINEMA.minClearanceM - 1e-6,
|
||
`a cold route flew to ${minHeight.toFixed(0)} m over ${REAL_FLOOR_M} m of terrain`,
|
||
);
|
||
});
|
||
|
||
test('the rendered mesh answers when the DEM never will — once, and only once', () => {
|
||
// The DEM can stay cold indefinitely (proxy down). Clipping requires terrain
|
||
// to BE rendered, and what is rendered is what sampleHeight reads — so the
|
||
// mesh probe closes the hole in the case where the hole is visible.
|
||
const ALBUQUERQUE = [[-106.61, 35.08], [-106.55, 35.08]];
|
||
const REAL_FLOOR_M = 1600;
|
||
const probeCalls = [];
|
||
const flight = flightFrom(ALBUQUERQUE, {
|
||
floorFn: () => null,
|
||
cameraHeightM: 826,
|
||
probeFn: (cells) => {
|
||
probeCalls.push(cells.length);
|
||
return { heightM: REAL_FLOOR_M, sampled: cells.length, requested: cells.length };
|
||
},
|
||
});
|
||
assert.equal(flight.floorKnown, true, 'the probe is consulted before the first frame');
|
||
assert.equal(flight.floorFromMeshProbe, true);
|
||
|
||
let minHeight = Infinity;
|
||
for (let i = 0; i < 20000; i += 1) {
|
||
const frame = advanceRouteFlight(flight, FRAME_S);
|
||
assert.notEqual(frame.arming, true, 'a probe hit means there is nothing to wait for');
|
||
minHeight = Math.min(minHeight, frame.heightM);
|
||
if (frame.finished) break;
|
||
}
|
||
assert.ok(minHeight >= REAL_FLOOR_M + ROUTE_CINEMA.minClearanceM - 1e-6, `flew at ${minHeight.toFixed(0)} m`);
|
||
|
||
// ONE-SHOT, for the whole route. The acquisition re-runs every arming frame
|
||
// to notice the DEM landing — that part is cache reads — but the probe is
|
||
// latched, so a regression to per-frame sampling turns this red.
|
||
assert.equal(probeCalls.length, 1, `the probe fired ${probeCalls.length} times over one route`);
|
||
const totalSampleHeightCalls = probeCalls.reduce((sum, n) => sum + n, 0);
|
||
assert.ok(
|
||
totalSampleHeightCalls <= 8,
|
||
`a route cost ${totalSampleHeightCalls} sampleHeight calls (budget 8)`,
|
||
);
|
||
|
||
// A route whose cache is already warm never probes at all.
|
||
const warmProbes = [];
|
||
const warm = flightFrom(ALBUQUERQUE, {
|
||
floorFn: () => REAL_FLOOR_M,
|
||
cameraHeightM: 826,
|
||
probeFn: (cells) => { warmProbes.push(cells.length); return { heightM: 0, sampled: 0, requested: 0 }; },
|
||
});
|
||
for (let i = 0; i < 300; i += 1) advanceRouteFlight(warm, FRAME_S);
|
||
assert.equal(warmProbes.length, 0, 'a warm corridor must not touch the mesh at all');
|
||
|
||
// The probe stays latched even when it answers with nothing, so an arm that
|
||
// runs its full 1.2 s still costs one probe.
|
||
const blindProbes = [];
|
||
const blind = flightFrom(ALBUQUERQUE, {
|
||
floorFn: () => null,
|
||
cameraHeightM: 826,
|
||
probeFn: (cells) => { blindProbes.push(cells.length); return { heightM: Number.NaN, sampled: 0, requested: cells.length }; },
|
||
});
|
||
assert.equal(blind.floorKnown, false);
|
||
for (let i = 0; i < 300; i += 1) advanceRouteFlight(blind, FRAME_S);
|
||
assert.equal(blindProbes.length, 1, `a blind probe fired ${blindProbes.length} times`);
|
||
|
||
// A hostile probe cannot take the flight down, and is still only tried once.
|
||
let hostileCalls = 0;
|
||
const hostile = flightFrom(ALBUQUERQUE, {
|
||
floorFn: () => null,
|
||
cameraHeightM: 826,
|
||
probeFn: () => { hostileCalls += 1; throw new Error('scene gone'); },
|
||
});
|
||
for (let i = 0; i < 300; i += 1) advanceRouteFlight(hostile, FRAME_S);
|
||
assert.equal(hostileCalls, 1);
|
||
});
|
||
|
||
test('probeMeshFloorM reports COVERAGE, not just a height', () => {
|
||
assert.deepEqual(probeMeshFloorM(null, [{ lat: 1, lon: 1 }]),
|
||
{ heightM: Number.NaN, sampled: 0, requested: 1 }, 'no scene, no reading');
|
||
assert.deepEqual(probeMeshFloorM({ sampleHeight: () => 120 }, []),
|
||
{ heightM: Number.NaN, sampled: 0, requested: 0 }, 'no cells, no reading');
|
||
// Cells whose tiles are not streamed contribute NOTHING — silence must never
|
||
// be mistaken for flat ground, which is why the caller gets a count.
|
||
const partial = probeMeshFloorM(
|
||
{ sampleHeight: (c) => (c.longitude > 0 ? 340 : undefined) },
|
||
[{ lat: 1, lon: 1 }, { lat: 1, lon: -1 }],
|
||
);
|
||
assert.equal(partial.heightM, 340, 'the probe takes the HIGHEST surface it saw');
|
||
assert.equal(partial.sampled, 1, 'and reports that it only reached one of two cells');
|
||
assert.equal(partial.requested, 2);
|
||
});
|
||
|
||
test('a PARTIALLY warm corridor is unresolved, and probes only the cold cells', () => {
|
||
// One cached cell says nothing about the ground under the other seven.
|
||
// Treating it as an answer let the dolly descend to 460 m over a 1,600 m
|
||
// rendered surface — the cache read succeeded, so neither the arm nor the
|
||
// probe ever ran.
|
||
const ALBUQUERQUE = [[-106.61, 35.08], [-106.55, 35.08]];
|
||
const VALLEY_M = 200;
|
||
const RIDGE_M = 1600;
|
||
const probedCells = [];
|
||
// Exactly one cell of the corridor is warm, and it is the LOW one.
|
||
let warmCell = null;
|
||
const partial = flightFrom(ALBUQUERQUE, {
|
||
cameraHeightM: 2400,
|
||
floorFn: (lat, lon) => {
|
||
if (warmCell === null) warmCell = `${lat},${lon}`; // the first cell asked for
|
||
return `${lat},${lon}` === warmCell ? VALLEY_M : null;
|
||
},
|
||
probeFn: (cells) => {
|
||
probedCells.push(cells.map((c) => `${c.lat},${c.lon}`));
|
||
return { heightM: RIDGE_M, sampled: cells.length, requested: cells.length };
|
||
},
|
||
});
|
||
assert.equal(probedCells.length, 1, 'the cold cells must be probed');
|
||
assert.ok(probedCells[0].length >= 1, 'and there were cold cells to probe');
|
||
assert.ok(!probedCells[0].includes(warmCell), 'the already-warm cell must not be re-probed');
|
||
assert.equal(flight_floorOf(partial), RIDGE_M, 'the corridor takes its HIGHEST known ground');
|
||
|
||
let minHeight = Infinity;
|
||
for (let i = 0; i < 20000; i += 1) {
|
||
const frame = advanceRouteFlight(partial, FRAME_S);
|
||
if (frame.arming) continue;
|
||
minHeight = Math.min(minHeight, frame.heightM);
|
||
if (frame.finished) break;
|
||
}
|
||
assert.ok(
|
||
minHeight >= RIDGE_M + ROUTE_CINEMA.minClearanceM - 1e-6,
|
||
`a half-warm corridor flew at ${minHeight.toFixed(0)} m over a ${RIDGE_M} m ridge`,
|
||
);
|
||
|
||
// And when the probe cannot reach the cold cells either, the corridor stays
|
||
// UNRESOLVED: the arm runs and the launch-altitude hold carries the flight.
|
||
let seen = null;
|
||
const unresolved = flightFrom(ALBUQUERQUE, {
|
||
cameraHeightM: 2400,
|
||
floorFn: (lat, lon) => {
|
||
if (seen === null) seen = `${lat},${lon}`;
|
||
return `${lat},${lon}` === seen ? VALLEY_M : null;
|
||
},
|
||
probeFn: (cells) => ({ heightM: Number.NaN, sampled: 0, requested: cells.length }),
|
||
});
|
||
assert.equal(unresolved.floorKnown, false, 'one warm cell does not resolve a corridor');
|
||
assert.equal(advanceRouteFlight(unresolved, FRAME_S).arming, true, 'so the dolly arms');
|
||
let held = Infinity;
|
||
for (let i = 0; i < 20000; i += 1) {
|
||
const frame = advanceRouteFlight(unresolved, FRAME_S);
|
||
if (frame.arming) continue;
|
||
held = Math.min(held, frame.heightM);
|
||
if (frame.finished) break;
|
||
}
|
||
assert.ok(held >= 2400 - 1e-6, `held ${held.toFixed(0)} m, below the launch altitude`);
|
||
});
|
||
|
||
test('floor data arriving mid-flight blends down, and never arriving holds', () => {
|
||
const ALBUQUERQUE = [[-106.61, 35.08], [-106.55, 35.08]];
|
||
const LAUNCH_M = 2400;
|
||
|
||
// (a) LATE arrival. Pre-departure acquisition takes the floor whole because
|
||
// nothing is moving yet; an arrival after the arm gave up must ease, or the
|
||
// gap between the safety hold and the ground becomes a mid-flight plummet.
|
||
let cold = true;
|
||
const late = flightFrom(ALBUQUERQUE, {
|
||
floorFn: () => (cold ? null : 150),
|
||
cameraHeightM: LAUNCH_M,
|
||
});
|
||
let previous = null;
|
||
let worstDropMps = 0;
|
||
let heldWhileCold = 0;
|
||
let floorBeforeArrival = Number.NaN;
|
||
let floorOnArrivalFrame = Number.NaN;
|
||
for (let i = 0; i < 20000; i += 1) {
|
||
if (i === 1200) { // ~20 s in, long after the arm expired
|
||
cold = false;
|
||
floorBeforeArrival = late.floorM;
|
||
}
|
||
const frame = advanceRouteFlight(late, FRAME_S);
|
||
if (frame.arming) continue;
|
||
if (i === 1200) floorOnArrivalFrame = late.floorM;
|
||
if (cold) heldWhileCold = frame.heightM;
|
||
if (previous !== null) worstDropMps = Math.max(worstDropMps, (previous - frame.heightM) / FRAME_S);
|
||
previous = frame.heightM;
|
||
if (frame.finished) break;
|
||
}
|
||
// The FLOOR itself blends. Taking a mid-flight arrival whole is what the
|
||
// descent limiter exists to survive, not a thing to rely on: the held floor
|
||
// on the arrival frame must sit BETWEEN the old value and the new one.
|
||
assert.ok(
|
||
floorOnArrivalFrame < floorBeforeArrival && floorOnArrivalFrame > 150,
|
||
`a late floor was snapped to ${floorOnArrivalFrame} rather than eased from ${floorBeforeArrival}`,
|
||
);
|
||
assert.ok(heldWhileCold >= LAUNCH_M - 1e-6, `held ${heldWhileCold.toFixed(0)} m, below the launch altitude`);
|
||
assert.ok(worstDropMps <= 10 + 1e-6, `the release dropped the eye at ${worstDropMps.toFixed(1)} m/s`);
|
||
assert.ok(worstDropMps > 1, 'and it did actually descend onto the real floor');
|
||
|
||
// (b) NEVER arrives. Honest degradation: hold the launch altitude for the
|
||
// whole route rather than descending into ground nobody has measured.
|
||
const never = flightFrom(ALBUQUERQUE, { floorFn: () => null, cameraHeightM: LAUNCH_M });
|
||
let minHeight = Infinity;
|
||
for (let i = 0; i < 20000; i += 1) {
|
||
const frame = advanceRouteFlight(never, FRAME_S);
|
||
if (frame.arming) continue;
|
||
minHeight = Math.min(minHeight, frame.heightM);
|
||
if (frame.finished) break;
|
||
}
|
||
assert.ok(minHeight >= LAUNCH_M - 1e-6, `held ${minHeight.toFixed(0)} m against a ${LAUNCH_M} m launch`);
|
||
assert.equal(never.floorKnown, false, 'and it never claimed to know the floor');
|
||
assert.ok(never.coldFrames > 100, 'the cold branch carried the whole flight');
|
||
|
||
// The seed is bounded: at globe scale the camera's altitude says nothing
|
||
// about the ground under the route.
|
||
assert.equal(routeColdSeedFloorM(0, 2400), 2140);
|
||
assert.equal(routeColdSeedFloorM(0, 17_000_000), 5000, 'the seed is capped');
|
||
assert.equal(routeColdSeedFloorM(0, 100), 0, 'the seed never goes below the path');
|
||
assert.equal(routeColdSeedFloorM(0, Number.NaN), 0);
|
||
});
|
||
|
||
test('a flight warms its own corridor instead of waiting for contact traffic', () => {
|
||
const warmed = [];
|
||
const flight = flightFrom([[-106.61, 35.08], [-106.55, 35.08]], {
|
||
floorFn: () => null,
|
||
warmFn: (cells) => warmed.push(cells),
|
||
});
|
||
assert.equal(warmed.length, 1, 'the head of the corridor is warmed before the first frame');
|
||
const startBatch = warmed[0];
|
||
assert.ok(startBatch.length > 10, `start batch was ${startBatch.length} cells`);
|
||
assert.ok(startBatch.length <= 96, 'one flight can never issue an unbounded warm batch');
|
||
for (const cell of startBatch) {
|
||
assert.equal(cell.lat, Number(cell.lat.toFixed(3)), 'cells sit on the house 0.001° grid');
|
||
assert.equal(cell.lon, Number(cell.lon.toFixed(3)));
|
||
}
|
||
assert.equal(new Set(startBatch.map((c) => `${c.lat},${c.lon}`)).size, startBatch.length, 'deduped');
|
||
|
||
// The corridor keeps being warmed AHEAD of the camera as the flight runs.
|
||
for (let i = 0; i < 60 * 6; i += 1) advanceRouteFlight(flight, FRAME_S);
|
||
assert.ok(warmed.length > 1, 'the rolling lookahead warm never fired');
|
||
const later = warmed.at(-1);
|
||
assert.ok(
|
||
later[0].lon > startBatch[0].lon,
|
||
'the rolling warm must move ahead of the camera, not re-warm the start',
|
||
);
|
||
|
||
// A warm hook that throws must not take the flight down with it.
|
||
const hostile = flightFrom([[-106.61, 35.08], [-106.55, 35.08]], {
|
||
floorFn: () => 1600,
|
||
warmFn: () => { throw new Error('proxy down'); },
|
||
});
|
||
assert.ok(Number.isFinite(advanceRouteFlight(hostile, FRAME_S).heightM));
|
||
|
||
// The collector itself is bounded and ordered.
|
||
const cells = routeCorridorCells(flight, 0, 1e9, 12);
|
||
assert.equal(cells.length, 12, 'the cap is honoured');
|
||
});
|
||
|
||
test('the shaping floor is smoothed both ways, and the CLAMP reads the raw sample', () => {
|
||
assert.equal(routeFloorHoldM(Number.NaN, 120, FRAME_S), 120, 'a cold hold adopts the first sample');
|
||
assert.equal(routeFloorHoldM(400, Number.NaN, FRAME_S), 400, 'a cold cell keeps the held floor');
|
||
// Both directions ease: floor cells are a ~111 m staircase, and adopting a
|
||
// rise instantly popped the eye at every boundary (21.7 m/s over flat ground).
|
||
const rising = routeFloorHoldM(100, 900, FRAME_S);
|
||
assert.ok(rising > 100 && rising < 130, `a rise should ease, got ${rising}`);
|
||
const sinking = routeFloorHoldM(900, 100, FRAME_S);
|
||
assert.ok(sinking < 900 && sinking > 880, `a descent should ease, got ${sinking}`);
|
||
|
||
// Safety does not depend on that smoothing: a cliff appearing under the
|
||
// camera is cleared on the frame it is SEEN, because the clamp reads the raw
|
||
// sample rather than the smoothed one.
|
||
const flight = flightFrom(STRAIGHT_ROUTE, { floorFn: () => 0 });
|
||
advanceRouteFlight(flight, FRAME_S);
|
||
flight.floorFn = () => 5000;
|
||
const cliff = advanceRouteFlight(flight, FRAME_S);
|
||
assert.ok(
|
||
cliff.heightM >= 5000 + ROUTE_CINEMA.minClearanceM - 1e-6,
|
||
`a cliff seen this frame must be cleared this frame, eye was ${cliff.heightM.toFixed(0)} m`,
|
||
);
|
||
assert.ok(flight.floorM < 1000, 'while the SHAPING floor is still easing up behind it');
|
||
});
|
||
|
||
test('prefers-reduced-motion flattens the roll and the altitude shaping', () => {
|
||
const flight = flightFrom(TWO_TURN_ROUTE, { floorFn: () => 0, reducedMotion: true });
|
||
const frames = record(flight);
|
||
assert.equal(Math.max(...frames.map((f) => Math.abs(f.bankDeg))), 0, 'no roll under reduced motion');
|
||
for (const frame of frames) {
|
||
assert.ok(Math.abs(frame.aglM - ROUTE_CINEMA.meanHeightM) < 1e-6, 'altitude holds flat');
|
||
assert.ok(Math.abs(Cesium.Cartesian3.dot(frame.up, frame.direction)) < 1e-9);
|
||
}
|
||
// Easing survives: a reduced-motion flight still starts and stops from rest,
|
||
// because an abrupt stop is the motion a viewer feels most.
|
||
assert.ok(frames[0].speed < 2);
|
||
assert.ok(frames.at(-1).speed < 2);
|
||
assert.equal(routeAltitudeOffsetM(500, 0.5, true), 0);
|
||
});
|
||
|
||
test('the reduced-motion PREFERENCE reaches a real flight, not just the flag', () => {
|
||
// Injecting state.reducedMotion proves the shaping honours a boolean. It does
|
||
// not prove the OS preference is ever read — this drives the public path.
|
||
const priorWindow = globalThis.window;
|
||
const queries = [];
|
||
globalThis.window = {
|
||
...(priorWindow || {}),
|
||
matchMedia: (query) => { queries.push(query); return { matches: true }; },
|
||
};
|
||
try {
|
||
assert.equal(prefersReducedMotion(), true);
|
||
assert.ok(queries.some((q) => /prefers-reduced-motion:\s*reduce/.test(q)), `queried ${queries}`);
|
||
|
||
const viewer = createTickableViewer();
|
||
initCameraVerbs(viewer.viewer, () => null);
|
||
const started = flyRoute([{
|
||
type: 'route', label: 'reduced', path: TWO_TURN_ROUTE.map(([lon, lat]) => ({ lon, lat, height: 0 })),
|
||
}]);
|
||
assert.equal(started.ok, true);
|
||
assert.equal(getActiveCameraMotion()?.reducedMotion, true, 'flyRoute must consult the preference');
|
||
for (let i = 0; i < 600; i += 1) viewer.tick();
|
||
assert.ok(
|
||
Math.abs(viewer.rollDeg()) < 1e-3,
|
||
`a reduced-motion flight rolled the real camera to ${viewer.rollDeg()}°`,
|
||
);
|
||
interruptCameraMotion('test-cleanup');
|
||
|
||
globalThis.window.matchMedia = () => ({ matches: false });
|
||
assert.equal(prefersReducedMotion(), false);
|
||
flyRoute([{
|
||
type: 'route', label: 'normal', path: TWO_TURN_ROUTE.map(([lon, lat]) => ({ lon, lat, height: 0 })),
|
||
}]);
|
||
assert.equal(getActiveCameraMotion()?.reducedMotion, false);
|
||
interruptCameraMotion('test-cleanup');
|
||
viewer.restore();
|
||
} finally {
|
||
if (priorWindow === undefined) delete globalThis.window;
|
||
else globalThis.window = priorWindow;
|
||
}
|
||
});
|
||
|
||
test('an exact U-turn reverses the heading instead of flying the return leg backwards', () => {
|
||
// A Cartesian lerp cannot cross an antipodal pair: below t=0.5 the blend
|
||
// still points the old way, and no per-frame t ever reaches 0.5. The camera
|
||
// used to look BACKWARDS down the entire return leg (direction·travel = −1).
|
||
const OUT_AND_BACK = [[-97.76, 30.26], [-97.74, 30.26], [-97.76, 30.26]];
|
||
const flight = flightFrom(OUT_AND_BACK, { speed: 'slow', floorFn: () => 0 });
|
||
const outbound = Cesium.Cartesian3.normalize(
|
||
Cesium.Cartesian3.subtract(flight.pts[1], flight.pts[0], new Cesium.Cartesian3()),
|
||
new Cesium.Cartesian3(),
|
||
);
|
||
const returning = Cesium.Cartesian3.negate(outbound, new Cesium.Cartesian3());
|
||
const frames = record(flight);
|
||
const halfM = flight.totalM / 2;
|
||
|
||
let worstOut = 1;
|
||
let worstBack = 1;
|
||
for (const frame of frames) {
|
||
const heading = horizontalOf(frame.eye, frame.direction);
|
||
if (frame.traveledM < halfM - 200) {
|
||
worstOut = Math.min(worstOut, Cesium.Cartesian3.dot(heading, outbound));
|
||
} else if (frame.traveledM > halfM + 200) {
|
||
worstBack = Math.min(worstBack, Cesium.Cartesian3.dot(heading, returning));
|
||
}
|
||
}
|
||
assert.ok(worstOut > 0.98, `outbound heading drifted (worst dot ${worstOut.toFixed(3)})`);
|
||
assert.ok(
|
||
worstBack > 0.98,
|
||
`the return leg looked backwards (worst dot ${worstBack.toFixed(3)}; the lerp bug reads −1)`,
|
||
);
|
||
|
||
// And the U-turn's bank direction is decided once, not re-rolled every frame
|
||
// by the sign of a floating-point zero.
|
||
const banks = frames.map((f) => f.bankDeg);
|
||
let flips = 0;
|
||
for (let i = 1; i < banks.length; i += 1) {
|
||
if (Math.abs(banks[i]) > 2 && Math.abs(banks[i - 1]) > 2
|
||
&& Math.sign(banks[i]) !== Math.sign(banks[i - 1])) flips += 1;
|
||
}
|
||
assert.equal(flips, 0, 'the bank flipped sign mid-turn');
|
||
assert.ok(Math.max(...banks.map(Math.abs)) > 3, 'a U-turn should bank');
|
||
assert.ok(Math.max(...banks.map(Math.abs)) <= ROUTE_CINEMA.maxBankDeg + 1e-9);
|
||
});
|
||
|
||
test('signedTurnRad is deterministic at exactly 180°', () => {
|
||
const up = new Cesium.Cartesian3(0, 0, 1);
|
||
const east = new Cesium.Cartesian3(1, 0, 0);
|
||
const west = new Cesium.Cartesian3(-1, 0, 0);
|
||
// Both signed-zero orderings must agree: the cross product is (0, 0, ±0) and
|
||
// atan2 would hand back +π or −π depending on which zero came out.
|
||
assert.equal(signedTurnRad(east, west, up), Math.PI);
|
||
assert.equal(signedTurnRad(west, east, up), Math.PI);
|
||
// Ordinary turns are unaffected.
|
||
const north = new Cesium.Cartesian3(0, 1, 0);
|
||
assert.ok(signedTurnRad(east, north, up) < 0, 'east → north is a LEFT turn');
|
||
assert.ok(signedTurnRad(north, east, up) > 0, 'north → east is a RIGHT turn');
|
||
assert.ok(Math.abs(Math.abs(signedTurnRad(east, north, up)) - (Math.PI / 2)) < 1e-9);
|
||
});
|
||
|
||
test('approachValue is frame-rate independent and adopts a cold value', () => {
|
||
assert.equal(approachValue(Number.NaN, 7, 2, FRAME_S), 7);
|
||
const oneBigStep = approachValue(0, 10, 2, 0.5);
|
||
let manySmallSteps = 0;
|
||
for (let i = 0; i < 30; i += 1) manySmallSteps = approachValue(manySmallSteps, 10, 2, 0.5 / 30);
|
||
assert.ok(Math.abs(oneBigStep - manySmallSteps) < 1e-9, 'smoothing must not depend on frame rate');
|
||
const afterStall = approachValue(0, 10, 2, 100);
|
||
assert.ok(afterStall <= 10 && afterStall > 9.99, 'a long stall converges without overshooting');
|
||
});
|
||
|
||
test('an interrupt MID-BANK stops the dolly and puts the horizon back level', () => {
|
||
const viewer = createTickableViewer();
|
||
initCameraVerbs(viewer.viewer, () => null);
|
||
|
||
const started = flyRoute([{
|
||
type: 'route',
|
||
label: 'evidence route',
|
||
path: TWO_TURN_ROUTE.map(([lon, lat]) => ({ lon, lat, height: 0 })),
|
||
}]);
|
||
assert.equal(started.ok, true);
|
||
assert.equal(started.action, 'fly_route');
|
||
assert.ok(started.distanceM > 0);
|
||
assert.ok(started.durationS > 0);
|
||
assert.equal(getActiveCameraMotion()?.kind, 'route');
|
||
|
||
viewer.tick();
|
||
viewer.tick();
|
||
assert.ok(viewer.setViews.length >= 2, 'an active dolly drives the camera every tick');
|
||
|
||
// Fly on until the REAL camera is genuinely banked. Cutting a level camera
|
||
// would prove nothing about levelling.
|
||
for (let i = 0; i < 20000 && Math.abs(viewer.rollDeg()) < 3; i += 1) viewer.tick(FRAME_S);
|
||
const rollAtCut = viewer.rollDeg();
|
||
assert.ok(Math.abs(rollAtCut) >= 3, `the camera must be banked before the cut (${rollAtCut.toFixed(2)}°)`);
|
||
const poseAtCut = {
|
||
position: Cesium.Cartesian3.clone(viewer.viewer.camera.positionWC),
|
||
heading: viewer.viewer.camera.heading,
|
||
pitch: viewer.viewer.camera.pitch,
|
||
};
|
||
|
||
const { wasActive, leveled } = interruptCameraMotion('manual-input');
|
||
assert.equal(wasActive, true);
|
||
assert.equal(leveled, true, 'the release must take the roll out');
|
||
assert.equal(getActiveCameraMotion(), null, 'the motion slot is free the instant it is cut');
|
||
assert.ok(
|
||
Math.abs(viewer.rollDeg()) < 1e-9,
|
||
`the horizon was left tilted at ${viewer.rollDeg().toFixed(3)}° after the cut`,
|
||
);
|
||
// Levelling preserves where the camera was looking — it is not a re-frame.
|
||
// Asserted NUMERICALLY, not just by the omitted destination: position,
|
||
// heading and pitch must come out the far side unchanged.
|
||
const levelling = viewer.setViews.at(-1);
|
||
assert.equal(levelling.orientation.roll, 0);
|
||
assert.equal(levelling.destination, undefined, 'levelling must not move the camera');
|
||
assert.equal(
|
||
Cesium.Cartesian3.distance(viewer.viewer.camera.positionWC, poseAtCut.position), 0,
|
||
'the camera moved while being levelled',
|
||
);
|
||
assert.equal(viewer.viewer.camera.heading, poseAtCut.heading, 'heading changed while levelling');
|
||
assert.equal(viewer.viewer.camera.pitch, poseAtCut.pitch, 'pitch changed while levelling');
|
||
|
||
const writesAtCut = viewer.setViews.length;
|
||
viewer.tick();
|
||
viewer.tick();
|
||
viewer.tick();
|
||
assert.equal(viewer.setViews.length, writesAtCut, 'no camera write survives the interrupt');
|
||
|
||
// A second interrupt is inert, and never resurrects the flight.
|
||
assert.equal(interruptCameraMotion('again').wasActive, false);
|
||
assert.equal(getActiveCameraMotion(), null);
|
||
});
|
||
|
||
test('a completed dolly lands wings level and releases the slot', () => {
|
||
const viewer = createTickableViewer();
|
||
initCameraVerbs(viewer.viewer, () => null);
|
||
// A route that is still turning as it ends — the case where a bank would
|
||
// otherwise be frozen into the final frame.
|
||
const started = flyRoute([{
|
||
type: 'route',
|
||
label: 'ends in a corner',
|
||
path: [
|
||
[-97.760, 30.260], [-97.748, 30.260], [-97.748, 30.268], [-97.740, 30.268],
|
||
].map(([lon, lat]) => ({ lon, lat, height: 0 })),
|
||
}], { speed: 'fast' });
|
||
assert.equal(started.ok, true);
|
||
|
||
let banked = 0;
|
||
for (let i = 0; i < 20000 && getActiveCameraMotion(); i += 1) {
|
||
viewer.tick(FRAME_S);
|
||
if (Math.abs(viewer.rollDeg()) > 3) banked += 1;
|
||
}
|
||
assert.ok(banked > 10, 'the route must actually bank before it ends');
|
||
assert.equal(getActiveCameraMotion(), null, 'completion releases the motion slot');
|
||
assert.ok(
|
||
Math.abs(viewer.rollDeg()) < 1e-3,
|
||
`the flight ended holding ${viewer.rollDeg().toFixed(5)}° of roll`,
|
||
);
|
||
// The roll came out through the ease-out, not as a snap on the last frame.
|
||
const rolls = viewer.setViews
|
||
.filter((v) => v.orientation?.direction)
|
||
.map((v) => rollDegOf(v.destination, v.orientation.direction, v.orientation.up));
|
||
assert.ok(maxStep(rolls) < 0.5, `the unwind stepped ${maxStep(rolls).toFixed(3)}° in one frame`);
|
||
assert.ok(Math.abs(rolls.at(-1)) < 1e-3, 'the last APPLIED frame is level on its own');
|
||
});
|
||
|
||
test('a completed dolly lands on the last waypoint', () => {
|
||
const flight = flightFrom(STRAIGHT_ROUTE, { speed: 'fast' });
|
||
const frames = record(flight);
|
||
assert.equal(frames.at(-1).progress, 1);
|
||
// The final frame is applied before the slot is released — the camera lands
|
||
// on the route's last waypoint rather than stopping short of it.
|
||
const endpoint = Cesium.Cartesian3.fromDegrees(...STRAIGHT_ROUTE.at(-1), 0);
|
||
const endCarto = Cesium.Cartographic.fromCartesian(frames.at(-1).eye);
|
||
const endGround = Cesium.Cartesian3.fromRadians(endCarto.longitude, endCarto.latitude, 0);
|
||
assert.ok(Cesium.Cartesian3.distance(endpoint, endGround) < 1, 'the dolly lands on the last waypoint');
|
||
assert.equal(frames.at(-1).bankDeg, 0, 'and it lands level');
|
||
});
|
||
|
||
test('the 0.5 s duration floor is the one place the speed word is not the mean', () => {
|
||
// Documented, not accidental: a route shorter than the camera is tall would
|
||
// otherwise be an instant teleport (and divides the profile by ~zero).
|
||
const tiny = flightFrom([[-97.7600, 30.2600], [-97.75995, 30.2600]]);
|
||
assert.ok(tiny.totalM < 10, `fixture should be a few metres, got ${tiny.totalM.toFixed(1)}`);
|
||
assert.equal(tiny.durationS, 0.5, 'the floor applies');
|
||
assert.ok(tiny.totalM / tiny.durationS < CRUISE_M_S.normal, 'and it flies SLOWER than asked, never faster');
|
||
// Anything long enough to see keeps the contract exactly.
|
||
const normal = flightFrom(TWO_TURN_ROUTE);
|
||
assert.ok(Math.abs((normal.totalM / normal.durationS) - CRUISE_M_S.normal) < 1e-9);
|
||
});
|
||
|
||
test('a flight can be stopped by its owner, and only while it is still theirs', () => {
|
||
// CLEAR in the Directions row must stop the flight IT started. By then the
|
||
// camera may belong to someone else — the user grabbed it, voice flew
|
||
// somewhere, a tracked aircraft took over — and stopping that would be a
|
||
// feature reaching across the app to cancel a stranger's motion.
|
||
const viewer = createTickableViewer();
|
||
initCameraVerbs(viewer.viewer, () => null);
|
||
const path = TWO_TURN_ROUTE.map(([lon, lat]) => ({ lon, lat, height: 0 }));
|
||
|
||
assert.equal(activeCameraMotionId(), 0, 'an idle camera has no motion id');
|
||
const first = flyRoute([{ type: 'route', label: 'mine', path }]);
|
||
assert.equal(first.ok, true);
|
||
assert.ok(first.motionId > 0, 'a started flight reports its id');
|
||
assert.equal(activeCameraMotionId(), first.motionId);
|
||
assert.equal(getActiveCameraMotion().motionId, first.motionId);
|
||
|
||
// Someone else replaces it. The first owner's cancel must now be inert.
|
||
const second = flyRoute([{ type: 'route', label: 'theirs', path }]);
|
||
assert.ok(second.motionId > first.motionId, 'ids are monotonic');
|
||
assert.equal(
|
||
interruptCameraMotionIfActive(first.motionId, 'stale-owner').wasActive,
|
||
false,
|
||
);
|
||
assert.equal(
|
||
activeCameraMotionId(),
|
||
second.motionId,
|
||
'the newer flight is still running',
|
||
);
|
||
|
||
// The current owner stops its own flight.
|
||
assert.equal(
|
||
interruptCameraMotionIfActive(second.motionId, 'owner-cancel').wasActive,
|
||
true,
|
||
);
|
||
assert.equal(activeCameraMotionId(), 0);
|
||
assert.equal(getActiveCameraMotion(), null);
|
||
|
||
// A second cancel, and a cancel with no id at all, stop nothing.
|
||
assert.equal(interruptCameraMotionIfActive(second.motionId).wasActive, false);
|
||
assert.equal(interruptCameraMotionIfActive(0).wasActive, false);
|
||
viewer.restore();
|
||
});
|
||
|
||
test('a new viewer lifetime drops the previous view-target getter', () => {
|
||
// The getter closes over the viewer it was built for. Re-initialising for a
|
||
// new viewer without one must not leave the old callback answering.
|
||
const first = createTickableViewer();
|
||
const target = Cesium.Cartesian3.fromDegrees(-97.76, 30.26, 0);
|
||
let asked = 0;
|
||
initCameraVerbs(first.viewer, () => { asked += 1; return target; });
|
||
const orbit = moveCamera({ motion: 'orbit', mode: 'continuous' });
|
||
assert.equal(orbit.ok, true);
|
||
assert.equal(orbit.armed, undefined, 'the installed getter supplied a target');
|
||
assert.ok(asked > 0);
|
||
interruptCameraMotion('test-cleanup');
|
||
|
||
// Same viewer, no getter: the installed one stays.
|
||
initCameraVerbs(first.viewer);
|
||
const again = moveCamera({ motion: 'orbit', mode: 'continuous' });
|
||
assert.equal(again.armed, undefined, 'the getter survives a call without one');
|
||
interruptCameraMotion('test-cleanup');
|
||
first.restore();
|
||
|
||
// New viewer, no getter: the old one is gone, so the orbit arms instead of
|
||
// capturing a target from a viewer that no longer exists.
|
||
const second = createTickableViewer();
|
||
initCameraVerbs(second.viewer);
|
||
const askedBefore = asked;
|
||
const armed = moveCamera({ motion: 'orbit', mode: 'continuous' });
|
||
assert.equal(armed.armed, 'waiting-for-arrival');
|
||
assert.equal(asked, askedBefore, 'the old getter was never called again');
|
||
interruptCameraMotion('test-cleanup');
|
||
second.restore();
|
||
});
|