mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 14:22:21 +00:00
Fix voice camera tilt from a top-down view
A voice fly-to frames a city top-down, at pitch -90. The tilt clamp rejected any step that left the pitch below -89, so the first frame of "tilt up" stopped the motion and "tilt down" was refused: from the view voice lands on, no tilt ever moved the camera. A bare "tilt the camera" also failed because tilt required a direction. - Clamp tilt only in the direction of travel, so -90 can tilt up. - Swing the camera about the ground under the crosshair, so the place stays centred and the heading is kept. A tracked view still tilts in place, and a pivot tilt yields when tracking takes the camera. - Add an optional move_camera pitchDeg for "tilt 45 degrees"; a tilt with neither angle nor direction settles at -45 (oblique view). - Re-derive the voice tool schema pins for the new argument and wording, and keep the Realtime prompt under its 52,000-character budget.
This commit is contained in:
1 parent
0e27d69fcc
commit
69152868e4
9 files changed
+403
-27
No files matched your search
@@ -0,0 +1,224 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
getActiveCameraMotion,
|
||||
initCameraVerbs,
|
||||
interruptCameraMotion,
|
||||
moveCamera,
|
||||
} from './cameraVerbs.js';
|
||||
|
||||
const radians = Cesium.Math.toRadians;
|
||||
const degrees = Cesium.Math.toDegrees;
|
||||
|
||||
/** Real Cesium camera over London with no renderer: the voice fly-to frame. */
|
||||
function harness(
|
||||
t,
|
||||
{ height = 4000, heading = 0, pitch = -90, pivot = true } = {},
|
||||
) {
|
||||
const realPerformance = globalThis.performance;
|
||||
let nowMs = 0;
|
||||
globalThis.performance = { now: () => nowMs };
|
||||
const canvas = Object.assign(new EventTarget(), {
|
||||
clientWidth: 1280,
|
||||
clientHeight: 800,
|
||||
width: 1280,
|
||||
height: 800,
|
||||
});
|
||||
const ellipsoid = Cesium.Ellipsoid.WGS84;
|
||||
const scene = {
|
||||
canvas,
|
||||
drawingBufferWidth: 1280,
|
||||
drawingBufferHeight: 800,
|
||||
frameState: { mode: Cesium.SceneMode.SCENE3D },
|
||||
mapProjection: new Cesium.GeographicProjection(ellipsoid),
|
||||
globe: { ellipsoid },
|
||||
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
|
||||
mode: Cesium.SceneMode.SCENE3D,
|
||||
pixelRatio: 1,
|
||||
tweens: [],
|
||||
requestRender() {},
|
||||
};
|
||||
const camera = new Cesium.Camera(scene);
|
||||
scene.camera = camera;
|
||||
camera.setView({
|
||||
destination: Cesium.Cartesian3.fromDegrees(-0.1276, 51.5072, height),
|
||||
orientation: { heading: radians(heading), pitch: radians(pitch), roll: 0 },
|
||||
});
|
||||
const viewer = {
|
||||
camera,
|
||||
scene,
|
||||
trackedEntity: undefined,
|
||||
trackedEntityChanged: new Cesium.Event(),
|
||||
clock: { onTick: new Cesium.Event() },
|
||||
};
|
||||
// The app's view target: the ground point under the screen centre.
|
||||
const viewTarget = () => {
|
||||
const ray = new Cesium.Ray(camera.positionWC, camera.directionWC);
|
||||
const hit = Cesium.IntersectionTests.rayEllipsoid(ray, ellipsoid);
|
||||
return hit ? Cesium.Ray.getPoint(ray, hit.start) : undefined;
|
||||
};
|
||||
const target = viewTarget();
|
||||
initCameraVerbs(viewer, pivot ? viewTarget : () => undefined);
|
||||
t.after(() => {
|
||||
interruptCameraMotion('test-cleanup');
|
||||
globalThis.performance = realPerformance;
|
||||
});
|
||||
const tick = (seconds = 1 / 30) => {
|
||||
nowMs += seconds * 1000;
|
||||
viewer.clock.onTick.raiseEvent(viewer.clock);
|
||||
};
|
||||
const finish = () => {
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick();
|
||||
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
|
||||
};
|
||||
return { viewer, camera, target, tick, finish, viewTarget };
|
||||
}
|
||||
|
||||
const pitchDeg = (camera) => degrees(camera.pitch);
|
||||
const headingDeg = (camera) =>
|
||||
degrees(Cesium.Math.negativePiToPi(camera.heading));
|
||||
|
||||
/** Metres between the original centre and the ground now under the crosshair. */
|
||||
function centreDrift(h) {
|
||||
return Cesium.Cartesian3.distance(h.viewTarget(), h.target);
|
||||
}
|
||||
|
||||
test('tilt up leaves a top-down view and keeps the place centred', (t) => {
|
||||
const h = harness(t);
|
||||
const result = moveCamera({ motion: 'tilt', direction: 'up' });
|
||||
assert.equal(result.ok, true);
|
||||
h.finish();
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - -75) < 0.5,
|
||||
`pitch ${pitchDeg(h.camera)}`,
|
||||
);
|
||||
assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`);
|
||||
assert.ok(Cesium.Matrix4.equals(h.camera.transform, Cesium.Matrix4.IDENTITY));
|
||||
});
|
||||
|
||||
test('continuous tilt up from top-down keeps moving', (t) => {
|
||||
const h = harness(t);
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }).ok,
|
||||
true,
|
||||
);
|
||||
for (let i = 0; i < 30; i += 1) h.tick();
|
||||
assert.equal(getActiveCameraMotion()?.kind, 'tilt');
|
||||
assert.ok(pitchDeg(h.camera) > -82, `pitch ${pitchDeg(h.camera)}`);
|
||||
});
|
||||
|
||||
test('a requested angle tilts to that pitch', (t) => {
|
||||
const h = harness(t);
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: -45 });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.direction, 'up');
|
||||
assert.equal(result.pitchDeg, -45);
|
||||
h.finish();
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - -45) < 0.5,
|
||||
`pitch ${pitchDeg(h.camera)}`,
|
||||
);
|
||||
assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`);
|
||||
});
|
||||
|
||||
test('a bare tilt settles on the default oblique angle', (t) => {
|
||||
const h = harness(t);
|
||||
const result = moveCamera({ motion: 'tilt' });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.pitchDeg, -45);
|
||||
h.finish();
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -45) < 0.5);
|
||||
});
|
||||
|
||||
test('a requested angle can tilt back down toward top-down', (t) => {
|
||||
const h = harness(t, { pitch: -30 });
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: -80 });
|
||||
assert.equal(result.direction, 'down');
|
||||
h.finish();
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -80) < 0.5);
|
||||
});
|
||||
|
||||
test('requested angles clamp to the tilt limits', (t) => {
|
||||
const h = harness(t);
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -2 }).pitchDeg, -5);
|
||||
h.finish();
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
|
||||
});
|
||||
|
||||
test('a positive angle is read as the same angle below the horizon', (t) => {
|
||||
const h = harness(t);
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: 45 }).pitchDeg, -45);
|
||||
h.finish();
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -45) < 0.5);
|
||||
});
|
||||
|
||||
for (const pitch of ['', null, false, ' ', 'steep'])
|
||||
test(`a blank or non-numeric angle ${JSON.stringify(pitch)} is ignored`, (t) => {
|
||||
harness(t);
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: pitch });
|
||||
// No angle and no direction: the default oblique view, never the horizon.
|
||||
assert.equal(result.pitchDeg, -45);
|
||||
});
|
||||
|
||||
test('a requested angle the camera already holds is a no-op', (t) => {
|
||||
harness(t, { pitch: -45 });
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: -45 });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.unchanged, true);
|
||||
assert.equal(getActiveCameraMotion(), null);
|
||||
});
|
||||
|
||||
test('tilt down from top-down still refuses honestly', (t) => {
|
||||
harness(t);
|
||||
const result = moveCamera({ motion: 'tilt', direction: 'down' });
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /straight down/);
|
||||
});
|
||||
|
||||
test('a tilt up nudge stops at the horizon limit', (t) => {
|
||||
const h = harness(t, { pitch: -12 });
|
||||
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
|
||||
h.finish();
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
|
||||
});
|
||||
|
||||
test('without a ground pivot a tilt still leaves top-down in place', (t) => {
|
||||
const h = harness(t, { pivot: false });
|
||||
const before = Cesium.Cartesian3.clone(h.camera.positionWC);
|
||||
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
|
||||
h.finish();
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -75) < 0.5);
|
||||
assert.ok(Cesium.Cartesian3.distance(h.camera.positionWC, before) < 1e-6);
|
||||
});
|
||||
|
||||
for (const heading of [0, 30, -120])
|
||||
test(`tilt from top-down keeps the ${heading}° heading`, (t) => {
|
||||
const h = harness(t, { heading, height: 67000 });
|
||||
// Top-down float noise must not pick a new heading for the pivot.
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -45 }).ok, true);
|
||||
h.finish();
|
||||
assert.ok(
|
||||
Math.abs(headingDeg(h.camera) - heading) < 1,
|
||||
`heading ${headingDeg(h.camera)}`,
|
||||
);
|
||||
assert.ok(centreDrift(h) < 50, `centre drifted ${centreDrift(h)} m`);
|
||||
});
|
||||
|
||||
test('an oblique tilt keeps its heading toward the pivot', (t) => {
|
||||
const h = harness(t, { heading: 75, pitch: -60 });
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -30 }).ok, true);
|
||||
h.finish();
|
||||
assert.ok(Math.abs(headingDeg(h.camera) - 75) < 0.5);
|
||||
assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`);
|
||||
});
|
||||
|
||||
test('a pivoting tilt yields when tracking takes the camera', (t) => {
|
||||
const h = harness(t);
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -45 }).ok, true);
|
||||
h.tick();
|
||||
const entity = { id: 'tracked' };
|
||||
h.viewer.trackedEntity = entity;
|
||||
h.viewer.trackedEntityChanged.raiseEvent(entity);
|
||||
assert.equal(getActiveCameraMotion(), null);
|
||||
});
|
||||
+140
-23
@@ -48,6 +48,10 @@ const ONCE = {
|
||||
};
|
||||
const PITCH_MIN = Cesium.Math.toRadians(-89);
|
||||
const PITCH_MAX = Cesium.Math.toRadians(-5);
|
||||
/** A bare "tilt the camera" / "oblique view" settles here. */
|
||||
const TILT_DEFAULT_PITCH_DEG = -45;
|
||||
/** °/s for a tilt to a requested angle; short ones keep the once duration. */
|
||||
const TILT_TO_DEG_S = 30;
|
||||
|
||||
/* ── Route dolly: cinematic tuning ──────────────────────────────────────────
|
||||
* Every knob an owner may want to retune lives in this block. The shaping is
|
||||
@@ -1052,7 +1056,9 @@ export function interruptCameraMotion(reason = 'interrupt') {
|
||||
if (
|
||||
wasActive &&
|
||||
!tracking &&
|
||||
(_active.kind === 'orbit' || _active.kind === 'route')
|
||||
(_active.kind === 'orbit' ||
|
||||
_active.kind === 'route' ||
|
||||
(_active.kind === 'tilt' && _active.hpr))
|
||||
)
|
||||
clearLookAt();
|
||||
// Instant, not eased: the user grabbed the camera, or the flight is over.
|
||||
@@ -1104,7 +1110,7 @@ function onTick() {
|
||||
let scale = 1;
|
||||
if (m.mode === 'once') {
|
||||
m.elapsed = (m.elapsed || 0) + dt;
|
||||
const t = Math.min(1, m.elapsed / ONCE.durationS);
|
||||
const t = Math.min(1, m.elapsed / (m.durationS || ONCE.durationS));
|
||||
scale = 1 - (1 - t) * (1 - t); // ease-out progress share
|
||||
if (t >= 1) {
|
||||
interruptCameraMotion('once-complete');
|
||||
@@ -1173,18 +1179,13 @@ function onTick() {
|
||||
else if (m.direction === 'right') cam.moveRight(step);
|
||||
else if (m.direction === 'up') cam.moveUp(step);
|
||||
else cam.moveDown(step);
|
||||
} else if (
|
||||
m.kind === 'tilt' ||
|
||||
m.kind === 'rotate' ||
|
||||
m.kind === 'rotate_globe'
|
||||
) {
|
||||
} else if (m.kind === 'tilt') {
|
||||
tickTilt(cam, m, scale, dt);
|
||||
} else if (m.kind === 'rotate' || m.kind === 'rotate_globe') {
|
||||
const degS = TILT_DEG_S[m.speed];
|
||||
const stepRad =
|
||||
m.mode === 'once'
|
||||
? Cesium.Math.toRadians(
|
||||
m.kind === 'tilt' ? ONCE.tiltDeg : ONCE.rotateDeg,
|
||||
) *
|
||||
(scale - (m.lastScale || 0))
|
||||
? Cesium.Math.toRadians(ONCE.rotateDeg) * (scale - (m.lastScale || 0))
|
||||
: Cesium.Math.toRadians(degS) * dt;
|
||||
m.lastScale = scale;
|
||||
if (m.kind === 'rotate_globe') {
|
||||
@@ -1218,16 +1219,6 @@ function onTick() {
|
||||
return;
|
||||
}
|
||||
cam.rotate(m.rotationAxis, angle);
|
||||
} else if (m.kind === 'tilt') {
|
||||
const up = m.direction === 'up';
|
||||
// Clamp pitch to [-89°, -5°]: never through the ground, never at the sky.
|
||||
const next = cam.pitch + (up ? stepRad : -stepRad);
|
||||
if (next > PITCH_MAX || next < PITCH_MIN) {
|
||||
interruptCameraMotion('tilt-clamp');
|
||||
return;
|
||||
}
|
||||
if (up) cam.lookUp(stepRad);
|
||||
else cam.lookDown(stepRad);
|
||||
} else if (m.direction === 'left') cam.lookLeft(stepRad);
|
||||
else cam.lookRight(stepRad);
|
||||
} else if (m.kind === 'route') {
|
||||
@@ -1252,6 +1243,67 @@ function onTick() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One frame of tilt. With a ground pivot the camera swings about it (lookAt),
|
||||
* so the place under the crosshair stays centred; without one it looks up or
|
||||
* down in place. Pitch is clamped to [-89°, -5°] only in the direction of
|
||||
* travel: a top-down view sits at -90°, below the limit, and must still be
|
||||
* able to tilt up out of it.
|
||||
*/
|
||||
function tickTilt(cam, m, scale, dt) {
|
||||
let step;
|
||||
if (m.mode === 'once') {
|
||||
step = m.tiltRad * (scale - (m.lastScale || 0));
|
||||
m.lastScale = scale;
|
||||
} else {
|
||||
step =
|
||||
Cesium.Math.toRadians(TILT_DEG_S[m.speed]) *
|
||||
dt *
|
||||
(m.direction === 'up' ? 1 : -1);
|
||||
}
|
||||
const from = m.hpr ? m.hpr.pitch : cam.pitch;
|
||||
const next = from + step;
|
||||
const limited =
|
||||
step > 0
|
||||
? Math.min(next, Math.max(from, PITCH_MAX))
|
||||
: Math.max(next, Math.min(from, PITCH_MIN));
|
||||
if (m.hpr) {
|
||||
m.hpr.pitch = limited;
|
||||
cam.lookAt(m.target, m.hpr);
|
||||
// A once motion that just completed released the lookAt frame before
|
||||
// this last step re-took it.
|
||||
if (_active !== m) clearLookAt();
|
||||
} else {
|
||||
cam.lookUp(limited - from);
|
||||
}
|
||||
if (limited !== next) interruptCameraMotion('tilt-clamp');
|
||||
}
|
||||
|
||||
/** Ground pivot for a tilt: heading, pitch and range in the target's own frame. */
|
||||
function tiltPivot(cam, target) {
|
||||
const local = Cesium.Matrix4.multiplyByPoint(
|
||||
Cesium.Matrix4.inverseTransformation(
|
||||
Cesium.Transforms.eastNorthUpToFixedFrame(target),
|
||||
new Cesium.Matrix4(),
|
||||
),
|
||||
cam.positionWC,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const range = Cesium.Cartesian3.magnitude(local);
|
||||
if (!(range > 1) || local.z <= 0) return null;
|
||||
// Near straight overhead the offset's heading is float noise (it flipped a
|
||||
// north-up London view to south); keep the camera's own.
|
||||
const heading =
|
||||
Math.hypot(local.x, local.y) > range * 0.01
|
||||
? Math.atan2(-local.x, -local.y)
|
||||
: cam.heading;
|
||||
return new Cesium.HeadingPitchRange(
|
||||
heading,
|
||||
-Math.asin(Math.min(1, local.z / range)),
|
||||
range,
|
||||
);
|
||||
}
|
||||
|
||||
/** Rigid origin-centred rotation stays outside the actual ellipsoid's bounding sphere. */
|
||||
function globeRotationHasClearance(position) {
|
||||
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
|
||||
@@ -1421,6 +1473,9 @@ export function initCameraVerbs(
|
||||
_trackingRemover = viewer.trackedEntityChanged?.addEventListener((entity) => {
|
||||
if (entity && _active?.kind === 'rotate_globe')
|
||||
interruptCameraMotion('globe-camera-owner-changed');
|
||||
// A pivoting tilt's lookAt would fight the follow camera every frame.
|
||||
else if (entity && _active?.kind === 'tilt' && _active.hpr)
|
||||
interruptCameraMotion('tilt-camera-owner-changed');
|
||||
});
|
||||
for (const rm of _inputRemovers) rm();
|
||||
_inputRemovers = [];
|
||||
@@ -1472,9 +1527,32 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, rotate_globe, or stop.`,
|
||||
};
|
||||
}
|
||||
// Tilt to an angle: the one asked for, or a default oblique view when a
|
||||
// bare "tilt the camera" names neither angle nor direction.
|
||||
let tiltToPitch = null;
|
||||
if (motion === 'tilt') {
|
||||
// Only a real number or numeric text: Number('') and Number(null) are 0,
|
||||
// which would swing the camera to the horizon.
|
||||
const raw = args.pitchDeg;
|
||||
const requested =
|
||||
typeof raw === 'number' || (typeof raw === 'string' && raw.trim() !== '')
|
||||
? Number(raw)
|
||||
: NaN;
|
||||
// The camera never looks above the horizon, so a positive angle can only
|
||||
// mean the same angle below it ("tilt 45 degrees" sent as 45).
|
||||
if (Number.isFinite(requested))
|
||||
tiltToPitch = Cesium.Math.clamp(
|
||||
Cesium.Math.toRadians(-Math.abs(requested)),
|
||||
PITCH_MIN,
|
||||
PITCH_MAX,
|
||||
);
|
||||
else if (direction === null)
|
||||
tiltToPitch = Cesium.Math.toRadians(TILT_DEFAULT_PITCH_DEG);
|
||||
}
|
||||
if (
|
||||
motion !== 'orbit' &&
|
||||
motion !== 'rotate_globe' &&
|
||||
tiltToPitch === null &&
|
||||
!['left', 'right', 'up', 'down'].includes(direction)
|
||||
) {
|
||||
return {
|
||||
@@ -1501,7 +1579,19 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
error: 'rotate_globe needs left or right.',
|
||||
};
|
||||
}
|
||||
if (motion === 'tilt') {
|
||||
if (motion === 'tilt' && tiltToPitch !== null) {
|
||||
if (
|
||||
Math.abs(tiltToPitch - _viewer.camera.pitch) < Cesium.Math.toRadians(0.5)
|
||||
) {
|
||||
return {
|
||||
ok: true,
|
||||
action: 'move_camera',
|
||||
motion,
|
||||
pitchDeg: Math.round(Cesium.Math.toDegrees(tiltToPitch)),
|
||||
unchanged: true,
|
||||
};
|
||||
}
|
||||
} else if (motion === 'tilt') {
|
||||
// Honest limits: at the clamp a tilt is a no-op — say so instead of
|
||||
// reporting success and letting the model gaslight itself (field test).
|
||||
const pitch = _viewer.camera.pitch;
|
||||
@@ -1615,6 +1705,30 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
speed,
|
||||
mode,
|
||||
};
|
||||
if (motion === 'tilt') {
|
||||
const cam = _viewer.camera;
|
||||
// Swing about the ground under the crosshair so the place stays
|
||||
// centred. The follow camera owns a tracked view's frame.
|
||||
const target = _viewer.trackedEntity ? null : _getTarget?.(_viewer);
|
||||
const pivot = target ? tiltPivot(cam, target) : null;
|
||||
if (pivot) {
|
||||
state.target = target;
|
||||
state.hpr = pivot;
|
||||
}
|
||||
const from = pivot ? pivot.pitch : cam.pitch;
|
||||
if (tiltToPitch !== null) {
|
||||
state.mode = 'once';
|
||||
state.direction = tiltToPitch > from ? 'up' : 'down';
|
||||
state.tiltRad = tiltToPitch - from;
|
||||
state.durationS = Math.max(
|
||||
ONCE.durationS,
|
||||
Math.abs(Cesium.Math.toDegrees(state.tiltRad)) / TILT_TO_DEG_S,
|
||||
);
|
||||
} else {
|
||||
state.tiltRad =
|
||||
Cesium.Math.toRadians(ONCE.tiltDeg) * (direction === 'up' ? 1 : -1);
|
||||
}
|
||||
}
|
||||
if (motion === 'rotate_globe') {
|
||||
// Convert an inherited ground-target frame without changing the world
|
||||
// pose. Rotation must pivot about Earth, never the previous local target.
|
||||
@@ -1699,7 +1813,10 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
motion,
|
||||
direction: state.direction,
|
||||
speed,
|
||||
mode,
|
||||
mode: state.mode,
|
||||
...(tiltToPitch !== null && {
|
||||
pitchDeg: Math.round(Cesium.Math.toDegrees(tiltToPitch)),
|
||||
}),
|
||||
};
|
||||
};
|
||||
const preserveCameraFlight =
|
||||
|
||||
@@ -1014,6 +1014,13 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
'noir',
|
||||
]);
|
||||
visualStyle.enum = [...visualStyle.enum, 'snow'];
|
||||
// Tilt to an angle adds pitchDeg and rewords direction; restore
|
||||
// the legacy camera fields to keep the pin.
|
||||
const camera = legacyTools.find((tool) => tool.name === 'move_camera')
|
||||
.parameters.properties;
|
||||
delete camera.pitchDeg;
|
||||
camera.direction.description =
|
||||
'Required for pan/tilt/rotate; orbit/rotate_globe default right.';
|
||||
const block = JSON.stringify(legacyTools);
|
||||
// Re-derived for the voice layer manifest (generated layer enums, alias and
|
||||
// field hints), point-and-ask's pointer/referent arguments and the prompt
|
||||
|
||||
@@ -225,10 +225,15 @@ test('no unchanged Realtime tool definition drifts silently', () => {
|
||||
camera.description,
|
||||
'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly"). Globe rotation (rotate_globe) spins Earth right continuously by default; explicit direction, mode and speed take precedence. It checks fit and may refuse.',
|
||||
);
|
||||
// Tilt to an angle adds pitchDeg and says a tilt nudge needs a
|
||||
// direction; check them, then restore the legacy fields for the pin.
|
||||
assert.equal(
|
||||
camera.parameters.properties.direction.description,
|
||||
'Required for pan/tilt/rotate; orbit/rotate_globe default right.',
|
||||
'Required for pan/rotate and a tilt nudge; orbit/rotate_globe default right.',
|
||||
);
|
||||
assert.equal(camera.parameters.properties.pitchDeg.type, 'number');
|
||||
assert.match(camera.parameters.properties.pitchDeg.description, /-45 oblique/);
|
||||
delete camera.parameters.properties.pitchDeg;
|
||||
assert.equal(camera.parameters.properties.mode.description, 'once = bounded eased nudge; continuous = until stop/manual input. rotate_globe defaults continuous; other motions default once.');
|
||||
camera.parameters.properties.mode.description = 'once = bounded eased nudge (default); continuous = until stop/manual input.';
|
||||
camera.parameters.properties.motion.enum =
|
||||
|
||||
@@ -749,6 +749,11 @@ const schemas = [
|
||||
type: 'string',
|
||||
enum: ['once', 'continuous'],
|
||||
},
|
||||
pitchDeg: {
|
||||
type: 'number',
|
||||
minimum: -90,
|
||||
maximum: 90,
|
||||
},
|
||||
},
|
||||
required: ['motion'],
|
||||
},
|
||||
|
||||
@@ -49,7 +49,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
|
||||
// Snow leaves the set_visual_style enum.
|
||||
// The Operator HUD layout leaves set_hud's layout enum.
|
||||
// The layer alias hint leaves out aliases the layer id already spells.
|
||||
'dc2bac1be93a503dc88d637beea2f1bc457e1f9167422bd91548b44756a8aca5',
|
||||
// move_camera tilt takes an optional pitchDeg angle.
|
||||
'35261624ef8596608b80e2876f83760723ecf6592acc43fd77691cdb0a972608',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -134,6 +135,8 @@ test('all legacy action arguments are byte-identical after removing the delibera
|
||||
'stop',
|
||||
]);
|
||||
motion.enum = motion.enum.filter((value) => value !== 'rotate_globe');
|
||||
// Tilt to an angle is additive.
|
||||
delete property('move_camera').pitchDeg;
|
||||
const panels = property('set_panel_open').panelId;
|
||||
panels.enum = panels.enum.filter(
|
||||
(id) =>
|
||||
@@ -272,3 +275,12 @@ test('direct voice timeout schema accepts None and exact integers, rejects malfo
|
||||
for (const minutes of [-1, 61, 1.5, '5'])
|
||||
assert.ok(validateArgs(tool.parameters, { minutes }).length > 0);
|
||||
});
|
||||
|
||||
test('tilt accepts an angle with no direction', () => {
|
||||
const schema = GEV_ACTION_SCHEMAS.find(
|
||||
(tool) => tool.name === 'move_camera',
|
||||
).parameters;
|
||||
assert.deepEqual(validateArgs(schema, { motion: 'tilt', pitchDeg: -45 }), []);
|
||||
assert.deepEqual(validateArgs(schema, { motion: 'tilt' }), []);
|
||||
assert.ok(validateArgs(schema, { motion: 'tilt', pitchDeg: 120 }).length > 0);
|
||||
});
|
||||
Reference in new issue
Block a user