mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-08 04:14:02 +00:00
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:
1 parent
2fc99e7c19
commit
14f06df735
10 files changed
+597
-21
No files matched your search
@@ -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.
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Vendored
+4
@@ -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. */
|
||||
|
||||
Vendored
+78
-11
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Vendored
+10
-3
@@ -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. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user