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