mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-13 23:02:22 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec354565c8 | ||
|
|
1d81955f1d |
No files matched your search
@@ -1,3 +0,0 @@
|
||||
- Draw a vessel whose feed sends no length at its ship type's typical size, so
|
||||
a cruise ship or container ship no longer shows as its type's smallest model
|
||||
(samehkhamis, #1715).
|
||||
@@ -0,0 +1,4 @@
|
||||
- The detection overlay no longer rebuilds every bracket on a paint where the
|
||||
view and every contact are unchanged, which cuts frame work by a fifth to a
|
||||
third at a whole-globe view of Live Flights with a still camera (mazy1998,
|
||||
#1719).
|
||||
@@ -7459,6 +7459,28 @@ apply to `public/` assets or built-preview serving.
|
||||
holds the way it stripped detection's would take bracket promptness with it, silently.
|
||||
- Known, pre-existing, and deliberately out of scope here: the overlay's backing store does not
|
||||
re-derive on a DPR change mid-session (`worldOverlay` sizing — untouched by this work).
|
||||
- **A detection paint reuses the sweep before it when nothing it reads has changed
|
||||
(`src/data/detectionPaintCache.js`).** The sweep in `_drawOverlay` (project every contact, fade
|
||||
it, append its bracket, build callout candidates) depends only on the view and on the contacts.
|
||||
Each paint still asks every layer for its contacts, then compares them with what the last sweep
|
||||
was built from: the same objects in the same order, the same position coordinates, and the same
|
||||
value in every field the sweep reads (`PAINTED_OBJECT_FIELDS`), under the same view (profile,
|
||||
style, canvas size, keyhole, camera position, view-projection matrix, UI rectangles; see
|
||||
`_paintViewOf`). On a match the kept bracket paths, counts and callout candidates are used again
|
||||
and only the strokes and callouts are repainted. No layer has to announce a change, so none can
|
||||
leave a stale picture behind. A sweep that READS a new object field or a new view value must add
|
||||
it to those two lists; `detectionHost.test.mjs` compares 41 frames against a full sweep, canvas
|
||||
call for canvas call.
|
||||
- Never reused or kept: a solve paint (the sweep gathers the cohort), any paint while the Cyber
|
||||
sonar sweep or a Global Context echo is live (brackets change with the clock), and any sweep
|
||||
that reached a depth-tested contact (its answer comes from the scene).
|
||||
- Where it pays: a held camera with contacts that move in steps. Live Flights re-positions the
|
||||
fleet every 80 ms, so at 60 fps about seven paints in ten reuse a sweep. Once a frame takes
|
||||
longer than 80 ms every paint sees moved aircraft and none is reused; after eight such sweeps
|
||||
the contacts are only recorded on every eighth, so the comparison costs nothing there.
|
||||
Satellites move on every frame and are never reused.
|
||||
- `getDetectionDiagnostics().paintReuseCount` (and `data-paint-reuse-count`) counts reused
|
||||
paints since `initDetection`; `placementBuildCount` is 0 on a reused paint.
|
||||
- The detection MODE BANNER (`DENSE VIS:… SRC:… DENS:…% ELASTIC …ms`) is
|
||||
developer telemetry and is HIDDEN by default. It paints only under
|
||||
`?detectDebug=1` (the `trafficDebug` convention), resolved once per
|
||||
|
||||
@@ -1149,7 +1149,6 @@
|
||||
70,
|
||||
79
|
||||
],
|
||||
"typical_len": 300,
|
||||
"rules": [
|
||||
{
|
||||
"min_len": 70,
|
||||
@@ -1189,7 +1188,6 @@
|
||||
80,
|
||||
89
|
||||
],
|
||||
"typical_len": 250,
|
||||
"rules": [
|
||||
{
|
||||
"name_re": "\\bLNG\\b|\\bGAS\\b",
|
||||
@@ -1216,7 +1214,6 @@
|
||||
60,
|
||||
69
|
||||
],
|
||||
"typical_len": 140,
|
||||
"rules": [
|
||||
{
|
||||
"min_len": 70,
|
||||
@@ -1246,7 +1243,6 @@
|
||||
37,
|
||||
37
|
||||
],
|
||||
"typical_len": 19,
|
||||
"rules": [
|
||||
{
|
||||
"min_len": 35,
|
||||
@@ -1277,7 +1273,6 @@
|
||||
30,
|
||||
30
|
||||
],
|
||||
"typical_len": 24,
|
||||
"rules": [
|
||||
{
|
||||
"min_len": 15,
|
||||
@@ -1348,7 +1343,6 @@
|
||||
55,
|
||||
55
|
||||
],
|
||||
"typical_len": 40,
|
||||
"rules": [
|
||||
{
|
||||
"min_len": 25,
|
||||
@@ -1364,7 +1358,6 @@
|
||||
40,
|
||||
49
|
||||
],
|
||||
"typical_len": 40,
|
||||
"rules": [
|
||||
{
|
||||
"min_len": 20,
|
||||
@@ -1380,7 +1373,6 @@
|
||||
33,
|
||||
33
|
||||
],
|
||||
"typical_len": 100,
|
||||
"rules": [
|
||||
{
|
||||
"min_len": 50,
|
||||
|
||||
@@ -2690,6 +2690,7 @@
|
||||
"src/data/detectionDraw.js",
|
||||
"src/data/detectionPolicy.js",
|
||||
"src/data/detectionOcclusion.js",
|
||||
"src/data/detectionPaintCache.js",
|
||||
"src/data/detectionPresentation.js",
|
||||
"src/data/detectionRenderDemand.js",
|
||||
"src/data/sceneOcclusionProbe.js",
|
||||
@@ -4372,6 +4373,7 @@
|
||||
"src/data/detectionRenderDemand.js",
|
||||
"src/data/detectionPresentation.js",
|
||||
"src/data/detectionOcclusion.js",
|
||||
"src/data/detectionPaintCache.js",
|
||||
"src/data/sceneOcclusionProbe.js",
|
||||
"src/data/modelEligibility.js",
|
||||
"src/app/layers/recentImagery.js",
|
||||
|
||||
+139
-11
@@ -51,6 +51,7 @@ import {
|
||||
} from './detectionPolicy.js';
|
||||
import { detectionBracketOpacity } from './detectionPresentation.js';
|
||||
import { SurfaceOcclusion } from './detectionOcclusion.js';
|
||||
import { createDetectionPaintCache } from './detectionPaintCache.js';
|
||||
import { SceneOcclusionProbe } from './sceneOcclusionProbe.js';
|
||||
import {
|
||||
createCyberSonarSampler,
|
||||
@@ -243,6 +244,15 @@ let _hostLane = null;
|
||||
let _calloutLane = null;
|
||||
let _transitBracketPaths = new Map();
|
||||
let _transitBracketAlpha = 1;
|
||||
/**
|
||||
* What the last full sweep of the objects built, kept for the paints that
|
||||
* follow it with the same view and the same objects. See `_drawOverlay`.
|
||||
*/
|
||||
const _paintCache = createDetectionPaintCache();
|
||||
/** Scratch for `_paintViewOf`; the cache copies it when it keeps a paint. */
|
||||
const _paintView = [];
|
||||
/** Paints since `initDetection` that reused a kept sweep (diagnostics). */
|
||||
let _paintReuseCount = 0;
|
||||
/**
|
||||
* Callouts solved by the sensor lane this frame, replayed by the callout lane.
|
||||
* The array and its rows are POOLED — `_calloutCount` is the live length — so a
|
||||
@@ -379,6 +389,8 @@ export function destroyDetection() {
|
||||
_calloutLane?.unregister?.();
|
||||
_calloutLane = null;
|
||||
_transitBracketPaths.clear();
|
||||
_paintCache.clear();
|
||||
_paintReuseCount = 0;
|
||||
_calloutCount = 0;
|
||||
_viewer = null;
|
||||
_layers = [];
|
||||
@@ -643,6 +655,7 @@ function _publishDiagnostics() {
|
||||
dataset.solveMs = String(_lastDiagnostics.solveMs ?? 0);
|
||||
dataset.frameTotalMs = String(_lastDiagnostics.frameTotalMs ?? 0);
|
||||
dataset.throttleSkipCount = String(_lastDiagnostics.throttleSkipCount ?? 0);
|
||||
dataset.paintReuseCount = String(_lastDiagnostics.paintReuseCount ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -718,6 +731,7 @@ function _applyModeState() {
|
||||
_syncSurfaceVisibility();
|
||||
_setLanesActive(false);
|
||||
_labelArbiter.clear();
|
||||
_paintCache.clear();
|
||||
_surfaceOcclusion.clear();
|
||||
_surfaceSuppressedKeys.clear();
|
||||
_occlusionProbe.reset();
|
||||
@@ -1259,15 +1273,53 @@ function _materializeCandidate(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every value of the view that the object sweep in `_drawOverlay` and the
|
||||
* callout candidates it builds read, in a fixed order, for
|
||||
* `detectionPaintCache`. A value the sweep starts to read belongs here.
|
||||
*/
|
||||
function _paintViewOf(frame, keyholeOutsideOpacity, cyberMapActive) {
|
||||
const view = _paintView;
|
||||
const { keyhole, cameraPosition, viewProjectionMatrix, uiRects } = frame;
|
||||
let n = 0;
|
||||
view[n++] = _mode;
|
||||
view[n++] = _theme;
|
||||
view[n++] = _charWidth;
|
||||
view[n++] = frame.width;
|
||||
view[n++] = frame.height;
|
||||
view[n++] = keyholeOutsideOpacity;
|
||||
view[n++] = cyberMapActive;
|
||||
view[n++] = keyhole?.centerX;
|
||||
view[n++] = keyhole?.centerY;
|
||||
view[n++] = keyhole?.radius;
|
||||
view[n++] = keyhole?.featherPx;
|
||||
view[n++] = cameraPosition?.x;
|
||||
view[n++] = cameraPosition?.y;
|
||||
view[n++] = cameraPosition?.z;
|
||||
for (let i = 0; i < 16; i++) view[n++] = viewProjectionMatrix[i];
|
||||
const rectCount = Array.isArray(uiRects) ? uiRects.length : -1;
|
||||
view[n++] = rectCount;
|
||||
for (let i = 0; i < rectCount; i++) {
|
||||
const rect = uiRects[i];
|
||||
view[n++] = rect?.x;
|
||||
view[n++] = rect?.y;
|
||||
view[n++] = rect?.w;
|
||||
view[n++] = rect?.h;
|
||||
}
|
||||
view.length = n;
|
||||
return view;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paint detection against the shared host frame. Brackets remain broad and
|
||||
* per-frame; rich placements rebuild only for selected/fading identities and
|
||||
* for a bounded cohort on solve ticks.
|
||||
*/
|
||||
function _drawOverlay(frame) {
|
||||
_transitBracketPaths.clear();
|
||||
const { width, height } = frame;
|
||||
if (!_ctx || width <= 0 || height <= 0) {
|
||||
_transitBracketPaths.clear();
|
||||
_paintCache.forget();
|
||||
return {
|
||||
didSolve: false,
|
||||
solveMs: 0,
|
||||
@@ -1309,6 +1361,8 @@ function _drawOverlay(frame) {
|
||||
// callout lane repaints the last solve on frames the sensor lane skips,
|
||||
// leaving rows here would strand the final callsigns on screen after the
|
||||
// last data layer is switched off.
|
||||
_transitBracketPaths.clear();
|
||||
_paintCache.clear();
|
||||
_calloutCount = 0;
|
||||
_ctx.font = FONT;
|
||||
_drawScanlines(width, height, now);
|
||||
@@ -1361,6 +1415,9 @@ function _drawOverlay(frame) {
|
||||
// every host lane. Detection retains the manual scalar projection below.
|
||||
const occluder = frame.occluder;
|
||||
const camPos = frame.cameraPosition;
|
||||
const camX = camPos.x;
|
||||
const camY = camPos.y;
|
||||
const camZ = camPos.z;
|
||||
// Answers to the occlusion tests started on earlier frames land here.
|
||||
_surfaceOcclusion.beginFrame(now);
|
||||
_occlusionProbe.beginFrame(_viewer?.scene, _surfaceOcclusion.now);
|
||||
@@ -1390,7 +1447,7 @@ function _drawOverlay(frame) {
|
||||
if (!_charWidth) _charWidth = _ctx.measureText('0000000000').width / 10 || 6;
|
||||
const tiers = _theme.tiers || null;
|
||||
const colorFor = (key) => (tiers && tiers[key]) || _theme.line;
|
||||
const bracketPaths = new Map();
|
||||
let bracketPaths = new Map();
|
||||
// Global Context sonar echoes: a second bracket, banded by strength, around
|
||||
// each flight or vessel a pulse front has just crossed.
|
||||
// Resting pulses light nothing, so the per-contact echo work is skipped.
|
||||
@@ -1415,14 +1472,41 @@ function _drawOverlay(frame) {
|
||||
};
|
||||
|
||||
let visibleCount = 0;
|
||||
const bracketOpacityCounts = { full: 0, partial: 0, hidden: 0 };
|
||||
const aircraftBracketSectors = { left: 0, front: 0, right: 0 };
|
||||
let bracketOpacityCounts = { full: 0, partial: 0, hidden: 0 };
|
||||
let aircraftBracketSectors = { left: 0, front: 0, right: 0 };
|
||||
let protectedVisibleCount = 0;
|
||||
const candidateMap = new Map();
|
||||
let candidateMap = new Map();
|
||||
const cohortBuilders = shouldSolve ? new Map() : null;
|
||||
const demandByLayer = shouldSolve ? new Map() : null;
|
||||
let placementBuildCount = 0;
|
||||
for (let i = 0; i < objects.length; i++) {
|
||||
|
||||
// The sweep below is the cost of this paint: every object projected, faded
|
||||
// and given its bracket. What it builds depends only on the view and on the
|
||||
// objects, so when both are exactly what the last sweep had, that sweep's
|
||||
// paths, counts and callout candidates are used again and only the strokes
|
||||
// and the callouts are repainted. Three things keep a paint from being
|
||||
// reused or kept:
|
||||
// - the Cyber sonar sweep and Global Context echoes change brackets with the
|
||||
// clock alone;
|
||||
// - a solve needs the sweep to gather its cohort;
|
||||
// - a depth-tested contact gets its answer from the scene, not from here.
|
||||
// A kept solve paint holds the solve's own candidates too, so the callouts a
|
||||
// solve has just chosen are placed from the same map either way.
|
||||
const paintKeepable = !sonarActive && !echoField;
|
||||
const paintView = paintKeepable
|
||||
? _paintViewOf(frame, keyholeOutsideOpacity, cyberMapActive)
|
||||
: null;
|
||||
const kept =
|
||||
paintKeepable && !shouldSolve
|
||||
? _paintCache.reuse(paintView, objects)
|
||||
: null;
|
||||
let depthTestedSwept = false;
|
||||
let identityLayerId;
|
||||
let renderSources;
|
||||
let selectedSources;
|
||||
if (!kept) _transitBracketPaths.clear();
|
||||
const sweepCount = kept ? 0 : objects.length;
|
||||
for (let i = 0; i < sweepCount; i++) {
|
||||
const obj = objects[i];
|
||||
if (!obj.position) continue;
|
||||
// Skip objects occluded by the ellipsoid (behind the horizon)
|
||||
@@ -1450,8 +1534,12 @@ function _drawOverlay(frame) {
|
||||
let halfW;
|
||||
let halfH;
|
||||
if (obj.type === 'AIR') {
|
||||
// Cartesian3.distance, without its two argument checks per object.
|
||||
const dx = camX - px;
|
||||
const dy = camY - py;
|
||||
const dz = camZ - pz;
|
||||
const bscale = nearFarScale(
|
||||
Cesium.Cartesian3.distance(camPos, obj.position),
|
||||
Math.sqrt(dx * dx + dy * dy + dz * dz),
|
||||
BILL_NEAR,
|
||||
BILL_NEAR_SCALE,
|
||||
BILL_FAR,
|
||||
@@ -1477,6 +1565,7 @@ function _drawOverlay(frame) {
|
||||
// Something the scene drew in front of a depth-tested contact hides its
|
||||
// bracket and callout too, on every frame, not only on solves.
|
||||
if (obj.depthTested === true) {
|
||||
depthTestedSwept = true;
|
||||
const testedLayerId = obj._layerId || 'unknown';
|
||||
const testedSourceId = obj.sourceId ?? obj.id ?? i;
|
||||
const hidden = _surfaceOcclusion.isHidden(
|
||||
@@ -1557,14 +1646,29 @@ function _drawOverlay(frame) {
|
||||
if (!primary && !micro) continue;
|
||||
const layerId = obj._layerId || 'unknown';
|
||||
const sourceId = obj.sourceId ?? obj.id ?? i;
|
||||
const liveForRender = renderIdentities.get(layerId)?.has(sourceId) || false;
|
||||
// Objects arrive a layer at a time, so the layer's sets are looked up once
|
||||
// per run of objects, not once per object.
|
||||
if (layerId !== identityLayerId) {
|
||||
identityLayerId = layerId;
|
||||
renderSources = renderIdentities.get(layerId);
|
||||
selectedSources = selectedIdentities?.get(layerId);
|
||||
}
|
||||
const liveForRender = renderSources?.has(sourceId) || false;
|
||||
if (!shouldSolve && !liveForRender) continue;
|
||||
|
||||
obj._cohortSourceId = sourceId;
|
||||
obj._cohortPriority = _semanticPriority(obj);
|
||||
obj._cohortBand =
|
||||
admissionAlpha >= 0.999 ? 8 : Math.floor(admissionAlpha * 8);
|
||||
obj._cohortHash = stableIdentityHash(layerId, sourceId);
|
||||
// The hash is of the identity alone, so it is worked out once per identity.
|
||||
if (
|
||||
obj._cohortHashLayerId !== layerId ||
|
||||
obj._cohortHashSourceId !== sourceId
|
||||
) {
|
||||
obj._cohortHash = stableIdentityHash(layerId, sourceId);
|
||||
obj._cohortHashLayerId = layerId;
|
||||
obj._cohortHashSourceId = sourceId;
|
||||
}
|
||||
obj._candidateScreenX = sx;
|
||||
obj._candidateScreenY = sy;
|
||||
obj._candidateHalfW = halfW;
|
||||
@@ -1578,8 +1682,7 @@ function _drawOverlay(frame) {
|
||||
demandByLayer.set(layerId, (demandByLayer.get(layerId) || 0) + 1);
|
||||
if (!cohortBuilders.has(layerId))
|
||||
cohortBuilders.set(layerId, new BoundedCohort(256));
|
||||
const incumbent =
|
||||
selectedIdentities?.get(layerId)?.has(sourceId) || false;
|
||||
const incumbent = selectedSources?.has(sourceId) || false;
|
||||
cohortBuilders.get(layerId).consider(obj, incumbent);
|
||||
}
|
||||
|
||||
@@ -1599,6 +1702,29 @@ function _drawOverlay(frame) {
|
||||
}
|
||||
// Start this frame's occlusion tests; nothing here waits for them.
|
||||
_occlusionProbe.endFrame();
|
||||
if (kept) {
|
||||
_paintReuseCount++;
|
||||
({
|
||||
bracketPaths,
|
||||
visibleCount,
|
||||
bracketOpacityCounts,
|
||||
aircraftBracketSectors,
|
||||
protectedVisibleCount,
|
||||
candidateMap,
|
||||
} = kept);
|
||||
} else if (paintKeepable && !depthTestedSwept) {
|
||||
// `candidateMap` is kept by reference: a solve below adds to it.
|
||||
_paintCache.keep(paintView, objects, {
|
||||
bracketPaths,
|
||||
visibleCount,
|
||||
bracketOpacityCounts,
|
||||
aircraftBracketSectors,
|
||||
protectedVisibleCount,
|
||||
candidateMap,
|
||||
});
|
||||
} else {
|
||||
_paintCache.forget();
|
||||
}
|
||||
|
||||
const altitude = _viewer?.camera?.positionCartographic?.height ?? 1e9;
|
||||
const collectiveBudget = labelBudgetFor(altitude, _densityPct);
|
||||
@@ -1782,6 +1908,7 @@ function _drawOverlay(frame) {
|
||||
// drawing them cost it.
|
||||
surfaceProbeTests: _occlusionProbe.lastFrameTests,
|
||||
surfaceProbeMs: _occlusionProbe.lastFrameMs,
|
||||
paintReuseCount: _paintReuseCount,
|
||||
};
|
||||
} else {
|
||||
const selectedCount = _labelArbiter.selectedKeys.size;
|
||||
@@ -1795,6 +1922,7 @@ function _drawOverlay(frame) {
|
||||
_lastDiagnostics.aircraftBracketSectors = aircraftBracketSectors;
|
||||
_lastDiagnostics.bracketPresentationOpacity = bracketPresentationOpacity;
|
||||
_lastDiagnostics.protectedVisibleCount = protectedVisibleCount;
|
||||
_lastDiagnostics.paintReuseCount = _paintReuseCount;
|
||||
}
|
||||
|
||||
_drawScanlines(width, height, now);
|
||||
|
||||
@@ -1661,3 +1661,234 @@ test('a WebGL 1 scene reads depth synchronously and reports it once', () => {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// --- Reusing a paint -------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Contacts as the fleet layers return them: the same objects on every paint,
|
||||
* changed in place. `fresh` returns equal copies instead, which no paint can
|
||||
* reuse, so the two can be compared.
|
||||
*/
|
||||
function steadyContacts({ fresh = false } = {}) {
|
||||
const objects = [
|
||||
{ position: new Cesium.Cartesian3(-0.4, 0.2, 6_356_752), sourceId: 'air-1', id: 'AIRONE', metric: 'FL350', type: 'AIR' },
|
||||
{ position: new Cesium.Cartesian3(0.4, -0.2, 6_356_752), sourceId: 'sat-1', id: 'SATONE', metric: '412KM', type: 'SAT' },
|
||||
{ position: new Cesium.Cartesian3(0.1, 0.5, 6_356_752), sourceId: 'sea-1', id: 'SEAONE', metric: '12 kn', type: 'SEA' },
|
||||
{ position: new Cesium.Cartesian3(-0.2, -0.5, 6_356_752), sourceId: 'mil-1', id: 'MILONE', metric: 'FL410', type: 'AIR', tier: 'military' },
|
||||
];
|
||||
let calls = 0;
|
||||
return {
|
||||
objects,
|
||||
get calls() { return calls; },
|
||||
layer: {
|
||||
id: 'flights',
|
||||
getDetectableObjects() {
|
||||
calls += 1;
|
||||
return fresh
|
||||
? objects.map((object) => ({ ...object, position: Cesium.Cartesian3.clone(object.position) }))
|
||||
: objects;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Everything painted since the trace was last emptied, with each path as its points. */
|
||||
function takePainted(env) {
|
||||
const painted = env.paintTrace.map((call) =>
|
||||
call.map((value) => (value instanceof MockPath2D ? value.points.map((point) => [...point]) : value)));
|
||||
env.paintTrace.length = 0;
|
||||
return painted;
|
||||
}
|
||||
|
||||
const bracketPointCount = (env) => env.detectionCtx.calls
|
||||
.filter(([name]) => name === 'stroke')
|
||||
.reduce((sum, [, path]) => sum + (path?.points?.length ?? 0), 0);
|
||||
|
||||
test('a paint with the same view and the same contacts reuses the last sweep, and paints exactly what a full sweep paints', () => {
|
||||
// The same frames twice: once with contacts a paint can reuse, once with
|
||||
// equal copies it cannot. Every canvas call must match, frame for frame.
|
||||
const frames = (fresh) => {
|
||||
const env = installEnvironment();
|
||||
const contacts = steadyContacts({ fresh });
|
||||
const painted = [];
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [contacts.layer], () => {});
|
||||
setMode('DENSE');
|
||||
settleFrame(env);
|
||||
painted.push(takePainted(env));
|
||||
for (let frame = 1; frame <= 40; frame++) {
|
||||
// Through the run: a contact moves, one is relabelled, one changes
|
||||
// tier, and the camera turns.
|
||||
if (frame === 9) contacts.objects[0].position.x += 0.05;
|
||||
if (frame === 17) contacts.objects[1].metric = '398KM';
|
||||
if (frame === 23) contacts.objects[2].tier = 'military';
|
||||
if (frame === 31) env.viewer.camera.viewMatrix[12] = 0.03;
|
||||
nextFrame(env);
|
||||
painted.push(takePainted(env));
|
||||
}
|
||||
return { painted, reused: getDetectionDiagnostics().paintReuseCount };
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
};
|
||||
const reusing = frames(false);
|
||||
const sweeping = frames(true);
|
||||
assert.equal(sweeping.reused, 0, 'copies are swept on every paint');
|
||||
// 40 frames 16 ms apart: a solve about every eighth, four changes, and the
|
||||
// rest reuse the sweep before them.
|
||||
assert.ok(reusing.reused >= 28, `${reusing.reused} of 40 paints reused a sweep`);
|
||||
assert.equal(reusing.painted.length, sweeping.painted.length);
|
||||
reusing.painted.forEach((frame, index) => {
|
||||
assert.ok(frame.some(([target, name]) => target === 'detection' && name === 'stroke'), `frame ${index} strokes brackets`);
|
||||
assert.deepEqual(frame, sweeping.painted[index], `frame ${index}`);
|
||||
});
|
||||
assert.ok(
|
||||
reusing.painted.some((frame) => frame.some(([, name, text]) => name === 'fillText' && text === 'AIRONE')),
|
||||
'callouts were painted during the run',
|
||||
);
|
||||
});
|
||||
|
||||
test('a reused paint builds no bracket paths and still strokes the kept ones', () => {
|
||||
const env = installEnvironment();
|
||||
const contacts = steadyContacts();
|
||||
const builtPaths = [];
|
||||
class CountingPath2D extends MockPath2D {
|
||||
constructor() { super(); builtPaths.push(this); }
|
||||
}
|
||||
try {
|
||||
globalThis.Path2D = CountingPath2D;
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [contacts.layer], () => {});
|
||||
setMode('DENSE');
|
||||
settleFrame(env);
|
||||
assert.equal(getDetectionDiagnostics().paintReuseCount, 0);
|
||||
const built = builtPaths.length;
|
||||
const points = builtPaths.reduce((sum, path) => sum + path.points.length, 0);
|
||||
assert.ok(points >= 4 * 12, 'four brackets of twelve points were built');
|
||||
|
||||
env.detectionCtx.calls.length = 0;
|
||||
nextFrame(env);
|
||||
assert.equal(getDetectionDiagnostics().paintReuseCount, 1);
|
||||
assert.equal(builtPaths.length, built, 'no path was built');
|
||||
assert.equal(builtPaths.reduce((sum, path) => sum + path.points.length, 0), points, 'no point was added to a kept path');
|
||||
assert.equal(bracketPointCount(env), 4 * 12, 'the kept brackets are stroked');
|
||||
assert.equal(getDetectionDiagnostics().visibleCount, 4);
|
||||
assert.equal(getDetectionDiagnostics().placementBuildCount, 0, 'no placement was built on the reused paint');
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('any change to a contact or to the view is swept on the very next paint', () => {
|
||||
const changes = {
|
||||
'a contact moves': (contacts) => { contacts.objects[0].position.y -= 0.01; },
|
||||
'a contact gets a new position object': (contacts) => {
|
||||
contacts.objects[0].position = new Cesium.Cartesian3(0.3, 0.3, 6_356_752);
|
||||
},
|
||||
'a contact is renamed': (contacts) => { contacts.objects[1].id = 'SATTWO'; },
|
||||
'a contact reading changes': (contacts) => { contacts.objects[0].metric = 'FL360'; },
|
||||
'a contact becomes the tracked one': (contacts) => { contacts.objects[0].skipLabel = true; },
|
||||
'a contact changes tier': (contacts) => { contacts.objects[3].tier = undefined; },
|
||||
'a contact changes type': (contacts) => { contacts.objects[2].type = 'VEH'; },
|
||||
'a contact joins': (contacts) => {
|
||||
contacts.objects.push({ position: new Cesium.Cartesian3(0.6, 0.6, 6_356_752), sourceId: 'air-2', id: 'AIRTWO', type: 'AIR' });
|
||||
},
|
||||
'a contact leaves': (contacts) => { contacts.objects.pop(); },
|
||||
'a contact is replaced by an equal one': (contacts) => { contacts.objects[1] = { ...contacts.objects[1] }; },
|
||||
'two contacts swap places in the list': (contacts) => { contacts.objects.reverse(); },
|
||||
'the camera turns': (contacts, env) => { env.viewer.camera.viewMatrix[13] = 0.02; },
|
||||
'the camera moves': (contacts, env) => {
|
||||
env.viewer.camera.positionWC = new Cesium.Cartesian3(0, 100, 10_000_000);
|
||||
},
|
||||
'the style changes': () => { setDetectionStyle('thermal'); },
|
||||
'the profile changes': () => { setMode('SPARSE'); },
|
||||
};
|
||||
for (const [name, change] of Object.entries(changes)) {
|
||||
const env = installEnvironment();
|
||||
const contacts = steadyContacts();
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [contacts.layer], () => {});
|
||||
setDetectionStyle('normal');
|
||||
setMode('DENSE');
|
||||
settleFrame(env);
|
||||
nextFrame(env);
|
||||
assert.equal(getDetectionDiagnostics().paintReuseCount, 1, `${name}: steady paints reuse`);
|
||||
|
||||
change(contacts, env);
|
||||
nextFrame(env);
|
||||
assert.equal(getDetectionDiagnostics().paintReuseCount, 1, `${name}: swept again`);
|
||||
assert.equal(getDetectionDiagnostics().observationCount, contacts.objects.length, name);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a depth-tested contact, the sonar sweep and echoes are swept on every paint', () => {
|
||||
const run = ({ depthTested = false, sonar = false, echo = false } = {}) => {
|
||||
const env = installEnvironment();
|
||||
const contacts = steadyContacts();
|
||||
if (depthTested) {
|
||||
contacts.objects[0].type = 'VEH';
|
||||
contacts.objects[0].depthTested = true;
|
||||
}
|
||||
if (sonar) {
|
||||
env.document.documentElement = {
|
||||
dataset: { uiTheme: 'cyber', cyberSonarOpacity: '35' },
|
||||
style: { setProperty() {}, removeProperty() {} },
|
||||
};
|
||||
const hud = env.document.createElement('div');
|
||||
hud.id = 'intel-hud';
|
||||
hud.classList = { contains: (className) => className === 'active' };
|
||||
env.document.body.appendChild(hud);
|
||||
}
|
||||
try {
|
||||
if (echo)
|
||||
setAwarenessEchoField({
|
||||
center: Cesium.Cartesian3.fromDegrees(0, 89, 0),
|
||||
radiusM: 250_000,
|
||||
pulse: { count: 1000, periodMs: 1000, startFraction: 0 },
|
||||
echo: { layerIds: ['flights'], fadeMs: 60_000, color: 'rgb(1, 2, 3)', spreadPx: 8, lineWidth: 3.25 },
|
||||
});
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [contacts.layer], () => {});
|
||||
setMode('DENSE');
|
||||
settleFrame(env);
|
||||
const sweeps = contacts.calls;
|
||||
for (let i = 0; i < 30; i++) nextFrame(env);
|
||||
assert.ok(contacts.calls > sweeps, 'frames painted');
|
||||
return getDetectionDiagnostics().paintReuseCount;
|
||||
} finally {
|
||||
clearAwarenessEchoField();
|
||||
env.cleanup();
|
||||
}
|
||||
};
|
||||
assert.ok(run() > 15, 'the same contacts with none of these are reused');
|
||||
assert.equal(run({ depthTested: true }), 0, 'depth-tested');
|
||||
assert.equal(run({ sonar: true }), 0, 'sonar');
|
||||
assert.equal(run({ echo: true }), 0, 'echo');
|
||||
});
|
||||
|
||||
test('turning detection off lets go of the kept sweep', () => {
|
||||
const env = installEnvironment();
|
||||
const contacts = steadyContacts();
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [contacts.layer], () => {});
|
||||
setMode('DENSE');
|
||||
settleFrame(env);
|
||||
nextFrame(env);
|
||||
assert.equal(getDetectionDiagnostics().paintReuseCount, 1);
|
||||
setMode('OFF');
|
||||
setMode('DENSE');
|
||||
// The first paint back is a solve, and the one after it reuses that.
|
||||
nextFrame(env);
|
||||
assert.equal(getDetectionDiagnostics().paintReuseCount, 1);
|
||||
nextFrame(env);
|
||||
assert.equal(getDetectionDiagnostics().paintReuseCount, 2);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* @module detectionPaintCache
|
||||
* @description Lets the detection overlay keep what one paint built (bracket
|
||||
* paths, counts, callout candidates) and use it again on the next paint when
|
||||
* that paint would build exactly the same thing.
|
||||
*
|
||||
* Nothing announces a change to this cache. Each paint hands over the values
|
||||
* the sweep reads from the view and the objects the layers returned, and the
|
||||
* cache compares them, value by value, with what the kept paint was built
|
||||
* from. A layer therefore cannot leave a stale picture behind by forgetting to
|
||||
* say it changed: an object that moved, appeared, left or was relabelled is
|
||||
* seen here, and the sweep runs again.
|
||||
*
|
||||
* Pure: no Cesium, no DOM.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The object fields the detection sweep and its callout candidates read.
|
||||
* Position is compared separately, by coordinate. `sameFields` and
|
||||
* `recordFields` below name each of these outright, in this order, because a
|
||||
* field read by a name held in a variable costs several times as much, and
|
||||
* this runs for every object on every paint.
|
||||
*/
|
||||
export const PAINTED_OBJECT_FIELDS = Object.freeze([
|
||||
'_layerId',
|
||||
'type',
|
||||
'tier',
|
||||
'skipLabel',
|
||||
'id',
|
||||
'metric',
|
||||
'sourceId',
|
||||
'bracketHalfWidth',
|
||||
'bracketHalfHeight',
|
||||
'depthTested',
|
||||
'cardSourceId',
|
||||
'cardEntryId',
|
||||
]);
|
||||
|
||||
/**
|
||||
* After this many sweeps in a row with no reuse between them, the objects are
|
||||
* only recorded on every RECORD_STRIDE-th sweep. A scene whose objects move on
|
||||
* every paint can never reuse one, so recording each of them would be work
|
||||
* with no return; a scene that settles is picked up again within RECORD_STRIDE
|
||||
* paints.
|
||||
*/
|
||||
export const SWEEPS_BEFORE_BACKOFF = 8;
|
||||
export const RECORD_STRIDE = 8;
|
||||
|
||||
const FIELD_COUNT = PAINTED_OBJECT_FIELDS.length;
|
||||
|
||||
function sameFields(object, fields, f) {
|
||||
return (
|
||||
object._layerId === fields[f] &&
|
||||
object.type === fields[f + 1] &&
|
||||
object.tier === fields[f + 2] &&
|
||||
object.skipLabel === fields[f + 3] &&
|
||||
object.id === fields[f + 4] &&
|
||||
object.metric === fields[f + 5] &&
|
||||
object.sourceId === fields[f + 6] &&
|
||||
object.bracketHalfWidth === fields[f + 7] &&
|
||||
object.bracketHalfHeight === fields[f + 8] &&
|
||||
object.depthTested === fields[f + 9] &&
|
||||
object.cardSourceId === fields[f + 10] &&
|
||||
object.cardEntryId === fields[f + 11]
|
||||
);
|
||||
}
|
||||
|
||||
/** Most fields are what they were at the last sweep; only the others are written. */
|
||||
function recordFields(object, fields, f) {
|
||||
let value;
|
||||
if ((value = object._layerId) !== fields[f]) fields[f] = value;
|
||||
if ((value = object.type) !== fields[f + 1]) fields[f + 1] = value;
|
||||
if ((value = object.tier) !== fields[f + 2]) fields[f + 2] = value;
|
||||
if ((value = object.skipLabel) !== fields[f + 3]) fields[f + 3] = value;
|
||||
if ((value = object.id) !== fields[f + 4]) fields[f + 4] = value;
|
||||
if ((value = object.metric) !== fields[f + 5]) fields[f + 5] = value;
|
||||
if ((value = object.sourceId) !== fields[f + 6]) fields[f + 6] = value;
|
||||
if ((value = object.bracketHalfWidth) !== fields[f + 7])
|
||||
fields[f + 7] = value;
|
||||
if ((value = object.bracketHalfHeight) !== fields[f + 8])
|
||||
fields[f + 8] = value;
|
||||
if ((value = object.depthTested) !== fields[f + 9]) fields[f + 9] = value;
|
||||
if ((value = object.cardSourceId) !== fields[f + 10]) fields[f + 10] = value;
|
||||
if ((value = object.cardEntryId) !== fields[f + 11]) fields[f + 11] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {{
|
||||
* reuse: (view: Array, objects: Array<object>) => (object|null),
|
||||
* keep: (view: Array, objects: Array<object>, built: object) => boolean,
|
||||
* forget: () => void,
|
||||
* clear: () => void,
|
||||
* }}
|
||||
*/
|
||||
export function createDetectionPaintCache() {
|
||||
let built = null;
|
||||
let viewKept = [];
|
||||
let count = 0;
|
||||
let references = [];
|
||||
let fields = [];
|
||||
let coordinates = new Float64Array(0);
|
||||
let positioned = new Uint8Array(0);
|
||||
let sweepsSinceReuse = 0;
|
||||
|
||||
function sameView(view) {
|
||||
if (view.length !== viewKept.length) return false;
|
||||
for (let i = 0; i < view.length; i++)
|
||||
if (view[i] !== viewKept[i]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function sameObjects(objects) {
|
||||
if (objects.length !== count) return false;
|
||||
for (let i = 0, c = 0, f = 0; i < count; i++, c += 3, f += FIELD_COUNT) {
|
||||
const object = objects[i];
|
||||
if (object !== references[i]) return false;
|
||||
const position = object.position;
|
||||
if (position) {
|
||||
// A coordinate that is not a number never equals the kept one, so such
|
||||
// an object is swept again on every paint.
|
||||
if (
|
||||
positioned[i] !== 1 ||
|
||||
position.x !== coordinates[c] ||
|
||||
position.y !== coordinates[c + 1] ||
|
||||
position.z !== coordinates[c + 2]
|
||||
)
|
||||
return false;
|
||||
} else if (positioned[i] !== 0) {
|
||||
return false;
|
||||
}
|
||||
if (!sameFields(object, fields, f)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* What the kept paint built, when `view` and `objects` are what it was
|
||||
* built from; otherwise null.
|
||||
* @param {Array} view - Every value of the view the sweep reads, in a fixed order.
|
||||
* @param {Array<object>} objects - The objects the layers returned this paint.
|
||||
*/
|
||||
reuse(view, objects) {
|
||||
if (!built || !sameView(view) || !sameObjects(objects)) return null;
|
||||
sweepsSinceReuse = 0;
|
||||
return built;
|
||||
},
|
||||
|
||||
/**
|
||||
* Keep `result` as the result of sweeping `objects` under `view`.
|
||||
* @returns {boolean} False when recording was skipped (see RECORD_STRIDE).
|
||||
*/
|
||||
keep(view, objects, result) {
|
||||
sweepsSinceReuse++;
|
||||
if (
|
||||
sweepsSinceReuse > SWEEPS_BEFORE_BACKOFF &&
|
||||
(sweepsSinceReuse - SWEEPS_BEFORE_BACKOFF) % RECORD_STRIDE !== 0
|
||||
) {
|
||||
built = null;
|
||||
return false;
|
||||
}
|
||||
viewKept = view.slice();
|
||||
count = objects.length;
|
||||
if (positioned.length < count) {
|
||||
// Room to spare, so a feed that grows by a few contacts a poll does
|
||||
// not allocate on each of them.
|
||||
const room = Math.ceil(count * 1.25);
|
||||
coordinates = new Float64Array(room * 3);
|
||||
positioned = new Uint8Array(room);
|
||||
references = new Array(room).fill(undefined);
|
||||
fields = new Array(room * FIELD_COUNT).fill(undefined);
|
||||
}
|
||||
for (let i = 0, c = 0, f = 0; i < count; i++, c += 3, f += FIELD_COUNT) {
|
||||
const object = objects[i];
|
||||
if (references[i] !== object) references[i] = object;
|
||||
const position = object.position;
|
||||
positioned[i] = position ? 1 : 0;
|
||||
if (position) {
|
||||
coordinates[c] = position.x;
|
||||
coordinates[c + 1] = position.y;
|
||||
coordinates[c + 2] = position.z;
|
||||
}
|
||||
recordFields(object, fields, f);
|
||||
}
|
||||
built = result;
|
||||
return true;
|
||||
},
|
||||
|
||||
/** Reuse nothing until the next `keep`. */
|
||||
forget() {
|
||||
built = null;
|
||||
},
|
||||
|
||||
/** Forget the kept paint and let go of its objects. */
|
||||
clear() {
|
||||
built = null;
|
||||
viewKept = [];
|
||||
count = 0;
|
||||
references = [];
|
||||
fields = [];
|
||||
coordinates = new Float64Array(0);
|
||||
positioned = new Uint8Array(0);
|
||||
sweepsSinceReuse = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createDetectionPaintCache,
|
||||
PAINTED_OBJECT_FIELDS,
|
||||
RECORD_STRIDE,
|
||||
SWEEPS_BEFORE_BACKOFF,
|
||||
} from './detectionPaintCache.js';
|
||||
|
||||
const contacts = () => [
|
||||
{
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
_layerId: 'flights',
|
||||
sourceId: 'a',
|
||||
id: 'A',
|
||||
metric: 'FL300',
|
||||
type: 'AIR',
|
||||
},
|
||||
{
|
||||
position: { x: 4, y: 5, z: 6 },
|
||||
_layerId: 'vessels',
|
||||
sourceId: 'b',
|
||||
id: 'B',
|
||||
type: 'SEA',
|
||||
},
|
||||
{ _layerId: 'cctv', sourceId: 'c', id: 'C', type: 'CAM' },
|
||||
];
|
||||
const view = () => ['DENSE', 800, 600, 0.25, 1, 0, 0, 1];
|
||||
|
||||
function kept() {
|
||||
const cache = createDetectionPaintCache();
|
||||
const objects = contacts();
|
||||
const built = { paths: 'kept' };
|
||||
assert.equal(cache.keep(view(), objects, built), true);
|
||||
return { cache, objects, built };
|
||||
}
|
||||
|
||||
test('the kept sweep is handed back for the same view and the same objects', () => {
|
||||
const { cache, objects, built } = kept();
|
||||
assert.equal(cache.reuse(view(), objects), built);
|
||||
assert.equal(cache.reuse(view(), objects), built, 'and again');
|
||||
// The same objects in a new list are still the same objects.
|
||||
assert.equal(cache.reuse(view(), [...objects]), built);
|
||||
});
|
||||
|
||||
test('nothing is handed back before a sweep is kept, or after it is forgotten', () => {
|
||||
const cache = createDetectionPaintCache();
|
||||
const objects = contacts();
|
||||
assert.equal(cache.reuse(view(), objects), null);
|
||||
cache.keep(view(), objects, {});
|
||||
cache.forget();
|
||||
assert.equal(cache.reuse(view(), objects), null);
|
||||
cache.keep(view(), objects, {});
|
||||
cache.clear();
|
||||
assert.equal(cache.reuse(view(), objects), null);
|
||||
});
|
||||
|
||||
test('any value of the view that differs is a miss', () => {
|
||||
for (let i = 0; i < view().length; i++) {
|
||||
const { cache, objects } = kept();
|
||||
const changed = view();
|
||||
changed[i] = typeof changed[i] === 'number' ? changed[i] + 1e-9 : 'SPARSE';
|
||||
assert.equal(cache.reuse(changed, objects), null, `view value ${i}`);
|
||||
}
|
||||
const { cache, objects } = kept();
|
||||
assert.equal(cache.reuse([...view(), 0], objects), null, 'one value more');
|
||||
assert.equal(
|
||||
cache.reuse(view().slice(0, -1), objects),
|
||||
null,
|
||||
'one value fewer',
|
||||
);
|
||||
});
|
||||
|
||||
test('a view value that is not a number never matches itself', () => {
|
||||
const cache = createDetectionPaintCache();
|
||||
const objects = contacts();
|
||||
cache.keep([NaN], objects, {});
|
||||
assert.equal(cache.reuse([NaN], objects), null);
|
||||
});
|
||||
|
||||
test('the view is copied when kept, so the caller may write the next one over it', () => {
|
||||
const cache = createDetectionPaintCache();
|
||||
const objects = contacts();
|
||||
const scratch = view();
|
||||
const built = {};
|
||||
cache.keep(scratch, objects, built);
|
||||
scratch[1] = 1024;
|
||||
assert.equal(cache.reuse(scratch, objects), null);
|
||||
scratch[1] = 800;
|
||||
assert.equal(cache.reuse(scratch, objects), built);
|
||||
});
|
||||
|
||||
test('a coordinate changed in place is a miss, however small', () => {
|
||||
for (const axis of ['x', 'y', 'z']) {
|
||||
const { cache, objects } = kept();
|
||||
objects[1].position[axis] += 1e-9;
|
||||
assert.equal(cache.reuse(view(), objects), null, axis);
|
||||
}
|
||||
});
|
||||
|
||||
test('a position object swapped for an equal one is still the same paint', () => {
|
||||
const { cache, objects, built } = kept();
|
||||
objects[0].position = { x: 1, y: 2, z: 3 };
|
||||
assert.equal(cache.reuse(view(), objects), built);
|
||||
});
|
||||
|
||||
test('a position that appears, goes away or is not a number is a miss', () => {
|
||||
let { cache, objects } = kept();
|
||||
objects[2].position = { x: 0, y: 0, z: 0 };
|
||||
assert.equal(cache.reuse(view(), objects), null, 'appears');
|
||||
|
||||
({ cache, objects } = kept());
|
||||
objects[0].position = null;
|
||||
assert.equal(cache.reuse(view(), objects), null, 'goes away');
|
||||
|
||||
cache = createDetectionPaintCache();
|
||||
objects = contacts();
|
||||
objects[0].position.x = NaN;
|
||||
cache.keep(view(), objects, {});
|
||||
assert.equal(cache.reuse(view(), objects), null, 'not a number');
|
||||
});
|
||||
|
||||
test('every field the sweep reads is compared', () => {
|
||||
for (const field of PAINTED_OBJECT_FIELDS) {
|
||||
const { cache, objects, built } = kept();
|
||||
const before = objects[0][field];
|
||||
objects[0][field] = before === 'changed' ? 'again' : 'changed';
|
||||
assert.equal(cache.reuse(view(), objects), null, field);
|
||||
objects[0][field] = before;
|
||||
assert.equal(cache.reuse(view(), objects), built, `${field} put back`);
|
||||
}
|
||||
});
|
||||
|
||||
test('a field the sweep does not read may change freely', () => {
|
||||
const { cache, objects, built } = kept();
|
||||
objects[0]._candidateScreenX = 412;
|
||||
objects[0]._cohortHash = 7;
|
||||
assert.equal(cache.reuse(view(), objects), built);
|
||||
});
|
||||
|
||||
test('a different object, count or order is a miss', () => {
|
||||
let { cache, objects } = kept();
|
||||
assert.equal(cache.reuse(view(), contacts()), null, 'equal copies');
|
||||
|
||||
({ cache, objects } = kept());
|
||||
assert.equal(cache.reuse(view(), objects.slice(0, 2)), null, 'one fewer');
|
||||
assert.equal(cache.reuse(view(), [...objects, objects[0]]), null, 'one more');
|
||||
|
||||
({ cache, objects } = kept());
|
||||
assert.equal(
|
||||
cache.reuse(view(), [objects[1], objects[0], objects[2]]),
|
||||
null,
|
||||
'order',
|
||||
);
|
||||
});
|
||||
|
||||
test('keeping again replaces what was kept, for more or fewer objects', () => {
|
||||
const { cache, objects, built } = kept();
|
||||
const more = [...objects, ...contacts(), ...contacts()];
|
||||
const builtMore = {};
|
||||
cache.keep(view(), more, builtMore);
|
||||
assert.equal(cache.reuse(view(), objects), null);
|
||||
assert.equal(cache.reuse(view(), more), builtMore);
|
||||
cache.keep(view(), objects, built);
|
||||
assert.equal(cache.reuse(view(), more), null);
|
||||
assert.equal(cache.reuse(view(), objects), built);
|
||||
});
|
||||
|
||||
test('objects that change on every paint are recorded only now and then', () => {
|
||||
const cache = createDetectionPaintCache();
|
||||
const objects = contacts();
|
||||
const recorded = [];
|
||||
const sweep = () => {
|
||||
objects[0].position.x += 1;
|
||||
assert.equal(cache.reuse(view(), objects), null);
|
||||
recorded.push(cache.keep(view(), objects, {}));
|
||||
};
|
||||
for (let i = 0; i < SWEEPS_BEFORE_BACKOFF; i++) sweep();
|
||||
assert.ok(recorded.every(Boolean), 'the first sweeps are all recorded');
|
||||
|
||||
recorded.length = 0;
|
||||
for (let i = 0; i < RECORD_STRIDE * 5; i++) sweep();
|
||||
assert.equal(recorded.filter(Boolean).length, 5, 'then one in every stride');
|
||||
|
||||
// A skipped sweep leaves nothing behind to reuse by mistake.
|
||||
const built = {};
|
||||
let wasRecorded = cache.keep(view(), objects, built);
|
||||
assert.equal(wasRecorded, false);
|
||||
assert.equal(cache.reuse(view(), objects), null);
|
||||
|
||||
// Once the objects hold still, the next recorded sweep is reused, and
|
||||
// every sweep after a reuse is recorded again.
|
||||
while (!wasRecorded) wasRecorded = cache.keep(view(), objects, built);
|
||||
assert.equal(cache.reuse(view(), objects), built);
|
||||
for (let i = 0; i < SWEEPS_BEFORE_BACKOFF; i++)
|
||||
assert.equal(cache.keep(view(), objects, built), true);
|
||||
});
|
||||
+9
-15
@@ -133,9 +133,8 @@ export function hullBeam(beam) {
|
||||
/**
|
||||
* The model for a vessel of AIS ship type `shipType` and the reported length
|
||||
* and beam (metres) and name: the first rule that matches in the first type
|
||||
* range that has one, else `default_vessel`. Drawn at the reported length;
|
||||
* when it is unknown (most live feeds send none), chosen and drawn at the
|
||||
* range's `typical_len`, else as built.
|
||||
* range that has one, else `default_vessel`. Drawn at the reported length, as
|
||||
* built when it is unknown.
|
||||
* @param {object} types Parsed `types.json`.
|
||||
* @param {number} [shipType]
|
||||
* @param {number} [length]
|
||||
@@ -146,14 +145,13 @@ export function hullBeam(beam) {
|
||||
export function resolveVessel(types, shipType, length, beam, name) {
|
||||
const L = hullLength(length) ?? 0;
|
||||
const B = hullBeam(beam) ?? 0;
|
||||
const drawn = L > 0 ? L : null;
|
||||
if (shipType !== undefined && shipType !== null) {
|
||||
for (const group of types.vessels) {
|
||||
if (!(shipType >= group.types[0] && shipType <= group.types[1])) continue;
|
||||
const len = L > 0 ? L : (group.typical_len ?? 0);
|
||||
const drawn = len > 0 ? len : null;
|
||||
for (const rule of group.rules) {
|
||||
if (rule.min_len && len < rule.min_len) continue;
|
||||
if (rule.max_len && len > rule.max_len) continue;
|
||||
if (rule.min_len && L < rule.min_len) continue;
|
||||
if (rule.max_len && L > rule.max_len) continue;
|
||||
if (rule.name_re && !new RegExp(rule.name_re).test(name ?? ''))
|
||||
continue;
|
||||
if (rule.max_bl && !(B > 0 && L > 0 && B / L <= rule.max_bl)) continue;
|
||||
@@ -161,7 +159,7 @@ export function resolveVessel(types, shipType, length, beam, name) {
|
||||
}
|
||||
}
|
||||
}
|
||||
return { model: types.default_vessel, length: L > 0 ? L : null };
|
||||
return { model: types.default_vessel, length: drawn };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -343,22 +341,18 @@ export function aircraftModelSpec(set, { typeCode, type, category } = {}) {
|
||||
* @returns {object} {@link fleetModelSpec}
|
||||
*/
|
||||
export function vesselModelSpec(set, { type, length, beam, name } = {}) {
|
||||
// A beam counts only beside a reported length: a vessel drawn at its type's
|
||||
// typical length keeps the model's proportions.
|
||||
const reported = hullLength(length ?? undefined);
|
||||
const hullWidth = reported === undefined ? undefined : (beam ?? undefined);
|
||||
const resolved = resolveVessel(
|
||||
set.types,
|
||||
aisShipTypeCode(type),
|
||||
reported,
|
||||
hullWidth,
|
||||
length ?? undefined,
|
||||
beam ?? undefined,
|
||||
name ?? undefined,
|
||||
);
|
||||
return specOf(
|
||||
set,
|
||||
resolved,
|
||||
(r) => ({ model: set.types.default_vessel, length: r.length }),
|
||||
{ beam: hullWidth },
|
||||
{ beam: beam ?? undefined },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,6 @@ const sliceTypes = {
|
||||
vessels: [
|
||||
{
|
||||
types: [70, 79],
|
||||
typical_len: 300,
|
||||
rules: [
|
||||
{ min_len: 70, max_len: 140, max_bl: 0.12, model: 'inland_cargo' },
|
||||
{
|
||||
@@ -134,29 +133,6 @@ test('vessels: the first matching rule of the first type range with one, else th
|
||||
});
|
||||
});
|
||||
|
||||
test("vessels of unknown length are chosen and drawn at their range's typical length", () => {
|
||||
assert.deepEqual(resolveVessel(sliceTypes, 70), {
|
||||
model: 'container_ship',
|
||||
length: 300,
|
||||
});
|
||||
assert.deepEqual(resolveVessel(sliceTypes, 70, 511, 126), {
|
||||
model: 'container_ship',
|
||||
length: 300,
|
||||
});
|
||||
assert.deepEqual(resolveVessel(sliceTypes, 70, 90, 15), {
|
||||
model: 'general_cargo',
|
||||
length: 90,
|
||||
});
|
||||
assert.deepEqual(resolveVessel(sliceTypes, 31), {
|
||||
model: 'tug',
|
||||
length: null,
|
||||
});
|
||||
// Every passenger, cargo and tanker range of the set has one.
|
||||
for (const code of [60, 70, 80]) {
|
||||
assert.ok(resolveVessel(types, code).length > 100, String(code));
|
||||
}
|
||||
});
|
||||
|
||||
test("vessels: AIS's out-of-range sizes (511 m long, 126 m abeam) are unknown, drawn as built", () => {
|
||||
assert.deepEqual(resolveVessel(sliceTypes, 52, 511), {
|
||||
model: 'tug',
|
||||
@@ -291,12 +267,6 @@ test('vessel specs read AIS codes and text families, stretched to the reported b
|
||||
const [sx, sy, sz] = box.scaleXYZ;
|
||||
assert.ok(Math.abs(sy - 51 / catalog.container_ship.beam) < 1e-12);
|
||||
assert.ok(Math.abs(sz - (sx + sy) / 2) < 1e-12);
|
||||
// A beam with no reported length: the type's typical length, in the model's
|
||||
// own proportions, not a typical-length hull squeezed to that beam.
|
||||
const unsized = vesselModelSpec(set, { type: '70', length: 0, beam: 14 });
|
||||
assert.equal(unsized.model, 'container_ship');
|
||||
const [ux, uy, uz] = unsized.scaleXYZ;
|
||||
assert.ok(Math.abs(uy - ux) < 1e-12 && Math.abs(uz - ux) < 1e-12);
|
||||
assert.equal(vesselModelSpec(set, { type: 'Tug', length: 30 }).model, 'tug');
|
||||
assert.equal(vesselModelSpec(set, { type: '36' }).model, 'sailboat');
|
||||
// No type, no size: the generic vessel as built.
|
||||
|
||||
Reference in new issue
Block a user