Settle the draw decisions in the renderer contract

- lodGroup links items across sets as one thing at different detail;
  picks name the member shown and report the group. displayRange is
  half-open.
- An orbits capability: the renderer propagates two-line elements with
  SGP4 on the set's motion clock. The stub propagates with satellite.js.
- Height queries see draw sets only with includeInHeightQueries (was
  excludeFromHeightQueries); HeightSample.source gains 'set'.
- courseGlide is a capability; courseBlendMs eases the course on every
  renderer.
- Arc segments climb at climbMps; a negative minDtS stays for the
  aircraft warm-up.
- The star catalogue is optional, a hidden column row keeps its motion,
  and draw.clampState() reports the drape fallback.

Checks cover the climb and warm-up, lodGroup ranges, orbits against SGP4
(with and without motionSegments), hidden rows resuming, and sets not
answering height queries by default.
This commit is contained in:
Sameh Khamis committed 2026-11-05 12:50:58 -08:00
1 parent 2fc99e7c19
commit 14f06df735
10 files changed
+597 -21

No files matched your search

+9 -2
View File
@@ -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.
+2 -1
View File
@@ -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"]
}
}
+416
View File
@@ -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: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><circle cx="8" cy="8" r="8" fill="#fff"/></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',
+4
View File
@@ -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. */
+78 -11
View File
@@ -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<AgentPathItem>;
/** `orbits`. */
orbits?(spec: SetSpec): DrawSet<OrbitItem>;
/**
* 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<string, ImageRef>): void;
}
+12 -1
View File
@@ -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(
+10 -3
View File
@@ -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. */
+34 -2
View File
@@ -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. */
+5 -1
View File
@@ -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}`);
+27
View File
@@ -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<string, object>} 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 };
}