mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Keep the place centred when a far tilt reaches the horizon
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.
This commit is contained in:
1 parent
395eba4548
commit
c4910d24d0
3 files changed
+164
-21
No files matched your search
@@ -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
|
> 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, 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 retriLine truncated
|
- **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; GeminiLine truncated
|
||||||
- **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).
|
||||||
|
|||||||
+58
-3
@@ -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);
|
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) => {
|
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
|
// The pivot pitch is shallower than the camera's by up to 4° at this
|
||||||
// range; the camera reached only -5.9° to -12.9° when the swing stopped
|
// range; the camera reached only -5.9° and -8.6° when the swing stopped
|
||||||
// at a pivot pitch of -5°.
|
// at a pivot pitch of -5°.
|
||||||
const h = harness(t, { height });
|
const h = harness(t, { height });
|
||||||
const result = moveCamera({ motion: 'tilt', pitchDeg: 5 });
|
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/);
|
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) => {
|
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
|
// 20 km up at -7°: the pivot 186 km out sits at -5.3°, but the camera can
|
||||||
// still tilt up 2°.
|
// still tilt up 2°.
|
||||||
|
|||||||
+105
-17
@@ -62,6 +62,13 @@ const TILT_MIN_CLEARANCE_M = 150;
|
|||||||
* crosshair point, so the tilt happens in place instead.
|
* crosshair point, so the tilt happens in place instead.
|
||||||
*/
|
*/
|
||||||
const TILT_PIVOT_MAX_RANGE_M = 1_000_000;
|
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 ──────────────────────────────────────────
|
/* ── 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
|
||||||
@@ -1273,7 +1280,10 @@ function onTick() {
|
|||||||
* is clamped to [-89°, -5°] only in the direction of travel: a top-down view
|
* 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.
|
* 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
|
* 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) {
|
function tickTilt(cam, m, scale, dt) {
|
||||||
const from = cam.pitch;
|
const from = cam.pitch;
|
||||||
@@ -1288,11 +1298,13 @@ function tickTilt(cam, m, scale, dt) {
|
|||||||
const limited = up
|
const limited = up
|
||||||
? Math.min(want, Math.max(from, PITCH_MAX))
|
? Math.min(want, Math.max(from, PITCH_MAX))
|
||||||
: Math.max(want, Math.min(from, PITCH_MIN));
|
: Math.max(want, Math.min(from, PITCH_MIN));
|
||||||
|
let atLimit = false;
|
||||||
if (m.hpr) {
|
if (m.hpr) {
|
||||||
// Apply a range a zoom set since the last frame, even when the pitch
|
// 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).
|
// then has nowhere to go (a zoom in to the clearance ends the pivot).
|
||||||
cam.lookAt(m.target, m.hpr);
|
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
|
const cap = up
|
||||||
? Math.max(m.hpr.pitch, tiltPivotPitchMax(m.hpr.range))
|
? Math.max(m.hpr.pitch, tiltPivotPitchMax(m.hpr.range))
|
||||||
: Infinity;
|
: Infinity;
|
||||||
@@ -1309,21 +1321,77 @@ function tickTilt(cam, m, scale, dt) {
|
|||||||
// this last step re-took it.
|
// this last step re-took it.
|
||||||
if (_active !== m) clearLookAt();
|
if (_active !== m) clearLookAt();
|
||||||
if (up && m.hpr.pitch >= cap && cam.pitch < limited - 1e-6) {
|
if (up && m.hpr.pitch >= cap && cam.pitch < limited - 1e-6) {
|
||||||
|
if (tiltClearanceBinds(m.hpr.range)) {
|
||||||
// Down to its clearance: let go of the pivot and tilt the rest in
|
// Down to its clearance: let go of the pivot and tilt the rest in
|
||||||
// place, which keeps the height.
|
// place, which keeps the height.
|
||||||
if (_active === m) clearLookAt();
|
if (_active === m) clearLookAt();
|
||||||
m.hpr = null;
|
m.hpr = null;
|
||||||
m.target = 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.hpr) cam.lookUp(limited - cam.pitch);
|
||||||
if (m.mode !== 'once' && limited !== want)
|
if (m.mode !== 'once' && (limited !== want || atLimit))
|
||||||
interruptCameraMotion('tilt-clamp');
|
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. */
|
/** Shallowest pivot pitch that keeps the clearance at this range. */
|
||||||
function tiltPivotPitchMax(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.`,
|
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') {
|
} 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).
|
||||||
const pitch = _viewer.camera.pitch;
|
const pitch = _viewer.camera.pitch;
|
||||||
if (direction === 'up' && pitch >= PITCH_MAX - Cesium.Math.toRadians(0.5)) {
|
if (
|
||||||
return {
|
direction === 'up' &&
|
||||||
ok: false,
|
pitch >=
|
||||||
action: 'move_camera',
|
tiltUpLimit(tiltStartFrame(_viewer.camera)) - Cesium.Math.toRadians(0.5)
|
||||||
error:
|
) {
|
||||||
'Already at the upper tilt limit (near the horizon) — tilt down, or zoom/pan instead.',
|
return tiltUpLimitRefusal();
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
direction === 'down' &&
|
direction === 'down' &&
|
||||||
@@ -1816,6 +1889,7 @@ export function moveCamera(args = {}, runNavigation = null) {
|
|||||||
error: 'Globe rotation requires a clear ellipsoid bounding sphere.',
|
error: 'Globe rotation requires a clear ellipsoid bounding sphere.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
let horizonLimited = null;
|
||||||
const start = () => {
|
const start = () => {
|
||||||
interruptCameraMotion('replaced');
|
interruptCameraMotion('replaced');
|
||||||
const state = {
|
const state = {
|
||||||
@@ -1825,27 +1899,36 @@ export function moveCamera(args = {}, runNavigation = null) {
|
|||||||
mode,
|
mode,
|
||||||
};
|
};
|
||||||
if (motion === 'tilt') {
|
if (motion === 'tilt') {
|
||||||
const { target, pivot, from } = tiltStartFrame(_viewer.camera);
|
const frame = tiltStartFrame(_viewer.camera);
|
||||||
|
const { target, pivot, from } = frame;
|
||||||
if (pivot) {
|
if (pivot) {
|
||||||
state.target = target;
|
state.target = target;
|
||||||
state.hpr = pivot;
|
state.hpr = pivot;
|
||||||
}
|
}
|
||||||
state.startPitch = from;
|
state.startPitch = from;
|
||||||
|
const upLimit = tiltUpLimit(frame);
|
||||||
if (tiltToPitch !== null) {
|
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.mode = 'once';
|
||||||
state.direction = tiltToPitch > from ? 'up' : 'down';
|
state.direction = goal > from ? 'up' : 'down';
|
||||||
state.goalPitch = tiltToPitch;
|
state.goalPitch = goal;
|
||||||
state.durationS = Math.max(
|
state.durationS = Math.max(
|
||||||
ONCE.durationS,
|
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 {
|
} else {
|
||||||
// A nudge stops at the limit it travels toward.
|
// A nudge stops at the limit it travels toward.
|
||||||
const step =
|
const step =
|
||||||
Cesium.Math.toRadians(ONCE.tiltDeg) * (direction === 'up' ? 1 : -1);
|
Cesium.Math.toRadians(ONCE.tiltDeg) * (direction === 'up' ? 1 : -1);
|
||||||
state.goalPitch =
|
state.goalPitch =
|
||||||
step > 0
|
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));
|
: Math.max(from + step, Math.min(from, PITCH_MIN));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1937,7 +2020,12 @@ export function moveCamera(args = {}, runNavigation = null) {
|
|||||||
speed,
|
speed,
|
||||||
mode: state.mode,
|
mode: state.mode,
|
||||||
...(tiltToPitch !== null && {
|
...(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.`,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in new issue
Block a user