diff --git a/docs/RENDERER-CONTRACT.md b/docs/RENDERER-CONTRACT.md
index fb38a01a1..63584876f 100644
--- a/docs/RENDERER-CONTRACT.md
+++ b/docs/RENDERER-CONTRACT.md
@@ -154,5 +154,12 @@ Browser checks use the chrome-devtools MCP against a page in its own window.
The Cesium scene renderer (`src/render/cesium/sceneRenderer.js`) draws the
icon, point, line, shape, model and city scenes, and the references were
recorded with it. It has only the members the scenes need. The quad, mesh,
-field and agent scenes have no reference yet. Each family's reference is
-recorded when the Cesium renderer gains that family.
+field, agent and orbit scenes have no reference yet. Each family's reference
+is recorded when the Cesium renderer gains that family.
+
+Some behaviour is only drawn, so the logic checks cannot see it: course
+easing (`courseBlendMs`, `courseGlide`), and a hidden row keeping its eased
+course. The stub keeps no drawn course. These wait for scenes that capture
+an icon mid-turn. Height queries that count a set
+(`includeInHeightQueries`) are not checked either: the stub's heights are
+scripted, so it does not model them.
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index 744e777b3..73fb261b5 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -5144,9 +5144,10 @@
"src/render/stub/camera.js",
"src/render/stub/geodesic.js",
"src/render/stub/viewRectangle.js",
+ "src/render/stub/orbits.js",
"src/render/conformance/goldens/camera.json",
"src/render/conformance/checks/camera.js"
],
- "external": []
+ "external": ["satellite.js"]
}
}
diff --git a/src/render/conformance/checks/ports.js b/src/render/conformance/checks/ports.js
index 7bf3f055a..81fa85d66 100644
--- a/src/render/conformance/checks/ports.js
+++ b/src/render/conformance/checks/ports.js
@@ -11,8 +11,21 @@ import {
checkRendererShape,
} from '../../contract/index.js';
import { AUSTIN, SCRIPT_VIEWPORT } from '../cameraScripts.js';
+import {
+ eciToEcf,
+ eciToGeodetic,
+ gstime,
+ propagate,
+ twoline2satrec,
+} from 'satellite.js';
import { deepEqual, equal, near, nearVec, ok, throws } from '../expect.js';
+/** Elements for the orbit check: a station-like orbit, epoch 2026-01-04. */
+const ISS_TLE = Object.freeze([
+ '1 25544U 98067A 26004.50000000 .00016717 00000-0 10270-3 0 9992',
+ '2 25544 51.6400 208.9163 0006317 69.9862 25.2906 15.49560532 10009',
+]);
+
const DOT = {
svg: '',
};
@@ -125,6 +138,7 @@ const SET_KINDS = [
['quads', 'quads'],
['meshes', 'meshes'],
['agents', 'agents'],
+ ['orbits', 'orbits'],
];
export function portChecks() {
@@ -690,6 +704,311 @@ export function portChecks() {
},
{ capability: 'motionSegments' },
),
+ check(
+ 'draw.motion.climb',
+ 'an arc climbs at climbMps, and a negative minDtS runs it backward',
+ async (h, r) => {
+ await overAustin(h, r, 30000);
+ const t0 = r.frame.time();
+ const fixAt = { ...AUSTIN, height: 12000 };
+ const fix = h.toEcef(fixAt);
+ const lon = (fixAt.lon * Math.PI) / 180;
+ const lat = (fixAt.lat * Math.PI) / 180;
+ const east = { x: -Math.sin(lon), y: Math.cos(lon), z: 0 };
+ const north = {
+ x: -Math.sin(lat) * Math.cos(lon),
+ y: -Math.sin(lat) * Math.sin(lon),
+ z: Math.cos(lat),
+ };
+ const up = {
+ x: Math.cos(lat) * Math.cos(lon),
+ y: Math.cos(lat) * Math.sin(lon),
+ z: Math.sin(lat),
+ };
+ // The contract's formula: the turn in the tangent plane, the climb up.
+ const at = (seg, dtS, climbs = true) => {
+ const track = (seg.trackDeg * Math.PI) / 180;
+ const w = (seg.turnRateDps * Math.PI) / 180;
+ const v = seg.speedMps;
+ const straight = Math.abs(w) < 1e-4;
+ const e = straight
+ ? v * dtS * Math.sin(track)
+ : (v / w) * (Math.cos(track) - Math.cos(track + w * dtS));
+ const n = straight
+ ? v * dtS * Math.cos(track)
+ : (v / w) * (Math.sin(track + w * dtS) - Math.sin(track));
+ const u = climbs ? seg.climbMps * dtS : 0;
+ return {
+ x: fix.x + east.x * e + north.x * n + up.x * u,
+ y: fix.y + east.y * e + north.y * n + up.y * u,
+ z: fix.z + east.z * e + north.z * n + up.z * u,
+ };
+ };
+ const segments = {
+ straight: {
+ kind: 'arc',
+ fix,
+ t0,
+ speedMps: 200,
+ trackDeg: 90,
+ turnRateDps: 0,
+ climbMps: 1000,
+ minDtS: -10,
+ maxDtS: 10,
+ },
+ turning: {
+ kind: 'arc',
+ fix,
+ t0,
+ speedMps: 150,
+ trackDeg: 0,
+ turnRateDps: 3,
+ climbMps: 1500,
+ minDtS: 0,
+ maxDtS: 20,
+ },
+ };
+ const set = r.draw.icons({
+ id: 'conf:climb',
+ motionClock: { kind: 'scene' },
+ });
+ const find = (id, p) => {
+ const s = r.query.project(p);
+ return r.query
+ .drillPick(s.x, s.y)
+ .find((x) => x.set === 'conf:climb' && x.id === id);
+ };
+ const apart = (a, b) => {
+ const sa = r.query.project(a);
+ const sb = r.query.project(b);
+ return Math.hypot(sa.x - sb.x, sa.y - sb.y);
+ };
+ for (const [id, seg, sceneDtS, dtS, name] of [
+ ['straight', segments.straight, 30, 10, 'climbed, clamped to maxDtS'],
+ ['straight', segments.straight, -30, -10, 'run backward to minDtS'],
+ ['turning', segments.turning, 8, 8, 'turning and climbing at 8 s'],
+ ]) {
+ set.set([{ id, pos: fix, image: DOT, size: [16, 16], motion: seg }]);
+ r.frame.setTime(t0 + sceneDtS * 1000);
+ await drawn(h, r);
+ const want = at(seg, dtS);
+ const flat = at(seg, dtS, false);
+ ok(apart(want, flat) > 20, `${name}: the test can tell`);
+ const hit = find(id, want);
+ ok(hit, `${name}: picked where the formula puts it`);
+ if (hit?.position)
+ nearVec(
+ hit.position,
+ want,
+ 50,
+ `${name}: at the formula's position`,
+ );
+ ok(!find(id, flat), `${name}: not at the fix's height`);
+ }
+ set.dispose();
+ },
+ { capability: 'motionSegments' },
+ ),
+ check(
+ 'draw.lodGroup',
+ 'members of a lodGroup show over their own ranges and picks report the group',
+ async (h, r) => {
+ const pos = h.toEcef({ ...AUSTIN, height: 0 });
+ const near = r.draw.points({ id: 'conf:lod-near' });
+ const far = r.draw.icons({ id: 'conf:lod-far' });
+ const members = (nearRange, farRange) => {
+ near.set([
+ {
+ id: 'a',
+ pos,
+ pixelSize: 20,
+ color: '#f00',
+ lodGroup: 'thing',
+ displayRange: nearRange,
+ },
+ ]);
+ far.set([
+ {
+ id: 'a',
+ pos,
+ image: DOT,
+ size: [20, 20],
+ lodGroup: 'thing',
+ displayRange: farRange,
+ },
+ ]);
+ };
+ const shown = () => {
+ const s = r.query.project(pos);
+ return r.query
+ .drillPick(s.x, s.y)
+ .filter(
+ (x) => x.set === 'conf:lod-near' || x.set === 'conf:lod-far',
+ );
+ };
+ members([0, 10000], [10000, Infinity]);
+ for (const [height, set] of [
+ [2000, 'conf:lod-near'],
+ [50000, 'conf:lod-far'],
+ ]) {
+ await overAustin(h, r, height);
+ await drawn(h, r);
+ const hits = shown();
+ deepEqual(
+ hits.map((x) => x.set),
+ [set],
+ `at ${height} m only ${set} is drawn`,
+ );
+ equal(hits[0]?.id, 'a', `at ${height} m the hit names the member`);
+ equal(hits[0]?.lodGroup, 'thing', `at ${height} m the group`);
+ }
+ members([0, 100000], [1000, Infinity]);
+ await drawn(h, r);
+ deepEqual(
+ shown()
+ .map((x) => x.set)
+ .sort(),
+ ['conf:lod-far', 'conf:lod-near'],
+ 'overlapping ranges show both members',
+ );
+ const plain = r.draw.points({ id: 'conf:lod-none' });
+ plain.set([{ id: 'b', pos, pixelSize: 20, color: '#0f0' }]);
+ await drawn(h, r);
+ const s = r.query.project(pos);
+ const hit = r.query.drillPick(s.x, s.y).find((x) => x.id === 'b');
+ ok(hit && !('lodGroup' in hit), 'an item without a group has none');
+ near.dispose();
+ far.dispose();
+ plain.dispose();
+ },
+ ),
+ check(
+ 'draw.orbits',
+ 'an orbit is drawn where SGP4 puts it on its set’s motion clock',
+ async (h, r) => {
+ const t = Date.UTC(2026, 0, 4, 12);
+ const expected = (ms) => {
+ const date = new Date(ms);
+ const satrec = twoline2satrec(ISS_TLE[0], ISS_TLE[1]);
+ const eci = propagate(satrec, date).position;
+ const gmst = gstime(date);
+ const ecf = eciToEcf(eci, gmst);
+ const geo = eciToGeodetic(eci, gmst);
+ return {
+ ecef: { x: ecf.x * 1000, y: ecf.y * 1000, z: ecf.z * 1000 },
+ lon: (geo.longitude * 180) / Math.PI,
+ lat: (geo.latitude * 180) / Math.PI,
+ height: geo.height * 1000,
+ };
+ };
+ const set = r.draw.orbits({
+ id: 'conf:orbits',
+ motionClock: { kind: 'scene' },
+ });
+ set.set([
+ {
+ id: 'iss',
+ tle: ISS_TLE,
+ marker: { pixelSize: 16, color: '#fff' },
+ meta: { n: 1 },
+ },
+ ]);
+ const hitAt = (p) => {
+ const s = r.query.project(p);
+ return s
+ ? r.query
+ .drillPick(s.x, s.y)
+ .find((x) => x.set === 'conf:orbits' && x.id === 'iss')
+ : undefined;
+ };
+ const now = expected(t);
+ r.camera.setView({
+ destination: {
+ lon: now.lon,
+ lat: now.lat,
+ height: now.height + 2000000,
+ },
+ orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
+ });
+ r.frame.setTime(t);
+ await drawn(h, r);
+ const hit = hitAt(now.ecef);
+ ok(hit, 'picked at its propagated position');
+ deepEqual(hit?.meta, { n: 1 }, 'Hit.meta');
+ if (hit?.position)
+ nearVec(hit.position, now.ecef, 1000, 'within a kilometre of SGP4');
+ r.frame.setTime(t + 60000);
+ await drawn(h, r);
+ const later = expected(t + 60000);
+ const laterHit = hitAt(later.ecef);
+ ok(laterHit, 'a minute on, where SGP4 puts it then');
+ if (laterHit?.position)
+ nearVec(
+ laterHit.position,
+ later.ecef,
+ 1000,
+ 'a minute on, within a kilometre',
+ );
+ ok(!hitAt(now.ecef), 'no longer where it was');
+ set.dispose();
+ // `orbits` alone, without `motionSegments`, still reads the clock.
+ const alone = await h.create({
+ viewport: SCRIPT_VIEWPORT,
+ capabilities: [...r.capabilities].filter(
+ (c) => c !== 'motionSegments',
+ ),
+ });
+ if (alone)
+ try {
+ const only = alone.draw.orbits({
+ id: 'conf:orbits-alone',
+ motionClock: { kind: 'scene' },
+ });
+ only.set([
+ {
+ id: 'iss',
+ tle: ISS_TLE,
+ marker: { pixelSize: 16, color: '#fff' },
+ },
+ ]);
+ alone.camera.setView({
+ destination: {
+ lon: now.lon,
+ lat: now.lat,
+ height: now.height + 2000000,
+ },
+ orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
+ });
+ alone.frame.setTime(t);
+ await drawn(h, alone);
+ const s = alone.query.project(now.ecef);
+ ok(
+ alone.query
+ .drillPick(s.x, s.y)
+ .some((x) => x.set === 'conf:orbits-alone'),
+ 'without motionSegments, on the scene clock it was given',
+ );
+ only.dispose();
+ } finally {
+ alone.destroy();
+ }
+ },
+ { capability: 'orbits' },
+ ),
+ check(
+ 'draw.clampState',
+ 'clampState says whether clamped items drape or overlay',
+ (h, r) => {
+ ok(
+ ['draped', 'overlay'].includes(r.draw.clampState()),
+ 'draped or overlay',
+ );
+ const off = r.draw.on('clampState', () => {});
+ equal(typeof off, 'function', 'on returns an Off');
+ off();
+ off();
+ },
+ ),
check(
'draw.columns',
'column updates are rows: sized, picked, removed',
@@ -729,6 +1048,58 @@ export function portChecks() {
},
{ capability: 'columns' },
),
+ check(
+ 'draw.columns.shown',
+ 'a row hidden by its column keeps its motion and resumes where it would be',
+ async (h, r) => {
+ if (!r.capabilities.has('motionSegments'))
+ return { skip: 'no motionSegments' };
+ await overAustin(h, r, 8000);
+ const t0 = r.frame.time();
+ const from = h.toEcef({ ...AUSTIN, lon: AUSTIN.lon - 0.02, height: 0 });
+ const to = h.toEcef({ ...AUSTIN, lon: AUSTIN.lon + 0.02, height: 0 });
+ const along = (f) => ({
+ x: from.x + (to.x - from.x) * f,
+ y: from.y + (to.y - from.y) * f,
+ z: from.z + (to.z - from.z) * f,
+ });
+ const set = r.draw.icons({
+ id: 'conf:shown',
+ motionClock: { kind: 'scene' },
+ });
+ set.set([
+ {
+ id: 'mover',
+ pos: from,
+ image: DOT,
+ size: [16, 16],
+ motion: { kind: 'lerp', from, to, t0, t1: t0 + 10000 },
+ },
+ ]);
+ const pickAt = (p) => {
+ const s = r.query.project(p);
+ return r.query
+ .drillPick(s.x, s.y)
+ .some((x) => x.set === 'conf:shown');
+ };
+ const shown = (on) =>
+ set.setColumns({ ids: ['mover'], shown: new Uint8Array([on]) });
+ r.frame.setTime(t0 + 2000);
+ await drawn(h, r);
+ ok(pickAt(along(0.2)), 'moving before it is hidden');
+ shown(0);
+ r.frame.setTime(t0 + 4000);
+ await drawn(h, r);
+ ok(!pickAt(along(0.4)), 'hidden');
+ r.frame.setTime(t0 + 7000);
+ shown(1);
+ await drawn(h, r);
+ ok(pickAt(along(0.7)), 'shown again where its segment puts it now');
+ ok(!pickAt(from), 'not restarted');
+ set.dispose();
+ },
+ { capability: 'columns' },
+ ),
check(
'draw.models',
'a model set says when a model is ready',
@@ -882,6 +1253,51 @@ export function portChecks() {
},
{ capability: 'sampleHeight' },
),
+ check(
+ 'query.heights.sets',
+ 'draw sets are not ground to height queries unless they ask to be',
+ async (h, r) => {
+ await overAustin(h, r);
+ await drawn(h, r);
+ const point = { ...AUSTIN };
+ const before = await r.query.sampleHeight([point]);
+ const shapes = r.draw.shapes({ id: 'conf:not-ground' });
+ shapes.set([
+ {
+ id: 'tower',
+ geometry: {
+ kind: 'box',
+ center: { ...AUSTIN, height: 500 },
+ dimensions: { x: 200, y: 200, z: 1000 },
+ },
+ fill: '#888',
+ },
+ ]);
+ const points = r.draw.points({ id: 'conf:not-ground-points' });
+ points.set([
+ {
+ id: 'p',
+ pos: { ...AUSTIN, height: 800 },
+ pixelSize: 40,
+ color: '#f00',
+ },
+ ]);
+ await drawn(h, r);
+ const after = await r.query.sampleHeight([point]);
+ equal(after[0] === null, before[0] === null, 'answered as before');
+ if (after[0] && before[0])
+ near(
+ after[0].height,
+ before[0].height,
+ 0.5,
+ 'the same height with sets drawn over the point',
+ );
+ ok(after[0]?.source !== 'set', 'not answered from a set');
+ shapes.dispose();
+ points.dispose();
+ },
+ { capability: 'sampleHeight' },
+ ),
check(
'query.withoutCapability',
'without sampleHeight the height members answer null',
diff --git a/src/render/contract/common.d.ts b/src/render/contract/common.d.ts
index 93bf8ed34..1390d6dfd 100644
--- a/src/render/contract/common.d.ts
+++ b/src/render/contract/common.d.ts
@@ -186,6 +186,10 @@ export type Capability =
| 'celestial'
/** Agents moving along polylines (traffic flow). */
| 'agents'
+ /** Satellites propagated by the renderer from their elements (`draw.orbits`). */
+ | 'orbits'
+ /** Rate-limited course easing (`IconItem.courseGlide`). */
+ | 'courseGlide'
/** Screen-space clustering of icons. */
| 'cluster'
/** Post-processing styles. */
diff --git a/src/render/contract/draw.d.ts b/src/render/contract/draw.d.ts
index 4a1827983..c48b9dabc 100644
--- a/src/render/contract/draw.d.ts
+++ b/src/render/contract/draw.d.ts
@@ -55,12 +55,16 @@ export interface SetSpec {
drawOrder?: number;
/** `false`: the set never takes part in picks. */
pickable?: boolean;
- /** Height queries see through this set. */
- excludeFromHeightQueries?: boolean;
+ /**
+ * Height queries (`query.sampleHeight` and the rest) see this set as
+ * ground. Off by default: queries answer from tiles and terrain alone.
+ */
+ includeInHeightQueries?: boolean;
/** What clamped items clamp to. */
clampTo?: ClampTarget;
/**
- * `motionSegments`: the clock motion segments are evaluated on.
+ * `motionSegments` or `orbits`: the clock motion segments and orbits are
+ * evaluated on.
* `live` (the default) is wall time minus `delayMs`, as a live feed is
* drawn a little behind real time; `scene` is the frame port's scene time.
*/
@@ -79,8 +83,16 @@ export interface Placed {
pickable?: boolean;
scaleByDistance?: NearFar;
fadeByDistance?: NearFar;
- /** Shown only between these eye distances, metres. */
+ /** Shown only at eye distances in `[near, far)`, metres. */
displayRange?: [near: number, far: number];
+ /**
+ * Items in different sets that share a `lodGroup` are one thing drawn at
+ * different detail (a model near, an icon far), each shown over its own
+ * `displayRange`. A renderer may draw the group as one entity; a pick on
+ * it names the member shown at that distance (`Hit.set`, `Hit.id`) and
+ * reports the group (`Hit.lodGroup`).
+ */
+ lodGroup?: string;
/** Draw over depth within this many metres of the eye (`Infinity`: always). */
disableDepthTest?: number;
}
@@ -102,12 +114,14 @@ export type ImageRef =
* at both ends; the course runs from `course0Deg` by `courseSlopeDeg`
* across the segment.
* - arc: a constant-rate turn in the plane tangent to the ellipsoid at `fix`
- * (its east and north axes), at the fix's height, from `fix` at `t0`, at
- * `speedMps` along `trackDeg` turning at `turnRateDps` (under 1e-4 rad/s is
- * straight). The elapsed time is clamped to `[minDtS, maxDtS]`; a negative
- * `minDtS` runs the fix backward before `t0` (a warm-up), `maxDtS` is how
- * long it coasts.
- * `courseGlide` eases the displayed course onto the segment's at `rateDps`.
+ * (its east and north axes), from `fix` at `t0`, at `speedMps` along
+ * `trackDeg` turning at `turnRateDps` (under 1e-4 rad/s is straight), and
+ * climbing along the fix's up axis at `climbMps` (default 0). The elapsed
+ * time is clamped to `[minDtS, maxDtS]`; a negative `minDtS` runs the same
+ * path backward before `t0` (a warm-up), `maxDtS` is how long it coasts.
+ * The drawn course eases onto a new one over the item's `courseBlendMs`; an
+ * item that also has `courseGlide` eases at its rate instead, on a renderer
+ * with the `courseGlide` capability.
*/
export type MotionSegment =
| { kind: 'hold'; courseDeg?: number }
@@ -129,6 +143,8 @@ export type MotionSegment =
speedMps: number;
trackDeg: number;
turnRateDps: number;
+ /** Metres per second along the fix's up axis. */
+ climbMps?: number;
};
export interface CourseGlide {
@@ -147,7 +163,14 @@ export interface IconItem extends Placed {
scale?: number;
/** `motionSegments`: motion on the renderer's clock. */
motion?: MotionSegment;
+ /** `courseGlide`: ease the drawn course at a rate, from `fromCourseDeg`. */
courseGlide?: CourseGlide;
+ /**
+ * Ease the drawn course onto a new one over this long; 0 or omitted turns
+ * at once. Every renderer has it; with the `courseGlide` capability an
+ * item's `courseGlide`, when it has one, is used instead.
+ */
+ courseBlendMs?: number;
/** Degrees clockwise from north. */
course?: number;
/**
@@ -389,7 +412,12 @@ export type CelestialSpec =
| {
kind: 'stars';
id: string;
- catalogue: Float32Array;
+ /**
+ * Packed stars; omitted draws the renderer's own catalogue (one it
+ * streams, say), down to `magnitudeLimit`, or no stars when it has
+ * none.
+ */
+ catalogue?: Float32Array;
magnitudeLimit?: number;
}
| {
@@ -401,6 +429,29 @@ export type CelestialSpec =
widthPx: number;
};
+/**
+ * `orbits`: a satellite from its elements. The renderer propagates it with
+ * SGP4 on its set's `motionClock` and draws the marker and, optionally, its
+ * ring. Without the capability Core propagates and draws the positions
+ * itself as icons and lines.
+ */
+export interface OrbitItem {
+ id: string;
+ /** A two-line element set. */
+ tle: [line1: string, line2: string];
+ marker:
+ | { image: ImageRef; size: [width: number, height: number] }
+ | { pixelSize: number; color: Color };
+ /** The orbit's ring over `periods` orbits (default 1), in the inertial frame. */
+ ring?: { color: Color; widthPx: number; periods?: number } | null;
+ meta?: Meta;
+ show?: boolean;
+ pickable?: boolean;
+ /** As on `Placed`. */
+ displayRange?: [near: number, far: number];
+ lodGroup?: string;
+}
+
/** `agents`: dots moving along polylines (traffic flow). */
export interface AgentPathItem {
id: string;
@@ -426,6 +477,10 @@ export interface Columns {
positions?: Float64Array;
/** Degrees clockwise from north. */
courses?: Float32Array;
+ /**
+ * 0 hides a row. A hidden row keeps its motion state (where it is on its
+ * segment, its eased course), so showing it again resumes, not restarts.
+ */
shown?: Uint8Array;
scales?: Float32Array;
alphas?: Float32Array;
@@ -472,6 +527,18 @@ export interface DrawPort {
celestial?(spec: CelestialSpec): Handle;
/** `agents`. */
agents?(spec: SetSpec): DrawSet;
+ /** `orbits`. */
+ orbits?(spec: SetSpec): DrawSet;
+ /**
+ * Whether clamped items (`clampTo: 'tiles' | 'both'`, draped lines and
+ * shapes) are draped onto the 3D tiles now (`draped`), or drawn over them
+ * and hidden past the ellipsoid's horizon (`overlay`), the fallback a
+ * renderer uses where it cannot drape. Without tiles in the base it is
+ * `draped`: clamping to terrain or the ellipsoid always drapes.
+ */
+ clampState(): 'draped' | 'overlay';
+ /** Fires when `clampState()` changes (not on subscribing). */
+ on(event: 'clampState', cb: (state: 'draped' | 'overlay') => void): Off;
/** Register an atlas of images icons refer to by slot. */
atlas(id: string, images: Record): void;
}
diff --git a/src/render/contract/index.js b/src/render/contract/index.js
index 2019f7995..382e0f470 100644
--- a/src/render/contract/index.js
+++ b/src/render/contract/index.js
@@ -36,6 +36,8 @@ export const CAPABILITIES = Object.freeze([
'fields',
'celestial',
'agents',
+ 'orbits',
+ 'courseGlide',
'cluster',
'styles',
'bloom',
@@ -149,13 +151,22 @@ export const PORTS = deepFreeze({
['imagery.setSplit', 'splitSwipe'],
]),
draw: Object.fromEntries([
- ...required('icons', 'points', 'lines', 'shapes', 'atlas'),
+ ...required(
+ 'icons',
+ 'points',
+ 'lines',
+ 'shapes',
+ 'atlas',
+ 'clampState',
+ 'on',
+ ),
['models', 'models'],
['quads', 'quads'],
['meshes', 'meshes'],
['field', 'fields'],
['celestial', 'celestial'],
['agents', 'agents'],
+ ['orbits', 'orbits'],
]),
camera: Object.fromEntries(
required(
diff --git a/src/render/contract/query.d.ts b/src/render/contract/query.d.ts
index 31a6e9e48..5fc6bf6d1 100644
--- a/src/render/contract/query.d.ts
+++ b/src/render/contract/query.d.ts
@@ -15,6 +15,8 @@ export interface Hit {
id: string;
/** The item's `meta`, as it was given; absent when the item had none. */
meta?: Meta;
+ /** The item's `lodGroup`, when it has one: the thing it is a detail level of. */
+ lodGroup?: string;
/** The map itself (tiles or globe), rather than a drawn item. */
surface: boolean;
/** Where the hit is, when the renderer knows it. */
@@ -33,9 +35,13 @@ export interface PickWindow {
}
export interface HeightOptions {
- /** `surface`: tiles where they are drawn, otherwise terrain. */
+ /**
+ * `surface`: tiles where they are drawn, otherwise terrain. Sets drawn
+ * with `includeInHeightQueries` count under `surface` and `tiles`, never
+ * under `terrain`.
+ */
against?: 'surface' | 'terrain' | 'tiles';
- /** Set ids to see through. */
+ /** Set ids to see through, of those drawn with `includeInHeightQueries`. */
exclude?: string[];
}
@@ -44,7 +50,8 @@ export interface HeightSample {
height: number;
/** 1 for the most detailed data, lower for a coarser level. */
confidence: number;
- source: 'tiles' | 'terrain' | 'ellipsoid';
+ /** `set`: a draw set drawn with `includeInHeightQueries`. */
+ source: 'tiles' | 'terrain' | 'ellipsoid' | 'set';
/** The detail answered from: what is drawn now, or (`finestHeights`) the finest available. */
lod: 'visible' | 'finest';
/** Edge length of the tile answered from, metres, when known. */
diff --git a/src/render/stub/index.js b/src/render/stub/index.js
index 26507eee6..24cbc854f 100644
--- a/src/render/stub/index.js
+++ b/src/render/stub/index.js
@@ -12,6 +12,7 @@
import { CAPABILITIES, CONTRACT_VERSION } from '../contract/index.js';
import { createStubCamera } from './camera.js';
import { motionPosition } from './motion.js';
+import { orbitPosition } from './orbits.js';
import { computeViewRectangle } from './viewRectangle.js';
import {
add,
@@ -408,6 +409,7 @@ export function createStubRenderer(options = {}) {
return handle;
}
+ const clampListeners = [];
const draw = {
icons: (spec) => makeSet('icons', spec),
points: (spec) => makeSet('points', spec),
@@ -417,6 +419,18 @@ export function createStubRenderer(options = {}) {
atlases.set(id, { ...images });
log('draw', 'atlas', [id]);
},
+ // The stub drapes wherever it is asked to: it never falls back.
+ clampState: () => 'draped',
+ on(event, cb) {
+ if (event !== 'clampState') return () => {};
+ const entry = { cb, live: true };
+ clampListeners.push(entry);
+ return () => {
+ entry.live = false;
+ const i = clampListeners.indexOf(entry);
+ if (i >= 0) clampListeners.splice(i, 1);
+ };
+ },
};
if (capabilities.has('models'))
draw.models = (spec) => makeSet('models', spec);
@@ -425,6 +439,10 @@ export function createStubRenderer(options = {}) {
draw.meshes = (spec) => makeSet('meshes', spec);
if (capabilities.has('agents'))
draw.agents = (spec) => makeSet('agents', spec);
+ /** Parsed orbit elements, by line pair; cleared on destroy. */
+ const satrecs = new Map();
+ if (capabilities.has('orbits'))
+ draw.orbits = (spec) => makeSet('orbits', spec);
if (capabilities.has('fields'))
draw.field = (spec) => {
const handle = simpleHandle('field', spec);
@@ -528,6 +546,10 @@ export function createStubRenderer(options = {}) {
}
function itemPosition(set, item, aheadMs = 0) {
+ if (set.kind === 'orbits')
+ return item.tle
+ ? orbitPosition(satrecs, item.tle, motionTime(set) + aheadMs)
+ : null;
if (!item.pos) return null;
const base = toEcef(item.pos);
if (
@@ -556,17 +578,25 @@ export function createStubRenderer(options = {}) {
if (
set.kind !== 'icons' &&
set.kind !== 'points' &&
- set.kind !== 'models'
+ set.kind !== 'models' &&
+ set.kind !== 'orbits'
)
continue;
for (const item of set.items.values()) {
if (item.show === false || item.pickable === false) continue;
const p = itemPosition(set, item);
if (!p || isOccluded(p)) continue;
+ if (item.displayRange) {
+ const distance = magnitude(sub(p, eye().position));
+ const [nearM, farM] = item.displayRange;
+ if (distance < nearM || distance >= farM) continue;
+ }
const s = project(p);
if (!s) continue;
+ const pixel = item.pixelSize ?? item.marker?.pixelSize ?? 10;
const size = item.pickProxy?.size ??
- item.size ?? [item.pixelSize ?? 10, item.pixelSize ?? 10];
+ item.size ??
+ item.marker?.size ?? [pixel, pixel];
if (
Math.abs(s.x - x) <= size[0] / 2 + halfW &&
Math.abs(s.y - y) <= size[1] / 2 + halfH
@@ -575,6 +605,7 @@ export function createStubRenderer(options = {}) {
set: setId,
id: item.id,
meta: item.meta,
+ ...(item.lodGroup !== undefined ? { lodGroup: item.lodGroup } : {}),
surface: false,
position: p,
depth: magnitude(sub(p, eye().position)),
@@ -1166,6 +1197,7 @@ export function createStubRenderer(options = {}) {
for (const clear of onceTimers) clear();
onceTimers.clear();
sets.clear();
+ satrecs.clear();
},
testing: {
/** Run one frame of the loop; true when it drew. */
diff --git a/src/render/stub/motion.js b/src/render/stub/motion.js
index d8bd5ad48..473b63b0d 100644
--- a/src/render/stub/motion.js
+++ b/src/render/stub/motion.js
@@ -44,8 +44,12 @@ export function motionPosition(segment, pos, t) {
east = (v / w) * (Math.cos(track) - Math.cos(track + w * dt));
north = (v / w) * (Math.sin(track + w * dt) - Math.sin(track));
}
+ const up = (segment.climbMps ?? 0) * dt;
const enu = eastNorthUp(segment.fix);
- return add(segment.fix, add(scale(enu.x, east), scale(enu.y, north)));
+ return add(
+ segment.fix,
+ add(add(scale(enu.x, east), scale(enu.y, north)), scale(enu.z, up)),
+ );
}
default:
throw new Error(`unknown motion segment ${segment.kind}`);
diff --git a/src/render/stub/orbits.js b/src/render/stub/orbits.js
new file mode 100644
index 000000000..640e91f6c
--- /dev/null
+++ b/src/render/stub/orbits.js
@@ -0,0 +1,27 @@
+/**
+ * @module render/stub/orbits
+ * @description Where an orbit item is at a time: its two-line elements
+ * propagated with SGP4 and turned into Earth-fixed metres.
+ */
+import { eciToEcf, gstime, propagate, twoline2satrec } from 'satellite.js';
+
+/**
+ * @param {Map} satrecs Parsed elements by line pair, the caller's cache.
+ * @param {[string, string]} tle
+ * @param {number} t Epoch ms.
+ * @returns {{x: number, y: number, z: number}|null} ECEF metres, or null when the elements cannot be propagated to `t`.
+ */
+export function orbitPosition(satrecs, tle, t) {
+ const key = `${tle[0]}\n${tle[1]}`;
+ let satrec = satrecs.get(key);
+ if (!satrec) {
+ satrec = twoline2satrec(tle[0], tle[1]);
+ satrecs.set(key, satrec);
+ }
+ const date = new Date(t);
+ const state = propagate(satrec, date);
+ const eci = state?.position;
+ if (!eci || typeof eci !== 'object') return null;
+ const ecf = eciToEcf(eci, gstime(date));
+ return { x: ecf.x * 1000, y: ecf.y * 1000, z: ecf.z * 1000 };
+}