mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
Add continuous globe rotation with direction and speed controls, viewport fit checks, camera ownership handoff, and voice refusal guards.
1839 lines
69 KiB
JavaScript
1839 lines
69 KiB
JavaScript
/**
|
||
* 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';
|
||
const routes = (annoList || []).filter(
|
||
(a) => a.type === 'route' && 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();
|
||
}
|