mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec354565c8 | ||
|
|
1d81955f1d |
No files matched your search
@@ -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.
|
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
|
- 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).
|
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
|
- The detection MODE BANNER (`DENSE VIS:… SRC:… DENS:…% ELASTIC …ms`) is
|
||||||
developer telemetry and is HIDDEN by default. It paints only under
|
developer telemetry and is HIDDEN by default. It paints only under
|
||||||
`?detectDebug=1` (the `trafficDebug` convention), resolved once per
|
`?detectDebug=1` (the `trafficDebug` convention), resolved once per
|
||||||
|
|||||||
@@ -2690,6 +2690,7 @@
|
|||||||
"src/data/detectionDraw.js",
|
"src/data/detectionDraw.js",
|
||||||
"src/data/detectionPolicy.js",
|
"src/data/detectionPolicy.js",
|
||||||
"src/data/detectionOcclusion.js",
|
"src/data/detectionOcclusion.js",
|
||||||
|
"src/data/detectionPaintCache.js",
|
||||||
"src/data/detectionPresentation.js",
|
"src/data/detectionPresentation.js",
|
||||||
"src/data/detectionRenderDemand.js",
|
"src/data/detectionRenderDemand.js",
|
||||||
"src/data/sceneOcclusionProbe.js",
|
"src/data/sceneOcclusionProbe.js",
|
||||||
@@ -4372,6 +4373,7 @@
|
|||||||
"src/data/detectionRenderDemand.js",
|
"src/data/detectionRenderDemand.js",
|
||||||
"src/data/detectionPresentation.js",
|
"src/data/detectionPresentation.js",
|
||||||
"src/data/detectionOcclusion.js",
|
"src/data/detectionOcclusion.js",
|
||||||
|
"src/data/detectionPaintCache.js",
|
||||||
"src/data/sceneOcclusionProbe.js",
|
"src/data/sceneOcclusionProbe.js",
|
||||||
"src/data/modelEligibility.js",
|
"src/data/modelEligibility.js",
|
||||||
"src/app/layers/recentImagery.js",
|
"src/app/layers/recentImagery.js",
|
||||||
|
|||||||
+138
-10
@@ -51,6 +51,7 @@ import {
|
|||||||
} from './detectionPolicy.js';
|
} from './detectionPolicy.js';
|
||||||
import { detectionBracketOpacity } from './detectionPresentation.js';
|
import { detectionBracketOpacity } from './detectionPresentation.js';
|
||||||
import { SurfaceOcclusion } from './detectionOcclusion.js';
|
import { SurfaceOcclusion } from './detectionOcclusion.js';
|
||||||
|
import { createDetectionPaintCache } from './detectionPaintCache.js';
|
||||||
import { SceneOcclusionProbe } from './sceneOcclusionProbe.js';
|
import { SceneOcclusionProbe } from './sceneOcclusionProbe.js';
|
||||||
import {
|
import {
|
||||||
createCyberSonarSampler,
|
createCyberSonarSampler,
|
||||||
@@ -243,6 +244,15 @@ let _hostLane = null;
|
|||||||
let _calloutLane = null;
|
let _calloutLane = null;
|
||||||
let _transitBracketPaths = new Map();
|
let _transitBracketPaths = new Map();
|
||||||
let _transitBracketAlpha = 1;
|
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.
|
* 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
|
* The array and its rows are POOLED — `_calloutCount` is the live length — so a
|
||||||
@@ -379,6 +389,8 @@ export function destroyDetection() {
|
|||||||
_calloutLane?.unregister?.();
|
_calloutLane?.unregister?.();
|
||||||
_calloutLane = null;
|
_calloutLane = null;
|
||||||
_transitBracketPaths.clear();
|
_transitBracketPaths.clear();
|
||||||
|
_paintCache.clear();
|
||||||
|
_paintReuseCount = 0;
|
||||||
_calloutCount = 0;
|
_calloutCount = 0;
|
||||||
_viewer = null;
|
_viewer = null;
|
||||||
_layers = [];
|
_layers = [];
|
||||||
@@ -643,6 +655,7 @@ function _publishDiagnostics() {
|
|||||||
dataset.solveMs = String(_lastDiagnostics.solveMs ?? 0);
|
dataset.solveMs = String(_lastDiagnostics.solveMs ?? 0);
|
||||||
dataset.frameTotalMs = String(_lastDiagnostics.frameTotalMs ?? 0);
|
dataset.frameTotalMs = String(_lastDiagnostics.frameTotalMs ?? 0);
|
||||||
dataset.throttleSkipCount = String(_lastDiagnostics.throttleSkipCount ?? 0);
|
dataset.throttleSkipCount = String(_lastDiagnostics.throttleSkipCount ?? 0);
|
||||||
|
dataset.paintReuseCount = String(_lastDiagnostics.paintReuseCount ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -718,6 +731,7 @@ function _applyModeState() {
|
|||||||
_syncSurfaceVisibility();
|
_syncSurfaceVisibility();
|
||||||
_setLanesActive(false);
|
_setLanesActive(false);
|
||||||
_labelArbiter.clear();
|
_labelArbiter.clear();
|
||||||
|
_paintCache.clear();
|
||||||
_surfaceOcclusion.clear();
|
_surfaceOcclusion.clear();
|
||||||
_surfaceSuppressedKeys.clear();
|
_surfaceSuppressedKeys.clear();
|
||||||
_occlusionProbe.reset();
|
_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
|
* Paint detection against the shared host frame. Brackets remain broad and
|
||||||
* per-frame; rich placements rebuild only for selected/fading identities and
|
* per-frame; rich placements rebuild only for selected/fading identities and
|
||||||
* for a bounded cohort on solve ticks.
|
* for a bounded cohort on solve ticks.
|
||||||
*/
|
*/
|
||||||
function _drawOverlay(frame) {
|
function _drawOverlay(frame) {
|
||||||
_transitBracketPaths.clear();
|
|
||||||
const { width, height } = frame;
|
const { width, height } = frame;
|
||||||
if (!_ctx || width <= 0 || height <= 0) {
|
if (!_ctx || width <= 0 || height <= 0) {
|
||||||
|
_transitBracketPaths.clear();
|
||||||
|
_paintCache.forget();
|
||||||
return {
|
return {
|
||||||
didSolve: false,
|
didSolve: false,
|
||||||
solveMs: 0,
|
solveMs: 0,
|
||||||
@@ -1309,6 +1361,8 @@ function _drawOverlay(frame) {
|
|||||||
// callout lane repaints the last solve on frames the sensor lane skips,
|
// callout lane repaints the last solve on frames the sensor lane skips,
|
||||||
// leaving rows here would strand the final callsigns on screen after the
|
// leaving rows here would strand the final callsigns on screen after the
|
||||||
// last data layer is switched off.
|
// last data layer is switched off.
|
||||||
|
_transitBracketPaths.clear();
|
||||||
|
_paintCache.clear();
|
||||||
_calloutCount = 0;
|
_calloutCount = 0;
|
||||||
_ctx.font = FONT;
|
_ctx.font = FONT;
|
||||||
_drawScanlines(width, height, now);
|
_drawScanlines(width, height, now);
|
||||||
@@ -1361,6 +1415,9 @@ function _drawOverlay(frame) {
|
|||||||
// every host lane. Detection retains the manual scalar projection below.
|
// every host lane. Detection retains the manual scalar projection below.
|
||||||
const occluder = frame.occluder;
|
const occluder = frame.occluder;
|
||||||
const camPos = frame.cameraPosition;
|
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.
|
// Answers to the occlusion tests started on earlier frames land here.
|
||||||
_surfaceOcclusion.beginFrame(now);
|
_surfaceOcclusion.beginFrame(now);
|
||||||
_occlusionProbe.beginFrame(_viewer?.scene, _surfaceOcclusion.now);
|
_occlusionProbe.beginFrame(_viewer?.scene, _surfaceOcclusion.now);
|
||||||
@@ -1390,7 +1447,7 @@ function _drawOverlay(frame) {
|
|||||||
if (!_charWidth) _charWidth = _ctx.measureText('0000000000').width / 10 || 6;
|
if (!_charWidth) _charWidth = _ctx.measureText('0000000000').width / 10 || 6;
|
||||||
const tiers = _theme.tiers || null;
|
const tiers = _theme.tiers || null;
|
||||||
const colorFor = (key) => (tiers && tiers[key]) || _theme.line;
|
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
|
// Global Context sonar echoes: a second bracket, banded by strength, around
|
||||||
// each flight or vessel a pulse front has just crossed.
|
// each flight or vessel a pulse front has just crossed.
|
||||||
// Resting pulses light nothing, so the per-contact echo work is skipped.
|
// Resting pulses light nothing, so the per-contact echo work is skipped.
|
||||||
@@ -1415,14 +1472,41 @@ function _drawOverlay(frame) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let visibleCount = 0;
|
let visibleCount = 0;
|
||||||
const bracketOpacityCounts = { full: 0, partial: 0, hidden: 0 };
|
let bracketOpacityCounts = { full: 0, partial: 0, hidden: 0 };
|
||||||
const aircraftBracketSectors = { left: 0, front: 0, right: 0 };
|
let aircraftBracketSectors = { left: 0, front: 0, right: 0 };
|
||||||
let protectedVisibleCount = 0;
|
let protectedVisibleCount = 0;
|
||||||
const candidateMap = new Map();
|
let candidateMap = new Map();
|
||||||
const cohortBuilders = shouldSolve ? new Map() : null;
|
const cohortBuilders = shouldSolve ? new Map() : null;
|
||||||
const demandByLayer = shouldSolve ? new Map() : null;
|
const demandByLayer = shouldSolve ? new Map() : null;
|
||||||
let placementBuildCount = 0;
|
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];
|
const obj = objects[i];
|
||||||
if (!obj.position) continue;
|
if (!obj.position) continue;
|
||||||
// Skip objects occluded by the ellipsoid (behind the horizon)
|
// Skip objects occluded by the ellipsoid (behind the horizon)
|
||||||
@@ -1450,8 +1534,12 @@ function _drawOverlay(frame) {
|
|||||||
let halfW;
|
let halfW;
|
||||||
let halfH;
|
let halfH;
|
||||||
if (obj.type === 'AIR') {
|
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(
|
const bscale = nearFarScale(
|
||||||
Cesium.Cartesian3.distance(camPos, obj.position),
|
Math.sqrt(dx * dx + dy * dy + dz * dz),
|
||||||
BILL_NEAR,
|
BILL_NEAR,
|
||||||
BILL_NEAR_SCALE,
|
BILL_NEAR_SCALE,
|
||||||
BILL_FAR,
|
BILL_FAR,
|
||||||
@@ -1477,6 +1565,7 @@ function _drawOverlay(frame) {
|
|||||||
// Something the scene drew in front of a depth-tested contact hides its
|
// Something the scene drew in front of a depth-tested contact hides its
|
||||||
// bracket and callout too, on every frame, not only on solves.
|
// bracket and callout too, on every frame, not only on solves.
|
||||||
if (obj.depthTested === true) {
|
if (obj.depthTested === true) {
|
||||||
|
depthTestedSwept = true;
|
||||||
const testedLayerId = obj._layerId || 'unknown';
|
const testedLayerId = obj._layerId || 'unknown';
|
||||||
const testedSourceId = obj.sourceId ?? obj.id ?? i;
|
const testedSourceId = obj.sourceId ?? obj.id ?? i;
|
||||||
const hidden = _surfaceOcclusion.isHidden(
|
const hidden = _surfaceOcclusion.isHidden(
|
||||||
@@ -1557,14 +1646,29 @@ function _drawOverlay(frame) {
|
|||||||
if (!primary && !micro) continue;
|
if (!primary && !micro) continue;
|
||||||
const layerId = obj._layerId || 'unknown';
|
const layerId = obj._layerId || 'unknown';
|
||||||
const sourceId = obj.sourceId ?? obj.id ?? i;
|
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;
|
if (!shouldSolve && !liveForRender) continue;
|
||||||
|
|
||||||
obj._cohortSourceId = sourceId;
|
obj._cohortSourceId = sourceId;
|
||||||
obj._cohortPriority = _semanticPriority(obj);
|
obj._cohortPriority = _semanticPriority(obj);
|
||||||
obj._cohortBand =
|
obj._cohortBand =
|
||||||
admissionAlpha >= 0.999 ? 8 : Math.floor(admissionAlpha * 8);
|
admissionAlpha >= 0.999 ? 8 : Math.floor(admissionAlpha * 8);
|
||||||
|
// 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._cohortHash = stableIdentityHash(layerId, sourceId);
|
||||||
|
obj._cohortHashLayerId = layerId;
|
||||||
|
obj._cohortHashSourceId = sourceId;
|
||||||
|
}
|
||||||
obj._candidateScreenX = sx;
|
obj._candidateScreenX = sx;
|
||||||
obj._candidateScreenY = sy;
|
obj._candidateScreenY = sy;
|
||||||
obj._candidateHalfW = halfW;
|
obj._candidateHalfW = halfW;
|
||||||
@@ -1578,8 +1682,7 @@ function _drawOverlay(frame) {
|
|||||||
demandByLayer.set(layerId, (demandByLayer.get(layerId) || 0) + 1);
|
demandByLayer.set(layerId, (demandByLayer.get(layerId) || 0) + 1);
|
||||||
if (!cohortBuilders.has(layerId))
|
if (!cohortBuilders.has(layerId))
|
||||||
cohortBuilders.set(layerId, new BoundedCohort(256));
|
cohortBuilders.set(layerId, new BoundedCohort(256));
|
||||||
const incumbent =
|
const incumbent = selectedSources?.has(sourceId) || false;
|
||||||
selectedIdentities?.get(layerId)?.has(sourceId) || false;
|
|
||||||
cohortBuilders.get(layerId).consider(obj, incumbent);
|
cohortBuilders.get(layerId).consider(obj, incumbent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1599,6 +1702,29 @@ function _drawOverlay(frame) {
|
|||||||
}
|
}
|
||||||
// Start this frame's occlusion tests; nothing here waits for them.
|
// Start this frame's occlusion tests; nothing here waits for them.
|
||||||
_occlusionProbe.endFrame();
|
_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 altitude = _viewer?.camera?.positionCartographic?.height ?? 1e9;
|
||||||
const collectiveBudget = labelBudgetFor(altitude, _densityPct);
|
const collectiveBudget = labelBudgetFor(altitude, _densityPct);
|
||||||
@@ -1782,6 +1908,7 @@ function _drawOverlay(frame) {
|
|||||||
// drawing them cost it.
|
// drawing them cost it.
|
||||||
surfaceProbeTests: _occlusionProbe.lastFrameTests,
|
surfaceProbeTests: _occlusionProbe.lastFrameTests,
|
||||||
surfaceProbeMs: _occlusionProbe.lastFrameMs,
|
surfaceProbeMs: _occlusionProbe.lastFrameMs,
|
||||||
|
paintReuseCount: _paintReuseCount,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const selectedCount = _labelArbiter.selectedKeys.size;
|
const selectedCount = _labelArbiter.selectedKeys.size;
|
||||||
@@ -1795,6 +1922,7 @@ function _drawOverlay(frame) {
|
|||||||
_lastDiagnostics.aircraftBracketSectors = aircraftBracketSectors;
|
_lastDiagnostics.aircraftBracketSectors = aircraftBracketSectors;
|
||||||
_lastDiagnostics.bracketPresentationOpacity = bracketPresentationOpacity;
|
_lastDiagnostics.bracketPresentationOpacity = bracketPresentationOpacity;
|
||||||
_lastDiagnostics.protectedVisibleCount = protectedVisibleCount;
|
_lastDiagnostics.protectedVisibleCount = protectedVisibleCount;
|
||||||
|
_lastDiagnostics.paintReuseCount = _paintReuseCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
_drawScanlines(width, height, now);
|
_drawScanlines(width, height, now);
|
||||||
|
|||||||
@@ -1661,3 +1661,234 @@ test('a WebGL 1 scene reads depth synchronously and reports it once', () => {
|
|||||||
env.cleanup();
|
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);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user