Files
gods-eye-view/src/cameraVerbs.js
T
Said Turksever 38eb98b8f8 Take a hidden shape off the globe whole, and keep every drawn shape
Answers the review of bdbbc3d7 and the IC8 pass after it.

A shape hidden from Draw's list was only set to alpha 0, so the line's
flowing stroke, which keeps its own colour, stayed painted. Now:
- the world renderer's sync() sets every entity of a hidden mark to
  show = false, and FlowMaterialProperty follows the mark's alpha;
- the screen renderer does not project a hidden callout, so it is not drawn
  and cannot push a visible label aside;
- hiding acts at once, even before a mark's first frame, and with every
  mark hidden the annotations render hold is released after one last frame;
- fly_route skips a hidden route.

Hand-drawn shapes are never merged as duplicates: every one is drawn on
purpose and unnamed until its row names it, so a second pin dropped near
the first was dropped, or replaced the first when its colour differed. A
restyle the renderer cannot draw now also syncs the renderer.

Tests cover each: renderer show/hide and flow alpha, the screen skip, the
render hold and its last frame, a show with no fade-in, hidden routes, two
nearby pins, and the restyle rollback.
2026-11-02 16:43:39 +00:00

1844 lines
69 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Camera verbs — the "spy satellite simulator" feel
* (docs/superpowers/specs/2026-07-23-camera-verbs-fly-route-spec.md).
*
* One motion at a time, driven per clock tick. `once` = bounded eased nudge;
* `continuous` runs until move_camera{stop}, ANY manual camera input on the
* canvas (pointerdown/wheel/touchstart — the cancelFlight reflex), or a navigation tool
* starts (the shared UI navigation policy releases this owner before the new
* destination mutates the camera). fly_route is a
* continuous motion in the same slot: a cinematic dolly along an existing
* route annotation's street-following path.
*
* Orbit circles the current screen-center ground target holding range and
* pitch while advancing heading (per-frame camera.lookAt; the lookAt
* transform is cleared on every stop so manual control returns cleanly).
* Globe rotation turns the world-frame camera about its screen-up axis through
* Earth's center, preserving range and framing. Left/right name the visible
* globe motion, opposite to the camera's travel around Earth.
*/
import * as Cesium from 'cesium';
import { GLOBE_VIEW } from './locations.js';
import {
projectEarthDiscToViewport,
earthDiscScreenRadius,
getKeyholeGeometry,
} from './celestialRing.js';
import {
holdContinuousRender,
releaseContinuousRender,
} from './renderGovernor.js';
let _isCockpitActive = null;
/** °/s by speed word — orbit; pan uses fractions of view height/s. */
const ORBIT_DEG_S = { slow: 2, normal: 6, fast: 15 };
const TILT_DEG_S = { slow: 4, normal: 10, fast: 20 };
const PAN_VIEW_FRACTION_S = { slow: 0.08, normal: 0.2, fast: 0.45 };
/** MEAN ground speed by speed word. The trapezoid profile below eases in and
* out around this mean, so a route still takes totalM / ROUTE_M_S seconds. */
const ROUTE_M_S = { slow: 20, normal: 40, fast: 90 };
/** Bounded `once` nudges (spec: tune by feel in the field test). */
const ONCE = {
orbitDeg: 30,
tiltDeg: 15,
rotateDeg: 15,
panViewFraction: 0.25,
durationS: 0.9,
};
const PITCH_MIN = Cesium.Math.toRadians(-89);
const PITCH_MAX = Cesium.Math.toRadians(-5);
/* ── Route dolly: cinematic tuning ──────────────────────────────────────────
* Every knob an owner may want to retune lives in this block. The shaping is
* built from four independent layers, each of which flattens to nothing on its
* own: a trapezoid speed profile, a banked-turn roll, altitude breathing, and
* a gaze that leads the path. Under prefers-reduced-motion the last three are
* zeroed and the dolly is a plain eased track along the route.
* -------------------------------------------------------------------------- */
/** Mean AGL the dolly rides at — the altitude shaping oscillates AROUND this. */
const ROUTE_CAMERA_HEIGHT_M = 260;
/** Locked look-down angle. Decoupled from the lookahead distance so the framing
* no longer changes with the speed word (it used to fall out of the geometry:
* a 260 m eye aiming at a point 260 m ahead is a hard −45° at the pavement). */
const ROUTE_PITCH_DEG = -32;
/** Seconds of travel the gaze leads the camera by (≈260 m at normal — the
* shipped constant — but coherent at slow and fast too). */
const ROUTE_LOOKAHEAD_S = 6.5;
const ROUTE_LOOKAHEAD_MIN_M = 120;
const ROUTE_LOOKAHEAD_MAX_M = 600;
/** 1/s — exponential smoothing rate for the dolly's gaze direction. */
const ROUTE_DIR_SMOOTH_RATE = 1.6;
/** Seconds of ease-in (and again ease-out) at the ends of the flight. */
const ROUTE_RAMP_S = 2.4;
/** Ramps never eat more than this share of a SHORT route's runtime. */
const ROUTE_RAMP_MAX_FRACTION = 0.35;
/** This is a map, not a flight sim: the roll is a hint, never a barrel roll. */
const ROUTE_BANK_MAX_DEG = 10;
/** Seconds of path (centred on the camera) the turn is measured over. The
* measure is a triangular pulse peaking exactly at the corner, so the bank
* rolls in before the turn and rolls out after it. */
const ROUTE_BANK_WINDOW_S = 4;
/** Degrees of bank per °/s of planned turn. After the roll filters below, a 90°
* street corner settles near 7.5° and a hairpin saturates the cap, so the cap
* stays a limit rather than the everyday value. */
const ROUTE_BANK_PER_DEG_S = 0.44;
/** Two cascaded first-order filters — C¹ roll entry/exit, no snap at the edges
* of the turn pulse. */
const ROUTE_BANK_LEAD_RATE = 2.2;
const ROUTE_BANK_SETTLE_RATE = 1.6;
/** Zero-mean altitude "breathing" over long straights. */
const ROUTE_BREATH_M = 20;
const ROUTE_BREATH_WAVELENGTH_M = 2200;
/** Extra height at full bank — the lookahead rise into a turn. Sized by its
* RATE, not its size: the lift has to be delivered inside the few seconds the
* camera takes to roll in, so a bigger number reads as a lurch, not a swell. */
const ROUTE_TURN_LIFT_M = 26;
/** 1/s — how fast the ALTITUDE follows the roll. Deliberately slower than the
* roll itself: the wings roll through a corner in a few seconds, but the swell
* that lifts the eye over it wants to be a long, shallow arc. */
const ROUTE_LIFT_TRACK_RATE = 0.5;
/** 1/s — the altitude shaping is filtered on its way to the camera so rolling
* out of a turn sinks the eye gently instead of dropping it. */
const ROUTE_ALT_SMOOTH_RATE = 0.9;
/** Hard clearance above the sampled ground floor; the shaping can never dip
* the eye below this, whatever the tuning above says. */
const ROUTE_MIN_CLEARANCE_M = 90;
/** 1/s — how fast the SHAPING floor follows the sampled one, in both
* directions. The hard clearance clamp reads the raw sample, so this rate is
* a comfort setting, not a safety one. */
const ROUTE_FLOOR_FALL_RATE = 0.9;
/** Below this |cos| the two chords are antipodal and the cross product's sign
* is a floating-point coin toss — an exact U-turn must not flip the bank
* direction frame to frame, so the degenerate case is branched explicitly. */
const ANTIPODAL_EPS = 1e-6;
/** Metres of corridor warmed when a flight starts, and the rolling window
* warmed ahead of the camera afterwards. Cells are the house 0.001° (~111 m)
* coarse cells, deduped by the warm batch, which also skips already-warm ones
* and queues (never drops) contending callers. */
const ROUTE_WARM_START_M = 4000;
const ROUTE_WARM_AHEAD_M = 3000;
const ROUTE_WARM_MAX_CELLS = 96;
/** Seconds between rolling corridor warms during a flight. */
const ROUTE_WARM_INTERVAL_S = 2;
/** Metres of arc between corridor samples (~half a coarse cell, so no cell the
* route crosses for more than ~55 m of ground is skipped). */
const ROUTE_WARM_STEP_M = 55;
/** Ceiling on the conservative floor seed used while the corridor is COLD.
* Above this the camera's own altitude stops being a useful proxy for the
* ground under it (a globe-scale view would park the dolly in orbit). */
const ROUTE_UNKNOWN_FLOOR_CAP_M = 5000;
/** Corridor cells the floor acquisition looks at, and therefore the HARD cap
* on sampleHeight calls a whole route may make: the mesh probe is latched to
* fire once per flight, so this is a per-route budget, not a per-frame one. */
const ROUTE_MESH_PROBE_CELLS = 8;
/** Seconds the dolly ARMS — camera untouched — waiting for real floor data
* before it starts moving. The corridor warm is fire-and-forget, so without
* this the first frame RACES it and can render underground. Matches the house
* one-shot floor deadline (FLOOR_RESOLVE_DEADLINE_MS), and is the same
* arm-then-act shape a chained orbit uses while its flight settles. */
const ROUTE_ARM_S = 1.2;
/** m/s — cap on how fast the eye may DESCEND. Climbs are never limited (they
* are the safety direction). Ordinary terrain following runs a few m/s, so
* this only bites when a hold is released onto late-arriving floor data — most
* visibly when the mesh probe seeded the floor from a downtown ROOFTOP and the
* real DEM later corrects it ~90 m downward. Held at the same bound as the
* altitude shaping so that correction reads as one more swell rather than as a
* sink of its own. Lagging HIGH is the safe direction, so a genuinely steep
* downhill simply floats a little wider of the ground. */
const ROUTE_MAX_DESCENT_MPS = 10;
/* ── Pure cinematic helpers (unit-tested; no viewer, no module state) ────── */
/** Whether the operating system asked us to keep motion boring. */
export function prefersReducedMotion() {
try {
return (
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches === true
);
} catch {
return false;
}
}
/**
* Frame-rate independent first-order approach (exponential smoothing).
* `1 - e^(-rate·dt)` rather than `rate·dt` so a dropped frame cannot overshoot.
* @param {number} current Current value (NaN adopts the target).
* @param {number} target Value to approach.
* @param {number} ratePerS Approach rate, 1/s.
* @param {number} dt Seconds elapsed.
* @returns {number} The smoothed value.
*/
export function approachValue(current, target, ratePerS, dt) {
if (!Number.isFinite(current)) return target;
if (!Number.isFinite(target)) return current;
const k = 1 - Math.exp(-Math.max(0, ratePerS) * Math.max(0, dt));
return current + (target - current) * k;
}
/**
* Share of the flight's runtime spent easing in (and again easing out).
* @param {number} durationS Flight duration.
* @returns {number} Ramp fraction in [0, ROUTE_RAMP_MAX_FRACTION].
*/
export function routeRampFraction(durationS) {
if (!(durationS > 0)) return 0;
return Math.min(ROUTE_RAMP_MAX_FRACTION, ROUTE_RAMP_S / durationS);
}
/**
* Trapezoid speed profile — smoothstep up, cruise, smoothstep down.
*
* Distance is the CLOSED-FORM integral of the speed curve, so position is
* exact (no per-frame accumulation drift) and speed is continuous everywhere:
* zero at both ends, and C¹ across the ramp/plateau joins because smoothstep
* has zero slope at 0 and 1. Waypoints are invisible to it — the profile is a
* function of arc length only, so nothing steps as a segment changes.
* @param {number} u Normalized time, 0..1.
* @param {number} r Ramp fraction from routeRampFraction().
* @returns {{distance: number, speed: number}} Arc fraction 0..1, and speed as
* a multiple of the plateau (cruise) speed.
*/
export function routeSpeedProfile(u, r) {
const t = Math.min(1, Math.max(0, Number.isFinite(u) ? u : 0));
if (!(r > 0)) return { distance: t, speed: 1 };
const area = 1 - r; // ∫₀¹ v du — both ramps together contribute r
const rampArea = (p) => r * (p * p * p - (p * p * p * p) / 2);
const smoothstep = (p) => p * p * (3 - 2 * p);
if (t <= r) {
const p = t / r;
return { distance: rampArea(p) / area, speed: smoothstep(p) };
}
if (t >= 1 - r) {
const p = (1 - t) / r;
return { distance: (area - rampArea(p)) / area, speed: smoothstep(p) };
}
return { distance: (r / 2 + (t - r)) / area, speed: 1 };
}
/**
* Bank angle for a planned turn rate — proportional, capped, honest about zero.
* @param {number} turnRateDegS Planned turn rate, °/s (+ = turning right).
* @param {boolean} [reducedMotion] Flatten the roll entirely.
* @returns {number} Degrees of roll, + = right wing down.
*/
export function routeBankTargetDeg(turnRateDegS, reducedMotion = false) {
if (reducedMotion || !Number.isFinite(turnRateDegS)) return 0;
const raw = turnRateDegS * ROUTE_BANK_PER_DEG_S;
return Math.max(-ROUTE_BANK_MAX_DEG, Math.min(ROUTE_BANK_MAX_DEG, raw));
}
/**
* Altitude offset around the route's mean AGL: a zero-mean breath over long
* straights, plus a small rise into turns for lookahead. Breathing fades out
* as the bank comes in so the two never fight.
* @param {number} arcM Distance travelled along the route.
* @param {number} bankFraction Current bank as a fraction of the cap, −1..1.
* @param {boolean} [reducedMotion] Flatten to a constant altitude.
* @returns {number} Metres to add to the mean AGL.
*/
export function routeAltitudeOffsetM(
arcM,
bankFraction,
reducedMotion = false,
) {
if (reducedMotion) return 0;
const bank = Number.isFinite(bankFraction)
? Math.max(-1, Math.min(1, bankFraction))
: 0;
const straight = 1 - Math.abs(bank);
const s = Number.isFinite(arcM) ? arcM : 0;
const breath =
Math.sin((2 * Math.PI * s) / ROUTE_BREATH_WAVELENGTH_M) *
ROUTE_BREATH_M *
straight;
return breath + Math.abs(bank) * ROUTE_TURN_LIFT_M;
}
/**
* Eye height above the ellipsoid. The last word on terrain: whatever the
* shaping asks for, the eye stays ROUTE_MIN_CLEARANCE_M above the floor.
* @param {number} floorM Ground floor height at the camera.
* @param {number} offsetM Altitude shaping offset.
* @returns {number} Height above the ellipsoid.
*/
export function routeEyeHeightM(floorM, offsetM) {
const base = Number.isFinite(floorM) ? floorM : 0;
const shaped =
base + ROUTE_CAMERA_HEIGHT_M + (Number.isFinite(offsetM) ? offsetM : 0);
return Math.max(base + ROUTE_MIN_CLEARANCE_M, shaped);
}
/**
* Track the ground floor SMOOTHLY, in both directions.
*
* The floor is a staircase: cells are 0.001° (~111 m) and the dolly reads the
* max of the cell it is over and one ahead, so crossing a boundary can step
* several metres in one frame. Adopting a rise instantly — which terrain safety
* seems to argue for — turns every one of those steps into an altitude pop
* (measured: 21.7 m/s of vertical rate over otherwise flat downtown). Safety
* does not actually need it: the hard clamp in advanceRouteFlight is applied
* against the RAW sample, so the smoothed value only ever shapes the ride.
* @param {number} previousM Held floor.
* @param {number} sampledM Newly sampled floor (NaN keeps the held value).
* @param {number} dt Seconds elapsed.
* @returns {number} The floor to shape the flight over this frame.
*/
export function routeFloorHoldM(previousM, sampledM, dt) {
if (!Number.isFinite(sampledM)) return previousM;
if (!Number.isFinite(previousM)) return sampledM;
return approachValue(previousM, sampledM, ROUTE_FLOOR_FALL_RATE, dt);
}
/** Read-only view of the tuning, for tests and harnesses. */
export const ROUTE_CINEMA = Object.freeze({
meanHeightM: ROUTE_CAMERA_HEIGHT_M,
pitchDeg: ROUTE_PITCH_DEG,
maxBankDeg: ROUTE_BANK_MAX_DEG,
minClearanceM: ROUTE_MIN_CLEARANCE_M,
breathM: ROUTE_BREATH_M,
turnLiftM: ROUTE_TURN_LIFT_M,
rampS: ROUTE_RAMP_S,
});
/* ── Route dolly geometry ────────────────────────────────────────────────── */
const _arcA = new Cesium.Cartesian3();
const _arcB = new Cesium.Cartesian3();
const _chordVertical = new Cesium.Cartesian3();
const _turnCross = new Cesium.Cartesian3();
const _framePos = new Cesium.Cartesian3();
const _frameAheadPos = new Cesium.Cartesian3();
const _frameUp = new Cesium.Cartesian3();
const _frameIn = new Cesium.Cartesian3();
const _frameOut = new Cesium.Cartesian3();
const _frameGaze = new Cesium.Cartesian3();
const _frameRight = new Cesium.Cartesian3();
const _frameLevelUp = new Cesium.Cartesian3();
const _frameDir = new Cesium.Cartesian3();
const _frameEye = new Cesium.Cartesian3();
const _frameBankedUp = new Cesium.Cartesian3();
const _frameCarto = new Cesium.Cartographic();
const _frameAheadCarto = new Cesium.Cartographic();
const _bankQuat = new Cesium.Quaternion();
const _bankMatrix = new Cesium.Matrix3();
const _headingQuat = new Cesium.Quaternion();
const _headingMatrix = new Cesium.Matrix3();
const _warmPos = new Cesium.Cartesian3();
const _warmCarto = new Cesium.Cartographic();
const _probeCarto = new Cesium.Cartographic();
/** Point at arc length `s` along the polyline (binary search — queries jump
* backwards for the inbound chord, so a forward cursor would not do). */
function arcPoint(state, s, result) {
const { pts, cumM } = state;
const clamped = Math.min(state.totalM, Math.max(0, s));
let lo = 0;
let hi = cumM.length - 2;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (cumM[mid] <= clamped) lo = mid;
else hi = mid - 1;
}
const segLen = cumM[lo + 1] - cumM[lo];
const t =
segLen > 1e-6 ? Math.min(1, Math.max(0, (clamped - cumM[lo]) / segLen)) : 0;
return Cesium.Cartesian3.lerp(pts[lo], pts[lo + 1], t, result);
}
/** Unit horizontal direction from arc `sA` to arc `sB`, or null when degenerate. */
function horizontalChord(state, sA, sB, up, result) {
const a = arcPoint(state, sA, _arcA);
const b = arcPoint(state, sB, _arcB);
Cesium.Cartesian3.subtract(b, a, result);
const vertical = Cesium.Cartesian3.multiplyByScalar(
up,
Cesium.Cartesian3.dot(result, up),
_chordVertical,
);
Cesium.Cartesian3.subtract(result, vertical, result);
const len = Cesium.Cartesian3.magnitude(result);
if (!(len > 1e-3)) return null;
return Cesium.Cartesian3.divideByScalar(result, len, result);
}
/**
* Signed angle from `a` to `b` about `up`; positive = turning RIGHT.
*
* At exactly 180° the cross product is zero and its sign is decided by
* floating-point signed zeros, so an out-and-back route would flip the bank
* direction frame to frame. A U-turn is resolved as a RIGHT turn, always —
* arbitrary, but deterministic, which is the property that matters.
* @param {Cesium.Cartesian3} a Unit horizontal direction turned FROM.
* @param {Cesium.Cartesian3} b Unit horizontal direction turned TO.
* @param {Cesium.Cartesian3} up Local up, the axis the turn is measured about.
* @returns {number} Radians, + = right.
*/
export function signedTurnRad(a, b, up) {
const cross = Cesium.Cartesian3.cross(a, b, _turnCross);
const sinLeft = Cesium.Cartesian3.dot(cross, up);
const cosTurn = Cesium.Cartesian3.dot(a, b);
if (cosTurn <= -1 + ANTIPODAL_EPS && Math.abs(sinLeft) < ANTIPODAL_EPS)
return Math.PI;
return -Math.atan2(sinLeft, cosTurn);
}
/**
* Coarse floor cells along a stretch of the route, in travel order.
* Deduped to the house 0.001° grid and capped, so one flight can never issue an
* unbounded warm batch.
* @param {Object} state Route flight state (needs pts/cumM/totalM).
* @param {number} fromM Arc length to start collecting at.
* @param {number} spanM Metres of arc to cover.
* @param {number} [maxCells] Cap on the returned cells.
* @returns {Array<{lat: number, lon: number}>} Cells to warm.
*/
export function routeCorridorCells(
state,
fromM,
spanM,
maxCells = ROUTE_WARM_MAX_CELLS,
) {
const out = [];
if (!(state?.totalM > 0) || !(spanM > 0) || !(maxCells > 0)) return out;
const start = Math.max(0, Math.min(state.totalM, fromM));
const end = Math.max(0, Math.min(state.totalM, fromM + spanM));
const seen = new Set();
for (
let s = start;
s <= end + 1e-6 && out.length < maxCells;
s += ROUTE_WARM_STEP_M
) {
const point = arcPoint(state, s, _warmPos);
const carto = Cesium.Cartographic.fromCartesian(
point,
Cesium.Ellipsoid.WGS84,
_warmCarto,
);
const lat = Number(Cesium.Math.toDegrees(carto.latitude).toFixed(3));
const lon = Number(Cesium.Math.toDegrees(carto.longitude).toFixed(3));
const key = `${lat},${lon}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({ lat, lon });
}
return out;
}
/** Fire-and-forget warm of a corridor stretch; never throws into the caller. */
function warmRouteCorridor(state, fromM, spanM) {
if (typeof state?.warmFn !== 'function') return 0;
const cells = routeCorridorCells(state, fromM, spanM);
if (!cells.length) return 0;
try {
state.warmFn(cells);
} catch {
return 0; // best effort — a cold cell holds the last known floor
}
state.warmedCells += cells.length;
return cells.length;
}
/**
* The floor to hold while the corridor is still cold.
*
* Cold is missing data, not flat ground, and the route's own vertices carry
* height 0 — so the previous fallback flew a Rocky Mountain route at 260 m
* above the ELLIPSOID. The camera's current position is, by construction,
* above the ground the user is looking at, so its height minus the cruise AGL
* is a floor we can defend. It is capped, because at globe scale the camera's
* altitude stops saying anything about the ground under it.
* @param {number} pathHeightM The route vertices' own height.
* @param {number} cameraHeightM Camera height at flight start, if known.
* @returns {number} A floor to hold until real terrain arrives.
*/
export function routeColdSeedFloorM(pathHeightM, cameraHeightM) {
const base = Number.isFinite(pathHeightM) ? pathHeightM : 0;
if (!Number.isFinite(cameraHeightM)) return base;
const implied = cameraHeightM - ROUTE_CAMERA_HEIGHT_M;
return Math.max(base, Math.min(base + ROUTE_UNKNOWN_FLOOR_CAP_M, implied));
}
/**
* Adopt the corridor's floor, from the cache or — failing that — from the
* rendered mesh directly.
*
* Runs at construction and again on every ARMING frame, because the corridor
* warm is fire-and-forget and this is how the dolly notices it landed. The
* per-frame part is cache READS (a Map lookup per cell); the mesh probe is
* latched to fire at most ONCE per flight, so a whole route costs at most
* ROUTE_MESH_PROBE_CELLS sampleHeight calls however long it arms.
*
* A partially warm corridor is NOT resolved. One cached cell says nothing
* about the ground under the other seven, and treating it as an answer let the
* dolly descend to 460 m over a 1,600 m surface. Every cell must be accounted
* for — by the cache, or by a probe that actually reached it.
*
* The probe is the answer to "what if the DEM never arrives at all", and it
* closes the hole in the case where the hole is observable: clipping requires
* terrain to BE rendered, and what is rendered is what sampleHeight reads. It
* reads the rendered surface at the CURRENT level of detail — rooftops and
* primitives included — which is a better estimate than nothing but is not a
* guaranteed upper bound. The per-frame raw-sample clamp remains the backstop.
* @param {Object} state Route flight state.
* @param {boolean} takeWhole Adopt the value outright (pre-departure) rather
* than easing onto it — a mid-flight arrival must never snap the eye.
* @returns {boolean} Whether the corridor's floor is now RESOLVED.
*/
function acquireCorridorFloor(state, takeWhole) {
const cells = routeCorridorCells(
state,
state.traveled,
ROUTE_WARM_START_M,
ROUTE_MESH_PROBE_CELLS,
);
if (!cells.length) return false;
const known = [];
const cold = [];
if (typeof state.floorFn === 'function') {
for (const cell of cells) {
let floor = Number.NaN;
try {
floor = state.floorFn(cell.lat, cell.lon);
} catch {
/* a hostile read is the same as a cold one */
}
if (Number.isFinite(floor)) known.push(floor);
else cold.push(cell);
}
} else {
cold.push(...cells);
}
// The probe fires once, for exactly the cells the cache could not answer.
if (
cold.length &&
!state.meshProbeSpent &&
typeof state.probeFn === 'function'
) {
state.meshProbeSpent = true; // latched whatever it returns — one per flight
try {
const probe = state.probeFn(cold) || {};
if (Number.isFinite(probe.heightM)) {
state.meshProbeFloorM = probe.heightM;
state.floorFromMeshProbe = true;
// Only a probe that reached EVERY cold cell resolves the corridor.
state.meshProbeCoveredCold = probe.sampled >= cold.length;
}
} catch {
/* tiles not streamed, or a scene mid-teardown */
}
}
if (Number.isFinite(state.meshProbeFloorM)) known.push(state.meshProbeFloorM);
if (!known.length) return false;
const floor = Math.max(...known);
// A partial answer still RAISES the held floor — knowing something is better
// than the seed — but it does not resolve the corridor, so the safe hold and
// the arm stay in force.
state.floorM =
takeWhole || !Number.isFinite(state.floorM)
? floor
: Math.max(state.floorM, floor);
if (cold.length && !state.meshProbeCoveredCold) return false;
state.floorKnown = true;
return true;
}
/**
* Highest rendered surface across a handful of corridor cells, with the number
* of cells that actually answered — a cell whose tiles are not streamed yet
* contributes nothing, and the caller must not mistake silence for flat ground.
* @param {Cesium.Scene|undefined} scene The live scene.
* @param {Array<{lat: number, lon: number}>} cells Cells to probe.
* @returns {{heightM: number, sampled: number, requested: number}} The reading.
*/
export function probeMeshFloorM(scene, cells) {
const requested = Array.isArray(cells) ? cells.length : 0;
const empty = { heightM: Number.NaN, sampled: 0, requested };
if (!scene || typeof scene.sampleHeight !== 'function' || !requested)
return empty;
let heightM = Number.NaN;
let sampled = 0;
for (const cell of cells) {
try {
const carto = Cesium.Cartographic.fromDegrees(
cell.lon,
cell.lat,
0,
_probeCarto,
);
const height = scene.sampleHeight(carto);
if (!Number.isFinite(height)) continue;
sampled += 1;
heightM = Number.isFinite(heightM) ? Math.max(heightM, height) : height;
} catch {
/* tiles not ready for this cell */
}
}
return { heightM, sampled, requested };
}
/**
* Build the state for one cinematic route flight.
* @param {Object} options
* @param {Cesium.Cartesian3[]} options.pts Route vertices.
* @param {number[]} options.cumM Cumulative arc length per vertex.
* @param {string} [options.speed] slow | normal | fast.
* @param {Function|null} [options.floorFn] (latDeg, lonDeg) => floor metres.
* @param {Function|null} [options.warmFn] (cells) => void — corridor floor warm.
* @param {Function|null} [options.probeFn] (cells) => {heightM, sampled} —
* synchronous rendered-mesh probe, fired at most ONCE per flight and only for
* the cells the cached floor could not answer.
* @param {number} [options.cameraHeightM] Camera height at start, for the cold seed.
* @param {boolean} [options.reducedMotion] Flatten bank + altitude shaping.
* @returns {Object} Motion state for advanceRouteFlight().
*/
export function createRouteFlight({
pts,
cumM,
speed = 'normal',
floorFn = null,
warmFn = null,
probeFn = null,
cameraHeightM = Number.NaN,
reducedMotion = false,
}) {
const totalM = cumM[cumM.length - 1];
const cruiseMps = ROUTE_M_S[speed] || ROUTE_M_S.normal;
// The 0.5 s floor keeps a degenerate route from being an instant teleport (it
// also divides the profile). It is the one case where the speed word is not
// the mean: routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly slower
// than asked. Those are shorter than the camera is tall — nothing to see.
const durationS = Math.max(0.5, totalM / cruiseMps);
const pathHeightM = Cesium.Cartographic.fromCartesian(
pts[0],
Cesium.Ellipsoid.WGS84,
_warmCarto,
).height;
const state = {
kind: 'route',
mode: 'continuous',
direction: 'forward',
speed,
pts,
cumM,
totalM,
durationS,
ramp: routeRampFraction(durationS),
u: 0,
traveled: 0,
floorFn,
warmFn,
probeFn,
reducedMotion,
floorM: Number.NaN,
floorKnown: false,
coldSeedFloorM: routeColdSeedFloorM(pathHeightM, cameraHeightM),
// The one altitude we can defend without terrain data: the camera is
// already there, and the user is already looking at the world from it. It
// is a NON-DESCENT guarantee, not a clearance guarantee — nothing can
// promise clearance over ground that was never measured.
safeHoldHeightM: Number.isFinite(cameraHeightM)
? cameraHeightM
: Number.NaN,
coldFrames: 0,
floorFromMeshProbe: false,
meshProbeSpent: false,
meshProbeFloorM: Number.NaN,
meshProbeCoveredCold: false,
armS: 0,
armed: false,
departed: false,
appliedHeightM: Number.NaN,
warmedCells: 0,
warmDueS: ROUTE_WARM_INTERVAL_S,
offsetM: Number.NaN,
bankLeadDeg: 0,
bankDeg: 0,
appliedBankDeg: 0,
liftBankDeg: 0,
headingDir: null,
groundSpeedMps: 0,
};
// Warm the head of the corridor before the first frame renders, then check
// whether anything is warm ALREADY (contact traffic, or a second run over the
// same route) — that flight needs no arming and no hold at all.
warmRouteCorridor(state, 0, ROUTE_WARM_START_M);
acquireCorridorFloor(state, true);
return state;
}
/**
* Advance one dolly frame. Returns the camera pose to apply; the caller owns
* the viewer (so this stays testable without one).
* @param {Object} state From createRouteFlight().
* @param {number} dt Seconds elapsed.
* @returns {{finished: boolean, eye: Cesium.Cartesian3, direction: Cesium.Cartesian3,
* up: Cesium.Cartesian3, bankDeg: number, heightM: number, aglM: number,
* groundSpeedMps: number, progress: number}} The frame.
*/
export function advanceRouteFlight(state, dt) {
const step = Math.min(0.25, Math.max(0, Number.isFinite(dt) ? dt : 0));
// ARMING. The corridor warm is fire-and-forget, so the alternative to waiting
// is racing it — and losing that race means rendering the first frames at an
// altitude derived from nothing. While armed the camera is not touched at
// all: no teleport onto the route, no descent. The wait is covered by the
// model speaking its confirmation, and a warm cache skips it entirely.
const canAnswer =
typeof state.floorFn === 'function' || typeof state.probeFn === 'function';
if (
canAnswer &&
!state.floorKnown &&
!state.departed &&
state.armS < ROUTE_ARM_S
) {
state.armS += step;
if (!acquireCorridorFloor(state, true)) {
return { arming: true, finished: false, progress: 0, bankDeg: 0 };
}
}
state.departed = true;
state.u = Math.min(1, (state.u || 0) + step / state.durationS);
const profile = routeSpeedProfile(state.u, state.ramp);
const traveled = state.totalM * profile.distance;
state.traveled = traveled;
const cruiseMps = state.totalM / (state.durationS * (1 - state.ramp));
state.groundSpeedMps = cruiseMps * profile.speed;
const pos = arcPoint(state, traveled, _framePos);
const carto = Cesium.Cartographic.fromCartesian(
pos,
Cesium.Ellipsoid.WGS84,
_frameCarto,
);
const up = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormalCartographic(
carto,
_frameUp,
);
// Planned turn: compare where we came from with where we are going, over a
// window centred on the camera. Peaks AT the corner, tapers either side.
const halfWindowM = Math.max(20, (ROUTE_BANK_WINDOW_S * cruiseMps) / 2);
const inbound = horizontalChord(
state,
traveled - halfWindowM,
traveled,
up,
_frameIn,
);
const outbound = horizontalChord(
state,
traveled,
traveled + halfWindowM,
up,
_frameOut,
);
const turnRateDegS =
inbound && outbound
? Cesium.Math.toDegrees(signedTurnRad(inbound, outbound, up)) /
ROUTE_BANK_WINDOW_S
: 0;
const bankTarget = routeBankTargetDeg(turnRateDegS, state.reducedMotion);
state.bankLeadDeg = approachValue(
state.bankLeadDeg,
bankTarget,
ROUTE_BANK_LEAD_RATE,
step,
);
state.bankDeg = approachValue(
state.bankDeg,
state.bankLeadDeg,
ROUTE_BANK_SETTLE_RATE,
step,
);
// Wings level for takeoff and landing. The speed profile is already a
// smoothstep envelope that is 1 across the cruise and exactly 0 at both ends,
// so reusing it unwinds the roll as the dolly slows — and guarantees the LAST
// applied frame is level, with no snap to get there.
const appliedBankDeg = state.bankDeg * profile.speed;
// Gaze leads along the arc, then eases toward the new heading so corners
// swing like a crane shot instead of a whip pan.
const lookaheadM = Math.min(
ROUTE_LOOKAHEAD_MAX_M,
Math.max(ROUTE_LOOKAHEAD_MIN_M, ROUTE_LOOKAHEAD_S * cruiseMps),
);
const gaze =
horizontalChord(state, traveled, traveled + lookaheadM, up, _frameGaze) ||
outbound ||
inbound;
if (gaze && !state.headingDir)
state.headingDir = Cesium.Cartesian3.clone(gaze);
else if (gaze) {
// Rotate the heading TOWARD the gaze about the local up, rather than
// lerping the two vectors. A Cartesian lerp cannot cross an antipodal pair
// (a U-turn): below t=0.5 the blend still points backwards, and no
// per-frame t ever reaches 0.5, so the camera stayed reversed for the whole
// return leg. An angle always crosses.
const k = 1 - Math.exp(-ROUTE_DIR_SMOOTH_RATE * step);
const turnToGaze = signedTurnRad(state.headingDir, gaze, up);
// signedTurnRad is + for a RIGHT turn; a positive rotation about up is a
// LEFT turn, so the step is negated.
const rotation = Cesium.Matrix3.fromQuaternion(
Cesium.Quaternion.fromAxisAngle(up, -turnToGaze * k, _headingQuat),
_headingMatrix,
);
const turned = Cesium.Matrix3.multiplyByVector(
rotation,
state.headingDir,
_frameDir,
);
const vertical = Cesium.Cartesian3.multiplyByScalar(
up,
Cesium.Cartesian3.dot(turned, up),
_chordVertical,
);
Cesium.Cartesian3.subtract(turned, vertical, turned);
if (Cesium.Cartesian3.magnitude(turned) > 1e-6) {
Cesium.Cartesian3.normalize(turned, state.headingDir);
}
}
// A path with no horizontal extent (identical lat/lon, differing heights)
// has no azimuth to fly: point east so the frame stays orthonormal.
if (!state.headingDir) {
const east = Cesium.Cartesian3.fromElements(-pos.y, pos.x, 0, _frameGaze);
state.headingDir =
Cesium.Cartesian3.magnitude(east) > 1e-6
? Cesium.Cartesian3.normalize(east, new Cesium.Cartesian3())
: Cesium.Cartesian3.fromElements(1, 0, 0, new Cesium.Cartesian3());
}
const heading = state.headingDir;
// Locked pitch: heading gives the azimuth, the pitch never wanders.
const pitch = Cesium.Math.toRadians(ROUTE_PITCH_DEG);
const direction = Cesium.Cartesian3.normalize(
Cesium.Cartesian3.add(
Cesium.Cartesian3.multiplyByScalar(heading, Math.cos(pitch), _frameDir),
Cesium.Cartesian3.multiplyByScalar(up, Math.sin(pitch), _frameLevelUp),
_frameDir,
),
_frameDir,
);
// Orthonormal camera frame, then roll it about its own forward axis: a
// positive rotation drops the right wing, which is what a right turn wants.
const right = Cesium.Cartesian3.normalize(
Cesium.Cartesian3.cross(direction, up, _frameRight),
_frameRight,
);
const levelUp = Cesium.Cartesian3.normalize(
Cesium.Cartesian3.cross(right, direction, _frameLevelUp),
_frameLevelUp,
);
const bankRad = Cesium.Math.toRadians(appliedBankDeg);
let bankedUp = levelUp;
if (Math.abs(bankRad) > 1e-6) {
const quat = Cesium.Quaternion.fromAxisAngle(direction, bankRad, _bankQuat);
const rotation = Cesium.Matrix3.fromQuaternion(quat, _bankMatrix);
bankedUp = Cesium.Matrix3.multiplyByVector(
rotation,
levelUp,
_frameBankedUp,
);
}
// Keep the corridor ahead of the camera warm. The batch dedupes to coarse
// cells, skips warm ones and queues contenders, so a repeat costs nothing.
state.warmDueS -= step;
if (state.warmDueS <= 0) {
state.warmDueS = ROUTE_WARM_INTERVAL_S;
warmRouteCorridor(state, traveled, ROUTE_WARM_AHEAD_M);
}
// Ground floor: the max of here and just ahead, so a rise is cleared BEFORE
// the camera reaches it. Both are cached reads — nothing samples the mesh.
let sampledFloor = Number.NaN;
if (typeof state.floorFn === 'function') {
const aheadPos = arcPoint(state, traveled + lookaheadM / 2, _frameAheadPos);
const aheadCarto = Cesium.Cartographic.fromCartesian(
aheadPos,
Cesium.Ellipsoid.WGS84,
_frameAheadCarto,
);
try {
const here = state.floorFn(
Cesium.Math.toDegrees(carto.latitude),
Cesium.Math.toDegrees(carto.longitude),
);
const ahead = state.floorFn(
Cesium.Math.toDegrees(aheadCarto.latitude),
Cesium.Math.toDegrees(aheadCarto.longitude),
);
const warm = [here, ahead].filter((value) => Number.isFinite(value));
if (warm.length) sampledFloor = Math.max(...warm);
} catch {
sampledFloor = Number.NaN;
}
}
if (!Number.isFinite(sampledFloor)) {
// NEVER descend blind. A cold cell is missing data, not flat ground — the
// route's own vertices carry height 0, so trusting them put the eye 1.3 km
// UNDER Albuquerque. Hold the last floor we actually knew; before we have
// ever known one, hold the conservative seed captured at flight start.
state.floorM = Number.isFinite(state.floorM)
? state.floorM
: state.coldSeedFloorM;
state.coldFrames += 1;
} else if (!state.floorKnown) {
// Data arrived AFTER the arm gave up. Easing is the right move now: the
// camera is already flying, so taking the gap between the safety hold and
// the ground in one frame would snap. (Pre-departure acquisition takes it
// whole — see acquireCorridorFloor — because nothing is moving yet.)
state.floorM = routeFloorHoldM(state.floorM, sampledFloor, step);
state.floorKnown = true;
} else {
state.floorM = routeFloorHoldM(state.floorM, sampledFloor, step);
}
state.liftBankDeg = approachValue(
state.liftBankDeg,
appliedBankDeg,
ROUTE_LIFT_TRACK_RATE,
step,
);
const bankFraction = state.liftBankDeg / ROUTE_BANK_MAX_DEG;
state.offsetM = approachValue(
state.offsetM,
routeAltitudeOffsetM(traveled, bankFraction, state.reducedMotion),
ROUTE_ALT_SMOOTH_RATE,
step,
);
// Two floors, two jobs. The SMOOTHED floor shapes the ride, so a cell
// boundary does not pop the eye. The RAW sample owns the safety clamp, so a
// real rise is cleared on the frame it is seen, smoothing or not.
let heightM = Math.max(
routeEyeHeightM(state.floorM, state.offsetM),
(Number.isFinite(sampledFloor) ? sampledFloor : state.floorM) +
ROUTE_MIN_CLEARANCE_M,
);
// Still no terrain data (the arm timed out, or the warm never landed at all):
// hold the launch altitude for as long as that lasts. It is not a clearance
// guarantee — nothing can promise clearance over ground that was never
// measured — but the dolly will not take the camera DOWN into the unknown.
if (!state.floorKnown && Number.isFinite(state.safeHoldHeightM)) {
heightM = Math.max(heightM, state.safeHoldHeightM);
}
// Releasing that hold onto late data is a controlled descent, never a drop.
if (Number.isFinite(state.appliedHeightM)) {
heightM = Math.max(
heightM,
state.appliedHeightM - ROUTE_MAX_DESCENT_MPS * step,
);
}
state.appliedHeightM = heightM;
const eye = Cesium.Cartesian3.fromRadians(
carto.longitude,
carto.latitude,
heightM,
Cesium.Ellipsoid.WGS84,
_frameEye,
);
state.appliedBankDeg = appliedBankDeg;
return {
finished: state.u >= 1,
eye,
direction,
up: bankedUp,
bankDeg: appliedBankDeg,
heightM,
aglM: heightM - state.floorM,
groundSpeedMps: state.groundSpeedMps,
progress: state.u,
};
}
let _viewer = null;
let _getTarget = null;
let _active = null; // { kind, motion, direction, speed, mode, motionId, ...state }
let _tickRemover = null;
let _trackingRemover = null;
let _inputRemovers = [];
/** Monotonic id for the motion currently running; 0 means "no motion". */
let _motionSeq = 0;
/**
* Install a motion and stamp it with a fresh id. The id is what lets a caller
* that started a flight stop THAT flight later without stopping whatever has
* replaced it in the meantime.
* @param {object|null} state Motion state, or null to clear.
* @returns {object|null} The installed state.
*/
function setActiveMotion(state) {
if (state) {
_motionSeq += 1;
state.motionId = _motionSeq;
}
_active = state;
return state;
}
/**
* @returns {number} Id of the running motion, or 0 when the camera is idle.
*/
export function activeCameraMotionId() {
return _active?.motionId || 0;
}
/**
* Stop a motion only while it is still the one running. A feature that owns a
* flight calls this on clear/teardown: if the user (or voice, or a tracked
* entity) has since taken the camera, the newer motion is left alone.
* @param {number} motionId Id returned when the motion started.
* @param {string} [reason] Diagnostic label.
* @returns {{wasActive: boolean, reason: string, leveled: boolean}}
*/
export function interruptCameraMotionIfActive(
motionId,
reason = 'owner-cancel',
) {
if (!motionId || _active?.motionId !== motionId) {
return { wasActive: false, reason, leveled: false };
}
return interruptCameraMotion(reason);
}
function clearLookAt() {
try {
_viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
} catch {
/* teardown race */
}
}
/**
* Put the horizon back.
*
* The route dolly is the only motion that rolls the camera, and Cesium keeps
* whatever world-space up vector it was last handed — so releasing a flight
* mid-bank leaves the user holding a tilted horizon until some later motion
* happens to level it. Heading, pitch and position are preserved; only the
* roll is taken out.
* @returns {boolean} Whether a roll was actually removed.
*/
function levelCameraRoll() {
try {
const cam = _viewer?.camera;
if (!cam || typeof cam.setView !== 'function') return false;
const roll = Cesium.Math.negativePiToPi(cam.roll || 0);
if (Math.abs(roll) < 1e-5) return false;
cam.setView({
orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 },
});
return true;
} catch {
return false; // teardown race
}
}
/** Stop any active motion. Returns whether one was running. */
export function interruptCameraMotion(reason = 'interrupt') {
const wasActive = Boolean(_active);
// While an entity is TRACKED, the follow camera owns the reference frame —
// resetting the lookAt transform here flings the camera into space (field
// finding). Leave the transform to the tracker; it re-asserts every frame.
const tracking = Boolean(_viewer?.trackedEntity);
const wasRoute = wasActive && _active.kind === 'route';
if (
wasActive &&
!tracking &&
(_active.kind === 'orbit' || _active.kind === 'route')
)
clearLookAt();
// Instant, not eased: the user grabbed the camera, or the flight is over.
// Either way the next thing they hold must be a level horizon.
const leveled = wasRoute && !tracking ? levelCameraRoll() : false;
_active = null;
releaseContinuousRender('camera-verb');
return { wasActive, reason, leveled };
}
/** Diagnostics / harness hook. */
export function getActiveCameraMotion() {
if (!_active) return null;
const info = {
kind: _active.kind,
mode: _active.mode,
speed: _active.speed,
motionId: _active.motionId || 0,
};
if (_active.kind === 'route') {
info.progress = _active.u;
info.bankDeg = _active.bankDeg;
info.groundSpeedMps = _active.groundSpeedMps;
info.traveledM = _active.traveled;
info.totalM = _active.totalM;
info.reducedMotion = _active.reducedMotion;
info.arming = !_active.departed;
info.floorKnown = _active.floorKnown;
info.floorFromMeshProbe = _active.floorFromMeshProbe;
info.meshProbeSpent = _active.meshProbeSpent;
}
return info;
}
function onTick() {
if (!_active || !_viewer) return;
// Wall-clock dt: clock.currentTime FREEZES when the app clock isn't
// animating, which starved motion to the 1 ms floor (harness catch).
const nowMs = performance.now();
const dt = Math.min(
0.25,
Math.max(0.001, (nowMs - (_active.lastMs ?? nowMs)) / 1000),
);
_active.lastMs = nowMs;
const cam = _viewer.camera;
const m = _active;
// `once` motions ease out and self-stop on budget exhaustion.
let scale = 1;
if (m.mode === 'once') {
m.elapsed = (m.elapsed || 0) + dt;
const t = Math.min(1, m.elapsed / ONCE.durationS);
scale = 1 - (1 - t) * (1 - t); // ease-out progress share
if (t >= 1) {
interruptCameraMotion('once-complete');
}
}
try {
if (m.kind === 'orbit' && m.pending) {
// Armed but target-less (started mid-flight, e.g. "fly to X and orbit
// it"): keep trying for a ground target until the camera settles.
m.pendingS = (m.pendingS || 0) + dt;
if (m.pendingS > 30) {
interruptCameraMotion('orbit-arm-timeout');
return;
}
// pickEllipsoid hits the globe from ANY altitude — target existence
// alone is not arrival. Camera flights are scene tweens, and multi-stage
// flights have GAPS between tweens: require the tween queue quiet for a
// continuous 0.8 s before capturing the orbit framing at the REAL
// destination (field finding: Eiffel chain orbited from mid-flight).
if (_viewer.scene.tweens.length > 0) {
m.settledS = 0;
return;
}
m.settledS = (m.settledS || 0) + dt;
if (m.settledS < 0.8) return;
const target = _getTarget?.(_viewer);
if (!target) return;
const cam2 = _viewer.camera;
const range = Cesium.Cartesian3.distance(cam2.positionWC, target);
const carto2 = Cesium.Cartographic.fromCartesian(cam2.positionWC);
const tCarto2 = Cesium.Cartographic.fromCartesian(target);
const dh2 = carto2.height - tCarto2.height;
m.target = target;
m.hpr = new Cesium.HeadingPitchRange(
cam2.heading,
-Math.asin(Math.min(1, Math.max(-1, dh2 / Math.max(1, range)))),
range,
);
m.hprStartHeading = cam2.heading;
m.pending = false;
m.elapsed = 0; // once-mode budget starts when the orbit actually begins
return;
}
if (m.kind === 'orbit') {
const rate =
Cesium.Math.toRadians(ORBIT_DEG_S[m.speed]) *
(m.direction === 'left' ? -1 : 1);
if (m.mode === 'once') {
const total =
Cesium.Math.toRadians(ONCE.orbitDeg) *
(m.direction === 'left' ? -1 : 1);
m.hpr.heading = m.hprStartHeading + total * scale;
} else {
m.hpr.heading += rate * dt;
}
cam.lookAt(m.target, m.hpr);
} else if (m.kind === 'pan') {
const heightM = Math.max(50, cam.positionCartographic.height);
const step =
m.mode === 'once'
? heightM * ONCE.panViewFraction * (scale - (m.lastScale || 0))
: heightM * PAN_VIEW_FRACTION_S[m.speed] * dt;
m.lastScale = scale;
if (m.direction === 'left') cam.moveLeft(step);
else if (m.direction === 'right') cam.moveRight(step);
else if (m.direction === 'up') cam.moveUp(step);
else cam.moveDown(step);
} else if (
m.kind === 'tilt' ||
m.kind === 'rotate' ||
m.kind === 'rotate_globe'
) {
const degS = TILT_DEG_S[m.speed];
const stepRad =
m.mode === 'once'
? Cesium.Math.toRadians(
m.kind === 'tilt' ? ONCE.tiltDeg : ONCE.rotateDeg,
) *
(scale - (m.lastScale || 0))
: Cesium.Math.toRadians(degS) * dt;
m.lastScale = scale;
if (m.kind === 'rotate_globe') {
// Tracking can acquire the camera without going through navigation.
// Yield without resetting its pose/frame: the world-space arc proof
// only describes Cesium's rotation while the frame is identity.
if (
_viewer.scene.mode !== Cesium.SceneMode.SCENE3D ||
_viewer.trackedEntity ||
_isCockpitActive?.() ||
!Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY)
) {
interruptCameraMotion('globe-camera-owner-changed');
return;
}
// Cesium rotates both position and basis about the reference-frame
// origin. The captured screen-up axis keeps left/right horizontal even
// in a tilted or rolled view; a positive angle moves the globe right.
const angle = m.direction === 'left' ? -stepRad : stepRad;
if (globeRotationFitsKeyhole() !== true) {
interruptCameraMotion('globe-framing-changed');
return;
}
if (
!globeRotationHasClearance(cam.positionWC) ||
Math.abs(
Cesium.Cartesian3.magnitude(cam.positionWC) - m.rotationRadius,
) > 0.01
) {
interruptCameraMotion('globe-surface-limit');
return;
}
cam.rotate(m.rotationAxis, angle);
} else if (m.kind === 'tilt') {
const up = m.direction === 'up';
// Clamp pitch to [-89°, -5°]: never through the ground, never at the sky.
const next = cam.pitch + (up ? stepRad : -stepRad);
if (next > PITCH_MAX || next < PITCH_MIN) {
interruptCameraMotion('tilt-clamp');
return;
}
if (up) cam.lookUp(stepRad);
else cam.lookDown(stepRad);
} else if (m.direction === 'left') cam.lookLeft(stepRad);
else cam.lookRight(stepRad);
} else if (m.kind === 'route') {
// The dolly's shaping lives in advanceRouteFlight(); the tick only
// applies the pose and honours the same completion path as before.
const frame = advanceRouteFlight(m, dt);
// Armed and waiting for floor data — the camera stays exactly where the
// user left it rather than teleporting to an altitude derived from
// nothing. An interrupt during the arm is still instant.
if (frame.arming) return;
cam.setView({
destination: frame.eye,
orientation: { direction: frame.direction, up: frame.up },
});
if (frame.finished) {
interruptCameraMotion('route-complete');
return;
}
}
} catch {
interruptCameraMotion('tick-error');
}
}
/** Rigid origin-centred rotation stays outside the actual ellipsoid's bounding sphere. */
function globeRotationHasClearance(position) {
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
const { minimumRadius, maximumRadius } = ellipsoid;
const radius = Cesium.Cartesian3.magnitude(position);
return (
Number.isFinite(minimumRadius) &&
minimumRadius > 0 &&
Number.isFinite(maximumRadius) &&
maximumRadius >= minimumRadius &&
Number.isFinite(radius) &&
radius > maximumRadius * (1 + 1e-12)
);
}
/** Use the existing WGS84/CSS-pixel projection; null means unsupported, not close-up. */
function globeRotationFitsKeyhole(camera = _viewer.camera) {
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
if (!Cesium.Cartesian3.equals(ellipsoid.radii, Cesium.Ellipsoid.WGS84.radii))
return null;
const canvas = _viewer.scene.canvas;
const width = canvas.clientWidth;
const height = canvas.clientHeight;
const frustum = camera.frustum;
if (
!Number.isFinite(width) ||
!Number.isFinite(height) ||
width <= 0 ||
height <= 0 ||
!(frustum instanceof Cesium.PerspectiveFrustum) ||
!Number.isFinite(frustum.near) ||
frustum.near <= 0 ||
!Number.isFinite(frustum.far) ||
frustum.far <= frustum.near ||
frustum.xOffset !== 0 ||
frustum.yOffset !== 0
)
return null;
try {
const cam = camera;
const distance = Cesium.Cartesian3.magnitude(cam.positionWC);
const sphereRadius = ellipsoid.maximumRadius;
if (!Number.isFinite(distance) || distance <= 0) return null;
if (distance <= sphereRadius) return false; // Near-surface polar views are close-up too.
const radiusPx = earthDiscScreenRadius(distance, height, frustum.fovy);
if (!radiusPx) return null;
const keyhole = getKeyholeGeometry(width, height);
// The known nadir reset probe uses the same exact cone below; projecting
// through SceneTransforms would read the live scene.camera instead.
if (
camera === _viewer.camera &&
!projectEarthDiscToViewport(_viewer, width, height)
) {
// A near-surface horizon view cannot fit even when pointed at Earth;
// an orbital camera looking away is unsupported, not a close-up offer.
return radiusPx > Math.min(keyhole.radius, width / 2, height / 2)
? false
: null;
}
// The shared disc is an overlay approximation off-axis. Bound the sphere
// against the existing keyhole cone. Canvas-edge clipping does not change
// framing during rigid globe rotation and is not a clearance condition,
// without changing the UI projection or defining another keyhole/mask.
const toCenter = Cesium.Cartesian3.negate(
cam.positionWC,
new Cesium.Cartesian3(),
);
const depth = Cesium.Cartesian3.dot(toCenter, cam.directionWC);
const right = Math.abs(Cesium.Cartesian3.dot(toCenter, cam.rightWC));
const up = Math.abs(Cesium.Cartesian3.dot(toCenter, cam.upWC));
const focalY = height / (2 * Math.tan(frustum.fovy / 2));
const focalX =
width / (2 * Math.tan(frustum.fovy / 2) * frustum.aspectRatio);
if (
![depth, right, up, focalX, focalY].every(Number.isFinite) ||
focalX <= 0 ||
focalY <= 0
)
return null;
const halfAngle = Math.asin(sphereRadius / distance);
const centerAngle = Math.atan2(Math.hypot(right, up), depth);
const keyholeAngle = Math.atan(keyhole.radius / Math.max(focalX, focalY));
// No overlay visibility hysteresis: a fully contained smaller globe needs no reset.
const framed = centerAngle + halfAngle <= keyholeAngle;
if (!framed) return false;
// Projection alone does not prove that a tiny globe is within depth planes.
if (
depth - sphereRadius < frustum.near ||
depth + sphereRadius > frustum.far
)
return null;
return true;
} catch {
return null;
}
}
/** Check the unchanged reset destination without moving the live camera. */
function globeResetFitsKeyhole() {
try {
const camera = Cesium.Camera.clone(_viewer.camera);
const { longitude, latitude } = _viewer.camera.positionCartographic;
camera.setView({
destination: Cesium.Cartesian3.fromRadians(
longitude,
latitude,
GLOBE_VIEW.heightM,
),
orientation: {
heading: 0,
pitch: Cesium.Math.toRadians(GLOBE_VIEW.pitchDeg),
roll: 0,
},
endTransform: Cesium.Matrix4.IDENTITY,
});
return globeRotationFitsKeyhole(camera) === true;
} catch {
return false;
}
}
/** Release only globe rotation when a new camera intent takes ownership. */
export function interruptGlobeRotation(reason = 'camera-intent') {
if (_active?.kind === 'rotate_globe') return interruptCameraMotion(reason);
return { wasActive: false, reason, leveled: false };
}
/**
* Wire the module to the viewer once (idempotent). The view-target getter is
* optional so a non-voice caller (the Directions FLY chip) can arm route
* flights before any voice session exists; a later call with a real getter
* installs it, and a call without one, FOR THE SAME VIEWER, never clears an
* installed getter.
*
* A getter closes over the viewer it was built for. When the viewer changes —
* a new application lifetime — the previous getter is dropped even if this
* call brings none, so a callback from the old viewer can never answer for the
* new one.
*/
export function initCameraVerbs(
viewer,
getViewTargetCartesian = null,
{ isCockpitActive } = {},
) {
const nextTarget =
typeof getViewTargetCartesian === 'function'
? getViewTargetCartesian
: null;
if (_viewer === viewer) {
if (nextTarget) _getTarget = nextTarget;
if (typeof isCockpitActive === 'function')
_isCockpitActive = isCockpitActive;
return;
}
// A globe motion belongs to the viewer whose world pose it captured.
if (_active?.kind === 'rotate_globe')
interruptCameraMotion('viewer-replaced');
_getTarget = nextTarget;
_isCockpitActive =
typeof isCockpitActive === 'function' ? isCockpitActive : null;
_viewer = viewer;
if (_tickRemover) _tickRemover();
_tickRemover = viewer.clock.onTick.addEventListener(onTick);
if (_trackingRemover) _trackingRemover();
// Cockpit can acquire then clear tracking before the next clock tick.
// Yield on acquisition itself without changing legacy camera motions.
_trackingRemover = viewer.trackedEntityChanged?.addEventListener((entity) => {
if (entity && _active?.kind === 'rotate_globe')
interruptCameraMotion('globe-camera-owner-changed');
});
for (const rm of _inputRemovers) rm();
_inputRemovers = [];
const canvas = viewer.scene.canvas;
// ANY manual camera input reclaims control — the cancelFlight reflex.
for (const evt of ['pointerdown', 'touchstart', 'wheel']) {
const h = () => interruptCameraMotion('manual-input');
canvas.addEventListener(evt, h, { passive: true });
_inputRemovers.push(() => canvas.removeEventListener(evt, h));
}
}
/**
* move_camera implementation. Returns the house result shape; rejections are
* plain-English `ok:false` errors.
* @param {Object} args
* @param {Function|null} runNavigation Validated camera-authority transaction.
*/
export function moveCamera(args = {}, runNavigation = null) {
const motion = String(args.motion || '').toLowerCase();
const direction = args.direction
? String(args.direction).toLowerCase()
: null;
const speed = ORBIT_DEG_S[String(args.speed || 'normal').toLowerCase()]
? String(args.speed || 'normal').toLowerCase()
: 'normal';
const mode =
args.mode === 'continuous' ||
(motion === 'rotate_globe' && args.mode === undefined)
? 'continuous'
: 'once';
if (motion === 'stop') {
const wasActiveBeforeHandoff = Boolean(_active);
const stop = () => {
const { wasActive } = interruptCameraMotion('voice-stop');
return {
ok: true,
action: 'move_camera',
motion: 'stop',
stopped: wasActiveBeforeHandoff || wasActive,
};
};
return typeof runNavigation === 'function' ? runNavigation(stop) : stop();
}
if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion)) {
return {
ok: false,
action: 'move_camera',
error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, rotate_globe, or stop.`,
};
}
if (
motion !== 'orbit' &&
motion !== 'rotate_globe' &&
!['left', 'right', 'up', 'down'].includes(direction)
) {
return {
ok: false,
action: 'move_camera',
error: `${motion} needs a direction (left/right${motion !== 'rotate' ? '/up/down' : ''}).`,
};
}
if (motion === 'rotate' && !['left', 'right'].includes(direction)) {
return {
ok: false,
action: 'move_camera',
error: 'rotate needs left or right.',
};
}
if (
motion === 'rotate_globe' &&
direction !== null &&
!['left', 'right'].includes(direction)
) {
return {
ok: false,
action: 'move_camera',
error: 'rotate_globe needs left or right.',
};
}
if (motion === 'tilt') {
// Honest limits: at the clamp a tilt is a no-op — say so instead of
// reporting success and letting the model gaslight itself (field test).
const pitch = _viewer.camera.pitch;
if (direction === 'up' && pitch >= PITCH_MAX - Cesium.Math.toRadians(0.5)) {
return {
ok: false,
action: 'move_camera',
error:
'Already at the upper tilt limit (near the horizon) — tilt down, or zoom/pan instead.',
};
}
if (
direction === 'down' &&
pitch <= PITCH_MIN + Cesium.Math.toRadians(0.5)
) {
return {
ok: false,
action: 'move_camera',
error: 'Already looking straight down — tilt up to raise the horizon.',
};
}
}
if (
(motion === 'rotate_globe' ||
(motion === 'orbit' && typeof runNavigation !== 'function')) &&
_viewer?.trackedEntity
) {
// The follow camera owns a tracked view; a lookAt orbit fights it frame
// by frame and stop rips the camera out (field finding). Honest refusal
// until tracked-orbit rides the follow camera natively (roadmap).
const label =
_viewer.trackedEntity?.name ||
_viewer.trackedEntity?.id ||
'the tracked target';
return {
ok: false,
action: 'move_camera',
error: `Already following ${label} — the follow camera owns the view while tracking. Say "stop tracking" first if you want ${motion === 'rotate_globe' ? 'to rotate the globe' : 'a free orbit'}.`,
};
}
let globePose;
if (motion === 'rotate_globe') {
if (_viewer.scene.mode !== Cesium.SceneMode.SCENE3D) {
return {
ok: false,
action: 'move_camera',
error: 'Globe rotation requires a 3D view.',
};
}
const cam = _viewer.camera;
globePose = {
position: Cesium.Cartesian3.clone(cam.positionWC),
direction: Cesium.Cartesian3.clone(cam.directionWC),
up: Cesium.Cartesian3.normalize(cam.upWC, new Cesium.Cartesian3()),
right: Cesium.Cartesian3.clone(cam.rightWC),
};
if (_isCockpitActive?.())
return {
ok: false,
action: 'move_camera',
reason: 'camera-owned',
error: 'Exit Cockpit before rotating the globe.',
};
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
if (!(
Number.isFinite(ellipsoid.minimumRadius) &&
ellipsoid.minimumRadius > 0 &&
Number.isFinite(ellipsoid.maximumRadius) &&
ellipsoid.maximumRadius >= ellipsoid.minimumRadius &&
Number.isFinite(Cesium.Cartesian3.magnitude(globePose.position))
))
return {
ok: false,
action: 'move_camera',
reason: 'unsafe-ellipsoid',
error:
'Globe rotation requires a finite supported ellipsoid and a clear globe view.',
};
const fits = globeRotationFitsKeyhole();
if (fits === null)
return {
ok: false,
action: 'move_camera',
reason: 'unsupported-view',
error: 'Globe rotation requires a supported perspective view.',
};
if (!fits) {
const canReset = globeResetFitsKeyhole();
return {
ok: false,
action: 'move_camera',
reason: canReset ? 'globe-view-required' : 'globe-reset-unavailable',
error: canReset
? 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’'
: 'Zoom out further to fit the globe inside the keyhole; the Globe View preset does not fit this viewport.',
};
}
if (!globeRotationHasClearance(globePose.position))
return {
ok: false,
action: 'move_camera',
reason: 'unsafe-ellipsoid',
error: 'Globe rotation requires a clear ellipsoid bounding sphere.',
};
}
const start = () => {
interruptCameraMotion('replaced');
const state = {
kind: motion,
direction: direction || 'right',
speed,
mode,
};
if (motion === 'rotate_globe') {
// Convert an inherited ground-target frame without changing the world
// pose. Rotation must pivot about Earth, never the previous local target.
const cam = _viewer.camera;
cam.cancelFlight();
cam.lookAtTransform(Cesium.Matrix4.IDENTITY);
// Handoff may level a route's bank. Keep the caller's exact world pose.
// Copy the basis directly: setView's heading/pitch conversion is singular
// at nadir and can change the captured orientation near the poles.
Cesium.Cartesian3.clone(globePose.position, cam.position);
Cesium.Cartesian3.clone(globePose.direction, cam.direction);
Cesium.Cartesian3.clone(globePose.up, cam.up);
Cesium.Cartesian3.clone(globePose.right, cam.right);
state.rotationAxis = globePose.up;
state.rotationRadius = Cesium.Cartesian3.magnitude(globePose.position);
}
if (motion === 'orbit') {
if (_viewer.scene.tweens.length > 0) {
// A camera flight is in progress ("fly to X and orbit it") — ALWAYS arm
// and capture at the destination; a target existing right now is
// meaningless (the globe is always under the crosshair).
setActiveMotion({
kind: 'orbit',
direction: direction || 'right',
speed,
mode,
pending: true,
});
holdContinuousRender('camera-verb');
return {
ok: true,
action: 'move_camera',
motion,
direction: direction || 'right',
speed,
mode,
armed: 'waiting-for-arrival',
};
}
const target = _getTarget?.(_viewer);
if (!target) {
// Mid-flight chain ("fly to X and orbit it"): ARM the orbit — the tick
// loop keeps trying for a ground target as the flight settles.
setActiveMotion({
kind: 'orbit',
direction: direction || 'right',
speed,
mode,
pending: true,
});
holdContinuousRender('camera-verb');
return {
ok: true,
action: 'move_camera',
motion,
direction: direction || 'right',
speed,
mode,
armed: 'waiting-for-arrival',
};
}
const cam = _viewer.camera;
const range = Cesium.Cartesian3.distance(cam.positionWC, target);
// Pitch from geometry so lookAt reproduces the CURRENT framing exactly.
const carto = Cesium.Cartographic.fromCartesian(cam.positionWC);
const tCarto = Cesium.Cartographic.fromCartesian(target);
const dh = carto.height - tCarto.height;
const pitch = -Math.asin(
Math.min(1, Math.max(-1, dh / Math.max(1, range))),
);
state.target = target;
state.hpr = new Cesium.HeadingPitchRange(cam.heading, pitch, range);
state.hprStartHeading = cam.heading;
}
setActiveMotion(state);
// Verb motion drives the camera per clock tick — hold until interrupted;
// interruptCameraMotion is the single release path. (perf wave 2)
holdContinuousRender('camera-verb');
return {
ok: true,
action: 'move_camera',
motion,
direction: state.direction,
speed,
mode,
};
};
const preserveCameraFlight =
motion === 'orbit' &&
_viewer.scene.tweens.length > 0 &&
!_viewer.trackedEntity;
return typeof runNavigation === 'function'
? runNavigation(start, { preserveCameraFlight })
: start();
}
/**
* Zoom while orbiting: scale the orbit RADIUS instead of letting the zoom
* fight the per-frame lookAt (field test: "zoom out did literally nothing").
* @param {number} factor >1 zooms out (radius grows), <1 zooms in.
* @returns {boolean} true when an orbit consumed the zoom.
*/
export function adjustOrbitRange(factor) {
if (!_active || _active.kind !== 'orbit' || !_active.hpr) return false;
_active.hpr.range = Math.max(
80,
Math.min(5_000_000, _active.hpr.range * factor),
);
return true;
}
/**
* fly_route implementation: dolly along an existing route annotation.
* @param {Array} annoList — engine list() output (raw annos incl. `path`)
* @param {Object} args
* @param {Function|null} floorFn (latDeg, lonDeg) => cached floor metres.
* @param {Function|null} runNavigation Validated camera-authority transaction.
* @param {Function|null} warmFn (cells) => void — batch-warms the floor cells
* along the route corridor. Without it a cold cache has no protection: route
* vertices carry height 0, so an unwarmed mountain corridor reads as sea level.
*/
export function flyRoute(
annoList,
args = {},
floorFn = null,
runNavigation = null,
warmFn = null,
) {
const speed = ROUTE_M_S[String(args.speed || 'normal').toLowerCase()]
? String(args.speed || 'normal').toLowerCase()
: 'normal';
// A route hidden from Draw's list is not on screen, so it is not flown.
const routes = (annoList || []).filter(
(a) =>
a.type === 'route' &&
!a.hidden &&
Array.isArray(a.path) &&
a.path.length >= 2,
);
if (!routes.length) {
return {
ok: false,
action: 'fly_route',
error:
'No route is drawn — draw a route first (e.g. "route from A to B"), then fly it.',
};
}
let route = routes[routes.length - 1];
if (args.label) {
const wanted = String(args.label).toLowerCase();
const byLabel = routes.filter((a) =>
String(a.label || '')
.toLowerCase()
.includes(wanted),
);
if (!byLabel.length) {
return {
ok: false,
action: 'fly_route',
error: `No route matches "${args.label}" — say fly the route without a name for the newest one.`,
};
}
route = byLabel[byLabel.length - 1];
}
if (
route.path.some(
(point) =>
!Number.isFinite(point?.lat) ||
point.lat < -90 ||
point.lat > 90 ||
!Number.isFinite(point?.lon) ||
point.lon < -180 ||
point.lon > 180 ||
(point.height !== undefined && !Number.isFinite(point.height)),
)
) {
return {
ok: false,
action: 'fly_route',
error: 'The selected route has an invalid waypoint.',
};
}
const pts = route.path.map((p) =>
Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 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]));
}
if (!Number.isFinite(cumM.at(-1)) || cumM.at(-1) <= 0) {
return {
ok: false,
action: 'fly_route',
error: 'The selected route has no flyable distance.',
};
}
const start = () => {
interruptCameraMotion('replaced');
setActiveMotion(
createRouteFlight({
pts,
cumM,
speed,
floorFn,
warmFn,
probeFn: (cells) => probeMeshFloorM(_viewer?.scene, cells),
cameraHeightM: _viewer?.camera?.positionCartographic?.height,
reducedMotion: prefersReducedMotion(),
}),
);
holdContinuousRender('camera-verb');
return {
ok: true,
action: 'fly_route',
label: route.label || null,
speed,
distanceM: Math.round(_active.totalM),
durationS: Math.round(_active.durationS),
waypoints: pts.length,
// The caller keeps this so it can stop ITS flight later without stopping
// whatever has replaced it (see interruptCameraMotionIfActive).
motionId: _active.motionId,
};
};
return typeof runNavigation === 'function' ? runNavigation(start) : start();
}