mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-07 11:54:02 +00:00
Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70123fe4a9 | ||
|
|
fbcac37f13 | ||
|
|
9049357916 | ||
|
|
8f612ba858 | ||
|
|
6ffdf30795 | ||
|
|
2bb0b1e367 | ||
|
|
8b6bbf0f21 | ||
|
|
96bc73181b | ||
|
|
c6bab5dcaa | ||
|
|
30c56a8ac8 | ||
|
|
910a691494 | ||
|
|
d0ef3a902b | ||
|
|
d6b553646e | ||
|
|
cf915e8c16 | ||
|
|
9c9ba376e4 | ||
|
|
764b40950f | ||
|
|
c6f043d3fd | ||
|
|
e5f8e5753a | ||
|
|
2adec097de | ||
|
|
e40a4c78d5 | ||
|
|
1d9c731c10 | ||
|
|
68855b2089 | ||
|
|
bc69e8a4ee | ||
|
|
0b1c5d0f23 | ||
|
|
e3baeec223 | ||
|
|
f95324acb1 | ||
|
|
de0ff6cd2d | ||
|
|
24bc8fce7e | ||
|
|
1f2479d3ab | ||
|
|
21f284f79c | ||
|
|
60a3a2808e | ||
|
|
468590485b | ||
|
|
3e48e453d0 | ||
|
|
3837e453ca | ||
|
|
48f06c68c8 | ||
|
|
bdc7692292 | ||
|
|
92fd2b0c2e | ||
|
|
a5143f4467 | ||
|
|
03f2e2a9b9 | ||
|
|
8d7f82f110 | ||
|
|
bcd24c458b | ||
|
|
6060c2cee8 | ||
|
|
bf758a0abb | ||
|
|
e32988ae5e | ||
|
|
52d82b453c | ||
|
|
fd1c09dbbc | ||
|
|
5652236a27 | ||
|
|
9f165e68f3 | ||
|
|
fefdf242ee | ||
|
|
e9c0dc5405 | ||
|
|
14f06df735 | ||
|
|
2fc99e7c19 | ||
|
|
fb30287898 | ||
|
|
ad8adc85f4 | ||
|
|
d51ea4007b | ||
|
|
b51f1bf7c5 | ||
|
|
6035de88e2 | ||
|
|
f008120b4c | ||
|
|
00d1bee127 | ||
|
|
89b07fddfd | ||
|
|
fe32e47bdf | ||
|
|
9fde0a2a59 | ||
|
|
2c4c66c710 | ||
|
|
5f36eb0e0d | ||
|
|
7204d66f0b | ||
|
|
c5c0803901 | ||
|
|
66869b09ef | ||
|
|
47f7eeb00e | ||
|
|
1cfff5803a | ||
|
|
a40e5c334c | ||
|
|
f86dcc9179 | ||
|
|
42b17ffab6 | ||
|
|
07c8d7a17c | ||
|
|
95c0070412 |
No files matched your search
@@ -9,6 +9,8 @@ coverage
|
||||
pinokio/ENVIRONMENT
|
||||
pinokio/.installed
|
||||
src/data/local_data/
|
||||
# Written by scripts/record-camera-goldens.mjs and compared byte for byte.
|
||||
src/render/conformance/goldens/
|
||||
*.geojson
|
||||
*.geojsonl
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Renderer conformance</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font:
|
||||
13px/1.4 system-ui,
|
||||
sans-serif;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: #0d1117;
|
||||
color: #e6edf3;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid #30363d;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
h1 {
|
||||
font-size: 15px;
|
||||
margin: 0 12px 0 0;
|
||||
}
|
||||
button,
|
||||
select {
|
||||
font: inherit;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
#stage {
|
||||
width: 640px;
|
||||
height: 360px;
|
||||
position: fixed;
|
||||
left: -10000px;
|
||||
top: 0;
|
||||
}
|
||||
main {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
td,
|
||||
th {
|
||||
border-bottom: 1px solid #21262d;
|
||||
padding: 4px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.pass {
|
||||
color: #3fb950;
|
||||
}
|
||||
.fail {
|
||||
color: #f85149;
|
||||
}
|
||||
.skip {
|
||||
color: #8b949e;
|
||||
}
|
||||
.views {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(330px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
figure {
|
||||
margin: 0;
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
padding: 6px;
|
||||
}
|
||||
figure img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
figure .row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
figcaption {
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Renderer conformance</h1>
|
||||
<label
|
||||
>Renderer
|
||||
<select id="renderer">
|
||||
<option value="cesium">cesium (scene renderer)</option>
|
||||
</select></label
|
||||
>
|
||||
<button id="logic">Run logic suite (stub)</button>
|
||||
<button id="logicCesium">Run logic suite (cesium)</button>
|
||||
<button id="check">Check scenes</button>
|
||||
<button id="record">Record references</button>
|
||||
<span id="status"></span>
|
||||
</header>
|
||||
<div id="stage"></div>
|
||||
<main>
|
||||
<section id="logicResults"></section>
|
||||
<section class="views" id="views"></section>
|
||||
</main>
|
||||
<script type="module" src="./main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* The conformance page: runs the logic suite in the browser, and draws the
|
||||
* reference scenes on a renderer to check them against the stored
|
||||
* references (or record new ones). Driven by hand or through the
|
||||
* chrome-devtools MCP; `window.conformance` exposes the same actions.
|
||||
*/
|
||||
import {
|
||||
DEFAULT_TOLERANCE,
|
||||
runConformance,
|
||||
} from '../../src/render/conformance/index.js';
|
||||
import {
|
||||
REFERENCE_SCENES,
|
||||
sceneSupport,
|
||||
} from '../../src/render/conformance/scenes.js';
|
||||
import {
|
||||
compareImages,
|
||||
drawScene,
|
||||
referenceName,
|
||||
} from '../../src/render/conformance/visual.js';
|
||||
import { createStubHarness } from '../../src/render/stub/harness.js';
|
||||
import { toEcef } from '../../src/render/stub/math.js';
|
||||
import { createCesiumSceneRenderer } from '../../src/render/cesium/sceneRenderer.js';
|
||||
import {
|
||||
CESIUM_CHECKS,
|
||||
createCesiumHarness,
|
||||
} from '../../src/render/cesium/harness.js';
|
||||
|
||||
const REFERENCES = '/src/render/conformance/goldens/visual/';
|
||||
const SAVE = '/__conformance/references/';
|
||||
const ctx = { toEcef, assets: { model: '/models/c172.glb' } };
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const status = (text) => ($('status').textContent = text);
|
||||
|
||||
const RENDERERS = {
|
||||
cesium: () => createCesiumSceneRenderer({ container: $('stage') }),
|
||||
};
|
||||
|
||||
const HARNESSES = {
|
||||
stub: () => [createStubHarness('stub (browser)'), {}],
|
||||
cesium: () => [
|
||||
createCesiumHarness({ model: ctx.assets.model }),
|
||||
{ only: (id) => CESIUM_CHECKS.has(id) },
|
||||
],
|
||||
};
|
||||
|
||||
/** Runs the logic suite on `which`; `all` runs every check, listed or not. */
|
||||
async function runLogic(which = 'stub', { all = false } = {}) {
|
||||
status(`running the logic suite on ${which}…`);
|
||||
const [harness, options] = HARNESSES[which]();
|
||||
const report = await runConformance(harness, all ? {} : options);
|
||||
$('logicResults').innerHTML =
|
||||
`<p>${report.harness}: <b class="pass">${report.passed} passed</b>, <b class="fail">${report.failed} failed</b>, ${report.skipped} skipped</p>
|
||||
<table>${report.results
|
||||
.filter((r) => r.status !== 'pass')
|
||||
.map(
|
||||
(r) =>
|
||||
`<tr><td class="${r.status}">${r.status}</td><td>${r.id}</td><td>${escape(r.detail ?? '')}</td></tr>`,
|
||||
)
|
||||
.join('')}</table>`;
|
||||
status('');
|
||||
return {
|
||||
passed: report.passed,
|
||||
failed: report.failed,
|
||||
skipped: report.skipped,
|
||||
failures: report.results.filter((r) => r.status === 'fail'),
|
||||
};
|
||||
}
|
||||
|
||||
const escape = (s) =>
|
||||
s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' })[c]);
|
||||
|
||||
async function pixels(blob) {
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
||||
const g = canvas.getContext('2d');
|
||||
g.drawImage(bitmap, 0, 0);
|
||||
return g.getImageData(0, 0, bitmap.width, bitmap.height);
|
||||
}
|
||||
|
||||
// Object URLs shown for the current run, revoked when the next one starts.
|
||||
let objectUrls = [];
|
||||
function objectUrl(blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
objectUrls.push(url);
|
||||
return url;
|
||||
}
|
||||
|
||||
async function toUrl(imageData) {
|
||||
const canvas = new OffscreenCanvas(imageData.width, imageData.height);
|
||||
canvas.getContext('2d').putImageData(imageData, 0, 0);
|
||||
return objectUrl(await canvas.convertToBlob());
|
||||
}
|
||||
|
||||
/** The views the stored manifest lists, or none. */
|
||||
async function storedViews() {
|
||||
try {
|
||||
const res = await fetch(REFERENCES + 'manifest.json', {
|
||||
cache: 'no-store',
|
||||
});
|
||||
return res.ok ? ((await res.json()).views ?? []) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function reference(name) {
|
||||
const res = await fetch(REFERENCES + name, { cache: 'no-store' });
|
||||
return res.ok && res.headers.get('content-type')?.startsWith('image/')
|
||||
? res.blob()
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Draws every scene; records references or compares against them. */
|
||||
async function runScenes({ record = false, only } = {}) {
|
||||
const which = $('renderer').value;
|
||||
const renderer = await RENDERERS[which]();
|
||||
const results = [];
|
||||
$('views').innerHTML = '';
|
||||
for (const url of objectUrls) URL.revokeObjectURL(url);
|
||||
objectUrls = [];
|
||||
try {
|
||||
for (const scene of REFERENCE_SCENES) {
|
||||
if (only && !only.includes(scene.id)) continue;
|
||||
const unsupported = sceneSupport(scene, renderer, ctx);
|
||||
if (unsupported) {
|
||||
results.push({ scene: scene.id, status: 'skip', detail: unsupported });
|
||||
continue;
|
||||
}
|
||||
status(`${scene.id}…`);
|
||||
await drawScene(renderer, scene, ctx, async (view, capture, settle) => {
|
||||
const name = referenceName(scene.id, view.name);
|
||||
const figure = document.createElement('figure');
|
||||
$('views').append(figure);
|
||||
if (settle.state !== 'settled') {
|
||||
const detail = `${settle.state}: ${settle.blockers.join(', ')}`;
|
||||
results.push({
|
||||
scene: scene.id,
|
||||
view: view.name,
|
||||
status: 'fail',
|
||||
detail,
|
||||
});
|
||||
figure.innerHTML = `<img src="${objectUrl(capture)}"><figcaption class="fail">${name}: ${escape(detail)}</figcaption>`;
|
||||
return;
|
||||
}
|
||||
if (record) {
|
||||
const res = await fetch(SAVE + name, {
|
||||
method: 'PUT',
|
||||
body: capture,
|
||||
});
|
||||
results.push({
|
||||
scene: scene.id,
|
||||
view: view.name,
|
||||
status: res.ok ? 'recorded' : 'fail',
|
||||
detail: res.ok ? undefined : `save ${res.status}`,
|
||||
});
|
||||
figure.innerHTML = `<img src="${objectUrl(capture)}"><figcaption>${name}: recorded</figcaption>`;
|
||||
return;
|
||||
}
|
||||
const refBlob = await reference(name);
|
||||
if (!refBlob) {
|
||||
results.push({
|
||||
scene: scene.id,
|
||||
view: view.name,
|
||||
status: 'fail',
|
||||
detail: 'no reference',
|
||||
});
|
||||
figure.innerHTML = `<img src="${objectUrl(capture)}"><figcaption class="fail">${name}: no reference</figcaption>`;
|
||||
return;
|
||||
}
|
||||
const [actual, expected] = await Promise.all([
|
||||
pixels(capture),
|
||||
pixels(refBlob),
|
||||
]);
|
||||
const diff = new Uint8ClampedArray(actual.data.length);
|
||||
const outcome = compareImages(actual, expected, { diff });
|
||||
results.push({
|
||||
scene: scene.id,
|
||||
view: view.name,
|
||||
status: outcome.match ? 'pass' : 'fail',
|
||||
diffFraction: outcome.diffFraction,
|
||||
solidPixels: outcome.solidPixels,
|
||||
mismatchedPixels: outcome.mismatchedPixels,
|
||||
...(outcome.reason ? { detail: outcome.reason } : {}),
|
||||
});
|
||||
const diffUrl = outcome.reason
|
||||
? ''
|
||||
: await toUrl(new ImageData(diff, actual.width, actual.height));
|
||||
figure.innerHTML = `<div class="row"><img src="${objectUrl(capture)}" title="${which}"><img src="${objectUrl(refBlob)}" title="reference"><img src="${diffUrl}" title="difference"></div>
|
||||
<figcaption class="${outcome.match ? 'pass' : 'fail'}">${name}: ${outcome.solidPixels} solid, ${outcome.mismatchedPixels} unexcused, ${(outcome.diffFraction * 100).toFixed(3)}% differ ${outcome.reason ?? ''}</figcaption>`;
|
||||
});
|
||||
}
|
||||
if (record) {
|
||||
// A partial recording keeps the other scenes' entries.
|
||||
const kept = only
|
||||
? (await storedViews()).filter(
|
||||
(view) => !only.some((id) => view.startsWith(`${id}-`)),
|
||||
)
|
||||
: [];
|
||||
const recorded = results
|
||||
.filter((r) => r.status === 'recorded')
|
||||
.map((r) => referenceName(r.scene, r.view));
|
||||
await fetch(SAVE + 'manifest.json', {
|
||||
method: 'PUT',
|
||||
body:
|
||||
JSON.stringify(
|
||||
{
|
||||
renderer: which,
|
||||
size: {
|
||||
width: renderer.canvas.width,
|
||||
height: renderer.canvas.height,
|
||||
},
|
||||
views: [...kept, ...recorded],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + '\n',
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
renderer.destroy();
|
||||
status('');
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
$('logic').onclick = () => runLogic('stub');
|
||||
$('logicCesium').onclick = () => runLogic('cesium');
|
||||
$('check').onclick = () => runScenes();
|
||||
$('record').onclick = () => runScenes({ record: true });
|
||||
window.conformance = { runLogic, runScenes };
|
||||
@@ -81,6 +81,100 @@ constructed component objects; the component instances themselves remain mutable
|
||||
`ready` means all constructors have returned. Background feed activity and the
|
||||
standalone share-restoration result keep their existing separate contracts.
|
||||
|
||||
## Pointer input
|
||||
|
||||
`gods-eye-view/input` exports `inputFor(viewer)`, the scene's one input
|
||||
dispatcher. Every layer and tool takes its pointer input from it, and so can
|
||||
the caller: one `ScreenSpaceEventHandler` per scene, and a click picked once
|
||||
and handed to every subscriber.
|
||||
|
||||
```js
|
||||
import { inputFor } from 'gods-eye-view/input';
|
||||
|
||||
const input = inputFor(viewer);
|
||||
const off = input.onClick((event) => {
|
||||
if (event.ownerId === 'flights') showFlight(event.id);
|
||||
});
|
||||
// Later:
|
||||
off();
|
||||
```
|
||||
|
||||
`onClick` runs while no tool holds the pointer (the draw tool, the imagery
|
||||
box), so a vertex placed on an aircraft is not also a click on it. Its event
|
||||
reads lazily; a subscriber that reads nothing costs no pick:
|
||||
|
||||
| Member | What it is |
|
||||
| ------------------ | -------------------------------------------------------------------- |
|
||||
| `screenPosition` | Where the user clicked, in CSS pixels; for DOM and overlay hit-tests |
|
||||
| `position` | Where that lands in the pick buffer (corrected under sensor styles) |
|
||||
| `gesture` | `{ travelPx, durationMs }` of the press, to tell a click from a drag |
|
||||
| `pick` | `scene.pick` at `position` |
|
||||
| `hit` | `pick`, or on a coarse pointer the contact under a surface pick |
|
||||
| `id`, `ownerId` | The hit's pick id and the layer that registered it, or `null` |
|
||||
| `meta` | The hit's own metadata, when its pick carries any |
|
||||
| `surface` | Whether the hit is the map itself |
|
||||
| `drill(limit)` | `scene.drillPick`, shared by every subscriber of the click |
|
||||
| `pickWindow(w, h)` | `scene.pick` over a `w`×`h` window around `position` |
|
||||
| `ground()` | The world position under the click, or `null` over the sky |
|
||||
|
||||
`onPointer(type, fn, { lease })` subscribes to `down`, `move`, `up`, `click`
|
||||
or `dblclick`; with a lease it runs only while that lease is current. A tool
|
||||
takes its lease with `claimPointer(ownerId)` from the same export (and gives
|
||||
it back with `releasePointer(lease)`), which also keeps every ambient
|
||||
subscriber off its clicks; `isPointerFree()` and `pointerOwner()` report who
|
||||
holds it. `suspendCamera(reason)` and `suppressStockClicks()` return their
|
||||
own release: camera input and the viewer's stock select and double-click-track
|
||||
come back when the last hold is released, so two holders cannot undo each
|
||||
other. The dispatcher owns the camera controller's `enableInputs`: a direct
|
||||
write (Cesium's camera flights make one when they start and end) is kept, but
|
||||
reads true only while no hold is out. A caller-supplied `createScene` that creates its own viewer registers
|
||||
`defer(() => inputFor(viewer).destroy())` right after it; the standalone scene
|
||||
does this and returns the dispatcher as `scene.input`.
|
||||
|
||||
## Height queries
|
||||
|
||||
`gods-eye-view/queries/heights` exports `heightsFor(scene)`, the one place
|
||||
height queries go. Layers, tools and the camera ask it instead of calling
|
||||
`scene.sampleHeight`, `globe.getHeight`, `scene.clampToHeight` or the
|
||||
`*MostDetailed` variants themselves, so a renderer that answers heights another
|
||||
way plugs in without touching them.
|
||||
|
||||
```js
|
||||
import { heightsFor } from 'gods-eye-view/queries/heights';
|
||||
|
||||
const heights = heightsFor(viewer.scene);
|
||||
const ground = heights.now({ lon, lat }, { exclude: [ownMarkers] });
|
||||
if (ground !== null) place(ground);
|
||||
```
|
||||
|
||||
| Member | What it answers |
|
||||
| ----------------------------- | ------------------------------------------------------------------------- |
|
||||
| `now(point, opts)` | Height in metres from what is loaded, or `null` |
|
||||
| `clampNow(position, opts)` | The `Cartesian3` moved onto the drawn surface, or `null` |
|
||||
| `batch(points, opts)` | A promise of heights once the most detailed tiles stream; `null` per miss |
|
||||
| `clampBatch(positions, opts)` | A promise of clamped copies of the positions; `null` per miss |
|
||||
| `supported()` | `{ surface, clamp }`: whether the drawn surface can be queried at all |
|
||||
|
||||
A point is `{ lon, lat }` in degrees or a `Cesium.Cartographic` in radians.
|
||||
`against: 'surface'` (the default) reads what is drawn, 3D tiles and globe;
|
||||
`against: 'terrain'` reads the loaded terrain tiles alone. `exclude` lists the
|
||||
primitives and entities a query sees through. Every miss is `null`, including
|
||||
a Cesium throw for missing depth-texture support, and every caller treats it
|
||||
as one. Cesium answers `now()` synchronously from the scene, so it costs what
|
||||
the direct call did.
|
||||
|
||||
## Cesium internals
|
||||
|
||||
Core reaches private Cesium fields only through
|
||||
`src/render/cesium/internals.js`, one named helper per field (the main and
|
||||
offscreen pick views, a billboard's collection, a cluster's point collection,
|
||||
the widget's render-loop latch, the WebGL context and whether a camera flight
|
||||
is under way). `internals.test.mjs` fails when the installed Cesium stops
|
||||
declaring one of them, and when a private field is reached anywhere else.
|
||||
Imagery that rations its tile requests states the policy as a hook with
|
||||
`withTileRequestHook(provider, { requestImage, credit })` from
|
||||
`src/render/cesium/tileRequests.js`, which leaves the provider untouched.
|
||||
|
||||
## Standalone wiring
|
||||
|
||||
`src/main.js` reads the existing browser configuration and starts
|
||||
|
||||
@@ -33,12 +33,13 @@ export inventory. Use declared exports rather than reaching into internal files.
|
||||
| `src/layers/<family>/` | Source acquisition, records and Cesium resources with explicit controller/renderer owners |
|
||||
| `src/sources/` | Portable protocols and source contracts; request state belongs to each factory instance |
|
||||
| `src/services/` | Supplied application operations; scene construction owns caches and cancellation |
|
||||
| `src/render/` | The renderer contract, the stub renderer, the conformance suite and Cesium renderer pieces |
|
||||
| `src/voice/` | Portable action schemas/session, common controls, action execution and separate protocol adapters |
|
||||
| `server/providers/` | Node route factories, process-scoped provider caches and shutdown cleanup |
|
||||
| `server/standalone/` | Environment, local settings writes and server composition |
|
||||
|
||||
See [application construction](APPLICATION.md) and the
|
||||
[infrastructure contract](INFRASTRUCTURE-LAYERS.md) for construction interfaces.
|
||||
[infrastructure contract](INFRASTRUCTURE-LAYERS.md) for construction interfaces, and the [renderer contract](RENDERER-CONTRACT.md) for what a renderer provides.
|
||||
|
||||
## Import direction gates
|
||||
|
||||
|
||||
+54
-22
@@ -1414,7 +1414,7 @@ and a Denver coordinate underground. Search now resolves the application's
|
||||
ground-floor elevation (bounded wait, same ellipsoidal datum) before framing and
|
||||
warms the cell; after landing, `src/cameraGroundGuard.js` samples the rendered
|
||||
surface under the target and the camera once tiles stream in and lifts the eye
|
||||
to 120 m clearance, holding heading and pitch. Navigation ownership changes (Director, tracking, keyboard/UI controls and direct camera flights), layer/app teardown, and pointer/wheel gestures cancel the check and remove its listeners. `scripts/qa-context-arrivals.mjs` types Camp Mabry
|
||||
to 120 m clearance, holding heading and pitch. Navigation ownership changes (Director, tracking, keyboard/UI controls), any other caller moving the camera or starting a flight before a retry, layer/app teardown, and pointer/wheel gestures cancel the check and remove its listeners; the guard compares the pose it started on rather than wrapping camera methods. `scripts/qa-context-arrivals.mjs` types Camp Mabry
|
||||
and a Denver coordinate and requires at least 60 m clearance.
|
||||
|
||||
## Layer panel ownership
|
||||
@@ -1473,11 +1473,14 @@ remain unchanged.
|
||||
|
||||
## Visual effects ownership
|
||||
|
||||
VisualEffects owns style-stage creation, crossfades, animation scheduling, bloom
|
||||
and sharpen. Display supplies actions and renders settings; effect execution has
|
||||
no DOM dependency. Existing presets and the 500 ms transition remain unchanged.
|
||||
Stopping the controller revokes animation before asynchronous UI teardown; final
|
||||
destruction removes its stages and restores the borrowed bloom configuration.
|
||||
VisualEffects keeps the records of the styles, their crossfades, bloom and
|
||||
sharpen, and drives them through the renderer's effects port: it defines each
|
||||
preset there as a style, and the renderer runs the passes and animates the
|
||||
animated ones. Display supplies actions and renders settings; effect execution
|
||||
has no DOM dependency. Existing presets and the 500 ms transition remain
|
||||
unchanged. Stopping the controller revokes animation before asynchronous UI
|
||||
teardown; final destruction removes its styles and turns bloom and sharpen off,
|
||||
and the renderer restores the scene's bloom configuration when it goes.
|
||||
Snow is no longer a visual preset. A share link that names it, in either the
|
||||
long or compact form, opens on Normal and drops the Snow shader parameters. A
|
||||
view passed to embed mode or `show_in_gods_eye_view`, and a saved scene shot,
|
||||
@@ -2396,9 +2399,9 @@ which embeds the MAP_KEY.
|
||||
> so a turned-off layer's entities stayed reachable (Data Centers kept 116
|
||||
> MiB). `src/sceneCommandRelease.js`, installed in `src/app/tools.js`,
|
||||
> requests one frame through `governorRequestRender` after every layer
|
||||
> disable and then trims those arrays. It reads private Cesium fields;
|
||||
> `src/sceneCommandRelease.test.mjs` pins them and fails if Cesium renames
|
||||
> them or changes which passes it truncates.
|
||||
> disable and then trims those arrays. It reads private Cesium fields through
|
||||
> `src/render/cesium/internals.js`; `src/sceneCommandRelease.test.mjs` pins
|
||||
> them and fails if Cesium renames them or changes which passes it truncates.
|
||||
|
||||
> **2026-08-08 — performance waves 1+2:** the app idles via an explicit render
|
||||
> governor (`src/renderGovernor.js` — hold/release from every per-frame
|
||||
@@ -3463,8 +3466,14 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
||||
> celestial ring, annotations SVG, missions frame tick, and traffic).
|
||||
> - **World-label accounting:** All intended label/card migrations use the
|
||||
> shared host. The former native `LabelGraphics` exception for cable reference
|
||||
> labels was retired on 2026-08-18; cable text may render over tile geometry,
|
||||
> so **zero native world-label creation sites remain**. The mission replay DOM vehicle, awareness
|
||||
> labels was retired on 2026-08-18; cable text may render over tile geometry.
|
||||
> The Directions A/B letters (`directions-markers` source) and data-pack media
|
||||
> labels (a pooled `data-pack-labels:<n>` source per pack resource, through
|
||||
> the pack handle's `label`) moved to the host on 2026-11-04, and
|
||||
> `src/noCesiumLabels.test.mjs` no longer keeps an allow-list, so **zero
|
||||
> native world-label creation sites remain**. A media pack shown without an
|
||||
> `overlayHost` fails to load rather than drawing a native label. The mission
|
||||
> replay DOM vehicle, awareness
|
||||
> compass, annotation SVG callouts, and host-owned detection isolation target
|
||||
> are explicit exceptions; the awareness ring and cockpit contact pips are
|
||||
> non-text out-of-scope surfaces. Annotation callout text, geometry, leaders,
|
||||
@@ -3513,7 +3522,7 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
||||
> ambient-label host source. Its getter reads the already-propagated point
|
||||
> cache, preserving the 1 Hz fleet epoch and eliminating the former second ISS
|
||||
> propagation. Satellite tracking continues to publish exactly one protected
|
||||
> tracked-lane card through `gevLabelModel` and `_trackedDisplayCached`; the
|
||||
> tracked-lane card through its entity record's `labelModel` and `_trackedDisplayCached`; the
|
||||
> tracked entity is point-only. Tracking ISS suppresses the ambient entry and
|
||||
> untracking restores it, preventing duplicate ISS text. Disable, orbit-text
|
||||
> preference changes, catalog rebuild, and destroy clear/hide the source.
|
||||
@@ -3528,7 +3537,7 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
||||
> - **Phase 5 tracked civil aircraft label:** A tracked flight's Cesium entity
|
||||
> is a billboard-only camera target with no native label. The flight source
|
||||
> retains callsign/registration fallback, flight-level/altitude, speed, stale
|
||||
> state, airline/type, and plausible-route formatting in `gevLabelModel`; the
|
||||
> state, airline/type, and plausible-route formatting in the entity record's `labelModel`; the
|
||||
> one protected tracked-lane host card renders that complete model. Its
|
||||
> position getter reads only `_trackedDisplayCached`, the same authoritative
|
||||
> Cartesian already consumed by the tracked visual and camera, and never
|
||||
@@ -3635,8 +3644,11 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
||||
> - **Tracked-readout ownership:** `trackedReadout.js` is now a presentation-model
|
||||
> bridge only; it owns no canvas, projection, post-render listener, layout,
|
||||
> keyhole fade, or paint path. Civilian flights, military flights, satellites,
|
||||
> and mapped installations write explicit `gevLabelModel` objects and expose
|
||||
> `gevDisplayPosition` getters backed by their layer-owned frame/display cache.
|
||||
> and mapped installations write explicit `labelModel` objects and
|
||||
> `displayPosition` getters, backed by their layer-owned frame/display cache,
|
||||
> into Core's entity records (`src/data/entityRecords.js`), never onto the
|
||||
> Cesium entity. A layer's private per-entity state goes in its own store from
|
||||
> `createEntityStore()` (ALPR markers: `src/layers/alpr/markerState.js`).
|
||||
> Selected AIS vessels continue using the vessel source's protected selected
|
||||
> card, so they do not create a duplicate tracked readout. The host registers
|
||||
> the active readout in the protected tracked lane with a zero ambient quota;
|
||||
@@ -4394,11 +4406,11 @@ the same navigation authority as every other camera destination.
|
||||
given; re-arming for the other endpoint reuses that lease rather than asking
|
||||
for a second claim, and every release names the lease, so a superseded instance
|
||||
can never free the claim its replacement holds. The claim is held until
|
||||
the click that uses it has finished being dispatched — every layer binds its
|
||||
own handler to the same canvas and they all run inside one browser event, so a
|
||||
claim dropped the instant the endpoint is placed would hand the rest of that
|
||||
same click to the ambient handlers bound after this one. It is returned on the
|
||||
next tick after placement, and immediately on cancel, Escape, CLEAR, disable
|
||||
the click that uses it has finished being dispatched: the input dispatcher
|
||||
checks the claim once per click, before any subscriber runs, but DOM canvas
|
||||
listeners (the evidence cards) still see the same browser event afterwards, so
|
||||
a claim dropped the instant the endpoint is placed would hand them that click.
|
||||
It is returned on the next tick after placement, and immediately on cancel, Escape, CLEAR, disable
|
||||
and destroy; if another tool already holds it nothing is armed and the row says
|
||||
so. Maneuver-dot selection is an ordinary ambient handler and yields while any
|
||||
tool holds the pointer. FLY runs through the UI shell's immediate-navigation
|
||||
@@ -5628,9 +5640,9 @@ request viewbox and continues searching without that geographic preference.
|
||||
- **Measurements and centres are computed on unwrapped longitudes** (`unwrapLongitudes` / `wrapLongitude` in `drawMode.js`, used by `ringAreaM2`, `ringCentroid` and the engine's manual-area anchor). A ~50,000 m² rectangle straddling the antimeridian reads its 0.002° width as 359.998° under raw arithmetic: it measured thousands of square kilometres and anchored on the Greenwich meridian, half a world from the shape. Path length was already safe — haversine is periodic in the longitude delta — and a test pins that so a "fix" cannot break it.
|
||||
- **Bounds.** One shape holds at most `MAX_VERTICES` = 512 points; two clicks within 0.5 m are one vertex (that is what makes a double-click finish without leaving a stray point); a vertex that is not a finite on-globe coordinate is refused. A shape that encloses nothing is refused rather than placed: three collinear points are not an area and a line under a metre long is not a line, and the hint says which.
|
||||
- Drawn-area measures round before choosing m², ha or km², so 9,999.6 m² displays as `1.0 ha`, not `10000 m²`, and 999,960 m² as `1.00 km²`, not `100.0 ha`.
|
||||
- Pointer ownership: while a session is open the tool holds the single pointer claim (`claimPointer('draw')`, `src/data/inputOwnership.js`) and **every** ambient scene selection handler — the shared tracking click gesture behind flights, military and CCTV, plus vessels, satellites, launches, fires, installations, bikeshare, radio, submarine cables, the bundled GeoJSON layers and the CCTV calibration gizmo — returns before picking. A vertex over an aircraft is a vertex, not a selection.
|
||||
- Pointer ownership: while a session is open the tool holds the single pointer claim (`claimPointer('draw')`, `src/data/inputOwnership.js`), and the scene's input dispatcher (`src/input/dispatcher.js`) hands its clicks, moves and double clicks to the draw tool's leased subscriptions only: **every** ambient subscriber — flights, military, CCTV and its calibration gizmo, vessels, satellites, launches, fires, installations, bikeshare, radio, submarine cables, the bundled GeoJSON layers — is skipped for that event before anything is picked. A vertex over an aircraft is a vertex, not a selection.
|
||||
- **The claim is a LEASE.** `claimPointer(owner)` returns an opaque token or `null`; `releasePointer(lease)` takes that token. A second claim fails even under the same owner name, because two live instances of one tool — an old one mid-teardown and its replacement — are two owners; releasing by name let the old instance free the claim its successor was holding, and every layer started selecting through the live session's vertices. A superseded lease now releases nothing.
|
||||
- **Cesium's own click actions are borrowed too.** The Viewer binds a selecting LEFT_CLICK and a tracking LEFT_DOUBLE_CLICK on its own handler, outside every layer and therefore outside the shared claim — guarding the layers alone still left a drawn vertex selecting the entity under it. Both are removed for the session and restored, as the same function objects, when it ends or the tool is destroyed.
|
||||
- **Cesium's own click actions are suppressed too.** The Viewer binds a selecting LEFT_CLICK and a tracking LEFT_DOUBLE_CLICK on its own handler, outside every layer and therefore outside the shared claim — guarding the layers alone still left a drawn vertex selecting the entity under it. The tool takes the dispatcher's `suppressStockClicks()` hold for the session; both actions come back, as the same function objects, when the last hold is released (the imagery box takes the same hold).
|
||||
- **Enter belongs to whatever has the keyboard.** The finish key is taken only from the canvas and the page body. With a control focused — Clear, a shape button — Enter activates that control, as a keyboard user expects; it does not quietly finish the shape.
|
||||
- Pure half: `src/annotations/drawMode.js` (session, minimum vertices, double-click de-dup at 0.5 m, vertex ceiling, degenerate-geometry refusal, ring closing, dateline-safe length/area/centroid, hint text). Regression surface: `src/annotations/drawMode.test.mjs` and `src/data/inputOwnership.test.mjs` (pure), `src/annotations/drawToolBehaviour.test.mjs` (the tool driven against DOM and viewer doubles — leases, borrowed input actions, teardown, the Enter key, preview heights), `src/annotations/drawTool.test.mjs` (structural: where files live, which ids the markup carries, what the docs may claim), and the rendered harness `scripts/qa-draw-tool.mjs`.
|
||||
- The annotation tree is a declared ownership group: `./annotations` → `src/annotations/index.js` in `package.json`, with its module graph listed in `scripts/package-boundaries.json`, so an annotation module reaching outside that graph is a gate failure rather than a surprise in a layer's bundle. `drawMode.js`, `drawTool.js` and their tests are in `scripts/format-scope.json`.
|
||||
@@ -5985,6 +5997,26 @@ easier to meet (detection is now on more often), but does not create it.
|
||||
above and the DISPLAY-rail entry below (was `OFF`).
|
||||
- Style shader starting params: CRT/NVG/FLIR pixelation `1.2` (just above the native `1.0` floor); thermal/FLIR ships an optional Ironbow "Predator" palette (`palette` uniform, default `0` = accurate grayscale).
|
||||
|
||||
## Renderer contract
|
||||
|
||||
`src/render/contract/` types the renderer contract, version `0`: nine ports
|
||||
(frame, globe, draw, camera, query, input, effects, credits and quality),
|
||||
plus the renderer itself. `PORTS` lists every member at run time, and
|
||||
`contract.test.mjs` keeps that list and the types in step. The application
|
||||
does not yet draw through the contract: it still drives Cesium directly.
|
||||
`src/render/stub/` is a renderer that draws nothing, with the default
|
||||
camera's exact maths. `src/render/conformance/` is the suite: logic checks
|
||||
in Node, plus camera traces recorded from Cesium's own `Camera` into
|
||||
`goldens/camera.json`. It also holds the reference scenes, whose views are
|
||||
checked against `goldens/visual/` on the `/dev/conformance/` page. See
|
||||
[RENDERER-CONTRACT.md](RENDERER-CONTRACT.md).
|
||||
|
||||
`src/geo/` (export `./geo`) is the renderer-neutral maths: WGS84, ECEF and
|
||||
geodetic conversions, surface normals and east-north-up frames, as plain
|
||||
`{x, y, z}` objects. It follows Cesium's formulas step for step, and
|
||||
`geo.test.mjs` checks it against Cesium. The stub renderer's maths is built
|
||||
on it.
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- **Earthquake discs are STATIC geometry.** Every quake is a `CLAMP_TO_GROUND`
|
||||
|
||||
@@ -95,6 +95,11 @@ registry of source functions. Each receives `{ path, signal, maxBytes }` and
|
||||
returns `{ bytes: Uint8Array, mimeType }`. Custom transports must honor the
|
||||
signal, bound reads and return the real MIME type. They remain application code,
|
||||
not scene document fields. Without registrations, referenced sources fail closed.
|
||||
A standalone `SceneDirector` also passes `dataPacks.overlayHost`, the
|
||||
world-overlay host that draws pack world text such as a media pack's label (an
|
||||
object with `setEntries(sourceId, entries)` and `clearSource(sourceId)`);
|
||||
without it a media pack fails to load. `createApplicationTools` passes the
|
||||
application's shared host.
|
||||
|
||||
The portable session receives format adapters separately. Each adapter receives
|
||||
`{ pack, asset, anchors, signal }` and returns a resource with an idempotent
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
# Renderer contract
|
||||
|
||||
The renderer contract is what the application asks of a 3D renderer. A
|
||||
renderer other than the default Cesium one can draw the same application if
|
||||
it meets the contract. The contract is version `0`. Today the application
|
||||
still drives Cesium directly; later work moves the layers onto the contract
|
||||
one family at a time.
|
||||
|
||||
The package exports three modules:
|
||||
|
||||
| Export | What it is |
|
||||
| ---------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| `gods-eye-view/render/contract` | The types, one `.d.ts` per port, plus the run-time member list and shape check |
|
||||
| `gods-eye-view/render/stub` | A renderer that draws nothing, for tests |
|
||||
| `gods-eye-view/render/conformance` | The conformance suite, reference scenes and visual comparison |
|
||||
|
||||
## Ports
|
||||
|
||||
A renderer is created by a `RendererPlugin` (`{ id, create(options) }`). It
|
||||
exposes nine ports, each typed in its own file in `src/render/contract/`:
|
||||
|
||||
| Port | File | Covers |
|
||||
| --------- | -------------- | ------------------------------------------------------------------------------ |
|
||||
| `frame` | `frame.d.ts` | The render-on-demand loop, frame phases, continuous holds, scene time |
|
||||
| `globe` | `globe.d.ts` | Base (photoreal, terrain, vector, imagery), imagery layers, split, sky |
|
||||
| `draw` | `draw.d.ts` | Draw sets: icons, points, lines, shapes, models, quads, meshes, fields, agents |
|
||||
| `camera` | `camera.d.ts` | Pose, views, flights, `lookAt`, follow, user controls, events, matrices |
|
||||
| `query` | `query.d.ts` | Picks, projection, rays, heights |
|
||||
| `input` | `input.d.ts` | Pointer events with their pick made, leases, stock behaviours |
|
||||
| `effects` | `effects.d.ts` | Screen styles, bloom, sharpen, screen modulation |
|
||||
| `credits` | `credits.d.ts` | Data credits owed |
|
||||
| `quality` | `quality.d.ts` | Quality knobs, device facts, frame signals, benchmark |
|
||||
|
||||
The renderer itself (`renderer.d.ts`) adds capture, pixel reads, settling,
|
||||
resize, context loss and `destroy`. `common.d.ts` holds the shared shapes
|
||||
(ECEF positions, colours, listener semantics) and the capability list.
|
||||
|
||||
The rules that hold everywhere:
|
||||
|
||||
- **Core keeps its own records.** It never reads a renderer to learn its own
|
||||
state, only what the renderer measures: picks, depth, heights and
|
||||
readiness.
|
||||
- **Capabilities.** An optional member (`?`) exists only with the capability
|
||||
its documentation names first. A required member that names a capability
|
||||
always exists, and without the capability it answers null or empty.
|
||||
Quality support is listed by `quality.knobs()`, not by capabilities.
|
||||
- **Heights are physical.** All heights are metres above the ellipsoid,
|
||||
unexaggerated. Only drawing follows exaggerated terrain.
|
||||
- **Listeners** run in subscription order. A listener removed during
|
||||
delivery does not run; one added during delivery runs from the next event.
|
||||
A listener that throws is reported to the renderer's `error` event, or to
|
||||
the platform's `reportError` when nothing listens for errors. The other
|
||||
listeners, and the frame, still run.
|
||||
- **The frame order is fixed:** camera flight → input → camera `changed` →
|
||||
follow → `preUpdate` → move detection → `preRender` → render →
|
||||
`postRender`, the default renderer's own. A camera move made by follow or
|
||||
in `preUpdate` is reported by `changed` on the next frame.
|
||||
- **Leases:** a tool takes the pointer with `claimPointer`. With `grab`, it
|
||||
takes only the presses it grabs on `down`; the rest, and wheel and pinch,
|
||||
stay with the camera and everyone else.
|
||||
|
||||
`src/render/contract/index.js` lists every member of every port in `PORTS`,
|
||||
along with `CAPABILITIES`, `FRAME_STEPS` and `QUALITY_KEYS`.
|
||||
`checkRendererShape(renderer)` names each required member that is missing,
|
||||
and each capability member present without its capability.
|
||||
`contract.test.mjs` type-checks the contract under strict TypeScript, and
|
||||
fails when `PORTS` and the types disagree on a single member.
|
||||
|
||||
## The stub renderer
|
||||
|
||||
`createStubRenderer({ capabilities, viewport, time })` implements every port
|
||||
in memory and draws nothing. It has:
|
||||
|
||||
- the default camera's exact maths: views, `lookAt`, flights with their
|
||||
height arc and easing, `changed` thresholds and follows;
|
||||
- picks against what was drawn, or a scripted table;
|
||||
- scripted heights;
|
||||
- a virtual clock that nothing advances on its own.
|
||||
|
||||
`renderer.testing` drives it with `tick()`, `advance(ms)`, `pointer(type, x, y)`,
|
||||
`scriptPicks`, `setHeights` and `cacheHeight`, and records its calls.
|
||||
A listener that throws with nothing subscribed to `error` goes to
|
||||
`reportError`; Node has none, so the stub rethrows it on a microtask and
|
||||
`node:test` fails the running test. A test that expects a throw subscribes
|
||||
to `error`.
|
||||
The stub passes the whole conformance suite (`src/render/stub/stub.test.mjs`).
|
||||
|
||||
## Conformance suite
|
||||
|
||||
`runConformance(harness)` runs every check against a renderer through the
|
||||
contract alone and reports each one as passed, failed or skipped. It runs in
|
||||
Node and in a browser page. A harness tells the suite how to drive the
|
||||
renderer under test:
|
||||
|
||||
- `create({ viewport, capabilities })`;
|
||||
- `frame(r)`: one frame, 1/60 s on a virtual clock;
|
||||
- `now(r)` and `toEcef(p)`;
|
||||
- `tolerance` and `flightPaths`.
|
||||
|
||||
Optional members widen what is checked: `pointer` for synthetic input,
|
||||
`scriptHeights`, and `assets.model`.
|
||||
|
||||
There are two layers of checks.
|
||||
|
||||
**Logic checks** run in CI. They cover:
|
||||
|
||||
- the shape check;
|
||||
- listener semantics and the frame loop;
|
||||
- draw-set bookkeeping, picks and the `meta` round-trip;
|
||||
- projection, rays and heights;
|
||||
- leases and click picks;
|
||||
- imagery order, sky, styles, credits and quality knobs;
|
||||
- the camera traces.
|
||||
|
||||
**Camera traces.** `cameraScripts.js` lists camera scripts in the contract's
|
||||
terms. `scripts/record-camera-goldens.mjs` runs them on Cesium's own
|
||||
`Camera`, headless, on a virtual clock, and writes
|
||||
`src/render/conformance/goldens/camera.json`. A renderer under test must
|
||||
match each recorded pose:
|
||||
|
||||
- within 1 mm in position, 1e-9 in unit vectors and angles, and 1e-3 px on
|
||||
screen;
|
||||
- with every flight ending within one frame of the recording.
|
||||
|
||||
`src/render/cesium/cameraGoldens.test.mjs` fails when the installed Cesium
|
||||
no longer records the committed traces. After a Cesium upgrade or a script
|
||||
change, run `node scripts/record-camera-goldens.mjs` and review the diff.
|
||||
|
||||
**Visual checks** run in the browser. The reference scenes
|
||||
(`src/render/conformance/scenes.js`) are built from contract calls only:
|
||||
|
||||
- one scene per primitive family;
|
||||
- a city scene;
|
||||
- a calibration path from orbit down to the street.
|
||||
|
||||
The scenes load nothing from the network.
|
||||
|
||||
`npm run dev`, then open `/dev/conformance/`. The page can:
|
||||
|
||||
- **Run logic suite (stub)** runs the logic checks in the browser.
|
||||
- **Record references** draws every scene the chosen renderer supports.
|
||||
It saves each view to `src/render/conformance/goldens/visual/`, through a
|
||||
dev-server-only endpoint.
|
||||
- **Check scenes** draws the scenes again and compares each capture with its
|
||||
reference. Pixels are compared as pixelmatch compares them: by YIQ
|
||||
difference (threshold 0.1), with antialiasing in either image excused. A
|
||||
capture matches when at most 250 pixels are unexcused and at most 2 are
|
||||
solid (a differing pixel whose four neighbours differ too). Between them
|
||||
the two catch a dropped 4 px point, a dropped 2 px line and a 1 px shift
|
||||
of the view, and pass any MSAA setting. A change nowhere 3 px thick (a
|
||||
thin line gone, an item moved a pixel or two) that covers at most 250 px
|
||||
passes. `visual.js` has the measurements.
|
||||
The page shows the capture, the reference and the difference side by side.
|
||||
|
||||
`window.conformance.runScenes()` and `runLogic()` do the same from a script.
|
||||
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, 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. Nor are video frames (a check needs a
|
||||
video), `fetchTile` (the stub fetches nothing) or what clouds and water
|
||||
draw.
|
||||
Generated
+15
@@ -32,6 +32,7 @@
|
||||
"prettier": "3.9.6",
|
||||
"puppeteer": "^25.16.0",
|
||||
"sharp": "^0.35.4",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "^6.4.4",
|
||||
"vite-plugin-cesium": "^1.2.23",
|
||||
"ws": "^8.21.0"
|
||||
@@ -3545,6 +3546,20 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "8.9.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
|
||||
|
||||
+9
-1
@@ -79,6 +79,7 @@
|
||||
"prettier": "3.9.6",
|
||||
"puppeteer": "^25.16.0",
|
||||
"sharp": "^0.35.4",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "^6.4.4",
|
||||
"vite-plugin-cesium": "^1.2.23",
|
||||
"ws": "^8.21.0"
|
||||
@@ -227,6 +228,9 @@
|
||||
"./standalone/catalog": "./src/standalone/catalog.js",
|
||||
"./application/layers": "./src/app/constructCatalog.js",
|
||||
"./application/operations": "./src/app/operations.js",
|
||||
"./data/entity-records": "./src/data/entityRecords.js",
|
||||
"./input": "./src/input/dispatcher.js",
|
||||
"./queries/heights": "./src/queries/heights.js",
|
||||
"./data/lifecycle": "./src/data/lifecycle.js",
|
||||
"./ui/navigation": "./src/ui/navigationController.js",
|
||||
"./ui/share-restoration": "./src/ui/shareRestoration.js",
|
||||
@@ -290,6 +294,10 @@
|
||||
"./tools/panel": "./src/tools/panel.js",
|
||||
"./tools/services": "./src/tools/services.js",
|
||||
"./view": "./src/view/index.js",
|
||||
"./agent/world-packet": "./src/agent/worldPacket.js"
|
||||
"./agent/world-packet": "./src/agent/worldPacket.js",
|
||||
"./geo": "./src/geo/index.js",
|
||||
"./render/contract": "./src/render/contract/index.js",
|
||||
"./render/stub": "./src/render/stub/index.js",
|
||||
"./render/conformance": "./src/render/conformance/index.js"
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
advanceSpriteFocus,
|
||||
} from '../src/data/focusDeemphasis.js';
|
||||
import {
|
||||
applyAircraftBillboardTreatment,
|
||||
} from '../src/data/aircraftRecession.js';
|
||||
import { advanceSpriteFocus } from '../src/data/focusDeemphasis.js';
|
||||
import { aircraftTreatmentAlpha } from '../src/data/aircraftRecession.js';
|
||||
import { createIconSet } from '../src/data/iconSet.js';
|
||||
|
||||
if (typeof global.gc !== 'function') {
|
||||
throw new Error('focus-allocation-check.mjs requires node --expose-gc');
|
||||
@@ -66,17 +63,14 @@ const focusInput = {
|
||||
target,
|
||||
};
|
||||
|
||||
const baseColor = { withAlpha: (alpha) => ({ alpha }) };
|
||||
const billboard = { scale: 1, color: { alpha: 1 } };
|
||||
const aircraftInput = {
|
||||
billboard,
|
||||
baseScale: 1,
|
||||
baseAlpha: 1,
|
||||
baseColor,
|
||||
focusFactor: 1,
|
||||
cameraDistanceM: 100,
|
||||
cameraHeightM: 1000,
|
||||
};
|
||||
const icons = createIconSet({ capacity: 4 });
|
||||
const row = icons.add('sprite', {});
|
||||
|
||||
const baseline = bytesPerCall(() => focusInput.nowMs);
|
||||
const focus = netOfLoopBaseline(
|
||||
@@ -84,30 +78,22 @@ const focus = netOfLoopBaseline(
|
||||
baseline,
|
||||
);
|
||||
const aircraft = netOfLoopBaseline(
|
||||
bytesPerCall(() => applyAircraftBillboardTreatment(aircraftInput).alpha),
|
||||
bytesPerCall(() => aircraftTreatmentAlpha(aircraftInput)),
|
||||
baseline,
|
||||
);
|
||||
const productionSpriteTick = netOfLoopBaseline(
|
||||
bytesPerCall(() => {
|
||||
// Match the hot civilian/military sprite path: the caller constructs fresh
|
||||
// option literals, consumes the shared focus result immediately, and then
|
||||
// constructs the composed aircraft-treatment options for the same sprite.
|
||||
// Match the hot icon-set focus pass: the caller constructs a fresh option
|
||||
// literal, consumes the shared focus result immediately and writes it to
|
||||
// the row's focus column (the shader composes it with limb recession).
|
||||
const focusResult = advanceSpriteFocus(sprite, {
|
||||
screenPosition: focusInput.screenPosition,
|
||||
cameraDistance: focusInput.cameraDistance,
|
||||
nowMs: focusInput.nowMs,
|
||||
target,
|
||||
});
|
||||
const treatment = applyAircraftBillboardTreatment({
|
||||
billboard,
|
||||
baseScale: aircraftInput.baseScale,
|
||||
baseAlpha: aircraftInput.baseAlpha,
|
||||
baseColor,
|
||||
focusFactor: focusResult.factor,
|
||||
cameraDistanceM: aircraftInput.cameraDistanceM,
|
||||
cameraHeightM: aircraftInput.cameraHeightM,
|
||||
});
|
||||
return focusResult.factor + treatment.alpha;
|
||||
icons.setFocus(row, focusResult.factor);
|
||||
return focusResult.factor;
|
||||
}),
|
||||
baseline,
|
||||
);
|
||||
@@ -118,22 +104,24 @@ assert.ok(
|
||||
);
|
||||
assert.ok(
|
||||
aircraft.roundedMedian <= PURE_CALL_BUDGET,
|
||||
`applyAircraftBillboardTreatment allocated ${aircraft.rawMedian.toFixed(3)} B/call median`,
|
||||
`aircraftTreatmentAlpha allocated ${aircraft.rawMedian.toFixed(3)} B/call median`,
|
||||
);
|
||||
assert.ok(
|
||||
productionSpriteTick.roundedMedian <= PRODUCTION_SPRITE_TICK_BUDGET,
|
||||
`production sprite tick allocated ${productionSpriteTick.rawMedian.toFixed(3)} B/call median`,
|
||||
);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
callsPerTrial: CALLS,
|
||||
trials: TRIALS,
|
||||
budgets: {
|
||||
pureCall: PURE_CALL_BUDGET,
|
||||
productionSpriteTick: PRODUCTION_SPRITE_TICK_BUDGET,
|
||||
},
|
||||
loopBaseline: baseline,
|
||||
advanceSpriteFocus: focus,
|
||||
applyAircraftBillboardTreatment: aircraft,
|
||||
productionSpriteTick,
|
||||
}));
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
callsPerTrial: CALLS,
|
||||
trials: TRIALS,
|
||||
budgets: {
|
||||
pureCall: PURE_CALL_BUDGET,
|
||||
productionSpriteTick: PRODUCTION_SPRITE_TICK_BUDGET,
|
||||
},
|
||||
loopBaseline: baseline,
|
||||
advanceSpriteFocus: focus,
|
||||
aircraftTreatmentAlpha: aircraft,
|
||||
productionSpriteTick,
|
||||
}),
|
||||
);
|
||||
@@ -43,6 +43,25 @@
|
||||
"scripts/build-admin-packs.mjs",
|
||||
"scripts/qa-admin-outlines.mjs",
|
||||
"src/data/inputOwnership.test.mjs",
|
||||
"src/input/dispatcher.test.mjs",
|
||||
"src/input/testDoubles.mjs",
|
||||
"src/input/sensorPick.js",
|
||||
"src/input/dispatcher.js",
|
||||
"src/queries/heights.js",
|
||||
"src/render/cesium/internals.js",
|
||||
"src/render/cesium/cameraInput.js",
|
||||
"src/render/cesium/ctrlWheelZoom.js",
|
||||
"src/render/cesium/input.js",
|
||||
"src/render/cesium/input.test.mjs",
|
||||
"src/render/cesium/cameraInput.test.mjs",
|
||||
"src/render/cesium/cameraControls.js",
|
||||
"src/render/cesium/cameraControls.test.mjs",
|
||||
"src/render/input/router.js",
|
||||
"src/render/input/router.test.mjs",
|
||||
"src/render/cesium/internals.test.mjs",
|
||||
"src/render/cesium/tileRequests.js",
|
||||
"src/render/cesium/tileRequests.test.mjs",
|
||||
"src/queries/heights.test.mjs",
|
||||
"docs/CODE-BOUNDARIES.md",
|
||||
"src/main.js",
|
||||
"docs/APPLICATION.md",
|
||||
@@ -661,6 +680,12 @@
|
||||
"src/services/application.js",
|
||||
"src/services/requests.js",
|
||||
"src/app/scene.js",
|
||||
"src/render/cesium/appRenderer.test.mjs",
|
||||
"src/render/noCesiumOutsideRender.test.mjs",
|
||||
"src/render/cesiumAllowList.json",
|
||||
"src/geo/geo.test.mjs",
|
||||
"src/geo/celestial.test.mjs",
|
||||
"src/geo/quaternion.test.mjs",
|
||||
"src/app/controls.js",
|
||||
"src/app/data.js",
|
||||
"src/app/tools.js",
|
||||
@@ -1111,5 +1136,26 @@
|
||||
"src/cameraGlobeRotation.test.mjs",
|
||||
"src/voice/globeResetConfirmation.test.mjs",
|
||||
"src/hudDms.test.mjs",
|
||||
"server/providers/common/rateLimitKeys.test.mjs"
|
||||
"server/providers/common/rateLimitKeys.test.mjs",
|
||||
"src/render/contract/contract.test.mjs",
|
||||
"src/render/contract/common.d.ts",
|
||||
"src/render/contract/renderer.d.ts",
|
||||
"src/render/contract/frame.d.ts",
|
||||
"src/render/contract/globe.d.ts",
|
||||
"src/render/contract/draw.d.ts",
|
||||
"src/render/contract/camera.d.ts",
|
||||
"src/render/contract/query.d.ts",
|
||||
"src/render/contract/input.d.ts",
|
||||
"src/render/contract/effects.d.ts",
|
||||
"src/render/contract/credits.d.ts",
|
||||
"src/render/contract/quality.d.ts",
|
||||
"src/render/contract/index.d.ts",
|
||||
"src/render/stub/stub.test.mjs",
|
||||
"src/render/conformance/scenes.test.mjs",
|
||||
"src/render/cesium/cameraGoldens.test.mjs",
|
||||
"scripts/record-camera-goldens.mjs",
|
||||
"server/standalone/conformance-references.js",
|
||||
"dev/conformance/index.html",
|
||||
"dev/conformance/main.js",
|
||||
"docs/RENDERER-CONTRACT.md"
|
||||
]
|
||||
+2
-1
@@ -479,7 +479,8 @@ function readMarkup(file) {
|
||||
|
||||
// Text that never reaches the page as UI: tool results and descriptions for
|
||||
// AI clients (MCP and the voice model).
|
||||
const NOT_UI = [/^src\/tools\//];
|
||||
// The conformance suite's check titles are developer output, not UI.
|
||||
const NOT_UI = [/^src\/tools\//, /^src\/render\/conformance\//];
|
||||
|
||||
function sourceFiles() {
|
||||
return trackedFiles(
|
||||
|
||||
+812
-29
File diff suppressed because it is too large.
Load diff
@@ -127,7 +127,7 @@ try {
|
||||
let animeOn = false;
|
||||
for (let i = 0; i < s.postProcessStages.length; i++) {
|
||||
const stage = s.postProcessStages.get(i);
|
||||
if (stage.name === 'godsEyeView_anime')
|
||||
if (stage.name === 'effects_style_anime')
|
||||
animeOn = stage.enabled && stage.uniforms.intensity > 0.99;
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -1669,7 +1669,7 @@ async function main() {
|
||||
const prims = window.__godsEyeView.viewer.scene.primitives;
|
||||
let n = 0;
|
||||
for (let i = 0; i < prims.length; i++) {
|
||||
if (prims.get(i) && prims.get(i)._gevViewshed) n += 1;
|
||||
if (window.__godsEyeView.entityRecords.read(prims.get(i))?.viewshedCameraId) n += 1;
|
||||
}
|
||||
return n;
|
||||
});
|
||||
@@ -1729,7 +1729,7 @@ async function main() {
|
||||
const prims = window.__godsEyeView.viewer.scene.primitives;
|
||||
window.__qaViewshedRefs = [];
|
||||
for (let i = 0; i < prims.length; i++) {
|
||||
if (prims.get(i) && prims.get(i)._gevViewshed)
|
||||
if (window.__godsEyeView.entityRecords.read(prims.get(i))?.viewshedCameraId)
|
||||
window.__qaViewshedRefs.push(prims.get(i));
|
||||
}
|
||||
});
|
||||
@@ -1738,7 +1738,7 @@ async function main() {
|
||||
const prims = window.__godsEyeView.viewer.scene.primitives;
|
||||
const now = [];
|
||||
for (let i = 0; i < prims.length; i++) {
|
||||
if (prims.get(i) && prims.get(i)._gevViewshed) now.push(prims.get(i));
|
||||
if (window.__godsEyeView.entityRecords.read(prims.get(i))?.viewshedCameraId) now.push(prims.get(i));
|
||||
}
|
||||
const before = window.__qaViewshedRefs || [];
|
||||
const sameCount = now.length === before.length;
|
||||
|
||||
@@ -763,7 +763,7 @@ try {
|
||||
} : null,
|
||||
navigation: snapshot?.navigation || null,
|
||||
afterId: trackedInfo?.icao24 || null,
|
||||
viewerTrackedId: viewer.trackedEntity?.gevTrackedId || null,
|
||||
viewerTrackedId: window.__godsEyeView.entityRecords.read(viewer.trackedEntity)?.trackedId || null,
|
||||
contextVisible: !document.getElementById('military-awareness-panel')?.hidden,
|
||||
entryAvailableAfterSettlement: Boolean(entry && !entry.hidden),
|
||||
blockerStillRegistered: dataManager.layers.has(blockerId),
|
||||
@@ -1362,7 +1362,7 @@ try {
|
||||
contextMode: styleManager._contextControls._contextMode,
|
||||
contextChanging: styleManager._contextControls._contextModeChanging,
|
||||
subject: context?.subject ? `${context.subject.layerId}:${context.subject.id}` : null,
|
||||
tracked: tracker?.gevTrackedId || null,
|
||||
tracked: window.__godsEyeView.entityRecords.read(tracker)?.trackedId || null,
|
||||
density: styleManager.getDetectionState().densityPct,
|
||||
detectionMode: styleManager.getDetectionState().detectionMode,
|
||||
installations: dataManager.getLayerLifecycleState('military-installations'),
|
||||
|
||||
@@ -360,7 +360,9 @@ async function contacts() {
|
||||
entities: ds?.entities.values.length ?? 0,
|
||||
created,
|
||||
tracked:
|
||||
window.__godsEyeView.viewer.trackedEntity?.gevTrackedId ?? null,
|
||||
window.__godsEyeView.entityRecords.read(
|
||||
window.__godsEyeView.viewer.trackedEntity,
|
||||
)?.trackedId ?? null,
|
||||
cockpit: document.body.classList.contains('cockpit-mode'),
|
||||
});
|
||||
if (samples.length > 5000) samples.splice(0, 1000);
|
||||
@@ -375,8 +377,9 @@ async function contacts() {
|
||||
await page
|
||||
.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.viewer.trackedEntity?.gevTrackedId ===
|
||||
'flights:aaa051',
|
||||
window.__godsEyeView.entityRecords.read(
|
||||
window.__godsEyeView.viewer.trackedEntity,
|
||||
)?.trackedId === 'flights:aaa051',
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.catch(() => {});
|
||||
|
||||
+32
-68
@@ -263,39 +263,16 @@ try {
|
||||
}),
|
||||
);
|
||||
|
||||
// The viewer's OWN click actions, captured before the tool ever runs. Both
|
||||
// must be absent while drawing and back — as the SAME functions — after.
|
||||
await page.evaluate(() => {
|
||||
const Cesium = window.__CESIUM__;
|
||||
const stock = window.__godsEyeView.viewer.screenSpaceEventHandler;
|
||||
window.__qaStock = {
|
||||
click:
|
||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null,
|
||||
double:
|
||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
|
||||
null,
|
||||
// The viewer's own select and follow, which the input port runs: on before
|
||||
// the tool runs, off while drawing, and on again after.
|
||||
const stockActions = async () => {
|
||||
const state = await drawState();
|
||||
return {
|
||||
clickOn: state.stockSingleClick,
|
||||
doubleOn: state.stockDoubleClick,
|
||||
};
|
||||
});
|
||||
const stockActions = () =>
|
||||
page.evaluate(() => {
|
||||
const Cesium = window.__CESIUM__;
|
||||
const stock = window.__godsEyeView.viewer.screenSpaceEventHandler;
|
||||
const live = {
|
||||
click:
|
||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null,
|
||||
double:
|
||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
|
||||
null,
|
||||
};
|
||||
return {
|
||||
clickPresent: Boolean(live.click),
|
||||
doublePresent: Boolean(live.double),
|
||||
clickIsOriginal: live.click === window.__qaStock.click,
|
||||
doubleIsOriginal: live.double === window.__qaStock.double,
|
||||
hadClick: Boolean(window.__qaStock.click),
|
||||
hadDouble: Boolean(window.__qaStock.double),
|
||||
};
|
||||
});
|
||||
};
|
||||
const beforeDraw = await stockActions();
|
||||
|
||||
// ── enable / disable cycles leave nothing behind ────────────────────────
|
||||
const before = await drawState();
|
||||
@@ -318,24 +295,21 @@ try {
|
||||
`handler ${after.sceneHandler}, owner ${after.pointerOwner}, stock dblclick ${after.stockDoubleClick}`,
|
||||
);
|
||||
|
||||
// ── the viewer's own click actions are borrowed, then given back ────────
|
||||
// ── the viewer's own click actions are held off, then given back ───────
|
||||
await page.click('#draw-toggle');
|
||||
const duringDraw = await stockActions();
|
||||
check(
|
||||
"Cesium's own selecting click and tracking double-click are both borrowed while drawing",
|
||||
duringDraw.clickPresent === false && duringDraw.doublePresent === false,
|
||||
`click present ${duringDraw.clickPresent}, double present ${duringDraw.doublePresent} ` +
|
||||
`(viewer had click ${duringDraw.hadClick}, double ${duringDraw.hadDouble})`,
|
||||
"Cesium's own selecting click and tracking double-click are both off while drawing",
|
||||
duringDraw.clickOn === false && duringDraw.doubleOn === false,
|
||||
JSON.stringify({ beforeDraw, duringDraw }),
|
||||
);
|
||||
await page.click('#draw-toggle');
|
||||
const afterDraw = await stockActions();
|
||||
check(
|
||||
'and both are restored as the SAME functions, not replacements',
|
||||
afterDraw.clickPresent === duringDraw.hadClick &&
|
||||
afterDraw.doublePresent === duringDraw.hadDouble &&
|
||||
afterDraw.clickIsOriginal &&
|
||||
afterDraw.doubleIsOriginal,
|
||||
JSON.stringify(afterDraw),
|
||||
'and both are back after',
|
||||
afterDraw.clickOn === beforeDraw.clickOn &&
|
||||
afterDraw.doubleOn === beforeDraw.doubleOn,
|
||||
JSON.stringify({ beforeDraw, afterDraw }),
|
||||
);
|
||||
|
||||
// ── a pin, finished with Enter ──────────────────────────────────────────
|
||||
@@ -664,19 +638,6 @@ try {
|
||||
document.getElementById('draw-toggle')?.getBoundingClientRect().width > 0,
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
// The reload built a new page context: re-capture the viewer's own click
|
||||
// actions so the identity comparisons below still mean something.
|
||||
await page.evaluate(() => {
|
||||
const Cesium = window.__CESIUM__;
|
||||
const stock = window.__godsEyeView.viewer.screenSpaceEventHandler;
|
||||
window.__qaStock = {
|
||||
click:
|
||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null,
|
||||
double:
|
||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
|
||||
null,
|
||||
};
|
||||
});
|
||||
await page.evaluate((view) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.camera.cancelFlight?.();
|
||||
@@ -827,24 +788,20 @@ try {
|
||||
const wasActive = tool.active;
|
||||
await tool.destroy();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
const Cesium = window.__CESIUM__;
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const stock = viewer.screenSpaceEventHandler;
|
||||
let previews = 0;
|
||||
for (let i = 0; i < viewer.dataSources.length; i += 1)
|
||||
if (viewer.dataSources.get(i)?.name === 'gev-draw-preview') previews += 1;
|
||||
const diagnostics = tool.diagnostics();
|
||||
return {
|
||||
wasActive,
|
||||
diagnostics: tool.diagnostics(),
|
||||
diagnostics,
|
||||
previews,
|
||||
dataSources: viewer.dataSources.length,
|
||||
handleGone: window.__gevDrawTool === undefined,
|
||||
clickRestored:
|
||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) ===
|
||||
window.__qaStock.click,
|
||||
doubleRestored:
|
||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ===
|
||||
window.__qaStock.double,
|
||||
// The viewer's own select and follow run again.
|
||||
clickRestored: diagnostics.stockSingleClick,
|
||||
doubleRestored: diagnostics.stockDoubleClick,
|
||||
drawingClass: document.body.classList.contains('gev-drawing'),
|
||||
};
|
||||
});
|
||||
@@ -941,6 +898,11 @@ async function findClickableTarget() {
|
||||
const site = await page
|
||||
.evaluate(async () => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
// Core registers each local feature's context as `<layer id>:<feature id>`.
|
||||
const isDataCentre = (entity) =>
|
||||
window.__godsEyeView.entityRecords
|
||||
.read(entity)
|
||||
?.contextId?.startsWith('local-datacenters:');
|
||||
await window.__godsEyeView.dataManager.setEnabled(
|
||||
'local-datacenters',
|
||||
true,
|
||||
@@ -960,7 +922,7 @@ async function findClickableTarget() {
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
for (let i = 0; i < viewer.dataSources.length; i += 1) {
|
||||
for (const entity of viewer.dataSources.get(i).entities.values) {
|
||||
if (entity.__localLayerId !== 'local-datacenters') continue;
|
||||
if (!isDataCentre(entity)) continue;
|
||||
const point = centreOf(entity);
|
||||
if (!point) continue;
|
||||
const carto = window.Cesium
|
||||
@@ -993,8 +955,10 @@ async function findClickableTarget() {
|
||||
viewer.scene.requestRender();
|
||||
}, site);
|
||||
await wait(4000);
|
||||
const spot = await onScreen(
|
||||
(entity) => entity.__localLayerId === 'local-datacenters',
|
||||
const spot = await onScreen((entity) =>
|
||||
window.__godsEyeView.entityRecords
|
||||
.read(entity)
|
||||
?.contextId?.startsWith('local-datacenters:'),
|
||||
);
|
||||
return spot ? { ...spot, layer: 'data centre' } : null;
|
||||
}
|
||||
|
||||
@@ -169,8 +169,8 @@ async function observeTrackedHostPaint(page, timeoutMs = 5000) {
|
||||
const tracked = viewer?.trackedEntity;
|
||||
const current = {
|
||||
elapsedMs: Math.round(performance.now() - startedAt),
|
||||
entryId: tracked?.gevTrackedId || null,
|
||||
hasPresentationModel: Boolean(tracked?.gevLabelModel?.title),
|
||||
entryId: window.__godsEyeView?.entityRecords?.read(tracked)?.trackedId || null,
|
||||
hasPresentationModel: Boolean(window.__godsEyeView?.entityRecords?.read(tracked)?.labelModel?.title),
|
||||
candidateCount: diagnostics.candidateCount || 0,
|
||||
projectedCount: diagnostics.projectedCount || 0,
|
||||
selectedCount: diagnostics.selectedCount || 0,
|
||||
@@ -610,7 +610,7 @@ async function main() {
|
||||
if (!pool.length) return null;
|
||||
const ent = v.trackedEntity;
|
||||
let tracked = null;
|
||||
if (ent && typeof ent.gevDisplayPosition === 'function') tracked = ent.gevDisplayPosition();
|
||||
if (ent && typeof window.__godsEyeView.entityRecords.read(ent)?.displayPosition === 'function') tracked = window.__godsEyeView.entityRecords.read(ent)?.displayPosition();
|
||||
if (!tracked) return pool[0];
|
||||
let best = null, bestD = Infinity;
|
||||
for (const m of pool) {
|
||||
|
||||
@@ -218,9 +218,11 @@ try {
|
||||
const gev = window.__godsEyeView;
|
||||
const snap = () => infraIds.map((id) => {
|
||||
const visibleIds = [];
|
||||
// Core registers each local feature's context as `<layer id>:<feature id>`.
|
||||
for (let i = 0; i < gev.viewer.dataSources.length; i++) {
|
||||
for (const entity of gev.viewer.dataSources.get(i).entities.values) {
|
||||
if (entity.__localLayerId === id && entity.show) visibleIds.push(String(entity.id));
|
||||
const contextId = gev.entityRecords.read(entity)?.contextId;
|
||||
if (contextId?.startsWith(`${id}:`) && entity.show) visibleIds.push(String(entity.id));
|
||||
}
|
||||
}
|
||||
return visibleIds.sort();
|
||||
|
||||
@@ -17,9 +17,11 @@ export async function checkInstallationPan(page, dir) {
|
||||
'military-installations',
|
||||
).module;
|
||||
const centerIds = [];
|
||||
// Core registers each local feature's context as `<layer id>:<feature id>`.
|
||||
for (let i = 0; i < g.viewer.dataSources.length; i++)
|
||||
for (const e of g.viewer.dataSources.get(i).entities.values)
|
||||
if (e.__localLayerId === 'local-datacenters') centerIds.push(e.id);
|
||||
if (g.entityRecords.read(e)?.contextId?.startsWith('local-datacenters:'))
|
||||
centerIds.push(e.id);
|
||||
window.__polishPanFrames = [];
|
||||
window.__stopPolishPan = g.viewer.scene.postRender.addEventListener(() => {
|
||||
const sites = [],
|
||||
@@ -333,7 +335,8 @@ export async function checkInstallationSelection(
|
||||
for (let y = 250; y < 650; y += 75)
|
||||
for (let x = 400; x < 850; x += 75) {
|
||||
const pick = g.viewer.scene.pick({ x, y });
|
||||
if (!pick?.id?.installationId && !pick?.id?.gevTrackedId)
|
||||
// Nothing picked: no installation marker, outline or tracked contact.
|
||||
if (!pick?.id)
|
||||
return { x, y };
|
||||
}
|
||||
throw new Error('No empty map point');
|
||||
|
||||
@@ -1277,8 +1277,9 @@ try {
|
||||
outlines = 0;
|
||||
for (let i = 0; i < viewer.dataSources.length; i++)
|
||||
for (const entity of viewer.dataSources.get(i).entities.values) {
|
||||
if (!ids.has(entity.installationId || entity.id) || !entity.show)
|
||||
continue;
|
||||
// Outline entities are `<record id>:outline:<ring>`.
|
||||
const recordId = String(entity.id).replace(/:outline:\d+$/, '');
|
||||
if (!ids.has(recordId) || !entity.show) continue;
|
||||
if (entity.billboard || entity.point) markers++;
|
||||
if (entity.polyline) outlines++;
|
||||
}
|
||||
|
||||
@@ -3817,7 +3817,7 @@ async function main() {
|
||||
id: `qa:${ownerPrefix}:radio-camera-owner`,
|
||||
position: viewer.camera.positionWC.clone(),
|
||||
});
|
||||
owner.gevTrackedId = `${ownerPrefix}:qa-radio-camera-owner`;
|
||||
window.__godsEyeView.entityRecords.write(owner).trackedId = `${ownerPrefix}:qa-radio-camera-owner`;
|
||||
viewer.trackedEntity = owner;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const beforeStationId = radio.getUIState().selected?.id || null;
|
||||
@@ -3831,7 +3831,7 @@ async function main() {
|
||||
afterStationId: radio.getUIState().selected?.id || null,
|
||||
flyToDelta: window.__qaRadioFlyToCalls.length - beforeFlyToCalls,
|
||||
trackedIdentityPreserved: viewer.trackedEntity === owner,
|
||||
trackedId: viewer.trackedEntity?.gevTrackedId || null,
|
||||
trackedId: window.__godsEyeView.entityRecords.read(viewer.trackedEntity)?.trackedId || null,
|
||||
};
|
||||
viewer.trackedEntity = undefined;
|
||||
viewer.entities.remove(owner);
|
||||
@@ -3867,11 +3867,11 @@ async function main() {
|
||||
id: 'qa:flights:delayed-radio-fallback-owner',
|
||||
position: viewer.camera.positionWC.clone(),
|
||||
});
|
||||
owner.gevTrackedId = 'flights:qa-delayed-radio-fallback-owner';
|
||||
window.__godsEyeView.entityRecords.write(owner).trackedId = 'flights:qa-delayed-radio-fallback-owner';
|
||||
viewer.trackedEntity = owner;
|
||||
window.__qaDelayedRadioTrackingOwner = owner;
|
||||
window.__qaRejectRadioPlay();
|
||||
return owner.gevTrackedId;
|
||||
return window.__godsEyeView.entityRecords.read(owner)?.trackedId;
|
||||
});
|
||||
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
|
||||
const delayedFallbackOwnership = await page.evaluate((beforeFlyToCalls) => {
|
||||
@@ -3880,7 +3880,7 @@ async function main() {
|
||||
const result = {
|
||||
flyToDelta: window.__qaRadioFlyToCalls.length - beforeFlyToCalls,
|
||||
trackedIdentityPreserved: viewer.trackedEntity === owner,
|
||||
trackedId: viewer.trackedEntity?.gevTrackedId || null,
|
||||
trackedId: window.__godsEyeView.entityRecords.read(viewer.trackedEntity)?.trackedId || null,
|
||||
selectedId: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().selected?.id || null,
|
||||
};
|
||||
viewer.trackedEntity = undefined;
|
||||
@@ -4222,7 +4222,7 @@ async function main() {
|
||||
id: 'qa:flights:radio-recenter-stage-owner',
|
||||
position: viewer.camera.positionWC.clone(),
|
||||
});
|
||||
owner.gevTrackedId = 'flights:qa-radio-recenter-stage-owner';
|
||||
window.__godsEyeView.entityRecords.write(owner).trackedId = 'flights:qa-radio-recenter-stage-owner';
|
||||
viewer.trackedEntity = owner;
|
||||
completeFlight(0);
|
||||
const trackingBetweenStagesCalls = calls.length;
|
||||
|
||||
@@ -169,7 +169,7 @@ try {
|
||||
result.bloom.intensityPct === 98 &&
|
||||
snapshot.bloom.intensity === 98 &&
|
||||
manager._visualEffects.bloomIntensity === 98 &&
|
||||
manager._bloomStage.enabled
|
||||
manager.viewer.scene.postProcessStages.bloom.enabled
|
||||
);
|
||||
}),
|
||||
);
|
||||
@@ -183,7 +183,11 @@ try {
|
||||
result.sharpen.intensityPct === 61 &&
|
||||
manager.getVisualState().sharpen.intensity === 61 &&
|
||||
manager._visualEffects.sharpenIntensity === 0.61 &&
|
||||
Math.abs(manager._sharpenStage.uniforms.amount - 1.32) < 1e-9
|
||||
Math.abs(
|
||||
manager.viewer.scene.postProcessStages.getStageByName(
|
||||
'effects_sharpen',
|
||||
).uniforms.amount - 1.32,
|
||||
) < 1e-9
|
||||
);
|
||||
}),
|
||||
);
|
||||
@@ -199,9 +203,10 @@ try {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const effects = manager._visualEffects;
|
||||
manager.setStyle('retro');
|
||||
const owned = [...Object.values(effects.stages), effects.sharpenStage];
|
||||
const collection = manager.viewer.scene.postProcessStages;
|
||||
const previousBloom = effects.previousBloom;
|
||||
const owned = Object.keys(effects.stages).map((name) =>
|
||||
collection.getStageByName(`effects_style_${name}`),
|
||||
);
|
||||
const pending = manager.dispose();
|
||||
const stoppedBeforeAwait =
|
||||
effects.stopped &&
|
||||
@@ -210,12 +215,15 @@ try {
|
||||
await pending;
|
||||
return {
|
||||
stoppedBeforeAwait,
|
||||
removed: owned.every((stage) => !collection.contains(stage)),
|
||||
restored:
|
||||
effects.bloomStage.enabled === previousBloom.enabled &&
|
||||
Object.entries(previousBloom.uniforms).every(
|
||||
([name, value]) => effects.bloomStage.uniforms[name] === value,
|
||||
removed:
|
||||
owned.every(Boolean) &&
|
||||
Object.keys(effects.stages).every(
|
||||
(name) => !collection.getStageByName(`effects_style_${name}`),
|
||||
),
|
||||
// The renderer gives the scene's bloom back when it goes.
|
||||
restored:
|
||||
!collection.bloom.enabled &&
|
||||
!collection.getStageByName('effects_sharpen')?.enabled,
|
||||
};
|
||||
});
|
||||
check(
|
||||
@@ -223,7 +231,7 @@ try {
|
||||
teardown.stoppedBeforeAwait,
|
||||
);
|
||||
check(
|
||||
'UI teardown removes owned stages and restores borrowed bloom',
|
||||
'UI teardown removes its styles and turns bloom and sharpen off',
|
||||
teardown.removed && teardown.restored,
|
||||
);
|
||||
check(
|
||||
|
||||
@@ -150,7 +150,7 @@ try {
|
||||
manager._bloomSlider.focus();
|
||||
return {
|
||||
value: Number(manager._bloomSlider.value),
|
||||
contrast: manager._bloomStage.uniforms.contrast,
|
||||
contrast: manager.viewer.scene.postProcessStages.bloom.uniforms.contrast,
|
||||
};
|
||||
});
|
||||
await page.keyboard.press('ArrowRight');
|
||||
@@ -160,7 +160,8 @@ try {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
return (
|
||||
Number(manager._bloomSlider.value) > before.value &&
|
||||
manager._bloomStage.uniforms.contrast < before.contrast
|
||||
manager.viewer.scene.postProcessStages.bloom.uniforms.contrast <
|
||||
before.contrast
|
||||
);
|
||||
}, displayBefore),
|
||||
);
|
||||
@@ -267,13 +268,15 @@ try {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager._displayBindings._detachDisplayControls();
|
||||
const before = manager.bloomEnabled;
|
||||
const contrast = manager._bloomStage.uniforms.contrast;
|
||||
const contrast =
|
||||
manager.viewer.scene.postProcessStages.bloom.uniforms.contrast;
|
||||
manager._bloomBtn.click();
|
||||
manager._bloomSlider.value = '15';
|
||||
manager._bloomSlider.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
return (
|
||||
manager.bloomEnabled === before &&
|
||||
manager._bloomStage.uniforms.contrast === contrast
|
||||
manager.viewer.scene.postProcessStages.bloom.uniforms.contrast ===
|
||||
contrast
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env node
|
||||
// Writes the reference camera traces the conformance suite compares a
|
||||
// renderer against: src/render/conformance/goldens/camera.json. Run it after
|
||||
// a Cesium upgrade or a change to the camera scripts, and review the diff.
|
||||
//
|
||||
// node scripts/record-camera-goldens.mjs [--check]
|
||||
//
|
||||
// With --check it writes nothing and exits 1 when the file is out of date.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { recordCameraGoldens } from '../src/render/cesium/cameraGoldens.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const OUT = path.join(ROOT, 'src/render/conformance/goldens/camera.json');
|
||||
|
||||
const text = `${JSON.stringify(await recordCameraGoldens(), null, 2)}\n`;
|
||||
if (process.argv.includes('--check')) {
|
||||
const current = fs.existsSync(OUT) ? fs.readFileSync(OUT, 'utf8') : '';
|
||||
if (current !== text) {
|
||||
console.error(`${path.relative(ROOT, OUT)} is out of date`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
fs.mkdirSync(path.dirname(OUT), { recursive: true });
|
||||
fs.writeFileSync(OUT, text);
|
||||
console.log(`wrote ${path.relative(ROOT, OUT)}`);
|
||||
}
|
||||
@@ -701,7 +701,7 @@ async function main() {
|
||||
const tracked = manager?.layers?.get('flights')?.module?.getTrackedInfo?.();
|
||||
return manager?.isEnabled?.('flights')
|
||||
&& tracked?.icao24 === 'aaa001'
|
||||
&& window.__godsEyeView.viewer.trackedEntity?.gevTrackedId === 'flights:aaa001';
|
||||
&& window.__godsEyeView.entityRecords.read(window.__godsEyeView.viewer.trackedEntity)?.trackedId === 'flights:aaa001';
|
||||
},
|
||||
{ timeout: 30000, polling: 100 },
|
||||
);
|
||||
@@ -712,7 +712,7 @@ async function main() {
|
||||
flightsEnabled: manager.isEnabled('flights'),
|
||||
trackedId: flights.getTrackedInfo()?.icao24 || null,
|
||||
durableId: flights.getParams()?.selectedFlightsTrackingId || null,
|
||||
viewerTrackedId: window.__godsEyeView.viewer.trackedEntity?.gevTrackedId || null,
|
||||
viewerTrackedId: window.__godsEyeView.entityRecords.read(window.__godsEyeView.viewer.trackedEntity)?.trackedId || null,
|
||||
};
|
||||
});
|
||||
record(
|
||||
@@ -742,7 +742,7 @@ async function main() {
|
||||
const tracked = manager?.layers?.get('flights')?.module?.getTrackedInfo?.();
|
||||
return manager?.isEnabled?.('flights')
|
||||
&& tracked?.icao24 === 'aaa001'
|
||||
&& window.__godsEyeView.viewer.trackedEntity?.gevTrackedId === 'flights:aaa001';
|
||||
&& window.__godsEyeView.entityRecords.read(window.__godsEyeView.viewer.trackedEntity)?.trackedId === 'flights:aaa001';
|
||||
},
|
||||
{ timeout: 30000, polling: 100 },
|
||||
);
|
||||
@@ -753,7 +753,7 @@ async function main() {
|
||||
flightsEnabled: manager.isEnabled('flights'),
|
||||
trackedId: flights.getTrackedInfo()?.icao24 || null,
|
||||
durableId: flights.getParams()?.selectedFlightsTrackingId || null,
|
||||
viewerTrackedId: window.__godsEyeView.viewer.trackedEntity?.gevTrackedId || null,
|
||||
viewerTrackedId: window.__godsEyeView.entityRecords.read(window.__godsEyeView.viewer.trackedEntity)?.trackedId || null,
|
||||
};
|
||||
});
|
||||
record(
|
||||
@@ -815,7 +815,7 @@ async function main() {
|
||||
const latchedShare = await evalPage(() => ({
|
||||
tracked: window.__godsEyeView.dataManager.layers
|
||||
.get('flights').module.getTrackedInfo()?.icao24 || null,
|
||||
viewerTrackedId: window.__godsEyeView.viewer.trackedEntity?.gevTrackedId || null,
|
||||
viewerTrackedId: window.__godsEyeView.entityRecords.read(window.__godsEyeView.viewer.trackedEntity)?.trackedId || null,
|
||||
}));
|
||||
record(
|
||||
'share-v2: the pending subject latches on when it arrives on a later poll',
|
||||
@@ -1380,7 +1380,7 @@ async function main() {
|
||||
if (exactPickModel) return exactPickModel;
|
||||
const ent = window.__godsEyeView.viewer.trackedEntity;
|
||||
let tracked = null;
|
||||
if (ent && typeof ent.gevDisplayPosition === 'function') tracked = ent.gevDisplayPosition();
|
||||
if (ent && typeof window.__godsEyeView.entityRecords.read(ent)?.displayPosition === 'function') tracked = window.__godsEyeView.entityRecords.read(ent)?.displayPosition();
|
||||
if (!tracked) return pool[0];
|
||||
let best = null, bestD = Infinity;
|
||||
for (const m of pool) {
|
||||
@@ -1427,7 +1427,7 @@ async function main() {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const fl = dm.layers.get('flights').module;
|
||||
const ent = window.__godsEyeView.viewer.trackedEntity;
|
||||
const disp = ent && typeof ent.gevDisplayPosition === 'function' ? ent.gevDisplayPosition() : null;
|
||||
const disp = ent && typeof window.__godsEyeView.entityRecords.read(ent)?.displayPosition === 'function' ? window.__godsEyeView.entityRecords.read(ent)?.displayPosition() : null;
|
||||
const dets = fl.getDetectableObjects({ maxCount: 1000 });
|
||||
const me = dets.find((d) => d.id === icao || d.id === (fl.getTrackedInfo() && fl.getTrackedInfo().callsign));
|
||||
if (!disp || !me || !me.position) return null;
|
||||
@@ -1656,7 +1656,7 @@ async function main() {
|
||||
const ti = fl.getTrackedInfo();
|
||||
if (!ti) return null;
|
||||
const ent = v.trackedEntity;
|
||||
const disp = ent && typeof ent.gevDisplayPosition === 'function' ? ent.gevDisplayPosition() : null;
|
||||
const disp = ent && typeof window.__godsEyeView.entityRecords.read(ent)?.displayPosition === 'function' ? window.__godsEyeView.entityRecords.read(ent)?.displayPosition() : null;
|
||||
const pos = disp || ti.position;
|
||||
if (!pos) return null;
|
||||
const center = v.scene.cartesianToCanvasCoordinates(pos);
|
||||
@@ -1908,7 +1908,7 @@ async function main() {
|
||||
// presentation model (entities are asserted native-label-free);
|
||||
// the native read stays as a fallback so this probe still fails
|
||||
// loudly if BOTH surfaces are ever absent.
|
||||
const model = ent.gevLabelModel;
|
||||
const model = window.__godsEyeView.entityRecords.read(ent)?.labelModel;
|
||||
if (model) return [model.title, ...(model.details || [])].filter(Boolean).join('\n');
|
||||
if (!ent.label || !ent.label.text) return null;
|
||||
const t = ent.label.text;
|
||||
@@ -2553,7 +2553,7 @@ async function main() {
|
||||
}
|
||||
|
||||
// WELD (tracked): the tracked CARD anchors to the model you can see.
|
||||
// `gevVisualPosition` is a SEPARATE accessor from `gevDisplayPosition` on
|
||||
// the record's `visualPosition` is a SEPARATE accessor from its `displayPosition` on
|
||||
// purpose — the latter carries the follow-camera anti-jitter contract and
|
||||
// must keep returning the cached dead-reckoned value the camera settled
|
||||
// on. So this asserts both halves: the visual accessor is welded to the
|
||||
@@ -2580,10 +2580,10 @@ async function main() {
|
||||
const at = (p) => (p ? Math.hypot(
|
||||
p.x - anchor.x, p.y - anchor.y, p.z - anchor.z,
|
||||
) : null);
|
||||
const visual = typeof ent?.gevVisualPosition === 'function' ? ent.gevVisualPosition() : null;
|
||||
const display = typeof ent?.gevDisplayPosition === 'function' ? ent.gevDisplayPosition() : null;
|
||||
const visual = typeof window.__godsEyeView.entityRecords.read(ent)?.visualPosition === 'function' ? window.__godsEyeView.entityRecords.read(ent)?.visualPosition() : null;
|
||||
const display = typeof window.__godsEyeView.entityRecords.read(ent)?.displayPosition === 'function' ? window.__godsEyeView.entityRecords.read(ent)?.displayPosition() : null;
|
||||
return {
|
||||
hasVisualAccessor: typeof ent?.gevVisualPosition === 'function',
|
||||
hasVisualAccessor: typeof window.__godsEyeView.entityRecords.read(ent)?.visualPosition === 'function',
|
||||
visualDelta: at(visual),
|
||||
displayDelta: at(display),
|
||||
};
|
||||
@@ -2599,7 +2599,7 @@ async function main() {
|
||||
// anti-jitter accessor has been repurposed and the follow camera is at
|
||||
// risk. Skipped (not failed) when the DR cache is momentarily invalid.
|
||||
const displayDelta = trackedWeld.displayDelta;
|
||||
record('weld: gevDisplayPosition still reports the dead-reckoned position (anti-jitter intact)',
|
||||
record('weld: the record displayPosition still reports the dead-reckoned position (anti-jitter intact)',
|
||||
!Number.isFinite(displayDelta) || displayDelta > ACCESSOR_SEPARATION_EPSILON_M,
|
||||
Number.isFinite(displayDelta)
|
||||
? `displayΔ=${displayDelta.toFixed(3)} m (must stay > ${ACCESSOR_SEPARATION_EPSILON_M} m — the ground-snap split)`
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const DIR = fileURLToPath(
|
||||
new URL('../../src/render/conformance/goldens/visual/', import.meta.url),
|
||||
);
|
||||
const NAME = /^[a-z0-9-]+\.(png|json)$/;
|
||||
const MAX_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Dev server only: lets `/dev/conformance/` save the visual references it
|
||||
* records into `src/render/conformance/goldens/visual/`. Takes a PUT of one
|
||||
* PNG or the manifest, named in the path; nothing else is written.
|
||||
*/
|
||||
export function conformanceReferencesPlugin() {
|
||||
return {
|
||||
name: 'conformance-references',
|
||||
apply: 'serve',
|
||||
configureServer(server) {
|
||||
server.middlewares.use('/__conformance/references/', (req, res) => {
|
||||
const name = decodeURIComponent((req.url ?? '').replace(/^\//, ''));
|
||||
// Only the page on this machine: a loopback peer, sent from the
|
||||
// dev server's own origin.
|
||||
const peer = req.socket.remoteAddress ?? '';
|
||||
const loopback = /^(127\.|::1$|::ffff:127\.)/.test(peer);
|
||||
let sameOrigin = false;
|
||||
try {
|
||||
sameOrigin = new URL(req.headers.origin).host === req.headers.host;
|
||||
} catch {
|
||||
// No Origin, or not a URL.
|
||||
}
|
||||
if (!loopback || !sameOrigin) {
|
||||
res.writeHead(403).end();
|
||||
return;
|
||||
}
|
||||
if (req.method !== 'PUT' || !NAME.test(name)) {
|
||||
res.writeHead(400).end();
|
||||
return;
|
||||
}
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
req.on('data', (chunk) => {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BYTES) req.destroy();
|
||||
else chunks.push(chunk);
|
||||
});
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
await mkdir(DIR, { recursive: true });
|
||||
await writeFile(DIR + name, Buffer.concat(chunks));
|
||||
res.writeHead(204).end();
|
||||
} catch (error) {
|
||||
res.writeHead(500).end(String(error?.message ?? error));
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { createBrowserViteConfig } from '../../build/vite.js';
|
||||
import { localProviderPlugins } from '../providers/local.js';
|
||||
import { localMcpPlugin } from '../mcp/plugin.js';
|
||||
import { apiNotFoundPlugin } from './api-not-found.js';
|
||||
import { conformanceReferencesPlugin } from './conformance-references.js';
|
||||
import { googleTokenUnavailablePlugin } from './google-token-unavailable.js';
|
||||
import { standaloneVoiceTools } from './voiceTools.js';
|
||||
import { unwatchedRuntimeDirsPlugin } from './unwatched-cache.js';
|
||||
@@ -30,6 +31,7 @@ export function standaloneConfig(
|
||||
}),
|
||||
localMcpPlugin(),
|
||||
googleTokenUnavailablePlugin(),
|
||||
conformanceReferencesPlugin(),
|
||||
apiNotFoundPlugin(),
|
||||
unwatchedRuntimeDirsPlugin(),
|
||||
],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { toLonLatH } from '../geo/index.js';
|
||||
import {
|
||||
feedProvenanceEnvelope,
|
||||
layerSnapshot,
|
||||
@@ -109,9 +109,7 @@ export function getWorldPacket(options = {}) {
|
||||
if (!dataManager || typeof dataManager.getAll !== 'function')
|
||||
throw new TypeError('World Packet requires a data manager');
|
||||
|
||||
const cartographic = Cesium.Cartographic.fromCartesian(
|
||||
viewer.camera.positionWC,
|
||||
);
|
||||
const eye = toLonLatH(viewer.camera.positionWC);
|
||||
// Read the roster once. Voice must not rewalk disabled layers: preserving the
|
||||
// current enabled-only view keeps the model's context budget bounded.
|
||||
const rows = dataManager.getAll();
|
||||
@@ -126,9 +124,9 @@ export function getWorldPacket(options = {}) {
|
||||
ok: true,
|
||||
action: 'get_current_view_state',
|
||||
camera: {
|
||||
latitude: Cesium.Math.toDegrees(cartographic.latitude),
|
||||
longitude: Cesium.Math.toDegrees(cartographic.longitude),
|
||||
heightM: cartographic.height,
|
||||
latitude: eye.lat,
|
||||
longitude: eye.lon,
|
||||
heightM: eye.height,
|
||||
},
|
||||
style: styleManager?.activeStyle || 'normal',
|
||||
context: contextState
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isUnavailableCapability } from '../sources/capability.js';
|
||||
import { defaultGeospatial } from '../search/defaults.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { cameraOf } from '../render/cesium/camera.js';
|
||||
import { greatCircleMeters } from '../geoDistance.js';
|
||||
import {
|
||||
holdContinuousRender,
|
||||
@@ -1700,14 +1701,18 @@ export function createAnnotationEngine({
|
||||
Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0),
|
||||
);
|
||||
const sphere = Cesium.BoundingSphere.fromPoints(cart);
|
||||
viewer.camera.flyToBoundingSphere(sphere, {
|
||||
offset: new Cesium.HeadingPitchRange(
|
||||
viewer.camera.heading,
|
||||
Cesium.Math.toRadians(-35),
|
||||
Math.max(900, sphere.radius * 2.6),
|
||||
),
|
||||
duration: 1.6,
|
||||
});
|
||||
const camera = cameraOf(viewer);
|
||||
camera.flyToBounds(
|
||||
{ center: sphere.center, radius: sphere.radius },
|
||||
{
|
||||
offset: {
|
||||
heading: camera.pose().heading,
|
||||
pitch: Cesium.Math.toRadians(-35),
|
||||
range: Math.max(900, sphere.radius * 2.6),
|
||||
},
|
||||
durationS: 1.6,
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
/* camera assist is best-effort */
|
||||
}
|
||||
@@ -1757,22 +1762,23 @@ export function createAnnotationEngine({
|
||||
: anno.ring,
|
||||
)
|
||||
: viewportRange(anno.viewport) || 600;
|
||||
viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(
|
||||
Cesium.Cartesian3.fromDegrees(
|
||||
target.lon,
|
||||
target.lat,
|
||||
anno.anchor.height || 0,
|
||||
),
|
||||
range,
|
||||
),
|
||||
const camera = cameraOf(viewer);
|
||||
camera.flyToBounds(
|
||||
{
|
||||
offset: new Cesium.HeadingPitchRange(
|
||||
viewer.camera.heading,
|
||||
Cesium.Math.toRadians(-35),
|
||||
range * 2.4,
|
||||
),
|
||||
duration: 1.8,
|
||||
center: {
|
||||
lon: target.lon,
|
||||
lat: target.lat,
|
||||
height: anno.anchor.height || 0,
|
||||
},
|
||||
radius: range,
|
||||
},
|
||||
{
|
||||
offset: {
|
||||
heading: camera.pose().heading,
|
||||
pitch: Cesium.Math.toRadians(-35),
|
||||
range: range * 2.4,
|
||||
},
|
||||
durationS: 1.8,
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
@@ -1978,15 +1984,17 @@ export function createAnnotationEngine({
|
||||
}) {
|
||||
if (destroyed) return;
|
||||
try {
|
||||
viewer.camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromDegrees(lon, lat, height),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(heading),
|
||||
pitch: Cesium.Math.toRadians(pitch),
|
||||
roll: 0,
|
||||
cameraOf(viewer).flyTo(
|
||||
{ lon, lat, height },
|
||||
{
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(heading),
|
||||
pitch: Cesium.Math.toRadians(pitch),
|
||||
roll: 0,
|
||||
},
|
||||
durationS: duration,
|
||||
},
|
||||
duration,
|
||||
});
|
||||
);
|
||||
} catch {
|
||||
/* camera flight best-effort */
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
|
||||
import { createStandalonePlaceSearch } from '../standalone/placeSearch.js';
|
||||
// Voice-annotation resilience contract tests — pure logic, no network, no browser.
|
||||
//
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
installRenderGovernor,
|
||||
_resetRenderGovernorForTest,
|
||||
} from '../renderGovernor.js';
|
||||
import { framePortFor } from '../testSupport/fakeFramePort.mjs';
|
||||
|
||||
// A resolver that replays a scripted sequence of outcomes (undefined = transient,
|
||||
// null = definitive miss, object = footprint) and counts its invocations.
|
||||
@@ -1261,7 +1263,7 @@ test('hand-drawn marks: one mark can be hidden, renamed, recoloured, framed and
|
||||
const { renderer, calls } = fakeRenderer();
|
||||
const flights = [];
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: { camera: { heading: 0, flyToBoundingSphere: (sphere) => flights.push(sphere) } },
|
||||
viewer: bindFakeCesiumCamera({ camera: { heading: 0, flyToBoundingSphere: (sphere) => flights.push(sphere) } }),
|
||||
renderer,
|
||||
});
|
||||
let changes = 0;
|
||||
@@ -1330,12 +1332,12 @@ test('hand-drawn marks: remove, hide, clear, expiry and framing have their effec
|
||||
const { renderer, calls } = fakeRenderer();
|
||||
const flights = [];
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {
|
||||
viewer: bindFakeCesiumCamera({
|
||||
camera: {
|
||||
heading: 0,
|
||||
flyToBoundingSphere: (sphere) => flights.push(sphere),
|
||||
},
|
||||
},
|
||||
}),
|
||||
renderer,
|
||||
});
|
||||
let changes = 0;
|
||||
@@ -1547,7 +1549,7 @@ test('hand-drawn marks: with every mark hidden the scene idles, and showing one
|
||||
_resetRenderGovernorForTest();
|
||||
t.after(() => _resetRenderGovernorForTest());
|
||||
let renders = 0;
|
||||
installRenderGovernor({ scene: { requestRenderMode: true, requestRender: () => (renders += 1) } });
|
||||
installRenderGovernor(framePortFor({ requestRenderMode: true, requestRender: () => (renders += 1) }));
|
||||
const { renderer } = fakeRenderer();
|
||||
const engine = createAnnotationEngine({ viewer: {}, renderer });
|
||||
await engine.annotate([
|
||||
|
||||
+32
-62
@@ -18,9 +18,9 @@
|
||||
* exceptions inside each.
|
||||
* - Cesium's Viewer binds its OWN left click (select the entity under the
|
||||
* pointer) and double click (track it) on the viewer's handler, outside every
|
||||
* layer and therefore outside the shared claim. Both are borrowed for the
|
||||
* layer and therefore outside the shared claim. Both are suppressed for the
|
||||
* session and given back on the way out, and `destroy()` gives back every
|
||||
* listener, the Cesium handler, the preview data source and the window
|
||||
* listener, the pointer subscriptions, the preview data source and the window
|
||||
* handle, so the tool can be turned off or the shell disposed without leaving
|
||||
* anything behind.
|
||||
*
|
||||
@@ -37,6 +37,8 @@ import {
|
||||
pointerOwner,
|
||||
releasePointer,
|
||||
} from '../data/inputOwnership.js';
|
||||
import { inputFor } from '../input/dispatcher.js';
|
||||
import { sensorPickPosition } from '../input/sensorPick.js';
|
||||
import { pointerOwnerName } from '../data/pointerOwnerNames.js';
|
||||
import {
|
||||
DRAW_SHAPES,
|
||||
@@ -88,10 +90,8 @@ export function initDrawTool({
|
||||
let shape = 'area';
|
||||
let color = 'primary';
|
||||
let hint = '';
|
||||
let handler = null;
|
||||
let unbindScene = null;
|
||||
let lease = null;
|
||||
let savedDoubleClick = null;
|
||||
let savedSingleClick = null;
|
||||
let cursor = null; // last mouse position on the canvas, for the rubber band
|
||||
// Bumped by anything that supersedes an in-flight finish: cancelling, clearing
|
||||
// the board, leaving draw mode, teardown. An `annotate()` that resolves after
|
||||
@@ -228,7 +228,9 @@ export function initDrawTool({
|
||||
};
|
||||
const onMove = (event) => {
|
||||
if (!session || session.shape === 'pin' || destroyed) return;
|
||||
const p = worldAt(event.endPosition);
|
||||
// The click's own position is sensor-corrected; the rubber band must
|
||||
// follow the same point the click would place.
|
||||
const p = worldAt(sensorPickPosition(viewer.scene, event.endPosition));
|
||||
cursor = p
|
||||
? Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0)
|
||||
: null;
|
||||
@@ -384,50 +386,29 @@ export function initDrawTool({
|
||||
}
|
||||
|
||||
function bindSceneHandler() {
|
||||
if (handler) return;
|
||||
handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
|
||||
handler.setInputAction(onClick, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
handler.setInputAction(onMove, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
|
||||
handler.setInputAction(() => {
|
||||
void finish();
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
|
||||
// Cesium's Viewer binds BOTH stock click actions on its own handler: the
|
||||
// single click picks an entity into `viewer.selectedEntity`, the double
|
||||
// click tracks it. Neither goes through a layer, so neither can be fixed by
|
||||
// the shared pointer claim — they have to be borrowed outright for the
|
||||
// session and given back on the way out. Borrowing the single click is what
|
||||
// stops a vertex placed on a contact from also selecting it.
|
||||
const stock = viewer.screenSpaceEventHandler;
|
||||
savedSingleClick =
|
||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null;
|
||||
savedDoubleClick =
|
||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
|
||||
null;
|
||||
stock.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
stock.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
|
||||
if (unbindScene) return;
|
||||
const input = inputFor(viewer);
|
||||
const held = { lease: () => lease };
|
||||
const offs = [
|
||||
input.onPointer('click', onClick, held),
|
||||
input.onPointer('move', onMove, held),
|
||||
input.onPointer('dblclick', () => void finish(), held),
|
||||
// Cesium's Viewer binds BOTH stock click actions on its own handler: the
|
||||
// single click picks an entity into `viewer.selectedEntity`, the double
|
||||
// click tracks it. Neither goes through a layer, so neither yields to the
|
||||
// shared pointer claim — they are suppressed for the session and given
|
||||
// back on the way out. Suppressing the single click is what stops a
|
||||
// vertex placed on a contact from also selecting it.
|
||||
input.suppressStockClicks(),
|
||||
];
|
||||
unbindScene = () => offs.forEach((off) => off());
|
||||
listen(document, 'keydown', onKey, true);
|
||||
}
|
||||
|
||||
function releaseSceneHandler() {
|
||||
if (handler) {
|
||||
handler.destroy();
|
||||
handler = null;
|
||||
}
|
||||
if (savedSingleClick) {
|
||||
viewer.screenSpaceEventHandler.setInputAction(
|
||||
savedSingleClick,
|
||||
Cesium.ScreenSpaceEventType.LEFT_CLICK,
|
||||
);
|
||||
savedSingleClick = null;
|
||||
}
|
||||
if (savedDoubleClick) {
|
||||
viewer.screenSpaceEventHandler.setInputAction(
|
||||
savedDoubleClick,
|
||||
Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK,
|
||||
);
|
||||
savedDoubleClick = null;
|
||||
}
|
||||
// Drop the keydown listener bound alongside this handler.
|
||||
unbindScene?.();
|
||||
unbindScene = null;
|
||||
// Drop the keydown listener bound alongside the pointer subscriptions.
|
||||
for (let i = domListeners.length - 1; i >= 0; i -= 1) {
|
||||
const [target, type, listener, options] = domListeners[i];
|
||||
if (listener !== onKey) continue;
|
||||
@@ -523,26 +504,15 @@ export function initDrawTool({
|
||||
return {
|
||||
active,
|
||||
destroyed,
|
||||
sceneHandler: Boolean(handler),
|
||||
sceneHandler: Boolean(unbindScene),
|
||||
domListeners: domListeners.length,
|
||||
previewDataSources: countPreviewDataSources(viewer),
|
||||
previewEntities: destroyed ? 0 : dataSource.entities.values.length,
|
||||
pointerOwner: pointerOwner(),
|
||||
// The viewer's OWN click actions. While a session is open both are
|
||||
// borrowed (absent here); after it closes both are back. A harness can
|
||||
// compare the restored functions by identity with what it captured
|
||||
// before, which is the only way to prove they were given back rather
|
||||
// than replaced.
|
||||
stockSingleClick: Boolean(
|
||||
viewer.screenSpaceEventHandler?.getInputAction?.(
|
||||
Cesium.ScreenSpaceEventType.LEFT_CLICK,
|
||||
),
|
||||
),
|
||||
stockDoubleClick: Boolean(
|
||||
viewer.screenSpaceEventHandler?.getInputAction?.(
|
||||
Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK,
|
||||
),
|
||||
),
|
||||
// The viewer's own select and follow, which the input port runs: off
|
||||
// while a session is open, back on after it closes.
|
||||
stockSingleClick: inputFor(viewer).stockEnabled('select'),
|
||||
stockDoubleClick: inputFor(viewer).stockEnabled('dblclickFollow'),
|
||||
};
|
||||
},
|
||||
/** Resolves once every deferred teardown step has run. */
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
releasePointer,
|
||||
resetPointerOwnership,
|
||||
} from '../data/inputOwnership.js';
|
||||
import { fakeInput } from '../input/testDoubles.mjs';
|
||||
|
||||
/* ── DOM doubles ───────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -223,10 +224,13 @@ function fakeAnnotations() {
|
||||
}
|
||||
|
||||
/** Stand up a tool against fresh doubles, with globals restored afterwards. */
|
||||
function harness({ navigation = null } = {}) {
|
||||
function harness({ navigation = null, beforeInput = () => {} } = {}) {
|
||||
resetPointerOwnership();
|
||||
const { byId, documentDouble } = fakeDom();
|
||||
const viewer = fakeViewer();
|
||||
// The viewer's own actions are bound before its input port takes them.
|
||||
beforeInput(viewer);
|
||||
const input = fakeInput(viewer);
|
||||
const annotations = fakeAnnotations();
|
||||
const previousDocument = globalThis.document;
|
||||
const previousWindow = globalThis.window;
|
||||
@@ -256,7 +260,9 @@ function harness({ navigation = null } = {}) {
|
||||
controls,
|
||||
/** A control's change, as the view sends it. */
|
||||
act: (name, ...args) => controls.actions?.[name]?.(...args),
|
||||
input,
|
||||
restore() {
|
||||
input.destroy();
|
||||
resetPointerOwnership();
|
||||
if (previousDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = previousDocument;
|
||||
@@ -335,30 +341,33 @@ test('a destroyed instance cannot free the pointer its replacement holds', async
|
||||
}
|
||||
});
|
||||
|
||||
test('both stock viewer click actions are borrowed for the session and given back', async () => {
|
||||
const h = harness();
|
||||
test('both stock viewer click actions are held off for the session and given back', async () => {
|
||||
const stockClick = () => 'viewer picks and selects';
|
||||
const stockDouble = () => 'viewer tracks';
|
||||
const h = harness({
|
||||
beforeInput(viewer) {
|
||||
viewer.screenSpaceEventHandler.setInputAction(stockClick, LEFT_CLICK);
|
||||
viewer.screenSpaceEventHandler.setInputAction(stockDouble, LEFT_DOUBLE_CLICK);
|
||||
},
|
||||
});
|
||||
try {
|
||||
const stockClick = () => 'viewer picks and selects';
|
||||
const stockDouble = () => 'viewer tracks';
|
||||
h.viewer.screenSpaceEventHandler.setInputAction(stockClick, LEFT_CLICK);
|
||||
h.viewer.screenSpaceEventHandler.setInputAction(stockDouble, LEFT_DOUBLE_CLICK);
|
||||
|
||||
assert.equal(h.input.stockEnabled('select'), true);
|
||||
h.act('toggleDraw');
|
||||
assert.equal(
|
||||
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK),
|
||||
undefined,
|
||||
h.input.stockEnabled('select'),
|
||||
false,
|
||||
"the viewer's own selecting click must not run while drawing",
|
||||
);
|
||||
assert.equal(
|
||||
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_DOUBLE_CLICK),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(h.input.stockEnabled('dblclickFollow'), false);
|
||||
|
||||
h.act('toggleDraw');
|
||||
assert.equal(h.input.stockEnabled('select'), true);
|
||||
assert.equal(h.input.stockEnabled('dblclickFollow'), true);
|
||||
h.input.destroy();
|
||||
assert.equal(
|
||||
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK),
|
||||
stockClick,
|
||||
'the exact function is restored, not a replacement',
|
||||
'the exact function goes back to the viewer, not a replacement',
|
||||
);
|
||||
assert.equal(
|
||||
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_DOUBLE_CLICK),
|
||||
@@ -468,7 +477,7 @@ test('destroy releases the pointer, the handler, the listeners and the stock act
|
||||
assert.equal(isPointerFree(), true, 'the pointer is free');
|
||||
const after = h.tool.diagnostics();
|
||||
assert.equal(after.destroyed, true);
|
||||
assert.equal(after.sceneHandler, false, 'the Cesium handler is gone');
|
||||
assert.equal(after.sceneHandler, false, 'the pointer subscriptions are gone');
|
||||
assert.equal(after.domListeners, 0, 'every DOM listener came off');
|
||||
assert.equal(h.documentDouble.listeners.length, 0, 'including the keydown');
|
||||
assert.equal(h.controls.actions, null, 'and the controls are disconnected');
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createPinia } from 'pinia';
|
||||
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
|
||||
import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
|
||||
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
|
||||
import { bindFakeCesiumCredits } from '../testSupport/fakeCreditsPort.mjs';
|
||||
|
||||
/** A hybrid renderer whose screen board is presented to its own store. */
|
||||
function hybridWithBoard(viewer) {
|
||||
@@ -110,7 +111,7 @@ function fakeViewer(lon, lat) {
|
||||
return {
|
||||
dataSources,
|
||||
frames,
|
||||
viewer: {
|
||||
viewer: bindFakeCesiumCredits({
|
||||
credits,
|
||||
creditDisplay: {
|
||||
addStaticCredit: (credit) => credits.add(credit),
|
||||
@@ -130,7 +131,7 @@ function fakeViewer(lon, lat) {
|
||||
return index >= 0;
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -540,8 +541,9 @@ test('buildings share one probe per frame and preserve probe geometry, filtering
|
||||
const detail = parent.detail;
|
||||
assert.equal(detail.index, index);
|
||||
assert.equal(detail.status, trace === firstTrace ? 'recorded' : 'missing');
|
||||
// The fourth probe finds nothing and the fifth throws: both are misses.
|
||||
assert.equal(detail.clampResult,
|
||||
trace === firstTrace && index === 4 ? 'threw' : 'returned');
|
||||
trace === firstTrace && index !== 3 && index !== 4 ? 'hit' : 'missed');
|
||||
assert.ok(Number.isFinite(detail.clampStart) && Number.isFinite(detail.clampEnd));
|
||||
assert.ok(parent.start <= detail.clampStart && detail.clampStart <= detail.clampEnd && detail.clampEnd <= parent.end);
|
||||
if (index === 7) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
|
||||
import { createAnnotationResolver } from './resolver.js';
|
||||
import { polygonsContain } from '../data/adminBoundaries.js';
|
||||
import { streetFootprint } from './outlineRungs.js';
|
||||
import { bindFakeCesiumCredits } from '../testSupport/fakeCreditsPort.mjs';
|
||||
|
||||
function viewerAt(lat, lon, height = 20_000) {
|
||||
return {
|
||||
@@ -180,10 +181,10 @@ test('a geocoded city or neighborhood upgrades to the bundled outline', async ()
|
||||
const credits = [];
|
||||
const cancelled = await city.resolve(
|
||||
'Austin',
|
||||
{
|
||||
bindFakeCesiumCredits({
|
||||
...viewerAt(39, -100, 5_000_000),
|
||||
creditDisplay: { addStaticCredit: (credit) => credits.push(credit) },
|
||||
},
|
||||
}),
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
const pending = cancelled.resolveOutline();
|
||||
|
||||
+11
-18
@@ -45,6 +45,7 @@ import {
|
||||
import { createOutlineRungs } from './outlineRungs.js';
|
||||
import { unavailablePlaceSearch } from '../search/placeSearch.js';
|
||||
import { isPickedWorldPosition } from '../data/scenePick.js';
|
||||
import { heightsFor } from '../queries/heights.js';
|
||||
|
||||
/** Prepare only local dateline outlines for planar admission and centering. */
|
||||
function localOutlineFrame(fp) {
|
||||
@@ -2351,26 +2352,18 @@ export function createAnnotationResolver({
|
||||
function sampleGroundHeight(viewer, lon, lat) {
|
||||
const scene = viewer?.scene;
|
||||
if (!scene) return 0;
|
||||
try {
|
||||
if (
|
||||
scene.clampToHeightSupported &&
|
||||
typeof scene.clampToHeight === 'function'
|
||||
) {
|
||||
const carto = Cesium.Cartographic.fromDegrees(lon, lat);
|
||||
const surface = Cesium.Cartographic.toCartesian(carto);
|
||||
const clamped = scene.clampToHeight(surface);
|
||||
if (clamped) {
|
||||
const h = Cesium.Cartographic.fromCartesian(clamped).height;
|
||||
if (Number.isFinite(h)) return h;
|
||||
}
|
||||
const heights = heightsFor(scene);
|
||||
// A miss is tiles not ready: fall through to the terrain, then 0.
|
||||
if (heights.supported().clamp) {
|
||||
const surface = Cesium.Cartesian3.fromDegrees(lon, lat, 0);
|
||||
const clamped = heights.clampNow(surface);
|
||||
if (clamped) {
|
||||
const h = Cesium.Cartographic.fromCartesian(clamped)?.height;
|
||||
if (Number.isFinite(h)) return h;
|
||||
}
|
||||
} catch {
|
||||
/* tiles not ready — fall through */
|
||||
}
|
||||
const globeHeight = scene.globe?.getHeight?.(
|
||||
Cesium.Cartographic.fromDegrees(lon, lat),
|
||||
);
|
||||
return Number.isFinite(globeHeight) && globeHeight > 0 ? globeHeight : 0;
|
||||
const globeHeight = heights.now({ lon, lat }, { against: 'terrain' });
|
||||
return globeHeight !== null && globeHeight > 0 ? globeHeight : 0;
|
||||
}
|
||||
|
||||
function shortLabel(formattedAddress) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { followedOf } from '../data/followed.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { getOverlayPaintRect } from '../overlays/worldOverlay.js';
|
||||
import {
|
||||
@@ -8,6 +9,7 @@ import { t } from '../i18n/index.js';
|
||||
import { nextTick } from 'vue';
|
||||
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
|
||||
import { applicationPinia } from '../ui/vueRuntime.js';
|
||||
import { heightsFor } from '../queries/heights.js';
|
||||
|
||||
/** A label whose detailed outline could not be resolved. */
|
||||
function outlineUnavailableLabel(label) {
|
||||
@@ -141,13 +143,10 @@ export function createScreenAnnotationRenderer(
|
||||
return cached.h;
|
||||
}
|
||||
try {
|
||||
if (
|
||||
scene.clampToHeightSupported &&
|
||||
typeof scene.clampToHeight === 'function'
|
||||
) {
|
||||
const c = scene.clampToHeight(
|
||||
Cesium.Cartesian3.fromDegrees(lon, lat, 0),
|
||||
);
|
||||
const heights = heightsFor(scene);
|
||||
if (heights.supported().clamp) {
|
||||
// A miss is tiles not ready.
|
||||
const c = heights.clampNow(Cesium.Cartesian3.fromDegrees(lon, lat, 0));
|
||||
if (c) {
|
||||
const h = Cesium.Cartographic.fromCartesian(c).height;
|
||||
if (Number.isFinite(h) && h > -430 && h < 9000) {
|
||||
@@ -422,12 +421,10 @@ export function createScreenAnnotationRenderer(
|
||||
let top = painted?.y;
|
||||
let bottom = painted ? painted.y + painted.h : undefined;
|
||||
|
||||
const ent = viewer.trackedEntity;
|
||||
const now = Cesium.JulianDate.now();
|
||||
const followed = followedOf(viewer);
|
||||
const displayPosition = followed?.displayPosition;
|
||||
const world =
|
||||
typeof ent?.gevDisplayPosition === 'function'
|
||||
? ent.gevDisplayPosition()
|
||||
: null;
|
||||
typeof displayPosition === 'function' ? displayPosition() : null;
|
||||
const win = world
|
||||
? Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
@@ -436,13 +433,14 @@ export function createScreenAnnotationRenderer(
|
||||
)
|
||||
: null;
|
||||
|
||||
// Billboard box — centered on the anchor, magnified by scaleByDistance.
|
||||
const bb = ent?.billboard;
|
||||
if (bb && win && Number.isFinite(win.x) && Number.isFinite(win.y)) {
|
||||
const baseW = bb.width?.getValue?.(now) ?? 28;
|
||||
const baseH = bb.height?.getValue?.(now) ?? 28;
|
||||
// Billboard box — centered on the anchor, magnified by scaleByDistance, from
|
||||
// the size the tracking layer recorded for its sprite.
|
||||
const sprite = followed?.sprite;
|
||||
if (sprite && win && Number.isFinite(win.x) && Number.isFinite(win.y)) {
|
||||
const baseW = sprite.width ?? 28;
|
||||
const baseH = sprite.height ?? 28;
|
||||
let scale = 1;
|
||||
const sbd = bb.scaleByDistance?.getValue?.(now);
|
||||
const sbd = sprite.scaleByDistance;
|
||||
if (sbd)
|
||||
scale = nearFarValue(
|
||||
sbd,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { recordWorkSlice } from '../sources/phaseTiming.js';
|
||||
import { ringCentroid } from '../sources/featureGeometry.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
|
||||
import { heightsFor } from '../queries/heights.js';
|
||||
|
||||
/**
|
||||
* World-space annotation renderer (Direction A).
|
||||
@@ -174,20 +175,16 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
const sliceStart = performance.now();
|
||||
let error;
|
||||
try {
|
||||
if (
|
||||
viewer.scene.clampToHeightSupported &&
|
||||
typeof viewer.scene.clampToHeight === 'function'
|
||||
) {
|
||||
if (heightsFor(viewer.scene).supported().clamp) {
|
||||
const [lon, lat] = task.points[task.index++];
|
||||
timing.probes += 1;
|
||||
try {
|
||||
const position = Cesium.Cartesian3.fromDegrees(lon, lat, 0);
|
||||
let c;
|
||||
note?.('clampStart');
|
||||
if (detail) detail.clampResult = 'threw';
|
||||
try {
|
||||
c = viewer.scene.clampToHeight(position);
|
||||
if (detail) detail.clampResult = 'returned';
|
||||
c = heightsFor(viewer.scene).clampNow(position);
|
||||
if (detail) detail.clampResult = c ? 'hit' : 'missed';
|
||||
} finally {
|
||||
note?.('clampEnd');
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createApplication } from './application.js';
|
||||
import { installTrackpadPinchZoom } from './viewer.js';
|
||||
import { installTrackpadPinchZoom } from '../render/cesium/ctrlWheelZoom.js';
|
||||
|
||||
const phases = ['Scene', 'Controls', 'Data', 'Tools'];
|
||||
function fixture(overrides = {}) {
|
||||
|
||||
+13
-14
@@ -13,6 +13,7 @@
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { announceNavigationAuthority } from '../navigationPolicy.js';
|
||||
import { cameraOf } from '../render/cesium/camera.js';
|
||||
import { annotationsFromParams, createView } from '../view/index.js';
|
||||
|
||||
const EMBED_VIEW_MESSAGE = 'gev:view';
|
||||
@@ -147,21 +148,19 @@ const delay = (ms, signal) =>
|
||||
function flyTo(viewer, camera) {
|
||||
announceNavigationAuthority('embed-view');
|
||||
return new Promise((resolve) => {
|
||||
viewer.camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromDegrees(
|
||||
camera.lon,
|
||||
camera.lat,
|
||||
camera.altitude_m,
|
||||
),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(camera.heading_deg),
|
||||
pitch: Cesium.Math.toRadians(camera.pitch_deg),
|
||||
roll: 0,
|
||||
cameraOf(viewer).flyTo(
|
||||
{ lon: camera.lon, lat: camera.lat, height: camera.altitude_m },
|
||||
{
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(camera.heading_deg),
|
||||
pitch: Cesium.Math.toRadians(camera.pitch_deg),
|
||||
roll: 0,
|
||||
},
|
||||
durationS: FLIGHT_SECONDS,
|
||||
onComplete: () => resolve(true),
|
||||
onCancel: () => resolve(false),
|
||||
},
|
||||
duration: FLIGHT_SECONDS,
|
||||
complete: () => resolve(true),
|
||||
cancel: () => resolve(false),
|
||||
});
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
@@ -12,7 +13,7 @@ import { createView } from '../view/index.js';
|
||||
|
||||
const fakeViewer = () => {
|
||||
const flights = [];
|
||||
return {
|
||||
return bindFakeCesiumCamera({
|
||||
flights,
|
||||
camera: {
|
||||
flyTo(options) {
|
||||
@@ -20,7 +21,7 @@ const fakeViewer = () => {
|
||||
options.complete();
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
const fakeLayers = (enabled) => ({
|
||||
getAll: () =>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createFirmsHeatmapLayer as createLayer } from '../../layers/firms/index.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
import * as sprites from '../../data/spriteOrder.js';
|
||||
@@ -29,8 +28,5 @@ export function createApplicationFirms({ surface, ...options }) {
|
||||
clearSource: overlays.clearOverlaySource,
|
||||
hitTest: overlays.hitTestWorldOverlay,
|
||||
},
|
||||
screenSpaceEventHandlerFactory:
|
||||
options.screenSpaceEventHandlerFactory ??
|
||||
((viewer) => new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)),
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createGhostMapsLayer } from '../../layers/ghostMaps/index.js';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
import * as overlays from '../../overlays/worldOverlay.js';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
|
||||
/** Wire the S2 Underground GhostMaps layer into the application catalog. */
|
||||
export function createApplicationGhostMaps(options) {
|
||||
@@ -14,10 +12,7 @@ export function createApplicationGhostMaps(options) {
|
||||
hitTest: overlays.hitTestWorldOverlay,
|
||||
},
|
||||
openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
|
||||
screenSpaceEventHandlerFactory: (viewer) =>
|
||||
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
|
||||
picking,
|
||||
pointer: { isPointerFree },
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
createFirePerimetersLayer,
|
||||
createInciwebIndexSource,
|
||||
@@ -6,7 +5,6 @@ import {
|
||||
} from '../../layers/perimeters/index.js';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
import * as overlays from '../../overlays/worldOverlay.js';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
|
||||
/** Wire WFIGS fire perimeters into the application catalog. */
|
||||
export function createApplicationFirePerimeters(options) {
|
||||
@@ -20,10 +18,7 @@ export function createApplicationFirePerimeters(options) {
|
||||
inciwebSource: createInciwebIndexSource(),
|
||||
inciwebPublications: createInciwebPublicationSource(),
|
||||
openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
|
||||
screenSpaceEventHandlerFactory: (viewer) =>
|
||||
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
|
||||
picking,
|
||||
pointer: { isPointerFree },
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { createKartaViewProvider } from '../../layers/streetLevel/providers/kart
|
||||
import { createPanoramaxProvider } from '../../layers/streetLevel/providers/panoramax/index.js';
|
||||
import * as sprites from '../../data/spriteOrder.js';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
import * as input from '../../data/inputOwnership.js';
|
||||
import * as scenePick from '../../data/scenePick.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
|
||||
@@ -37,6 +36,6 @@ export function createApplicationStreetLevel({
|
||||
}) {
|
||||
return createStreetLevelLayer({
|
||||
providers,
|
||||
services: { sprites, picking, input, scenePick, render },
|
||||
services: { sprites, picking, scenePick, render },
|
||||
});
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createSubmarineCableLayer } from '../../layers/submarineCables/index.js';
|
||||
import { overlayHost } from './overlayHost.js';
|
||||
/** Wire cable source geometry to the application overlay host. */
|
||||
export function createApplicationCables(options) {
|
||||
return createSubmarineCableLayer({
|
||||
overlayHost,
|
||||
screenSpaceEventHandlerFactory: (canvas) =>
|
||||
new Cesium.ScreenSpaceEventHandler(canvas),
|
||||
mapStackEventTarget: typeof window !== 'undefined' ? window : null,
|
||||
...options,
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
governorRequestRender,
|
||||
getRenderGovernorDiagnostics,
|
||||
} from '../renderGovernor.js';
|
||||
import { framePortFor } from '../testSupport/fakeFramePort.mjs';
|
||||
|
||||
test('destroy invalidates a pending imagery provider before it can touch the viewer', async () => {
|
||||
let resolveProvider;
|
||||
@@ -44,11 +45,11 @@ test('uninstall only releases the current viewer and makes later render requests
|
||||
},
|
||||
},
|
||||
};
|
||||
installRenderGovernor(viewer);
|
||||
installRenderGovernor(framePortFor(viewer.scene));
|
||||
holdContinuousRender('fixture');
|
||||
uninstallRenderGovernor({});
|
||||
uninstallRenderGovernor(framePortFor({}));
|
||||
assert.equal(getRenderGovernorDiagnostics().installed, true);
|
||||
uninstallRenderGovernor(viewer);
|
||||
uninstallRenderGovernor(framePortFor(viewer.scene));
|
||||
const before = renders;
|
||||
governorRequestRender('late');
|
||||
assert.equal(renders, before);
|
||||
|
||||
+53
-16
@@ -1,23 +1,32 @@
|
||||
import { createApplicationOperations } from './operations.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
createApplicationViewer,
|
||||
installTrackpadPinchZoom,
|
||||
} from '../app/viewer.js';
|
||||
cesiumRenderer,
|
||||
cesiumViewerOf,
|
||||
} from '../render/cesium/appRenderer.js';
|
||||
import { registerDataCredits } from '../data/dataCredits.js';
|
||||
import { configureCreditKeyboardAccess } from '../creditKeyboard.js';
|
||||
import { MapStackController } from '../mapStackController.js';
|
||||
import { loadPhotorealisticTileset } from '../mapStartup.js';
|
||||
import { initLogoGaze } from '../logoGaze.js';
|
||||
import { inputFor } from '../input/dispatcher.js';
|
||||
import {
|
||||
holdContinuousRender,
|
||||
installRenderGovernor,
|
||||
uninstallRenderGovernor,
|
||||
governorRequestRender,
|
||||
} from '../renderGovernor.js';
|
||||
import { describeError } from './errors.js';
|
||||
import { onLocaleChange, t } from '../i18n/index.js';
|
||||
|
||||
/** Construct the application globe using the caller's local configuration. */
|
||||
/**
|
||||
* Construct the application globe using the caller's local configuration.
|
||||
* `renderer` is the `RendererPlugin` that draws it (default: Cesium). The
|
||||
* application still runs on the Cesium viewer until its layers are ported,
|
||||
* so a plugin whose renderer has none is refused.
|
||||
*/
|
||||
export async function createApplicationScene({
|
||||
renderer: rendererPlugin = cesiumRenderer,
|
||||
requestServices,
|
||||
googleApiKey,
|
||||
googleTokens = null,
|
||||
@@ -44,20 +53,41 @@ export async function createApplicationScene({
|
||||
});
|
||||
}
|
||||
loaderStatus.textContent = t('Configuring viewer...');
|
||||
// Provider attribution stays visible, including clean-view and recording.
|
||||
const creditContainer = document.createElement('div');
|
||||
creditContainer.id = 'cesium-credits';
|
||||
document.body.appendChild(creditContainer);
|
||||
defer(() => creditContainer.remove());
|
||||
const viewer = createApplicationViewer({
|
||||
container: 'cesiumContainer',
|
||||
creditContainer,
|
||||
// The scene still loads the tiles, so their credentials stay here.
|
||||
const renderer = await rendererPlugin.create({
|
||||
container: document.getElementById('cesiumContainer'),
|
||||
signal,
|
||||
});
|
||||
const viewer = cesiumViewerOf(renderer);
|
||||
if (!viewer) {
|
||||
const refusal = new Error(
|
||||
`The ${rendererPlugin.id} renderer cannot run the application yet`,
|
||||
);
|
||||
try {
|
||||
renderer.destroy();
|
||||
} catch (error) {
|
||||
console.warn('[Init] Refused renderer failed to destroy:', error);
|
||||
}
|
||||
throw refusal;
|
||||
}
|
||||
// The governor keeps its holds on the renderer's frame port. The loop
|
||||
// draws every frame until the application's tools are up (they release
|
||||
// 'boot'), as it did before anything could hold it.
|
||||
installRenderGovernor(renderer.frame);
|
||||
holdContinuousRender('boot');
|
||||
defer(() => {
|
||||
uninstallRenderGovernor(viewer);
|
||||
if (!viewer.isDestroyed()) viewer.destroy();
|
||||
try {
|
||||
uninstallRenderGovernor(renderer.frame);
|
||||
} finally {
|
||||
renderer.destroy();
|
||||
}
|
||||
});
|
||||
defer(installTrackpadPinchZoom(viewer));
|
||||
// Before any layer binds a click: every layer and tool shares this one.
|
||||
// Deferred after the viewer, so it is released before the viewer goes.
|
||||
const input = inputFor(viewer);
|
||||
defer(() => input.destroy());
|
||||
// Ctrl+wheel and trackpad pinches zoom the globe, not the page.
|
||||
renderer.input.ctrlWheelZoom = true;
|
||||
registerDataCredits(viewer, credits);
|
||||
configureCreditKeyboardAccess(document);
|
||||
defer(onLocaleChange(() => configureCreditKeyboardAccess(document)));
|
||||
@@ -128,5 +158,12 @@ export async function createApplicationScene({
|
||||
});
|
||||
|
||||
signal.throwIfAborted();
|
||||
return { viewer, tileset, mapStackController, operations };
|
||||
return {
|
||||
renderer,
|
||||
viewer,
|
||||
tileset,
|
||||
mapStackController,
|
||||
operations,
|
||||
input,
|
||||
};
|
||||
}
|
||||
@@ -1,25 +1,68 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { flyToAustin } from '../camera.js';
|
||||
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
|
||||
|
||||
test('teardown before the initial camera delay prevents a late flight', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let flights = 0;
|
||||
let cancelled = 0;
|
||||
const stop = flyToAustin({
|
||||
isDestroyed: () => false,
|
||||
camera: {
|
||||
setView() {},
|
||||
flyTo() {
|
||||
flights++;
|
||||
const stop = flyToAustin(
|
||||
bindFakeCesiumCamera({
|
||||
isDestroyed: () => false,
|
||||
camera: {
|
||||
setView() {},
|
||||
flyTo() {
|
||||
flights++;
|
||||
},
|
||||
cancelFlight() {
|
||||
cancelled++;
|
||||
},
|
||||
},
|
||||
cancelFlight() {
|
||||
cancelled++;
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
stop();
|
||||
t.mock.timers.tick(1000);
|
||||
assert.equal(flights, 0);
|
||||
assert.equal(cancelled, 1);
|
||||
assert.equal(cancelled, 0, 'no fly-in started, so none to cancel');
|
||||
});
|
||||
|
||||
test('teardown cancels the fly-in, not a flight the user started since', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const cancelled = [];
|
||||
let current = null;
|
||||
const viewer = bindFakeCesiumCamera({
|
||||
isDestroyed: () => false,
|
||||
camera: {
|
||||
get _currentFlight() {
|
||||
return current;
|
||||
},
|
||||
setView() {},
|
||||
flyTo(options) {
|
||||
current?.cancel();
|
||||
current = {
|
||||
name: options.duration === 4 ? 'fly-in' : 'user',
|
||||
...options,
|
||||
};
|
||||
},
|
||||
cancelFlight() {
|
||||
cancelled.push(current?.name);
|
||||
const flight = current;
|
||||
current = null;
|
||||
flight?.cancel();
|
||||
},
|
||||
},
|
||||
});
|
||||
const stop = flyToAustin(viewer);
|
||||
t.mock.timers.tick(500);
|
||||
stop();
|
||||
assert.deepEqual(cancelled, ['fly-in']);
|
||||
|
||||
// A user flight replaces the fly-in; teardown leaves it alone.
|
||||
const second = flyToAustin(viewer);
|
||||
t.mock.timers.tick(500);
|
||||
viewer.camera.flyTo({ duration: 2 });
|
||||
second();
|
||||
assert.deepEqual(cancelled, ['fly-in']);
|
||||
assert.equal(current.name, 'user');
|
||||
});
|
||||
+15
-14
@@ -1,3 +1,4 @@
|
||||
import { onFollowChanged } from '../data/followed.js';
|
||||
import { SceneDirector } from '../scenes/director.js';
|
||||
import { initAnnotations } from '../annotations/index.js';
|
||||
import { initDrawTool } from '../annotations/drawTool.js';
|
||||
@@ -11,12 +12,13 @@ import { installViews, isEmbeddedInline } from './embed.js';
|
||||
import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
|
||||
import { installLayerReleaseTrim } from '../sceneCommandRelease.js';
|
||||
import {
|
||||
installRenderGovernor,
|
||||
getRenderGovernorDiagnostics,
|
||||
governorRequestRender,
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
} from '../renderGovernor.js';
|
||||
import { entityRecord, readEntityRecord } from '../data/entityRecords.js';
|
||||
import { overlayHost } from './layers/overlayHost.js';
|
||||
|
||||
/** Attach scene tools, rendering listeners and the application debug handle. */
|
||||
export function createApplicationTools({
|
||||
@@ -32,11 +34,11 @@ export function createApplicationTools({
|
||||
signal,
|
||||
defer,
|
||||
}) {
|
||||
const { viewer, tileset, mapStackController, operations } = scene;
|
||||
const { viewer, renderer, tileset, mapStackController, operations } = scene;
|
||||
const { styleManager, weatherEffects, cockpitCloudEffects } = controls;
|
||||
const { dataManager } = data;
|
||||
const sceneDirector = new SceneDirector(viewer, styleManager, dataManager, {
|
||||
dataPacks: sceneDataPacks,
|
||||
dataPacks: { overlayHost, ...sceneDataPacks },
|
||||
isMapStackAvailable: (id) =>
|
||||
mapStackController?.isStackAvailable(id) === true,
|
||||
});
|
||||
@@ -125,10 +127,10 @@ export function createApplicationTools({
|
||||
}
|
||||
if (startChrome)
|
||||
defer(startChrome({ loadingScreen, styleManager, dataManager, signal }));
|
||||
// Idle render governor: flips the scene into requestRenderMode whenever
|
||||
// nothing animates per frame. Installed AFTER every module above has had
|
||||
// its chance to register pre-install holds. (perf wave 2)
|
||||
installRenderGovernor(viewer);
|
||||
// Idle render governor: the loop draws on demand whenever nothing animates
|
||||
// per frame. It goes idle only now, after every module above has had its
|
||||
// chance to hold. (perf wave 2)
|
||||
releaseContinuousRender('boot');
|
||||
// Cesium keeps a turned-off layer's draw commands (and so all its entities)
|
||||
// alive in reused per-pass arrays; trim them after each disable.
|
||||
defer(
|
||||
@@ -145,12 +147,10 @@ export function createApplicationTools({
|
||||
|
||||
// The follow camera recomputes the tracked target's dead-reckon position
|
||||
// every frame — tracking anything is a per-frame animation. (perf wave 2)
|
||||
const removeTrackingListener = viewer.trackedEntityChanged.addEventListener(
|
||||
() => {
|
||||
if (viewer.trackedEntity) holdContinuousRender('tracked-entity');
|
||||
else releaseContinuousRender('tracked-entity');
|
||||
},
|
||||
);
|
||||
const removeTrackingListener = onFollowChanged(viewer, (followed) => {
|
||||
if (followed) holdContinuousRender('tracked-entity');
|
||||
else releaseContinuousRender('tracked-entity');
|
||||
});
|
||||
|
||||
// Hidden-state suspension (perf wave 2): when the window/tab is hidden,
|
||||
// stop the default render loop outright — a hidden canvas repaints for
|
||||
@@ -161,7 +161,7 @@ export function createApplicationTools({
|
||||
// A panel's host may report it hidden while it is on screen; the panel
|
||||
// keeps drawing itself (see keepPanelRendering in embed.js).
|
||||
const hidden = document.hidden && !isEmbeddedInline();
|
||||
viewer.useDefaultRenderLoop = !hidden;
|
||||
renderer.frame.suspend(hidden);
|
||||
cockpitCloudEffects?.setSuspended?.(hidden);
|
||||
if (!hidden) {
|
||||
data.presentation.flushVisible();
|
||||
@@ -194,6 +194,7 @@ export function createApplicationTools({
|
||||
getRenderGovernorDiagnostics,
|
||||
surfaceServices: operations.surface,
|
||||
requestRender: governorRequestRender,
|
||||
entityRecords: { read: readEntityRecord, write: entityRecord },
|
||||
};
|
||||
const debug = window.__godsEyeView;
|
||||
defer(() => {
|
||||
|
||||
@@ -2,107 +2,6 @@ import * as Cesium from 'cesium';
|
||||
import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js';
|
||||
import { StarrySky } from '../sky/starrySky.js';
|
||||
|
||||
const PINCH_ZOOM_MULTIPLIER = 8;
|
||||
const MAX_PINCH_PIXEL_DELTA = 120;
|
||||
|
||||
function boundedPinchDelta(delta) {
|
||||
if (!Number.isFinite(delta) || delta === 0) return delta;
|
||||
return (
|
||||
Math.sign(delta) *
|
||||
Math.min(Math.abs(delta) * PINCH_ZOOM_MULTIPLIER, MAX_PINCH_PIXEL_DELTA)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add browser trackpad pinch to Cesium's zoom inputs and return its disposer.
|
||||
* Browsers expose this gesture as a small pixel-mode Ctrl+wheel event.
|
||||
*/
|
||||
export function installTrackpadPinchZoom(
|
||||
viewer,
|
||||
{ createWheelEvent = (type, init) => new WheelEvent(type, init) } = {},
|
||||
) {
|
||||
const controller = viewer?.scene?.screenSpaceCameraController;
|
||||
const container = viewer?.container;
|
||||
const canvas = viewer?.canvas;
|
||||
if (!controller || !container || !canvas)
|
||||
throw new TypeError('A complete Cesium viewer is required');
|
||||
|
||||
const originalZoomEventTypes = controller.zoomEventTypes;
|
||||
const zoomEventTypes = Array.isArray(originalZoomEventTypes)
|
||||
? originalZoomEventTypes
|
||||
: originalZoomEventTypes === undefined
|
||||
? []
|
||||
: [originalZoomEventTypes];
|
||||
const alreadyHandlesControlWheel = zoomEventTypes.some(
|
||||
(binding) =>
|
||||
binding?.eventType === Cesium.CameraEventType.WHEEL &&
|
||||
binding?.modifier === Cesium.KeyboardEventModifier.CTRL,
|
||||
);
|
||||
const configuredZoomEventTypes = alreadyHandlesControlWheel
|
||||
? originalZoomEventTypes
|
||||
: [
|
||||
...zoomEventTypes,
|
||||
{
|
||||
eventType: Cesium.CameraEventType.WHEEL,
|
||||
modifier: Cesium.KeyboardEventModifier.CTRL,
|
||||
},
|
||||
];
|
||||
if (!alreadyHandlesControlWheel)
|
||||
controller.zoomEventTypes = configuredZoomEventTypes;
|
||||
|
||||
const relayedEvents = new WeakSet();
|
||||
const relayPinch = (event) => {
|
||||
if (
|
||||
!event.ctrlKey ||
|
||||
relayedEvents.has(event) ||
|
||||
event.deltaMode !== 0 ||
|
||||
!Number.isFinite(event.deltaY) ||
|
||||
event.deltaY === 0
|
||||
)
|
||||
return;
|
||||
let relayed;
|
||||
try {
|
||||
relayed = createWheelEvent('wheel', {
|
||||
deltaX: event.deltaX,
|
||||
deltaY: boundedPinchDelta(event.deltaY),
|
||||
deltaZ: event.deltaZ,
|
||||
deltaMode: event.deltaMode,
|
||||
screenX: event.screenX,
|
||||
screenY: event.screenY,
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: globalThis.window,
|
||||
});
|
||||
} catch {
|
||||
// The registered Ctrl+wheel binding can still consume the original.
|
||||
return;
|
||||
}
|
||||
relayedEvents.add(relayed);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
canvas.dispatchEvent(relayed);
|
||||
};
|
||||
container.addEventListener('wheel', relayPinch, {
|
||||
capture: true,
|
||||
passive: false,
|
||||
});
|
||||
|
||||
let active = true;
|
||||
return () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
container.removeEventListener('wheel', relayPinch, true);
|
||||
if (
|
||||
!alreadyHandlesControlWheel &&
|
||||
controller.zoomEventTypes === configuredZoomEventTypes
|
||||
)
|
||||
controller.zoomEventTypes = originalZoomEventTypes;
|
||||
};
|
||||
}
|
||||
|
||||
/** Cesium.Viewer options of the standard globe viewer. */
|
||||
export function applicationViewerOptions(creditContainer) {
|
||||
return {
|
||||
|
||||
+16
-18
@@ -1,36 +1,34 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { toRadians } from './geo/index.js';
|
||||
import { cameraOf } from './render/rendererOf.js';
|
||||
|
||||
/**
|
||||
* Set camera to Austin on load with a cinematic fly-in.
|
||||
* @returns {Function} Cancels the pending or active startup flight.
|
||||
*/
|
||||
export function flyToAustin(viewer) {
|
||||
const camera = cameraOf(viewer);
|
||||
// Start from a high altitude, then fly down
|
||||
viewer.camera.setView({
|
||||
destination: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 25000),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(0),
|
||||
pitch: Cesium.Math.toRadians(-90),
|
||||
roll: 0.0,
|
||||
},
|
||||
camera.setView({
|
||||
destination: { lon: -97.7431, lat: 30.2672, height: 25000 },
|
||||
orientation: { heading: 0, pitch: toRadians(-90), roll: 0 },
|
||||
});
|
||||
|
||||
// Cinematic fly-in after a brief pause
|
||||
let flight = null;
|
||||
const timer = setTimeout(() => {
|
||||
if (viewer.isDestroyed()) return;
|
||||
viewer.camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 600),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(15),
|
||||
pitch: Cesium.Math.toRadians(-30),
|
||||
roll: 0.0,
|
||||
flight = camera.flyTo(
|
||||
{ lon: -97.7431, lat: 30.2672, height: 600 },
|
||||
{
|
||||
orientation: { heading: toRadians(15), pitch: toRadians(-30), roll: 0 },
|
||||
durationS: 4.0,
|
||||
easing: 'cubicInOut',
|
||||
},
|
||||
duration: 4.0,
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
});
|
||||
);
|
||||
}, 500);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
if (!viewer.isDestroyed()) viewer.camera.cancelFlight();
|
||||
// Only the fly-in: a flight the user started since is theirs.
|
||||
if (!viewer.isDestroyed()) flight?.cancel();
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { bindFakeCesiumCamera } from './testSupport/fakeCameraPort.mjs';
|
||||
import { GLOBE_VIEW } from './locations.js';
|
||||
import { projectEarthDiscToViewport } from './celestialRing.js';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -146,13 +147,13 @@ function harness(
|
||||
roll: radians(roll),
|
||||
},
|
||||
});
|
||||
const viewer = {
|
||||
const viewer = bindFakeCesiumCamera({
|
||||
camera,
|
||||
scene,
|
||||
trackedEntity: undefined,
|
||||
trackedEntityChanged: new Cesium.Event(),
|
||||
clock: { onTick: new Cesium.Event() },
|
||||
};
|
||||
});
|
||||
const target = Cesium.Cartesian3.fromDegrees(
|
||||
longitudeDeg,
|
||||
latitude,
|
||||
@@ -288,9 +289,12 @@ test('replaced viewer releases the old tracking listener without duplicate subsc
|
||||
true,
|
||||
);
|
||||
const id = activeCameraMotionId();
|
||||
first.viewer.trackedEntityChanged.raiseEvent({ id: 'stale-aircraft' });
|
||||
// As Cesium's setter does: the value changes, then the event fires.
|
||||
first.viewer.trackedEntity = { id: 'stale-aircraft' };
|
||||
first.viewer.trackedEntityChanged.raiseEvent(first.viewer.trackedEntity);
|
||||
assert.equal(activeCameraMotionId(), id);
|
||||
next.viewer.trackedEntityChanged.raiseEvent({ id: 'new-aircraft' });
|
||||
next.viewer.trackedEntity = { id: 'new-aircraft' };
|
||||
next.viewer.trackedEntityChanged.raiseEvent(next.viewer.trackedEntity);
|
||||
assert.equal(getActiveCameraMotion(), null);
|
||||
assert.equal(hasRenderHold(), false);
|
||||
});
|
||||
@@ -597,7 +601,7 @@ for (const mode of ['once', 'continuous']) {
|
||||
|
||||
test('direct globe rotation refuses a tracked camera without disturbing its reference frame', (t) => {
|
||||
const { viewer, camera, target } = harness(t);
|
||||
viewer.trackedEntity = { id: 'tracked-aircraft' };
|
||||
viewer.trackedEntity = { id: 'tracked-aircraft', name: 'tracked-aircraft' };
|
||||
camera.lookAt(target, new Cesium.HeadingPitchRange(0, radians(-40), 1_000));
|
||||
const before = pose(camera);
|
||||
const transform = Cesium.Matrix4.clone(camera.transform);
|
||||
|
||||
+81
-49
@@ -14,8 +14,12 @@
|
||||
* lifts the eye if it sits below a usable clearance. Heading and pitch are
|
||||
* held, so the correction reads as the shot settling.
|
||||
*/
|
||||
import { onFollowChanged } from './data/followed.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { ownCameraArrival } from './data/cameraArrival.js';
|
||||
import { heightsFor } from './queries/heights.js';
|
||||
import { cameraOf } from './render/cesium/camera.js';
|
||||
import { cameraFlightActive } from './render/cesium/internals.js';
|
||||
|
||||
/** Minimum eye height above the rendered surface for a usable view. */
|
||||
export const MIN_EYE_CLEARANCE_M = 120;
|
||||
@@ -24,6 +28,11 @@ const GUARD_ATTEMPTS = 8;
|
||||
const GUARD_INTERVAL_MS = 600;
|
||||
/** Ignore sub-metre noise rather than nudging the camera forever. */
|
||||
const GUARD_EPSILON_M = 2;
|
||||
/** Eye travel (m) and turn (cosine) that mean another owner moved the camera. */
|
||||
const MOVED_M = 0.01;
|
||||
const TURNED_COS = 1 - 1e-9;
|
||||
|
||||
const groundScratch = new Cesium.Cartesian3();
|
||||
|
||||
const probeScratch = new Cesium.Cartographic();
|
||||
|
||||
@@ -37,22 +46,60 @@ const probeScratch = new Cesium.Cartographic();
|
||||
function sampleSurfaceM(scene, cells) {
|
||||
let heightM = Number.NaN;
|
||||
let sampled = 0;
|
||||
if (typeof scene?.sampleHeight !== 'function') return { heightM, sampled };
|
||||
if (!scene) return { heightM, sampled };
|
||||
const heights = heightsFor(scene);
|
||||
for (const cell of cells) {
|
||||
try {
|
||||
const height = scene.sampleHeight(
|
||||
Cesium.Cartographic.fromDegrees(cell.lon, cell.lat, 0, probeScratch),
|
||||
);
|
||||
if (!Number.isFinite(height)) continue;
|
||||
sampled += 1;
|
||||
heightM = Number.isFinite(heightM) ? Math.max(heightM, height) : height;
|
||||
} catch {
|
||||
/* tiles not ready for this cell */
|
||||
}
|
||||
// A miss is a cell whose tiles are not ready.
|
||||
const height = heights.now(
|
||||
Cesium.Cartographic.fromDegrees(cell.lon, cell.lat, 0, probeScratch),
|
||||
);
|
||||
if (height === null) continue;
|
||||
sampled += 1;
|
||||
heightM = Number.isFinite(heightM) ? Math.max(heightM, height) : height;
|
||||
}
|
||||
return { heightM, sampled };
|
||||
}
|
||||
|
||||
/** The eye's ground point (height 0), its height, and which way it looks. */
|
||||
function poseOf(camera) {
|
||||
const carto =
|
||||
camera?.positionWC && Cesium.Cartographic.fromCartesian(camera.positionWC);
|
||||
if (!carto || !camera.directionWC || !camera.upWC) return null;
|
||||
return {
|
||||
ground: Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude),
|
||||
height: carto.height,
|
||||
direction: Cesium.Cartesian3.clone(camera.directionWC),
|
||||
up: Cesium.Cartesian3.clone(camera.upWC),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether another owner has moved the camera off `pose`: across the ground,
|
||||
* down, or around. A pure rise is not one: Cesium's terrain collision lifts a
|
||||
* buried eye straight up as tiles load, which is the case the guard exists
|
||||
* for, so `pose` follows the highest eye seen and the guard keeps measuring.
|
||||
*/
|
||||
function moved(camera, pose) {
|
||||
const carto = Cesium.Cartographic.fromCartesian(camera.positionWC);
|
||||
if (!carto) return true;
|
||||
const ground = Cesium.Cartesian3.fromRadians(
|
||||
carto.longitude,
|
||||
carto.latitude,
|
||||
0,
|
||||
undefined,
|
||||
groundScratch,
|
||||
);
|
||||
if (
|
||||
Cesium.Cartesian3.distance(ground, pose.ground) > MOVED_M ||
|
||||
carto.height < pose.height - MOVED_M ||
|
||||
Cesium.Cartesian3.dot(camera.directionWC, pose.direction) < TURNED_COS ||
|
||||
Cesium.Cartesian3.dot(camera.upWC, pose.up) < TURNED_COS
|
||||
)
|
||||
return true;
|
||||
pose.height = Math.max(pose.height, carto.height);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* How far the camera must rise to clear the ground, given a measured surface.
|
||||
* Pure so the policy is testable without a scene.
|
||||
@@ -76,8 +123,10 @@ export function groundClearanceDeficitM(
|
||||
* Watch an arrival and lift the camera once the real surface can be measured.
|
||||
*
|
||||
* Stops quietly on a clear view, exhausted attempts, the user taking the
|
||||
* controls (pointer or wheel on the canvas), or `isStale()` reporting that a
|
||||
* newer arrival owns the camera.
|
||||
* controls (pointer or wheel on the canvas), `isStale()` reporting that a
|
||||
* newer arrival owns the camera, or any other caller moving the camera or
|
||||
* starting a flight in the meantime: the guard only ever corrects the pose it
|
||||
* was started on.
|
||||
* @param {Cesium.Viewer} viewer Live viewer.
|
||||
* @param {{lat: number, lon: number}} at Destination being framed.
|
||||
* @param {{clearanceM?: number, attempts?: number, intervalMs?: number,
|
||||
@@ -95,6 +144,10 @@ export function guardCameraAboveGround(viewer, at, options = {}) {
|
||||
if (!viewer?.scene || !Number.isFinite(at?.lat) || !Number.isFinite(at?.lon))
|
||||
return () => {};
|
||||
|
||||
// Without a readable pose the guard could not tell a later owner's move.
|
||||
const arrival = poseOf(viewer.camera);
|
||||
if (!arrival) return () => {};
|
||||
|
||||
let timer = null;
|
||||
let done = false;
|
||||
const canvas = viewer.scene.canvas;
|
||||
@@ -112,32 +165,7 @@ export function guardCameraAboveGround(viewer, at, options = {}) {
|
||||
release();
|
||||
};
|
||||
release = ownCameraArrival(viewer, stop);
|
||||
// Direct camera API callers (including duration-zero flights) must yield
|
||||
// before moving the eye. Restore the exact method ownership on completion.
|
||||
for (const name of [
|
||||
'flyTo',
|
||||
'flyToBoundingSphere',
|
||||
'setView',
|
||||
'lookAt',
|
||||
'cancelFlight',
|
||||
]) {
|
||||
const camera = viewer.camera;
|
||||
const original = camera[name];
|
||||
if (typeof original !== 'function') continue;
|
||||
const descriptor = Object.getOwnPropertyDescriptor(camera, name);
|
||||
const wrapped = function (...args) {
|
||||
stop();
|
||||
return original.apply(this, args);
|
||||
};
|
||||
camera[name] = wrapped;
|
||||
removers.push(() => {
|
||||
if (camera[name] !== wrapped) return;
|
||||
if (descriptor) Object.defineProperty(camera, name, descriptor);
|
||||
else delete camera[name];
|
||||
});
|
||||
}
|
||||
const removeTracking = viewer.trackedEntityChanged?.addEventListener(stop);
|
||||
if (removeTracking) removers.push(removeTracking);
|
||||
removers.push(onFollowChanged(viewer, stop));
|
||||
documentRef?.addEventListener?.('keydown', stop, true);
|
||||
// A manual gesture ends the guard, as it interrupts camera motion elsewhere.
|
||||
for (const type of ['pointerdown', 'wheel'])
|
||||
@@ -147,8 +175,10 @@ export function guardCameraAboveGround(viewer, at, options = {}) {
|
||||
const attempt = () => {
|
||||
if (done) return;
|
||||
if (isStale() || viewer.isDestroyed?.()) return stop();
|
||||
remaining -= 1;
|
||||
const camera = viewer.camera;
|
||||
// Another caller owns the camera now; lifting would undo its move.
|
||||
if (cameraFlightActive(camera) || moved(camera, arrival)) return stop();
|
||||
remaining -= 1;
|
||||
const carto = camera.positionCartographic;
|
||||
// The eye must clear both the framed target and the ground beneath it.
|
||||
const below = {
|
||||
@@ -167,20 +197,22 @@ export function guardCameraAboveGround(viewer, at, options = {}) {
|
||||
);
|
||||
if (deficit > 0) {
|
||||
stop();
|
||||
camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromRadians(
|
||||
cameraOf(viewer).flyTo(
|
||||
Cesium.Cartesian3.fromRadians(
|
||||
carto.longitude,
|
||||
carto.latitude,
|
||||
carto.height + deficit,
|
||||
),
|
||||
orientation: {
|
||||
heading: camera.heading,
|
||||
pitch: camera.pitch,
|
||||
roll: camera.roll,
|
||||
{
|
||||
orientation: {
|
||||
heading: camera.heading,
|
||||
pitch: camera.pitch,
|
||||
roll: camera.roll,
|
||||
},
|
||||
durationS: 0.9,
|
||||
easing: 'cubicInOut',
|
||||
},
|
||||
duration: 0.9,
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
});
|
||||
);
|
||||
try {
|
||||
onLift?.(deficit);
|
||||
} catch {
|
||||
|
||||
+146
-22
@@ -3,6 +3,7 @@
|
||||
// Arrival framing is computed before the destination's tiles exist, so it
|
||||
// works from a predicted ground height. The guard measures the real surface
|
||||
// after arrival and lifts the eye; these tests pin when it acts.
|
||||
import { bindFakeCesiumCamera } from './testSupport/fakeCameraPort.mjs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
@@ -49,8 +50,12 @@ test('the clearance frames a subject without turning into an overflight', () =>
|
||||
|
||||
function fakeViewer({ cameraHeightM, surfaceM }) {
|
||||
const flights = [];
|
||||
const position = Cesium.Cartographic.fromDegrees(-97.7603, 30.3141, cameraHeightM);
|
||||
return {
|
||||
const position = Cesium.Cartographic.fromDegrees(
|
||||
-97.7603,
|
||||
30.3141,
|
||||
cameraHeightM,
|
||||
);
|
||||
return bindFakeCesiumCamera({
|
||||
flights,
|
||||
scene: {
|
||||
canvas: null,
|
||||
@@ -59,12 +64,19 @@ function fakeViewer({ cameraHeightM, surfaceM }) {
|
||||
},
|
||||
camera: {
|
||||
positionCartographic: position,
|
||||
positionWC: Cesium.Cartographic.toCartesian(position),
|
||||
directionWC: new Cesium.Cartesian3(0, 0, -1),
|
||||
upWC: new Cesium.Cartesian3(0, 1, 0),
|
||||
heading: 0.5,
|
||||
pitch: -0.55,
|
||||
roll: 0,
|
||||
flyTo: (options) => flights.push(options),
|
||||
// As Cesium does: a flight is under way until it completes.
|
||||
flyTo(options) {
|
||||
flights.push(options);
|
||||
this._currentFlight = {};
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
test('the guard lifts a buried arrival once the surface answers', async () => {
|
||||
@@ -104,7 +116,11 @@ test('the guard waits for streaming tiles and yields to a newer arrival', async
|
||||
|
||||
test('the guard leaves a clear view untouched', async () => {
|
||||
const viewer = fakeViewer({ cameraHeightM: 400, surfaceM: 171 });
|
||||
guardCameraAboveGround(viewer, { lat: 30.3125, lon: -97.765 }, { intervalMs: 1 });
|
||||
guardCameraAboveGround(
|
||||
viewer,
|
||||
{ lat: 30.3125, lon: -97.765 },
|
||||
{ intervalMs: 1 },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.equal(viewer.flights.length, 0);
|
||||
});
|
||||
@@ -122,33 +138,76 @@ function handoffFixture(t) {
|
||||
viewer.trackedEntityChanged = new Cesium.Event();
|
||||
viewer.camera.cancelFlight = () => {};
|
||||
viewer.camera.lookAtTransform = () => {};
|
||||
viewer.camera.flyToBoundingSphere = () => {};
|
||||
viewer.camera.setView = () => {};
|
||||
viewer.camera.flyToBoundingSphere = function () {
|
||||
this._currentFlight = {};
|
||||
};
|
||||
viewer.camera.setView = function () {
|
||||
this.positionWC = Cesium.Cartesian3.add(
|
||||
this.positionWC,
|
||||
new Cesium.Cartesian3(0, 0, 50),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
};
|
||||
const originalFly = viewer.camera.flyTo;
|
||||
const navigation = new NavigationController({
|
||||
viewer,
|
||||
tracking: Object.fromEntries(['flightsLayer', 'militaryFlightsLayer',
|
||||
'satellitesLayer', 'aisLiveVesselsLayer', 'militaryAwarenessLayer',
|
||||
'rocketLaunchesLayer'].map((key) => [key, {}])),
|
||||
cancelOrientation() {}, clearLocation() {}, cancelShareSelection() {},
|
||||
getDataManager() {}, interruptCameraMotion() {}, stopOrbit() {},
|
||||
isCockpitActive: () => false, showToast() {},
|
||||
tracking: Object.fromEntries(
|
||||
[
|
||||
'flightsLayer',
|
||||
'militaryFlightsLayer',
|
||||
'satellitesLayer',
|
||||
'aisLiveVesselsLayer',
|
||||
'militaryAwarenessLayer',
|
||||
'rocketLaunchesLayer',
|
||||
].map((key) => [key, {}]),
|
||||
),
|
||||
cancelOrientation() {},
|
||||
clearLocation() {},
|
||||
cancelShareSelection() {},
|
||||
getDataManager() {},
|
||||
interruptCameraMotion() {},
|
||||
stopOrbit() {},
|
||||
isCockpitActive: () => false,
|
||||
showToast() {},
|
||||
});
|
||||
guardCameraAboveGround(viewer, { lat: 30.3, lon: -97.7 });
|
||||
return { viewer, navigation, originalFly };
|
||||
}
|
||||
|
||||
for (const handoff of ['Director', 'tracking', 'follow', 'keyboard', 'UI',
|
||||
'flyTo', 'flyToBoundingSphere', 'setView', 'layer-disable', 'layer-destroy', 'app-stop']) {
|
||||
for (const handoff of [
|
||||
'Director',
|
||||
'tracking',
|
||||
'follow',
|
||||
'keyboard',
|
||||
'UI',
|
||||
'flyTo',
|
||||
'flyToBoundingSphere',
|
||||
'setView',
|
||||
'layer-disable',
|
||||
'layer-destroy',
|
||||
'app-stop',
|
||||
]) {
|
||||
test(`arrival guard relinquishes and removes hooks on ${handoff}`, async (t) => {
|
||||
const { viewer, navigation, originalFly } = handoffFixture(t);
|
||||
const methods = [
|
||||
'flyTo',
|
||||
'flyToBoundingSphere',
|
||||
'setView',
|
||||
'lookAt',
|
||||
'cancelFlight',
|
||||
].map((name) => viewer.camera[name]);
|
||||
if (handoff === 'Director') {
|
||||
SceneDirector.prototype._claimCameraOwnership.call({
|
||||
viewer, styleManager: { runImmediateNavigation: (noun, fn) =>
|
||||
navigation._runExplicitNavigation(noun, fn) },
|
||||
viewer,
|
||||
styleManager: {
|
||||
runImmediateNavigation: (noun, fn) =>
|
||||
navigation._runExplicitNavigation(noun, fn),
|
||||
},
|
||||
});
|
||||
} else if (handoff === 'tracking') {
|
||||
viewer.trackedEntityChanged.raiseEvent({ id: 'contact' });
|
||||
// As Cesium's setter does: the value changes, then the event fires.
|
||||
viewer.trackedEntity = { id: 'contact' };
|
||||
viewer.trackedEntityChanged.raiseEvent(viewer.trackedEntity);
|
||||
} else if (handoff === 'follow' || handoff === 'UI') {
|
||||
navigation._stampNavigation({ cancelPendingSelection: false });
|
||||
} else if (handoff === 'keyboard') {
|
||||
@@ -156,16 +215,81 @@ for (const handoff of ['Director', 'tracking', 'follow', 'keyboard', 'UI',
|
||||
} else if (handoff === 'app-stop') navigation.stop();
|
||||
else if (handoff.startsWith('layer-')) {
|
||||
const lifecycle = new LayerLifecycle(viewer);
|
||||
lifecycle.register({ id: 'test', init() {}, enable() {}, disable() {}, destroy() {} });
|
||||
lifecycle.register({
|
||||
id: 'test',
|
||||
init() {},
|
||||
enable() {},
|
||||
disable() {},
|
||||
destroy() {},
|
||||
});
|
||||
await lifecycle.setEnabled('test', true);
|
||||
if (handoff === 'layer-disable') await lifecycle.setEnabled('test', false);
|
||||
if (handoff === 'layer-disable')
|
||||
await lifecycle.setEnabled('test', false);
|
||||
else await lifecycle.destroyLayer('test');
|
||||
} else viewer.camera[handoff]({});
|
||||
// A direct flyTo is itself one recorded flight; the guard adds none.
|
||||
const priorFlights = viewer.flights.length;
|
||||
t.mock.timers.tick(10000);
|
||||
assert.equal(viewer.flights.length, priorFlights, 'the old probe cannot fly');
|
||||
assert.equal(viewer.camera.flyTo, originalFly, 'direct-call hook is removed');
|
||||
assert.equal(
|
||||
viewer.flights.length,
|
||||
priorFlights,
|
||||
'the old probe cannot fly',
|
||||
);
|
||||
assert.equal(viewer.camera.flyTo, originalFly);
|
||||
assert.deepEqual(
|
||||
['flyTo', 'flyToBoundingSphere', 'setView', 'lookAt', 'cancelFlight'].map(
|
||||
(name) => viewer.camera[name],
|
||||
),
|
||||
methods,
|
||||
'the guard never replaces camera methods',
|
||||
);
|
||||
assert.equal(viewer.trackedEntityChanged.numberOfListeners, 0);
|
||||
navigation.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
test('the guard yields to a camera moved without any camera method', async () => {
|
||||
const viewer = fakeViewer({ cameraHeightM: 172, surfaceM: 171 });
|
||||
guardCameraAboveGround(
|
||||
viewer,
|
||||
{ lat: 30.3125, lon: -97.765 },
|
||||
{ intervalMs: 5 },
|
||||
);
|
||||
// A cockpit or follow frame writes the pose directly.
|
||||
viewer.camera.directionWC = new Cesium.Cartesian3(1, 0, 0);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.equal(viewer.flights.length, 0, 'the moved view is not corrected');
|
||||
});
|
||||
|
||||
test('Cesium lifting a buried eye straight up is not another owner', async () => {
|
||||
const viewer = fakeViewer({ cameraHeightM: 104, surfaceM: 171 });
|
||||
guardCameraAboveGround(
|
||||
viewer,
|
||||
{ lat: 30.3125, lon: -97.765 },
|
||||
{ intervalMs: 5 },
|
||||
);
|
||||
// Terrain collision raises the eye to just above the loaded ground.
|
||||
const raised = Cesium.Cartographic.fromDegrees(-97.7603, 30.3141, 172);
|
||||
viewer.camera.positionCartographic = raised;
|
||||
viewer.camera.positionWC = Cesium.Cartographic.toCartesian(raised);
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(viewer.flights.length, 1, 'the guard still lifts the eye');
|
||||
const lifted = Cesium.Cartographic.fromCartesian(
|
||||
viewer.flights[0].destination,
|
||||
);
|
||||
assert.ok(Math.abs(lifted.height - (171 + MIN_EYE_CLEARANCE_M)) < 0.5);
|
||||
});
|
||||
|
||||
test('another owner lowering the eye is not corrected', async () => {
|
||||
const viewer = fakeViewer({ cameraHeightM: 172, surfaceM: 171 });
|
||||
guardCameraAboveGround(
|
||||
viewer,
|
||||
{ lat: 30.3125, lon: -97.765 },
|
||||
{ intervalMs: 5 },
|
||||
);
|
||||
const lowered = Cesium.Cartographic.fromDegrees(-97.7603, 30.3141, 150);
|
||||
viewer.camera.positionCartographic = lowered;
|
||||
viewer.camera.positionWC = Cesium.Cartographic.toCartesian(lowered);
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(viewer.flights.length, 0);
|
||||
});
|
||||
@@ -44,8 +44,8 @@ test('Cockpit takeover invalidates deferred work before camera cancellation', ()
|
||||
ordered(enter, [
|
||||
'if (!info || !entity?.position) return false;',
|
||||
'this.onCameraTakeover?.();',
|
||||
'this.viewer.camera.cancelFlight();',
|
||||
'this.viewer.trackedEntity = undefined;',
|
||||
'cameraOf(this.viewer).cancelFlight();',
|
||||
'stopFollowing(this.viewer);',
|
||||
], 'Cockpit takeover');
|
||||
assert.match(
|
||||
ui,
|
||||
@@ -97,7 +97,7 @@ test('voice Cockpit entry reaches the camera only through stamping seams', () =>
|
||||
// The transaction has exactly two camera-owner mutations, and each one
|
||||
// stamps:
|
||||
// 1. selectedLayer.trackById(id) -> viewer.trackedEntity
|
||||
// -> viewer.trackedEntityChanged -> _stampNavigation()
|
||||
// -> onFollowChanged(viewer) -> _stampNavigation()
|
||||
// 2. cockpitView.enter() -> onCameraTakeover() -> _stampNavigation()
|
||||
const transaction = body(
|
||||
cockpitTracking,
|
||||
@@ -117,7 +117,7 @@ test('voice Cockpit entry reaches the camera only through stamping seams', () =>
|
||||
// Seam 1: any tracker handoff stamps, so the adoption step is covered.
|
||||
assert.match(
|
||||
ui,
|
||||
/viewer\.trackedEntityChanged\.addEventListener\(\s*\(entity\) => \{\s*if \(entity && !this\._disposed\)\s*this\._stampNavigation\(\{\s*cancelPendingSelection: false,?\s*\}\);/,
|
||||
/onFollowChanged\(\s*this\.viewer,\s*\(followed\) => \{\s*if \(followed && !this\._disposed\)\s*this\._stampNavigation\(\{\s*cancelPendingSelection: false,?\s*\}\);/,
|
||||
'tracker handoff must stamp',
|
||||
);
|
||||
// Seam 2 is pinned by "Cockpit takeover invalidates deferred work" above.
|
||||
@@ -190,10 +190,10 @@ test('accepted navigation releases through PR15-aware ownership before flight',
|
||||
'origin: trackingOrigin',
|
||||
'satellitesLayer.stopTracking?.({ origin: trackingOrigin })',
|
||||
'rocketLaunchesLayer.releaseCameraOwnership?.()',
|
||||
'this.viewer.trackedEntity = undefined;',
|
||||
'stopFollowing(this.viewer);',
|
||||
"interruptCameraMotion('explicit-navigation')",
|
||||
'this.viewer.camera.cancelFlight();',
|
||||
'this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);',
|
||||
'camera?.cancelFlight();',
|
||||
'camera?.release();',
|
||||
], 'follow release');
|
||||
assert.match(release, /flightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/);
|
||||
assert.match(release, /militaryFlightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/);
|
||||
@@ -389,7 +389,7 @@ test('vessel and fire layers announce valid clicks and never fly cameras', () =>
|
||||
}
|
||||
const vesselClick = body(
|
||||
vessels,
|
||||
/handler\.setInputAction\(\s*\(click\) => \{([\s\S]*?)\n\s*\},\s*Cesium\.ScreenSpaceEventType\.LEFT_CLICK,?\s*\);/,
|
||||
/inputFor\(viewer\)\.onClick\(\(event\) => \{([\s\S]*?)\n \}\);/,
|
||||
'vessel click',
|
||||
);
|
||||
ordered(vesselClick, [
|
||||
@@ -405,7 +405,7 @@ test('vessel and fire layers announce valid clicks and never fly cameras', () =>
|
||||
assert.match(vesselFocus, /requestWorldFocus\(\{/);
|
||||
const fireClick = body(
|
||||
firms,
|
||||
/_clickHandler\.setInputAction\(\(click\) => \{([\s\S]*?)\n \}, Cesium\.ScreenSpaceEventType\.LEFT_CLICK\);/,
|
||||
/input\.onClick\(\(event\) => \{([\s\S]*?)\n \}\);/,
|
||||
'fire click',
|
||||
);
|
||||
ordered(fireClick, [
|
||||
|
||||
+42
-46
@@ -18,6 +18,7 @@
|
||||
* globe motion, opposite to the camera's travel around Earth.
|
||||
*/
|
||||
|
||||
import { followedOf, isFollowing, onFollowChanged } from './data/followed.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { GLOBE_VIEW } from './locations.js';
|
||||
import {
|
||||
@@ -29,6 +30,9 @@ import {
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
} from './renderGovernor.js';
|
||||
import { heightsFor } from './queries/heights.js';
|
||||
import { cameraOf } from './render/cesium/camera.js';
|
||||
import { findCamera } from './render/rendererOf.js';
|
||||
|
||||
let _isCockpitActive = null;
|
||||
/** °/s by speed word — orbit; pan uses fractions of view height/s. */
|
||||
@@ -559,25 +563,18 @@ function acquireCorridorFloor(state, takeWhole) {
|
||||
export function probeMeshFloorM(scene, cells) {
|
||||
const requested = Array.isArray(cells) ? cells.length : 0;
|
||||
const empty = { heightM: Number.NaN, sampled: 0, requested };
|
||||
if (!scene || typeof scene.sampleHeight !== 'function' || !requested)
|
||||
return empty;
|
||||
if (!scene || !requested) return empty;
|
||||
const heights = heightsFor(scene);
|
||||
let heightM = Number.NaN;
|
||||
let sampled = 0;
|
||||
for (const cell of cells) {
|
||||
try {
|
||||
const carto = Cesium.Cartographic.fromDegrees(
|
||||
cell.lon,
|
||||
cell.lat,
|
||||
0,
|
||||
_probeCarto,
|
||||
);
|
||||
const height = scene.sampleHeight(carto);
|
||||
if (!Number.isFinite(height)) continue;
|
||||
sampled += 1;
|
||||
heightM = Number.isFinite(heightM) ? Math.max(heightM, height) : height;
|
||||
} catch {
|
||||
/* tiles not ready for this cell */
|
||||
}
|
||||
// A miss is a cell whose tiles are not ready.
|
||||
const height = heights.now(
|
||||
Cesium.Cartographic.fromDegrees(cell.lon, cell.lat, 0, _probeCarto),
|
||||
);
|
||||
if (height === null) continue;
|
||||
sampled += 1;
|
||||
heightM = Number.isFinite(heightM) ? Math.max(heightM, height) : height;
|
||||
}
|
||||
return { heightM, sampled, requested };
|
||||
}
|
||||
@@ -1010,7 +1007,7 @@ export function interruptCameraMotionIfActive(
|
||||
|
||||
function clearLookAt() {
|
||||
try {
|
||||
_viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
findCamera(_viewer)?.release();
|
||||
} catch {
|
||||
/* teardown race */
|
||||
}
|
||||
@@ -1028,13 +1025,11 @@ function clearLookAt() {
|
||||
*/
|
||||
function levelCameraRoll() {
|
||||
try {
|
||||
const cam = _viewer?.camera;
|
||||
if (!cam || typeof cam.setView !== 'function') return false;
|
||||
const roll = Cesium.Math.negativePiToPi(cam.roll || 0);
|
||||
if (Math.abs(roll) < 1e-5) return false;
|
||||
cam.setView({
|
||||
orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 },
|
||||
});
|
||||
const camera = findCamera(_viewer);
|
||||
if (!camera) return false;
|
||||
const { heading, pitch, roll } = camera.pose();
|
||||
if (Math.abs(Cesium.Math.negativePiToPi(roll || 0)) < 1e-5) return false;
|
||||
camera.setView({ orientation: { heading, pitch, roll: 0 } });
|
||||
return true;
|
||||
} catch {
|
||||
return false; // teardown race
|
||||
@@ -1047,7 +1042,7 @@ export function interruptCameraMotion(reason = 'interrupt') {
|
||||
// While an entity is TRACKED, the follow camera owns the reference frame —
|
||||
// resetting the lookAt transform here flings the camera into space (field
|
||||
// finding). Leave the transform to the tracker; it re-asserts every frame.
|
||||
const tracking = Boolean(_viewer?.trackedEntity);
|
||||
const tracking = isFollowing(_viewer);
|
||||
const wasRoute = wasActive && _active.kind === 'route';
|
||||
if (
|
||||
wasActive &&
|
||||
@@ -1087,6 +1082,9 @@ export function getActiveCameraMotion() {
|
||||
return info;
|
||||
}
|
||||
|
||||
/** A pan's direction as the camera move it makes; any other is down. */
|
||||
const PAN_MOVES = Object.freeze({ left: 'left', right: 'right', up: 'up' });
|
||||
|
||||
function onTick() {
|
||||
if (!_active || !_viewer) return;
|
||||
// Wall-clock dt: clock.currentTime FREEZES when the app clock isn't
|
||||
@@ -1097,6 +1095,8 @@ function onTick() {
|
||||
Math.max(0.001, (nowMs - (_active.lastMs ?? nowMs)) / 1000),
|
||||
);
|
||||
_active.lastMs = nowMs;
|
||||
// Reads stay on Cesium's camera until the query port has them; every
|
||||
// write goes through the camera port.
|
||||
const cam = _viewer.camera;
|
||||
const m = _active;
|
||||
|
||||
@@ -1112,6 +1112,7 @@ function onTick() {
|
||||
}
|
||||
|
||||
try {
|
||||
const camera = cameraOf(_viewer);
|
||||
if (m.kind === 'orbit' && m.pending) {
|
||||
// Armed but target-less (started mid-flight, e.g. "fly to X and orbit
|
||||
// it"): keep trying for a ground target until the camera settles.
|
||||
@@ -1161,7 +1162,7 @@ function onTick() {
|
||||
} else {
|
||||
m.hpr.heading += rate * dt;
|
||||
}
|
||||
cam.lookAt(m.target, m.hpr);
|
||||
camera.lookAt(m.target, m.hpr);
|
||||
} else if (m.kind === 'pan') {
|
||||
const heightM = Math.max(50, cam.positionCartographic.height);
|
||||
const step =
|
||||
@@ -1169,10 +1170,7 @@ function onTick() {
|
||||
? heightM * ONCE.panViewFraction * (scale - (m.lastScale || 0))
|
||||
: heightM * PAN_VIEW_FRACTION_S[m.speed] * dt;
|
||||
m.lastScale = scale;
|
||||
if (m.direction === 'left') cam.moveLeft(step);
|
||||
else if (m.direction === 'right') cam.moveRight(step);
|
||||
else if (m.direction === 'up') cam.moveUp(step);
|
||||
else cam.moveDown(step);
|
||||
camera.move(PAN_MOVES[m.direction] ?? 'down', step);
|
||||
} else if (
|
||||
m.kind === 'tilt' ||
|
||||
m.kind === 'rotate' ||
|
||||
@@ -1193,7 +1191,7 @@ function onTick() {
|
||||
// only describes Cesium's rotation while the frame is identity.
|
||||
if (
|
||||
_viewer.scene.mode !== Cesium.SceneMode.SCENE3D ||
|
||||
_viewer.trackedEntity ||
|
||||
isFollowing(_viewer) ||
|
||||
_isCockpitActive?.() ||
|
||||
!Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY)
|
||||
) {
|
||||
@@ -1217,7 +1215,7 @@ function onTick() {
|
||||
interruptCameraMotion('globe-surface-limit');
|
||||
return;
|
||||
}
|
||||
cam.rotate(m.rotationAxis, angle);
|
||||
camera.rotateAround(m.rotationAxis, angle);
|
||||
} else if (m.kind === 'tilt') {
|
||||
const up = m.direction === 'up';
|
||||
// Clamp pitch to [-89°, -5°]: never through the ground, never at the sky.
|
||||
@@ -1226,10 +1224,8 @@ function onTick() {
|
||||
interruptCameraMotion('tilt-clamp');
|
||||
return;
|
||||
}
|
||||
if (up) cam.lookUp(stepRad);
|
||||
else cam.lookDown(stepRad);
|
||||
} else if (m.direction === 'left') cam.lookLeft(stepRad);
|
||||
else cam.lookRight(stepRad);
|
||||
camera.look(up ? 'up' : 'down', stepRad);
|
||||
} else camera.look(m.direction === 'left' ? 'left' : 'right', stepRad);
|
||||
} else if (m.kind === 'route') {
|
||||
// The dolly's shaping lives in advanceRouteFlight(); the tick only
|
||||
// applies the pose and honours the same completion path as before.
|
||||
@@ -1238,7 +1234,7 @@ function onTick() {
|
||||
// user left it rather than teleporting to an altitude derived from
|
||||
// nothing. An interrupt during the arm is still instant.
|
||||
if (frame.arming) return;
|
||||
cam.setView({
|
||||
camera.setView({
|
||||
destination: frame.eye,
|
||||
orientation: { direction: frame.direction, up: frame.up },
|
||||
});
|
||||
@@ -1418,8 +1414,8 @@ export function initCameraVerbs(
|
||||
if (_trackingRemover) _trackingRemover();
|
||||
// Cockpit can acquire then clear tracking before the next clock tick.
|
||||
// Yield on acquisition itself without changing legacy camera motions.
|
||||
_trackingRemover = viewer.trackedEntityChanged?.addEventListener((entity) => {
|
||||
if (entity && _active?.kind === 'rotate_globe')
|
||||
_trackingRemover = onFollowChanged(viewer, (followed) => {
|
||||
if (followed && _active?.kind === 'rotate_globe')
|
||||
interruptCameraMotion('globe-camera-owner-changed');
|
||||
});
|
||||
for (const rm of _inputRemovers) rm();
|
||||
@@ -1527,15 +1523,14 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
if (
|
||||
(motion === 'rotate_globe' ||
|
||||
(motion === 'orbit' && typeof runNavigation !== 'function')) &&
|
||||
_viewer?.trackedEntity
|
||||
isFollowing(_viewer)
|
||||
) {
|
||||
// The follow camera owns a tracked view; a lookAt orbit fights it frame
|
||||
// by frame and stop rips the camera out (field finding). Honest refusal
|
||||
// until tracked-orbit rides the follow camera natively (roadmap).
|
||||
const followed = followedOf(_viewer);
|
||||
const label =
|
||||
_viewer.trackedEntity?.name ||
|
||||
_viewer.trackedEntity?.id ||
|
||||
'the tracked target';
|
||||
followed?.name || followed?.labelModel?.title || 'the tracked target';
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
@@ -1618,9 +1613,10 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
if (motion === 'rotate_globe') {
|
||||
// Convert an inherited ground-target frame without changing the world
|
||||
// pose. Rotation must pivot about Earth, never the previous local target.
|
||||
const camera = cameraOf(_viewer);
|
||||
camera.cancelFlight();
|
||||
camera.release();
|
||||
const cam = _viewer.camera;
|
||||
cam.cancelFlight();
|
||||
cam.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
// Handoff may level a route's bank. Keep the caller's exact world pose.
|
||||
// Copy the basis directly: setView's heading/pitch conversion is singular
|
||||
// at nadir and can change the captured orientation near the poles.
|
||||
@@ -1705,7 +1701,7 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
const preserveCameraFlight =
|
||||
motion === 'orbit' &&
|
||||
_viewer.scene.tweens.length > 0 &&
|
||||
!_viewer.trackedEntity;
|
||||
!isFollowing(_viewer);
|
||||
return typeof runNavigation === 'function'
|
||||
? runNavigation(start, { preserveCameraFlight })
|
||||
: start();
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
/**
|
||||
* Activates the nearest CCTV record on layer enable and flies only when no
|
||||
* aircraft tracker or cockpit camera owns the view.
|
||||
* followed target or cockpit camera owns the view.
|
||||
* @param {Object} options Layer-enable focus inputs.
|
||||
* @param {*} options.trackedEntity Current Cesium tracked entity, if any.
|
||||
* @param {*} options.followed What the camera follows, if anything
|
||||
* (`followedOf`).
|
||||
* @param {boolean} options.cockpitActive Whether cockpit mode owns the camera.
|
||||
* @param {() => (string|null)} options.activate Nearest-camera activation.
|
||||
* @param {(cameraId: string) => *} options.fly Optional camera flight.
|
||||
* @returns {*} Activated camera ID for a held view, or the flight result.
|
||||
*/
|
||||
export function runCctvLayerEnableFocus({
|
||||
trackedEntity = null,
|
||||
followed = null,
|
||||
cockpitActive = false,
|
||||
activate,
|
||||
fly,
|
||||
} = {}) {
|
||||
const cameraId = activate?.();
|
||||
if (!cameraId) return false;
|
||||
if (trackedEntity || cockpitActive) return cameraId;
|
||||
if (followed || cockpitActive) return cameraId;
|
||||
return fly?.(cameraId);
|
||||
}
|
||||
|
||||
@@ -25,11 +26,11 @@ export function runCctvLayerEnableFocus({
|
||||
* chain. Either observation suppresses the optional CCTV focus flight.
|
||||
* @param {Object} [before={}] Ownership immediately before the await.
|
||||
* @param {Object} [after={}] Ownership immediately after the await.
|
||||
* @returns {{ trackedEntity: *, cockpitActive: boolean }} Conservative ownership.
|
||||
* @returns {{ followed: *, cockpitActive: boolean }} Conservative ownership.
|
||||
*/
|
||||
function mergeCctvEnableOwnership(before = {}, after = {}) {
|
||||
return {
|
||||
trackedEntity: before.trackedEntity || after.trackedEntity || null,
|
||||
followed: before.followed || after.followed || null,
|
||||
cockpitActive: !!(before.cockpitActive || after.cockpitActive),
|
||||
};
|
||||
}
|
||||
@@ -60,7 +61,7 @@ export async function runCctvLayerEnableTransition({
|
||||
const ownershipBefore = readOwnership?.() || {};
|
||||
if (target === true) {
|
||||
debug?.('[UI:CCTV] ownership before setEnabled await', {
|
||||
trackedId: ownershipBefore.trackedEntity?.id ?? null,
|
||||
trackedId: ownershipBefore.followed?.key ?? null,
|
||||
cockpitActive: !!ownershipBefore.cockpitActive,
|
||||
});
|
||||
}
|
||||
@@ -68,7 +69,7 @@ export async function runCctvLayerEnableTransition({
|
||||
const ownershipAfter = readOwnership?.() || {};
|
||||
if (target === true) {
|
||||
debug?.('[UI:CCTV] ownership after setEnabled await', {
|
||||
trackedId: ownershipAfter.trackedEntity?.id ?? null,
|
||||
trackedId: ownershipAfter.followed?.key ?? null,
|
||||
cockpitActive: !!ownershipAfter.cockpitActive,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
|
||||
test('CCTV layer enable activates nearest without stealing a tracked or cockpit view', () => {
|
||||
for (const ownership of [
|
||||
{ trackedEntity: { id: 'tracked-plane' }, cockpitActive: false },
|
||||
{ trackedEntity: null, cockpitActive: true },
|
||||
{ followed: { key: 'tracked-plane' }, cockpitActive: false },
|
||||
{ followed: null, cockpitActive: true },
|
||||
]) {
|
||||
const calls = [];
|
||||
const result = runCctvLayerEnableFocus({
|
||||
@@ -48,15 +48,15 @@ test('CCTV layer enable activates nearest without stealing a tracked or cockpit
|
||||
});
|
||||
|
||||
test('CCTV enable retains a pre-await tracking snapshot when tracking clears during enable', async () => {
|
||||
let trackedEntity = { id: 'tracked-plane' };
|
||||
let followed = { key: 'tracked-plane' };
|
||||
let flyCalls = 0;
|
||||
const diagnostics = [];
|
||||
const result = await runCctvLayerEnableTransition({
|
||||
target: true,
|
||||
readOwnership: () => ({ trackedEntity, cockpitActive: false }),
|
||||
readOwnership: () => ({ followed, cockpitActive: false }),
|
||||
setEnabled: async () => {
|
||||
await Promise.resolve();
|
||||
trackedEntity = null;
|
||||
followed = null;
|
||||
},
|
||||
shouldFocus: () => true,
|
||||
activate: () => 'cam-near',
|
||||
@@ -83,7 +83,7 @@ test('CCTV disable transition does not emit enable-ownership diagnostics', async
|
||||
|
||||
const result = await runCctvLayerEnableTransition({
|
||||
target: false,
|
||||
readOwnership: () => ({ trackedEntity: { id: 'tracked-plane' }, cockpitActive: false }),
|
||||
readOwnership: () => ({ followed: { key: 'tracked-plane' }, cockpitActive: false }),
|
||||
setEnabled: async (target) => transitions.push(target),
|
||||
shouldFocus: () => true,
|
||||
activate: () => 'cam-near',
|
||||
|
||||
+18
-14
@@ -1,5 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { governorRequestRender } from './renderGovernor.js';
|
||||
import { cameraOf } from './render/cesium/camera.js';
|
||||
|
||||
/** Outer edge of the existing NVG/FLIR keyhole in normalized shader space. */
|
||||
export const KEYHOLE_OUTER_RADIUS = 1.05;
|
||||
@@ -576,30 +577,33 @@ export class CelestialRing {
|
||||
const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4);
|
||||
const altitude = Math.max(earthRadius * 1.55, distance - earthRadius);
|
||||
|
||||
const camera = cameraOf(this.viewer);
|
||||
this._focusInProgress = true;
|
||||
const finishFocus = () => {
|
||||
this._focusInProgress = false;
|
||||
this.viewer.scene.requestRender?.();
|
||||
};
|
||||
this.viewer.camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromRadians(
|
||||
camera.flyTo(
|
||||
Cesium.Cartesian3.fromRadians(
|
||||
cartographic.longitude,
|
||||
cartographic.latitude,
|
||||
altitude,
|
||||
),
|
||||
orientation: {
|
||||
heading: this.viewer.camera.heading,
|
||||
pitch: -Cesium.Math.PI_OVER_TWO,
|
||||
roll: 0,
|
||||
{
|
||||
orientation: {
|
||||
heading: camera.pose().heading,
|
||||
pitch: -Cesium.Math.PI_OVER_TWO,
|
||||
roll: 0,
|
||||
},
|
||||
durationS: duration,
|
||||
easing: 'cubicInOut',
|
||||
onComplete: finishFocus,
|
||||
onCancel: () => {
|
||||
finishFocus();
|
||||
if (!this.isGlobeFullyVisible()) this._autoDisable();
|
||||
},
|
||||
},
|
||||
duration,
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
complete: finishFocus,
|
||||
cancel: () => {
|
||||
finishFocus();
|
||||
if (!this.isGlobeFullyVisible()) this._autoDisable();
|
||||
},
|
||||
});
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -227,7 +227,7 @@ test('Cockpit Escape handling precedes form-control shortcut suppression and foc
|
||||
assert.doesNotMatch(exit, /applyTrackedCameraFrame/);
|
||||
assert.match(
|
||||
ui,
|
||||
/restoreTrackingFrame: \(entity\) => \{[\s\S]*?gevTrackedId[\s\S]*?flightsLayer\.refocusTrackedById[\s\S]*?militaryFlightsLayer\.refocusTrackedById/,
|
||||
/restoreTrackingFrame: \(entity\) => \{[\s\S]*?readEntityRecord\(entity\)\?\.trackedId[\s\S]*?flightsLayer\.refocusTrackedById[\s\S]*?militaryFlightsLayer\.refocusTrackedById/,
|
||||
'Cockpit exit must return camera-frame ownership through the source layer so Contact Focus cannot accumulate a second owner',
|
||||
);
|
||||
});
|
||||
@@ -238,7 +238,7 @@ test('Cockpit shortcut failures do not leak and open utilities own the first Esc
|
||||
assert.match(keydown, /if \(this\.isUtilityDisclosureOpen\?\.\(\)\) return;/);
|
||||
assert.match(
|
||||
keydown,
|
||||
/const cockpitAttempt = !!\(\s*this\.readAircraftInfo\(\) && this\.viewer\.trackedEntity\?\.position\s*\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/,
|
||||
/const cockpitAttempt = !!\(\s*this\.readAircraftInfo\(\) && followedOf\(this\.viewer\)\?\.entity\?\.position\s*\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -295,14 +295,14 @@ test('the Contact panel never hides itself out from under its own NEXT button',
|
||||
);
|
||||
});
|
||||
|
||||
test('the cockpit reads its aircraft from the layer that owns Cesium tracking', () => {
|
||||
test('the cockpit reads its aircraft from the layer the camera follows', () => {
|
||||
const read = readAircraftInfo.toString();
|
||||
assert.ok(read, 'readAircraftInfo is missing');
|
||||
assert.match(read, /resolveTrackedAircraftInfo\(\{/);
|
||||
assert.match(read, /gevTrackedId/);
|
||||
// In cockpit mode the controller moves the entity off viewer.trackedEntity,
|
||||
// so its own handle is the tracked identity there.
|
||||
assert.match(read, /this\.viewer\?\.trackedEntity \|\| this\.trackedEntity/);
|
||||
assert.match(read, /trackedId: followed\?\.key/);
|
||||
// In cockpit mode the controller stops the camera following, so its own
|
||||
// handle is the tracked identity there.
|
||||
assert.match(read, /followedOf\(this\.viewer\) \|\| this\.trackedFollow/);
|
||||
});
|
||||
|
||||
test('programmatic Context layer changes cannot bypass explicit expansion policy', () => {
|
||||
@@ -1171,7 +1171,7 @@ test('cockpit route direction uses self-contained vector artwork, not a font lig
|
||||
test('cockpit aircraft handoff invalidates the prior world-position anchor', () => {
|
||||
const match = _adoptTrackedEntity.toString();
|
||||
assert.ok(match, 'cockpit tracked-entity handoff block is missing');
|
||||
assert.match(match, /this\.viewer\.trackedEntity = undefined;/);
|
||||
assert.match(match, /stopFollowing\(this\.viewer\);/);
|
||||
assert.match(match, /this\.cockpitAnchorValid = false;/);
|
||||
assert.match(
|
||||
match,
|
||||
|
||||
+1
-1
@@ -306,7 +306,7 @@ export function relativeBearing(bearing, heading) {
|
||||
* Resolve which flight layer owns the cockpit's tracked aircraft.
|
||||
*
|
||||
* Both layers can briefly describe a tracked aircraft during a cross-layer
|
||||
* handoff, so the viewer's normalized tracked identity (`gevTrackedId`) is the
|
||||
* handoff, so the viewer's normalized tracked identity (the entity record's `trackedId`) is the
|
||||
* tie-breaker — the same rule `militaryAwareness.currentTrackedFlightSubject`
|
||||
* uses. Layer precedence remains the fallback when no normalized identity is
|
||||
* available, so tracking paths that never stamp one keep working.
|
||||
|
||||
@@ -365,7 +365,7 @@ test('tracked-identity resolution falls back to layer precedence', () => {
|
||||
const civilian = { icao24: 'aaa077' };
|
||||
const military = { icao24: 'ae01ce' };
|
||||
// No stamped identity (or one that matches neither layer) keeps the historic
|
||||
// civilian-first order so tracking paths without gevTrackedId still work.
|
||||
// civilian-first order so tracking paths without an entity record trackedId still work.
|
||||
assert.equal(resolveTrackedAircraftInfo({ civilian, military }).layerId, 'flights');
|
||||
assert.equal(
|
||||
resolveTrackedAircraftInfo({ civilian, military, trackedId: 'satellites:25544' }).layerId,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/*
|
||||
* Cockpit aircraft metadata itself is resolved by `resolveTrackedAircraftInfo`
|
||||
* in `cockpitMath.js` — the landed owner of that rule. It keeps layer
|
||||
* precedence as the fallback when no normalized `gevTrackedId` is available,
|
||||
* precedence as the fallback when the entity record has no normalized `trackedId`,
|
||||
* so tracking paths that never stamp one keep working; `readAircraftInfo` is
|
||||
* pinned to it by `cockpitMarkup.test.mjs`. This module owns only the
|
||||
* multi-step ENTRY TRANSACTION (adopt → enter → roll back).
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
enterCockpitWithTracking,
|
||||
restoreAircraftTrackingOwner,
|
||||
} from './cockpitTracking.js';
|
||||
import { readEntityRecord, withEntityRecord } from './data/entityRecords.js';
|
||||
|
||||
/**
|
||||
* Model the two production stamping seams that sit under this transaction:
|
||||
@@ -22,7 +23,7 @@ function stampingWorld({ entryAllowed = true } = {}) {
|
||||
layerId,
|
||||
trackById(id) {
|
||||
if (!trackable) return false;
|
||||
world.trackedEntity = { gevTrackedId: `${layerId}:${id}` };
|
||||
world.trackedEntity = withEntityRecord({}, { trackedId: `${layerId}:${id}` });
|
||||
stamp('trackedEntityChanged'); // the ui.js listener
|
||||
return true;
|
||||
},
|
||||
@@ -80,7 +81,7 @@ test('a refused Cockpit entry still stamps for the tracker it already moved', ()
|
||||
// Adoption stamped, and the rollback re-track stamped again.
|
||||
assert.deepEqual(world.stamps, ['trackedEntityChanged', 'trackedEntityChanged']);
|
||||
assert.equal(world.generation, 2);
|
||||
assert.equal(world.trackedEntity.gevTrackedId, 'military:ae01ce');
|
||||
assert.equal(readEntityRecord(world.trackedEntity)?.trackedId, 'military:ae01ce');
|
||||
});
|
||||
|
||||
test('a Cockpit entry that mutates nothing takes no camera authority', () => {
|
||||
|
||||
+14
-5
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { IconSetPrimitive } from './data/iconSetPrimitive.js';
|
||||
|
||||
// Narrow, version-tested engine adapter. Cesium has no public custom shader
|
||||
// option for native point/billboard collections. Do not patch its prototypes or
|
||||
@@ -64,9 +65,15 @@ export function sonarVertexSource(source, kind) {
|
||||
kind === 'point' ? 'positionHighAndSize' : 'positionHighAndScale';
|
||||
const lowAttribute =
|
||||
kind === 'point' ? 'positionLowAndOutline' : 'positionLowAndRotation';
|
||||
// Icon sets move rows on the GPU, so their shader publishes the anchor itself.
|
||||
const anchorSource =
|
||||
kind === 'icon'
|
||||
? 'iconAnchorClip'
|
||||
: `czm_modelViewProjectionRelativeToEye * czm_translateRelativeToEye(${attribute}.xyz, ${lowAttribute}.xyz)`;
|
||||
if (
|
||||
!text.includes(attribute) ||
|
||||
!text.includes(lowAttribute) ||
|
||||
(kind === 'icon'
|
||||
? !text.includes('iconAnchorClip')
|
||||
: !text.includes(attribute) || !text.includes(lowAttribute)) ||
|
||||
!text.includes('v_color') ||
|
||||
!/void\s+main\s*\(\s*\)/.test(text)
|
||||
)
|
||||
@@ -83,7 +90,7 @@ void main() {
|
||||
gev_sonar_main();
|
||||
// All corners/glyphs share the contact anchor, before native screen offsets.
|
||||
// Leave gl_Position untouched so appearance never changes geometry or picks.
|
||||
vec4 anchor = czm_modelViewProjectionRelativeToEye * czm_translateRelativeToEye(${attribute}.xyz, ${lowAttribute}.xyz);
|
||||
vec4 anchor = ${anchorSource};
|
||||
if (anchor.w <= 0.0) return;
|
||||
vec2 pixel = anchor.xy / anchor.w * u_gevSonarViewport * 0.5;
|
||||
float floorValue = u_gevSonar.z;
|
||||
@@ -108,7 +115,7 @@ void main() {
|
||||
v_color.rgb = v_color.rgb * 0.28 + (vec3(0.62, 0.68, 0.72) + vec3(0.38, 0.16, 0.12) * coverage) * 0.72;
|
||||
// Apply before native fragment pass classification, never after main().
|
||||
v_color.a *= factor;
|
||||
${kind === 'point' ? 'v_outlineColor.a *= factor;' : '#ifdef SDF\n v_outlineColor.a *= factor;\n #endif'}
|
||||
${kind === 'point' ? 'v_outlineColor.a *= factor;' : kind === 'icon' ? '' : '#ifdef SDF\n v_outlineColor.a *= factor;\n #endif'}
|
||||
}
|
||||
`,
|
||||
];
|
||||
@@ -208,7 +215,9 @@ export function createCyberSonarGpu(scene, readFrame) {
|
||||
? 'point'
|
||||
: native.owner instanceof Cesium.BillboardCollection
|
||||
? 'billboard'
|
||||
: null;
|
||||
: native.owner instanceof IconSetPrimitive
|
||||
? 'icon'
|
||||
: null;
|
||||
if (!kind || !native.shaderProgram || !native.uniformMap) continue;
|
||||
const source = native.shaderProgram;
|
||||
// Label backgrounds are non-SDF billboard batches. The version-tested
|
||||
|
||||
@@ -3,6 +3,12 @@ import { TRACKED_ICON_COLOR } from '../layers/military/policy.js';
|
||||
import { createRendering as civilRendering } from '../layers/flights/rendering.js';
|
||||
import { createRendering as militaryRendering } from '../layers/military/rendering.js';
|
||||
import { createMilitarySnapshotRenderer } from '../layers/military/snapshotRenderer.js';
|
||||
import { createIcons as createMilitaryIcons } from '../layers/military/icons.js';
|
||||
import { createIconSet } from './iconSet.js';
|
||||
import {
|
||||
aircraftMotionRegime,
|
||||
createMotionRegime,
|
||||
} from './aircraftMotionRegime.js';
|
||||
import { aircraftEmergency } from './aircraftEmergency.js';
|
||||
// Source-to-voice provenance, status retention/clearing, contact-time contracts,
|
||||
// and civil/military billboard, Cockpit and model tint regressions.
|
||||
@@ -462,18 +468,16 @@ test('tint priorities cover normal, priority, emergency, tracking and Cockpit do
|
||||
}
|
||||
});
|
||||
|
||||
test('GERMAN: an existing untracked military billboard repaints each poll without alpha loss', () => {
|
||||
test('GERMAN: an existing untracked military icon repaints each poll without losing its fade', () => {
|
||||
const { flightState, rendering } = renderHarness(militaryRendering);
|
||||
const bb = {
|
||||
color: rendering._fleetBillboardColor('abc123').withAlpha(0.65),
|
||||
};
|
||||
Object.assign(flightState, {
|
||||
_billboards: new Map([['abc123', bb]]),
|
||||
_icons: createIconSet(),
|
||||
_models: new Map(),
|
||||
_positionHistory: new Map(),
|
||||
_cullPositions: new Map(),
|
||||
_displayCourse: new Map(),
|
||||
_groundSnap: { forget() {} },
|
||||
_cockpitContactMode: false,
|
||||
_cockpitNearContacts: new Set(),
|
||||
});
|
||||
const records = new MilitaryFlightRecords({
|
||||
geoidHeight: () => 0,
|
||||
@@ -481,18 +485,42 @@ test('GERMAN: an existing untracked military billboard repaints each poll withou
|
||||
floorAltitudeM: (value) => value,
|
||||
});
|
||||
flightState.records = records;
|
||||
const parts = {
|
||||
rendering: {
|
||||
...rendering,
|
||||
_iconKind: () => 'jet',
|
||||
_syncModelToClass() {},
|
||||
},
|
||||
motion: {
|
||||
_motionRegime: (history, info, renderTime) =>
|
||||
aircraftMotionRegime(
|
||||
history,
|
||||
info,
|
||||
renderTime,
|
||||
{ speed: 'speedMps', track: 'track' },
|
||||
createMotionRegime(),
|
||||
),
|
||||
},
|
||||
};
|
||||
const icons = createMilitaryIcons({
|
||||
flightState,
|
||||
services: { focus: { focusPassIsNeeded: () => false } },
|
||||
parts,
|
||||
});
|
||||
const row = flightState._icons.add('abc123', {
|
||||
layer: 'military',
|
||||
id: 'abc123',
|
||||
});
|
||||
// The focus fade the fleet tick composes lives in its own column.
|
||||
flightState._icons.setFocus(row, 0.65);
|
||||
const apply = createMilitarySnapshotRenderer({
|
||||
flightState,
|
||||
records,
|
||||
groundFloor: { warmGroundFloor() {}, cachedGroundFloor: () => null },
|
||||
meshFloor: { sampleMeshFloorCells() {} },
|
||||
militaryRegistry: { registerMilitaryIcaos() {} },
|
||||
rendering: {
|
||||
...rendering,
|
||||
_syncModelToClass() {},
|
||||
_applyFleetBillboardPresentation() {},
|
||||
},
|
||||
motion: {},
|
||||
rendering: parts.rendering,
|
||||
icons,
|
||||
tracking: {},
|
||||
queries: {},
|
||||
});
|
||||
@@ -518,7 +546,7 @@ test('GERMAN: an existing untracked military billboard repaints each poll withou
|
||||
{ observedAtMs: NOW_MS },
|
||||
);
|
||||
apply(snapshot, {});
|
||||
assert.equal(bb.color.green < 0.5, red);
|
||||
assert.equal(bb.color.alpha, 0.65);
|
||||
assert.equal(flightState._icons.color(row).green < 0.5, red);
|
||||
assert.ok(Math.abs(flightState._icons.focus(row) - 0.65) < 1e-6);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,451 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
COURSE_HOLD_SPEED_MPS,
|
||||
courseSlewCapDps,
|
||||
norm180,
|
||||
} from './motionModel.js';
|
||||
import {
|
||||
createIconSet,
|
||||
ICON_CULL_LIFT,
|
||||
ICON_RECEDES,
|
||||
ICON_ROTATES,
|
||||
} from './iconSet.js';
|
||||
import {
|
||||
segmentCourse,
|
||||
writeArcSegment,
|
||||
writeCourseGlide,
|
||||
writeHoldSegment,
|
||||
writeLerpSegment,
|
||||
} from './iconSegment.js';
|
||||
import { IconSetPrimitive } from './iconSetPrimitive.js';
|
||||
import { iconAtlas } from './iconAtlas.js';
|
||||
import {
|
||||
aircraftIcon,
|
||||
FLEET_ICON_PX,
|
||||
TRACKED_ICON_PX,
|
||||
} from './aircraftIcons.js';
|
||||
import { cockpitContactDotImage } from './cockpitContactDot.js';
|
||||
import { horizonOccluder } from './iconOrientation.js';
|
||||
|
||||
/** Raster size of the cockpit contact pip (cockpitContactDot.js draws 16 px). */
|
||||
const COCKPIT_DOT_PX = 16;
|
||||
/** Fleet glyphs keep the established close-range 3× scale. */
|
||||
const NORMAL_SCALE_BY_DISTANCE = new Cesium.NearFarScalar(
|
||||
1000,
|
||||
3.0,
|
||||
8000000,
|
||||
0.5,
|
||||
);
|
||||
const COCKPIT_SCALE_BY_DISTANCE = new Cesium.NearFarScalar(
|
||||
1000,
|
||||
1.15,
|
||||
8000000,
|
||||
0.65,
|
||||
);
|
||||
/** Contacts drawn below this height (m) are judged for the horizon a little
|
||||
* above the ellipsoid (ICON_CULL_LIFT). */
|
||||
const CULL_LIFT_BELOW_M = 10;
|
||||
|
||||
/**
|
||||
* An aircraft layer's contacts as one icon set: each row's motion segment
|
||||
* mirrors the layer's dead reckoning for the delayed display time, so the GPU
|
||||
* moves every untracked contact between polls with no per-frame CPU pass.
|
||||
*
|
||||
* The layer's state supplies `_icons`, `_iconPrimitive`, `_scratchIconRenderTime`,
|
||||
* `_iconFocusActive`, `_positionHistory`, `_displayCourse`, `_groundSnap`,
|
||||
* `_models`, `_trackedIcao`, `_viewer`, `_cockpitContactMode`,
|
||||
* `_cockpitNearContacts` and `records` (`data`, `missingPolls`, `forget`).
|
||||
* `parts.motion._motionRegime` picks the motion; `parts.rendering` supplies
|
||||
* the icon kind, scale, colours and distance scalars, and `_releaseModel`.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {object} options.state The layer's mutable state.
|
||||
* @param {object} options.services The layer's services (`focus`).
|
||||
* @param {object} options.parts The layer's parts (`motion`, `rendering`).
|
||||
* @param {string} options.layerId Pick and focus owner id.
|
||||
* @param {number} options.renderDelaySec How far behind real time contacts draw.
|
||||
* @param {number} options.courseMaxDps The fleet's course slew cap.
|
||||
* @param {number} options.cockpitContactSizePx Cockpit far-contact pip size.
|
||||
* @param {(icao24: string) => boolean} [options.holdsPlan] True while something
|
||||
* else writes this row's segment (it is not planned).
|
||||
* @param {(icao24: string) => void} [options.beforeSettle] Runs before a row is
|
||||
* planned afresh.
|
||||
* @param {(icao24: string) => void} [options.afterSettle] Runs after it.
|
||||
* @param {(icao24: string) => void} [options.onForget] Drops the layer's own
|
||||
* per-contact state when a contact goes.
|
||||
*/
|
||||
export function createAircraftIcons({
|
||||
state,
|
||||
services,
|
||||
parts,
|
||||
layerId,
|
||||
renderDelaySec,
|
||||
courseMaxDps,
|
||||
cockpitContactSizePx,
|
||||
holdsPlan = () => false,
|
||||
beforeSettle = () => {},
|
||||
afterSettle = () => {},
|
||||
onForget = () => {},
|
||||
}) {
|
||||
const { focusPassIsNeeded } = services.focus;
|
||||
const scratchTime = new Cesium.JulianDate();
|
||||
const scratchPosition = new Cesium.Cartesian3();
|
||||
const scratchCull = new Cesium.Cartesian3();
|
||||
const scratchScaleByDistance = new Cesium.NearFarScalar();
|
||||
|
||||
/** The delayed instant every untracked contact is drawn at. */
|
||||
function _displayTime(result = scratchTime) {
|
||||
return Cesium.JulianDate.addSeconds(
|
||||
Cesium.JulianDate.now(result),
|
||||
-renderDelaySec,
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
function _displayTau() {
|
||||
return state._icons.seconds(_displayTime());
|
||||
}
|
||||
|
||||
/** Create the set and its primitive for one layer lifetime. */
|
||||
function _createIcons(viewer) {
|
||||
state._icons = createIconSet({ epoch: _displayTime() });
|
||||
state._iconPrimitive = new IconSetPrimitive({
|
||||
set: state._icons,
|
||||
renderTime: () => _displayTime(state._scratchIconRenderTime),
|
||||
});
|
||||
viewer.scene.primitives.add(state._iconPrimitive);
|
||||
return state._iconPrimitive;
|
||||
}
|
||||
|
||||
/** Where a contact is drawn right now, or null when it has no row. */
|
||||
function _iconPosition(icao24, result = new Cesium.Cartesian3()) {
|
||||
const set = state._icons;
|
||||
const row = set?.row(icao24) ?? -1;
|
||||
return row < 0 ? null : set.position(row, _displayTau(), result);
|
||||
}
|
||||
|
||||
/** The nose direction a contact is drawn with right now. */
|
||||
function _iconCourse(icao24) {
|
||||
const set = state._icons;
|
||||
const row = set?.row(icao24) ?? -1;
|
||||
return row < 0 ? null : set.course(row, _displayTau());
|
||||
}
|
||||
|
||||
/** Whether the layer draws this contact's icon (not tracked, not model-owned). */
|
||||
function _iconShown(icao24) {
|
||||
const set = state._icons;
|
||||
const row = set?.row(icao24) ?? -1;
|
||||
return row >= 0 && set.shown(row);
|
||||
}
|
||||
|
||||
/** The camera's horizon test, or null when there is no placed camera. */
|
||||
function _occluder() {
|
||||
const camera = state._viewer?.camera;
|
||||
return camera?.positionWC ? horizonOccluder(camera) : null;
|
||||
}
|
||||
|
||||
/** Whether a drawn row at `position` clears the camera's horizon, judged
|
||||
* exactly as the icon shader judges it. No camera, no horizon. */
|
||||
function _aboveHorizon(row, position, occluder) {
|
||||
return (
|
||||
!occluder ||
|
||||
occluder.isPointVisible(
|
||||
state._icons.cullPoint(row, position, scratchCull),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the segment the layer's dead reckoning would follow from now on:
|
||||
* interpolation between the bracketing fixes, the warm-up arc back from the
|
||||
* oldest fix, or the coast arc past the newest. The displayed nose glides
|
||||
* from where it is onto the new course at the fleet's slew cap; a hovering
|
||||
* contact keeps its nose. The row expires when the regime would change.
|
||||
* @param {string} icao24
|
||||
* @param {{fromCourseDeg?: number|null}} [options] Nose to glide from when
|
||||
* the row's own segment is not what was on screen (tracking handoff).
|
||||
*/
|
||||
function _planIcon(icao24, { fromCourseDeg } = {}) {
|
||||
const set = state._icons;
|
||||
const row = set?.row(icao24) ?? -1;
|
||||
if (row < 0 || holdsPlan(icao24)) return;
|
||||
const history = state._positionHistory.get(icao24);
|
||||
if (!history?.length) return;
|
||||
const info = state.records.data.get(icao24);
|
||||
const renderTime = _displayTime();
|
||||
const tau = set.seconds(renderTime);
|
||||
const seg = set.segments;
|
||||
const shownCourse =
|
||||
fromCourseDeg ??
|
||||
(set.expires(row) === -Infinity ? null : set.course(row, tau));
|
||||
|
||||
const regime = parts.motion._motionRegime(history, info, renderTime);
|
||||
let speed;
|
||||
let expiresAt = Infinity;
|
||||
if (regime.kind === 'between') {
|
||||
const { a, b, span, trackFrom, trackTo, chordCourse } = regime;
|
||||
speed = regime.speed;
|
||||
const blend = chordCourse == null ? 0 : regime.blend;
|
||||
if (span > 0) {
|
||||
writeLerpSegment(seg, row, {
|
||||
from: a.position,
|
||||
to: b.position,
|
||||
t0: set.seconds(a.time),
|
||||
t1: set.seconds(b.time),
|
||||
course0Deg:
|
||||
trackFrom + blend * norm180((chordCourse ?? 0) - trackFrom),
|
||||
courseSlopeDeg: norm180(trackTo - trackFrom) * (1 - blend),
|
||||
});
|
||||
} else {
|
||||
writeHoldSegment(
|
||||
seg,
|
||||
row,
|
||||
b.position,
|
||||
trackTo + blend * norm180((chordCourse ?? 0) - trackTo),
|
||||
);
|
||||
}
|
||||
expiresAt = set.seconds(b.time);
|
||||
} else {
|
||||
const { fix, heading } = regime;
|
||||
speed = regime.speed;
|
||||
const t0 = set.seconds(fix.time);
|
||||
if (speed === 0) {
|
||||
writeHoldSegment(seg, row, fix.position, heading);
|
||||
} else {
|
||||
writeArcSegment(seg, row, {
|
||||
fix: fix.position,
|
||||
t0,
|
||||
minDt: regime.minDt,
|
||||
maxDt: regime.maxDt,
|
||||
speedMps: speed,
|
||||
trackDeg: heading,
|
||||
turnRateDps: regime.turnRateDps,
|
||||
});
|
||||
}
|
||||
// A warm-up's arc ends at its fix; a coast's runs to its limit.
|
||||
if (regime.maxDt === 0) expiresAt = t0;
|
||||
}
|
||||
// Below hover speed the nose holds what is on screen (or, for a new row,
|
||||
// where this segment starts); above it, it glides on at the slew cap.
|
||||
const hold = speed < COURSE_HOLD_SPEED_MPS;
|
||||
if (hold || shownCourse != null) {
|
||||
writeCourseGlide(
|
||||
seg,
|
||||
row,
|
||||
shownCourse ?? segmentCourse(seg, row, tau),
|
||||
tau,
|
||||
hold ? 0 : courseSlewCapDps(speed, courseMaxDps),
|
||||
);
|
||||
}
|
||||
set.segmentWritten(row, expiresAt);
|
||||
}
|
||||
|
||||
/** Re-plan every row whose regime ended before `tau`. */
|
||||
function _replanExpired(tau) {
|
||||
const set = state._icons;
|
||||
const due = [];
|
||||
for (let row = 0; row < set.count; row++)
|
||||
if (set.expires(row) <= tau) due.push(set.idAt(row));
|
||||
for (const icao24 of due) _planIcon(icao24);
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance focus de-emphasis for every drawn row (and every row a fleet model
|
||||
* draws for, whose alpha reads it), only while a focus target exists or some
|
||||
* row is still relaxing back to full emphasis.
|
||||
*/
|
||||
function _focusIcons(tau, scene, camera, nowMs, target) {
|
||||
if (!focusPassIsNeeded(target, state._iconFocusActive)) return;
|
||||
const { advanceProjectedSpriteFocus, nearFarScalarValueAtDistance } =
|
||||
services.focus;
|
||||
const set = state._icons;
|
||||
let active = 0;
|
||||
for (let row = 0; row < set.count; row++) {
|
||||
if (!set.shown(row) && !state._models.has(set.idAt(row))) continue;
|
||||
const position = set.position(row, tau, scratchPosition);
|
||||
const distanceScale = nearFarScalarValueAtDistance(
|
||||
set.scaleByDistance(row, scratchScaleByDistance),
|
||||
Cesium.Cartesian3.distance(camera.positionWC, position),
|
||||
);
|
||||
const half = set.scale(row) * distanceScale * 0.5;
|
||||
const focus = advanceProjectedSpriteFocus(
|
||||
set.metaAt(row),
|
||||
position,
|
||||
scene,
|
||||
camera,
|
||||
nowMs,
|
||||
target,
|
||||
undefined,
|
||||
set.width(row) * half,
|
||||
set.height(row) * half,
|
||||
);
|
||||
set.setFocus(row, focus.factor);
|
||||
if (focus.active || focus.transitioning) active++;
|
||||
}
|
||||
state._iconFocusActive = active;
|
||||
}
|
||||
|
||||
/** Image, size, scale, tint and freshness for one row under the current
|
||||
* normal/cockpit contract. Motion and rotation stay with the segment. */
|
||||
function _applyIconPresentation(icao24) {
|
||||
const set = state._icons;
|
||||
const row = set?.row(icao24) ?? -1;
|
||||
if (row < 0) return;
|
||||
const rendering = parts.rendering;
|
||||
const info = state.records.data.get(icao24);
|
||||
const tracked = icao24 === state._trackedIcao;
|
||||
const cockpitDot =
|
||||
state._cockpitContactMode &&
|
||||
!tracked &&
|
||||
!state._cockpitNearContacts.has(icao24);
|
||||
if (cockpitDot) {
|
||||
const dot = iconAtlas.slot(cockpitContactDotImage(), COCKPIT_DOT_PX);
|
||||
set.setImage(row, dot, dot);
|
||||
set.setSize(row, cockpitContactSizePx, cockpitContactSizePx);
|
||||
set.setScale(row, 1);
|
||||
set.setScaleByDistance(row, COCKPIT_SCALE_BY_DISTANCE);
|
||||
set.setColor(row, rendering._cockpitContactColor(icao24, info));
|
||||
set.setFlag(row, ICON_ROTATES, false);
|
||||
} else {
|
||||
set.setImage(
|
||||
row,
|
||||
iconAtlas.slot(
|
||||
aircraftIcon(rendering._iconKind(icao24, info?.klass)),
|
||||
FLEET_ICON_PX,
|
||||
),
|
||||
iconAtlas.slot(
|
||||
aircraftIcon(
|
||||
rendering._iconKind(icao24, info?.klass),
|
||||
TRACKED_ICON_PX,
|
||||
),
|
||||
TRACKED_ICON_PX,
|
||||
),
|
||||
);
|
||||
const size = tracked ? 24 : 20;
|
||||
set.setSize(row, size, size);
|
||||
set.setScale(row, rendering._fleetBillboardScale(icao24, info?.klass));
|
||||
set.setScaleByDistance(row, NORMAL_SCALE_BY_DISTANCE);
|
||||
set.setColor(row, rendering._fleetBillboardColor(icao24, info));
|
||||
set.setFlag(row, ICON_ROTATES, true);
|
||||
}
|
||||
set.setFlag(row, ICON_RECEDES, true);
|
||||
set.setFlag(
|
||||
row,
|
||||
ICON_CULL_LIFT,
|
||||
Number.isFinite(info?.renderAltitudeM) &&
|
||||
info.renderAltitudeM < CULL_LIFT_BELOW_M,
|
||||
);
|
||||
set.setAlpha(row, state.records.missingPolls.get(icao24) ? 0.45 : 1);
|
||||
}
|
||||
|
||||
/** Add a row for a new contact, hidden while it is the tracked one. */
|
||||
function _addIcon(icao24) {
|
||||
const set = state._icons;
|
||||
const row = set.add(icao24, { layer: layerId, id: icao24 });
|
||||
_applyIconPresentation(icao24);
|
||||
_settleIcon(icao24);
|
||||
set.setShown(row, icao24 !== state._trackedIcao);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan a row afresh, with the layer's own settle work on either side, and
|
||||
* let a dimmed row relax: one that sat out the focus pass (tracked) may come
|
||||
* back dimmed after the pass went idle, so it is counted to run the pass.
|
||||
* @param {string} icao24
|
||||
* @param {{fromCourseDeg?: number}} [options] The course on screen to glide from.
|
||||
*/
|
||||
function _settleIcon(icao24, options) {
|
||||
const set = state._icons;
|
||||
const row = set?.row(icao24) ?? -1;
|
||||
if (row < 0) return;
|
||||
beforeSettle(icao24);
|
||||
if (set.focus(row) < 1) state._iconFocusActive += 1;
|
||||
_planIcon(icao24, options);
|
||||
afterSettle(icao24);
|
||||
}
|
||||
|
||||
/** Drop a contact and everything the layer keeps for it. */
|
||||
function _forgetContact(icao24) {
|
||||
state._icons?.remove(icao24);
|
||||
parts.rendering._releaseModel(icao24);
|
||||
state.records.forget(icao24);
|
||||
state._positionHistory.delete(icao24);
|
||||
state._displayCourse.delete(icao24);
|
||||
state._groundSnap.forget(icao24);
|
||||
onForget(icao24);
|
||||
}
|
||||
|
||||
/** Icon ids, so ray samples never land on an aircraft instead of the ground. */
|
||||
function _iconIds() {
|
||||
const set = state._icons;
|
||||
const ids = new Array(set?.count ?? 0);
|
||||
for (let row = 0; row < ids.length; row++) ids[row] = set.idAt(row);
|
||||
return ids;
|
||||
}
|
||||
|
||||
return {
|
||||
_displayTime,
|
||||
_displayTau,
|
||||
_createIcons,
|
||||
_iconPosition,
|
||||
_iconCourse,
|
||||
_iconShown,
|
||||
_occluder,
|
||||
_aboveHorizon,
|
||||
_planIcon,
|
||||
_settleIcon,
|
||||
_replanExpired,
|
||||
_focusIcons,
|
||||
_applyIconPresentation,
|
||||
_addIcon,
|
||||
_forgetContact,
|
||||
_iconIds,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seams over an aircraft layer's icon rows, shared by the flights and
|
||||
* military `testing` parts.
|
||||
* @param {object} state The layer state holding `_icons`.
|
||||
* @param {{icons: ReturnType<typeof createAircraftIcons>}} parts Read at call time.
|
||||
* @param {string} layerId
|
||||
*/
|
||||
export function aircraftIconTestSeams(state, parts, layerId) {
|
||||
/** Give a test contact an icon row held at `icon.position`, shown as
|
||||
* `icon.show` says (hidden by default while tracked). */
|
||||
function _seedIconForTest(icao24, icon, tracked) {
|
||||
const set = state._icons;
|
||||
const row = set.add(icao24, { layer: layerId, id: icao24 });
|
||||
if (icon?.position) writeHoldSegment(set.segments, row, icon.position, 0);
|
||||
set.segmentWritten(row);
|
||||
set.setShown(row, icon?.show ?? !tracked);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Where a contact's icon is drawn now and whether the layer shows it. */
|
||||
function _iconForTest(icao24) {
|
||||
const position = parts.icons._iconPosition(icao24);
|
||||
return position
|
||||
? {
|
||||
position,
|
||||
shown: parts.icons._iconShown(icao24),
|
||||
course: parts.icons._iconCourse(icao24),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Show or hide a contact's icon as a handoff would, or hold it somewhere. */
|
||||
function _setIconForTest(icao24, { shown, position } = {}) {
|
||||
const set = state._icons;
|
||||
const row = set?.row(icao24) ?? -1;
|
||||
if (row < 0) return;
|
||||
if (shown !== undefined) set.setShown(row, shown);
|
||||
if (position) {
|
||||
writeHoldSegment(set.segments, row, position, 0);
|
||||
set.segmentWritten(row);
|
||||
}
|
||||
}
|
||||
|
||||
return { _seedIconForTest, _iconForTest, _setIconForTest };
|
||||
}
|
||||
@@ -252,6 +252,8 @@ const _b64 = (s) =>
|
||||
* NEAR the display size lets the browser's SVG AA do the work: crisp AND
|
||||
* smooth. 64 covers the 40–58 px fleet band with ≤1.6× minification. */
|
||||
const FLEET_RASTER_PX = 64;
|
||||
/** Raster size of the default fleet glyph. */
|
||||
export const FLEET_ICON_PX = FLEET_RASTER_PX;
|
||||
/** Tracked raster: the tracked billboard is the one SUSTAINED large 2D glyph
|
||||
* (close-zoom fleet flybys hand off to 3D models). Keep the 192 px texture so
|
||||
* it stays crisp at its biggest on-screen sizes. */
|
||||
|
||||
@@ -0,0 +1,888 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { selectModelEligible } from './modelEligibility.js';
|
||||
import { horizonOccluder } from './iconOrientation.js';
|
||||
import { aircraftTreatmentAlpha } from './aircraftRecession.js';
|
||||
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from './aircraftModelLighting.js';
|
||||
import {
|
||||
createModelRecord,
|
||||
modelIsRendering,
|
||||
placeModel,
|
||||
showModel,
|
||||
} from './modelRecord.js';
|
||||
|
||||
/**
|
||||
* An aircraft layer's 3D models: the fleet regime and its gap-proof handoff
|
||||
* from the icon set (`createAircraftIcons`), the standalone tracked model, and
|
||||
* the ~12 Hz fleet tick that drives both. Shared by the flights and military
|
||||
* layers, which differ only in the options below.
|
||||
* @param {object} options
|
||||
* @param {object} options.state The layer state.
|
||||
* @param {object} options.services
|
||||
* @param {object} options.parts The layer's parts; `icons`, `motion` and
|
||||
* `tracking` are read at call time.
|
||||
* @param {(url: string) => string} options.resolveAsset
|
||||
* @param {string} options.logLabel Console prefix, e.g. `Data:Flights`.
|
||||
* @param {object} options.policy The layer's model budgets, radii, pixel
|
||||
* bounds, heading offset, IR reload batch and fleet tick interval.
|
||||
* @param {(klass: string) => object} options.modelSpec The class's GLB, scale,
|
||||
* geometry and tint blend; `headingOffsetDeg` overrides the policy's.
|
||||
* @param {(icao24: string) => Cesium.Color} options.modelColor Model tint,
|
||||
* including the tracked contact's.
|
||||
* @param {(icao24: string) => void} [options.onModelEligible] Runs when a
|
||||
* contact is about to render in 3D.
|
||||
* @param {(renderTime: Cesium.JulianDate, tau: number, nowMs: number,
|
||||
* camera: Cesium.Camera) => void} [options.afterReplan] The layer's own pass
|
||||
* in the fleet tick, after expired rows are re-planned.
|
||||
*/
|
||||
export function createAircraftModels({
|
||||
state,
|
||||
services,
|
||||
parts,
|
||||
resolveAsset,
|
||||
logLabel,
|
||||
policy,
|
||||
modelSpec: _modelSpec,
|
||||
modelColor: _modelColor,
|
||||
onModelEligible = () => {},
|
||||
afterReplan = () => {},
|
||||
}) {
|
||||
const { isTr3b } = services.aircraftPresentation;
|
||||
const { trackedModelScaleForPixelCap } = services.camera;
|
||||
const { focusNowMs, getFocusTarget } = services.focus;
|
||||
const { applyAircraftModelTreatment } = services.recession;
|
||||
|
||||
/** The model heading offset a class's GLB needs. */
|
||||
const _headingOffsetDeg = (klass) =>
|
||||
_modelSpec(klass).headingOffsetDeg ?? policy.MODEL_HEADING_OFFSET_DEG;
|
||||
|
||||
/** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude
|
||||
* ceiling. Since 2026-08-22 the toggle DEFAULTS ON in `proximity`, which is itself the
|
||||
* budget: models only appear below MODEL_ALT_CEIL_M and only for the nearest MODEL_MAX in
|
||||
* view. The toggle still OWNS the fleet — an operator who wants every in-view plane arms
|
||||
* `all`, and one who wants none turns 3D off — this predicate is unchanged. The TRACKED
|
||||
* contact does not route through here: it is one model, it is what the camera is aimed at,
|
||||
* and it takes its own default-on, hysteretic zoom regime
|
||||
* (`_trackedModelRegimeActive`). */
|
||||
|
||||
function _modelRegimeActive() {
|
||||
if (!state._models3dEnabled) return false;
|
||||
const h = state._viewer?.camera?.positionCartographic?.height ?? Infinity;
|
||||
return h < policy.MODEL_ALT_CEIL_M;
|
||||
}
|
||||
|
||||
/** Active model cap — the eligibility pre-pass AND _ensureModel's admission checks must use the
|
||||
* SAME value, else 'all' (MODEL_MAX_ALL) would mark planes eligible that _ensureModel then refuses
|
||||
* at the lower MODEL_MAX, silently degrading 'all' to 'proximity'. */
|
||||
|
||||
function _modelCap() {
|
||||
const mapCap =
|
||||
state._models3dMode === 'all' ? policy.MODEL_MAX_ALL : policy.MODEL_MAX;
|
||||
// `Math.min` on purpose: cockpit may only ever LOWER the GLB budget. Cockpit is
|
||||
// already the heaviest mode (20 Hz camera setView ahead of scene update, photoreal
|
||||
// retraversal, the cloud pass) and every model is its own draw call.
|
||||
return state._cockpitContactMode
|
||||
? Math.min(policy.COCKPIT_MODEL_MAX, mapCap)
|
||||
: mapCap;
|
||||
}
|
||||
|
||||
/** Active ADD radius (m) — new planes inside this range get a model. Mode-aware: 'all' reaches far. */
|
||||
|
||||
function _modelAddDistM() {
|
||||
return state._models3dMode === 'all'
|
||||
? policy.MODEL_ALL_ADD_M
|
||||
: policy.MODEL_PROX_ADD_M;
|
||||
}
|
||||
|
||||
/** Active KEEP radius (m) — a modeled plane keeps its model out to here (hysteresis vs ADD). */
|
||||
|
||||
function _modelKeepDistM() {
|
||||
return state._models3dMode === 'all'
|
||||
? policy.MODEL_ALL_KEEP_M
|
||||
: policy.MODEL_PROX_KEEP_M;
|
||||
}
|
||||
|
||||
/** World model matrix from a position + course heading (pitch/roll 0; ENU frame). Writes into
|
||||
* `result` (a shared scratch by default) and returns it. Push it with `placeModel`, which copies
|
||||
* it into the model's record and its own matrix, so models never share one mutable matrix
|
||||
* object. `Model.modelMatrix` is a plain field (not a cloning setter); Cesium
|
||||
* clones it per frame in updateModelMatrix(). Sharing a single scratch made every model render at
|
||||
* the LAST-written transform — all stacked on one plane — and, once the tracked model wrote the
|
||||
* scratch every frame, the stack point oscillated frame-to-frame: the "flickering like mad" bug. */
|
||||
|
||||
function _modelMatrix(
|
||||
pos,
|
||||
headingDeg,
|
||||
result = state._scratchModelMtx,
|
||||
offsetDeg = policy.MODEL_HEADING_OFFSET_DEG,
|
||||
) {
|
||||
state._scratchModelHpr.heading = Cesium.Math.toRadians(
|
||||
(headingDeg || 0) + offsetDeg,
|
||||
);
|
||||
state._scratchModelHpr.pitch = 0;
|
||||
state._scratchModelHpr.roll = 0;
|
||||
return Cesium.Transforms.headingPitchRollToFixedFrame(
|
||||
pos,
|
||||
state._scratchModelHpr,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
undefined,
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
/** Everything scene.sampleHeight must NOT hit when snapping a grounded model: the
|
||||
* vertical pick ray at a plane's own lat/lon otherwise lands on its (or a parked
|
||||
* neighbor's) billboard/model instead of the tile skin. Cesium's ray-pick exclusion
|
||||
* matches picked-object IDs, and every icon AND model in this layer carries its
|
||||
* icao as `id`, so the icao strings cover both; the tracked entity is excluded as the
|
||||
* object itself. Built lazily — only when a sample actually fires (one-shot). */
|
||||
|
||||
function _groundSampleExclusions() {
|
||||
const out = parts.icons._iconIds();
|
||||
if (state._trackedEntity) out.push(state._trackedEntity);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Position a 3D MODEL renders at. Airborne planes use their dead-reckoned position
|
||||
* verbatim. GROUNDED planes' meta altitude is last-known baro or 0 m — nowhere near
|
||||
* the photoreal tile skin in ellipsoid heights (buried ~100+ m at inland airports,
|
||||
* hovering ~30 m at sea-level ones), and unlike the ground billboards a depth-tested
|
||||
* model can't hide behind disableDepthTestDistance. So a modeled grounded plane rides
|
||||
* a ONE-SHOT cached scene.sampleHeight of the skin at its lat/lon (groundSnap.js,
|
||||
* CCTV-B9b discipline: never per-frame; taxiing >50 m retires the cached value to a
|
||||
* bounded last-known and resamples), plus
|
||||
* the belly offset so it sits on its gear rather than sinking to the model-origin
|
||||
* fuselage centerline. Until the FIRST sample lands (tiles streaming / sample miss)
|
||||
* there is no safe depth-tested placement at all: this returns null, the caller
|
||||
* keeps the 2D billboard visible and the model hidden, and it retries later. Once
|
||||
* a contact has resolved once, a later outage holds that measurement inside
|
||||
* groundSnap's drift bound instead — a taxiing aircraft does not pop back to 2D
|
||||
* because a resample is mid-backoff. */
|
||||
|
||||
function _modelDisplayPosition(icao24, pos, result) {
|
||||
const meta = state.records.data.get(icao24);
|
||||
if (!meta || !meta.onGround) return pos;
|
||||
const h = state._groundSnap.heightFor(
|
||||
state._viewer,
|
||||
icao24,
|
||||
pos,
|
||||
_groundSampleExclusions,
|
||||
);
|
||||
if (h == null) return null;
|
||||
const carto = Cesium.Cartographic.fromCartesian(
|
||||
pos,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
state._scratchGroundCarto,
|
||||
);
|
||||
carto.height = h + _modelSpec(meta.klass).bellyM;
|
||||
return Cesium.Cartesian3.fromRadians(
|
||||
carto.longitude,
|
||||
carto.latitude,
|
||||
carto.height,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
/** Atomically hand one fleet contact from its icon to a safely placed,
|
||||
* ready 3D model. Missing/loading models and unresolved terrain always leave
|
||||
* the icon owning the visual, so no render frame can hide both.
|
||||
* `beforeShow` runs only on the committing path — the per-tick model treatment
|
||||
* belongs to a model that is about to draw, not to one still waiting. */
|
||||
|
||||
function _driveFleetModelHandoff(
|
||||
icao24,
|
||||
record,
|
||||
row,
|
||||
pos,
|
||||
course,
|
||||
beforeShow,
|
||||
) {
|
||||
const icons = state._icons;
|
||||
if (!record) {
|
||||
icons.setShown(row, true);
|
||||
return false;
|
||||
}
|
||||
const displayPos = _modelDisplayPosition(
|
||||
icao24,
|
||||
pos,
|
||||
state._scratchGroundPos,
|
||||
);
|
||||
if (!displayPos) {
|
||||
showModel(record, false); // no ground evidence → nothing safe to place a depth-tested model at
|
||||
icons.setShown(row, true);
|
||||
return false;
|
||||
}
|
||||
// The matrix is written BEFORE the readiness test on purpose: a model can flip
|
||||
// `ready` during scene update after this tick, and a first rendered frame on a
|
||||
// stale load-start matrix is the one-frame jump this ordering prevents.
|
||||
placeModel(
|
||||
record,
|
||||
_modelMatrix(
|
||||
displayPos,
|
||||
course,
|
||||
undefined,
|
||||
_headingOffsetDeg(state.records.data.get(icao24)?.klass),
|
||||
),
|
||||
);
|
||||
if (!record.model.ready) {
|
||||
showModel(record, false); // not loaded yet → keep the 2D icon, no half-model flash
|
||||
icons.setShown(row, true);
|
||||
return false;
|
||||
}
|
||||
beforeShow?.();
|
||||
if (!record.show) showModel(record, true);
|
||||
icons.setShown(row, false); // hand off ONLY once the model renders
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a 3D model — not the billboard — is what the user actually SEES for
|
||||
* this contact, and therefore whether the display floor should stand aside.
|
||||
*
|
||||
* Model EXISTENCE is not ownership. A fleet model that is still loading, or has
|
||||
* no resolved ground to stand on, stays hidden while `bb.show` stays true (the
|
||||
* gap-proof handoff: "hand off ONLY once the model renders"), and a tracked model
|
||||
* is retained but hidden whenever the model regime is off — 3D disabled, camera
|
||||
* zoomed past the ceiling, or cockpit mode. Gating on `has()`/existence therefore
|
||||
* suppressed the clamp in exactly the states where the BILLBOARD is the visual,
|
||||
* putting the burial straight back.
|
||||
*
|
||||
* Cesium's own default `show === true` is the reason admission clears it
|
||||
* explicitly: a fleet model registered in `_models` the instant its glTF resolves
|
||||
* would otherwise claim ownership — at the identity matrix — for the frames
|
||||
* between admission and the next fleet tick, while the BILLBOARD was still the
|
||||
* visual. Ownership means actually rendering, and every site that sets `show`
|
||||
* true does so only after committing a matrix.
|
||||
*
|
||||
* The two halves:
|
||||
* - Fleet: the rendering test alone. Safe for the ground snap either way,
|
||||
* because a fleet model is positioned from the RAW dead-reckon, not from
|
||||
* the billboard.
|
||||
* - Tracked: the rendering test AND `_trackedModelRegimeActive()`. The tracked
|
||||
* model is fed from `_trackedDisplayPosition`, so once it is live the clamp
|
||||
* must stand aside: two different chains would otherwise be deciding one
|
||||
* contact's ground, and the billboard's is the one the operator is not
|
||||
* looking at (T7). The regime check is what makes a regime
|
||||
* flip take effect on the same frame rather than waiting for
|
||||
* `_updateTrackedModel` to clear `show`; the rendering test is what keeps a
|
||||
* null or still-loading tracked model from claiming a visual it is not
|
||||
* drawing yet.
|
||||
* @param {string} icao24
|
||||
* @param {import('./modelRecord.js').ModelRecord|null} [record] Resolved
|
||||
* fleet model record; null means absent.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
|
||||
function _modelOwnsVisual(icao24, record) {
|
||||
if (icao24 === state._trackedIcao) {
|
||||
return (
|
||||
parts.tracking._trackedModelRegimeActive() &&
|
||||
modelIsRendering(state._trackedModel)
|
||||
);
|
||||
}
|
||||
return modelIsRendering(
|
||||
record === undefined ? state._models.get(icao24) : record,
|
||||
);
|
||||
}
|
||||
|
||||
// A model is the visual only once it actually draws, loaded AND shown
|
||||
// (`modelIsRendering`). `show` is sufficient evidence of a safe placement
|
||||
// because only ONE site ever sets it true for a fleet model
|
||||
// (`_driveFleetModelHandoff`, after the matrix is committed) and one for the
|
||||
// tracked model (`_updateTrackedModel`, likewise); everything else —
|
||||
// admission, an unresolved ground, a not-yet-ready glTF, the limb cull, a
|
||||
// regime exit — only ever clears it.
|
||||
|
||||
/** Spec identity for a LOADED model: URL and scale together (same-URL classes
|
||||
* differ by scale — airliner vs quadjet both ship airplane.glb). */
|
||||
|
||||
const _specKeyFor = (klass) => {
|
||||
const spec = _modelSpec(klass);
|
||||
return `${spec.url}@${spec.scale}`;
|
||||
};
|
||||
|
||||
/** Class-change model sync (enrichment AND poll-path klass updates): when the
|
||||
* aircraft's live model or in-flight load no longer matches its class's spec,
|
||||
* drop it so the eligibility pass reloads the right asset at the right scale.
|
||||
* Gap-proof: the fleet billboard is re-shown BEFORE the release so the
|
||||
* contact never goes invisible for the tick gap; _releaseModel's generation
|
||||
* bump also invalidates any pending load. The tracked standalone model gets
|
||||
* the same rule (its billboard entity is always the fallback visual). */
|
||||
|
||||
function _syncModelToClass(icao24) {
|
||||
const key = _specKeyFor(state.records.data.get(icao24)?.klass);
|
||||
const current = state._models.get(icao24);
|
||||
if (
|
||||
(current && current.specKey !== key) ||
|
||||
(!current && state._modelPending.has(icao24))
|
||||
) {
|
||||
_releaseModel(icao24);
|
||||
}
|
||||
if (
|
||||
icao24 === state._trackedIcao &&
|
||||
state._trackedModel &&
|
||||
state._trackedModel.specKey !== key
|
||||
) {
|
||||
_releaseTrackedModel();
|
||||
}
|
||||
}
|
||||
|
||||
function _reloadModelsForIrBoost() {
|
||||
state._irReloadQueue = [...state._models.keys()];
|
||||
for (const icao of state._modelPending) {
|
||||
if (!state._models.has(icao))
|
||||
state._modelGen.set(icao, (state._modelGen.get(icao) || 0) + 1);
|
||||
}
|
||||
_releaseTrackedModel();
|
||||
}
|
||||
|
||||
function _drainIrReloadQueue() {
|
||||
if (!state._irReloadQueue) return;
|
||||
const batch = state._irReloadQueue.splice(0, policy.IR_RELOAD_BATCH);
|
||||
for (const icao of batch) {
|
||||
const record = state._models.get(icao);
|
||||
if (!record || record.irBoost === state._irBoost) continue; // already right state
|
||||
_releaseModel(icao);
|
||||
}
|
||||
if (state._irReloadQueue.length === 0) state._irReloadQueue = null;
|
||||
}
|
||||
|
||||
/** Lazily create the glTF model for an aircraft (fire-and-forget; billboard shows until ready). */
|
||||
|
||||
async function _ensureModel(icao24) {
|
||||
// Never model the TRACKED aircraft — it owns a separate entity billboard, and the fleet
|
||||
// tick skips it, so a model here would be orphaned + double-rendered.
|
||||
if (icao24 === state._trackedIcao) return;
|
||||
onModelEligible(icao24);
|
||||
if (state._models.has(icao24) || state._modelPending.has(icao24)) return;
|
||||
// Count PENDING loads in the cap so a zoomed-in tick can't fire 100s of concurrent loads
|
||||
// (the cap is rechecked post-await too, before the add). Mode-aware so 'all' can reach MAX_ALL.
|
||||
if (state._models.size + state._modelPending.size >= _modelCap()) return;
|
||||
const epoch = state._modelEpoch; // lifecycle token: if destroy() bumps it, this load is dead
|
||||
const gen = state._modelGen.get(icao24) || 0; // capture; if it changes during the load, we're stale
|
||||
state._modelPending.add(icao24);
|
||||
let model = null;
|
||||
// Spec identity captured at load START — if enrichment reclassifies the
|
||||
// aircraft mid-load, the post-await admission below rejects the stale asset.
|
||||
// Boost state likewise: the creation options bake it in, so a mid-load
|
||||
// toggle must reject too (the reload queue only covers ADMITTED models).
|
||||
const specKey = _specKeyFor(state.records.data.get(icao24)?.klass);
|
||||
const loadIrBoost = state._irBoost;
|
||||
try {
|
||||
const spec = _modelSpec(state.records.data.get(icao24)?.klass);
|
||||
model = await Cesium.Model.fromGltfAsync({
|
||||
url: resolveAsset(spec.url),
|
||||
asynchronous: false,
|
||||
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
|
||||
minimumPixelSize: policy.MODEL_MIN_PX,
|
||||
scale: spec.scale,
|
||||
color: state._irBoost ? Cesium.Color.WHITE : _modelColor(icao24),
|
||||
colorBlendMode: Cesium.ColorBlendMode.MIX,
|
||||
// Launch presentation keeps the code-side tint dominant for every approved
|
||||
// model; IR boost removes the remaining diffuse hint with flat UNLIT white.
|
||||
colorBlendAmount: state._irBoost ? 1.0 : spec.blendAmount,
|
||||
customShader: state._irBoost ? state._IR_UNLIT_SHADER : undefined,
|
||||
id: icao24, // so scene.pick returns the icao for click-to-track
|
||||
});
|
||||
} catch {
|
||||
// asset/decode fail — stay an icon. Only touch this lifecycle's state if still current
|
||||
// (a destroy/re-init may have swapped the globals while this load was in flight).
|
||||
if (epoch === state._modelEpoch) {
|
||||
state._modelPending.delete(icao24);
|
||||
_cleanupModelGen(icao24);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A load from a PREVIOUS lifecycle (destroy→init happened mid-load) must NOT mutate the new
|
||||
// epoch's _modelPending/_modelGen or add to the new collection — just drop its model.
|
||||
if (epoch !== state._modelEpoch) {
|
||||
try {
|
||||
model.destroy();
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
return;
|
||||
}
|
||||
state._modelPending.delete(icao24);
|
||||
// Post-await admission: reject (and DESTROY the loaded model) if anything changed during the
|
||||
// load — a release bumped the generation (track/untrack/remove), the layer toggled off / was
|
||||
// torn down, the aircraft is gone or now tracked, a model already exists, or the cap filled.
|
||||
// Recheck the shared Display 3D toggle and altitude ceiling after the async
|
||||
// load. Cockpit uses the same OFF / Proximity / All contract as map Display.
|
||||
const stale =
|
||||
(state._modelGen.get(icao24) || 0) !== gen ||
|
||||
!_modelRegimeActive() ||
|
||||
!state._modelCollection ||
|
||||
state._modelCollection.isDestroyed() ||
|
||||
!state.records.data.has(icao24) ||
|
||||
icao24 === state._trackedIcao ||
|
||||
state._models.has(icao24) ||
|
||||
state._models.size >= _modelCap() ||
|
||||
// Class reclassified mid-load → this GLB/scale is for the OLD class.
|
||||
_specKeyFor(state.records.data.get(icao24)?.klass) !== specKey ||
|
||||
// IR boost flipped mid-load → this model baked the wrong shader/tint.
|
||||
state._irBoost !== loadIrBoost;
|
||||
if (stale) {
|
||||
try {
|
||||
model.destroy();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
_cleanupModelGen(icao24); // bound the map
|
||||
return;
|
||||
}
|
||||
// Keep the pick identity explicit on the resolved primitive. This also
|
||||
// protects injected/custom loaders that do not copy the creation option.
|
||||
model.id = icao24;
|
||||
const record = createModelRecord(model, {
|
||||
specKey, // class-change sync compares against this
|
||||
irBoost: loadIrBoost, // boost-flip reload queue compares against this
|
||||
});
|
||||
// Admitted, not yet the visual. Cesium's default is show=true, which would let
|
||||
// an unplaced primitive claim ownership from the icon for the frames
|
||||
// between admission and the next fleet tick (and draw at the identity matrix,
|
||||
// i.e. the Earth's centre). The handoff turns it on once it has a matrix.
|
||||
showModel(record, false);
|
||||
state._modelCollection.add(model);
|
||||
state._models.set(icao24, record);
|
||||
state._planeModelLoaded = true; // GLB is cached now — the tracked entity's model can fade in its billboard
|
||||
}
|
||||
|
||||
/** Remove the 3D model for ONE aircraft (removal / military-suppression / track handoff).
|
||||
* Bumps the load generation so any in-flight load for this icao is rejected on completion. */
|
||||
|
||||
function _releaseModel(icao24) {
|
||||
const record = state._models.get(icao24);
|
||||
const pending = state._modelPending.has(icao24);
|
||||
// Only bump the generation when there's something to invalidate (an in-flight load or a
|
||||
// live model) — so removing never-modeled aircraft doesn't grow _modelGen.
|
||||
if (record || pending) {
|
||||
state._modelGen.set(icao24, (state._modelGen.get(icao24) || 0) + 1);
|
||||
}
|
||||
if (record) {
|
||||
if (state._modelCollection && !state._modelCollection.isDestroyed()) {
|
||||
try {
|
||||
state._modelCollection.remove(record.model);
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
}
|
||||
state._models.delete(icao24);
|
||||
_showReleasedIcon(icao24);
|
||||
}
|
||||
// Do NOT clear _modelPending here — the in-flight load's OWN post-await removes it. Keeping
|
||||
// it (a) prevents a duplicate load from starting and (b) keeps the bumped gen entry alive so
|
||||
// the resolving load's gen-check still rejects it.
|
||||
_cleanupModelGen(icao24);
|
||||
}
|
||||
|
||||
/** Drop an icao's generation entry once nothing references it (no live model, no in-flight
|
||||
* load) — keeps _modelGen bounded. Shared by _releaseModel + both _ensureModel exit paths. */
|
||||
|
||||
function _cleanupModelGen(icao24) {
|
||||
if (!state._modelPending.has(icao24) && !state._models.has(icao24))
|
||||
state._modelGen.delete(icao24);
|
||||
}
|
||||
|
||||
/** Remove all live models (toggle-off / zoom-out); billboards take back over next tick. */
|
||||
|
||||
function _releaseModels() {
|
||||
state._irReloadQueue = null; // a full release supersedes any pending boost-flip drain
|
||||
// Invalidate in-flight loads so a completion after this bulk release can't add a model.
|
||||
for (const icao of state._modelPending)
|
||||
state._modelGen.set(icao, (state._modelGen.get(icao) || 0) + 1);
|
||||
if (state._modelCollection && !state._modelCollection.isDestroyed()) {
|
||||
for (const record of state._models.values()) {
|
||||
try {
|
||||
state._modelCollection.remove(record.model);
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const icao24 of state._models.keys()) _showReleasedIcon(icao24);
|
||||
state._models.clear();
|
||||
}
|
||||
|
||||
/** A released fleet model hands the visual straight back to its icon (the
|
||||
* tracked contact's entity keeps its own). */
|
||||
|
||||
function _showReleasedIcon(icao24) {
|
||||
const icons = state._icons;
|
||||
const row = icons?.row(icao24) ?? -1;
|
||||
if (row >= 0 && icao24 !== state._trackedIcao) icons.setShown(row, true);
|
||||
}
|
||||
|
||||
/** Destroy the standalone tracked-aircraft model and invalidate any in-flight load. */
|
||||
|
||||
function _releaseTrackedModel() {
|
||||
state._trackedModelGen++;
|
||||
state._trackedModelLoading = false;
|
||||
if (state._trackedModel) {
|
||||
if (state._modelCollection && !state._modelCollection.isDestroyed()) {
|
||||
try {
|
||||
state._modelCollection.remove(state._trackedModel.model);
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
}
|
||||
state._trackedModel = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-frame driver for the standalone tracked model. Runs every preUpdate (not the 80 ms fleet
|
||||
* cadence) so the centered plane moves smoothly. The tracked entity stays a pure billboard, so the
|
||||
* follow-camera's bounding sphere is ALWAYS ready — toggling 3D, or tracking while already zoomed
|
||||
* in, can never stall or freeze the centering (the old model-graphic-on-entity failure mode). */
|
||||
|
||||
function _updateTrackedModel() {
|
||||
const active =
|
||||
state._trackedIcao &&
|
||||
parts.tracking._trackedModelRegimeActive() &&
|
||||
state._modelCollection &&
|
||||
!state._modelCollection.isDestroyed();
|
||||
if (!active) {
|
||||
if (state._trackedModel) showModel(state._trackedModel, false);
|
||||
return;
|
||||
}
|
||||
// Ask the frame-cached source directly. If the entity callback already ran,
|
||||
// this is a no-op; if model loading completed between phases, it establishes
|
||||
// this frame's single sample before the model renders. Camera, detection, and
|
||||
// readout consumers then reuse that exact cached position.
|
||||
const pos =
|
||||
parts.motion._trackedDisplayPosition(state._trackedIcao) ||
|
||||
parts.icons._iconPosition(state._trackedIcao);
|
||||
if (!pos) {
|
||||
if (state._trackedModel) showModel(state._trackedModel, false);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!state._trackedModel &&
|
||||
!state._trackedModelLoading &&
|
||||
parts.tracking._trackedModelLoadAllowed()
|
||||
) {
|
||||
state._trackedModelLoading = true;
|
||||
const gen = state._trackedModelGen;
|
||||
const trackedSpec = _modelSpec(
|
||||
state.records.data.get(state._trackedIcao)?.klass,
|
||||
);
|
||||
const trackedKey = _specKeyFor(
|
||||
state.records.data.get(state._trackedIcao)?.klass,
|
||||
);
|
||||
const trackedIrBoost = state._irBoost;
|
||||
Cesium.Model.fromGltfAsync({
|
||||
url: resolveAsset(trackedSpec.url),
|
||||
asynchronous: false,
|
||||
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
|
||||
minimumPixelSize: policy.TRACKED_MODEL_MIN_PX,
|
||||
scale: trackedSpec.scale,
|
||||
color: state._irBoost
|
||||
? Cesium.Color.WHITE
|
||||
: _modelColor(state._trackedIcao),
|
||||
colorBlendMode: Cesium.ColorBlendMode.MIX,
|
||||
// The tracked aircraft uses the same dominant light tint as the fleet;
|
||||
// IR boost removes the remaining diffuse hint with flat UNLIT white.
|
||||
colorBlendAmount: state._irBoost ? 1.0 : trackedSpec.blendAmount,
|
||||
customShader: state._irBoost ? state._IR_UNLIT_SHADER : undefined,
|
||||
// Pick id (H1): without it, clicking the very plane being tracked read as
|
||||
// EMPTY SPACE (scene.pick → primitive with no id) → an unintended
|
||||
// deselect. With the icao, the click handler recognizes it as ours.
|
||||
id: state._trackedIcao,
|
||||
})
|
||||
.then((m) => {
|
||||
// Untracked / re-tracked / torn down during the load → drop it.
|
||||
if (
|
||||
gen !== state._trackedModelGen ||
|
||||
!state._modelCollection ||
|
||||
state._modelCollection.isDestroyed()
|
||||
) {
|
||||
try {
|
||||
m.destroy();
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Class reclassified OR boost flipped mid-load (no release ran —
|
||||
// _trackedModel was still null): drop the stale asset; driver reloads.
|
||||
if (
|
||||
_specKeyFor(state.records.data.get(state._trackedIcao)?.klass) !==
|
||||
trackedKey ||
|
||||
state._irBoost !== trackedIrBoost
|
||||
) {
|
||||
try {
|
||||
m.destroy();
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
state._trackedModelLoading = false;
|
||||
return;
|
||||
}
|
||||
// Assign after resolution as well as in the creation options so the
|
||||
// standalone primitive always exposes the tracked aircraft pick id.
|
||||
m.id = state._trackedIcao;
|
||||
const record = createModelRecord(m, {
|
||||
specKey: trackedKey, // class-change sync compares against this
|
||||
irBoost: trackedIrBoost,
|
||||
});
|
||||
showModel(record, false); // admitted, not yet the visual — the driver shows it once placed
|
||||
// Seed the world transform before the primitive enters the scene. A model
|
||||
// can become ready+shown between render phases; leaving Cesium's identity
|
||||
// default here produces a one-frame jump to the Earth's center.
|
||||
const currentPos =
|
||||
parts.motion._trackedDisplayCached() ||
|
||||
parts.icons._iconPosition(state._trackedIcao);
|
||||
if (currentPos) {
|
||||
const displayPos = _modelDisplayPosition(
|
||||
state._trackedIcao,
|
||||
currentPos,
|
||||
state._scratchGroundPos,
|
||||
);
|
||||
if (displayPos)
|
||||
placeModel(
|
||||
record,
|
||||
_modelMatrix(
|
||||
displayPos,
|
||||
parts.motion._trackedDisplayCourse(),
|
||||
undefined,
|
||||
trackedSpec.headingOffsetDeg ??
|
||||
policy.MODEL_HEADING_OFFSET_DEG,
|
||||
),
|
||||
);
|
||||
}
|
||||
state._trackedModel = record;
|
||||
state._trackedModelLoading = false;
|
||||
// A good load retires this selection's failure budget — a contact that
|
||||
// recovers after a transient blip is not one attempt from giving up.
|
||||
state._trackedModelFailIcao = null;
|
||||
state._trackedModelFailCount = 0;
|
||||
state._trackedModelRetryAtMs = 0;
|
||||
state._modelCollection.add(m);
|
||||
state._planeModelLoaded = true; // GLB cached — the tracked billboard can fade out once the model is up
|
||||
})
|
||||
.catch((err) => {
|
||||
if (gen !== state._trackedModelGen) return; // superseded load — not this selection's failure
|
||||
state._trackedModelLoading = false;
|
||||
parts.tracking._noteTrackedModelLoadFailure(trackedSpec.url, err);
|
||||
});
|
||||
return; // billboard carries the visual until the model is ready
|
||||
}
|
||||
if (state._trackedModel) {
|
||||
// Keep the transform current while GPU resources are still loading. Cesium
|
||||
// can flip ready during scene update after this callback; waiting for ready
|
||||
// here would let that first rendered frame use a stale load-start matrix.
|
||||
const displayPos = _modelDisplayPosition(
|
||||
state._trackedIcao,
|
||||
pos,
|
||||
state._scratchGroundPos,
|
||||
);
|
||||
if (!displayPos) {
|
||||
showModel(state._trackedModel, false); // no ground evidence → the icon carries it
|
||||
return;
|
||||
}
|
||||
const spec = _modelSpec(
|
||||
state.records.data.get(state._trackedIcao)?.klass,
|
||||
);
|
||||
placeModel(
|
||||
state._trackedModel,
|
||||
_modelMatrix(
|
||||
displayPos,
|
||||
parts.motion._trackedDisplayCourse(),
|
||||
undefined,
|
||||
spec.headingOffsetDeg ?? policy.MODEL_HEADING_OFFSET_DEG,
|
||||
),
|
||||
);
|
||||
if (!state._trackedModel.model.ready) return;
|
||||
state._trackedModel.model.scale = trackedModelScaleForPixelCap({
|
||||
baseScale: spec.scale,
|
||||
nativeRadiusM: spec.nativeRadiusM,
|
||||
rangeM: Cesium.Cartesian3.distance(
|
||||
state._viewer.camera.positionWC,
|
||||
displayPos,
|
||||
),
|
||||
viewportHeightPx: state._viewer.scene.canvas.clientHeight,
|
||||
fovyRad: state._viewer.camera.frustum.fovy,
|
||||
maximumPixelSize: policy.TRACKED_MODEL_MAX_PX,
|
||||
});
|
||||
showModel(state._trackedModel, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ~12 Hz fleet pass. Motion, rotation, the horizon cull, limb recession and
|
||||
* the two-tier glyph swap all run on the GPU from each row's segment, so this
|
||||
* only re-plans rows whose regime ended, runs the layer's own pass after
|
||||
* that (flights floors grounded contacts), advances focus de-emphasis while
|
||||
* a target exists, and drives the 3D-model handoff inside the model regime.
|
||||
* Driven by scene.preRender.
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
function _fleetTick() {
|
||||
const icons = state._icons;
|
||||
if (!state._viewer || !icons || !icons.show) return;
|
||||
const scene = state._viewer.scene;
|
||||
const camera = state._viewer.camera;
|
||||
const nowMs = focusNowMs(Date.now());
|
||||
|
||||
if (nowMs - state._lastFleetTickMs < policy.FLEET_DR_INTERVAL_MS) return;
|
||||
state._lastFleetTickMs = nowMs;
|
||||
// Every contact in this pass shares the same delayed display instant.
|
||||
const renderTime = parts.icons._displayTime(state._scratchFleetRenderTime);
|
||||
const tau = icons.seconds(renderTime);
|
||||
|
||||
_drainIrReloadQueue(); // bounded per-tick slice of any pending boost-flip reload
|
||||
if (state._cockpitContactMode) parts.tracking._refreshCockpitNearContacts();
|
||||
parts.icons._replanExpired(tau);
|
||||
afterReplan(renderTime, tau, nowMs, camera);
|
||||
const focusTarget = getFocusTarget();
|
||||
parts.icons._focusIcons(tau, scene, camera, nowMs, focusTarget);
|
||||
|
||||
// 3D model regime: only when enabled AND the camera is zoomed in past the altitude ceiling.
|
||||
// Drop all models the moment we leave it (toggled off / zoomed out) so icons resume.
|
||||
const useModels = _modelRegimeActive();
|
||||
// Drop live models AND invalidate in-flight loads on leaving the regime (else a load that
|
||||
// resolves after zoom-out could briefly add a model outside the 3D-model regime).
|
||||
if (!useModels && (state._models.size || state._modelPending.size))
|
||||
_releaseModels();
|
||||
if (!useModels) return;
|
||||
|
||||
// 3D-model eligibility: by DISTANCE (the mode's add/keep band), with ON-SCREEN PRIORITY under the
|
||||
// cap. FOUR passes, visible-first, so the slots are spent on what you can see: (1) KEEP on-screen
|
||||
// already-modeled (hysteresis for visible planes); (2) ADD on-screen new inside the add radius,
|
||||
// nearest first; (3) KEEP off-screen already-modeled; (4) ADD off-screen new with leftover slots
|
||||
// (so a plane just off the cone still models — the "planes right next to me aren't 3D" complaint).
|
||||
// Crucially KEEP is SPLIT by frustum: an off-screen retained model (pass 3) can never starve an
|
||||
// on-screen plane that wants one (passes 1–2) — a single KEEP-everything pass could fill the cap
|
||||
// with off-screen retained models. Pure-distance let off-screen planes eat the cap; pure-frustum
|
||||
// filtering dropped near off-screen planes entirely. This does both right.
|
||||
const cap = _modelCap();
|
||||
const camPos = camera.positionWC;
|
||||
const addM = _modelAddDistM();
|
||||
const addDistSq = addM * addM;
|
||||
const keepM = _modelKeepDistM();
|
||||
const keepDistSq = keepM * keepM;
|
||||
const cull = camera.frustum.computeCullingVolume(
|
||||
camPos,
|
||||
camera.directionWC,
|
||||
camera.upWC,
|
||||
);
|
||||
// Candidates = planes within the KEEP radius, nearest first, each tagged with on-screen-ness.
|
||||
const cand = [];
|
||||
for (let row = 0; row < icons.count; row++) {
|
||||
const icao = icons.idAt(row);
|
||||
if (icao === state._trackedIcao) continue;
|
||||
// A converted TR-3B renders as an icon and can never take a model, so
|
||||
// it must not occupy a CAP SLOT either — excluded here at selection time,
|
||||
// not just at the handoff below, or accumulated conversions would starve
|
||||
// ordinary contacts of 3D models. (The handoff guard stays as defence.)
|
||||
if (isTr3b(icao)) continue;
|
||||
// Ground planes compete for model slots like everyone else (owner decision
|
||||
// 2026-07-03: "3D mode is respected regardless of whether a plane is on the
|
||||
// ground or in the air — no distinction"). The cap + nearest-first ordering
|
||||
// below already bound airport clusters; grounded placement is handled by the
|
||||
// one-shot ground snap in _modelDisplayPosition.
|
||||
const position = icons.position(row, tau, state._scratchModelBS.center);
|
||||
const d2 = Cesium.Cartesian3.distanceSquared(camPos, position);
|
||||
if (d2 > keepDistSq) continue; // beyond the keep radius → never eligible
|
||||
cand.push([
|
||||
icao,
|
||||
d2,
|
||||
cull.computeVisibility(state._scratchModelBS) !==
|
||||
Cesium.Intersect.OUTSIDE,
|
||||
]);
|
||||
}
|
||||
cand.sort((a, b) => a[1] - b[1]); // nearest first
|
||||
if (cand.length > cap && nowMs - state._lastModelCapWarnMs > 5000) {
|
||||
console.warn(
|
||||
`[${logLabel}] ${cand.length} planes in 3D range; capped at ${cap} (${state._models3dMode}). On-screen planes are prioritized.`,
|
||||
);
|
||||
state._lastModelCapWarnMs = nowMs;
|
||||
}
|
||||
const modelEligible = selectModelEligible(cand, {
|
||||
cap,
|
||||
addDistSq,
|
||||
isModeled: (icao) => state._models.has(icao),
|
||||
});
|
||||
const toRelease = [];
|
||||
for (const icao of state._models.keys()) {
|
||||
if (icao !== state._trackedIcao && !modelEligible.has(icao))
|
||||
toRelease.push(icao);
|
||||
}
|
||||
for (const icao of toRelease) _releaseModel(icao);
|
||||
|
||||
// 3D model takes over from the icon for eligible planes. GAP-PROOF: the icon stays shown
|
||||
// until the model is actually READY to render, so a plane is never both iconless AND
|
||||
// modelless (the "planes vanish when 3D turns on" bug). Position the model every tick
|
||||
// regardless so it's framed the instant it becomes ready. Converted TR-3Bs stay 2D on
|
||||
// purpose: the Easter egg IS the triangle, and there is no GLB for it.
|
||||
const occluder = horizonOccluder(camera);
|
||||
for (const icao24 of modelEligible) {
|
||||
if (isTr3b(icao24)) continue;
|
||||
const row = icons.row(icao24);
|
||||
if (row < 0) continue;
|
||||
const position = icons.position(row, tau, state._scratchFleetPos);
|
||||
if (!parts.icons._aboveHorizon(row, position, occluder)) {
|
||||
// A model that crossed the limb would keep rendering through the
|
||||
// hidden globe at its last matrix; the icon's own cull is on the GPU.
|
||||
const hidden = state._models.get(icao24);
|
||||
if (hidden?.show) showModel(hidden, false);
|
||||
continue;
|
||||
}
|
||||
_ensureModel(icao24);
|
||||
const record = state._models.get(icao24);
|
||||
const cameraDistanceM = Cesium.Cartesian3.distance(camPos, position);
|
||||
_driveFleetModelHandoff(
|
||||
icao24,
|
||||
record,
|
||||
row,
|
||||
position,
|
||||
icons.course(row, tau),
|
||||
() => {
|
||||
applyAircraftModelTreatment({
|
||||
model: record.model,
|
||||
// IR boost must survive the per-tick treatment write — otherwise
|
||||
// any alpha change would repaint the ordinary tint over the hot
|
||||
// white. Boosted models also skip the recession fade: hot targets
|
||||
// stay full-strength at any range (icons keep their normal
|
||||
// fade — full-opacity glyph walls read as overwhelming).
|
||||
baseColor: state._irBoost
|
||||
? Cesium.Color.WHITE
|
||||
: _modelColor(icao24),
|
||||
alpha: state._irBoost
|
||||
? 1
|
||||
: aircraftTreatmentAlpha({
|
||||
baseAlpha: icons.alpha(row),
|
||||
focusFactor: icons.focus(row),
|
||||
cameraDistanceM,
|
||||
cameraHeightM: camera.positionCartographic?.height,
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
_modelRegimeActive,
|
||||
_modelCap,
|
||||
_modelAddDistM,
|
||||
_modelKeepDistM,
|
||||
_modelMatrix,
|
||||
_groundSampleExclusions,
|
||||
_modelDisplayPosition,
|
||||
_driveFleetModelHandoff,
|
||||
_modelOwnsVisual,
|
||||
_specKeyFor,
|
||||
_syncModelToClass,
|
||||
_reloadModelsForIrBoost,
|
||||
_drainIrReloadQueue,
|
||||
_ensureModel,
|
||||
_releaseModel,
|
||||
_cleanupModelGen,
|
||||
_releaseModels,
|
||||
_showReleasedIcon,
|
||||
_releaseTrackedModel,
|
||||
_updateTrackedModel,
|
||||
_fleetTick,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
courseBetweenCartesians,
|
||||
speedRamp,
|
||||
staleCoastLimitSeconds,
|
||||
} from './motionModel.js';
|
||||
|
||||
/**
|
||||
* Which record fields carry an aircraft layer's reported ground speed (m/s)
|
||||
* and track (degrees clockwise from north).
|
||||
* @typedef {{speed: string, track: string}} AircraftRecordFields
|
||||
*/
|
||||
|
||||
/** A fix's own ground speed, else the record's. */
|
||||
export function fixSpeedMps(fix, info, fields) {
|
||||
return Number.isFinite(fix.velocity)
|
||||
? fix.velocity
|
||||
: (info && info[fields.speed]) || 0;
|
||||
}
|
||||
|
||||
/** A fix's own track, else the record's. */
|
||||
export function fixTrackDeg(fix, info, fields) {
|
||||
return Number.isFinite(fix.track)
|
||||
? fix.track
|
||||
: (info && info[fields.track]) || 0;
|
||||
}
|
||||
|
||||
/** A reusable result for `aircraftMotionRegime`. */
|
||||
export function createMotionRegime() {
|
||||
return {
|
||||
kind: 'fix',
|
||||
a: null,
|
||||
b: null,
|
||||
span: 0,
|
||||
speed: 0,
|
||||
trackFrom: 0,
|
||||
trackTo: 0,
|
||||
chordCourse: null,
|
||||
blend: 0,
|
||||
fix: null,
|
||||
heading: 0,
|
||||
turnRateDps: 0,
|
||||
minDt: 0,
|
||||
maxDt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Which motion an aircraft's history gives at `renderTime` — the one choice
|
||||
* the CPU dead reckoning and the icon planner (GPU segment) both draw from.
|
||||
*
|
||||
* - `between`: interpolate the bracketing pair `a`→`b`, no extrapolation
|
||||
* error and no snap-back. The course of the DISPLAYED motion blends the
|
||||
* time-interpolated reported track toward the chord by `blend`: the chord
|
||||
* is only trustworthy when the segment covers real ground (at hover its
|
||||
* direction is GPS jitter; on a slow tight turn it STEPS the whole
|
||||
* per-segment turn at each boundary), and helicopters never use it
|
||||
* (rotorcraft chords are noise-dominated at their typical speeds).
|
||||
* - `fix`: extrapolate `fix` by the elapsed time clamped to [minDt, maxDt].
|
||||
* Warm-up (renderTime predates ALL history: freshly seen or just tracked,
|
||||
* before a poll interval of history exists) runs the OLDEST fix BACKWARD,
|
||||
* preserving the behind-real-time invariant, so as history fills the icon
|
||||
* glides FORWARD into interpolation with no freeze and no backward snap
|
||||
* (holding the oldest fix froze the icon; extrapolating the newest to
|
||||
* wall-clock made it jump back a poll interval when interpolation took
|
||||
* over). Otherwise the newest fix coasts. A feed can still be receiving
|
||||
* fresh contact messages for that aircraft; freezing 60 s after the
|
||||
* position produced a stop → catch-up → stop cadence, so it coasts through
|
||||
* the latest real contact plus a bounded grace, with an absolute cap so a
|
||||
* stale cached feed cannot drift forever.
|
||||
*
|
||||
* @param {Array<object>} history Non-empty, oldest first.
|
||||
* @param {object|undefined} info The contact's record.
|
||||
* @param {Cesium.JulianDate} renderTime
|
||||
* @param {AircraftRecordFields} fields
|
||||
* @param {object} result From `createMotionRegime`; returned filled in.
|
||||
*/
|
||||
export function aircraftMotionRegime(
|
||||
history,
|
||||
info,
|
||||
renderTime,
|
||||
fields,
|
||||
result,
|
||||
) {
|
||||
for (let i = history.length - 1; i >= 1; i--) {
|
||||
const a = history[i - 1];
|
||||
const b = history[i];
|
||||
if (
|
||||
!Cesium.JulianDate.lessThanOrEquals(a.time, renderTime) ||
|
||||
!Cesium.JulianDate.lessThanOrEquals(renderTime, b.time)
|
||||
)
|
||||
continue;
|
||||
const span = Cesium.JulianDate.secondsDifference(b.time, a.time);
|
||||
const chordLenM = Cesium.Cartesian3.distance(a.position, b.position);
|
||||
const speed =
|
||||
span > 0 ? chordLenM / span : (info && info[fields.speed]) || 0;
|
||||
const fallbackTrack = (info && info[fields.track]) || 0;
|
||||
const trackFrom = Number.isFinite(a.track) ? a.track : fallbackTrack;
|
||||
const blend = info && info.klass === 'helicopter' ? 0 : speedRamp(speed);
|
||||
result.kind = 'between';
|
||||
result.a = a;
|
||||
result.b = b;
|
||||
result.span = span;
|
||||
result.speed = speed;
|
||||
result.trackFrom = trackFrom;
|
||||
result.trackTo = Number.isFinite(b.track) ? b.track : trackFrom;
|
||||
result.chordCourse =
|
||||
blend > 0 ? courseBetweenCartesians(a.position, b.position) : null;
|
||||
result.blend = blend;
|
||||
return result;
|
||||
}
|
||||
const newest = history[history.length - 1];
|
||||
const warming = Cesium.JulianDate.lessThan(renderTime, history[0].time);
|
||||
result.kind = 'fix';
|
||||
result.fix = warming ? history[0] : newest;
|
||||
result.speed = fixSpeedMps(result.fix, info, fields);
|
||||
result.heading = fixTrackDeg(result.fix, info, fields);
|
||||
result.turnRateDps = (info && info.turnRateDps) || 0;
|
||||
result.minDt = -60;
|
||||
// A warm-up ends when real time reaches the oldest fix; a single fix keeps
|
||||
// coasting forward on the same arc.
|
||||
result.maxDt =
|
||||
warming && history.length > 1
|
||||
? 0
|
||||
: staleCoastLimitSeconds({
|
||||
// `epochMs` is captured once when the poll is normalized. Avoid
|
||||
// allocating a Date per aircraft on every 12 Hz fleet tick.
|
||||
fixEpochMs: Number.isFinite(newest.epochMs)
|
||||
? newest.epochMs
|
||||
: Cesium.JulianDate.toDate(newest.time).getTime(),
|
||||
lastContactEpochMs: info?.lastContactEpochMs,
|
||||
// Permit one minute of contact grace but cap any cached-feed drift
|
||||
// at five minutes. Source backoff is exposed separately as a STALE cue.
|
||||
minimumSec: 60,
|
||||
maximumSec: 300,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* Limb-relative recession for distant aircraft billboards.
|
||||
* Limb-relative recession for distant aircraft: the factors icon sets apply in
|
||||
* their shader (ICON_RECEDES) and models apply through their colour alpha.
|
||||
*
|
||||
* This multiplies the billboard's ordinary scale, which Cesium then composes
|
||||
* with the locked NearFarScalar. It never edits that scalar, culls a contact,
|
||||
* or allows alpha to reach zero.
|
||||
* It multiplies an icon's ordinary scale, which composes with its locked
|
||||
* NearFarScalar. It never edits that scalar, culls a contact, or allows alpha
|
||||
* to reach zero.
|
||||
*/
|
||||
|
||||
export const DEFAULT_AIRCRAFT_RECESSION_PARAMS = Object.freeze({
|
||||
@@ -19,13 +20,6 @@ export const DEFAULT_AIRCRAFT_RECESSION_PARAMS = Object.freeze({
|
||||
|
||||
let _params = { ...DEFAULT_AIRCRAFT_RECESSION_PARAMS };
|
||||
const _scratchFactors = { scale: 1, alpha: 1, limbRatio: null };
|
||||
const _treatmentResult = {
|
||||
scale: 1,
|
||||
alpha: 1,
|
||||
scaleWrites: 0,
|
||||
alphaWrites: 0,
|
||||
factors: _scratchFactors,
|
||||
};
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
@@ -178,28 +172,18 @@ export function aircraftRecessionFactors(
|
||||
}
|
||||
|
||||
/**
|
||||
* Production wire helper: compose base scale/alpha, focus emphasis, and limb
|
||||
* recession into one deadband-gated billboard write site.
|
||||
* The alpha an aircraft draws with: base × max(combined floor, focus × limb
|
||||
* haze). Icon sets compose the same thing in their shader; models need it here.
|
||||
* @param {object} input
|
||||
* @param {object} input.billboard
|
||||
* @param {number} input.baseScale
|
||||
* @param {number} input.baseAlpha
|
||||
* @param {{withAlpha:(alpha:number)=>object}} input.baseColor
|
||||
* @param {number} input.focusFactor
|
||||
* @param {number} input.cameraDistanceM
|
||||
* @param {number} input.cameraHeightM
|
||||
* @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [input.params]
|
||||
*
|
||||
* IMPORTANT: the returned object (including `factors`) is a module-owned
|
||||
* mutable singleton. Consume every needed property before the next call;
|
||||
* never retain it as a snapshot.
|
||||
* @returns {{scale:number,alpha:number,scaleWrites:number,alphaWrites:number,factors:object}}
|
||||
* @returns {number}
|
||||
*/
|
||||
export function applyAircraftBillboardTreatment({
|
||||
billboard,
|
||||
baseScale,
|
||||
export function aircraftTreatmentAlpha({
|
||||
baseAlpha,
|
||||
baseColor,
|
||||
focusFactor,
|
||||
cameraDistanceM,
|
||||
cameraHeightM,
|
||||
@@ -212,33 +196,13 @@ export function applyAircraftBillboardTreatment({
|
||||
tuning,
|
||||
_scratchFactors,
|
||||
);
|
||||
const scale = baseScale * factors.scale;
|
||||
const composedTreatment = Math.max(
|
||||
tuning.combinedAlphaFloor,
|
||||
clamp(focusFactor, 0, 1) * factors.alpha,
|
||||
return (
|
||||
baseAlpha *
|
||||
Math.max(
|
||||
tuning.combinedAlphaFloor,
|
||||
clamp(focusFactor, 0, 1) * factors.alpha,
|
||||
)
|
||||
);
|
||||
const alpha = baseAlpha * composedTreatment;
|
||||
let scaleWrites = 0;
|
||||
let alphaWrites = 0;
|
||||
if (
|
||||
!Number.isFinite(billboard.scale) ||
|
||||
Math.abs(billboard.scale - scale) > tuning.writeEpsilon
|
||||
) {
|
||||
billboard.scale = scale;
|
||||
scaleWrites = 1;
|
||||
}
|
||||
if (
|
||||
!Number.isFinite(billboard.color?.alpha) ||
|
||||
Math.abs(billboard.color.alpha - alpha) > tuning.writeEpsilon
|
||||
) {
|
||||
billboard.color = baseColor.withAlpha(alpha);
|
||||
alphaWrites = 1;
|
||||
}
|
||||
_treatmentResult.scale = scale;
|
||||
_treatmentResult.alpha = alpha;
|
||||
_treatmentResult.scaleWrites = scaleWrites;
|
||||
_treatmentResult.alphaWrites = alphaWrites;
|
||||
return _treatmentResult;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,9 +3,9 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
DEFAULT_AIRCRAFT_RECESSION_PARAMS,
|
||||
aircraftRecessionFactors,
|
||||
applyAircraftBillboardTreatment,
|
||||
applyAircraftModelTreatment,
|
||||
cameraLimbDistanceM,
|
||||
aircraftTreatmentAlpha,
|
||||
} from './aircraftRecession.js';
|
||||
|
||||
const params = { ...DEFAULT_AIRCRAFT_RECESSION_PARAMS };
|
||||
@@ -66,72 +66,15 @@ test('globe-view transition eases to identity without a threshold pop', () => {
|
||||
assert.deepEqual(end, { scale: 1, alpha: 1, limbRatio: null });
|
||||
});
|
||||
|
||||
test('aircraft billboard wire writes scale and alpha only for the far treatment', () => {
|
||||
const color = { withAlpha: (alpha) => ({ alpha }) };
|
||||
const near = { scale: 1.2, color: { alpha: 0.8 } };
|
||||
const nearResult = applyAircraftBillboardTreatment({
|
||||
billboard: near,
|
||||
baseScale: 1.2,
|
||||
baseAlpha: 0.8,
|
||||
baseColor: color,
|
||||
focusFactor: 1,
|
||||
cameraDistanceM: limb * 0.4,
|
||||
cameraHeightM,
|
||||
params,
|
||||
});
|
||||
assert.equal(nearResult.scaleWrites, 0);
|
||||
assert.equal(nearResult.alphaWrites, 0);
|
||||
|
||||
const far = { scale: 1.2, color: { alpha: 0.8 } };
|
||||
const farResult = applyAircraftBillboardTreatment({
|
||||
billboard: far,
|
||||
baseScale: 1.2,
|
||||
baseAlpha: 0.8,
|
||||
baseColor: color,
|
||||
focusFactor: 0.5,
|
||||
cameraDistanceM: limb,
|
||||
cameraHeightM,
|
||||
params,
|
||||
});
|
||||
assert.equal(farResult.scaleWrites, 1);
|
||||
assert.equal(farResult.alphaWrites, 1);
|
||||
assert.ok(Math.abs(far.scale - (1.2 * 0.45)) < 1e-12);
|
||||
assert.ok(Math.abs(far.color.alpha - (0.8 * 0.20)) < 1e-12);
|
||||
});
|
||||
|
||||
test('combined focus and haze product is clamped at the composed alpha floor', () => {
|
||||
const color = { withAlpha: (alpha) => ({ alpha }) };
|
||||
const billboard = { scale: 0.45, color: { alpha: 1 } };
|
||||
const result = applyAircraftBillboardTreatment({
|
||||
billboard,
|
||||
baseScale: 1,
|
||||
const alpha = aircraftTreatmentAlpha({
|
||||
baseAlpha: 1,
|
||||
baseColor: color,
|
||||
focusFactor: 0.25,
|
||||
cameraDistanceM: limb,
|
||||
cameraHeightM,
|
||||
params,
|
||||
});
|
||||
assert.equal(result.alpha, 0.20);
|
||||
assert.equal(billboard.color.alpha, 0.20);
|
||||
});
|
||||
|
||||
test('applyAircraftBillboardTreatment returns its documented module singleton', () => {
|
||||
const color = { withAlpha: (alpha) => ({ alpha }) };
|
||||
const billboard = { scale: 1, color: { alpha: 1 } };
|
||||
const input = {
|
||||
billboard,
|
||||
baseAlpha: 1,
|
||||
baseColor: color,
|
||||
focusFactor: 1,
|
||||
cameraDistanceM: 0,
|
||||
cameraHeightM,
|
||||
params,
|
||||
};
|
||||
const first = applyAircraftBillboardTreatment({ ...input, baseScale: 1 });
|
||||
const second = applyAircraftBillboardTreatment({ ...input, baseScale: 0.5 });
|
||||
assert.strictEqual(first, second);
|
||||
assert.equal(first.scale, 0.5, 'the prior reference reflects the next call');
|
||||
assert.equal(alpha, 0.2);
|
||||
});
|
||||
|
||||
test('ambient model presentation receives composed alpha without changing blend semantics', () => {
|
||||
|
||||
+560
-296
File diff suppressed because it is too large.
Load diff
@@ -3,6 +3,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
|
||||
import alprCamerasLayer, {
|
||||
createAlprCamerasLayer,
|
||||
configureAlprSource,
|
||||
@@ -31,6 +32,8 @@ import {
|
||||
isAlprSurveillanceType,
|
||||
normalizeAlprNode,
|
||||
} from './alprCameras.js';
|
||||
import { readEntityRecord } from './entityRecords.js';
|
||||
import { alprMarkers } from '../layers/alpr/markerState.js';
|
||||
|
||||
test('normalizeAlprNode: maps a full OSM node to a plain record', () => {
|
||||
const record = normalizeAlprNode({
|
||||
@@ -340,13 +343,21 @@ function cameraHarness(layer = alprCamerasLayer) {
|
||||
remove() {},
|
||||
},
|
||||
};
|
||||
bindFakeCesiumCamera(viewer);
|
||||
// The renderer's camera port relays moveEnd; the layer's own come on top.
|
||||
const portMoveEndListeners = viewer.camera.moveEnd.numberOfListeners;
|
||||
const setInput = Cesium.ScreenSpaceEventHandler.prototype.setInputAction;
|
||||
Cesium.ScreenSpaceEventHandler.prototype.setInputAction = function (
|
||||
fn,
|
||||
type,
|
||||
modifier,
|
||||
) {
|
||||
if (type === Cesium.ScreenSpaceEventType.LEFT_CLICK) click = fn;
|
||||
// The unmodified click: the input port binds one per modifier.
|
||||
if (
|
||||
type === Cesium.ScreenSpaceEventType.LEFT_CLICK &&
|
||||
modifier === undefined
|
||||
)
|
||||
click = fn;
|
||||
return setInput.call(this, fn, type, modifier);
|
||||
};
|
||||
try {
|
||||
@@ -357,6 +368,7 @@ function cameraHarness(layer = alprCamerasLayer) {
|
||||
layer.enable();
|
||||
return {
|
||||
viewer,
|
||||
portMoveEndListeners,
|
||||
requests,
|
||||
credits,
|
||||
get source() {
|
||||
@@ -443,25 +455,25 @@ test('all manufacturers share one ALPR title and color; only supplied metadata a
|
||||
const entities = h.source.entities.values;
|
||||
assert.equal(alprCamerasLayer.name, 'ALPR Cameras');
|
||||
assert.deepEqual(
|
||||
entities.map((entity) => entity.gevLabelModel.title),
|
||||
entities.map((entity) => readEntityRecord(entity)?.labelModel.title),
|
||||
['ALPR-0042', 'ALPR-0043', 'ALPR-0044'],
|
||||
);
|
||||
assert.equal(
|
||||
new Set(entities.map((entity) => entity.billboard.image.getValue())).size,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(entities[0].gevLabelModel.details, [
|
||||
assert.deepEqual(readEntityRecord(entities[0])?.labelModel.details, [
|
||||
'OSM MAPPED',
|
||||
'FLOCK SAFETY · CITY POLICE · FIXED',
|
||||
'PUBLIC MAP DATA',
|
||||
]);
|
||||
assert.deepEqual(entities[1].gevLabelModel.details, [
|
||||
assert.deepEqual(readEntityRecord(entities[1])?.labelModel.details, [
|
||||
'OSM MAPPED',
|
||||
'MOTOROLA SOLUTIONS',
|
||||
'PUBLIC MAP DATA',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
entities[2].gevLabelModel.details,
|
||||
readEntityRecord(entities[2])?.labelModel.details,
|
||||
['OSM MAPPED', 'PUBLIC MAP DATA'],
|
||||
'unknown metadata is not guessed, but the source is always named',
|
||||
);
|
||||
@@ -813,7 +825,7 @@ test('selected readout samples the rendered ground, not zero elevation or the di
|
||||
h.click('alpr:42');
|
||||
const entity = h.source.entities.getById('alpr:42');
|
||||
const anchor = Cesium.Cartographic.fromCartesian(
|
||||
entity.gevDisplayPosition(),
|
||||
readEntityRecord(entity)?.displayPosition(),
|
||||
);
|
||||
assert.ok(Math.abs(anchor.height - 187) < 0.001);
|
||||
assert.ok(
|
||||
@@ -821,7 +833,7 @@ test('selected readout samples the rendered ground, not zero elevation or the di
|
||||
);
|
||||
for (let i = 0; i < 60; i++) {
|
||||
h.viewer.scene.postRender.raiseEvent();
|
||||
entity.gevDisplayPosition();
|
||||
readEntityRecord(entity)?.displayPosition();
|
||||
}
|
||||
assert.equal(samples, 1, 'no per-frame raycasts');
|
||||
h.advance(1000);
|
||||
@@ -921,11 +933,14 @@ test('two factories keep their requests, records and destruction independent', a
|
||||
assert.equal(b.source.entities.values[0].id, 'directory:99');
|
||||
assert.equal(second.source, 'Test camera directory');
|
||||
assert.ok(
|
||||
b.source.entities.values[0].gevLabelModel.details.includes(
|
||||
'Source: Test directory',
|
||||
),
|
||||
readEntityRecord(
|
||||
b.source.entities.values[0],
|
||||
)?.labelModel.details.includes('Source: Test directory'),
|
||||
);
|
||||
assert.equal(
|
||||
a.viewer.camera.moveEnd.numberOfListeners,
|
||||
a.portMoveEndListeners,
|
||||
);
|
||||
assert.equal(a.viewer.camera.moveEnd.numberOfListeners, 0);
|
||||
assert.equal(a.viewer.scene.postRender.numberOfListeners, 0);
|
||||
} finally {
|
||||
b.restore();
|
||||
@@ -1444,12 +1459,12 @@ test('map-source changes reposition all ALPR entities and saved native appearanc
|
||||
await layer.update();
|
||||
const entities = [...h.source.entities.values];
|
||||
const saved = entities[1];
|
||||
saved.gevAlprNativeAppearance = {
|
||||
alprMarkers.write(saved).nativeAppearance = {
|
||||
position: saved.position,
|
||||
heightReference: saved.billboard.heightReference,
|
||||
};
|
||||
saved.position = Cesium.Cartesian3.fromDegrees(-97.7431, 30.26721, 190);
|
||||
saved.gevAlprPickPosition = saved.position;
|
||||
alprMarkers.write(saved).pickPosition = saved.position;
|
||||
for (const next of [150, 120, null, 180]) {
|
||||
floor = next;
|
||||
// Includes a provider switch with the same globe.show value.
|
||||
@@ -1470,10 +1485,10 @@ test('map-source changes reposition all ALPR entities and saved native appearanc
|
||||
? Cesium.HeightReference.CLAMP_TO_GROUND
|
||||
: Cesium.HeightReference.NONE,
|
||||
);
|
||||
assert.equal(entity.gevAlprCanvasPosition, null);
|
||||
assert.equal(alprMarkers.read(entity)?.canvasPosition, null);
|
||||
}
|
||||
assert.equal(
|
||||
saved.gevAlprNativeAppearance,
|
||||
alprMarkers.read(saved)?.nativeAppearance,
|
||||
null,
|
||||
'saved appearance restores at the new floor',
|
||||
);
|
||||
|
||||
+36
-40
@@ -28,6 +28,9 @@ import { createImagerySplit } from '../ui/imagerySplit.js';
|
||||
import { formatDate, t, tn, msg, onLocaleChange } from '../i18n/index.js';
|
||||
import { useBhoteKoshiPanelStore } from '../ui/stores/bhoteKoshiPanel.js';
|
||||
import { applicationPinia } from '../ui/vueRuntime.js';
|
||||
import { heightsFor } from '../queries/heights.js';
|
||||
import { cameraOf } from '../render/cesium/camera.js';
|
||||
import { findCamera } from '../render/rendererOf.js';
|
||||
|
||||
export const BHOTE_KOSHI_LAYER_ID = 'bhote-koshi-2026';
|
||||
export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses';
|
||||
@@ -122,20 +125,11 @@ const DEFAULT_EVENT_LOADER = async () => {
|
||||
const DEFAULT_IMAGERY_PROVIDER_FACTORY = (url, options) =>
|
||||
Cesium.SingleTileImageryProvider.fromUrl(url, options);
|
||||
const DEFAULT_TERRAIN_SAMPLER = async (viewer, observations) => {
|
||||
if (
|
||||
!viewer?.terrainProvider ||
|
||||
!viewer.terrainProvider.availability ||
|
||||
typeof Cesium.sampleTerrainMostDetailed !== 'function'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const cartographics = observations.map((observation) =>
|
||||
Cesium.Cartographic.fromDegrees(observation.lon, observation.lat),
|
||||
);
|
||||
return Cesium.sampleTerrainMostDetailed(
|
||||
viewer.terrainProvider,
|
||||
cartographics,
|
||||
);
|
||||
if (!viewer?.scene || !viewer.terrainProvider?.availability) return null;
|
||||
const heights = await heightsFor(viewer.scene).batch(observations, {
|
||||
against: 'terrain',
|
||||
});
|
||||
return heights.map((height) => ({ height }));
|
||||
};
|
||||
const DEFAULT_MEDIA_LOADER = async (url, { signal } = {}) => {
|
||||
if (typeof createImageBitmap !== 'function') {
|
||||
@@ -896,20 +890,24 @@ function focusView(viewer, viewpoint) {
|
||||
viewpoint.lat,
|
||||
viewpoint.targetElevationM,
|
||||
);
|
||||
const offset = new Cesium.HeadingPitchRange(
|
||||
Cesium.Math.toRadians(viewpoint.headingDeg),
|
||||
Cesium.Math.toRadians(viewpoint.pitchDeg),
|
||||
viewpoint.rangeM,
|
||||
);
|
||||
viewer.camera.cancelFlight?.();
|
||||
viewer.camera.flyToBoundingSphere(new Cesium.BoundingSphere(target, 80), {
|
||||
offset,
|
||||
duration: 2.2,
|
||||
complete: () => {
|
||||
viewer.camera.lookAt(target, offset);
|
||||
viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
const offset = {
|
||||
heading: Cesium.Math.toRadians(viewpoint.headingDeg),
|
||||
pitch: Cesium.Math.toRadians(viewpoint.pitchDeg),
|
||||
range: viewpoint.rangeM,
|
||||
};
|
||||
const camera = cameraOf(viewer);
|
||||
camera.cancelFlight();
|
||||
camera.flyToBounds(
|
||||
{ center: target, radius: 80 },
|
||||
{
|
||||
offset,
|
||||
durationS: 2.2,
|
||||
onComplete: () => {
|
||||
camera.lookAt(target, offset);
|
||||
camera.release();
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
function setElementProperty(element, property, value) {
|
||||
@@ -1053,7 +1051,7 @@ export function createBhoteKoshiEventLayer({
|
||||
let _cinematicActive = false;
|
||||
let _cinematicReplayArmed = false;
|
||||
let _cinematicPose = { target: new Cesium.Cartesian3() };
|
||||
let _cinematicOffset = new Cesium.HeadingPitchRange();
|
||||
const _cinematicOffset = { heading: 0, pitch: 0, range: 0 };
|
||||
let _lastCinematicProgress = null;
|
||||
let _cameraInputTarget = null;
|
||||
let _evidenceClickTarget = null;
|
||||
@@ -1197,7 +1195,8 @@ export function createBhoteKoshiEventLayer({
|
||||
}
|
||||
throwIfEnableCancelled(signal, _enabled);
|
||||
return observations.map((observation, index) => {
|
||||
const sampledHeight = Number(sampled?.[index]?.height);
|
||||
// A miss is null or undefined, never sea level.
|
||||
const sampledHeight = Number(sampled?.[index]?.height ?? Number.NaN);
|
||||
const fallbackHeight = Number(observation.fallbackElevationM) || 0;
|
||||
return Cesium.Cartesian3.fromDegrees(
|
||||
observation.lon,
|
||||
@@ -1573,14 +1572,12 @@ export function createBhoteKoshiEventLayer({
|
||||
// a single high sample can make the front dive backward in screen space.
|
||||
if (_sceneControls.evidencePathElevation !== 'source') {
|
||||
// Sample once while preparing the shot, never from an animation frame.
|
||||
try {
|
||||
height = _viewer?.scene?.sampleHeight?.(
|
||||
position,
|
||||
[_floodCore, _floodHalo, _surgePoint].filter(Boolean),
|
||||
);
|
||||
} catch {}
|
||||
const heights = _viewer?.scene ? heightsFor(_viewer.scene) : null;
|
||||
height = heights?.now(position, {
|
||||
exclude: [_floodCore, _floodHalo, _surgePoint].filter(Boolean),
|
||||
});
|
||||
if (!Number.isFinite(height))
|
||||
height = _viewer?.scene?.globe?.getHeight?.(position);
|
||||
height = heights?.now(position, { against: 'terrain' });
|
||||
}
|
||||
if (!Number.isFinite(height)) height = point.fallbackElevationM;
|
||||
return Cesium.Cartesian3.fromDegrees(point.lon, point.lat, height + 8);
|
||||
@@ -2635,7 +2632,7 @@ export function createBhoteKoshiEventLayer({
|
||||
_cinematicOffset.heading = Cesium.Math.toRadians(pose.headingDeg);
|
||||
_cinematicOffset.pitch = Cesium.Math.toRadians(pose.pitchDeg);
|
||||
_cinematicOffset.range = pose.rangeM;
|
||||
_viewer.camera.lookAt(pose.target, _cinematicOffset);
|
||||
findCamera(_viewer)?.lookAt(pose.target, _cinematicOffset);
|
||||
_lastCinematicProgress = _progress;
|
||||
}
|
||||
|
||||
@@ -2705,7 +2702,7 @@ export function createBhoteKoshiEventLayer({
|
||||
_cinematicActive = true;
|
||||
_lastCinematicProgress = null;
|
||||
announceNavigationAuthority('bhote-koshi-cinematic');
|
||||
_viewer?.camera?.cancelFlight?.();
|
||||
findCamera(_viewer)?.cancelFlight();
|
||||
}
|
||||
syncCinematicButton();
|
||||
return true;
|
||||
@@ -2722,8 +2719,7 @@ export function createBhoteKoshiEventLayer({
|
||||
}
|
||||
_cinematicActive = false;
|
||||
_lastCinematicProgress = null;
|
||||
if (releaseCamera)
|
||||
_viewer?.camera?.lookAtTransform?.(Cesium.Matrix4.IDENTITY);
|
||||
if (releaseCamera) findCamera(_viewer)?.release();
|
||||
syncCinematicButton();
|
||||
renderHost.request('bhote-koshi-cinematic-stop');
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
@@ -239,7 +240,7 @@ function eventViewer(splitPosition = 0.37) {
|
||||
const canvas = new FakeElement('canvas');
|
||||
const cameraLooks = [];
|
||||
const cameraTransforms = [];
|
||||
return {
|
||||
return bindFakeCesiumCamera({
|
||||
scene: { splitPosition, canvas },
|
||||
camera: {
|
||||
cancelFlight() {},
|
||||
@@ -272,7 +273,7 @@ function eventViewer(splitPosition = 0.37) {
|
||||
cameraLooks,
|
||||
cameraTransforms,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
test('public lifecycle restores Nepal scene ownership without forwarding origin to enable', async () => {
|
||||
@@ -2992,3 +2993,43 @@ test('Bhote Koshi corridor remains ordered downstream and inside the imagery rec
|
||||
priorChainage = point.chainageM;
|
||||
}
|
||||
});
|
||||
|
||||
test('an evidence point whose terrain sample misses sits at its fallback elevation, not sea level', async () => {
|
||||
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
|
||||
const originalDocument = globalThis.document;
|
||||
const originalWindow = globalThis.window;
|
||||
installEventDom();
|
||||
globalThis.window = { setTimeout: () => 71, clearTimeout() {} };
|
||||
const viewer = eventViewer();
|
||||
const publications = [];
|
||||
const layer = createBhoteKoshiEventLayer({
|
||||
eventLoader: async () => event,
|
||||
imageryProviderFactory: async (url) => ({ url }),
|
||||
terrainSampler: async (_viewer, observations) => observations.map(() => ({ height: null })),
|
||||
mediaLoader: async () => {
|
||||
const error = new Error('fixture media intentionally unavailable');
|
||||
error.name = 'AbortError';
|
||||
throw error;
|
||||
},
|
||||
overlayHost: { setEntries(_source, entries) { publications.push(entries); }, setVisible() {}, clearSource() {} },
|
||||
renderHost: { request() {}, hold() {}, release() {} },
|
||||
});
|
||||
try {
|
||||
layer.setParams({
|
||||
presentation: 'scene-beat',
|
||||
sceneSurface: 'evidence-beat',
|
||||
beatId: 'gyirong-border-gate',
|
||||
}, { origin: 'scene' });
|
||||
await layer.enable(viewer, { origin: 'scene' });
|
||||
const card = publications.at(-1).find((entry) => entry.id === 'evidence-card-gyirong-border-gate');
|
||||
const fallback = event.evidenceSpine.find(({ id }) => id === 'gyirong-border-gate').fallbackElevationM;
|
||||
assert.ok(fallback > 100, 'the fixture has a real fallback elevation');
|
||||
const height = Cesium.Cartographic.fromCartesian(card.position()).height;
|
||||
assert.ok(Math.abs(height - (fallback + 18)) < 1, `card at ${height} m`);
|
||||
await layer.disable();
|
||||
} finally {
|
||||
await layer.destroy();
|
||||
globalThis.document = originalDocument;
|
||||
globalThis.window = originalWindow;
|
||||
}
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
setOverlayEntries,
|
||||
} from '../overlays/worldOverlay.js';
|
||||
import { governorRequestRender } from '../renderGovernor.js';
|
||||
import { cameraOf } from '../render/cesium/camera.js';
|
||||
import {
|
||||
BHOTE_KOSHI_LOCATOR_CREDIT,
|
||||
registerDynamicCredit,
|
||||
@@ -620,6 +621,7 @@ export function createBhoteKoshiLocatorLayer({
|
||||
requestRender('bhote-koshi-trigger-record-reveal');
|
||||
const camera = _viewer?.camera;
|
||||
if (!camera?.flyTo) return;
|
||||
const port = cameraOf(_viewer);
|
||||
const generation = _generation;
|
||||
_triggerApproachInFlight = true;
|
||||
const finish = () => {
|
||||
@@ -627,50 +629,54 @@ export function createBhoteKoshiLocatorLayer({
|
||||
attempt.phase = 'finished';
|
||||
_triggerApproachInFlight = false;
|
||||
};
|
||||
camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromDegrees(
|
||||
TRIGGER_APPROACH_CAMERA.lon,
|
||||
TRIGGER_APPROACH_CAMERA.lat,
|
||||
TRIGGER_APPROACH_CAMERA.alt,
|
||||
),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.heading),
|
||||
pitch: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.pitch),
|
||||
roll: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.roll),
|
||||
port.flyTo(
|
||||
{
|
||||
lon: TRIGGER_APPROACH_CAMERA.lon,
|
||||
lat: TRIGGER_APPROACH_CAMERA.lat,
|
||||
height: TRIGGER_APPROACH_CAMERA.alt,
|
||||
},
|
||||
duration: TRIGGER_APPROACH_DURATION_SEC,
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
complete: () => {
|
||||
if (_triggerApproachAttempt !== attempt || attempt.phase !== 'flying')
|
||||
return;
|
||||
if (
|
||||
!_enabled ||
|
||||
generation !== _generation ||
|
||||
_presentation !== BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION
|
||||
) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
attempt.phase = 'orbiting';
|
||||
camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromDegrees(
|
||||
TRIGGER_ORBIT_CAMERA.lon,
|
||||
TRIGGER_ORBIT_CAMERA.lat,
|
||||
TRIGGER_ORBIT_CAMERA.alt,
|
||||
),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.heading),
|
||||
pitch: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.pitch),
|
||||
roll: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.roll),
|
||||
},
|
||||
duration: TRIGGER_ORBIT_DURATION_SEC,
|
||||
easingFunction: Cesium.EasingFunction.SINUSOIDAL_IN_OUT,
|
||||
complete: finish,
|
||||
cancel: finish,
|
||||
});
|
||||
{
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.heading),
|
||||
pitch: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.pitch),
|
||||
roll: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.roll),
|
||||
},
|
||||
durationS: TRIGGER_APPROACH_DURATION_SEC,
|
||||
easing: 'cubicInOut',
|
||||
onComplete: () => {
|
||||
if (_triggerApproachAttempt !== attempt || attempt.phase !== 'flying')
|
||||
return;
|
||||
if (
|
||||
!_enabled ||
|
||||
generation !== _generation ||
|
||||
_presentation !== BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION
|
||||
) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
attempt.phase = 'orbiting';
|
||||
port.flyTo(
|
||||
{
|
||||
lon: TRIGGER_ORBIT_CAMERA.lon,
|
||||
lat: TRIGGER_ORBIT_CAMERA.lat,
|
||||
height: TRIGGER_ORBIT_CAMERA.alt,
|
||||
},
|
||||
{
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.heading),
|
||||
pitch: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.pitch),
|
||||
roll: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.roll),
|
||||
},
|
||||
durationS: TRIGGER_ORBIT_DURATION_SEC,
|
||||
easing: 'sineInOut',
|
||||
onComplete: finish,
|
||||
onCancel: finish,
|
||||
},
|
||||
);
|
||||
},
|
||||
onCancel: finish,
|
||||
},
|
||||
cancel: finish,
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
function armTriggerApproach() {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
@@ -647,6 +648,7 @@ test('Bhote Koshi trigger-record waits for the scene camera, zooms, then orbits'
|
||||
},
|
||||
cancelFlight() {},
|
||||
};
|
||||
bindFakeCesiumCamera(viewer);
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => null,
|
||||
requestRender() {},
|
||||
@@ -703,6 +705,9 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
|
||||
},
|
||||
cancelFlight() {},
|
||||
};
|
||||
bindFakeCesiumCamera(viewer);
|
||||
// The renderer's camera port relays these; the layer's own come on top.
|
||||
const portListeners = [moveStart, moveEnd].map((e) => e.numberOfListeners);
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => null,
|
||||
overlayHost: overlayHostFixture(),
|
||||
@@ -745,8 +750,10 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
|
||||
const oldFlights = [...flights];
|
||||
SceneDirector.prototype.stopScene.call(director);
|
||||
assert.equal(timers.size, 0, 'Stop cancels the actual delayed work');
|
||||
assert.equal(moveStart.numberOfListeners, 0);
|
||||
assert.equal(moveEnd.numberOfListeners, 0);
|
||||
assert.deepEqual(
|
||||
[moveStart, moveEnd].map((e) => e.numberOfListeners),
|
||||
portListeners,
|
||||
);
|
||||
callbacks[0]();
|
||||
moveEnd.raiseEvent();
|
||||
for (const flight of oldFlights) flight.complete();
|
||||
|
||||
@@ -65,6 +65,7 @@ export const materializeCctvVisibleCoverageEntities =
|
||||
export const _extractPickedCameraIdForTest =
|
||||
layer._extractPickedCameraIdForTest;
|
||||
export const _setCctvCoverageStateForTest = layer._setCctvCoverageStateForTest;
|
||||
export const _cctvIconsForTest = layer._cctvIconsForTest;
|
||||
export const focusCctvRecord = layer.focusCctvRecord;
|
||||
export const maybeAutoHop = layer.maybeAutoHop;
|
||||
export const cctvCycleIndex = layer.cctvCycleIndex;
|
||||
|
||||
+48
-61
@@ -13,6 +13,7 @@
|
||||
//
|
||||
// computeFrustumGeometry is PURE (no viewer, no scene queries) so it runs under
|
||||
// plain node:test.
|
||||
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
|
||||
import { test } from 'node:test';
|
||||
// Preserve structural pins across component qualification and formatter wrapping.
|
||||
function componentFunctionSource(fn) {
|
||||
@@ -32,6 +33,7 @@ import cctvLayer, {
|
||||
_extractPickedCameraIdForTest,
|
||||
_updateCctvProjectionPlaneForTest,
|
||||
_setCctvCoverageStateForTest,
|
||||
_cctvIconsForTest,
|
||||
_pushAmbientCardEntriesForTest,
|
||||
_setCctvOverlayHostForTest,
|
||||
activationProbeClampRange,
|
||||
@@ -79,27 +81,12 @@ import {
|
||||
IDLE_CAMERA_COLOR,
|
||||
LIVE_CAMERA_COLOR,
|
||||
} from '../layers/cctv/policy.js';
|
||||
import { cctvPicks } from '../layers/cctv/pickState.js';
|
||||
import { FAKE_EVENT_TYPES, fakeInput } from '../input/testDoubles.mjs';
|
||||
|
||||
const ASPECT = 16 / 9;
|
||||
const toRad = (deg) => (deg * Math.PI) / 180;
|
||||
const GESTURE_TYPES = {
|
||||
LEFT_DOWN: 'left-down',
|
||||
MOUSE_MOVE: 'mouse-move',
|
||||
LEFT_UP: 'left-up',
|
||||
LEFT_CLICK: 'left-click',
|
||||
};
|
||||
|
||||
function makeGestureHandler() {
|
||||
const actions = new Map();
|
||||
return {
|
||||
setInputAction(callback, type) {
|
||||
actions.set(type, callback);
|
||||
},
|
||||
fire(type, event) {
|
||||
actions.get(type)?.(event);
|
||||
},
|
||||
};
|
||||
}
|
||||
const GESTURE_TYPES = FAKE_EVENT_TYPES;
|
||||
// Same spherical earth radius projectPoint uses (R = 6371 km) so the
|
||||
// lat/lon→metres conversion in the assertions matches the module's math.
|
||||
const M_PER_DEG = (Math.PI / 180) * 6371000;
|
||||
@@ -355,7 +342,6 @@ test('default COVERAGE refresh materializes the active and visible camera frustu
|
||||
test('camera icons are violet for video feeds, cyan for stills, amber when active', () => {
|
||||
const record = (id, feedType) => ({
|
||||
camera: { ...UNCLAMPED_CAMERA, id, feedType, groundElevationM: 0 },
|
||||
billboard: {},
|
||||
coverageEntities: [],
|
||||
projection: null,
|
||||
});
|
||||
@@ -375,9 +361,15 @@ test('camera icons are violet for video feeds, cyan for stills, amber when activ
|
||||
coverageMode: 'off',
|
||||
});
|
||||
refreshCoverageStyles();
|
||||
assert.equal(idleVideo.billboard.color, LIVE_CAMERA_COLOR);
|
||||
assert.equal(idleStill.billboard.color, IDLE_CAMERA_COLOR);
|
||||
assert.equal(activeVideo.billboard.color, ACTIVE_CAMERA_COLOR);
|
||||
const icons = _cctvIconsForTest();
|
||||
const color = (record) => icons.color(icons.row(record.camera.id));
|
||||
const bytes = (c) =>
|
||||
[c.red, c.green, c.blue, c.alpha].map(Cesium.Color.floatToByte);
|
||||
assert.deepEqual(bytes(color(idleVideo)), bytes(LIVE_CAMERA_COLOR));
|
||||
assert.deepEqual(bytes(color(idleStill)), bytes(IDLE_CAMERA_COLOR));
|
||||
assert.deepEqual(bytes(color(activeVideo)), bytes(ACTIVE_CAMERA_COLOR));
|
||||
assert.equal(icons.scale(icons.row(activeVideo.camera.id)), 1.25);
|
||||
assert.equal(icons.scale(icons.row(idleVideo.camera.id)), 1);
|
||||
} finally {
|
||||
_setCctvCoverageStateForTest({ enabled: false });
|
||||
}
|
||||
@@ -386,7 +378,7 @@ test('camera icons are violet for video feeds, cyan for stills, amber when activ
|
||||
test('geometry drain pacing yields to tracked and cockpit camera ownership', () => {
|
||||
assert.deepEqual(cctvGeometryDrainPacing(), { batchSize: 4, delayMs: 120 });
|
||||
assert.deepEqual(
|
||||
cctvGeometryDrainPacing({ trackedEntity: { id: 'flight-1' } }),
|
||||
cctvGeometryDrainPacing({ followed: { key: 'flights:1' } }),
|
||||
{ batchSize: 2, delayMs: 250 },
|
||||
);
|
||||
assert.deepEqual(cctvGeometryDrainPacing({ cockpitActive: true }), {
|
||||
@@ -401,13 +393,13 @@ test('geometry drain pacing yields to tracked and cockpit camera ownership', ()
|
||||
});
|
||||
|
||||
test('geometry drain rechecks pacing when tracking releases between batches', () => {
|
||||
let trackedEntity = { id: 'flight-1' };
|
||||
let followed = { key: 'flights:1' };
|
||||
const queue = Array.from({ length: 10 }, (_, index) => index + 1);
|
||||
const visited = [];
|
||||
const runBatch = () =>
|
||||
processCctvGeometryDrainBatch({
|
||||
queue,
|
||||
readOwnership: () => ({ trackedEntity, cockpitActive: false }),
|
||||
readOwnership: () => ({ followed, cockpitActive: false }),
|
||||
visit: (record) => visited.push(record),
|
||||
progress: () => {},
|
||||
complete: () => {},
|
||||
@@ -417,7 +409,7 @@ test('geometry drain rechecks pacing when tracking releases between batches', ()
|
||||
assert.deepEqual(trackedBatch, { hasMore: true, batchSize: 2, delayMs: 250 });
|
||||
assert.deepEqual(visited, [1, 2]);
|
||||
|
||||
trackedEntity = null;
|
||||
followed = null;
|
||||
const untrackedBatch = runBatch();
|
||||
assert.deepEqual(untrackedBatch, {
|
||||
hasMore: true,
|
||||
@@ -682,7 +674,6 @@ test('CCTV disable hide sweep hides record visuals and destroys viewsheds withou
|
||||
camera: { id: 'a' },
|
||||
activationDone: true,
|
||||
probeClampRangeM: 105,
|
||||
billboard: { color: 'keep-color', scale: 1.25 },
|
||||
coverageEntities: [{ show: true }, { show: true }],
|
||||
projection: { planeEntity: { show: true } },
|
||||
viewshedPrimitive: viewshedA,
|
||||
@@ -691,7 +682,6 @@ test('CCTV disable hide sweep hides record visuals and destroys viewsheds withou
|
||||
camera: { id: 'b' },
|
||||
activationDone: true,
|
||||
probeClampRangeM: 146,
|
||||
billboard: { color: 'keep-color', scale: 1 },
|
||||
projection: null,
|
||||
viewshedPrimitive: viewshedB,
|
||||
},
|
||||
@@ -725,13 +715,6 @@ test('CCTV disable hide sweep hides record visuals and destroys viewsheds withou
|
||||
records.map((record) => record.activationDone),
|
||||
[false, true],
|
||||
);
|
||||
assert.deepEqual(
|
||||
records.map((record) => record.billboard),
|
||||
[
|
||||
{ color: 'keep-color', scale: 1.25 },
|
||||
{ color: 'keep-color', scale: 1 },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('real active monitor plane owns one protected host label and no native label entity', () => {
|
||||
@@ -886,6 +869,7 @@ test('CCTV focus distinguishes tracking ownership from a missing active camera',
|
||||
},
|
||||
},
|
||||
};
|
||||
bindFakeCesiumCamera(viewer);
|
||||
const record = {
|
||||
camera: { rangeM: 210, headingDeg: 41 },
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
@@ -917,6 +901,7 @@ test('CCTV focus reports when the camera flight starts', () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
bindFakeCesiumCamera(viewer);
|
||||
const record = {
|
||||
camera: { rangeM: 210, headingDeg: 41 },
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
@@ -937,6 +922,7 @@ test('CCTV focus refuses camera flights while cockpit owns the view', () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
bindFakeCesiumCamera(viewer);
|
||||
const record = {
|
||||
camera: { rangeM: 210, headingDeg: 41 },
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
@@ -989,7 +975,7 @@ test('CCTV repeated in-world clicks dispatch focus only for the one real activat
|
||||
assert.deepEqual(requests, [{ cameraId: 'atx-cam-3' }]);
|
||||
assert.match(
|
||||
componentFunctionSource(cctvLayer.init),
|
||||
/bindCctvWorldClickGesture\(_clickHandler/,
|
||||
/bindCctvWorldClickGesture\(\s*input,/,
|
||||
);
|
||||
assert.match(
|
||||
componentFunctionSource(cctvLayer.init),
|
||||
@@ -1128,14 +1114,12 @@ test('CCTV empty-click gate excludes every identified scene object, ADJUST, and
|
||||
});
|
||||
|
||||
test('CCTV camera extraction requires layer ownership, not a colliding sibling ID', () => {
|
||||
const billboard = { id: 'shared-id' };
|
||||
const ownedCoverageEntity = {
|
||||
id: 'cctv-shared-id-cap',
|
||||
properties: { cctvCameraId: 'shared-id' },
|
||||
};
|
||||
const ownedCoverageEntity = { id: 'cctv-shared-id-cap' };
|
||||
cctvPicks.write(ownedCoverageEntity).cameraId = 'shared-id';
|
||||
const strayEntity = { id: 'cctv-shared-id-stray' };
|
||||
cctvPicks.write(strayEntity).cameraId = 'shared-id';
|
||||
const record = {
|
||||
...makeDeselectRecord('shared-id'),
|
||||
billboard,
|
||||
coverageEntities: [ownedCoverageEntity],
|
||||
};
|
||||
_setCctvCoverageStateForTest({
|
||||
@@ -1144,12 +1128,17 @@ test('CCTV camera extraction requires layer ownership, not a colliding sibling I
|
||||
});
|
||||
try {
|
||||
assert.equal(
|
||||
_extractPickedCameraIdForTest({ id: 'shared-id', primitive: billboard }),
|
||||
_extractPickedCameraIdForTest({
|
||||
id: 'shared-id',
|
||||
iconSet: _cctvIconsForTest(),
|
||||
primitive: { id: 'shared-id' },
|
||||
}),
|
||||
'shared-id',
|
||||
);
|
||||
assert.equal(
|
||||
_extractPickedCameraIdForTest({
|
||||
id: 'shared-id',
|
||||
iconSet: {},
|
||||
primitive: { id: 'shared-id' },
|
||||
}),
|
||||
null,
|
||||
@@ -1175,6 +1164,11 @@ test('CCTV camera extraction requires layer ownership, not a colliding sibling I
|
||||
null,
|
||||
'a sibling cannot impersonate CCTV by copying its property shape',
|
||||
);
|
||||
assert.equal(
|
||||
_extractPickedCameraIdForTest({ id: strayEntity }),
|
||||
null,
|
||||
'a camera id alone is not ownership: the record must hold the entity',
|
||||
);
|
||||
} finally {
|
||||
_setCctvCoverageStateForTest({ enabled: false });
|
||||
}
|
||||
@@ -1401,29 +1395,22 @@ test('active CCTV camera is absent from host by default and protected only when
|
||||
test('CCTV drag-then-release over a camera is inert, while a clean tap activates and dispatches', () => {
|
||||
let timeMs = 0;
|
||||
let activationCalls = 0;
|
||||
const handler = makeGestureHandler();
|
||||
const { input, handler } = fakeInput({ scene: {} }, { now: () => timeMs });
|
||||
const target = new EventTarget();
|
||||
const requests = [];
|
||||
target.addEventListener(CCTV_FOCUS_REQUEST_EVENT, (event) =>
|
||||
requests.push(event.detail),
|
||||
);
|
||||
bindCctvWorldClickGesture(
|
||||
handler,
|
||||
() => {
|
||||
activateCctvCameraFromWorldClick(
|
||||
'atx-cam-3',
|
||||
() => {
|
||||
activationCalls += 1;
|
||||
return CCTV_ACTIVATION_RESULT.ACTIVATED;
|
||||
},
|
||||
target,
|
||||
);
|
||||
},
|
||||
{
|
||||
now: () => timeMs,
|
||||
eventTypes: GESTURE_TYPES,
|
||||
},
|
||||
);
|
||||
bindCctvWorldClickGesture(input, () => {
|
||||
activateCctvCameraFromWorldClick(
|
||||
'atx-cam-3',
|
||||
() => {
|
||||
activationCalls += 1;
|
||||
return CCTV_ACTIVATION_RESULT.ACTIVATED;
|
||||
},
|
||||
target,
|
||||
);
|
||||
});
|
||||
|
||||
handler.fire(GESTURE_TYPES.LEFT_DOWN, { position: { x: 10, y: 10 } });
|
||||
timeMs = 20;
|
||||
|
||||
+23
-20
@@ -8,7 +8,7 @@
|
||||
* - Pure drag math (this top section): ray↔axis closest-point, ray↔plane
|
||||
* intersection with a grazing-angle guard, ring angles. All unit-tested
|
||||
* under node:test with zero scene dependencies.
|
||||
* - createCalibrationGizmo (below): the entity + ScreenSpaceEventHandler
|
||||
* - createCalibrationGizmo (below): the entity + pointer-input
|
||||
* controller that renders grab-able handles on the ACTIVE camera and turns
|
||||
* drags into calibration patches through a narrow callback interface. It
|
||||
* never touches the layer's records/stores directly.
|
||||
@@ -24,7 +24,7 @@
|
||||
* invariant is untouched.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { isPointerFree } from './inputOwnership.js';
|
||||
import { inputFor } from '../input/dispatcher.js';
|
||||
|
||||
// Grazing guard (spec §5): reject plane intersections when the view ray is
|
||||
// nearly parallel to the constraint plane — the hit point races to infinity
|
||||
@@ -118,8 +118,8 @@ export function signedAngleDelta(fromRad, toRad) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Entity id prefix for every gizmo part — the layer's pick-owner regex and
|
||||
* click-to-select guard key off this. Gizmo entities NEVER carry a
|
||||
* `cctvCameraId` property, so the layer's selection path ignores them. */
|
||||
* click-to-select guard key off this. Gizmo entities NEVER get a camera id in
|
||||
* the layer's pick store, so the layer's selection path ignores them. */
|
||||
export const GIZMO_ID_PREFIX = 'cctv-gizmo-';
|
||||
|
||||
const RING_SEGMENTS = 96;
|
||||
@@ -237,7 +237,8 @@ export function createCalibrationGizmo({
|
||||
const entities = new Map(); // part -> Entity
|
||||
|
||||
const scene = viewer.scene;
|
||||
const handler = new Cesium.ScreenSpaceEventHandler(scene.canvas);
|
||||
const input = inputFor(viewer);
|
||||
let releaseCamera = null;
|
||||
|
||||
/** Adds (or re-adds) a gizmo entity, clearing any stale duplicate id. */
|
||||
function addEntity(part, options) {
|
||||
@@ -533,9 +534,7 @@ export function createCalibrationGizmo({
|
||||
|
||||
drag = state;
|
||||
setCursor('grabbing');
|
||||
if (scene.screenSpaceCameraController) {
|
||||
scene.screenSpaceCameraController.enableInputs = false;
|
||||
}
|
||||
releaseCamera = input.suspendCamera('cctv-gizmo-drag');
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -615,9 +614,8 @@ export function createCalibrationGizmo({
|
||||
if (!drag) return;
|
||||
const record = drag.record;
|
||||
drag = null;
|
||||
if (scene.screenSpaceCameraController) {
|
||||
scene.screenSpaceCameraController.enableInputs = true;
|
||||
}
|
||||
releaseCamera?.();
|
||||
releaseCamera = null;
|
||||
setCursor(hoveredId ? 'grab' : '');
|
||||
endPatch(record);
|
||||
}
|
||||
@@ -629,18 +627,21 @@ export function createCalibrationGizmo({
|
||||
}
|
||||
}
|
||||
|
||||
handler.setInputAction((event) => {
|
||||
// A tool owns the pointer (src/data/inputOwnership.js): yield the press.
|
||||
if (!isPointerFree()) return;
|
||||
// An ambient press: the dispatcher withholds it while a tool owns the pointer
|
||||
// (src/data/inputOwnership.js).
|
||||
const offDown = input.onPointer('down', (event) => {
|
||||
if (!enabled) return;
|
||||
// A release Cesium never reported (one with a modifier key held) leaves
|
||||
// the last drag open; a new press ends it, hold and patch included.
|
||||
if (drag) endDrag();
|
||||
const part = pickGizmoPart(event.position);
|
||||
debugLog('LEFT_DOWN', event.position, 'part:', part);
|
||||
if (!part) return;
|
||||
const started = beginDrag(part, event.position);
|
||||
debugLog('beginDrag', part, '→', started);
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_DOWN);
|
||||
});
|
||||
|
||||
handler.setInputAction((event) => {
|
||||
const offMove = input.onPointer('move', (event) => {
|
||||
if (!enabled) return;
|
||||
const now = Date.now();
|
||||
if (drag) {
|
||||
@@ -667,12 +668,12 @@ export function createCalibrationGizmo({
|
||||
if (now - lastHoverAt < HOVER_THROTTLE_MS) return;
|
||||
lastHoverAt = now;
|
||||
setHovered(pickGizmoPart(event.endPosition));
|
||||
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
|
||||
});
|
||||
|
||||
handler.setInputAction(() => {
|
||||
const offUp = input.onPointer('up', () => {
|
||||
if (!enabled) return;
|
||||
endDrag();
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_UP);
|
||||
});
|
||||
|
||||
return {
|
||||
setEnabled(value) {
|
||||
@@ -688,7 +689,9 @@ export function createCalibrationGizmo({
|
||||
refresh,
|
||||
destroy() {
|
||||
if (drag) endDrag();
|
||||
handler.destroy();
|
||||
offDown();
|
||||
offMove();
|
||||
offUp();
|
||||
for (const entity of entities.values()) {
|
||||
viewer.entities.remove(entity);
|
||||
}
|
||||
|
||||
+140
-15
@@ -8,53 +8,87 @@
|
||||
// - rayPlaneIntersect refuses grazing rays (|dir·normal| < 0.08 — spec §5
|
||||
// precision guard) and behind-origin hits;
|
||||
// - ringAngle/signedAngleDelta give quadrant-correct, wrap-safe angles for
|
||||
// the heading/pitch ring drags.
|
||||
// the heading/pitch ring drags;
|
||||
// - the controller ends an open drag, and its camera hold, on the next press.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
GIZMO_ID_PREFIX,
|
||||
closestParamOnAxis,
|
||||
createCalibrationGizmo,
|
||||
rayPlaneIntersect,
|
||||
ringAngle,
|
||||
signedAngleDelta,
|
||||
} from './cctvGizmo.js';
|
||||
import { FAKE_EVENT_TYPES, fakeInput } from '../input/testDoubles.mjs';
|
||||
|
||||
const c3 = (x, y, z) => new Cesium.Cartesian3(x, y, z);
|
||||
|
||||
test('closestParamOnAxis: perpendicular ray hits the axis at its own offset', () => {
|
||||
// Axis along +X from origin. Ray shooting straight down (-Z) from (10, 0, 5):
|
||||
// nearest axis point is x=10 → t=10.
|
||||
const t = closestParamOnAxis(c3(10, 0, 5), c3(0, 0, -1), c3(0, 0, 0), c3(1, 0, 0));
|
||||
const t = closestParamOnAxis(
|
||||
c3(10, 0, 5),
|
||||
c3(0, 0, -1),
|
||||
c3(0, 0, 0),
|
||||
c3(1, 0, 0),
|
||||
);
|
||||
assert.ok(Math.abs(t - 10) < 1e-9, `expected 10, got ${t}`);
|
||||
});
|
||||
|
||||
test('closestParamOnAxis: skew ray resolves to the geometric closest point', () => {
|
||||
// Axis +X. Ray from (0, 10, 0) toward (1, -1, 0)/√2 passes closest to the
|
||||
// axis around x=10 (it reaches y=0 at x=10).
|
||||
const dir = Cesium.Cartesian3.normalize(c3(1, -1, 0), new Cesium.Cartesian3());
|
||||
const dir = Cesium.Cartesian3.normalize(
|
||||
c3(1, -1, 0),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const t = closestParamOnAxis(c3(0, 10, 0), dir, c3(0, 0, 0), c3(1, 0, 0));
|
||||
assert.ok(Math.abs(t - 10) < 1e-6, `expected ~10, got ${t}`);
|
||||
});
|
||||
|
||||
test('closestParamOnAxis: axis origin offset shifts the parameter', () => {
|
||||
const t = closestParamOnAxis(c3(10, 0, 5), c3(0, 0, -1), c3(4, 0, 0), c3(1, 0, 0));
|
||||
const t = closestParamOnAxis(
|
||||
c3(10, 0, 5),
|
||||
c3(0, 0, -1),
|
||||
c3(4, 0, 0),
|
||||
c3(1, 0, 0),
|
||||
);
|
||||
assert.ok(Math.abs(t - 6) < 1e-9, `expected 6, got ${t}`);
|
||||
});
|
||||
|
||||
test('closestParamOnAxis: near-parallel ray/axis returns null', () => {
|
||||
assert.equal(closestParamOnAxis(c3(0, 1, 0), c3(1, 0, 0), c3(0, 0, 0), c3(1, 0, 0)), null);
|
||||
const nearly = Cesium.Cartesian3.normalize(c3(1, 1e-9, 0), new Cesium.Cartesian3());
|
||||
assert.equal(closestParamOnAxis(c3(0, 1, 0), nearly, c3(0, 0, 0), c3(1, 0, 0)), null);
|
||||
assert.equal(
|
||||
closestParamOnAxis(c3(0, 1, 0), c3(1, 0, 0), c3(0, 0, 0), c3(1, 0, 0)),
|
||||
null,
|
||||
);
|
||||
const nearly = Cesium.Cartesian3.normalize(
|
||||
c3(1, 1e-9, 0),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
assert.equal(
|
||||
closestParamOnAxis(c3(0, 1, 0), nearly, c3(0, 0, 0), c3(1, 0, 0)),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('rayPlaneIntersect: straight-on hit lands at the expected point', () => {
|
||||
const hit = rayPlaneIntersect(c3(0, 0, 10), c3(0, 0, -1), c3(0, 0, 0), c3(0, 0, 1));
|
||||
const hit = rayPlaneIntersect(
|
||||
c3(0, 0, 10),
|
||||
c3(0, 0, -1),
|
||||
c3(0, 0, 0),
|
||||
c3(0, 0, 1),
|
||||
);
|
||||
assert.ok(hit, 'expected a hit');
|
||||
assert.ok(Cesium.Cartesian3.distance(hit, c3(0, 0, 0)) < 1e-9);
|
||||
});
|
||||
|
||||
test('rayPlaneIntersect: oblique hit resolves correctly', () => {
|
||||
const dir = Cesium.Cartesian3.normalize(c3(1, 0, -1), new Cesium.Cartesian3());
|
||||
const dir = Cesium.Cartesian3.normalize(
|
||||
c3(1, 0, -1),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const hit = rayPlaneIntersect(c3(0, 0, 5), dir, c3(0, 0, 0), c3(0, 0, 1));
|
||||
assert.ok(hit);
|
||||
assert.ok(Cesium.Cartesian3.distance(hit, c3(5, 0, 0)) < 1e-9, `got ${hit}`);
|
||||
@@ -62,12 +96,21 @@ test('rayPlaneIntersect: oblique hit resolves correctly', () => {
|
||||
|
||||
test('rayPlaneIntersect: grazing ray (|dir·n| < 0.08) returns null', () => {
|
||||
// dir almost in-plane: z component 0.05 < 0.08 threshold.
|
||||
const dir = Cesium.Cartesian3.normalize(c3(1, 0, -0.05), new Cesium.Cartesian3());
|
||||
assert.equal(rayPlaneIntersect(c3(0, 0, 5), dir, c3(0, 0, 0), c3(0, 0, 1)), null);
|
||||
const dir = Cesium.Cartesian3.normalize(
|
||||
c3(1, 0, -0.05),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
assert.equal(
|
||||
rayPlaneIntersect(c3(0, 0, 5), dir, c3(0, 0, 0), c3(0, 0, 1)),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('rayPlaneIntersect: plane behind the ray origin returns null', () => {
|
||||
assert.equal(rayPlaneIntersect(c3(0, 0, 10), c3(0, 0, 1), c3(0, 0, 0), c3(0, 0, 1)), null);
|
||||
assert.equal(
|
||||
rayPlaneIntersect(c3(0, 0, 10), c3(0, 0, 1), c3(0, 0, 0), c3(0, 0, 1)),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('ringAngle: quadrant sweep in the (basisA, basisB) frame', () => {
|
||||
@@ -75,9 +118,15 @@ test('ringAngle: quadrant sweep in the (basisA, basisB) frame', () => {
|
||||
const a = c3(1, 0, 0);
|
||||
const b = c3(0, 1, 0);
|
||||
assert.ok(Math.abs(ringAngle(c3(5, 0, 0), center, a, b) - 0) < 1e-9);
|
||||
assert.ok(Math.abs(ringAngle(c3(0, 5, 0), center, a, b) - Math.PI / 2) < 1e-9);
|
||||
assert.ok(Math.abs(Math.abs(ringAngle(c3(-5, 0, 0), center, a, b)) - Math.PI) < 1e-9);
|
||||
assert.ok(Math.abs(ringAngle(c3(0, -5, 0), center, a, b) + Math.PI / 2) < 1e-9);
|
||||
assert.ok(
|
||||
Math.abs(ringAngle(c3(0, 5, 0), center, a, b) - Math.PI / 2) < 1e-9,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(Math.abs(ringAngle(c3(-5, 0, 0), center, a, b)) - Math.PI) < 1e-9,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(ringAngle(c3(0, -5, 0), center, a, b) + Math.PI / 2) < 1e-9,
|
||||
);
|
||||
});
|
||||
|
||||
test('signedAngleDelta: shortest-path wrap', () => {
|
||||
@@ -86,3 +135,79 @@ test('signedAngleDelta: shortest-path wrap', () => {
|
||||
assert.ok(Math.abs(signedAngleDelta(d2r(-170), d2r(170)) - d2r(-20)) < 1e-9);
|
||||
assert.ok(Math.abs(signedAngleDelta(d2r(10), d2r(30)) - d2r(20)) < 1e-9);
|
||||
});
|
||||
|
||||
test('a press after a release Cesium never reported ends the open drag and its camera hold', (t) => {
|
||||
const viewer = {
|
||||
entities: {
|
||||
byId: new Map(),
|
||||
getById(id) {
|
||||
return this.byId.get(id);
|
||||
},
|
||||
add(options) {
|
||||
this.byId.set(options.id, options);
|
||||
return options;
|
||||
},
|
||||
remove(entity) {
|
||||
this.byId.delete(entity.id);
|
||||
},
|
||||
},
|
||||
scene: {
|
||||
canvas: { style: {} },
|
||||
screenSpaceCameraController: { enableInputs: true },
|
||||
pick: () => ({ id: { id: `${GIZMO_ID_PREFIX}move-up` } }),
|
||||
drillPick: () => [],
|
||||
},
|
||||
};
|
||||
const mount = Cesium.Cartesian3.fromDegrees(0, 0, 10);
|
||||
const east = new Cesium.Cartesian3(0, 1, 0); // east at lon 0, lat 0
|
||||
viewer.camera = {
|
||||
getPickRay: () => ({
|
||||
origin: Cesium.Cartesian3.add(
|
||||
mount,
|
||||
Cesium.Cartesian3.multiplyByScalar(east, 100, new Cesium.Cartesian3()),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
direction: Cesium.Cartesian3.negate(east, new Cesium.Cartesian3()),
|
||||
}),
|
||||
};
|
||||
const { input, handler } = fakeInput(viewer);
|
||||
t.after(() => input.destroy());
|
||||
const record = {
|
||||
frustumPositions: {
|
||||
mount,
|
||||
capCenter: mount,
|
||||
tl: mount,
|
||||
tr: mount,
|
||||
bl: mount,
|
||||
br: mount,
|
||||
},
|
||||
frustumGeometry: { rangeM: 50 },
|
||||
camera: {
|
||||
headingDeg: 0,
|
||||
pitchDeg: 0,
|
||||
rangeM: 50,
|
||||
calibration: {},
|
||||
basePose: {},
|
||||
},
|
||||
};
|
||||
const ended = [];
|
||||
const gizmo = createCalibrationGizmo({
|
||||
viewer,
|
||||
getActiveRecord: () => record,
|
||||
applyPatch() {},
|
||||
endPatch: (r) => ended.push(r),
|
||||
});
|
||||
t.after(() => gizmo.destroy());
|
||||
gizmo.setEnabled(true);
|
||||
const controller = viewer.scene.screenSpaceCameraController;
|
||||
|
||||
handler.fire(FAKE_EVENT_TYPES.LEFT_DOWN, { position: { x: 5, y: 5 } });
|
||||
assert.equal(gizmo.isDragging(), true);
|
||||
assert.equal(controller.enableInputs, false);
|
||||
// The release came with a modifier key held: Cesium raised no LEFT_UP.
|
||||
handler.fire(FAKE_EVENT_TYPES.LEFT_DOWN, { position: { x: 5, y: 5 } });
|
||||
assert.deepEqual(ended, [record], 'the open drag was committed');
|
||||
handler.fire(FAKE_EVENT_TYPES.LEFT_UP, { position: { x: 5, y: 5 } });
|
||||
assert.equal(gizmo.isDragging(), false);
|
||||
assert.equal(controller.enableInputs, true, 'no hold was stranded');
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import cctvLayer, {
|
||||
_cctvIconsForTest,
|
||||
_setCctvCoverageStateForTest,
|
||||
_viewRecordIdsForTest,
|
||||
refreshHorizonCulling,
|
||||
@@ -40,7 +41,6 @@ function recordsAt(
|
||||
mountHeightM: 8,
|
||||
},
|
||||
position,
|
||||
billboard: { position, show: true },
|
||||
coverageEntities: [],
|
||||
projection: null,
|
||||
};
|
||||
@@ -59,7 +59,11 @@ function viewerAt(heightM, screenByPosition, shiftX = 0) {
|
||||
scene: {
|
||||
canvas: { clientWidth: WIDTH, clientHeight: HEIGHT },
|
||||
cartesianToCanvasCoordinates: (position) => {
|
||||
const screen = screenByPosition.get(position);
|
||||
// Match by value: a record's position may be a copy of the fixture's.
|
||||
const key = [...screenByPosition.keys()].find((candidate) =>
|
||||
Cesium.Cartesian3.equals(candidate, position),
|
||||
);
|
||||
const screen = screenByPosition.get(key);
|
||||
return screen && { x: screen.x + shiftX, y: screen.y };
|
||||
},
|
||||
},
|
||||
@@ -68,7 +72,10 @@ function viewerAt(heightM, screenByPosition, shiftX = 0) {
|
||||
|
||||
function shownIds(records) {
|
||||
return records
|
||||
.filter((record) => record.billboard.show)
|
||||
.filter((record) => {
|
||||
const icons = _cctvIconsForTest();
|
||||
return icons.shown(icons.row(record.camera.id));
|
||||
})
|
||||
.map((record) => record.camera.id)
|
||||
.sort();
|
||||
}
|
||||
@@ -160,7 +167,11 @@ test('the active camera always shows, even in a crowded cell', () => {
|
||||
activeCameraId: active.camera.id,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
assert.ok(active.billboard.show, `${active.camera.id} shows while active`);
|
||||
const icons = _cctvIconsForTest();
|
||||
assert.ok(
|
||||
icons.shown(icons.row(active.camera.id)),
|
||||
`${active.camera.id} shows while active`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@ const LAYERS = [
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
icao24,
|
||||
entity: null,
|
||||
billboard: candidateBillboard(icao24),
|
||||
billboardCollection: { show: true, remove() {} },
|
||||
icon: candidateBillboard(icao24),
|
||||
iconLayer: { show: true, remove() {} },
|
||||
viewer: candidateViewer(),
|
||||
tracked: false,
|
||||
meta: {
|
||||
@@ -48,8 +48,8 @@ const LAYERS = [
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
icao24,
|
||||
entity: null,
|
||||
billboard: candidateBillboard(icao24),
|
||||
billboardCollection: { show: true, remove() {} },
|
||||
icon: candidateBillboard(icao24),
|
||||
iconLayer: { show: true, remove() {} },
|
||||
viewer: candidateViewer(),
|
||||
tracked: false,
|
||||
meta: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
@@ -39,9 +40,9 @@ for (const layer of LAYERS) {
|
||||
test(`${layer.name}: Cockpit 3D obeys the shared Display toggle`, () => {
|
||||
const regime = /^([ \t]*)function _modelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
|
||||
assert.ok(regime, '_modelRegimeActive is defined');
|
||||
assert.match(regime, /if\s*\(\s*!(?:flightState\.)?_models3dEnabled,?\s*\)\s*return\s*false;/,
|
||||
assert.match(regime, /if\s*\(\s*!(?:(?:flightState|state)\.)?_models3dEnabled,?\s*\)\s*return\s*false;/,
|
||||
'OFF must keep Cockpit AIR contacts in 2D');
|
||||
assert.doesNotMatch(regime, /!(?:flightState\.)?_models3dEnabled\s*&&\s*!(?:flightState\.)?_cockpitContactMode/,
|
||||
assert.doesNotMatch(regime, /!(?:(?:flightState|state)\.)?_models3dEnabled\s*&&\s*!(?:(?:flightState|state)\.)?_cockpitContactMode/,
|
||||
'Cockpit must not bypass the owner-visible Display toggle');
|
||||
});
|
||||
|
||||
@@ -53,7 +54,7 @@ for (const layer of LAYERS) {
|
||||
// suppression is now an explicit early return.
|
||||
const regime = /^([ \t]*)function _trackedModelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
|
||||
assert.ok(regime, '_trackedModelRegimeActive is defined');
|
||||
assert.match(regime, /if\s*\(\s*!(?:flightState\.)?_trackedIcao\s*\|\|\s*(?:flightState\.)?_cockpitContactMode\s*\|\|[\s\S]*?return\s*false;/,
|
||||
assert.match(regime, /if\s*\(\s*!(?:(?:flightState|state)\.)?_trackedIcao\s*\|\|\s*(?:(?:flightState|state)\.)?_cockpitContactMode\s*\|\|[\s\S]*?return\s*false;/,
|
||||
'_trackedModelRegimeActive excludes cockpit');
|
||||
const tracked = /^([ \t]*)function _updateTrackedModel\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
|
||||
assert.ok(tracked, '_updateTrackedModel is defined');
|
||||
@@ -70,37 +71,59 @@ for (const layer of LAYERS) {
|
||||
|
||||
const add = /^([ \t]*)function _modelAddDistM\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
|
||||
const keep = /^([ \t]*)function _modelKeepDistM\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
|
||||
assert.match(add, /(?:flightState\.)?_models3dMode\s*===\s*'all'\s*\?\s*MODEL_ALL_ADD_M\s*:\s*MODEL_PROX_ADD_M/);
|
||||
assert.match(keep, /(?:flightState\.)?_models3dMode\s*===\s*'all'\s*\?\s*MODEL_ALL_KEEP_M\s*:\s*MODEL_PROX_KEEP_M/);
|
||||
assert.match(add, /(?:(?:flightState|state)\.)?_models3dMode\s*===\s*'all'\s*\?\s*(?:policy\.)?MODEL_ALL_ADD_M\s*:\s*(?:policy\.)?MODEL_PROX_ADD_M/);
|
||||
assert.match(keep, /(?:(?:flightState|state)\.)?_models3dMode\s*===\s*'all'\s*\?\s*(?:policy\.)?MODEL_ALL_KEEP_M\s*:\s*(?:policy\.)?MODEL_PROX_KEEP_M/);
|
||||
assert.doesNotMatch(add, /COCKPIT_MODEL_ADD_M/);
|
||||
assert.doesNotMatch(keep, /COCKPIT_MODEL_KEEP_M/);
|
||||
|
||||
const cap = /^([ \t]*)function _modelCap\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
|
||||
assert.match(cap, /Math\.min\(\s*COCKPIT_MODEL_MAX/,
|
||||
assert.match(cap, /Math\.min\(\s*(?:policy\.)?COCKPIT_MODEL_MAX/,
|
||||
'Cockpit keeps its 60-model performance ceiling');
|
||||
});
|
||||
|
||||
test(`${layer.name}: near AIR state is independent from model admission`, () => {
|
||||
assert.match(source, /nextCockpitNearContacts\(/,
|
||||
'Cockpit derives a separate near-contact hysteresis set');
|
||||
assert.match(source, /isCockpitContact && !isCockpitNear[\s\S]*cockpitContactDotImage\(\)/,
|
||||
assert.match(source, /!state\._cockpitNearContacts\.has\(icao24\);\s*if \(cockpitDot\) \{\s*const dot = iconAtlas\.slot\(cockpitContactDotImage\(\)/,
|
||||
'only out-of-range Cockpit contacts become dots');
|
||||
// `_iconKind` is identity for every unconverted contact (see
|
||||
// tr3bRegistry.test.mjs) — it only swaps the glyph for a contact the
|
||||
// operator explicitly converted into a TR-3B.
|
||||
assert.match(source, /bb\.image\s*=\s*aircraftIcon\(\s*_iconKind\(\s*icao24,\s*meta\?\.klass,?\s*\)(,\s*bb\._gevIconLarge\s*\?\s*TRACKED_ICON_PX\s*:\s*undefined)?,?\s*\)/,
|
||||
assert.match(source, /aircraftIcon\(\s*(?:parts\.)?rendering\._iconKind\(icao24, info\?\.klass\),?\s*\)/,
|
||||
'near contacts and model fallbacks retain the class-derived aircraft silhouette');
|
||||
assert.match(source, /bb\.rotation\s*=\s*0;/,
|
||||
assert.match(source, /set\.setFlag\(row, ICON_ROTATES, false\);/,
|
||||
'far dots are reset to a rotation-free presentation');
|
||||
assert.match(source, /\(\s*!(?:flightState\.)?_cockpitContactMode\s*\|\|\s*isCockpitNear,?\s*\)\s*&&\s*\(\s*doRotations\s*\|\|\s*revealed,?\s*\)/,
|
||||
assert.match(source, /set\.setFlag\(row, ICON_ROTATES, true\);/,
|
||||
'near 2D silhouettes continue to receive projected course');
|
||||
assert.match(source, /if\s*\(\s*bb\.show,?\s*\)\s*bb\.show\s*=\s*false;\s*\/\/\s*hand\s*off\s*ONLY\s*once\s*the\s*model\s*renders/,
|
||||
'the gap-proof billboard-to-model handoff remains intact');
|
||||
assert.match(source, /icons\.setShown\(row, false\);\s*\/\/\s*hand\s*off\s*ONLY\s*once\s*the\s*model\s*renders/,
|
||||
'the gap-proof icon-to-model handoff remains intact');
|
||||
});
|
||||
|
||||
test(`${layer.name}: Cockpit exit clears near state before restoring map presentation`, () => {
|
||||
const setMode = /^([ \t]*)function _setCockpitContactMode\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
|
||||
assert.match(setMode, /else\s*(?:flightState\.)?_cockpitNearContacts\s*=\s*new\s*Set\(\s*,?\s*\);/);
|
||||
assert.match(setMode, /for\s*\(\s*const\s*\[icao24,\s*bb\]\s*of\s*(?:flightState\.)?_billboards,?\s*\)\s*(?:parts\.\w+\.)?_applyFleetBillboardPresentation\(\s*icao24,\s*bb,?\s*\);/);
|
||||
assert.match(setMode, /else\s*(?:(?:flightState|state)\.)?_cockpitNearContacts\s*=\s*new\s*Set\(\s*,?\s*\);/);
|
||||
assert.match(setMode, /for \(let row = 0; row < \(icons\?\.count \?\? 0\); row\+\+\)\s*parts\.icons\._applyIconPresentation\(icons\.idAt\(row\)\);/);
|
||||
});
|
||||
}
|
||||
|
||||
// The source reads above include the shared model module, so they pass from it
|
||||
// alone. These pin that each layer actually builds its models from it, and that
|
||||
// flights keeps its own hooks.
|
||||
const RENDERING = {
|
||||
flights: new URL('../layers/flights/rendering.js', import.meta.url),
|
||||
militaryFlights: new URL('../layers/military/rendering.js', import.meta.url),
|
||||
};
|
||||
|
||||
for (const [name, url] of Object.entries(RENDERING)) {
|
||||
test(`${name}: 3D models come from the shared aircraft model module`, () => {
|
||||
const source = readFileSync(url, 'utf8');
|
||||
assert.match(source, /createAircraftModels\(\{/);
|
||||
assert.match(source, /\.\.\.models\b/);
|
||||
});
|
||||
}
|
||||
|
||||
test('flights: model eligibility nudges enrichment and the tick floors grounded icons', () => {
|
||||
const source = readFileSync(RENDERING.flights, 'utf8');
|
||||
assert.match(source, /onModelEligible:[\s\S]*?_requestTypeEnrichment\(icao24, true\)/);
|
||||
assert.match(source, /afterReplan:[\s\S]*?_floorGroundedIcons\(/);
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { entityRecord, readEntityRecord } from './entityRecords.js';
|
||||
|
||||
const STORE_KEY = '__gevContextStore';
|
||||
|
||||
function createStore() {
|
||||
@@ -36,14 +38,14 @@ export function registerEntityContext(entity, metadata) {
|
||||
entity,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
entity.__gevContextId = metadata.id;
|
||||
entityRecord(entity).contextId = metadata.id;
|
||||
store.entities.set(metadata.id, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
export function selectEntityContext(entity) {
|
||||
const store = getContextStore();
|
||||
const contextId = entity?.__gevContextId;
|
||||
const contextId = readEntityRecord(entity)?.contextId;
|
||||
if (!contextId || !store.entities.has(contextId)) return null;
|
||||
store.selectedEntityId = contextId;
|
||||
store.selectedAt = Date.now();
|
||||
@@ -84,7 +86,7 @@ export function selectTrackedSubjectContext(metadata) {
|
||||
store.entities.delete(key);
|
||||
}
|
||||
// Reuse the existing carrier so a per-poll refresh does not churn identity.
|
||||
const carrier = store.entities.get(id)?.entity || { __gevContextId: id };
|
||||
const carrier = store.entities.get(id)?.entity || {};
|
||||
const record = registerEntityContext(carrier, { ...metadata, id });
|
||||
if (!record) return null;
|
||||
store.selectedEntityId = id;
|
||||
|
||||
@@ -96,7 +96,7 @@ test('a sibling tracking layer keeps its own subject slot', () => {
|
||||
test('the per-poll refresh updates values without stealing the selection', () => {
|
||||
withWindow(() => {
|
||||
selectTrackedSubjectContext(flightSubject('aaa001'));
|
||||
const other = registerEntityContext({ __gevContextId: 'dc-7' }, {
|
||||
const other = registerEntityContext({}, {
|
||||
id: 'dc-7', layerId: 'local-datacenters', label: 'Datacenter 7',
|
||||
});
|
||||
selectEntityContext(other.entity);
|
||||
@@ -130,7 +130,7 @@ test('deselecting a contact releases the shared slot', () => {
|
||||
|
||||
test('clearing one tracking layer leaves another layer selection alone', () => {
|
||||
withWindow(() => {
|
||||
const other = registerEntityContext({ __gevContextId: 'dam-3' }, {
|
||||
const other = registerEntityContext({}, {
|
||||
id: 'dam-3', layerId: 'local-dams', label: 'Dam 3',
|
||||
});
|
||||
selectEntityContext(other.entity);
|
||||
|
||||
+95
-49
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { creditFromMarkup } from '../render/creditMarkup.js';
|
||||
import { findCredits } from '../render/rendererOf.js';
|
||||
|
||||
/**
|
||||
* Per-layer data attribution registered into Cesium's credit display.
|
||||
@@ -36,6 +37,20 @@ export const OPENMAPTILES_CREDIT = {
|
||||
|
||||
const osmDisplays = new WeakMap();
|
||||
|
||||
/** The short inline OSM credits, as the renderer's credits port takes them. */
|
||||
const OSM_INLINE_CREDITS = Object.freeze({
|
||||
osm: Object.freeze({
|
||||
text: '© OpenStreetMap',
|
||||
href: 'https://www.openstreetmap.org/copyright',
|
||||
inline: true,
|
||||
}),
|
||||
tiles: Object.freeze({
|
||||
text: '© OpenMapTiles',
|
||||
href: 'https://openmaptiles.org',
|
||||
inline: true,
|
||||
}),
|
||||
});
|
||||
|
||||
function updateOsmDisplay(viewer, state) {
|
||||
const osm = state.owners.size > 0;
|
||||
let tiles = false;
|
||||
@@ -44,37 +59,27 @@ function updateOsmDisplay(viewer, state) {
|
||||
['osm', osm],
|
||||
['tiles', tiles],
|
||||
]) {
|
||||
if (state[key].visible === visible) continue;
|
||||
state[key].visible = visible;
|
||||
viewer.creditDisplay[visible ? 'addStaticCredit' : 'removeStaticCredit'](
|
||||
state[key].credit,
|
||||
);
|
||||
viewer.scene?.requestRender?.();
|
||||
if (Boolean(state.offs[key]) === visible) continue;
|
||||
if (visible) {
|
||||
// Looked up each time: a renderer bound later takes over.
|
||||
const credits = findCredits(viewer);
|
||||
if (credits) state.offs[key] = credits.addStatic(OSM_INLINE_CREDITS[key]);
|
||||
} else {
|
||||
state.offs[key]();
|
||||
state.offs[key] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep one short inline credit while an OSM display owner has visible data. */
|
||||
/**
|
||||
* Keep one short inline credit, through the renderer's credits port, while
|
||||
* an OSM display owner has visible data.
|
||||
*/
|
||||
export function showOsmCredit(viewer, owner, { openMapTiles = false } = {}) {
|
||||
if (!viewer?.creditDisplay?.addStaticCredit || !owner) return false;
|
||||
if (!owner || !findCredits(viewer)) return false;
|
||||
let state = osmDisplays.get(viewer);
|
||||
if (!state) {
|
||||
state = {
|
||||
owners: new Map(),
|
||||
osm: {
|
||||
visible: false,
|
||||
credit: new Cesium.Credit(
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap</a>',
|
||||
true,
|
||||
),
|
||||
},
|
||||
tiles: {
|
||||
visible: false,
|
||||
credit: new Cesium.Credit(
|
||||
'<a href="https://openmaptiles.org" target="_blank" rel="noopener">© OpenMapTiles</a>',
|
||||
true,
|
||||
),
|
||||
},
|
||||
};
|
||||
state = { owners: new Map(), offs: { osm: null, tiles: null } };
|
||||
osmDisplays.set(viewer, state);
|
||||
}
|
||||
if (state.owners.get(owner) === openMapTiles) return false;
|
||||
@@ -612,43 +617,84 @@ export const BHOTE_KOSHI_LOCATOR_CREDIT = {
|
||||
'(CC BY-NC 4.0)',
|
||||
};
|
||||
|
||||
/** A link, its whole element. */
|
||||
const LINK = /<a\b(?:[^>"']|"[^"]*"|'[^']*')*>[\s\S]*?<\/a\s*>/gi;
|
||||
/**
|
||||
* The separators text between two links can break at; it breaks at its
|
||||
* last. A semicolon ending a character reference is not one. A full stop
|
||||
* after an abbreviation reads as one, so keep "Dr." and "e.g." out of the
|
||||
* text between links.
|
||||
*/
|
||||
const SEPARATOR = /\s*(?:[,·—–]|(?<!&#?\w+);)\s*|\.\s+|\s+(?=\()/g;
|
||||
|
||||
/**
|
||||
* A credit's markup as the renderer's credits, one link each: the
|
||||
* contract's credit carries one link, so markup with several becomes one
|
||||
* credit per link, shown together in order. The text between two links
|
||||
* breaks at its last separator (a comma, semicolon, dot or dash, a full
|
||||
* stop, or before an opening bracket); closing brackets right after a link
|
||||
* stay with it, and a break's comma, semicolon, dot or dash is dropped.
|
||||
* @param {string} html
|
||||
* @returns {import('../render/contract/credits').Credit[]}
|
||||
*/
|
||||
export function creditsFromMarkup(html) {
|
||||
const markup = String(html);
|
||||
const pieces = [''];
|
||||
let at = 0;
|
||||
for (const link of markup.matchAll(LINK)) {
|
||||
let gap = markup.slice(at, link.index);
|
||||
if (at > 0) {
|
||||
const close = /^[)\]]*/.exec(gap)[0];
|
||||
gap = gap.slice(close.length);
|
||||
const cut = [...gap.matchAll(SEPARATOR)].at(-1);
|
||||
const before = cut ? gap.slice(0, cut.index) : '';
|
||||
const stop = cut && cut[0].trimStart().startsWith('.') ? '.' : '';
|
||||
pieces[pieces.length - 1] += close + before + stop;
|
||||
pieces.push(cut ? gap.slice(cut.index + cut[0].length) : gap);
|
||||
} else pieces[0] += gap;
|
||||
pieces[pieces.length - 1] += link[0];
|
||||
at = link.index + link[0].length;
|
||||
}
|
||||
pieces[pieces.length - 1] += markup.slice(at);
|
||||
return pieces
|
||||
.map((piece) => ({ ...creditFromMarkup(piece), inline: false }))
|
||||
.filter((credit) => credit.text);
|
||||
}
|
||||
|
||||
/** Add a data credit to the attribution list through the renderer. */
|
||||
function addDataCredit(viewer, credit) {
|
||||
const credits = findCredits(viewer);
|
||||
if (!credits) return false;
|
||||
for (const one of creditsFromMarkup(credit.html)) credits.addStatic(one);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @type {Set<string>} Keys of dynamic credits already registered this session. */
|
||||
const _dynamicCreditKeys = new Set();
|
||||
|
||||
/**
|
||||
* Register a conditional credit at the moment its data source activates.
|
||||
* Idempotent per `credit.key`; lands in the same "Data attribution" popover
|
||||
* as the static credits (showOnScreen=false).
|
||||
* @param {Cesium.Viewer} viewer — the initialized Cesium viewer
|
||||
* Idempotent per `credit.key`; lands in the same "Data attribution" list
|
||||
* as the static credits, through the renderer's credits port.
|
||||
* @param {object} viewer — the scene object the renderer is bound to
|
||||
* @param {{ key: string, html: string }} credit — e.g. `TOMTOM_CREDIT`
|
||||
* @returns {boolean} True when the credit is (now) registered.
|
||||
*/
|
||||
export function registerDynamicCredit(viewer, credit) {
|
||||
const creditDisplay = viewer?.creditDisplay;
|
||||
if (!creditDisplay || typeof creditDisplay.addStaticCredit !== 'function') {
|
||||
return false;
|
||||
}
|
||||
if (!credit?.key || !credit?.html) return false;
|
||||
if (_dynamicCreditKeys.has(credit.key)) return true;
|
||||
creditDisplay.addStaticCredit(new Cesium.Credit(credit.html, false));
|
||||
if (!viewer || !credit?.key || !credit?.html) return false;
|
||||
if (_dynamicCreditKeys.has(credit.key)) return Boolean(findCredits(viewer));
|
||||
if (!addDataCredit(viewer, credit)) return false;
|
||||
_dynamicCreditKeys.add(credit.key);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register every per-layer data credit into the viewer's credit display.
|
||||
* Idempotent: safe to call once at init. Credits are static and always
|
||||
* present in the "Data attribution" popover.
|
||||
* @param {Cesium.Viewer} viewer — the initialized Cesium viewer
|
||||
* Register every per-layer data credit through the renderer's credits
|
||||
* port. Safe to call once at init. Credits are static and always present
|
||||
* in the "Data attribution" list, not on the map's credit line.
|
||||
* @param {object} viewer — the scene object the renderer is bound to
|
||||
*/
|
||||
export function registerDataCredits(viewer, credits = DATA_CREDITS) {
|
||||
const creditDisplay = viewer?.creditDisplay;
|
||||
if (!creditDisplay || typeof creditDisplay.addStaticCredit !== 'function') {
|
||||
return;
|
||||
}
|
||||
for (const { html } of credits) {
|
||||
// showOnScreen=false → lives in the expandable "Data attribution" popover,
|
||||
// not the on-globe credit line.
|
||||
creditDisplay.addStaticCredit(new Cesium.Credit(html, false));
|
||||
}
|
||||
if (!viewer) return;
|
||||
for (const credit of credits) addDataCredit(viewer, credit);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DATA_CREDITS, NYC_DOT_DISCLAIMER } from './dataCredits.js';
|
||||
import { bindFakeCesiumCredits } from '../testSupport/fakeCreditsPort.mjs';
|
||||
|
||||
test('every credit carries a unique key and some markup to render', () => {
|
||||
const keys = DATA_CREDITS.map((entry) => entry.key);
|
||||
@@ -60,13 +61,13 @@ test('inline OSM attribution persists until its last display owner leaves', asyn
|
||||
const { showOsmCredit, hideOsmCredit } = await import('./dataCredits.js');
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const visible = new Set();
|
||||
const viewer = {
|
||||
const viewer = bindFakeCesiumCredits({
|
||||
creditDisplay: {
|
||||
addStaticCredit: (c) => visible.add(c),
|
||||
removeStaticCredit: (c) => visible.delete(c),
|
||||
},
|
||||
scene: { requestRender() {} },
|
||||
};
|
||||
});
|
||||
assert.equal(showOsmCredit(viewer, 'alpr'), true);
|
||||
assert.equal(showOsmCredit(viewer, 'alpr'), false);
|
||||
assert.equal(visible.size, 1);
|
||||
@@ -100,13 +101,13 @@ test('tile attribution follows source changes and viewers have independent owner
|
||||
const { showOsmCredit, hideOsmCredit } = await import('./dataCredits.js');
|
||||
const create = () => {
|
||||
const credits = new Set();
|
||||
return {
|
||||
return bindFakeCesiumCredits({
|
||||
credits,
|
||||
creditDisplay: {
|
||||
addStaticCredit: (c) => credits.add(c),
|
||||
removeStaticCredit: (c) => credits.delete(c),
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
const a = create(),
|
||||
b = create();
|
||||
@@ -141,3 +142,105 @@ test('every Street Level imagery source is in the attribution lightbox', () => {
|
||||
`${key} has no credit`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a credit with several links becomes one credit per link, in order', async () => {
|
||||
const {
|
||||
creditsFromMarkup,
|
||||
OSM_CREDIT,
|
||||
OPENMAPTILES_CREDIT,
|
||||
WOF_CREDIT,
|
||||
BHOTE_KOSHI_CREDIT,
|
||||
} = await import('./dataCredits.js');
|
||||
const html = (key) => DATA_CREDITS.find((entry) => entry.key === key).html;
|
||||
const read = (markup) =>
|
||||
creditsFromMarkup(markup).map(({ text, href }) => [text, href]);
|
||||
assert.deepEqual(read(OSM_CREDIT.html), [
|
||||
[
|
||||
'Map and place data © OpenStreetMap contributors',
|
||||
'https://www.openstreetmap.org/copyright',
|
||||
],
|
||||
['(ODbL)', 'https://opendatacommons.org/licenses/odbl/1-0/'],
|
||||
]);
|
||||
assert.deepEqual(read(OPENMAPTILES_CREDIT.html), [
|
||||
['Vector tiles: OpenFreeMap', 'https://openfreemap.org'],
|
||||
['© OpenMapTiles', 'https://openmaptiles.org'],
|
||||
]);
|
||||
assert.deepEqual(read(html('nasa-gibs'))[1], [
|
||||
'HLS product page',
|
||||
'https://lpdaac.usgs.gov/products/hlss30v002/',
|
||||
]);
|
||||
assert.deepEqual(read(html('wfigs')), [
|
||||
[
|
||||
'Wildfire perimeters: National Interagency Fire Center (WFIGS)',
|
||||
'https://data-nifc.opendata.arcgis.com/',
|
||||
],
|
||||
['Incident information: InciWeb', 'https://inciweb.wildfire.gov/'],
|
||||
]);
|
||||
assert.deepEqual(read(html('eccc-aqhi')), [
|
||||
[
|
||||
'Air quality (AQHI): Environment and Climate Change Canada — MSC GeoMet (eccc-msc.github.io/open-data)',
|
||||
'https://eccc-msc.github.io/open-data/',
|
||||
],
|
||||
[
|
||||
'ECCC Data Servers End-use Licence',
|
||||
'https://eccc-msc.github.io/open-data/licence/readme_en/',
|
||||
],
|
||||
]);
|
||||
assert.deepEqual(
|
||||
read(BHOTE_KOSHI_CREDIT.html)[1][0].slice(0, 15),
|
||||
'and GeoPera (CC',
|
||||
);
|
||||
assert.deepEqual(
|
||||
read(WOF_CREDIT.html).map(([text]) => text),
|
||||
[
|
||||
"Neighborhood boundaries: Who's On First and contributors, simplified",
|
||||
'(sources and licences)',
|
||||
],
|
||||
);
|
||||
const link = (text) => `<a href="https://example.org">${text}</a>`;
|
||||
assert.deepEqual(
|
||||
read(`${link('A')} by Hoffleit & Warren ${link('B')}`).map(
|
||||
([text]) => text,
|
||||
),
|
||||
['A', 'by Hoffleit & Warren B'],
|
||||
'a character reference is not a separator',
|
||||
);
|
||||
assert.deepEqual(read(html('usgs')), [
|
||||
['Earthquakes: Data courtesy of the U.S. Geological Survey', undefined],
|
||||
]);
|
||||
for (const entry of DATA_CREDITS) {
|
||||
const credits = creditsFromMarkup(entry.html);
|
||||
const links = entry.html.match(/<a\b/g)?.length ?? 0;
|
||||
assert.equal(credits.length, Math.max(links, 1), entry.key);
|
||||
assert.ok(
|
||||
credits.every((credit) => credit.inline === false),
|
||||
`${entry.key} belongs in the attribution list`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('data credits go through the renderer to the attribution list', async () => {
|
||||
const { registerDataCredits, registerDynamicCredit, TOMTOM_CREDIT } =
|
||||
await import('./dataCredits.js');
|
||||
const shown = [];
|
||||
const viewer = bindFakeCesiumCredits({
|
||||
creditDisplay: {
|
||||
addStaticCredit: (c) => shown.push(c),
|
||||
removeStaticCredit() {},
|
||||
},
|
||||
});
|
||||
const credits = DATA_CREDITS.filter((entry) =>
|
||||
['openstreetmap', 'usgs'].includes(entry.key),
|
||||
);
|
||||
registerDataCredits(viewer, credits);
|
||||
assert.equal(shown.length, 3);
|
||||
assert.ok(shown.every((credit) => !credit.showOnScreen));
|
||||
const odbl = shown.find((credit) => credit.html.includes('opendatacommons'));
|
||||
assert.match(odbl.html, /href="https:\/\/opendatacommons.org/);
|
||||
assert.match(odbl.html, />\(ODbL\)</);
|
||||
assert.equal(registerDynamicCredit(viewer, TOMTOM_CREDIT), true);
|
||||
assert.equal(registerDynamicCredit(viewer, TOMTOM_CREDIT), true);
|
||||
assert.equal(shown.length, 4, 'a dynamic credit is added once');
|
||||
assert.match(shown[3].html, /Traffic flow data © TomTom/);
|
||||
assert.equal(registerDynamicCredit({}, { key: 'none', html: 'x' }), false);
|
||||
});
|
||||
@@ -54,6 +54,8 @@ export {
|
||||
STEP_ANCHOR_FAST_ATTEMPTS,
|
||||
STEP_ANCHOR_RETRY_MS,
|
||||
STEP_ANCHOR_SLOW_RETRY_MS,
|
||||
DIRECTIONS_MARKER_OVERLAY_SOURCE_ID,
|
||||
createDirectionsMarkerOverlayEntry,
|
||||
createDirectionsStepOverlayEntry,
|
||||
directionsRequestUrl,
|
||||
directionsRowControls,
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
DIRECTIONS_MODES,
|
||||
DIRECTIONS_POINTER_OWNER,
|
||||
DIRECTIONS_ROUTE_COLOR,
|
||||
DIRECTIONS_MARKER_OVERLAY_SOURCE_ID,
|
||||
createDirectionsMarkerOverlayEntry,
|
||||
createDirectionsStepOverlayEntry,
|
||||
directionsRequestUrl,
|
||||
directionsRowControls,
|
||||
@@ -950,3 +952,83 @@ test('the reroute tooltip keeps German mode nouns capitalized', (t) => {
|
||||
'Fahrrad – Route im Modus „Fahrrad“ neu berechnen',
|
||||
);
|
||||
});
|
||||
|
||||
test('the A and B letters are protected overlay labels in the marker colour', () => {
|
||||
const position = Cesium.Cartesian3.fromDegrees(-122.39, 37.79);
|
||||
const entry = createDirectionsMarkerOverlayEntry('B', position, '#ff6b6b');
|
||||
assert.equal(entry.id, 'directions-marker-b');
|
||||
assert.equal(entry.title, 'B');
|
||||
assert.equal(entry.variant, 'label');
|
||||
assert.equal(entry.accent, '#ff6b6b');
|
||||
assert.equal(entry.protected, true);
|
||||
assert.equal(entry.position, position);
|
||||
assert.equal(entry.placement, 'above');
|
||||
});
|
||||
|
||||
test('a placed endpoint draws its letter through the overlay, not as entity text', async (t) => {
|
||||
const published = [];
|
||||
const cleared = [];
|
||||
const visibility = [];
|
||||
const entities = [];
|
||||
const markerVisibility = () =>
|
||||
visibility
|
||||
.filter(([source]) => source === DIRECTIONS_MARKER_OVERLAY_SOURCE_ID)
|
||||
.at(-1)?.[1];
|
||||
directionsLayer = createApplicationDirectionsLayer();
|
||||
directionsLayer._setOverlayHostForTest({
|
||||
setEntries: (source, entries) => published.push([source, entries]),
|
||||
setVisible: (source, visible) => visibility.push([source, visible]),
|
||||
clearSource: (source) => cleared.push(source),
|
||||
});
|
||||
resetPointerOwnership();
|
||||
const viewer = {
|
||||
isDestroyed: () => false,
|
||||
entities: {
|
||||
add: (spec) => (entities.push(spec), spec),
|
||||
remove() {},
|
||||
},
|
||||
scene: {
|
||||
requestRender() {},
|
||||
primitives: {
|
||||
add: (primitive) => primitive,
|
||||
remove() {},
|
||||
raiseToTop() {},
|
||||
lowerToBottom() {},
|
||||
contains: () => true,
|
||||
length: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
t.after(() => {
|
||||
directionsLayer.destroy(viewer);
|
||||
directionsLayer._setOverlayHostForTest(null);
|
||||
resetPointerOwnership();
|
||||
});
|
||||
directionsLayer.init(viewer);
|
||||
assert.equal(markerVisibility(), false, 'hidden until enabled');
|
||||
directionsLayer.placeEndpoint('a', { lat: 37.7955, lon: -122.3937 });
|
||||
|
||||
assert.equal(entities.length, 1);
|
||||
assert.equal(entities[0].label, undefined, 'no Cesium label on the marker');
|
||||
assert.ok(entities[0].point, 'the marker keeps its point');
|
||||
const [source, entries] = published.at(-1);
|
||||
assert.equal(source, DIRECTIONS_MARKER_OVERLAY_SOURCE_ID);
|
||||
assert.deepEqual(
|
||||
entries.map((entry) => entry.title),
|
||||
['A'],
|
||||
);
|
||||
// Hidden until the floor cell settles, then anchored (the ellipsoid here,
|
||||
// where the test resolver finds no height).
|
||||
assert.equal(entries[0].position(), null);
|
||||
const started = Date.now();
|
||||
while (!entries[0].position() && Date.now() - started < 5000)
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
const anchor = Cesium.Cartographic.fromCartesian(entries[0].position());
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(anchor.latitude) - 37.7955) < 1e-9);
|
||||
|
||||
directionsLayer.setParams({ clear: true });
|
||||
assert.ok(cleared.includes(DIRECTIONS_MARKER_OVERLAY_SOURCE_ID));
|
||||
cleared.length = 0;
|
||||
directionsLayer.destroy(viewer);
|
||||
assert.ok(cleared.includes(DIRECTIONS_MARKER_OVERLAY_SOURCE_ID));
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
installRenderGovernor,
|
||||
_resetRenderGovernorForTest,
|
||||
} from '../renderGovernor.js';
|
||||
import { framePortFor } from '../testSupport/fakeFramePort.mjs';
|
||||
|
||||
const FULL_RAW = {
|
||||
id: 'us7000abcd',
|
||||
@@ -246,7 +247,7 @@ test('a quake poll still reaches the screen with the render loop idle', async ()
|
||||
});
|
||||
|
||||
_resetRenderGovernorForTest();
|
||||
installRenderGovernor(viewer);
|
||||
installRenderGovernor(framePortFor(viewer.scene));
|
||||
// Installing the governor enters idle mode, which itself paints one settling
|
||||
// frame. Drop that from the baseline or the enable assertion below can pass on
|
||||
// the install alone, without the manager ever requesting anything.
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @module entityRecords
|
||||
* @description Core-owned records for the entities layers draw, so layer state lives in Core
|
||||
* rather than on the renderer's objects. A layer writes the record for an entity it draws;
|
||||
* the tracked readout, follow camera, cockpit, share links and awareness read it back
|
||||
* through the entity the renderer hands them (a pick or the tracked entity).
|
||||
*
|
||||
* Fields a record may hold:
|
||||
* - `trackedId`: the normalized `layer:id` identity, e.g. `flights:a1b2c3`.
|
||||
* - `displayPosition()`: the layer's cached display position (the follow camera's
|
||||
* anti-jitter contract: the value the camera settled on, never recomputed late).
|
||||
* - `visualPosition()`: where a 3D model is actually drawn, for the tracked card.
|
||||
* - `labelModel`: the tracked card's `{ title, details, accent, … }`.
|
||||
* - `selectionOrigin`: what started the selection (`'user'`, …).
|
||||
* - `sprite`: a tracked billboard's pushed `{ width, height, scale, scaleByDistance }`, for
|
||||
* the close-range camera guard and the screen annotations' tracked footprint.
|
||||
*
|
||||
* A layer's private per-entity state goes in its own store ({@link createEntityStore}).
|
||||
*/
|
||||
|
||||
/**
|
||||
* A store of one record per renderer object, held by Core and dropped with the object.
|
||||
* @returns {{ write: (entity: Object) => Object, read: (entity: unknown) => Object|null }}
|
||||
* `write` makes the record empty on first use; `read` gives null when there is none.
|
||||
*/
|
||||
export function createEntityStore() {
|
||||
const records = new WeakMap();
|
||||
return {
|
||||
write(entity) {
|
||||
let record = records.get(entity);
|
||||
if (!record) {
|
||||
record = {};
|
||||
records.set(entity, record);
|
||||
}
|
||||
return record;
|
||||
},
|
||||
read(entity) {
|
||||
if (!entity || typeof entity !== 'object') return null;
|
||||
return records.get(entity) ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const shared = createEntityStore();
|
||||
|
||||
/**
|
||||
* The record for `entity`, made empty on first use. For layers writing their own state.
|
||||
* @param {Object} entity An entity the layer draws.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function entityRecord(entity) {
|
||||
return shared.write(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* The record for `entity`, or null when it has none (or there is no entity). For readers.
|
||||
* @param {Object|null|undefined} entity
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
export function readEntityRecord(entity) {
|
||||
return shared.read(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write `fields` into `entity`'s record and return the entity.
|
||||
* @param {Object} entity
|
||||
* @param {Object} fields Record fields to set.
|
||||
* @returns {Object} `entity`
|
||||
*/
|
||||
export function withEntityRecord(entity, fields) {
|
||||
Object.assign(entityRecord(entity), fields);
|
||||
return entity;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createEntityStore,
|
||||
entityRecord,
|
||||
readEntityRecord,
|
||||
withEntityRecord,
|
||||
} from './entityRecords.js';
|
||||
|
||||
test('an entity record is made on first write and read back by the same entity', () => {
|
||||
const entity = {};
|
||||
assert.equal(readEntityRecord(entity), null);
|
||||
entityRecord(entity).trackedId = 'flights:a1b2c3';
|
||||
assert.equal(entityRecord(entity), entityRecord(entity), 'one record per entity');
|
||||
assert.equal(readEntityRecord(entity)?.trackedId, 'flights:a1b2c3');
|
||||
assert.equal(readEntityRecord({}), null, 'another entity has its own record');
|
||||
});
|
||||
|
||||
test('the record lives in Core, not on the entity', () => {
|
||||
const entity = {};
|
||||
entityRecord(entity).labelModel = { title: 'DAL123', details: [] };
|
||||
assert.deepEqual(Object.keys(entity), []);
|
||||
});
|
||||
|
||||
test('reading without an entity gives null', () => {
|
||||
assert.equal(readEntityRecord(null), null);
|
||||
assert.equal(readEntityRecord(undefined), null);
|
||||
assert.equal(readEntityRecord('flights:a1b2c3'), null);
|
||||
});
|
||||
|
||||
test('withEntityRecord writes fields into the record and returns the entity', () => {
|
||||
const entity = withEntityRecord({ id: 'alpr:42' }, { trackedId: 'alpr:42' });
|
||||
assert.equal(entity.id, 'alpr:42');
|
||||
assert.equal(readEntityRecord(entity)?.trackedId, 'alpr:42');
|
||||
assert.equal(Object.hasOwn(entity, 'trackedId'), false);
|
||||
});
|
||||
|
||||
test('a layer store is separate from the shared records and from other stores', () => {
|
||||
const entity = {};
|
||||
const a = createEntityStore();
|
||||
const b = createEntityStore();
|
||||
a.write(entity).canvasPosition = 1;
|
||||
assert.equal(a.read(entity)?.canvasPosition, 1);
|
||||
assert.equal(b.read(entity), null);
|
||||
assert.equal(readEntityRecord(entity), null);
|
||||
assert.equal(a.read(null), null);
|
||||
assert.deepEqual(Object.keys(entity), []);
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { FIRE_ANCHOR_LIFT_M } from './fireAnchors.js';
|
||||
import { getLocale, setLocale } from '../i18n/index.js';
|
||||
import { reportMeshFloorCell, setMeshFloorPreferred } from './groundFloor.js';
|
||||
import { fakeInput } from '../input/testDoubles.mjs';
|
||||
|
||||
const NOW = Date.UTC(2026, 6, 17, 4, 0);
|
||||
const H = 3600000;
|
||||
@@ -216,6 +217,7 @@ test('real FIRMS lifecycle clears host entries on disable and destroy', async ()
|
||||
primitives: { contains() { return false; } },
|
||||
},
|
||||
};
|
||||
fakeInput(viewer);
|
||||
const layer = createFirmsHeatmapLayer({
|
||||
id: 'firms',
|
||||
name: 'FIRMS',
|
||||
@@ -224,7 +226,6 @@ test('real FIRMS lifecycle clears host entries on disable and destroy', async ()
|
||||
setVisible: (...args) => calls.push(['visible', ...args]),
|
||||
clearSource: (...args) => calls.push(['clear', ...args]),
|
||||
},
|
||||
screenSpaceEventHandlerFactory: () => ({ setInputAction() {}, destroy() {} }),
|
||||
});
|
||||
try {
|
||||
layer.init(viewer);
|
||||
|
||||
@@ -16,7 +16,6 @@ const helpers = createFirmsHelpers({
|
||||
});
|
||||
export const mapAnalystRecord = helpers.mapAnalystRecord;
|
||||
export const fireCullPosition = helpers.fireCullPosition;
|
||||
export const applyHorizonCull = helpers.applyHorizonCull;
|
||||
export const buildSelectedFireCard = helpers.buildSelectedFireCard;
|
||||
export const buildFireCard = helpers.buildFireCard;
|
||||
export const buildCellCard = helpers.buildCellCard;
|
||||
|
||||
+168
-130
@@ -1,11 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
applyHorizonCull,
|
||||
createFirmsHeatmapLayer,
|
||||
fireCullPosition,
|
||||
} from './firmsHeatmap.js';
|
||||
import { createFirmsHeatmapLayer, fireCullPosition } from './firmsHeatmap.js';
|
||||
import { FIRE_ANCHOR_LIFT_M } from './fireAnchors.js';
|
||||
import { FIRMS_AMBIENT_COHORT_LIMIT, FIRMS_OVERLAY_SOURCE_ID } from './firmsLabels.js';
|
||||
import {
|
||||
@@ -15,6 +11,9 @@ import {
|
||||
} from './groundFloor.js';
|
||||
import { unregisterSpriteCollection } from './spriteOrder.js';
|
||||
import { isOverlayPointVisible, normalizeOverlayEntry } from '../overlays/worldOverlay.js';
|
||||
import { createIconSet, ICON_CULL_LIFT } from './iconSet.js';
|
||||
import { writeHoldSegment } from './iconSegment.js';
|
||||
import { fakeInput } from '../input/testDoubles.mjs';
|
||||
|
||||
const AUSTIN = { lon: -97.7, lat: 30.2 };
|
||||
/** Antipode of Austin — as far behind the limb as a point on Earth can be. */
|
||||
@@ -28,24 +27,26 @@ const REPRO_HEIGHT_M = 1_500_000;
|
||||
* at −22 m but visible at +5/+12 m. Measured against Cesium's own occluder.
|
||||
*/
|
||||
const LIMB_DLON = 41.87;
|
||||
/** Farther east, where 11 m reads hidden and 12 m visible from the same camera. */
|
||||
const LIFT_BAND_DLON = 41.9452;
|
||||
|
||||
/**
|
||||
* Billboard stub that records every write to `show` so the tests can assert
|
||||
* the pass does not dirty the collection's vertex buffer needlessly.
|
||||
* Whether the icon set's GPU pass draws `row` from `occluder`'s camera: the
|
||||
* shader's horizon test on the row's cull point (its CPU twin).
|
||||
*/
|
||||
function billboard(position, show = true) {
|
||||
return {
|
||||
position,
|
||||
_show: show,
|
||||
writes: 0,
|
||||
get show() { return this._show; },
|
||||
set show(value) { this._show = value; this.writes += 1; },
|
||||
};
|
||||
function gpuShows(set, row, occluder) {
|
||||
const position = set.position(row, 0, new Cesium.Cartesian3());
|
||||
return set.shown(row) && occluder.isPointVisible(set.cullPoint(row, position));
|
||||
}
|
||||
|
||||
/** Cesium BillboardCollection-shaped stub. */
|
||||
function collection(items) {
|
||||
return { length: items.length, get: (i) => items[i] };
|
||||
/** A fire-shaped row (shown, CULL_LIFT) held at `position`. */
|
||||
function fireRow(set, id, position) {
|
||||
const row = set.add(id, { layer: 'firms', id });
|
||||
set.setFlag(row, ICON_CULL_LIFT, true);
|
||||
set.setShown(row, true);
|
||||
writeHoldSegment(set.segments, row, position, 0);
|
||||
set.segmentWritten(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Horizon occluder for a camera directly above Austin at `heightM`. */
|
||||
@@ -57,92 +58,71 @@ function occluderOverAustin(heightM) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The pure cull predicate
|
||||
// The GPU horizon cull a fire row gets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('applyHorizonCull: far-side fires are hidden, near-side fires stay visible', () => {
|
||||
test('icon cull: far-side fires are hidden, near-side fires stay visible', () => {
|
||||
const occluder = occluderOverAustin(REPRO_HEIGHT_M);
|
||||
const near = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0));
|
||||
const nearby = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon + 6, AUSTIN.lat + 4, 0));
|
||||
const far = billboard(Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, 0));
|
||||
const otherSide = billboard(Cesium.Cartesian3.fromDegrees(TOKYO.lon, TOKYO.lat, 0));
|
||||
const set = createIconSet();
|
||||
const at = (p) => Cesium.Cartesian3.fromDegrees(p.lon, p.lat, 0);
|
||||
const near = fireRow(set, 'near', at(AUSTIN));
|
||||
const nearby = fireRow(set, 'nearby', at({ lon: AUSTIN.lon + 6, lat: AUSTIN.lat + 4 }));
|
||||
const far = fireRow(set, 'far', at(ANTIPODE));
|
||||
const otherSide = fireRow(set, 'tokyo', at(TOKYO));
|
||||
|
||||
const visible = applyHorizonCull(collection([near, nearby, far, otherSide]), occluder);
|
||||
|
||||
assert.equal(near.show, true, 'the fire under the camera renders');
|
||||
assert.equal(nearby.show, true, 'a fire well inside the horizon renders');
|
||||
assert.equal(far.show, false, 'the antipodal fire must not shine through the planet');
|
||||
assert.equal(otherSide.show, false, 'Tokyo is beyond the limb from Austin at 1.5 Mm');
|
||||
assert.equal(visible, 2, 'returns the surviving count');
|
||||
assert.equal(gpuShows(set, near, occluder), true, 'the fire under the camera renders');
|
||||
assert.equal(gpuShows(set, nearby, occluder), true, 'a fire well inside the horizon renders');
|
||||
assert.equal(gpuShows(set, far, occluder), false, 'the antipodal fire must not shine through the planet');
|
||||
assert.equal(gpuShows(set, otherSide, occluder), false, 'Tokyo is beyond the limb from Austin at 1.5 Mm');
|
||||
});
|
||||
|
||||
test('applyHorizonCull: raising the camera reveals fires the low camera hid', () => {
|
||||
const paris = () => billboard(Cesium.Cartesian3.fromDegrees(2.35, 48.86, 0));
|
||||
|
||||
const low = paris();
|
||||
applyHorizonCull(collection([low]), occluderOverAustin(REPRO_HEIGHT_M));
|
||||
assert.equal(low.show, false, 'Paris is over the horizon from a 1.5 Mm Austin camera');
|
||||
|
||||
const high = paris();
|
||||
applyHorizonCull(collection([high]), occluderOverAustin(35_000_000));
|
||||
assert.equal(high.show, true, 'from geostationary height the same fire is in view');
|
||||
test('icon cull: raising the camera reveals fires the low camera hid', () => {
|
||||
const set = createIconSet();
|
||||
const paris = fireRow(set, 'paris', Cesium.Cartesian3.fromDegrees(2.35, 48.86, 0));
|
||||
assert.equal(gpuShows(set, paris, occluderOverAustin(REPRO_HEIGHT_M)), false);
|
||||
assert.equal(gpuShows(set, paris, occluderOverAustin(35_000_000)), true, 'in view from geostationary height');
|
||||
});
|
||||
|
||||
test('applyHorizonCull: a hidden fire is restored when it swings back into view', () => {
|
||||
const revealed = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0), false);
|
||||
applyHorizonCull(collection([revealed]), occluderOverAustin(REPRO_HEIGHT_M));
|
||||
assert.equal(revealed.show, true, 'the cull un-hides as well as hides');
|
||||
assert.equal(revealed.writes, 1);
|
||||
});
|
||||
|
||||
test('applyHorizonCull: only flipped billboards are written (vertex-buffer churn)', () => {
|
||||
test('icon cull: a sub-ellipsoid anchor is judged lifted, a far-side one still culls', () => {
|
||||
const occluder = occluderOverAustin(REPRO_HEIGHT_M);
|
||||
const stays = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0), true);
|
||||
const flips = billboard(Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, 0), true);
|
||||
const set = createIconSet();
|
||||
const sunken = fireRow(set, 'sunken', Cesium.Cartesian3.fromDegrees(AUSTIN.lon + LIMB_DLON, AUSTIN.lat, -22));
|
||||
assert.equal(gpuShows(set, sunken, occluder), true, 'the lifted cull point keeps the near-limb fire on screen');
|
||||
set.setFlag(sunken, ICON_CULL_LIFT, false);
|
||||
assert.equal(gpuShows(set, sunken, occluder), false, 'baseline: the raw anchor false-hides at the limb');
|
||||
|
||||
applyHorizonCull(collection([stays, flips]), occluder);
|
||||
applyHorizonCull(collection([stays, flips]), occluder);
|
||||
|
||||
assert.equal(stays.writes, 0, 'an already-visible billboard is never re-assigned');
|
||||
assert.equal(flips.writes, 1, 'a hidden billboard is written once, not once per pass');
|
||||
const far = fireRow(set, 'far', Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, -22));
|
||||
assert.equal(gpuShows(set, far, occluder), false, '12 m of lift must not defeat the actual horizon cull');
|
||||
});
|
||||
|
||||
test('applyHorizonCull: missing collection/occluder/holes are no-ops, not throws', () => {
|
||||
assert.equal(applyHorizonCull(null, occluderOverAustin(REPRO_HEIGHT_M)), 0);
|
||||
assert.equal(applyHorizonCull({ length: 3 }, occluderOverAustin(REPRO_HEIGHT_M)), 0, 'non-collection shape');
|
||||
const item = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0));
|
||||
assert.equal(applyHorizonCull(collection([item]), null), 0, 'no occluder → leave the layer alone');
|
||||
assert.equal(applyHorizonCull(collection([item]), {}), 0, 'occluder without isPointVisible');
|
||||
assert.equal(item.writes, 0, 'a skipped pass must not touch show');
|
||||
assert.equal(
|
||||
applyHorizonCull(collection([undefined, item]), occluderOverAustin(REPRO_HEIGHT_M)),
|
||||
1,
|
||||
'sparse slots are skipped, surviving neighbours still counted',
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-ellipsoid anchors: datum-correct render point, lifted CULL point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('applyHorizonCull: an index-aligned cull position overrides the render anchor', () => {
|
||||
test('fireCullPosition: a card is judged where its sprite is', () => {
|
||||
const occluder = occluderOverAustin(REPRO_HEIGHT_M);
|
||||
const sunken = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon + LIMB_DLON, AUSTIN.lat, -22));
|
||||
const lifted = Cesium.Cartesian3.fromDegrees(AUSTIN.lon + LIMB_DLON, AUSTIN.lat, 12);
|
||||
|
||||
applyHorizonCull(collection([sunken]), occluder);
|
||||
assert.equal(sunken.show, false, 'baseline: the raw sub-ellipsoid anchor false-hides at the limb');
|
||||
|
||||
applyHorizonCull(collection([sunken]), occluder, [lifted]);
|
||||
assert.equal(sunken.show, true, 'the lifted cull point keeps the near-limb fire on screen');
|
||||
});
|
||||
|
||||
test('applyHorizonCull: a lifted cull point does NOT resurrect a genuinely far-side fire', () => {
|
||||
const occluder = occluderOverAustin(REPRO_HEIGHT_M);
|
||||
const far = billboard(Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, -22));
|
||||
const lifted = Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, 12);
|
||||
applyHorizonCull(collection([far]), occluder, [lifted]);
|
||||
assert.equal(far.show, false, '12 m of lift must not defeat the actual horizon cull');
|
||||
const set = createIconSet();
|
||||
setMeshFloorPreferred(true);
|
||||
try {
|
||||
// Sunk, an 11 m anchor (floor 6 + lift), and well above: each card must
|
||||
// agree with the GPU test on its own row, at limb points where they can.
|
||||
for (const [i, floor] of [-27, 6, 900].entries()) {
|
||||
_clearMeshFloorCellsForTest(); // a reported cell latches its first floor
|
||||
for (const dLon of [LIMB_DLON, LIFT_BAND_DLON]) {
|
||||
const lat = AUSTIN.lat;
|
||||
const lon = AUSTIN.lon + dLon;
|
||||
reportMeshFloorCell(lat, lon, floor);
|
||||
const fire = { index: i, lat, lon, frp: 10, position: null };
|
||||
const cull = fireCullPosition(fire);
|
||||
assert.equal(fire.positionHeight, floor + FIRE_ANCHOR_LIFT_M);
|
||||
const row = fireRow(set, `${floor}:${dLon}`, fire.position);
|
||||
assert.equal(
|
||||
occluder.isPointVisible(cull),
|
||||
gpuShows(set, row, occluder),
|
||||
`floor ${floor} m at +${dLon}°`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
_clearMeshFloorCellsForTest();
|
||||
setMeshFloorPreferred(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('fireCullPosition: negative-geoid anchor renders at the datum but culls lifted', () => {
|
||||
@@ -304,17 +284,21 @@ class MockEvent {
|
||||
emit() { for (const listener of [...this.listeners]) listener(); }
|
||||
}
|
||||
|
||||
/** Canvas stub good enough for the pre-baked glow sprites. */
|
||||
/**
|
||||
* Canvas stub good enough for the pre-baked glow sprites. Its data URL names
|
||||
* the gradient's colours, so each colour stop gets its own atlas slot.
|
||||
*/
|
||||
function canvasStub() {
|
||||
const stops = [];
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: () => ({
|
||||
createRadialGradient: () => ({ addColorStop() {} }),
|
||||
createRadialGradient: () => ({ addColorStop: (offset, color) => stops.push(color) }),
|
||||
fillStyle: null,
|
||||
fillRect() {},
|
||||
}),
|
||||
toDataURL: () => 'data:image/png;base64,iVBORw0KGgo=',
|
||||
toDataURL: () => `data:image/png;base64,iVBORw0KGgo=#${stops.join('|')}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -335,9 +319,8 @@ function rawFire(lat, lon, frp) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Boots the REAL layer against a headless viewer stub with a REAL
|
||||
* Cesium.BillboardCollection, so the production hooks (not just the pure
|
||||
* predicate) are what the assertions exercise.
|
||||
* Boots the REAL layer against a headless viewer stub; what the GPU would draw
|
||||
* is read off the layer's real icon set (`gpuShows`) from the stub camera.
|
||||
*/
|
||||
function createHarness(rawFires) {
|
||||
const originalFetch = globalThis.fetch;
|
||||
@@ -383,13 +366,20 @@ function createHarness(rawFires) {
|
||||
preRender,
|
||||
primitives: {
|
||||
add: (value) => { primitives.push(value); return value; },
|
||||
remove: () => {},
|
||||
remove: (value) => {
|
||||
const at = primitives.indexOf(value);
|
||||
if (at < 0) return false;
|
||||
primitives.splice(at, 1);
|
||||
value.destroy();
|
||||
return true;
|
||||
},
|
||||
contains: () => false,
|
||||
},
|
||||
frameState: { mode: Cesium.SceneMode.SCENE3D, mapProjection: new Cesium.GeographicProjection() },
|
||||
mapProjection: new Cesium.GeographicProjection(),
|
||||
},
|
||||
};
|
||||
fakeInput(viewer);
|
||||
|
||||
const layer = createFirmsHeatmapLayer({
|
||||
id: 'firms',
|
||||
@@ -399,7 +389,6 @@ function createHarness(rawFires) {
|
||||
setVisible: () => {},
|
||||
clearSource: () => {},
|
||||
},
|
||||
screenSpaceEventHandlerFactory: () => ({ setInputAction() {}, destroy() {} }),
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -407,15 +396,19 @@ function createHarness(rawFires) {
|
||||
viewer,
|
||||
preRender,
|
||||
moveEnd,
|
||||
/** The real BillboardCollection the layer created. */
|
||||
billboards: () => primitives[0],
|
||||
/** The icon set the layer draws its detections from. */
|
||||
icons: () => primitives[0].set,
|
||||
/** Primitives currently in the stub scene. */
|
||||
primitives,
|
||||
/** Latest entry list published to the overlay host. */
|
||||
latestEntries: () => entryCalls[entryCalls.length - 1] || [],
|
||||
/** `show` flags keyed by billboard pick id. */
|
||||
/** Whether the GPU draws each row from the current camera, by pick id. */
|
||||
showById() {
|
||||
const bb = primitives[0];
|
||||
const set = primitives[0].set;
|
||||
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, viewer.camera.positionWC);
|
||||
const out = new Map();
|
||||
for (let i = 0; i < bb.length; i += 1) out.set(bb.get(i).id, bb.get(i).show);
|
||||
for (let row = 0; row < set.count; row += 1)
|
||||
out.set(set.idAt(row), set.show && gpuShows(set, row, occluder));
|
||||
return out;
|
||||
},
|
||||
moveCameraTo(lon, lat, height = REPRO_HEIGHT_M) {
|
||||
@@ -433,7 +426,7 @@ function createHarness(rawFires) {
|
||||
};
|
||||
}
|
||||
|
||||
test('layer: a detection rebuild culls its own freshly-added sprites', async () => {
|
||||
test('layer: far-side detections are culled from the first frame', async () => {
|
||||
const harness = createHarness([
|
||||
rawFire(AUSTIN.lat, AUSTIN.lon, 120),
|
||||
rawFire(ANTIPODE.lat, ANTIPODE.lon, 900),
|
||||
@@ -453,7 +446,7 @@ test('layer: a detection rebuild culls its own freshly-added sprites', async ()
|
||||
}
|
||||
});
|
||||
|
||||
test('layer: camera moveEnd re-culls without a rebuild', async () => {
|
||||
test('layer: the GPU cull follows the camera with no CPU show pass', async () => {
|
||||
const harness = createHarness([
|
||||
rawFire(AUSTIN.lat, AUSTIN.lon, 120),
|
||||
rawFire(TOKYO.lat, TOKYO.lon, 800),
|
||||
@@ -466,6 +459,9 @@ test('layer: camera moveEnd re-culls without a rebuild', async () => {
|
||||
harness.moveCameraTo(TOKYO.lon, TOKYO.lat);
|
||||
harness.moveEnd.emit();
|
||||
|
||||
const set = harness.icons();
|
||||
for (let row = 0; row < set.count; row += 1)
|
||||
assert.equal(set.shown(row), true, `${set.idAt(row)} keeps its row shown; the GPU decides`);
|
||||
const show = harness.showById();
|
||||
assert.equal(show.get('firms-1'), true, 'Tokyo becomes visible once the camera is over it');
|
||||
assert.equal(show.get('firms-0'), false, 'Austin drops behind the limb');
|
||||
@@ -474,32 +470,33 @@ test('layer: camera moveEnd re-culls without a rebuild', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('layer: the throttled preRender watcher re-culls on a camera move', async () => {
|
||||
const harness = createHarness([
|
||||
rawFire(AUSTIN.lat, AUSTIN.lon, 120),
|
||||
rawFire(TOKYO.lat, TOKYO.lon, 800),
|
||||
]);
|
||||
test('layer: a refresh keeps rows and restyles one whose index now holds another fire', async () => {
|
||||
const fires = [rawFire(AUSTIN.lat, AUSTIN.lon, 400), rawFire(TOKYO.lat, TOKYO.lon, 800)];
|
||||
const harness = createHarness(fires);
|
||||
try {
|
||||
harness.layer.init(harness.viewer);
|
||||
await harness.layer.enable(harness.viewer);
|
||||
harness.moveCameraTo(TOKYO.lon, TOKYO.lat);
|
||||
// The watcher is throttled to LOD_CHECK_MS (650 ms) against
|
||||
// performance.now(), whose value at this point depends on process
|
||||
// start-up — so emit, wait out the full throttle window, and emit again.
|
||||
// Whichever tick passes the gate, one of them must run the pass.
|
||||
harness.preRender.emit();
|
||||
await new Promise((resolve) => { setTimeout(resolve, 700); });
|
||||
harness.preRender.emit();
|
||||
const set = harness.icons();
|
||||
const austin = set.row('firms-0');
|
||||
const handle = set.handleAt(austin);
|
||||
const before = set.width(austin);
|
||||
const beforeImage = set.imageFar(austin);
|
||||
|
||||
const show = harness.showById();
|
||||
assert.equal(show.get('firms-1'), true, 'Tokyo revealed by the preRender pass');
|
||||
assert.equal(show.get('firms-0'), false, 'Austin culled by the preRender pass');
|
||||
fires[0] = rawFire(AUSTIN.lat + 0.5, AUSTIN.lon, 1);
|
||||
await harness.layer.update();
|
||||
|
||||
const row = set.row('firms-0');
|
||||
assert.equal(set.handleAt(row), handle, 'the row (and its pick id) survives the rebuild');
|
||||
assert.ok(set.width(row) < before, `restyled for the weaker fire: ${set.width(row)} < ${before}`);
|
||||
assert.notEqual(set.imageFar(row), beforeImage, 'a red glow becomes a yellow one');
|
||||
const carto = Cesium.Cartographic.fromCartesian(set.position(row, 0, new Cesium.Cartesian3()));
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(carto.latitude) - (AUSTIN.lat + 0.5)) < 1e-6, 'moved to the new fire');
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('layer: re-enabling after the camera moved does not expose stale far-side sprites', async () => {
|
||||
test('layer: re-enabling after the camera moved shows the icons, culled from the new camera', async () => {
|
||||
const harness = createHarness([
|
||||
rawFire(AUSTIN.lat, AUSTIN.lon, 120),
|
||||
rawFire(TOKYO.lat, TOKYO.lon, 800),
|
||||
@@ -516,10 +513,10 @@ test('layer: re-enabling after the camera moved does not expose stale far-side s
|
||||
await harness.layer.enable(harness.viewer);
|
||||
|
||||
// Assert with NO camera event fired and NO refetch (fires are already
|
||||
// loaded, so enable() does not reload): the enable path itself must have
|
||||
// re-culled before the collection went visible.
|
||||
// loaded, so enable() does not reload): the GPU culls from the current
|
||||
// camera on the first frame.
|
||||
const show = harness.showById();
|
||||
assert.equal(harness.billboards().show, true, 'the collection is visible again');
|
||||
assert.equal(harness.icons().show, true, 'the icon set is visible again');
|
||||
assert.equal(show.get('firms-1'), true, 'the Tokyo fire is revealed at enable time');
|
||||
assert.equal(show.get('firms-0'), false, 'the Austin fire is NOT left shining through the planet');
|
||||
} finally {
|
||||
@@ -527,6 +524,47 @@ test('layer: re-enabling after the camera moved does not expose stale far-side s
|
||||
}
|
||||
});
|
||||
|
||||
test('layer: a refresh drops rows whose fires left, and an empty one drops them all', async () => {
|
||||
const fires = [
|
||||
rawFire(AUSTIN.lat, AUSTIN.lon, 120),
|
||||
rawFire(AUSTIN.lat + 1, AUSTIN.lon, 300),
|
||||
rawFire(AUSTIN.lat + 2, AUSTIN.lon, 200),
|
||||
];
|
||||
const harness = createHarness(fires);
|
||||
try {
|
||||
harness.layer.init(harness.viewer);
|
||||
await harness.layer.enable(harness.viewer);
|
||||
const set = harness.icons();
|
||||
assert.equal(set.count, 3);
|
||||
|
||||
// Index 0 still holds a fire; 1 and 2 are gone, so their rows swap out.
|
||||
fires.splice(1, 2);
|
||||
await harness.layer.update();
|
||||
assert.deepEqual(
|
||||
Array.from({ length: set.count }, (_, row) => set.idAt(row)),
|
||||
['firms-0'],
|
||||
);
|
||||
assert.equal(set.metaAt(set.row('firms-0')).fire.lat, AUSTIN.lat, 'the kept row holds its own fire');
|
||||
|
||||
fires.length = 0;
|
||||
await harness.layer.update();
|
||||
assert.equal(set.count, 0, 'an empty refresh leaves no sprite behind');
|
||||
assert.equal(harness.latestEntries().length, 0, 'nor any card');
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('layer: destroy takes the icons out of the scene', async () => {
|
||||
const harness = createHarness([rawFire(AUSTIN.lat, AUSTIN.lon, 120)]);
|
||||
harness.layer.init(harness.viewer);
|
||||
await harness.layer.enable(harness.viewer);
|
||||
const primitive = harness.primitives[0];
|
||||
harness.cleanup();
|
||||
assert.equal(harness.primitives.includes(primitive), false);
|
||||
assert.equal(primitive.isDestroyed(), true);
|
||||
});
|
||||
|
||||
test('layer: a near-limb fire on a sub-ellipsoid anchor is not culled by its datum', async () => {
|
||||
const lat = AUSTIN.lat;
|
||||
const lon = AUSTIN.lon + LIMB_DLON;
|
||||
@@ -542,14 +580,14 @@ test('layer: a near-limb fire on a sub-ellipsoid anchor is not culled by its dat
|
||||
harness.layer.init(harness.viewer);
|
||||
await harness.layer.enable(harness.viewer);
|
||||
|
||||
const bb = harness.billboards();
|
||||
const sunken = bb.get(1);
|
||||
const carto = Cesium.Cartographic.fromCartesian(sunken.position);
|
||||
const set = harness.icons();
|
||||
const sunken = set.row('firms-1');
|
||||
const carto = Cesium.Cartographic.fromCartesian(set.position(sunken, 0, new Cesium.Cartesian3()));
|
||||
assert.ok(
|
||||
Math.abs(carto.height - (-27 + FIRE_ANCHOR_LIFT_M)) < 0.5,
|
||||
`the sprite still RENDERS at the datum-correct anchor, got ${carto.height}`,
|
||||
);
|
||||
assert.equal(sunken.show, true, 'the near-limb fire stays on screen (lifted cull anchor)');
|
||||
assert.equal(harness.showById().get('firms-1'), true, 'the near-limb fire stays on screen (lifted cull anchor)');
|
||||
assert.equal(harness.showById().get('firms-0'), true, 'the camera-nadir fire is unaffected');
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
|
||||
Loaded 100 of 478 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user