From 69152868e4eff63d17b8cbeef8bdffbd68e71c05 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 16:42:18 +0530 Subject: [PATCH 01/39] Fix voice camera tilt from a top-down view A voice fly-to frames a city top-down, at pitch -90. The tilt clamp rejected any step that left the pitch below -89, so the first frame of "tilt up" stopped the motion and "tilt down" was refused: from the view voice lands on, no tilt ever moved the camera. A bare "tilt the camera" also failed because tilt required a direction. - Clamp tilt only in the direction of travel, so -90 can tilt up. - Swing the camera about the ground under the crosshair, so the place stays centred and the heading is kept. A tracked view still tilts in place, and a pivot tilt yields when tracking takes the camera. - Add an optional move_camera pitchDeg for "tilt 45 degrees"; a tilt with neither angle nor direction settles at -45 (oblique view). - Re-derive the voice tool schema pins for the new argument and wording, and keep the Realtime prompt under its 52,000-character budget. --- docs/CURRENT-STATE.md | 2 +- scripts/format-scope.json | 1 + server/providers/openai/toolDescriptions.js | 7 +- src/cameraTilt.test.mjs | 224 ++++++++++++++++++++ src/cameraVerbs.js | 163 ++++++++++++-- src/firstRunExperience.test.mjs | 7 + src/radioMarkup.test.mjs | 7 +- src/voice/actionSchemas.js | 5 + src/voice/actionSchemas.test.mjs | 14 +- 9 files changed, 403 insertions(+), 27 deletions(-) create mode 100644 src/cameraTilt.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index be0718fd1..3c17ed79f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5364,7 +5364,7 @@ and unreachable upstream (502/504) separately from road geometry. > branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on > `main` + 26 route pins; the same tree on Node 24 reports 2,295. -- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. +- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair (the tracked view excepted, which tilts in place), so the place stays centred and the heading is kept; pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. - **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing. - **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm 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 rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped. - Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet). diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 447dbf199..79117e775 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1108,6 +1108,7 @@ "scripts/qa-status-badges.mjs", "src/dockerTrustedPeers.test.mjs", "src/cameraGlobeRotation.test.mjs", + "src/cameraTilt.test.mjs", "src/voice/globeResetConfirmation.test.mjs", "src/hudDms.test.mjs", "src/hudSummaryGate.test.mjs", diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js index 23d5da9de..873a52c8a 100644 --- a/server/providers/openai/toolDescriptions.js +++ b/server/providers/openai/toolDescriptions.js @@ -523,7 +523,7 @@ export const ACTION_DESCRIPTIONS = { properties: { direction: { description: - 'Required for pan/tilt/rotate; orbit/rotate_globe default right.', + 'Required for pan/rotate and a tilt nudge; orbit/rotate_globe default right.', $position: 2, }, mode: { @@ -531,6 +531,11 @@ export const ACTION_DESCRIPTIONS = { 'once = bounded eased nudge; continuous = until stop/manual input. rotate_globe defaults continuous; other motions default once.', $position: 2, }, + pitchDeg: { + description: + 'Tilt to this angle below the horizon (-45 oblique, -89 top-down). Bare tilt: -45.', + $position: 2, + }, }, }, }, diff --git a/src/cameraTilt.test.mjs b/src/cameraTilt.test.mjs new file mode 100644 index 000000000..babe95807 --- /dev/null +++ b/src/cameraTilt.test.mjs @@ -0,0 +1,224 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { + getActiveCameraMotion, + initCameraVerbs, + interruptCameraMotion, + moveCamera, +} from './cameraVerbs.js'; + +const radians = Cesium.Math.toRadians; +const degrees = Cesium.Math.toDegrees; + +/** Real Cesium camera over London with no renderer: the voice fly-to frame. */ +function harness( + t, + { height = 4000, heading = 0, pitch = -90, pivot = true } = {}, +) { + const realPerformance = globalThis.performance; + let nowMs = 0; + globalThis.performance = { now: () => nowMs }; + const canvas = Object.assign(new EventTarget(), { + clientWidth: 1280, + clientHeight: 800, + width: 1280, + height: 800, + }); + const ellipsoid = Cesium.Ellipsoid.WGS84; + const scene = { + canvas, + drawingBufferWidth: 1280, + drawingBufferHeight: 800, + frameState: { mode: Cesium.SceneMode.SCENE3D }, + mapProjection: new Cesium.GeographicProjection(ellipsoid), + globe: { ellipsoid }, + mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL, + mode: Cesium.SceneMode.SCENE3D, + pixelRatio: 1, + tweens: [], + requestRender() {}, + }; + const camera = new Cesium.Camera(scene); + scene.camera = camera; + camera.setView({ + destination: Cesium.Cartesian3.fromDegrees(-0.1276, 51.5072, height), + orientation: { heading: radians(heading), pitch: radians(pitch), roll: 0 }, + }); + const viewer = { + camera, + scene, + trackedEntity: undefined, + trackedEntityChanged: new Cesium.Event(), + clock: { onTick: new Cesium.Event() }, + }; + // The app's view target: the ground point under the screen centre. + const viewTarget = () => { + const ray = new Cesium.Ray(camera.positionWC, camera.directionWC); + const hit = Cesium.IntersectionTests.rayEllipsoid(ray, ellipsoid); + return hit ? Cesium.Ray.getPoint(ray, hit.start) : undefined; + }; + const target = viewTarget(); + initCameraVerbs(viewer, pivot ? viewTarget : () => undefined); + t.after(() => { + interruptCameraMotion('test-cleanup'); + globalThis.performance = realPerformance; + }); + const tick = (seconds = 1 / 30) => { + nowMs += seconds * 1000; + viewer.clock.onTick.raiseEvent(viewer.clock); + }; + const finish = () => { + for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick(); + assert.equal(getActiveCameraMotion(), null, 'tilt must finish'); + }; + return { viewer, camera, target, tick, finish, viewTarget }; +} + +const pitchDeg = (camera) => degrees(camera.pitch); +const headingDeg = (camera) => + degrees(Cesium.Math.negativePiToPi(camera.heading)); + +/** Metres between the original centre and the ground now under the crosshair. */ +function centreDrift(h) { + return Cesium.Cartesian3.distance(h.viewTarget(), h.target); +} + +test('tilt up leaves a top-down view and keeps the place centred', (t) => { + const h = harness(t); + const result = moveCamera({ motion: 'tilt', direction: 'up' }); + assert.equal(result.ok, true); + h.finish(); + assert.ok( + Math.abs(pitchDeg(h.camera) - -75) < 0.5, + `pitch ${pitchDeg(h.camera)}`, + ); + assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`); + assert.ok(Cesium.Matrix4.equals(h.camera.transform, Cesium.Matrix4.IDENTITY)); +}); + +test('continuous tilt up from top-down keeps moving', (t) => { + const h = harness(t); + assert.equal( + moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }).ok, + true, + ); + for (let i = 0; i < 30; i += 1) h.tick(); + assert.equal(getActiveCameraMotion()?.kind, 'tilt'); + assert.ok(pitchDeg(h.camera) > -82, `pitch ${pitchDeg(h.camera)}`); +}); + +test('a requested angle tilts to that pitch', (t) => { + const h = harness(t); + const result = moveCamera({ motion: 'tilt', pitchDeg: -45 }); + assert.equal(result.ok, true); + assert.equal(result.direction, 'up'); + assert.equal(result.pitchDeg, -45); + h.finish(); + assert.ok( + Math.abs(pitchDeg(h.camera) - -45) < 0.5, + `pitch ${pitchDeg(h.camera)}`, + ); + assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`); +}); + +test('a bare tilt settles on the default oblique angle', (t) => { + const h = harness(t); + const result = moveCamera({ motion: 'tilt' }); + assert.equal(result.ok, true); + assert.equal(result.pitchDeg, -45); + h.finish(); + assert.ok(Math.abs(pitchDeg(h.camera) - -45) < 0.5); +}); + +test('a requested angle can tilt back down toward top-down', (t) => { + const h = harness(t, { pitch: -30 }); + const result = moveCamera({ motion: 'tilt', pitchDeg: -80 }); + assert.equal(result.direction, 'down'); + h.finish(); + assert.ok(Math.abs(pitchDeg(h.camera) - -80) < 0.5); +}); + +test('requested angles clamp to the tilt limits', (t) => { + const h = harness(t); + assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -2 }).pitchDeg, -5); + h.finish(); + assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5); +}); + +test('a positive angle is read as the same angle below the horizon', (t) => { + const h = harness(t); + assert.equal(moveCamera({ motion: 'tilt', pitchDeg: 45 }).pitchDeg, -45); + h.finish(); + assert.ok(Math.abs(pitchDeg(h.camera) - -45) < 0.5); +}); + +for (const pitch of ['', null, false, ' ', 'steep']) + test(`a blank or non-numeric angle ${JSON.stringify(pitch)} is ignored`, (t) => { + harness(t); + const result = moveCamera({ motion: 'tilt', pitchDeg: pitch }); + // No angle and no direction: the default oblique view, never the horizon. + assert.equal(result.pitchDeg, -45); + }); + +test('a requested angle the camera already holds is a no-op', (t) => { + harness(t, { pitch: -45 }); + const result = moveCamera({ motion: 'tilt', pitchDeg: -45 }); + assert.equal(result.ok, true); + assert.equal(result.unchanged, true); + assert.equal(getActiveCameraMotion(), null); +}); + +test('tilt down from top-down still refuses honestly', (t) => { + harness(t); + const result = moveCamera({ motion: 'tilt', direction: 'down' }); + assert.equal(result.ok, false); + assert.match(result.error, /straight down/); +}); + +test('a tilt up nudge stops at the horizon limit', (t) => { + const h = harness(t, { pitch: -12 }); + assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true); + h.finish(); + assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5); +}); + +test('without a ground pivot a tilt still leaves top-down in place', (t) => { + const h = harness(t, { pivot: false }); + const before = Cesium.Cartesian3.clone(h.camera.positionWC); + assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true); + h.finish(); + assert.ok(Math.abs(pitchDeg(h.camera) - -75) < 0.5); + assert.ok(Cesium.Cartesian3.distance(h.camera.positionWC, before) < 1e-6); +}); + +for (const heading of [0, 30, -120]) + test(`tilt from top-down keeps the ${heading}° heading`, (t) => { + const h = harness(t, { heading, height: 67000 }); + // Top-down float noise must not pick a new heading for the pivot. + assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -45 }).ok, true); + h.finish(); + assert.ok( + Math.abs(headingDeg(h.camera) - heading) < 1, + `heading ${headingDeg(h.camera)}`, + ); + assert.ok(centreDrift(h) < 50, `centre drifted ${centreDrift(h)} m`); + }); + +test('an oblique tilt keeps its heading toward the pivot', (t) => { + const h = harness(t, { heading: 75, pitch: -60 }); + assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -30 }).ok, true); + h.finish(); + assert.ok(Math.abs(headingDeg(h.camera) - 75) < 0.5); + assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`); +}); + +test('a pivoting tilt yields when tracking takes the camera', (t) => { + const h = harness(t); + assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -45 }).ok, true); + h.tick(); + const entity = { id: 'tracked' }; + h.viewer.trackedEntity = entity; + h.viewer.trackedEntityChanged.raiseEvent(entity); + assert.equal(getActiveCameraMotion(), null); +}); diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index e037472cb..3898acaa7 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -48,6 +48,10 @@ const ONCE = { }; const PITCH_MIN = Cesium.Math.toRadians(-89); const PITCH_MAX = Cesium.Math.toRadians(-5); +/** A bare "tilt the camera" / "oblique view" settles here. */ +const TILT_DEFAULT_PITCH_DEG = -45; +/** °/s for a tilt to a requested angle; short ones keep the once duration. */ +const TILT_TO_DEG_S = 30; /* ── Route dolly: cinematic tuning ────────────────────────────────────────── * Every knob an owner may want to retune lives in this block. The shaping is @@ -1052,7 +1056,9 @@ export function interruptCameraMotion(reason = 'interrupt') { if ( wasActive && !tracking && - (_active.kind === 'orbit' || _active.kind === 'route') + (_active.kind === 'orbit' || + _active.kind === 'route' || + (_active.kind === 'tilt' && _active.hpr)) ) clearLookAt(); // Instant, not eased: the user grabbed the camera, or the flight is over. @@ -1104,7 +1110,7 @@ function onTick() { let scale = 1; if (m.mode === 'once') { m.elapsed = (m.elapsed || 0) + dt; - const t = Math.min(1, m.elapsed / ONCE.durationS); + const t = Math.min(1, m.elapsed / (m.durationS || ONCE.durationS)); scale = 1 - (1 - t) * (1 - t); // ease-out progress share if (t >= 1) { interruptCameraMotion('once-complete'); @@ -1173,18 +1179,13 @@ function onTick() { 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' - ) { + } else if (m.kind === 'tilt') { + tickTilt(cam, m, scale, dt); + } else if (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(ONCE.rotateDeg) * (scale - (m.lastScale || 0)) : Cesium.Math.toRadians(degS) * dt; m.lastScale = scale; if (m.kind === 'rotate_globe') { @@ -1218,16 +1219,6 @@ function onTick() { 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') { @@ -1252,6 +1243,67 @@ function onTick() { } } +/** + * One frame of tilt. With a ground pivot the camera swings about it (lookAt), + * so the place under the crosshair stays centred; without one it looks up or + * down in place. Pitch is clamped to [-89°, -5°] only in the direction of + * travel: a top-down view sits at -90°, below the limit, and must still be + * able to tilt up out of it. + */ +function tickTilt(cam, m, scale, dt) { + let step; + if (m.mode === 'once') { + step = m.tiltRad * (scale - (m.lastScale || 0)); + m.lastScale = scale; + } else { + step = + Cesium.Math.toRadians(TILT_DEG_S[m.speed]) * + dt * + (m.direction === 'up' ? 1 : -1); + } + const from = m.hpr ? m.hpr.pitch : cam.pitch; + const next = from + step; + const limited = + step > 0 + ? Math.min(next, Math.max(from, PITCH_MAX)) + : Math.max(next, Math.min(from, PITCH_MIN)); + if (m.hpr) { + m.hpr.pitch = limited; + cam.lookAt(m.target, m.hpr); + // A once motion that just completed released the lookAt frame before + // this last step re-took it. + if (_active !== m) clearLookAt(); + } else { + cam.lookUp(limited - from); + } + if (limited !== next) interruptCameraMotion('tilt-clamp'); +} + +/** Ground pivot for a tilt: heading, pitch and range in the target's own frame. */ +function tiltPivot(cam, target) { + const local = Cesium.Matrix4.multiplyByPoint( + Cesium.Matrix4.inverseTransformation( + Cesium.Transforms.eastNorthUpToFixedFrame(target), + new Cesium.Matrix4(), + ), + cam.positionWC, + new Cesium.Cartesian3(), + ); + const range = Cesium.Cartesian3.magnitude(local); + if (!(range > 1) || local.z <= 0) return null; + // Near straight overhead the offset's heading is float noise (it flipped a + // north-up London view to south); keep the camera's own. + const heading = + Math.hypot(local.x, local.y) > range * 0.01 + ? Math.atan2(-local.x, -local.y) + : cam.heading; + return new Cesium.HeadingPitchRange( + heading, + -Math.asin(Math.min(1, local.z / range)), + range, + ); +} + /** Rigid origin-centred rotation stays outside the actual ellipsoid's bounding sphere. */ function globeRotationHasClearance(position) { const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84; @@ -1421,6 +1473,9 @@ export function initCameraVerbs( _trackingRemover = viewer.trackedEntityChanged?.addEventListener((entity) => { if (entity && _active?.kind === 'rotate_globe') interruptCameraMotion('globe-camera-owner-changed'); + // A pivoting tilt's lookAt would fight the follow camera every frame. + else if (entity && _active?.kind === 'tilt' && _active.hpr) + interruptCameraMotion('tilt-camera-owner-changed'); }); for (const rm of _inputRemovers) rm(); _inputRemovers = []; @@ -1472,9 +1527,32 @@ export function moveCamera(args = {}, runNavigation = null) { error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, rotate_globe, or stop.`, }; } + // Tilt to an angle: the one asked for, or a default oblique view when a + // bare "tilt the camera" names neither angle nor direction. + let tiltToPitch = null; + if (motion === 'tilt') { + // Only a real number or numeric text: Number('') and Number(null) are 0, + // which would swing the camera to the horizon. + const raw = args.pitchDeg; + const requested = + typeof raw === 'number' || (typeof raw === 'string' && raw.trim() !== '') + ? Number(raw) + : NaN; + // The camera never looks above the horizon, so a positive angle can only + // mean the same angle below it ("tilt 45 degrees" sent as 45). + if (Number.isFinite(requested)) + tiltToPitch = Cesium.Math.clamp( + Cesium.Math.toRadians(-Math.abs(requested)), + PITCH_MIN, + PITCH_MAX, + ); + else if (direction === null) + tiltToPitch = Cesium.Math.toRadians(TILT_DEFAULT_PITCH_DEG); + } if ( motion !== 'orbit' && motion !== 'rotate_globe' && + tiltToPitch === null && !['left', 'right', 'up', 'down'].includes(direction) ) { return { @@ -1501,7 +1579,19 @@ export function moveCamera(args = {}, runNavigation = null) { error: 'rotate_globe needs left or right.', }; } - if (motion === 'tilt') { + if (motion === 'tilt' && tiltToPitch !== null) { + if ( + Math.abs(tiltToPitch - _viewer.camera.pitch) < Cesium.Math.toRadians(0.5) + ) { + return { + ok: true, + action: 'move_camera', + motion, + pitchDeg: Math.round(Cesium.Math.toDegrees(tiltToPitch)), + unchanged: true, + }; + } + } else 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; @@ -1615,6 +1705,30 @@ export function moveCamera(args = {}, runNavigation = null) { speed, mode, }; + if (motion === 'tilt') { + const cam = _viewer.camera; + // Swing about the ground under the crosshair so the place stays + // centred. The follow camera owns a tracked view's frame. + const target = _viewer.trackedEntity ? null : _getTarget?.(_viewer); + const pivot = target ? tiltPivot(cam, target) : null; + if (pivot) { + state.target = target; + state.hpr = pivot; + } + const from = pivot ? pivot.pitch : cam.pitch; + if (tiltToPitch !== null) { + state.mode = 'once'; + state.direction = tiltToPitch > from ? 'up' : 'down'; + state.tiltRad = tiltToPitch - from; + state.durationS = Math.max( + ONCE.durationS, + Math.abs(Cesium.Math.toDegrees(state.tiltRad)) / TILT_TO_DEG_S, + ); + } else { + state.tiltRad = + Cesium.Math.toRadians(ONCE.tiltDeg) * (direction === 'up' ? 1 : -1); + } + } 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. @@ -1699,7 +1813,10 @@ export function moveCamera(args = {}, runNavigation = null) { motion, direction: state.direction, speed, - mode, + mode: state.mode, + ...(tiltToPitch !== null && { + pitchDeg: Math.round(Cesium.Math.toDegrees(tiltToPitch)), + }), }; }; const preserveCameraFlight = diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 87229e5c5..9ca5262a4 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -1014,6 +1014,13 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping 'noir', ]); visualStyle.enum = [...visualStyle.enum, 'snow']; + // Tilt to an angle adds pitchDeg and rewords direction; restore + // the legacy camera fields to keep the pin. + const camera = legacyTools.find((tool) => tool.name === 'move_camera') + .parameters.properties; + delete camera.pitchDeg; + camera.direction.description = + 'Required for pan/tilt/rotate; orbit/rotate_globe default right.'; const block = JSON.stringify(legacyTools); // Re-derived for the voice layer manifest (generated layer enums, alias and // field hints), point-and-ask's pointer/referent arguments and the prompt diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index 74a44e77d..cfc2da5a7 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -225,10 +225,15 @@ test('no unchanged Realtime tool definition drifts silently', () => { camera.description, 'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly"). Globe rotation (rotate_globe) spins Earth right continuously by default; explicit direction, mode and speed take precedence. It checks fit and may refuse.', ); + // Tilt to an angle adds pitchDeg and says a tilt nudge needs a + // direction; check them, then restore the legacy fields for the pin. assert.equal( camera.parameters.properties.direction.description, - 'Required for pan/tilt/rotate; orbit/rotate_globe default right.', + 'Required for pan/rotate and a tilt nudge; orbit/rotate_globe default right.', ); + assert.equal(camera.parameters.properties.pitchDeg.type, 'number'); + assert.match(camera.parameters.properties.pitchDeg.description, /-45 oblique/); + delete camera.parameters.properties.pitchDeg; assert.equal(camera.parameters.properties.mode.description, 'once = bounded eased nudge; continuous = until stop/manual input. rotate_globe defaults continuous; other motions default once.'); camera.parameters.properties.mode.description = 'once = bounded eased nudge (default); continuous = until stop/manual input.'; camera.parameters.properties.motion.enum = diff --git a/src/voice/actionSchemas.js b/src/voice/actionSchemas.js index 9ec47028c..2b31c26d6 100644 --- a/src/voice/actionSchemas.js +++ b/src/voice/actionSchemas.js @@ -749,6 +749,11 @@ const schemas = [ type: 'string', enum: ['once', 'continuous'], }, + pitchDeg: { + type: 'number', + minimum: -90, + maximum: 90, + }, }, required: ['motion'], }, diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index d15a7c25e..d7805e215 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -49,7 +49,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // Snow leaves the set_visual_style enum. // The Operator HUD layout leaves set_hud's layout enum. // The layer alias hint leaves out aliases the layer id already spells. - 'dc2bac1be93a503dc88d637beea2f1bc457e1f9167422bd91548b44756a8aca5', + // move_camera tilt takes an optional pitchDeg angle. + '35261624ef8596608b80e2876f83760723ecf6592acc43fd77691cdb0a972608', ); }); @@ -134,6 +135,8 @@ test('all legacy action arguments are byte-identical after removing the delibera 'stop', ]); motion.enum = motion.enum.filter((value) => value !== 'rotate_globe'); + // Tilt to an angle is additive. + delete property('move_camera').pitchDeg; const panels = property('set_panel_open').panelId; panels.enum = panels.enum.filter( (id) => @@ -272,3 +275,12 @@ test('direct voice timeout schema accepts None and exact integers, rejects malfo for (const minutes of [-1, 61, 1.5, '5']) assert.ok(validateArgs(tool.parameters, { minutes }).length > 0); }); + +test('tilt accepts an angle with no direction', () => { + const schema = GEV_ACTION_SCHEMAS.find( + (tool) => tool.name === 'move_camera', + ).parameters; + assert.deepEqual(validateArgs(schema, { motion: 'tilt', pitchDeg: -45 }), []); + assert.deepEqual(validateArgs(schema, { motion: 'tilt' }), []); + assert.ok(validateArgs(schema, { motion: 'tilt', pitchDeg: 120 }).length > 0); +}); From 91a3f787d4bc4afd4495c5ff6a5b77e5d2bedd9a Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 16:42:39 +0530 Subject: [PATCH 02/39] Hold a voice tilt until its fly-to lands "Fly to London and tilt the camera" reaches the app as two sibling tool calls, and OpenAI runs them concurrently. The tilt's navigation step cancelled the flight already in the air, so the camera tilted where it was; when the place still needed a lookup, the late flight replaced the tilt and London came out top-down. The voice runner now tracks its latest flight. A tilt sent while that flight is still looking up or flying waits for it to land (up to 30 s), then tilts at the destination. If the flight is cancelled, finds nothing or times out, the tilt is not run and says so. --- docs/CURRENT-STATE.md | 2 +- src/voice/gevActions.js | 90 +++++++++++++++++++++++-- src/voice/gevActions.test.mjs | 123 ++++++++++++++++++++++++++++++++++ 3 files changed, 207 insertions(+), 8 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3c17ed79f..2730ae8de 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5364,7 +5364,7 @@ and unreachable upstream (502/504) separately from road geometry. > branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on > `main` + 26 route pins; the same tree on Node 24 reports 2,295. -- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair (the tracked view excepted, which tilts in place), so the place stays centred and the heading is kept; pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. +- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair (the tracked view excepted, which tilts in place), so the place stays centred and the heading is kept; pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. A voice tilt sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" arrives as two sibling calls) waits for that flight to land, up to 30 s, so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the tilt is not run and says so. A tilt sent before its fly-to is not reordered. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. - **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing. - **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm 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 rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped. - Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet). diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index fbb6cc5ce..985dece53 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -276,6 +276,10 @@ export function createGevActionRunner({ ? { ...SPEECH_BUILDERS, ...speechBuilders } : SPEECH_BUILDERS; const _layerEnabledAt = new Map(); + // The latest voice flight. A tilt in the same reply as "fly to X" waits for + // it to land: run at once, its navigation cancelled the flight, or the + // flight (still geocoding) then replaced the tilt. + let voiceFlight = null; let analystEngine; const resolveRegionRing = (name, signal) => annotationResolver.resolveRegionRingForQuery(name, signal, placeSearch); @@ -1103,7 +1107,10 @@ export function createGevActionRunner({ runOptions, ); } - return flyToRequestedLocation(viewer, args, { + const flight = trackVoiceFlight(); + voiceFlight = flight; + const flown = flyToRequestedLocation(viewer, args, { + onFlightEnd: flight.settle, onProgress: reportProgress, placeSearch, searchNavigation, @@ -1131,6 +1138,14 @@ export function createGevActionRunner({ if (viewer.trackedEntity) stopAllTracking(viewer, dataManager); }, }); + // No flight started (not found, refused, cancelled): nothing to wait for. + flown.then( + (result) => { + if (result?.ok !== true) flight.settle('failed'); + }, + () => flight.settle('failed'), + ); + return flown; } if (name === 'adjust_camera_zoom') { @@ -1173,6 +1188,23 @@ export function createGevActionRunner({ } if (name === 'move_camera') { + if ( + String(args.motion || '').toLowerCase() === 'tilt' && + voiceFlight && + !voiceFlight.done + ) { + const status = await waitForVoiceFlight(voiceFlight, runOptions.signal); + if (!current()) + return { ok: false, action: 'move_camera', cancelled: true }; + if (status !== 'arrived') + return { + ok: false, + action: 'move_camera', + motion: 'tilt', + error: + 'Not run: the flight this tilt was waiting for did not arrive.', + }; + } return moveCamera(args, (navigate, releaseOptions) => runManagedVoiceNavigation( styleManager, @@ -2690,6 +2722,42 @@ async function frameOverhead(viewer, dataManager, styleManager, args = {}) { } /** Run one validated voice camera mutation through the UI-owned authority seam. */ +/** A voice flight's end: 'arrived', 'cancelled' or 'failed', settled once. */ +function trackVoiceFlight() { + let resolve; + const flight = { + done: false, + ended: new Promise((settle) => { + resolve = settle; + }), + settle: (status) => { + if (flight.done) return; + flight.done = true; + resolve(status); + }, + }; + return flight; +} + +/** Longest a tilt waits for a flight (geocode plus flight) before giving up. */ +const VOICE_FLIGHT_WAIT_MS = 30_000; + +/** Wait for a voice flight to end; 'timeout' or 'aborted' if it does not. */ +function waitForVoiceFlight(flight, signal) { + if (signal?.aborted) return Promise.resolve('aborted'); + return new Promise((resolve) => { + const finish = (status) => { + clearTimeout(timer); + signal?.removeEventListener?.('abort', onAbort); + resolve(status); + }; + const onAbort = () => finish('aborted'); + const timer = setTimeout(() => finish('timeout'), VOICE_FLIGHT_WAIT_MS); + signal?.addEventListener?.('abort', onAbort, { once: true }); + flight.ended.then(finish); + }); +} + function runManagedVoiceNavigation( styleManager, noun, @@ -3252,6 +3320,7 @@ async function flyToRequestedLocation( runImmediate = null, beginDeferred = null, reassertDeferred = null, + onFlightEnd = null, } = {}, ) { const requestedRangeM = Number(args.rangeM); @@ -3275,12 +3344,19 @@ async function flyToRequestedLocation( settleArrival = resolve; }) : null; - const arrivalHooks = arrival - ? { - onComplete: () => settleArrival?.('arrived'), - onCancel: () => settleArrival?.('cancelled'), - } - : {}; + const arrivalHooks = + arrival || onFlightEnd + ? { + onComplete: () => { + settleArrival?.('arrived'); + onFlightEnd?.('arrived'); + }, + onCancel: () => { + settleArrival?.('cancelled'); + onFlightEnd?.('cancelled'); + }, + } + : {}; const afterArrival = async (result, label) => { if (!arrival || result?.ok !== true) return result; onProgress('fly', label); diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index f6bccdfa9..ddf29babe 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -217,6 +217,129 @@ test('dependent voice navigation waits for the destination viewport to arrive', assert.equal(result.arrived, true); }); +// "fly to London and tilt the camera" arrives as two sibling calls. +// Run at once, the tilt's navigation cancelled the flight; with a geocoded +// place, the late flight replaced the tilt. +function tiltAfterFlightHarness() { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const harness = createVoiceNavigationHarness(); + const { viewer, styleManager } = harness; + const flights = []; + viewer.camera.flyTo = (options) => { + harness.order.push('fly'); + flights.push(options); + }; + styleManager.runImmediateLocationNavigation = (navigate) => ( + styleManager.runImmediateNavigation('location', navigate) + ); + return { ...harness, flights }; +} + +const settledYet = (promise) => { + let settled = false; + promise.then(() => { settled = true; }); + return () => settled; +}; + +test('a tilt in the same reply as a fly-to waits for the flight to land', async () => { + const { viewer, styleManager, order, flights } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + const fly = await runner('fly_to_location', { locationId: 'austin' }); + assert.equal(fly.ok, true); + const tilt = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + const tiltDone = settledYet(tilt); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(tiltDone(), false, 'the tilt must wait while the camera flies'); + assert.equal( + order.slice(order.indexOf('fly')).includes('cancel'), + false, + 'the tilt must not cancel the flight', + ); + flights[0].complete(); + const result = await tilt; + assert.equal(result.ok, true); + assert.equal(result.motion, 'tilt'); +}); + +test('a tilt waits through a geocoded fly-to search, then for its flight', async () => { + const { viewer, styleManager } = tiltAfterFlightHarness(); + let finishSearch; + let landFlight; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + finishSearch = () => { + landFlight = options.onComplete; + resolve({ label: query, navigationMode: 'city-overview' }); + }; + }), + }); + const fly = runner('fly_to_location', { query: 'Somewhere Far' }); + const tilt = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + const tiltDone = settledYet(tilt); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(tiltDone(), false, 'the tilt must wait for the place lookup'); + finishSearch(); + assert.equal((await fly).ok, true); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(tiltDone(), false, 'the tilt must wait for the flight too'); + landFlight(); + assert.equal((await tilt).ok, true); +}); + +test('a tilt waiting on a flight that never lands is not run', async () => { + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + await runner('fly_to_location', { locationId: 'austin' }); + const tilt = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + flights[0].cancel(); + const result = await tilt; + assert.equal(result.ok, false); + assert.match(result.error, /did not arrive/); +}); + +test('a tilt beside a fly-to that found nothing is not run', async () => { + const { viewer, styleManager } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: async () => null, + }); + const fly = runner('fly_to_location', { query: 'Nowhere' }); + const tilt = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + assert.equal((await fly).ok, false); + // Nothing flew: the tilt answers at once and honestly, never at the old view. + const result = await tilt; + assert.equal(result.ok, false); + assert.match(result.error, /did not arrive/); + // A later, separate tilt is not held by the failed lookup. + assert.equal((await runner('move_camera', { motion: 'tilt', pitchDeg: -45 })).ok, true); +}); + +test('a tilt with no voice flight pending runs at once', async () => { + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + await runner('fly_to_location', { locationId: 'austin' }); + flights[0].complete(); + const result = await runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + assert.equal(result.ok, true); +}); + test('nearest-aircraft voice action serializes layer enable, arrival, refresh, airborne query, and selection', async () => { globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; const { viewer, styleManager } = createVoiceNavigationHarness(); From 75000cf6a9f71fb8f534a47c5844160ce6b95e1c Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 16:42:50 +0530 Subject: [PATCH 03/39] Hold a voice orbit, pan or rotate until its fly-to lands "Fly to Paris and orbit it" reaches the app as two sibling tool calls, which OpenAI runs at once. With a place that needs a lookup, the orbit started over the old view and the late flight then replaced it; with a known place, pan and rotate cancelled the flight already in the air. Orbit, pan and rotate now wait for a pending voice flight the same way tilt does: up to 30 s, and not run (saying so) if the flight is cancelled, finds nothing or times out. Stop and rotate_globe do not wait. Orbit keeps its own arming for flights the voice runner does not track. --- docs/CURRENT-STATE.md | 2 +- src/cameraVerbs.js | 8 +-- src/voice/gevActions.js | 24 ++++++--- src/voice/gevActions.test.mjs | 93 +++++++++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 10 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2730ae8de..6f20b01ad 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5364,7 +5364,7 @@ and unreachable upstream (502/504) separately from road geometry. > branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on > `main` + 26 route pins; the same tree on Node 24 reports 2,295. -- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair (the tracked view excepted, which tilts in place), so the place stays centred and the heading is kept; pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. A voice tilt sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" arrives as two sibling calls) waits for that flight to land, up to 30 s, so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the tilt is not run and says so. A tilt sent before its fly-to is not reordered. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. +- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair (the tracked view excepted, which tilts in place), so the place stays centred and the heading is kept; pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 30 s, so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the motion is not run and says so. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered. Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. - **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing. - **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm 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 rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped. - Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet). diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 3898acaa7..0b0f06792 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -1747,9 +1747,11 @@ export function moveCamera(args = {}, runNavigation = null) { } 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). + // A camera flight is in progress — ALWAYS arm and capture at the + // destination; a target existing right now is meaningless (the globe + // is always under the crosshair). The voice runner already holds an + // orbit until its own fly-to lands; this covers any other + // flight. setActiveMotion({ kind: 'orbit', direction: direction || 'right', diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 985dece53..6d9411dfe 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -1188,8 +1188,9 @@ export function createGevActionRunner({ } if (name === 'move_camera') { + const motion = String(args.motion || '').toLowerCase(); if ( - String(args.motion || '').toLowerCase() === 'tilt' && + VOICE_FLIGHT_WAITING_MOTIONS.has(motion) && voiceFlight && !voiceFlight.done ) { @@ -1200,9 +1201,8 @@ export function createGevActionRunner({ return { ok: false, action: 'move_camera', - motion: 'tilt', - error: - 'Not run: the flight this tilt was waiting for did not arrive.', + motion, + error: `Not run: the flight this ${motion} was waiting for did not arrive.`, }; } return moveCamera(args, (navigate, releaseOptions) => @@ -2721,7 +2721,6 @@ async function frameOverhead(viewer, dataManager, styleManager, args = {}) { ); } -/** Run one validated voice camera mutation through the UI-owned authority seam. */ /** A voice flight's end: 'arrived', 'cancelled' or 'failed', settled once. */ function trackVoiceFlight() { let resolve; @@ -2739,9 +2738,21 @@ function trackVoiceFlight() { return flight; } -/** Longest a tilt waits for a flight (geocode plus flight) before giving up. */ +/** Longest a motion waits for a flight (geocode plus flight) before giving up. */ const VOICE_FLIGHT_WAIT_MS = 30_000; +/** + * Motions that act on the place the camera ends up at, so one sent beside a + * fly-to waits for it. Stop is not one, and rotate_globe turns the + * whole Earth from Globe View rather than at a place. + */ +const VOICE_FLIGHT_WAITING_MOTIONS = new Set([ + 'tilt', + 'orbit', + 'pan', + 'rotate', +]); + /** Wait for a voice flight to end; 'timeout' or 'aborted' if it does not. */ function waitForVoiceFlight(flight, signal) { if (signal?.aborted) return Promise.resolve('aborted'); @@ -2758,6 +2769,7 @@ function waitForVoiceFlight(flight, signal) { }); } +/** Run one validated voice camera mutation through the UI-owned authority seam. */ function runManagedVoiceNavigation( styleManager, noun, diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index ddf29babe..ee078ac57 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -340,6 +340,99 @@ test('a tilt with no voice flight pending runs at once', async () => { assert.equal(result.ok, true); }); +// Orbit, pan and rotate also act at the place the camera ends up, so "fly to +// Paris and orbit it" holds the orbit the same way. +for (const motion of [ + { motion: 'orbit' }, + { motion: 'pan', direction: 'left' }, + { motion: 'rotate', direction: 'right' }, +]) { + test(`${motion.motion} in the same reply as a fly-to waits for the flight to land`, async () => { + const { viewer, styleManager, order, flights } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', motion); + const movedDone = settledYet(moved); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(movedDone(), false, `the ${motion.motion} must wait while the camera flies`); + assert.equal( + order.slice(order.indexOf('fly')).includes('cancel'), + false, + `the ${motion.motion} must not cancel the flight`, + ); + flights[0].complete(); + const result = await moved; + assert.equal(result.ok, true); + assert.equal(result.motion, motion.motion); + interruptCameraMotion('test-cleanup'); + }); + + test(`${motion.motion} waits through a geocoded fly-to search, then for its flight`, async () => { + const { viewer, styleManager } = tiltAfterFlightHarness(); + let finishSearch; + let landFlight; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + finishSearch = () => { + landFlight = options.onComplete; + resolve({ label: query, navigationMode: 'city-overview' }); + }; + }), + }); + const fly = runner('fly_to_location', { query: 'Somewhere Far' }); + const moved = runner('move_camera', motion); + const movedDone = settledYet(moved); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(movedDone(), false, `the ${motion.motion} must wait for the place lookup`); + assert.equal(getActiveCameraMotion(), null, 'nothing moves before the flight'); + finishSearch(); + assert.equal((await fly).ok, true); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(movedDone(), false, `the ${motion.motion} must wait for the flight too`); + landFlight(); + assert.equal((await moved).ok, true); + assert.equal(getActiveCameraMotion()?.kind, motion.motion); + interruptCameraMotion('test-cleanup'); + }); + + test(`${motion.motion} waiting on a flight that never lands is not run`, async () => { + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + await runner('fly_to_location', { locationId: 'austin' }); + const moved = runner('move_camera', motion); + flights[0].cancel(); + const result = await moved; + assert.equal(result.ok, false); + assert.equal(result.motion, motion.motion); + assert.match(result.error, new RegExp(`this ${motion.motion} was waiting for did not arrive`)); + assert.equal(getActiveCameraMotion(), null); + }); +} + +test('stop does not wait for a voice flight', async () => { + const { viewer, styleManager } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + await runner('fly_to_location', { locationId: 'austin' }); + const result = await runner('move_camera', { motion: 'stop' }); + assert.equal(result.ok, true); + assert.equal(result.motion, 'stop'); +}); + test('nearest-aircraft voice action serializes layer enable, arrival, refresh, airborne query, and selection', async () => { globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; const { viewer, styleManager } = createVoiceNavigationHarness(); From 2ac73519ec71a5c54da96e3544f18d96985a35d2 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 16:43:01 +0530 Subject: [PATCH 04/39] Add changelog fragment for voice camera tilt (#1520) --- changelog.d/1520-voice-camera-tilt.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/1520-voice-camera-tilt.md diff --git a/changelog.d/1520-voice-camera-tilt.md b/changelog.d/1520-voice-camera-tilt.md new file mode 100644 index 000000000..87d2aef3a --- /dev/null +++ b/changelog.d/1520-voice-camera-tilt.md @@ -0,0 +1,5 @@ +- Voice "tilt the camera" works from the top-down view a fly-to lands on: the + camera swings about the place under the crosshair, keeps its heading, and can + tilt to a spoken angle ("tilt 45 degrees") or a default oblique view. A tilt, + orbit, pan or rotate said with a fly-to ("fly to Paris and orbit it") now + waits for the flight to land (Manjunath, @manjunath22466, #1520). From 5c74b860b77aa83ddbc36fa87442d6db620d28bc Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 19:35:02 +0530 Subject: [PATCH 05/39] Address review: zoom, drag, clearance and pitchDeg for voice tilt - A zoom during a pivoting tilt now scales the range the tilt holds, as it already did for orbit, instead of being undone on the next frame while reporting success. The zoom limits no longer move a view the wrong way: a zoom in leaves a view closer than 80 m in place, and a zoom out leaves an orbit beyond 5,000 km where it is. - A grab, touch or wheel on the map while a voice fly-to is in flight drops the motion queued behind it. Cesium lands the flight anyway, so the orbit or tilt would otherwise take the camera back. Chained fly-tos in one reply keep the earliest snapshot. - A pivot tilt toward the horizon stops lowering the camera at 150 m above the pivot and tilts the rest in place, so a close view no longer ends inside tiles or a hillside. - pitchDeg is an absolute angle. A direction that contradicts it is refused, judged from the pitch the swing starts at, and the tool description says so. --- changelog.d/1520-voice-camera-tilt.md | 3 +- docs/CURRENT-STATE.md | 2 +- server/providers/openai/toolDescriptions.js | 2 +- src/cameraTilt.test.mjs | 146 ++++++++++++++++++++ src/cameraVerbs.js | 113 ++++++++++++--- src/voice/actionSchemas.test.mjs | 5 +- src/voice/gevActions.js | 22 ++- src/voice/gevActions.test.mjs | 69 +++++++++ 8 files changed, 331 insertions(+), 31 deletions(-) diff --git a/changelog.d/1520-voice-camera-tilt.md b/changelog.d/1520-voice-camera-tilt.md index 87d2aef3a..f46ae3c51 100644 --- a/changelog.d/1520-voice-camera-tilt.md +++ b/changelog.d/1520-voice-camera-tilt.md @@ -2,4 +2,5 @@ camera swings about the place under the crosshair, keeps its heading, and can tilt to a spoken angle ("tilt 45 degrees") or a default oblique view. A tilt, orbit, pan or rotate said with a fly-to ("fly to Paris and orbit it") now - waits for the flight to land (Manjunath, @manjunath22466, #1520). + waits for the flight to land, and is dropped if you move the map meanwhile + (Manjunath, @manjunath22466, #1520). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 6f20b01ad..15aeddfec 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5364,7 +5364,7 @@ and unreachable upstream (502/504) separately from road geometry. > branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on > `main` + 26 route pins; the same tree on Node 24 reports 2,295. -- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair (the tracked view excepted, which tilts in place), so the place stays centred and the heading is kept; pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 30 s, so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the motion is not run and says so. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered. Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. +- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair (the tracked view excepted, which tilts in place), so the place stays centred and the heading is kept; pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. A zoom during a pivoting tilt scales the range it holds, as for orbit; a zoom in moves the camera along its view line and may take it below that height, as any zoom does, and the tilt then continues in place. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 30 s, so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, or the user grabs, touches or scrolls the map during it, the motion is not run and says so. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered. Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. - **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing. - **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm 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 rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped. - Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet). diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js index 873a52c8a..c79511e14 100644 --- a/server/providers/openai/toolDescriptions.js +++ b/server/providers/openai/toolDescriptions.js @@ -533,7 +533,7 @@ export const ACTION_DESCRIPTIONS = { }, pitchDeg: { description: - 'Tilt to this angle below the horizon (-45 oblique, -89 top-down). Bare tilt: -45.', + 'Absolute angle below the horizon, not a step (-45 oblique, -89 top-down). Bare tilt: -45.', $position: 2, }, }, diff --git a/src/cameraTilt.test.mjs b/src/cameraTilt.test.mjs index babe95807..d70b07cbb 100644 --- a/src/cameraTilt.test.mjs +++ b/src/cameraTilt.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import * as Cesium from 'cesium'; import { + adjustOrbitRange, getActiveCameraMotion, initCameraVerbs, interruptCameraMotion, @@ -222,3 +223,148 @@ test('a pivoting tilt yields when tracking takes the camera', (t) => { h.viewer.trackedEntityChanged.raiseEvent(entity); assert.equal(getActiveCameraMotion(), null); }); + +/** Metres between the camera and the point it pivots about. */ +const pivotRange = (h) => + Cesium.Cartesian3.distance(h.camera.positionWC, h.target); +const heightM = (camera) => + Cesium.Cartographic.fromCartesian(camera.positionWC).height; + +test('a zoom during a pivoting tilt changes the range it holds', (t) => { + const h = harness(t); + assert.equal( + moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }).ok, + true, + ); + h.tick(); + const before = pivotRange(h); + assert.equal(adjustOrbitRange(0.5), true, 'the tilt must take the zoom'); + for (let i = 0; i < 5; i += 1) h.tick(); + // The next frame's lookAt must not put the old range back. + assert.ok( + Math.abs(pivotRange(h) - before / 2) < 1, + `range ${pivotRange(h)} m, wanted ${before / 2} m`, + ); +}); + +test('a zoom with no pivot to hold is left to the camera', (t) => { + harness(t, { pivot: false }); + moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }); + assert.equal(adjustOrbitRange(0.5), false); +}); + +test('a pivot tilt toward the horizon keeps its clearance above the ground', (t) => { + // A close top-down view: swinging 250 m of range to -5° would end ~22 m up. + const h = harness(t, { height: 250 }); + assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -5 }).ok, true); + let lowest = Infinity; + for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) { + h.tick(); + lowest = Math.min(lowest, heightM(h.camera)); + } + assert.equal(getActiveCameraMotion(), null, 'tilt must finish'); + assert.ok(lowest > 149, `camera dropped to ${lowest} m`); + assert.ok( + Math.abs(pitchDeg(h.camera) - -5) < 0.5, + `pitch ${pitchDeg(h.camera)}`, + ); + assert.ok(Cesium.Matrix4.equals(h.camera.transform, Cesium.Matrix4.IDENTITY)); +}); + +test('a continuous tilt up from close in keeps its clearance', (t) => { + const h = harness(t, { height: 250 }); + moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }); + for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) { + h.tick(); + assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`); + } + assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5); +}); + +test('a city-scale pivot tilt keeps the place centred to the horizon limit', (t) => { + const h = harness(t); + moveCamera({ motion: 'tilt', pitchDeg: -5 }); + h.finish(); + // 4 km of range clears the floor at every pitch: all pivot, no drift. + assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`); +}); + +test('pitchDeg against a contradicting direction is refused', (t) => { + harness(t, { pitch: -45 }); + // "tilt down 10 degrees": -10° is an absolute angle, which is up from -45°. + const result = moveCamera({ + motion: 'tilt', + direction: 'down', + pitchDeg: 10, + }); + assert.equal(result.ok, false); + assert.match(result.error, /absolute angle/); + assert.equal(getActiveCameraMotion(), null); +}); + +test('pitchDeg with a direction that agrees still tilts', (t) => { + const h = harness(t, { pitch: -45 }); + const result = moveCamera({ + motion: 'tilt', + direction: 'down', + pitchDeg: -80, + }); + assert.equal(result.ok, true); + assert.equal(result.direction, 'down'); + h.finish(); + assert.ok(Math.abs(pitchDeg(h.camera) - -80) < 0.5); +}); + +test('the pitchDeg and direction check uses the pitch the swing starts from', (t) => { + // At regional range the Earth curves away: the pivot pitch is a degree or + // two shallower than the camera's (-30°), so -29° is still down from it. + harness(t, { height: 150000, pitch: -30 }); + const up = moveCamera({ motion: 'tilt', direction: 'up', pitchDeg: -29 }); + assert.equal(up.ok, false); + assert.match(up.error, /tilts down, not up/); + const down = moveCamera({ motion: 'tilt', direction: 'down', pitchDeg: -29 }); + assert.equal(down.ok, true); + assert.equal(down.direction, 'down'); +}); + +test('after a zoom in, a pivoting tilt never lowers the camera further', (t) => { + const h = harness(t, { height: 1000 }); + moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }); + while (pitchDeg(h.camera) < -30) h.tick(); + adjustOrbitRange(1 / 2.4); + adjustOrbitRange(1 / 2.4); + h.tick(); + const afterZoom = heightM(h.camera); + for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) { + h.tick(); + assert.ok( + heightM(h.camera) > afterZoom - 1, + `camera fell from ${afterZoom} m to ${heightM(h.camera)} m`, + ); + } + assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5); +}); + +test('a zoom in during a close pivot tilt never pushes the camera out', (t) => { + // 62 m of range, under the 80 m zoom floor. + const h = harness(t, { height: 40, pitch: -40 }); + moveCamera({ motion: 'tilt', direction: 'down', mode: 'continuous' }); + h.tick(); + const before = pivotRange(h); + assert.ok(before < 80, `range ${before} m`); + adjustOrbitRange(1 / 1.6); + h.tick(); + assert.ok(pivotRange(h) <= before + 0.01, `range grew to ${pivotRange(h)} m`); +}); + +test('a zoom out during a far orbit never pulls the camera in', (t) => { + // 15,000 km up, beyond the 5,000 km zoom cap. + const h = harness(t, { height: 15_000_000 }); + assert.equal(moveCamera({ motion: 'orbit', mode: 'continuous' }).ok, true); + h.tick(); + const before = pivotRange(h); + assert.ok(before > 5_000_000, `range ${before} m`); + assert.equal(adjustOrbitRange(2.4), true); + h.tick(); + assert.ok(pivotRange(h) >= before - 1, `range fell to ${pivotRange(h)} m`); +}); diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 0b0f06792..7e94d0edf 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -52,6 +52,11 @@ const PITCH_MAX = Cesium.Math.toRadians(-5); const TILT_DEFAULT_PITCH_DEG = -45; /** °/s for a tilt to a requested angle; short ones keep the once duration. */ const TILT_TO_DEG_S = 30; +/** + * A pivoting tilt keeps the range, so its height above the pivot is + * range × sin|pitch|. It swings no lower than this; the rest tilts in place. + */ +const TILT_MIN_CLEARANCE_M = 150; /* ── Route dolly: cinematic tuning ────────────────────────────────────────── * Every knob an owner may want to retune lives in this block. The shaping is @@ -970,6 +975,17 @@ let _trackingRemover = null; let _inputRemovers = []; /** Monotonic id for the motion currently running; 0 means "no motion". */ let _motionSeq = 0; +/** Canvas grabs, touches and wheel turns since load. */ +let _manualInputCount = 0; + +/** + * Lets a caller holding a motion back (behind a fly-to) see whether the user + * took the camera meanwhile; Cesium does not cancel a flight on input. + * @returns {number} Manual camera inputs so far. + */ +export function manualCameraInputCount() { + return _manualInputCount; +} /** * Install a motion and stamp it with a fresh id. The id is what lets a caller @@ -1248,7 +1264,8 @@ function onTick() { * so the place under the crosshair stays centred; without one it looks up or * down in place. Pitch is clamped to [-89°, -5°] only in the direction of * travel: a top-down view sits at -90°, below the limit, and must still be - * able to tilt up out of it. + * able to tilt up out of it. A pivot swing toward the horizon lowers the + * camera, so it stops at TILT_MIN_CLEARANCE_M and the rest tilts in place. */ function tickTilt(cam, m, scale, dt) { let step; @@ -1261,24 +1278,58 @@ function tickTilt(cam, m, scale, dt) { dt * (m.direction === 'up' ? 1 : -1); } - const from = m.hpr ? m.hpr.pitch : cam.pitch; - const next = from + step; - const limited = - step > 0 - ? Math.min(next, Math.max(from, PITCH_MAX)) - : Math.max(next, Math.min(from, PITCH_MIN)); if (m.hpr) { + const from = m.hpr.pitch; + const next = from + step; + const limited = + step > 0 + ? Math.min(next, Math.max(from, tiltPivotPitchMax(m.hpr.range))) + : Math.max(next, Math.min(from, PITCH_MIN)); m.hpr.pitch = limited; cam.lookAt(m.target, m.hpr); // A once motion that just completed released the lookAt frame before // this last step re-took it. if (_active !== m) clearLookAt(); - } else { - cam.lookUp(limited - from); + if (limited === next) return; + if (step < 0 || limited >= PITCH_MAX) { + interruptCameraMotion('tilt-clamp'); + return; + } + // Down to its clearance: let go of the pivot and tilt the rest in place, + // which keeps the height. + if (_active === m) clearLookAt(); + m.hpr = null; + m.target = null; + step = next - limited; } + const from = cam.pitch; + const next = from + step; + const limited = + step > 0 + ? Math.min(next, Math.max(from, PITCH_MAX)) + : Math.max(next, Math.min(from, PITCH_MIN)); + cam.lookUp(limited - from); if (limited !== next) interruptCameraMotion('tilt-clamp'); } +/** Shallowest pivot pitch that keeps the clearance at this range. */ +function tiltPivotPitchMax(range) { + return Math.min( + PITCH_MAX, + -Math.asin(Math.min(1, TILT_MIN_CLEARANCE_M / range)), + ); +} + +/** + * Where a tilt starts. It swings about the ground under the crosshair so the + * place stays centred; the follow camera owns a tracked view's frame. + */ +function tiltStartFrame(cam) { + const target = _viewer.trackedEntity ? null : _getTarget?.(_viewer); + const pivot = target ? tiltPivot(cam, target) : null; + return { target, pivot, from: pivot ? pivot.pitch : cam.pitch }; +} + /** Ground pivot for a tilt: heading, pitch and range in the target's own frame. */ function tiltPivot(cam, target) { const local = Cesium.Matrix4.multiplyByPoint( @@ -1482,7 +1533,10 @@ export function initCameraVerbs( 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'); + const h = () => { + _manualInputCount += 1; + interruptCameraMotion('manual-input'); + }; canvas.addEventListener(evt, h, { passive: true }); _inputRemovers.push(() => canvas.removeEventListener(evt, h)); } @@ -1580,9 +1634,10 @@ export function moveCamera(args = {}, runNavigation = null) { }; } if (motion === 'tilt' && tiltToPitch !== null) { - if ( - Math.abs(tiltToPitch - _viewer.camera.pitch) < Cesium.Math.toRadians(0.5) - ) { + // Judge from the pitch the motion will start at: the pivot's, which at + // regional range is a degree or two shallower than the camera's. + const { from } = tiltStartFrame(_viewer.camera); + if (Math.abs(tiltToPitch - from) < Cesium.Math.toRadians(0.5)) { return { ok: true, action: 'move_camera', @@ -1591,6 +1646,18 @@ export function moveCamera(args = {}, runNavigation = null) { unchanged: true, }; } + // pitchDeg is an absolute angle, not a step: "tilt down 10 degrees" sent + // as {direction:'down', pitchDeg:10} would tilt up from -45°. + const towards = tiltToPitch > from ? 'up' : 'down'; + if ((direction === 'up' || direction === 'down') && direction !== towards) { + const target = Math.round(Cesium.Math.toDegrees(tiltToPitch)); + const now = Math.round(Cesium.Math.toDegrees(from)); + return { + ok: false, + action: 'move_camera', + error: `pitchDeg is an absolute angle, not a step: ${target}° from ${now}° tilts ${towards}, not ${direction}. Send direction alone for a nudge, or pitchDeg alone for an angle.`, + }; + } } else 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). @@ -1706,16 +1773,11 @@ export function moveCamera(args = {}, runNavigation = null) { mode, }; if (motion === 'tilt') { - const cam = _viewer.camera; - // Swing about the ground under the crosshair so the place stays - // centred. The follow camera owns a tracked view's frame. - const target = _viewer.trackedEntity ? null : _getTarget?.(_viewer); - const pivot = target ? tiltPivot(cam, target) : null; + const { target, pivot, from } = tiltStartFrame(_viewer.camera); if (pivot) { state.target = target; state.hpr = pivot; } - const from = pivot ? pivot.pitch : cam.pitch; if (tiltToPitch !== null) { state.mode = 'once'; state.direction = tiltToPitch > from ? 'up' : 'down'; @@ -1833,14 +1895,19 @@ export function moveCamera(args = {}, runNavigation = null) { /** * Zoom while orbiting: scale the orbit RADIUS instead of letting the zoom * fight the per-frame lookAt (field test: "zoom out did literally nothing"). + * A tilt swinging about its pivot holds a range the same way. * @param {number} factor >1 zooms out (radius grows), <1 zooms in. - * @returns {boolean} true when an orbit consumed the zoom. + * @returns {boolean} true when an orbit or pivoting tilt consumed the zoom. */ export function adjustOrbitRange(factor) { - if (!_active || _active.kind !== 'orbit' || !_active.hpr) return false; + if (!_active || !['orbit', 'tilt'].includes(_active.kind) || !_active.hpr) + return false; + // The limits never move a view the wrong way: a zoom in leaves a closer + // view in place, and a zoom out leaves a farther one. + const range = _active.hpr.range; _active.hpr.range = Math.max( - 80, - Math.min(5_000_000, _active.hpr.range * factor), + Math.min(80, range), + Math.min(Math.max(5_000_000, range), range * factor), ); return true; } diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index d7805e215..436828dba 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -49,8 +49,9 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // Snow leaves the set_visual_style enum. // The Operator HUD layout leaves set_hud's layout enum. // The layer alias hint leaves out aliases the layer id already spells. - // move_camera tilt takes an optional pitchDeg angle. - '35261624ef8596608b80e2876f83760723ecf6592acc43fd77691cdb0a972608', + // move_camera tilt takes an optional pitchDeg angle, described as + // absolute rather than a step. + '7258f48a19dead97d1a52cd897355ac84cb1f9077f1e8573fd64e3937660f5d0', ); }); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 6d9411dfe..07d0e4021 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -47,6 +47,7 @@ import { flyRoute, interruptCameraMotion, adjustOrbitRange, + manualCameraInputCount, } from '../cameraVerbs.js'; import * as defaultFloorServices from '../data/groundFloor.js'; import { isPickedWorldPosition } from '../data/scenePick.js'; @@ -1107,7 +1108,10 @@ export function createGevActionRunner({ runOptions, ); } - const flight = trackVoiceFlight(); + // Chained fly-tos in one reply: a grab during the earlier one counts. + const flight = trackVoiceFlight( + voiceFlight && !voiceFlight.done ? voiceFlight.inputCount : undefined, + ); voiceFlight = flight; const flown = flyToRequestedLocation(viewer, args, { onFlightEnd: flight.settle, @@ -1194,7 +1198,8 @@ export function createGevActionRunner({ voiceFlight && !voiceFlight.done ) { - const status = await waitForVoiceFlight(voiceFlight, runOptions.signal); + const flight = voiceFlight; + const status = await waitForVoiceFlight(flight, runOptions.signal); if (!current()) return { ok: false, action: 'move_camera', cancelled: true }; if (status !== 'arrived') @@ -1204,6 +1209,15 @@ export function createGevActionRunner({ motion, error: `Not run: the flight this ${motion} was waiting for did not arrive.`, }; + // The flight lands even when the user grabs the globe mid-flight; + // the queued motion must not take the camera back from them. + if (manualCameraInputCount() !== flight.inputCount) + return { + ok: false, + action: 'move_camera', + motion, + error: `Not run: the user moved the camera during the flight, so the ${motion} was dropped.`, + }; } return moveCamera(args, (navigate, releaseOptions) => runManagedVoiceNavigation( @@ -2722,10 +2736,12 @@ async function frameOverhead(viewer, dataManager, styleManager, args = {}) { } /** A voice flight's end: 'arrived', 'cancelled' or 'failed', settled once. */ -function trackVoiceFlight() { +function trackVoiceFlight(inputCount = manualCameraInputCount()) { let resolve; const flight = { done: false, + // Manual input after this means the user took the camera mid-flight. + inputCount, ended: new Promise((settle) => { resolve = settle; }), diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index ee078ac57..d71892130 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -420,6 +420,75 @@ for (const motion of [ }); } +// Cesium lands a flight even when the user grabs the globe mid-flight, so the +// motion queued behind it must not then take the camera back. +for (const motion of [ + { motion: 'orbit' }, + { motion: 'tilt', pitchDeg: -45 }, +]) { + test(`${motion.motion} queued behind a fly-to is dropped when the user drags mid-flight`, async () => { + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const canvasListeners = new Map(); + viewer.scene.canvas.addEventListener = (type, listener) => { + canvasListeners.set(type, listener); + }; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', motion); + canvasListeners.get('pointerdown')(); + flights[0].complete(); + const result = await moved; + assert.equal(result.ok, false); + assert.equal(result.motion, motion.motion); + assert.match(result.error, /moved the camera during the flight/); + assert.equal(getActiveCameraMotion(), null); + }); +} + +test('a drag during the first of two chained fly-tos still drops the queued motion', async () => { + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const canvasListeners = new Map(); + viewer.scene.canvas.addEventListener = (type, listener) => { + canvasListeners.set(type, listener); + }; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + canvasListeners.get('touchstart')(); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'orbit' }); + flights.at(-1).complete(); + const result = await moved; + assert.equal(result.ok, false); + assert.match(result.error, /moved the camera during the flight/); +}); + +test('input before a fly-to does not drop the motion queued behind it', async () => { + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const canvasListeners = new Map(); + viewer.scene.canvas.addEventListener = (type, listener) => { + canvasListeners.set(type, listener); + }; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + canvasListeners.get('wheel')(); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'orbit' }); + flights[0].complete(); + assert.equal((await moved).ok, true); + interruptCameraMotion('test-cleanup'); +}); + test('stop does not wait for a voice flight', async () => { const { viewer, styleManager } = tiltAfterFlightHarness(); const runner = createGevActionRunner({ From 9e64d517adf3e6c8c538a92c1ba6736fd3a97483 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 20:05:34 +0530 Subject: [PATCH 06/39] Follow up voice tilt review: superseded flights, wait, globe range - A motion waiting on a voice fly-to follows a later fly-to in the same reply that cancels it, within one deadline, instead of reporting that its flight did not arrive. A stale reply stops following. - The wait is 15 s, down from 30 s: it holds the tool output, and on Gemini the later tools in the turn. - Beyond 1,000 km of range a tilt happens in place instead of swinging thousands of km about the crosshair point. - The tilt-nudge limits read the pivot pitch the motion starts from, including for a voice tilt from a tracked view, whose follow camera is released before it moves. - move_camera's argument checks run before the flight wait, so a malformed call is refused at once. - Tests: t.after cleanup in the fly-then-motion loop, the pitchDeg pin asserts the field before removing it, and the handoff-order pin follows the extracted argument checks. --- docs/CURRENT-STATE.md | 2 +- src/cameraHandoff.test.mjs | 11 ++- src/cameraTilt.test.mjs | 32 +++++++ src/cameraVerbs.js | 165 +++++++++++++++++++------------- src/firstRunExperience.test.mjs | 1 + src/voice/gevActions.js | 38 ++++++-- src/voice/gevActions.test.mjs | 96 +++++++++++++++++-- 7 files changed, 266 insertions(+), 79 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5b3b88201..babe26a0b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5372,7 +5372,7 @@ and unreachable upstream (502/504) separately from road geometry. > branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on > `main` + 26 route pins; the same tree on Node 24 reports 2,295. -- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair (the tracked view excepted, which tilts in place), so the place stays centred and the heading is kept; pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. A zoom during a pivoting tilt scales the range it holds, as for orbit; a zoom in moves the camera along its view line and may take it below that height, as any zoom does, and the tilt then continues in place. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 30 s, so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, or the user grabs, touches or scrolls the map during it, the motion is not run and says so. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered. Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. +- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. A zoom during a pivoting tilt scales the range it holds, as for orbit; a zoom in moves the camera along its view line and may take it below that height, as any zoom does, and the tilt then continues in place. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (following a later fly-to in the same reply that cancels it), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, or the user grabs, touches or scrolls the map during it, the motion is not run and says so. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered. Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. - **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing. - **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm 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 rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped. - Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet). diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 2c0d356b2..398c51c34 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -223,11 +223,20 @@ test('validated voice camera destinations share the UI navigation authority faca 'move camera', ); ordered(move, [ - "if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))", + 'if (argsError) return argsError;', 'const start = () => {', /const preserveCameraFlight =\s*motion === 'orbit'/, 'runNavigation(start, { preserveCameraFlight })', ], 'move validation before handoff'); + const moveArgs = body( + cameraVerbs, + /export function moveCameraArgsError\(args = \{\}\) \{([\s\S]*?)\n\}/, + 'move camera argument checks', + ); + assert.ok( + moveArgs.includes("if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))"), + 'unknown motions are refused before handoff', + ); const route = body( cameraVerbs, diff --git a/src/cameraTilt.test.mjs b/src/cameraTilt.test.mjs index d70b07cbb..592098445 100644 --- a/src/cameraTilt.test.mjs +++ b/src/cameraTilt.test.mjs @@ -368,3 +368,35 @@ test('a zoom out during a far orbit never pulls the camera in', (t) => { h.tick(); assert.ok(pivotRange(h) >= before - 1, `range fell to ${pivotRange(h)} m`); }); + +test('from globe range a tilt happens in place', (t) => { + // A pivot 2,000 km out would swing the camera thousands of km. + const h = harness(t, { height: 2_000_000 }); + const before = Cesium.Cartesian3.clone(h.camera.positionWC); + assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -45 }).ok, true); + h.finish(); + assert.ok(Math.abs(pitchDeg(h.camera) - -45) < 0.5); + assert.ok(Cesium.Cartesian3.distance(h.camera.positionWC, before) < 1e-3); +}); + +test('a regional tilt-up nudge at the pivot limit refuses honestly', (t) => { + // 20 km up at -7°, the pivot 186 km out sits at -5.3°: already at the limit. + harness(t, { height: 20000, pitch: -7 }); + const result = moveCamera({ motion: 'tilt', direction: 'up' }); + assert.equal(result.ok, false); + assert.match(result.error, /upper tilt limit/); + assert.equal(getActiveCameraMotion(), null); +}); + +test('a voice tilt from a tracked view judges its limit from the pivot it will use', (t) => { + const h = harness(t, { height: 20000, pitch: -7 }); + h.viewer.trackedEntity = { id: 'tracked' }; + // Voice releases the follow camera before the motion starts. + const release = (start) => { + h.viewer.trackedEntity = undefined; + return start(); + }; + const result = moveCamera({ motion: 'tilt', direction: 'up' }, release); + assert.equal(result.ok, false); + assert.match(result.error, /upper tilt limit/); +}); diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 7e94d0edf..1c12a9a74 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -57,6 +57,11 @@ const TILT_TO_DEG_S = 30; * range × sin|pitch|. It swings no lower than this; the rest tilts in place. */ const TILT_MIN_CLEARANCE_M = 150; +/** + * Beyond this range a pivot would swing the camera thousands of km about the + * crosshair point, so the tilt happens in place instead. + */ +const TILT_PIVOT_MAX_RANGE_M = 1_000_000; /* ── Route dolly: cinematic tuning ────────────────────────────────────────── * Every knob an owner may want to retune lives in this block. The shaping is @@ -1322,11 +1327,15 @@ function tiltPivotPitchMax(range) { /** * Where a tilt starts. It swings about the ground under the crosshair so the - * place stays centred; the follow camera owns a tracked view's frame. + * place stays centred. It tilts in place with no ground there, from globe + * range, or while an entity is tracked (the follow camera owns that frame; + * voice releases it before moving, so a voice tilt still pivots). */ -function tiltStartFrame(cam) { - const target = _viewer.trackedEntity ? null : _getTarget?.(_viewer); - const pivot = target ? tiltPivot(cam, target) : null; +function tiltStartFrame(cam, { releasesTracking = false } = {}) { + const tracked = Boolean(_viewer.trackedEntity) && !releasesTracking; + const target = tracked ? null : _getTarget?.(_viewer); + let pivot = target ? tiltPivot(cam, target) : null; + if (pivot && pivot.range > TILT_PIVOT_MAX_RANGE_M) pivot = null; return { target, pivot, from: pivot ? pivot.pitch : cam.pitch }; } @@ -1542,6 +1551,83 @@ export function initCameraVerbs( } } +/** + * A tilt's angle: the one asked for, the default oblique view when a bare + * "tilt the camera" names neither angle nor direction, or null for a nudge. + */ +function requestedTiltPitch(args, direction) { + // Only a real number or numeric text: Number('') and Number(null) are 0, + // which would swing the camera to the horizon. + const raw = args.pitchDeg; + const requested = + typeof raw === 'number' || (typeof raw === 'string' && raw.trim() !== '') + ? Number(raw) + : NaN; + // The camera never looks above the horizon, so a positive angle can only + // mean the same angle below it ("tilt 45 degrees" sent as 45). + if (Number.isFinite(requested)) + return Cesium.Math.clamp( + Cesium.Math.toRadians(-Math.abs(requested)), + PITCH_MIN, + PITCH_MAX, + ); + return direction === null + ? Cesium.Math.toRadians(TILT_DEFAULT_PITCH_DEG) + : null; +} + +/** + * Argument errors move_camera refuses whatever the camera is doing, so a + * caller holding a motion back behind a flight can refuse it before waiting. + * @param {Object} args + * @returns {Object|null} The refusal, or null when the arguments are usable. + */ +export function moveCameraArgsError(args = {}) { + const motion = String(args.motion || '').toLowerCase(); + const direction = args.direction + ? String(args.direction).toLowerCase() + : null; + if (motion === 'stop') return null; + 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' && + (motion !== 'tilt' || requestedTiltPitch(args, direction) === null) && + !['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.', + }; + } + return null; +} + /** * move_camera implementation. Returns the house result shape; rejections are * plain-English `ok:false` errors. @@ -1574,69 +1660,17 @@ export function moveCamera(args = {}, runNavigation = null) { }; 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.`, - }; - } - // Tilt to an angle: the one asked for, or a default oblique view when a - // bare "tilt the camera" names neither angle nor direction. - let tiltToPitch = null; - if (motion === 'tilt') { - // Only a real number or numeric text: Number('') and Number(null) are 0, - // which would swing the camera to the horizon. - const raw = args.pitchDeg; - const requested = - typeof raw === 'number' || (typeof raw === 'string' && raw.trim() !== '') - ? Number(raw) - : NaN; - // The camera never looks above the horizon, so a positive angle can only - // mean the same angle below it ("tilt 45 degrees" sent as 45). - if (Number.isFinite(requested)) - tiltToPitch = Cesium.Math.clamp( - Cesium.Math.toRadians(-Math.abs(requested)), - PITCH_MIN, - PITCH_MAX, - ); - else if (direction === null) - tiltToPitch = Cesium.Math.toRadians(TILT_DEFAULT_PITCH_DEG); - } - if ( - motion !== 'orbit' && - motion !== 'rotate_globe' && - tiltToPitch === null && - !['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.', - }; - } + const argsError = moveCameraArgsError(args); + if (argsError) return argsError; + const tiltToPitch = + motion === 'tilt' ? requestedTiltPitch(args, direction) : null; + // A managed (voice) navigation releases the follow camera before start(), + // so checks made now must judge from the frame start() will see. + const releasesTracking = typeof runNavigation === 'function'; if (motion === 'tilt' && tiltToPitch !== null) { // Judge from the pitch the motion will start at: the pivot's, which at // regional range is a degree or two shallower than the camera's. - const { from } = tiltStartFrame(_viewer.camera); + const { from } = tiltStartFrame(_viewer.camera, { releasesTracking }); if (Math.abs(tiltToPitch - from) < Cesium.Math.toRadians(0.5)) { return { ok: true, @@ -1661,7 +1695,8 @@ export function moveCamera(args = {}, runNavigation = null) { } else 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; + // The clamp reads the pitch the motion starts from. + const pitch = tiltStartFrame(_viewer.camera, { releasesTracking }).from; if (direction === 'up' && pitch >= PITCH_MAX - Cesium.Math.toRadians(0.5)) { return { ok: false, diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 9ca5262a4..60a37f73c 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -1018,6 +1018,7 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // the legacy camera fields to keep the pin. const camera = legacyTools.find((tool) => tool.name === 'move_camera') .parameters.properties; + assert.equal(camera.pitchDeg.type, 'number'); delete camera.pitchDeg; camera.direction.description = 'Required for pan/tilt/rotate; orbit/rotate_globe default right.'; diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 926876f6d..66199e277 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -48,6 +48,7 @@ import { interruptCameraMotion, adjustOrbitRange, manualCameraInputCount, + moveCameraArgsError, } from '../cameraVerbs.js'; import * as defaultFloorServices from '../data/groundFloor.js'; import { isPickedWorldPosition } from '../data/scenePick.js'; @@ -1198,8 +1199,26 @@ export function createGevActionRunner({ voiceFlight && !voiceFlight.done ) { - const flight = voiceFlight; - const status = await waitForVoiceFlight(flight, runOptions.signal); + // Bad arguments are refused now, not after waiting on the flight. + const argsError = moveCameraArgsError(args); + if (argsError) return argsError; + const deadline = Date.now() + VOICE_FLIGHT_WAIT_MS; + let flight = voiceFlight; + let status = await waitForVoiceFlight( + flight, + runOptions.signal, + deadline, + ); + // A later fly-to in the same reply cancels this one: follow it to + // where the camera lands, within the same deadline. + while (status === 'cancelled' && voiceFlight !== flight && current()) { + flight = voiceFlight; + status = await waitForVoiceFlight( + flight, + runOptions.signal, + deadline, + ); + } if (!current()) return { ok: false, action: 'move_camera', cancelled: true }; if (status !== 'arrived') @@ -2759,8 +2778,12 @@ function trackVoiceFlight(inputCount = manualCameraInputCount()) { return flight; } -/** Longest a motion waits for a flight (geocode plus flight) before giving up. */ -const VOICE_FLIGHT_WAIT_MS = 30_000; +/** + * Longest a motion waits for its flight (geocode plus flight, which is about + * 2 s) before giving up. The wait also holds the tool output, and on Gemini + * the later tools in the turn. + */ +const VOICE_FLIGHT_WAIT_MS = 15_000; /** * Motions that act on the place the camera ends up at, so one sent beside a @@ -2775,7 +2798,7 @@ const VOICE_FLIGHT_WAITING_MOTIONS = new Set([ ]); /** Wait for a voice flight to end; 'timeout' or 'aborted' if it does not. */ -function waitForVoiceFlight(flight, signal) { +function waitForVoiceFlight(flight, signal, deadline) { if (signal?.aborted) return Promise.resolve('aborted'); return new Promise((resolve) => { const finish = (status) => { @@ -2784,7 +2807,10 @@ function waitForVoiceFlight(flight, signal) { resolve(status); }; const onAbort = () => finish('aborted'); - const timer = setTimeout(() => finish('timeout'), VOICE_FLIGHT_WAIT_MS); + const timer = setTimeout( + () => finish('timeout'), + Math.max(0, deadline - Date.now()), + ); signal?.addEventListener?.('abort', onAbort, { once: true }); flight.ended.then(finish); }); diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index e4bffb26b..08af6b288 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -347,7 +347,8 @@ for (const motion of [ { motion: 'pan', direction: 'left' }, { motion: 'rotate', direction: 'right' }, ]) { - test(`${motion.motion} in the same reply as a fly-to waits for the flight to land`, async () => { + test(`${motion.motion} in the same reply as a fly-to waits for the flight to land`, async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); const { viewer, styleManager, order, flights } = tiltAfterFlightHarness(); const runner = createGevActionRunner({ viewer, @@ -368,10 +369,10 @@ for (const motion of [ const result = await moved; assert.equal(result.ok, true); assert.equal(result.motion, motion.motion); - interruptCameraMotion('test-cleanup'); }); - test(`${motion.motion} waits through a geocoded fly-to search, then for its flight`, async () => { + test(`${motion.motion} waits through a geocoded fly-to search, then for its flight`, async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); const { viewer, styleManager } = tiltAfterFlightHarness(); let finishSearch; let landFlight; @@ -399,7 +400,6 @@ for (const motion of [ landFlight(); assert.equal((await moved).ok, true); assert.equal(getActiveCameraMotion()?.kind, motion.motion); - interruptCameraMotion('test-cleanup'); }); test(`${motion.motion} waiting on a flight that never lands is not run`, async () => { @@ -470,7 +470,8 @@ test('a drag during the first of two chained fly-tos still drops the queued moti assert.match(result.error, /moved the camera during the flight/); }); -test('input before a fly-to does not drop the motion queued behind it', async () => { +test('input before a fly-to does not drop the motion queued behind it', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); const { viewer, styleManager, flights } = tiltAfterFlightHarness(); const canvasListeners = new Map(); viewer.scene.canvas.addEventListener = (type, listener) => { @@ -486,7 +487,90 @@ test('input before a fly-to does not drop the motion queued behind it', async () const moved = runner('move_camera', { motion: 'orbit' }); flights[0].complete(); assert.equal((await moved).ok, true); - interruptCameraMotion('test-cleanup'); +}); + +test('a motion follows a later fly-to in the same reply that cancels its flight', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'orbit' }); + const movedDone = settledYet(moved); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + // The second flight supersedes the first, as Cesium's flyTo does. + flights[0].cancel(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(movedDone(), false, 'the orbit must wait for the second flight'); + flights[1].complete(); + const result = await moved; + assert.equal(result.ok, true); + assert.equal(result.motion, 'orbit'); +}); + +test('a stale motion does not follow a newer reply\'s fly-to', async () => { + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + let live = true; + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'orbit' }, { isCurrent: () => live }); + // A newer reply takes over and flies elsewhere; this reply is stale. + live = false; + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + flights[0].cancel(); + // The newer flight never lands here: following it would hold this answer. + const result = await Promise.race([ + moved, + new Promise((resolve) => setTimeout(() => resolve('still waiting'), 50)), + ]); + assert.notEqual(result, 'still waiting'); + assert.equal(result.ok, false); + assert.equal(result.cancelled, true); +}); + +test('a motion gives up on a flight that has not landed in 15 s', async (t) => { + const { viewer, styleManager } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); + const moved = runner('move_camera', { motion: 'orbit' }); + const movedDone = settledYet(moved); + await Promise.resolve(); + t.mock.timers.tick(14_999); + await Promise.resolve(); + assert.equal(movedDone(), false); + t.mock.timers.tick(1); + const result = await moved; + assert.equal(result.ok, false); + assert.match(result.error, /did not arrive/); +}); + +test('bad move_camera arguments are refused without waiting for a flight', async () => { + const { viewer, styleManager } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'pan' }); + const movedDone = settledYet(moved); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(movedDone(), true, 'the refusal must not wait on the flight'); + const result = await moved; + assert.equal(result.ok, false); + assert.match(result.error, /needs a direction/); }); test('stop does not wait for a voice flight', async () => { From fdecc8c5818e8ad4d1a0d09749472b76957e69f1 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 22:23:38 +0530 Subject: [PATCH 07/39] Keep the tilt clearance through a zoom in A zoom in during a pivoting tilt stops at the range that keeps the camera 150 m above the pivot at its current pitch. A zoom that moves the range by less than half a metre (at the clearance, the 80 m floor or the 5,000 km cap) now answers not zoomed instead of ok, and a tilt reports tiltRangeAdjusted rather than orbitRadiusAdjusted. --- docs/CURRENT-STATE.md | 2 +- src/cameraTilt.test.mjs | 49 ++++++++++++++++++++++++++++++++++++++--- src/cameraVerbs.js | 24 +++++++++++++------- src/voice/gevActions.js | 18 ++++++++++++--- 4 files changed, 78 insertions(+), 15 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5eb025b1f..55a46973f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5381,7 +5381,7 @@ and unreachable upstream (502/504) separately from road geometry. > branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on > `main` + 26 route pins; the same tree on Node 24 reports 2,295. -- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. A zoom during a pivoting tilt scales the range it holds, as for orbit; a zoom in moves the camera along its view line and may take it below that height, as any zoom does, and the tilt then continues in place. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (following a later fly-to in the same reply that cancels it), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, or the user grabs, touches or scrolls the map during it, the motion is not run and says so. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered. Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. +- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. A zoom during a pivoting tilt scales the range it holds, as for orbit, but a zoom in stops at the range that keeps that 150 m clearance; a zoom that can move no further (that floor, the 80 m floor or the 5,000 km cap) is reported as not zoomed. Once the tilt continues in place, a zoom moves the camera like any zoom. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (following a later fly-to in the same reply that cancels it), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, or the user grabs, touches or scrolls the map during it, the motion is not run and says so. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered. Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. - **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing. - **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm 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 rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped. - Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet). diff --git a/src/cameraTilt.test.mjs b/src/cameraTilt.test.mjs index 592098445..58cdbc05f 100644 --- a/src/cameraTilt.test.mjs +++ b/src/cameraTilt.test.mjs @@ -8,6 +8,7 @@ import { interruptCameraMotion, moveCamera, } from './cameraVerbs.js'; +import { createGevActionRunner } from './voice/gevActions.js'; const radians = Cesium.Math.toRadians; const degrees = Cesium.Math.toDegrees; @@ -238,7 +239,11 @@ test('a zoom during a pivoting tilt changes the range it holds', (t) => { ); h.tick(); const before = pivotRange(h); - assert.equal(adjustOrbitRange(0.5), true, 'the tilt must take the zoom'); + assert.deepEqual( + adjustOrbitRange(0.5), + { kind: 'tilt', changed: true }, + 'the tilt must take the zoom', + ); for (let i = 0; i < 5; i += 1) h.tick(); // The next frame's lookAt must not put the old range back. assert.ok( @@ -250,7 +255,7 @@ test('a zoom during a pivoting tilt changes the range it holds', (t) => { test('a zoom with no pivot to hold is left to the camera', (t) => { harness(t, { pivot: false }); moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }); - assert.equal(adjustOrbitRange(0.5), false); + assert.equal(adjustOrbitRange(0.5), null); }); test('a pivot tilt toward the horizon keeps its clearance above the ground', (t) => { @@ -364,11 +369,49 @@ test('a zoom out during a far orbit never pulls the camera in', (t) => { h.tick(); const before = pivotRange(h); assert.ok(before > 5_000_000, `range ${before} m`); - assert.equal(adjustOrbitRange(2.4), true); + assert.deepEqual(adjustOrbitRange(2.4), { kind: 'orbit', changed: false }); h.tick(); assert.ok(pivotRange(h) >= before - 1, `range fell to ${pivotRange(h)} m`); }); +test('a zoom in during a pivot tilt stops at the clearance', (t) => { + // 400 m of range at -40°, then "zoom in a lot" twice: unfloored, that left + // the camera about 107 m up, then at the 80 m zoom floor. + const h = harness(t, { height: 400 }); + moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }); + while (pitchDeg(h.camera) < -40) h.tick(); + assert.deepEqual(adjustOrbitRange(1 / 2.4), { kind: 'tilt', changed: true }); + // At -40° the floor is 150 / sin 40° ≈ 233 m, which the first zoom reached. + assert.deepEqual(adjustOrbitRange(1 / 2.4), { kind: 'tilt', changed: false }); + for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) { + h.tick(); + assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`); + } + assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5); +}); + +test('a voice zoom during a pivot tilt says what it changed', async (t) => { + const h = harness(t, { height: 400 }); + const runner = createGevActionRunner({ + viewer: h.viewer, + styleManager: {}, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }); + h.tick(); + const zoom = { direction: 'in', amount: 'lot' }; + const first = await runner('adjust_camera_zoom', zoom); + assert.equal(first.ok, true); + assert.equal(first.tiltRangeAdjusted, true); + assert.equal('orbitRadiusAdjusted' in first, false); + // 400 m → 167 m → the 150 m clearance; a third zoom in moves nothing, and + // says so. + assert.equal((await runner('adjust_camera_zoom', zoom)).ok, true); + const third = await runner('adjust_camera_zoom', zoom); + assert.equal(third.ok, false); + assert.match(third.error, /tilt is already at its closest range/); +}); + test('from globe range a tilt happens in place', (t) => { // A pivot 2,000 km out would swing the camera thousands of km. const h = harness(t, { height: 2_000_000 }); diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 1c12a9a74..16888d73e 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -1932,19 +1932,27 @@ export function moveCamera(args = {}, runNavigation = null) { * fight the per-frame lookAt (field test: "zoom out did literally nothing"). * A tilt swinging about its pivot holds a range the same way. * @param {number} factor >1 zooms out (radius grows), <1 zooms in. - * @returns {boolean} true when an orbit or pivoting tilt consumed the zoom. + * @returns {{kind: string, changed: boolean}|null} the motion that consumed + * the zoom, and whether its range moved; null when none did. */ export function adjustOrbitRange(factor) { if (!_active || !['orbit', 'tilt'].includes(_active.kind) || !_active.hpr) - return false; - // The limits never move a view the wrong way: a zoom in leaves a closer - // view in place, and a zoom out leaves a farther one. - const range = _active.hpr.range; - _active.hpr.range = Math.max( - Math.min(80, range), + return null; + // A tilt's zoom in stops at the range that keeps its clearance. The limits + // never move a view the wrong way: a zoom in leaves a closer view in place, + // and a zoom out leaves a farther one. + const { kind, hpr } = _active; + const floor = + kind === 'tilt' + ? Math.max(80, TILT_MIN_CLEARANCE_M / Math.abs(Math.sin(hpr.pitch))) + : 80; + const range = hpr.range; + hpr.range = Math.max( + Math.min(floor, range), Math.min(Math.max(5_000_000, range), range * factor), ); - return true; + // Under half a metre counts as not moved, as for a plain zoom. + return { kind, changed: Math.abs(hpr.range - range) >= 0.5 }; } /** diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 66199e277..e851827df 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -411,13 +411,25 @@ export function createGevActionRunner({ const zoomOut = String(args.direction || '').toLowerCase() === 'out'; const amt = String(args.amount || 'medium').toLowerCase(); const factor = { little: 1.25, medium: 1.6, lot: 2.4 }[amt] || 1.6; - if (adjustOrbitRange(zoomOut ? factor : 1 / factor)) { + const held = adjustOrbitRange(zoomOut ? factor : 1 / factor); + if (held) { + const direction = zoomOut ? 'out' : 'in'; + if (!held.changed) { + return { + ok: false, + action: 'adjust_camera_zoom', + direction, + amount: amt, + error: `Not zoomed: the ${held.kind} is already at its ${zoomOut ? 'farthest' : 'closest'} range.`, + }; + } return { ok: true, action: 'adjust_camera_zoom', - direction: zoomOut ? 'out' : 'in', + direction, amount: amt, - orbitRadiusAdjusted: true, + [held.kind === 'tilt' ? 'tiltRangeAdjusted' : 'orbitRadiusAdjusted']: + true, }; } } From 99520b529c4d1a3a4adc7268ca935d66bcdba923 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 20:02:54 +0530 Subject: [PATCH 08/39] fix(camera): keep the globe in view from orbit From the whole-globe view the camera could end up facing empty space with only stars on screen. Cesium's free look (Shift+drag) turns the camera in place, and from 18,000 km the planet's edge is only about 15 degrees from nadir, so a few drags turned it out of frame. The rolled pose was then saved into the share hash, so a reload or shared link opened on stars too. - Allow free look only below 1,000 km. Orbit, tilt and zoom are unchanged. - Measure how much of the frame shows the planet's surface by casting a 32x18 grid of view rays at the WGS84 ellipsoid. While the scope mask is on (the default) only the rays inside its keyhole count, since the mask hides or dims the rest of the frame. - When a camera move ends above 1,000 km with no ray on the surface, turn straight down at the same position and heading. Limb glow and a sliver up to about one grid cell thick can count as out of view. The end of a move comes from Cesium's moveEnd and from a check of its own that samples the pose every 250 ms after moveStart and acts once it has held for half a second: at some of these poses the camera drifts by a rounding error every frame, so Cesium never sees it stop and never raises moveEnd. The zoom-out guard from #1522 keeps wheel and pinch zoom-outs in view; this catches the other ways a move can end looking away, such as a saved or shared view that did, or a drag that Cesium turns into an in-place look below its 7,500 km trackball height (one that starts on the stars, or a tilt drag with the screen centre off the planet). Views with more surface, tracked or locked cameras and the 2D and Columbus modes are left alone. - Voice tilt and in-place turns of a free 3D globe camera above 1,000 km stop when about a tenth of the frame still shows the planet (or about as much as it showed when it started, two grid cells of sampling noise allowed), never below about 1% of the visible frame; a view that already shows less only keeps or gains what it has. A turn that cannot go half a degree further without crossing that limit is refused with a reason instead of reported as done, and a turn it accepts turns at least that half degree. A turn that keeps or gains surface is not refused, even after the window has been resized, except that near the limit, where the planet's edge crosses the sample grid, sampling wobble can occasionally refuse one (most often above about 15,000 km and on phone or ultrawide frames); nothing then turns away from the planet. A turn that starts where the last voice turn left the camera keeps that turn's limit, or a higher one if the planet now fills more, so repeating the request does not take any more; once anything else has moved or turned the camera, a turn is measured from what is on screen. A request made while the camera is tracked is judged by the pose voice's release of the follow camera will leave, before that release, so a refused turn keeps the tracking. The frame measure and the look-step limit live in src/globeInView.js, shared by the viewer and the voice camera verbs. --- docs/CURRENT-STATE.md | 40 +++ scripts/package-boundaries.json | 5 + src/app/application.test.mjs | 431 ++++++++++++++++++++++++++- src/app/scene.js | 11 + src/app/viewer.js | 172 +++++++++++ src/cameraGlobeRotation.test.mjs | 491 +++++++++++++++++++++++++++++++ src/cameraVerbs.js | 179 ++++++++++- src/globeInView.js | 167 +++++++++++ 8 files changed, 1491 insertions(+), 5 deletions(-) create mode 100644 src/globeInView.js diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 87b918a4e..3fde50e05 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -65,6 +65,46 @@ settling. Oblique views below 300 km are left alone. `QA_BASE_URL=http://localhost:4173 node scripts/qa-zoom-out-guard.mjs` exercises the real browser controller at desktop and phone widths with keyless fixtures. +Cesium's free look (Shift+drag, which turns the camera in place) works only +below 1,000 km. Above that height a few drags could turn the whole planet out of +frame and leave only stars, so Shift+drag does nothing there. Orbit, tilt and +zoom are unchanged at every height. How much of the frame shows the planet's +surface is measured by casting a 32×18 grid of view rays at the ellipsoid; while +the scope mask is on (the default) only the rays inside its keyhole count, since +the mask hides or dims the rest. Voice tilt and in-place turns of a free 3D +camera above 1,000 km stop when about a tenth of the frame still shows the +surface (or about as much as it showed when it started), never less than about +1% of the visible frame, and a view that already shows less only keeps or gains +what it has. A turn that cannot go half a degree further without crossing that +limit is refused with a reason instead of reported as done, and a turn it +accepts turns at least that half degree. A turn that keeps or gains surface is +not refused, even after the window has been resized, except that near the limit, +where the planet's edge crosses the sample grid, sampling wobble can +occasionally refuse one (most often above about 15,000 km and on phone or +ultrawide frames); nothing then turns away from the planet. A turn that starts +where the last voice turn left the camera keeps that turn's limit, or a higher +one if the planet now fills more, so repeating the request does not take any +more; once anything else has moved or turned the camera, a turn is measured from +what is on screen. When a camera move ends above 1,000 km with no ray on the +surface, the camera turns straight down at the same position and heading. The +end of a move is taken from Cesium's move-end event and from a check of its own +that samples the pose every 250 ms after a move starts and acts once it has held +for half a second, because at some of these poses the camera drifts by a +rounding error every frame and Cesium never sees it stop. That check keeps +watching until move-end arrives, recovering once after each move that comes to +rest, and gives up five minutes after the camera last moved. Limb glow, and a +surface sliver up to about one grid cell thick (some 50 px on a 900 px tall +frame), can count as out of view. The zoom-out guard above keeps wheel and +pinch zoom-outs in view; this catches the other ways a move can end looking +away, such as a saved or shared view that did, or a drag +that Cesium turns into an in-place look below its 7,500 km trackball height (one +that starts on the stars, or a tilt drag with the screen centre off the planet). +Views with more surface than that, such as the oblique orbital Director shots, +are left as they are, and so are tracked or locked cameras and the 2D and +Columbus modes. A voice turn requested while the camera is tracked is judged +by the pose voice's release of the follow camera will leave, before that +release, so a refused turn keeps the tracking. + Analyst region queries resolve unique Natural Earth names offline without geocoding, so Gold Coast and Ivory Coast name the West African natural regions. Duplicate names require a geocoded containment anchor; when several entries for diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 44c481152..b029af646 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -158,6 +158,7 @@ "modules": [ "src/app/viewer.js", "src/app/atmosphereCompat.js", + "src/globeInView.js", "src/sky/starrySky.js", "src/sky/stars.js", "src/data/bundledJson.js", @@ -2297,6 +2298,7 @@ "src/camera.js", "src/cameraGroundGuard.js", "src/cameraVerbs.js", + "src/globeInView.js", "src/cctvFocusPolicy.js", "src/cctvFocusRequest.js", "src/celestialRing.js", @@ -3604,6 +3606,9 @@ "src/annotations/worldAnnotationRenderer.js", "src/cameraGroundGuard.js", "src/cameraVerbs.js", + "src/globeInView.js", + "src/scopeMaskPolicy.js", + "src/scopeMask.js", "src/services/groundFloor.js", "src/data/routeSteps.js", "src/layers/directions/index.js", diff --git a/src/app/application.test.mjs b/src/app/application.test.mjs index 3e9079b87..401eefa0b 100644 --- a/src/app/application.test.mjs +++ b/src/app/application.test.mjs @@ -2,7 +2,20 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { createApplication } from './application.js'; -import { installTrackpadPinchZoom } from './viewer.js'; +import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js'; +import * as Cesium from 'cesium'; +import { + installFreeLookHeightLimit, + installGlobeInViewRecovery, + installTrackpadPinchZoom, +} from './viewer.js'; +import { + FREE_LOOK_MAX_HEIGHT_METERS, + globeFrameShare, + globeOutOfFrame, + lookAngleWithinFrame, + lookStepLosesGlobe, +} from '../globeInView.js'; const phases = ['Scene', 'Controls', 'Data', 'Tools']; function fixture(overrides = {}) { @@ -343,3 +356,419 @@ test('trackpad pinch falls back to the original event when relay construction fa assert.equal(event.propagationStopped, false); assert.equal(f.relayed.length, 0); }); + +function freeLookFixture({ enableLook = true, height = 0 } = {}) { + const listeners = new Set(); + const controller = { enableLook }; + const camera = { positionCartographic: { height } }; + const viewer = { + camera, + scene: { + screenSpaceCameraController: controller, + preUpdate: { + addEventListener(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }, + }, + }; + const frame = (nextHeight) => { + camera.positionCartographic = { height: nextHeight }; + for (const listener of listeners) listener(); + }; + return { viewer, controller, listeners, frame }; +} + +test('free look follows camera height and is restored on dispose', () => { + const f = freeLookFixture({ height: 500 }); + const dispose = installFreeLookHeightLimit(f.viewer); + assert.equal(f.controller.enableLook, true); + + f.frame(FREE_LOOK_MAX_HEIGHT_METERS + 1); + assert.equal(f.controller.enableLook, false); + f.frame(FREE_LOOK_MAX_HEIGHT_METERS); + assert.equal(f.controller.enableLook, true); + f.frame(Number.NaN); + assert.equal(f.controller.enableLook, true); + + f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20); + dispose(); + dispose(); + assert.equal(f.controller.enableLook, true); + assert.equal(f.listeners.size, 0); +}); + +test('free look stays off when the viewer had it disabled', () => { + const f = freeLookFixture({ enableLook: false, height: 500 }); + installFreeLookHeightLimit(f.viewer); + f.frame(10); + assert.equal(f.controller.enableLook, false); +}); + +// A real Cesium camera on the smallest scene its constructor reads, so the +// frustum test runs on Cesium's own view math rather than a stub. +function realCamera({ width = 1600, height = 900, mode } = {}) { + const scene = { + canvas: { clientWidth: width, clientHeight: height }, + drawingBufferWidth: width, + drawingBufferHeight: height, + mapProjection: new Cesium.GeographicProjection(), + mode: mode ?? Cesium.SceneMode.SCENE3D, + pixelRatio: 1, + ellipsoid: Cesium.Ellipsoid.WGS84, + mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL, + renders: 0, + requestRender() { + this.renders += 1; + }, + }; + const camera = new Cesium.Camera(scene); + camera.frustum.aspectRatio = width / height; + return { camera, scene, viewer: { camera, scene } }; +} + +function aim( + camera, + { lon = -94.69, lat = 18.87, alt, heading = 0, pitch, roll = 0 }, +) { + camera.setView({ + destination: Cesium.Cartesian3.fromDegrees(lon, lat, alt), + orientation: { + heading: Cesium.Math.toRadians(heading), + pitch: Cesium.Math.toRadians(pitch), + roll: Cesium.Math.toRadians(roll), + }, + }); +} + +const reportedStuckView = { + alt: 17_996_811, + heading: 180, + pitch: -42, + roll: 180, +}; +// Oblique poses that still show part of the planet: Director recipe shots +// (Global Flights Radar, Omniscience Pullback) and a voice tilt from orbit. +const partialGlobeViews = [ + { lon: -30, lat: 20, alt: 19_000_000, pitch: -65 }, + { lon: 2, lat: 46, alt: 8_000_000, pitch: -52 }, + { lon: 110, lat: 20, alt: 9_500_000, pitch: -66 }, + { lon: 0, lat: 0, alt: 2_000_000, pitch: -30 }, +]; + +// Stars and a few pixels of limb, no surface: where a frame limit measured +// against a sphere of the planet's largest radius used to stop a voice tilt. +const limbOnlyView = { alt: 18_000_000, pitch: -56.75 }; + +/** + * An oracle independent of the code under test: the share of an 80×45 grid of + * Cesium pick rays across the canvas that hit the ellipsoid surface. + */ +function surfacePixelShare(camera, width, height, { keyhole = false } = {}) { + const radius = (KEYHOLE_OUTER_RADIUS * height) / 2; + let hits = 0; + let counted = 0; + for (let row = 0; row < 45; row += 1) { + for (let column = 0; column < 80; column += 1) { + const x = ((column + 0.5) * width) / 80; + const y = ((row + 0.5) * height) / 45; + if (keyhole && Math.hypot(x - width / 2, y - height / 2) > radius) + continue; + counted += 1; + const ray = camera.getPickRay(new Cesium.Cartesian2(x, y)); + if (Cesium.IntersectionTests.rayEllipsoid(ray, Cesium.Ellipsoid.WGS84)) + hits += 1; + } + } + return hits / counted; +} + +test('the globe is out of frame only when no surface is on screen', () => { + const cases = [ + [reportedStuckView, 1225, 923, true], + [limbOnlyView, 1600, 900, true], + [{ alt: 17_996_811, pitch: -90 }, 1225, 923, false], + ...partialGlobeViews.map((view) => [view, 1600, 900, false]), + ]; + for (const [view, width, height, out] of cases) { + const { camera } = realCamera({ width, height }); + aim(camera, view); + const label = JSON.stringify(view); + assert.equal(globeOutOfFrame(camera), out, label); + assert.equal(surfacePixelShare(camera, width, height) === 0, out, label); + assert.ok( + Math.abs( + globeFrameShare(camera) - surfacePixelShare(camera, width, height), + ) < 0.03, + `${label}: the frame share matches the pick-ray oracle`, + ); + } +}); + +test('a move that ends with the globe out of frame turns back to nadir', () => { + const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 }); + const dispose = installGlobeInViewRecovery(viewer); + aim(camera, reportedStuckView); + const position = Cesium.Cartesian3.clone(camera.positionWC); + const heading = camera.heading; + camera.moveEnd.raiseEvent(); + assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6)); + assert.ok(Cesium.Math.equalsEpsilon(camera.heading, heading, 1e-6)); + assert.ok( + Cesium.Cartesian3.equalsEpsilon(camera.positionWC, position, 0, 1e-3), + ); + assert.equal(globeOutOfFrame(camera), false); + assert.equal(scene.renders, 1); + + // Looking up near the ground, then zoomed out past the limit. + aim(camera, { alt: 3_834_000, pitch: 10 }); + camera.moveEnd.raiseEvent(); + assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6)); + + // Only the limb on a 16:9 screen still counts as lost. + const wide = realCamera({ width: 1600, height: 900 }); + const disposeWide = installGlobeInViewRecovery(wide.viewer); + aim(wide.camera, limbOnlyView); + wide.camera.moveEnd.raiseEvent(); + assert.ok(Cesium.Math.equalsEpsilon(wide.camera.pitch, -Math.PI / 2, 1e-6)); + disposeWide(); + + dispose(); + dispose(); + aim(camera, reportedStuckView); + camera.moveEnd.raiseEvent(); + assert.equal(globeOutOfFrame(camera), true, 'disposed recovery is inert'); +}); + +// True when a moveEnd leaves the camera's view direction as it was. +function moveEndKeepsView(camera) { + const direction = Cesium.Cartesian3.clone(camera.directionWC); + camera.moveEnd.raiseEvent(); + return Cesium.Cartesian3.equalsEpsilon(camera.directionWC, direction, 1e-9); +} + +test('globe recovery leaves partial, low, locked and 2D views alone', () => { + for (const view of partialGlobeViews) { + const { camera, viewer } = realCamera(); + installGlobeInViewRecovery(viewer); + aim(camera, view); + assert.ok(moveEndKeepsView(camera), JSON.stringify(view)); + } + + const low = realCamera(); + installGlobeInViewRecovery(low.viewer); + aim(low.camera, { alt: 900_000, pitch: 85 }); + assert.equal(globeOutOfFrame(low.camera), true); + assert.ok(moveEndKeepsView(low.camera), 'below the limit'); + + // Locked on a point in space, between it and the planet, facing away. + const locked = realCamera(); + installGlobeInViewRecovery(locked.viewer); + locked.camera.lookAtTransform( + Cesium.Matrix4.fromTranslation(new Cesium.Cartesian3(30_000_000, 0, 0)), + new Cesium.Cartesian3(-1_000_000, 0, 0), + ); + assert.equal(globeOutOfFrame(locked.camera), true); + assert.ok(moveEndKeepsView(locked.camera), 'locked camera'); + + // Tracking an entity that has no position yet keeps an identity transform. + const tracked = realCamera(); + tracked.viewer.trackedEntity = { id: 'pending-fix' }; + installGlobeInViewRecovery(tracked.viewer); + aim(tracked.camera, reportedStuckView); + assert.ok(moveEndKeepsView(tracked.camera), 'tracked camera'); + + const flat = realCamera({ mode: Cesium.SceneMode.COLUMBUS_VIEW }); + installGlobeInViewRecovery(flat.viewer); + flat.scene.mode = Cesium.SceneMode.SCENE3D; + aim(flat.camera, reportedStuckView); + flat.scene.mode = Cesium.SceneMode.COLUMBUS_VIEW; + assert.ok(moveEndKeepsView(flat.camera), 'Columbus view'); +}); + +test('the look limit from orbit tolerates grid noise and never reaches zero', () => { + const radians = Cesium.Math.toRadians; + // About 2% of a 16:9 frame on the planet, below the 10% limit. + const sliver = { alt: 18_000_000, pitch: -60 }; + const { camera } = realCamera(); + aim(camera, sliver); + const start = surfacePixelShare(camera, 1600, 900); + assert.ok(start > 0 && start < 0.1, `starts below the limit: ${start}`); + + // Sideways across the sliver: the share wobbles by a cell, the planet stays. + assert.equal(lookStepLosesGlobe(camera, 'lookLeft', radians(0.5)), false); + const startShare = globeFrameShare(camera); + for (let i = 0; i < 20; i += 1) { + const step = lookAngleWithinFrame(camera, 'lookLeft', radians(0.25), { + startShare, + }); + assert.equal(step, radians(0.25), `sideways step ${i + 1}`); + camera.lookLeft(step); + } + assert.ok(surfacePixelShare(camera, 1600, 900) > 0); + + // Toward the planet is always allowed. + aim(camera, sliver); + assert.equal( + lookAngleWithinFrame(camera, 'lookDown', radians(10)), + radians(10), + ); + + // Away from it stops short of losing it. + aim(camera, sliver); + const away = lookAngleWithinFrame(camera, 'lookUp', radians(30)); + assert.ok(away < radians(30)); + camera.lookUp(away); + assert.ok(globeFrameShare(camera) > 0); + assert.ok(surfacePixelShare(camera, 1600, 900) > 0); +}); + +test('the look limit leaves a view with no surface to the move-end recovery', () => { + const { camera } = realCamera({ width: 1225, height: 923 }); + aim(camera, reportedStuckView); + assert.equal( + lookAngleWithinFrame(camera, 'lookUp', Cesium.Math.toRadians(5)), + Cesium.Math.toRadians(5), + ); +}); + +// Surface only in the corners of a 16:9 frame, all of it outside the scope's +// keyhole: the full frame shows some planet, the scope shows none. +const cornerOnlyView = { alt: 18_000_000, pitch: -52, roll: 60 }; + +test('with a scope keyhole, surface hidden in the masked corners counts as none', () => { + const { camera } = realCamera(); + aim(camera, cornerOnlyView); + const keyhole = { keyholeRadius: KEYHOLE_OUTER_RADIUS }; + assert.ok(surfacePixelShare(camera, 1600, 900) > 0); + assert.equal(surfacePixelShare(camera, 1600, 900, { keyhole: true }), 0); + assert.equal(globeOutOfFrame(camera), false); + assert.equal(globeOutOfFrame(camera, keyhole), true); + aim(camera, { alt: 18_000_000, pitch: -90 }); + assert.ok( + Math.abs( + globeFrameShare(camera, keyhole) - + surfacePixelShare(camera, 1600, 900, { keyhole: true }), + ) < 0.03, + 'the keyhole share matches the pick-ray oracle', + ); +}); + +test('the recovery follows the scope: corner-only views are turned back only behind it', () => { + for (const [scopeOn, expectRecovery] of [ + [true, true], + [false, false], + ]) { + const { camera, viewer } = realCamera(); + installGlobeInViewRecovery(viewer, { + keyholeRadius: () => (scopeOn ? KEYHOLE_OUTER_RADIUS : 0), + }); + aim(camera, cornerOnlyView); + camera.moveEnd.raiseEvent(); + assert.equal( + Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6), + expectRecovery, + `scope ${scopeOn ? 'on' : 'off'}`, + ); + } +}); + +function fakeTimers() { + let next = 1; + const pending = new Map(); + return { + setTimeout: (callback) => { + const id = next++; + pending.set(id, callback); + return id; + }, + clearTimeout: (id) => pending.delete(id), + pending: () => pending.size, + /** Run the earliest pending callback, as 250 ms passing would. */ + tick() { + const [id, callback] = pending.entries().next().value ?? []; + if (id === undefined) return false; + pending.delete(id); + callback(); + return true; + }, + }; +} + +test('a stuck view is recovered once it has held still, without waiting for moveEnd', () => { + // At this pose the camera can drift by a rounding error every frame, so + // Cesium never sees it stop and never raises moveEnd for it. + const timers = fakeTimers(); + const { camera, viewer } = realCamera({ width: 1225, height: 923 }); + installGlobeInViewRecovery(viewer, timers); + aim(camera, reportedStuckView); + camera.moveStart.raiseEvent(); + timers.tick(); + assert.equal(globeOutOfFrame(camera), true, 'one quiet sample is not enough'); + // A rounding-error drift between samples still counts as at rest. + camera.lookUp(1e-15); + timers.tick(); + assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6)); + // Under that drift Cesium raises no new moveStart, so a later move that + // ends looking away is caught by the same check. + aim(camera, reportedStuckView); + timers.tick(); + timers.tick(); + assert.equal(globeOutOfFrame(camera), true, 'not yet at rest'); + timers.tick(); + assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6)); + camera.moveEnd.raiseEvent(); + assert.equal(timers.pending(), 0, 'moveEnd ends the check'); +}); + +test('the settle check follows a long move and gives up five minutes after the last one', () => { + // A drag of half a minute that stops at a stuck pose is still recovered. + const long = fakeTimers(); + const dragged = realCamera({ width: 1225, height: 923 }); + installGlobeInViewRecovery(dragged.viewer, long); + aim(dragged.camera, reportedStuckView); + dragged.camera.moveStart.raiseEvent(); + for (let i = 0; i < 120; i += 1) { + dragged.camera.moveLeft(0.01); + long.tick(); + } + assert.equal(globeOutOfFrame(dragged.camera), true, 'still moving'); + while (long.tick()); + assert.ok( + Cesium.Math.equalsEpsilon(dragged.camera.pitch, -Math.PI / 2, 1e-6), + 'recovered once it stopped', + ); + + // A camera that keeps moving is followed, and left alone, for as long as it + // moves; once it rests the check gives up five minutes later. + const timers = fakeTimers(); + const { camera, viewer } = realCamera({ width: 1225, height: 923 }); + installGlobeInViewRecovery(viewer, timers); + aim(camera, reportedStuckView); + camera.moveStart.raiseEvent(); + for (let i = 0; i < 2000; i += 1) { + camera.moveLeft(0.01); + timers.tick(); + } + assert.equal(timers.pending(), 1, 'still following after eight minutes'); + assert.equal(globeOutOfFrame(camera), true, 'a moving camera is left alone'); + let idleSamples = 0; + while (timers.tick()) idleSamples += 1; + assert.ok( + Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6), + 'recovered once it stopped', + ); + assert.ok( + idleSamples >= 1200 && idleSamples <= 1205, + `gave up ${idleSamples} samples after it stopped`, + ); + + const disposed = fakeTimers(); + const other = realCamera({ width: 1225, height: 923 }); + const dispose = installGlobeInViewRecovery(other.viewer, disposed); + aim(other.camera, reportedStuckView); + other.camera.moveStart.raiseEvent(); + dispose(); + assert.equal(disposed.pending(), 0, 'dispose cancels the pending check'); +}); diff --git a/src/app/scene.js b/src/app/scene.js index 1455b8268..9c115638d 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -2,9 +2,13 @@ import { createApplicationOperations } from './operations.js'; import * as Cesium from 'cesium'; import { createApplicationViewer, + installFreeLookHeightLimit, + installGlobeInViewRecovery, installTrackpadPinchZoom, } from '../app/viewer.js'; import { registerDataCredits } from '../data/dataCredits.js'; +import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js'; +import { isScopeMaskEnabled } from '../scopeMask.js'; import { configureCreditKeyboardAccess } from '../creditKeyboard.js'; import { MapStackController } from '../mapStackController.js'; import { loadPhotorealisticTileset } from '../mapStartup.js'; @@ -58,6 +62,13 @@ export async function createApplicationScene({ if (!viewer.isDestroyed()) viewer.destroy(); }); defer(installTrackpadPinchZoom(viewer)); + defer(installFreeLookHeightLimit(viewer)); + defer( + installGlobeInViewRecovery(viewer, { + // The scope mask hides everything outside its keyhole. + keyholeRadius: () => (isScopeMaskEnabled() ? KEYHOLE_OUTER_RADIUS : 0), + }), + ); registerDataCredits(viewer, credits); configureCreditKeyboardAccess(document); defer(onLocaleChange(() => configureCreditKeyboardAccess(document))); diff --git a/src/app/viewer.js b/src/app/viewer.js index c8b841352..ce182a0ab 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -1,6 +1,10 @@ import * as Cesium from 'cesium'; import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js'; import { StarrySky } from '../sky/starrySky.js'; +import { + FREE_LOOK_MAX_HEIGHT_METERS, + globeOutOfFrame, +} from '../globeInView.js'; const PINCH_ZOOM_MULTIPLIER = 8; const MAX_PINCH_PIXEL_DELTA = 120; @@ -103,6 +107,174 @@ export function installTrackpadPinchZoom( }; } +/** + * Allow Cesium's free look only below `maxHeight` and return its disposer. + * Orbit, tilt and zoom stay available at every height. `enableLook` gates the + * Shift+drag binding only; a drag that starts on the stars below Cesium's + * trackball height still turns the camera in place, which + * `installGlobeInViewRecovery` catches. + */ +export function installFreeLookHeightLimit( + viewer, + maxHeight = FREE_LOOK_MAX_HEIGHT_METERS, +) { + const scene = viewer?.scene; + const controller = scene?.screenSpaceCameraController; + const camera = viewer?.camera; + if (!controller || !camera || !scene?.preUpdate) + throw new TypeError('A complete Cesium viewer is required'); + + const originalEnableLook = controller.enableLook; + const sync = () => { + const height = camera.positionCartographic?.height; + controller.enableLook = + originalEnableLook && !(Number.isFinite(height) && height > maxHeight); + }; + sync(); + const removeListener = scene.preUpdate.addEventListener(sync); + + let active = true; + return () => { + if (!active) return; + active = false; + removeListener(); + controller.enableLook = originalEnableLook; + }; +} + +// How often a moving camera is sampled to see whether it has come to rest, how +// far a pose may drift between samples and still count as at rest, and how +// long after the camera last moved the check gives up if Cesium never raises +// moveEnd. +const SETTLE_SAMPLE_MS = 250; +const SETTLE_POSITION_TOLERANCE_M = 1e-3; +const SETTLE_DIRECTION_TOLERANCE = 1e-9; +const SETTLE_GIVE_UP_MS = 5 * 60_000; + +/** + * Turn the camera back to the planet when a move ends above `minHeight` with + * no surface on screen (`globeOutOfFrame`: limb glow and slivers up to about + * one sample cell thick can count as none), and return the disposer. Views with + * more surface, such as an oblique orbital shot, are left as they are. This + * catches a saved or shared view that looked away, and a drag that Cesium turns + * into an in-place look below its trackball height whatever `enableLook` says. + * Heading and position are kept. Tracked cameras, locked ones (non-identity + * transform) and the 2D and Columbus modes are left alone. `keyholeRadius` + * returns the radius of the visible circle when a scope mask hides the rest of + * the frame (see `globeFrameShare`), or 0; it is read at every check. + * + * Cesium's `moveEnd` alone is not enough: Cesium compares the camera with the + * last frame's to the last rounding error, and at some of these poses the + * camera drifts by a rounding error every frame, so Cesium never sees it stop. + * So each `moveStart` also starts a check of its own that samples the pose + * every 250 ms, with a tolerance for that drift, and once the pose has held + * for half a second after moving runs the same recovery. It keeps watching + * until Cesium raises `moveEnd`, since under that drift Cesium raises no new + * `moveStart` for a later move, and gives up five minutes after the camera + * last moved. + * `setTimeout` and `clearTimeout` can be injected for tests. + */ +export function installGlobeInViewRecovery( + viewer, + { + minHeight = FREE_LOOK_MAX_HEIGHT_METERS, + keyholeRadius = () => 0, + setTimeout: schedule = globalThis.setTimeout, + clearTimeout: unschedule = globalThis.clearTimeout, + } = {}, +) { + const scene = viewer?.scene; + const camera = viewer?.camera; + if (!scene || !camera?.moveEnd || !camera?.moveStart) + throw new TypeError('A complete Cesium viewer is required'); + + const recover = () => { + if (scene.mode !== Cesium.SceneMode.SCENE3D || viewer.trackedEntity) return; + if (!Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY)) + return; + const height = camera.positionCartographic?.height; + if ( + !(height > minHeight) || + !globeOutOfFrame(camera, { keyholeRadius: keyholeRadius() }) + ) + return; + camera.setView({ + orientation: { + heading: camera.heading, + pitch: -Cesium.Math.PI_OVER_TWO, + roll: 0, + }, + }); + scene.requestRender?.(); + }; + + const lastPosition = new Cesium.Cartesian3(); + const lastDirection = new Cesium.Cartesian3(); + let settleTimer = null; + let settleElapsed = 0; + let restingSamples = 0; + let movedSinceCheck = false; + const stopSettling = () => { + if (settleTimer !== null) unschedule(settleTimer); + settleTimer = null; + }; + const sample = () => { + settleTimer = null; + settleElapsed += SETTLE_SAMPLE_MS; + const atRest = + Cesium.Cartesian3.equalsEpsilon( + camera.positionWC, + lastPosition, + 0, + SETTLE_POSITION_TOLERANCE_M, + ) && + Cesium.Cartesian3.equalsEpsilon( + camera.directionWC, + lastDirection, + 0, + SETTLE_DIRECTION_TOLERANCE, + ); + restingSamples = atRest ? restingSamples + 1 : 0; + if (!atRest) { + movedSinceCheck = true; + settleElapsed = 0; + } + // Two quiet samples in a row after a move: at rest for half a second, as + // for moveEnd. + if (restingSamples >= 2 && movedSinceCheck) { + movedSinceCheck = false; + recover(); + } + if (settleElapsed >= SETTLE_GIVE_UP_MS) return; + Cesium.Cartesian3.clone(camera.positionWC, lastPosition); + Cesium.Cartesian3.clone(camera.directionWC, lastDirection); + settleTimer = schedule(sample, SETTLE_SAMPLE_MS); + }; + const startSettling = () => { + stopSettling(); + settleElapsed = 0; + restingSamples = 0; + movedSinceCheck = true; + Cesium.Cartesian3.clone(camera.positionWC, lastPosition); + Cesium.Cartesian3.clone(camera.directionWC, lastDirection); + settleTimer = schedule(sample, SETTLE_SAMPLE_MS); + }; + const removeMoveEnd = camera.moveEnd.addEventListener(() => { + stopSettling(); + recover(); + }); + const removeMoveStart = camera.moveStart.addEventListener(startSettling); + + let active = true; + return () => { + if (!active) return; + active = false; + stopSettling(); + removeMoveEnd(); + removeMoveStart(); + }; +} + /** Cesium.Viewer options of the standard globe viewer. */ export function applicationViewerOptions(creditContainer) { return { diff --git a/src/cameraGlobeRotation.test.mjs b/src/cameraGlobeRotation.test.mjs index 814e4de1e..f15ae4978 100644 --- a/src/cameraGlobeRotation.test.mjs +++ b/src/cameraGlobeRotation.test.mjs @@ -13,6 +13,9 @@ import { moveCamera, } from './cameraVerbs.js'; import { getRenderGovernorDiagnostics } from './renderGovernor.js'; +import { MIN_GLOBE_FRAME_SHARE, globeFrameShare } from './globeInView.js'; +import { KEYHOLE_OUTER_RADIUS } from './celestialRing.js'; +import { setScopeMaskEnabled } from './scopeMask.js'; const radians = Cesium.Math.toRadians; const clone = (value) => Cesium.Cartesian3.clone(value); @@ -1347,3 +1350,491 @@ test('close-up ultrawide refusal does not offer an unfit reset or move the camer samePose(h.camera, before); assert.equal(hasRenderHold(), false); }); + +/** + * An oracle independent of the code under test: the share of an 80×45 grid of + * Cesium pick rays across the canvas that hit the ellipsoid surface. With + * `keyhole`, only the pixels inside the scope's visible circle count. + */ +function surfacePixelShare(camera, width, height, { keyhole = false } = {}) { + const radius = (KEYHOLE_OUTER_RADIUS * height) / 2; + let hits = 0; + let counted = 0; + for (let row = 0; row < 45; row += 1) { + for (let column = 0; column < 80; column += 1) { + const x = ((column + 0.5) * width) / 80; + const y = ((row + 0.5) * height) / 45; + if (keyhole && Math.hypot(x - width / 2, y - height / 2) > radius) + continue; + counted += 1; + const ray = camera.getPickRay(new Cesium.Cartesian2(x, y)); + if (Cesium.IntersectionTests.rayEllipsoid(ray, Cesium.Ellipsoid.WGS84)) + hits += 1; + } + } + return hits / counted; +} + +/** Run a test with the scope mask on or off, restoring the default after. */ +function withScope(t, enabled) { + setScopeMaskEnabled(enabled); + t.after(() => setScopeMaskEnabled(true)); +} + +// The frame limit is measured on a coarser grid than the oracle. +const SHARE_SLACK = 0.02; +// Far-out poses, height in metres and pitch in degrees, where a few tilts up +// are accepted before the limit refuses the next one. +const FAR_OUT_POSES = [ + [35_884_000, -86.26], + [35_884_000, -89], + [30_000_000, -86], + [25_000_000, -80], + [40_000_000, -88], +]; + +test('voice tilt from orbit stops with the planet still on screen and then says so', (t) => { + withScope(t, false); // the whole frame is visible + // Slightly off nadir: from straight down a tilt is clamped before it moves. + const { camera, finishOnce } = harness(t, { + pitch: -80, + viewportWidth: 1600, + viewportHeight: 900, + }); + const results = []; + for (let i = 0; i < 4; i += 1) { + const result = moveCamera({ + motion: 'tilt', + direction: 'up', + mode: 'once', + }); + results.push(result); + if (result.ok) finishOnce(); + assert.ok( + surfacePixelShare(camera, 1600, 900) >= + MIN_GLOBE_FRAME_SHARE - SHARE_SLACK, + `after tilt ${i + 1}`, + ); + } + // From 18,000 km a 16:9 frame keeps a tenth of its area on the planet down + // to about -67°: the first tilt stops there, and later ones are refused. + assert.deepEqual( + results.map((result) => result.ok), + [true, false, false, false], + ); + assert.match(results[1].error, /globe out of view/); + assert.ok(camera.pitch > radians(-68.5) && camera.pitch < radians(-66)); +}); + +test('voice turn in place from orbit stops with the planet still on screen', (t) => { + withScope(t, false); // the whole frame is visible + const { camera, tick } = harness(t, { pitch: -75 }); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }).ok, + true, + ); + const heading = camera.heading; + for (let i = 0; i < 400 && getActiveCameraMotion(); i += 1) { + tick(); + assert.ok( + surfacePixelShare(camera, 1280, 800) >= + MIN_GLOBE_FRAME_SHARE - SHARE_SLACK, + `tick ${i + 1}`, + ); + } + assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit'); + assert.notEqual(camera.heading, heading, 'the turn moved before stopping'); +}); + +test('a long voice turn below the limit keeps the share it started with', (t) => { + withScope(t, false); // the whole frame is visible + // About 2% of a 16:9 frame on the planet: the limit is that share, less two + // grid cells of sampling noise, for the whole motion rather than per step. + const { camera, tick } = harness(t, { + pitch: -60, + viewportWidth: 1600, + viewportHeight: 900, + }); + const start = globeFrameShare(camera); + assert.ok(start > 0 && start < MIN_GLOBE_FRAME_SHARE); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }).ok, + true, + ); + for (let i = 0; i < 400 && getActiveCameraMotion(); i += 1) { + tick(); + assert.ok( + globeFrameShare(camera) >= start - 2 / (32 * 18) - 1e-9, + `tick ${i + 1}`, + ); + } + assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit'); + assert.ok(surfacePixelShare(camera, 1600, 900) > 0); +}); + +test('repeated voice turns from one spot cannot each take a little more', (t) => { + withScope(t, false); // the whole frame is visible + // About 2% of a 16:9 frame on the planet. Every turn from this position is + // measured against the same baseline, so asking again stops being allowed. + const { camera, finishOnce, tick } = harness(t, { + pitch: -60, + viewportWidth: 1600, + viewportHeight: 900, + }); + const start = globeFrameShare(camera); + const floor = start - 2 / (32 * 18) - 1e-9; + const results = []; + for (const mode of ['once', 'once', 'continuous', 'once', 'continuous']) { + for (let i = 0; i < 4; i += 1) { + const result = moveCamera({ motion: 'rotate', direction: 'left', mode }); + results.push(result.ok); + if (!result.ok) continue; + if (mode === 'once') finishOnce(); + else for (let j = 0; j < 400 && getActiveCameraMotion(); j += 1) tick(); + assert.ok(globeFrameShare(camera) >= floor, `${mode} turn ${i + 1}`); + } + } + assert.ok(results.includes(false), 'a turn at the limit is refused'); + assert.equal(results.at(-1), false); + assert.ok(surfacePixelShare(camera, 1600, 900) > 0); +}); + +test('a voice turn after something else turned the camera starts over', (t) => { + withScope(t, false); // the whole frame is visible + // The same spot, first framed straight down, then turned to about 2% of the + // frame by something other than voice: a turn left is measured from there. + const { camera, finishOnce } = harness(t, { + pitch: -90, + viewportWidth: 1600, + viewportHeight: 900, + }); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'left', mode: 'once' }).ok, + true, + ); + finishOnce(); + camera.setView({ + orientation: { heading: 0, pitch: radians(-60), roll: 0 }, + }); + const start = globeFrameShare(camera); + assert.ok(start > 0 && start < MIN_GLOBE_FRAME_SHARE); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'left', mode: 'once' }).ok, + true, + ); + finishOnce(); + assert.ok(globeFrameShare(camera) >= start - 2 / (32 * 18) - 1e-9); +}); + +test('nudging the camera between voice turns cannot slide the planet away', (t) => { + withScope(t, false); // the whole frame is visible + // A wheel notch or an orbit drag between requests starts each turn afresh; + // the 1% floor still holds, and below it a turn may only keep what it has. + for (const [nudge, pitch] of [ + [(camera) => camera.moveBackward(2), -60], + [(camera) => camera.rotateRight(radians(0.2)), -60], + [(camera) => camera.moveBackward(2), -58], + ]) { + const { camera, tick } = harness(t, { + pitch, + viewportWidth: 1600, + viewportHeight: 900, + }); + const start = globeFrameShare(camera); + const floor = Math.min(0.01, start) - 1e-9; + for (let i = 0; i < 12; i += 1) { + nudge(camera); + if ( + moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }) + .ok + ) + for (let j = 0; j < 400 && getActiveCameraMotion(); j += 1) tick(); + assert.ok( + globeFrameShare(camera) >= floor, + `start ${start}, turn ${i + 1}`, + ); + } + assert.ok(surfacePixelShare(camera, 1600, 900) > 0); + } +}); + +test('after the frame changes size, a voice turn toward the planet is allowed', (t) => { + withScope(t, false); // the whole frame is visible + // A tilt stops at its limit; then the window widens at the same pose, so the + // frame shows less than the carried limit. Toward the planet must still go. + const { camera, canvas, viewer, finishOnce } = harness(t, { + pitch: -80, + viewportWidth: 1600, + viewportHeight: 900, + }); + assert.equal( + moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok, + true, + ); + finishOnce(); + canvas.clientWidth = canvas.width = viewer.scene.drawingBufferWidth = 3200; + camera.frustum.aspectRatio = 3200 / 900; + const narrowed = globeFrameShare(camera); + assert.ok(narrowed < MIN_GLOBE_FRAME_SHARE, `frame now shows ${narrowed}`); + + const down = moveCamera({ motion: 'tilt', direction: 'down', mode: 'once' }); + assert.equal(down.ok, true, down.error); + finishOnce(); + assert.ok(globeFrameShare(camera) > narrowed); + assert.equal( + moveCamera({ motion: 'tilt', direction: 'down', mode: 'once' }).ok, + true, + ); + finishOnce(); + const up = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }); + if (up.ok) finishOnce(); + assert.ok(surfacePixelShare(camera, 3200, 900) > 0); +}); + +test('with the scope on, a voice tilt stops while the keyhole still shows the planet', (t) => { + withScope(t, true); + const { camera, finishOnce } = harness(t, { + pitch: -80, + viewportWidth: 1600, + viewportHeight: 900, + }); + const results = []; + for (let i = 0; i < 4; i += 1) { + const result = moveCamera({ + motion: 'tilt', + direction: 'up', + mode: 'once', + }); + results.push(result.ok); + if (result.ok) finishOnce(); + assert.ok( + surfacePixelShare(camera, 1600, 900, { keyhole: true }) >= + MIN_GLOBE_FRAME_SHARE - SHARE_SLACK, + `after tilt ${i + 1}`, + ); + } + assert.equal(results[0], true); + assert.equal(results.at(-1), false, 'a tilt past the limit is refused'); +}); + +test('with the scope on, a voice turn cannot leave the planet only in the masked corners', (t) => { + // An oblique orbit view: turning in place swings the planet toward a corner, + // where the full frame still shows it but the scope mask hides it. + for (const [height, pitch] of [ + [5_000_000, -70], + [3_000_000, -75], + [18_000_000, -80], + ]) { + withScope(t, true); + const { camera, tick } = harness(t, { + height, + pitch, + viewportWidth: 1600, + viewportHeight: 900, + }); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }) + .ok, + true, + ); + for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick(); + assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit'); + assert.ok( + surfacePixelShare(camera, 1600, 900, { keyhole: true }) >= + MIN_GLOBE_FRAME_SHARE - SHARE_SLACK, + `${height / 1000} km at ${pitch}°`, + ); + } +}); + +test('far out, a voice tilt is either refused or really moves, at display frame rates', (t) => { + // A small planet disc: its sampled share wobbles as it crosses the grid. + // Whatever the limit decides, "done" must mean the camera turned, and some + // of these tilts must be accepted for that to be checked at all. + let accepted = 0; + for (const [height, pitch] of FAR_OUT_POSES) { + withScope(t, true); + const { camera, tick } = harness(t, { + latitude: 45.37, + longitudeDeg: -33.33, + height, + heading: 89.9, + pitch, + viewportWidth: 1280, + viewportHeight: 1024, + }); + for (let i = 0; i < 4; i += 1) { + const before = camera.pitch; + const result = moveCamera({ + motion: 'tilt', + direction: 'up', + mode: 'once', + }); + if (!result.ok) { + assert.match(result.error, /zoom in first/); + continue; + } + accepted += 1; + for (let j = 0; j < 600 && getActiveCameraMotion(); j += 1) tick(1 / 60); + assert.ok( + camera.pitch - before > radians(0.5) - 1e-9, + `${height / 1000} km at ${pitch}°: tilt ${i + 1} reported done but moved ${Cesium.Math.toDegrees(camera.pitch - before)}°`, + ); + } + assert.ok(surfacePixelShare(camera, 1280, 1024, { keyhole: true }) > 0); + } + assert.ok(accepted > 0, 'no far-out tilt was accepted'); +}); + +test('a voice turn reported as done turns at least the half degree its check proved', (t) => { + // A grazing sliver: the first frame's small step lands in a dip of the + // sampled share, which used to stop the turn before it moved at all. + for (const scope of [true, false]) { + withScope(t, scope); + const { camera, tick } = harness(t, { + latitude: 20, + longitudeDeg: 10, + height: 18_000_000, + pitch: -58, + heading: 37, + viewportWidth: 1600, + viewportHeight: 900, + }); + const before = Cesium.Cartesian3.clone(camera.directionWC); + const result = moveCamera({ + motion: 'rotate', + direction: 'left', + mode: 'once', + }); + if (!result.ok) { + assert.match(result.error, /globe out of view/); + continue; + } + for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick(1 / 60); + const turned = Cesium.Cartesian3.angleBetween(before, camera.directionWC); + assert.ok( + turned >= radians(0.5) - 1e-9, + `scope ${scope ? 'on' : 'off'}: turned ${Cesium.Math.toDegrees(turned)}°`, + ); + assert.ok(surfacePixelShare(camera, 1600, 900, { keyhole: scope }) > 0); + } +}); + +/** Track an entity below `camera`, locking it into that entity's frame. */ +function trackFrom(viewer, camera) { + viewer.trackedEntity = { id: 'high-satellite' }; + const below = Cesium.Cartesian3.fromDegrees(10, 20, 400_000); + camera.lookAtTransform(Cesium.Transforms.eastNorthUpToFixedFrame(below)); +} + +test('a voice turn that releases a tracked camera is checked as released', (t) => { + // Voice releases the follow camera inside the navigation that runs the + // motion, so the request is judged by the pose the release will leave. + let accepted = 0; + for (const [scope, direction, motion] of [ + [true, 'up', 'tilt'], + [false, 'up', 'tilt'], + [false, 'left', 'rotate'], + ]) { + withScope(t, scope); + const { camera, viewer, tick } = harness(t, { + latitude: 20, + longitudeDeg: 10, + height: 18_000_000, + pitch: -58, + heading: 37, + viewportWidth: 1600, + viewportHeight: 900, + }); + trackFrom(viewer, camera); + const releaseThenRun = (start) => { + viewer.trackedEntity = undefined; + camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + return start(); + }; + const before = Cesium.Cartesian3.clone(camera.directionWC); + const result = moveCamera( + { motion, direction, mode: 'once' }, + releaseThenRun, + ); + const label = `${motion} ${direction}, scope ${scope ? 'on' : 'off'}`; + if (!result.ok) { + assert.match(result.error, /globe out of view/, label); + continue; + } + accepted += 1; + for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick(1 / 60); + const turned = Cesium.Cartesian3.angleBetween(before, camera.directionWC); + assert.ok(turned >= radians(0.5) - 1e-9, `${label}: turned ${turned}`); + assert.ok( + surfacePixelShare(camera, 1600, 900, { keyhole: scope }) > 0, + `${label}: the planet stays on screen`, + ); + } + assert.ok(accepted > 0, 'no released turn was accepted'); +}); + +test('a refused voice turn leaves a tracked camera tracking', (t) => { + for (const scope of [true, false]) { + withScope(t, scope); + const { camera, viewer, tick } = harness(t, { + pitch: -80, + viewportWidth: 1600, + viewportHeight: 900, + }); + // Tilt up to the limit untracked, then track from there. + for (let i = 0; i < 40; i += 1) { + if (!moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok) + break; + for (let j = 0; j < 600 && getActiveCameraMotion(); j += 1) tick(1 / 60); + } + trackFrom(viewer, camera); + const tracked = viewer.trackedEntity; + const transform = Cesium.Matrix4.clone(camera.transform); + let released = false; + const result = moveCamera( + { motion: 'tilt', direction: 'up', mode: 'once' }, + (start) => { + released = true; + viewer.trackedEntity = undefined; + camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + return start(); + }, + ); + const label = `scope ${scope ? 'on' : 'off'}`; + assert.equal(result.ok, false, label); + assert.match(result.error, /globe out of view/, label); + assert.equal(released, false, label); + assert.equal(viewer.trackedEntity, tracked, label); + assert.ok(Cesium.Matrix4.equals(camera.transform, transform), label); + } +}); + +test('the voice frame limit leaves Columbus view alone', (t) => { + // At its limit in 3D a further tilt up is refused; the same tilt in Columbus + // view is not limited by the frame. + withScope(t, false); + const { viewer, finishOnce } = harness(t, { + pitch: -80, + viewportWidth: 1600, + viewportHeight: 900, + }); + assert.equal( + moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok, + true, + ); + finishOnce(); + const refused = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }); + assert.match(refused.error || '', /globe out of view/); + viewer.scene.mode = Cesium.SceneMode.COLUMBUS_VIEW; + const result = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }); + assert.doesNotMatch(result.error || '', /globe out of view/); +}); + +test('voice tilt below the free-look height is not limited by the frame', (t) => { + const { camera, finishOnce } = harness(t, { height: 500_000, pitch: -60 }); + const result = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }); + assert.equal(result.ok, true); + finishOnce(); + near(camera.pitch, radians(-45), 1e-6, 'full 15° tilt'); +}); diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index e037472cb..964b5c1a7 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -20,11 +20,18 @@ import * as Cesium from 'cesium'; import { GLOBE_VIEW } from './locations.js'; +import { + globeFrameShare, + lookAngleWithinFrame, + lookStepLosesGlobe, +} from './globeInView.js'; import { projectEarthDiscToViewport, earthDiscScreenRadius, getKeyholeGeometry, + KEYHOLE_OUTER_RADIUS, } from './celestialRing.js'; +import { isScopeMaskEnabled } from './scopeMask.js'; import { holdContinuousRender, releaseContinuousRender, @@ -1008,6 +1015,137 @@ export function interruptCameraMotionIfActive( return interruptCameraMotion(reason); } +/** + * A free globe camera: 3D, not tracking, not locked to a frame. The frame + * limit for in-place turns applies to it alone, as the move-end recovery does. + */ +function isFreeGlobeCamera(cam) { + return ( + _viewer.scene.mode === Cesium.SceneMode.SCENE3D && + !_viewer.trackedEntity && + Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY) + ); +} + +// The frame share that in-place voice turns measure their limit against, with +// the pose the last voice turn left the camera in. A turn that starts where +// the last one left off keeps that baseline (raised if the planet now fills +// more of the frame), so repeating a request cannot take a little more each +// time. Once anything else has moved or turned the camera, a turn starts over +// from what is on screen. +let _lookBaseline = null; + +// The pre-check's proof that a half-degree turn from this pose keeps the planet +// in view: which way, from which direction. A motion that starts from that +// pose turns at least that far, so a request reported as done really moves +// even when a smaller first step lands in a dip of the sampled share. +const PROVEN_TURN_RAD = Cesium.Math.toRadians(0.5); +let _provenLook = null; + +/** The part of the frame a viewer can see: the scope keyhole when it is on. */ +function visibleFrame() { + return { keyholeRadius: isScopeMaskEnabled() ? KEYHOLE_OUTER_RADIUS : 0 }; +} + +/** The frame share an in-place turn from `cam`'s pose is limited against. */ +function lookBaselineShare(cam) { + const share = globeFrameShare(cam, visibleFrame()); + const continues = + _lookBaseline && + Cesium.Cartesian3.equalsEpsilon( + _lookBaseline.position, + cam.positionWC, + 0, + 1, + ) && + Cesium.Cartesian3.equalsEpsilon( + _lookBaseline.direction, + cam.directionWC, + 0, + 1e-9, + ); + _lookBaseline = { + position: Cesium.Cartesian3.clone(cam.positionWC), + direction: Cesium.Cartesian3.clone(cam.directionWC), + share: continues ? Math.max(_lookBaseline.share, share) : share, + }; + return _lookBaseline.share; +} + +/** + * One in-place look step of motion `m`. From orbit a free globe camera turns + * only as far as keeps the planet in frame, measured against the baseline the + * turn started from, then the motion ends there. + */ +function lookWithinFrame(cam, m, look, angle) { + let allowed = angle; + if (isFreeGlobeCamera(cam)) { + if (m.globeShareAtStart === undefined) { + m.globeShareAtStart = lookBaselineShare(cam); + m.provenTurnRad = lookProven(cam, look) ? PROVEN_TURN_RAD : 0; + m.turnedRad = 0; + } + _provenLook = null; + allowed = lookAngleWithinFrame(cam, look, angle, { + ...visibleFrame(), + startShare: m.globeShareAtStart, + }); + const owed = Math.min(angle, m.provenTurnRad - m.turnedRad); + if (allowed < owed) allowed = owed; + m.turnedRad += allowed; + } + if (allowed > 0) cam[look](allowed); + // The pose this turn leaves, for a turn that continues from it. + if (_lookBaseline) + Cesium.Cartesian3.clone(cam.directionWC, _lookBaseline.direction); + if (allowed < angle) interruptCameraMotion('globe-out-of-frame'); +} + +/** True when the pre-check proved a half-degree `look` from `cam`'s pose. */ +function lookProven(cam, look) { + return ( + _provenLook?.look === look && + Cesium.Cartesian3.equalsEpsilon( + _provenLook.direction, + cam.directionWC, + 0, + 1e-9, + ) + ); +} + +/** + * The check a voice in-place turn (`look`: 'lookUp', 'lookDown', 'lookLeft' + * or 'lookRight') must pass from orbit: refused when even half a degree would + * cross the frame limit, otherwise proven for that half degree from this pose. + * With `released`, the turn runs through a navigation that first releases a + * tracked or locked camera and keeps its world pose. The frame measure and the + * turn are the same in any reference frame, so such a camera is judged as it + * is, before the release, and a refusal leaves tracking as it was. Otherwise a + * camera that is not a free globe camera is not checked. Returns the refusal, + * or null. + */ +function checkLookFromOrbit(look, { released = false } = {}) { + _provenLook = null; + if (_viewer.scene.mode !== Cesium.SceneMode.SCENE3D) return null; + const cam = _viewer.camera; + if (!released && !isFreeGlobeCamera(cam)) return null; + if ( + lookStepLosesGlobe(cam, look, PROVEN_TURN_RAD, { + ...visibleFrame(), + startShare: lookBaselineShare(cam), + }) + ) + return { + ok: false, + action: 'move_camera', + error: + 'From this height that would turn the globe out of view — zoom in first, or turn the other way.', + }; + _provenLook = { look, direction: Cesium.Cartesian3.clone(cam.directionWC) }; + return null; +} + function clearLookAt() { try { _viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); @@ -1226,10 +1364,15 @@ function onTick() { 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); + lookWithinFrame(cam, m, up ? 'lookUp' : 'lookDown', stepRad); + } else { + lookWithinFrame( + cam, + m, + m.direction === 'left' ? 'lookLeft' : '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. @@ -1413,6 +1556,7 @@ export function initCameraVerbs( _isCockpitActive = typeof isCockpitActive === 'function' ? isCockpitActive : null; _viewer = viewer; + _lookBaseline = null; if (_tickRemover) _tickRemover(); _tickRemover = viewer.clock.onTick.addEventListener(onTick); if (_trackingRemover) _trackingRemover(); @@ -1524,6 +1668,27 @@ export function moveCamera(args = {}, runNavigation = null) { }; } } + const look = + motion === 'tilt' || motion === 'rotate' + ? { + up: 'lookUp', + down: 'lookDown', + left: 'lookLeft', + right: 'lookRight', + }[direction] + : null; + // Honest limits from orbit too: a stopped turn rests within 0.05° of its + // limit, so one that cannot go half a degree further is refused rather than + // run and reported as done, and one that can is then turned at least that + // far. Near the limit the sampled share wobbles as the planet's edge crosses + // the grid, so a turn that would keep the planet can occasionally be refused + // too. + // A camera that is tracked or locked now is judged as the navigation that + // runs the turn will release it, so a refusal keeps the tracking. + const lookRefusal = + look && + checkLookFromOrbit(look, { released: typeof runNavigation === 'function' }); + if (lookRefusal) return lookRefusal; if ( (motion === 'rotate_globe' || (motion === 'orbit' && typeof runNavigation !== 'function')) && @@ -1609,6 +1774,12 @@ export function moveCamera(args = {}, runNavigation = null) { } const start = () => { interruptCameraMotion('replaced'); + // Checked again only if the release left the camera somewhere other than + // where the check above expected it. + if (look && !lookProven(_viewer.camera, look)) { + const refusal = checkLookFromOrbit(look); + if (refusal) return refusal; + } const state = { kind: motion, direction: direction || 'right', diff --git a/src/globeInView.js b/src/globeInView.js new file mode 100644 index 000000000..1ccbcda23 --- /dev/null +++ b/src/globeInView.js @@ -0,0 +1,167 @@ +import * as Cesium from 'cesium'; + +// Cesium's free look (Shift+drag) turns the camera in place. Near the ground +// that surveys a city; from orbit a few drags turn the whole planet out of +// frame and leave only stars. +export const FREE_LOOK_MAX_HEIGHT_METERS = 1_000_000; + +// A voice turn from orbit stops when about this share of the frame still shows +// the planet's surface. +export const MIN_GLOBE_FRAME_SHARE = 0.1; + +const GRID_COLUMNS = 32; +const GRID_ROWS = 18; +// A turn across a sliver of planet moves its edge through sample points, so +// the share wobbles by a cell or two without the planet leaving. +const GRID_NOISE_CELLS = 2; +// Whatever the baseline, a voice turn never takes the planet below this share +// of the frame; a view that already shows less may only keep what it has. +const LOOK_ABSOLUTE_FLOOR_SHARE = 0.01; +const sampleRay = new Cesium.Ray(); +const sampleOffset = new Cesium.Cartesian3(); + +/** + * Cast a 32×18 grid of view rays, one through the centre of each cell, at the + * WGS84 ellipsoid. With `keyholeRadius` (a radius around the frame centre, in + * units of half the frame height) only the cells inside that circle count: the + * scope mask hides the rest. Returns the surface hits and the cells counted, or + * null for a frustum other than a perspective one. + */ +function sampleFrame(camera, keyholeRadius) { + const frustum = camera.frustum; + if (!(frustum instanceof Cesium.PerspectiveFrustum)) return null; + const aspect = frustum.aspectRatio; + const tanY = Math.tan(frustum.fovy / 2); + const tanX = tanY * aspect; + const keyholeSquared = keyholeRadius > 0 ? keyholeRadius * keyholeRadius : 0; + Cesium.Cartesian3.clone(camera.positionWC, sampleRay.origin); + let hits = 0; + let cells = 0; + for (let row = 0; row < GRID_ROWS; row += 1) { + const rowY = (2 * (row + 0.5)) / GRID_ROWS - 1; + for (let column = 0; column < GRID_COLUMNS; column += 1) { + const columnX = (2 * (column + 0.5)) / GRID_COLUMNS - 1; + if ( + keyholeSquared && + (columnX * aspect) ** 2 + rowY * rowY > keyholeSquared + ) + continue; + cells += 1; + Cesium.Cartesian3.multiplyByScalar( + camera.rightWC, + columnX * tanX, + sampleOffset, + ); + Cesium.Cartesian3.add( + camera.directionWC, + sampleOffset, + sampleRay.direction, + ); + Cesium.Cartesian3.multiplyByScalar( + camera.upWC, + rowY * tanY, + sampleOffset, + ); + Cesium.Cartesian3.add( + sampleRay.direction, + sampleOffset, + sampleRay.direction, + ); + Cesium.Cartesian3.normalize(sampleRay.direction, sampleRay.direction); + if ( + Cesium.IntersectionTests.rayEllipsoid(sampleRay, Cesium.Ellipsoid.WGS84) + ) + hits += 1; + } + } + return { hits, cells }; +} + +/** + * Share of the frame, from 0 to 1, that shows the planet's surface: the + * fraction of a 32×18 grid of view rays, one through the centre of each cell, + * that hit the WGS84 ellipsoid. With `keyholeRadius` (see `sampleFrame`) it is + * the share of the visible circle instead. A frustum other than a perspective + * one counts as fully covered. + */ +export function globeFrameShare(camera, { keyholeRadius = 0 } = {}) { + const sample = sampleFrame(camera, keyholeRadius); + if (!sample || sample.cells === 0) return 1; + return sample.hits / sample.cells; +} + +/** True when none of the planet's surface is in `camera`'s visible frame. */ +export function globeOutOfFrame(camera, options) { + return globeFrameShare(camera, options) === 0; +} + +const lookProbes = new WeakMap(); +const LOOK_EDGE_TOLERANCE = Cesium.Math.toRadians(0.05); + +/** + * How much of one in-place look step (`lookUp`, `lookDown`, `lookLeft` or + * `lookRight` by a non-negative `angle` in radians) keeps the planet in frame + * from orbit. Above the free-look height the step may not leave less than + * `MIN_GLOBE_FRAME_SHARE` of the visible frame on the planet, or less than + * `startShare` when that is lower, give or take two grid cells of sampling + * noise. However the baseline was taken, it never leaves less than 1% of the + * frame, and a view that already shows less may only keep or gain what it has. + * `startShare` is the baseline the turn is measured against (the current share + * by default): callers pass one value to every step of a motion, and to every + * turn that continues from where the last one left off, so the limit cannot + * slide down a step or a command at a time. `keyholeRadius` limits the measure + * to the scope's visible circle, as in `globeFrameShare`. + * With nothing on screen the limit is nothing either, so the step is left to + * the move-end recovery. Returns `angle` when the whole step fits, otherwise a + * smaller angle that fits, found by bisection to within 0.05°, so a stopped + * motion rests at the limit. Steps are tried on a copy; `camera` is not moved. + */ +export function lookAngleWithinFrame( + camera, + look, + angle, + { startShare, keyholeRadius = 0 } = {}, +) { + if ( + !(angle > 0) || + !(camera.positionCartographic?.height > FREE_LOOK_MAX_HEIGHT_METERS) + ) + return angle; + const sample = sampleFrame(camera, keyholeRadius); + if (!sample || sample.cells === 0) return angle; + const share = sample.hits / sample.cells; + const baseline = startShare ?? share; + // Capped at what is on screen now, so a step that keeps or gains surface is + // never refused, even when the frame has shrunk since the baseline was taken + // (a resized window or a turned phone). + const floor = Math.min( + share, + Math.max( + Math.min(MIN_GLOBE_FRAME_SHARE, baseline) - + GRID_NOISE_CELLS / sample.cells, + Math.min(LOOK_ABSOLUTE_FLOOR_SHARE, baseline), + ), + ); + const fits = (step) => { + const probe = Cesium.Camera.clone(camera, lookProbes.get(camera)); + lookProbes.set(camera, probe); + probe[look](step); + // Shares are whole grid cells; the epsilon keeps rounding in `floor` from + // costing a cell. + return globeFrameShare(probe, { keyholeRadius }) >= floor - 1e-9; + }; + if (fits(angle)) return angle; + let inside = 0; + let outside = angle; + while (outside - inside > LOOK_EDGE_TOLERANCE) { + const middle = (inside + outside) / 2; + if (fits(middle)) inside = middle; + else outside = middle; + } + return inside; +} + +/** True when an in-place look step of `angle` would lose part of its turn. */ +export function lookStepLosesGlobe(camera, look, angle, options) { + return lookAngleWithinFrame(camera, look, angle, options) < angle; +} From d36b28f352389c7f9caff1c46801cd698f93b209 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Wed, 4 Nov 2026 01:19:22 +0530 Subject: [PATCH 09/39] docs(changelog): add the globe-in-view fragment --- changelog.d/1532-globe-in-view.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 changelog.d/1532-globe-in-view.md diff --git a/changelog.d/1532-globe-in-view.md b/changelog.d/1532-globe-in-view.md new file mode 100644 index 000000000..f697959b2 --- /dev/null +++ b/changelog.d/1532-globe-in-view.md @@ -0,0 +1,8 @@ +- Keep the globe in view from orbit. Cesium's free look (Shift+drag) turned + the camera in place, so a few drags from the whole-globe view left only + stars, and the pose stayed in the share link. Free look now works only below + 1,000 km; a camera move that ends above it with no planet surface in view + (inside the scope keyhole when the scope is on) turns straight down; and + voice tilt and turns from orbit stop while the planet is still on screen and + say so instead of reporting a turn they could not make (Manjunath, + @manjunath22466, #1532). From 44860df90e78d854c0523f9563d32f56d0c86e03 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Wed, 4 Nov 2026 13:40:43 +0530 Subject: [PATCH 10/39] Follow a replacement voice flight whatever its predecessor reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A motion queued behind a voice fly-to now follows a later fly-to in the same reply whether the earlier one arrived, was cancelled or failed. A geocode that loses navigation reports failed, so the motion used to be dropped; and a flight left flying while the next place is looked up could land and run the motion there, taking navigation and cancelling the newer flight. The deadline, abort and stale-reply checks still end the wait. pitchDeg -90 or -89 from a top-down view is now unchanged rather than a 1° tilt up, and the tilt tests' pitch loops are bounded. --- src/cameraTilt.test.mjs | 23 +++++++++++-- src/cameraVerbs.js | 8 ++++- src/voice/gevActions.js | 14 ++++++-- src/voice/gevActions.test.mjs | 65 +++++++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 6 deletions(-) diff --git a/src/cameraTilt.test.mjs b/src/cameraTilt.test.mjs index 7d2664c9f..93bbf246e 100644 --- a/src/cameraTilt.test.mjs +++ b/src/cameraTilt.test.mjs @@ -78,6 +78,11 @@ function harness( } const pitchDeg = (camera) => degrees(camera.pitch); +/** Tick until done() holds; a regression fails here instead of hanging. */ +const tickUntil = (h, done, frames = 600) => { + for (let i = 0; i < frames && !done(); i += 1) h.tick(); + assert.ok(done(), `not reached in ${frames} frames`); +}; const headingDeg = (camera) => degrees(Cesium.Math.negativePiToPi(camera.heading)); @@ -171,6 +176,20 @@ test('a requested angle the camera already holds is a no-op', (t) => { assert.equal(getActiveCameraMotion(), null); }); +for (const pitch of [-90, -89]) { + test(`a top-down view asked for ${pitch}° stays put`, (t) => { + // The tool description calls -89 top-down; the clamp must not tilt a + // -90° view up 1° and report it as a move up. + const h = harness(t); + const before = pitchDeg(h.camera); + const result = moveCamera({ motion: 'tilt', pitchDeg: pitch }); + assert.equal(result.ok, true); + assert.equal(result.unchanged, true); + assert.equal(getActiveCameraMotion(), null); + assert.equal(pitchDeg(h.camera), before); + }); +} + test('tilt down from top-down still refuses honestly', (t) => { harness(t); const result = moveCamera({ motion: 'tilt', direction: 'down' }); @@ -335,7 +354,7 @@ test('the pitchDeg and direction check uses the pitch the swing starts from', (t test('after a zoom in, a pivoting tilt never lowers the camera further', (t) => { const h = harness(t, { height: 1000 }); moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }); - while (pitchDeg(h.camera) < -30) h.tick(); + tickUntil(h, () => pitchDeg(h.camera) >= -30); adjustOrbitRange(1 / 2.4); adjustOrbitRange(1 / 2.4); h.tick(); @@ -390,7 +409,7 @@ test('a zoom in during a pivot tilt stops at the clearance', (t) => { // the camera about 107 m up, then at the 80 m zoom floor. const h = harness(t, { height: 400 }); moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }); - while (pitchDeg(h.camera) < -40) h.tick(); + tickUntil(h, () => pitchDeg(h.camera) >= -40); assert.deepEqual(adjustOrbitRange(1 / 2.4), { kind: 'tilt', changed: true }); // At -40° the floor is 150 / sin 40° ≈ 233 m, which the first zoom reached. assert.deepEqual(adjustOrbitRange(1 / 2.4), { kind: 'tilt', changed: false }); diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 16888d73e..380a737f0 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -1671,7 +1671,13 @@ export function moveCamera(args = {}, runNavigation = null) { // Judge from the pitch the motion will start at: the pivot's, which at // regional range is a degree or two shallower than the camera's. const { from } = tiltStartFrame(_viewer.camera, { releasesTracking }); - if (Math.abs(tiltToPitch - from) < Cesium.Math.toRadians(0.5)) { + // -89 or -90 means top-down, which a -90° view already is: the clamp + // must not tilt it up 1°. + const atTopDown = tiltToPitch <= PITCH_MIN && from <= PITCH_MIN; + if ( + atTopDown || + Math.abs(tiltToPitch - from) < Cesium.Math.toRadians(0.5) + ) { return { ok: true, action: 'move_camera', diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index f7573075e..c9bc811a4 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -1302,9 +1302,17 @@ export function createGevActionRunner({ runOptions.signal, deadline, ); - // A later fly-to in the same reply cancels this one: follow it to - // where the camera lands, within the same deadline. - while (status === 'cancelled' && voiceFlight !== flight && current()) { + // A later fly-to in the same reply replaces this one: follow it to + // where the camera lands, within the same deadline. Its status does + // not matter. A geocode that loses navigation reports failed, and a + // flight left flying while the next place is looked up can still + // land; running there would take navigation and cancel the newer one. + while ( + voiceFlight !== flight && + status !== 'aborted' && + status !== 'timeout' && + current() + ) { flight = voiceFlight; status = await waitForVoiceFlight( flight, diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index a464c94bd..14d008933 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -570,6 +570,71 @@ test('a motion follows a later fly-to in the same reply that cancels its flight' assert.equal(result.motion, 'orbit'); }); +test('a motion follows a later fly-to whose start cancels its geocode', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager } = tiltAfterFlightHarness(); + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + const flyA = runner('fly_to_location', { query: 'Place A' }); + const moved = runner('move_camera', { motion: 'orbit' }); + const movedDone = settledYet(moved); + const flyB = runner('fly_to_location', { query: 'Place B' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + // B takes navigation authority, so A's lookup comes back cancelled. + searches[0].resolve({ cancelled: true }); + assert.equal((await flyA).cancelled, true); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(movedDone(), false, 'the orbit must follow B, not drop'); + searches[1].resolve({ label: 'Place B', navigationMode: 'city-overview' }); + assert.equal((await flyB).ok, true); + searches[1].options.onComplete(); + const result = await moved; + assert.equal(result.ok, true); + assert.equal(result.motion, 'orbit'); +}); + +test('a motion does not run at a flight that lands while the next place is looked up', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, order, flights } = tiltAfterFlightHarness(); + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + const movedDone = settledYet(moved); + const flyB = runner('fly_to_location', { query: 'Place B' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + // Deferred navigation leaves A flying while B is looked up, and A lands. + const before = order.length; + flights[0].complete(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(movedDone(), false, 'the tilt must wait for B'); + assert.equal( + order.slice(before).includes('cancel'), + false, + 'the tilt must not take navigation and cancel B', + ); + searches[0].resolve({ label: 'Place B', navigationMode: 'city-overview' }); + assert.equal((await flyB).ok, true); + searches[0].options.onComplete(); + const result = await moved; + assert.equal(result.ok, true); + assert.equal(result.motion, 'tilt'); +}); + test('a stale motion does not follow a newer reply\'s fly-to', async () => { const { viewer, styleManager, flights } = tiltAfterFlightHarness(); const runner = createGevActionRunner({ From b683b8a64b7d71e33f468469e4b7494f60f6c6ff Mon Sep 17 00:00:00 2001 From: Manjunath Date: Wed, 4 Nov 2026 14:29:37 +0530 Subject: [PATCH 11/39] Look past voice lookups that found nothing A motion queued behind a voice fly-to acts on the last flight that moved the camera. A later lookup that was not found, threw, or was cancelled once a newer voice fly-to had begun moved nothing, so the motion looks past it rather than refusing or running early and cancelling the flight still in the air. It never acts at a flight older than the one it started waiting on. A lookup cancelled while still the latest voice flight (a search, a share link, Cockpit) still refuses. The top-down no-op now reports the camera's real pitch. --- src/cameraTilt.test.mjs | 1 + src/cameraVerbs.js | 4 +- src/voice/gevActions.js | 108 ++++++++++++++++------- src/voice/gevActions.test.mjs | 156 ++++++++++++++++++++++++++++++++++ 4 files changed, 237 insertions(+), 32 deletions(-) diff --git a/src/cameraTilt.test.mjs b/src/cameraTilt.test.mjs index 93bbf246e..d372d0746 100644 --- a/src/cameraTilt.test.mjs +++ b/src/cameraTilt.test.mjs @@ -185,6 +185,7 @@ for (const pitch of [-90, -89]) { const result = moveCamera({ motion: 'tilt', pitchDeg: pitch }); assert.equal(result.ok, true); assert.equal(result.unchanged, true); + assert.equal(result.pitchDeg, Math.round(before)); assert.equal(getActiveCameraMotion(), null); assert.equal(pitchDeg(h.camera), before); }); diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 380a737f0..c1e5c5d1a 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -1682,7 +1682,9 @@ export function moveCamera(args = {}, runNavigation = null) { ok: true, action: 'move_camera', motion, - pitchDeg: Math.round(Cesium.Math.toDegrees(tiltToPitch)), + pitchDeg: Math.round( + Cesium.Math.toDegrees(atTopDown ? from : tiltToPitch), + ), unchanged: true, }; } diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index c9bc811a4..83cdea367 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -288,9 +288,10 @@ export function createGevActionRunner({ ? { ...SPEECH_BUILDERS, ...speechBuilders } : SPEECH_BUILDERS; const _layerEnabledAt = new Map(); - // The latest voice flight. A tilt in the same reply as "fly to X" waits for - // it to land: run at once, its navigation cancelled the flight, or the - // flight (still geocoding) then replaced the tilt. + // The latest voice flight. A tilt, orbit, pan or rotate in the same reply + // as "fly to X" waits for it to land: run at once, its navigation + // cancelled the flight, or the flight (still geocoding) then replaced the + // motion. The wait follows later fly-tos (voiceFlightTarget). let voiceFlight = null; let analystEngine; // Area handles live for this runner (the page): resolve_area, the @@ -1143,10 +1144,11 @@ export function createGevActionRunner({ runOptions, ); } - // Chained fly-tos in one reply: a grab during the earlier one counts. - const flight = trackVoiceFlight( - voiceFlight && !voiceFlight.done ? voiceFlight.inputCount : undefined, - ); + // Chained fly-tos: a grab during the earlier one counts. The flight + // replaced is the last one that moved, past lookups that found nothing. + const prior = voiceFlightTarget(voiceFlight); + const replaced = prior && !prior.done ? prior : null; + const flight = trackVoiceFlight(replaced?.inputCount, replaced); voiceFlight = flight; const flown = flyToRequestedLocation(viewer, args, { onFlightEnd: flight.settle, @@ -1177,13 +1179,23 @@ export function createGevActionRunner({ if (viewer.trackedEntity) stopAllTracking(viewer, dataManager); }, }); - // No flight started (not found, refused, cancelled): nothing to wait for. - flown.then( - (result) => { - if (result?.ok !== true) flight.settle('failed'); - }, - () => flight.settle('failed'), - ); + // No flight started: nothing to wait for. A lookup that was not found, + // threw, or was cancelled once a newer voice fly-to had begun (which + // takes navigation from it) moved nothing, so a queued motion looks + // past it to the flight it replaced. One cancelled while still the + // latest voice flight lost navigation to something else (a search, a + // share link, Cockpit): the motion then refuses. A flight its own hooks + // already settled (cancelled mid-air) keeps its status. + const notStarted = () => { + if (!flight.done) flight.movedNothing = true; + flight.settle('failed'); + }; + flown.then((result) => { + if (result?.ok === true) return; + if (result?.cancelled && voiceFlight === flight) + flight.settle('failed'); + else notStarted(); + }, notStarted); return flown; } @@ -1287,39 +1299,45 @@ export function createGevActionRunner({ if (name === 'move_camera') { const motion = String(args.motion || '').toLowerCase(); + // A lookup that already found nothing does not hide the last flight + // that moved: running now would cancel that flight mid-air. + const pending = voiceFlightTarget(voiceFlight); if ( VOICE_FLIGHT_WAITING_MOTIONS.has(motion) && - voiceFlight && - !voiceFlight.done + pending && + !pending.done ) { // Bad arguments are refused now, not after waiting on the flight. const argsError = moveCameraArgsError(args); if (argsError) return argsError; const deadline = Date.now() + VOICE_FLIGHT_WAIT_MS; - let flight = voiceFlight; + const start = pending; + let flight = start; let status = await waitForVoiceFlight( flight, runOptions.signal, deadline, ); - // A later fly-to in the same reply replaces this one: follow it to - // where the camera lands, within the same deadline. Its status does - // not matter. A geocode that loses navigation reports failed, and a - // flight left flying while the next place is looked up can still - // land; running there would take navigation and cancel the newer one. - while ( - voiceFlight !== flight && - status !== 'aborted' && - status !== 'timeout' && - current() - ) { - flight = voiceFlight; + // A later fly-to replaces this one: follow it to where the camera + // lands, within the same deadline, whatever this flight reported. A + // geocode that loses navigation reports failed, and a flight left + // flying while the next place is looked up can still land; running + // there would take navigation and cancel the newer one. A later + // lookup that found nothing moved nothing, so the motion acts on the + // last flight it saw that did move the camera. + let landed = flight.movedNothing ? null : { flight, status }; + while (status !== 'aborted' && status !== 'timeout' && current()) { + const target = voiceFlightTarget(voiceFlight, start); + if (!target || target === flight) break; + flight = target; status = await waitForVoiceFlight( flight, runOptions.signal, deadline, ); + if (!flight.movedNothing) landed = { flight, status }; } + if (flight.movedNothing && landed) ({ flight, status } = landed); if (!current()) return { ok: false, action: 'move_camera', cancelled: true }; if (status !== 'arrived') @@ -2939,11 +2957,25 @@ async function frameOverhead(viewer, dataManager, styleManager, args = {}) { ); } -/** A voice flight's end: 'arrived', 'cancelled' or 'failed', settled once. */ -function trackVoiceFlight(inputCount = manualCameraInputCount()) { +let voiceFlightCount = 0; + +/** + * A voice flight's end: 'arrived', 'cancelled' or 'failed', settled once. + * `replaced` is the last flight that moved, if still pending when this one + * began (past lookups that found nothing). It is kept only + * while this one may turn out to have moved nothing (a lookup that found no + * place), so a queued motion can look past it. + */ +function trackVoiceFlight( + inputCount = manualCameraInputCount(), + replaced = null, +) { let resolve; const flight = { done: false, + seq: (voiceFlightCount += 1), + replaced, + movedNothing: false, // Manual input after this means the user took the camera mid-flight. inputCount, ended: new Promise((settle) => { @@ -2952,12 +2984,26 @@ function trackVoiceFlight(inputCount = manualCameraInputCount()) { settle: (status) => { if (flight.done) return; flight.done = true; + if (!flight.movedNothing) flight.replaced = null; resolve(status); }, }; return flight; } +/** + * The flight a motion queued since `start` acts on: the latest one that + * actually started. A lookup that found nothing moved nothing, so look past + * it to the flight it replaced, but never to one older than `start` (a flight + * from an earlier reply). Null when every later lookup found nothing. With + * no `start`, the latest flight that started, however old. + */ +function voiceFlightTarget(latest, start = null) { + let flight = latest; + while (flight?.movedNothing) flight = flight.replaced; + return flight && (!start || flight.seq >= start.seq) ? flight : null; +} + /** * Longest a motion waits for its flight (geocode plus flight, which is about * 2 s) before giving up. The wait also holds the tool output, and on Gemini diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 14d008933..ece2f92e0 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -635,6 +635,162 @@ test('a motion does not run at a flight that lands while the next place is looke assert.equal(result.motion, 'tilt'); }); +// A later fly-to that finds nothing moves nothing: the motion still runs where +// the earlier flight lands, whether the lookup fails before or after that. +for (const order of ['lands first', 'not found first']) { + test(`a motion runs at its flight when a later fly-to finds nothing (${order})`, async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const harness = tiltAfterFlightHarness(); + const { viewer, styleManager, flights } = harness; + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + const flyB = runner('fly_to_location', { query: 'Nowhere' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + if (order === 'lands first') flights[0].complete(); + searches[0].resolve(null); + assert.equal((await flyB).ok, false); + if (order !== 'lands first') flights[0].complete(); + const result = await moved; + assert.equal(result.ok, true, result.error); + assert.equal(result.motion, 'tilt'); + }); +} + +test('a motion beside a fly-to that finds nothing never acts at an earlier reply\'s flight', async () => { + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + // Reply 1: "fly to Austin", still flying. + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + // Reply 2: "fly to Nowhere and tilt". + const flyB = runner('fly_to_location', { query: 'Nowhere' }); + const moved = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + await new Promise((resolve) => setTimeout(resolve, 0)); + searches[0].resolve(null); + assert.equal((await flyB).ok, false); + flights[0].complete(); + const result = await moved; + assert.equal(result.ok, false); + assert.match(result.error, /did not arrive/); +}); + +// Two later lookups that both find nothing, in either order, leave the motion +// acting where the first flight landed. +for (const first of [0, 1]) { + test(`a motion looks past two lookups that found nothing (lookup ${first + 1} fails first)`, async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'orbit' }); + const flyB = runner('fly_to_location', { query: 'Nowhere B' }); + const flyC = runner('fly_to_location', { query: 'Nowhere C' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + searches[first].resolve(null); + searches[1 - first].resolve(null); + assert.equal((await flyB).ok, false); + assert.equal((await flyC).ok, false); + flights[0].complete(); + const result = await moved; + assert.equal(result.ok, true, result.error); + }); +} + +test('a lookup cancelled by a newer one that finds nothing does not hide the flight that landed', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + const flyB = runner('fly_to_location', { query: 'Place B' }); + const flyC = runner('fly_to_location', { query: 'Nowhere C' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + flights[0].complete(); + // C's start takes navigation from B's lookup; C then finds nothing. + searches[0].resolve({ cancelled: true }); + searches[1].resolve(null); + assert.equal((await flyB).cancelled, true); + assert.equal((await flyC).ok, false); + const result = await moved; + assert.equal(result.ok, true, result.error); +}); + +test('a motion sent after a fly-to that found nothing still waits for the flight before it', async (t) => { + // Serial dispatch: "fly to Austin, fly to Nowhere, tilt", one call at a time. + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, order, flights } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: async () => null, + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + assert.equal((await runner('fly_to_location', { query: 'Nowhere' })).ok, false); + const before = order.length; + const moved = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + const movedDone = settledYet(moved); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(movedDone(), false, 'the tilt must wait for the Austin flight'); + assert.equal(order.slice(before).includes('cancel'), false); + flights[0].complete(); + assert.equal((await moved).ok, true); +}); + +test('two lookups that found nothing in a row still leave the motion waiting for the flight', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, order, flights } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: async () => null, + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + assert.equal((await runner('fly_to_location', { query: 'Nowhere' })).ok, false); + assert.equal((await runner('fly_to_location', { query: 'Nowhere 2' })).ok, false); + const before = order.length; + const moved = runner('move_camera', { motion: 'orbit' }); + const movedDone = settledYet(moved); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(movedDone(), false, 'the orbit must wait for the Austin flight'); + assert.equal(order.slice(before).includes('cancel'), false); + flights[0].complete(); + assert.equal((await moved).ok, true); +}); + test('a stale motion does not follow a newer reply\'s fly-to', async () => { const { viewer, styleManager, flights } = tiltAfterFlightHarness(); const runner = createGevActionRunner({ From 4c9b27dc204d52603942be7803e2432d2e8b1b0e Mon Sep 17 00:00:00 2001 From: Manjunath Date: Wed, 4 Nov 2026 14:48:07 +0530 Subject: [PATCH 12/39] Keep a settled flight in reach of a later failed lookup A new voice fly-to now records the last flight that moved as the one it replaced, landed or not; only a still-flying one passes on its manual-input count. A slow lookup cancelled by a direct flight that landed, followed by a lookup that found nothing, used to drop the queued motion, and a cancelled direct flight in the same place let it fall back to an older landing. --- src/voice/gevActions.js | 22 +++++++------ src/voice/gevActions.test.mjs | 59 +++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 83cdea367..089d95be0 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -1144,11 +1144,14 @@ export function createGevActionRunner({ runOptions, ); } - // Chained fly-tos: a grab during the earlier one counts. The flight - // replaced is the last one that moved, past lookups that found nothing. + // Chained fly-tos: a grab during an earlier one still flying counts. + // The flight replaced is the last one that moved, past lookups that + // found nothing. const prior = voiceFlightTarget(voiceFlight); - const replaced = prior && !prior.done ? prior : null; - const flight = trackVoiceFlight(replaced?.inputCount, replaced); + const flight = trackVoiceFlight( + prior && !prior.done ? prior.inputCount : undefined, + prior, + ); voiceFlight = flight; const flown = flyToRequestedLocation(viewer, args, { onFlightEnd: flight.settle, @@ -2961,10 +2964,10 @@ let voiceFlightCount = 0; /** * A voice flight's end: 'arrived', 'cancelled' or 'failed', settled once. - * `replaced` is the last flight that moved, if still pending when this one - * began (past lookups that found nothing). It is kept only - * while this one may turn out to have moved nothing (a lookup that found no - * place), so a queued motion can look past it. + * `replaced` is the last flight that moved when this one began (past lookups + * that found nothing), landed or not. It is kept only while this one may turn + * out to have moved nothing (a lookup that found no place), so a queued + * motion can look past it to that flight and its outcome. */ function trackVoiceFlight( inputCount = manualCameraInputCount(), @@ -2996,7 +2999,8 @@ function trackVoiceFlight( * actually started. A lookup that found nothing moved nothing, so look past * it to the flight it replaced, but never to one older than `start` (a flight * from an earlier reply). Null when every later lookup found nothing. With - * no `start`, the latest flight that started, however old. + * no `start`, the latest flight that started, back through lookups that + * found nothing. */ function voiceFlightTarget(latest, start = null) { let flight = latest; diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index ece2f92e0..678a13659 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -791,6 +791,65 @@ test('two lookups that found nothing in a row still leave the motion waiting for assert.equal((await moved).ok, true); }); +// A flight that already settled is still what a later failed lookup replaced. +test('a motion acts at a landed flight that a later failed lookup replaced', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + const flyA = runner('fly_to_location', { query: 'Slow A' }); + const moved = runner('move_camera', { motion: 'orbit' }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + flights[0].complete(); + const flyC = runner('fly_to_location', { query: 'Nowhere C' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + // B's start made A's lookup stale; C then finds nothing. + searches[0].resolve({ cancelled: true }); + searches[1].resolve(null); + assert.equal((await flyA).cancelled, true); + assert.equal((await flyC).ok, false); + const result = await moved; + assert.equal(result.ok, true, result.error); +}); + +test('a motion refuses when the flight a later failed lookup replaced was cancelled', async () => { + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'orbit' }); + const flyA = runner('fly_to_location', { query: 'Slow A' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + // The motion sees Austin land, then follows A's lookup. + flights[0].complete(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + flights[1].cancel(); + const flyC = runner('fly_to_location', { query: 'Nowhere C' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + searches[0].resolve({ cancelled: true }); + searches[1].resolve(null); + assert.equal((await flyA).cancelled, true); + assert.equal((await flyC).ok, false); + const result = await moved; + assert.equal(result.ok, false); + assert.match(result.error, /did not arrive/); +}); + test('a stale motion does not follow a newer reply\'s fly-to', async () => { const { viewer, styleManager, flights } = tiltAfterFlightHarness(); const runner = createGevActionRunner({ From 1c59105a80defce5a5e2987b5654505840b55227 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Thu, 5 Nov 2026 11:36:29 +0530 Subject: [PATCH 13/39] Judge tilts by camera pitch and follow a newer flight at once - A tilt's angle, limits and "unchanged" check use the camera's own pitch. At regional range the pivot pitch is up to about 8 degrees shallower, so "tilt to 5 degrees" from 900 km ended at -12.9 degrees. The swing now solves for the pivot pitch that gives the camera angle. - A zoom out during a pivoting tilt stops at the 1,000 km pivot limit instead of the orbit's 5,000 km, and a zoom in that reaches the clearance is applied before the pivot ends. - An angle the camera already holds still takes navigation, so it stops a running motion and releases the follow camera. - A motion queued behind a fly-to moves to a newer fly-to as soon as it begins, so a stalled lookup no longer holds it past a flight that landed. Wait listeners are removed when a wait ends, and a lookup that found nothing points straight at the flight that moved. - A fly-to that stops a running motion names it (stoppedMotion). - A tilt left or right is refused, and the pitchDeg description says steps use direction. --- docs/CURRENT-STATE.md | 2 +- server/providers/openai/toolDescriptions.js | 2 +- src/cameraTilt.test.mjs | 115 ++++++++++++--- src/cameraVerbs.js | 154 +++++++++++--------- src/voice/actionSchemas.test.mjs | 4 +- src/voice/gevActions.js | 113 +++++++++----- src/voice/gevActions.test.mjs | 44 ++++++ src/voice/pointAndAsk.test.mjs | 4 +- 8 files changed, 310 insertions(+), 128 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 520085495..e135ec175 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5520,7 +5520,7 @@ and unreachable upstream (502/504) separately from road geometry. > branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on > `main` + 26 route pins; the same tree on Node 24 reports 2,295. -- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. A zoom during a pivoting tilt scales the range it holds, as for orbit, but a zoom in stops at the range that keeps that 150 m clearance; a zoom that can move no further (that floor, the 80 m floor or the 5,000 km cap) is reported as not zoomed. Once the tilt continues in place, a zoom moves the camera like any zoom. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (following a later fly-to in the same reply that cancels it), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, or the user grabs, touches or scrolls the map during it, the motion is not run and says so. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered. Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. +- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. A zoom during a pivoting tilt scales the range it holds, as for orbit, but a zoom in stops at the range that keeps that 150 m clearance; a zoom that can move no further (that floor, the 80 m floor, or the cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) is reported as not zoomed. Once the tilt continues in place, a zoom moves the camera like any zoom. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, or the user grabs, touches or scrolls the map during it, the motion is not run and says so. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. - **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing. - **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm 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 rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped. - Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet). diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js index ee90434ee..33d1ea68a 100644 --- a/server/providers/openai/toolDescriptions.js +++ b/server/providers/openai/toolDescriptions.js @@ -533,7 +533,7 @@ export const ACTION_DESCRIPTIONS = { }, pitchDeg: { description: - 'Absolute pitch, not a step: -45 oblique (default), -89 top-down.', + 'Absolute; steps use direction. -45 oblique (default), -89 top-down.', $position: 1, }, }, diff --git a/src/cameraTilt.test.mjs b/src/cameraTilt.test.mjs index d372d0746..a82b2ce37 100644 --- a/src/cameraTilt.test.mjs +++ b/src/cameraTilt.test.mjs @@ -340,16 +340,16 @@ test('pitchDeg with a direction that agrees still tilts', (t) => { assert.ok(Math.abs(pitchDeg(h.camera) - -80) < 0.5); }); -test('the pitchDeg and direction check uses the pitch the swing starts from', (t) => { - // At regional range the Earth curves away: the pivot pitch is a degree or - // two shallower than the camera's (-30°), so -29° is still down from it. +test('the pitchDeg and direction check uses the camera pitch', (t) => { + // At regional range the Earth curves away and the pivot pitch is a degree + // or two shallower than the camera's (-30°); -29° is still up from it. harness(t, { height: 150000, pitch: -30 }); - const up = moveCamera({ motion: 'tilt', direction: 'up', pitchDeg: -29 }); - assert.equal(up.ok, false); - assert.match(up.error, /tilts down, not up/); const down = moveCamera({ motion: 'tilt', direction: 'down', pitchDeg: -29 }); - assert.equal(down.ok, true); - assert.equal(down.direction, 'down'); + assert.equal(down.ok, false); + assert.match(down.error, /tilts up, not down/); + const up = moveCamera({ motion: 'tilt', direction: 'up', pitchDeg: -29 }); + assert.equal(up.ok, true); + assert.equal(up.direction, 'up'); }); test('after a zoom in, a pivoting tilt never lowers the camera further', (t) => { @@ -414,6 +414,13 @@ test('a zoom in during a pivot tilt stops at the clearance', (t) => { assert.deepEqual(adjustOrbitRange(1 / 2.4), { kind: 'tilt', changed: true }); // At -40° the floor is 150 / sin 40° ≈ 233 m, which the first zoom reached. assert.deepEqual(adjustOrbitRange(1 / 2.4), { kind: 'tilt', changed: false }); + // The camera must take that range, not just report it. + h.tick(); + const floor = 150 / Math.sin(radians(40)); + assert.ok( + Math.abs(pivotRange(h) - floor) < floor * 0.02, + `range ${pivotRange(h)} m, wanted about ${floor} m`, + ); for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) { h.tick(); assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`); @@ -453,17 +460,39 @@ test('from globe range a tilt happens in place', (t) => { assert.ok(Cesium.Cartesian3.distance(h.camera.positionWC, before) < 1e-3); }); -test('a regional tilt-up nudge at the pivot limit refuses honestly', (t) => { - // 20 km up at -7°, the pivot 186 km out sits at -5.3°: already at the limit. - harness(t, { height: 20000, pitch: -7 }); - const result = moveCamera({ motion: 'tilt', direction: 'up' }); - assert.equal(result.ok, false); - assert.match(result.error, /upper tilt limit/); - assert.equal(getActiveCameraMotion(), null); +for (const height of [100_000, 400_000, 900_000]) + test(`from ${height / 1000} km a tilt to -5° ends with the camera at -5°`, (t) => { + // The pivot pitch is shallower than the camera's by up to 8° at this + // range; the camera reached only -5.9° to -12.9° when the swing stopped + // at a pivot pitch of -5°. + const h = harness(t, { height }); + const result = moveCamera({ motion: 'tilt', pitchDeg: 5 }); + assert.equal(result.pitchDeg, -5); + h.finish(); + assert.ok( + Math.abs(pitchDeg(h.camera) - -5) < 0.5, + `pitch ${pitchDeg(h.camera)}`, + ); + assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`); + const nudge = moveCamera({ motion: 'tilt', direction: 'up' }); + assert.equal(nudge.ok, false); + assert.match(nudge.error, /upper tilt limit/); + }); + +test('a regional tilt-up nudge goes to the camera horizon limit', (t) => { + // 20 km up at -7°: the pivot 186 km out sits at -5.3°, but the camera can + // still tilt up 2°. + const h = harness(t, { height: 20000, pitch: -7 }); + assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true); + h.finish(); + assert.ok( + Math.abs(pitchDeg(h.camera) - -5) < 0.5, + `pitch ${pitchDeg(h.camera)}`, + ); }); -test('a voice tilt from a tracked view judges its limit from the pivot it will use', (t) => { - const h = harness(t, { height: 20000, pitch: -7 }); +test('a voice tilt from a tracked view judges its limit from the camera pitch', (t) => { + const h = harness(t, { height: 20000, pitch: -5.2 }); h.viewer.trackedEntity = { id: 'tracked' }; // Voice releases the follow camera before the motion starts. const release = (start) => { @@ -474,3 +503,55 @@ test('a voice tilt from a tracked view judges its limit from the pivot it will u assert.equal(result.ok, false); assert.match(result.error, /upper tilt limit/); }); + +test('a zoom out during a pivoting tilt stops at the pivot range limit', (t) => { + // 900 km top-down, "tilt to 10°", then "zoom out a lot" twice: capped at + // the orbit's 5,000 km, the swing reached 2,386 km up at a real -44°. + const h = harness(t, { height: 900_000 }); + assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -10 }).ok, true); + h.tick(); + assert.deepEqual(adjustOrbitRange(2.4), { kind: 'tilt', changed: true }); + assert.deepEqual(adjustOrbitRange(2.4), { kind: 'tilt', changed: false }); + let farthest = 0; + for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) { + h.tick(); + farthest = Math.max(farthest, pivotRange(h)); + } + assert.equal(getActiveCameraMotion(), null, 'tilt must finish'); + assert.ok(farthest <= 1_000_001, `swung ${farthest} m from the pivot`); + assert.ok( + Math.abs(pitchDeg(h.camera) - -10) < 0.5, + `pitch ${pitchDeg(h.camera)}`, + ); +}); + +test('an unchanged angle still stops a running motion through navigation', (t) => { + const h = harness(t); + moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }); + tickUntil(h, () => pitchDeg(h.camera) >= -45.3); + let handoffs = 0; + const navigate = (start) => { + handoffs += 1; + return start(); + }; + const result = moveCamera({ motion: 'tilt', pitchDeg: -45 }, navigate); + assert.equal(result.ok, true); + assert.equal(result.unchanged, true); + assert.equal(handoffs, 1, 'the request must take navigation'); + assert.equal(getActiveCameraMotion(), null, 'the tilt must stop'); + const held = pitchDeg(h.camera); + for (let i = 0; i < 30; i += 1) h.tick(); + assert.equal(pitchDeg(h.camera), held); +}); + +for (const direction of ['left', 'right']) + test(`a tilt ${direction} is refused`, (t) => { + harness(t, { pitch: -45 }); + const result = moveCamera({ motion: 'tilt', direction }); + assert.equal(result.ok, false); + assert.match(result.error, /tilt needs up or down/); + const angled = moveCamera({ motion: 'tilt', direction, pitchDeg: -30 }); + assert.equal(angled.ok, false); + assert.match(angled.error, /pitchDeg alone/); + assert.equal(getActiveCameraMotion(), null); + }); diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index c1e5c5d1a..70b83eff5 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -1267,62 +1267,63 @@ function onTick() { /** * One frame of tilt. With a ground pivot the camera swings about it (lookAt), * so the place under the crosshair stays centred; without one it looks up or - * down in place. Pitch is clamped to [-89°, -5°] only in the direction of - * travel: a top-down view sits at -90°, below the limit, and must still be - * able to tilt up out of it. A pivot swing toward the horizon lowers the - * camera, so it stops at TILT_MIN_CLEARANCE_M and the rest tilts in place. + * down in place. Every angle and limit is the camera's own pitch, which at + * regional range is several degrees steeper than the pivot's (the Earth + * curves away), so the swing solves for the pivot pitch that gives it. Pitch + * is clamped to [-89°, -5°] only in the direction of travel: a top-down view + * sits at -90°, below the limit, and must still be able to tilt up out of it. + * A pivot swing toward the horizon lowers the camera, so it stops at + * TILT_MIN_CLEARANCE_M and the rest tilts in place. */ function tickTilt(cam, m, scale, dt) { - let step; - if (m.mode === 'once') { - step = m.tiltRad * (scale - (m.lastScale || 0)); - m.lastScale = scale; - } else { - step = - Cesium.Math.toRadians(TILT_DEG_S[m.speed]) * - dt * - (m.direction === 'up' ? 1 : -1); - } + const from = cam.pitch; + const want = + m.mode === 'once' + ? m.startPitch + (m.goalPitch - m.startPitch) * scale + : from + + Cesium.Math.toRadians(TILT_DEG_S[m.speed]) * + dt * + (m.direction === 'up' ? 1 : -1); + const up = want > from; + const limited = up + ? Math.min(want, Math.max(from, PITCH_MAX)) + : Math.max(want, Math.min(from, PITCH_MIN)); if (m.hpr) { - const from = m.hpr.pitch; - const next = from + step; - const limited = - step > 0 - ? Math.min(next, Math.max(from, tiltPivotPitchMax(m.hpr.range))) - : Math.max(next, Math.min(from, PITCH_MIN)); - m.hpr.pitch = limited; + // Apply a range a zoom set since the last frame, even when the pitch + // then has nowhere to go (a zoom in to the clearance ends the pivot). cam.lookAt(m.target, m.hpr); + // Up toward the horizon the swing lowers the camera: hold the clearance. + const cap = up + ? Math.max(m.hpr.pitch, tiltPivotPitchMax(m.hpr.range)) + : Infinity; + for (let i = 0; i < 3 && Math.abs(limited - cam.pitch) > 1e-6; i += 1) { + const pitch = Math.min( + cap, + Math.max(-Cesium.Math.PI_OVER_TWO, m.hpr.pitch + limited - cam.pitch), + ); + if (pitch === m.hpr.pitch) break; + m.hpr.pitch = pitch; + cam.lookAt(m.target, m.hpr); + } // A once motion that just completed released the lookAt frame before // this last step re-took it. if (_active !== m) clearLookAt(); - if (limited === next) return; - if (step < 0 || limited >= PITCH_MAX) { - interruptCameraMotion('tilt-clamp'); - return; + if (up && m.hpr.pitch >= cap && cam.pitch < limited - 1e-6) { + // Down to its clearance: let go of the pivot and tilt the rest in + // place, which keeps the height. + if (_active === m) clearLookAt(); + m.hpr = null; + m.target = null; } - // Down to its clearance: let go of the pivot and tilt the rest in place, - // which keeps the height. - if (_active === m) clearLookAt(); - m.hpr = null; - m.target = null; - step = next - limited; } - const from = cam.pitch; - const next = from + step; - const limited = - step > 0 - ? Math.min(next, Math.max(from, PITCH_MAX)) - : Math.max(next, Math.min(from, PITCH_MIN)); - cam.lookUp(limited - from); - if (limited !== next) interruptCameraMotion('tilt-clamp'); + if (!m.hpr) cam.lookUp(limited - cam.pitch); + if (m.mode !== 'once' && limited !== want) + interruptCameraMotion('tilt-clamp'); } /** Shallowest pivot pitch that keeps the clearance at this range. */ function tiltPivotPitchMax(range) { - return Math.min( - PITCH_MAX, - -Math.asin(Math.min(1, TILT_MIN_CLEARANCE_M / range)), - ); + return -Math.asin(Math.min(1, TILT_MIN_CLEARANCE_M / range)); } /** @@ -1331,12 +1332,11 @@ function tiltPivotPitchMax(range) { * range, or while an entity is tracked (the follow camera owns that frame; * voice releases it before moving, so a voice tilt still pivots). */ -function tiltStartFrame(cam, { releasesTracking = false } = {}) { - const tracked = Boolean(_viewer.trackedEntity) && !releasesTracking; - const target = tracked ? null : _getTarget?.(_viewer); +function tiltStartFrame(cam) { + const target = _viewer.trackedEntity ? null : _getTarget?.(_viewer); let pivot = target ? tiltPivot(cam, target) : null; if (pivot && pivot.range > TILT_PIVOT_MAX_RANGE_M) pivot = null; - return { target, pivot, from: pivot ? pivot.pitch : cam.pitch }; + return { target, pivot, from: cam.pitch }; } /** Ground pivot for a tilt: heading, pitch and range in the target's own frame. */ @@ -1604,7 +1604,14 @@ export function moveCameraArgsError(args = {}) { return { ok: false, action: 'move_camera', - error: `${motion} needs a direction (left/right${motion !== 'rotate' ? '/up/down' : ''}).`, + error: `${motion} needs a direction (${motion === 'tilt' ? 'up/down' : motion === 'rotate' ? 'left/right' : 'left/right/up/down'}).`, + }; + } + if (motion === 'tilt' && (direction === 'left' || direction === 'right')) { + return { + ok: false, + action: 'move_camera', + error: 'tilt needs up or down, or pitchDeg alone for an angle.', }; } if (motion === 'rotate' && !['left', 'right'].includes(direction)) { @@ -1664,13 +1671,10 @@ export function moveCamera(args = {}, runNavigation = null) { if (argsError) return argsError; const tiltToPitch = motion === 'tilt' ? requestedTiltPitch(args, direction) : null; - // A managed (voice) navigation releases the follow camera before start(), - // so checks made now must judge from the frame start() will see. - const releasesTracking = typeof runNavigation === 'function'; if (motion === 'tilt' && tiltToPitch !== null) { - // Judge from the pitch the motion will start at: the pivot's, which at - // regional range is a degree or two shallower than the camera's. - const { from } = tiltStartFrame(_viewer.camera, { releasesTracking }); + // Angles and limits are the camera's own pitch, whatever the swing pivots + // about; releasing the follow camera does not change it. + const from = _viewer.camera.pitch; // -89 or -90 means top-down, which a -90° view already is: the clamp // must not tilt it up 1°. const atTopDown = tiltToPitch <= PITCH_MIN && from <= PITCH_MIN; @@ -1678,15 +1682,21 @@ export function moveCamera(args = {}, runNavigation = null) { atTopDown || Math.abs(tiltToPitch - from) < Cesium.Math.toRadians(0.5) ) { - return { - ok: true, - action: 'move_camera', - motion, - pitchDeg: Math.round( - Cesium.Math.toDegrees(atTopDown ? from : tiltToPitch), - ), - unchanged: true, + // Still a camera request: it stops a running motion and releases the + // follow camera, as a tilt that moves would. + const hold = () => { + interruptCameraMotion('replaced'); + return { + ok: true, + action: 'move_camera', + motion, + pitchDeg: Math.round( + Cesium.Math.toDegrees(atTopDown ? from : tiltToPitch), + ), + unchanged: true, + }; }; + return typeof runNavigation === 'function' ? runNavigation(hold) : hold(); } // pitchDeg is an absolute angle, not a step: "tilt down 10 degrees" sent // as {direction:'down', pitchDeg:10} would tilt up from -45°. @@ -1703,8 +1713,7 @@ export function moveCamera(args = {}, runNavigation = null) { } else 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). - // The clamp reads the pitch the motion starts from. - const pitch = tiltStartFrame(_viewer.camera, { releasesTracking }).from; + const pitch = _viewer.camera.pitch; if (direction === 'up' && pitch >= PITCH_MAX - Cesium.Math.toRadians(0.5)) { return { ok: false, @@ -1821,17 +1830,23 @@ export function moveCamera(args = {}, runNavigation = null) { state.target = target; state.hpr = pivot; } + state.startPitch = from; if (tiltToPitch !== null) { state.mode = 'once'; state.direction = tiltToPitch > from ? 'up' : 'down'; - state.tiltRad = tiltToPitch - from; + state.goalPitch = tiltToPitch; state.durationS = Math.max( ONCE.durationS, - Math.abs(Cesium.Math.toDegrees(state.tiltRad)) / TILT_TO_DEG_S, + Math.abs(Cesium.Math.toDegrees(tiltToPitch - from)) / TILT_TO_DEG_S, ); } else { - state.tiltRad = + // A nudge stops at the limit it travels toward. + const step = Cesium.Math.toRadians(ONCE.tiltDeg) * (direction === 'up' ? 1 : -1); + state.goalPitch = + step > 0 + ? Math.min(from + step, Math.max(from, PITCH_MAX)) + : Math.max(from + step, Math.min(from, PITCH_MIN)); } } if (motion === 'rotate_globe') { @@ -1954,10 +1969,13 @@ export function adjustOrbitRange(factor) { kind === 'tilt' ? Math.max(80, TILT_MIN_CLEARANCE_M / Math.abs(Math.sin(hpr.pitch))) : 80; + // A tilt pivots only within TILT_PIVOT_MAX_RANGE_M; farther out the swing + // would carry the camera thousands of km. + const ceiling = kind === 'tilt' ? TILT_PIVOT_MAX_RANGE_M : 5_000_000; const range = hpr.range; hpr.range = Math.max( Math.min(floor, range), - Math.min(Math.max(5_000_000, range), range * factor), + Math.min(Math.max(ceiling, range), range * factor), ); // Under half a metre counts as not moved, as for a plain zoom. return { kind, changed: Math.abs(hpr.range - range) >= 0.5 }; diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index ecebe8a5d..15c94c96c 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -56,8 +56,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // area scopes and the osm-places layer; natural-region candidates and the // tightened area tool wording follow review. // move_camera tilt takes an optional pitchDeg angle, described as - // absolute rather than a step. - '1dbdef45a477806046cfe4a74a7766f137d7a2b551fdd0b9595986068a85ee8d', + // absolute, with steps sent as a direction. + 'b192c42d1a1c59bea010da93ed0cfb1ce63d2e3ab5e5685921d3bf66e3f94e26', ); }); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 089d95be0..5a30a8cf3 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -49,6 +49,7 @@ import { adjustOrbitRange, manualCameraInputCount, moveCameraArgsError, + getActiveCameraMotion, } from '../cameraVerbs.js'; import * as defaultFloorServices from '../data/groundFloor.js'; import { isPickedWorldPosition } from '../data/scenePick.js'; @@ -293,6 +294,8 @@ export function createGevActionRunner({ // cancelled the flight, or the flight (still geocoding) then replaced the // motion. The wait follows later fly-tos (voiceFlightTarget). let voiceFlight = null; + // Waiters to tell when a newer voice flight begins. + const voiceFlightWatchers = new Set(); let analystEngine; // Area handles live for this runner (the page): resolve_area, the // analyst's region/area/drawn scopes, find_imagery and osm_query all read @@ -1153,6 +1156,25 @@ export function createGevActionRunner({ prior, ); voiceFlight = flight; + for (const notify of [...voiceFlightWatchers]) notify(); + // A motion already running (one the model sent before this fly-to) is + // stopped when the flight takes navigation; the result says so. + let stoppedMotion = null; + const noteStop = + (hook) => + (...hookArgs) => { + const before = getActiveCameraMotion(); + try { + return hook(...hookArgs); + } finally { + if ( + before && + !stoppedMotion && + getActiveCameraMotion()?.motionId !== before.motionId + ) + stoppedMotion = before.kind; + } + }; const flown = flyToRequestedLocation(viewer, args, { onFlightEnd: flight.settle, onProgress: reportProgress, @@ -1161,26 +1183,28 @@ export function createGevActionRunner({ signal: runOptions.signal, runImmediate: typeof styleManager?.runImmediateLocationNavigation === 'function' - ? (navigate) => - styleManager.runImmediateLocationNavigation(navigate) + ? noteStop((navigate) => + styleManager.runImmediateLocationNavigation(navigate), + ) : null, beginDeferred: typeof styleManager?.beginDeferredLocationNavigation === 'function' - ? () => styleManager.beginDeferredLocationNavigation() + ? noteStop(() => styleManager.beginDeferredLocationNavigation()) : null, reassertDeferred: typeof styleManager?.reassertDeferredLocationNavigation === 'function' - ? (generation) => - styleManager.reassertDeferredLocationNavigation(generation) + ? noteStop((generation) => + styleManager.reassertDeferredLocationNavigation(generation), + ) : null, - onStart: () => { + onStart: noteStop(() => { if (typeof styleManager?.beginLocationNavigation === 'function') { styleManager.beginLocationNavigation(); return; } interruptCameraMotion('nav:fly_to_location'); if (viewer.trackedEntity) stopAllTracking(viewer, dataManager); - }, + }), }); // No flight started: nothing to wait for. A lookup that was not found, // threw, or was cancelled once a newer voice fly-to had begun (which @@ -1199,7 +1223,11 @@ export function createGevActionRunner({ flight.settle('failed'); else notStarted(); }, notStarted); - return flown; + return flown.then((result) => + stoppedMotion && result && typeof result === 'object' + ? { ...result, stoppedMotion } + : result, + ); } if (name === 'adjust_camera_zoom') { @@ -1315,35 +1343,31 @@ export function createGevActionRunner({ if (argsError) return argsError; const deadline = Date.now() + VOICE_FLIGHT_WAIT_MS; const start = pending; + // Act on where the camera lands. Whatever a flight reports, a later + // fly-to replaces it, and the wait moves to that one as soon as it + // begins (a stalled geocode must not hold the motion past a newer + // flight that landed). A geocode that loses navigation reports + // failed, and a flight left flying while the next place is looked up + // can still land; running there would take navigation and cancel the + // newer one. A later lookup that found nothing moved nothing, so the + // motion looks past it to the last flight that did move the camera. let flight = start; - let status = await waitForVoiceFlight( - flight, - runOptions.signal, - deadline, - ); - // A later fly-to replaces this one: follow it to where the camera - // lands, within the same deadline, whatever this flight reported. A - // geocode that loses navigation reports failed, and a flight left - // flying while the next place is looked up can still land; running - // there would take navigation and cancel the newer one. A later - // lookup that found nothing moved nothing, so the motion acts on the - // last flight it saw that did move the camera. - let landed = flight.movedNothing ? null : { flight, status }; - while (status !== 'aborted' && status !== 'timeout' && current()) { - const target = voiceFlightTarget(voiceFlight, start); - if (!target || target === flight) break; - flight = target; + let status; + for (;;) { status = await waitForVoiceFlight( flight, runOptions.signal, deadline, + voiceFlightWatchers, ); - if (!flight.movedNothing) landed = { flight, status }; + if (status === 'aborted' || status === 'timeout' || !current()) break; + const target = voiceFlightTarget(voiceFlight, start); + if (!target || (target === flight && status !== 'newer')) break; + flight = target; } - if (flight.movedNothing && landed) ({ flight, status } = landed); if (!current()) return { ok: false, action: 'move_camera', cancelled: true }; - if (status !== 'arrived') + if (status !== 'arrived' || flight.movedNothing) return { ok: false, action: 'move_camera', @@ -2973,7 +2997,6 @@ function trackVoiceFlight( inputCount = manualCameraInputCount(), replaced = null, ) { - let resolve; const flight = { done: false, seq: (voiceFlightCount += 1), @@ -2981,14 +3004,20 @@ function trackVoiceFlight( movedNothing: false, // Manual input after this means the user took the camera mid-flight. inputCount, - ended: new Promise((settle) => { - resolve = settle; - }), + status: null, + // Waiters to tell when this flight ends. + watchers: new Set(), settle: (status) => { if (flight.done) return; flight.done = true; - if (!flight.movedNothing) flight.replaced = null; - resolve(status); + flight.status = status; + // Kept only past a lookup that found nothing, and then straight to the + // flight that moved, so a run of such lookups holds no chain. + flight.replaced = flight.movedNothing + ? voiceFlightTarget(flight.replaced) + : null; + for (const notify of [...flight.watchers]) notify(status); + flight.watchers.clear(); }, }; return flight; @@ -3027,22 +3056,32 @@ const VOICE_FLIGHT_WAITING_MOTIONS = new Set([ 'rotate', ]); -/** Wait for a voice flight to end; 'timeout' or 'aborted' if it does not. */ -function waitForVoiceFlight(flight, signal, deadline) { +/** + * Wait for a voice flight to end, or for a newer one to begin ('newer'): + * 'timeout' or 'aborted' if neither happens. A flight that has ended answers + * at once. Every listener is removed when the wait ends, so a flight that + * never settles holds nothing. + */ +function waitForVoiceFlight(flight, signal, deadline, newerWatchers) { if (signal?.aborted) return Promise.resolve('aborted'); + if (flight.done) return Promise.resolve(flight.status); return new Promise((resolve) => { const finish = (status) => { clearTimeout(timer); signal?.removeEventListener?.('abort', onAbort); + flight.watchers.delete(finish); + newerWatchers?.delete(onNewer); resolve(status); }; const onAbort = () => finish('aborted'); + const onNewer = () => finish('newer'); const timer = setTimeout( () => finish('timeout'), Math.max(0, deadline - Date.now()), ); signal?.addEventListener?.('abort', onAbort, { once: true }); - flight.ended.then(finish); + flight.watchers.add(finish); + newerWatchers?.add(onNewer); }); } diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 678a13659..b2cdbedc4 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -895,6 +895,50 @@ test('a motion gives up on a flight that has not landed in 15 s', async (t) => { assert.match(result.error, /did not arrive/); }); +test('a motion follows a newer fly-to that lands while its own lookup stalls', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + // Place A's lookup never answers. + searchNavigation: () => new Promise(() => {}), + }); + t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); + runner('fly_to_location', { query: 'Place A' }); + const moved = runner('move_camera', { motion: 'orbit' }); + const movedDone = settledYet(moved); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + t.mock.timers.tick(2_000); + await Promise.resolve(); + assert.equal(movedDone(), false, 'the orbit must wait for the newer flight to land'); + flights[0].complete(); + for (let i = 0; i < 5 && !movedDone(); i += 1) await Promise.resolve(); + assert.equal(movedDone(), true, 'the orbit must not wait out A'); + const result = await moved; + assert.equal(result.ok, true); + assert.equal(result.motion, 'orbit'); +}); + +test('a fly-to that stops a running motion says so', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + // The model sent the orbit first, so it ran at once. + const orbit = await runner('move_camera', { motion: 'orbit', mode: 'continuous' }); + assert.equal(orbit.ok, true); + const fly = await runner('fly_to_location', { locationId: 'austin' }); + assert.equal(fly.ok, true); + assert.equal(fly.stoppedMotion, 'orbit'); + const again = await runner('fly_to_location', { locationId: 'austin' }); + assert.equal('stoppedMotion' in again, false); +}); + test('bad move_camera arguments are refused without waiting for a flight', async () => { const { viewer, styleManager } = tiltAfterFlightHarness(); const runner = createGevActionRunner({ diff --git a/src/voice/pointAndAsk.test.mjs b/src/voice/pointAndAsk.test.mjs index 66a8e7eda..1f1993a07 100644 --- a/src/voice/pointAndAsk.test.mjs +++ b/src/voice/pointAndAsk.test.mjs @@ -350,8 +350,8 @@ test('deixis prompt lint: one short rule, and every sentinel it names exists in // the analyst area scopes) raised the ceiling to 55,500; tightening the // AREAS rule and the area tool descriptions left it at 55,103 as measured // below (every tool, osm_query included); main's Sea Marks and Ski Pistes - // layers bring it to 55,210, and the tilt pitchDeg argument to 55,332. - // integrationContract's Overpass prompt is the tightest, at 55,492 (8 + // layers bring it to 55,210, and the tilt pitchDeg argument to 55,335. + // integrationContract's Overpass prompt is the tightest, at 55,495 (5 // under). A new tool or rule has to fit under this ceiling. const size = instructions.length + JSON.stringify(GEV_REALTIME_TOOLS).length; assert.ok(size <= 55_500, `prompt size ${size}`); From 64377db91abbb8d324a3dd90dda715d95f2e6570 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Thu, 5 Nov 2026 11:59:11 +0530 Subject: [PATCH 14/39] fix(camera): leave flights and scenes to their framing Review follow-ups for the globe-in-view recovery and voice limits. - Leave a camera flight in progress and a playing Director scene, holds included, to their own framing. The recovery is now installed with the scene tools, which own the Director, and reads its running state. - Reuse a voice turn's half-degree proof only from the position and direction it was proven at, so a release that moves the camera checks the turn again. - Share one free-globe-camera check between the recovery, the voice turn limit and the globe rotation. - Keep an enableLook setting other code makes after the free-look limit is installed, instead of writing back the install-time value. - Name the nudging test for what it asserts, the 1% floor, and document that a view losing its surface without a camera move waits for the next one. --- docs/CURRENT-STATE.md | 7 +++-- src/app/application.test.mjs | 44 ++++++++++++++++++++++++++++++++ src/app/scene.js | 9 ------- src/app/tools.js | 16 +++++++++++- src/app/viewer.js | 27 +++++++++++++------- src/cameraGlobeRotation.test.mjs | 28 +++++++++++++++++++- src/cameraVerbs.js | 38 ++++++++++++--------------- src/globeInView.js | 12 +++++++++ 8 files changed, 137 insertions(+), 44 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3fde50e05..4244d5937 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -100,8 +100,11 @@ away, such as a saved or shared view that did, or a drag that Cesium turns into an in-place look below its 7,500 km trackball height (one that starts on the stars, or a tilt drag with the screen centre off the planet). Views with more surface than that, such as the oblique orbital Director shots, -are left as they are, and so are tracked or locked cameras and the 2D and -Columbus modes. A voice turn requested while the camera is tracked is judged +are left as they are, and so are tracked or locked cameras, a camera flight in +progress, a Director scene while it plays (holds included) and the 2D and +Columbus modes. A view that loses its surface without a camera move (a resize, +or turning the scope on) is left as it is until the next move. A voice turn +requested while the camera is tracked is judged by the pose voice's release of the follow camera will leave, before that release, so a refused turn keeps the tracking. diff --git a/src/app/application.test.mjs b/src/app/application.test.mjs index 401eefa0b..d9d915f20 100644 --- a/src/app/application.test.mjs +++ b/src/app/application.test.mjs @@ -399,6 +399,26 @@ test('free look follows camera height and is restored on dispose', () => { assert.equal(f.listeners.size, 0); }); +test('free look keeps a setting other code makes after it is installed', () => { + const f = freeLookFixture({ height: 500 }); + const dispose = installFreeLookHeightLimit(f.viewer); + f.controller.enableLook = false; + f.frame(10); + assert.equal(f.controller.enableLook, false, 'turned off stays off'); + f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20); + f.frame(10); + assert.equal(f.controller.enableLook, false, 'and stays off after orbit'); + f.controller.enableLook = true; + f.frame(10); + assert.equal(f.controller.enableLook, true, 'turned back on'); + f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20); + assert.equal(f.controller.enableLook, false, 'still limited from orbit'); + f.frame(10); + f.controller.enableLook = false; + dispose(); + assert.equal(f.controller.enableLook, false, 'dispose keeps a later setting'); +}); + test('free look stays off when the viewer had it disabled', () => { const f = freeLookFixture({ enableLook: false, height: 500 }); installFreeLookHeightLimit(f.viewer); @@ -418,6 +438,7 @@ function realCamera({ width = 1600, height = 900, mode } = {}) { pixelRatio: 1, ellipsoid: Cesium.Ellipsoid.WGS84, mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL, + tweens: new Cesium.TweenCollection(), renders: 0, requestRender() { this.renders += 1; @@ -674,6 +695,29 @@ test('the recovery follows the scope: corner-only views are turned back only beh } }); +test('the recovery leaves a camera flight and an authored scene alone', () => { + let playing = true; + const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 }); + scene.camera = camera; + scene.screenSpaceCameraController = { enableInputs: true }; + installGlobeInViewRecovery(viewer, { paused: () => playing }); + aim(camera, reportedStuckView); + camera.moveEnd.raiseEvent(); + assert.equal(globeOutOfFrame(camera), true, 'a playing scene is left alone'); + playing = false; + + // A flight that pauses looking away is left alone until it ends. + camera.flyTo({ + destination: Cesium.Cartesian3.fromDegrees(10, 20, 18_000_000), + duration: 5, + }); + camera.moveEnd.raiseEvent(); + assert.equal(globeOutOfFrame(camera), true, 'a flight is left alone'); + camera.cancelFlight(); + camera.moveEnd.raiseEvent(); + assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6)); +}); + function fakeTimers() { let next = 1; const pending = new Map(); diff --git a/src/app/scene.js b/src/app/scene.js index 9c115638d..8792a1f4d 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -3,12 +3,9 @@ import * as Cesium from 'cesium'; import { createApplicationViewer, installFreeLookHeightLimit, - installGlobeInViewRecovery, installTrackpadPinchZoom, } from '../app/viewer.js'; import { registerDataCredits } from '../data/dataCredits.js'; -import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js'; -import { isScopeMaskEnabled } from '../scopeMask.js'; import { configureCreditKeyboardAccess } from '../creditKeyboard.js'; import { MapStackController } from '../mapStackController.js'; import { loadPhotorealisticTileset } from '../mapStartup.js'; @@ -63,12 +60,6 @@ export async function createApplicationScene({ }); defer(installTrackpadPinchZoom(viewer)); defer(installFreeLookHeightLimit(viewer)); - defer( - installGlobeInViewRecovery(viewer, { - // The scope mask hides everything outside its keyhole. - keyholeRadius: () => (isScopeMaskEnabled() ? KEYHOLE_OUTER_RADIUS : 0), - }), - ); registerDataCredits(viewer, credits); configureCreditKeyboardAccess(document); defer(onLocaleChange(() => configureCreditKeyboardAccess(document))); diff --git a/src/app/tools.js b/src/app/tools.js index 9d91f70a9..68033947d 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -8,7 +8,13 @@ import { initImageryBoxTool } from '../ui/imageryBoxTool.js'; import { createRecentImageryPanel } from '../ui/recentImagery.js'; import { initGevVoiceCommands } from '../voice/gevRealtime.js'; import { installViews, isEmbeddedInline } from './embed.js'; -import { installScopeMask, destroyScopeMask } from '../scopeMask.js'; +import { + installScopeMask, + destroyScopeMask, + isScopeMaskEnabled, +} from '../scopeMask.js'; +import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js'; +import { installGlobeInViewRecovery } from './viewer.js'; import { installLayerReleaseTrim } from '../sceneCommandRelease.js'; import { installRenderGovernor, @@ -44,6 +50,14 @@ export function createApplicationTools({ .get('bhote-koshi-2026') ?.module.attachSceneController(sceneDirector); defer(() => sceneDirector.destroy()); + defer( + installGlobeInViewRecovery(viewer, { + // The scope mask hides everything outside its keyhole. + keyholeRadius: () => (isScopeMaskEnabled() ? KEYHOLE_OUTER_RADIUS : 0), + // An authored scene frames its own shots, holds included. + paused: () => sceneDirector.running, + }), + ); onSceneDirector?.(sceneDirector); const annotations = initAnnotations({ viewer, diff --git a/src/app/viewer.js b/src/app/viewer.js index ce182a0ab..c99a34d07 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -4,6 +4,7 @@ import { StarrySky } from '../sky/starrySky.js'; import { FREE_LOOK_MAX_HEIGHT_METERS, globeOutOfFrame, + isFreeGlobeCamera, } from '../globeInView.js'; const PINCH_ZOOM_MULTIPLIER = 8; @@ -109,7 +110,9 @@ export function installTrackpadPinchZoom( /** * Allow Cesium's free look only below `maxHeight` and return its disposer. - * Orbit, tilt and zoom stay available at every height. `enableLook` gates the + * Orbit, tilt and zoom stay available at every height. A value other code + * sets later is kept as the one to allow below that height, and restored on + * dispose. `enableLook` gates the * Shift+drag binding only; a drag that starts on the stars below Cesium's * trackball height still turns the camera in place, which * `installGlobeInViewRecovery` catches. @@ -124,11 +127,14 @@ export function installFreeLookHeightLimit( if (!controller || !camera || !scene?.preUpdate) throw new TypeError('A complete Cesium viewer is required'); - const originalEnableLook = controller.enableLook; + let wanted = controller.enableLook; + let written; const sync = () => { + if (written !== undefined && controller.enableLook !== written) + wanted = controller.enableLook; const height = camera.positionCartographic?.height; - controller.enableLook = - originalEnableLook && !(Number.isFinite(height) && height > maxHeight); + written = wanted && !(Number.isFinite(height) && height > maxHeight); + controller.enableLook = written; }; sync(); const removeListener = scene.preUpdate.addEventListener(sync); @@ -138,7 +144,7 @@ export function installFreeLookHeightLimit( if (!active) return; active = false; removeListener(); - controller.enableLook = originalEnableLook; + if (controller.enableLook === written) controller.enableLook = wanted; }; } @@ -159,7 +165,9 @@ const SETTLE_GIVE_UP_MS = 5 * 60_000; * catches a saved or shared view that looked away, and a drag that Cesium turns * into an in-place look below its trackball height whatever `enableLook` says. * Heading and position are kept. Tracked cameras, locked ones (non-identity - * transform) and the 2D and Columbus modes are left alone. `keyholeRadius` + * transform), a camera flight in progress, the 2D and Columbus modes, and any + * time `paused` returns true (an authored scene playing) are left alone. + * `keyholeRadius` * returns the radius of the visible circle when a scope mask hides the rest of * the frame (see `globeFrameShare`), or 0; it is read at every check. * @@ -179,6 +187,7 @@ export function installGlobeInViewRecovery( { minHeight = FREE_LOOK_MAX_HEIGHT_METERS, keyholeRadius = () => 0, + paused = () => false, setTimeout: schedule = globalThis.setTimeout, clearTimeout: unschedule = globalThis.clearTimeout, } = {}, @@ -189,9 +198,9 @@ export function installGlobeInViewRecovery( throw new TypeError('A complete Cesium viewer is required'); const recover = () => { - if (scene.mode !== Cesium.SceneMode.SCENE3D || viewer.trackedEntity) return; - if (!Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY)) - return; + if (!isFreeGlobeCamera(viewer)) return; + // Cesium has no public running-flight flag. + if (camera._currentFlight || paused()) return; const height = camera.positionCartographic?.height; if ( !(height > minHeight) || diff --git a/src/cameraGlobeRotation.test.mjs b/src/cameraGlobeRotation.test.mjs index f15ae4978..543c01bff 100644 --- a/src/cameraGlobeRotation.test.mjs +++ b/src/cameraGlobeRotation.test.mjs @@ -1526,7 +1526,7 @@ test('a voice turn after something else turned the camera starts over', (t) => { assert.ok(globeFrameShare(camera) >= start - 2 / (32 * 18) - 1e-9); }); -test('nudging the camera between voice turns cannot slide the planet away', (t) => { +test('nudging the camera between voice turns cannot take the planet below the 1% floor', (t) => { withScope(t, false); // the whole frame is visible // A wheel notch or an orbit drag between requests starts each turn afresh; // the 1% floor still holds, and below it a turn may only keep what it has. @@ -1810,6 +1810,32 @@ test('a refused voice turn leaves a tracked camera tracking', (t) => { } }); +test('a turn proven at one position is checked again after a release moves the camera', (t) => { + withScope(t, false); + const { camera, tick } = harness(t, { + pitch: -80, + viewportWidth: 1600, + viewportHeight: 900, + }); + // Tilt up to the limit, then check from 4,000 km closer along the same view + // direction, where the next tilt fits; the release moves the camera back. + for (let i = 0; i < 40; i += 1) { + if (!moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok) + break; + for (let j = 0; j < 600 && getActiveCameraMotion(); j += 1) tick(1 / 60); + } + camera.moveForward(4_000_000); + const result = moveCamera( + { motion: 'tilt', direction: 'up', mode: 'once' }, + (start) => { + camera.moveBackward(4_000_000); + return start(); + }, + ); + assert.equal(result.ok, false); + assert.match(result.error, /globe out of view/); +}); + test('the voice frame limit leaves Columbus view alone', (t) => { // At its limit in 3D a further tilt up is refused; the same tilt in Columbus // view is not limited by the frame. diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 964b5c1a7..3c8424b82 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -22,6 +22,7 @@ import * as Cesium from 'cesium'; import { GLOBE_VIEW } from './locations.js'; import { globeFrameShare, + isFreeGlobeCamera, lookAngleWithinFrame, lookStepLosesGlobe, } from './globeInView.js'; @@ -1015,18 +1016,6 @@ export function interruptCameraMotionIfActive( return interruptCameraMotion(reason); } -/** - * A free globe camera: 3D, not tracking, not locked to a frame. The frame - * limit for in-place turns applies to it alone, as the move-end recovery does. - */ -function isFreeGlobeCamera(cam) { - return ( - _viewer.scene.mode === Cesium.SceneMode.SCENE3D && - !_viewer.trackedEntity && - Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY) - ); -} - // The frame share that in-place voice turns measure their limit against, with // the pose the last voice turn left the camera in. A turn that starts where // the last one left off keeps that baseline (raised if the planet now fills @@ -1036,7 +1025,7 @@ function isFreeGlobeCamera(cam) { let _lookBaseline = null; // The pre-check's proof that a half-degree turn from this pose keeps the planet -// in view: which way, from which direction. A motion that starts from that +// in view: which way, from which position and direction. A motion that starts from that // pose turns at least that far, so a request reported as done really moves // even when a smaller first step lands in a dip of the sampled share. const PROVEN_TURN_RAD = Cesium.Math.toRadians(0.5); @@ -1079,7 +1068,7 @@ function lookBaselineShare(cam) { */ function lookWithinFrame(cam, m, look, angle) { let allowed = angle; - if (isFreeGlobeCamera(cam)) { + if (isFreeGlobeCamera(_viewer)) { if (m.globeShareAtStart === undefined) { m.globeShareAtStart = lookBaselineShare(cam); m.provenTurnRad = lookProven(cam, look) ? PROVEN_TURN_RAD : 0; @@ -1105,6 +1094,12 @@ function lookWithinFrame(cam, m, look, angle) { function lookProven(cam, look) { return ( _provenLook?.look === look && + Cesium.Cartesian3.equalsEpsilon( + _provenLook.position, + cam.positionWC, + 0, + 1, + ) && Cesium.Cartesian3.equalsEpsilon( _provenLook.direction, cam.directionWC, @@ -1129,7 +1124,7 @@ function checkLookFromOrbit(look, { released = false } = {}) { _provenLook = null; if (_viewer.scene.mode !== Cesium.SceneMode.SCENE3D) return null; const cam = _viewer.camera; - if (!released && !isFreeGlobeCamera(cam)) return null; + if (!released && !isFreeGlobeCamera(_viewer)) return null; if ( lookStepLosesGlobe(cam, look, PROVEN_TURN_RAD, { ...visibleFrame(), @@ -1142,7 +1137,11 @@ function checkLookFromOrbit(look, { released = false } = {}) { error: 'From this height that would turn the globe out of view — zoom in first, or turn the other way.', }; - _provenLook = { look, direction: Cesium.Cartesian3.clone(cam.directionWC) }; + _provenLook = { + look, + position: Cesium.Cartesian3.clone(cam.positionWC), + direction: Cesium.Cartesian3.clone(cam.directionWC), + }; return null; } @@ -1329,12 +1328,7 @@ function onTick() { // 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) - ) { + if (!isFreeGlobeCamera(_viewer) || _isCockpitActive?.()) { interruptCameraMotion('globe-camera-owner-changed'); return; } diff --git a/src/globeInView.js b/src/globeInView.js index 1ccbcda23..98a3445dc 100644 --- a/src/globeInView.js +++ b/src/globeInView.js @@ -9,6 +9,18 @@ export const FREE_LOOK_MAX_HEIGHT_METERS = 1_000_000; // the planet's surface. export const MIN_GLOBE_FRAME_SHARE = 0.1; +/** + * A free globe camera: the viewer is in 3D, follows no tracked entity, and its + * camera is not locked to a reference frame. + */ +export function isFreeGlobeCamera(viewer) { + return ( + viewer.scene.mode === Cesium.SceneMode.SCENE3D && + !viewer.trackedEntity && + Cesium.Matrix4.equals(viewer.camera.transform, Cesium.Matrix4.IDENTITY) + ); +} + const GRID_COLUMNS = 32; const GRID_ROWS = 18; // A turn across a sliver of planet moves its edge through sample points, so From 2f9d13f39d1b05d60aeded65e4a0334f13952734 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Thu, 5 Nov 2026 12:20:26 +0530 Subject: [PATCH 15/39] fix(camera): orbit on drags from the stars above 1,000 km Below Cesium's trackball height (7,500 km by default) a drag that starts on the stars turns the camera in place, whatever enableLook says, which was the drag path to a view with no planet in it. Lower that height to the free-look height with the free-look limit, never raising it, and restore it on dispose. Above 1,000 km such a drag now orbits the globe, as it already did above 7,500 km, at a similar rate, and a tilt drag from the stars there tilts about the planet's edge or does nothing instead of turning the camera. Drags that start on the planet are unchanged. --- docs/CURRENT-STATE.md | 9 +++++---- src/app/application.test.mjs | 23 ++++++++++++++++++++++- src/app/viewer.js | 17 +++++++++++------ 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 4244d5937..94c2dc1aa 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -67,7 +67,10 @@ the real browser controller at desktop and phone widths with keyless fixtures. Cesium's free look (Shift+drag, which turns the camera in place) works only below 1,000 km. Above that height a few drags could turn the whole planet out of -frame and leave only stars, so Shift+drag does nothing there. Orbit, tilt and +frame and leave only stars, so Shift+drag does nothing there. Cesium also +turns the camera in place for a drag that starts on the stars below its +trackball height (7,500 km by default); that height is lowered to 1,000 km, so +such a drag orbits the globe above it, as it does higher up. Orbit, tilt and zoom are unchanged at every height. How much of the frame shows the planet's surface is measured by casting a 32×18 grid of view rays at the ellipsoid; while the scope mask is on (the default) only the rays inside its keyhole count, since @@ -96,9 +99,7 @@ rest, and gives up five minutes after the camera last moved. Limb glow, and a surface sliver up to about one grid cell thick (some 50 px on a 900 px tall frame), can count as out of view. The zoom-out guard above keeps wheel and pinch zoom-outs in view; this catches the other ways a move can end looking -away, such as a saved or shared view that did, or a drag -that Cesium turns into an in-place look below its 7,500 km trackball height (one -that starts on the stars, or a tilt drag with the screen centre off the planet). +away, such as a saved or shared view that did. Views with more surface than that, such as the oblique orbital Director shots, are left as they are, and so are tracked or locked cameras, a camera flight in progress, a Director scene while it plays (holds included) and the 2D and diff --git a/src/app/application.test.mjs b/src/app/application.test.mjs index d9d915f20..fa489cc69 100644 --- a/src/app/application.test.mjs +++ b/src/app/application.test.mjs @@ -359,7 +359,7 @@ test('trackpad pinch falls back to the original event when relay construction fa function freeLookFixture({ enableLook = true, height = 0 } = {}) { const listeners = new Set(); - const controller = { enableLook }; + const controller = { enableLook, minimumTrackBallHeight: 7_500_000 }; const camera = { positionCartographic: { height } }; const viewer = { camera, @@ -399,6 +399,27 @@ test('free look follows camera height and is restored on dispose', () => { assert.equal(f.listeners.size, 0); }); +test('drags that start on the stars orbit above the free-look height', () => { + // Below Cesium's trackball height such a drag turns the camera in place. + const f = freeLookFixture({ height: 500 }); + const dispose = installFreeLookHeightLimit(f.viewer); + assert.equal( + f.controller.minimumTrackBallHeight, + FREE_LOOK_MAX_HEIGHT_METERS, + ); + dispose(); + assert.equal(f.controller.minimumTrackBallHeight, 7_500_000); + + const lower = freeLookFixture({ height: 500 }); + lower.controller.minimumTrackBallHeight = 500_000; + installFreeLookHeightLimit(lower.viewer)(); + assert.equal( + lower.controller.minimumTrackBallHeight, + 500_000, + 'never raised', + ); +}); + test('free look keeps a setting other code makes after it is installed', () => { const f = freeLookFixture({ height: 500 }); const dispose = installFreeLookHeightLimit(f.viewer); diff --git a/src/app/viewer.js b/src/app/viewer.js index c99a34d07..e44d1aa10 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -112,10 +112,10 @@ export function installTrackpadPinchZoom( * Allow Cesium's free look only below `maxHeight` and return its disposer. * Orbit, tilt and zoom stay available at every height. A value other code * sets later is kept as the one to allow below that height, and restored on - * dispose. `enableLook` gates the - * Shift+drag binding only; a drag that starts on the stars below Cesium's - * trackball height still turns the camera in place, which - * `installGlobeInViewRecovery` catches. + * dispose. `enableLook` gates the Shift+drag binding only: below its trackball + * height Cesium also turns the camera in place for a drag that starts on the + * stars. That height is lowered to `maxHeight` too, so above it such a drag + * orbits the globe, as it does above Cesium's own 7,500 km. */ export function installFreeLookHeightLimit( viewer, @@ -127,6 +127,9 @@ export function installFreeLookHeightLimit( if (!controller || !camera || !scene?.preUpdate) throw new TypeError('A complete Cesium viewer is required'); + const originalTrackBallHeight = controller.minimumTrackBallHeight; + const trackBallHeight = Math.min(originalTrackBallHeight, maxHeight); + controller.minimumTrackBallHeight = trackBallHeight; let wanted = controller.enableLook; let written; const sync = () => { @@ -145,6 +148,8 @@ export function installFreeLookHeightLimit( active = false; removeListener(); if (controller.enableLook === written) controller.enableLook = wanted; + if (controller.minimumTrackBallHeight === trackBallHeight) + controller.minimumTrackBallHeight = originalTrackBallHeight; }; } @@ -162,8 +167,8 @@ const SETTLE_GIVE_UP_MS = 5 * 60_000; * no surface on screen (`globeOutOfFrame`: limb glow and slivers up to about * one sample cell thick can count as none), and return the disposer. Views with * more surface, such as an oblique orbital shot, are left as they are. This - * catches a saved or shared view that looked away, and a drag that Cesium turns - * into an in-place look below its trackball height whatever `enableLook` says. + * catches a saved or shared view that looked away, or any other move that ends + * looking away from the planet. * Heading and position are kept. Tracked cameras, locked ones (non-identity * transform), a camera flight in progress, the 2D and Columbus modes, and any * time `paused` returns true (an authored scene playing) are left alone. From 29935cc094480308ef381a51bd9af14249911699 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Thu, 5 Nov 2026 14:36:10 +0530 Subject: [PATCH 16/39] fix(camera): run a recovery check a scene deferred once it ends A check skipped because a flight or a playing Director scene was in progress used to be dropped, so a run that ended or held on a stars-only shot stayed there until the next camera move. Keep watching instead: the settle check retries once the flight or scene ends, within its existing five-minute bound, and a moveEnd that arrives while it is deferred keeps the check armed rather than stopping it. Also test that the free-look limit never raises Cesium's trackball height and that dispose keeps a value set after install, note in its JSDoc the enableLook case it cannot tell apart, and document the trackball change and the retry in CURRENT-STATE and the changelog. --- changelog.d/1532-globe-in-view.md | 11 +++++---- docs/CURRENT-STATE.md | 32 +++++++++++++------------ src/app/application.test.mjs | 39 ++++++++++++++++++++++++++++++- src/app/viewer.js | 25 ++++++++++++-------- 4 files changed, 76 insertions(+), 31 deletions(-) diff --git a/changelog.d/1532-globe-in-view.md b/changelog.d/1532-globe-in-view.md index f697959b2..676dfebf3 100644 --- a/changelog.d/1532-globe-in-view.md +++ b/changelog.d/1532-globe-in-view.md @@ -1,8 +1,9 @@ - Keep the globe in view from orbit. Cesium's free look (Shift+drag) turned the camera in place, so a few drags from the whole-globe view left only stars, and the pose stayed in the share link. Free look now works only below - 1,000 km; a camera move that ends above it with no planet surface in view - (inside the scope keyhole when the scope is on) turns straight down; and - voice tilt and turns from orbit stop while the planet is still on screen and - say so instead of reporting a turn they could not make (Manjunath, - @manjunath22466, #1532). + 1,000 km, and above it a drag that starts on the stars orbits the globe + instead of turning the camera; a camera move that ends above it with no + planet surface in view (inside the scope keyhole when the scope is on) turns + straight down; and voice tilt and turns from orbit stop while the planet is + still on screen and say so instead of reporting a turn they could not make + (Manjunath, @manjunath22466, #1532). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 94c2dc1aa..6fbf52a1e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -67,10 +67,11 @@ the real browser controller at desktop and phone widths with keyless fixtures. Cesium's free look (Shift+drag, which turns the camera in place) works only below 1,000 km. Above that height a few drags could turn the whole planet out of -frame and leave only stars, so Shift+drag does nothing there. Cesium also -turns the camera in place for a drag that starts on the stars below its -trackball height (7,500 km by default); that height is lowered to 1,000 km, so -such a drag orbits the globe above it, as it does higher up. Orbit, tilt and +frame and leave only stars, so Shift+drag does nothing there. Cesium also turns +the camera in place for a drag that starts on the stars below its trackball +height (7,500 km by default); that height is lowered to 1,000 km, so such a drag +orbits the globe above it, as it does higher up, and a tilt drag from the stars +there tilts about the planet's edge or does nothing. Otherwise orbit, tilt and zoom are unchanged at every height. How much of the frame shows the planet's surface is measured by casting a 32×18 grid of view rays at the ellipsoid; while the scope mask is on (the default) only the rays inside its keyhole count, since @@ -97,17 +98,18 @@ rounding error every frame and Cesium never sees it stop. That check keeps watching until move-end arrives, recovering once after each move that comes to rest, and gives up five minutes after the camera last moved. Limb glow, and a surface sliver up to about one grid cell thick (some 50 px on a 900 px tall -frame), can count as out of view. The zoom-out guard above keeps wheel and -pinch zoom-outs in view; this catches the other ways a move can end looking -away, such as a saved or shared view that did. -Views with more surface than that, such as the oblique orbital Director shots, -are left as they are, and so are tracked or locked cameras, a camera flight in -progress, a Director scene while it plays (holds included) and the 2D and -Columbus modes. A view that loses its surface without a camera move (a resize, -or turning the scope on) is left as it is until the next move. A voice turn -requested while the camera is tracked is judged -by the pose voice's release of the follow camera will leave, before that -release, so a refused turn keeps the tracking. +frame), can count as out of view. The zoom-out guard above keeps wheel and pinch +zoom-outs in view; this catches the other ways a move can end looking away, such +as a saved or shared view that did. Views with more surface than that, such as +the oblique orbital Director shots, are left as they are, and so are tracked or +locked cameras, a camera flight in progress, a Director scene while it plays +(holds included) and the 2D and Columbus modes; a check a flight or scene defers +runs once it ends. A Director shot loaded or replayed outside a run is not a +playing scene. A view that loses its surface without a camera move (a resize, or +turning the scope on) is left as it is until the next move. A voice turn +requested while the camera is tracked is judged by the pose voice's release of +the follow camera will leave, before that release, so a refused turn keeps the +tracking. Analyst region queries resolve unique Natural Earth names offline without geocoding, so Gold Coast and Ivory Coast name the West African natural regions. diff --git a/src/app/application.test.mjs b/src/app/application.test.mjs index fa489cc69..3e4a359e7 100644 --- a/src/app/application.test.mjs +++ b/src/app/application.test.mjs @@ -412,12 +412,24 @@ test('drags that start on the stars orbit above the free-look height', () => { const lower = freeLookFixture({ height: 500 }); lower.controller.minimumTrackBallHeight = 500_000; - installFreeLookHeightLimit(lower.viewer)(); + const disposeLower = installFreeLookHeightLimit(lower.viewer); assert.equal( lower.controller.minimumTrackBallHeight, 500_000, 'never raised', ); + disposeLower(); + assert.equal(lower.controller.minimumTrackBallHeight, 500_000); + + const later = freeLookFixture({ height: 500 }); + const disposeLater = installFreeLookHeightLimit(later.viewer); + later.controller.minimumTrackBallHeight = 2_000_000; + disposeLater(); + assert.equal( + later.controller.minimumTrackBallHeight, + 2_000_000, + 'dispose keeps a value set after install', + ); }); test('free look keeps a setting other code makes after it is installed', () => { @@ -739,6 +751,31 @@ test('the recovery leaves a camera flight and an authored scene alone', () => { assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6)); }); +test('a check a scene or flight defers runs once it ends', () => { + for (const viaMoveEnd of [false, true]) { + let playing = true; + const timers = fakeTimers(); + const { camera, viewer } = realCamera({ width: 1225, height: 923 }); + installGlobeInViewRecovery(viewer, { + paused: () => playing, + ...timers, + }); + aim(camera, reportedStuckView); + camera.moveStart.raiseEvent(); + // A hold on a stars-only shot: quiet, with or without Cesium's moveEnd. + for (let i = 0; i < 4; i += 1) timers.tick(); + if (viaMoveEnd) camera.moveEnd.raiseEvent(); + assert.equal(globeOutOfFrame(camera), true, 'left alone while it plays'); + assert.equal(timers.pending(), 1, 'still watching'); + playing = false; + timers.tick(); + assert.ok( + Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6), + `recovered once the scene ends (${viaMoveEnd ? 'after moveEnd' : 'no moveEnd'})`, + ); + } +}); + function fakeTimers() { let next = 1; const pending = new Map(); diff --git a/src/app/viewer.js b/src/app/viewer.js index e44d1aa10..1bc94ddd8 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -112,7 +112,9 @@ export function installTrackpadPinchZoom( * Allow Cesium's free look only below `maxHeight` and return its disposer. * Orbit, tilt and zoom stay available at every height. A value other code * sets later is kept as the one to allow below that height, and restored on - * dispose. `enableLook` gates the Shift+drag binding only: below its trackball + * dispose; a `false` written while the camera is above `maxHeight` cannot be + * told apart from this limit's own write, so dispose restores the earlier + * value then. `enableLook` gates the Shift+drag binding only: below its trackball * height Cesium also turns the camera in place for a drag that starts on the * stars. That height is lowered to `maxHeight` too, so above it such a drag * orbits the globe, as it does above Cesium's own 7,500 km. @@ -171,7 +173,8 @@ const SETTLE_GIVE_UP_MS = 5 * 60_000; * looking away from the planet. * Heading and position are kept. Tracked cameras, locked ones (non-identity * transform), a camera flight in progress, the 2D and Columbus modes, and any - * time `paused` returns true (an authored scene playing) are left alone. + * time `paused` returns true (an authored scene playing) are left alone; a + * check they defer runs again once they end, within the settle check's bound. * `keyholeRadius` * returns the radius of the visible circle when a scope mask hides the rest of * the frame (see `globeFrameShare`), or 0; it is read at every check. @@ -202,16 +205,18 @@ export function installGlobeInViewRecovery( if (!scene || !camera?.moveEnd || !camera?.moveStart) throw new TypeError('A complete Cesium viewer is required'); + // Returns false while a flight or a paused scene defers the check, so the + // caller can try again once it ends. const recover = () => { - if (!isFreeGlobeCamera(viewer)) return; + if (!isFreeGlobeCamera(viewer)) return true; // Cesium has no public running-flight flag. - if (camera._currentFlight || paused()) return; + if (camera._currentFlight || paused()) return false; const height = camera.positionCartographic?.height; if ( !(height > minHeight) || !globeOutOfFrame(camera, { keyholeRadius: keyholeRadius() }) ) - return; + return true; camera.setView({ orientation: { heading: camera.heading, @@ -220,6 +225,7 @@ export function installGlobeInViewRecovery( }, }); scene.requestRender?.(); + return true; }; const lastPosition = new Cesium.Cartesian3(); @@ -255,10 +261,8 @@ export function installGlobeInViewRecovery( } // Two quiet samples in a row after a move: at rest for half a second, as // for moveEnd. - if (restingSamples >= 2 && movedSinceCheck) { + if (restingSamples >= 2 && movedSinceCheck && recover()) movedSinceCheck = false; - recover(); - } if (settleElapsed >= SETTLE_GIVE_UP_MS) return; Cesium.Cartesian3.clone(camera.positionWC, lastPosition); Cesium.Cartesian3.clone(camera.directionWC, lastDirection); @@ -274,8 +278,9 @@ export function installGlobeInViewRecovery( settleTimer = schedule(sample, SETTLE_SAMPLE_MS); }; const removeMoveEnd = camera.moveEnd.addEventListener(() => { - stopSettling(); - recover(); + if (recover()) stopSettling(); + // Deferred: keep sampling so the check runs once the flight or scene ends. + else if (settleTimer === null) startSettling(); }); const removeMoveStart = camera.moveStart.addEventListener(startSettling); From 852e93d074761477575cbead33f2f12dd71c7bbf Mon Sep 17 00:00:00 2001 From: Manjunath Date: Thu, 5 Nov 2026 14:45:29 +0530 Subject: [PATCH 17/39] fix(camera): keep a deferred recovery check through long holds A Director hold longer than the settle check's five-minute bound used to expire the deferred check, so a run that ended on a stars-only shot after a long hold stayed there until the next move. While a flight or a playing scene defers the check, its bound no longer counts down; the flight or scene bounds the deferral itself. Also correct the trackball note in CURRENT-STATE: Cesium's tilt tests the screen centre, so any tilt (drag or touch) with the centre on the stars between 1,000 and 7,500 km now tilts about the planet's edge or does nothing, wherever it starts. Rotation drags that start on the planet are unchanged. --- docs/CURRENT-STATE.md | 80 ++++++++++++++++++------------------ src/app/application.test.mjs | 2 + src/app/viewer.js | 9 ++-- 3 files changed, 48 insertions(+), 43 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 6fbf52a1e..3dd7a59d0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -70,46 +70,46 @@ below 1,000 km. Above that height a few drags could turn the whole planet out of frame and leave only stars, so Shift+drag does nothing there. Cesium also turns the camera in place for a drag that starts on the stars below its trackball height (7,500 km by default); that height is lowered to 1,000 km, so such a drag -orbits the globe above it, as it does higher up, and a tilt drag from the stars -there tilts about the planet's edge or does nothing. Otherwise orbit, tilt and -zoom are unchanged at every height. How much of the frame shows the planet's -surface is measured by casting a 32×18 grid of view rays at the ellipsoid; while -the scope mask is on (the default) only the rays inside its keyhole count, since -the mask hides or dims the rest. Voice tilt and in-place turns of a free 3D -camera above 1,000 km stop when about a tenth of the frame still shows the -surface (or about as much as it showed when it started), never less than about -1% of the visible frame, and a view that already shows less only keeps or gains -what it has. A turn that cannot go half a degree further without crossing that -limit is refused with a reason instead of reported as done, and a turn it -accepts turns at least that half degree. A turn that keeps or gains surface is -not refused, even after the window has been resized, except that near the limit, -where the planet's edge crosses the sample grid, sampling wobble can -occasionally refuse one (most often above about 15,000 km and on phone or -ultrawide frames); nothing then turns away from the planet. A turn that starts -where the last voice turn left the camera keeps that turn's limit, or a higher -one if the planet now fills more, so repeating the request does not take any -more; once anything else has moved or turned the camera, a turn is measured from -what is on screen. When a camera move ends above 1,000 km with no ray on the -surface, the camera turns straight down at the same position and heading. The -end of a move is taken from Cesium's move-end event and from a check of its own -that samples the pose every 250 ms after a move starts and acts once it has held -for half a second, because at some of these poses the camera drifts by a -rounding error every frame and Cesium never sees it stop. That check keeps -watching until move-end arrives, recovering once after each move that comes to -rest, and gives up five minutes after the camera last moved. Limb glow, and a -surface sliver up to about one grid cell thick (some 50 px on a 900 px tall -frame), can count as out of view. The zoom-out guard above keeps wheel and pinch -zoom-outs in view; this catches the other ways a move can end looking away, such -as a saved or shared view that did. Views with more surface than that, such as -the oblique orbital Director shots, are left as they are, and so are tracked or -locked cameras, a camera flight in progress, a Director scene while it plays -(holds included) and the 2D and Columbus modes; a check a flight or scene defers -runs once it ends. A Director shot loaded or replayed outside a run is not a -playing scene. A view that loses its surface without a camera move (a resize, or -turning the scope on) is left as it is until the next move. A voice turn -requested while the camera is tracked is judged by the pose voice's release of -the follow camera will leave, before that release, so a refused turn keeps the -tracking. +orbits the globe above it, as it does higher up, and a tilt (drag or touch) +while the screen centre is on the stars there tilts about the planet's edge or +does nothing, wherever it starts. Rotation drags that start on the planet, and +zoom, are unchanged. How much of the frame shows the planet's surface is +measured by casting a 32×18 grid of view rays at the ellipsoid; while the scope +mask is on (the default) only the rays inside its keyhole count, since the mask +hides or dims the rest. Voice tilt and in-place turns of a free 3D camera above +1,000 km stop when about a tenth of the frame still shows the surface (or about +as much as it showed when it started), never less than about 1% of the visible +frame, and a view that already shows less only keeps or gains what it has. A +turn that cannot go half a degree further without crossing that limit is refused +with a reason instead of reported as done, and a turn it accepts turns at least +that half degree. A turn that keeps or gains surface is not refused, even after +the window has been resized, except that near the limit, where the planet's edge +crosses the sample grid, sampling wobble can occasionally refuse one (most often +above about 15,000 km and on phone or ultrawide frames); nothing then turns away +from the planet. A turn that starts where the last voice turn left the camera +keeps that turn's limit, or a higher one if the planet now fills more, so +repeating the request does not take any more; once anything else has moved or +turned the camera, a turn is measured from what is on screen. When a camera move +ends above 1,000 km with no ray on the surface, the camera turns straight down +at the same position and heading. The end of a move is taken from Cesium's +move-end event and from a check of its own that samples the pose every 250 ms +after a move starts and acts once it has held for half a second, because at some +of these poses the camera drifts by a rounding error every frame and Cesium +never sees it stop. That check keeps watching until move-end arrives, recovering +once after each move that comes to rest, and gives up five minutes after the +camera last moved. Limb glow, and a surface sliver up to about one grid cell +thick (some 50 px on a 900 px tall frame), can count as out of view. The +zoom-out guard above keeps wheel and pinch zoom-outs in view; this catches the +other ways a move can end looking away, such as a saved or shared view that did. +Views with more surface than that, such as the oblique orbital Director shots, +are left as they are, and so are tracked or locked cameras, a camera flight in +progress, a Director scene while it plays (holds included) and the 2D and +Columbus modes; a check a flight or scene defers runs once it ends. A Director +shot loaded or replayed outside a run is not a playing scene. A view that loses +its surface without a camera move (a resize, or turning the scope on) is left as +it is until the next move. A voice turn requested while the camera is tracked is +judged by the pose voice's release of the follow camera will leave, before that +release, so a refused turn keeps the tracking. Analyst region queries resolve unique Natural Earth names offline without geocoding, so Gold Coast and Ivory Coast name the West African natural regions. diff --git a/src/app/application.test.mjs b/src/app/application.test.mjs index 3e4a359e7..da01ff61c 100644 --- a/src/app/application.test.mjs +++ b/src/app/application.test.mjs @@ -765,6 +765,8 @@ test('a check a scene or flight defers runs once it ends', () => { // A hold on a stars-only shot: quiet, with or without Cesium's moveEnd. for (let i = 0; i < 4; i += 1) timers.tick(); if (viaMoveEnd) camera.moveEnd.raiseEvent(); + // Longer than the settle check's five-minute bound. + for (let i = 0; i < 1500; i += 1) timers.tick(); assert.equal(globeOutOfFrame(camera), true, 'left alone while it plays'); assert.equal(timers.pending(), 1, 'still watching'); playing = false; diff --git a/src/app/viewer.js b/src/app/viewer.js index 1bc94ddd8..ef36feb8a 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -174,7 +174,7 @@ const SETTLE_GIVE_UP_MS = 5 * 60_000; * Heading and position are kept. Tracked cameras, locked ones (non-identity * transform), a camera flight in progress, the 2D and Columbus modes, and any * time `paused` returns true (an authored scene playing) are left alone; a - * check they defer runs again once they end, within the settle check's bound. + * check they defer runs once they end, however long they last. * `keyholeRadius` * returns the radius of the visible circle when a scope mask hides the rest of * the frame (see `globeFrameShare`), or 0; it is read at every check. @@ -261,8 +261,11 @@ export function installGlobeInViewRecovery( } // Two quiet samples in a row after a move: at rest for half a second, as // for moveEnd. - if (restingSamples >= 2 && movedSinceCheck && recover()) - movedSinceCheck = false; + if (restingSamples >= 2 && movedSinceCheck) { + if (recover()) movedSinceCheck = false; + // A flight or a playing scene bounds its own deferral. + else settleElapsed = 0; + } if (settleElapsed >= SETTLE_GIVE_UP_MS) return; Cesium.Cartesian3.clone(camera.positionWC, lastPosition); Cesium.Cartesian3.clone(camera.directionWC, lastDirection); From ef4b319f975dcd1515997101226311127867267e Mon Sep 17 00:00:00 2001 From: Manjunath Date: Thu, 5 Nov 2026 15:57:09 +0530 Subject: [PATCH 18/39] docs(changelog): credit the review behind the follow-ups --- changelog.d/1532-globe-in-view.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/changelog.d/1532-globe-in-view.md b/changelog.d/1532-globe-in-view.md index 676dfebf3..4ac444023 100644 --- a/changelog.d/1532-globe-in-view.md +++ b/changelog.d/1532-globe-in-view.md @@ -5,5 +5,7 @@ instead of turning the camera; a camera move that ends above it with no planet surface in view (inside the scope keyhole when the scope is on) turns straight down; and voice tilt and turns from orbit stop while the planet is - still on screen and say so instead of reporting a turn they could not make - (Manjunath, @manjunath22466, #1532). + still on screen and say so instead of reporting a turn they could not make. + Thanks to [Chad Elliott](https://github.com/THEtheChad) for the review that + closed the drag path and kept authored scenes framed (Manjunath, + @manjunath22466, #1532). From 14f88b3e08f61f5b069660dd593471544d3c93db Mon Sep 17 00:00:00 2001 From: Yehe Liu Date: Thu, 5 Nov 2026 16:07:16 -0500 Subject: [PATCH 19/39] feat: Wildlife Sightings layer from iNaturalist and Acartia Recent community wildlife observations, as a new Wildlife group in the layer panel. - Server: keyless iNaturalist proxy (v2 observations with only the projected fields; undocumented v1 UTFGrid density tiles, decoded on the server) sharing one paced queue (burst 4, ~1/s, 60/min, 8,000/day budget, up to four in flight), in-memory caches and upstream-only cooldowns; Acartia Salish Sea marine-mammal feed rounded to 0.01 degrees with comments dropped. - Layer: grid counts drawn as a soft view-ranked density glow; real sampled sightings as taxon badges at their true positions with ambient photo thumbnails and labels; obscured records stay privacy cells; a clicked sighting stays pinned through camera moves and refreshes. - Anchored photo card per selection with the single iNaturalist link; attribution in the layer row and the Data attribution popover. - Filters (CONFIRMED, taxa presets) round-trip in share links with token 9. Co-Authored-By: Claude Opus 5.5 --- DATA_SOURCES.md | 5 + README.md | 3 +- SECURITY.md | 2 +- changelog.d/0000-wildlife-sightings.md | 12 + docs/CURRENT-STATE.md | 90 + docs/KNOWN-ISSUES.md | 16 + index.html | 2 +- package.json | 7 +- scripts/format-scope.json | 86 +- scripts/package-boundaries.json | 157 +- scripts/run-perf-tests.mjs | 59 + server/providers/acartia.js | 179 ++ server/providers/acartia.test.mjs | 381 +++ server/providers/common/userAgent.js | 4 + server/providers/inaturalist/client.js | 370 +++ server/providers/inaturalist/client.test.mjs | 163 ++ server/providers/inaturalist/grid.fixture.mjs | 51 + server/providers/inaturalist/grid.js | 127 + server/providers/inaturalist/grid.test.mjs | 374 +++ server/providers/inaturalist/observations.js | 343 +++ .../inaturalist/observations.test.mjs | 893 +++++++ .../providers/inaturalist/proxy.fixture.mjs | 70 + server/providers/inaturalist/queue.js | 109 + server/providers/inaturalist/queue.test.mjs | 111 + server/providers/inaturalist/speed.test.mjs | 218 ++ server/providers/local.js | 4 + src/app/catalog.js | 1 + src/app/catalog.test.mjs | 2 + src/app/constructCatalog.js | 5 + src/app/constructCatalog.test.mjs | 4 +- src/app/layers/wildlifeSightings.js | 30 + src/app/sourceComposition.js | 1 + src/app/sourceComposition.test.mjs | 2 +- src/data/dataCredits.js | 12 + src/data/layerState.js | 23 + src/data/layerState.test.mjs | 41 +- src/data/layerStateTokenLedger.test.mjs | 40 +- src/data/layerStateTokenReservations.json | 1 + src/data/spriteOrder.js | 1 + src/i18n/locales/ar.json | 59 +- src/i18n/locales/de.json | 59 +- src/i18n/locales/es.json | 59 +- src/i18n/locales/fr.json | 59 +- src/i18n/locales/it.json | 59 +- src/i18n/locales/ja.json | 59 +- src/i18n/locales/pt.json | 59 +- src/i18n/locales/ru.json | 59 +- src/i18n/locales/uk.json | 59 +- src/i18n/locales/zh-CN.json | 59 +- src/i18n/locales/zh-Hant.json | 59 +- src/i18n/same-as-english.json | 76 +- src/layers/wildlifeSightings/acartiaTaxa.js | 155 ++ .../wildlifeSightings/acartiaTaxa.test.mjs | 37 + .../wildlifeSightings/acquisition.test.mjs | 944 +++++++ .../wildlifeSightings/billboardModel.js | 114 + src/layers/wildlifeSightings/bounds.js | 51 + .../cache.integration.test.mjs | 111 + src/layers/wildlifeSightings/cards.js | 281 +++ src/layers/wildlifeSightings/cards.test.mjs | 627 +++++ src/layers/wildlifeSightings/controls.js | 250 ++ src/layers/wildlifeSightings/density.js | 462 ++++ src/layers/wildlifeSightings/density.test.mjs | 508 ++++ src/layers/wildlifeSightings/docs.test.mjs | 111 + src/layers/wildlifeSightings/gridViewport.js | 178 ++ .../wildlifeSightings/gridViewport.test.mjs | 142 ++ src/layers/wildlifeSightings/index.js | 130 + src/layers/wildlifeSightings/lifecycle.js | 804 ++++++ src/layers/wildlifeSightings/model.js | 208 ++ .../wildlifeSightings/model.perf.test.mjs | 76 + src/layers/wildlifeSightings/model.test.mjs | 162 ++ .../wildlifeSightings/ownership.test.mjs | 2224 +++++++++++++++++ src/layers/wildlifeSightings/performance.js | 115 + .../wildlifeSightings/performance.test.mjs | 299 +++ src/layers/wildlifeSightings/photoText.js | 36 + src/layers/wildlifeSightings/photos.js | 183 ++ src/layers/wildlifeSightings/photos.test.mjs | 147 ++ src/layers/wildlifeSightings/policy.js | 142 ++ src/layers/wildlifeSightings/records.js | 350 +++ src/layers/wildlifeSightings/records.test.mjs | 542 ++++ .../wildlifeSightings/render.perf.test.mjs | 166 ++ .../renderPerformanceFixtures.mjs | 185 ++ src/layers/wildlifeSightings/rendering.js | 691 +++++ .../wildlifeSightings/rendering.test.mjs | 544 ++++ src/layers/wildlifeSightings/selection.js | 560 +++++ .../wildlifeSightings/selection.test.mjs | 301 +++ src/layers/wildlifeSightings/source.js | 557 +++++ src/layers/wildlifeSightings/source.test.mjs | 816 ++++++ src/layers/wildlifeSightings/sprites.js | 136 + src/layers/wildlifeSightings/sprites.test.mjs | 189 ++ src/layers/wildlifeSightings/viewport.js | 83 + .../wildlifeSightings/viewport.test.mjs | 136 + src/materialSymbolsSubset.test.mjs | 3 + src/navigationPolicy.js | 2 + src/overlays/wildlifeSightingOverlay.test.mjs | 186 ++ src/overlays/worldOverlay.js | 203 +- src/overlays/worldOverlay.test.mjs | 174 ++ src/overlays/worldOverlayChrome.js | 29 + src/overlays/worldOverlayDraw.js | 115 +- src/overlays/worldOverlayDraw.test.mjs | 474 +++- src/perfTestRunner.test.mjs | 81 + src/share/viewCodec.js | 1 + src/sources/rateLimit.js | 19 +- src/sources/rateLimit.test.mjs | 23 + src/sources/reference.js | 2 + src/sources/reference.test.mjs | 3 + src/testSupport/wildlifeBillboardModel.mjs | 82 + src/tooling/previewServing.test.mjs | 4 + src/tooling/transitQa.test.mjs | 2 +- src/ui/applicationShell.js | 16 + src/ui/layerNames.js | 1 + src/ui/layerPanel.js | 4 + src/ui/layerPanel.test.mjs | 21 + src/ui/styles/cyber.css | 9 +- src/ui/views/panels/WildlifeSightingCard.vue | 131 + src/ui/wildlifeSightingCard.js | 270 ++ src/ui/wildlifeSightingCard.test.mjs | 767 ++++++ src/ui/wildlifeSightingPlacement.js | 274 ++ src/voice/layerManifest.js | 4 +- src/worldFocus.js | 8 +- src/worldFocus.test.mjs | 160 +- 120 files changed, 21260 insertions(+), 285 deletions(-) create mode 100644 changelog.d/0000-wildlife-sightings.md create mode 100644 scripts/run-perf-tests.mjs create mode 100644 server/providers/acartia.js create mode 100644 server/providers/acartia.test.mjs create mode 100644 server/providers/common/userAgent.js create mode 100644 server/providers/inaturalist/client.js create mode 100644 server/providers/inaturalist/client.test.mjs create mode 100644 server/providers/inaturalist/grid.fixture.mjs create mode 100644 server/providers/inaturalist/grid.js create mode 100644 server/providers/inaturalist/grid.test.mjs create mode 100644 server/providers/inaturalist/observations.js create mode 100644 server/providers/inaturalist/observations.test.mjs create mode 100644 server/providers/inaturalist/proxy.fixture.mjs create mode 100644 server/providers/inaturalist/queue.js create mode 100644 server/providers/inaturalist/queue.test.mjs create mode 100644 server/providers/inaturalist/speed.test.mjs create mode 100644 src/app/layers/wildlifeSightings.js create mode 100644 src/layers/wildlifeSightings/acartiaTaxa.js create mode 100644 src/layers/wildlifeSightings/acartiaTaxa.test.mjs create mode 100644 src/layers/wildlifeSightings/acquisition.test.mjs create mode 100644 src/layers/wildlifeSightings/billboardModel.js create mode 100644 src/layers/wildlifeSightings/bounds.js create mode 100644 src/layers/wildlifeSightings/cache.integration.test.mjs create mode 100644 src/layers/wildlifeSightings/cards.js create mode 100644 src/layers/wildlifeSightings/cards.test.mjs create mode 100644 src/layers/wildlifeSightings/controls.js create mode 100644 src/layers/wildlifeSightings/density.js create mode 100644 src/layers/wildlifeSightings/density.test.mjs create mode 100644 src/layers/wildlifeSightings/docs.test.mjs create mode 100644 src/layers/wildlifeSightings/gridViewport.js create mode 100644 src/layers/wildlifeSightings/gridViewport.test.mjs create mode 100644 src/layers/wildlifeSightings/index.js create mode 100644 src/layers/wildlifeSightings/lifecycle.js create mode 100644 src/layers/wildlifeSightings/model.js create mode 100644 src/layers/wildlifeSightings/model.perf.test.mjs create mode 100644 src/layers/wildlifeSightings/model.test.mjs create mode 100644 src/layers/wildlifeSightings/ownership.test.mjs create mode 100644 src/layers/wildlifeSightings/performance.js create mode 100644 src/layers/wildlifeSightings/performance.test.mjs create mode 100644 src/layers/wildlifeSightings/photoText.js create mode 100644 src/layers/wildlifeSightings/photos.js create mode 100644 src/layers/wildlifeSightings/photos.test.mjs create mode 100644 src/layers/wildlifeSightings/policy.js create mode 100644 src/layers/wildlifeSightings/records.js create mode 100644 src/layers/wildlifeSightings/records.test.mjs create mode 100644 src/layers/wildlifeSightings/render.perf.test.mjs create mode 100644 src/layers/wildlifeSightings/renderPerformanceFixtures.mjs create mode 100644 src/layers/wildlifeSightings/rendering.js create mode 100644 src/layers/wildlifeSightings/rendering.test.mjs create mode 100644 src/layers/wildlifeSightings/selection.js create mode 100644 src/layers/wildlifeSightings/selection.test.mjs create mode 100644 src/layers/wildlifeSightings/source.js create mode 100644 src/layers/wildlifeSightings/source.test.mjs create mode 100644 src/layers/wildlifeSightings/sprites.js create mode 100644 src/layers/wildlifeSightings/sprites.test.mjs create mode 100644 src/layers/wildlifeSightings/viewport.js create mode 100644 src/layers/wildlifeSightings/viewport.test.mjs create mode 100644 src/overlays/wildlifeSightingOverlay.test.mjs create mode 100644 src/overlays/worldOverlayChrome.js create mode 100644 src/perfTestRunner.test.mjs create mode 100644 src/sources/rateLimit.test.mjs create mode 100644 src/testSupport/wildlifeBillboardModel.mjs create mode 100644 src/ui/views/panels/WildlifeSightingCard.vue create mode 100644 src/ui/wildlifeSightingCard.js create mode 100644 src/ui/wildlifeSightingCard.test.mjs create mode 100644 src/ui/wildlifeSightingPlacement.js diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index ed0895b0a..1153015b8 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -41,6 +41,8 @@ How to read this: | **S2 Underground GhostMaps** (`github.com/s2underground/GhostMaps`) | S2 GhostMaps layer: the current edition of S2's KMZ intel products (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran and Yemen products, newest incident drop). Keyless. `/api/ghostmaps` reads the branch head from git's ref advertisement every 15 s (`GHOSTMAPS_POLL_SECONDS`, minimum 5 s), lists the tree through the GitHub REST API only when the head changes (optional `GITHUB_TOKEN`), downloads files by commit-pinned raw URLs into `.gev-cache/ghostmaps/` and pushes new catalogs to browsers over server-sent events | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) (repository README: "S2 Underground © 2025 by S2A1"); non-commercial, attribution required; files are fetched at runtime, never stored in this repository | "S2 GhostMaps intel products: S2 Underground © by S2A1, CC BY-NC-SA 4.0" — shown in-app | | **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" | | **FAA Special Use Airspace** (`services6.arcgis.com` FeatureServer `Special_Use_Airspace`) | Prohibited & Restricted Airspace layer: US prohibited (P) and restricted (R) special-use airspace polygons (not the UAS Facility Map or UAS flight restrictions), altitude limits from the `*_VAL`/`*_UOM`/`*_CODE` fields, times of use, and controlling agency | U.S. public domain (FAA aeronautical data published through the [FAA AIS Open Data portal](https://adds-faa.opendata.arcgis.com/)); fetched keyless through `/api/drone-airspace` (1-hour server cache). Informational only — not a substitute for official UAS flight-planning tools | "Prohibited & restricted airspace: FAA Special Use Airspace" | +| **iNaturalist API** (`api.inaturalist.org/v2/observations`, `/v1/grid/{z}/{x}/{y}.grid.json`) | Wildlife Sightings layer: verifiable wildlife observed within the last 48 h (whales 7 d), by observation time, in the current view, with taxon, observation time, quality grade and eligible photo | Per-record licences; observations default CC BY-NC. Photos CC0/BY/BY-SA/BY-NC/BY-NC-SA shown; ND and all-rights-reserved photos link-only. Keyless via `/api/inaturalist/observations` (5-min cache; global sample 10 min; stale ≤ 1 h; 60 requests/min and 8,000/day upstream caps). **NonCommercial content:** commercial redistributors must filter to CC0/BY/BY-SA or remove the layer. No AI training on this content | "Wildlife sightings: iNaturalist contributors (licences per record; photos credited on each card)" | +| **Acartia** (`acartia.io/api/v1/sightings/current`) | Wildlife Sightings layer: Salish Sea marine mammal reports with validated species, observation time, reporting provider and trust flag; WHALES includes only the frozen cetacean list | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/), each provider credited per sighting. Keyless via `/api/acartia/sightings` (10-min cache; stale ≤ 24 h). Positions rounded to 0.01° on the server; free-text comments excluded. Marine-conservation use; Be Whale Wise | "Wildlife sightings: Acartia (CC BY 4.0; each provider credited per sighting) · Be Whale Wise" | | **OpenFreeMap / OpenMapTiles** | Vector tiles | OpenStreetMap data under ODbL 1.0 | OpenFreeMap © OpenMapTiles | | **Overture Maps Foundation** | Military area names pack | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | Overture Maps Foundation | | **TomTom Traffic API** (flow vector tiles) | Traffic roads and live congestion (optional, BYOK) | [TomTom for Developers terms](https://docs.tomtom.com/legal/terms-and-conditions) (proprietary, your own key; free tier currently 200K tile requests/month — see [current pricing](https://docs.tomtom.com/pricing/)) | "Traffic flow data © TomTom" — registered when live mode activates | @@ -195,6 +197,9 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Re:Earth Terrain.** Keyless (no API key). Used two ways: (1) `src/mapStackController.js` swaps in a `Cesium.CesiumTerrainProvider` pointed at Re:Earth's `cesium-mesh/ellipsoid` quantized-mesh endpoint for globe stacks without a Cesium ion token (e.g. OSM), replacing a flat `EllipsoidTerrainProvider`; falls back to the flat provider if the endpoint can't be reached. (2) The server-side `/api/terrain/heights` proxy (disk-cached, serve-stale) resolves per-point ellipsoidal ground height for entity placement. Both are best-effort with a keyless-safe fallback (bundled EGM96 geoid math) if Re:Earth is unreachable. - **OSRM on the FOSSGIS routing servers.** Keyless (no API key). Every route is requested by a person: clicking A and B in the Directions layer, or a voice "route from A to B". The server-side `/api/route` proxy sends an identifying `User-Agent`, pins the upstream host, accepts 2-12 coordinates, rejects any leg over 600 km or a total over 2,500 km, caps the buffered response at 8 MB, times the upstream out at 12 s, rate-limits per client, caches each route for 10 minutes, and coalesces identical requests that are in flight at the same time. Turn-by-turn maneuvers are asked for only when a caller wants them (`steps=1`), so the callers that do not read them send the smaller request. The "fix the map" link the policy asks for is in the Data attribution popover beside the OpenStreetMap credit. - **FAA Special Use Airspace.** Keyless. `/api/drone-airspace` queries the public FAA AIS FeatureServer for prohibited (P) and restricted (R) zones only — not MOAs, TFRs or other SUA types — with simplified geometry, a body cap, paging, a one-hour cache and stale-on-error. The layer is US coverage, informational only, and is not a substitute for B4UFLY, LAANC or a preflight briefing. Data is fetched at request time and never stored or redistributed. US government public domain; courtesy attribution is registered in the Data attribution popover. +- **iNaturalist.** Keyless. `/api/inaturalist/observations` returns cached random view samples (newest first globally), at most 200 per request. Recency uses observation time: the v1 `d1` filter and a rolling 48 h cutoff (whales 7 d); date-only rows qualify when their day starts within the window. Observation caches last 5 min (global 10 min), with stale fallback ≤1 h. `/api/inaturalist/grid` supplies density counts, cached 15 min. Both share a paced queue capped at 60/min and 8,000/day; only request counters persist in `.gev-cache/`. The server caches projected records, never raw responses. Private records are excluded; obscured coordinates become 0.2° cells. Observer identity is retained only for photo attribution. Eligible photos load directly from `inaturalist-open-data.s3.amazonaws.com`, with creator credit on ambient cards and creator/licence on selection. Per-record licences and noncommercial restrictions apply as listed above. Attribution appears in the layer row and Data attribution popover. Coverage follows community observations, not a census; Acartia adds Salish Sea marine mammals. +- **iNaturalist grid (undocumented endpoint risk).** `/api/inaturalist/grid` shares observation-date, taxa, quality, pacing and budget rules with observations. A strict 64×64 UTFGrid schema guard fails closed; only logical-cell keys, counts and coordinates survive projection. Raw tiles, observation identifiers and free text are not retained. Projected tiles use a 400-entry LRU, 15-minute freshness and at most one hour of stale fallback. Views request at most six tiles; cached and adjacent-zoom coverage publishes before network waits, independently of observation pages. Cells supply a noninteractive density glow ranked within the view, from teal to amber with mono/CRT support. Every sighting badge comes from a real observation at its public position. Counts describe observations, not a population census. The endpoint is outside the documented v1 contract; schema drift disables grids until revalidated. See `docs/KNOWN-ISSUES.md`. +- **Acartia.** Keyless. `/api/acartia/sightings` reads the current 7-day marine-mammal feed for ALL, MAMMALS and WHALES views intersecting 45.5–50.5° N, 126–121° W in the Salish Sea. WHALES accepts recognized cetaceans; other identified marine mammals appear under ALL/MAMMALS. The server rounds positions to 0.01° and drops comments before caching projected records (10 min fresh, stale ≤24 h); raw responses are not retained. Selected text cards credit the reporting provider under CC BY 4.0 and show Be Whale Wise guidance. Attribution also appears in the layer row and Data attribution popover. Opportunistic sightings, not a census. - **Mapped installations.** OpenFreeMap military polygons use a bundled Overture/OSM name index; wide views show named points. Optional operator-configured Overpass and explicit Google Places searches keep their own source paths. Mapped data does not establish current activity or complete coverage. - **Place search.** Named-place fly-to uses Google Geocoding when a Maps key is configured, then Photon (`PHOTON_URL`; unset uses the public instance, empty disables it), then `/api/geocode` (Nominatim search) when neither answers. `/api/geocode` and the voice outline route `/api/geocode/outline` share one Nominatim queue (`NOMINATIM_URL`; unset, empty and invalid values disable it; an operator must explicitly select an authorized endpoint): one request at a time at least 1.1 s apart, an identifying `User-Agent` and `Referer`, a short queue that refuses a burst, a pause after 429/403/5xx answers (honouring `Retry-After` in full), at most one retry after a network failure, and a per-install daily cap on public-instance requests (`NOMINATIM_DAILY_CAP`, default 50). Processes using the same installation state file hold one lock through the actual fetch invocation and recording the next public start, with the daily slot persisted before invocation; restarts share that count, spacing and any pause. An unfinished dispatch marker fails closed until operator recovery; it is never automatically reclaimed. Public access fails closed if the state file cannot be read, locked, written or semantically validated. This coordination does not span independent installations or state directories and does not establish an application-wide public-service allowance or authorize public use. An operator-selected non-public endpoint is outside the public cap, spacing and durable-state requirement; an upstream cooldown is kept with the endpoint that returned it rather than leaking across operator URLs. Answers are cached on disk (outlines 90 days, places 30 days, no-results 24 hours); failures are not cached. Outlines are looked up only for an explicit ask, after bundled data and any configured Overpass, and only results whose type suits the ask are used. Administrative outline responses expose country/admin-1/admin-2 identity only when the selected Nominatim row has a recognized, unambiguous address type consistent with its own name; numeric rank, ambiguous types, missing fields and contradictory names do not manufacture identity. It is not used for systematic or bulk queries. - **Street and building outlines.** Voice street highlights, pointed-at buildings and grounds use the OpenFreeMap vector tiles the traffic layer already loads. Traffic and outlines share one bounded pending fetch/decode owner per immutable XYZ tile; materialized outline geometry is charged to the retained-byte ceiling and capped by coordinate count. Consumer result caches and request lifetimes remain independent, while eviction or explicit teardown cancels queued idle projections and releases their decoded owner. No Overpass request is made unless `OVERPASS_UPSTREAMS` is configured. Named grounds require a returned name matching the selected place, except that an ask whose head noun names a kind of grounds (a school, university or college, hospital, cemetery, stadium or zoo) may use the part of the smallest unnamed tile area of that class that holds the resolved place; a differently named area never answers. Other anonymous landcover polygons cannot establish identity, so unsupported grounds stay unavailable rather than borrowing a nearby POI name. diff --git a/README.md b/README.md index 6212f3991..bce41b190 100644 --- a/README.md +++ b/README.md @@ -426,7 +426,7 @@ _Ask for radio near anywhere and the globe starts broadcasting — every station ## 🛰️ What's on the Globe -Twenty-one layers and map sources. **Nineteen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.) +Twenty-two layers and map sources. **Twenty have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.) | Layer | What you get | Source | Auth | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- | @@ -452,6 +452,7 @@ Twenty-one layers and map sources. **Nineteen have a keyless path.** Some offer | 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🔴 optional paid token raises the allowance) | | 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 | | 🚫 **Prohibited & Restricted Airspace** | FAA prohibited (P) and restricted (R) special-use airspace as ground polygons, with name, altitude range and times of use on select. Not the UAS Facility Map or UAS flight restrictions; informational only, not for flight planning | FAA SUA | 🟢 | +| 🦉 **Wildlife Sightings** | Wildlife observed within 48 h (whales 7 d): density glow, taxon badges and anchored photo cards with creator/licence credit. Salish Sea marine mammals via Acartia; sensitive locations shown as 0.2° cells. Not a census | iNaturalist, Acartia | 🟢 | | 🌬️ **Wind** | Animated 10 m forecast wind with an optional speed, temperature or pressure color field and a reading at map center | NOAA GFS / ECMWF IFS | 🟢 | | 🌦️ **Observed Weather** | Rain radar (contiguous US), Satellite clouds (GOES regional or global infrared, Clouds only or Full image) and Lightning density (Americas and Pacific) on one history timeline | NOAA nowCOAST | 🟢 | | 🌀 **Cyclones** | Advisory positions, forecast tracks and uncertainty cones for the Atlantic and eastern/central North Pacific | NOAA NHC / CPHC | 🟢 | diff --git a/SECURITY.md b/SECURITY.md index 4dceaed6f..7b894c346 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -81,7 +81,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur - **Sanitized errors** — internal error details are not echoed back to clients. - **Coalesced OAuth refresh** and cached successful responses only (OpenSky). - **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing. -- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an ``/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless map routes (`/api/terrain/heights`, `/api/drone-airspace`, `/api/overpass` and voice place search's `/api/osm/features`, which spends the same operator Overpass) use the same cross-site checks, minus the forwarding-header refusal: they guard only per-client quotas and caches, not a key or a bill, and the map and voice need them to work behind a reverse proxy. `/api/overpass` also admits at most 300 POSTs a minute per peer (IPv6 peers keyed by /64) and 900 a minute across all remote peers before it reads a body. A direct loopback request — a loopback socket with none of the forwarding headers `hasProxySignals` checks (`Forwarded`, `Via`, `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Port`, `X-Forwarded-Proto`, `X-Real-IP`, `CF-Connecting-IP`, `CF-Ray`) — draws on its own per-peer admission window, so remote peers cannot exhaust the owner's admission. A reverse proxy on the same machine must add at least one of those headers (Caddy and Cloudflare do by default; nginx needs `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`, plus `proxy_set_header Host $http_host;` and `proxy_set_header X-Forwarded-Proto $scheme;` so the `Origin` check matches), or its traffic is indistinguishable from the owner's and shares the owner's window. With one, its requests are then counted as remote, in one bucket per proxy socket — the forwarded client address is never trusted as identity — so all users behind one proxy share 300/min. The isolation needs a loopback socket: under the default Docker bridge network every host-side client, the owner included, arrives from the container gateway and is one remote peer. **Limit:** this isolates admission only. The upstream budget (90 requests/min per client key, 300/min across all clients) is shared, so once remote traffic has used the global upstream budget, an owner request that misses the cache can still get `429`, or `503` when remote misses hold every upstream concurrency slot; cache hits are unaffected. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers. +- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an ``/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless map routes (`/api/terrain/heights`, `/api/drone-airspace`, `/api/inaturalist/observations`, `/api/acartia/sightings`, `/api/overpass` and voice place search's `/api/osm/features`, which spends the same operator Overpass) use the same cross-site checks, minus the forwarding-header refusal: they guard only per-client quotas and caches, not a key or a bill, and the map and voice need them to work behind a reverse proxy. `/api/overpass` also admits at most 300 POSTs a minute per peer (IPv6 peers keyed by /64) and 900 a minute across all remote peers before it reads a body. A direct loopback request — a loopback socket with none of the forwarding headers `hasProxySignals` checks (`Forwarded`, `Via`, `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Port`, `X-Forwarded-Proto`, `X-Real-IP`, `CF-Connecting-IP`, `CF-Ray`) — draws on its own per-peer admission window, so remote peers cannot exhaust the owner's admission. A reverse proxy on the same machine must add at least one of those headers (Caddy and Cloudflare do by default; nginx needs `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`, plus `proxy_set_header Host $http_host;` and `proxy_set_header X-Forwarded-Proto $scheme;` so the `Origin` check matches), or its traffic is indistinguishable from the owner's and shares the owner's window. With one, its requests are then counted as remote, in one bucket per proxy socket — the forwarded client address is never trusted as identity — so all users behind one proxy share 300/min. The isolation needs a loopback socket: under the default Docker bridge network every host-side client, the owner included, arrives from the container gateway and is one remote peer. **Limit:** this isolates admission only. The upstream budget (90 requests/min per client key, 300/min across all clients) is shared, so once remote traffic has used the global upstream budget, an owner request that misses the cache can still get `429`, or `503` when remote misses hold every upstream concurrency slot; cache hits are unaffected. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers. - **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects ` diff --git a/src/ui/wildlifeSightingCard.js b/src/ui/wildlifeSightingCard.js new file mode 100644 index 000000000..390aedc47 --- /dev/null +++ b/src/ui/wildlifeSightingCard.js @@ -0,0 +1,270 @@ +import { h, nextTick, shallowRef } from 'vue'; +import WildlifeSightingCard from './views/panels/WildlifeSightingCard.vue'; +import { + panWildlifeSighting, + placeWildlifeSightingCard, + projectWildlifeSighting, + readWildlifeCardSafeArea, + WILDLIFE_SELECTED_CARD_WIDTH, + WILDLIFE_CARD_CHROME_SELECTORS, +} from './wildlifeSightingPlacement.js'; + +/** Anchor the selected sighting's photo and source link clear of map chrome. */ +export function createWildlifeSightingCard({ + layer, + ui, + viewer = null, + documentRef = globalThis.document, + windowRef = globalThis.window, + now = () => Date.now(), + project = projectWildlifeSighting, + pan = panWildlifeSighting, +}) { + const selected = shallowRef(null); + const host = documentRef.createElement('div'); + host.dataset.wildlifeSightingCardHost = ''; + Object.assign(host.style, { + position: 'fixed', + zIndex: '89', + display: 'none', + width: `${WILDLIFE_SELECTED_CARD_WIDTH}px`, + }); + documentRef.body.append(host); + let destroyed = false; + let moving = false; + let removeFrame = null; + let returnFocus = null; + let panForId = null; + const removers = []; + const observed = new Set(); + const resizeObserver = windowRef?.ResizeObserver + ? new windowRef.ResizeObserver(() => position()) + : null; + const mutationObserver = windowRef?.MutationObserver + ? new windowRef.MutationObserver(() => position()) + : null; + + const chromeSelector = WILDLIFE_CARD_CHROME_SELECTORS.join(','); + const structureObserver = windowRef?.MutationObserver + ? new windowRef.MutationObserver((records) => { + if (destroyed || !selected.value) return; + const hasChromeChange = records.some((record) => + [...record.addedNodes, ...record.removedNodes].some( + (node) => + node.nodeType === 1 && + !host.contains(node) && + (node.matches(chromeSelector) || + node.querySelector(chromeSelector)), + ), + ); + if (!hasChromeChange) return; + // Newly mounted chrome can cover a previously clear anchor. + panForId = null; + position({ allowPan: true }); + }) + : null; + + function observeLayout(elements) { + const next = new Set( + [host.firstElementChild, viewer?.scene?.canvas, ...elements].filter( + Boolean, + ), + ); + let changed = false; + for (const element of observed) { + if (next.has(element)) continue; + resizeObserver?.unobserve(element); + observed.delete(element); + changed = true; + } + for (const element of next) { + if (observed.has(element)) continue; + observed.add(element); + resizeObserver?.observe(element); + changed = true; + } + if (!changed) return; + mutationObserver?.disconnect(); + for (const element of observed) { + // Ignore HUD clock/text changes and the card's own positioning styles. + if (!host.contains(element)) + mutationObserver?.observe(element, { + attributes: true, + attributeFilter: ['class', 'style', 'hidden'], + }); + } + } + + function position({ allowPan = false } = {}) { + if (destroyed || !selected.value || !host.firstElementChild) return; + const point = project(viewer, selected.value, layer); + if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) { + host.style.display = 'none'; + return; + } + const safe = readWildlifeCardSafeArea({ documentRef, viewer, windowRef }); + observeLayout(safe.elements); + host.style.width = `${Math.min(WILDLIFE_SELECTED_CARD_WIDTH, safe.bounds.width)}px`; + host.style.maxHeight = `${safe.bounds.height}px`; + host.style.display = 'block'; + const rect = host.firstElementChild.getBoundingClientRect(); + const result = placeWildlifeSightingCard( + point, + { + width: + rect.width || + Math.min(WILDLIFE_SELECTED_CARD_WIDTH, safe.bounds.width), + height: + rect.height || + (selected.value.photo && + !selected.value.obscured && + selected.value.origin !== 'acartia' + ? 300 + : 132), + }, + safe, + ); + if (!result) { + host.style.display = 'none'; + return; + } + if ( + (result.pan.x || result.pan.y) && + allowPan && + panForId !== selected.value.stableId + ) { + panForId = selected.value.stableId; + const reduced = + windowRef?.matchMedia?.('(prefers-reduced-motion: reduce)').matches === + true; + pan(viewer, point, result.anchor, reduced); + } + host.style.left = `${result.left}px`; + host.style.top = `${result.top}px`; + host.dataset.placement = result.placement; + } + + const unmount = ui.mount( + { + setup: () => () => + selected.value + ? h(WildlifeSightingCard, { + sighting: selected.value, + nowMs: now(), + taxa: layer.getParams?.()?.taxa, + onClose: () => { + if (!destroyed) layer.clearSightingSelection(); + }, + onResize: () => position(), + }) + : null, + }, + host, + ); + + function restoreFocus() { + if (returnFocus?.isConnected) returnFocus.focus?.({ preventScroll: true }); + returnFocus = null; + } + const unsubscribe = layer.subscribeSelection((row, metadata) => { + if (destroyed) return; + const fresh = row?.stableId !== selected.value?.stableId; + if (row && !selected.value) { + returnFocus = documentRef.activeElement; + structureObserver?.observe(documentRef.body, { + childList: true, + subtree: true, + }); + } + selected.value = row; + if (!row) { + host.style.display = 'none'; + panForId = null; + removeFrame?.(); + removeFrame = null; + resizeObserver?.disconnect(); + mutationObserver?.disconnect(); + structureObserver?.disconnect(); + observed.clear(); + restoreFocus(); + return; + } + if (fresh) panForId = null; + nextTick(() => { + if (destroyed || !selected.value) return; + position({ allowPan: fresh || metadata?.explicitSelection === true }); + if (moving) startFrameUpdates(); + // Focus the container so keyboard navigation starts inside the card. + if (fresh) + host + .querySelector('[data-wildlife-sighting-card]') + ?.focus?.({ preventScroll: true }); + }); + }); + + const keydown = (event) => { + if (!selected.value || event.key !== 'Escape') return; + event.preventDefault(); + event.stopImmediatePropagation(); + layer.clearSightingSelection(); + }; + const outside = (event) => { + if (!selected.value || host.contains(event.target)) return; + // The layer selects on pointer-up; its following click must keep the card open. + const canvas = viewer?.scene?.canvas; + if (canvas && (event.target === canvas || canvas.contains?.(event.target))) + return; + layer.clearSightingSelection(); + }; + documentRef.addEventListener('keydown', keydown, true); + documentRef.addEventListener('click', outside, true); + const onResize = () => position(); + windowRef?.addEventListener('resize', onResize); + + function listen(event, callback) { + const remove = event?.addEventListener?.(callback); + if (typeof remove === 'function') removers.push(remove); + else if (event?.removeEventListener) + removers.push(() => event.removeEventListener(callback)); + } + function startFrameUpdates() { + if (!selected.value || removeFrame) return; + // Installed only for camera motion; an idle card has zero per-frame work. + removeFrame = + viewer?.scene?.preRender?.addEventListener?.(() => position()) || null; + } + listen(viewer?.camera?.moveStart, () => { + moving = true; + startFrameUpdates(); + }); + listen(viewer?.camera?.moveEnd, () => { + moving = false; + removeFrame?.(); + removeFrame = null; + position(); + }); + listen(viewer?.camera?.changed, () => { + if (!moving) position(); + }); + + return { + /** Release the card, selection subscription and placement listeners. */ + destroy() { + if (destroyed) return; + destroyed = true; + unsubscribe?.(); + for (const remove of removers) remove(); + removeFrame?.(); + resizeObserver?.disconnect(); + mutationObserver?.disconnect(); + structureObserver?.disconnect(); + documentRef.removeEventListener('keydown', keydown, true); + documentRef.removeEventListener('click', outside, true); + windowRef?.removeEventListener('resize', onResize); + unmount(); + host.remove(); + selected.value = null; + restoreFocus(); + }, + }; +} diff --git a/src/ui/wildlifeSightingCard.test.mjs b/src/ui/wildlifeSightingCard.test.mjs new file mode 100644 index 000000000..d7837af7f --- /dev/null +++ b/src/ui/wildlifeSightingCard.test.mjs @@ -0,0 +1,767 @@ +import '../testSupport/registerDom.mjs'; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { nextTick } from 'vue'; +import { readFileSync } from 'node:fs'; +import { getLocale, setLocale, t } from '../i18n/index.js'; +import { LOCALES } from '../i18n/locale.js'; +import { createSelection } from '../layers/wildlifeSightings/selection.js'; +import { buildWildlifeThumbnailEntry } from '../layers/wildlifeSightings/cards.js'; +import { createWildlifeSightingCard } from './wildlifeSightingCard.js'; +import { + panWildlifeSighting, + placeWildlifeSightingCard, + readWildlifeCardSafeArea, +} from './wildlifeSightingPlacement.js'; +import { createUiRuntime } from './vueRuntime.js'; +import { PANEL_RAIL_ITEMS } from './panelRailCatalog.js'; +import { sheetForPanelId } from './mobileShell.js'; + +const NOW = Date.parse('2026-11-04T13:00:00Z'); +const square = + 'https://inaturalist-open-data.s3.amazonaws.com/photos/456/square.jpg'; +const row = { + stableId: 'inat:456', + origin: 'inaturalist', + lat: 0, + lon: 0, + commonName: 'Synthetic bird', + scientificName: 'Avis imaginaria', + iconic: 'Aves', + observedAt: NOW - 3_600_000, + observedOn: '2026-11-04', + obscured: false, + photo: { + square, + attribution: '(c) Synthetic Photographer, some rights reserved (CC BY-SA)', + licence: 'cc-by-sa', + }, + link: 'https://www.inaturalist.org/observations/456', +}; +const rect = (left, top, width, height) => ({ + left, + top, + width, + height, + right: left + width, + bottom: top + height, +}); +const events = () => { + const listeners = new Set(); + return { + addEventListener(fn) { + listeners.add(fn); + return () => listeners.delete(fn); + }, + emit() { + for (const fn of listeners) fn(); + }, + size: () => listeners.size, + }; +}; +const settle = async () => { + await nextTick(); + await Promise.resolve(); + await nextTick(); +}; + +// All scene rows, media events and rectangles are synthetic. Browser image +// requests are disabled; decode resolution below proves the photo is exposed +// only after it has loaded into the compact card. +function fixture( + context, + initialRow = row, + point = { x: 600, y: 450 }, + extraWindow = {}, +) { + document.body.replaceChildren(); + const opener = document.body.appendChild(document.createElement('button')); + opener.textContent = 'Globe'; + opener.focus(); + const canvas = document.body.appendChild(document.createElement('canvas')); + canvas.getBoundingClientRect = () => rect(0, 0, 1200, 900); + const windowRef = { + innerWidth: 1200, + innerHeight: 900, + getComputedStyle: window.getComputedStyle.bind(window), + addEventListener() {}, + removeEventListener() {}, + matchMedia: () => ({ matches: false }), + ...extraWindow, + }; + const ui = createUiRuntime(); + const overlays = new Map(); + const moveStart = events(); + const moveEnd = events(); + const changed = events(); + const preRender = events(); + const viewer = { + scene: { canvas, preRender, pick: () => picked }, + camera: { moveStart, moveEnd, changed }, + }; + const state = { + enabled: true, + destroyed: false, + selectedId: null, + selectedRow: null, + ambientRows: [], + rowById: new Map([[initialRow.stableId, initialRow]]), + positions: new Map([[initialRow.stableId, { x: 1, y: 2, z: 3 }]]), + contextIds: new Set(), + viewer, + }; + let picked = null; + let hit = null; + let onClick; + let clears = 0; + let staleListener; + const subscriptions = new Set(); + const flights = []; + const pans = []; + const selection = createSelection({ + state, + services: { + now: () => NOW, + cesium: { ScreenSpaceEventType: { LEFT_CLICK: 0 } }, + overlayHost: { + setEntries: (id, entries) => overlays.set(id, entries), + hitTest: (_x, _y, { sourceId }) => + hit?.sourceId === sourceId ? hit : null, + }, + screenSpaceEventHandlerFactory: () => ({ + setInputAction(fn) { + onClick = fn; + }, + destroy() {}, + }), + focus: { requestWorldFocus: (value) => flights.push(value) }, + }, + parts: { + rendering: { refreshSelection() {}, requestRender() {} }, + }, + }); + const layer = { + getParams: () => ({ taxa: 'all' }), + subscribeSelection(listener) { + staleListener = listener; + subscriptions.add(listener); + const off = selection.subscribe(listener); + return () => { + subscriptions.delete(listener); + off(); + }; + }, + clearSightingSelection() { + clears++; + selection.clearAndRebuild(); + }, + }; + let currentPoint = point; + let projections = 0; + const card = createWildlifeSightingCard({ + layer, + ui, + viewer, + documentRef: document, + windowRef, + now: () => NOW, + project: () => { + projections++; + return currentPoint; + }, + pan: (_viewer, from, to, reduced) => { + pans.push({ from, to, reduced }); + currentPoint = to; + return true; + }, + }); + selection.install(); + context.after(() => { + card.destroy(); + selection.destroy(); + ui.destroy(); + }); + return { + state, + selection, + layer, + card, + viewer, + windowRef, + overlays, + flights, + pans, + projections: () => projections, + subscriptions, + opener, + canvas, + moveStart, + moveEnd, + preRender, + cardElement: () => document.querySelector('[data-wildlife-sighting-card]'), + host: () => document.querySelector('[data-wildlife-sighting-card-host]'), + staleListener: () => staleListener, + clears: () => clears, + async click(kind = 'dot', target = initialRow) { + picked = + kind === 'icon' + ? { primitive: { id: `wildlife:${target.stableId}` } } + : { id: `wildlife:${target.stableId}` }; + hit = + kind === 'ambient photo card' + ? { + sourceId: 'wildlife-sightings', + entry: buildWildlifeThumbnailEntry(target, NOW, { + select: selection.selectAndFocus, + }), + } + : null; + onClick({ position: { x: 50, y: 50 } }); + await settle(); + }, + async publish(next) { + if (next) { + state.rowById.set(next.stableId, next); + if (state.selectedId === next.stableId) state.selectedRow = next; + } + selection.notify(); + await settle(); + }, + obstacle(selector, bounds) { + const element = document.body.appendChild(document.createElement('div')); + if (selector.startsWith('#')) element.id = selector.slice(1); + else element.className = selector.slice(1); + element.getBoundingClientRect = () => bounds; + return element; + }, + setPoint(next) { + currentPoint = next; + }, + }; +} + +test('dot, icon and ambient photo clicks open one contained medium-photo card at the sighting', async (context) => { + for (const kind of ['dot', 'icon', 'ambient photo card']) + await context.test(kind, async (context) => { + const f = fixture(context); + await f.click(kind); + const card = f.cardElement(); + const image = card.querySelector('img'); + assert.equal(f.state.selectedId, row.stableId); + assert.equal( + document.querySelector('dialog'), + null, + 'there is no fullscreen or modal dialog', + ); + assert.equal(image.src, square.replace('/square.', '/medium.')); + assert.equal(image.referrerPolicy, 'no-referrer'); + assert.equal(card.querySelector('h2').textContent, row.commonName); + assert.equal(card.querySelector('i').textContent, row.scientificName); + assert.equal(card.querySelector('time').textContent, 'observed 1h ago'); + assert.ok( + card.textContent.includes('© Synthetic Photographer · CC BY-SA'), + ); + const link = card.querySelector('a'); + assert.equal(link.href, row.link); + assert.equal(link.target, '_blank'); + assert.equal(link.rel, 'noopener noreferrer'); + assert.equal( + link.getAttribute('aria-label'), + `Open ${row.commonName} on iNaturalist`, + ); + assert.equal( + link.querySelector('.wildlife-sighting-card-source').textContent, + 'iNaturalist ↗', + ); + assert.ok( + link.contains(image), + 'the photograph and text are one external action', + ); + assert.equal(f.flights.length, 1); + assert.equal(f.flights[0].kind, 'sighting'); + assert.deepEqual( + f.flights[0].position, + f.state.positions.get(row.stableId), + ); + assert.equal(f.pans.length, 0); + assert.equal( + f.preRender.size(), + 0, + 'an idle card installs no frame work', + ); + let decoded; + image.decode = () => + new Promise((resolve) => { + decoded = resolve; + }); + image.dispatchEvent(new Event('load')); + await settle(); + assert.equal( + image.classList.contains('loaded'), + false, + 'a request alone does not expose an empty frame', + ); + decoded(); + await settle(); + assert.equal( + image.classList.contains('loaded'), + true, + 'the loaded medium photo is painted inside the card', + ); + assert.equal(card.querySelector('[role=status]'), null); + const style = readFileSync( + new URL('./views/panels/WildlifeSightingCard.vue', import.meta.url), + 'utf8', + ); + assert.match(style, /object-fit: contain/); + assert.match(style, /height: 180px/); + }); +}); + +test('missing, restricted, obscured and Acartia photos all use the same compact text card', async (context) => { + for (const [label, extra] of [ + ['missing', { photo: null }], + ['restricted', { photo: { ...row.photo, licence: 'cc-by-nd' } }], + [ + 'untrusted', + { photo: { ...row.photo, square: 'https://other.example/square.jpg' } }, + ], + ['uncredited', { photo: { ...row.photo, attribution: '' } }], + ['obscured', { obscured: true }], + [ + 'Acartia', + { + origin: 'acartia', + stableId: 'acartia:456', + provider: { name: 'Synthetic network' }, + link: 'https://acartia.io', + }, + ], + ]) + await context.test(label, async (context) => { + const f = fixture(context, { ...row, ...extra }); + await f.click(); + const card = f.cardElement(); + assert.equal(card.querySelector('img'), null); + assert.equal(card.querySelector('[role=status]'), null); + assert.equal(card.querySelector('h2').textContent, row.commonName); + assert.equal(card.querySelector('i').textContent, row.scientificName); + assert.equal(document.querySelector('dialog'), null); + if (extra.obscured) { + assert.doesNotMatch(card.textContent, /ago|Photographer/); + assert.ok( + card.textContent.includes('Location obscured to a 0.2° cell'), + ); + assert.equal( + card.querySelector('time').getAttribute('datetime'), + row.observedOn, + ); + } + if (extra.origin === 'acartia') { + assert.ok(card.textContent.includes('seen 1h ago')); + assert.ok(card.textContent.includes('Reported by Synthetic network')); + assert.ok( + card.textContent.includes('Acartia · CC BY 4.0 · Salish Sea'), + ); + assert.ok(card.querySelector('a[href="https://www.bewhalewise.org"]')); + assert.equal( + card.querySelector('a').getAttribute('aria-label'), + `Open ${row.commonName} on Acartia`, + ); + } + }); +}); + +test('safe placements near each viewport edge exclude actual HUD, toolbar and rail rects', () => { + const obstacles = [ + rect(0, 0, 260, 160), + rect(450, 0, 300, 80), + rect(1050, 200, 150, 500), + rect(0, 760, 300, 140), + ]; + const safe = { bounds: rect(6, 6, 1188, 888), obstacles }; + for (const point of [ + { x: 7, y: 450 }, + { x: 1193, y: 750 }, + { x: 850, y: 7 }, + { x: 600, y: 893 }, + ]) { + const result = placeWildlifeSightingCard( + point, + { width: 258, height: 300 }, + safe, + ); + assert.ok(result); + assert.deepEqual(result.pan, { x: 0, y: 0 }); + assert.ok( + result.left >= safe.bounds.left && result.right <= safe.bounds.right, + ); + assert.ok( + result.top >= safe.bounds.top && result.bottom <= safe.bounds.bottom, + ); + assert.ok( + obstacles.every( + (r) => + !( + result.left < r.right && + result.right > r.left && + result.top < r.bottom && + result.bottom > r.top + ), + ), + ); + } +}); + +test('a selected dot flies to its true position and its card pans clear of chrome', async (context) => { + const f = fixture(context, row, { x: 100, y: 100 }); + const hud = f.obstacle('.hud-top-left', rect(0, 0, 260, 160)); + f.obstacle('#right-context-rail', rect(1040, 200, 160, 700)); + f.windowRef.matchMedia = () => ({ matches: true }); + await f.click(); + assert.equal(f.pans.length, 1); + assert.equal(f.pans[0].reduced, true); + assert.deepEqual( + f.pans[0].to, + { x: 100, y: 178 }, + 'nearest clear point follows the measured HUD bottom', + ); + const safe = readWildlifeCardSafeArea({ + documentRef: document, + viewer: f.viewer, + windowRef: f.windowRef, + }); + assert.equal( + safe.obstacles[0].bottom, + hud.getBoundingClientRect().bottom + 6, + ); + assert.equal(f.flights.length, 1); + assert.deepEqual(f.flights[0].position, f.state.positions.get(row.stableId)); + f.moveEnd.emit(); + await settle(); + assert.equal(f.pans.length, 1); + f.layer.clearSightingSelection(); + await settle(); + f.setPoint({ x: -20, y: 400 }); + await f.click(); + assert.equal(f.pans.length, 2); + assert.deepEqual(f.pans[1].to, { x: 18, y: 400 }); +}); + +test('the camera correction preserves altitude and orientation and honors reduced motion', () => { + const flights = []; + const camera = { + positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 250_000), + positionCartographic: { height: 250_000 }, + heading: 0.2, + pitch: -1.2, + roll: 0.1, + pickEllipsoid: (p) => Cesium.Cartesian3.fromDegrees(p.x / 100, p.y / 100), + flyTo: (value) => flights.push(value), + }; + const viewer = { + camera, + scene: { + canvas: { getBoundingClientRect: () => rect(0, 0, 1200, 900) }, + globe: { ellipsoid: Cesium.Ellipsoid.WGS84 }, + }, + }; + for (const reduced of [false, true]) { + assert.equal( + panWildlifeSighting(viewer, { x: 10, y: 10 }, { x: 30, y: 50 }, reduced), + true, + ); + const flight = flights.at(-1); + assert.ok( + Math.abs( + Cesium.Cartographic.fromCartesian(flight.destination).height - 250_000, + ) < 0.001, + ); + assert.deepEqual(flight.orientation, { + heading: 0.2, + pitch: -1.2, + roll: 0.1, + }); + assert.equal(flight.duration, reduced ? 0 : 0.22); + } +}); + +test('the browser click that follows a globe selection does not close the card it opened', async (context) => { + const f = fixture(context); + await f.click(); + assert.ok(f.cardElement(), 'the globe click opened the card'); + assert.equal( + document.activeElement, + f.cardElement(), + 'focus lands on the card, not as a ring on its close button', + ); + // Cesium selects on pointer-up; the DOM click event lands on the canvas + // afterwards and must not count as an outside click. + f.canvas.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await settle(); + assert.ok(f.cardElement(), 'the card stays open'); + assert.equal(f.clears(), 0); + document.body.click(); + await settle(); + assert.equal(f.cardElement(), null, 'a real outside click still closes it'); +}); + +test('one card at a time, Escape, outside clicks and close all clear the selection', async (context) => { + const f = fixture(context); + await f.click(); + const second = { + ...row, + stableId: 'inat:789', + commonName: 'Another synthetic bird', + }; + f.state.rowById.set(second.stableId, second); + await f.click('dot', second); + assert.equal( + document.querySelectorAll('[data-wildlife-sighting-card]').length, + 1, + ); + assert.equal( + f.cardElement().querySelector('h2').textContent, + second.commonName, + ); + for (const close of ['Escape', 'outside', 'button']) { + if (close === 'Escape') + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }), + ); + if (close === 'outside') document.body.click(); + if (close === 'button') f.cardElement().querySelector('button').click(); + await settle(); + assert.equal(f.cardElement(), null); + assert.equal(f.state.selectedId, null); + assert.deepEqual(f.overlays.get('wildlife-sightings-selected'), []); + assert.equal(document.activeElement, f.opener); + await f.click(); + } + assert.equal(f.clears(), 3); +}); + +test('camera movement repositions the same card and removes frame work at rest', async (context) => { + const f = fixture(context); + await f.click(); + const card = f.cardElement(); + const before = f.host().style.left; + f.moveStart.emit(); + assert.equal(f.preRender.size(), 1); + f.setPoint({ x: 800, y: 450 }); + f.preRender.emit(); + assert.equal(f.cardElement(), card); + assert.notEqual(f.host().style.left, before); + f.moveEnd.emit(); + assert.equal(f.preRender.size(), 0); + await f.publish({ ...row, commonName: 'Updated synthetic bird' }); + assert.equal(f.cardElement(), card); + assert.equal(card.querySelector('h2').textContent, 'Updated synthetic bird'); + f.state.rowById.delete(row.stableId); + await f.publish(null); + assert.equal( + f.cardElement(), + card, + 'a refreshed sample cannot close the selected card', + ); + f.layer.clearSightingSelection(); + await settle(); + assert.equal(f.cardElement(), null); +}); + +test('replacement photo loads cannot expose the previous image and failures retain the text card', async (context) => { + const f = fixture(context); + await f.click(); + const previous = f.cardElement().querySelector('img'); + let decoded; + previous.decode = () => + new Promise((resolve) => { + decoded = resolve; + }); + previous.dispatchEvent(new Event('load')); + await f.publish({ + ...row, + photo: { ...row.photo, square: square.replace('/456/', '/789/') }, + }); + const current = f.cardElement().querySelector('img'); + decoded(); + await settle(); + assert.equal(current.classList.contains('loaded'), false); + current.dispatchEvent(new Event('error')); + await settle(); + assert.equal( + f.cardElement().querySelector('[role=status]').textContent, + 'Photo unavailable', + ); + assert.equal(current.style.display, 'none'); + assert.equal(f.cardElement().querySelector('a').href, row.link); +}); + +test('teardown removes subscriptions, placement listeners and host, and ignores stale notifications', async (context) => { + const f = fixture(context); + await f.click(); + f.moveStart.emit(); + const stale = f.staleListener(); + f.card.destroy(); + assert.equal(f.cardElement(), null); + assert.equal(f.host(), null); + assert.equal(f.subscriptions.size, 0); + assert.equal(f.preRender.size(), 0); + stale(row); + await f.click(); + assert.equal(f.cardElement(), null); + assert.equal(document.activeElement, f.opener); +}); + +test('locale changes translate the card without replacing the image or credit', async (context) => { + const before = getLocale(); + context.after(() => setLocale(before)); + const f = fixture(context); + await f.click(); + const card = f.cardElement(); + const image = card.querySelector('img'); + for (const locale of LOCALES) { + setLocale(locale); + await settle(); + assert.equal(f.cardElement(), card); + assert.equal(card.querySelector('img'), image); + assert.equal( + card.querySelector('button').getAttribute('aria-label'), + t('Close sighting'), + ); + assert.ok(card.textContent.includes('© Synthetic Photographer · CC BY-SA')); + } +}); + +test('date-only and unsafe-link rows remain readable, and no sighting rail or lightbox is shipped', async (context) => { + const f = fixture(context, { + ...row, + observedAt: null, + photo: null, + link: 'javascript:alert(1)', + }); + await f.click(); + assert.equal(f.cardElement().querySelector('a'), null); + assert.equal( + f.cardElement().querySelector('time').getAttribute('datetime'), + row.observedOn, + ); + assert.equal(PANEL_RAIL_ITEMS['wildlife-sighting-panel'], undefined); + assert.equal(sheetForPanelId('wildlife-sighting-panel'), null); + const source = readFileSync( + new URL('./wildlifeSightingCard.js', import.meta.url), + 'utf8', + ); + assert.doesNotMatch( + source, + /Lightbox|showModal|requestAnimationFrame|setInterval/, + ); +}); + +test('WHALES cards retain compact whale guidance outside the observation action', async (context) => { + const f = fixture(context); + f.layer.getParams = () => ({ taxa: 'whales' }); + await f.click(); + const guidance = f + .cardElement() + .querySelector('a[href="https://www.bewhalewise.org"]'); + assert.equal(guidance.textContent, 'Be Whale Wise'); + assert.equal( + guidance.parentElement.closest('a'), + null, + 'the external guidance link never nests inside the observation action', + ); +}); + +test('opening during camera motion tracks the card and closing stops frame work immediately', async (context) => { + const f = fixture(context); + f.moveStart.emit(); + assert.equal(f.preRender.size(), 0); + await f.click(); + assert.equal(f.preRender.size(), 1); + f.layer.clearSightingSelection(); + await settle(); + assert.equal(f.preRender.size(), 0); +}); + +test('newly mounted chrome repositions the open card without camera events or idle text work', async (context) => { + const observers = []; + class MutationObserver { + constructor(callback) { + this.callback = callback; + this.targets = []; + observers.push(this); + } + observe(target, options) { + this.targets.push({ target, options }); + } + disconnect() { + this.targets = []; + } + } + const f = fixture(context, row, { x: 600, y: 450 }, { MutationObserver }); + await f.click(); + const structural = observers.find((observer) => + observer.targets.some( + ({ target, options }) => + target === document.body && options.childList && options.subtree, + ), + ); + assert.ok( + structural, + 'structural observation exists only while a card is open', + ); + const original = f.cardElement(); + const before = f.host().style.top; + const hudWrapper = document.body.appendChild(document.createElement('div')); + const hud = hudWrapper.appendChild(document.createElement('div')); + hud.className = 'hud-top-left'; + hud.getBoundingClientRect = () => rect(500, 350, 200, 200); + structural.callback([{ addedNodes: [hudWrapper], removedNodes: [] }]); + await settle(); + assert.equal( + f.pans.length, + 1, + 'chrome newly covering the dot triggers one minimal pan', + ); + assert.equal(f.cardElement(), original); + assert.notEqual(f.host().style.top, before); + assert.equal(f.preRender.size(), 0); + const placed = rect( + Number.parseFloat(f.host().style.left), + Number.parseFloat(f.host().style.top), + 258, + 300, + ); + assert.ok( + placed.right <= 494 || + placed.left >= 706 || + placed.bottom <= 344 || + placed.top >= 556, + 'the card clears the measured HUD rect', + ); + const afterChrome = f.projections(); + // The observer ignores text nodes, ordinary clock wrappers and the card's + // own image/caption patches even though body observation sees their records. + const clock = hud.appendChild(document.createElement('span')); + const text = clock.appendChild(document.createTextNode('13:00:01')); + structural.callback([ + { + addedNodes: [clock, text, f.cardElement().querySelector('img')], + removedNodes: [], + }, + ]); + assert.equal(f.projections(), afterChrome); + f.layer.clearSightingSelection(); + await settle(); + assert.equal( + structural.targets.length, + 0, + 'closing disconnects structural observation', + ); +}); diff --git a/src/ui/wildlifeSightingPlacement.js b/src/ui/wildlifeSightingPlacement.js new file mode 100644 index 000000000..e638b9ccb --- /dev/null +++ b/src/ui/wildlifeSightingPlacement.js @@ -0,0 +1,274 @@ +import * as Cesium from 'cesium'; +import { WORLD_OVERLAY_OCCLUDER_SELECTORS } from '../overlays/worldOverlayChrome.js'; + +// DOM cards also avoid the HUD edges and responsive sheet containers. +export const WILDLIFE_CARD_CHROME_SELECTORS = Object.freeze([ + ...WORLD_OVERLAY_OCCLUDER_SELECTORS, + '.hud-left-edge', + '.hud-right-edge', + '#right-panel-stack', + '.gev-panel-rail', + '#location-bar', + '#control-panel', + '#param-slider-panel', + '#cockpit-hud .cockpit-topline', + '[data-mobile-sheet]', +]); +const GAP = 12; +const EDGE = 6; +export const WILDLIFE_SELECTED_CARD_WIDTH = 258; + +const overlaps = (a, b) => + a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; +const contains = (r, p) => + p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom; +const clamp = (value, low, high) => Math.max(low, Math.min(high, value)); +const rectAt = (left, top, width, height) => ({ + left, + top, + right: left + width, + bottom: top + height, + width, + height, +}); + +/** Read visible chrome rectangles inside the viewer's usable screen area. */ +export function readWildlifeCardSafeArea({ + documentRef, + viewer, + windowRef = globalThis.window, +}) { + const canvasRect = viewer?.scene?.canvas?.getBoundingClientRect?.(); + const width = + windowRef?.innerWidth || documentRef.documentElement.clientWidth; + const height = + windowRef?.innerHeight || documentRef.documentElement.clientHeight; + const left = Math.max(0, canvasRect?.left || 0) + EDGE; + const top = Math.max(0, canvasRect?.top || 0) + EDGE; + const right = + Math.min(width, canvasRect?.width > 0 ? canvasRect.right : width) - EDGE; + const bottom = + Math.min(height, canvasRect?.height > 0 ? canvasRect.bottom : height) - + EDGE; + const bounds = rectAt( + left, + top, + Math.max(0, right - left), + Math.max(0, bottom - top), + ); + const obstacles = []; + const elements = [ + ...documentRef.querySelectorAll(WILDLIFE_CARD_CHROME_SELECTORS.join(',')), + ]; + for (const element of elements) { + let hidden = false; + for ( + let node = element; + node && node !== documentRef.body; + node = node.parentElement + ) { + const style = windowRef?.getComputedStyle?.(node); + if ( + node.hidden || + style?.display === 'none' || + style?.visibility === 'hidden' || + style?.opacity === '0' + ) { + hidden = true; + break; + } + } + if (hidden) continue; + const rect = element.getBoundingClientRect(); + if (!(rect.width > 0 && rect.height > 0) || !overlaps(rect, bounds)) + continue; + obstacles.push( + rectAt( + rect.left - EDGE, + rect.top - EDGE, + rect.width + EDGE * 2, + rect.height + EDGE * 2, + ), + ); + } + return { bounds, obstacles, elements }; +} + +function nearestSafeAnchor(point, { bounds, obstacles }) { + const xs = [clamp(point.x, bounds.left + GAP, bounds.right - GAP)]; + const ys = [clamp(point.y, bounds.top + GAP, bounds.bottom - GAP)]; + for (const rect of obstacles) { + xs.push( + clamp(rect.left - GAP, bounds.left + GAP, bounds.right - GAP), + clamp(rect.right + GAP, bounds.left + GAP, bounds.right - GAP), + ); + ys.push( + clamp(rect.top - GAP, bounds.top + GAP, bounds.bottom - GAP), + clamp(rect.bottom + GAP, bounds.top + GAP, bounds.bottom - GAP), + ); + } + return ( + xs + .flatMap((x) => ys.map((y) => ({ x, y }))) + .filter( + (p) => contains(bounds, p) && !obstacles.some((r) => contains(r, p)), + ) + .sort( + (a, b) => + Math.hypot(a.x - point.x, a.y - point.y) - + Math.hypot(b.x - point.x, b.y - point.y), + )[0] || null + ); +} + +/** Four anchored placements, clamped along their free axis. Chrome is a set of + * actual DOM rectangles, so corner HUDs do not erase otherwise clear lanes. */ +export function placeWildlifeSightingCard(point, size, safeArea) { + const { bounds, obstacles } = safeArea; + const anchor = + contains(bounds, point) && !obstacles.some((r) => contains(r, point)) + ? point + : nearestSafeAnchor(point, safeArea); + if (!anchor) return null; + const width = Math.min(size.width, bounds.width); + const height = Math.min(size.height, bounds.height); + const centeredX = clamp( + anchor.x - width / 2, + bounds.left, + bounds.right - width, + ); + const centeredY = clamp( + anchor.y - height / 2, + bounds.top, + bounds.bottom - height, + ); + const candidates = [ + { + placement: 'above', + ...rectAt(centeredX, anchor.y - GAP - height, width, height), + }, + { placement: 'below', ...rectAt(centeredX, anchor.y + GAP, width, height) }, + { placement: 'right', ...rectAt(anchor.x + GAP, centeredY, width, height) }, + { + placement: 'left', + ...rectAt(anchor.x - GAP - width, centeredY, width, height), + }, + ]; + const fits = (rect) => + rect.left >= bounds.left && + rect.right <= bounds.right && + rect.top >= bounds.top && + rect.bottom <= bounds.bottom && + !obstacles.some((r) => overlaps(rect, r)); + let chosen = candidates.find(fits); + if (!chosen) { + // Crowded chrome may require sliding a side placement farther from its + // anchor. Search only obstacle/bounds edges; no pixels or idle frame loop. + const xs = [bounds.left, bounds.right - width, centeredX]; + const ys = [bounds.top, bounds.bottom - height, centeredY]; + for (const r of obstacles) { + xs.push(r.left - width, r.right); + ys.push(r.top - height, r.bottom); + } + const alternatives = xs + .flatMap((x) => + ys.map((y) => ({ + placement: + y + height <= anchor.y + ? 'above' + : y >= anchor.y + ? 'below' + : x >= anchor.x + ? 'right' + : 'left', + ...rectAt(x, y, width, height), + })), + ) + .filter((r) => fits(r) && !contains(r, anchor)); + alternatives.sort( + (a, b) => + Math.hypot( + clamp(anchor.x, a.left, a.right) - anchor.x, + clamp(anchor.y, a.top, a.bottom) - anchor.y, + ) - + Math.hypot( + clamp(anchor.x, b.left, b.right) - anchor.x, + clamp(anchor.y, b.top, b.bottom) - anchor.y, + ), + ); + chosen = alternatives[0]; + } + return chosen + ? { + ...chosen, + anchor, + pan: { x: anchor.x - point.x, y: anchor.y - point.y }, + } + : null; +} + +/** Project a front-side sighting into viewport coordinates. */ +export function projectWildlifeSighting(viewer, row, layer) { + if ( + !viewer?.scene || + !Number.isFinite(row?.lat) || + !Number.isFinite(row?.lon) + ) + return null; + const position = + layer?.getSightingPosition?.(row.stableId) || + Cesium.Cartesian3.fromDegrees(row.lon, row.lat); + const ellipsoid = viewer.scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84; + if ( + viewer.camera?.positionWC && + new Cesium.EllipsoidalOccluder( + ellipsoid, + viewer.camera.positionWC, + ).isPointVisible(position) !== true + ) + return null; + const point = Cesium.SceneTransforms.worldToWindowCoordinates( + viewer.scene, + position, + ); + if (!point) return null; + const rect = viewer.scene.canvas?.getBoundingClientRect?.(); + return { x: point.x + (rect?.left || 0), y: point.y + (rect?.top || 0) }; +} + +/** Translate the camera by the smallest screen correction and preserve its + * altitude/orientation. A card must never zoom merely to reveal its anchor. */ +export function panWildlifeSighting(viewer, from, to, reducedMotion = false) { + const camera = viewer?.camera; + if (!camera?.pickEllipsoid || !camera.flyTo) return false; + const rect = viewer.scene.canvas.getBoundingClientRect(); + const local = (p) => new Cesium.Cartesian2(p.x - rect.left, p.y - rect.top); + const ellipsoid = viewer.scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84; + const start = camera.pickEllipsoid(local(from), ellipsoid); + const finish = camera.pickEllipsoid(local(to), ellipsoid); + if (!start || !finish || !camera.positionWC) return false; + const delta = Cesium.Cartesian3.subtract( + start, + finish, + new Cesium.Cartesian3(), + ); + const destination = Cesium.Cartesian3.add( + camera.positionWC, + delta, + new Cesium.Cartesian3(), + ); + const cartographic = ellipsoid.cartesianToCartographic(destination); + if (!cartographic) return false; + cartographic.height = camera.positionCartographic.height; + camera.flyTo({ + destination: ellipsoid.cartographicToCartesian(cartographic), + orientation: { + heading: camera.heading, + pitch: camera.pitch, + roll: camera.roll, + }, + duration: reducedMotion ? 0 : 0.22, + easingFunction: Cesium.EasingFunction.QUADRATIC_OUT, + }); + return true; +} diff --git a/src/voice/layerManifest.js b/src/voice/layerManifest.js index 1234fb61e..9b5684243 100644 --- a/src/voice/layerManifest.js +++ b/src/voice/layerManifest.js @@ -453,10 +453,12 @@ const layers = [ export const VOICE_OFF_LAYERS = Object.freeze({ 'bhote-koshi-2026': 'scene-driven event layer (control_scene)', 'bhote-koshi-locator': 'scene-driven event layer (control_scene)', - // The Realtime prompt is at its 52,000-character budget; voice toggle and + // The Realtime prompt is at its 55,500-character budget; voice toggle and // query for this layer wait until there is room. 'drone-airspace': 'informational airspace layer; no room in the prompt budget', + 'wildlife-sightings': + 'community observation layer; no room in the prompt budget', }); const TYPE_NAMES = Object.freeze({ diff --git a/src/worldFocus.js b/src/worldFocus.js index 5b5ca7a8e..6ed3a3b32 100644 --- a/src/worldFocus.js +++ b/src/worldFocus.js @@ -9,6 +9,12 @@ export const WORLD_CLICK_FOCUS_DURATION_SEC = 1.9; export const WORLD_FOCUS_FRAMING = Object.freeze({ vessel: Object.freeze({ radiusM: 150, rangeM: 1200, pitchDeg: -30 }), fire: Object.freeze({ radiusM: 400, rangeM: 3000, pitchDeg: -35 }), + sighting: Object.freeze({ radiusM: 250, rangeM: 2500, pitchDeg: -40 }), + 'sighting-area': Object.freeze({ + radiusM: 11000, + rangeM: 60000, + pitchDeg: -55, + }), }); /** Validate a layer-owned focus target before camera policy can release tracking. */ @@ -23,7 +29,7 @@ export function isValidWorldFocusTarget(detail) { !Number.isFinite(position.z) ) return false; - // Vessel and fire targets are surface-anchored Earth positions. Merely + // These targets are surface-anchored Earth positions. Merely // finite coordinates near the ECEF origin cannot be flown to, and must be // rejected before the camera policy releases a current follow owner. const magnitude = Cesium.Cartesian3.magnitude(position); diff --git a/src/worldFocus.test.mjs b/src/worldFocus.test.mjs index f5e8146db..3a10b75eb 100644 --- a/src/worldFocus.test.mjs +++ b/src/worldFocus.test.mjs @@ -21,16 +21,28 @@ function stubCamera() { return { calls, heading: Cesium.Math.toRadians(45), - cancelFlight() { calls.cancelFlight += 1; }, - flyToBoundingSphere(sphere, options) { calls.flights.push({ sphere, options }); }, + cancelFlight() { + calls.cancelFlight += 1; + }, + flyToBoundingSphere(sphere, options) { + calls.flights.push({ sphere, options }); + }, }; } test('a focus request carries the clicked target to any registered listener', () => { const target = new EventTarget(); const seen = []; - const dispose = registerWorldFocusRequestListener(target, (event) => seen.push(event.detail)); - assert.equal(requestWorldFocus({ kind: 'vessel', position: POSITION, id: '123' }, target), true); + const dispose = registerWorldFocusRequestListener(target, (event) => + seen.push(event.detail), + ); + assert.equal( + requestWorldFocus( + { kind: 'vessel', position: POSITION, id: '123' }, + target, + ), + true, + ); assert.equal(seen.length, 1); assert.equal(seen[0].kind, 'vessel'); assert.equal(seen[0].id, '123'); @@ -44,7 +56,9 @@ test('a focus request carries the clicked target to any registered listener', () test('requests without a kind or a position are dropped, never dispatched', () => { const target = new EventTarget(); let count = 0; - target.addEventListener(WORLD_FOCUS_REQUEST_EVENT, () => { count += 1; }); + target.addEventListener(WORLD_FOCUS_REQUEST_EVENT, () => { + count += 1; + }); assert.equal(requestWorldFocus({ position: POSITION }, target), false); assert.equal(requestWorldFocus({ kind: 'vessel' }, target), false); assert.equal(requestWorldFocus(null, target), false); @@ -74,51 +88,94 @@ test('a refusing policy (cockpit owns the camera) never reaches the flight', () const result = routeWorldFocusRequest( { detail: { kind: 'vessel', id: '123', position: POSITION } }, () => false, - () => { flights += 1; }, + () => { + flights += 1; + }, ); assert.equal(result, false); assert.equal(flights, 0); }); test('malformed requests are inert', () => { - assert.equal(routeWorldFocusRequest(null, () => 'x', () => 'y'), false); - assert.equal(routeWorldFocusRequest({ detail: { kind: 'vessel' } }, () => 'x', () => 'y'), false); + assert.equal( + routeWorldFocusRequest( + null, + () => 'x', + () => 'y', + ), + false, + ); + assert.equal( + routeWorldFocusRequest( + { detail: { kind: 'vessel' } }, + () => 'x', + () => 'y', + ), + false, + ); }); // A request that cannot fly must never cost the user their tracking: the UI // policy releases the follow camera, so validation happens BEFORE it runs. test('an unflyable request never reaches the release policy', () => { const unflyable = [ - { kind: 'plane', position: POSITION }, // no framing for this kind - { kind: 'vessel', position: { x: NaN, y: 0, z: 0 } }, // malformed position - { kind: 'vessel', position: { x: 0, y: 0 } }, // truthy but incomplete + { kind: 'plane', position: POSITION }, // no framing for this kind + { kind: 'vessel', position: { x: NaN, y: 0, z: 0 } }, // malformed position + { kind: 'vessel', position: { x: 0, y: 0 } }, // truthy but incomplete { kind: 'vessel', id: '123', position: { x: 0, y: 0, z: 0 } }, // ECEF origin { kind: 'fire', id: 'fire-1', position: { x: 1, y: 1, z: 1 } }, // finite but inside Earth { kind: 'vessel', position: 'somewhere' }, - { kind: 'vessel', position: POSITION }, // missing owner id + { kind: 'vessel', position: POSITION }, // missing owner id { position: POSITION }, ]; for (const detail of unflyable) { - assert.equal(isValidWorldFocusTarget(detail), false, JSON.stringify(detail)); + assert.equal( + isValidWorldFocusTarget(detail), + false, + JSON.stringify(detail), + ); let policyRuns = 0; const result = routeWorldFocusRequest( { detail }, - () => { policyRuns += 1; return 'released'; }, + () => { + policyRuns += 1; + return 'released'; + }, () => 'flew', ); assert.equal(result, false, JSON.stringify(detail)); - assert.equal(policyRuns, 0, `the policy must not run for ${JSON.stringify(detail)}`); + assert.equal( + policyRuns, + 0, + `the policy must not run for ${JSON.stringify(detail)}`, + ); // Nor may such a request be dispatched in the first place. assert.equal(requestWorldFocus(detail, new EventTarget()), false); } - assert.equal(isValidWorldFocusTarget({ kind: 'vessel', id: '123', position: POSITION }), true); - assert.equal(isValidWorldFocusTarget({ kind: 'fire', id: 'fire-1', position: POSITION }), true); + assert.equal( + isValidWorldFocusTarget({ kind: 'vessel', id: '123', position: POSITION }), + true, + ); + assert.equal( + isValidWorldFocusTarget({ kind: 'fire', id: 'fire-1', position: POSITION }), + true, + ); }); test('the transfer flight supersedes any flight in progress and keeps the operator heading', () => { const camera = stubCamera(); - assert.equal(flyToWorldTarget({ camera }, { kind: 'vessel', id: '123', position: POSITION }), true); - assert.equal(camera.calls.cancelFlight, 1, 'a prior flight must be cancelled, not queued'); + assert.equal( + flyToWorldTarget( + { camera }, + { kind: 'vessel', id: '123', position: POSITION }, + ), + true, + ); + assert.equal( + camera.calls.cancelFlight, + 1, + 'a prior flight must be cancelled, not queued', + ); assert.equal(camera.calls.flights.length, 1); const { sphere, options } = camera.calls.flights[0]; assert.equal(sphere.radius, WORLD_FOCUS_FRAMING.vessel.radiusM); @@ -131,39 +188,82 @@ test('the transfer flight supersedes any flight in progress and keeps the operat test('fires frame wider than vessels — a fire is read by its surroundings', () => { const camera = stubCamera(); - flyToWorldTarget({ camera }, { kind: 'fire', id: 'fire-1', position: POSITION }); + flyToWorldTarget( + { camera }, + { kind: 'fire', id: 'fire-1', position: POSITION }, + ); const { sphere, options } = camera.calls.flights[0]; assert.equal(sphere.radius, WORLD_FOCUS_FRAMING.fire.radiusM); assert.equal(options.offset.range, WORLD_FOCUS_FRAMING.fire.rangeM); - assert.ok(WORLD_FOCUS_FRAMING.fire.rangeM > WORLD_FOCUS_FRAMING.vessel.rangeM); - assert.ok(WORLD_FOCUS_FRAMING.fire.radiusM > WORLD_FOCUS_FRAMING.vessel.radiusM); + assert.ok( + WORLD_FOCUS_FRAMING.fire.rangeM > WORLD_FOCUS_FRAMING.vessel.rangeM, + ); + assert.ok( + WORLD_FOCUS_FRAMING.fire.radiusM > WORLD_FOCUS_FRAMING.vessel.radiusM, + ); }); test('every framing is a real oblique standoff, not a nadir or an inside-out sphere', () => { const kinds = Object.keys(WORLD_FOCUS_FRAMING); - assert.deepEqual(kinds.sort(), ['fire', 'vessel']); + assert.deepEqual(kinds.sort(), [ + 'fire', + 'sighting', + 'sighting-area', + 'vessel', + ]); for (const kind of kinds) { const framing = WORLD_FOCUS_FRAMING[kind]; // Looking DOWN at the target, but obliquely — a nadir drop reads as a map. - assert.ok(framing.pitchDeg < 0, `${kind}: pitch must look down, got ${framing.pitchDeg}`); - assert.ok(framing.pitchDeg > -80, `${kind}: pitch must stay oblique, got ${framing.pitchDeg}`); + assert.ok( + framing.pitchDeg < 0, + `${kind}: pitch must look down, got ${framing.pitchDeg}`, + ); + assert.ok( + framing.pitchDeg > -80, + `${kind}: pitch must stay oblique, got ${framing.pitchDeg}`, + ); // Standing off outside the framed sphere, at a human-readable distance. assert.ok(framing.radiusM > 0, `${kind}: radius must be positive`); assert.ok( framing.rangeM > framing.radiusM, `${kind}: range ${framing.rangeM} must stand off outside radius ${framing.radiusM}`, ); - assert.ok(framing.rangeM <= 10000, `${kind}: range must stay close-in, got ${framing.rangeM}`); + // An obscured sighting frames its entire 0.2° privacy cell. + const maximumRange = kind === 'sighting-area' ? 60000 : 10000; + assert.ok( + framing.rangeM <= maximumRange, + `${kind}: range must fit its surface extent, got ${framing.rangeM}`, + ); } // Exact shipped values — a silent retune must show up as a failing test. - assert.deepEqual({ ...WORLD_FOCUS_FRAMING.vessel }, { radiusM: 150, rangeM: 1200, pitchDeg: -30 }); - assert.deepEqual({ ...WORLD_FOCUS_FRAMING.fire }, { radiusM: 400, rangeM: 3000, pitchDeg: -35 }); + assert.deepEqual( + { ...WORLD_FOCUS_FRAMING.vessel }, + { radiusM: 150, rangeM: 1200, pitchDeg: -30 }, + ); + assert.deepEqual( + { ...WORLD_FOCUS_FRAMING.fire }, + { radiusM: 400, rangeM: 3000, pitchDeg: -35 }, + ); + assert.deepEqual( + { ...WORLD_FOCUS_FRAMING.sighting }, + { radiusM: 250, rangeM: 2500, pitchDeg: -40 }, + ); + assert.deepEqual( + { ...WORLD_FOCUS_FRAMING['sighting-area'] }, + { radiusM: 11000, rangeM: 60000, pitchDeg: -55 }, + ); }); test('unknown kinds and missing viewers issue no flight', () => { const camera = stubCamera(); - assert.equal(flyToWorldTarget({ camera }, { kind: 'plane', position: POSITION }), false); + assert.equal( + flyToWorldTarget({ camera }, { kind: 'plane', position: POSITION }), + false, + ); assert.equal(flyToWorldTarget({ camera }, { kind: 'vessel' }), false); - assert.equal(flyToWorldTarget(null, { kind: 'vessel', position: POSITION }), false); + assert.equal( + flyToWorldTarget(null, { kind: 'vessel', position: POSITION }), + false, + ); assert.equal(camera.calls.flights.length, 0); }); From ecbe1d99e67f09f622f1afe6a8bb450edddc0909 Mon Sep 17 00:00:00 2001 From: Yehe Liu Date: Thu, 5 Nov 2026 16:18:27 -0500 Subject: [PATCH 20/39] docs: number the wildlife sightings changelog fragment (#1591) Co-Authored-By: Claude Opus 5.5 --- .../{0000-wildlife-sightings.md => 1591-wildlife-sightings.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename changelog.d/{0000-wildlife-sightings.md => 1591-wildlife-sightings.md} (95%) diff --git a/changelog.d/0000-wildlife-sightings.md b/changelog.d/1591-wildlife-sightings.md similarity index 95% rename from changelog.d/0000-wildlife-sightings.md rename to changelog.d/1591-wildlife-sightings.md index 75abdd8dd..64f4e7c54 100644 --- a/changelog.d/0000-wildlife-sightings.md +++ b/changelog.d/1591-wildlife-sightings.md @@ -9,4 +9,4 @@ Acartia comments are excluded. Attribution appears in the layer row and Data attribution popover. Cached progressive tiles, bounded image/billboard work and a paced queue preserve iNaturalist's 60/minute and 8,000/day limits. - All 11 locales supported. (lyehe, #0000) + All 11 locales supported. (lyehe, #1591) From 395eba4548c1b7a7004f528760dff16c16909ab2 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 15:38:32 -0800 Subject: [PATCH 21/39] Fit the tilt wording under the Realtime prompt ceiling With main merged, the Overpass prompt measured 55,549 against the 55,500 ceiling. The pitchDeg and direction descriptions now read 'Absolute; -89 top-down.' and 'Needed for pan/rotate/tilt steps', bringing it to 55,497. The schema pin and the measured-size note follow. --- server/providers/openai/toolDescriptions.js | 5 ++--- src/radioMarkup.test.mjs | 6 +++--- src/voice/actionSchemas.test.mjs | 2 +- src/voice/pointAndAsk.test.mjs | 7 ++++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js index 1deff2335..f4000ddad 100644 --- a/server/providers/openai/toolDescriptions.js +++ b/server/providers/openai/toolDescriptions.js @@ -523,7 +523,7 @@ export const ACTION_DESCRIPTIONS = { properties: { direction: { description: - 'Required for pan/rotate and a tilt nudge; orbit/rotate_globe default right.', + 'Needed for pan/rotate/tilt steps; orbit/rotate_globe default right.', $position: 2, }, mode: { @@ -532,8 +532,7 @@ export const ACTION_DESCRIPTIONS = { $position: 2, }, pitchDeg: { - description: - 'Absolute; steps use direction. -45 oblique (default), -89 top-down.', + description: 'Absolute; -89 top-down.', $position: 1, }, }, diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index 6e72d6318..3c59d989d 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -232,14 +232,14 @@ test('no unchanged Realtime tool definition drifts silently', () => { camera.description, 'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly"). Globe rotation (rotate_globe) spins Earth right continuously by default; explicit direction, mode and speed take precedence. It checks fit and may refuse.', ); - // Tilt to an angle adds pitchDeg and says a tilt nudge needs a + // Tilt to an angle adds an absolute pitchDeg and says tilt steps need a // direction; check them, then restore the legacy fields for the pin. assert.equal( camera.parameters.properties.direction.description, - 'Required for pan/rotate and a tilt nudge; orbit/rotate_globe default right.', + 'Needed for pan/rotate/tilt steps; orbit/rotate_globe default right.', ); assert.equal(camera.parameters.properties.pitchDeg.type, 'number'); - assert.match(camera.parameters.properties.pitchDeg.description, /-45 oblique/); + assert.match(camera.parameters.properties.pitchDeg.description, /^Absolute;/); delete camera.parameters.properties.pitchDeg; assert.equal(camera.parameters.properties.mode.description, 'once = bounded eased nudge; continuous = until stop/manual input. rotate_globe defaults continuous; other motions default once.'); camera.parameters.properties.mode.description = 'once = bounded eased nudge (default); continuous = until stop/manual input.'; diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index 13f593204..fa9d40bec 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -58,7 +58,7 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // control_cockpit states the own-layer entry rule (Contacts not required). // move_camera tilt takes an optional pitchDeg angle, described as // absolute, with steps sent as a direction. - '8698aee3cf691351f5c5a68e115a5913ec0f048067ecdf34b252a31afeeec737', + '17e54c1a04054edf48f2eddcf88c60ac66ec7c885fb48596b778bfee0be8a544', ); }); diff --git a/src/voice/pointAndAsk.test.mjs b/src/voice/pointAndAsk.test.mjs index 1f1993a07..c413477b8 100644 --- a/src/voice/pointAndAsk.test.mjs +++ b/src/voice/pointAndAsk.test.mjs @@ -350,9 +350,10 @@ test('deixis prompt lint: one short rule, and every sentinel it names exists in // the analyst area scopes) raised the ceiling to 55,500; tightening the // AREAS rule and the area tool descriptions left it at 55,103 as measured // below (every tool, osm_query included); main's Sea Marks and Ski Pistes - // layers bring it to 55,210, and the tilt pitchDeg argument to 55,335. - // integrationContract's Overpass prompt is the tightest, at 55,495 (5 - // under). A new tool or rule has to fit under this ceiling. + // layers bring it to 55,210, and the tilt pitchDeg argument with main's + // cockpit entry rule to 55,337. integrationContract's Overpass prompt is + // the tightest, at 55,497 (3 under). A new tool or rule has to fit under + // this ceiling. const size = instructions.length + JSON.stringify(GEV_REALTIME_TOOLS).length; assert.ok(size <= 55_500, `prompt size ${size}`); }); From c4910d24d03713056affaad92dd67f72606daa98 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 15:43:51 -0800 Subject: [PATCH 22/39] Keep the place centred when a far tilt reaches the horizon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 550 to 1,000 km of range the camera's -5° limit lies past the place's own horizon. The swing ran into its 150 m clearance, let go of the pivot and tilted the rest in place, which lifted the crosshair onto the sky (28 to 63 km up from 600 to 900 km), and a return to top-down then landed hundreds of km from the place. The swing now hands over to an in-place tilt only where the clearance is what stops it (within about 17 km). Farther out it stops with the camera 0.5° above the place's horizon, still pivoting: a tilt to an angle reports the angle it reached with a note, a continuous tilt stops there, and a further tilt up is refused as at the limit. Tests check that the crosshair stays on the place, frame by frame for a continuous tilt, and that top-down returns over it. --- docs/CURRENT-STATE.md | 2 +- src/cameraTilt.test.mjs | 61 ++++++++++++++++++- src/cameraVerbs.js | 132 +++++++++++++++++++++++++++++++++------- 3 files changed, 169 insertions(+), 26 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 4b322618c..3166ab976 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5590,7 +5590,7 @@ and unreachable upstream (502/504) separately from road geometry. > branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on > `main` + 26 route pins; the same tree on Node 24 reports 2,295. -- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. A zoom during a pivoting tilt scales the range it holds, as for orbit, but a zoom in stops at the range that keeps that 150 m clearance; a zoom that can move no further (that floor, the 80 m floor, or the cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) is reported as not zoomed. Once the tilt continues in place, a zoom moves the camera like any zoom. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, or the user grabs, touches or scrolls the map during it, the motion is not run and says so. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. +- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. A zoom during a pivoting tilt scales the range it holds, as for orbit, but a zoom in stops at the range that keeps that 150 m clearance; a zoom that can move no further (that floor, the 80 m floor, or the cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) is reported as not zoomed. Once the tilt continues in place, a zoom moves the camera like any zoom. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, or the user grabs, touches or scrolls the map during it, the motion is not run and says so. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. - **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing. - **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm 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 rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped. - Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet). diff --git a/src/cameraTilt.test.mjs b/src/cameraTilt.test.mjs index a82b2ce37..a67ecee86 100644 --- a/src/cameraTilt.test.mjs +++ b/src/cameraTilt.test.mjs @@ -460,10 +460,10 @@ test('from globe range a tilt happens in place', (t) => { assert.ok(Cesium.Cartesian3.distance(h.camera.positionWC, before) < 1e-3); }); -for (const height of [100_000, 400_000, 900_000]) +for (const height of [100_000, 400_000]) test(`from ${height / 1000} km a tilt to -5° ends with the camera at -5°`, (t) => { - // The pivot pitch is shallower than the camera's by up to 8° at this - // range; the camera reached only -5.9° to -12.9° when the swing stopped + // The pivot pitch is shallower than the camera's by up to 4° at this + // range; the camera reached only -5.9° and -8.6° when the swing stopped // at a pivot pitch of -5°. const h = harness(t, { height }); const result = moveCamera({ motion: 'tilt', pitchDeg: 5 }); @@ -479,6 +479,61 @@ for (const height of [100_000, 400_000, 900_000]) assert.match(nudge.error, /upper tilt limit/); }); +// From 550 km out the camera's -5° lies beyond the place's own horizon: the +// swing ran into its clearance, let go of the pivot and tilted the rest in +// place, which put the crosshair on the sky (28 to 63 km up), and a return +// to top-down then landed 600 to 900 km from the place. +for (const height of [600_000, 700_000, 900_000]) { + test(`from ${height / 1000} km a tilt to -5° keeps the place centred`, (t) => { + const h = harness(t, { height }); + const result = moveCamera({ motion: 'tilt', pitchDeg: 5 }); + assert.equal(result.ok, true); + h.finish(); + assert.ok(h.viewTarget(), 'the crosshair is on the sky'); + assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`); + assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`); + // The reply names the angle the camera reached, not the one asked for. + assert.ok(result.pitchDeg < -5, `reported ${result.pitchDeg}°`); + assert.ok( + Math.abs(pitchDeg(h.camera) - result.pitchDeg) < 0.5, + `camera at ${pitchDeg(h.camera)}°, reported ${result.pitchDeg}°`, + ); + assert.match(result.note, /horizon/); + const nudge = moveCamera({ motion: 'tilt', direction: 'up' }); + assert.equal(nudge.ok, false); + assert.match(nudge.error, /upper tilt limit/); + assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -89 }).ok, true); + h.finish(); + assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`); + // Back over the place at the range it began with (-89° is ~1° off nadir). + const range = Cesium.Cartesian3.distance(h.camera.positionWC, h.target); + assert.ok( + Math.abs(range - height) < height * 0.01, + `top-down ${range} m from the place`, + ); + }); + + test(`a continuous tilt up from ${height / 1000} km stops with the place centred`, (t) => { + const h = harness(t, { height }); + moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }); + for (let i = 0; i < 1200 && getActiveCameraMotion(); i += 1) { + h.tick(); + assert.ok(h.viewTarget(), `the crosshair is on the sky at frame ${i}`); + } + assert.equal(getActiveCameraMotion(), null, 'tilt must stop at its limit'); + assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`); + }); +} + +test('a pitchDeg tilt toward the horizon limit says it is already there', (t) => { + const h = harness(t, { height: 900_000 }); + moveCamera({ motion: 'tilt', pitchDeg: 5 }); + h.finish(); + const again = moveCamera({ motion: 'tilt', pitchDeg: 5 }); + assert.equal(again.ok, false); + assert.match(again.error, /upper tilt limit/); +}); + test('a regional tilt-up nudge goes to the camera horizon limit', (t) => { // 20 km up at -7°: the pivot 186 km out sits at -5.3°, but the camera can // still tilt up 2°. diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 70b83eff5..4618a434d 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -62,6 +62,13 @@ const TILT_MIN_CLEARANCE_M = 150; * crosshair point, so the tilt happens in place instead. */ const TILT_PIVOT_MAX_RANGE_M = 1_000_000; +/** + * The shallowest a pivot swing goes when its clearance is not what stops it. + * From several hundred km the camera's own horizon limit lies past the + * place's horizon; tilting the rest in place there would lift the crosshair + * onto the sky, so the swing stops with the place this far above it. + */ +const TILT_MIN_PIVOT_ELEVATION = Cesium.Math.toRadians(0.5); /* ── Route dolly: cinematic tuning ────────────────────────────────────────── * Every knob an owner may want to retune lives in this block. The shaping is @@ -1273,7 +1280,10 @@ function onTick() { * is clamped to [-89°, -5°] only in the direction of travel: a top-down view * sits at -90°, below the limit, and must still be able to tilt up out of it. * A pivot swing toward the horizon lowers the camera, so it stops at - * TILT_MIN_CLEARANCE_M and the rest tilts in place. + * TILT_MIN_CLEARANCE_M and the rest tilts in place. Farther out it stops with + * the camera TILT_MIN_PIVOT_ELEVATION above the place's horizon instead, and + * keeps the place centred: tilting in place there would lift the crosshair + * onto the sky. */ function tickTilt(cam, m, scale, dt) { const from = cam.pitch; @@ -1288,11 +1298,13 @@ function tickTilt(cam, m, scale, dt) { const limited = up ? Math.min(want, Math.max(from, PITCH_MAX)) : Math.max(want, Math.min(from, PITCH_MIN)); + let atLimit = false; if (m.hpr) { // Apply a range a zoom set since the last frame, even when the pitch // then has nowhere to go (a zoom in to the clearance ends the pivot). cam.lookAt(m.target, m.hpr); - // Up toward the horizon the swing lowers the camera: hold the clearance. + // Up toward the horizon the swing lowers the camera: hold the clearance, + // and keep the place below the horizon. const cap = up ? Math.max(m.hpr.pitch, tiltPivotPitchMax(m.hpr.range)) : Infinity; @@ -1309,21 +1321,77 @@ function tickTilt(cam, m, scale, dt) { // this last step re-took it. if (_active !== m) clearLookAt(); if (up && m.hpr.pitch >= cap && cam.pitch < limited - 1e-6) { - // Down to its clearance: let go of the pivot and tilt the rest in - // place, which keeps the height. - if (_active === m) clearLookAt(); - m.hpr = null; - m.target = null; + if (tiltClearanceBinds(m.hpr.range)) { + // Down to its clearance: let go of the pivot and tilt the rest in + // place, which keeps the height. + if (_active === m) clearLookAt(); + m.hpr = null; + m.target = null; + } else { + // The place is at its horizon limit: stop here, still centred. + atLimit = true; + } } } if (!m.hpr) cam.lookUp(limited - cam.pitch); - if (m.mode !== 'once' && limited !== want) + if (m.mode !== 'once' && (limited !== want || atLimit)) interruptCameraMotion('tilt-clamp'); } +/** The refusal for a tilt up the camera has no room left for. */ +function tiltUpLimitRefusal() { + return { + ok: false, + action: 'move_camera', + error: + 'Already at the upper tilt limit (near the horizon) — tilt down, or zoom/pan instead.', + }; +} + /** Shallowest pivot pitch that keeps the clearance at this range. */ function tiltPivotPitchMax(range) { - return -Math.asin(Math.min(1, TILT_MIN_CLEARANCE_M / range)); + return Math.min( + -Math.asin(Math.min(1, TILT_MIN_CLEARANCE_M / range)), + -TILT_MIN_PIVOT_ELEVATION, + ); +} + +/** + * Whether the clearance, not the horizon, stops a swing at this range. Only + * then does the rest of the tilt happen in place: that close in (under ~17 + * km) the camera's -5° limit stays well below its own horizon. + */ +function tiltClearanceBinds(range) { + return TILT_MIN_CLEARANCE_M / range >= Math.sin(TILT_MIN_PIVOT_ELEVATION); +} + +/** + * The shallowest camera pitch a tilt starting from this frame can reach with + * the place under the crosshair still in view: PITCH_MAX, or less where a + * far pivot meets its horizon limit first. + */ +function tiltUpLimit({ target, pivot }) { + if (!pivot || tiltClearanceBinds(pivot.range)) return PITCH_MAX; + const pitch = Math.max(pivot.pitch, tiltPivotPitchMax(pivot.range)); + // The camera's position for that pivot pitch, as lookAt places it. + const offset = new Cesium.Cartesian3( + -pivot.range * Math.cos(pitch) * Math.sin(pivot.heading), + -pivot.range * Math.cos(pitch) * Math.cos(pivot.heading), + -pivot.range * Math.sin(pitch), + ); + const eye = Cesium.Matrix4.multiplyByPoint( + Cesium.Transforms.eastNorthUpToFixedFrame(target), + offset, + new Cesium.Cartesian3(), + ); + const look = Cesium.Cartesian3.subtract(target, eye, new Cesium.Cartesian3()); + Cesium.Cartesian3.normalize(look, look); + const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84; + const upAtEye = ellipsoid.geodeticSurfaceNormal(eye, new Cesium.Cartesian3()); + return Math.min( + PITCH_MAX, + Math.asin(Cesium.Math.clamp(Cesium.Cartesian3.dot(look, upAtEye), -1, 1)), + ); } /** @@ -1710,17 +1778,22 @@ export function moveCamera(args = {}, runNavigation = null) { error: `pitchDeg is an absolute angle, not a step: ${target}° from ${now}° tilts ${towards}, not ${direction}. Send direction alone for a nudge, or pitchDeg alone for an angle.`, }; } + if ( + towards === 'up' && + from >= + tiltUpLimit(tiltStartFrame(_viewer.camera)) - Cesium.Math.toRadians(0.5) + ) + return tiltUpLimitRefusal(); } else 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 === 'up' && + pitch >= + tiltUpLimit(tiltStartFrame(_viewer.camera)) - Cesium.Math.toRadians(0.5) + ) { + return tiltUpLimitRefusal(); } if ( direction === 'down' && @@ -1816,6 +1889,7 @@ export function moveCamera(args = {}, runNavigation = null) { error: 'Globe rotation requires a clear ellipsoid bounding sphere.', }; } + let horizonLimited = null; const start = () => { interruptCameraMotion('replaced'); const state = { @@ -1825,27 +1899,36 @@ export function moveCamera(args = {}, runNavigation = null) { mode, }; if (motion === 'tilt') { - const { target, pivot, from } = tiltStartFrame(_viewer.camera); + const frame = tiltStartFrame(_viewer.camera); + const { target, pivot, from } = frame; if (pivot) { state.target = target; state.hpr = pivot; } state.startPitch = from; + const upLimit = tiltUpLimit(frame); if (tiltToPitch !== null) { + // From far out the place reaches its horizon before the camera + // reaches the asked angle: stop there, and say so. + const goal = + tiltToPitch > from + ? Math.min(tiltToPitch, Math.max(from, upLimit)) + : tiltToPitch; state.mode = 'once'; - state.direction = tiltToPitch > from ? 'up' : 'down'; - state.goalPitch = tiltToPitch; + state.direction = goal > from ? 'up' : 'down'; + state.goalPitch = goal; state.durationS = Math.max( ONCE.durationS, - Math.abs(Cesium.Math.toDegrees(tiltToPitch - from)) / TILT_TO_DEG_S, + Math.abs(Cesium.Math.toDegrees(goal - from)) / TILT_TO_DEG_S, ); + if (goal !== tiltToPitch) horizonLimited = goal; } else { // A nudge stops at the limit it travels toward. const step = Cesium.Math.toRadians(ONCE.tiltDeg) * (direction === 'up' ? 1 : -1); state.goalPitch = step > 0 - ? Math.min(from + step, Math.max(from, PITCH_MAX)) + ? Math.min(from + step, Math.max(from, upLimit)) : Math.max(from + step, Math.min(from, PITCH_MIN)); } } @@ -1937,7 +2020,12 @@ export function moveCamera(args = {}, runNavigation = null) { speed, mode: state.mode, ...(tiltToPitch !== null && { - pitchDeg: Math.round(Cesium.Math.toDegrees(tiltToPitch)), + pitchDeg: Math.round( + Cesium.Math.toDegrees(horizonLimited ?? tiltToPitch), + ), + }), + ...(horizonLimited !== null && { + note: `Stopped at ${Math.round(Cesium.Math.toDegrees(horizonLimited))}°: from this range a shallower angle puts the place past the horizon. Zoom in to tilt further.`, }), }; }; From 1eef14454ae7bb2b3868578978897c00ed6383a3 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 15:48:21 -0800 Subject: [PATCH 23/39] Drop a queued voice motion when another navigation takes the camera mid-flight A motion queued behind a voice fly-to checked only that its reply was current and that the user had not touched the map. A location-panel search, share link or file import stamps a deferred navigation without stopping the flight, so the flight landed, the motion took navigation, and the search's reassert then failed: the search was silently dropped while the camera kept orbiting. The runner now counts camera handoffs from subscribeCameraHandoff, less the ones voice fly-tos make themselves, and each voice flight records that count when it starts. A motion whose flight saw another navigation since then is not run and says so. Voice fly-tos' own stamps do not count, so a later fly-to in the same reply, including a lookup that finds nothing, is still followed as before. The test harness now publishes handoffs and stamps its voice lookups, as the shell does. --- docs/CURRENT-STATE.md | 2 +- src/voice/gevActions.js | 26 ++++++++++ src/voice/gevActions.test.mjs | 91 +++++++++++++++++++++++++++++++++-- 3 files changed, 113 insertions(+), 6 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3166ab976..2f269ee48 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5590,7 +5590,7 @@ and unreachable upstream (502/504) separately from road geometry. > branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on > `main` + 26 route pins; the same tree on Node 24 reports 2,295. -- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. A zoom during a pivoting tilt scales the range it holds, as for orbit, but a zoom in stops at the range that keeps that 150 m clearance; a zoom that can move no further (that floor, the 80 m floor, or the cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) is reported as not zoomed. Once the tilt continues in place, a zoom moves the camera like any zoom. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, or the user grabs, touches or scrolls the map during it, the motion is not run and says so. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. +- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. A zoom during a pivoting tilt scales the range it holds, as for orbit, but a zoom in stops at the range that keeps that 150 m clearance; a zoom that can move no further (that floor, the 80 m floor, or the cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) is reported as not zoomed. Once the tilt continues in place, a zoom moves the camera like any zoom. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the user grabs, touches or scrolls the map during it, or another navigation (a location-panel search, share link or file import, which leave the flight flying) takes the camera during it, the motion is not run and says so. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. - **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing. - **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm 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 rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped. - Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet). diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 5a30a8cf3..045969a4f 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -296,6 +296,16 @@ export function createGevActionRunner({ let voiceFlight = null; // Waiters to tell when a newer voice flight begins. const voiceFlightWatchers = new Set(); + // Camera handoffs (navigation stamps) seen, and how many of them voice + // fly-tos made. A search, share link or import that stamps during a flight + // leaves it flying; the motion queued behind it then yields to that one. + let cameraHandoffs = 0; + let voiceFlightHandoffs = 0; + const otherHandoffs = () => cameraHandoffs - voiceFlightHandoffs; + const unsubscribeCameraHandoff = + styleManager?.subscribeCameraHandoff?.(() => { + cameraHandoffs += 1; + }) ?? null; let analystEngine; // Area handles live for this runner (the page): resolve_area, the // analyst's region/area/drawn scopes, find_imagery and osm_query all read @@ -1155,6 +1165,8 @@ export function createGevActionRunner({ prior && !prior.done ? prior.inputCount : undefined, prior, ); + // Navigation by anything but a voice fly-to since this one began. + flight.handoffs = otherHandoffs(); voiceFlight = flight; for (const notify of [...voiceFlightWatchers]) notify(); // A motion already running (one the model sent before this fly-to) is @@ -1164,9 +1176,13 @@ export function createGevActionRunner({ (hook) => (...hookArgs) => { const before = getActiveCameraMotion(); + const handoffsBefore = cameraHandoffs; try { return hook(...hookArgs); } finally { + // A voice fly-to's own stamps are not another navigation, even + // for the flight it replaces (a later lookup may find nothing). + voiceFlightHandoffs += cameraHandoffs - handoffsBefore; if ( before && !stoppedMotion && @@ -1383,6 +1399,15 @@ export function createGevActionRunner({ motion, error: `Not run: the user moved the camera during the flight, so the ${motion} was dropped.`, }; + // A search, share link or import that took navigation during the + // flight leaves it flying; running now would cancel that navigation. + if (otherHandoffs() !== flight.handoffs) + return { + ok: false, + action: 'move_camera', + motion, + error: `Not run: another navigation took the camera during the flight, so the ${motion} was dropped.`, + }; } return moveCamera(args, (navigate, releaseOptions) => runManagedVoiceNavigation( @@ -1712,6 +1737,7 @@ export function createGevActionRunner({ if (disposed) return; runVoiceAction.resetConversation(); disposed = true; + unsubscribeCameraHandoff?.(); voiceAreas.dispose(); }; return runVoiceAction; diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index b2cdbedc4..f94eb554b 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -143,6 +143,14 @@ test('track_entity runner narrates a callsign-less aircraft by its registration' function createVoiceNavigationHarness({ cockpitActive = false } = {}) { const order = []; let generation = 0; + // Camera handoffs, published on every navigation stamp as the shell does. + const handoffListeners = new Set(); + const stamp = (noun) => { + generation += 1; + order.push(`stamp:${noun}`); + for (const listener of [...handoffListeners]) listener({ generation }); + return generation; + }; const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.26, 500); const viewer = { trackedEntity: { id: 'prior-aircraft' }, @@ -179,11 +187,7 @@ function createVoiceNavigationHarness({ cockpitActive = false } = {}) { return runExplicitNavigation({ cockpitActive, noun, - stamp: () => { - generation += 1; - order.push(`stamp:${noun}`); - return generation; - }, + stamp: () => stamp(noun), release: () => { order.push('release'); viewer.trackedEntity = undefined; @@ -195,12 +199,21 @@ function createVoiceNavigationHarness({ cockpitActive = false } = {}) { }); }, getDetectionState: () => ({ detectionMode: 'DENSE' }), + subscribeCameraHandoff(listener) { + handoffListeners.add(listener); + return () => handoffListeners.delete(listener); + }, + // A voice lookup stamps when it begins and leaves any flight flying. + beginDeferredLocationNavigation: () => stamp('location-lookup'), + reassertDeferredLocationNavigation: (stamped) => stamped === generation, }; return { order, viewer, styleManager, currentGeneration: () => generation, + // A location-panel search: a deferred stamp that leaves a flight flying. + panelSearch: () => stamp('panel-search'), }; } @@ -921,6 +934,74 @@ test('a motion follows a newer fly-to that lands while its own lookup stalls', a assert.equal(result.motion, 'orbit'); }); +// "Fly to Paris and orbit it", then a location-panel search while the camera +// flies: the search stamps a deferred navigation without stopping the flight. +// Run where the flight landed, the orbit took navigation and the search's +// reassert failed, so the search was silently dropped. +test('a motion queued behind a fly-to yields to a search started during the flight', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, flights, panelSearch, currentGeneration } = + tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'orbit' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const search = panelSearch(); + flights[0].complete(); + const result = await moved; + assert.equal(result.ok, false); + assert.match(result.error, /Not run: another navigation took the camera/); + assert.equal(getActiveCameraMotion(), null); + assert.equal(currentGeneration(), search, 'the search still owns navigation'); +}); + +test('a motion behind a geocoded fly-to yields to a search started while it flies', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, panelSearch } = tiltAfterFlightHarness(); + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + const fly = runner('fly_to_location', { query: 'Place A' }); + const moved = runner('move_camera', { motion: 'orbit' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + searches[0].resolve({ label: 'Place A', navigationMode: 'city-overview' }); + assert.equal((await fly).ok, true); + panelSearch(); + searches[0].options.onComplete(); + const result = await moved; + assert.equal(result.ok, false); + assert.match(result.error, /another navigation took the camera/); + assert.equal(getActiveCameraMotion(), null); +}); + +test('a search before the fly-to does not drop the motion queued behind it', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, flights, panelSearch } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + panelSearch(); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + await new Promise((resolve) => setTimeout(resolve, 0)); + flights[0].complete(); + const result = await moved; + assert.equal(result.ok, true); + assert.equal(result.motion, 'tilt'); +}); + test('a fly-to that stops a running motion says so', async (t) => { t.after(() => interruptCameraMotion('test-cleanup')); const { viewer, styleManager } = tiltAfterFlightHarness(); From dae95e1c7f866d07dba57f10c5bd0675378eba0c Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 15:51:14 -0800 Subject: [PATCH 24/39] Refuse a motion after its reply's fly-to found nothing, also on Gemini Gemini runs one turn's calls one after another. By the time "pan left" ran beside "fly to Xyzzyville", the lookup had already found nothing, so there was no flight to wait on and the pan moved the old view with ok: true. OpenAI's parallel calls refused the same reply. The runner now remembers, for the latest reply that sent a voice fly-to, whether one moved the camera and whether one found nothing. A tilt, orbit, pan or rotate of that reply with no flight left to wait on is refused when its fly-to found nothing and none moved. The reply is Gemini's turn, passed as a per-turn reply object, or OpenAI's authorityGroup. A motion in a later reply, or beside a fly-to of the same reply that did land, runs as before. --- docs/CURRENT-STATE.md | 2 +- src/voice/geminiSession.js | 9 ++++ src/voice/geminiSession.test.mjs | 28 ++++++++++++ src/voice/gevActions.js | 41 ++++++++++++++++++ src/voice/gevActions.test.mjs | 74 ++++++++++++++++++++++++++++++++ 5 files changed, 153 insertions(+), 1 deletion(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2f269ee48..9036db387 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5590,7 +5590,7 @@ and unreachable upstream (502/504) separately from road geometry. > branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on > `main` + 26 route pins; the same tree on Node 24 reports 2,295. -- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. A zoom during a pivoting tilt scales the range it holds, as for orbit, but a zoom in stops at the range that keeps that 150 m clearance; a zoom that can move no further (that floor, the 80 m floor, or the cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) is reported as not zoomed. Once the tilt continues in place, a zoom moves the camera like any zoom. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the user grabs, touches or scrolls the map during it, or another navigation (a location-panel search, share link or file import, which leave the flight flying) takes the camera during it, the motion is not run and says so. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. +- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. A zoom during a pivoting tilt scales the range it holds, as for orbit, but a zoom in stops at the range that keeps that 150 m clearance; a zoom that can move no further (that floor, the 80 m floor, or the cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) is reported as not zoomed. Once the tilt continues in place, a zoom moves the camera like any zoom. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the user grabs, touches or scrolls the map during it, or another navigation (a location-panel search, share link or file import, which leave the flight flying) takes the camera during it, the motion is not run and says so. A motion that runs after a fly-to of the same reply has already found nothing (Gemini runs one turn's calls one by one, and passes its turn as the reply) is refused the same way rather than moving the old view; a motion in a later reply runs. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. - **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing. - **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm 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 rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped. - Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet). diff --git a/src/voice/geminiSession.js b/src/voice/geminiSession.js index da93b4bb6..8996774b3 100644 --- a/src/voice/geminiSession.js +++ b/src/voice/geminiSession.js @@ -184,6 +184,9 @@ export class GeminiSessionController { this.model = null; this.epoch = 0; this.turn = 0; + // Identifies one turn's tool calls to the runner as one reply; a new + // object each turn, so no two sessions or turns share it. + this.turnReply = {}; this.globeToolsBlocked = false; this.globeResetFailed = false; this.inputGeneration = 0; @@ -971,6 +974,7 @@ export class GeminiSessionController { const tools = [...this.tools.values()]; this.turnAbort = new AbortController(); this.turn++; + this.turnReply = {}; this.typedIntent = null; this.radioStrongerCommit = null; this.pruneControls(); @@ -1544,6 +1548,7 @@ export class GeminiSessionController { ) throw new Error(msg('Gemini requested too many simultaneous actions')); const turn = this.turn; + const reply = this.turnReply; this.userTurnPending = false; this.modelComplete = false; // Admit each valid native call ID once per connection. @@ -1565,6 +1570,7 @@ export class GeminiSessionController { const action = new AbortController(); action.call = { id: call.id, name: call.name }; action.sequence = ++this.toolSequence; + action.reply = reply; this.tools.set(call.id, action); const priorTools = this.toolQueue; const args = @@ -1674,6 +1680,9 @@ export class GeminiSessionController { this.runAction(call.name, args, { signal: toolSignal, callId: call.id, + // Calls run one by one, so a motion can follow a fly-to of the + // same reply that already found nothing. + reply: action.reply, progress: (update) => { if (actionCurrent()) this.narration.progress(call.id, update); }, diff --git a/src/voice/geminiSession.test.mjs b/src/voice/geminiSession.test.mjs index 9ce8d351a..82f403ef7 100644 --- a/src/voice/geminiSession.test.mjs +++ b/src/voice/geminiSession.test.mjs @@ -1241,6 +1241,34 @@ test('successful stronger Radio stop still invalidates the prepared group', asyn h.c.stop(); }); +test('Gemini tools of one turn share a reply identity, and a new turn gets another', async () => { + const replies = []; + const h = harness({ + runAction: (name, _args, next) => { + replies.push([name, next.reply]); + return { ok: true, action: name }; + }, + }); + await h.c.start({ pushToTalk: true }); + h.c.sendTextCommand('Fly to Xyzzyville and pan left'); + const ws = h.sockets[0]; + requestTool(ws, 'fly', 'fly_to_location', { query: 'Xyzzyville' }); + requestTool(ws, 'pan', 'move_camera', { motion: 'pan', direction: 'left' }); + await h.c.toolQueue; + assert.equal(replies.length, 2); + assert.ok(replies[0][1], 'a sequentially run call carries its reply'); + assert.equal(replies[1][1], replies[0][1]); + drainRadioReply(h); + await tick(); + assert.equal(h.c.sendTextCommand('Pan left'), true); + requestTool(ws, 'pan-2', 'move_camera', { motion: 'pan', direction: 'left' }); + await h.c.toolQueue; + assert.equal(replies.length, 3); + assert.ok(replies[2][1]); + assert.notEqual(replies[2][1], replies[0][1]); + h.c.stop(); +}); + test('slow Gemini tools reuse shared progress timing without injecting a conversation turn', async () => { const pending = deferred(); let options; diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 045969a4f..a01e3328e 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -299,6 +299,11 @@ export function createGevActionRunner({ // Camera handoffs (navigation stamps) seen, and how many of them voice // fly-tos made. A search, share link or import that stamps during a flight // leaves it flying; the motion queued behind it then yields to that one. + // The voice fly-tos of the latest reply that sent one: whether any moved + // the camera, and whether one found nothing. A motion of that reply run + // after its fly-to found nothing (Gemini runs a reply's calls one by one) + // has no flight to wait on, and must not move the old view instead. + let replyFlights = null; let cameraHandoffs = 0; let voiceFlightHandoffs = 0; const otherHandoffs = () => cameraHandoffs - voiceFlightHandoffs; @@ -1168,6 +1173,10 @@ export function createGevActionRunner({ // Navigation by anything but a voice fly-to since this one began. flight.handoffs = otherHandoffs(); voiceFlight = flight; + const reply = voiceReplyOf(runOptions); + if (reply !== null && replyFlights?.reply !== reply) + replyFlights = { reply, moved: false, foundNothing: false }; + const replyRecord = reply !== null ? replyFlights : null; for (const notify of [...voiceFlightWatchers]) notify(); // A motion already running (one the model sent before this fly-to) is // stopped when the flight takes navigation; the result says so. @@ -1239,6 +1248,13 @@ export function createGevActionRunner({ flight.settle('failed'); else notStarted(); }, notStarted); + // After the outcome above, which marks a lookup that moved nothing. + const noteReply = (result) => { + if (!replyRecord) return; + if (result?.ok === true) replyRecord.moved = true; + else if (flight.movedNothing) replyRecord.foundNothing = true; + }; + flown.then(noteReply, () => noteReply(null)); return flown.then((result) => stoppedMotion && result && typeof result === 'object' ? { ...result, stoppedMotion } @@ -1349,6 +1365,21 @@ export function createGevActionRunner({ // A lookup that already found nothing does not hide the last flight // that moved: running now would cancel that flight mid-air. const pending = voiceFlightTarget(voiceFlight); + const reply = voiceReplyOf(runOptions); + if ( + VOICE_FLIGHT_WAITING_MOTIONS.has(motion) && + !(pending && !pending.done) && + reply !== null && + replyFlights?.reply === reply && + replyFlights.foundNothing && + !replyFlights.moved + ) + return { + ok: false, + action: 'move_camera', + motion, + error: `Not run: the fly-to in this reply found nothing, so the ${motion} was not run at the old view.`, + }; if ( VOICE_FLIGHT_WAITING_MOTIONS.has(motion) && pending && @@ -1724,6 +1755,7 @@ export function createGevActionRunner({ * forget the analyst's "last answer". Area handles stay (page lifetime). */ runVoiceAction.resetConversation = () => { + replyFlights = null; conversationAbort.abort(); conversationAbort = new AbortController(); conversation++; @@ -3012,6 +3044,15 @@ async function frameOverhead(viewer, dataManager, styleManager, args = {}) { let voiceFlightCount = 0; +/** + * The model reply a voice call belongs to: Gemini's turn (`reply`), or the + * OpenAI response its sibling calls share (`authorityGroup`). Null when the + * caller names none. + */ +function voiceReplyOf(options) { + return options?.reply ?? options?.authorityGroup ?? null; +} + /** * A voice flight's end: 'arrived', 'cancelled' or 'failed', settled once. * `replaced` is the last flight that moved when this one began (past lookups diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index f94eb554b..f4be07148 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -938,6 +938,80 @@ test('a motion follows a newer fly-to that lands while its own lookup stalls', a // flies: the search stamps a deferred navigation without stopping the flight. // Run where the flight landed, the orbit took navigation and the search's // reassert failed, so the search was silently dropped. +// Gemini runs one reply's calls one after another: by the time "pan left" +// runs, "fly to Xyzzyville" has already found nothing, so there is no flight +// to wait on, and the pan moved the old view with ok: true. +test('a motion run after its reply\'s fly-to found nothing is not run', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: async () => null, + }); + const reply = { turn: 1 }; + const fly = await runner('fly_to_location', { query: 'Xyzzyville' }, { reply }); + assert.equal(fly.ok, false); + const pan = await runner( + 'move_camera', + { motion: 'pan', direction: 'left' }, + { reply }, + ); + assert.equal(pan.ok, false); + assert.match(pan.error, /Not run: the fly-to in this reply found nothing/); + assert.equal(getActiveCameraMotion(), null); + // The same holds for OpenAI's reply identity. + const group = 'response-1'; + await runner('fly_to_location', { query: 'Xyzzyville' }, { authorityGroup: group }); + const tilt = await runner( + 'move_camera', + { motion: 'tilt', pitchDeg: -45 }, + { authorityGroup: group }, + ); + assert.equal(tilt.ok, false); + assert.match(tilt.error, /found nothing/); +}); + +test('a motion in a later reply runs after a fly-to that found nothing', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: async () => null, + }); + await runner('fly_to_location', { query: 'Xyzzyville' }, { reply: { turn: 1 } }); + const pan = await runner( + 'move_camera', + { motion: 'pan', direction: 'left' }, + { reply: { turn: 2 } }, + ); + assert.equal(pan.ok, true, pan.error); +}); + +test('a motion runs at a flight of its reply that landed after another lookup found nothing', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: async () => null, + }); + const reply = { turn: 1 }; + await runner('fly_to_location', { query: 'Xyzzyville' }, { reply }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' }, { reply })).ok, true); + flights[0].complete(); + const pan = await runner( + 'move_camera', + { motion: 'pan', direction: 'left' }, + { reply }, + ); + assert.equal(pan.ok, true, pan.error); +}); + test('a motion queued behind a fly-to yields to a search started during the flight', async (t) => { t.after(() => interruptCameraMotion('test-cleanup')); const { viewer, styleManager, flights, panelSearch, currentGeneration } = From a20076fc5ef8976b1007170a05a3fc91765c48d3 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 15:55:52 -0800 Subject: [PATCH 25/39] fix(camera): judge a voice tilt's frame limit by the way its pitch moves The pre-check picked the in-place look from args.direction alone, while the tilt itself turns down for any direction but up. A tilt sent as left or right at the frame limit was checked as a turn left or right, refused as losing the globe, though it turns down toward the planet. A rotate sent as up or down was checked as a look up or down, though it turns right. A tilt's look now comes from its goal pitch against the current pitch (tiltLook), and the tick tilts in place through tiltInPlaceWithinFrame(cam, m, goalPitch). Neither depends on the direction word, so a tilt to an absolute angle, or a tilt with no direction, can use them as they are. --- src/cameraGlobeRotation.test.mjs | 20 ++++++++++++++++ src/cameraVerbs.js | 40 +++++++++++++++++++++++++------- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/src/cameraGlobeRotation.test.mjs b/src/cameraGlobeRotation.test.mjs index 543c01bff..09fc93f00 100644 --- a/src/cameraGlobeRotation.test.mjs +++ b/src/cameraGlobeRotation.test.mjs @@ -1426,6 +1426,26 @@ test('voice tilt from orbit stops with the planet still on screen and then says assert.ok(camera.pitch > radians(-68.5) && camera.pitch < radians(-66)); }); +// The frame limit judges a tilt by the way its pitch moves, not by the +// direction word: a tilt sent as left or right turns down. +for (const direction of ['left', 'right']) { + test(`a tilt ${direction} from the frame limit is judged as the look down it is`, (t) => { + withScope(t, false); + const { camera, finishOnce } = harness(t, { + pitch: -80, + viewportWidth: 1600, + viewportHeight: 900, + }); + assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true); + finishOnce(); + const atLimit = camera.pitch; + const result = moveCamera({ motion: 'tilt', direction }); + assert.equal(result.ok, true, result.error); + finishOnce(); + assert.ok(camera.pitch < atLimit - radians(5), 'the tilt turned down'); + }); +} + test('voice turn in place from orbit stops with the planet still on screen', (t) => { withScope(t, false); // the whole frame is visible const { camera, tick } = harness(t, { pitch: -75 }); diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 3c8424b82..9715e24f7 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -1090,6 +1090,23 @@ function lookWithinFrame(cam, m, look, angle) { if (allowed < angle) interruptCameraMotion('globe-out-of-frame'); } +/** + * The in-place look a pitch change from `fromPitch` to `goalPitch` makes: + * 'lookUp', 'lookDown', or null when it does not move. A tilt is judged by + * the way its pitch moves, whatever form or direction word it came in. + */ +function tiltLook(fromPitch, goalPitch) { + if (!Number.isFinite(fromPitch) || !Number.isFinite(goalPitch)) return null; + if (goalPitch > fromPitch) return 'lookUp'; + return goalPitch < fromPitch ? 'lookDown' : null; +} + +/** Tilt `m` in place to `goalPitch`, as far as the frame limit allows. */ +function tiltInPlaceWithinFrame(cam, m, goalPitch) { + const look = tiltLook(cam.pitch, goalPitch); + if (look) lookWithinFrame(cam, m, look, Math.abs(goalPitch - cam.pitch)); +} + /** True when the pre-check proved a half-degree `look` from `cam`'s pose. */ function lookProven(cam, look) { return ( @@ -1358,7 +1375,7 @@ function onTick() { interruptCameraMotion('tilt-clamp'); return; } - lookWithinFrame(cam, m, up ? 'lookUp' : 'lookDown', stepRad); + tiltInPlaceWithinFrame(cam, m, next); } else { lookWithinFrame( cam, @@ -1662,15 +1679,20 @@ export function moveCamera(args = {}, runNavigation = null) { }; } } + // A tilt is judged by the way its pitch will move (any direction other + // than up tilts down); a rotate turns left only when told to, as it runs. const look = - motion === 'tilt' || motion === 'rotate' - ? { - up: 'lookUp', - down: 'lookDown', - left: 'lookLeft', - right: 'lookRight', - }[direction] - : null; + motion === 'tilt' + ? tiltLook( + _viewer.camera.pitch, + _viewer.camera.pitch + + Cesium.Math.toRadians(ONCE.tiltDeg) * (direction === 'up' ? 1 : -1), + ) + : motion === 'rotate' + ? direction === 'left' + ? 'lookLeft' + : 'lookRight' + : null; // Honest limits from orbit too: a stopped turn rests within 0.05° of its // limit, so one that cannot go half a degree further is refused rather than // run and reported as done, and one that can is then turned at least that From 44106b2bfbc1634d5ea91175cc530f83c381cae5 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 15:55:52 -0800 Subject: [PATCH 26/39] fix(camera): hold the globe recovery while a pointer is down Holding a drag still for half a second on a view that already showed no surface looked like a camera at rest, so the recovery turned the view down under the user's hand. The recovery now defers while a pointer pressed on the canvas is held, and runs once it is released, as for a flight or a playing scene. --- docs/CURRENT-STATE.md | 5 +++-- src/app/application.test.mjs | 33 +++++++++++++++++++++++++++++++++ src/app/viewer.js | 31 +++++++++++++++++++++++++------ 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 27c31f4ce..545c4efd8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -103,8 +103,9 @@ zoom-out guard above keeps wheel and pinch zoom-outs in view; this catches the other ways a move can end looking away, such as a saved or shared view that did. Views with more surface than that, such as the oblique orbital Director shots, are left as they are, and so are tracked or locked cameras, a camera flight in -progress, a Director scene while it plays (holds included) and the 2D and -Columbus modes; a check a flight or scene defers runs once it ends. A Director +progress, a Director scene while it plays (holds included), a pointer held +down on the globe (a drag held still) and the 2D and Columbus modes; a check a +flight, scene or held pointer defers runs once it ends. A Director shot loaded or replayed outside a run is not a playing scene. A view that loses its surface without a camera move (a resize, or turning the scope on) is left as it is until the next move. A voice turn requested while the camera is tracked is diff --git a/src/app/application.test.mjs b/src/app/application.test.mjs index da01ff61c..e25b2ce11 100644 --- a/src/app/application.test.mjs +++ b/src/app/application.test.mjs @@ -778,6 +778,39 @@ test('a check a scene or flight defers runs once it ends', () => { } }); +// Holding a drag still for half a second on a view that already showed no +// surface looked like a settled camera, and the view was turned down under +// the user's hand. +test('the recovery waits while a pointer is held on the globe', () => { + const timers = fakeTimers(); + const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 }); + const document = new EventTarget(); + scene.canvas = Object.assign(new EventTarget(), { + clientWidth: 1225, + clientHeight: 923, + ownerDocument: document, + }); + const dispose = installGlobeInViewRecovery(viewer, timers); + aim(camera, reportedStuckView); + scene.canvas.dispatchEvent(new Event('pointerdown')); + camera.moveStart.raiseEvent(); + for (let i = 0; i < 8; i += 1) timers.tick(); + camera.moveEnd.raiseEvent(); + for (let i = 0; i < 8; i += 1) timers.tick(); + assert.equal(globeOutOfFrame(camera), true, 'left alone while held'); + document.dispatchEvent(new Event('pointerup')); + for (let i = 0; i < 3; i += 1) timers.tick(); + assert.ok( + Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6), + 'recovered once the pointer is released', + ); + dispose(); + aim(camera, reportedStuckView); + scene.canvas.dispatchEvent(new Event('pointerdown')); + document.dispatchEvent(new Event('pointerup')); + assert.equal(timers.pending(), 0, 'disposed recovery holds no listeners'); +}); + function fakeTimers() { let next = 1; const pending = new Map(); diff --git a/src/app/viewer.js b/src/app/viewer.js index ef36feb8a..12ef67269 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -172,9 +172,10 @@ const SETTLE_GIVE_UP_MS = 5 * 60_000; * catches a saved or shared view that looked away, or any other move that ends * looking away from the planet. * Heading and position are kept. Tracked cameras, locked ones (non-identity - * transform), a camera flight in progress, the 2D and Columbus modes, and any - * time `paused` returns true (an authored scene playing) are left alone; a - * check they defer runs once they end, however long they last. + * transform), a camera flight in progress, a pointer held down on the canvas + * (a drag held still), the 2D and Columbus modes, and any time `paused` + * returns true (an authored scene playing) are left alone; a check they defer + * runs once they end, however long they last. * `keyholeRadius` * returns the radius of the visible circle when a scope mask hides the rest of * the frame (see `globeFrameShare`), or 0; it is read at every check. @@ -205,12 +206,27 @@ export function installGlobeInViewRecovery( if (!scene || !camera?.moveEnd || !camera?.moveStart) throw new TypeError('A complete Cesium viewer is required'); - // Returns false while a flight or a paused scene defers the check, so the - // caller can try again once it ends. + // A pointer held on the globe is a gesture in progress, even when it holds + // still for half a second: the check waits for its release. + const canvas = scene.canvas; + const releaseTarget = canvas?.ownerDocument ?? canvas; + let pointerHeld = false; + const onPointerDown = () => { + pointerHeld = true; + }; + const onPointerUp = () => { + pointerHeld = false; + }; + canvas?.addEventListener?.('pointerdown', onPointerDown); + releaseTarget?.addEventListener?.('pointerup', onPointerUp); + releaseTarget?.addEventListener?.('pointercancel', onPointerUp); + + // Returns false while a flight, a paused scene or a held pointer defers the + // check, so the caller can try again once it ends. const recover = () => { if (!isFreeGlobeCamera(viewer)) return true; // Cesium has no public running-flight flag. - if (camera._currentFlight || paused()) return false; + if (camera._currentFlight || paused() || pointerHeld) return false; const height = camera.positionCartographic?.height; if ( !(height > minHeight) || @@ -294,6 +310,9 @@ export function installGlobeInViewRecovery( stopSettling(); removeMoveEnd(); removeMoveStart(); + canvas?.removeEventListener?.('pointerdown', onPointerDown); + releaseTarget?.removeEventListener?.('pointerup', onPointerUp); + releaseTarget?.removeEventListener?.('pointercancel', onPointerUp); }; } From 82138478d0ff25c57f5f906e494ed0beca723437 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:03:52 -0800 Subject: [PATCH 27/39] fix(wildlife): treat a valid empty Acartia snapshot as a fresh success A quiet or fully filtered feed raised invalid_snapshot, so the proxy answered 502 with a 15 s cooldown and re-polled about four times a minute. Only a non-array payload is malformed now; an empty projection caches for the normal ten minutes and clears earlier rows. --- docs/CURRENT-STATE.md | 5 +- server/providers/acartia.js | 4 +- server/providers/acartia.test.mjs | 84 ++++++++++++++++++++++--------- 3 files changed, 68 insertions(+), 25 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1b1b73d2c..b615bb70d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -123,7 +123,10 @@ known `Retry-After`. Failures keep filters and drawn rows, reporting DEGRADED with retry timing; logs exclude request URLs and upstream exception text. `/api/acartia/sightings` adds Salish Sea marine mammals within 45.5–50.5° N, -126–121° W, cached ten minutes and stale for at most 24 h. WHALES accepts only +126–121° W, cached ten minutes and stale for at most 24 h. A valid empty +snapshot, including one whose rows all fall outside coverage or the species +checks, is a fresh success that clears earlier rows; only a malformed or failed +upstream reply serves stale and starts a retry cooldown. WHALES accepts only recognized cetaceans; other identified marine mammals appear under ALL or MAMMALS. Sensitive iNaturalist locations remain 0.2° privacy cells; Acartia positions are rounded to 0.01° on the server. Acartia comments are never retained. diff --git a/server/providers/acartia.js b/server/providers/acartia.js index e3dafd220..c8f8b90d7 100644 --- a/server/providers/acartia.js +++ b/server/providers/acartia.js @@ -100,7 +100,9 @@ export function acartiaProxy({ signal, ); const rows = normalizeAcartiaSightings(payload, now()); - if (!rows?.length) throw new Error('invalid_snapshot'); + // Only a non-array is malformed. A quiet feed, or one whose rows all fall + // outside coverage or the species checks, is a valid empty snapshot. + if (!rows) throw new Error('invalid_snapshot'); return { fetchedAt: now(), rows }; } catch (error) { if (error.status !== 429) coolDown(502, FAILURE_COOLDOWN_MS); diff --git a/server/providers/acartia.test.mjs b/server/providers/acartia.test.mjs index bf61d987c..e7b3cd32e 100644 --- a/server/providers/acartia.test.mjs +++ b/server/providers/acartia.test.mjs @@ -221,43 +221,81 @@ test('one global snapshot single-flights and stays fresh for ten minutes', async assert.equal(calls, 2); }); -test('empty, unprojectable and invented wrapped snapshots never replace the cache', async () => { - for (const bad of [ +test('a valid empty or fully filtered snapshot is a fresh success that clears old rows', async () => { + for (const quiet of [ [], [null], [{ ...sighting(), data_source_name: '' }], - { results: [sighting()] }, + [{ ...sighting(), latitude: 37.8 }], + [{ ...sighting(), type: 'Unknown' }], ]) { let at = NOW; - let fail = false; + let calls = 0; + let empty = false; const cache = new Map(); const request = install({ cache, now: () => at, - fetchImpl: async () => Response.json(fail ? bad : [sighting()]), + fetchImpl: async () => { + calls += 1; + return Response.json(empty ? quiet : [sighting()]); + }, }); - const fresh = await request(); - const retained = cache.get('sightings'); - fail = true; + assert.equal((await request()).body.rows.length, 1); + empty = true; at += 600_001; - const stale = await request(); - assert.equal(stale.status, 200); - assert.equal(stale.headers['X-Data-Stale'], 'true'); - assert.equal(stale.body.stale, true); - assert.equal(stale.body.fetchedAt, fresh.body.fetchedAt); - assert.equal(cache.get('sightings'), retained); - const coldCache = new Map(); - const cold = install({ - cache: coldCache, - fetchImpl: async () => Response.json(bad), - }); - const result = await cold(); - assert.equal(result.status, 502); - assert.equal(result.body.error, 'acartia_unavailable'); - assert.equal(coldCache.size, 0); + const result = await request(); + assert.equal(result.status, 200); + assert.deepEqual(result.body, { fetchedAt: at, rows: [] }); + assert.equal(result.headers['X-Data-Stale'], undefined); + assert.equal(result.headers['Retry-After'], undefined); + assert.deepEqual(cache.get('sightings').value.rows, []); + // The quiet feed keeps the normal ten-minute cadence, not a failure retry. + at += 599_999; + assert.equal((await request()).status, 200); + assert.equal(calls, 2); + at += 1; + await request(); + assert.equal(calls, 3); + const cold = install({ fetchImpl: async () => Response.json(quiet) }); + const coldResult = await cold(); + assert.equal(coldResult.status, 200); + assert.deepEqual(coldResult.body, { fetchedAt: NOW, rows: [] }); } }); +test('an invented wrapped snapshot never replaces the cache', async () => { + const bad = { results: [sighting()] }; + let at = NOW; + let fail = false; + const cache = new Map(); + const request = install({ + cache, + now: () => at, + fetchImpl: async () => Response.json(fail ? bad : [sighting()]), + }); + const fresh = await request(); + const retained = cache.get('sightings'); + fail = true; + at += 600_001; + const stale = await request(); + assert.equal(stale.status, 200); + assert.equal(stale.headers['X-Data-Stale'], 'true'); + assert.equal(stale.body.stale, true); + assert.equal(stale.body.fetchedAt, fresh.body.fetchedAt); + assert.equal(cache.get('sightings'), retained); + const coldCache = new Map(); + const cold = install({ + cache: coldCache, + fetchImpl: async () => Response.json(bad), + }); + const result = await cold(); + assert.equal(result.status, 502); + assert.equal(result.body.error, 'acartia_unavailable'); + assert.equal(result.headers['Retry-After'], '15'); + assert.equal(coldCache.size, 0); +}); + test('failure serves stale for at most 24 hours and never reports a cold success', async () => { let at = NOW; let fail = false; From 3cb30c3f975e8dfb87a3e7105a650c509d93b263 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:05:02 -0800 Subject: [PATCH 28/39] fix(wildlife): point the Acartia card link only at acartia.io The card's "Acartia" link was built from the contributor-supplied photo_url, so any https site could appear labelled as Acartia. Acartia has no documented per-sighting page, so the source link is now always the Acartia site, on the server projection and again in the card. The separate "Open on Acartia" fallback link is no longer needed. --- docs/CURRENT-STATE.md | 3 +- src/i18n/locales/ar.json | 1 - src/i18n/locales/de.json | 1 - src/i18n/locales/es.json | 1 - src/i18n/locales/fr.json | 1 - src/i18n/locales/it.json | 1 - src/i18n/locales/ja.json | 1 - src/i18n/locales/pt.json | 1 - src/i18n/locales/ru.json | 1 - src/i18n/locales/uk.json | 1 - src/i18n/locales/zh-CN.json | 1 - src/i18n/locales/zh-Hant.json | 1 - src/layers/wildlifeSightings/records.js | 17 +++-------- src/layers/wildlifeSightings/records.test.mjs | 8 +++-- src/ui/views/panels/WildlifeSightingCard.vue | 5 ++-- src/ui/wildlifeSightingCard.test.mjs | 30 +++++++++++++++++++ 16 files changed, 45 insertions(+), 29 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b615bb70d..fbbaa19b0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -174,7 +174,8 @@ compact anchored photo card. Selection stays pinned across camera, filter and sample changes until explicit deselection or layer disable. Its medium image uses `object-fit: contain`, with names, compact observed age and creator/licence. The card is the single source link to iNaturalist. Obscured sightings also stay -text-only; Acartia stays text-only with provider credit and whale guidance. +text-only; Acartia stays text-only with provider credit and whale guidance, +and its source link is always `https://acartia.io/`, never a contributed URL. Placement avoids actual HUD, toolbar and rail rectangles, panning only when necessary while preserving zoom. Escape, close or an outside click closes the card and clears selection. Camera/layout changes reposition the card without diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index da291e035..b491c3c93 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3453,7 +3453,6 @@ "Loading photo...": "جارٍ تحميل الصورة…", "Photo unavailable": "الصورة غير متاحة", "Location obscured to a 0.2° cell": "الموقع مخفي ضمن خلية بمقدار 0.2°", - "Open on Acartia ↗": "فتح على Acartia ↗", "Photo of {name}": "صورة {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 69b039fc5..d01ff292f 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -3153,7 +3153,6 @@ "Loading photo...": "Foto wird geladen…", "Photo unavailable": "Foto nicht verfügbar", "Location obscured to a 0.2° cell": "Standort auf eine 0,2°-Rasterzelle verschleiert", - "Open on Acartia ↗": "Auf Acartia öffnen ↗", "Photo of {name}": "Foto von {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index a34a9bf39..aa403f743 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3228,7 +3228,6 @@ "Loading photo...": "Cargando foto…", "Photo unavailable": "Foto no disponible", "Location obscured to a 0.2° cell": "Ubicación oculta en una celda de 0,2°", - "Open on Acartia ↗": "Abrir en Acartia ↗", "Photo of {name}": "Foto de {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index a09806cfa..b3ae72189 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3228,7 +3228,6 @@ "Loading photo...": "Chargement de la photo…", "Photo unavailable": "Photo indisponible", "Location obscured to a 0.2° cell": "Localisation masquée dans une cellule de 0,2°", - "Open on Acartia ↗": "Ouvrir sur Acartia ↗", "Photo of {name}": "Photo de {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index b800b010c..4c69e1d4c 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3228,7 +3228,6 @@ "Loading photo...": "Caricamento della foto…", "Photo unavailable": "Foto non disponibile", "Location obscured to a 0.2° cell": "Posizione oscurata in una cella di 0,2°", - "Open on Acartia ↗": "Apri su Acartia ↗", "Photo of {name}": "Foto di {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index fbfc9dd96..9a053f869 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -3078,7 +3078,6 @@ "Loading photo...": "写真を読み込み中…", "Photo unavailable": "写真を表示できません", "Location obscured to a 0.2° cell": "位置は0.2°のセル内に秘匿されています", - "Open on Acartia ↗": "Acartiaで開く ↗", "Photo of {name}": "{name}の写真", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index a57f4eeaf..79c507759 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3228,7 +3228,6 @@ "Loading photo...": "Carregando foto…", "Photo unavailable": "Foto indisponível", "Location obscured to a 0.2° cell": "Localização ocultada em uma célula de 0,2°", - "Open on Acartia ↗": "Abrir no Acartia ↗", "Photo of {name}": "Foto de {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 2946ba482..11fddb3e9 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3303,7 +3303,6 @@ "Loading photo...": "Загрузка фото…", "Photo unavailable": "Фото недоступно", "Location obscured to a 0.2° cell": "Местоположение скрыто в ячейке 0,2°", - "Open on Acartia ↗": "Открыть на Acartia ↗", "Photo of {name}": "Фото {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 7f6c8c3e2..c0fab8533 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3303,7 +3303,6 @@ "Loading photo...": "Завантаження фото…", "Photo unavailable": "Фото недоступне", "Location obscured to a 0.2° cell": "Місцеположення приховано в комірці 0,2°", - "Open on Acartia ↗": "Відкрити на Acartia ↗", "Photo of {name}": "Фото {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 28854ce5d..8687af790 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -3078,7 +3078,6 @@ "Loading photo...": "正在加载照片…", "Photo unavailable": "照片不可用", "Location obscured to a 0.2° cell": "位置已模糊至0.2°网格", - "Open on Acartia ↗": "在Acartia上打开 ↗", "Photo of {name}": "{name}的照片", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 63088293a..a95e71dd5 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -3078,7 +3078,6 @@ "Loading photo...": "正在載入照片…", "Photo unavailable": "照片無法使用", "Location obscured to a 0.2° cell": "位置已模糊至0.2°網格", - "Open on Acartia ↗": "在Acartia上開啟 ↗", "Photo of {name}": "{name}的照片", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/layers/wildlifeSightings/records.js b/src/layers/wildlifeSightings/records.js index a1e50f52c..1af1cc296 100644 --- a/src/layers/wildlifeSightings/records.js +++ b/src/layers/wildlifeSightings/records.js @@ -247,18 +247,9 @@ export function normalizeInatObservations( return rows; } -function acartiaLink(value) { - if (typeof value !== 'string' || value.length > 500) - return 'https://acartia.io'; - try { - const url = new URL(value); - return url.protocol === 'https:' && !url.username && !url.password - ? url.href - : 'https://acartia.io'; - } catch { - return 'https://acartia.io'; - } -} +// Acartia has no documented per-sighting page, and photo_url is supplied by the +// contributor. The source link is therefore always the Acartia site itself. +export const ACARTIA_SITE = 'https://acartia.io/'; /** Acartia documents flat rows only. Name and entity are text fields; unknown * shapes are skipped rather than guessing nested provider data. */ @@ -315,7 +306,7 @@ export function normalizeAcartiaSightings(payload, nowMs) { cetacean: speciesGroup === 'cetacean', threatened: false, provider: { name, entity: cleanText(raw.data_source_entity, 60) }, - link: acartiaLink(raw.photo_url), + link: ACARTIA_SITE, photo: null, favesCount: 0, identificationAgreements: 0, diff --git a/src/layers/wildlifeSightings/records.test.mjs b/src/layers/wildlifeSightings/records.test.mjs index 18a1693d8..9909338a1 100644 --- a/src/layers/wildlifeSightings/records.test.mjs +++ b/src/layers/wildlifeSightings/records.test.mjs @@ -330,7 +330,7 @@ test('Acartia requires flat provider text, handles trust, and sanitizes type', ( ); }); -test('Acartia rejects invalid times/positions and unsafe photo links become the canonical page', () => { +test('Acartia rejects invalid times/positions and its link is always the Acartia site', () => { for (const overrides of [ { created: new Date(now - 8 * 24 * hour - 1).toISOString() }, { created: new Date(now + 10 * 60_000 + 1).toISOString() }, @@ -341,6 +341,10 @@ test('Acartia rejects invalid times/positions and unsafe photo links become the ]) assert.deepEqual(normalizeAcartiaSightings([sighting(overrides)], now), []); for (const photo_url of [ + // A contributor-supplied URL is never labelled as the Acartia source. + 'https://provider.example/photo.jpg', + 'https://phishing.example/acartia.io/login', + 'https://acartia.io.attacker.example/', 'http://provider.example/p.jpg', 'https://user:secret@provider.example/p.jpg', 'javascript:bad()', @@ -349,7 +353,7 @@ test('Acartia rejects invalid times/positions and unsafe photo links become the ]) { assert.equal( normalizeAcartiaSightings([sighting({ photo_url })], now)[0].link, - 'https://acartia.io', + 'https://acartia.io/', ); } }); diff --git a/src/ui/views/panels/WildlifeSightingCard.vue b/src/ui/views/panels/WildlifeSightingCard.vue index fc92c7ee4..9caac8fdd 100644 --- a/src/ui/views/panels/WildlifeSightingCard.vue +++ b/src/ui/views/panels/WildlifeSightingCard.vue @@ -2,7 +2,7 @@ import { computed, shallowRef, useTemplateRef, watch } from 'vue'; import { formatDate, t, translateKnown } from '../../../i18n/index.js'; import { photoCreator, formatAge } from '../../../layers/wildlifeSightings/photoText.js'; -import { inatLicenceLabel, inatPhotoUrl, isOpenPhoto } from '../../../layers/wildlifeSightings/records.js'; +import { ACARTIA_SITE, inatLicenceLabel, inatPhotoUrl, isOpenPhoto } from '../../../layers/wildlifeSightings/records.js'; const props = defineProps({ sighting: { type: Object, required: true }, @@ -36,6 +36,8 @@ const age = computed(() => { }); const observedDate = computed(() => observedDay.value ? formatDate(Date.parse(`${observedDay.value}T00:00:00Z`), { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' }) : ''); const link = computed(() => { + // The "Acartia" label only ever points at the Acartia site, never a row URL. + if (acartia.value) return ACARTIA_SITE; try { const url = new URL(props.sighting.link); return url.protocol === 'https:' && !url.username && !url.password ? url.href : null; @@ -89,7 +91,6 @@ watch([() => props.sighting.stableId, () => photo.value?.src], () => { loaded.va

- {{ t('Open on Acartia ↗') }} {{ t('Be Whale Wise') }}

diff --git a/src/ui/wildlifeSightingCard.test.mjs b/src/ui/wildlifeSightingCard.test.mjs index d7837af7f..f685c75e0 100644 --- a/src/ui/wildlifeSightingCard.test.mjs +++ b/src/ui/wildlifeSightingCard.test.mjs @@ -373,6 +373,36 @@ test('missing, restricted, obscured and Acartia photos all use the same compact }); }); +test('an Acartia card labels only the Acartia site as Acartia', async (context) => { + for (const link of [ + 'https://provider.example/photos/synthetic.jpg', + 'https://acartia.io.attacker.example/', + 'https://acartia.io/', + ]) + await context.test(link, async (context) => { + const f = fixture(context, { + ...row, + origin: 'acartia', + stableId: 'acartia:456', + provider: { name: 'Synthetic network' }, + link, + }); + await f.click(); + const card = f.cardElement(); + const hrefs = [...card.querySelectorAll('a')].map((a) => + a.getAttribute('href'), + ); + assert.deepEqual(hrefs, [ + 'https://acartia.io/', + 'https://www.bewhalewise.org', + ]); + assert.equal( + card.querySelector('.wildlife-sighting-card-source').textContent, + 'Acartia ↗', + ); + }); +}); + test('safe placements near each viewport edge exclude actual HUD, toolbar and rail rects', () => { const obstacles = [ rect(0, 0, 260, 160), From 5391687d4649c3fd9e7f0d0c46624caaf6c7582e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:06:41 -0800 Subject: [PATCH 29/39] fix(wildlife): draw what arrived when one sighting source fails An iNaturalist rejection discarded a successful Acartia reply, and the antimeridian Promise.all lost both halves when one failed. Each box now settles independently; the failed part is reported on sources.inaturalist (error and failed boxes), the layer keeps that area's last iNaturalist rows and shows DEGRADED with retry timing, and the update fails outright only when every requested source failed. --- docs/CURRENT-STATE.md | 4 + src/layers/wildlifeSightings/lifecycle.js | 16 +++- .../wildlifeSightings/ownership.test.mjs | 41 +++++++++++ src/layers/wildlifeSightings/source.js | 26 ++++++- src/layers/wildlifeSightings/source.test.mjs | 73 +++++++++++++++++++ 5 files changed, 155 insertions(+), 5 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fbbaa19b0..2449130f4 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -121,6 +121,10 @@ Provider 429 cooldown is shared; 5xx cooldown is resource-specific. Failed tiles retain good cells for at most one hour. Stale replies carry `X-Data-Stale` and known `Retry-After`. Failures keep filters and drawn rows, reporting DEGRADED with retry timing; logs exclude request URLs and upstream exception text. +A partial failure still draws what arrived: a failed iNaturalist request, or +one failed half of an antimeridian view, keeps a good Acartia reply and the +other half, retains that area's last iNaturalist rows and reports DEGRADED. +The update fails outright only when every requested source failed. `/api/acartia/sightings` adds Salish Sea marine mammals within 45.5–50.5° N, 126–121° W, cached ten minutes and stale for at most 24 h. A valid empty diff --git a/src/layers/wildlifeSightings/lifecycle.js b/src/layers/wildlifeSightings/lifecycle.js index e092a6ef1..6da425c7a 100644 --- a/src/layers/wildlifeSightings/lifecycle.js +++ b/src/layers/wildlifeSightings/lifecycle.js @@ -345,8 +345,18 @@ export function createLifecycle({ state, services, parts }) { state.loadedScope = query.scope; state.pendingClear = false; // A successful page replaces this queried area, including a healthy - // empty page. Keep only other visited areas in the bounded LRU. + // empty page. Keep only other visited areas in the bounded LRU. Areas + // whose iNaturalist request failed keep their last iNaturalist rows. + const inat = snapshot.sources?.inaturalist; + const failedInatBoxes = + inat?.ok === false ? inat.failedBoxes || query.boxes : null; for (const [id, row] of state.rows) { + if ( + failedInatBoxes && + row.origin === 'inaturalist' && + rowInWildlifeBoxes(row, failedInatBoxes, 0) + ) + continue; if (rowInWildlifeBoxes(row, query.boxes, 0)) state.rows.delete(id); } for (const row of snapshot.rows) { @@ -397,6 +407,10 @@ export function createLifecycle({ state, services, parts }) { const sourceError = gridError || staleError || + (snapshot.sources?.inaturalist?.ok === false + ? snapshot.sources.inaturalist.error || + new Error(msg('Wildlife sightings source unavailable')) + : null) || (snapshot.sources?.acartia?.ok === false ? new Error(msg('Acartia feed unavailable')) : null); diff --git a/src/layers/wildlifeSightings/ownership.test.mjs b/src/layers/wildlifeSightings/ownership.test.mjs index c8bfaac62..e118a87bc 100644 --- a/src/layers/wildlifeSightings/ownership.test.mjs +++ b/src/layers/wildlifeSightings/ownership.test.mjs @@ -1568,6 +1568,47 @@ test('provider credits sit in the layer row, never on the globe; Acartia failure h.layer.destroy(); }); +test('an iNaturalist failure beside a good Acartia reply keeps both and reports degraded', async () => { + let inatHealthy = true; + const acartia = { + ...base, + stableId: 'acartia:1', + origin: 'acartia', + iconic: 'Mammalia', + lon: -122.3, + provider: { name: 'Synthetic Provider', entity: null }, + }; + const failure = Object.assign(new Error('iNaturalist HTTP 502'), { + status: 502, + retryAfterSec: 30, + }); + const h = harness({ + getSnapshot: async () => [], + getSnapshotDetail: async ({ boxes }) => ({ + rows: inatHealthy ? [base, acartia] : [acartia], + sources: { + inaturalist: inatHealthy + ? { ok: true } + : { ok: false, error: failure, failedBoxes: boxes }, + acartia: { ok: true }, + }, + }), + }); + await h.update(); + assert.equal(h.layer.getStats().count, 2); + inatHealthy = false; + await h.update(); + // The failed iNaturalist area keeps its last rows; Acartia still refreshes. + assert.equal(h.layer.getStats().count, 2); + assert.equal(h.layer.getStats().error, 'iNaturalist HTTP 502'); + assert.equal(layerFeedState(h.layer.getStats()), 'degraded'); + assert.equal( + h.layer.getRowControls().info, + 'iNaturalist contributors · Acartia 1 (Salish Sea) · licences per record', + ); + h.layer.destroy(); +}); + test('ambient cards prefilter the horizon, spread over screen regions and retain visible slots', async () => { const rows = Array.from({ length: 32 }, (_, i) => ({ ...photographed, diff --git a/src/layers/wildlifeSightings/source.js b/src/layers/wildlifeSightings/source.js index 7af6d1e72..8bd41bf1d 100644 --- a/src/layers/wildlifeSightings/source.js +++ b/src/layers/wildlifeSightings/source.js @@ -454,7 +454,9 @@ export function createWildlifeSightingsSource({ ['all', 'Mammalia', 'whales'].includes(preset) && requestBoxes.some((bounds) => boxIntersects(bounds, ACARTIA_GUARD_BOX)); - const inatRequest = Promise.all( + // Each box settles independently: one failed antimeridian half must not + // discard the other half, and neither may discard a good Acartia reply. + const inatRequest = Promise.allSettled( requestBoxes.map((bounds) => { const params = new URLSearchParams({ scope: scope === 'global' ? 'global' : 'view', @@ -480,8 +482,23 @@ export function createWildlifeSightingsSource({ acartiaRequest, ]); signal?.throwIfAborted(); - if (inatResult.status === 'rejected') throw inatResult.reason; - const inatSnapshots = inatResult.value; + const inatSnapshots = []; + const failedBoxes = []; + let inatError = null; + inatResult.value.forEach((result, index) => { + if (result.status === 'fulfilled') inatSnapshots.push(result.value); + else { + failedBoxes.push(requestBoxes[index]); + inatError ??= result.reason; + } + }); + // Fail outright only when nothing succeeded; otherwise draw what arrived + // and report the failed source in its per-source status. + if ( + !inatSnapshots.length && + (!useAcartia || acartiaResult.status !== 'fulfilled') + ) + throw inatError; const inatRows = inatSnapshots.flatMap((snapshot) => snapshot.rows); const acartiaSnapshot = acartiaResult.status === 'fulfilled' ? acartiaResult.value : null; @@ -529,10 +546,11 @@ export function createWildlifeSightingsSource({ ...(retryAfter.length ? { retryAfterSec: Math.max(...retryAfter) } : {}), sources: { inaturalist: { - ok: true, + ok: !inatError, stale: inatStale, fetchedAt: inatFetchedAt, count: rows.filter((row) => row.origin === 'inaturalist').length, + ...(inatError ? { error: inatError, failedBoxes } : {}), }, acartia: { ok: !useAcartia || acartiaResult.status === 'fulfilled', diff --git a/src/layers/wildlifeSightings/source.test.mjs b/src/layers/wildlifeSightings/source.test.mjs index 7c1191e4b..2a21ca372 100644 --- a/src/layers/wildlifeSightings/source.test.mjs +++ b/src/layers/wildlifeSightings/source.test.mjs @@ -148,6 +148,79 @@ test('Acartia failure preserves iNat rows with an explicit failed source', async assert.equal(result.sources.inaturalist.ok, true); }); +test('iNat failure keeps a successful Acartia result and reports iNat degraded', async () => { + const source = createWildlifeSightingsSource({ + now: () => now, + fetchImpl: async (url) => + url.startsWith('/api/acartia') + ? Response.json(acartiaEnvelope()) + : new Response('', { + status: 502, + headers: { 'X-Upstream-Status': '503', 'Retry-After': '30' }, + }), + }); + const result = await source.getSnapshotDetail({ box: seattle }); + assert.deepEqual(result.rows, [acartia]); + assert.equal(result.totalResults, 1); + assert.equal(result.fetchedAt, now); + assert.equal(result.sources.acartia.ok, true); + assert.equal(result.sources.inaturalist.ok, false); + assert.equal(result.sources.inaturalist.count, 0); + assert.equal( + result.sources.inaturalist.error.message, + 'iNaturalist HTTP 502', + ); + assert.equal(result.sources.inaturalist.error.retryAfterSec, 30); + assert.deepEqual(result.sources.inaturalist.failedBoxes, [seattle]); +}); + +test('observations reject only when every requested source failed', async () => { + const source = createWildlifeSightingsSource({ + now: () => now, + fetchImpl: async (url) => + new Response('', { status: url.startsWith('/api/acartia') ? 502 : 503 }), + }); + await assert.rejects(source.getSnapshotDetail({ box: seattle }), { + message: 'iNaturalist HTTP 503', + }); + const antimeridian = createWildlifeSightingsSource({ + now: () => now, + fetchImpl: async () => new Response('', { status: 503 }), + }); + await assert.rejects( + antimeridian.getSnapshotDetail({ + boxes: [ + { south: 10, west: 179, north: 12, east: 180 }, + { south: 10, west: -180, north: 12, east: -178 }, + ], + }), + { message: 'iNaturalist HTTP 503' }, + ); +}); + +test('one failed antimeridian half keeps the other half and reports the failed box', async () => { + const east = { south: 10, west: 179, north: 12, east: 180 }; + const west = { south: 10, west: -180, north: 12, east: -178 }; + const eastRow = { ...inat, stableId: 'inat:east', lat: 11, lon: 179.5 }; + const source = createWildlifeSightingsSource({ + now: () => now, + fetchImpl: async (url) => + new URL(url, 'https://example.test').searchParams.get('west') === '179' + ? Response.json(inatEnvelope({ rows: [eastRow], totalResults: 3 })) + : new Response('', { status: 503 }), + }); + const result = await source.getSnapshotDetail({ boxes: [east, west] }); + assert.deepEqual(result.rows, [eastRow]); + assert.equal(result.totalResults, 3); + assert.equal(result.sources.inaturalist.ok, false); + assert.equal(result.sources.inaturalist.count, 1); + assert.equal( + result.sources.inaturalist.error.message, + 'iNaturalist HTTP 503', + ); + assert.deepEqual(result.sources.inaturalist.failedBoxes, [west]); +}); + test('proxy staleness and original fetch dates pass through with per-source counts', async () => { const fetchedAt = now - 50 * 60_000; const source = createWildlifeSightingsSource({ From fb0a58ef3c55dd03eae038df8d510140c71f16c0 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:07:48 -0800 Subject: [PATCH 30/39] docs(wildlife): cite the documented iNaturalist UTFGrid endpoint and Acartia's key request /grid/{z}/{x}/{y}.grid.json is documented in the v1 and v2 API docs as Grid Tiles UTFGrid; only its per-cell records are unspecified, which is what the schema guard covers. DATA_SOURCES.md now also notes that Acartia's README asks API users to create an account and an API key, although /sightings/current answers without one today. --- DATA_SOURCES.md | 4 ++-- docs/CURRENT-STATE.md | 3 ++- docs/KNOWN-ISSUES.md | 8 +++++--- src/layers/wildlifeSightings/docs.test.mjs | 18 ++++++++++++++++++ 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 1153015b8..f2b002428 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -198,8 +198,8 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **OSRM on the FOSSGIS routing servers.** Keyless (no API key). Every route is requested by a person: clicking A and B in the Directions layer, or a voice "route from A to B". The server-side `/api/route` proxy sends an identifying `User-Agent`, pins the upstream host, accepts 2-12 coordinates, rejects any leg over 600 km or a total over 2,500 km, caps the buffered response at 8 MB, times the upstream out at 12 s, rate-limits per client, caches each route for 10 minutes, and coalesces identical requests that are in flight at the same time. Turn-by-turn maneuvers are asked for only when a caller wants them (`steps=1`), so the callers that do not read them send the smaller request. The "fix the map" link the policy asks for is in the Data attribution popover beside the OpenStreetMap credit. - **FAA Special Use Airspace.** Keyless. `/api/drone-airspace` queries the public FAA AIS FeatureServer for prohibited (P) and restricted (R) zones only — not MOAs, TFRs or other SUA types — with simplified geometry, a body cap, paging, a one-hour cache and stale-on-error. The layer is US coverage, informational only, and is not a substitute for B4UFLY, LAANC or a preflight briefing. Data is fetched at request time and never stored or redistributed. US government public domain; courtesy attribution is registered in the Data attribution popover. - **iNaturalist.** Keyless. `/api/inaturalist/observations` returns cached random view samples (newest first globally), at most 200 per request. Recency uses observation time: the v1 `d1` filter and a rolling 48 h cutoff (whales 7 d); date-only rows qualify when their day starts within the window. Observation caches last 5 min (global 10 min), with stale fallback ≤1 h. `/api/inaturalist/grid` supplies density counts, cached 15 min. Both share a paced queue capped at 60/min and 8,000/day; only request counters persist in `.gev-cache/`. The server caches projected records, never raw responses. Private records are excluded; obscured coordinates become 0.2° cells. Observer identity is retained only for photo attribution. Eligible photos load directly from `inaturalist-open-data.s3.amazonaws.com`, with creator credit on ambient cards and creator/licence on selection. Per-record licences and noncommercial restrictions apply as listed above. Attribution appears in the layer row and Data attribution popover. Coverage follows community observations, not a census; Acartia adds Salish Sea marine mammals. -- **iNaturalist grid (undocumented endpoint risk).** `/api/inaturalist/grid` shares observation-date, taxa, quality, pacing and budget rules with observations. A strict 64×64 UTFGrid schema guard fails closed; only logical-cell keys, counts and coordinates survive projection. Raw tiles, observation identifiers and free text are not retained. Projected tiles use a 400-entry LRU, 15-minute freshness and at most one hour of stale fallback. Views request at most six tiles; cached and adjacent-zoom coverage publishes before network waits, independently of observation pages. Cells supply a noninteractive density glow ranked within the view, from teal to amber with mono/CRT support. Every sighting badge comes from a real observation at its public position. Counts describe observations, not a population census. The endpoint is outside the documented v1 contract; schema drift disables grids until revalidated. See `docs/KNOWN-ISSUES.md`. -- **Acartia.** Keyless. `/api/acartia/sightings` reads the current 7-day marine-mammal feed for ALL, MAMMALS and WHALES views intersecting 45.5–50.5° N, 126–121° W in the Salish Sea. WHALES accepts recognized cetaceans; other identified marine mammals appear under ALL/MAMMALS. The server rounds positions to 0.01° and drops comments before caching projected records (10 min fresh, stale ≤24 h); raw responses are not retained. Selected text cards credit the reporting provider under CC BY 4.0 and show Be Whale Wise guidance. Attribution also appears in the layer row and Data attribution popover. Opportunistic sightings, not a census. +- **iNaturalist grid (cell schema risk).** `/api/inaturalist/grid` shares observation-date, taxa, quality, pacing and budget rules with observations. A strict 64×64 UTFGrid schema guard fails closed; only logical-cell keys, counts and coordinates survive projection. Raw tiles, observation identifiers and free text are not retained. Projected tiles use a 400-entry LRU, 15-minute freshness and at most one hour of stale fallback. Views request at most six tiles; cached and adjacent-zoom coverage publishes before network waits, independently of observation pages. Cells supply a noninteractive density glow ranked within the view, from teal to amber with mono/CRT support. Every sighting badge comes from a real observation at its public position. Counts describe observations, not a population census. The endpoint is documented in the v1 and v2 API docs as “Grid Tiles UTFGrid” (`/grid/{z}/{x}/{y}.grid.json`), but its per-cell records are not specified; schema drift disables grids until revalidated. See `docs/KNOWN-ISSUES.md`. +- **Acartia.** Keyless. `/api/acartia/sightings` reads the current 7-day marine-mammal feed for ALL, MAMMALS and WHALES views intersecting 45.5–50.5° N, 126–121° W in the Salish Sea. WHALES accepts recognized cetaceans; other identified marine mammals appear under ALL/MAMMALS. The server rounds positions to 0.01° and drops comments before caching projected records (10 min fresh, stale ≤24 h); raw responses are not retained. Selected text cards credit the reporting provider under CC BY 4.0 and show Be Whale Wise guidance. `/sightings/current` answers without a key today, but Acartia's README asks API users to create an account and generate an API key on the [Acartia dashboard](https://acartia.io/) and to follow its [community guidelines](https://acartia.io/register). Attribution also appears in the layer row and Data attribution popover. Opportunistic sightings, not a census. - **Mapped installations.** OpenFreeMap military polygons use a bundled Overture/OSM name index; wide views show named points. Optional operator-configured Overpass and explicit Google Places searches keep their own source paths. Mapped data does not establish current activity or complete coverage. - **Place search.** Named-place fly-to uses Google Geocoding when a Maps key is configured, then Photon (`PHOTON_URL`; unset uses the public instance, empty disables it), then `/api/geocode` (Nominatim search) when neither answers. `/api/geocode` and the voice outline route `/api/geocode/outline` share one Nominatim queue (`NOMINATIM_URL`; unset, empty and invalid values disable it; an operator must explicitly select an authorized endpoint): one request at a time at least 1.1 s apart, an identifying `User-Agent` and `Referer`, a short queue that refuses a burst, a pause after 429/403/5xx answers (honouring `Retry-After` in full), at most one retry after a network failure, and a per-install daily cap on public-instance requests (`NOMINATIM_DAILY_CAP`, default 50). Processes using the same installation state file hold one lock through the actual fetch invocation and recording the next public start, with the daily slot persisted before invocation; restarts share that count, spacing and any pause. An unfinished dispatch marker fails closed until operator recovery; it is never automatically reclaimed. Public access fails closed if the state file cannot be read, locked, written or semantically validated. This coordination does not span independent installations or state directories and does not establish an application-wide public-service allowance or authorize public use. An operator-selected non-public endpoint is outside the public cap, spacing and durable-state requirement; an upstream cooldown is kept with the endpoint that returned it rather than leaking across operator URLs. Answers are cached on disk (outlines 90 days, places 30 days, no-results 24 hours); failures are not cached. Outlines are looked up only for an explicit ask, after bundled data and any configured Overpass, and only results whose type suits the ask are used. Administrative outline responses expose country/admin-1/admin-2 identity only when the selected Nominatim row has a recognized, unambiguous address type consistent with its own name; numeric rank, ambiguous types, missing fields and contradictory names do not manufacture identity. It is not used for systematic or bulk queries. - **Street and building outlines.** Voice street highlights, pointed-at buildings and grounds use the OpenFreeMap vector tiles the traffic layer already loads. Traffic and outlines share one bounded pending fetch/decode owner per immutable XYZ tile; materialized outline geometry is charged to the retained-byte ceiling and capped by coordinate count. Consumer result caches and request lifetimes remain independent, while eviction or explicit teardown cancels queued idle projections and releases their decoded owner. No Overpass request is made unless `OVERPASS_UPSTREAMS` is configured. Named grounds require a returned name matching the selected place, except that an ask whose head noun names a kind of grounds (a school, university or college, hospital, cemetery, stadium or zoo) may use the part of the smallest unnamed tile area of that class that holds the resolved place; a differently named area never answers. Other anonymous landcover polygons cannot establish identity, so unsupported grounds stay unavailable rather than borrowing a nearby POI name. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2449130f4..96d85431f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -160,7 +160,8 @@ adjacent-zoom tiles publish before network waits; each arriving tile replaces its covered area, including healthy empty tiles. Grid tiles publish even while the independent observation request is pending or failed. Enable waits for one camera settle; camera acquisition uses moveEnd with a 600 ms debounce. The -undocumented UTFGrid endpoint has a schema guard; see `KNOWN-ISSUES.md`. +documented UTFGrid endpoint's unspecified cell records have a schema guard; +see `KNOWN-ISSUES.md`. Ambient cards use a fixed 96 px image width and a short creator name credit, with a bounded frame cache. New cards fill free screen regions; visible cards diff --git a/docs/KNOWN-ISSUES.md b/docs/KNOWN-ISSUES.md index ccd3ed906..3fdcbe67b 100644 --- a/docs/KNOWN-ISSUES.md +++ b/docs/KNOWN-ISSUES.md @@ -10,12 +10,14 @@ For the roadmap and open backlog, see the repository issue tracker. ## Open -### Wildlife grid is an undocumented iNaturalist endpoint +### Wildlife grid relies on the iNaturalist UTFGrid cell schema Status: Open (guarded fallback) -The operational `/v1/grid/{z}/{x}/{y}.grid.json` endpoint is not part of the -published v1 Swagger contract. Its 64×64 UTFGrid shape, keys and cell records +The `/v1/grid/{z}/{x}/{y}.grid.json` endpoint is documented in the v1 and v2 +API docs (“Grid Tiles UTFGrid”), but the documented response only defines the +UTFGrid `grid`, `keys` and `data` members; the per-cell records are not +specified. Its 64×64 UTFGrid shape, keys and cell records are validated in full at the allow-list proxy. A schema change or unavailable tile retains its last good cells for at most one hour; a cold unavailable grid uses sampled observations with the same badges and screen buckets, without diff --git a/src/layers/wildlifeSightings/docs.test.mjs b/src/layers/wildlifeSightings/docs.test.mjs index 3f84357d3..794bd62b5 100644 --- a/src/layers/wildlifeSightings/docs.test.mjs +++ b/src/layers/wildlifeSightings/docs.test.mjs @@ -109,3 +109,21 @@ test('Wildlife docs describe one source link, name declutter and globe glow limi assert.match(section, /5% radius margin/); assert.match(section, /add no per-frame work/); }); + +test('Wildlife docs cite the documented UTFGrid endpoint and the Acartia key request', () => { + for (const path of [ + 'docs/KNOWN-ISSUES.md', + 'DATA_SOURCES.md', + 'docs/CURRENT-STATE.md', + ]) { + const text = read(path); + assert.doesNotMatch(text, /undocumented (iNaturalist|UTFGrid) endpoint/i); + assert.doesNotMatch(text, /grid \(undocumented/i); + } + for (const path of ['docs/KNOWN-ISSUES.md', 'DATA_SOURCES.md']) + assert.match(read(path), /Grid Tiles UTFGrid/); + assert.match( + read('DATA_SOURCES.md'), + /create an account and generate an API key/, + ); +}); From 6479167b500357004ca2f0a11aaee1ae23d33a82 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:14:27 -0800 Subject: [PATCH 31/39] Pivot a tilt at exactly 1,000 km and report where a zoom now ends it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pivot range rebuilt from the camera's position came back a hair over the 1,000 km cutoff when the view sat exactly on it, so a tilt from 1,000 km ran in place and lifted the crosshair onto the sky. The cutoff now allows 1 m of slack. A zoom during a tilt to an angle changes the range the pivot holds, and with it the place's horizon limit, but the tilt kept its old goal and its reply its old angle: from 400 km, a tilt to -5° zoomed out x2.5 reported -5° with no note and ended at -9.4°. The zoom now re-judges the goal at the new range, eases the tilt from where it is to it, and its result names the angle the tilt now ends at, with a note. --- docs/CURRENT-STATE.md | 2 +- src/cameraTilt.test.mjs | 76 +++++++++++++++++++++++++++++++++++++++++ src/cameraVerbs.js | 64 ++++++++++++++++++++++++++++++---- src/voice/gevActions.js | 5 +++ 4 files changed, 139 insertions(+), 8 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9036db387..d41c435f1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5590,7 +5590,7 @@ and unreachable upstream (502/504) separately from road geometry. > branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on > `main` + 26 route pins; the same tree on Node 24 reports 2,295. -- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. A zoom during a pivoting tilt scales the range it holds, as for orbit, but a zoom in stops at the range that keeps that 150 m clearance; a zoom that can move no further (that floor, the 80 m floor, or the cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) is reported as not zoomed. Once the tilt continues in place, a zoom moves the camera like any zoom. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the user grabs, touches or scrolls the map during it, or another navigation (a location-panel search, share link or file import, which leave the flight flying) takes the camera during it, the motion is not run and says so. A motion that runs after a fly-to of the same reply has already found nothing (Gemini runs one turn's calls one by one, and passes its turn as the reply) is refused the same way rather than moving the old view; a motion in a later reply runs. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. +- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. A zoom during a pivoting tilt scales the range it holds, as for orbit, but a zoom in stops at the range that keeps that 150 m clearance; a zoom that can move no further (that floor, the 80 m floor, or the cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) is reported as not zoomed. A zoom during a tilt to an angle re-judges the place's horizon limit at the new range: when that moves where the tilt ends, the tilt eases from where it is to the new end, and the zoom's result names that angle (`pitchDeg`) with a `note`. Once the tilt continues in place, a zoom moves the camera like any zoom. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the user grabs, touches or scrolls the map during it, or another navigation (a location-panel search, share link or file import, which leave the flight flying) takes the camera during it, the motion is not run and says so. A motion that runs after a fly-to of the same reply has already found nothing (Gemini runs one turn's calls one by one, and passes its turn as the reply) is refused the same way rather than moving the old view; a motion in a later reply runs. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. - **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing. - **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm 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 rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped. - Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet). diff --git a/src/cameraTilt.test.mjs b/src/cameraTilt.test.mjs index a67ecee86..0dcb2d1b0 100644 --- a/src/cameraTilt.test.mjs +++ b/src/cameraTilt.test.mjs @@ -610,3 +610,79 @@ for (const direction of ['left', 'right']) assert.match(angled.error, /pitchDeg alone/); assert.equal(getActiveCameraMotion(), null); }); + +// The 1,000 km pivot cutoff includes its boundary: at exactly 1,000 km the +// pivot range came back a hair over it, so the tilt ran in place and put the +// crosshair on the sky. +test('from exactly 1,000 km a tilt to -5° still pivots about the place', (t) => { + const h = harness(t, { height: 1_000_000 }); + const result = moveCamera({ motion: 'tilt', pitchDeg: 5 }); + assert.equal(result.ok, true); + h.finish(); + assert.ok(h.viewTarget(), 'the crosshair is on the sky'); + assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`); + assert.ok( + Math.abs(pitchDeg(h.camera) - result.pitchDeg) < 0.5, + `camera at ${pitchDeg(h.camera)}°, reported ${result.pitchDeg}°`, + ); + assert.match(result.note, /horizon/); +}); + +// A zoom mid-tilt moves the pivot's horizon limit: the zoom's reply names the +// angle the tilt now ends at, and the camera ends there. +test('a zoom out during a tilt to an angle reports the angle it now ends at', (t) => { + const h = harness(t, { height: 400_000 }); + const result = moveCamera({ motion: 'tilt', pitchDeg: -5 }); + assert.equal(result.pitchDeg, -5); + assert.equal(result.note, undefined); + for (let i = 0; i < 10; i += 1) h.tick(); + const zoom = adjustOrbitRange(2.5); + assert.equal(zoom.kind, 'tilt'); + assert.equal(zoom.changed, true); + h.finish(); + assert.ok(h.viewTarget(), 'the crosshair is on the sky'); + assert.ok(zoom.pitchDeg < -5, `zoom reported ${zoom.pitchDeg}°`); + assert.ok( + Math.abs(pitchDeg(h.camera) - zoom.pitchDeg) < 0.5, + `camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`, + ); + assert.match(zoom.note, /horizon/); +}); + +test('a zoom in during a horizon-limited tilt reports the angle it now reaches', (t) => { + const h = harness(t, { height: 900_000 }); + const result = moveCamera({ motion: 'tilt', pitchDeg: -5 }); + assert.ok(result.pitchDeg < -5, `reported ${result.pitchDeg}°`); + for (let i = 0; i < 10; i += 1) h.tick(); + const zoom = adjustOrbitRange(1 / 2.4); + assert.equal(zoom.changed, true); + h.finish(); + assert.equal(zoom.pitchDeg, -5); + assert.ok( + Math.abs(pitchDeg(h.camera) - -5) < 0.5, + `camera at ${pitchDeg(h.camera)}°`, + ); +}); + +test('a voice zoom during a tilt to an angle passes on the new end angle', async (t) => { + const h = harness(t, { height: 400_000 }); + const runner = createGevActionRunner({ + viewer: h.viewer, + styleManager: {}, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + moveCamera({ motion: 'tilt', pitchDeg: -5 }); + for (let i = 0; i < 10; i += 1) h.tick(); + const zoom = await runner('adjust_camera_zoom', { + direction: 'out', + amount: 'lot', + }); + assert.equal(zoom.ok, true); + assert.equal(zoom.tiltRangeAdjusted, true); + h.finish(); + assert.ok( + Math.abs(pitchDeg(h.camera) - zoom.pitchDeg) < 0.5, + `camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`, + ); + assert.match(zoom.note, /horizon/); +}); diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 4618a434d..0681d6a2b 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -62,6 +62,12 @@ const TILT_MIN_CLEARANCE_M = 150; * crosshair point, so the tilt happens in place instead. */ const TILT_PIVOT_MAX_RANGE_M = 1_000_000; +/** + * A pivot range rebuilt from the camera's position comes back a hair off the + * range it was set at: a range this close past the cutoff still counts as on + * it. + */ +const TILT_PIVOT_RANGE_SLACK_M = 1; /** * The shallowest a pivot swing goes when its clearance is not what stops it. * From several hundred km the camera's own horizon limit lies past the @@ -1403,10 +1409,20 @@ function tiltUpLimit({ target, pivot }) { function tiltStartFrame(cam) { const target = _viewer.trackedEntity ? null : _getTarget?.(_viewer); let pivot = target ? tiltPivot(cam, target) : null; - if (pivot && pivot.range > TILT_PIVOT_MAX_RANGE_M) pivot = null; + if (pivot && !tiltPivotInReach(pivot.range)) pivot = null; return { target, pivot, from: cam.pitch }; } +/** Whether a tilt may pivot at this range: up to and including the cutoff. */ +function tiltPivotInReach(range) { + return range <= TILT_PIVOT_MAX_RANGE_M + TILT_PIVOT_RANGE_SLACK_M; +} + +/** The reply's note for a tilt the place's horizon stops short of its angle. */ +function tiltHorizonNote(pitch) { + return `Stopped at ${Math.round(Cesium.Math.toDegrees(pitch))}°: from this range a shallower angle puts the place past the horizon. Zoom in to tilt further.`; +} + /** Ground pivot for a tilt: heading, pitch and range in the target's own frame. */ function tiltPivot(cam, target) { const local = Cesium.Matrix4.multiplyByPoint( @@ -1917,6 +1933,9 @@ export function moveCamera(args = {}, runNavigation = null) { state.mode = 'once'; state.direction = goal > from ? 'up' : 'down'; state.goalPitch = goal; + // A zoom mid-tilt moves the horizon limit; it re-judges the goal + // against the angle asked for. + state.askedPitch = tiltToPitch; state.durationS = Math.max( ONCE.durationS, Math.abs(Cesium.Math.toDegrees(goal - from)) / TILT_TO_DEG_S, @@ -2024,9 +2043,7 @@ export function moveCamera(args = {}, runNavigation = null) { Cesium.Math.toDegrees(horizonLimited ?? tiltToPitch), ), }), - ...(horizonLimited !== null && { - note: `Stopped at ${Math.round(Cesium.Math.toDegrees(horizonLimited))}°: from this range a shallower angle puts the place past the horizon. Zoom in to tilt further.`, - }), + ...(horizonLimited !== null && { note: tiltHorizonNote(horizonLimited) }), }; }; const preserveCameraFlight = @@ -2043,8 +2060,10 @@ export function moveCamera(args = {}, runNavigation = null) { * fight the per-frame lookAt (field test: "zoom out did literally nothing"). * A tilt swinging about its pivot holds a range the same way. * @param {number} factor >1 zooms out (radius grows), <1 zooms in. - * @returns {{kind: string, changed: boolean}|null} the motion that consumed - * the zoom, and whether its range moved; null when none did. + * @returns {{kind: string, changed: boolean, pitchDeg?: number, + * note?: string}|null} the motion that consumed the zoom, and whether its + * range moved; null when none did. A tilt to an angle whose end the new + * range moves also names the angle it now ends at (pitchDeg), with a note. */ export function adjustOrbitRange(factor) { if (!_active || !['orbit', 'tilt'].includes(_active.kind) || !_active.hpr) @@ -2066,7 +2085,38 @@ export function adjustOrbitRange(factor) { Math.min(Math.max(ceiling, range), range * factor), ); // Under half a metre counts as not moved, as for a plain zoom. - return { kind, changed: Math.abs(hpr.range - range) >= 0.5 }; + const changed = Math.abs(hpr.range - range) >= 0.5; + return { kind, changed, ...(changed && retargetTiltAfterZoom(_active)) }; +} + +/** + * A zoom changed the range a tilt to an angle holds, and with it the place's + * horizon limit: aim the tilt at the angle asked for or the new limit, + * whichever comes first, from where the camera is now. Returns the end angle + * and a note for the reply when that end moved, otherwise nothing. + */ +function retargetTiltAfterZoom(m) { + if (m.kind !== 'tilt' || m.mode !== 'once' || m.askedPitch === undefined) + return {}; + const from = _viewer.camera.pitch; + if (!(m.askedPitch > from)) return {}; + const limit = tiltUpLimit({ target: m.target, pivot: m.hpr }); + const goal = Math.min(m.askedPitch, Math.max(from, limit)); + if (Math.abs(goal - m.goalPitch) < 1e-6) return {}; + m.goalPitch = goal; + m.startPitch = from; + m.elapsed = 0; + m.durationS = Math.max( + ONCE.durationS, + Math.abs(Cesium.Math.toDegrees(goal - from)) / TILT_TO_DEG_S, + ); + const limited = goal !== m.askedPitch; + return { + pitchDeg: Math.round(Cesium.Math.toDegrees(goal)), + note: limited + ? tiltHorizonNote(goal) + : `The tilt now reaches ${Math.round(Cesium.Math.toDegrees(goal))}°.`, + }; } /** diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index a01e3328e..9692edf94 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -471,6 +471,11 @@ export function createGevActionRunner({ amount: amt, [held.kind === 'tilt' ? 'tiltRangeAdjusted' : 'orbitRadiusAdjusted']: true, + // A tilt to an angle names the angle the new range now ends it at. + ...(held.pitchDeg !== undefined && { + pitchDeg: held.pitchDeg, + note: held.note, + }), }; } } From 198c2faf4f9cf11f266e145afaa68b00157b8dff Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:14:27 -0800 Subject: [PATCH 32/39] =?UTF-8?q?Say=20the=20-45=C2=B0=20tilt=20default=20?= =?UTF-8?q?in=20the=20pitchDeg=20description=20again?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trim that fitted the tilt wording under the Realtime prompt ceiling dropped the -45° default from pitchDeg, so the model no longer read why a bare tilt goes oblique. pitchDeg reads 'Absolute; -45 default, -89 top-down.' again, and the direction description becomes 'Pan/rotate/tilt steps; orbit/rotate_globe default right.' to pay for it: the Overpass prompt measures 55,499, 1 under the 55,500 ceiling. The schema pin and the measured-size note follow. --- server/providers/openai/toolDescriptions.js | 4 ++-- src/radioMarkup.test.mjs | 8 +++++--- src/voice/actionSchemas.test.mjs | 4 ++-- src/voice/pointAndAsk.test.mjs | 8 ++++---- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js index f4000ddad..c7e78c7d6 100644 --- a/server/providers/openai/toolDescriptions.js +++ b/server/providers/openai/toolDescriptions.js @@ -523,7 +523,7 @@ export const ACTION_DESCRIPTIONS = { properties: { direction: { description: - 'Needed for pan/rotate/tilt steps; orbit/rotate_globe default right.', + 'Pan/rotate/tilt steps; orbit/rotate_globe default right.', $position: 2, }, mode: { @@ -532,7 +532,7 @@ export const ACTION_DESCRIPTIONS = { $position: 2, }, pitchDeg: { - description: 'Absolute; -89 top-down.', + description: 'Absolute; -45 default, -89 top-down.', $position: 1, }, }, diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index 3c59d989d..324adceb5 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -232,14 +232,16 @@ test('no unchanged Realtime tool definition drifts silently', () => { camera.description, 'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly"). Globe rotation (rotate_globe) spins Earth right continuously by default; explicit direction, mode and speed take precedence. It checks fit and may refuse.', ); - // Tilt to an angle adds an absolute pitchDeg and says tilt steps need a - // direction; check them, then restore the legacy fields for the pin. + // Tilt to an angle adds an absolute pitchDeg with its -45° default, and + // names tilt steps among the direction's uses; check them, then restore + // the legacy fields for the pin. assert.equal( camera.parameters.properties.direction.description, - 'Needed for pan/rotate/tilt steps; orbit/rotate_globe default right.', + 'Pan/rotate/tilt steps; orbit/rotate_globe default right.', ); assert.equal(camera.parameters.properties.pitchDeg.type, 'number'); assert.match(camera.parameters.properties.pitchDeg.description, /^Absolute;/); + assert.match(camera.parameters.properties.pitchDeg.description, /-45 default/); delete camera.parameters.properties.pitchDeg; assert.equal(camera.parameters.properties.mode.description, 'once = bounded eased nudge; continuous = until stop/manual input. rotate_globe defaults continuous; other motions default once.'); camera.parameters.properties.mode.description = 'once = bounded eased nudge (default); continuous = until stop/manual input.'; diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index fa9d40bec..296ae0251 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -57,8 +57,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // tightened area tool wording follow review. // control_cockpit states the own-layer entry rule (Contacts not required). // move_camera tilt takes an optional pitchDeg angle, described as - // absolute, with steps sent as a direction. - '17e54c1a04054edf48f2eddcf88c60ac66ec7c885fb48596b778bfee0be8a544', + // absolute with its -45 default, with steps sent as a direction. + '6a0d154e68beb787c4e20f12d29a5c86577dc343f3e71d8b5651998ea45d8c3c', ); }); diff --git a/src/voice/pointAndAsk.test.mjs b/src/voice/pointAndAsk.test.mjs index c413477b8..5d6d0fdd1 100644 --- a/src/voice/pointAndAsk.test.mjs +++ b/src/voice/pointAndAsk.test.mjs @@ -350,10 +350,10 @@ test('deixis prompt lint: one short rule, and every sentinel it names exists in // the analyst area scopes) raised the ceiling to 55,500; tightening the // AREAS rule and the area tool descriptions left it at 55,103 as measured // below (every tool, osm_query included); main's Sea Marks and Ski Pistes - // layers bring it to 55,210, and the tilt pitchDeg argument with main's - // cockpit entry rule to 55,337. integrationContract's Overpass prompt is - // the tightest, at 55,497 (3 under). A new tool or rule has to fit under - // this ceiling. + // layers bring it to 55,210, and the tilt pitchDeg argument (with its -45 + // default) and main's cockpit entry rule to 55,339. integrationContract's + // Overpass prompt is the tightest, at 55,499 (1 under). A new tool or rule + // has to fit under this ceiling. const size = instructions.length + JSON.stringify(GEV_REALTIME_TOOLS).length; assert.ok(size <= 55_500, `prompt size ${size}`); }); From f332150689a889718b1389dc094470d44ca46bd9 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:16:05 -0800 Subject: [PATCH 33/39] fix(camera): hold the globe recovery until every pointer is released MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The held-pointer guard was one flag, so with two fingers down, lifting one cleared it and the recovery turned the view down under the other (pitch -42° to -90°). It now tracks each pressed pointer by id and defers until the last is released. A release outside the window that the page never sees could leave the recovery deferred for good. A lost window focus, a hidden page or a lost pointer capture now releases every held pointer, and a pointer that moves with no button down is released. --- docs/CURRENT-STATE.md | 8 ++- src/app/application.test.mjs | 96 ++++++++++++++++++++++++++++++++++++ src/app/viewer.js | 43 +++++++++++----- 3 files changed, 132 insertions(+), 15 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 545c4efd8..08cdf3a49 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -104,8 +104,12 @@ other ways a move can end looking away, such as a saved or shared view that did. Views with more surface than that, such as the oblique orbital Director shots, are left as they are, and so are tracked or locked cameras, a camera flight in progress, a Director scene while it plays (holds included), a pointer held -down on the globe (a drag held still) and the 2D and Columbus modes; a check a -flight, scene or held pointer defers runs once it ends. A Director +down on the globe (a drag held still; with several fingers down, until the +last is released) and the 2D and Columbus modes; a check a flight, scene or +held pointer defers runs once it ends. A release outside the window that the +page never sees counts as given once the window loses focus, the page is +hidden, the canvas loses the pointer's capture, or the pointer moves with no +button down. A Director shot loaded or replayed outside a run is not a playing scene. A view that loses its surface without a camera move (a resize, or turning the scope on) is left as it is until the next move. A voice turn requested while the camera is tracked is diff --git a/src/app/application.test.mjs b/src/app/application.test.mjs index e25b2ce11..c01878d89 100644 --- a/src/app/application.test.mjs +++ b/src/app/application.test.mjs @@ -811,6 +811,102 @@ test('the recovery waits while a pointer is held on the globe', () => { assert.equal(timers.pending(), 0, 'disposed recovery holds no listeners'); }); +/** A held-pointer recovery over a canvas in a document in a window. */ +function heldPointerRecovery() { + const timers = fakeTimers(); + const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 }); + const window = new EventTarget(); + const document = Object.assign(new EventTarget(), { + defaultView: window, + visibilityState: 'visible', + }); + scene.canvas = Object.assign(new EventTarget(), { + clientWidth: 1225, + clientHeight: 923, + ownerDocument: document, + }); + const dispose = installGlobeInViewRecovery(viewer, timers); + const pointer = (target, type, pointerId, init = {}) => + target.dispatchEvent( + Object.assign(new Event(type), { pointerId, ...init }), + ); + /** Press, move and hold still: the check defers while a pointer is held. */ + const holdStill = () => { + aim(camera, reportedStuckView); + camera.moveStart.raiseEvent(); + for (let i = 0; i < 8; i += 1) timers.tick(); + camera.moveEnd.raiseEvent(); + for (let i = 0; i < 8; i += 1) timers.tick(); + }; + const settle = () => { + for (let i = 0; i < 3; i += 1) timers.tick(); + }; + const recovered = () => + Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6); + return { + camera, + document, + window, + dispose, + pointer, + holdStill, + settle, + recovered, + canvas: scene.canvas, + }; +} + +// Releasing one of two fingers cleared the held flag, and the recovery +// turned the view down under the finger still on the glass. +test('the recovery waits until every held pointer is released', () => { + const h = heldPointerRecovery(); + h.pointer(h.canvas, 'pointerdown', 1); + h.pointer(h.canvas, 'pointerdown', 2); + h.holdStill(); + h.pointer(h.document, 'pointerup', 1); + h.settle(); + assert.equal(h.recovered(), false, 'left alone under the second finger'); + assert.equal(globeOutOfFrame(h.camera), true); + h.pointer(h.document, 'pointercancel', 2); + h.settle(); + assert.equal(h.recovered(), true, 'recovered once both are released'); + h.dispose(); +}); + +// A release the page never sees (the pointer let go outside the window) +// must not hold the recovery off for good. +for (const [label, lose] of [ + ['the window loses focus', (h) => h.window.dispatchEvent(new Event('blur'))], + [ + 'the page is hidden', + (h) => { + h.document.visibilityState = 'hidden'; + h.document.dispatchEvent(new Event('visibilitychange')); + }, + ], + [ + 'the canvas loses the pointer capture', + (h) => h.pointer(h.canvas, 'lostpointercapture', 1), + ], + [ + 'the pointer moves with no button down', + (h) => h.pointer(h.document, 'pointermove', 1, { buttons: 0 }), + ], +]) { + test(`a held pointer whose release is lost stops deferring when ${label}`, () => { + const h = heldPointerRecovery(); + h.pointer(h.canvas, 'pointerdown', 1); + h.holdStill(); + h.pointer(h.document, 'pointermove', 1, { buttons: 1 }); + h.settle(); + assert.equal(h.recovered(), false, 'left alone while held'); + lose(h); + h.settle(); + assert.equal(h.recovered(), true, 'recovered once the hold is let go'); + h.dispose(); + }); +} + function fakeTimers() { let next = 1; const pending = new Map(); diff --git a/src/app/viewer.js b/src/app/viewer.js index 12ef67269..fc75c0498 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -207,26 +207,43 @@ export function installGlobeInViewRecovery( throw new TypeError('A complete Cesium viewer is required'); // A pointer held on the globe is a gesture in progress, even when it holds - // still for half a second: the check waits for its release. + // still for half a second: the check waits until every pointer pressed on + // it (each finger of a pinch) is released. A release the page never sees, + // outside the window, is taken as given when the window loses focus, the + // page is hidden, the canvas loses the pointer's capture, or the pointer + // moves with no button down. const canvas = scene.canvas; const releaseTarget = canvas?.ownerDocument ?? canvas; - let pointerHeld = false; - const onPointerDown = () => { - pointerHeld = true; + const releaseWindow = canvas?.ownerDocument?.defaultView; + const heldPointers = new Set(); + const onPointerDown = (event) => heldPointers.add(event?.pointerId); + const onPointerUp = (event) => heldPointers.delete(event?.pointerId); + const onPointerMove = (event) => { + if (event?.buttons === 0) heldPointers.delete(event.pointerId); }; - const onPointerUp = () => { - pointerHeld = false; + const releaseAll = () => heldPointers.clear(); + const onVisibilityChange = () => { + if (canvas.ownerDocument.visibilityState === 'hidden') releaseAll(); }; - canvas?.addEventListener?.('pointerdown', onPointerDown); - releaseTarget?.addEventListener?.('pointerup', onPointerUp); - releaseTarget?.addEventListener?.('pointercancel', onPointerUp); + const listeners = [ + [canvas, 'pointerdown', onPointerDown], + [canvas, 'lostpointercapture', onPointerUp], + [releaseTarget, 'pointerup', onPointerUp], + [releaseTarget, 'pointercancel', onPointerUp], + [releaseTarget, 'pointermove', onPointerMove], + [canvas?.ownerDocument, 'visibilitychange', onVisibilityChange], + [releaseWindow, 'blur', releaseAll], + ]; + for (const [target, type, listener] of listeners) + target?.addEventListener?.(type, listener); // Returns false while a flight, a paused scene or a held pointer defers the // check, so the caller can try again once it ends. const recover = () => { if (!isFreeGlobeCamera(viewer)) return true; // Cesium has no public running-flight flag. - if (camera._currentFlight || paused() || pointerHeld) return false; + if (camera._currentFlight || paused() || heldPointers.size > 0) + return false; const height = camera.positionCartographic?.height; if ( !(height > minHeight) || @@ -310,9 +327,9 @@ export function installGlobeInViewRecovery( stopSettling(); removeMoveEnd(); removeMoveStart(); - canvas?.removeEventListener?.('pointerdown', onPointerDown); - releaseTarget?.removeEventListener?.('pointerup', onPointerUp); - releaseTarget?.removeEventListener?.('pointercancel', onPointerUp); + for (const [target, type, listener] of listeners) + target?.removeEventListener?.(type, listener); + heldPointers.clear(); }; } From 42fa974ee38a07293df64b70feabc45787737ed5 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:27:19 -0800 Subject: [PATCH 34/39] fix(wildlife): keep each failed source's retry timing after a partial failure A partial failure reset the layer's cooldown, so a camera move retried at once. Acartia's error was discarded, an absent X-Upstream-Status hid the HTTP 503 (the proxy's daily-budget reply), and a grid schema error masked the observation 503. Each failed source now keeps its status and Retry-After, the HTTP status counts when no upstream status is sent, and the layer waits for the longest 429/5xx cooldown among them. The shown error is the one that explains the cooldown. --- docs/CURRENT-STATE.md | 6 +- .../wildlifeSightings/acquisition.test.mjs | 136 +++++++++++++++++- src/layers/wildlifeSightings/lifecycle.js | 79 ++++++---- src/layers/wildlifeSightings/source.js | 41 +++++- 4 files changed, 229 insertions(+), 33 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 96d85431f..5166e4a2a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -124,7 +124,11 @@ with retry timing; logs exclude request URLs and upstream exception text. A partial failure still draws what arrived: a failed iNaturalist request, or one failed half of an antimeridian view, keeps a good Acartia reply and the other half, retains that area's last iNaturalist rows and reports DEGRADED. -The update fails outright only when every requested source failed. +The update fails outright only when every requested source failed. Each failed +source keeps its HTTP status and `Retry-After` (the HTTP status counts when no +`X-Upstream-Status` is sent), and the next request waits out the longest 429 or +5xx cooldown among them. One update requests both sources, so the cooldown +holds the whole layer; a fully successful refresh clears it. `/api/acartia/sightings` adds Salish Sea marine mammals within 45.5–50.5° N, 126–121° W, cached ten minutes and stale for at most 24 h. A valid empty diff --git a/src/layers/wildlifeSightings/acquisition.test.mjs b/src/layers/wildlifeSightings/acquisition.test.mjs index ea1d6fc1a..d343ad9b0 100644 --- a/src/layers/wildlifeSightings/acquisition.test.mjs +++ b/src/layers/wildlifeSightings/acquisition.test.mjs @@ -833,10 +833,10 @@ test('an empty tile retires its old zoom cells while uncovered density survives assert.equal(h.state.gridTotal, 2); }); -test('only marked upstream failures create cooldown; local pacing, network and schema errors can retry on the next view', async () => { +test('only 429 and 5xx failures create cooldown; client, network and schema errors can retry on the next view', async () => { for (const error of [ - Object.assign(new Error('iNaturalist HTTP 503'), { - status: 503, + Object.assign(new Error('iNaturalist HTTP 400'), { + status: 400, upstreamStatus: 0, retryAfterSec: 30, }), @@ -873,6 +873,19 @@ test('only marked upstream failures create cooldown; local pacing, network and s 2, 'actual Retry-After is honored without imposing a 30s local floor', ); + // The proxy's daily-budget 503 has no upstream status. Its HTTP status and + // Retry-After still govern the next request. + const budget = harness({ + getSnapshot: async () => { + throw Object.assign(new Error('iNaturalist HTTP 503'), { + status: 503, + upstreamStatus: 0, + retryAfterSec: 3600, + }); + }, + }); + await budget.layer.update(); + assert.equal(budget.layer.getStats().retryInSec, 3600); }); test('stale cached tiles with local pacing metadata keep their display and do not enter cooldown', async () => { @@ -942,3 +955,120 @@ test('an explicit upstream Retry-After zero retries immediately while a missing } } }); + +const salishSea = { south: 47.5, west: -122.5, north: 47.7, east: -122.2 }; +const inatRow = { + stableId: 'inat:301', + origin: 'inaturalist', + lat: 47.6, + lon: -122.3, + observedAt: NOW - 3_600_000, + iconic: 'Mammalia', +}; +const acartiaRow = { + stableId: 'acartia:301', + origin: 'acartia', + lat: 47.6, + lon: -122.35, + observedAt: NOW - 3_600_000, + cetacean: true, + quality: 'research', + provider: { name: 'Provider', entity: null }, +}; +const emptyGrid = () => + Response.json({ cells: [], totalResults: 0, fetchedAt: NOW }); +const unavailable = (headers = {}) => + new Response('', { + status: 503, + headers: { 'Retry-After': '120', ...headers }, + }); + +// Each case runs the real source and lifecycle. A healthy source draws its +// rows while the failed source's Retry-After holds the next acquisition. +for (const [name, respond] of [ + [ + 'Acartia alone returns 503', + (url) => + url.startsWith('/api/acartia') + ? unavailable() + : url.startsWith('/api/inaturalist/grid?') + ? emptyGrid() + : Response.json({ + rows: [inatRow], + totalResults: 1, + fetchedAt: NOW, + }), + ], + [ + 'iNaturalist returns 503 without X-Upstream-Status', + (url) => + url.startsWith('/api/acartia') + ? Response.json({ rows: [acartiaRow], fetchedAt: NOW }) + : url.startsWith('/api/inaturalist/grid?') + ? emptyGrid() + : unavailable(), + ], + [ + 'iNaturalist returns 503 while every grid tile fails its schema', + (url) => + url.startsWith('/api/acartia') + ? Response.json({ rows: [acartiaRow], fetchedAt: NOW }) + : url.startsWith('/api/inaturalist/grid?') + ? Response.json({ cells: 'malformed' }) + : unavailable({ 'X-Upstream-Status': '503' }), + ], +]) { + test(`a partial failure keeps its Retry-After: ${name}`, async () => { + let healthy = false; + let requests = 0; + const source = createWildlifeSightingsSource({ + now: () => NOW, + fetchImpl: async (url) => { + if (!url.startsWith('/api/inaturalist/grid?')) requests++; + if (healthy) + return url.startsWith('/api/acartia') + ? Response.json({ rows: [acartiaRow], fetchedAt: NOW }) + : url.startsWith('/api/inaturalist/grid?') + ? emptyGrid() + : Response.json({ + rows: [inatRow], + totalResults: 1, + fetchedAt: NOW, + }); + return respond(url); + }, + }); + const h = harness(source); + h.bounds(salishSea); + h.state.enabled = false; + h.layer.enable(); + const first = h.layer.update(); + await h.advance(300); + await first; + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(h.state.renderedRows.length, 1, 'the healthy source draws'); + assert.ok(h.state.error, 'the failed source is reported'); + // The source clock stays at NOW; the layer settled 300 ms of camera time. + assert.equal(h.state.retryAt, NOW + 300 + 120_000); + assert.equal(h.layer.getStats().retryInSec, 120); + const attempted = requests; + h.bounds({ ...salishSea, north: 47.8 }); + h.moveEnd(); + assert.equal([...h.timers.values()][0]?.delay, 120_000); + await h.advance(119_999); + await h.layer.update(); + assert.equal( + requests, + attempted, + 'neither a camera move nor manager polling skips the cooldown', + ); + healthy = true; + await h.advance(1); + await new Promise((resolve) => setImmediate(resolve)); + assert.ok(requests > attempted, 'acquisition resumes at the deadline'); + assert.equal(h.state.error, null); + assert.equal(h.state.retryAt, 0, 'a fully successful refresh clears it'); + assert.equal(h.layer.getStats().retryInSec, undefined); + assert.equal(h.state.renderedRows.length, 2); + }); +} diff --git a/src/layers/wildlifeSightings/lifecycle.js b/src/layers/wildlifeSightings/lifecycle.js index 6da425c7a..3b2a2999a 100644 --- a/src/layers/wildlifeSightings/lifecycle.js +++ b/src/layers/wildlifeSightings/lifecycle.js @@ -1,6 +1,9 @@ import { msg } from '../../i18n/marker.js'; import { getWildlifeHeightBand, rowInWildlifeBoxes } from './model.js'; -import { combineWildlifeGridSnapshots } from './source.js'; +import { + combineWildlifeGridSnapshots, + wildlifeFailureRetryable, +} from './source.js'; import { wildlifeMeasure } from './performance.js'; import { isRecentWildlifeSighting } from './records.js'; import { @@ -92,25 +95,26 @@ export function createLifecycle({ state, services, parts }) { state.pendingParams = null; } - function applyRetry(error, retryAfterSec = null) { - // Only upstream failures impose cooldown; network/schema errors retry on pan. - const status = - error?.upstreamStatus ?? - error?.status ?? - Number(/HTTP (\d{3})/.exec(error?.message || '')?.[1]); - const retryDelays = [error?.retryAfterSec, retryAfterSec].filter( - (value) => Number.isFinite(value) && value >= 0, - ); - const retryFromSnapshot = - Number.isFinite(retryAfterSec) && retryAfterSec >= 0; - if (!(status === 429 || status >= 500) && !retryFromSnapshot) { + function applyRetry(errors, retryAfterSec = null) { + // Only upstream failures impose cooldown; network/schema errors retry on + // pan. Several sources can fail together: wait for the longest of them. + const failures = [errors].flat().filter(wildlifeFailureRetryable); + const snapshotDelay = + Number.isFinite(retryAfterSec) && retryAfterSec >= 0 + ? retryAfterSec * 1000 + : null; + if (!failures.length && snapshotDelay === null) { state.failures = 0; state.retryAt = 0; return; } - const backoff = retryDelays.length - ? Math.max(...retryDelays) * 1000 - : retryDelayMs(state.failures || 0); + const delays = failures.map((error) => + Number.isFinite(error.retryAfterSec) && error.retryAfterSec >= 0 + ? error.retryAfterSec * 1000 + : retryDelayMs(state.failures || 0), + ); + if (snapshotDelay !== null) delays.push(snapshotDelay); + const backoff = Math.max(...delays); state.failures = backoff; state.retryAt = services.now() + backoff; } @@ -404,16 +408,39 @@ export function createLifecycle({ state, services, parts }) { }, ) : null; - const sourceError = - gridError || - staleError || - (snapshot.sources?.inaturalist?.ok === false - ? snapshot.sources.inaturalist.error || + const inat = snapshot.sources?.inaturalist; + const acartia = snapshot.sources?.acartia; + // Keep each failed source's status and Retry-After; the displayed error + // is the one that explains the cooldown. + const sourceErrors = [ + gridError, + staleError, + inat?.ok === false + ? inat.error || new Error(msg('Wildlife sightings source unavailable')) - : null) || - (snapshot.sources?.acartia?.ok === false - ? new Error(msg('Acartia feed unavailable')) - : null); + : null, + acartia?.ok === false + ? Object.assign(new Error(msg('Acartia feed unavailable')), { + ...(acartia.error?.status + ? { status: acartia.error.status } + : {}), + ...(acartia.error?.upstreamStatus + ? { upstreamStatus: acartia.error.upstreamStatus } + : {}), + ...(Number.isFinite(acartia.error?.retryAfterSec) + ? { retryAfterSec: acartia.error.retryAfterSec } + : {}), + }) + : null, + ].filter(Boolean); + const sourceError = + sourceErrors.find( + (error) => + wildlifeFailureRetryable(error) || + Number.isFinite(error.retryAfterSec), + ) || + sourceErrors[0] || + null; state.stale = !sourceError && (snapshot.stale === true || grid?.stale === true); const fetchedAt = [snapshot.fetchedAt, grid?.fetchedAt].filter( @@ -438,7 +465,7 @@ export function createLifecycle({ state, services, parts }) { if (sourceError) { failParams(params); applyRetry( - sourceError, + sourceErrors, retryAfter.length ? Math.max(...retryAfter) : null, ); } else { diff --git a/src/layers/wildlifeSightings/source.js b/src/layers/wildlifeSightings/source.js index 8bd41bf1d..aa218f34e 100644 --- a/src/layers/wildlifeSightings/source.js +++ b/src/layers/wildlifeSightings/source.js @@ -102,6 +102,36 @@ function validProjectedRows(rows, origin) { ); } +/** Return the status a failure reports. The proxy marks upstream failures + * with X-Upstream-Status; without it the HTTP status is the signal. */ +function wildlifeFailureStatus(error) { + return ( + error?.upstreamStatus || + error?.status || + Number(/HTTP (\d{3})/.exec(error?.message || '')?.[1]) || + 0 + ); +} + +/** Whether a failure asks clients to wait before the next request. */ +export function wildlifeFailureRetryable(error) { + const status = wildlifeFailureStatus(error); + return status === 429 || status >= 500; +} + +/** Prefer the failure that carries retry timing, then the longest wait. */ +export function preferredWildlifeFailure(errors) { + const failures = errors.filter(Boolean); + const retryable = failures.filter(wildlifeFailureRetryable); + if (!retryable.length) return failures[0] || null; + return retryable.reduce((best, error) => + (Number.isFinite(error.retryAfterSec) ? error.retryAfterSec : -1) > + (Number.isFinite(best.retryAfterSec) ? best.retryAfterSec : -1) + ? error + : best, + ); +} + /** Merge occupied cells incrementally; neighboring UTFGrid hit tiles can * report the same geographic cell, so source ids remain the dedupe key. */ export function combineWildlifeGridSnapshots(snapshots, error = null) { @@ -421,7 +451,7 @@ export function createWildlifeSightingsSource({ ), ); signal?.throwIfAborted(); - const failure = failures.find(Boolean); + const failure = preferredWildlifeFailure(failures); if (!snapshots.some(Boolean) && failure) throw failure; return combineWildlifeGridSnapshots(snapshots.filter(Boolean), failure); } @@ -494,11 +524,15 @@ export function createWildlifeSightingsSource({ }); // Fail outright only when nothing succeeded; otherwise draw what arrived // and report the failed source in its per-source status. + const acartiaError = + useAcartia && acartiaResult.status === 'rejected' + ? acartiaResult.reason + : null; if ( !inatSnapshots.length && (!useAcartia || acartiaResult.status !== 'fulfilled') ) - throw inatError; + throw preferredWildlifeFailure([inatError, acartiaError]); const inatRows = inatSnapshots.flatMap((snapshot) => snapshot.rows); const acartiaSnapshot = acartiaResult.status === 'fulfilled' ? acartiaResult.value : null; @@ -553,12 +587,13 @@ export function createWildlifeSightingsSource({ ...(inatError ? { error: inatError, failedBoxes } : {}), }, acartia: { - ok: !useAcartia || acartiaResult.status === 'fulfilled', + ok: !acartiaError, stale: acartiaStale, fetchedAt: Number.isFinite(acartiaSnapshot?.fetchedAt) ? acartiaSnapshot.fetchedAt : null, count: drawnAcartia, + ...(acartiaError ? { error: acartiaError } : {}), }, }, }; From 3438a88cc7780058fb971ec3b087addcdbdc404e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:31:46 -0800 Subject: [PATCH 35/39] Ease a late zoomed tilt to its new limit instead of jumping there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 400 km, a tilt to -5° that a zoom out x2.5 caught at -8.0005° reported -8°, and the next frame jumped to -9.407° and ended there. The zoom kept the camera's pitch as the goal when it was already past the new range's horizon limit, but at the same pivot pitch the longer range looks down more steeply, so the next lookAt dropped the camera 1.4° at once. The zoom now re-solves the pivot pitch so the new range keeps the camera's pitch, and aims the tilt at the asked angle or the new limit, even when that limit is below where the camera stands: the tilt eases down to it. The zoom's reply names that end, and the camera reaches it with no frame moving more than 0.1°. --- docs/CURRENT-STATE.md | 2 +- src/cameraTilt.test.mjs | 29 ++++++++++++++++++++++ src/cameraVerbs.js | 54 ++++++++++++++++++++++++++++++++--------- 3 files changed, 72 insertions(+), 13 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d41c435f1..d5ef4af05 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5590,7 +5590,7 @@ and unreachable upstream (502/504) separately from road geometry. > branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on > `main` + 26 route pins; the same tree on Node 24 reports 2,295. -- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. A zoom during a pivoting tilt scales the range it holds, as for orbit, but a zoom in stops at the range that keeps that 150 m clearance; a zoom that can move no further (that floor, the 80 m floor, or the cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) is reported as not zoomed. A zoom during a tilt to an angle re-judges the place's horizon limit at the new range: when that moves where the tilt ends, the tilt eases from where it is to the new end, and the zoom's result names that angle (`pitchDeg`) with a `note`. Once the tilt continues in place, a zoom moves the camera like any zoom. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the user grabs, touches or scrolls the map during it, or another navigation (a location-panel search, share link or file import, which leave the flight flying) takes the camera during it, the motion is not run and says so. A motion that runs after a fly-to of the same reply has already found nothing (Gemini runs one turn's calls one by one, and passes its turn as the reply) is refused the same way rather than moving the old view; a motion in a later reply runs. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. +- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. A zoom during a pivoting tilt scales the range it holds, as for orbit, but a zoom in stops at the range that keeps that 150 m clearance; a zoom that can move no further (that floor, the 80 m floor, or the cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) is reported as not zoomed. A zoom during a tilt to an angle re-judges the place's horizon limit at the new range: when that moves where the tilt ends, the tilt eases from where it is to the new end (holding the camera's pitch through the range change, and easing back down to a limit it is already past rather than jumping there), and the zoom's result names that angle (`pitchDeg`) with a `note`. Once the tilt continues in place, a zoom moves the camera like any zoom. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the user grabs, touches or scrolls the map during it, or another navigation (a location-panel search, share link or file import, which leave the flight flying) takes the camera during it, the motion is not run and says so. A motion that runs after a fly-to of the same reply has already found nothing (Gemini runs one turn's calls one by one, and passes its turn as the reply) is refused the same way rather than moving the old view; a motion in a later reply runs. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. - **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing. - **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm 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 rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped. - Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet). diff --git a/src/cameraTilt.test.mjs b/src/cameraTilt.test.mjs index 0dcb2d1b0..738c71e88 100644 --- a/src/cameraTilt.test.mjs +++ b/src/cameraTilt.test.mjs @@ -686,3 +686,32 @@ test('a voice zoom during a tilt to an angle passes on the new end angle', async ); assert.match(zoom.note, /horizon/); }); + +// Late in the tilt the camera is already past the angle the new range's +// horizon allows (from 400 km at -8°, a zoom out x2.5 reported -8° and the +// next frame jumped to -9.4°): it eases to that limit with no jump, and the +// zoom's reply names the angle it ends at, not the one it stood at. +test('a late zoom out during a tilt eases to the new limit and reports it', (t) => { + const h = harness(t, { height: 400_000 }); + moveCamera({ motion: 'tilt', pitchDeg: -5 }); + for (let i = 0; i < 70; i += 1) h.tick(); + const zoom = adjustOrbitRange(2.5); + assert.equal(zoom.changed, true); + let last = pitchDeg(h.camera); + let maxStep = 0; + for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) { + h.tick(); + maxStep = Math.max(maxStep, Math.abs(pitchDeg(h.camera) - last)); + last = pitchDeg(h.camera); + } + assert.equal(getActiveCameraMotion(), null, 'tilt must finish'); + assert.ok(maxStep < 0.5, `pitch jumped ${maxStep}° in one frame`); + assert.ok(h.viewTarget(), 'the crosshair is on the sky'); + // Replies give whole degrees: the reported angle is the reached one rounded. + assert.equal( + zoom.pitchDeg, + Math.round(pitchDeg(h.camera)), + `camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`, + ); + assert.match(zoom.note, /horizon/); +}); diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 0681d6a2b..437688d7a 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -1378,12 +1378,22 @@ function tiltClearanceBinds(range) { */ function tiltUpLimit({ target, pivot }) { if (!pivot || tiltClearanceBinds(pivot.range)) return PITCH_MAX; - const pitch = Math.max(pivot.pitch, tiltPivotPitchMax(pivot.range)); - // The camera's position for that pivot pitch, as lookAt places it. + return Math.min( + PITCH_MAX, + pivotCameraPitch( + target, + pivot, + Math.max(pivot.pitch, tiltPivotPitchMax(pivot.range)), + ), + ); +} + +/** The camera pitch lookAt gives for this pivot at the given pivot pitch. */ +function pivotCameraPitch(target, { heading, range }, pitch) { const offset = new Cesium.Cartesian3( - -pivot.range * Math.cos(pitch) * Math.sin(pivot.heading), - -pivot.range * Math.cos(pitch) * Math.cos(pivot.heading), - -pivot.range * Math.sin(pitch), + -range * Math.cos(pitch) * Math.sin(heading), + -range * Math.cos(pitch) * Math.cos(heading), + -range * Math.sin(pitch), ); const eye = Cesium.Matrix4.multiplyByPoint( Cesium.Transforms.eastNorthUpToFixedFrame(target), @@ -1394,9 +1404,8 @@ function tiltUpLimit({ target, pivot }) { Cesium.Cartesian3.normalize(look, look); const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84; const upAtEye = ellipsoid.geodeticSurfaceNormal(eye, new Cesium.Cartesian3()); - return Math.min( - PITCH_MAX, - Math.asin(Cesium.Math.clamp(Cesium.Cartesian3.dot(look, upAtEye), -1, 1)), + return Math.asin( + Cesium.Math.clamp(Cesium.Cartesian3.dot(look, upAtEye), -1, 1), ); } @@ -2092,16 +2101,37 @@ export function adjustOrbitRange(factor) { /** * A zoom changed the range a tilt to an angle holds, and with it the place's * horizon limit: aim the tilt at the angle asked for or the new limit, - * whichever comes first, from where the camera is now. Returns the end angle - * and a note for the reply when that end moved, otherwise nothing. + * whichever comes first, from where the camera is now. The pivot pitch is + * re-solved so the new range keeps the camera's pitch, and a limit the camera + * is already past is eased down to rather than jumped to. Returns the end + * angle and a note for the reply when that end moved, otherwise nothing. */ function retargetTiltAfterZoom(m) { if (m.kind !== 'tilt' || m.mode !== 'once' || m.askedPitch === undefined) return {}; const from = _viewer.camera.pitch; if (!(m.askedPitch > from)) return {}; - const limit = tiltUpLimit({ target: m.target, pivot: m.hpr }); - const goal = Math.min(m.askedPitch, Math.max(from, limit)); + if (m.hpr) { + // At the same pivot pitch a longer range looks down more steeply (the + // Earth curves away): find the pivot pitch that holds this camera pitch. + for (let i = 0; i < 4; i += 1) { + const error = from - pivotCameraPitch(m.target, m.hpr, m.hpr.pitch); + if (Math.abs(error) < 1e-7) break; + m.hpr.pitch = Cesium.Math.clamp( + m.hpr.pitch + error, + -Cesium.Math.PI_OVER_TWO, + 0, + ); + } + } + const limit = + m.hpr && !tiltClearanceBinds(m.hpr.range) + ? Math.min( + PITCH_MAX, + pivotCameraPitch(m.target, m.hpr, tiltPivotPitchMax(m.hpr.range)), + ) + : PITCH_MAX; + const goal = Math.min(m.askedPitch, limit); if (Math.abs(goal - m.goalPitch) < 1e-6) return {}; m.goalPitch = goal; m.startPitch = from; From dac9b5627d629655304f11eddd5e45f861f512b9 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:42:45 -0800 Subject: [PATCH 36/39] fix(wildlife): wait for the longest effective delay among every failed request One error was chosen before each failure's delay was known. An antimeridian box 400 hid the other box's 503 Retry-After; a grid tile 400 with Retry-After set a cooldown through the snapshot metadata; a short Retry-After on one tile undercut a sibling 5xx's default backoff; and a huge Retry-After made the deadline Infinity. Every failed request now keeps its status and Retry-After. One function gives each failure's delay: 4xx other than 429 none, 429 and 5xx their Retry-After or the default backoff. The layer waits for the longest, and shows the failure that set it. Retry-After is capped at one day, the proxy's own daily-budget deadline, and non-finite values are ignored. Each publish of one acquisition derives the default backoff from the backoff before it started, so repeated tile publishes no longer double it. --- .../wildlifeSightings/acquisition.test.mjs | 95 +++++++++++++++++++ src/layers/wildlifeSightings/lifecycle.js | 74 ++++++++------- src/layers/wildlifeSightings/policy.js | 3 + src/layers/wildlifeSightings/source.js | 89 +++++++++++++---- 4 files changed, 209 insertions(+), 52 deletions(-) diff --git a/src/layers/wildlifeSightings/acquisition.test.mjs b/src/layers/wildlifeSightings/acquisition.test.mjs index d343ad9b0..b4ac47813 100644 --- a/src/layers/wildlifeSightings/acquisition.test.mjs +++ b/src/layers/wildlifeSightings/acquisition.test.mjs @@ -1072,3 +1072,98 @@ for (const [name, respond] of [ assert.equal(h.state.renderedRows.length, 2); }); } + +// Every failed request keeps its own timing. The layer waits for the longest +// effective delay: 4xx other than 429 never waits, 429/5xx wait for their +// Retry-After or the default backoff. The shown error explains that wait. +async function settleThroughSource(fetchImpl, bounds = salishSea) { + const h = harness( + createWildlifeSightingsSource({ now: () => NOW, fetchImpl }), + ); + h.bounds(bounds); + h.state.enabled = false; + h.layer.enable(); + const first = h.layer.update(); + await h.advance(300); + await first; + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + return h; +} +const tileX = (url) => + Number(new URL(url, 'https://example.test').searchParams.get('x')); +const observations = () => + Response.json({ rows: [inatRow], totalResults: 1, fetchedAt: NOW }); + +test('an antimeridian box 400 never hides the other box 503 Retry-After', async () => { + for (const westFails of [true, false]) { + const h = await settleThroughSource( + async (url) => { + if (url.startsWith('/api/inaturalist/grid?')) return emptyGrid(); + const west = + Number( + new URL(url, 'https://example.test').searchParams.get('west'), + ) > 0; + return west === westFails + ? new Response('', { status: 400 }) + : unavailable(); + }, + { south: 10, west: 179, north: 12, east: -179 }, + ); + assert.equal(h.state.error, 'iNaturalist HTTP 503'); + assert.equal(h.state.retryAt, NOW + 300 + 120_000); + assert.equal(h.layer.getStats().retryInSec, 120); + } +}); + +test('a grid tile 400 with Retry-After beside successful tiles creates no cooldown', async () => { + let tiles = new Set(); + const h = await settleThroughSource(async (url) => { + if (!url.startsWith('/api/inaturalist/grid?')) return observations(); + tiles.add(tileX(url)); + return tileX(url) === Math.min(...tiles) && tiles.size === 1 + ? new Response('', { status: 400, headers: { 'Retry-After': '120' } }) + : emptyGrid(); + }); + assert.ok(tiles.size > 1, 'the view spans several grid tiles'); + assert.equal(h.state.error, 'iNaturalist HTTP 400'); + assert.equal(h.state.retryAt, 0); + assert.equal(h.layer.getStats().retryInSec, undefined); +}); + +test('a short grid Retry-After never undercuts a sibling 5xx default backoff', async () => { + let failed = 0; + const h = await settleThroughSource(async (url) => { + if (!url.startsWith('/api/inaturalist/grid?')) return observations(); + failed++; + if (failed === 1) + return new Response('', { status: 503, headers: { 'Retry-After': '2' } }); + if (failed === 2) return new Response('', { status: 502 }); + return emptyGrid(); + }); + assert.ok(failed >= 2); + assert.equal(h.state.error, 'iNaturalist HTTP 502'); + assert.equal(h.state.retryAt, NOW + 300 + 30_000); + assert.equal(h.layer.getStats().retryInSec, 30); +}); + +test('an enormous Retry-After is capped at one day and a non-finite one uses the default backoff', async () => { + for (const [header, delay] of [ + ['1' + '0'.repeat(306), 86_400_000], + ['1' + '0'.repeat(400), 30_000], + ]) { + const h = await settleThroughSource(async (url) => + url.startsWith('/api/inaturalist/grid?') + ? emptyGrid() + : url.startsWith('/api/acartia') + ? Response.json({ rows: [], fetchedAt: NOW }) + : new Response('', { + status: 503, + headers: { 'Retry-After': header }, + }), + ); + assert.equal(h.state.error, 'iNaturalist HTTP 503'); + assert.ok(Number.isFinite(h.state.retryAt)); + assert.equal(h.state.retryAt, NOW + 300 + delay, header.length); + } +}); diff --git a/src/layers/wildlifeSightings/lifecycle.js b/src/layers/wildlifeSightings/lifecycle.js index 3b2a2999a..0fe430fbe 100644 --- a/src/layers/wildlifeSightings/lifecycle.js +++ b/src/layers/wildlifeSightings/lifecycle.js @@ -2,7 +2,10 @@ import { msg } from '../../i18n/marker.js'; import { getWildlifeHeightBand, rowInWildlifeBoxes } from './model.js'; import { combineWildlifeGridSnapshots, - wildlifeFailureRetryable, + preferredWildlifeFailure, + wildlifeFailureDelayMs, + wildlifeFailures, + wildlifeRetryAfterMs, } from './source.js'; import { wildlifeMeasure } from './performance.js'; import { isRecentWildlifeSighting } from './records.js'; @@ -12,11 +15,7 @@ import { wildlifeGridTiles, wildlifeGridCellId, } from './gridViewport.js'; -import { - wildlifeViewportBoxes, - snapWildlifeBox, - retryDelayMs, -} from './viewport.js'; +import { wildlifeViewportBoxes, snapWildlifeBox } from './viewport.js'; import { WILDLIFE_DEBOUNCE_MS, WILDLIFE_MAX_ROWS, @@ -95,25 +94,25 @@ export function createLifecycle({ state, services, parts }) { state.pendingParams = null; } - function applyRetry(errors, retryAfterSec = null) { - // Only upstream failures impose cooldown; network/schema errors retry on - // pan. Several sources can fail together: wait for the longest of them. - const failures = [errors].flat().filter(wildlifeFailureRetryable); - const snapshotDelay = - Number.isFinite(retryAfterSec) && retryAfterSec >= 0 - ? retryAfterSec * 1000 - : null; - if (!failures.length && snapshotDelay === null) { + function applyRetry( + errors, + retryAfterSec = null, + previousMs = state.failures || 0, + ) { + // Only 429/5xx failures impose cooldown; client, network and schema + // errors retry on pan. Several requests can fail together: wait for the + // longest effective delay among them. + const delays = [ + ...wildlifeFailures(errors).map((error) => + wildlifeFailureDelayMs(error, previousMs), + ), + wildlifeRetryAfterMs(retryAfterSec), + ].filter((delay) => delay !== null); + if (!delays.length) { state.failures = 0; state.retryAt = 0; return; } - const delays = failures.map((error) => - Number.isFinite(error.retryAfterSec) && error.retryAfterSec >= 0 - ? error.retryAfterSec * 1000 - : retryDelayMs(state.failures || 0), - ); - if (snapshotDelay !== null) delays.push(snapshotDelay); const backoff = Math.max(...delays); state.failures = backoff; state.retryAt = services.now() + backoff; @@ -282,6 +281,7 @@ export function createLifecycle({ state, services, parts }) { request, signal, epoch, + previousFailures, replaceRows = true, }) { const current = currentQuery(); @@ -398,10 +398,14 @@ export function createLifecycle({ state, services, parts }) { value.upstreamStatus === 429 || value.upstreamStatus >= 500), ); + // The filter above admits only 429/5xx or an unmarked stale answer. const staleError = failedStale.length ? Object.assign( new Error(msg('Wildlife sightings source unavailable')), { + upstreamStatus: Math.max( + ...failedStale.map((value) => value.upstreamStatus || 503), + ), retryAfterSec: Math.max( ...failedStale.map((value) => value.retryAfterSec), ), @@ -410,8 +414,8 @@ export function createLifecycle({ state, services, parts }) { : null; const inat = snapshot.sources?.inaturalist; const acartia = snapshot.sources?.acartia; - // Keep each failed source's status and Retry-After; the displayed error - // is the one that explains the cooldown. + // Keep each failed request's status and Retry-After; the displayed + // error is the one whose effective delay sets the cooldown. const sourceErrors = [ gridError, staleError, @@ -433,14 +437,10 @@ export function createLifecycle({ state, services, parts }) { }) : null, ].filter(Boolean); - const sourceError = - sourceErrors.find( - (error) => - wildlifeFailureRetryable(error) || - Number.isFinite(error.retryAfterSec), - ) || - sourceErrors[0] || - null; + const sourceError = preferredWildlifeFailure( + sourceErrors, + previousFailures, + ); state.stale = !sourceError && (snapshot.stale === true || grid?.stale === true); const fetchedAt = [snapshot.fetchedAt, grid?.fetchedAt].filter( @@ -467,12 +467,13 @@ export function createLifecycle({ state, services, parts }) { applyRetry( sourceErrors, retryAfter.length ? Math.max(...retryAfter) : null, + previousFailures, ); } else { state.pendingParams = null; state.failedParams = null; state.retryAt = retryAfter.length - ? services.now() + Math.max(...retryAfter) * 1000 + ? services.now() + wildlifeRetryAfterMs(Math.max(...retryAfter)) : 0; state.failures = 0; } @@ -632,6 +633,9 @@ export function createLifecycle({ state, services, parts }) { state.request?.abort(); const request = new AbortController(); const requestLifetime = lifetimeEpoch; + // One acquisition publishes several times (tiles, rows, final grid). + // Each derives its default backoff from the backoff before it started. + const previousFailures = state.failures || 0; const supersededResult = () => state.enabled && !state.destroyed && @@ -699,6 +703,7 @@ export function createLifecycle({ state, services, parts }) { request, signal, epoch: requestLifetime, + previousFailures, }); } function finishTiles() { @@ -804,11 +809,12 @@ export function createLifecycle({ state, services, parts }) { continueTiles(); state.stale = false; state.error = - error?.message || msg('Wildlife sightings source unavailable'); + preferredWildlifeFailure(error, previousFailures)?.message || + msg('Wildlife sightings source unavailable'); failParams(params); state.lastQueryKey = null; requestQueryKey = null; - applyRetry(error); + applyRetry(error, null, previousFailures); console.warn('[Data:WildlifeSightings] Update failed:', state.error); return true; } finally { diff --git a/src/layers/wildlifeSightings/policy.js b/src/layers/wildlifeSightings/policy.js index 4af9d7d17..f1b6df277 100644 --- a/src/layers/wildlifeSightings/policy.js +++ b/src/layers/wildlifeSightings/policy.js @@ -74,6 +74,9 @@ export { WILDLIFE_MAX_VIEW_DEGREES } from './bounds.js'; export const WILDLIFE_VIEW_PADDING = 0.3; export const WILDLIFE_RETRY_MIN_MS = 30_000; export const WILDLIFE_RETRY_MAX_MS = 240_000; +// Cap a Retry-After so one reply can never stall refresh forever. The proxy's +// daily-budget reply waits until the next UTC midnight, so allow one day. +export const WILDLIFE_RETRY_AFTER_MAX_SEC = 86_400; // Faster polling would reread the proxy's five-minute cache. export const WILDLIFE_UPDATE_INTERVAL_MS = 5 * 60_000; diff --git a/src/layers/wildlifeSightings/source.js b/src/layers/wildlifeSightings/source.js index aa218f34e..e2e3f1b80 100644 --- a/src/layers/wildlifeSightings/source.js +++ b/src/layers/wildlifeSightings/source.js @@ -1,8 +1,12 @@ import { readResponseJsonCapped } from '../../sources/httpBody.js'; import { mergeSightings } from './records.js'; import { wildlifeMeasure } from './performance.js'; -import { ACARTIA_GUARD_BOX, TAXA_PRESETS } from './policy.js'; -import { boxIntersects } from './viewport.js'; +import { + ACARTIA_GUARD_BOX, + TAXA_PRESETS, + WILDLIFE_RETRY_AFTER_MAX_SEC, +} from './policy.js'; +import { boxIntersects, retryDelayMs } from './viewport.js'; import { WILDLIFE_GRID_MAX_ZOOM, WILDLIFE_GRID_TILE_CAP, @@ -72,14 +76,19 @@ function retryAfterSeconds(value, nowMs) { const text = value.trim(); if (/^\d+(?:\.\d+)?$/.test(text)) { const seconds = Number(text); - return Number.isFinite(seconds) ? Math.ceil(seconds) : null; + return Number.isFinite(seconds) + ? Math.min(WILDLIFE_RETRY_AFTER_MAX_SEC, Math.ceil(seconds)) + : null; } // Reject malformed numeric values rather than letting Date.parse interpret // them as calendar dates. HTTP dates include a named weekday or month. if (!/[a-z]/i.test(text)) return null; const deadline = Date.parse(text); return Number.isFinite(deadline) && Number.isFinite(nowMs) - ? Math.max(0, Math.ceil((deadline - nowMs) / 1000)) + ? Math.min( + WILDLIFE_RETRY_AFTER_MAX_SEC, + Math.max(0, Math.ceil((deadline - nowMs) / 1000)), + ) : null; } @@ -119,17 +128,56 @@ export function wildlifeFailureRetryable(error) { return status === 429 || status >= 500; } -/** Prefer the failure that carries retry timing, then the longest wait. */ -export function preferredWildlifeFailure(errors) { - const failures = errors.filter(Boolean); - const retryable = failures.filter(wildlifeFailureRetryable); - if (!retryable.length) return failures[0] || null; - return retryable.reduce((best, error) => - (Number.isFinite(error.retryAfterSec) ? error.retryAfterSec : -1) > - (Number.isFinite(best.retryAfterSec) ? best.retryAfterSec : -1) - ? error - : best, - ); +/** Return a Retry-After in milliseconds, capped at one day, or null when + * it is missing, negative or not finite. */ +export function wildlifeRetryAfterMs(seconds) { + return Number.isFinite(seconds) && seconds >= 0 + ? Math.min(WILDLIFE_RETRY_AFTER_MAX_SEC, seconds) * 1000 + : null; +} + +/** Return how long a failure asks the layer to wait, or null for none: + * 429 and 5xx wait for their Retry-After, else the default backoff after + * `previousMs`; other failures may retry on the next view. */ +export function wildlifeFailureDelayMs(error, previousMs = 0) { + if (!error || !wildlifeFailureRetryable(error)) return null; + return wildlifeRetryAfterMs(error.retryAfterSec) ?? retryDelayMs(previousMs); +} + +/** Flatten failures, expanding a group into every request that failed. */ +export function wildlifeFailures(errors) { + return [errors] + .flat() + .filter(Boolean) + .flatMap((error) => + Array.isArray(error.failures) && error.failures.length + ? error.failures + : [error], + ); +} + +/** Return the failure with the longest effective delay (the first failure + * when none waits), carrying every failure so the caller can recompute. */ +export function preferredWildlifeFailure(errors, previousMs = 0) { + const failures = wildlifeFailures(errors); + if (!failures.length) return null; + let chosen = failures[0]; + let longest = wildlifeFailureDelayMs(chosen, previousMs) ?? -1; + for (const error of failures.slice(1)) { + const delay = wildlifeFailureDelayMs(error, previousMs) ?? -1; + if (delay > longest) [chosen, longest] = [error, delay]; + } + if (failures.length === 1) return chosen; + // Grid errors are shared between consumers of one tile request; describe + // the group with a new error rather than mutating any of them. + return Object.assign(new Error(chosen.message, { cause: chosen }), { + ...(chosen.status ? { status: chosen.status } : {}), + ...(chosen.upstreamStatus ? { upstreamStatus: chosen.upstreamStatus } : {}), + ...(Number.isFinite(chosen.retryAfterSec) + ? { retryAfterSec: chosen.retryAfterSec } + : {}), + failures, + }); } /** Merge occupied cells incrementally; neighboring UTFGrid hit tiles can @@ -145,9 +193,12 @@ export function combineWildlifeGridSnapshots(snapshots, error = null) { const totalResults = cells.reduce((sum, cell) => sum + cell.cellCount, 0); if (!Number.isSafeInteger(totalResults)) throw new Error('Malformed iNaturalist grid response'); + // A failure's Retry-After counts only when its status asks for a wait. const retryAfter = [ ...snapshots.map((snapshot) => snapshot.retryAfterSec), - error?.retryAfterSec, + ...wildlifeFailures(error) + .filter(wildlifeFailureRetryable) + .map((failure) => failure.retryAfterSec), ].filter(Number.isFinite); const stale = snapshots.some((snapshot) => snapshot.stale === true); return { @@ -514,14 +565,16 @@ export function createWildlifeSightingsSource({ signal?.throwIfAborted(); const inatSnapshots = []; const failedBoxes = []; - let inatError = null; + const inatErrors = []; inatResult.value.forEach((result, index) => { if (result.status === 'fulfilled') inatSnapshots.push(result.value); else { failedBoxes.push(requestBoxes[index]); - inatError ??= result.reason; + inatErrors.push(result.reason); } }); + // Keep every failed box; the layer waits for the longest of them. + const inatError = preferredWildlifeFailure(inatErrors); // Fail outright only when nothing succeeded; otherwise draw what arrived // and report the failed source in its per-source status. const acartiaError = From 6d7fe53570465f0fabf19a678ae1ba3795cdf23b Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:55:54 -0800 Subject: [PATCH 37/39] Let a shell without its navigation owner report no camera handoffs The voice runner subscribes to camera handoffs as it is created. The Cyber sonar test builds its runner on a StyleManager made from the prototype, which has no navigation owner, so the facade's delegation threw and both Node jobs failed. The facade now returns nothing there, like its other optional owners; the real shell builds its navigation owner before voice creates a runner. --- scripts/format-scope.json | 1 + src/ui/shellFacade.js | 2 +- src/ui/shellFacade.test.mjs | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 src/ui/shellFacade.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index a28a8c53c..40bca019a 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1128,6 +1128,7 @@ "src/dockerTrustedPeers.test.mjs", "src/cameraGlobeRotation.test.mjs", "src/cameraTilt.test.mjs", + "src/ui/shellFacade.test.mjs", "src/voice/globeResetConfirmation.test.mjs", "src/hudDms.test.mjs", "src/hudSummaryGate.test.mjs", diff --git a/src/ui/shellFacade.js b/src/ui/shellFacade.js index 95bf791d4..678b69c79 100644 --- a/src/ui/shellFacade.js +++ b/src/ui/shellFacade.js @@ -6,7 +6,7 @@ export class ShellFacade { /** Observe camera ownership transfers through the navigation owner. */ subscribeCameraHandoff(listener) { - return this._navigation.subscribeCameraHandoff(listener); + return this._navigation?.subscribeCameraHandoff(listener); } get _models3dEnabled() { diff --git a/src/ui/shellFacade.test.mjs b/src/ui/shellFacade.test.mjs new file mode 100644 index 000000000..abb892b37 --- /dev/null +++ b/src/ui/shellFacade.test.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { ShellFacade } from './shellFacade.js'; + +// A shell assembled without its navigation owner (as unit tests build one +// from the prototype) has no handoffs to report, like the facade's other +// optional owners; the voice runner subscribes as it is created. +test('camera handoffs without a navigation owner subscribe to nothing', () => { + const shell = Object.create(ShellFacade.prototype); + assert.equal( + shell.subscribeCameraHandoff(() => {}), + undefined, + ); +}); + +test('camera handoffs reach listeners through the navigation owner', () => { + const listeners = new Set(); + const shell = Object.create(ShellFacade.prototype); + shell._navigation = { + subscribeCameraHandoff(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; + let handoffs = 0; + const unsubscribe = shell.subscribeCameraHandoff(() => { + handoffs += 1; + }); + for (const listener of listeners) listener(); + assert.equal(handoffs, 1); + unsubscribe(); + assert.equal(listeners.size, 0); +}); From 495139399719ad56fe38606931987331387ffdf9 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:55:54 -0800 Subject: [PATCH 38/39] Keep the place in view when a late zoom out catches a tilt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 400 km, a tilt to -5° that a zoom out x2.5 caught 70 frames in eased down from the camera's old pitch at the new range. That put the camera below the place's horizon: the first frame's crosshair hit the ground 186 km from London, and London stayed hidden for about 11 frames. The re-solve also left the pivot pitch at 0°, so a zoom in before the next frame computed an infinite clearance floor, was refused, and stayed at 1,000 km. The re-solved pivot pitch now stops at the place's horizon limit, or where the pivot already was. A camera that the zoom leaves past that limit steps down to it with the zoom and holds it, and the zoom still reports that angle. A zoom in straight after brings the range back to 400 km. --- docs/CURRENT-STATE.md | 2 +- src/cameraTilt.test.mjs | 66 +++++++++++++++++++++++++++++++++++++---- src/cameraVerbs.js | 13 ++++++-- 3 files changed, 72 insertions(+), 9 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d5ef4af05..390c61c4a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5590,7 +5590,7 @@ and unreachable upstream (502/504) separately from road geometry. > branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on > `main` + 26 route pins; the same tree on Node 24 reports 2,295. -- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. A zoom during a pivoting tilt scales the range it holds, as for orbit, but a zoom in stops at the range that keeps that 150 m clearance; a zoom that can move no further (that floor, the 80 m floor, or the cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) is reported as not zoomed. A zoom during a tilt to an angle re-judges the place's horizon limit at the new range: when that moves where the tilt ends, the tilt eases from where it is to the new end (holding the camera's pitch through the range change, and easing back down to a limit it is already past rather than jumping there), and the zoom's result names that angle (`pitchDeg`) with a `note`. Once the tilt continues in place, a zoom moves the camera like any zoom. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the user grabs, touches or scrolls the map during it, or another navigation (a location-panel search, share link or file import, which leave the flight flying) takes the camera during it, the motion is not run and says so. A motion that runs after a fly-to of the same reply has already found nothing (Gemini runs one turn's calls one by one, and passes its turn as the reply) is refused the same way rather than moving the old view; a motion in a later reply runs. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. +- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. A zoom during a pivoting tilt scales the range it holds, as for orbit, but a zoom in stops at the range that keeps that 150 m clearance; a zoom that can move no further (that floor, the 80 m floor, or the cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) is reported as not zoomed. A zoom during a tilt to an angle re-judges the place's horizon limit at the new range: when that moves where the tilt ends, the tilt eases from where it is to the new end (holding the camera's pitch through the range change; a camera the zoom leaves past the new range's limit steps down to it with the zoom, since past it the place would drop behind the Earth), and the zoom's result names that angle (`pitchDeg`) with a `note`. Once the tilt continues in place, a zoom moves the camera like any zoom. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the user grabs, touches or scrolls the map during it, or another navigation (a location-panel search, share link or file import, which leave the flight flying) takes the camera during it, the motion is not run and says so. A motion that runs after a fly-to of the same reply has already found nothing (Gemini runs one turn's calls one by one, and passes its turn as the reply) is refused the same way rather than moving the old view; a motion in a later reply runs. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. - **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing. - **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm 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 rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped. - Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet). diff --git a/src/cameraTilt.test.mjs b/src/cameraTilt.test.mjs index 738c71e88..8a3d1447f 100644 --- a/src/cameraTilt.test.mjs +++ b/src/cameraTilt.test.mjs @@ -688,15 +688,18 @@ test('a voice zoom during a tilt to an angle passes on the new end angle', async }); // Late in the tilt the camera is already past the angle the new range's -// horizon allows (from 400 km at -8°, a zoom out x2.5 reported -8° and the -// next frame jumped to -9.4°): it eases to that limit with no jump, and the -// zoom's reply names the angle it ends at, not the one it stood at. -test('a late zoom out during a tilt eases to the new limit and reports it', (t) => { +// horizon allows (from 400 km at -8°, a zoom out x2.5 reported -8°): past it +// the place is behind the Earth, so the camera steps down to that limit with +// the zoom, then holds it with no further jump, and the zoom's reply names +// the angle it ends at, not the one it stood at. +test('a late zoom out during a tilt steps to the new limit and reports it', (t) => { const h = harness(t, { height: 400_000 }); moveCamera({ motion: 'tilt', pitchDeg: -5 }); for (let i = 0; i < 70; i += 1) h.tick(); const zoom = adjustOrbitRange(2.5); assert.equal(zoom.changed, true); + h.tick(); + assert.equal(zoom.pitchDeg, Math.round(pitchDeg(h.camera))); let last = pitchDeg(h.camera); let maxStep = 0; for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) { @@ -705,7 +708,7 @@ test('a late zoom out during a tilt eases to the new limit and reports it', (t) last = pitchDeg(h.camera); } assert.equal(getActiveCameraMotion(), null, 'tilt must finish'); - assert.ok(maxStep < 0.5, `pitch jumped ${maxStep}° in one frame`); + assert.ok(maxStep < 0.1, `pitch jumped ${maxStep}° in one frame`); assert.ok(h.viewTarget(), 'the crosshair is on the sky'); // Replies give whole degrees: the reported angle is the reached one rounded. assert.equal( @@ -715,3 +718,56 @@ test('a late zoom out during a tilt eases to the new limit and reports it', (t) ); assert.match(zoom.note, /horizon/); }); + +// Easing from the camera's old pitch at the zoomed-out range put the camera +// below the place's horizon: the first frame's crosshair hit the ground +// 186 km from London, and London stayed hidden for ~11 frames. +test('a late zoom out during a tilt keeps the place under the crosshair', (t) => { + const h = harness(t, { height: 400_000 }); + moveCamera({ motion: 'tilt', pitchDeg: -5 }); + for (let i = 0; i < 70; i += 1) h.tick(); + const zoom = adjustOrbitRange(2.5); + assert.equal(zoom.changed, true); + for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) { + h.tick(); + const hit = h.viewTarget(); + assert.ok(hit, `frame ${i}: the crosshair is on the sky`); + const drift = centreDrift(h); + assert.ok( + drift < 100, + `frame ${i}: crosshair ${Math.round(drift)} m off the place`, + ); + } + assert.equal(getActiveCameraMotion(), null, 'tilt must finish'); + assert.ok( + Math.abs(pivotRange(h) - 1_000_000) < 1, + `range ${pivotRange(h)} m`, + ); + assert.equal( + zoom.pitchDeg, + Math.round(pitchDeg(h.camera)), + `camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`, + ); + assert.match(zoom.note, /horizon/); +}); + +// The zoom out left the pivot pitch level with the place, so the zoom in's +// clearance floor came out infinite and it was refused at 1,000 km. +test('a zoom in right after a late zoom out brings the tilt back in', (t) => { + const h = harness(t, { height: 400_000 }); + moveCamera({ motion: 'tilt', pitchDeg: -5 }); + for (let i = 0; i < 70; i += 1) h.tick(); + const range = pivotRange(h); + assert.equal(adjustOrbitRange(2.5).changed, true); + const zoomIn = adjustOrbitRange(0.4); + assert.equal(zoomIn.changed, true, 'the zoom in must not be refused'); + h.finish(); + assert.ok( + Math.abs(pivotRange(h) - range) < 1, + `range ${pivotRange(h)} m, wanted ${range} m`, + ); + assert.ok( + centreDrift(h) < 100, + `crosshair ${centreDrift(h)} m off the place`, + ); +}); diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 437688d7a..d7127c2f7 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -2109,20 +2109,27 @@ export function adjustOrbitRange(factor) { function retargetTiltAfterZoom(m) { if (m.kind !== 'tilt' || m.mode !== 'once' || m.askedPitch === undefined) return {}; - const from = _viewer.camera.pitch; + let from = _viewer.camera.pitch; if (!(m.askedPitch > from)) return {}; if (m.hpr) { // At the same pivot pitch a longer range looks down more steeply (the // Earth curves away): find the pivot pitch that holds this camera pitch. + // It stays below the place's horizon limit (or where the pivot already + // was): past it the place drops behind the Earth, so a camera the zoom + // leaves past that limit steps down to it with the zoom. + const cap = Math.max(m.hpr.pitch, tiltPivotPitchMax(m.hpr.range)); for (let i = 0; i < 4; i += 1) { const error = from - pivotCameraPitch(m.target, m.hpr, m.hpr.pitch); if (Math.abs(error) < 1e-7) break; - m.hpr.pitch = Cesium.Math.clamp( + const pitch = Cesium.Math.clamp( m.hpr.pitch + error, -Cesium.Math.PI_OVER_TWO, - 0, + cap, ); + if (pitch === m.hpr.pitch) break; + m.hpr.pitch = pitch; } + from = Math.min(from, pivotCameraPitch(m.target, m.hpr, m.hpr.pitch)); } const limit = m.hpr && !tiltClearanceBinds(m.hpr.range) From 58adbb11c7f3cfa33cefe85cc17952f4c923fef5 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 21:21:26 -0800 Subject: [PATCH 39/39] fix(traffic): name an account's traffic limit instead of a daily budget A failed flow tile now carries the server's reason. A hosted deployment that limits each account answers USAGE_LIMIT_REACHED or RATE_LIMITED, which the traffic panel labels as the monthly allowance or a rate limit; a 503 with another reason reads as the traffic service being unavailable, not a missing key. The local proxy's no_key and daily budget labels are unchanged. --- src/data/traffic.test.mjs | 22 +++++++++++++++++++++ src/i18n/locales/ar.json | 3 +++ src/i18n/locales/de.json | 3 +++ src/i18n/locales/es.json | 3 +++ src/i18n/locales/fr.json | 3 +++ src/i18n/locales/it.json | 3 +++ src/i18n/locales/ja.json | 3 +++ src/i18n/locales/pt.json | 3 +++ src/i18n/locales/ru.json | 3 +++ src/i18n/locales/uk.json | 3 +++ src/i18n/locales/zh-CN.json | 3 +++ src/i18n/locales/zh-Hant.json | 3 +++ src/layers/traffic/flow.js | 8 ++++++++ src/layers/traffic/flowSource.js | 31 +++++++++++++++++++++++++++++- src/layers/traffic/source.test.mjs | 26 +++++++++++++++++++++++++ 15 files changed, 119 insertions(+), 1 deletion(-) diff --git a/src/data/traffic.test.mjs b/src/data/traffic.test.mjs index 23924448a..0e8ada30a 100644 --- a/src/data/traffic.test.mjs +++ b/src/data/traffic.test.mjs @@ -30,6 +30,28 @@ test('a superseded flow fetch is not an outage', () => { assert.equal(deriveTrafficFlowError(undefined), null); }); +test('hosted account limits and service failures name their own reason', () => { + const failure = (status, code) => + deriveTrafficFlowError( + Object.assign(new Error(`TomTom flow unavailable (HTTP ${status})`), { + status, + code, + }), + ); + assert.equal( + failure(429, 'USAGE_LIMIT_REACHED'), + 'Monthly traffic allowance used', + ); + assert.equal(failure(429, 'RATE_LIMITED'), 'Traffic rate limit reached'); + assert.equal(failure(429, 'budget'), 'TomTom daily budget reached'); + assert.equal(failure(503, 'no_key'), 'TomTom key unavailable'); + assert.equal(failure(503, null), 'TomTom key unavailable'); + assert.equal( + failure(503, 'USAGE_UNAVAILABLE'), + 'Traffic service unavailable', + ); +}); + test('flow failures map onto short, specific reasons', () => { const reason = (message) => deriveTrafficFlowError(new Error(message)); assert.equal(reason('flow tile 12/1/1: HTTP 503'), 'TomTom key unavailable'); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index b491c3c93..8a2b81e20 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -1749,6 +1749,9 @@ "Hybrid": "هجين", "TomTom key unavailable": "مفتاح TomTom غير متاح", "TomTom daily budget reached": "بلغت الحصة اليومية لـ TomTom", + "Monthly traffic allowance used": "تم استهلاك حصة حركة المرور الشهرية", + "Traffic rate limit reached": "تم بلوغ حد طلبات حركة المرور", + "Traffic service unavailable": "خدمة حركة المرور غير متاحة", "TomTom upstream unreachable": "تعذّر الوصول إلى خادم TomTom", "TomTom flow unavailable": "تدفق TomTom غير متاح", "SIMULATED — {reason}": "محاكى — {reason}", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index d01ff292f..837a87471 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1667,6 +1667,9 @@ "Hybrid": "Hybrid", "TomTom key unavailable": "TomTom-Schlüssel nicht verfügbar", "TomTom daily budget reached": "TomTom-Tagesbudget erreicht", + "Monthly traffic allowance used": "Monatliches Verkehrskontingent aufgebraucht", + "Traffic rate limit reached": "Verkehrs-Anfragelimit erreicht", + "Traffic service unavailable": "Verkehrsdienst nicht verfügbar", "TomTom upstream unreachable": "TomTom-Upstream nicht erreichbar", "TomTom flow unavailable": "TomTom-Verkehrsfluss nicht verfügbar", "SIMULATED — {reason}": "SIMULIERT – {reason}", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index aa403f743..57aa09eb7 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2274,6 +2274,9 @@ "SIMULATED — {reason}": "SIMULADO — {reason}", "TomTom key unavailable": "Clave de TomTom no disponible", "TomTom daily budget reached": "Se alcanzó el límite diario de TomTom", + "Monthly traffic allowance used": "Cupo mensual de tráfico agotado", + "Traffic rate limit reached": "Límite de solicitudes de tráfico alcanzado", + "Traffic service unavailable": "Servicio de tráfico no disponible", "TomTom upstream unreachable": "Servidor de TomTom inaccesible", "TomTom flow unavailable": "Flujo de TomTom no disponible", "API key rejected — check AISSTREAM_API_KEY": "Clave de API rechazada — revisa AISSTREAM_API_KEY", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index b3ae72189..bfd2a5975 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1105,6 +1105,9 @@ "Hybrid": "Hybride", "TomTom key unavailable": "Clé TomTom indisponible", "TomTom daily budget reached": "Quota quotidien TomTom atteint", + "Monthly traffic allowance used": "Quota mensuel de trafic épuisé", + "Traffic rate limit reached": "Limite de requêtes de trafic atteinte", + "Traffic service unavailable": "Service de trafic indisponible", "TomTom upstream unreachable": "Serveur TomTom injoignable", "TomTom flow unavailable": "Flux TomTom indisponible", "SIMULATED — {reason}": "SIMULÉ — {reason}", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 4c69e1d4c..74c0d01aa 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1105,6 +1105,9 @@ "Hybrid": "Ibrido", "TomTom key unavailable": "Chiave TomTom non disponibile", "TomTom daily budget reached": "Budget giornaliero TomTom esaurito", + "Monthly traffic allowance used": "Quota mensile del traffico esaurita", + "Traffic rate limit reached": "Limite di richieste del traffico raggiunto", + "Traffic service unavailable": "Servizio traffico non disponibile", "TomTom upstream unreachable": "Servizio TomTom irraggiungibile", "TomTom flow unavailable": "Flusso TomTom non disponibile", "SIMULATED — {reason}": "SIMULATO: {reason}", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 9a053f869..932d851e2 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1073,6 +1073,9 @@ "Hybrid": "ハイブリッド", "TomTom key unavailable": "TomTomキーを利用できません", "TomTom daily budget reached": "TomTomの1日の上限に達しました", + "Monthly traffic allowance used": "今月の交通情報の上限に達しました", + "Traffic rate limit reached": "交通情報のリクエスト上限に達しました", + "Traffic service unavailable": "交通情報サービスを利用できません", "TomTom upstream unreachable": "TomTomのサービスに接続できません", "TomTom flow unavailable": "TomTomの交通流を利用できません", "SIMULATED — {reason}": "シミュレーション:{reason}", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 79c507759..e71f144d5 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1103,6 +1103,9 @@ "Hybrid": "Híbrido", "TomTom key unavailable": "Chave do TomTom indisponível", "TomTom daily budget reached": "Limite diário do TomTom atingido", + "Monthly traffic allowance used": "Cota mensal de trânsito esgotada", + "Traffic rate limit reached": "Limite de solicitações de trânsito atingido", + "Traffic service unavailable": "Serviço de trânsito indisponível", "TomTom upstream unreachable": "Servidor do TomTom inacessível", "TomTom flow unavailable": "Fluxo do TomTom indisponível", "SIMULATED — {reason}": "SIMULADO — {reason}", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 11fddb3e9..6573ad11b 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -2313,6 +2313,9 @@ "SIMULATED — {reason}": "СИМУЛЯЦИЯ — {reason}", "TomTom key unavailable": "Ключ TomTom недоступен", "TomTom daily budget reached": "Исчерпан дневной лимит TomTom", + "Monthly traffic allowance used": "Месячный лимит данных о трафике исчерпан", + "Traffic rate limit reached": "Достигнут лимит запросов данных о трафике", + "Traffic service unavailable": "Сервис данных о трафике недоступен", "TomTom upstream unreachable": "Сервер TomTom не отвечает", "TomTom flow unavailable": "Поток TomTom недоступен", "API key rejected — check AISSTREAM_API_KEY": "Ключ API отклонён — проверьте AISSTREAM_API_KEY", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index c0fab8533..18ea8dc4f 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -1120,6 +1120,9 @@ "Hybrid": "Гібрид", "TomTom key unavailable": "Ключ TomTom недоступний", "TomTom daily budget reached": "Вичерпано денний ліміт TomTom", + "Monthly traffic allowance used": "Місячний ліміт даних про трафік вичерпано", + "Traffic rate limit reached": "Досягнуто ліміту запитів даних про трафік", + "Traffic service unavailable": "Сервіс даних про трафік недоступний", "TomTom upstream unreachable": "Сервер TomTom не відповідає", "TomTom flow unavailable": "Потік TomTom недоступний", "SIMULATED — {reason}": "ІМІТАЦІЯ — {reason}", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 8687af790..8d09b74a3 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1049,6 +1049,9 @@ "Hybrid": "混合", "TomTom key unavailable": "TomTom 密钥不可用", "TomTom daily budget reached": "已达 TomTom 每日配额", + "Monthly traffic allowance used": "本月交通流量额度已用完", + "Traffic rate limit reached": "已达到交通数据请求频率上限", + "Traffic service unavailable": "交通服务不可用", "TomTom upstream unreachable": "TomTom 上游服务无法连接", "TomTom flow unavailable": "TomTom 路况不可用", "SIMULATED — {reason}": "模拟——{reason}", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index a95e71dd5..30801f0f9 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1044,6 +1044,9 @@ "Hybrid": "混合", "TomTom key unavailable": "TomTom 密鑰不可用", "TomTom daily budget reached": "已達 TomTom 每日配額", + "Monthly traffic allowance used": "本月交通流量額度已用完", + "Traffic rate limit reached": "已達交通資料請求頻率上限", + "Traffic service unavailable": "交通服務無法使用", "TomTom upstream unreachable": "TomTom 上游服務無法連接", "TomTom flow unavailable": "TomTom 路況不可用", "SIMULATED — {reason}": "模擬——{reason}", diff --git a/src/layers/traffic/flow.js b/src/layers/traffic/flow.js index 2064c10ce..caf8aede2 100644 --- a/src/layers/traffic/flow.js +++ b/src/layers/traffic/flow.js @@ -38,7 +38,15 @@ export function createFlow({ state: layerState, services, parts, source }) { const status = Number.isFinite(error.status) ? error.status : Number(message.match(/HTTP (\d{3})/)?.[1]); + const code = typeof error.code === 'string' ? error.code : null; // English in state; trafficFeedPresentation translates it for the panel. + // A hosted deployment limits each account (code from the server); the + // local proxy has one daily budget (`budget`) and answers `no_key`. + if (code === 'USAGE_LIMIT_REACHED') + return msg('Monthly traffic allowance used'); + if (code === 'RATE_LIMITED') return msg('Traffic rate limit reached'); + if (status === 503 && code && code !== 'no_key') + return msg('Traffic service unavailable'); if (status === 503) return msg('TomTom key unavailable'); if (status === 429) return msg('TomTom daily budget reached'); if (status === 502 || status === 504) diff --git a/src/layers/traffic/flowSource.js b/src/layers/traffic/flowSource.js index 12e746e43..e5402cf12 100644 --- a/src/layers/traffic/flowSource.js +++ b/src/layers/traffic/flowSource.js @@ -11,6 +11,26 @@ const FLOW_TILE_PATH = (z, x, y) => `/api/tomtom/flow/${z}/${x}/${y}.pbf`; * (STALE-BUDGET once the daily budget is spent, STALE-ERROR when TomTom * fails), and when the proxy fetched it. */ +/** + * The machine-readable reason a failed tile carries: `code` (hosted usage, + * e.g. USAGE_LIMIT_REACHED or RATE_LIMITED) or the local proxy's `error` + * (`no_key`, `budget`). Only short identifiers are kept; prose is ignored. + */ +async function failureCode(response) { + if (!/json/i.test(response.headers?.get?.('content-type') || '')) return null; + try { + const text = await response.text(); + if (text.length > 4096) return null; + const body = JSON.parse(text); + const code = body?.code ?? body?.error; + return typeof code === 'string' && /^[A-Za-z_]{1,40}$/.test(code) + ? code + : null; + } catch { + return null; + } +} + function tileFreshness(response) { const status = response.headers?.get?.('x-tomtom-cache') || ''; const fetchedAt = Number(response.headers?.get?.('x-tomtom-fetched-at')); @@ -60,7 +80,16 @@ export function createFlowTileSource({ }, fetchImpl: async (url, init) => { const response = await fetchImpl(url, init); - if (response.ok && !init.signal.aborted) { + if (!response.ok) { + const code = await failureCode(response); + await response.body?.cancel().catch(() => {}); + throw Object.assign( + // Classified for display by deriveTrafficFlowError. + new Error(`TomTom flow unavailable (HTTP ${response.status})`), + { status: response.status, code }, + ); + } + if (!init.signal.aborted) { const path = String(url); const entry = { mark: tileFreshness(response), diff --git a/src/layers/traffic/source.test.mjs b/src/layers/traffic/source.test.mjs index 779164b7c..e45f5ef0f 100644 --- a/src/layers/traffic/source.test.mjs +++ b/src/layers/traffic/source.test.mjs @@ -1050,3 +1050,29 @@ test('cancelling a flow body releases its freshness before a new request', async assert.equal(getEventListeners(requestSignal, 'abort').length, 0); assert.equal(source.getFlowSessionStats().cacheEntries, 0); }); + +test('a failed flow tile carries the server reason for its label', async () => { + for (const [status, body, code] of [ + [ + 429, + { error: 'Monthly usage limit reached', code: 'USAGE_LIMIT_REACHED' }, + 'USAGE_LIMIT_REACHED', + ], + [ + 429, + { error: 'Request rate limit reached', code: 'RATE_LIMITED' }, + 'RATE_LIMITED', + ], + [503, { error: 'no_key' }, 'no_key'], + [503, { error: 'Usage enforcement is temporarily unavailable' }, null], + ]) { + const source = createFlowTileSource({ + fetchImpl: async () => Response.json(body, { status }), + }); + await assert.rejects(source.fetchFlowForBounds(bounds), (error) => { + assert.equal(error.status, status); + assert.equal(error.code, code); + return true; + }); + } +});