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.
This commit is contained in:
Manjunath committed 2026-11-03 19:35:02 +05:30
1 parent 2ac73519ec
commit 5c74b860b7
8 files changed
+328 -28

No files matched your search

+2 -1
View File
@@ -2,4 +2,5 @@
camera swings about the place under the crosshair, keeps its heading, and can 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, 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 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).
+1 -1
View File
@@ -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 > 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. > `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. - **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. - **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). - 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).
+1 -1
View File
@@ -533,7 +533,7 @@ export const ACTION_DESCRIPTIONS = {
}, },
pitchDeg: { pitchDeg: {
description: 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, $position: 2,
}, },
}, },
+146
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { import {
adjustOrbitRange,
getActiveCameraMotion, getActiveCameraMotion,
initCameraVerbs, initCameraVerbs,
interruptCameraMotion, interruptCameraMotion,
@@ -222,3 +223,148 @@ test('a pivoting tilt yields when tracking takes the camera', (t) => {
h.viewer.trackedEntityChanged.raiseEvent(entity); h.viewer.trackedEntityChanged.raiseEvent(entity);
assert.equal(getActiveCameraMotion(), null); 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`);
});
+87 -20
View File
@@ -52,6 +52,11 @@ const PITCH_MAX = Cesium.Math.toRadians(-5);
const TILT_DEFAULT_PITCH_DEG = -45; const TILT_DEFAULT_PITCH_DEG = -45;
/** °/s for a tilt to a requested angle; short ones keep the once duration. */ /** °/s for a tilt to a requested angle; short ones keep the once duration. */
const TILT_TO_DEG_S = 30; 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 ────────────────────────────────────────── /* ── Route dolly: cinematic tuning ──────────────────────────────────────────
* Every knob an owner may want to retune lives in this block. The shaping is * Every knob an owner may want to retune lives in this block. The shaping is
@@ -970,6 +975,17 @@ let _trackingRemover = null;
let _inputRemovers = []; let _inputRemovers = [];
/** Monotonic id for the motion currently running; 0 means "no motion". */ /** Monotonic id for the motion currently running; 0 means "no motion". */
let _motionSeq = 0; 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 * 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 * 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 * 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 * 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) { function tickTilt(cam, m, scale, dt) {
let step; let step;
@@ -1261,24 +1278,58 @@ function tickTilt(cam, m, scale, dt) {
dt * dt *
(m.direction === 'up' ? 1 : -1); (m.direction === 'up' ? 1 : -1);
} }
const from = m.hpr ? m.hpr.pitch : cam.pitch; if (m.hpr) {
const from = m.hpr.pitch;
const next = from + step; const next = from + step;
const limited = const limited =
step > 0 step > 0
? Math.min(next, Math.max(from, PITCH_MAX)) ? Math.min(next, Math.max(from, tiltPivotPitchMax(m.hpr.range)))
: Math.max(next, Math.min(from, PITCH_MIN)); : Math.max(next, Math.min(from, PITCH_MIN));
if (m.hpr) {
m.hpr.pitch = limited; m.hpr.pitch = limited;
cam.lookAt(m.target, m.hpr); cam.lookAt(m.target, m.hpr);
// A once motion that just completed released the lookAt frame before // A once motion that just completed released the lookAt frame before
// this last step re-took it. // this last step re-took it.
if (_active !== m) clearLookAt(); if (_active !== m) clearLookAt();
} else { if (limited === next) return;
cam.lookUp(limited - from); 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'); 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. */ /** Ground pivot for a tilt: heading, pitch and range in the target's own frame. */
function tiltPivot(cam, target) { function tiltPivot(cam, target) {
const local = Cesium.Matrix4.multiplyByPoint( const local = Cesium.Matrix4.multiplyByPoint(
@@ -1482,7 +1533,10 @@ export function initCameraVerbs(
const canvas = viewer.scene.canvas; const canvas = viewer.scene.canvas;
// ANY manual camera input reclaims control — the cancelFlight reflex. // ANY manual camera input reclaims control — the cancelFlight reflex.
for (const evt of ['pointerdown', 'touchstart', 'wheel']) { 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 }); canvas.addEventListener(evt, h, { passive: true });
_inputRemovers.push(() => canvas.removeEventListener(evt, h)); _inputRemovers.push(() => canvas.removeEventListener(evt, h));
} }
@@ -1580,9 +1634,10 @@ export function moveCamera(args = {}, runNavigation = null) {
}; };
} }
if (motion === 'tilt' && tiltToPitch !== null) { if (motion === 'tilt' && tiltToPitch !== null) {
if ( // Judge from the pitch the motion will start at: the pivot's, which at
Math.abs(tiltToPitch - _viewer.camera.pitch) < Cesium.Math.toRadians(0.5) // 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 { return {
ok: true, ok: true,
action: 'move_camera', action: 'move_camera',
@@ -1591,6 +1646,18 @@ export function moveCamera(args = {}, runNavigation = null) {
unchanged: true, 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') { } else if (motion === 'tilt') {
// Honest limits: at the clamp a tilt is a no-op — say so instead of // 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). // reporting success and letting the model gaslight itself (field test).
@@ -1706,16 +1773,11 @@ export function moveCamera(args = {}, runNavigation = null) {
mode, mode,
}; };
if (motion === 'tilt') { if (motion === 'tilt') {
const cam = _viewer.camera; const { target, pivot, from } = tiltStartFrame(_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) { if (pivot) {
state.target = target; state.target = target;
state.hpr = pivot; state.hpr = pivot;
} }
const from = pivot ? pivot.pitch : cam.pitch;
if (tiltToPitch !== null) { if (tiltToPitch !== null) {
state.mode = 'once'; state.mode = 'once';
state.direction = tiltToPitch > from ? 'up' : 'down'; 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 * Zoom while orbiting: scale the orbit RADIUS instead of letting the zoom
* fight the per-frame lookAt (field test: "zoom out did literally nothing"). * 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. * @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) { 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( _active.hpr.range = Math.max(
80, Math.min(80, range),
Math.min(5_000_000, _active.hpr.range * factor), Math.min(Math.max(5_000_000, range), range * factor),
); );
return true; return true;
} }
+3 -2
View File
@@ -49,8 +49,9 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
// Snow leaves the set_visual_style enum. // Snow leaves the set_visual_style enum.
// The Operator HUD layout leaves set_hud's layout enum. // The Operator HUD layout leaves set_hud's layout enum.
// The layer alias hint leaves out aliases the layer id already spells. // The layer alias hint leaves out aliases the layer id already spells.
// move_camera tilt takes an optional pitchDeg angle. // move_camera tilt takes an optional pitchDeg angle, described as
'35261624ef8596608b80e2876f83760723ecf6592acc43fd77691cdb0a972608', // absolute rather than a step.
'7258f48a19dead97d1a52cd897355ac84cb1f9077f1e8573fd64e3937660f5d0',
); );
}); });
+19 -3
View File
@@ -47,6 +47,7 @@ import {
flyRoute, flyRoute,
interruptCameraMotion, interruptCameraMotion,
adjustOrbitRange, adjustOrbitRange,
manualCameraInputCount,
} from '../cameraVerbs.js'; } from '../cameraVerbs.js';
import * as defaultFloorServices from '../data/groundFloor.js'; import * as defaultFloorServices from '../data/groundFloor.js';
import { isPickedWorldPosition } from '../data/scenePick.js'; import { isPickedWorldPosition } from '../data/scenePick.js';
@@ -1107,7 +1108,10 @@ export function createGevActionRunner({
runOptions, 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; voiceFlight = flight;
const flown = flyToRequestedLocation(viewer, args, { const flown = flyToRequestedLocation(viewer, args, {
onFlightEnd: flight.settle, onFlightEnd: flight.settle,
@@ -1194,7 +1198,8 @@ export function createGevActionRunner({
voiceFlight && voiceFlight &&
!voiceFlight.done !voiceFlight.done
) { ) {
const status = await waitForVoiceFlight(voiceFlight, runOptions.signal); const flight = voiceFlight;
const status = await waitForVoiceFlight(flight, runOptions.signal);
if (!current()) if (!current())
return { ok: false, action: 'move_camera', cancelled: true }; return { ok: false, action: 'move_camera', cancelled: true };
if (status !== 'arrived') if (status !== 'arrived')
@@ -1204,6 +1209,15 @@ export function createGevActionRunner({
motion, motion,
error: `Not run: the flight this ${motion} was waiting for did not arrive.`, 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) => return moveCamera(args, (navigate, releaseOptions) =>
runManagedVoiceNavigation( runManagedVoiceNavigation(
@@ -2722,10 +2736,12 @@ async function frameOverhead(viewer, dataManager, styleManager, args = {}) {
} }
/** A voice flight's end: 'arrived', 'cancelled' or 'failed', settled once. */ /** A voice flight's end: 'arrived', 'cancelled' or 'failed', settled once. */
function trackVoiceFlight() { function trackVoiceFlight(inputCount = manualCameraInputCount()) {
let resolve; let resolve;
const flight = { const flight = {
done: false, done: false,
// Manual input after this means the user took the camera mid-flight.
inputCount,
ended: new Promise((settle) => { ended: new Promise((settle) => {
resolve = settle; resolve = settle;
}), }),
+69
View File
@@ -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 () => { test('stop does not wait for a voice flight', async () => {
const { viewer, styleManager } = tiltAfterFlightHarness(); const { viewer, styleManager } = tiltAfterFlightHarness();
const runner = createGevActionRunner({ const runner = createGevActionRunner({