fix(cockpit): a new ride's TRACK ACQUIRED keeps its own time; an arrow during the ease back tilts on from the view; a site beyond the reach ends a focus; the horizon uses the WGS84 surface normal; a hop to the opposite side goes round the Earth; drop the unused in-frame listing

This commit is contained in:
Sameh Khamis committed 2026-11-08 17:12:04 -08:00
1 parent 7d6314b981
commit 6be8af17f1
10 files changed
+142 -170

No files matched your search

+20 -80
View File
@@ -24,8 +24,9 @@ export const ORBITAL_FOCUS_EASE_TAU_MS = 250;
/** How often the orbital Live Signals rows are rebuilt, ms. */
export const ORBITAL_REACH_REFRESH_MS = 1000;
/** The most mapped sites offered for the rows, nearest to nadir first. */
export const ORBITAL_REACH_LIST_MAX = 6;
/** WGS84 semi-axes squared, m²: the surface normal for the horizon test. */
const WGS84_A2 = 6378137 ** 2;
const WGS84_B2 = 6356752.314245179 ** 2;
/** Slack on the reach limit so a target held exactly at 45° stays. */
const REACH_EPSILON_DEG = 1e-6;
@@ -71,14 +72,15 @@ export function focusLook(nadir, satellite, target) {
const along = dot(v, nadir.direction);
const up = dot(v, nadir.up);
const right = Math.min(1, Math.max(-1, dot(v, nadir.right)));
// The target's own zenith: the satellite is above its horizon when the
// line from the target to it points up.
const targetRadius = Math.hypot(target.x, target.y, target.z);
// The target's own zenith, the WGS84 surface normal there: the satellite
// is above its horizon when the line from the target to it points up.
const normal = {
x: target.x / WGS84_A2,
y: target.y / WGS84_A2,
z: target.z / WGS84_B2,
};
const visible =
targetRadius > 0 &&
-(line.x * target.x + line.y * target.y + line.z * target.z) /
targetRadius >
0;
Math.hypot(normal.x, normal.y, normal.z) > 0 && -dot(line, normal) > 0;
const yawDeg = toDegrees(Math.asin(right));
const pitchDeg = toDegrees(Math.atan2(up, along));
return {
@@ -106,38 +108,6 @@ export function focusInReach(look) {
);
}
/**
* Whether `target` is inside the picture as it is now framed.
* @param {{direction: object, up: object, right: object}} view The camera's
* orientation (the nadir frame tilted by the eased look).
* @param {{x: number, y: number, z: number}} satellite ECEF, metres.
* @param {{x: number, y: number, z: number}} target ECEF, metres.
* @param {{verticalFovRad: number, aspect?: number}} frame
* @returns {boolean}
*/
export function inFrame(
view,
satellite,
target,
{ verticalFovRad, aspect = 1 },
) {
if (!view || !finiteVector(satellite) || !finiteVector(target)) return false;
if (!(verticalFovRad > 0)) return false;
const line = {
x: target.x - satellite.x,
y: target.y - satellite.y,
z: target.z - satellite.z,
};
const along = dot(line, view.direction);
if (!(along > 0)) return false;
const halfTan = Math.tan(verticalFovRad / 2);
const ratio = Number.isFinite(aspect) && aspect > 0 ? aspect : 1;
return (
Math.abs(dot(line, view.up)) / along <= halfTan &&
Math.abs(dot(line, view.right)) / along <= halfTan * ratio
);
}
/**
* Ease what is left of the turn onto a focus target toward nothing: the
* look's easing (`easeLook`) on the focus's slower clock. The view is the
@@ -171,53 +141,23 @@ export function focusEaseDtMs(dtMs) {
}
/**
* The mapped points the view shows now or can be turned to: those facing
* the satellite and either within the off-nadir reach (`reachable`) or in
* the frame as it is. Nearest to nadir first, at most `max`.
* The mapped points the view can be turned to: those facing the satellite
* and within the off-nadir reach, nearest to nadir first.
* @param {Iterable<{key: string, position: {x: number, y: number,
* z: number}}>} candidates
* @param {{nadir: object, view: object, satellite: object,
* verticalFovRad: number, aspect?: number, max?: number}} frame
* @returns {{count: number, entries: Array<{key: string,
* offNadirDeg: number, reachable: boolean, look: object}>}} `count` is
* every point in reach, `entries` the nearest.
* @param {{nadir: object, satellite: object}} frame
* @returns {Array<{key: string, offNadirDeg: number, look: object}>}
*/
export function pointsInReach(
candidates,
{
nadir,
view,
satellite,
verticalFovRad,
aspect = 1,
max = ORBITAL_REACH_LIST_MAX,
},
) {
export function pointsInReach(candidates, { nadir, satellite }) {
const entries = [];
if (!nadir || !finiteVector(satellite)) return { count: 0, entries };
if (!nadir || !finiteVector(satellite)) return entries;
for (const candidate of candidates) {
const look = focusLook(nadir, satellite, candidate?.position);
if (!look?.visible) continue;
const reachable = focusInReach(look);
if (
!reachable &&
!inFrame(view, satellite, candidate.position, { verticalFovRad, aspect })
)
continue;
entries.push({
key: candidate.key,
offNadirDeg: look.offNadirDeg,
reachable,
look,
});
if (!focusInReach(look)) continue;
entries.push({ key: candidate.key, offNadirDeg: look.offNadirDeg, look });
}
entries.sort(
return entries.sort(
(a, b) =>
a.offNadirDeg - b.offNadirDeg || String(a.key).localeCompare(b.key),
);
const limit =
max === Infinity || (Number.isInteger(max) && max >= 0)
? max
: ORBITAL_REACH_LIST_MAX;
return { count: entries.length, entries: entries.slice(0, limit) };
}
+23 -45
View File
@@ -6,12 +6,10 @@ import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
ORBITAL_FOCUS_EASE_TAU_MS,
ORBITAL_REACH_LIST_MAX,
easeFocusRemainder,
focusEaseDtMs,
focusInReach,
focusLook,
inFrame,
pointsInReach,
} from './cockpitOrbitalFocus.js';
import {
@@ -200,50 +198,30 @@ test('the reach list keeps points in reach or in frame, nearest first, bounded',
for (let i = 0; i < 80; i += 1)
candidates.push({ key: `east-${i}`, position: geodeticToEcef(0, i * 0.1) });
candidates.push({ key: 'far-side', position: geodeticToEcef(0, 180) });
const frame = {
nadir,
view: nadir,
satellite,
verticalFovRad: (60 * Math.PI) / 180,
aspect: 16 / 9,
};
const all = pointsInReach(candidates, { ...frame, max: 100 });
assert.ok(all.entries.every((entry) => entry.key !== 'far-side'));
const angles = all.entries.map((entry) => entry.offNadirDeg);
const entries = pointsInReach(candidates, { nadir, satellite });
assert.ok(entries.every((entry) => entry.key !== 'far-side'));
const angles = entries.map((entry) => entry.offNadirDeg);
assert.deepEqual(angles, [...angles].sort((a, b) => a - b), 'nearest first');
assert.equal(all.entries[0].key, 'east-0');
assert.equal(all.entries[0].offNadirDeg, 0);
// The widest frame (16:9 at 60° tall) reaches past 45° across: those
// points are listed, but cannot be focused.
const beyond = all.entries.filter((entry) => !entry.reachable);
assert.ok(beyond.length > 0, 'some only in frame');
for (const entry of beyond) assert.ok(Math.abs(entry.look.yawDeg) > 45);
for (const entry of all.entries.filter((e) => e.reachable))
assert.ok(Math.abs(entry.look.yawDeg) <= 45);
// Out of frame and out of reach: not listed.
assert.ok(all.count < 80);
const bounded = pointsInReach(candidates, frame);
assert.equal(bounded.entries.length, ORBITAL_REACH_LIST_MAX);
assert.equal(bounded.count, all.count, 'the count is all of them');
assert.deepEqual(
bounded.entries.map((entry) => entry.key),
all.entries.slice(0, ORBITAL_REACH_LIST_MAX).map((entry) => entry.key),
);
assert.deepEqual(pointsInReach([], frame), { count: 0, entries: [] });
assert.equal(entries[0].key, 'east-0');
assert.equal(entries[0].offNadirDeg, 0);
// Only those within the 45° tilt are listed.
assert.ok(entries.length > 0 && entries.length < 80);
for (const entry of entries) assert.ok(Math.abs(entry.look.yawDeg) <= 45);
const beyond = focusLook(nadir, satellite, candidates[entries.length].position);
assert.ok(Math.abs(beyond.yawDeg) > 45, 'the next one east is past the reach');
assert.deepEqual(pointsInReach([], { nadir, satellite }), []);
});
test('in frame follows the view, its height and its width', () => {
const satellite = satelliteAt(0, 0);
const nadir = nadirOrientation(satellite);
const target = geodeticToEcef(0, 3);
const narrow = { verticalFovRad: (5 * Math.PI) / 180, aspect: 1 };
assert.equal(inFrame(nadir, satellite, target, narrow), false);
const look = focusLook(nadir, satellite, target);
const tilted = offNadirOrientation(nadir, {
northDeg: look.pitchDeg,
eastDeg: look.yawDeg,
});
assert.equal(inFrame(tilted, satellite, target, narrow), true);
assert.equal(inFrame(tilted, satellite, target, { verticalFovRad: 0 }), false);
test('the horizon is the ground’s own: the WGS84 surface normal, not the centre line', () => {
const target = geodeticToEcef(45, 0);
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(target);
const satelliteAtElevation = (elevationDeg) => {
const e = Cesium.Math.toRadians(elevationDeg);
const local = new Cesium.Cartesian3(0, -Math.cos(e) * 4e7, Math.sin(e) * 4e7);
return Cesium.Matrix4.multiplyByPoint(enu, local, new Cesium.Cartesian3());
};
const below = satelliteAtElevation(-0.1);
assert.equal(focusLook(nadirOrientation(below), below, target).visible, false);
const above = satelliteAtElevation(0.1);
assert.equal(focusLook(nadirOrientation(above), above, target).visible, true);
});
+23 -3
View File
@@ -22,8 +22,8 @@ function finiteVector(v) {
/**
* Where the camera is part-way through a hop: along the great circle
* between the two positions (never through the Earth), its distance from the
* centre interpolated linearly. Both ends may move during the hop; pass
* their positions now.
* centre interpolated linearly. Opposite ends turn about a fixed axis
* square to them, so even then the path stays off the Earth.
* @param {{x: number, y: number, z: number}} from
* @param {{x: number, y: number, z: number}} to
* @param {number} progress Eased, 0..1.
@@ -39,6 +39,27 @@ export function hopPosition(from, to, progress) {
const b = { x: to.x / rb, y: to.y / rb, z: to.z / rb };
const cosine = Math.min(1, Math.max(-1, a.x * b.x + a.y * b.y + a.z * b.z));
const omega = Math.acos(cosine);
const r = ra + (rb - ra) * p;
if (Math.PI - omega < 1e-6) {
// No single great circle joins opposite points: turn about the axis
// square to `a` that is least aligned with the Earth's.
const pole =
Math.abs(a.z) < 0.9 ? { x: 0, y: 0, z: 1 } : { x: 1, y: 0, z: 0 };
const along = pole.x * a.x + pole.y * a.y + pole.z * a.z;
const c = {
x: pole.x - along * a.x,
y: pole.y - along * a.y,
z: pole.z - along * a.z,
};
const rc = Math.hypot(c.x, c.y, c.z);
const cos = Math.cos(p * Math.PI);
const sin = Math.sin(p * Math.PI);
return {
x: (a.x * cos + (c.x / rc) * sin) * r,
y: (a.y * cos + (c.y / rc) * sin) * r,
z: (a.z * cos + (c.z / rc) * sin) * r,
};
}
let ka = 1 - p;
let kb = p;
if (omega > 1e-9) {
@@ -46,7 +67,6 @@ export function hopPosition(from, to, progress) {
ka = Math.sin((1 - p) * omega) / sine;
kb = Math.sin(p * omega) / sine;
}
const r = ra + (rb - ra) * p;
return {
x: (a.x * ka + b.x * kb) * r,
y: (a.y * ka + b.y * kb) * r,
+13
View File
@@ -26,3 +26,16 @@ test('the hop follows the great circle and lands on the satellite', () => {
assert.ok(Math.abs(Math.atan2(mid.y, mid.x) - Math.PI / 4) < 1e-9);
assert.equal(hopPosition(null, to, 0.5), null);
});
test('a hop to the opposite side goes round the Earth, not through it', () => {
const from = at(0, 0, 400_000);
const to = at(0, 180, 400_000);
const radius = Cesium.Cartesian3.magnitude(from);
for (const p of [0.25, 0.5, 0.75]) {
const point = hopPosition(from, to, p);
assert.ok(Math.abs(Math.hypot(point.x, point.y, point.z) - radius) < 1e-3, `p=${p}`);
}
const end = hopPosition(from, to, 1);
for (const axis of ['x', 'y', 'z'])
assert.ok(Math.abs(end[axis] - to[axis]) < 1e-3, axis);
});
+7 -3
View File
@@ -389,10 +389,14 @@ export function createControls({ state: layerState, services, parts, source }) {
{ excludeIds = [], excludeClasses = [] } = {},
) {
const nearest = [];
if (!center || !layerState._points || layerState._points.size === 0)
const cap = Number.isFinite(maxCount) ? Math.floor(maxCount) : 8;
if (
cap < 1 ||
!center ||
!layerState._points ||
layerState._points.size === 0
)
return nearest;
const cap =
Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 8;
const excluded = new Set(excludeIds.map(String));
const classes = new Set(excludeClasses);
const dense = layerState._denseIds.length > 0;
@@ -64,4 +64,7 @@ test('the nearest shown satellites come first, bounded, with their distance', ()
assert.equal(nearest[0].satelliteClass, 'imaging');
assert.equal(methods.getNearestPositions(CENTRE, 1, { excludeIds: [6] })[0].label, 'DEB');
assert.deepEqual(methods.getNearestPositions(null, 3), []);
assert.deepEqual(methods.getNearestPositions(CENTRE, 0), []);
assert.deepEqual(methods.getNearestPositions(CENTRE, 0.5), []);
assert.equal(methods.getNearestPositions(CENTRE, 2.5).length, 2);
});
+23 -35
View File
@@ -10,10 +10,7 @@
*/
import * as Cesium from 'cesium';
import { clampOrbitalLook, easeLook } from '../cockpitLook.js';
import {
ORBITAL_EARTH_RADIUS_M,
offNadirOrientation,
} from '../cockpitOrbital.js';
import { ORBITAL_EARTH_RADIUS_M } from '../cockpitOrbital.js';
import {
easeFocusRemainder,
focusEaseDtMs,
@@ -181,8 +178,12 @@ function startFocus(site) {
export function releaseOrbitalFocus({ handOver = false } = {}) {
const state = this.orbital;
if (!state?.focus) {
// An arrow during the ease back takes the arrows' own clock again.
if (handOver && state) state.focusReleasing = false;
// An arrow during the ease back takes the arrows' own clock again and
// tilts on from where the view is, not from the nadir it was easing to.
if (handOver && state?.focusReleasing) {
state.lookTarget = clampOrbitalLook(state.lookView);
state.focusReleasing = false;
}
return false;
}
state.focus = null;
@@ -249,8 +250,7 @@ export function onOrbitalClick(position) {
if (site) {
if (state.focus?.key === String(site.id)) return true;
if (startFocus.call(this, site)) return true;
// A mapped site beyond the reach leaves the view as it is.
return false;
// A mapped site beyond the reach is a click elsewhere: the focus ends.
}
releaseOrbitalFocus.call(this);
return false;
@@ -283,8 +283,9 @@ export function routeSceneClick() {
/**
* The orbital view's look for one frame, while a focus holds or is ending.
* A held focus aims at its target: the live look plus whatever is left of
* the turn onto it. The focus ends, easing back, when its site is gone (its
* layer off, the site no longer loaded) or beyond the reach. Also rebuilds
* the turn onto it. The focus ends, easing back, when its layer is turned
* off or its site is beyond the reach (a site the layer stops loading is
* held from its last record). Also rebuilds
* the orbital Live Signals rows on their own clock.
* @param {object} state The orbital session.
* @param {{direction: object, up: object, right: object}} nadir This
@@ -389,9 +390,8 @@ export function orbitalFocusReadout() {
* with the layer off.
* @param {{latitude: number, longitude: number, altitudeM: number}} info
* The ridden satellite.
* @returns {{count: number, entries: Array<{site: object,
* offNadirDeg: number, focused: boolean}>}} `count` also counts sites in
* the frame but beyond the reach.
* @returns {{entries: Array<{site: object, offNadirDeg: number,
* focused: boolean}>}}
*/
export function orbitalSitesInReach(info) {
const state = this.orbital;
@@ -401,9 +401,9 @@ export function orbitalSitesInReach(info) {
// then, and no row is left to focus from.
if (state && !sitesLayerOn(this)) state.listedSites = new Map();
if (!frame || typeof layer?.getNearby !== 'function' || !sitesLayerOn(this))
return { count: 0, entries: [] };
return { entries: [] };
if (!Number.isFinite(info?.latitude) || !Number.isFinite(info?.longitude))
return { count: 0, entries: [] };
return { entries: [] };
const rangeM = reachGroundRangeM(info.altitudeM);
let sites = [];
try {
@@ -449,27 +449,15 @@ export function orbitalSitesInReach(info) {
seenMs: nowMs,
});
state.listedSites = remembered;
const frustum = this.viewer.camera?.frustum;
const reach = pointsInReach(candidates, {
nadir: frame.nadir,
view: offNadirOrientation(frame.nadir, {
northDeg: state.lookView.pitchDeg,
eastDeg: state.lookView.yawDeg,
}),
satellite: frame.position,
verticalFovRad: state.verticalFov,
aspect: Number.isFinite(frustum?.aspectRatio) ? frustum.aspectRatio : 1,
max: Infinity,
});
const focusKey = state.focus?.key ?? null;
return {
count: reach.count,
entries: reach.entries
.filter((entry) => entry.reachable)
.map(({ key, offNadirDeg }) => ({
site: byId.get(key),
offNadirDeg,
focused: key === focusKey,
})),
entries: pointsInReach(candidates, {
nadir: frame.nadir,
satellite: frame.position,
}).map(({ key, offNadirDeg }) => ({
site: byId.get(key),
offNadirDeg,
focused: key === focusKey,
})),
};
}
+19 -1
View File
@@ -573,9 +573,14 @@ test('a click on a site marker focuses it through the layer; a click elsewhere e
const largest = run(scene, LAND_FRAMES, { drift: 0 });
assert.ok(largest < MAX_TURN_PER_FRAME_DEG);
assert.ok(offNadir(scene.shots.at(-1)) < 1e-6);
// A site beyond the reach is not focused.
// A site beyond the reach is not focused, and ends a focus as a click
// elsewhere does.
scene.click(SITES.far.at);
assert.equal(cockpit.orbital.focus ?? null, null);
scene.click(SITES.alpha.at);
assert.equal(cockpit.orbital.focus.key, SITES.alpha.id);
scene.click(SITES.far.at);
assert.equal(cockpit.orbital.focus, null);
});
test('a click a tool owns is left alone', async (t) => {
@@ -676,7 +681,12 @@ test('a satellite row rides it, staying in orbit; RETURN goes back', async (t) =
run(scene, 5);
const before = Cesium.Cartesian3.clone(scene.shots.at(-1).destination);
const firstTrack = cockpit.signalItems.find((item) => item.key === 'track');
firstTrack.timestamp -= 60_000;
satelliteRow(scene, 49260).click();
const newTrack = cockpit.signalItems.filter((item) => item.key === 'track');
assert.equal(newTrack.length, 1);
assert.ok(newTrack[0].timestamp > firstTrack.timestamp, 'the new ride’s own time');
assert.deepEqual(satellites.calls.slice(-2), [['track', 49260], ['subject', 49260]]);
assert.equal(viewer.trackedEntity, undefined, 'the Cockpit keeps the camera');
assert.equal(cockpit.subjectId, 49260);
@@ -793,6 +803,8 @@ test('an arrow during the ease back from a focus turns on the arrows’ clock',
scene.click(EMPTY_GROUND);
scene.frame();
assert.equal(cockpit.orbital.focusReleasing, true);
const view = { ...cockpit.orbital.lookView };
assert.ok(Math.abs(view.pitchDeg) > 1 || Math.abs(view.yawDeg) > 1, 'still turned');
cockpit.onKeyDown({
key: 'ArrowUp',
target: document.body,
@@ -800,6 +812,12 @@ test('an arrow during the ease back from a focus turns on the arrows’ clock',
stopImmediatePropagation() {},
});
assert.equal(cockpit.orbital.focusReleasing, false);
// The arrow tilts on from where the view is, not from nadir.
assert.ok(
Math.abs(cockpit.orbital.lookTarget.yawDeg - view.yawDeg) < 1e-6,
'across kept',
);
assert.ok(cockpit.orbital.lookTarget.pitchDeg > view.pitchDeg, 'tilted on up');
});
test('leaving orbit leaves no orbital rows to act on', async (t) => {
+3
View File
@@ -150,6 +150,9 @@ export function switchOrbitalSatellite(noradId) {
this.lastHudUpdateMs = 0;
this.lastContextUpdateMs = 0;
this.dispatchCockpitModeChanged(true, info);
// The new satellite's TRACK ACQUIRED replaces the old one, time and all.
this.signalItems = this.signalItems.filter((item) => item.key !== 'track');
this.signalSignatures.delete('track');
this.pushCockpitSignal(
'track',
'track',