mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
feat(navara): render CCTV media and calibration handles
Port camera markers, coverage, media planes, volumes and calibration to the Navara renderer. Preserve shared source/media policy and add exact plane picking, owned resource cleanup and idle hold handling. Validate paired keyless dev/preview media and physical drags, native legacy, engine contracts and full CPU/build gates. Record remaining shell, viewshed browser and W2 performance gates; defer Dioxus and plan the transit port.
This commit is contained in:
1 parent
798d6636ab
commit
694616d5c2
22 files changed
+1466
-7
No files matched your search
@@ -542,6 +542,13 @@
|
|||||||
"src/layers/cctv/projectionScheduling.test.mjs",
|
"src/layers/cctv/projectionScheduling.test.mjs",
|
||||||
"src/layers/cctv/gizmo.test.mjs",
|
"src/layers/cctv/gizmo.test.mjs",
|
||||||
"src/layers/cctv/render/cesiumGizmo.js",
|
"src/layers/cctv/render/cesiumGizmo.js",
|
||||||
|
"src/layers/cctv/render/navara.js",
|
||||||
|
"src/layers/cctv/render/navara.test.mjs",
|
||||||
|
"src/layers/cctv/render/navaraMeshes.js",
|
||||||
|
"src/layers/cctv/render/navaraMeshes.test.mjs",
|
||||||
|
"src/layers/cctv/render/navaraGizmo.js",
|
||||||
|
"src/layers/cctv/render/navaraGizmo.test.mjs",
|
||||||
|
"src/layers/cctv/render/navaraLine.js",
|
||||||
"src/layers/cctv/render/cesium.js",
|
"src/layers/cctv/render/cesium.js",
|
||||||
"src/layers/cctv/render/cesiumViewshed.js",
|
"src/layers/cctv/render/cesiumViewshed.js",
|
||||||
"src/layers/cctv/render/cesium.test.mjs",
|
"src/layers/cctv/render/cesium.test.mjs",
|
||||||
|
|||||||
@@ -2705,6 +2705,10 @@
|
|||||||
"src/layers/radio/render/cesium.js",
|
"src/layers/radio/render/cesium.js",
|
||||||
"src/layers/firms/render/navara.js",
|
"src/layers/firms/render/navara.js",
|
||||||
"src/layers/radio/render/navara.js",
|
"src/layers/radio/render/navara.js",
|
||||||
|
"src/layers/cctv/render/navara.js",
|
||||||
|
"src/layers/cctv/render/navaraMeshes.js",
|
||||||
|
"src/layers/cctv/render/navaraGizmo.js",
|
||||||
|
"src/layers/cctv/render/navaraLine.js",
|
||||||
"src/layers/alpr/render/navara.js",
|
"src/layers/alpr/render/navara.js",
|
||||||
"src/layers/alpr/render/navaraOverlay.js",
|
"src/layers/alpr/render/navaraOverlay.js",
|
||||||
"src/layers/radio/screenClusters.js",
|
"src/layers/radio/screenClusters.js",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
bindCctvWorldClickGesture,
|
bindCctvWorldClickGesture,
|
||||||
} from '../../layers/cctv/render/cesium.js';
|
} from '../../layers/cctv/render/cesium.js';
|
||||||
import { createCctvLayer } from '../../layers/cctv/index.js';
|
import { createCctvLayer } from '../../layers/cctv/index.js';
|
||||||
|
import { rendererFor } from './renderers.js';
|
||||||
import * as credits from '../../data/dataCredits.js';
|
import * as credits from '../../data/dataCredits.js';
|
||||||
import * as sprites from '../../data/spriteOrder.js';
|
import * as sprites from '../../data/spriteOrder.js';
|
||||||
import * as activation from '../../cctvFocusRequest.js';
|
import * as activation from '../../cctvFocusRequest.js';
|
||||||
@@ -16,7 +17,10 @@ import * as render from '../../renderGovernor.js';
|
|||||||
export function createApplicationCctv({
|
export function createApplicationCctv({
|
||||||
surface,
|
surface,
|
||||||
source,
|
source,
|
||||||
rendererFactory = createCctvCesiumRenderer,
|
rendererFactory = ({ engine, ...context }) =>
|
||||||
|
engine?.kind === 'navara'
|
||||||
|
? rendererFor('cctv', engine, context)
|
||||||
|
: createCctvCesiumRenderer(context),
|
||||||
}) {
|
}) {
|
||||||
const { terrain, groundFloor: ground, meshFloor: mesh } = surface;
|
const { terrain, groundFloor: ground, meshFloor: mesh } = surface;
|
||||||
const layer = createCctvLayer({
|
const layer = createCctvLayer({
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ test('the Navara mode registers migrated renderers once', async () => {
|
|||||||
'bikeshare',
|
'bikeshare',
|
||||||
'radio',
|
'radio',
|
||||||
'alpr-cameras',
|
'alpr-cameras',
|
||||||
|
'cctv',
|
||||||
])
|
])
|
||||||
assert.equal(layerAvailableOn(id, 'navara'), true, id);
|
assert.equal(layerAvailableOn(id, 'navara'), true, id);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
@@ -44,6 +45,7 @@ test('the Navara mode registers migrated renderers once', async () => {
|
|||||||
'alpr-cameras',
|
'alpr-cameras',
|
||||||
'satellites',
|
'satellites',
|
||||||
'rocket-launches',
|
'rocket-launches',
|
||||||
|
'cctv',
|
||||||
'radio',
|
'radio',
|
||||||
'bikeshare',
|
'bikeshare',
|
||||||
'ais-live-vessels',
|
'ais-live-vessels',
|
||||||
|
|||||||
@@ -65,6 +65,15 @@ const NAVARA_CREDIT_KEYS = new Set([
|
|||||||
*/
|
*/
|
||||||
export async function registerNavaraLayerRenderers() {
|
export async function registerNavaraLayerRenderers() {
|
||||||
const loaders = {
|
const loaders = {
|
||||||
|
cctv: async () => {
|
||||||
|
const { createCctvNavaraRenderer } =
|
||||||
|
await import('../layers/cctv/render/navara.js');
|
||||||
|
return (engine, options) =>
|
||||||
|
createCctvNavaraRenderer(engine, {
|
||||||
|
...options,
|
||||||
|
...navaraViews.get(engine),
|
||||||
|
});
|
||||||
|
},
|
||||||
'alpr-cameras': async () =>
|
'alpr-cameras': async () =>
|
||||||
(await import('../layers/alpr/render/navara.js'))
|
(await import('../layers/alpr/render/navara.js'))
|
||||||
.createAlprNavaraRenderer,
|
.createAlprNavaraRenderer,
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ const toColor = ([r, g, b, a], alphaScale = 1) =>
|
|||||||
|
|
||||||
/** The Cesium material type and uniforms of a line (pure, for tests). */
|
/** The Cesium material type and uniforms of a line (pure, for tests). */
|
||||||
export function materialSpec(options, color, alphaScale = 1) {
|
export function materialSpec(options, color, alphaScale = 1) {
|
||||||
|
if (options.arrow)
|
||||||
|
return {
|
||||||
|
type: 'PolylineArrow',
|
||||||
|
uniforms: { color: toColor(color, alphaScale) },
|
||||||
|
};
|
||||||
const { dash } = options;
|
const { dash } = options;
|
||||||
if (dash?.kind === 'pixels')
|
if (dash?.kind === 'pixels')
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -143,3 +143,23 @@ test('a pick of a line primitive names the line', () => {
|
|||||||
});
|
});
|
||||||
assert.equal(identify({ primitive: {}, id: 'o' }), null);
|
assert.equal(identify({ primitive: {}, id: 'o' }), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('arrow lines retain the native arrow material in both depth passes', () => {
|
||||||
|
const { factories, made } = fakePrimitives();
|
||||||
|
const { lines, destroy } = createCesiumLines(createFakeViewer(), {
|
||||||
|
factories,
|
||||||
|
});
|
||||||
|
lines
|
||||||
|
.createLine({
|
||||||
|
id: 'arrow',
|
||||||
|
widthPx: 8,
|
||||||
|
color: [1, 0, 0, 1],
|
||||||
|
arrow: true,
|
||||||
|
depthFailAlpha: 0.45,
|
||||||
|
})
|
||||||
|
.setPositions(points);
|
||||||
|
assert.equal(made[0].appearance.material.type, 'PolylineArrow');
|
||||||
|
assert.equal(made[0].depthFailAppearance.material.type, 'PolylineArrow');
|
||||||
|
assert.equal(made[0].depthFailAppearance.material.uniforms.color.alpha, 0.45);
|
||||||
|
destroy();
|
||||||
|
});
|
||||||
@@ -107,7 +107,13 @@ function transformed(matrix, flat, index, result) {
|
|||||||
* @returns {(at: { x: number, y: number }, limit?: number, options?: { includeMovers?: boolean }) => object[]}
|
* @returns {(at: { x: number, y: number }, limit?: number, options?: { includeMovers?: boolean }) => object[]}
|
||||||
* Pick results ({ id, owner, instance? }) under a window position, nearest first.
|
* Pick results ({ id, owner, instance? }) under a window position, nearest first.
|
||||||
*/
|
*/
|
||||||
export function createCpuPicker({ projector, camera, movers, lines }) {
|
export function createCpuPicker({
|
||||||
|
projector,
|
||||||
|
camera,
|
||||||
|
movers,
|
||||||
|
lines,
|
||||||
|
meshes = () => [],
|
||||||
|
}) {
|
||||||
const depthOf = (position) =>
|
const depthOf = (position) =>
|
||||||
geo.cartesian3.distance(position, camera.livePosition);
|
geo.cartesian3.distance(position, camera.livePosition);
|
||||||
|
|
||||||
@@ -181,11 +187,12 @@ export function createCpuPicker({ projector, camera, movers, lines }) {
|
|||||||
const drill = (
|
const drill = (
|
||||||
at,
|
at,
|
||||||
limit = Infinity,
|
limit = Infinity,
|
||||||
{ includeMovers = true, includeLines = true } = {},
|
{ includeMovers = true, includeLines = true, includeMeshes = true } = {},
|
||||||
) => {
|
) => {
|
||||||
const hits = [];
|
const hits = [];
|
||||||
if (includeMovers) moverHits(at, hits);
|
if (includeMovers) moverHits(at, hits);
|
||||||
if (includeLines) lineHits(at, hits);
|
if (includeLines) lineHits(at, hits);
|
||||||
|
if (includeMeshes) hits.push(...meshes(at));
|
||||||
hits.sort(
|
hits.sort(
|
||||||
(a, b) => b.onTop - a.onTop || b.order - a.order || a.depth - b.depth,
|
(a, b) => b.onTop - a.onTop || b.order - a.order || a.depth - b.depth,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export function normalizeLineOptions(options) {
|
|||||||
arc,
|
arc,
|
||||||
depthFailAlpha,
|
depthFailAlpha,
|
||||||
dash: dashOf(options.dash),
|
dash: dashOf(options.dash),
|
||||||
|
...(options.arrow === true ? { arrow: true } : {}),
|
||||||
modelMatrix: modelMatrix ? [...modelMatrix] : null,
|
modelMatrix: modelMatrix ? [...modelMatrix] : null,
|
||||||
pickId: options.pickId,
|
pickId: options.pickId,
|
||||||
order: isFiniteNumber(options.order) ? options.order : 0,
|
order: isFiniteNumber(options.order) ? options.order : 0,
|
||||||
|
|||||||
@@ -222,6 +222,8 @@ export function createNavaraEngine(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const picker = {
|
const picker = {
|
||||||
|
// Navara mesh owners supply exact triangle hits for sync/hover/click picks.
|
||||||
|
registerMeshPicker: (pick) => picking.registerMeshPicker(pick),
|
||||||
// The CPU pick of movers and lines (picking.js); 3D tiles carry no ids.
|
// The CPU pick of movers and lines (picking.js); 3D tiles carry no ids.
|
||||||
// Clicks with { pick } get Navara's GPU pick of the gesture instead.
|
// Clicks with { pick } get Navara's GPU pick of the gesture instead.
|
||||||
pick: (screen) => picking.pick(screen),
|
pick: (screen) => picking.pick(screen),
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ uniform float uGevAlphaScale;
|
|||||||
uniform float uGevPixelRatio;
|
uniform float uGevPixelRatio;
|
||||||
${OVERLAY_DEPTH_FRAGMENT}
|
${OVERLAY_DEPTH_FRAGMENT}
|
||||||
uniform float uGevDash;
|
uniform float uGevDash;
|
||||||
|
uniform bool uGevArrow;
|
||||||
uniform vec2 uGevDashPx;
|
uniform vec2 uGevDashPx;
|
||||||
uniform vec2 uGevTactical;
|
uniform vec2 uGevTactical;
|
||||||
varying float vGevS;
|
varying float vGevS;
|
||||||
@@ -93,6 +94,12 @@ if (gevOverlayHidden()) discard;
|
|||||||
// sRGB colours (as Cesium takes them) into the linear output.
|
// sRGB colours (as Cesium takes them) into the linear output.
|
||||||
diffuseColor = sRGBTransferEOTF(uGevColor);
|
diffuseColor = sRGBTransferEOTF(uGevColor);
|
||||||
diffuseColor.a *= uGevAlphaScale;
|
diffuseColor.a *= uGevAlphaScale;
|
||||||
|
if (uGevArrow) {
|
||||||
|
float head = max(fwidth(vGevS) * 10.0 * uGevPixelRatio, 1e-6);
|
||||||
|
float halfWidth = vGevS < 1.0 - head ? 0.15 : 0.5 * (1.0 - vGevS) / head;
|
||||||
|
float edge = max(fwidth(vGevT), 1e-5);
|
||||||
|
diffuseColor.a *= 1.0 - smoothstep(halfWidth - edge, halfWidth + edge, abs(vGevT - 0.5));
|
||||||
|
}
|
||||||
if (uGevDash > 1.5) {
|
if (uGevDash > 1.5) {
|
||||||
diffuseColor.a *= gevTactical();
|
diffuseColor.a *= gevTactical();
|
||||||
} else if (uGevDash > 0.5) {
|
} else if (uGevDash > 0.5) {
|
||||||
@@ -201,6 +208,7 @@ export function createLineMesh(
|
|||||||
uGevWidthPx: { value: options.widthPx },
|
uGevWidthPx: { value: options.widthPx },
|
||||||
uGevColor: { value: [...options.color] },
|
uGevColor: { value: [...options.color] },
|
||||||
uGevDash: { value: dash?.kind === 'tactical' ? 2 : dash ? 1 : 0 },
|
uGevDash: { value: dash?.kind === 'tactical' ? 2 : dash ? 1 : 0 },
|
||||||
|
uGevArrow: { value: options.arrow === true },
|
||||||
uGevDashPx: {
|
uGevDashPx: {
|
||||||
value: dash?.kind === 'pixels' ? [dash.lengthPx, dash.gapPx] : [1, 0],
|
value: dash?.kind === 'pixels' ? [dash.lengthPx, dash.gapPx] : [1, 0],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -37,7 +37,14 @@ export function createNavaraPicking({
|
|||||||
timeoutMs = CLICK_TIMEOUT_MS,
|
timeoutMs = CLICK_TIMEOUT_MS,
|
||||||
now = () => performance.now(),
|
now = () => performance.now(),
|
||||||
}) {
|
}) {
|
||||||
const drillPick = createCpuPicker({ projector, camera, movers, lines });
|
const meshPickers = new Set();
|
||||||
|
const drillPick = createCpuPicker({
|
||||||
|
projector,
|
||||||
|
camera,
|
||||||
|
movers,
|
||||||
|
lines,
|
||||||
|
meshes: (at) => [...meshPickers].flatMap((pick) => pick(at)),
|
||||||
|
});
|
||||||
const isModelSet = (id) =>
|
const isModelSet = (id) =>
|
||||||
movers().some(
|
movers().some(
|
||||||
({ options }) => options.id === id && options.kind === 'model',
|
({ options }) => options.id === id && options.kind === 'model',
|
||||||
@@ -75,7 +82,7 @@ export function createNavaraPicking({
|
|||||||
!drillPick.near(hit.set, hit.instance, at, 1) ||
|
!drillPick.near(hit.set, hit.instance, at, 1) ||
|
||||||
(top !== undefined &&
|
(top !== undefined &&
|
||||||
!(top.owner === hit.set && top.instance === hit.instance) &&
|
!(top.owner === hit.set && top.instance === hit.instance) &&
|
||||||
!isModelSet(hit.set));
|
(!isModelSet(hit.set) || top.instance === undefined));
|
||||||
if (stale) {
|
if (stale) {
|
||||||
stats.stale++;
|
stats.stale++;
|
||||||
return drillPick(at, 1)[0];
|
return drillPick(at, 1)[0];
|
||||||
@@ -102,6 +109,10 @@ export function createNavaraPicking({
|
|||||||
view.on('featureClick', onFeatureClick);
|
view.on('featureClick', onFeatureClick);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
registerMeshPicker(pick) {
|
||||||
|
meshPickers.add(pick);
|
||||||
|
return () => meshPickers.delete(pick);
|
||||||
|
},
|
||||||
pick: (at) => drillPick(at, 1)[0],
|
pick: (at) => drillPick(at, 1)[0],
|
||||||
drillPick: (at, limit) => drillPick(at, limit ?? Infinity),
|
drillPick: (at, limit) => drillPick(at, limit ?? Infinity),
|
||||||
/** The answer for a click at `at`: the GPU pick of its gesture. */
|
/** The answer for a click at `at`: the GPU pick of its gesture. */
|
||||||
@@ -132,6 +143,7 @@ export function createNavaraPicking({
|
|||||||
/** How clicks were answered: GPU picks, timeouts, recent latencies (ms). */
|
/** How clicks were answered: GPU picks, timeouts, recent latencies (ms). */
|
||||||
clickStats: () => ({ ...stats, latencies: [...stats.latencies] }),
|
clickStats: () => ({ ...stats, latencies: [...stats.latencies] }),
|
||||||
destroy() {
|
destroy() {
|
||||||
|
meshPickers.clear();
|
||||||
view.off('featureClick', onFeatureClick);
|
view.off('featureClick', onFeatureClick);
|
||||||
for (const entry of waiting.splice(0)) {
|
for (const entry of waiting.splice(0)) {
|
||||||
cancel(entry.timer);
|
cancel(entry.timer);
|
||||||
|
|||||||
@@ -434,3 +434,85 @@ test('stale GPU model hits cannot select hidden or removed instances', async ()
|
|||||||
assert.equal(await removed, undefined);
|
assert.equal(await removed, undefined);
|
||||||
picking.destroy();
|
picking.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('owned mesh triangle hits participate in hover, GPU-empty clicks and disposal', async () => {
|
||||||
|
const view = fakeView();
|
||||||
|
const picking = createNavaraPicking({
|
||||||
|
view,
|
||||||
|
projector,
|
||||||
|
camera,
|
||||||
|
movers: () => [],
|
||||||
|
lines: () => [],
|
||||||
|
resolveBatch: () => null,
|
||||||
|
...timers(),
|
||||||
|
});
|
||||||
|
const hit = { id: 'plane', owner: 'plane' };
|
||||||
|
const release = picking.registerMeshPicker((at) =>
|
||||||
|
at.x === 10 ? [{ onTop: 0, order: 0, depth: 100, result: hit }] : [],
|
||||||
|
);
|
||||||
|
assert.equal(picking.pick({ x: 10, y: 20 }), hit);
|
||||||
|
assert.equal(picking.pick({ x: 11, y: 20 }), undefined);
|
||||||
|
const clicked = picking.clickPick({ x: 10, y: 20 });
|
||||||
|
view.emit('featureClick', null);
|
||||||
|
assert.equal(await clicked, hit);
|
||||||
|
release();
|
||||||
|
assert.equal(picking.pick({ x: 10, y: 20 }), undefined);
|
||||||
|
picking.registerMeshPicker(() => [
|
||||||
|
{ onTop: 0, order: 0, depth: 100, result: hit },
|
||||||
|
]);
|
||||||
|
picking.destroy();
|
||||||
|
assert.equal(picking.pick({ x: 10, y: 20 }), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mesh picks preserve always-on-top siblings and reject stale sprite GPU answers', async () => {
|
||||||
|
const view = fakeView();
|
||||||
|
const sprite = moverSet('points', [[100, 100, 500, 'sprite']]);
|
||||||
|
const picking = createNavaraPicking({
|
||||||
|
view,
|
||||||
|
projector,
|
||||||
|
camera,
|
||||||
|
movers: () => [sprite],
|
||||||
|
lines: () => [],
|
||||||
|
resolveBatch: () => ({ set: 'points', instance: 0, pickId: 'sprite' }),
|
||||||
|
...timers(),
|
||||||
|
});
|
||||||
|
picking.registerMeshPicker(() => [
|
||||||
|
{ onTop: 0, order: 0, depth: 10, result: { id: 'plane', owner: 'plane' } },
|
||||||
|
]);
|
||||||
|
const pending = picking.clickPick({ x: 100, y: 100 });
|
||||||
|
view.emit('featureClick', { batchId: 1 });
|
||||||
|
assert.equal((await pending).id, 'plane');
|
||||||
|
sprite.options.depthTest = 'always';
|
||||||
|
assert.equal(picking.pick({ x: 100, y: 100 }).id, 'sprite');
|
||||||
|
picking.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an exact foreground mesh wins over a GPU model pick behind it', async () => {
|
||||||
|
const view = fakeView();
|
||||||
|
const model = modelSet('models', [[100, 100, 500, 'jet']], 20);
|
||||||
|
const picking = createNavaraPicking({
|
||||||
|
view,
|
||||||
|
projector,
|
||||||
|
camera,
|
||||||
|
movers: () => [model],
|
||||||
|
lines: () => [],
|
||||||
|
resolveBatch: () => ({ set: 'models', instance: 0, pickId: 'jet' }),
|
||||||
|
...timers(),
|
||||||
|
});
|
||||||
|
const release = picking.registerMeshPicker(() => [
|
||||||
|
{
|
||||||
|
onTop: 0,
|
||||||
|
order: 0,
|
||||||
|
depth: 10,
|
||||||
|
result: { id: 'plane', owner: 'plane' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const pending = picking.clickPick({ x: 100, y: 100 });
|
||||||
|
view.emit('featureClick', { batchId: 1 });
|
||||||
|
assert.equal((await pending).id, 'plane');
|
||||||
|
release();
|
||||||
|
const uncovered = picking.clickPick({ x: 100, y: 100 });
|
||||||
|
view.emit('featureClick', { batchId: 1 });
|
||||||
|
assert.equal((await uncovered).id, 'jet');
|
||||||
|
picking.destroy();
|
||||||
|
});
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import * as geo from '../../../geo/index.js';
|
||||||
|
import {
|
||||||
|
bindTrackingClickGesture,
|
||||||
|
isTrackingClickGesture,
|
||||||
|
} from '../../../data/trackingClickGesture.js';
|
||||||
|
import { createCctvNavaraMeshes } from './navaraMeshes.js';
|
||||||
|
import { createCctvNavaraLine } from './navaraLine.js';
|
||||||
|
import { createNavaraCctvGizmoRenderer } from './navaraGizmo.js';
|
||||||
|
import {
|
||||||
|
CAMERA_ICON,
|
||||||
|
IDLE_CAMERA_COLOR,
|
||||||
|
IDLE_COVERAGE_COLOR,
|
||||||
|
} from '../policy.js';
|
||||||
|
|
||||||
|
export function createCctvNavaraRenderer(
|
||||||
|
engine,
|
||||||
|
{ state, services, view, MeshDesc },
|
||||||
|
) {
|
||||||
|
const meshes = createCctvNavaraMeshes(engine, { view, MeshDesc });
|
||||||
|
const markers = [];
|
||||||
|
const coverage = new Set();
|
||||||
|
const planes = new Set();
|
||||||
|
const gizmos = new Set();
|
||||||
|
const removers = new Set();
|
||||||
|
let initialized = false,
|
||||||
|
destroyed = false,
|
||||||
|
visible = true,
|
||||||
|
dirty = false;
|
||||||
|
let set = null,
|
||||||
|
capacity = 0,
|
||||||
|
credit = null;
|
||||||
|
const request = () => engine.render.request('cctv');
|
||||||
|
const markDirty = () => {
|
||||||
|
if (dirty || destroyed) return;
|
||||||
|
dirty = true;
|
||||||
|
request();
|
||||||
|
};
|
||||||
|
function syncMarkers() {
|
||||||
|
if (!dirty || !set || destroyed) return;
|
||||||
|
dirty = false;
|
||||||
|
set.setCount(markers.length);
|
||||||
|
markers.forEach((marker, index) =>
|
||||||
|
set.set(index, {
|
||||||
|
position: marker.position,
|
||||||
|
show: visible && marker.show,
|
||||||
|
scale: marker.scale,
|
||||||
|
color: marker.color.rgba(),
|
||||||
|
cell: 'camera',
|
||||||
|
pickId: marker.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
set.commit();
|
||||||
|
}
|
||||||
|
function allocateMarkers(count) {
|
||||||
|
if (set && capacity >= count) return;
|
||||||
|
set?.destroy();
|
||||||
|
capacity = Math.max(128, count, capacity * 2);
|
||||||
|
set = engine.movers.createSet({
|
||||||
|
id: 'cctv',
|
||||||
|
kind: 'sprite',
|
||||||
|
capacity,
|
||||||
|
order: 50,
|
||||||
|
pickable: true,
|
||||||
|
depthTest: 'always',
|
||||||
|
sharpen: true,
|
||||||
|
sprite: {
|
||||||
|
sizePx: 24,
|
||||||
|
scaleByDistance: [350, 1.25, 4_000_000, 0.42],
|
||||||
|
atlas: { url: CAMERA_ICON, cells: { camera: [0, 0, 1, 1] } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
markDirty();
|
||||||
|
}
|
||||||
|
const ownListener = (remove) => {
|
||||||
|
const release = () => {
|
||||||
|
if (removers.delete(release)) remove();
|
||||||
|
};
|
||||||
|
removers.add(release);
|
||||||
|
return release;
|
||||||
|
};
|
||||||
|
function pickedCameraId(picked) {
|
||||||
|
if (!picked) return null;
|
||||||
|
if (picked.owner === 'cctv' && state._recordById.has(picked.id))
|
||||||
|
return picked.id;
|
||||||
|
for (const entity of [...coverage, ...planes])
|
||||||
|
if (picked.id === entity.id && picked.owner === entity.id && entity.show)
|
||||||
|
return entity.cameraId;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const removeMeshPicker = engine.picker.registerMeshPicker((at) => {
|
||||||
|
const hit = meshes.pickPlane(engine.camera.pickRay(at));
|
||||||
|
if (!hit) return [];
|
||||||
|
// The rendered depth includes terrain, tiles and foreground models.
|
||||||
|
const surface = engine.picker.pickPosition(at);
|
||||||
|
if (
|
||||||
|
surface &&
|
||||||
|
geo.cartesian3.distance(engine.camera.livePosition, surface) + 0.1 <
|
||||||
|
hit.distance
|
||||||
|
)
|
||||||
|
return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
onTop: 0,
|
||||||
|
order: 0,
|
||||||
|
depth: hit.distance,
|
||||||
|
result: { id: hit.plane.id, owner: hit.plane.id },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
const removeFrame = engine.frames.preRender.addEventListener(syncMarkers);
|
||||||
|
const removePlane = (runtime) => {
|
||||||
|
const plane = runtime?.planeEntity;
|
||||||
|
if (planes.delete(plane)) plane.destroy();
|
||||||
|
runtime?.planeMaterial?.dispose();
|
||||||
|
};
|
||||||
|
const points = (p) => [p.tl, p.tr, p.br, p.bl, p.tl];
|
||||||
|
return {
|
||||||
|
attach() {
|
||||||
|
if (destroyed) throw new Error('CCTV renderer is destroyed');
|
||||||
|
initialized = true;
|
||||||
|
},
|
||||||
|
get ready() {
|
||||||
|
return initialized && !destroyed && !engine.isDestroyed();
|
||||||
|
},
|
||||||
|
cameraCartographic: () => engine.camera.pose().cartographic,
|
||||||
|
following: () => Boolean(engine.camera.following()),
|
||||||
|
globeVisible: () => !engine.maps.photorealActive(),
|
||||||
|
tilesReady: () => engine.surface.tilesLoaded(),
|
||||||
|
project: (position, result) =>
|
||||||
|
engine.projector.worldToWindow(position, result),
|
||||||
|
distance: (position) =>
|
||||||
|
geo.cartesian3.distance(engine.camera.livePosition, position),
|
||||||
|
horizonTest: () => (position) => engine.projector.horizonVisible(position),
|
||||||
|
viewport: () => engine.host.size(),
|
||||||
|
request,
|
||||||
|
pick(position) {
|
||||||
|
syncMarkers();
|
||||||
|
return engine.picker.pick(position);
|
||||||
|
},
|
||||||
|
extractPickedCameraId: pickedCameraId,
|
||||||
|
onMoveEnd: (fn) => ownListener(engine.camera.moveEnd.addEventListener(fn)),
|
||||||
|
onMoveStart: (fn) =>
|
||||||
|
ownListener(engine.camera.moveStart.addEventListener(fn)),
|
||||||
|
bindClick(onClick, options = {}) {
|
||||||
|
const removes = [];
|
||||||
|
bindTrackingClickGesture(
|
||||||
|
{
|
||||||
|
setInputAction(fn, type) {
|
||||||
|
removes.push(engine.input.on(type, fn));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(event, gesture) => {
|
||||||
|
if (isTrackingClickGesture(gesture)) onClick(event);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
eventTypes: {
|
||||||
|
LEFT_DOWN: 'down',
|
||||||
|
MOUSE_MOVE: 'move',
|
||||||
|
LEFT_UP: 'up',
|
||||||
|
LEFT_CLICK: 'click',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return ownListener(() => removes.forEach((remove) => remove()));
|
||||||
|
},
|
||||||
|
createGizmoRenderer() {
|
||||||
|
const gizmo = createNavaraCctvGizmoRenderer(engine);
|
||||||
|
gizmos.add(gizmo);
|
||||||
|
return gizmo;
|
||||||
|
},
|
||||||
|
createMarkers() {
|
||||||
|
allocateMarkers(1);
|
||||||
|
return set;
|
||||||
|
},
|
||||||
|
createMarker(camera, position) {
|
||||||
|
const marker = { id: camera.id };
|
||||||
|
for (const [key, initial] of Object.entries({
|
||||||
|
position,
|
||||||
|
color: IDLE_CAMERA_COLOR,
|
||||||
|
scale: 1,
|
||||||
|
show: true,
|
||||||
|
})) {
|
||||||
|
const copy = (item) => (key === 'position' ? { ...item } : item);
|
||||||
|
const equal = (a, b) =>
|
||||||
|
key === 'position'
|
||||||
|
? a.x === b.x && a.y === b.y && a.z === b.z
|
||||||
|
: key === 'color'
|
||||||
|
? a.red === b.red &&
|
||||||
|
a.green === b.green &&
|
||||||
|
a.blue === b.blue &&
|
||||||
|
a.alpha === b.alpha
|
||||||
|
: a === b;
|
||||||
|
let value = copy(initial);
|
||||||
|
Object.defineProperty(marker, key, {
|
||||||
|
enumerable: true,
|
||||||
|
get: () => value,
|
||||||
|
set(next) {
|
||||||
|
if (!equal(next, value)) {
|
||||||
|
value = copy(next);
|
||||||
|
markDirty();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
markers.push(marker);
|
||||||
|
allocateMarkers(markers.length);
|
||||||
|
markDirty();
|
||||||
|
return marker;
|
||||||
|
},
|
||||||
|
setMarkersVisible(value) {
|
||||||
|
if (visible !== Boolean(value)) {
|
||||||
|
visible = Boolean(value);
|
||||||
|
markDirty();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
restoreOrder: syncMarkers,
|
||||||
|
showCredit() {
|
||||||
|
credit ??= engine.credits.addStatic(
|
||||||
|
'© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">OpenStreetMap</a> contributors',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
hideCredit() {
|
||||||
|
credit?.();
|
||||||
|
credit = null;
|
||||||
|
},
|
||||||
|
sampleMeshFloorCells: (p, options) =>
|
||||||
|
services.mesh.sampleMeshFloorCells(engine, p, options),
|
||||||
|
obstructionRay: (origin, direction, exclude, far) =>
|
||||||
|
engine.surface.raycast({ origin, direction, exclude, far }),
|
||||||
|
createPlaneMaterial: meshes.createMaterial,
|
||||||
|
createTestPlaneMaterial: () => meshes.createMaterial(null),
|
||||||
|
setPlaneImage: (runtime, image) => runtime.planeMaterial.setImage(image),
|
||||||
|
createPlane(record, runtime, geometry, p, orientation) {
|
||||||
|
const plane = meshes.createPlane(
|
||||||
|
record.camera.id,
|
||||||
|
runtime.planeMaterial,
|
||||||
|
geometry,
|
||||||
|
p,
|
||||||
|
orientation,
|
||||||
|
);
|
||||||
|
planes.add(plane);
|
||||||
|
return plane;
|
||||||
|
},
|
||||||
|
updatePlane: (runtime, geometry, p, orientation) =>
|
||||||
|
meshes.updatePlane(runtime.planeEntity, geometry, p, orientation),
|
||||||
|
setPlaneVisible(runtime, value) {
|
||||||
|
if (runtime.planeEntity) runtime.planeEntity.show = value;
|
||||||
|
},
|
||||||
|
removePlane,
|
||||||
|
createCoverage(cameraId, p) {
|
||||||
|
const created = [];
|
||||||
|
try {
|
||||||
|
for (const [role, positions] of [
|
||||||
|
['ray-tl', [p.mount, p.tl]],
|
||||||
|
['ray-tr', [p.mount, p.tr]],
|
||||||
|
['ray-br', [p.mount, p.br]],
|
||||||
|
['ray-bl', [p.mount, p.bl]],
|
||||||
|
['cap', points(p)],
|
||||||
|
]) {
|
||||||
|
const line = createCctvNavaraLine(
|
||||||
|
engine,
|
||||||
|
`cctv-${cameraId}-${role}`,
|
||||||
|
{ width: 1.2, tint: IDLE_COVERAGE_COLOR },
|
||||||
|
positions,
|
||||||
|
);
|
||||||
|
line.cameraId = cameraId;
|
||||||
|
line._coverageRole = role === 'cap' ? 'cap' : 'edge';
|
||||||
|
coverage.add(line);
|
||||||
|
created.push(line);
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
} catch (error) {
|
||||||
|
for (const line of created) {
|
||||||
|
coverage.delete(line);
|
||||||
|
line.destroy();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateCoverage(record, p) {
|
||||||
|
const lines = record.coverageEntities;
|
||||||
|
if (!lines || lines.length < 5) return;
|
||||||
|
['tl', 'tr', 'br', 'bl'].forEach((role, i) => {
|
||||||
|
lines[i].polyline.positions = [p.mount, p[role]];
|
||||||
|
});
|
||||||
|
lines[4].polyline.positions = points(p);
|
||||||
|
},
|
||||||
|
removeCoverageEntity(entity) {
|
||||||
|
if (coverage.delete(entity)) entity.destroy();
|
||||||
|
},
|
||||||
|
styleLine: (entity, style) => entity.style(style),
|
||||||
|
hideCoverage(record) {
|
||||||
|
for (const entity of record.coverageEntities || []) entity.show = false;
|
||||||
|
},
|
||||||
|
removeCoverage(record) {
|
||||||
|
for (const entity of record.coverageEntities || [])
|
||||||
|
if (coverage.delete(entity)) entity.destroy();
|
||||||
|
},
|
||||||
|
createVolume: (record, tint) =>
|
||||||
|
meshes.createVolume(record.frustumPositions, tint),
|
||||||
|
removeVolume: (volume) => volume?.destroy(),
|
||||||
|
focus(record, duration) {
|
||||||
|
engine.camera.flyToSphere(
|
||||||
|
{
|
||||||
|
center: record.position,
|
||||||
|
radius: Math.max(40, record.camera.rangeM * 0.36),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: geo.math.toRadians(record.camera.headingDeg),
|
||||||
|
pitch: geo.math.toRadians(-22),
|
||||||
|
range: Math.max(280, record.camera.rangeM * 1.18),
|
||||||
|
duration: Math.max(0.2, duration || 0),
|
||||||
|
easing: 'cubicInOut',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
if (destroyed) return;
|
||||||
|
destroyed = true;
|
||||||
|
initialized = false;
|
||||||
|
removeFrame();
|
||||||
|
removeMeshPicker();
|
||||||
|
for (const remove of [...removers]) remove();
|
||||||
|
for (const gizmo of gizmos) gizmo.destroy();
|
||||||
|
for (const line of coverage) line.destroy();
|
||||||
|
meshes.destroy();
|
||||||
|
set?.destroy();
|
||||||
|
set = null;
|
||||||
|
credit?.();
|
||||||
|
credit = null;
|
||||||
|
markers.length = 0;
|
||||||
|
coverage.clear();
|
||||||
|
planes.clear();
|
||||||
|
gizmos.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
|
||||||
|
import { createCctvNavaraRenderer } from './navara.js';
|
||||||
|
|
||||||
|
function fixture(t) {
|
||||||
|
const engine = createFakeEngine({ kind: 'navara' });
|
||||||
|
const pickers = new Set();
|
||||||
|
engine.picker.registerMeshPicker = (pick) => {
|
||||||
|
pickers.add(pick);
|
||||||
|
return () => pickers.delete(pick);
|
||||||
|
};
|
||||||
|
const descriptors = new Map();
|
||||||
|
const view = {
|
||||||
|
registerMesh: (key, value) => descriptors.set(key, value),
|
||||||
|
addMesh(config) {
|
||||||
|
const key = Object.keys(config)[0];
|
||||||
|
const desc = new (descriptors.get(key))(view, {}, config);
|
||||||
|
desc.createMesh();
|
||||||
|
return { delete: () => desc.onDestroy() };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
class MeshDesc {
|
||||||
|
onDestroy() {}
|
||||||
|
}
|
||||||
|
const state = { _recordById: new Map() };
|
||||||
|
const renderer = createCctvNavaraRenderer(engine, {
|
||||||
|
state,
|
||||||
|
services: {},
|
||||||
|
view,
|
||||||
|
MeshDesc,
|
||||||
|
});
|
||||||
|
renderer.attach();
|
||||||
|
t.after(() => renderer.destroy());
|
||||||
|
return { engine, renderer, state, pickers };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('CCTV focus uses the engine sphere-flight fields and native range/pitch', (t) => {
|
||||||
|
const { engine, renderer } = fixture(t);
|
||||||
|
let flight;
|
||||||
|
engine.camera.flyToSphere = (sphere, options) => {
|
||||||
|
flight = { sphere, options };
|
||||||
|
};
|
||||||
|
const position = { x: 100, y: 200, z: 300 };
|
||||||
|
renderer.focus({ position, camera: { headingDeg: 84, rangeM: 126 } }, 0.2);
|
||||||
|
assert.deepEqual(flight, {
|
||||||
|
sphere: { center: position, radius: 45.36 },
|
||||||
|
options: {
|
||||||
|
heading: (84 * Math.PI) / 180,
|
||||||
|
pitch: (-22 * Math.PI) / 180,
|
||||||
|
range: 280,
|
||||||
|
duration: 0.2,
|
||||||
|
easing: 'cubicInOut',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CCTV markers own position snapshots and batch mutations without idle writes', (t) => {
|
||||||
|
const { engine, renderer } = fixture(t);
|
||||||
|
renderer.createMarkers();
|
||||||
|
const position = { x: 6378137, y: 10, z: 20 };
|
||||||
|
const marker = renderer.createMarker({ id: 'camera' }, position);
|
||||||
|
renderer.restoreOrder();
|
||||||
|
const store = engine.fake.moverSet('cctv').store;
|
||||||
|
position.y = 35;
|
||||||
|
marker.position = position;
|
||||||
|
engine.fake.frame();
|
||||||
|
assert.equal(store.positions[1], 35);
|
||||||
|
position.y = 42;
|
||||||
|
assert.equal(
|
||||||
|
marker.position.y,
|
||||||
|
35,
|
||||||
|
'a caller cannot mutate the retained position',
|
||||||
|
);
|
||||||
|
engine.fake.resetCalls();
|
||||||
|
marker.position = { ...marker.position };
|
||||||
|
marker.color = marker.color.withAlpha(marker.color.alpha);
|
||||||
|
renderer.restoreOrder();
|
||||||
|
assert.equal(engine.fake.callsOf('render.request').length, 0);
|
||||||
|
renderer.setMarkersVisible(false);
|
||||||
|
renderer.restoreOrder();
|
||||||
|
assert.equal(store.shows[0], 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CCTV picking requires actual owned primitives, and destroy removes its picker', (t) => {
|
||||||
|
const { engine, renderer, state, pickers } = fixture(t);
|
||||||
|
renderer.createMarkers();
|
||||||
|
const position = { x: 6378137, y: 10, z: 20 };
|
||||||
|
renderer.createMarker({ id: 'a' }, position);
|
||||||
|
state._recordById.set('a', {});
|
||||||
|
assert.equal(
|
||||||
|
renderer.extractPickedCameraId({ id: 'a', owner: 'sibling' }),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.equal(renderer.extractPickedCameraId({ id: 'a', owner: 'cctv' }), 'a');
|
||||||
|
const p = {
|
||||||
|
mount: position,
|
||||||
|
tl: position,
|
||||||
|
tr: position,
|
||||||
|
br: position,
|
||||||
|
bl: position,
|
||||||
|
};
|
||||||
|
const lines = renderer.createCoverage('a', p);
|
||||||
|
lines[0].show = true;
|
||||||
|
assert.equal(
|
||||||
|
renderer.extractPickedCameraId({ id: lines[0].id, owner: lines[0].id }),
|
||||||
|
'a',
|
||||||
|
);
|
||||||
|
renderer.removeCoverageEntity(lines[0]);
|
||||||
|
assert.equal(
|
||||||
|
renderer.extractPickedCameraId({ id: lines[0].id, owner: lines[0].id }),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
renderer.destroy();
|
||||||
|
renderer.destroy();
|
||||||
|
assert.equal(pickers.size, 0);
|
||||||
|
assert.equal(engine.lines.list().length, 0);
|
||||||
|
assert.equal(engine.movers.list().length, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { GIZMO_ID_PREFIX } from '../../../data/cctvGizmo.js';
|
||||||
|
import { createCctvNavaraLine } from './navaraLine.js';
|
||||||
|
|
||||||
|
export function createNavaraCctvGizmoRenderer(engine) {
|
||||||
|
const entities = new Map();
|
||||||
|
const removers = [];
|
||||||
|
let destroyed = false;
|
||||||
|
const pointSets = new Set();
|
||||||
|
const makePoint = (part, color, size) => {
|
||||||
|
const id = `${GIZMO_ID_PREFIX}${part}`;
|
||||||
|
const set = engine.movers.createSet({
|
||||||
|
id,
|
||||||
|
kind: 'point',
|
||||||
|
capacity: 1,
|
||||||
|
pickable: true,
|
||||||
|
depthTest: 'always',
|
||||||
|
order: 100,
|
||||||
|
point: { sizePx: size, outlinePx: 2, outlineColor: [0, 0, 0, 0.7] },
|
||||||
|
});
|
||||||
|
pointSets.add(set);
|
||||||
|
let position = { x: 0, y: 0, z: 0 };
|
||||||
|
let visible = false;
|
||||||
|
let pixelSize = size;
|
||||||
|
const sync = () => {
|
||||||
|
if (destroyed) return;
|
||||||
|
set.setCount(1);
|
||||||
|
set.set(0, {
|
||||||
|
position,
|
||||||
|
show: visible,
|
||||||
|
scale: pixelSize / size,
|
||||||
|
color: color.rgba(),
|
||||||
|
pickId: id,
|
||||||
|
});
|
||||||
|
set.commit();
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
get position() {
|
||||||
|
return position;
|
||||||
|
},
|
||||||
|
set position(value) {
|
||||||
|
position = value;
|
||||||
|
sync();
|
||||||
|
},
|
||||||
|
get show() {
|
||||||
|
return visible;
|
||||||
|
},
|
||||||
|
set show(value) {
|
||||||
|
if (visible !== Boolean(value)) {
|
||||||
|
visible = Boolean(value);
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
point: {
|
||||||
|
get pixelSize() {
|
||||||
|
return pixelSize;
|
||||||
|
},
|
||||||
|
set pixelSize(value) {
|
||||||
|
if (value !== pixelSize) {
|
||||||
|
pixelSize = value;
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const add = (part, entity) => {
|
||||||
|
entities.set(part, entity);
|
||||||
|
return entity;
|
||||||
|
};
|
||||||
|
const assertNew = (part) => {
|
||||||
|
if (destroyed) throw new Error('CCTV gizmo is destroyed');
|
||||||
|
if (entities.has(part))
|
||||||
|
throw new Error(`CCTV handle is already owned: ${part}`);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
createPoint(part, color, size) {
|
||||||
|
assertNew(part);
|
||||||
|
return add(part, makePoint(part, color, size));
|
||||||
|
},
|
||||||
|
createLine(part, color, width, arrow = false) {
|
||||||
|
assertNew(part);
|
||||||
|
const line = createCctvNavaraLine(engine, `${GIZMO_ID_PREFIX}${part}`, {
|
||||||
|
tint: color.withAlpha(arrow ? 1 : 0.9),
|
||||||
|
depthFail: color.withAlpha(0.45),
|
||||||
|
width,
|
||||||
|
arrow,
|
||||||
|
order: 100,
|
||||||
|
});
|
||||||
|
if (!arrow) return add(part, line);
|
||||||
|
let point;
|
||||||
|
try {
|
||||||
|
point = makePoint(part, color, 14);
|
||||||
|
} catch (error) {
|
||||||
|
line.destroy();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const visibility = Object.getOwnPropertyDescriptor(point, 'show');
|
||||||
|
Object.defineProperty(point, 'show', {
|
||||||
|
get: visibility.get,
|
||||||
|
set(value) {
|
||||||
|
visibility.set(value);
|
||||||
|
line.show = value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
point.polyline = line.polyline;
|
||||||
|
point.destroy = () => line.destroy();
|
||||||
|
return add(part, point);
|
||||||
|
},
|
||||||
|
pickPart(position) {
|
||||||
|
for (const hit of engine.picker.drillPick(position, 6)) {
|
||||||
|
if (typeof hit?.id !== 'string' || !hit.id.startsWith(GIZMO_ID_PREFIX))
|
||||||
|
continue;
|
||||||
|
const part = hit.id.slice(GIZMO_ID_PREFIX.length);
|
||||||
|
if (hit.owner === hit.id && entities.get(part)?.show) return part;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
pickRay: (position) => engine.camera.pickRay(position),
|
||||||
|
setCursor(value) {
|
||||||
|
if (engine.host.canvas.style) engine.host.canvas.style.cursor = value;
|
||||||
|
},
|
||||||
|
suspendCameraInputs: () => engine.input.suspendCameraInputs(),
|
||||||
|
on(type, listener) {
|
||||||
|
if (type !== 'cancel') {
|
||||||
|
removers.push(engine.input.on(type, listener));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const canvas = engine.host.canvas;
|
||||||
|
for (const [target, event] of [
|
||||||
|
[canvas, 'pointercancel'],
|
||||||
|
[canvas.ownerDocument?.defaultView, 'blur'],
|
||||||
|
]) {
|
||||||
|
if (!target?.addEventListener) continue;
|
||||||
|
target.addEventListener(event, listener);
|
||||||
|
removers.push(() => target.removeEventListener(event, listener));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
if (destroyed) return;
|
||||||
|
destroyed = true;
|
||||||
|
for (const remove of removers.splice(0)) remove();
|
||||||
|
for (const entity of entities.values()) entity.destroy?.();
|
||||||
|
for (const set of pointSets) set.destroy();
|
||||||
|
entities.clear();
|
||||||
|
pointSets.clear();
|
||||||
|
if (engine.host.canvas.style) engine.host.canvas.style.cursor = '';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
|
||||||
|
import { viewshedColors } from '../../../data/cctvViewshed.js';
|
||||||
|
import { createNavaraCctvGizmoRenderer } from './navaraGizmo.js';
|
||||||
|
|
||||||
|
const color = viewshedColors(30).fill;
|
||||||
|
|
||||||
|
test('Navara gizmo releases arrow resources, input and cancellation listeners', () => {
|
||||||
|
const engine = createFakeEngine({ kind: 'navara' });
|
||||||
|
const canvas = new EventTarget();
|
||||||
|
const window = new EventTarget();
|
||||||
|
canvas.style = {};
|
||||||
|
canvas.ownerDocument = { defaultView: window };
|
||||||
|
engine.host.canvas = canvas;
|
||||||
|
const renderer = createNavaraCctvGizmoRenderer(engine);
|
||||||
|
renderer.createLine('east', color, 8, true);
|
||||||
|
renderer.createLine('heading', color, 3);
|
||||||
|
renderer.createPoint('range', color, 10);
|
||||||
|
let cancels = 0;
|
||||||
|
let moves = 0;
|
||||||
|
renderer.on('cancel', () => cancels++);
|
||||||
|
renderer.on('move', () => moves++);
|
||||||
|
canvas.dispatchEvent(new Event('pointercancel'));
|
||||||
|
window.dispatchEvent(new Event('blur'));
|
||||||
|
engine.fake.input('move', { position: { x: 1, y: 1 } });
|
||||||
|
assert.equal(cancels, 2);
|
||||||
|
assert.equal(moves, 1);
|
||||||
|
assert.throws(
|
||||||
|
() => renderer.createLine('east', color, 8, true),
|
||||||
|
/already owned/,
|
||||||
|
);
|
||||||
|
assert.equal(engine.lines.list().length, 2);
|
||||||
|
assert.equal(engine.movers.list().length, 2);
|
||||||
|
renderer.setCursor('grabbing');
|
||||||
|
renderer.destroy();
|
||||||
|
renderer.destroy();
|
||||||
|
canvas.dispatchEvent(new Event('pointercancel'));
|
||||||
|
window.dispatchEvent(new Event('blur'));
|
||||||
|
engine.fake.input('move', { position: { x: 1, y: 1 } });
|
||||||
|
assert.equal(cancels, 2);
|
||||||
|
assert.equal(moves, 1);
|
||||||
|
assert.equal(engine.lines.list().length, 0);
|
||||||
|
assert.equal(engine.movers.list().length, 0);
|
||||||
|
assert.equal(canvas.style.cursor, '');
|
||||||
|
assert.throws(() => renderer.createPoint('new', color, 10), /destroyed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Navara gizmo rolls back the arrow shaft if its tip cannot be allocated', () => {
|
||||||
|
const engine = createFakeEngine({ kind: 'navara' });
|
||||||
|
engine.movers.createSet = () => {
|
||||||
|
throw new Error('allocation failed');
|
||||||
|
};
|
||||||
|
const renderer = createNavaraCctvGizmoRenderer(engine);
|
||||||
|
assert.throws(
|
||||||
|
() => renderer.createLine('east', color, 8, true),
|
||||||
|
/allocation failed/,
|
||||||
|
);
|
||||||
|
assert.equal(engine.lines.list().length, 0);
|
||||||
|
renderer.destroy();
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/** Mutable CCTV line backed by the existing engine ribbon renderer. */
|
||||||
|
export function createCctvNavaraLine(engine, id, initial, points = []) {
|
||||||
|
let style = { ...initial };
|
||||||
|
let positions = points;
|
||||||
|
let visible = false;
|
||||||
|
let destroyed = false;
|
||||||
|
let line = null;
|
||||||
|
const build = () => {
|
||||||
|
line?.destroy();
|
||||||
|
line = engine.lines.createLine({
|
||||||
|
id,
|
||||||
|
pickId: id,
|
||||||
|
widthPx: style.width,
|
||||||
|
color: style.tint.rgba(),
|
||||||
|
depthFailAlpha: style.depthFail
|
||||||
|
? style.depthFail.alpha / Math.max(style.tint.alpha, 1e-6)
|
||||||
|
: 0,
|
||||||
|
dash: style.dashed ? { lengthPx: 8, gapPx: 8 } : null,
|
||||||
|
arrow: Boolean(style.arrow),
|
||||||
|
order: style.order ?? 0,
|
||||||
|
});
|
||||||
|
line.setPositions(positions);
|
||||||
|
line.show(visible);
|
||||||
|
};
|
||||||
|
build();
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
get show() {
|
||||||
|
return !destroyed && visible;
|
||||||
|
},
|
||||||
|
set show(value) {
|
||||||
|
if (destroyed || visible === Boolean(value)) return;
|
||||||
|
visible = Boolean(value);
|
||||||
|
line.show(visible);
|
||||||
|
},
|
||||||
|
polyline: {
|
||||||
|
get positions() {
|
||||||
|
return positions;
|
||||||
|
},
|
||||||
|
set positions(value) {
|
||||||
|
if (destroyed) return;
|
||||||
|
positions = value;
|
||||||
|
line.setPositions(value);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
style(next) {
|
||||||
|
if (destroyed) return;
|
||||||
|
const depth = (s) =>
|
||||||
|
s.depthFail ? s.depthFail.alpha / Math.max(s.tint.alpha, 1e-6) : 0;
|
||||||
|
const rebuild =
|
||||||
|
next.width !== style.width ||
|
||||||
|
Boolean(next.dashed) !== Boolean(style.dashed) ||
|
||||||
|
depth(next) !== depth(style);
|
||||||
|
const colorChanged = next.tint
|
||||||
|
.rgba()
|
||||||
|
.some((v, i) => v !== style.tint.rgba()[i]);
|
||||||
|
style = { ...style, ...next };
|
||||||
|
if (rebuild) build();
|
||||||
|
else if (colorChanged) line.setColor(style.tint.rgba());
|
||||||
|
this.show = next.visible;
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
if (destroyed) return;
|
||||||
|
destroyed = true;
|
||||||
|
line.destroy();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
import {
|
||||||
|
BufferAttribute,
|
||||||
|
BufferGeometry,
|
||||||
|
CanvasTexture,
|
||||||
|
DoubleSide,
|
||||||
|
Group,
|
||||||
|
LinearFilter,
|
||||||
|
Mesh,
|
||||||
|
MeshBasicMaterial,
|
||||||
|
Quaternion,
|
||||||
|
Raycaster,
|
||||||
|
SRGBColorSpace,
|
||||||
|
Vector3,
|
||||||
|
VideoTexture,
|
||||||
|
} from 'three';
|
||||||
|
import { frustumVolumeGeometryData } from '../../../data/cctvViewshed.js';
|
||||||
|
import { PLANE_OUTLINE_COLOR } from '../policy.js';
|
||||||
|
|
||||||
|
const KIND = 'gevCctvMesh';
|
||||||
|
const registered = new WeakSet();
|
||||||
|
|
||||||
|
/** Local mesh coordinates keep ECEF precision in the double world matrix. */
|
||||||
|
function placeGeometry(mesh, positions, indices, origin) {
|
||||||
|
const local = new Float32Array(positions.length);
|
||||||
|
for (let i = 0; i < positions.length; i += 3) {
|
||||||
|
local[i] = positions[i] - origin.x;
|
||||||
|
local[i + 1] = positions[i + 1] - origin.y;
|
||||||
|
local[i + 2] = positions[i + 2] - origin.z;
|
||||||
|
}
|
||||||
|
mesh.geometry.setAttribute('position', new BufferAttribute(local, 3));
|
||||||
|
mesh.geometry.setIndex(Array.from(indices));
|
||||||
|
mesh.geometry.computeBoundingSphere();
|
||||||
|
mesh.matrix.makeTranslation(origin.x, origin.y, origin.z);
|
||||||
|
mesh.matrixWorldNeedsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CCTV owns these resources; media playback remains in shared projection. */
|
||||||
|
export function createCctvNavaraMeshes(engine, { view, MeshDesc }) {
|
||||||
|
if (!view?.addMesh || !MeshDesc)
|
||||||
|
throw new TypeError('CCTV requires Navara MeshDesc support');
|
||||||
|
if (!registered.has(view)) {
|
||||||
|
view.registerMesh(
|
||||||
|
KIND,
|
||||||
|
class extends MeshDesc {
|
||||||
|
constructor(owner, ctx, config) {
|
||||||
|
super(owner, ctx, config);
|
||||||
|
this.cctv = config[KIND];
|
||||||
|
}
|
||||||
|
getPassKey() {
|
||||||
|
return 'transparent';
|
||||||
|
}
|
||||||
|
createMesh() {
|
||||||
|
return this.cctv.group;
|
||||||
|
}
|
||||||
|
onDestroy() {
|
||||||
|
this.cctv.dispose();
|
||||||
|
super.onDestroy();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
registered.add(view);
|
||||||
|
}
|
||||||
|
const owned = new Set();
|
||||||
|
const materials = new Set();
|
||||||
|
const planes = new Set();
|
||||||
|
const raycaster = new Raycaster();
|
||||||
|
let destroyed = false;
|
||||||
|
const request = () => engine.render.request('cctv-mesh');
|
||||||
|
const alive = () => {
|
||||||
|
if (destroyed) throw new Error('CCTV meshes are destroyed');
|
||||||
|
};
|
||||||
|
|
||||||
|
function createMaterial(image) {
|
||||||
|
alive();
|
||||||
|
const material = new MeshBasicMaterial({
|
||||||
|
color: 0xffffff,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.95,
|
||||||
|
side: DoubleSide,
|
||||||
|
depthWrite: false,
|
||||||
|
toneMapped: false,
|
||||||
|
});
|
||||||
|
const resource = {
|
||||||
|
material,
|
||||||
|
image: null,
|
||||||
|
texture: null,
|
||||||
|
disposed: false,
|
||||||
|
setImage(next) {
|
||||||
|
if (resource.disposed) return;
|
||||||
|
if (next === resource.image) {
|
||||||
|
if (resource.texture && !resource.texture.isVideoTexture)
|
||||||
|
resource.texture.needsUpdate = true;
|
||||||
|
request();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const texture =
|
||||||
|
next?.tagName === 'VIDEO'
|
||||||
|
? new VideoTexture(next)
|
||||||
|
: new CanvasTexture(next);
|
||||||
|
texture.colorSpace = SRGBColorSpace;
|
||||||
|
texture.minFilter = texture.magFilter = LinearFilter;
|
||||||
|
texture.generateMipmaps = false;
|
||||||
|
const previous = resource.texture;
|
||||||
|
resource.image = next;
|
||||||
|
resource.texture = texture;
|
||||||
|
material.map = texture;
|
||||||
|
material.needsUpdate = true;
|
||||||
|
previous?.dispose();
|
||||||
|
request();
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
if (resource.disposed) return;
|
||||||
|
resource.disposed = true;
|
||||||
|
resource.texture?.dispose();
|
||||||
|
resource.texture = resource.image = null;
|
||||||
|
material.map = null;
|
||||||
|
material.dispose();
|
||||||
|
materials.delete(resource);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
materials.add(resource);
|
||||||
|
try {
|
||||||
|
if (image) resource.setImage(image);
|
||||||
|
return resource;
|
||||||
|
} catch (error) {
|
||||||
|
resource.dispose();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ownMesh(mesh, disposeExtra = () => {}) {
|
||||||
|
alive();
|
||||||
|
const group = new Group();
|
||||||
|
group.frustumCulled = mesh.frustumCulled = false;
|
||||||
|
mesh.matrixAutoUpdate = false;
|
||||||
|
group.add(mesh);
|
||||||
|
let freed = false;
|
||||||
|
let handle;
|
||||||
|
const dispose = () => {
|
||||||
|
if (freed) return;
|
||||||
|
freed = true;
|
||||||
|
mesh.geometry.dispose();
|
||||||
|
disposeExtra();
|
||||||
|
owned.delete(resource);
|
||||||
|
};
|
||||||
|
const resource = {
|
||||||
|
group,
|
||||||
|
mesh,
|
||||||
|
get show() {
|
||||||
|
return !freed && group.visible;
|
||||||
|
},
|
||||||
|
set show(value) {
|
||||||
|
if (freed || group.visible === Boolean(value)) return;
|
||||||
|
group.visible = Boolean(value);
|
||||||
|
request();
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
if (freed) return;
|
||||||
|
try {
|
||||||
|
handle?.delete();
|
||||||
|
} finally {
|
||||||
|
dispose();
|
||||||
|
request();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
owned.add(resource);
|
||||||
|
try {
|
||||||
|
handle = view.addMesh({ [KIND]: { group, dispose } });
|
||||||
|
return resource;
|
||||||
|
} catch (error) {
|
||||||
|
dispose();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePlane(plane, geometry, positions, orientation) {
|
||||||
|
if (!planes.has(plane)) return;
|
||||||
|
// Match the native plane's quaternion and dimensions, including its UVs.
|
||||||
|
const q = new Quaternion(
|
||||||
|
orientation.x,
|
||||||
|
orientation.y,
|
||||||
|
orientation.z,
|
||||||
|
orientation.w,
|
||||||
|
);
|
||||||
|
const flat = [];
|
||||||
|
for (const [x, y] of [
|
||||||
|
[-1, 1],
|
||||||
|
[1, 1],
|
||||||
|
[-1, -1],
|
||||||
|
[1, -1],
|
||||||
|
]) {
|
||||||
|
const p = new Vector3(x * geometry.halfW, y * geometry.halfH, 0)
|
||||||
|
.applyQuaternion(q)
|
||||||
|
.add(positions.capCenter);
|
||||||
|
flat.push(p.x, p.y, p.z);
|
||||||
|
}
|
||||||
|
placeGeometry(plane.mesh, flat, [0, 2, 1, 2, 3, 1], positions.capCenter);
|
||||||
|
plane.outline.setPositions(
|
||||||
|
[0, 1, 3, 2, 0].map((i) => ({
|
||||||
|
x: flat[i * 3],
|
||||||
|
y: flat[i * 3 + 1],
|
||||||
|
z: flat[i * 3 + 2],
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
request();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
createMaterial,
|
||||||
|
createPlane(cameraId, material, geometry, positions, orientation) {
|
||||||
|
alive();
|
||||||
|
if (!materials.has(material) || material.disposed)
|
||||||
|
throw new Error('CCTV plane material is not owned');
|
||||||
|
const mesh = new Mesh(new BufferGeometry(), material.material);
|
||||||
|
mesh.geometry.setAttribute(
|
||||||
|
'uv',
|
||||||
|
new BufferAttribute(new Float32Array([0, 1, 1, 1, 0, 0, 1, 0]), 2),
|
||||||
|
);
|
||||||
|
let outline;
|
||||||
|
let plane;
|
||||||
|
let transferred = false;
|
||||||
|
try {
|
||||||
|
outline = engine.lines.createLine({
|
||||||
|
id: `cctv-${cameraId}-plane-outline`,
|
||||||
|
widthPx: 1,
|
||||||
|
color: PLANE_OUTLINE_COLOR.rgba(),
|
||||||
|
});
|
||||||
|
transferred = true;
|
||||||
|
plane = ownMesh(mesh, () => {
|
||||||
|
outline.destroy();
|
||||||
|
material.dispose();
|
||||||
|
planes.delete(plane);
|
||||||
|
});
|
||||||
|
plane.id = `cctv-${cameraId}-plane`;
|
||||||
|
plane.cameraId = cameraId;
|
||||||
|
plane.outline = outline;
|
||||||
|
const visibility = Object.getOwnPropertyDescriptor(plane, 'show');
|
||||||
|
Object.defineProperty(plane, 'show', {
|
||||||
|
get: visibility.get,
|
||||||
|
set(value) {
|
||||||
|
visibility.set(value);
|
||||||
|
if (planes.has(plane)) outline.show(plane.show);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
planes.add(plane);
|
||||||
|
updatePlane(plane, geometry, positions, orientation);
|
||||||
|
plane.show = false;
|
||||||
|
return plane;
|
||||||
|
} catch (error) {
|
||||||
|
plane?.destroy();
|
||||||
|
if (!transferred) {
|
||||||
|
mesh.geometry.dispose();
|
||||||
|
outline?.destroy();
|
||||||
|
material.dispose();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updatePlane,
|
||||||
|
createVolume(positions, tint) {
|
||||||
|
alive();
|
||||||
|
const data = frustumVolumeGeometryData(positions);
|
||||||
|
const material = new MeshBasicMaterial({
|
||||||
|
transparent: true,
|
||||||
|
opacity: tint.alpha,
|
||||||
|
side: DoubleSide,
|
||||||
|
depthWrite: false,
|
||||||
|
toneMapped: false,
|
||||||
|
});
|
||||||
|
material.color.setRGB(tint.red, tint.green, tint.blue, SRGBColorSpace);
|
||||||
|
const mesh = new Mesh(new BufferGeometry(), material);
|
||||||
|
placeGeometry(mesh, data.positions, data.indices, positions.mount);
|
||||||
|
return ownMesh(mesh, () => material.dispose());
|
||||||
|
},
|
||||||
|
/** Actual plane triangles, not a projected rectangle or marker proxy. */
|
||||||
|
pickPlane(ray) {
|
||||||
|
if (destroyed || !ray) return undefined;
|
||||||
|
raycaster.ray.origin.copy(ray.origin);
|
||||||
|
raycaster.ray.direction.copy(ray.direction).normalize();
|
||||||
|
let nearest;
|
||||||
|
for (const plane of planes) {
|
||||||
|
if (!plane.show) continue;
|
||||||
|
plane.group.updateWorldMatrix(true, true);
|
||||||
|
const hit = raycaster.intersectObject(plane.mesh, false)[0];
|
||||||
|
if (hit && (!nearest || hit.distance < nearest.distance))
|
||||||
|
nearest = { ...hit, plane };
|
||||||
|
}
|
||||||
|
return nearest;
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
if (destroyed) return;
|
||||||
|
destroyed = true;
|
||||||
|
for (const resource of [...owned]) resource.destroy();
|
||||||
|
for (const material of [...materials]) material.dispose();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { Group, Vector3 } from 'three';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { createCctvNavaraMeshes } from './navaraMeshes.js';
|
||||||
|
import { viewshedColors } from '../../../data/cctvViewshed.js';
|
||||||
|
|
||||||
|
function harness() {
|
||||||
|
const scene = new Group();
|
||||||
|
const kinds = new Map();
|
||||||
|
const handles = new Set();
|
||||||
|
const lines = new Set();
|
||||||
|
let requests = 0;
|
||||||
|
let fail = false;
|
||||||
|
let failedGeometryDisposals = 0;
|
||||||
|
class MeshDesc {
|
||||||
|
onDestroy() {}
|
||||||
|
}
|
||||||
|
const view = {
|
||||||
|
registerMesh: (kind, desc) => kinds.set(kind, desc),
|
||||||
|
addMesh(config) {
|
||||||
|
if (fail) {
|
||||||
|
config.gevCctvMesh.group.children[0].geometry.addEventListener(
|
||||||
|
'dispose',
|
||||||
|
() => failedGeometryDisposals++,
|
||||||
|
);
|
||||||
|
throw new Error('descriptor failed');
|
||||||
|
}
|
||||||
|
const kind = Object.keys(config)[0];
|
||||||
|
const desc = new (kinds.get(kind))(view, {}, config);
|
||||||
|
assert.equal(desc.getPassKey(), 'transparent');
|
||||||
|
const object = desc.createMesh();
|
||||||
|
scene.add(object);
|
||||||
|
const handle = {
|
||||||
|
delete() {
|
||||||
|
handles.delete(handle);
|
||||||
|
scene.remove(object);
|
||||||
|
desc.onDestroy();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
handles.add(handle);
|
||||||
|
return handle;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const engine = {
|
||||||
|
render: { request: () => requests++ },
|
||||||
|
lines: {
|
||||||
|
createLine(options) {
|
||||||
|
const line = {
|
||||||
|
options,
|
||||||
|
setPositions: (value) => (line.positions = value),
|
||||||
|
show: (value) => (line.visible = value),
|
||||||
|
destroy: () => lines.delete(line),
|
||||||
|
};
|
||||||
|
lines.add(line);
|
||||||
|
return line;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const owner = createCctvNavaraMeshes(engine, { view, MeshDesc });
|
||||||
|
return {
|
||||||
|
owner,
|
||||||
|
scene,
|
||||||
|
handles,
|
||||||
|
lines,
|
||||||
|
requests: () => requests,
|
||||||
|
failedGeometryDisposals: () => failedGeometryDisposals,
|
||||||
|
fail: () => (fail = true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const shape = { halfW: 80, halfH: 45 };
|
||||||
|
const positions = { capCenter: { x: 6378137, y: 1234, z: 5678 } };
|
||||||
|
const identity = { x: 0, y: 0, z: 0, w: 1 };
|
||||||
|
const canvas = () => ({ width: 8, height: 8, tagName: 'CANVAS' });
|
||||||
|
|
||||||
|
test('Navara CCTV plane preserves native rotated geometry, UVs and metre precision', (t) => {
|
||||||
|
const h = harness();
|
||||||
|
t.after(() => h.owner.destroy());
|
||||||
|
const q = Cesium.Quaternion.fromAxisAngle(
|
||||||
|
Cesium.Cartesian3.normalize(
|
||||||
|
new Cesium.Cartesian3(1, 2, 3),
|
||||||
|
new Cesium.Cartesian3(),
|
||||||
|
),
|
||||||
|
0.75,
|
||||||
|
);
|
||||||
|
const material = h.owner.createMaterial(canvas());
|
||||||
|
const plane = h.owner.createPlane('a', material, shape, positions, q);
|
||||||
|
const matrix = Cesium.Matrix3.fromQuaternion(q);
|
||||||
|
plane.group.updateWorldMatrix(true, true);
|
||||||
|
const attribute = plane.mesh.geometry.getAttribute('position');
|
||||||
|
const uv = plane.mesh.geometry.getAttribute('uv');
|
||||||
|
for (const [i, [x, y]] of [
|
||||||
|
[-1, 1],
|
||||||
|
[1, 1],
|
||||||
|
[-1, -1],
|
||||||
|
[1, -1],
|
||||||
|
].entries()) {
|
||||||
|
const native = Cesium.Matrix3.multiplyByVector(
|
||||||
|
matrix,
|
||||||
|
new Cesium.Cartesian3(x * shape.halfW, y * shape.halfH, 0),
|
||||||
|
new Cesium.Cartesian3(),
|
||||||
|
);
|
||||||
|
Cesium.Cartesian3.add(native, positions.capCenter, native);
|
||||||
|
const actual = new Vector3()
|
||||||
|
.fromBufferAttribute(attribute, i)
|
||||||
|
.applyMatrix4(plane.mesh.matrixWorld);
|
||||||
|
assert.ok(Cesium.Cartesian3.distance(native, actual) < 0.00001);
|
||||||
|
assert.equal(uv.getX(i), (x + 1) / 2);
|
||||||
|
assert.equal(uv.getY(i), (y + 1) / 2);
|
||||||
|
}
|
||||||
|
assert.equal(plane.show, false);
|
||||||
|
assert.equal(plane.outline.visible, false);
|
||||||
|
plane.show = true;
|
||||||
|
assert.equal(plane.outline.visible, true);
|
||||||
|
assert.equal(material.material.opacity, 0.95);
|
||||||
|
assert.equal(material.material.depthWrite, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Navara CCTV picks actual visible plane triangles from either side, nearest first', (t) => {
|
||||||
|
const h = harness();
|
||||||
|
t.after(() => h.owner.destroy());
|
||||||
|
const make = (id, z) => {
|
||||||
|
const plane = h.owner.createPlane(
|
||||||
|
id,
|
||||||
|
h.owner.createMaterial(canvas()),
|
||||||
|
shape,
|
||||||
|
{ capCenter: { ...positions.capCenter, z } },
|
||||||
|
identity,
|
||||||
|
);
|
||||||
|
plane.show = true;
|
||||||
|
return plane;
|
||||||
|
};
|
||||||
|
const far = make('far', 5600);
|
||||||
|
const near = make('near', 5700);
|
||||||
|
const ray = {
|
||||||
|
origin: { ...positions.capCenter, z: 5800 },
|
||||||
|
direction: { x: 0, y: 0, z: -1 },
|
||||||
|
};
|
||||||
|
assert.equal(h.owner.pickPlane(ray).plane, near);
|
||||||
|
assert.equal(h.owner.pickPlane(ray).distance, 100);
|
||||||
|
near.show = false;
|
||||||
|
assert.equal(h.owner.pickPlane(ray).plane, far);
|
||||||
|
assert.equal(
|
||||||
|
h.owner.pickPlane({
|
||||||
|
...ray,
|
||||||
|
origin: { ...ray.origin, x: ray.origin.x + 81 },
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
h.owner.pickPlane({
|
||||||
|
origin: { ...ray.origin, z: 5500 },
|
||||||
|
direction: { x: 0, y: 0, z: 1 },
|
||||||
|
}).plane,
|
||||||
|
far,
|
||||||
|
);
|
||||||
|
far.destroy();
|
||||||
|
assert.equal(h.owner.pickPlane(ray), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Navara CCTV releases replaced video textures and ignores retired image updates', () => {
|
||||||
|
const h = harness();
|
||||||
|
const first = canvas();
|
||||||
|
const material = h.owner.createMaterial(first);
|
||||||
|
let firstDisposed = 0;
|
||||||
|
material.texture.addEventListener('dispose', () => firstDisposed++);
|
||||||
|
const version = material.texture.version;
|
||||||
|
material.setImage(first);
|
||||||
|
assert.ok(material.texture.version > version);
|
||||||
|
let cancelled = 0;
|
||||||
|
const video = {
|
||||||
|
tagName: 'VIDEO',
|
||||||
|
requestVideoFrameCallback: () => 17,
|
||||||
|
cancelVideoFrameCallback: (id) => {
|
||||||
|
assert.equal(id, 17);
|
||||||
|
cancelled++;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
material.setImage(video);
|
||||||
|
assert.equal(firstDisposed, 1);
|
||||||
|
assert.equal(material.texture.isVideoTexture, true);
|
||||||
|
material.setImage(canvas());
|
||||||
|
assert.equal(cancelled, 1);
|
||||||
|
let finalDisposed = 0;
|
||||||
|
material.texture.addEventListener('dispose', () => finalDisposed++);
|
||||||
|
const plane = h.owner.createPlane('a', material, shape, positions, identity);
|
||||||
|
plane.destroy();
|
||||||
|
plane.destroy();
|
||||||
|
const requests = h.requests();
|
||||||
|
material.setImage(video);
|
||||||
|
assert.equal(h.requests(), requests);
|
||||||
|
assert.equal(finalDisposed, 1);
|
||||||
|
assert.equal(h.handles.size, 0);
|
||||||
|
assert.equal(h.lines.size, 0);
|
||||||
|
h.owner.destroy();
|
||||||
|
h.owner.destroy();
|
||||||
|
assert.equal(finalDisposed, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Navara CCTV rolls back a failed descriptor and rejects new resources after destroy', () => {
|
||||||
|
const h = harness();
|
||||||
|
const material = h.owner.createMaterial(canvas());
|
||||||
|
let releases = 0;
|
||||||
|
material.material.addEventListener('dispose', () => releases++);
|
||||||
|
h.fail();
|
||||||
|
assert.throws(
|
||||||
|
() => h.owner.createPlane('a', material, shape, positions, identity),
|
||||||
|
/descriptor failed/,
|
||||||
|
);
|
||||||
|
assert.equal(releases, 1);
|
||||||
|
assert.equal(h.failedGeometryDisposals(), 1);
|
||||||
|
assert.equal(h.handles.size, 0);
|
||||||
|
assert.equal(h.lines.size, 0);
|
||||||
|
h.owner.destroy();
|
||||||
|
assert.throws(() => h.owner.createMaterial(canvas()), /destroyed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Navara CCTV volume preserves native world vertices and releases geometry/material', () => {
|
||||||
|
const h = harness();
|
||||||
|
const mount = positions.capCenter;
|
||||||
|
const p = (x, y, z) => ({ x: mount.x + x, y: mount.y + y, z: mount.z + z });
|
||||||
|
const volume = h.owner.createVolume(
|
||||||
|
{
|
||||||
|
mount,
|
||||||
|
tl: p(-5, 5, 100),
|
||||||
|
tr: p(5, 5, 100),
|
||||||
|
br: p(5, -5, 100),
|
||||||
|
bl: p(-5, -5, 100),
|
||||||
|
},
|
||||||
|
viewshedColors(30).fill,
|
||||||
|
);
|
||||||
|
assert.equal(volume.mesh.geometry.index.count, 18);
|
||||||
|
assert.equal(volume.mesh.geometry.getAttribute('position').count, 5);
|
||||||
|
assert.equal(volume.mesh.matrix.elements[12], mount.x);
|
||||||
|
let geometryReleases = 0,
|
||||||
|
materialReleases = 0;
|
||||||
|
volume.mesh.geometry.addEventListener('dispose', () => geometryReleases++);
|
||||||
|
volume.mesh.material.addEventListener('dispose', () => materialReleases++);
|
||||||
|
h.owner.destroy();
|
||||||
|
h.owner.destroy();
|
||||||
|
assert.equal(geometryReleases, 1);
|
||||||
|
assert.equal(materialReleases, 1);
|
||||||
|
assert.equal(h.handles.size, 0);
|
||||||
|
});
|
||||||
@@ -58,8 +58,8 @@ function applyMode() {
|
|||||||
if (!_installed) return;
|
if (!_installed) return;
|
||||||
const continuous = _holds.size > 0;
|
const continuous = _holds.size > 0;
|
||||||
if (_engine) {
|
if (_engine) {
|
||||||
if (_engine.render.isContinuous() !== continuous)
|
if (_engine.render.isContinuous() === continuous) return;
|
||||||
_engine.render.setContinuous(continuous);
|
_engine.render.setContinuous(continuous);
|
||||||
if (!continuous) _engine.render.request('governor-idle');
|
if (!continuous) _engine.render.request('governor-idle');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,3 +136,20 @@ test('on a non-Cesium globe engine the governor drives engine.render with owner-
|
|||||||
uninstallRenderGovernor(engine);
|
uninstallRenderGovernor(engine);
|
||||||
_resetRenderGovernorForTest();
|
_resetRenderGovernorForTest();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('releasing an absent Navara hold does not wake an already idle engine', async () => {
|
||||||
|
const { createFakeEngine } = await import('./globe/testing/fakeEngine.js');
|
||||||
|
_resetRenderGovernorForTest();
|
||||||
|
const engine = createFakeEngine({ kind: 'navara' });
|
||||||
|
installRenderGovernor(engine);
|
||||||
|
engine.fake.resetCalls();
|
||||||
|
for (let i = 0; i < 22; i++) releaseContinuousRender('cctv-projection');
|
||||||
|
assert.equal(engine.fake.callsOf('render.request').length, 0);
|
||||||
|
holdContinuousRender('video');
|
||||||
|
releaseContinuousRender('video');
|
||||||
|
assert.equal(engine.fake.callsOf('render.request').length, 1);
|
||||||
|
releaseContinuousRender('video');
|
||||||
|
assert.equal(engine.fake.callsOf('render.request').length, 1);
|
||||||
|
uninstallRenderGovernor(engine);
|
||||||
|
_resetRenderGovernorForTest();
|
||||||
|
});
|
||||||
Reference in new issue
Block a user