mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
Refuse a motion after its reply's fly-to found nothing, also on Gemini
Gemini runs one turn's calls one after another. By the time "pan left" ran beside "fly to Xyzzyville", the lookup had already found nothing, so there was no flight to wait on and the pan moved the old view with ok: true. OpenAI's parallel calls refused the same reply. The runner now remembers, for the latest reply that sent a voice fly-to, whether one moved the camera and whether one found nothing. A tilt, orbit, pan or rotate of that reply with no flight left to wait on is refused when its fly-to found nothing and none moved. The reply is Gemini's turn, passed as a per-turn reply object, or OpenAI's authorityGroup. A motion in a later reply, or beside a fly-to of the same reply that did land, runs as before.
This commit is contained in:
1 parent
1eef14454a
commit
dae95e1c7f
5 files changed
+153
-1
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
|
||||
> `main` + 26 route pins; the same tree on Node 24 reports 2,295.
|
||||
|
||||
- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. A zoom during a pivoting tilt scales the range it holds, as for orbit, but a zoom in stops at the range that keeps that 150 m clearance; a zoom that can move no further (that floor, the 80 m floor, or the cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) is reported as not zoomed. Once the tilt continues in place, a zoom moves the camera like any zoom. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the user grabs, touches or scrolls the map during it, or another navigation (a location-panel search, share link or file import, which leave the flight flying) takes the camera during it, the motion is not run and says so. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including cLine 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, the user grabs, touches or scrolls the map during it, or another navigation (a location-panel search, share link or file import, which leave the flight flying) takes the camera during it, the motion is not run and says so. A motion that runs after a fly-to of the same reply has already found nothing (Gemini runs one turn's calls one by one, and passes its turn as the reply) is refused the same way rather than moving the old view; a motion in a later reply runs. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before Line 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.
|
||||
- **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).
|
||||
|
||||
@@ -184,6 +184,9 @@ export class GeminiSessionController {
|
||||
this.model = null;
|
||||
this.epoch = 0;
|
||||
this.turn = 0;
|
||||
// Identifies one turn's tool calls to the runner as one reply; a new
|
||||
// object each turn, so no two sessions or turns share it.
|
||||
this.turnReply = {};
|
||||
this.globeToolsBlocked = false;
|
||||
this.globeResetFailed = false;
|
||||
this.inputGeneration = 0;
|
||||
@@ -971,6 +974,7 @@ export class GeminiSessionController {
|
||||
const tools = [...this.tools.values()];
|
||||
this.turnAbort = new AbortController();
|
||||
this.turn++;
|
||||
this.turnReply = {};
|
||||
this.typedIntent = null;
|
||||
this.radioStrongerCommit = null;
|
||||
this.pruneControls();
|
||||
@@ -1544,6 +1548,7 @@ export class GeminiSessionController {
|
||||
)
|
||||
throw new Error(msg('Gemini requested too many simultaneous actions'));
|
||||
const turn = this.turn;
|
||||
const reply = this.turnReply;
|
||||
this.userTurnPending = false;
|
||||
this.modelComplete = false;
|
||||
// Admit each valid native call ID once per connection.
|
||||
@@ -1565,6 +1570,7 @@ export class GeminiSessionController {
|
||||
const action = new AbortController();
|
||||
action.call = { id: call.id, name: call.name };
|
||||
action.sequence = ++this.toolSequence;
|
||||
action.reply = reply;
|
||||
this.tools.set(call.id, action);
|
||||
const priorTools = this.toolQueue;
|
||||
const args =
|
||||
@@ -1674,6 +1680,9 @@ export class GeminiSessionController {
|
||||
this.runAction(call.name, args, {
|
||||
signal: toolSignal,
|
||||
callId: call.id,
|
||||
// Calls run one by one, so a motion can follow a fly-to of the
|
||||
// same reply that already found nothing.
|
||||
reply: action.reply,
|
||||
progress: (update) => {
|
||||
if (actionCurrent()) this.narration.progress(call.id, update);
|
||||
},
|
||||
|
||||
@@ -1241,6 +1241,34 @@ test('successful stronger Radio stop still invalidates the prepared group', asyn
|
||||
h.c.stop();
|
||||
});
|
||||
|
||||
test('Gemini tools of one turn share a reply identity, and a new turn gets another', async () => {
|
||||
const replies = [];
|
||||
const h = harness({
|
||||
runAction: (name, _args, next) => {
|
||||
replies.push([name, next.reply]);
|
||||
return { ok: true, action: name };
|
||||
},
|
||||
});
|
||||
await h.c.start({ pushToTalk: true });
|
||||
h.c.sendTextCommand('Fly to Xyzzyville and pan left');
|
||||
const ws = h.sockets[0];
|
||||
requestTool(ws, 'fly', 'fly_to_location', { query: 'Xyzzyville' });
|
||||
requestTool(ws, 'pan', 'move_camera', { motion: 'pan', direction: 'left' });
|
||||
await h.c.toolQueue;
|
||||
assert.equal(replies.length, 2);
|
||||
assert.ok(replies[0][1], 'a sequentially run call carries its reply');
|
||||
assert.equal(replies[1][1], replies[0][1]);
|
||||
drainRadioReply(h);
|
||||
await tick();
|
||||
assert.equal(h.c.sendTextCommand('Pan left'), true);
|
||||
requestTool(ws, 'pan-2', 'move_camera', { motion: 'pan', direction: 'left' });
|
||||
await h.c.toolQueue;
|
||||
assert.equal(replies.length, 3);
|
||||
assert.ok(replies[2][1]);
|
||||
assert.notEqual(replies[2][1], replies[0][1]);
|
||||
h.c.stop();
|
||||
});
|
||||
|
||||
test('slow Gemini tools reuse shared progress timing without injecting a conversation turn', async () => {
|
||||
const pending = deferred();
|
||||
let options;
|
||||
|
||||
@@ -299,6 +299,11 @@ export function createGevActionRunner({
|
||||
// Camera handoffs (navigation stamps) seen, and how many of them voice
|
||||
// fly-tos made. A search, share link or import that stamps during a flight
|
||||
// leaves it flying; the motion queued behind it then yields to that one.
|
||||
// The voice fly-tos of the latest reply that sent one: whether any moved
|
||||
// the camera, and whether one found nothing. A motion of that reply run
|
||||
// after its fly-to found nothing (Gemini runs a reply's calls one by one)
|
||||
// has no flight to wait on, and must not move the old view instead.
|
||||
let replyFlights = null;
|
||||
let cameraHandoffs = 0;
|
||||
let voiceFlightHandoffs = 0;
|
||||
const otherHandoffs = () => cameraHandoffs - voiceFlightHandoffs;
|
||||
@@ -1168,6 +1173,10 @@ export function createGevActionRunner({
|
||||
// Navigation by anything but a voice fly-to since this one began.
|
||||
flight.handoffs = otherHandoffs();
|
||||
voiceFlight = flight;
|
||||
const reply = voiceReplyOf(runOptions);
|
||||
if (reply !== null && replyFlights?.reply !== reply)
|
||||
replyFlights = { reply, moved: false, foundNothing: false };
|
||||
const replyRecord = reply !== null ? replyFlights : null;
|
||||
for (const notify of [...voiceFlightWatchers]) notify();
|
||||
// A motion already running (one the model sent before this fly-to) is
|
||||
// stopped when the flight takes navigation; the result says so.
|
||||
@@ -1239,6 +1248,13 @@ export function createGevActionRunner({
|
||||
flight.settle('failed');
|
||||
else notStarted();
|
||||
}, notStarted);
|
||||
// After the outcome above, which marks a lookup that moved nothing.
|
||||
const noteReply = (result) => {
|
||||
if (!replyRecord) return;
|
||||
if (result?.ok === true) replyRecord.moved = true;
|
||||
else if (flight.movedNothing) replyRecord.foundNothing = true;
|
||||
};
|
||||
flown.then(noteReply, () => noteReply(null));
|
||||
return flown.then((result) =>
|
||||
stoppedMotion && result && typeof result === 'object'
|
||||
? { ...result, stoppedMotion }
|
||||
@@ -1349,6 +1365,21 @@ export function createGevActionRunner({
|
||||
// A lookup that already found nothing does not hide the last flight
|
||||
// that moved: running now would cancel that flight mid-air.
|
||||
const pending = voiceFlightTarget(voiceFlight);
|
||||
const reply = voiceReplyOf(runOptions);
|
||||
if (
|
||||
VOICE_FLIGHT_WAITING_MOTIONS.has(motion) &&
|
||||
!(pending && !pending.done) &&
|
||||
reply !== null &&
|
||||
replyFlights?.reply === reply &&
|
||||
replyFlights.foundNothing &&
|
||||
!replyFlights.moved
|
||||
)
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
motion,
|
||||
error: `Not run: the fly-to in this reply found nothing, so the ${motion} was not run at the old view.`,
|
||||
};
|
||||
if (
|
||||
VOICE_FLIGHT_WAITING_MOTIONS.has(motion) &&
|
||||
pending &&
|
||||
@@ -1724,6 +1755,7 @@ export function createGevActionRunner({
|
||||
* forget the analyst's "last answer". Area handles stay (page lifetime).
|
||||
*/
|
||||
runVoiceAction.resetConversation = () => {
|
||||
replyFlights = null;
|
||||
conversationAbort.abort();
|
||||
conversationAbort = new AbortController();
|
||||
conversation++;
|
||||
@@ -3012,6 +3044,15 @@ async function frameOverhead(viewer, dataManager, styleManager, args = {}) {
|
||||
|
||||
let voiceFlightCount = 0;
|
||||
|
||||
/**
|
||||
* The model reply a voice call belongs to: Gemini's turn (`reply`), or the
|
||||
* OpenAI response its sibling calls share (`authorityGroup`). Null when the
|
||||
* caller names none.
|
||||
*/
|
||||
function voiceReplyOf(options) {
|
||||
return options?.reply ?? options?.authorityGroup ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A voice flight's end: 'arrived', 'cancelled' or 'failed', settled once.
|
||||
* `replaced` is the last flight that moved when this one began (past lookups
|
||||
|
||||
@@ -938,6 +938,80 @@ test('a motion follows a newer fly-to that lands while its own lookup stalls', a
|
||||
// flies: the search stamps a deferred navigation without stopping the flight.
|
||||
// Run where the flight landed, the orbit took navigation and the search's
|
||||
// reassert failed, so the search was silently dropped.
|
||||
// Gemini runs one reply's calls one after another: by the time "pan left"
|
||||
// runs, "fly to Xyzzyville" has already found nothing, so there is no flight
|
||||
// to wait on, and the pan moved the old view with ok: true.
|
||||
test('a motion run after its reply\'s fly-to found nothing is not run', async (t) => {
|
||||
t.after(() => interruptCameraMotion('test-cleanup'));
|
||||
const { viewer, styleManager } = tiltAfterFlightHarness();
|
||||
const runner = createGevActionRunner({
|
||||
viewer,
|
||||
styleManager,
|
||||
dataManager: { layers: new Map(), getAll: () => [] },
|
||||
searchNavigation: async () => null,
|
||||
});
|
||||
const reply = { turn: 1 };
|
||||
const fly = await runner('fly_to_location', { query: 'Xyzzyville' }, { reply });
|
||||
assert.equal(fly.ok, false);
|
||||
const pan = await runner(
|
||||
'move_camera',
|
||||
{ motion: 'pan', direction: 'left' },
|
||||
{ reply },
|
||||
);
|
||||
assert.equal(pan.ok, false);
|
||||
assert.match(pan.error, /Not run: the fly-to in this reply found nothing/);
|
||||
assert.equal(getActiveCameraMotion(), null);
|
||||
// The same holds for OpenAI's reply identity.
|
||||
const group = 'response-1';
|
||||
await runner('fly_to_location', { query: 'Xyzzyville' }, { authorityGroup: group });
|
||||
const tilt = await runner(
|
||||
'move_camera',
|
||||
{ motion: 'tilt', pitchDeg: -45 },
|
||||
{ authorityGroup: group },
|
||||
);
|
||||
assert.equal(tilt.ok, false);
|
||||
assert.match(tilt.error, /found nothing/);
|
||||
});
|
||||
|
||||
test('a motion in a later reply runs after a fly-to that found nothing', async (t) => {
|
||||
t.after(() => interruptCameraMotion('test-cleanup'));
|
||||
const { viewer, styleManager } = tiltAfterFlightHarness();
|
||||
const runner = createGevActionRunner({
|
||||
viewer,
|
||||
styleManager,
|
||||
dataManager: { layers: new Map(), getAll: () => [] },
|
||||
searchNavigation: async () => null,
|
||||
});
|
||||
await runner('fly_to_location', { query: 'Xyzzyville' }, { reply: { turn: 1 } });
|
||||
const pan = await runner(
|
||||
'move_camera',
|
||||
{ motion: 'pan', direction: 'left' },
|
||||
{ reply: { turn: 2 } },
|
||||
);
|
||||
assert.equal(pan.ok, true, pan.error);
|
||||
});
|
||||
|
||||
test('a motion runs at a flight of its reply that landed after another lookup found nothing', async (t) => {
|
||||
t.after(() => interruptCameraMotion('test-cleanup'));
|
||||
const { viewer, styleManager, flights } = tiltAfterFlightHarness();
|
||||
const runner = createGevActionRunner({
|
||||
viewer,
|
||||
styleManager,
|
||||
dataManager: { layers: new Map(), getAll: () => [] },
|
||||
searchNavigation: async () => null,
|
||||
});
|
||||
const reply = { turn: 1 };
|
||||
await runner('fly_to_location', { query: 'Xyzzyville' }, { reply });
|
||||
assert.equal((await runner('fly_to_location', { locationId: 'austin' }, { reply })).ok, true);
|
||||
flights[0].complete();
|
||||
const pan = await runner(
|
||||
'move_camera',
|
||||
{ motion: 'pan', direction: 'left' },
|
||||
{ reply },
|
||||
);
|
||||
assert.equal(pan.ok, true, pan.error);
|
||||
});
|
||||
|
||||
test('a motion queued behind a fly-to yields to a search started during the flight', async (t) => {
|
||||
t.after(() => interruptCameraMotion('test-cleanup'));
|
||||
const { viewer, styleManager, flights, panelSearch, currentGeneration } =
|
||||
|
||||
Reference in new issue
Block a user