From 14f06df7358ae9eedb27a5e45a2ac59cf9428244 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 12:50:58 -0800 Subject: [PATCH] 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. --- docs/RENDERER-CONTRACT.md | 11 +- scripts/package-boundaries.json | 3 +- src/render/conformance/checks/ports.js | 416 +++++++++++++++++++++++++ src/render/contract/common.d.ts | 4 + src/render/contract/draw.d.ts | 89 +++++- src/render/contract/index.js | 13 +- src/render/contract/query.d.ts | 13 +- src/render/stub/index.js | 36 ++- src/render/stub/motion.js | 6 +- src/render/stub/orbits.js | 27 ++ 10 files changed, 597 insertions(+), 21 deletions(-) create mode 100644 src/render/stub/orbits.js 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 }; +}