mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge pull request #1023 from rudycelekli/contrib/gods-002-director-dateline-seek-20261011-reviewed
fix(director): seek camera longitude across the shortest dateline arc
This commit is contained in:
5 files changed
+74
-3
No files matched your search
@@ -24,6 +24,8 @@
|
|||||||
the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is
|
the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is
|
||||||
unchanged; extra cameras share it round-robin with the other packs.
|
unchanged; extra cameras share it round-robin with the other packs.
|
||||||
|
|
||||||
|
- Director timeline seeks cross the dateline along the short longitude arc.
|
||||||
|
|
||||||
- Treat explicitly current, complete empty vessel coverage as a successful update,
|
- Treat explicitly current, complete empty vessel coverage as a successful update,
|
||||||
clearing obsolete contacts while retaining stale or incomplete snapshots.
|
clearing obsolete contacts while retaining stale or incomplete snapshots.
|
||||||
|
|
||||||
|
|||||||
@@ -399,6 +399,9 @@ camera moves with easing, duration and holds. Playback and seek share one
|
|||||||
coordinate sampler; navigation/manual input revokes authored motion and pending
|
coordinate sampler; navigation/manual input revokes authored motion and pending
|
||||||
holds. Anchor heights and explicit endpoints require the WGS84 ellipsoid
|
holds. Anchor heights and explicit endpoints require the WGS84 ellipsoid
|
||||||
reference. Legacy projects retain their ordinary flights, content and edits.
|
reference. Legacy projects retain their ordinary flights, content and edits.
|
||||||
|
Seeking between ordinary camera poses uses the shortest longitude arc across the
|
||||||
|
dateline while preserving both authored endpoint longitudes, and turns heading
|
||||||
|
and roll the short way for any authored angles.
|
||||||
See [camera directions](DIRECTOR-CAMERA.md).
|
See [camera directions](DIRECTOR-CAMERA.md).
|
||||||
|
|
||||||
Realtime voice composes separate connection, response/tool, Radio handoff, input
|
Realtime voice composes separate connection, response/tool, Radio handoff, input
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ export function resolveCameraMove(scene, shot) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Wrap an interpolated camera longitude to WGS84 without changing authored endpoints. */
|
||||||
|
export function normalizeCameraLongitude(longitude) {
|
||||||
|
return ((((longitude + 180) % 360) + 360) % 360) - 180;
|
||||||
|
}
|
||||||
|
|
||||||
/** Sample the same authored curve for playback and seeking, taking the short longitude/angle arc. */
|
/** Sample the same authored curve for playback and seeking, taking the short longitude/angle arc. */
|
||||||
export function sampleCameraMove(move, progress) {
|
export function sampleCameraMove(move, progress) {
|
||||||
const t = Math.max(0, Math.min(1, Number(progress) || 0));
|
const t = Math.max(0, Math.min(1, Number(progress) || 0));
|
||||||
@@ -45,7 +50,7 @@ export function sampleCameraMove(move, progress) {
|
|||||||
const { from, to } = move;
|
const { from, to } = move;
|
||||||
return {
|
return {
|
||||||
lat: lerp(from.lat, to.lat),
|
lat: lerp(from.lat, to.lat),
|
||||||
lon: ((((angle(from.lon, to.lon) + 180) % 360) + 360) % 360) - 180,
|
lon: normalizeCameraLongitude(angle(from.lon, to.lon)),
|
||||||
alt: lerp(from.alt, to.alt),
|
alt: lerp(from.alt, to.alt),
|
||||||
heading: angle(from.heading, to.heading),
|
heading: angle(from.heading, to.heading),
|
||||||
pitch: lerp(from.pitch, to.pitch),
|
pitch: lerp(from.pitch, to.pitch),
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { cameraAtProgress } from './timeline.js';
|
||||||
|
const camera = (lon) => ({
|
||||||
|
lat: 10,
|
||||||
|
lon,
|
||||||
|
alt: 1000,
|
||||||
|
heading: 0,
|
||||||
|
pitch: -40,
|
||||||
|
roll: 0,
|
||||||
|
});
|
||||||
|
test('director seek follows the short dateline arc in both directions', () => {
|
||||||
|
for (const [a, b] of [
|
||||||
|
[179, -179],
|
||||||
|
[-179, 179],
|
||||||
|
]) {
|
||||||
|
assert.equal(
|
||||||
|
Math.abs(cameraAtProgress(camera(a), camera(b), 0.5).lon),
|
||||||
|
180,
|
||||||
|
);
|
||||||
|
assert.equal(cameraAtProgress(camera(a), camera(b), 0).lon, a);
|
||||||
|
assert.equal(cameraAtProgress(camera(a), camera(b), 1).lon, b);
|
||||||
|
assert.ok(Math.abs(cameraAtProgress(camera(a), camera(b), 0.25).lon) > 179);
|
||||||
|
}
|
||||||
|
assert.equal(cameraAtProgress(camera(20), camera(24), 0.5).lon, 22);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('director seek keeps exact ±180 endpoints and picks the short arc near the antipode', () => {
|
||||||
|
for (const [a, b] of [
|
||||||
|
[180, -180],
|
||||||
|
[-180, 180],
|
||||||
|
]) {
|
||||||
|
assert.equal(cameraAtProgress(camera(a), camera(b), 0).lon, a);
|
||||||
|
assert.equal(cameraAtProgress(camera(a), camera(b), 1).lon, b);
|
||||||
|
assert.equal(
|
||||||
|
Math.abs(cameraAtProgress(camera(a), camera(b), 0.5).lon),
|
||||||
|
180,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 10°E to 169.9°W is 179.9° westward through Greenwich, not 180.1° east.
|
||||||
|
const mid = cameraAtProgress(camera(10), camera(-169.9), 0.5).lon;
|
||||||
|
assert.ok(mid > -81 && mid < -79, `short arc midpoint, got ${mid}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('director seek turns heading and roll the short way for any authored angles', () => {
|
||||||
|
const pose = (heading, roll) => ({ ...camera(0), heading, roll });
|
||||||
|
const mid = cameraAtProgress(pose(350, 350), pose(-350, -350), 0.5);
|
||||||
|
// 350° and -350° are 20° apart through 0°/360°, not 340° through 180°.
|
||||||
|
for (const value of [mid.heading, mid.roll]) {
|
||||||
|
const wrapped = ((value % 360) + 360) % 360;
|
||||||
|
assert.ok(wrapped < 1e-9 || 360 - wrapped < 1e-9, `got ${value}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
resolveCameraPose,
|
resolveCameraPose,
|
||||||
resolveCameraMove,
|
resolveCameraMove,
|
||||||
sampleCameraMove,
|
sampleCameraMove,
|
||||||
|
normalizeCameraLongitude,
|
||||||
} from './camera.js';
|
} from './camera.js';
|
||||||
|
|
||||||
const DEFAULT_SHOT_DURATION_SEC = 4;
|
const DEFAULT_SHOT_DURATION_SEC = 4;
|
||||||
@@ -40,12 +41,19 @@ export function cameraAtProgress(fromCamera, toCamera, progress) {
|
|||||||
const lerp = (from, to) => Number(from) + (Number(to) - Number(from)) * eased;
|
const lerp = (from, to) => Number(from) + (Number(to) - Number(from)) * eased;
|
||||||
const lerpAngle = (from, to) => {
|
const lerpAngle = (from, to) => {
|
||||||
const start = Number(from) || 0;
|
const start = Number(from) || 0;
|
||||||
const delta = ((Number(to) - start + 540) % 360) - 180;
|
// Double modulo keeps the delta in [-180, 180) for any authored angles,
|
||||||
|
// as sampleCameraMove does; a single % goes negative below -540.
|
||||||
|
const delta = ((((Number(to) - start + 540) % 360) + 360) % 360) - 180;
|
||||||
return start + delta * eased;
|
return start + delta * eased;
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
lat: lerp(source.lat, target.lat),
|
lat: lerp(source.lat, target.lat),
|
||||||
lon: lerp(source.lon, target.lon),
|
lon:
|
||||||
|
t === 0
|
||||||
|
? Number(source.lon)
|
||||||
|
: t === 1
|
||||||
|
? Number(target.lon)
|
||||||
|
: normalizeCameraLongitude(lerpAngle(source.lon, target.lon)),
|
||||||
alt: lerp(source.alt, target.alt),
|
alt: lerp(source.alt, target.alt),
|
||||||
heading: lerpAngle(source.heading, target.heading),
|
heading: lerpAngle(source.heading, target.heading),
|
||||||
pitch: lerp(source.pitch, target.pitch),
|
pitch: lerp(source.pitch, target.pitch),
|
||||||
|
|||||||
Reference in new issue
Block a user