mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-10 05:12:23 +00:00
Draw CCTV and data packs through engine.meshes
Add indexed world triangles to the required globe contract on Cesium, Navara and the fake engine. Own geometry, textures, visibility, depth tests, pick identities and teardown inside each engine. Reused canvas images request another frame until their new pixels upload. Move obstruction rays and owned mesh, line and mover exclusions into the Cesium surface adapter. Draw CCTV monitor planes and viewshed volumes through shared mesh handles. Draw data-pack points, straight lines, raised polygons, image rectangles and media captions through movers, lines and meshes. Delete the feature renderers for each engine and retain decoded assets across scene rebinds. Register the modules, dependency, format scope and reduced coupling baseline. Cesium is the reference for geometry, colours, image placement, marker sizes, caption offsets, picking and resource ownership. Navara now uses straight pack lines and per-position polygon heights without edge densification. Navara meshes draw in the transparent pass after the effects, with the scene-depth test and the view's exposure undone, as the pack meshes did: in the scene pass the landscape look's exposure washed a red pack image to white. The scene-depth helper moves into the Navara engine, and the stems helper that only its test still used is deleted. The mesh picker takes the globe when Navara's depth read answers with the camera itself, which it does right after another click and which hid a polygon from a quick second click. The 256 px media caption is not pickable: its transparent square took clicks meant for nearby pack features, and no action targets it. Annotation drapes remain on their existing renderers because bounded building classification, animated fills and wire cages need a separate contract extension to preserve Cesium behaviour. Coupling falls from 344 to 210 Cesium layer-renderer lines, 1294 to 794 Navara layer-renderer lines, 9 to 7 external Cesium import files, 1026 to 1005 viewer lines, 9 to 6 extension reads and 42 to 39 engine-name lines. Other counts do not grow. The full unit suite, import directions, package boundaries, formatting and coupling checks pass. No user-visible strings change. In the browser, keyless: the contract suite passes on Navara and on Cesium apart from the camera-events check that also fails intermittently before this change; CCTV v2 (58 checks), the CCTV and director engine switches pass, with the pack image at the same pixel as before on both engines. The mesh contract fixture now sits at 3 km, above the terrain Navara draws there. The director interactions script starts at its view in the link so the app's arrival flight no longer lands after the shot, and waits for the click's asynchronous pick; its last step, the next-shot action, fails the same way before this change.
This commit is contained in:
1 parent
3907b7084e
commit
f4b83a3a31
57 files changed
+1891
-1588
No files matched your search
@@ -280,17 +280,19 @@ one application, and only the globe-bound parts move to the next engine.
|
||||
layer manifest loads and passes in).
|
||||
The Active Fires far-zoom heatmap is an `engine.imagery` layer, and Recent
|
||||
Imagery draws its days through `engine.imagery` from its own
|
||||
`render/display.js`. CCTV's monitor plane and viewshed volume, which the
|
||||
contract has no primitive for yet, draw behind one seam,
|
||||
`src/layers/cctv/render/meshes.js` (`render/cesiumMeshes.js` on Cesium;
|
||||
`render/navaraMeshes.js`, which the layer manifest loads and passes in).
|
||||
`render/display.js`. CCTV's monitor plane and viewshed volume use
|
||||
`engine.meshes` through `src/layers/cctv/render/meshes.js`, with no layer
|
||||
backend per engine. Data packs share `src/scenes/dataPacks/display.js`:
|
||||
movers, lines and world meshes retain authored heights, polygon holes,
|
||||
images and picking across scene detach and reattach. Annotation drapes
|
||||
remain on their existing renderers pending bounded classification support.
|
||||
Navara ground effects (heat, pulses, trails, pads, drapes) are declared
|
||||
with `defineGroundEffect` (`src/layers/shared/render/navaraGroundEffect.js`);
|
||||
their insert order is pinned by `navaraGroundOrder.test.mjs`.
|
||||
- **One layer manifest.** `LAYER_MANIFEST` in `src/app/layers/renderers.js`
|
||||
says how each directory layer draws: its display with the contract
|
||||
primitives it needs (and an engine-specific backend option keyed by engine
|
||||
kind: CCTV's meshes and Global Context's pulses on Navara), a layer that
|
||||
kind: Global Context's pulses on Navara), a layer that
|
||||
draws itself through the contract (Wildlife Sightings), or, without a
|
||||
display, its drawing per engine kind (wind, the weather rasters and the
|
||||
Bhote Koshi event and locator draw themselves on Cesium and through a
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
# Globe primitives
|
||||
|
||||
A layer draws through the globe-engine contract (`src/globe/contract.js`),
|
||||
not through Cesium or Navara. The contract has four drawing primitives:
|
||||
not through Cesium or Navara. The contract has five drawing primitives:
|
||||
|
||||
- `engine.movers`: things at points;
|
||||
- `engine.lines`: lines in the air;
|
||||
- `engine.ground`: fills on the ground;
|
||||
- `engine.imagery`: rasters on the ground.
|
||||
- `engine.imagery`: rasters on the ground;
|
||||
- `engine.meshes`: textured or coloured world triangles.
|
||||
|
||||
Each engine implements all four. Cesium's are in `src/globe/cesium/`,
|
||||
Each engine implements all five. Cesium's are in `src/globe/cesium/`,
|
||||
Navara's in `src/globe/navara/`, and the fake engine's in
|
||||
`src/globe/testing/fakeEngine.js`. The checks every engine must pass are in
|
||||
`src/globe/testing/contractSuite.mjs`.
|
||||
@@ -522,3 +523,46 @@ sharpening on both engines, following Cesium.
|
||||
the engine animates.
|
||||
- **Pixels on the ground** (tiles, a raster, a photo): an imagery layer.
|
||||
Fade it with `setAlpha`, and give a new raster to `setSource`.
|
||||
|
||||
## Meshes: world triangles
|
||||
|
||||
`engine.meshes.createMesh({ id, positions, indices, uv, image, color, show,
|
||||
depthTest, pickId })` draws indexed triangles on both sides. Positions are
|
||||
flat Earth-fixed xyz metres and indices name their vertices. Optional UVs run
|
||||
from (0, 0) at the image's bottom left to (1, 1) at its top right. The engine
|
||||
copies geometry arrays; the caller owns the canvas, image or video.
|
||||
|
||||
`update(patch)` replaces only supplied fields. Repeating `image` uploads new
|
||||
canvas pixels; videos update on rendered frames. RGBA `color` multiplies the
|
||||
image, white by default. `show()` reads visibility and `show(boolean)` changes
|
||||
it. `depthTest` is `normal` or `always`; null `pickId` disables picking. Picks
|
||||
return the authored id and the mesh id as owner. `destroy()` is idempotent;
|
||||
`engine.meshes.list()` returns live ids in creation order.
|
||||
|
||||
Cesium draws a synchronous Primitive with a flat material, no back-face
|
||||
culling and no depth writes. Colour, image and visibility changes retain the
|
||||
primitive; geometry, pick-id and depth-test changes replace it. The engine
|
||||
requests frames while the primitive or its image is preparing. Navara draws
|
||||
local coordinates relative to a double-precision world origin in its
|
||||
scene pass, with scene depth and triangle picking. Its meshes receive the
|
||||
scene effects, as Cesium primitives do.
|
||||
Both engines own geometry, material and texture disposal. The fake engine
|
||||
keeps the same state and tests triangle intersections for picks.
|
||||
|
||||
CCTV's `render/meshes.js` describes its live monitor quad and viewshed pyramid
|
||||
through this primitive. Its markers and outlines remain movers and lines.
|
||||
Obstruction probes use `engine.surface.raycast`; exclusions accept owned mesh,
|
||||
line and mover handles on Cesium and restore their visibility after the query.
|
||||
|
||||
Data packs use one `src/scenes/dataPacks/display.js`: points on movers,
|
||||
straight raised paths on lines, raised polygons and image rectangles on
|
||||
meshes, and media markers with captions in a composed sprite. Polygon holes
|
||||
and authored vertex heights are retained. Shared triangulation uses `earcut`,
|
||||
with tests comparing its area to Cesium's polygon geometry. Images retain
|
||||
Cesium's one-degree rectangle grid. Scene detach releases drawn resources and
|
||||
pick targets; reattach redraws the retained decoded assets.
|
||||
|
||||
Annotation drapes still use their existing renderers. Their bounded building
|
||||
classification volumes, animated fills and wire cages are not ordinary world
|
||||
triangles; they need a classification contract before that migration can
|
||||
preserve Cesium's behaviour.
|
||||
Generated
+1
@@ -14,6 +14,7 @@
|
||||
"@mapbox/vector-tile": "^3.0.0",
|
||||
"@meri-imperiumi/eccodes-wasm": "^2.48.2",
|
||||
"cesium": "^1.124.0",
|
||||
"earcut": "^3.0.2",
|
||||
"egm96-universal": "^1.1.1",
|
||||
"hls.js": "^1.7.3",
|
||||
"mapillary-js": "^4.1.2",
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
"@navaramap/three": "0.2.1",
|
||||
"@navaramap/three-default-plugin": "0.2.1",
|
||||
"cesium": "^1.124.0",
|
||||
"earcut": "^3.0.2",
|
||||
"egm96-universal": "^1.1.1",
|
||||
"hls.js": "^1.7.3",
|
||||
"kdbush": "4.0.2",
|
||||
|
||||
@@ -85,7 +85,7 @@ const cesiumLayerRenderer = (file) =>
|
||||
/\/render\/cesium(?:[^/]*|\/[^/]+)\.js$/.test(file);
|
||||
// Navara renderers outside a layer's render/ directory: like those, they
|
||||
// reach the app only through the Navara mode's import().
|
||||
const NAVARA_SCENE_RENDERERS = new Set(['src/scenes/dataPacks/navara.js']);
|
||||
const NAVARA_SCENE_RENDERERS = new Set();
|
||||
// The modules that load Navara renderers, each through import().
|
||||
const NAVARA_RENDERER_LOADERS = new Set([
|
||||
'src/app/navaraMode.js',
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"metrics": {
|
||||
"cesiumLayerRendererLines": 344,
|
||||
"navaraLayerRendererLines": 1294,
|
||||
"cesiumImportFiles": 9,
|
||||
"viewerLines": 1026,
|
||||
"cesiumLayerRendererLines": 210,
|
||||
"navaraLayerRendererLines": 794,
|
||||
"cesiumImportFiles": 7,
|
||||
"viewerLines": 1005,
|
||||
"navaraKindChecks": 2,
|
||||
"engineSniffCalls": 0,
|
||||
"engineExtensionReads": 9,
|
||||
"engineExtensionReads": 6,
|
||||
"groundEffectPasses": 1,
|
||||
"cesiumSdkLoads": 3,
|
||||
"screenSpaceHandlerFiles": 4,
|
||||
"engineNameLines": 42
|
||||
"engineNameLines": 39
|
||||
},
|
||||
"measuredAt": "Move Cesium internals into the engine and draw imported records through shared primitives"
|
||||
"measuredAt": "engine.meshes: CCTV and data packs on one primitive; dead Navara stems helper removed"
|
||||
}
|
||||
@@ -1231,8 +1231,6 @@
|
||||
"src/layers/bikeshare/presentation.test.mjs",
|
||||
"src/layers/bikeshare/selectionModel.js",
|
||||
"src/layers/cctv/projectionScheduling.test.mjs",
|
||||
"src/layers/cctv/render/navaraMeshes.js",
|
||||
"src/layers/cctv/render/navaraMeshes.test.mjs",
|
||||
"src/layers/cctv/lifecycle.test.mjs",
|
||||
"src/layers/cctv/projectionMedia.test.mjs",
|
||||
"src/layers/cctv/ingestion.test.mjs",
|
||||
@@ -1563,7 +1561,6 @@
|
||||
"src/testSupport/vesselViewerRenderer.mjs",
|
||||
"scripts/qa-wildlife-switch.mjs",
|
||||
"scripts/fixtures/wildlife.mjs",
|
||||
"src/layers/cctv/render/cesiumMeshes.js",
|
||||
"src/layers/cctv/render/display.js",
|
||||
"src/layers/cctv/render/display.test.mjs",
|
||||
"src/layers/cctv/render/displayGizmo.test.mjs",
|
||||
@@ -1673,5 +1670,15 @@
|
||||
"src/globe/cesium/sonarCommands.test.mjs",
|
||||
"src/globe/cesium/starrySky.js",
|
||||
"src/globe/cesium/zoomOutGuard.js",
|
||||
"scripts/qa-geo-import-probe.mjs"
|
||||
"scripts/qa-geo-import-probe.mjs",
|
||||
"src/globe/cesium/meshes.js",
|
||||
"src/globe/meshes.js",
|
||||
"src/globe/navara/meshes.js",
|
||||
"src/globe/navara/meshes.test.mjs",
|
||||
"src/scenes/dataPacks/display.js",
|
||||
"src/scenes/dataPacks/shapes.js",
|
||||
"src/scenes/dataPacks/shapes.test.mjs",
|
||||
"src/globe/cesium/meshes.test.mjs",
|
||||
"src/globe/meshes.test.mjs",
|
||||
"src/scenes/dataPacks/display.test.mjs"
|
||||
]
|
||||
@@ -1302,7 +1302,8 @@
|
||||
"src/director/packs/manifest.js",
|
||||
"src/scenes/dataPacks/controller.js",
|
||||
"src/scenes/dataPacks/presentation.js",
|
||||
"src/scenes/dataPacks/geometry.js",
|
||||
"src/scenes/dataPacks/display.js",
|
||||
"src/scenes/dataPacks/shapes.js",
|
||||
"src/director/interactions/document.js",
|
||||
"src/director/interactions/session.js",
|
||||
"src/scenes/interactions.js",
|
||||
@@ -1341,7 +1342,7 @@
|
||||
"src/globe/contract.js",
|
||||
"src/geo/"
|
||||
],
|
||||
"external": ["cesium", "vue", "pinia", "vue-i18n"]
|
||||
"external": ["cesium", "vue", "pinia", "vue-i18n", "earcut"]
|
||||
},
|
||||
"live-observations": {
|
||||
"exports": ["./sources/live"],
|
||||
@@ -3150,7 +3151,8 @@
|
||||
"src/director/packs/manifest.js",
|
||||
"src/scenes/dataPacks/controller.js",
|
||||
"src/scenes/dataPacks/presentation.js",
|
||||
"src/scenes/dataPacks/geometry.js",
|
||||
"src/scenes/dataPacks/display.js",
|
||||
"src/scenes/dataPacks/shapes.js",
|
||||
"src/director/interactions/document.js",
|
||||
"src/director/interactions/session.js",
|
||||
"src/scenes/interactions.js",
|
||||
@@ -3456,10 +3458,7 @@
|
||||
"src/celestialRing/view.js",
|
||||
"src/layers/shared/render/navaraPolygons.js",
|
||||
"src/layers/awareness/render/navaraPulses.js",
|
||||
"src/layers/shared/render/navaraStems.js",
|
||||
"src/layers/shared/render/navaraSceneDepth.js",
|
||||
"src/scenes/dataPacks/navara.js",
|
||||
"src/scenes/dataPacks/navaraGeometry.js",
|
||||
"src/globe/navara/sceneDepth.js",
|
||||
"src/layers/shared/render/navaraStory.js",
|
||||
"src/layers/wind/render/flowMesh.js",
|
||||
"src/layers/shared/render/navaraGroundPicks.js",
|
||||
@@ -3469,7 +3468,6 @@
|
||||
"src/layers/firms/rectangle.js",
|
||||
"src/layers/shared/render/navaraMoverBank.js",
|
||||
"src/layers/shared/render/moverBank.js",
|
||||
"src/layers/cctv/render/navaraMeshes.js",
|
||||
"src/layers/radio/screenClusters.js",
|
||||
"src/layers/shared/render/imageAtlas.js",
|
||||
"src/layers/shared/render/navaraGroundSurface.js",
|
||||
@@ -3538,7 +3536,6 @@
|
||||
"src/globe/navara/ground/pass.js",
|
||||
"src/globe/navara/ground/pack.js",
|
||||
"src/globe/navara/ground/index.js",
|
||||
"src/layers/cctv/render/cesiumMeshes.js",
|
||||
"src/layers/cctv/render/meshes.js",
|
||||
"src/layers/cctv/render/display.js",
|
||||
"src/layers/cctv/worldClick.js",
|
||||
@@ -3579,7 +3576,10 @@
|
||||
"src/globe/cesium/postStages.js",
|
||||
"src/data/geoImportRecords.js",
|
||||
"src/data/geoImportKml.js",
|
||||
"src/data/geoImportDisplay.js"
|
||||
"src/data/geoImportDisplay.js",
|
||||
"src/globe/cesium/meshes.js",
|
||||
"src/globe/meshes.js",
|
||||
"src/globe/navara/meshes.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -3598,7 +3598,8 @@
|
||||
"@navaramap/three",
|
||||
"@navaramap/three-default-plugin",
|
||||
"three",
|
||||
"postprocessing"
|
||||
"postprocessing",
|
||||
"earcut"
|
||||
]
|
||||
},
|
||||
"application-catalog": {
|
||||
@@ -4289,7 +4290,6 @@
|
||||
"src/layers/radio/render/display.js",
|
||||
"src/layers/transit/render/style.js",
|
||||
"src/layers/cctv/render/style.js",
|
||||
"src/layers/cctv/render/cesiumMeshes.js",
|
||||
"src/layers/cctv/render/meshes.js",
|
||||
"src/layers/cctv/render/display.js",
|
||||
"src/layers/cctv/worldClick.js",
|
||||
@@ -4322,7 +4322,6 @@
|
||||
"src/layers/wind/render/navaraFlow.js",
|
||||
"src/layers/bhoteKoshi/render/display.js",
|
||||
"src/layers/shared/render/navaraStory.js",
|
||||
"src/layers/cctv/render/navaraMeshes.js",
|
||||
"src/layers/shared/render/navaraGroundSurface.js",
|
||||
"src/layers/wind/render/flowMesh.js",
|
||||
"src/layers/shared/render/navaraGroundEffect.js",
|
||||
@@ -4338,7 +4337,9 @@
|
||||
"src/styles/noir.js",
|
||||
"src/data/geoImportRecords.js",
|
||||
"src/data/geoImportKml.js",
|
||||
"src/data/geoImportDisplay.js"
|
||||
"src/data/geoImportDisplay.js",
|
||||
"src/globe/cesium/meshes.js",
|
||||
"src/globe/meshes.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -5100,7 +5101,9 @@
|
||||
"src/styles/surveillance.js",
|
||||
"src/styles/thermal.js",
|
||||
"src/styles/anime.js",
|
||||
"src/styles/noir.js"
|
||||
"src/styles/noir.js",
|
||||
"src/globe/cesium/meshes.js",
|
||||
"src/globe/meshes.js"
|
||||
],
|
||||
"external": ["cesium", "egm96-universal", "pbf"]
|
||||
},
|
||||
|
||||
+32
-52
@@ -294,8 +294,8 @@ function installRenderFramesHelper(page) {
|
||||
// lines named `cctv-<camera>-<role>` and `cctv-gizmo-<part>`, and the gizmo's
|
||||
// handles in the `cctv-gizmo-points` point set. engine.lines.list() names the
|
||||
// live lines (id, points, shown); their positions are what the layer's own
|
||||
// line handles hold (record.coverageEntities). The monitor plane is still a
|
||||
// Cesium plane Entity (render/cesiumMeshes.js), read as before.
|
||||
// line handles hold (record.coverageEntities). The monitor plane is a
|
||||
// world mesh, with placement held by the display's plane handle.
|
||||
const CONTRACT_SRC = `
|
||||
function cctvRecord(camId) {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
@@ -354,18 +354,15 @@ const CONTRACT_SRC = `
|
||||
);
|
||||
return shown.every(Boolean) ? 'shown' : 'hidden';
|
||||
}
|
||||
// The viewshed volumes the layer holds (record.viewshedPrimitive), with
|
||||
// their Cesium Primitive, only where that primitive is in the scene.
|
||||
// The layer's viewshed volumes whose engine meshes are still live.
|
||||
function viewshedVolumes() {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
const primitives = window.__godsEyeView.viewer.scene.primitives;
|
||||
const live = new Set(window.__godsEyeView.engine.meshes.list());
|
||||
const out = [];
|
||||
for (const record of mod._cctvRecordsForTest()) {
|
||||
const volume = record.viewshedPrimitive;
|
||||
const primitive = volume && !volume.destroyed && volume.native
|
||||
? volume.native.primitive
|
||||
: null;
|
||||
if (primitive && primitives.contains(primitive))
|
||||
const primitive = volume && !volume.destroyed ? volume.mesh : null;
|
||||
if (primitive && live.has(primitive.id))
|
||||
out.push({ camera: record.camera.id, volume, primitive });
|
||||
}
|
||||
return out;
|
||||
@@ -379,23 +376,21 @@ const CONTRACT_SRC = `
|
||||
const SERIALIZE_GEOM_SRC = `
|
||||
${CONTRACT_SRC}
|
||||
function serializeGeom(camId) {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const time = viewer.clock.currentTime;
|
||||
const roles = ['ray-tl', 'ray-tr', 'ray-br', 'ray-bl', 'cap'];
|
||||
const lines = liveLines();
|
||||
const poly = {};
|
||||
for (const role of roles)
|
||||
poly[role] = coverageLinePositions(camId, role, lines);
|
||||
const planeEnt = viewer.entities.getById('cctv-' + camId + '-plane');
|
||||
const planeEnt = cctvRecord(camId)?.projection?.planeEntity;
|
||||
const live = window.__godsEyeView.engine.meshes.list().includes(planeEnt?.id);
|
||||
let plane = null;
|
||||
if (planeEnt) {
|
||||
const pos = planeEnt.position.getValue(time);
|
||||
const orient = planeEnt.orientation.getValue(time);
|
||||
const dims = planeEnt.plane.dimensions.getValue(time);
|
||||
if (live) {
|
||||
const { positions, orientation: orient, shape } = planeEnt.mesh;
|
||||
const pos = positions.capCenter;
|
||||
plane = {
|
||||
position: [pos.x, pos.y, pos.z],
|
||||
orientation: [orient.x, orient.y, orient.z, orient.w],
|
||||
dimensions: [dims.x, dims.y],
|
||||
dimensions: [shape.halfW * 2, shape.halfH * 2],
|
||||
};
|
||||
}
|
||||
return { poly, plane };
|
||||
@@ -1036,7 +1031,7 @@ async function main() {
|
||||
// source. Assert the migrated surface via the host diagnostics.
|
||||
const diag = window.__gevWorldOverlay?.getDiagnostics?.();
|
||||
g.hasLabel = (diag?.entriesBySource?.['cctv-projection'] || 0) >= 1;
|
||||
const planeEnt = viewer.entities.getById('cctv-' + camId + '-plane');
|
||||
const planeEnt = cctvRecord(camId)?.projection?.planeEntity;
|
||||
g.planeShow = planeEnt ? planeEnt.show : null;
|
||||
return g;
|
||||
})(${JSON.stringify(activeId)})`);
|
||||
@@ -1056,7 +1051,7 @@ async function main() {
|
||||
.join(', '),
|
||||
);
|
||||
record(
|
||||
'plane + plane-label entities exist for the active camera',
|
||||
'plane mesh and label exist for the active camera',
|
||||
!!geomInfo.plane && geomInfo.hasLabel,
|
||||
`plane=${!!geomInfo.plane} label=${geomInfo.hasLabel} show=${geomInfo.planeShow}`,
|
||||
);
|
||||
@@ -1424,15 +1419,11 @@ async function main() {
|
||||
// never null/undefined once the runtime exists, i.e. there's no gap where
|
||||
// the plane shows nothing.
|
||||
const materialInfo = await page.evaluate((camId) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const time = viewer.clock.currentTime;
|
||||
const planeEnt = viewer.entities.getById('cctv-' + camId + '-plane');
|
||||
const mat = planeEnt?.plane?.material;
|
||||
const image = mat?.image;
|
||||
const resolved =
|
||||
image && typeof image.getValue === 'function'
|
||||
? image.getValue(time)
|
||||
: image;
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
const runtime = mod._cctvRecordsForTest().find((r) => r.camera.id === camId)?.projection;
|
||||
const planeEnt = runtime?.planeEntity;
|
||||
const mat = runtime?.planeMaterial;
|
||||
const resolved = mat?.image;
|
||||
return {
|
||||
hasMaterial: !!mat,
|
||||
hasImage: !!resolved,
|
||||
@@ -1863,9 +1854,7 @@ async function main() {
|
||||
console.log(
|
||||
'Checking viewshed mode (coverage tri-state + color-coded volumes)...',
|
||||
);
|
||||
// The volumes are the layer's (record.viewshedPrimitive), each a Cesium
|
||||
// Primitive in scene.primitives (render/cesiumMeshes.js); they carry no
|
||||
// tag of their own any more.
|
||||
// Every layer volume owns one engine mesh.
|
||||
const countVolumes = () =>
|
||||
page.evaluate(`(function(){
|
||||
${CONTRACT_SRC}
|
||||
@@ -1875,29 +1864,20 @@ async function main() {
|
||||
const cBeforeViewshed = await readCounters();
|
||||
await page.evaluate(`(function(){
|
||||
${CONTRACT_SRC}
|
||||
const primitives = window.__godsEyeView.viewer.scene.primitives;
|
||||
const meshes = window.__godsEyeView.engine.meshes;
|
||||
const events = [];
|
||||
window.__qaViewshedTrace = events;
|
||||
// A volume is a synchronous Primitive the pick pass skips; which
|
||||
// camera it belongs to is looked up when the trace is read.
|
||||
const isVolume = (primitive) =>
|
||||
primitive &&
|
||||
primitive.allowPicking === false &&
|
||||
primitive.asynchronous === false &&
|
||||
typeof primitive.get !== 'function';
|
||||
for (const method of ['add', 'remove']) {
|
||||
const original = primitives[method].bind(primitives);
|
||||
primitives[method] = (primitive, ...args) => {
|
||||
if (isVolume(primitive))
|
||||
events.push({
|
||||
method,
|
||||
primitive,
|
||||
at: performance.now(),
|
||||
stack: new Error().stack,
|
||||
});
|
||||
return original(primitive, ...args);
|
||||
};
|
||||
}
|
||||
const create = meshes.createMesh.bind(meshes);
|
||||
meshes.createMesh = (options) => {
|
||||
const primitive = create(options);
|
||||
if (options.id.startsWith('cctv-volume-')) {
|
||||
const record = (method) => events.push({ method, primitive, at: performance.now(), stack: new Error().stack });
|
||||
record('add');
|
||||
const destroy = primitive.destroy.bind(primitive);
|
||||
primitive.destroy = () => { record('remove'); destroy(); };
|
||||
}
|
||||
return primitive;
|
||||
};
|
||||
window.__qaViewshedTraceRead = () => {
|
||||
const owners = new Map(
|
||||
viewshedVolumes().map((entry) => [entry.primitive, entry.camera]),
|
||||
|
||||
@@ -47,7 +47,9 @@ const load = () =>
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(
|
||||
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
|
||||
// A view in the link: the app starts there instead of flying to a city
|
||||
// after the shot has placed the camera.
|
||||
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0#v=2&lat=30.2672&lon=-97.742&alt=4000&heading=0&pitch=-90&l=`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
@@ -105,7 +107,7 @@ try {
|
||||
from: { ...camera, alt: 4100, altitudeReference: 'ellipsoid' },
|
||||
easing: 'linear',
|
||||
},
|
||||
visual: { style: 'normal', mapStack: 'photoreal' },
|
||||
visual: { style: 'normal', mapStack: 'sentinel2' },
|
||||
layers: { traffic: false },
|
||||
dataPackIds: ['data'],
|
||||
interactions: [
|
||||
@@ -162,21 +164,26 @@ try {
|
||||
),
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.tileset?.tilesLoaded === true,
|
||||
() => window.__godsEyeView.sceneDirector.engine.surface.tilesLoaded(),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
const point = await page.evaluate(async () => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
const entity = d.viewer.entities.values.find(
|
||||
(e) => e.name === 'data / point',
|
||||
);
|
||||
const pos = entity.position.getValue(d.viewer.clock.currentTime);
|
||||
const screen = d.viewer.scene.cartesianToCanvasCoordinates(pos);
|
||||
const rect = d.viewer.scene.canvas.getBoundingClientRect();
|
||||
const { cartesian3 } = await import('/src/geo/index.js');
|
||||
const pos = cartesian3.fromDegrees(-97.742, 30.2672, 600);
|
||||
const screen = d.engine.projector.worldToWindow(pos);
|
||||
const rect = d.engine.host.canvas.getBoundingClientRect();
|
||||
return { x: screen.x + rect.left, y: screen.y + rect.top };
|
||||
});
|
||||
await page.mouse.click(point.x, point.y);
|
||||
// The click's pick answers asynchronously; the focus follows it.
|
||||
await page
|
||||
.waitForFunction(
|
||||
() => document.activeElement?.dataset.directorAction === 'card',
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.catch(() => {});
|
||||
check(
|
||||
'real globe pick selects the feature and focuses its keyboard action',
|
||||
await page.evaluate(
|
||||
@@ -204,7 +211,8 @@ try {
|
||||
`${output}/report.json`,
|
||||
JSON.stringify(
|
||||
await page.evaluate(() => ({
|
||||
tilesSettled: window.__godsEyeView.tileset?.tilesLoaded === true,
|
||||
tilesSettled:
|
||||
window.__godsEyeView.sceneDirector.engine.surface.tilesLoaded(),
|
||||
})),
|
||||
null,
|
||||
2,
|
||||
|
||||
@@ -53,7 +53,7 @@ test('the scene constructs the engine with the viewer and returns it beside the
|
||||
assert.match(scene, /tilesetRef\.current = tileset;/);
|
||||
assert.match(
|
||||
scene.replace(/\s+/g, ' '),
|
||||
/return \{ viewer, engine, tileset, mapStackController, operations, packRenderer,? \};/,
|
||||
/return \{ viewer, engine, tileset, mapStackController, operations,? \};/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
// default); the entry's `display` is its loader. `backends` is the
|
||||
// display's engine-specific option, keyed by engine kind: an import()
|
||||
// loader of `{ option: value }` merged into the display's options on that
|
||||
// engine (CCTV's meshes, Global Context's pulses on Navara).
|
||||
// engine (Global Context's pulses on Navara).
|
||||
// - `drawsItself(needs)`: no renderer; the layer draws itself through the
|
||||
// contract primitives in `needs` (Wildlife Sightings).
|
||||
// - `perEngine({ [kind]: loader | DRAWS_ITSELF })`: a layer with no display
|
||||
@@ -60,6 +60,7 @@ export const CONTRACT_PRIMITIVES = Object.freeze([
|
||||
'lines',
|
||||
'ground',
|
||||
'imagery',
|
||||
'meshes',
|
||||
]);
|
||||
|
||||
/** The primitives an engine kind draws with: all of them, on every kind. */
|
||||
@@ -174,14 +175,7 @@ export const LAYER_MANIFEST = Object.freeze({
|
||||
satellites: display('createSatellitesDisplay', ['movers', 'lines']),
|
||||
'rocket-launches': display('createLaunchesDisplay', ['movers', 'lines']),
|
||||
traffic: display('createTrafficDisplay', ['movers', 'ground']),
|
||||
// CCTV's display, with the Navara backend of its plane and volume
|
||||
// (render/meshes.js).
|
||||
cctv: display('createCctvDisplay', ['movers', 'lines'], {
|
||||
navara: async () => ({
|
||||
navaraMeshes: (await import('../../layers/cctv/render/navaraMeshes.js'))
|
||||
.createCctvNavaraMeshes,
|
||||
}),
|
||||
}),
|
||||
cctv: display('createCctvDisplay', ['movers', 'lines', 'meshes']),
|
||||
radio: display('createRadioDisplay', ['movers']),
|
||||
transit: display('createTransitDisplay', ['movers', 'lines']),
|
||||
bikeshare: display('createBikeshareDisplay', ['movers']),
|
||||
|
||||
@@ -91,15 +91,10 @@ test('the layers drawn through one display, and the ones that draw without one',
|
||||
// Engine-specific options of a display: the Navara backends.
|
||||
assert.deepEqual(
|
||||
kinds((entry) => Object.keys(entry.backends ?? {}).length),
|
||||
[
|
||||
'bhote-koshi-2026',
|
||||
'bhote-koshi-locator',
|
||||
'cctv',
|
||||
'military-awareness',
|
||||
'wind',
|
||||
],
|
||||
['bhote-koshi-2026', 'bhote-koshi-locator', 'military-awareness', 'wind'],
|
||||
);
|
||||
assert.deepEqual(Object.keys(LAYER_MANIFEST.cctv.backends), ['navara']);
|
||||
assert.deepEqual(Object.keys(LAYER_MANIFEST.cctv.backends), []);
|
||||
assert.ok(LAYER_MANIFEST.cctv.needs.includes('meshes'));
|
||||
// The layer that draws itself through the contract.
|
||||
assert.deepEqual(
|
||||
kinds((entry) => !entry.display && entry.needs),
|
||||
|
||||
@@ -121,22 +121,9 @@ export async function createNavaraScene({
|
||||
engine,
|
||||
(credits ?? DATA_CREDITS).filter(({ key }) => NAVARA_CREDIT_KEYS.has(key)),
|
||||
);
|
||||
let packRenderer;
|
||||
const { createNavaraPackRenderer } =
|
||||
await import('../scenes/dataPacks/navara.js');
|
||||
const packGraphics = () =>
|
||||
(packRenderer ??= createNavaraPackRenderer(
|
||||
engine,
|
||||
engine.extensions.navara,
|
||||
));
|
||||
return {
|
||||
viewer: null,
|
||||
engine,
|
||||
packRenderer: {
|
||||
geometry: (...args) => packGraphics().geometry(...args),
|
||||
image: (...args) => packGraphics().image(...args),
|
||||
media: (...args) => packGraphics().media(...args),
|
||||
},
|
||||
tileset: null,
|
||||
suspend() {
|
||||
uninstallRenderGovernor(engine);
|
||||
|
||||
+3
-5
@@ -76,10 +76,9 @@ export async function createApplicationScene({
|
||||
signal,
|
||||
...resources,
|
||||
});
|
||||
const { viewer, engine, tileset, mapStackController, packRenderer } =
|
||||
rendererCache
|
||||
? await rendererCache.acquire(options.engineKind, build, defer)
|
||||
: await build({ defer });
|
||||
const { viewer, engine, tileset, mapStackController } = rendererCache
|
||||
? await rendererCache.acquire(options.engineKind, build, defer)
|
||||
: await build({ defer });
|
||||
if (layerSession) {
|
||||
// DEM cells belong to the session. Mesh samples came from the outgoing
|
||||
// scene, including when the incoming canvas was warm in the cache.
|
||||
@@ -94,7 +93,6 @@ export async function createApplicationScene({
|
||||
tileset,
|
||||
mapStackController,
|
||||
operations,
|
||||
packRenderer,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+1
-2
@@ -75,7 +75,7 @@ export async function createApplicationTools({
|
||||
layerSession?.director ??
|
||||
new SceneDirector(first.viewer, styleManager, dataManager, {
|
||||
engine: first.engine,
|
||||
dataPacks: { ...sceneDataPacks, native: first.packRenderer },
|
||||
dataPacks: sceneDataPacks,
|
||||
isMapStackAvailable: isStackAvailable(first.mapStackController),
|
||||
});
|
||||
// The Director built on this globe is attached to it already.
|
||||
@@ -126,7 +126,6 @@ export async function createApplicationTools({
|
||||
next.viewer,
|
||||
next.engine,
|
||||
styleManager,
|
||||
next.packRenderer,
|
||||
isStackAvailable(next.mapStackController),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -742,7 +742,7 @@ test('real active monitor plane owns one protected host label and no native labe
|
||||
setVisible: (...args) => calls.push(['visible', ...args]),
|
||||
clearSource: (...args) => calls.push(['clear', ...args]),
|
||||
};
|
||||
const { engine, viewer } = createCesiumMoversEngine();
|
||||
const { engine, viewer, primitive } = createCesiumMoversEngine();
|
||||
const record = {
|
||||
camera: {
|
||||
...UNCLAMPED_CAMERA,
|
||||
@@ -770,9 +770,11 @@ test('real active monitor plane owns one protected host label and no native labe
|
||||
runtime.planeEntity,
|
||||
'runtime guard requires a real monitor plane',
|
||||
);
|
||||
const planeEntity = viewer.entities.getById(runtime.planeEntity.id);
|
||||
assert.equal(planeEntity.label, undefined);
|
||||
assert.ok(planeEntity.plane, 'monitor plane geometry remains native');
|
||||
assert.ok(
|
||||
engine.meshes.list().includes(runtime.planeEntity.id),
|
||||
'monitor plane geometry is an engine mesh',
|
||||
);
|
||||
assert.equal(viewer.entities.getById(runtime.planeEntity.id), undefined);
|
||||
assert.ok(
|
||||
viewer.entities.values.every((entity) => entity.label === undefined),
|
||||
);
|
||||
@@ -819,7 +821,7 @@ test('real active monitor plane owns one protected host label and no native labe
|
||||
});
|
||||
refreshCoverageStyles();
|
||||
assert.equal(runtime.planeEntity.show, false);
|
||||
assert.equal(planeEntity.show, false);
|
||||
assert.equal(primitive(runtime.planeEntity.id).show, false);
|
||||
assert.deepEqual(calls.slice(-2), [
|
||||
['clear', 'cctv-projection'],
|
||||
['visible', 'cctv-projection', false],
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createCesiumCamera } from './camera.js';
|
||||
import { createCesiumClock } from './clock.js';
|
||||
import { createCesiumCredits } from './credits.js';
|
||||
import { createCesiumFrames } from './frames.js';
|
||||
import { createCesiumMeshes } from './meshes.js';
|
||||
import { createCesiumGround } from './ground.js';
|
||||
import { createCesiumImagery } from './imagery.js';
|
||||
import { createCesiumInput } from './input.js';
|
||||
@@ -67,6 +68,7 @@ export function createCesiumEngine(viewer, options = {}) {
|
||||
requestRender,
|
||||
...(options.groundFactories ? { factories: options.groundFactories } : {}),
|
||||
});
|
||||
const meshes = createCesiumMeshes(viewer, { requestRender });
|
||||
const maps = createCesiumMaps(viewer, {
|
||||
...options,
|
||||
credits: credits.credits,
|
||||
@@ -77,7 +79,8 @@ export function createCesiumEngine(viewer, options = {}) {
|
||||
identify: (picked) =>
|
||||
movers.identify(picked) ??
|
||||
lines.identify(picked) ??
|
||||
ground.identify(picked),
|
||||
ground.identify(picked) ??
|
||||
meshes.identify(picked),
|
||||
});
|
||||
const parts = {
|
||||
camera: createCesiumCamera(viewer),
|
||||
@@ -88,10 +91,14 @@ export function createCesiumEngine(viewer, options = {}) {
|
||||
movers,
|
||||
lines,
|
||||
ground,
|
||||
meshes,
|
||||
render,
|
||||
// Mover sets in a surface `exclude` are hidden for the sample.
|
||||
surface: createCesiumSurface(viewer, {
|
||||
engineExclude: (entry) => movers.primitiveOf(entry),
|
||||
engineExclude: (entry) =>
|
||||
movers.primitiveOf(entry) ??
|
||||
meshes.primitiveOf(entry) ??
|
||||
lines.primitiveOf(entry),
|
||||
}),
|
||||
input: createCesiumInput(viewer, { ...options, picker: picker.picker }),
|
||||
credits,
|
||||
|
||||
@@ -67,6 +67,7 @@ test('the engine draws the visual styles, bloom and contact sonar, not the cloud
|
||||
lines: true,
|
||||
ground: true,
|
||||
imagery: true,
|
||||
meshes: true,
|
||||
});
|
||||
assert.equal(engine.contactSonarDiagnostics().contacts, 0);
|
||||
engine.destroy();
|
||||
|
||||
@@ -231,6 +231,16 @@ export function createCesiumLines(
|
||||
});
|
||||
return {
|
||||
lines: registry.lines,
|
||||
primitiveOf(handle) {
|
||||
const state = [...registry.live.values()].find(
|
||||
(entry) => entry.line === handle,
|
||||
);
|
||||
if (!state) return null;
|
||||
const entry = ordered.find(
|
||||
({ slot }) => slot.get(0)?.gevLine === state.options.id,
|
||||
);
|
||||
return entry?.slot ?? null;
|
||||
},
|
||||
/** The live line a Cesium pick result belongs to, with its pickId, or null. */
|
||||
identify(picked) {
|
||||
const owner = picked?.primitive?.gevLine;
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createMeshRegistry } from '../meshes.js';
|
||||
|
||||
/** World triangles with flat colour or a live image, drawn on both sides. */
|
||||
export function createCesiumMeshes(viewer, { requestRender = () => {} } = {}) {
|
||||
let root;
|
||||
let removeFrame;
|
||||
const owned = new Map();
|
||||
const registry = createMeshRegistry((state) => {
|
||||
root ??= viewer.scene.primitives.add(new Cesium.PrimitiveCollection());
|
||||
let primitive;
|
||||
let material;
|
||||
removeFrame ??= viewer.scene.postRender.addEventListener(() => {
|
||||
for (const { state, backend } of registry.live.values()) {
|
||||
if (state.show && !backend.ready()) {
|
||||
requestRender('meshes');
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
function build(next) {
|
||||
const attributes = {
|
||||
position: new Cesium.GeometryAttribute({
|
||||
componentDatatype: Cesium.ComponentDatatype.DOUBLE,
|
||||
componentsPerAttribute: 3,
|
||||
values: next.positions,
|
||||
}),
|
||||
};
|
||||
if (next.uv)
|
||||
attributes.st = new Cesium.GeometryAttribute({
|
||||
componentDatatype: Cesium.ComponentDatatype.FLOAT,
|
||||
componentsPerAttribute: 2,
|
||||
values: next.uv,
|
||||
});
|
||||
const geometry = new Cesium.Geometry({
|
||||
attributes,
|
||||
indices: next.indices,
|
||||
primitiveType: Cesium.PrimitiveType.TRIANGLES,
|
||||
boundingSphere: Cesium.BoundingSphere.fromVertices(next.positions),
|
||||
});
|
||||
Cesium.GeometryPipeline.computeNormal(geometry);
|
||||
const color = new Cesium.Color(...next.color);
|
||||
const nextMaterial = next.image
|
||||
? Cesium.Material.fromType('Image', {
|
||||
image: next.image,
|
||||
color,
|
||||
transparent: true,
|
||||
})
|
||||
: Cesium.Material.fromType('Color', { color });
|
||||
const nextPrimitive = new Cesium.Primitive({
|
||||
geometryInstances: new Cesium.GeometryInstance({
|
||||
geometry,
|
||||
id: next.pickId,
|
||||
}),
|
||||
appearance: new Cesium.MaterialAppearance({
|
||||
material: nextMaterial,
|
||||
materialSupport: next.image
|
||||
? Cesium.MaterialAppearance.MaterialSupport.TEXTURED
|
||||
: Cesium.MaterialAppearance.MaterialSupport.BASIC,
|
||||
flat: true,
|
||||
faceForward: true,
|
||||
translucent: true,
|
||||
renderState: {
|
||||
cull: { enabled: false },
|
||||
depthTest: { enabled: next.depthTest !== 'always' },
|
||||
depthMask: false,
|
||||
},
|
||||
}),
|
||||
asynchronous: false,
|
||||
allowPicking: next.pickId != null,
|
||||
show: next.show,
|
||||
});
|
||||
nextPrimitive.gevMesh = next.id;
|
||||
root.add(nextPrimitive);
|
||||
if (primitive) {
|
||||
owned.delete(primitive);
|
||||
root.remove(primitive);
|
||||
}
|
||||
if (material && !material.isDestroyed()) material.destroy();
|
||||
primitive = nextPrimitive;
|
||||
material = nextMaterial;
|
||||
owned.set(primitive, state);
|
||||
}
|
||||
build(state);
|
||||
return {
|
||||
primitive: () => primitive,
|
||||
ready: () =>
|
||||
primitive.ready &&
|
||||
material._loadedImages.length === 0 &&
|
||||
(!state.image ||
|
||||
(material._textures.image &&
|
||||
material._textures.image !== material._defaultTexture &&
|
||||
(state.image.tagName === 'VIDEO' ||
|
||||
material._texturePaths.image === state.image))),
|
||||
update(next, patch) {
|
||||
if (
|
||||
['positions', 'indices', 'uv', 'depthTest', 'pickId'].some(
|
||||
(key) => key in patch,
|
||||
) ||
|
||||
Boolean(next.image) !== Boolean(state.image)
|
||||
)
|
||||
build(next);
|
||||
else {
|
||||
primitive.show = next.show;
|
||||
material.uniforms.color = new Cesium.Color(...next.color);
|
||||
if (next.image) {
|
||||
material.uniforms.image = next.image;
|
||||
if ('image' in patch) material._texturePaths.image = undefined;
|
||||
}
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
owned.delete(primitive);
|
||||
root.remove(primitive);
|
||||
if (!material.isDestroyed()) material.destroy();
|
||||
if (!registry.live.size) {
|
||||
removeFrame?.();
|
||||
removeFrame = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}, requestRender);
|
||||
return {
|
||||
meshes: registry.meshes,
|
||||
identify(picked) {
|
||||
const state = owned.get(picked?.primitive);
|
||||
return state
|
||||
? {
|
||||
id: state.pickId,
|
||||
owner: state.id,
|
||||
visible: state.show,
|
||||
pickable: state.pickId != null,
|
||||
}
|
||||
: null;
|
||||
},
|
||||
primitiveOf(handle) {
|
||||
const entry = registry.live.get(handle?.id);
|
||||
return entry?.handle === handle ? entry.backend.primitive() : null;
|
||||
},
|
||||
destroy() {
|
||||
registry.destroy();
|
||||
removeFrame?.();
|
||||
removeFrame = null;
|
||||
if (root) viewer.scene.primitives.remove(root);
|
||||
root = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createCesiumMoversEngine } from '../../testSupport/cesiumMoversEngine.mjs';
|
||||
|
||||
const triangles = {
|
||||
id: 'mesh',
|
||||
positions: [6378137, 0, 0, 6378137, 20, 0, 6378137, 0, 20],
|
||||
indices: [0, 1, 2],
|
||||
};
|
||||
|
||||
test('Cesium meshes draw indexed triangles and restyle without rebuilding', (t) => {
|
||||
const h = createCesiumMoversEngine();
|
||||
t.after(() => h.engine.destroy());
|
||||
const mesh = h.engine.meshes.createMesh({ ...triangles, pickId: 'feature' });
|
||||
const first = h.primitive(mesh.id);
|
||||
assert.deepEqual(
|
||||
[...first.geometryInstances.geometry.attributes.position.values],
|
||||
triangles.positions,
|
||||
);
|
||||
assert.equal(
|
||||
first.geometryInstances.geometry.attributes.normal.values.length,
|
||||
9,
|
||||
);
|
||||
assert.equal(first.allowPicking, true);
|
||||
assert.equal(first.asynchronous, false);
|
||||
assert.equal(first.appearance.renderState.cull.enabled, false);
|
||||
assert.equal(first.appearance.renderState.depthMask, false);
|
||||
mesh.update({ color: [1, 0, 0, 0.2], show: false });
|
||||
assert.equal(h.primitive(mesh.id), first);
|
||||
assert.equal(first.appearance.material.uniforms.color.alpha, 0.2);
|
||||
assert.equal(first.show, false);
|
||||
mesh.update({ depthTest: 'always', show: true, pickId: 'replacement' });
|
||||
const second = h.primitive(mesh.id);
|
||||
assert.notEqual(second, first);
|
||||
assert.equal(first.isDestroyed(), true);
|
||||
assert.equal(second.appearance.renderState.depthTest.enabled, false);
|
||||
h.viewer.scene.pick = () => ({ primitive: second, id: 'replacement' });
|
||||
assert.equal(h.engine.picker.pick({ x: 1, y: 1 }).id, 'replacement');
|
||||
assert.equal(h.engine.picker.pick({ x: 1, y: 1 }).owner, mesh.id);
|
||||
mesh.destroy();
|
||||
mesh.destroy();
|
||||
assert.equal(second.isDestroyed(), true);
|
||||
assert.equal(h.primitive(mesh.id), undefined);
|
||||
});
|
||||
|
||||
test('Cesium mesh image refresh invalidates a reused canvas and releases frame listeners', (t) => {
|
||||
let requests = 0;
|
||||
const h = createCesiumMoversEngine({
|
||||
engineOptions: { requestRender: () => requests++ },
|
||||
});
|
||||
t.after(() => h.engine.destroy());
|
||||
const frame = h.viewer.scene.postRender;
|
||||
const before = frame.numberOfListeners;
|
||||
const canvas = new HTMLCanvasElement();
|
||||
canvas.width = canvas.height = 8;
|
||||
const mesh = h.engine.meshes.createMesh({
|
||||
...triangles,
|
||||
uv: [0, 0, 1, 0, 0, 1],
|
||||
image: canvas,
|
||||
});
|
||||
const material = h.primitive(mesh.id).appearance.material;
|
||||
material._texturePaths.image = canvas;
|
||||
mesh.update({ image: canvas });
|
||||
assert.equal(material._texturePaths.image, undefined);
|
||||
assert.equal(material.uniforms.image, canvas);
|
||||
assert.equal(frame.numberOfListeners, before + 1);
|
||||
h.primitive(mesh.id)._ready = true;
|
||||
material._textures.image = { destroy() {} };
|
||||
material._texturePaths.image = canvas;
|
||||
material._loadedImages.push({ id: 'image', image: canvas });
|
||||
const pending = requests;
|
||||
h.frame();
|
||||
assert.ok(requests > pending, 'a queued upload requests another frame');
|
||||
material._loadedImages.length = 0;
|
||||
const uploaded = requests;
|
||||
h.frame();
|
||||
assert.equal(requests, uploaded, 'an uploaded still image can stop drawing');
|
||||
mesh.destroy();
|
||||
assert.equal(frame.numberOfListeners, before);
|
||||
assert.equal(material.isDestroyed(), true);
|
||||
});
|
||||
|
||||
test('ray obstruction hides excluded mesh and line handles and restores them on a failed query', (t) => {
|
||||
const h = createCesiumMoversEngine();
|
||||
t.after(() => h.engine.destroy());
|
||||
const mesh = h.engine.meshes.createMesh(triangles);
|
||||
const primitive = h.primitive(mesh.id);
|
||||
const line = h.engine.lines.createLine({
|
||||
id: 'line',
|
||||
pickId: 'line',
|
||||
widthPx: 2,
|
||||
color: [1, 1, 1, 1],
|
||||
});
|
||||
line.setPositions([
|
||||
{ x: 6378137, y: 0, z: 0 },
|
||||
{ x: 6378137, y: 20, z: 0 },
|
||||
]);
|
||||
const root = [...Array(h.viewer.scene.primitives.length)].map((_, i) =>
|
||||
h.viewer.scene.primitives.get(i),
|
||||
);
|
||||
const slot = root
|
||||
.flatMap((r) => [...Array(r.length)].map((_, i) => r.get(i)))
|
||||
.find((p) => p.get?.(0)?.gevLine === 'line');
|
||||
h.viewer.scene.pickFromRay = () => {
|
||||
assert.equal(primitive.show, false);
|
||||
assert.equal(slot.show, false);
|
||||
throw new Error('ray failed');
|
||||
};
|
||||
assert.throws(
|
||||
() =>
|
||||
h.engine.surface.raycast({
|
||||
origin: { x: 6378137, y: 0, z: 0 },
|
||||
direction: { x: 1, y: 0, z: 0 },
|
||||
far: 100,
|
||||
exclude: [mesh, line],
|
||||
}),
|
||||
/ray failed/,
|
||||
);
|
||||
assert.equal(primitive.show, true);
|
||||
assert.equal(slot.show, true);
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
// set is hidden for the sample instead (synchronous: nothing draws in
|
||||
// between) and shown again after it.
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { firstVisibleTileset } from '../../services/groundSnap.js';
|
||||
import { normalizeSurfaceOptions } from '../surfaceDetail.js';
|
||||
import { toGeo3 } from './convert.js';
|
||||
@@ -63,6 +64,21 @@ export function createCesiumSurface(
|
||||
scene.globe ? finite(scene.globe.getHeight(cartographic)) : undefined;
|
||||
|
||||
const surface = {
|
||||
raycast({ origin, direction, far, exclude = [] }) {
|
||||
if (!scene.pickFromRay || !Number.isFinite(far) || far <= 0)
|
||||
return undefined;
|
||||
return excluding(exclude, (native) => {
|
||||
const hit = scene.pickFromRay(
|
||||
new Cesium.Ray(origin, direction),
|
||||
native,
|
||||
);
|
||||
if (!hit?.position) return undefined;
|
||||
const distance = Cesium.Cartesian3.distance(origin, hit.position);
|
||||
return distance <= far
|
||||
? { position: toGeo3(hit.position), distance, object: hit.object }
|
||||
: undefined;
|
||||
});
|
||||
},
|
||||
sampleHeight,
|
||||
// Cesium samples the tiles it has loaded and does not say which tile
|
||||
// answered.
|
||||
|
||||
+30
-5
@@ -198,8 +198,8 @@
|
||||
* sampleHeight with the answer's quality (src/globe/surfaceDetail.js):
|
||||
* from the tiles drawn now (Navara: where they miss, the finest loaded
|
||||
* ones; Cesium samples what it has loaded). Where no tile answers, the
|
||||
* terrain. `exclude` takes this engine's mover sets as well as engine
|
||||
* objects.
|
||||
* terrain. `exclude` takes this engine's mover sets, lines and meshes as
|
||||
* well as native engine objects.
|
||||
* @property {boolean} supported Whether sampleHeight works.
|
||||
* @property {(cartographic: Cartographic) => (number | undefined)} terrainHeight
|
||||
* Height of the terrain/globe surface only (Cesium's globe.getHeight).
|
||||
@@ -209,7 +209,8 @@
|
||||
* @property {boolean} clampToHeightSupported
|
||||
* @property {() => boolean} tilesLoaded
|
||||
* @property {(ray: { origin: Cartesian3, direction: Cartesian3, far: number, exclude?: any[] }) => ({ position: Cartesian3, distance: number, object: any } | undefined)} [raycast]
|
||||
* Nearest visible tile triangle within finite positive `far` metres. Direction
|
||||
* Nearest visible scene object within finite positive `far` metres (Navara:
|
||||
* loaded tile triangles). Direction
|
||||
* is normalized by the adapter. Excluded roots/meshes and hidden objects do
|
||||
* not obstruct; a miss or unsupported geometry returns undefined, not terrain.
|
||||
*
|
||||
@@ -641,6 +642,28 @@
|
||||
* now: the globe, 3D tiles, or nothing (a Cesium stack with neither
|
||||
* draws no imagery until one shows; the layers wait).
|
||||
*
|
||||
* @typedef {object} MeshOptions
|
||||
* @property {string} id Unique within the engine.
|
||||
* @property {ArrayLike<number>} positions Flat Earth-fixed xyz metres, copied.
|
||||
* @property {ArrayLike<number>} indices Triangle vertex indices, copied.
|
||||
* @property {ArrayLike<number>} [uv] Texture coordinates, (0,0) at bottom left.
|
||||
* @property {number[]} [color] RGBA in 0..1, white by default; multiplies image.
|
||||
* @property {object|null} [image] Canvas, image or video; caller owns its lifetime.
|
||||
* @property {boolean} [show=true]
|
||||
* @property {'normal'|'always'} [depthTest='normal']
|
||||
* @property {any} [pickId] Null disables picking. Both faces draw and pick.
|
||||
*
|
||||
* @typedef {object} GlobeMesh
|
||||
* @property {string} id
|
||||
* @property {(patch: Partial<MeshOptions>) => void} update Updates geometry or style.
|
||||
* Repeating an image uploads changed pixels. Omitted fields retain their values.
|
||||
* @property {(visible?: boolean) => (boolean|void)} show Read or set visibility.
|
||||
* @property {() => void} destroy Idempotent; releases engine-owned resources.
|
||||
*
|
||||
* @typedef {object} GlobeMeshes
|
||||
* @property {(options: MeshOptions) => GlobeMesh} createMesh
|
||||
* @property {() => string[]} list Live mesh ids in creation order.
|
||||
*
|
||||
* @typedef {object} GlobeEngine
|
||||
* @property {'cesium'|'navara'} kind
|
||||
* @property {{ container: object, canvas: object, size: () => { width: number, height: number } }} host
|
||||
@@ -662,6 +685,7 @@
|
||||
* @property {GlobeMovers} movers The layers' moving objects (sprites,
|
||||
* points, models), drawn by the engine.
|
||||
* @property {GlobeLines} lines The layers' dynamic polylines.
|
||||
* @property {GlobeMeshes} meshes World triangles.
|
||||
* @property {GlobeGround} ground The layers' fills on the ground.
|
||||
* @property {GlobeImagery} imagery The layers' rasters on the ground.
|
||||
* @property {() => void} destroy
|
||||
@@ -876,6 +900,7 @@ const SHAPE = {
|
||||
clock: { now: isFunction, frameTime: isFunction },
|
||||
movers: { createSet: isFunction, list: isFunction },
|
||||
lines: { createLine: isFunction, list: isFunction },
|
||||
meshes: { createMesh: isFunction, list: isFunction },
|
||||
ground: { createSet: isFunction, list: isFunction },
|
||||
imagery: {
|
||||
createLayer: isFunction,
|
||||
@@ -924,7 +949,7 @@ const OPTIONAL_SHAPE = {
|
||||
|
||||
// Required capabilities engineCapabilities reports as well: true for an
|
||||
// engine that has them whole, as every valid engine does.
|
||||
const REPORTED_REQUIRED = ['movers', 'lines', 'ground', 'imagery'];
|
||||
const REPORTED_REQUIRED = ['movers', 'lines', 'ground', 'imagery', 'meshes'];
|
||||
|
||||
const memberAt = (engine, path) =>
|
||||
path.split('.').reduce((value, key) => value?.[key], engine);
|
||||
@@ -934,7 +959,7 @@ const memberAt = (engine, path) =>
|
||||
* booleans. A caller that refuses a feature on an engine without one
|
||||
* (voice, say) asks here. Cesium and Navara draw the styles, bloom and
|
||||
* contact sonar; only Navara has cloudCover. `movers`, `lines`, `ground`
|
||||
* and `imagery` are required; they are reported too, true for every valid
|
||||
* and `imagery`, plus `meshes`, are required; they are reported too, true for every valid
|
||||
* engine.
|
||||
*/
|
||||
export function engineCapabilities(engine) {
|
||||
|
||||
@@ -343,6 +343,20 @@ test('imagery is required and reported as a capability', () => {
|
||||
assert.ok(names.some((name) => name.startsWith('imagery:')));
|
||||
});
|
||||
|
||||
test('meshes are required and reported as a capability', () => {
|
||||
const engine = createFakeEngine();
|
||||
assert.equal(engineCapabilities(engine).meshes, true);
|
||||
assert.throws(
|
||||
() => assertGlobeEngine({ ...engine, meshes: undefined }),
|
||||
/meshes/,
|
||||
);
|
||||
assert.throws(
|
||||
() => assertGlobeEngine({ ...engine, meshes: { createMesh() {} } }),
|
||||
/meshes.list/,
|
||||
);
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
test('engineCapabilities names the optional capabilities an engine has whole', () => {
|
||||
const plain = createFakeEngine();
|
||||
for (const flag of ['visualStyles', 'bloom', 'cloudCover', 'contactSonar'])
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// Indexed triangles in Earth-fixed metres. The registry owns copied geometry
|
||||
// and style; each engine owns the resources that draw it.
|
||||
|
||||
import { cartesian3 } from '../geo/index.js';
|
||||
|
||||
function geometry(options) {
|
||||
const positions = Float64Array.from(options.positions ?? []);
|
||||
const indices = Array.from(options.indices ?? []);
|
||||
const uv = options.uv == null ? null : Float32Array.from(options.uv);
|
||||
if (
|
||||
positions.length < 9 ||
|
||||
positions.length % 3 ||
|
||||
!positions.every(Number.isFinite) ||
|
||||
!indices.length ||
|
||||
indices.length % 3 ||
|
||||
!indices.every(
|
||||
(i) => Number.isInteger(i) && i >= 0 && i < positions.length / 3,
|
||||
) ||
|
||||
(uv &&
|
||||
(uv.length !== (positions.length / 3) * 2 || !uv.every(Number.isFinite)))
|
||||
)
|
||||
throw new TypeError('Invalid mesh triangles');
|
||||
return { positions, indices: new Uint32Array(indices), uv };
|
||||
}
|
||||
|
||||
function normalize(options, previous, patch = options) {
|
||||
const color = Array.from(options.color ?? [1, 1, 1, 1]);
|
||||
if (
|
||||
color.length !== 4 ||
|
||||
!color.every((v) => Number.isFinite(v) && v >= 0 && v <= 1)
|
||||
)
|
||||
throw new TypeError('Invalid mesh color');
|
||||
const depthTest = options.depthTest ?? 'normal';
|
||||
if (!['normal', 'always'].includes(depthTest))
|
||||
throw new TypeError('Invalid mesh depthTest');
|
||||
if (options.show !== undefined && typeof options.show !== 'boolean')
|
||||
throw new TypeError('Mesh show must be a boolean');
|
||||
if (
|
||||
options.image != null &&
|
||||
(typeof options.image !== 'object' || !options.uv)
|
||||
)
|
||||
throw new TypeError(
|
||||
'A mesh image needs an image object and UV coordinates',
|
||||
);
|
||||
return {
|
||||
...options,
|
||||
...(previous && !['positions', 'indices', 'uv'].some((key) => key in patch)
|
||||
? {
|
||||
positions: previous.positions,
|
||||
indices: previous.indices,
|
||||
uv: previous.uv,
|
||||
}
|
||||
: geometry(options)),
|
||||
color,
|
||||
depthTest,
|
||||
show: options.show !== false,
|
||||
image: options.image ?? null,
|
||||
pickId: options.pickId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Nearest forward intersection with either face of the indexed triangles. */
|
||||
export function meshRayDistance(state, ray) {
|
||||
if (!ray) return Infinity;
|
||||
const { positions: p, indices } = state;
|
||||
const point = (i) => ({ x: p[i * 3], y: p[i * 3 + 1], z: p[i * 3 + 2] });
|
||||
const sub = (a, b) => cartesian3.subtract(a, b, cartesian3.create());
|
||||
const cross = (a, b) => cartesian3.cross(a, b, cartesian3.create());
|
||||
const direction = cartesian3.normalize(ray.direction, cartesian3.create());
|
||||
let nearest = Infinity;
|
||||
for (let i = 0; i < indices.length; i += 3) {
|
||||
const a = point(indices[i]);
|
||||
const ab = sub(point(indices[i + 1]), a);
|
||||
const ac = sub(point(indices[i + 2]), a);
|
||||
const normal = cross(direction, ac);
|
||||
const determinant = cartesian3.dot(ab, normal);
|
||||
if (Math.abs(determinant) < 1e-10) continue;
|
||||
const offset = sub(ray.origin, a);
|
||||
const u = cartesian3.dot(offset, normal) / determinant;
|
||||
if (u < 0 || u > 1) continue;
|
||||
const q = cross(offset, ab);
|
||||
const v = cartesian3.dot(direction, q) / determinant;
|
||||
if (v < 0 || u + v > 1) continue;
|
||||
const distance = cartesian3.dot(ac, q) / determinant;
|
||||
if (distance >= 0) nearest = Math.min(nearest, distance);
|
||||
}
|
||||
return nearest;
|
||||
}
|
||||
|
||||
export function createMeshRegistry(make, request = () => {}) {
|
||||
const live = new Map();
|
||||
let destroyed = false;
|
||||
const meshes = {
|
||||
createMesh(options) {
|
||||
if (destroyed) throw new Error('Meshes are destroyed');
|
||||
if (
|
||||
typeof options?.id !== 'string' ||
|
||||
!options.id ||
|
||||
live.has(options.id)
|
||||
)
|
||||
throw new TypeError('Mesh id must be unique and nonempty');
|
||||
const state = normalize(options);
|
||||
let freed = false;
|
||||
const backend = make(state);
|
||||
const handle = {
|
||||
id: state.id,
|
||||
update(patch) {
|
||||
if (freed) return;
|
||||
const next = normalize(
|
||||
{ ...state, ...patch, id: state.id },
|
||||
state,
|
||||
patch,
|
||||
);
|
||||
backend.update(next, patch);
|
||||
Object.assign(state, next);
|
||||
request();
|
||||
},
|
||||
show(value) {
|
||||
if (value === undefined) return !freed && state.show;
|
||||
handle.update({ show: Boolean(value) });
|
||||
},
|
||||
destroy() {
|
||||
if (freed) return;
|
||||
freed = true;
|
||||
live.delete(state.id);
|
||||
backend.destroy();
|
||||
request();
|
||||
},
|
||||
};
|
||||
live.set(state.id, { state, handle, backend });
|
||||
request();
|
||||
return handle;
|
||||
},
|
||||
list: () => [...live.keys()],
|
||||
};
|
||||
return {
|
||||
meshes,
|
||||
live,
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
for (const { handle } of [...live.values()]) handle.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createMeshRegistry, meshRayDistance } from './meshes.js';
|
||||
|
||||
const triangles = {
|
||||
id: 'mesh',
|
||||
positions: [0, 0, 0, 2, 0, 0, 0, 2, 0],
|
||||
indices: [0, 1, 2],
|
||||
};
|
||||
|
||||
test('mesh updates validate before drawing and copy caller geometry', () => {
|
||||
let state;
|
||||
let updates = 0;
|
||||
const registry = createMeshRegistry((initial) => {
|
||||
state = initial;
|
||||
return {
|
||||
update() {
|
||||
updates++;
|
||||
},
|
||||
destroy() {},
|
||||
};
|
||||
});
|
||||
const positions = [...triangles.positions];
|
||||
const handle = registry.meshes.createMesh({ ...triangles, positions });
|
||||
positions.fill(99);
|
||||
assert.deepEqual([...state.positions], triangles.positions);
|
||||
const previous = state.positions;
|
||||
handle.update({ color: [1, 0, 0, 0.5] });
|
||||
assert.equal(
|
||||
state.positions,
|
||||
previous,
|
||||
'colour does not copy or rebuild geometry',
|
||||
);
|
||||
for (const patch of [
|
||||
{ positions: [0, 0, 0] },
|
||||
{ positions: [NaN, ...triangles.positions.slice(1)] },
|
||||
{ indices: [0, 1, 3] },
|
||||
{ indices: [0, 1, 1.5] },
|
||||
{ indices: [0, 1] },
|
||||
{ uv: [0, 1] },
|
||||
{ color: [1, 0, 0, 2] },
|
||||
{ depthTest: 'bad' },
|
||||
{ show: 0 },
|
||||
{ image: {} },
|
||||
])
|
||||
assert.throws(() => handle.update(patch), TypeError);
|
||||
assert.equal(updates, 1);
|
||||
assert.throws(() => registry.meshes.createMesh(triangles), /unique/);
|
||||
handle.destroy();
|
||||
handle.update({ show: true });
|
||||
assert.equal(handle.show(), false);
|
||||
registry.meshes.createMesh(triangles);
|
||||
registry.destroy();
|
||||
registry.destroy();
|
||||
assert.deepEqual(registry.meshes.list(), []);
|
||||
assert.throws(() => registry.meshes.createMesh(triangles), /destroyed/);
|
||||
});
|
||||
|
||||
test('triangle ray intersection respects both faces, the triangle bounds and forward distance', () => {
|
||||
const ray = {
|
||||
origin: { x: 0.5, y: 0.5, z: 3 },
|
||||
direction: { x: 0, y: 0, z: -2 },
|
||||
};
|
||||
assert.equal(meshRayDistance(triangles, ray), 3);
|
||||
assert.equal(
|
||||
meshRayDistance(triangles, {
|
||||
...ray,
|
||||
origin: { x: 0.5, y: 0.5, z: -3 },
|
||||
direction: { x: 0, y: 0, z: 1 },
|
||||
}),
|
||||
3,
|
||||
);
|
||||
assert.equal(
|
||||
meshRayDistance(triangles, { ...ray, origin: { x: 1.5, y: 1.5, z: 3 } }),
|
||||
Infinity,
|
||||
);
|
||||
assert.equal(
|
||||
meshRayDistance(triangles, { ...ray, direction: { x: 0, y: 0, z: 1 } }),
|
||||
Infinity,
|
||||
);
|
||||
});
|
||||
@@ -48,6 +48,12 @@ const NO_LINES = Object.freeze({
|
||||
},
|
||||
list: () => [],
|
||||
});
|
||||
const NO_MESHES = Object.freeze({
|
||||
createMesh() {
|
||||
throw new Error('Navara meshes need the view runtime');
|
||||
},
|
||||
list: () => [],
|
||||
});
|
||||
const NO_GROUND = Object.freeze({
|
||||
createSet() {
|
||||
throw new Error("Navara ground needs the view's three runtime (ground/)");
|
||||
@@ -125,6 +131,7 @@ export function createNavaraEngine(
|
||||
lines: linesPort = null,
|
||||
ground: groundPort = null,
|
||||
imagery: imageryPort = null,
|
||||
meshes: meshesPort = null,
|
||||
sharpen: sharpenPort = null,
|
||||
styles: stylesPort = null,
|
||||
bloom: bloomPort = null,
|
||||
@@ -305,6 +312,7 @@ export function createNavaraEngine(
|
||||
},
|
||||
};
|
||||
|
||||
const meshes = meshesPort?.({ camera: model.camera, picker }) ?? null;
|
||||
let sharpen = { ...SHARPEN_INITIAL };
|
||||
let destroyed = false;
|
||||
// render.visualStyles/setStyle/style, with a styles port only.
|
||||
@@ -434,6 +442,7 @@ export function createNavaraEngine(
|
||||
movers: moversPort?.movers ?? NO_MOVERS,
|
||||
lines: linesPort?.lines ?? NO_LINES,
|
||||
ground: groundPort?.ground ?? NO_GROUND,
|
||||
meshes: meshes?.meshes ?? NO_MESHES,
|
||||
imagery: imagery?.imagery ?? NO_IMAGERY,
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
@@ -456,6 +465,7 @@ export function createNavaraEngine(
|
||||
moversPort?.destroy();
|
||||
linesPort?.destroy();
|
||||
groundPort?.destroy();
|
||||
meshes?.destroy();
|
||||
},
|
||||
isDestroyed: () => destroyed,
|
||||
// Navara only, not contract: the post pipeline as QA reads it (the
|
||||
|
||||
@@ -34,6 +34,7 @@ import { animeShader } from '../../styles/anime.js';
|
||||
import { noirShader } from '../../styles/noir.js';
|
||||
import { createNavaraMovers } from './movers/index.js';
|
||||
import { createNavaraLines } from './lines/index.js';
|
||||
import { createNavaraMeshes } from './meshes.js';
|
||||
import { createNavaraGround } from './ground/index.js';
|
||||
import { createGroundPass } from './ground/pass.js';
|
||||
import { createNavaraImagery } from './imagery/index.js';
|
||||
@@ -174,6 +175,7 @@ export async function createNavaraGlobe({ container, search, hd = false }) {
|
||||
),
|
||||
}),
|
||||
lines: createNavaraLines(view, { overlay }),
|
||||
meshes: (parts) => createNavaraMeshes(view, { MeshDesc, ...parts }),
|
||||
ground: createNavaraGround(view, { createPass: createGroundPass }),
|
||||
imagery: (parts) =>
|
||||
createNavaraImagery(view, { ...parts, createPass: createImageryPass }),
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import {
|
||||
BufferAttribute,
|
||||
BufferGeometry,
|
||||
CanvasTexture,
|
||||
Color,
|
||||
DoubleSide,
|
||||
Group,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
Raycaster,
|
||||
SRGBColorSpace,
|
||||
Texture,
|
||||
VideoTexture,
|
||||
} from 'three';
|
||||
import { createMeshRegistry } from '../meshes.js';
|
||||
import { cartesian3 } from '../../geo/index.js';
|
||||
import { attachSceneDepth, createSceneDepthTest } from './sceneDepth.js';
|
||||
|
||||
const KIND = 'gevWorldMesh';
|
||||
const NEAR_SURFACE_M = 2;
|
||||
const registered = new WeakSet();
|
||||
|
||||
export function createNavaraMeshes(view, { MeshDesc, camera, picker }) {
|
||||
if (!registered.has(view)) {
|
||||
view.registerMesh(
|
||||
KIND,
|
||||
class extends MeshDesc {
|
||||
constructor(owner, ctx, config) {
|
||||
super(owner, ctx, config);
|
||||
this.resource = config[KIND];
|
||||
}
|
||||
// Transparent, drawn after the effects: the colours are the ones
|
||||
// asked for (as Cesium draws them), not tone-mapped with the look.
|
||||
// The scene-depth test hides a mesh behind terrain, tiles and Earth.
|
||||
getPassKey() {
|
||||
return 'transparent';
|
||||
}
|
||||
createMesh() {
|
||||
attachSceneDepth(this.resource.group, this.ctx);
|
||||
return this.resource.group;
|
||||
}
|
||||
onDestroy() {
|
||||
this.resource.dispose();
|
||||
super.onDestroy();
|
||||
}
|
||||
},
|
||||
);
|
||||
registered.add(view);
|
||||
}
|
||||
const registry = createMeshRegistry(
|
||||
(state) => {
|
||||
const group = new Group();
|
||||
const geometry = new BufferGeometry();
|
||||
const material = new MeshBasicMaterial({
|
||||
side: DoubleSide,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
toneMapped: false,
|
||||
});
|
||||
const mesh = new Mesh(geometry, material);
|
||||
const sceneDepth = createSceneDepthTest();
|
||||
sceneDepth.material(material);
|
||||
sceneDepth.object(group);
|
||||
sceneDepth.object(mesh);
|
||||
let depthFree = false;
|
||||
const pigment = new Color();
|
||||
const bindDepth = mesh.onBeforeRender;
|
||||
mesh.onBeforeRender = function onBeforeRender(...args) {
|
||||
bindDepth.apply(this, args);
|
||||
if (depthFree) sceneDepth.uniforms.uGevSceneDepthOn.value = 0;
|
||||
// The pass still scales by the view's exposure: undo it.
|
||||
material.color
|
||||
.copy(pigment)
|
||||
.multiplyScalar(1 / Math.max(1, view.toneMappingExposure ?? 1));
|
||||
};
|
||||
group.frustumCulled = mesh.frustumCulled = false;
|
||||
group.add(mesh);
|
||||
let texture,
|
||||
image,
|
||||
freed = false;
|
||||
const dispose = () => {
|
||||
if (freed) return;
|
||||
freed = true;
|
||||
geometry.dispose();
|
||||
material.dispose();
|
||||
texture?.dispose();
|
||||
};
|
||||
function update(next, patch) {
|
||||
if ('positions' in patch || 'indices' in patch || 'uv' in patch) {
|
||||
const p = next.positions;
|
||||
const local = new Float32Array(p.length);
|
||||
for (let i = 0; i < p.length; i++) local[i] = p[i] - p[i % 3];
|
||||
mesh.position.set(p[0], p[1], p[2]);
|
||||
geometry.setAttribute('position', new BufferAttribute(local, 3));
|
||||
geometry.setIndex(new BufferAttribute(next.indices, 1));
|
||||
if (next.uv)
|
||||
geometry.setAttribute('uv', new BufferAttribute(next.uv, 2));
|
||||
else geometry.deleteAttribute('uv');
|
||||
geometry.computeBoundingSphere();
|
||||
}
|
||||
if (image !== next.image) {
|
||||
texture?.dispose();
|
||||
image = next.image;
|
||||
texture = !image
|
||||
? null
|
||||
: image.tagName === 'VIDEO'
|
||||
? new VideoTexture(image)
|
||||
: image.getContext
|
||||
? new CanvasTexture(image)
|
||||
: new Texture(image);
|
||||
if (texture) {
|
||||
texture.colorSpace = SRGBColorSpace;
|
||||
texture.needsUpdate = true;
|
||||
}
|
||||
material.map = texture;
|
||||
material.needsUpdate = true;
|
||||
} else if (texture && !texture.isVideoTexture && 'image' in patch)
|
||||
texture.needsUpdate = true;
|
||||
pigment.setRGB(...next.color.slice(0, 3), SRGBColorSpace);
|
||||
material.color.copy(pigment);
|
||||
material.opacity = next.color[3];
|
||||
depthFree = next.depthTest === 'always';
|
||||
material.depthTest = !depthFree;
|
||||
group.visible = next.show;
|
||||
}
|
||||
let holder;
|
||||
try {
|
||||
update(state, state);
|
||||
holder = view.addMesh({ [KIND]: { group, dispose } });
|
||||
} catch (error) {
|
||||
dispose();
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
group,
|
||||
mesh,
|
||||
update,
|
||||
destroy() {
|
||||
try {
|
||||
holder.delete();
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
() => view.forceUpdate(),
|
||||
);
|
||||
const raycaster = new Raycaster();
|
||||
const removePicks = picker.registerMeshPicker((at) => {
|
||||
const candidates = [...registry.live.values()].filter(
|
||||
({ state }) => state.show && state.pickId != null,
|
||||
);
|
||||
if (!candidates.length) return [];
|
||||
const ray = camera.pickRay(at);
|
||||
if (!ray) return [];
|
||||
raycaster.ray.origin.copy(ray.origin);
|
||||
raycaster.ray.direction.copy(ray.direction).normalize();
|
||||
// The rendered surface hides what lies behind it. Right after another
|
||||
// click Navara's depth read can answer with the camera itself: a
|
||||
// surface nearer than the near plane is no surface, and the globe
|
||||
// stands in for it.
|
||||
const surface = picker.pickPosition(at);
|
||||
let depth = surface ? cartesian3.distance(ray.origin, surface) : Infinity;
|
||||
if (depth < NEAR_SURFACE_M) {
|
||||
const globe = picker.pickGlobe?.(at);
|
||||
depth = globe ? cartesian3.distance(ray.origin, globe) : Infinity;
|
||||
}
|
||||
const hits = [];
|
||||
for (const { state, backend } of candidates) {
|
||||
backend.group.updateWorldMatrix(true, true);
|
||||
const hit = raycaster.intersectObject(backend.mesh, false)[0];
|
||||
if (!hit || (state.depthTest !== 'always' && hit.distance > depth + 0.1))
|
||||
continue;
|
||||
hits.push({
|
||||
onTop: state.depthTest === 'always' ? 1 : 0,
|
||||
order: 0,
|
||||
depth: hit.distance,
|
||||
result: { id: state.pickId, owner: state.id },
|
||||
});
|
||||
}
|
||||
return hits;
|
||||
});
|
||||
return {
|
||||
meshes: registry.meshes,
|
||||
destroy() {
|
||||
removePicks();
|
||||
registry.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2,8 +2,9 @@ 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';
|
||||
import { createNavaraMeshes } from './meshes.js';
|
||||
import { createCctvMeshes } from '../../layers/cctv/render/meshes.js';
|
||||
import { viewshedColors } from '../../data/cctvViewshed.js';
|
||||
|
||||
function harness() {
|
||||
const scene = new Group();
|
||||
@@ -16,10 +17,11 @@ function harness() {
|
||||
onDestroy() {}
|
||||
}
|
||||
const view = {
|
||||
forceUpdate: () => requests++,
|
||||
registerMesh: (kind, desc) => kinds.set(kind, desc),
|
||||
addMesh(config) {
|
||||
if (fail) {
|
||||
config.gevCctvMesh.group.children[0].geometry.addEventListener(
|
||||
config.gevWorldMesh.group.children[0].geometry.addEventListener(
|
||||
'dispose',
|
||||
() => failedGeometryDisposals++,
|
||||
);
|
||||
@@ -27,8 +29,8 @@ function harness() {
|
||||
}
|
||||
const kind = Object.keys(config)[0];
|
||||
const desc = new (kinds.get(kind))(view, {}, config);
|
||||
// Depth-tested against the scene: the frame must not paint through walls.
|
||||
assert.equal(desc.getPassKey(), 'opaque');
|
||||
// After the effects, depth-tested against the scene's packed depth.
|
||||
assert.equal(desc.getPassKey(), 'transparent');
|
||||
const object = desc.createMesh();
|
||||
scene.add(object);
|
||||
const handle = {
|
||||
@@ -54,9 +56,17 @@ function harness() {
|
||||
},
|
||||
},
|
||||
};
|
||||
const owner = createCctvNavaraMeshes(engine, { view, MeshDesc });
|
||||
const port = createNavaraMeshes(view, {
|
||||
MeshDesc,
|
||||
camera: engine.camera,
|
||||
picker: engine.picker,
|
||||
});
|
||||
engine.meshes = port.meshes;
|
||||
const owner = createCctvMeshes(engine);
|
||||
return {
|
||||
owner,
|
||||
port,
|
||||
drawn: () => scene.children.at(-1)?.children[0],
|
||||
engine,
|
||||
scene,
|
||||
handles,
|
||||
@@ -74,7 +84,10 @@ 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());
|
||||
t.after(() => {
|
||||
h.owner.destroy();
|
||||
h.port.destroy();
|
||||
});
|
||||
const q = Cesium.Quaternion.fromAxisAngle(
|
||||
Cesium.Cartesian3.normalize(
|
||||
new Cesium.Cartesian3(1, 2, 3),
|
||||
@@ -85,9 +98,10 @@ test('Navara CCTV plane preserves native rotated geometry, UVs and metre precisi
|
||||
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');
|
||||
const drawn = h.drawn();
|
||||
drawn.updateWorldMatrix(true, true);
|
||||
const attribute = drawn.geometry.getAttribute('position');
|
||||
const uv = drawn.geometry.getAttribute('uv');
|
||||
for (const [i, [x, y]] of [
|
||||
[-1, 1],
|
||||
[1, 1],
|
||||
@@ -102,7 +116,7 @@ test('Navara CCTV plane preserves native rotated geometry, UVs and metre precisi
|
||||
Cesium.Cartesian3.add(native, positions.capCenter, native);
|
||||
const actual = new Vector3()
|
||||
.fromBufferAttribute(attribute, i)
|
||||
.applyMatrix4(plane.mesh.matrixWorld);
|
||||
.applyMatrix4(drawn.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);
|
||||
@@ -110,14 +124,153 @@ test('Navara CCTV plane preserves native rotated geometry, UVs and metre precisi
|
||||
assert.equal(plane.show, false);
|
||||
assert.equal(plane.id, 'cctv-a-plane');
|
||||
plane.show = true;
|
||||
assert.equal(plane.group.visible, true);
|
||||
assert.equal(material.material.opacity, 0.95);
|
||||
assert.equal(material.material.depthWrite, false);
|
||||
assert.equal(drawn.parent.visible, true);
|
||||
assert.equal(drawn.material.opacity, 0.95);
|
||||
assert.equal(drawn.material.depthWrite, false);
|
||||
});
|
||||
|
||||
test('Navara CCTV picks actual visible plane triangles from either side, nearest first', (t) => {
|
||||
test('world mesh picks test both faces, visibility, depth and retirement', () => {
|
||||
const h = harness();
|
||||
const plane = h.owner.createPlane(
|
||||
'a',
|
||||
h.owner.createMaterial(canvas()),
|
||||
shape,
|
||||
positions,
|
||||
identity,
|
||||
);
|
||||
plane.show = true;
|
||||
const [pick] = h.pickers;
|
||||
let ray = {
|
||||
origin: { ...positions.capCenter, z: 5800 },
|
||||
direction: { x: 0, y: 0, z: -1 },
|
||||
};
|
||||
h.engine.camera.pickRay = () => ray;
|
||||
assert.equal(pick({})[0].depth, 122);
|
||||
assert.deepEqual(pick({})[0].result, { id: plane.id, owner: plane.id });
|
||||
ray = {
|
||||
origin: { ...positions.capCenter, z: 5500 },
|
||||
direction: { x: 0, y: 0, z: 1 },
|
||||
};
|
||||
assert.equal(pick({})[0].depth, 178);
|
||||
ray.origin.x += 81;
|
||||
assert.deepEqual(pick({}), []);
|
||||
ray.origin.x -= 81;
|
||||
// A depth read that answers with the camera itself hides nothing; the
|
||||
// globe stands in for the surface.
|
||||
h.engine.picker.pickPosition = () => ({ ...ray.origin });
|
||||
h.engine.picker.pickGlobe = () => ({ ...positions.capCenter, z: 6000 });
|
||||
assert.equal(pick({})[0].depth, 178);
|
||||
h.engine.picker.pickGlobe = () => ({ ...positions.capCenter, z: 5550 });
|
||||
assert.deepEqual(pick({}), [], 'a nearer globe still hides it');
|
||||
h.engine.picker.pickPosition = () => ({ ...positions.capCenter, z: 5550 });
|
||||
assert.deepEqual(pick({}), [], 'a nearer wall hides the mesh');
|
||||
plane.mesh.update({ depthTest: 'always' });
|
||||
assert.equal(pick({}).length, 1);
|
||||
plane.show = false;
|
||||
assert.deepEqual(pick({}), []);
|
||||
h.owner.destroy();
|
||||
h.port.destroy();
|
||||
assert.equal(h.pickers.size, 0);
|
||||
assert.equal(h.handles.size, 0);
|
||||
});
|
||||
|
||||
test('world meshes release replaced images and video callbacks exactly once', () => {
|
||||
const h = harness();
|
||||
const first = canvas();
|
||||
const material = h.owner.createMaterial(first);
|
||||
const plane = h.owner.createPlane('a', material, shape, positions, identity);
|
||||
const drawn = h.drawn();
|
||||
let disposed = 0;
|
||||
drawn.material.map.addEventListener('dispose', () => disposed++);
|
||||
const version = drawn.material.map.version;
|
||||
material.setImage(first);
|
||||
assert.ok(drawn.material.map.version > version);
|
||||
let cancelled = 0;
|
||||
const video = {
|
||||
tagName: 'VIDEO',
|
||||
requestVideoFrameCallback: () => 17,
|
||||
cancelVideoFrameCallback: (id) => {
|
||||
assert.equal(id, 17);
|
||||
cancelled++;
|
||||
},
|
||||
};
|
||||
material.setImage(video);
|
||||
assert.equal(disposed, 1);
|
||||
assert.equal(drawn.material.map.isVideoTexture, true);
|
||||
material.setImage(canvas());
|
||||
assert.equal(cancelled, 1);
|
||||
let finalDisposed = 0;
|
||||
drawn.material.map.addEventListener('dispose', () => finalDisposed++);
|
||||
plane.destroy();
|
||||
plane.destroy();
|
||||
assert.equal(finalDisposed, 1, 'the mesh owns its texture');
|
||||
material.dispose();
|
||||
const requests = h.requests();
|
||||
material.setImage(video);
|
||||
assert.equal(h.requests(), requests);
|
||||
h.owner.destroy();
|
||||
h.port.destroy();
|
||||
assert.equal(finalDisposed, 1);
|
||||
});
|
||||
|
||||
test('world mesh descriptor failure releases geometry and rejects later creates after teardown', () => {
|
||||
const h = harness();
|
||||
h.fail();
|
||||
assert.throws(
|
||||
() =>
|
||||
h.owner.createPlane(
|
||||
'a',
|
||||
h.owner.createMaterial(canvas()),
|
||||
shape,
|
||||
positions,
|
||||
identity,
|
||||
),
|
||||
/descriptor failed/,
|
||||
);
|
||||
assert.equal(h.failedGeometryDisposals(), 1);
|
||||
assert.equal(h.handles.size, 0);
|
||||
assert.deepEqual(h.port.meshes.list(), []);
|
||||
h.owner.destroy();
|
||||
h.port.destroy();
|
||||
assert.throws(() => h.owner.createMaterial(null), /destroyed/);
|
||||
});
|
||||
|
||||
test('world volume preserves world vertices and releases geometry and 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 });
|
||||
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,
|
||||
);
|
||||
const mesh = h.drawn();
|
||||
mesh.updateWorldMatrix(true, true);
|
||||
assert.equal(mesh.geometry.index.count, 18);
|
||||
assert.equal(mesh.geometry.getAttribute('position').count, 5);
|
||||
const first = new Vector3()
|
||||
.fromBufferAttribute(mesh.geometry.getAttribute('position'), 0)
|
||||
.applyMatrix4(mesh.matrixWorld);
|
||||
assert.equal(first.x, mount.x);
|
||||
let geometryReleases = 0,
|
||||
materialReleases = 0;
|
||||
mesh.geometry.addEventListener('dispose', () => geometryReleases++);
|
||||
mesh.material.addEventListener('dispose', () => materialReleases++);
|
||||
h.owner.destroy();
|
||||
h.port.destroy();
|
||||
h.port.destroy();
|
||||
assert.equal(geometryReleases, 1);
|
||||
assert.equal(materialReleases, 1);
|
||||
assert.equal(h.handles.size, 0);
|
||||
});
|
||||
|
||||
test('world mesh picks retain the nearest shown triangle among several meshes', () => {
|
||||
const h = harness();
|
||||
t.after(() => h.owner.destroy());
|
||||
const make = (id, z) => {
|
||||
const plane = h.owner.createPlane(
|
||||
id,
|
||||
@@ -131,144 +284,21 @@ test('Navara CCTV picks actual visible plane triangles from either side, nearest
|
||||
};
|
||||
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();
|
||||
assert.equal(finalDisposed, 0, 'the plane leaves its material to its owner');
|
||||
material.dispose();
|
||||
const requests = h.requests();
|
||||
material.setImage(video);
|
||||
assert.equal(h.requests(), requests);
|
||||
assert.equal(finalDisposed, 1);
|
||||
assert.equal(h.handles.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, 0, 'the material stays with its owner');
|
||||
assert.equal(h.failedGeometryDisposals(), 1);
|
||||
assert.equal(h.handles.size, 0);
|
||||
h.owner.destroy();
|
||||
assert.equal(releases, 1);
|
||||
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);
|
||||
});
|
||||
|
||||
test('Navara CCTV plane picks go through a mesh picker that defers to nearer depth', () => {
|
||||
const h = harness();
|
||||
const plane = h.owner.createPlane(
|
||||
'a',
|
||||
h.owner.createMaterial(canvas()),
|
||||
shape,
|
||||
positions,
|
||||
identity,
|
||||
);
|
||||
plane.show = true;
|
||||
const [pick] = h.pickers;
|
||||
h.engine.camera.livePosition = { ...positions.capCenter, z: 5800 };
|
||||
h.engine.camera.pickRay = () => ({
|
||||
origin: { ...positions.capCenter, z: 5800 },
|
||||
direction: { x: 0, y: 0, z: -1 },
|
||||
});
|
||||
const [hit] = pick({ x: 1, y: 1 });
|
||||
assert.deepEqual(hit.result, { id: 'cctv-a-plane', owner: 'cctv-a-plane' });
|
||||
assert.equal(hit.depth, 122);
|
||||
assert.equal(h.owner.planeFromPick(hit.result), plane);
|
||||
assert.equal(
|
||||
h.owner.planeFromPick({ id: 'cctv-a-plane', owner: 'sibling' }),
|
||||
null,
|
||||
);
|
||||
h.engine.picker.pickPosition = () => ({ ...positions.capCenter, z: 5750 });
|
||||
assert.deepEqual(pick({ x: 1, y: 1 }), [], 'a wall in front hides it');
|
||||
const nearest = () =>
|
||||
[...h.pickers]
|
||||
.flatMap((pick) => pick({}))
|
||||
.sort((a, b) => a.depth - b.depth)[0];
|
||||
assert.equal(nearest().result.id, near.id);
|
||||
assert.equal(nearest().depth, 100);
|
||||
assert.equal(h.owner.planeFromPick({ id: near.id, owner: 'sibling' }), null);
|
||||
near.show = false;
|
||||
assert.equal(nearest().result.id, far.id);
|
||||
far.destroy();
|
||||
assert.equal(nearest(), undefined);
|
||||
h.owner.destroy();
|
||||
assert.equal(h.pickers.size, 0);
|
||||
h.port.destroy();
|
||||
});
|
||||
File renamed without changes.
+17
-13
@@ -11,9 +11,9 @@ import {
|
||||
SCENE_DEPTH_BIAS,
|
||||
createSceneDepthTest,
|
||||
withSceneDepthTest,
|
||||
} from './navaraSceneDepth.js';
|
||||
import { createStemBatch } from './navaraStems.js';
|
||||
import { createNavaraPackRenderer } from '../../../scenes/dataPacks/navara.js';
|
||||
} from './sceneDepth.js';
|
||||
import { createPackRenderer } from '../../scenes/dataPacks/display.js';
|
||||
import { createNavaraMeshes } from './meshes.js';
|
||||
|
||||
const count = (text, part) => text.split(part).length - 1;
|
||||
|
||||
@@ -92,6 +92,7 @@ function fakeView() {
|
||||
onDestroy() {}
|
||||
}
|
||||
const view = {
|
||||
forceUpdate() {},
|
||||
registerMesh: (kind, desc) => kinds.set(kind, desc),
|
||||
addMesh(config) {
|
||||
const kind = Object.keys(config)[0];
|
||||
@@ -137,21 +138,24 @@ function assertTested(object, mrt) {
|
||||
}
|
||||
}
|
||||
|
||||
test('stems and pack meshes stay transparent and test the scene depth', () => {
|
||||
const stems = fakeView();
|
||||
createStemBatch(stems, 0xffffff, 4);
|
||||
test('pack meshes stay transparent and test the scene depth', () => {
|
||||
const packs = fakeView();
|
||||
const owned = [];
|
||||
const engine = { render: { request() {} } };
|
||||
createNavaraPackRenderer(engine, packs).image(
|
||||
const port = createNavaraMeshes(packs.view, {
|
||||
MeshDesc: packs.MeshDesc,
|
||||
camera: {},
|
||||
picker: { registerMeshPicker: () => () => {} },
|
||||
});
|
||||
const engine = { meshes: port.meshes, render: { request() {} } };
|
||||
createPackRenderer(engine).image(
|
||||
{ id: 'p', placement: { bounds: [0, 0, 0.01, 0.01], height: 300 } },
|
||||
{},
|
||||
{ own: (fn) => owned.push(fn) },
|
||||
);
|
||||
for (const { created, mrt } of [stems, packs]) {
|
||||
assert.equal(created.length, 1);
|
||||
assert.equal(created[0].pass, 'transparent', created[0].kind);
|
||||
assertTested(created[0].object, mrt);
|
||||
}
|
||||
assert.equal(packs.created.length, 1);
|
||||
assert.equal(packs.created[0].pass, 'transparent');
|
||||
assertTested(packs.created[0].object, packs.mrt);
|
||||
assert.equal(packs.created[0].object.children[0].material.depthTest, true);
|
||||
for (const release of owned) release();
|
||||
port.destroy();
|
||||
});
|
||||
@@ -2798,6 +2798,7 @@ export function createContractSuite(
|
||||
],
|
||||
];
|
||||
checks.push(
|
||||
...meshChecks(engine, { settle, until, nadirView, readRegion, makeCanvas }),
|
||||
...groundChecks(engine, { settle, until, nadirView, readRegion, pointer }),
|
||||
...imageryChecks(engine, { settle, until, nadirView, readRegion }),
|
||||
);
|
||||
@@ -4474,6 +4475,147 @@ function mapChecks(maps, { imagery: [first, second], photoreal }) {
|
||||
];
|
||||
}
|
||||
|
||||
function meshChecks(
|
||||
engine,
|
||||
{ settle, until, nadirView, readRegion, makeCanvas },
|
||||
) {
|
||||
// Above any terrain under (20°E, 10°N), which stands about 400 m high.
|
||||
const positions = [
|
||||
[19.98, 10.02, 3000],
|
||||
[20.02, 10.02, 3000],
|
||||
[19.98, 9.98, 3000],
|
||||
[20.02, 9.98, 3000],
|
||||
].flatMap((p) => {
|
||||
const at = cartesian3.fromDegrees(...p);
|
||||
return [at.x, at.y, at.z];
|
||||
});
|
||||
const options = {
|
||||
positions,
|
||||
indices: [0, 2, 1, 2, 3, 1],
|
||||
uv: [0, 1, 1, 1, 0, 0, 1, 0],
|
||||
};
|
||||
const centre = () => {
|
||||
const { width, height } = engine.host.size();
|
||||
return { x: width / 2, y: height / 2 };
|
||||
};
|
||||
const checks = [
|
||||
[
|
||||
'meshes: copied triangles, visibility, updates and lifetime',
|
||||
async () => {
|
||||
const id = 'contract-mesh';
|
||||
const source = [...positions];
|
||||
const mesh = engine.meshes.createMesh({
|
||||
...options,
|
||||
positions: source,
|
||||
id,
|
||||
show: false,
|
||||
});
|
||||
try {
|
||||
source.fill(0);
|
||||
ensure(engine.meshes.list().includes(id), 'mesh is not listed');
|
||||
ensure(mesh.show() === false, 'mesh starts hidden');
|
||||
mesh.show(true);
|
||||
ensure(mesh.show(), 'mesh did not show');
|
||||
mesh.update({ color: [0, 1, 1, 0.35], depthTest: 'always' });
|
||||
mesh.update({
|
||||
positions,
|
||||
indices: options.indices,
|
||||
depthTest: 'normal',
|
||||
});
|
||||
await settle();
|
||||
let rejected = false;
|
||||
try {
|
||||
mesh.update({ indices: [0, 1, 99] });
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
ensure(rejected, 'invalid indices were accepted');
|
||||
ensure(mesh.show(), 'invalid update changed visibility');
|
||||
} finally {
|
||||
mesh.destroy();
|
||||
mesh.destroy();
|
||||
}
|
||||
ensure(!engine.meshes.list().includes(id), 'destroyed mesh is listed');
|
||||
ensure(!mesh.show(), 'destroyed mesh is shown');
|
||||
},
|
||||
],
|
||||
[
|
||||
'meshes: picks use the live id and ignore hidden or unpickable triangles',
|
||||
async () => {
|
||||
await nadirView(10000);
|
||||
const id = 'contract-mesh-pick';
|
||||
const mesh = engine.meshes.createMesh({
|
||||
...options,
|
||||
id,
|
||||
pickId: 'first',
|
||||
});
|
||||
try {
|
||||
await until(
|
||||
() => engine.picker.pick(centre())?.id === 'first',
|
||||
'mesh pick',
|
||||
);
|
||||
ensure(engine.picker.pick(centre()).owner === id, 'mesh owner');
|
||||
mesh.update({ pickId: 'second' });
|
||||
await until(
|
||||
() => engine.picker.pick(centre())?.id === 'second',
|
||||
'updated mesh pick',
|
||||
);
|
||||
mesh.show(false);
|
||||
await settle();
|
||||
ensure(
|
||||
engine.picker.pick(centre())?.owner !== id,
|
||||
'hidden mesh picked',
|
||||
);
|
||||
mesh.update({ show: true, pickId: null });
|
||||
await settle();
|
||||
ensure(
|
||||
engine.picker.pick(centre())?.owner !== id,
|
||||
'unpickable mesh picked',
|
||||
);
|
||||
} finally {
|
||||
mesh.destroy();
|
||||
}
|
||||
},
|
||||
],
|
||||
];
|
||||
if (readRegion && makeCanvas)
|
||||
checks.push([
|
||||
'meshes: image pixels update on the same canvas',
|
||||
async () => {
|
||||
await nadirView(10000);
|
||||
const canvas = makeCanvas(8, 8);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#ff0000';
|
||||
ctx.fillRect(0, 0, 8, 8);
|
||||
const mesh = engine.meshes.createMesh({
|
||||
...options,
|
||||
id: 'contract-mesh-image',
|
||||
image: canvas,
|
||||
});
|
||||
const pixel = () => {
|
||||
const { x, y } = centre();
|
||||
return readRegion(Math.floor(x), Math.floor(y), 1, 1);
|
||||
};
|
||||
try {
|
||||
await until(() => {
|
||||
const p = pixel();
|
||||
return p[0] > p[1] + 50;
|
||||
}, 'red mesh image');
|
||||
ctx.fillStyle = '#00ff00';
|
||||
ctx.fillRect(0, 0, 8, 8);
|
||||
mesh.update({ image: canvas });
|
||||
await until(() => {
|
||||
const p = pixel();
|
||||
return p[1] > p[0] + 50;
|
||||
}, 'updated green mesh image');
|
||||
} finally {
|
||||
mesh.destroy();
|
||||
}
|
||||
},
|
||||
]);
|
||||
return checks;
|
||||
}
|
||||
|
||||
/** Runs every check in order; never throws for a failing check. */
|
||||
export async function runContractSuite(engine, options) {
|
||||
const results = [];
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
viewRectangle,
|
||||
} from '../cameraMath.js';
|
||||
import { createGroundPicks, createGroundRegistry } from '../ground.js';
|
||||
import { createMeshRegistry, meshRayDistance } from '../meshes.js';
|
||||
import { createImageryRegistry } from '../imagery.js';
|
||||
import { createLineRegistry } from '../lines.js';
|
||||
import { createMoverRegistry } from '../movers.js';
|
||||
@@ -261,6 +262,10 @@ export function createFakeEngine(options = {}) {
|
||||
});
|
||||
// Ground sets keep their items for tests; commit counts as a render
|
||||
// request, and so does every frame while a shown set animates.
|
||||
const meshRegistry = createMeshRegistry(() => ({
|
||||
update() {},
|
||||
destroy() {},
|
||||
}));
|
||||
const groundRegistry = createGroundRegistry(() => ({
|
||||
commit() {
|
||||
fake.renderRequests++;
|
||||
@@ -636,7 +641,30 @@ export function createFakeEngine(options = {}) {
|
||||
modelMatrix: state.modelMatrix,
|
||||
})),
|
||||
// Ground items under the picked globe point (src/globe/ground.js).
|
||||
meshes: (at) => groundPicks(at),
|
||||
meshes: (at) => {
|
||||
const ray = engine.camera.pickRay(at);
|
||||
const surface =
|
||||
engine.picker.pickPosition(at) ?? engine.picker.pickGlobe(at);
|
||||
const distance =
|
||||
surface && ray ? cartesian3.distance(ray.origin, surface) : Infinity;
|
||||
const hits = [];
|
||||
for (const { state } of meshRegistry.live.values()) {
|
||||
if (!state.show || state.pickId == null) continue;
|
||||
const depth = meshRayDistance(state, ray);
|
||||
if (
|
||||
!Number.isFinite(depth) ||
|
||||
(state.depthTest !== 'always' && depth > distance + 0.1)
|
||||
)
|
||||
continue;
|
||||
hits.push({
|
||||
onTop: state.depthTest === 'always' ? 1 : 0,
|
||||
order: 0,
|
||||
depth,
|
||||
result: { id: state.pickId, owner: state.id },
|
||||
});
|
||||
}
|
||||
return [...hits, ...groundPicks(at)];
|
||||
},
|
||||
});
|
||||
const groundPicks = createGroundPicks({
|
||||
pickGlobe: (screen) => engine.picker.pickGlobe(screen),
|
||||
@@ -704,6 +732,7 @@ export function createFakeEngine(options = {}) {
|
||||
},
|
||||
movers: moverRegistry.movers,
|
||||
lines: lineRegistry.lines,
|
||||
meshes: meshRegistry.meshes,
|
||||
ground: groundRegistry.ground,
|
||||
imagery: imageryRegistry.imagery,
|
||||
clock: {
|
||||
@@ -858,6 +887,7 @@ export function createFakeEngine(options = {}) {
|
||||
cancelCurrentFlight();
|
||||
moverRegistry.destroyAll();
|
||||
lineRegistry.destroyAll();
|
||||
meshRegistry.destroy();
|
||||
groundRegistry.destroyAll();
|
||||
imageryRegistry.destroyAll();
|
||||
fake.destroyed = true;
|
||||
@@ -951,6 +981,7 @@ export function createFakeEngine(options = {}) {
|
||||
* A live imagery layer's state, by id: { options, source, alpha, shown,
|
||||
* split, raise, detail, staging }; `layers()` all of them in draw order.
|
||||
*/
|
||||
mesh: (id) => meshRegistry.live.get(id)?.state,
|
||||
imageryLayer(id) {
|
||||
const state = imageryRegistry.live.get(id);
|
||||
return state
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
// The CCTV meshes on Cesium (behind render/meshes.js): the monitor plane is
|
||||
// a plane Entity with an image material, the viewshed volume a synchronous
|
||||
// Primitive of six triangles in a flat translucent colour, both faces drawn,
|
||||
// never picked. The activation probe's ray pick lives here too, while the
|
||||
// Cesium engine has no surface.raycast.
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { frustumVolumeGeometryData } from '../../../data/cctvViewshed.js';
|
||||
import { PLANE_IMAGE_ALPHA } from './style.js';
|
||||
|
||||
const toColor = (tint) => new Cesium.Color(...tint.rgba());
|
||||
|
||||
/** The frustum volume's Primitive over positions { mount, tl, tr, br, bl }. */
|
||||
export function createFrustumVolumePrimitive(positions, tint) {
|
||||
const { positions: flat, indices } = frustumVolumeGeometryData(positions);
|
||||
const geometry = new Cesium.Geometry({
|
||||
attributes: {
|
||||
position: new Cesium.GeometryAttribute({
|
||||
componentDatatype: Cesium.ComponentDatatype.DOUBLE,
|
||||
componentsPerAttribute: 3,
|
||||
values: flat,
|
||||
}),
|
||||
},
|
||||
indices,
|
||||
primitiveType: Cesium.PrimitiveType.TRIANGLES,
|
||||
boundingSphere: Cesium.BoundingSphere.fromVertices(Array.from(flat)),
|
||||
});
|
||||
return new Cesium.Primitive({
|
||||
geometryInstances: new Cesium.GeometryInstance({
|
||||
geometry,
|
||||
attributes: {
|
||||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(toColor(tint)),
|
||||
},
|
||||
}),
|
||||
appearance: new Cesium.PerInstanceColorAppearance({
|
||||
flat: true,
|
||||
translucent: true,
|
||||
// The camera is routinely inside or behind a cone.
|
||||
renderState: { cull: { enabled: false } },
|
||||
}),
|
||||
asynchronous: false,
|
||||
// Clicks fall through to the markers, wedges and plane.
|
||||
allowPicking: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ viewer: Cesium.Viewer }} cesium The engine's Cesium extension.
|
||||
*/
|
||||
export function createCctvCesiumMeshes({ viewer: { entities, scene } }) {
|
||||
const planes = new Map();
|
||||
const volumes = new Set();
|
||||
const dimensions = (shape) =>
|
||||
new Cesium.Cartesian2(shape.halfW * 2, shape.halfH * 2);
|
||||
return {
|
||||
createMaterial(image) {
|
||||
const property = image
|
||||
? new Cesium.ImageMaterialProperty({
|
||||
image,
|
||||
transparent: true,
|
||||
color: Cesium.Color.WHITE.withAlpha(PLANE_IMAGE_ALPHA),
|
||||
})
|
||||
: new Cesium.ColorMaterialProperty(Cesium.Color.WHITE);
|
||||
return {
|
||||
property,
|
||||
setImage(next) {
|
||||
property.image = next;
|
||||
},
|
||||
dispose() {},
|
||||
};
|
||||
},
|
||||
createPlane(cameraId, material, shape, positions, orientation) {
|
||||
const entity = entities.add({
|
||||
id: `cctv-${cameraId}-plane`,
|
||||
show: false,
|
||||
position: positions.capCenter,
|
||||
orientation: Cesium.Quaternion.clone(orientation),
|
||||
plane: {
|
||||
plane: new Cesium.Plane(Cesium.Cartesian3.UNIT_Z, 0.0),
|
||||
dimensions: dimensions(shape),
|
||||
material: material.property,
|
||||
},
|
||||
});
|
||||
const plane = {
|
||||
entity,
|
||||
get show() {
|
||||
return entity.show;
|
||||
},
|
||||
set show(value) {
|
||||
entity.show = value;
|
||||
},
|
||||
destroy() {
|
||||
if (!planes.delete(entity)) return;
|
||||
if (entities.getById(entity.id) === entity) entities.remove(entity);
|
||||
},
|
||||
};
|
||||
planes.set(entity, plane);
|
||||
return plane;
|
||||
},
|
||||
updatePlane({ entity }, shape, positions, orientation) {
|
||||
entity.position = positions.capCenter;
|
||||
entity.orientation = Cesium.Quaternion.clone(orientation);
|
||||
if (entity.plane) entity.plane.dimensions = dimensions(shape);
|
||||
},
|
||||
createVolume(positions, tint) {
|
||||
const primitive = createFrustumVolumePrimitive(positions, tint);
|
||||
scene.primitives.add(primitive);
|
||||
volumes.add(primitive);
|
||||
return {
|
||||
primitive,
|
||||
destroy() {
|
||||
if (volumes.delete(primitive)) scene.primitives.remove(primitive);
|
||||
},
|
||||
};
|
||||
},
|
||||
// The engine's pick result keeps Cesium's own id: the plane Entity.
|
||||
planeFromPick: (picked) => planes.get(picked.id) ?? null,
|
||||
drawnBy: (native) => native.entity ?? native.primitive,
|
||||
obstructionRay(origin, direction, far, exclude) {
|
||||
if (!scene.pickFromRay) return undefined;
|
||||
const hit = scene.pickFromRay(new Cesium.Ray(origin, direction), exclude);
|
||||
return hit?.position &&
|
||||
Cesium.Cartesian3.distance(origin, hit.position) <= far
|
||||
? hit
|
||||
: undefined;
|
||||
},
|
||||
destroy() {
|
||||
for (const plane of [...planes.values()]) plane.destroy();
|
||||
for (const primitive of volumes) scene.primitives.remove(primitive);
|
||||
volumes.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// The CCTV cameras on the globe contract (engine.movers, engine.lines,
|
||||
// The CCTV cameras on the globe contract (engine.movers, engine.lines, engine.meshes,
|
||||
// engine.input, engine.picker, engine.camera, engine.projector,
|
||||
// engine.surface, engine.credits), the same on every engine:
|
||||
// - the camera markers, one sprite set (24 px, shrinking with distance,
|
||||
@@ -9,8 +9,8 @@
|
||||
// or depth fail changes;
|
||||
// - the calibration gizmo: rings and arrow shafts as lines, arrow tips and
|
||||
// handles in one point set, drags through engine.input;
|
||||
// - the monitor plane and the viewshed volume behind render/meshes.js, the
|
||||
// engine-specific part the contract has no primitive for yet.
|
||||
// - the monitor plane and the viewshed volume as world triangles through
|
||||
// engine.meshes (render/meshes.js).
|
||||
// The layer (../index.js) keeps the records, the policy and the style
|
||||
// choices (../rendering.js, ./style.js).
|
||||
|
||||
@@ -86,16 +86,11 @@ export function planeOutline(shape, positions, orientation) {
|
||||
|
||||
/**
|
||||
* @param {import('../../../globe/contract.js').GlobeEngine} engine
|
||||
* @param {{ state: object, services: object, navaraMeshes?: Function }} options
|
||||
* The layer's state and services; `navaraMeshes` is the Navara meshes
|
||||
* backend (render/navaraMeshes.js), which the Navara mode passes in.
|
||||
* @param {{ state: object, services: object }} options
|
||||
*/
|
||||
export function createCctvDisplay(
|
||||
engine,
|
||||
{ state, services = {}, navaraMeshes } = {},
|
||||
) {
|
||||
export function createCctvDisplay(engine, { state, services = {} } = {}) {
|
||||
if (!engine) throw new TypeError('The CCTV display needs a globe engine');
|
||||
const meshes = createCctvMeshes(engine, { navaraMeshes });
|
||||
const meshes = createCctvMeshes(engine);
|
||||
const markers = [];
|
||||
const markerHandles = new WeakSet();
|
||||
const changedMarkers = new Set();
|
||||
@@ -328,25 +323,6 @@ export function createCctvDisplay(
|
||||
const engineObjects = (handles = []) => [
|
||||
...new Set(handles.flatMap(drawnObjects)),
|
||||
];
|
||||
/**
|
||||
* The same for a ray pick that skips objects by pick id (Cesium's): every
|
||||
* marker, as the whole set was, and the listed lines, planes and volumes.
|
||||
*/
|
||||
function rayExclusions(handles = []) {
|
||||
const exclude = markers.map((marker) => marker.id);
|
||||
for (const handle of handles) {
|
||||
if (!handle || ownsMarker(handle)) continue;
|
||||
if (lines.get(handle.id) === handle) exclude.push(handle.pickId);
|
||||
else if (planes.has(handle))
|
||||
exclude.push(
|
||||
handle.outline.pickId,
|
||||
...[meshes.drawnBy(handle.mesh)].filter(Boolean),
|
||||
);
|
||||
else exclude.push(...[meshes.drawnBy(handle)].filter(Boolean));
|
||||
}
|
||||
return exclude;
|
||||
}
|
||||
|
||||
// ── Gizmo ───────────────────────────────────────────────────────────────
|
||||
function createGizmoRenderer() {
|
||||
const handles = new Map();
|
||||
@@ -727,12 +703,7 @@ export function createCctvDisplay(
|
||||
far,
|
||||
exclude: [markerSet, ...engineObjects(exclude)].filter(Boolean),
|
||||
});
|
||||
return meshes.obstructionRay(
|
||||
origin,
|
||||
direction,
|
||||
far,
|
||||
rayExclusions(exclude),
|
||||
);
|
||||
return undefined;
|
||||
},
|
||||
createPlaneMaterial: (image) => meshes.createMaterial(image),
|
||||
createTestPlaneMaterial: () => meshes.createMaterial(null),
|
||||
|
||||
@@ -359,17 +359,11 @@ for (const makeHarness of CCTV_HARNESSES) {
|
||||
near(geo.cartesian3.distance(corners[index], expected), 0, 'corner');
|
||||
}
|
||||
if (h.cesium) {
|
||||
const entity = h.viewer.entities.getById('cctv-a-plane');
|
||||
const now = Cesium.JulianDate.now();
|
||||
assert.equal(entity.show, true);
|
||||
assert.deepEqual(
|
||||
entity.plane.dimensions.getValue(now),
|
||||
new Cesium.Cartesian2(40, 22.5),
|
||||
);
|
||||
assert.equal(entity.plane.outline, undefined, 'the outline is a line');
|
||||
assert.equal(entity.plane.material.color.getValue(now).alpha, 0.95);
|
||||
assert.equal(entity.plane.material.transparent.getValue(now), true);
|
||||
h.scriptPicks({ native: { id: entity, primitive: {} } });
|
||||
const primitive = h.primitive('cctv-a-plane');
|
||||
assert.equal(primitive.show, true);
|
||||
assert.equal(primitive.appearance.material.uniforms.color.alpha, 0.95);
|
||||
assert.equal(primitive.allowPicking, true);
|
||||
h.scriptPicks({ native: { id: 'cctv-a-plane', primitive } });
|
||||
assert.equal(
|
||||
h.display.extractPickedCameraId(h.display.pick({ x: 1, y: 1 })),
|
||||
null,
|
||||
@@ -433,10 +427,10 @@ for (const makeHarness of CCTV_HARNESSES) {
|
||||
assert.equal(excludeObjects.length, 6);
|
||||
const direction = p(0, 1, 0);
|
||||
if (h.cesium) {
|
||||
const primitive = volume.native.primitive;
|
||||
const primitive = h.primitive(volume.mesh.id);
|
||||
assert.equal(primitive.allowPicking, false);
|
||||
assert.equal(primitive.asynchronous, false);
|
||||
assert.ok(h.viewer.scene.primitives.contains(primitive));
|
||||
assert.ok(primitive);
|
||||
let query;
|
||||
h.viewer.scene.pickFromRay = (ray, exclusions) => {
|
||||
query = { ray, exclusions };
|
||||
@@ -458,15 +452,10 @@ for (const makeHarness of CCTV_HARNESSES) {
|
||||
h.display.obstructionRay(POSITIONS.mount, direction, exclude, 30),
|
||||
);
|
||||
assert.ok(query.ray instanceof Cesium.Ray);
|
||||
assert.deepEqual(
|
||||
query.exclusions.slice(0, 2),
|
||||
['a', 'b'],
|
||||
'every marker',
|
||||
);
|
||||
assert.ok(query.exclusions.includes('cctv-a-ray-tl'));
|
||||
assert.ok(query.exclusions.includes(primitive));
|
||||
assert.equal(primitive.show, true, 'the excluded volume is restored');
|
||||
assert.ok(query.exclusions.every((item) => typeof item !== 'string'));
|
||||
h.display.removeVolume(volume);
|
||||
assert.ok(!h.viewer.scene.primitives.contains(primitive));
|
||||
assert.equal(h.primitive(volume.mesh.id), undefined);
|
||||
} else {
|
||||
assert.equal(
|
||||
h.display.obstructionRay(POSITIONS.mount, direction, [], 30),
|
||||
@@ -598,34 +587,11 @@ test('the plane outline closes on its first corner', () => {
|
||||
assert.deepEqual({ ...corners[2] }, { x: 12, y: -1, z: 0 });
|
||||
});
|
||||
|
||||
test('on Navara the display draws its plane through the backend it is given', async (t) => {
|
||||
const { createCctvNavaraMeshes } = await import('./navaraMeshes.js');
|
||||
const engine = createFakeEngine({ kind: 'navara' });
|
||||
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 pickers = new Set();
|
||||
engine.picker.registerMeshPicker = (pick) => {
|
||||
pickers.add(pick);
|
||||
return () => pickers.delete(pick);
|
||||
};
|
||||
engine.extensions = { navara: { view, MeshDesc } };
|
||||
const state = { _recordById: new Map(), _projectionEntities: [] };
|
||||
const display = createCctvDisplay(engine, {
|
||||
state,
|
||||
navaraMeshes: createCctvNavaraMeshes,
|
||||
});
|
||||
test('the display draws and picks a plane on the fake engine', (t) => {
|
||||
const engine = createFakeEngine();
|
||||
t.after(() => engine.destroy());
|
||||
const state = { _recordById: new Map(), _projectionEntities: [] };
|
||||
const display = createCctvDisplay(engine, { state });
|
||||
display.attach();
|
||||
const record = recordFor(state, 'a');
|
||||
const runtime = { planeMaterial: display.createPlaneMaterial(null) };
|
||||
@@ -637,12 +603,11 @@ test('on Navara the display draws its plane through the backend it is given', as
|
||||
{ x: 0, y: 0, z: 0, w: 1 },
|
||||
);
|
||||
record.projection = runtime;
|
||||
assert.equal(pickers.size, 1);
|
||||
const picked = { id: 'cctv-a-plane', owner: 'cctv-a-plane' };
|
||||
assert.equal(display.extractPickedCameraId(picked), null, 'hidden');
|
||||
display.setPlaneVisible(runtime, true);
|
||||
assert.equal(runtime.planeEntity.mesh.native.group.visible, true);
|
||||
assert.equal(engine.fake.mesh(picked.id).show, true);
|
||||
assert.equal(display.extractPickedCameraId(picked), 'a');
|
||||
display.destroy();
|
||||
assert.equal(pickers.size, 0);
|
||||
assert.deepEqual(engine.meshes.list(), []);
|
||||
});
|
||||
@@ -1,171 +1,151 @@
|
||||
// The CCTV meshes the globe contract has no primitive for yet, behind one
|
||||
// seam: the monitor plane (a quad textured with a live canvas or video) and
|
||||
// the viewshed volume (the frustum pyramid in a flat translucent colour,
|
||||
// both faces, not pickable). Movers draw a glTF per instance at a uniform
|
||||
// scale and lines draw strips, so neither carries a texture that changes
|
||||
// every frame or a pyramid whose corners move independently.
|
||||
//
|
||||
// This is the only CCTV module that reads engine.extensions. Each engine
|
||||
// draws through its own backend: render/cesiumMeshes.js (Cesium entities and
|
||||
// primitives) and render/navaraMeshes.js (three meshes in Navara's view),
|
||||
// which the Navara mode loads and passes in, as Navara modules reach the app
|
||||
// only through its import(). The handles here keep what the layer asked
|
||||
// for, so an engine with no backend (the fake engine) draws nothing and
|
||||
// still answers. The plane's outline is an engine line, drawn by the display.
|
||||
// CCTV's live monitor quad and translucent viewshed triangles.
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { frustumVolumeGeometryData } from '../../../data/cctvViewshed.js';
|
||||
import { PLANE_IMAGE_ALPHA } from './style.js';
|
||||
|
||||
import { createCctvCesiumMeshes } from './cesiumMeshes.js';
|
||||
function planeGeometry(shape, positions, orientation) {
|
||||
const rotation = geo.matrix3.fromQuaternion(
|
||||
orientation,
|
||||
geo.matrix3.create(),
|
||||
);
|
||||
const flat = [];
|
||||
for (const [x, y] of [
|
||||
[-1, 1],
|
||||
[1, 1],
|
||||
[-1, -1],
|
||||
[1, -1],
|
||||
]) {
|
||||
const p = geo.matrix3.multiplyByVector(
|
||||
rotation,
|
||||
{ x: x * shape.halfW, y: y * shape.halfH, z: 0 },
|
||||
geo.cartesian3.create(),
|
||||
);
|
||||
geo.cartesian3.add(p, positions.capCenter, p);
|
||||
flat.push(p.x, p.y, p.z);
|
||||
}
|
||||
return {
|
||||
positions: flat,
|
||||
indices: [0, 2, 1, 2, 3, 1],
|
||||
uv: [0, 1, 1, 1, 0, 0, 1, 0],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../../../globe/contract.js').GlobeEngine} engine
|
||||
* @param {{ navaraMeshes?: (engine: object, navara: object) => object }} [options]
|
||||
* The Navara backend's factory (render/navaraMeshes.js).
|
||||
*/
|
||||
export function createCctvMeshes(engine, { navaraMeshes } = {}) {
|
||||
const cesium = engine.extensions?.cesium;
|
||||
const navara = engine.extensions?.navara;
|
||||
export function createCctvMeshes(engine) {
|
||||
const materials = new Set();
|
||||
const planes = new Set();
|
||||
const volumes = new Set();
|
||||
const planeByNative = new Map();
|
||||
const backend = cesium
|
||||
? createCctvCesiumMeshes(cesium)
|
||||
: navara && navaraMeshes
|
||||
? navaraMeshes(engine, navara)
|
||||
: null;
|
||||
let destroyed = false;
|
||||
|
||||
let volumeId = 0;
|
||||
const alive = () => {
|
||||
if (destroyed) throw new Error('CCTV meshes are destroyed');
|
||||
};
|
||||
|
||||
const appearance = (material) => ({
|
||||
image: material.image,
|
||||
color: [1, 1, 1, material.image ? PLANE_IMAGE_ALPHA : 1],
|
||||
});
|
||||
return {
|
||||
/** A plane material showing `image` (a canvas or video), or plain white. */
|
||||
createMaterial(image) {
|
||||
alive();
|
||||
const material = {
|
||||
image: image ?? null,
|
||||
native: null,
|
||||
disposed: false,
|
||||
setImage(next) {
|
||||
if (material.disposed) return;
|
||||
material.image = next;
|
||||
material.native?.setImage(next);
|
||||
for (const plane of planes)
|
||||
if (plane.material === material)
|
||||
plane.mesh.update(appearance(material));
|
||||
},
|
||||
dispose() {
|
||||
if (material.disposed) return;
|
||||
material.disposed = true;
|
||||
materials.delete(material);
|
||||
material.native?.dispose();
|
||||
material.native = null;
|
||||
},
|
||||
};
|
||||
material.native = backend?.createMaterial(material.image) ?? null;
|
||||
materials.add(material);
|
||||
return material;
|
||||
},
|
||||
/**
|
||||
* The monitor plane, hidden: `shape` { halfW, halfH } metres, centred on
|
||||
* positions.capCenter, its local +Z along `orientation`'s normal.
|
||||
*/
|
||||
createPlane(cameraId, material, shape, positions, orientation) {
|
||||
alive();
|
||||
if (!materials.has(material))
|
||||
throw new Error('CCTV plane material is not owned');
|
||||
const id = `cctv-${cameraId}-plane`;
|
||||
const mesh = engine.meshes.createMesh({
|
||||
id,
|
||||
pickId: id,
|
||||
show: false,
|
||||
...planeGeometry(shape, positions, orientation),
|
||||
...appearance(material),
|
||||
});
|
||||
const plane = {
|
||||
id: `cctv-${cameraId}-plane`,
|
||||
id,
|
||||
cameraId,
|
||||
material,
|
||||
shape,
|
||||
positions,
|
||||
orientation,
|
||||
visible: false,
|
||||
native: null,
|
||||
mesh,
|
||||
destroyed: false,
|
||||
get show() {
|
||||
return !plane.destroyed && plane.visible;
|
||||
return mesh.show();
|
||||
},
|
||||
set show(value) {
|
||||
if (plane.destroyed) return;
|
||||
plane.visible = Boolean(value);
|
||||
if (plane.native) plane.native.show = plane.visible;
|
||||
mesh.show(value);
|
||||
},
|
||||
destroy() {
|
||||
if (plane.destroyed) return;
|
||||
plane.destroyed = true;
|
||||
planes.delete(plane);
|
||||
if (plane.native) {
|
||||
planeByNative.delete(plane.native);
|
||||
plane.native.destroy();
|
||||
plane.native = null;
|
||||
}
|
||||
mesh.destroy();
|
||||
},
|
||||
};
|
||||
if (backend) {
|
||||
plane.native = backend.createPlane(
|
||||
cameraId,
|
||||
material.native,
|
||||
shape,
|
||||
positions,
|
||||
orientation,
|
||||
);
|
||||
planeByNative.set(plane.native, plane);
|
||||
}
|
||||
planes.add(plane);
|
||||
return plane;
|
||||
},
|
||||
updatePlane(plane, shape, positions, orientation) {
|
||||
if (!planes.has(plane)) return;
|
||||
plane.mesh.update(planeGeometry(shape, positions, orientation));
|
||||
Object.assign(plane, { shape, positions, orientation });
|
||||
if (plane.native)
|
||||
backend.updatePlane(plane.native, shape, positions, orientation);
|
||||
},
|
||||
/** The viewshed volume over positions { mount, tl, tr, br, bl }. */
|
||||
createVolume(positions, tint) {
|
||||
alive();
|
||||
const mesh = engine.meshes.createMesh({
|
||||
id: `cctv-volume-${++volumeId}`,
|
||||
...frustumVolumeGeometryData(positions),
|
||||
color: tint.rgba(),
|
||||
});
|
||||
const volume = {
|
||||
positions,
|
||||
tint,
|
||||
native: null,
|
||||
mesh,
|
||||
destroyed: false,
|
||||
destroy() {
|
||||
if (volume.destroyed) return;
|
||||
volume.destroyed = true;
|
||||
volumes.delete(volume);
|
||||
volume.native?.destroy();
|
||||
volume.native = null;
|
||||
mesh.destroy();
|
||||
},
|
||||
};
|
||||
volume.native = backend?.createVolume(positions, tint) ?? null;
|
||||
volumes.add(volume);
|
||||
return volume;
|
||||
},
|
||||
/** The live, shown plane an engine pick result names, or null. */
|
||||
planeFromPick(picked) {
|
||||
if (!backend || !picked) return null;
|
||||
const plane = planeByNative.get(backend.planeFromPick(picked));
|
||||
return plane?.show ? plane : null;
|
||||
},
|
||||
/** What draws a plane or volume, for an engine `exclude` list. */
|
||||
drawnBy(handle) {
|
||||
return backend && handle?.native
|
||||
? backend.drawnBy(handle.native)
|
||||
: undefined;
|
||||
},
|
||||
/**
|
||||
* The first scene object along a ray within `far` metres, skipping
|
||||
* `exclude` (pick ids and drawn objects), on an engine without
|
||||
* surface.raycast; undefined where the backend has no ray pick.
|
||||
*/
|
||||
obstructionRay(origin, direction, far, exclude) {
|
||||
return backend?.obstructionRay?.(origin, direction, far, exclude);
|
||||
return (
|
||||
[...planes].find(
|
||||
(plane) =>
|
||||
plane.show &&
|
||||
plane.id === picked?.id &&
|
||||
(picked.owner == null || picked.owner === plane.id),
|
||||
) ?? null
|
||||
);
|
||||
},
|
||||
drawnBy: (handle) => handle?.mesh,
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
for (const plane of [...planes]) plane.destroy();
|
||||
for (const volume of [...volumes]) volume.destroy();
|
||||
for (const material of [...materials]) material.dispose();
|
||||
backend?.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -18,107 +18,58 @@ const positions = {
|
||||
};
|
||||
const identity = { x: 0, y: 0, z: 0, w: 1 };
|
||||
|
||||
/** A backend that records what it is asked to draw. */
|
||||
function recordingBackend(calls) {
|
||||
const native = (kind, args) => {
|
||||
const object = {
|
||||
kind,
|
||||
args,
|
||||
show: false,
|
||||
destroy: () => calls.push(['destroy', kind]),
|
||||
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
|
||||
|
||||
for (const [name, make] of [
|
||||
['fake', () => ({ engine: createFakeEngine() })],
|
||||
['Cesium', createCesiumMoversEngine],
|
||||
]) {
|
||||
test(`CCTV mesh ownership, live image and geometry updates (${name})`, (t) => {
|
||||
const h = make();
|
||||
t.after(() => h.engine.destroy());
|
||||
const meshes = createCctvMeshes(h.engine);
|
||||
const material = meshes.createMaterial(null);
|
||||
const plane = meshes.createPlane('a', material, shape, positions, identity);
|
||||
const volume = meshes.createVolume(positions, viewshedColors(4).fill);
|
||||
assert.deepEqual(h.engine.meshes.list(), ['cctv-a-plane', volume.mesh.id]);
|
||||
assert.equal(plane.show, false);
|
||||
plane.show = true;
|
||||
assert.equal(
|
||||
meshes.planeFromPick({ id: plane.id, owner: plane.id }),
|
||||
plane,
|
||||
);
|
||||
plane.show = false;
|
||||
assert.equal(meshes.planeFromPick({ id: plane.id }), null);
|
||||
assert.equal(meshes.drawnBy(plane), plane.mesh);
|
||||
assert.equal(meshes.drawnBy(volume), volume.mesh);
|
||||
const image = new HTMLCanvasElement();
|
||||
image.width = image.height = 8;
|
||||
material.setImage(image);
|
||||
const moved = {
|
||||
...positions,
|
||||
capCenter: { ...positions.capCenter, x: positions.capCenter.x + 3 },
|
||||
};
|
||||
calls.push(['create', kind, object]);
|
||||
return object;
|
||||
};
|
||||
return {
|
||||
createMaterial: (image) => ({
|
||||
...native('material', [image]),
|
||||
setImage: (image) => calls.push(['image', image]),
|
||||
dispose: () => calls.push(['dispose', 'material']),
|
||||
}),
|
||||
createPlane: (...args) => native('plane', args),
|
||||
updatePlane: (plane, ...args) => calls.push(['update', ...args]),
|
||||
createVolume: (...args) => native('volume', args),
|
||||
planeFromPick: (picked) => picked.native,
|
||||
drawnBy: (object) => object,
|
||||
destroy: () => calls.push(['destroy', 'backend']),
|
||||
};
|
||||
}
|
||||
|
||||
test('an engine without a backend keeps the handles and draws nothing', () => {
|
||||
const engine = createFakeEngine();
|
||||
const meshes = createCctvMeshes(engine);
|
||||
const material = meshes.createMaterial(null);
|
||||
const plane = meshes.createPlane('a', material, shape, positions, identity);
|
||||
assert.equal(plane.id, 'cctv-a-plane');
|
||||
assert.equal(plane.show, false);
|
||||
plane.show = true;
|
||||
assert.equal(plane.show, true);
|
||||
assert.equal(meshes.planeFromPick({ id: plane.id }), null);
|
||||
assert.equal(meshes.drawnBy(plane), undefined);
|
||||
assert.equal(
|
||||
meshes.obstructionRay(positions.mount, positions.tl, 10, []),
|
||||
undefined,
|
||||
);
|
||||
assert.throws(
|
||||
() => meshes.createPlane('b', { image: null }, shape, positions, identity),
|
||||
/not owned/,
|
||||
);
|
||||
meshes.destroy();
|
||||
assert.equal(plane.show, false);
|
||||
assert.equal(material.disposed, true);
|
||||
assert.throws(() => meshes.createMaterial(null), /destroyed/);
|
||||
});
|
||||
|
||||
test('the Navara backend the Navara mode passes in draws the meshes', () => {
|
||||
const engine = createFakeEngine({ kind: 'navara' });
|
||||
const navara = { view: {}, MeshDesc: class {} };
|
||||
engine.extensions = { navara };
|
||||
const calls = [];
|
||||
let given;
|
||||
const meshes = createCctvMeshes(engine, {
|
||||
navaraMeshes(target, extension) {
|
||||
given = { target, extension };
|
||||
return recordingBackend(calls);
|
||||
},
|
||||
meshes.updatePlane(plane, shape, moved, identity);
|
||||
const state = h.engine.fake?.mesh(plane.id);
|
||||
const primitive = h.primitive?.(plane.id);
|
||||
const flat =
|
||||
state?.positions ??
|
||||
primitive.geometryInstances.geometry.attributes.position.values;
|
||||
assert.equal(flat[0], moved.capCenter.x - shape.halfW);
|
||||
assert.equal(
|
||||
state?.image ?? primitive.appearance.material.uniforms.image,
|
||||
image,
|
||||
);
|
||||
assert.throws(
|
||||
() => meshes.createPlane('b', {}, shape, positions, identity),
|
||||
/not owned/,
|
||||
);
|
||||
meshes.destroy();
|
||||
meshes.destroy();
|
||||
assert.equal(plane.show, false);
|
||||
assert.equal(material.disposed, true);
|
||||
assert.deepEqual(h.engine.meshes.list(), []);
|
||||
material.setImage(null);
|
||||
assert.throws(() => meshes.createMaterial(null), /destroyed/);
|
||||
});
|
||||
assert.equal(given.target, engine);
|
||||
assert.equal(given.extension, navara);
|
||||
const material = meshes.createMaterial('canvas');
|
||||
material.setImage('video');
|
||||
const plane = meshes.createPlane('a', material, shape, positions, identity);
|
||||
const volume = meshes.createVolume(positions, viewshedColors(4).fill);
|
||||
const created = calls.filter(([type]) => type === 'create');
|
||||
assert.deepEqual(
|
||||
created.map(([, kind]) => kind),
|
||||
['material', 'plane', 'volume'],
|
||||
);
|
||||
assert.deepEqual(calls[1], ['image', 'video']);
|
||||
const [, , drawnPlane] = created[1];
|
||||
assert.equal(drawnPlane.args[1], material.native, 'its drawn material');
|
||||
plane.show = true;
|
||||
assert.equal(drawnPlane.show, true);
|
||||
assert.equal(meshes.planeFromPick({ native: drawnPlane }), plane);
|
||||
plane.show = false;
|
||||
assert.equal(meshes.planeFromPick({ native: drawnPlane }), null);
|
||||
meshes.updatePlane(plane, shape, positions, identity);
|
||||
assert.deepEqual(calls.at(-1), ['update', shape, positions, identity]);
|
||||
assert.equal(meshes.drawnBy(volume), created[2][2]);
|
||||
meshes.destroy();
|
||||
meshes.destroy();
|
||||
assert.deepEqual(calls.slice(-4), [
|
||||
['destroy', 'plane'],
|
||||
['destroy', 'volume'],
|
||||
['dispose', 'material'],
|
||||
['destroy', 'backend'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('a Navara engine without the backend draws nothing', () => {
|
||||
const engine = createFakeEngine({ kind: 'navara' });
|
||||
engine.extensions = { navara: {} };
|
||||
const meshes = createCctvMeshes(engine);
|
||||
const volume = meshes.createVolume(positions, viewshedColors(4).fill);
|
||||
assert.equal(meshes.drawnBy(volume), undefined);
|
||||
meshes.destroy();
|
||||
});
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
// The CCTV meshes on Navara (behind render/meshes.js, loaded through
|
||||
// import()): the monitor plane and the viewshed volume as three meshes in
|
||||
// Navara's view, depth-tested against the scene, and a mesh picker for the
|
||||
// plane's own triangles.
|
||||
|
||||
import {
|
||||
BufferAttribute,
|
||||
BufferGeometry,
|
||||
CanvasTexture,
|
||||
DoubleSide,
|
||||
Group,
|
||||
LinearFilter,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
Quaternion,
|
||||
Raycaster,
|
||||
SRGBColorSpace,
|
||||
Vector3,
|
||||
VideoTexture,
|
||||
} from 'three';
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { frustumVolumeGeometryData } from '../../../data/cctvViewshed.js';
|
||||
import { PLANE_IMAGE_ALPHA } from './style.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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../../../globe/contract.js').GlobeEngine} engine
|
||||
* @param {{ view: object, MeshDesc: Function }} navara The engine's Navara
|
||||
* extension.
|
||||
*/
|
||||
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];
|
||||
}
|
||||
// The opaque scene draws after the scene depth is copied into its
|
||||
// target, so walls and terrain hide the frame and volume; Navara's
|
||||
// transparent pass runs after the effects without that depth and
|
||||
// painted them through.
|
||||
getPassKey() {
|
||||
return 'opaque';
|
||||
}
|
||||
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: PLANE_IMAGE_ALPHA,
|
||||
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, shape, positions, orientation) {
|
||||
if (!planes.has(plane)) return;
|
||||
// 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 * shape.halfW, y * shape.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);
|
||||
request();
|
||||
}
|
||||
|
||||
/** The nearest shown plane's triangles along a ray, from either side. */
|
||||
function 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;
|
||||
}
|
||||
|
||||
const removeMeshPicker = engine.picker.registerMeshPicker((at) => {
|
||||
const hit = 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 },
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return {
|
||||
createMaterial,
|
||||
createPlane(cameraId, material, shape, 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),
|
||||
);
|
||||
// A failed descriptor disposes the geometry; the material stays its own.
|
||||
let plane = null;
|
||||
plane = ownMesh(mesh, () => planes.delete(plane));
|
||||
plane.id = `cctv-${cameraId}-plane`;
|
||||
plane.cameraId = cameraId;
|
||||
planes.add(plane);
|
||||
updatePlane(plane, shape, positions, orientation);
|
||||
plane.show = false;
|
||||
return plane;
|
||||
},
|
||||
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());
|
||||
},
|
||||
pickPlane,
|
||||
// The mesh picker's result names the plane by its id.
|
||||
planeFromPick(picked) {
|
||||
for (const plane of planes)
|
||||
if (picked.id === plane.id && picked.owner === plane.id) return plane;
|
||||
return null;
|
||||
},
|
||||
drawnBy: (native) => native.group,
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
removeMeshPicker();
|
||||
for (const resource of [...owned]) resource.destroy();
|
||||
for (const material of [...materials]) material.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { LineSegments2 } from 'three/addons/lines/LineSegments2.js';
|
||||
import { LineSegmentsGeometry } from 'three/addons/lines/LineSegmentsGeometry.js';
|
||||
import { LineMaterial } from 'three/addons/lines/LineMaterial.js';
|
||||
import { attachSceneDepth, createSceneDepthTest } from './navaraSceneDepth.js';
|
||||
|
||||
const KEY = 'gevInfrastructureStems';
|
||||
const views = new WeakSet();
|
||||
/** All visible stems use one draw and one fixed buffer; tips are a point bank. */
|
||||
export function createStemBatch(
|
||||
{ view, MeshDesc },
|
||||
color,
|
||||
capacity,
|
||||
width = 3.5,
|
||||
opacity = 1,
|
||||
) {
|
||||
if (!views.has(view)) {
|
||||
view.registerMesh(
|
||||
KEY,
|
||||
class extends MeshDesc {
|
||||
constructor(host, ctx, config) {
|
||||
super(host, ctx, config);
|
||||
this.batch = config[KEY];
|
||||
}
|
||||
// Transparent, so the effects leave its look alone; the scene-depth
|
||||
// test hides a stem behind terrain, 3D tiles and the Earth.
|
||||
getPassKey() {
|
||||
return 'transparent';
|
||||
}
|
||||
createMesh() {
|
||||
attachSceneDepth(this.batch.line, this.ctx);
|
||||
return this.batch.line;
|
||||
}
|
||||
onDestroy() {
|
||||
this.batch.dispose();
|
||||
super.onDestroy();
|
||||
}
|
||||
},
|
||||
);
|
||||
views.add(view);
|
||||
}
|
||||
const xyz = new Float32Array(6 * capacity);
|
||||
const geometry = new LineSegmentsGeometry().setPositions(xyz);
|
||||
geometry.instanceCount = 0;
|
||||
const material = new LineMaterial({
|
||||
color,
|
||||
linewidth: width,
|
||||
opacity,
|
||||
depthWrite: false,
|
||||
transparent: true,
|
||||
toneMapped: false,
|
||||
});
|
||||
const depthTest = createSceneDepthTest();
|
||||
depthTest.material(material);
|
||||
const line = depthTest.object(new LineSegments2(geometry, material));
|
||||
line.frustumCulled = false;
|
||||
const holder = view.addMesh({
|
||||
[KEY]: {
|
||||
line,
|
||||
dispose() {
|
||||
geometry.dispose();
|
||||
material.dispose();
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
set(records, origin) {
|
||||
let at = 0;
|
||||
for (const { base, tip } of records)
|
||||
for (const p of [base, tip]) {
|
||||
xyz[at++] = p.x - origin.x;
|
||||
xyz[at++] = p.y - origin.y;
|
||||
xyz[at++] = p.z - origin.z;
|
||||
}
|
||||
line.position.set(origin.x, origin.y, origin.z);
|
||||
line.updateMatrix();
|
||||
line.matrixWorldNeedsUpdate = true;
|
||||
geometry.instanceCount = records.length;
|
||||
geometry.attributes.instanceStart.data.needsUpdate = true;
|
||||
},
|
||||
destroy: () => holder.delete(),
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import { parsers } from 'prettier/plugins/babel.mjs';
|
||||
|
||||
// Existing entity graphics only. Remove entries as these callers migrate.
|
||||
const legacyFiles = new Set(['src/scenes/dataPacks/presentation.js']);
|
||||
const legacyFiles = new Set();
|
||||
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||
const propertyName = (node) => node?.name ?? node?.value;
|
||||
const memberName = (node) =>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { createDataPackSession } from '../../director/packs/session.js';
|
||||
import { createPackPresentations } from './presentation.js';
|
||||
|
||||
/** Bind selected shot packs to one viewer and expose copied lifecycle diagnostics. */
|
||||
/** Bind selected shot packs to one engine and expose copied lifecycle diagnostics. */
|
||||
export function createSceneDataPacks(
|
||||
viewer,
|
||||
{ sources = {}, native = null, container = viewer?.container } = {},
|
||||
engine,
|
||||
{ sources = {}, container = engine?.host?.container } = {},
|
||||
) {
|
||||
const targets = new Map();
|
||||
const presentations = createPackPresentations(viewer, targets, {
|
||||
native,
|
||||
const presentations = createPackPresentations(engine, targets, {
|
||||
container,
|
||||
});
|
||||
const session = createDataPackSession({
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { polygonShape, rectangleShape } from './shapes.js';
|
||||
|
||||
const cyan = [0, 1, 1, 1];
|
||||
const xyz = ([lon, lat, height]) =>
|
||||
geo.cartesian3.fromDegrees(lon, lat, height);
|
||||
|
||||
export function createPackRenderer(engine) {
|
||||
function addMesh(handle, shape, style, id) {
|
||||
if (!shape) return;
|
||||
const mesh = engine.meshes.createMesh({
|
||||
id: `scene-pack-mesh-${id}`,
|
||||
positions: shape.attributes.position.values,
|
||||
indices: shape.indices,
|
||||
uv: shape.attributes.st?.values,
|
||||
...style,
|
||||
});
|
||||
handle.own(() => mesh.destroy());
|
||||
}
|
||||
return {
|
||||
geometry(pack, features, handle) {
|
||||
const points = features.filter((f) => f.type === 'Point');
|
||||
let bank;
|
||||
if (points.length) {
|
||||
bank = engine.movers.createSet({
|
||||
id: 'scene-pack-points-' + pack.id,
|
||||
capacity: points.length,
|
||||
kind: 'point',
|
||||
depthTest: 'normal',
|
||||
pickable: true,
|
||||
point: { sizePx: 12, outlinePx: 2, outlineColor: [0, 0, 0, 1] },
|
||||
});
|
||||
handle.own(() => bank.destroy());
|
||||
}
|
||||
let index = 0;
|
||||
for (const f of features) {
|
||||
const id = JSON.stringify([pack.id, f.id]);
|
||||
handle.feature({ packId: pack.id, featureId: f.id }, id);
|
||||
if (f.type === 'Point') {
|
||||
bank.set(index++, {
|
||||
position: xyz(f.coordinates),
|
||||
color: cyan,
|
||||
pickId: id,
|
||||
show: true,
|
||||
});
|
||||
} else if (f.type === 'LineString') {
|
||||
const line = engine.lines.createLine({
|
||||
id,
|
||||
widthPx: 4,
|
||||
color: cyan,
|
||||
pickId: id,
|
||||
arc: 'none',
|
||||
});
|
||||
handle.own(() => line.destroy());
|
||||
line.setPositions(f.coordinates.map(xyz));
|
||||
} else {
|
||||
addMesh(
|
||||
handle,
|
||||
polygonShape(f.coordinates),
|
||||
{ color: [0, 1, 1, 0.35], pickId: id },
|
||||
id,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (bank) {
|
||||
bank.setCount(index);
|
||||
bank.commit();
|
||||
}
|
||||
engine.render.request('scene-pack');
|
||||
},
|
||||
image(pack, image, handle) {
|
||||
addMesh(
|
||||
handle,
|
||||
rectangleShape(pack.placement.bounds, pack.placement.height),
|
||||
{ image, pickId: { packId: pack.id } },
|
||||
pack.id,
|
||||
);
|
||||
},
|
||||
media(pack, anchor, handle) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 256;
|
||||
canvas.height = 256;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.font = '14px sans-serif';
|
||||
canvas.width = canvas.height = Math.max(
|
||||
256,
|
||||
Math.ceil(ctx.measureText(pack.id).width + 12),
|
||||
);
|
||||
const center = canvas.width / 2;
|
||||
ctx.font = '14px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.fillText(pack.id, center, center - 24);
|
||||
ctx.fillStyle = '#ff0';
|
||||
ctx.beginPath();
|
||||
ctx.arc(center, center, 8, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
const set = engine.movers.createSet({
|
||||
id: 'scene-media-' + pack.id,
|
||||
kind: 'sprite',
|
||||
capacity: 1,
|
||||
// The whole square would take clicks meant for the pack's features
|
||||
// around the anchor; no action targets the media marker.
|
||||
pickable: false,
|
||||
depthTest: 'normal',
|
||||
sprite: {
|
||||
sizePx: canvas.width,
|
||||
atlas: { canvas, cells: { icon: [0, 0, 1, 1] } },
|
||||
},
|
||||
});
|
||||
handle.own(() => set.destroy());
|
||||
set.set(0, {
|
||||
position: xyz([anchor.lon, anchor.lat, anchor.alt]),
|
||||
cell: 'icon',
|
||||
color: [1, 1, 1, 1],
|
||||
show: true,
|
||||
});
|
||||
set.setCount(1);
|
||||
set.commit();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
|
||||
import { createCesiumMoversEngine } from '../../testSupport/cesiumMoversEngine.mjs';
|
||||
import { createPackRenderer } from './display.js';
|
||||
import { createPackPresentations } from './presentation.js';
|
||||
|
||||
const features = [
|
||||
{ id: 'point', type: 'Point', coordinates: [20, 10, 200] },
|
||||
{
|
||||
id: 'line',
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[20, 10, 200],
|
||||
[21, 11, 300],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'area',
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[20, 10, 200],
|
||||
[21, 10, 300],
|
||||
[21, 11, 400],
|
||||
[20, 11, 500],
|
||||
[20, 10, 200],
|
||||
],
|
||||
],
|
||||
},
|
||||
];
|
||||
const pack = {
|
||||
id: 'pack',
|
||||
attribution: { text: 'Fixture', license: 'CC0' },
|
||||
placement: { bounds: [20, 10, 21, 11], height: 200 },
|
||||
};
|
||||
|
||||
function ownership() {
|
||||
const releases = [];
|
||||
const targets = new Map();
|
||||
return {
|
||||
own: (release) => releases.push(release),
|
||||
feature: (feature, id) => targets.set(feature.featureId, id),
|
||||
targets,
|
||||
destroy() {
|
||||
for (const release of releases.splice(0).reverse()) release();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
for (const [name, make] of [
|
||||
['fake', () => ({ engine: createFakeEngine() })],
|
||||
['Cesium', createCesiumMoversEngine],
|
||||
]) {
|
||||
test(`pack points, straight lines, raised polygon, image and ownership (${name})`, (t) => {
|
||||
const h = make();
|
||||
t.after(() => h.engine.destroy());
|
||||
const handle = ownership();
|
||||
t.after(() => handle.destroy());
|
||||
const display = createPackRenderer(h.engine);
|
||||
display.geometry(pack, features, handle);
|
||||
assert.equal(h.engine.movers.list().length, 1);
|
||||
assert.equal(h.engine.lines.list().length, 1);
|
||||
assert.equal(h.engine.meshes.list().length, 1);
|
||||
assert.equal(handle.targets.size, 3);
|
||||
const polygonId = 'scene-pack-mesh-' + handle.targets.get('area');
|
||||
const state = h.engine.fake?.mesh(polygonId);
|
||||
const primitive = h.primitive?.(polygonId);
|
||||
const positions =
|
||||
state?.positions ??
|
||||
primitive.geometryInstances.geometry.attributes.position.values;
|
||||
assert.equal(
|
||||
positions.length,
|
||||
12,
|
||||
'authored per-position heights keep four vertices',
|
||||
);
|
||||
assert.equal(
|
||||
state?.color[3] ?? primitive.appearance.material.uniforms.color.alpha,
|
||||
0.35,
|
||||
);
|
||||
const image = new HTMLCanvasElement();
|
||||
image.width = image.height = 8;
|
||||
display.image(pack, image, handle);
|
||||
const imageId = 'scene-pack-mesh-pack';
|
||||
assert.equal(
|
||||
h.engine.fake?.mesh(imageId).image ??
|
||||
h.primitive(imageId).appearance.material.uniforms.image,
|
||||
image,
|
||||
);
|
||||
handle.destroy();
|
||||
handle.destroy();
|
||||
assert.deepEqual(h.engine.meshes.list(), []);
|
||||
assert.deepEqual(h.engine.movers.list(), []);
|
||||
assert.deepEqual(h.engine.lines.list(), []);
|
||||
});
|
||||
|
||||
test(`pack media caption keeps Cesium's font, offset and marker size (${name})`, (t) => {
|
||||
const h = make();
|
||||
t.after(() => h.engine.destroy());
|
||||
const previous = globalThis.document;
|
||||
const drawn = [];
|
||||
const ctx = {
|
||||
measureText: () => ({ width: 400 }),
|
||||
fillText: (...args) => drawn.push(['text', ...args]),
|
||||
beginPath() {},
|
||||
arc: (...args) => drawn.push(['arc', ...args]),
|
||||
fill() {},
|
||||
};
|
||||
globalThis.document = {
|
||||
createElement: () =>
|
||||
Object.assign(new HTMLCanvasElement(), { getContext: () => ctx }),
|
||||
};
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete globalThis.document;
|
||||
else globalThis.document = previous;
|
||||
});
|
||||
const handle = ownership();
|
||||
createPackRenderer(h.engine).media(
|
||||
pack,
|
||||
{ lon: 20, lat: 10, alt: 200 },
|
||||
handle,
|
||||
);
|
||||
assert.equal(ctx.font, '14px sans-serif');
|
||||
assert.equal(ctx.textAlign, 'center');
|
||||
assert.equal(ctx.textBaseline, 'middle');
|
||||
assert.deepEqual(drawn[0], ['text', pack.id, 206, 182]);
|
||||
assert.deepEqual(drawn[1], ['arc', 206, 206, 8, 0, Math.PI * 2]);
|
||||
assert.equal(h.engine.movers.list().length, 1);
|
||||
handle.destroy();
|
||||
assert.deepEqual(h.engine.movers.list(), []);
|
||||
});
|
||||
|
||||
test(`pack detach and reattach retain decoded geometry and target identities (${name})`, async (t) => {
|
||||
const h = make();
|
||||
t.after(() => h.engine.destroy());
|
||||
const targets = new Map();
|
||||
const presentation = createPackPresentations(h.engine, targets, {
|
||||
store: { publish() {} },
|
||||
});
|
||||
const bytes = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
type: 'FeatureCollection',
|
||||
features: features.map((f) => ({
|
||||
type: 'Feature',
|
||||
id: f.id,
|
||||
properties: {},
|
||||
geometry: { type: f.type, coordinates: f.coordinates },
|
||||
})),
|
||||
}),
|
||||
);
|
||||
const handle = await presentation.geojson({
|
||||
pack,
|
||||
asset: { bytes, mimeType: 'application/geo+json' },
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
const before = [...targets];
|
||||
assert.equal(before.length, 3);
|
||||
presentation.setScene(null);
|
||||
assert.equal(targets.size, 0);
|
||||
assert.deepEqual(h.engine.meshes.list(), []);
|
||||
presentation.setScene(h.engine);
|
||||
assert.deepEqual([...targets], before);
|
||||
assert.equal(h.engine.meshes.list().length, 1);
|
||||
handle.dispose();
|
||||
handle.dispose();
|
||||
assert.equal(targets.size, 0);
|
||||
assert.deepEqual(h.engine.meshes.list(), []);
|
||||
});
|
||||
|
||||
test(`pack partial geometry failure releases prior resources (${name})`, async (t) => {
|
||||
const h = make();
|
||||
t.after(() => h.engine.destroy());
|
||||
const targets = new Map();
|
||||
h.engine.meshes.createMesh = () => {
|
||||
throw new Error('mesh failed');
|
||||
};
|
||||
const presentation = createPackPresentations(h.engine, targets, {
|
||||
store: { publish() {} },
|
||||
});
|
||||
const bytes = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
type: 'FeatureCollection',
|
||||
features: features.map((f) => ({
|
||||
type: 'Feature',
|
||||
id: f.id,
|
||||
properties: {},
|
||||
geometry: { type: f.type, coordinates: f.coordinates },
|
||||
})),
|
||||
}),
|
||||
);
|
||||
await assert.rejects(
|
||||
presentation.geojson({
|
||||
pack,
|
||||
asset: { bytes, mimeType: 'application/json' },
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
/mesh failed/,
|
||||
);
|
||||
assert.equal(targets.size, 0);
|
||||
assert.deepEqual(h.engine.movers.list(), []);
|
||||
assert.deepEqual(h.engine.lines.list(), []);
|
||||
});
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import * as Cesium from 'cesium';
|
||||
const xyz = (p) => Cesium.Cartesian3.fromDegrees(...p);
|
||||
|
||||
/** Render decoded pack geometry in owned batches; no asynchronous entity geometry or update loop. */
|
||||
export function renderDecodedGeometry(features, pack, handle) {
|
||||
const polygons = [];
|
||||
let lines;
|
||||
for (const feature of features) {
|
||||
const id = { packId: pack.id, featureId: feature.id };
|
||||
if (feature.type === 'Point') {
|
||||
const entity = handle.add({
|
||||
name: `${pack.id} / ${feature.id}`,
|
||||
position: xyz(feature.coordinates),
|
||||
point: {
|
||||
pixelSize: 12,
|
||||
color: Cesium.Color.CYAN,
|
||||
outlineColor: Cesium.Color.BLACK,
|
||||
outlineWidth: 2,
|
||||
},
|
||||
});
|
||||
handle.feature?.(id, entity);
|
||||
} else if (feature.type === 'LineString') {
|
||||
handle.feature?.(id, id);
|
||||
if (!lines) {
|
||||
lines = new Cesium.PolylineCollection();
|
||||
handle.primitive(lines);
|
||||
}
|
||||
lines.add({
|
||||
id,
|
||||
positions: feature.coordinates.map(xyz),
|
||||
width: 4,
|
||||
material: Cesium.Material.fromType('Color', {
|
||||
color: Cesium.Color.CYAN,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
handle.feature?.(id, id);
|
||||
polygons.push(
|
||||
new Cesium.GeometryInstance({
|
||||
id,
|
||||
geometry: new Cesium.PolygonGeometry({
|
||||
polygonHierarchy: new Cesium.PolygonHierarchy(
|
||||
feature.coordinates[0].map(xyz),
|
||||
feature.coordinates
|
||||
.slice(1)
|
||||
.map((ring) => new Cesium.PolygonHierarchy(ring.map(xyz))),
|
||||
),
|
||||
perPositionHeight: true,
|
||||
vertexFormat: Cesium.PerInstanceColorAppearance.VERTEX_FORMAT,
|
||||
arcType: Cesium.ArcType.GEODESIC,
|
||||
}),
|
||||
attributes: {
|
||||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
Cesium.Color.CYAN.withAlpha(0.35),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (polygons.length)
|
||||
handle.primitive(
|
||||
new Cesium.Primitive({
|
||||
asynchronous: false,
|
||||
geometryInstances: polygons,
|
||||
appearance: new Cesium.PerInstanceColorAppearance({
|
||||
translucent: true,
|
||||
flat: true,
|
||||
closed: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
import {
|
||||
BufferAttribute,
|
||||
BufferGeometry,
|
||||
Color,
|
||||
DoubleSide,
|
||||
Group,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
Raycaster,
|
||||
SRGBColorSpace,
|
||||
Texture,
|
||||
} from 'three';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { polygonShape, rectangleShape } from './navaraGeometry.js';
|
||||
import {
|
||||
attachSceneDepth,
|
||||
createSceneDepthTest,
|
||||
} from '../../layers/shared/render/navaraSceneDepth.js';
|
||||
const KEY = 'gevScenePack',
|
||||
registered = new WeakSet();
|
||||
const cyan = [0, 1, 1, 1],
|
||||
xyz = ([lon, lat, height]) => geo.cartesian3.fromDegrees(lon, lat, height);
|
||||
export function createNavaraPackRenderer(engine, { view, MeshDesc }) {
|
||||
if (!registered.has(view)) {
|
||||
view.registerMesh(
|
||||
KEY,
|
||||
class extends MeshDesc {
|
||||
constructor(host, ctx, config) {
|
||||
super(host, ctx, config);
|
||||
this.object = config[KEY];
|
||||
}
|
||||
// Transparent, so the effects leave its look alone; the scene-depth
|
||||
// test hides a pack behind terrain, 3D tiles and the Earth.
|
||||
getPassKey() {
|
||||
return 'transparent';
|
||||
}
|
||||
createMesh() {
|
||||
attachSceneDepth(this.object.group, this.ctx);
|
||||
return this.object.group;
|
||||
}
|
||||
onDestroy() {
|
||||
this.object.dispose();
|
||||
super.onDestroy();
|
||||
}
|
||||
},
|
||||
);
|
||||
registered.add(view);
|
||||
}
|
||||
const meshGroup = (handle) => {
|
||||
const depthTest = createSceneDepthTest();
|
||||
const group = depthTest.object(new Group()),
|
||||
owned = [];
|
||||
let released = false;
|
||||
const dispose = () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
for (const item of owned) item.dispose();
|
||||
};
|
||||
group.frustumCulled = false;
|
||||
let holder;
|
||||
try {
|
||||
holder = view.addMesh({ [KEY]: { group, dispose } });
|
||||
} catch (e) {
|
||||
dispose();
|
||||
throw e;
|
||||
}
|
||||
handle.own(() => {
|
||||
try {
|
||||
holder.delete();
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
return { group, owned, depthTest };
|
||||
};
|
||||
function addMesh(batch, shape, properties, id, texture) {
|
||||
if (!shape) return;
|
||||
const source = shape.attributes.position.values,
|
||||
origin = shape.boundingSphere.center;
|
||||
const coords = new Float32Array(source.length);
|
||||
for (let i = 0; i < source.length; i += 3) {
|
||||
coords[i] = source[i] - origin.x;
|
||||
coords[i + 1] = source[i + 1] - origin.y;
|
||||
coords[i + 2] = source[i + 2] - origin.z;
|
||||
}
|
||||
const geometry = new BufferGeometry();
|
||||
batch.owned.push(geometry);
|
||||
geometry.setAttribute('position', new BufferAttribute(coords, 3));
|
||||
geometry.setIndex(new BufferAttribute(shape.indices, 1));
|
||||
if (shape.attributes.st)
|
||||
geometry.setAttribute(
|
||||
'uv',
|
||||
new BufferAttribute(new Float32Array(shape.attributes.st.values), 2),
|
||||
);
|
||||
const material = new MeshBasicMaterial({
|
||||
...properties,
|
||||
map: texture,
|
||||
side: DoubleSide,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
toneMapped: false,
|
||||
});
|
||||
batch.owned.push(material);
|
||||
const mesh = new Mesh(geometry, material);
|
||||
const pigment = material.color.clone();
|
||||
mesh.onBeforeRender = () => {
|
||||
material.color
|
||||
.copy(pigment)
|
||||
.multiplyScalar(1 / Math.max(1, view.toneMappingExposure ?? 1));
|
||||
};
|
||||
batch.depthTest.material(material);
|
||||
batch.depthTest.object(mesh);
|
||||
mesh.position.set(origin.x, origin.y, origin.z);
|
||||
mesh.userData.pickId = id;
|
||||
batch.group.add(mesh);
|
||||
return mesh;
|
||||
}
|
||||
return {
|
||||
geometry(pack, features, handle) {
|
||||
const points = features.filter((f) => f.type === 'Point');
|
||||
let bank;
|
||||
if (points.length) {
|
||||
bank = engine.movers.createSet({
|
||||
id: 'scene-pack-points-' + pack.id,
|
||||
capacity: points.length,
|
||||
kind: 'point',
|
||||
depthTest: 'normal',
|
||||
pickable: true,
|
||||
point: { sizePx: 12, outlinePx: 2, outlineColor: [0, 0, 0, 1] },
|
||||
});
|
||||
handle.own(() => bank.destroy());
|
||||
}
|
||||
let index = 0,
|
||||
batch;
|
||||
for (const f of features) {
|
||||
const id = JSON.stringify([pack.id, f.id]);
|
||||
handle.feature({ packId: pack.id, featureId: f.id }, id);
|
||||
if (f.type === 'Point') {
|
||||
bank.set(index++, {
|
||||
position: xyz(f.coordinates),
|
||||
color: cyan,
|
||||
pickId: id,
|
||||
show: true,
|
||||
});
|
||||
} else if (f.type === 'LineString') {
|
||||
const line = engine.lines.createLine({
|
||||
id,
|
||||
widthPx: 4,
|
||||
color: cyan,
|
||||
pickId: id,
|
||||
arc: 'geodesic',
|
||||
});
|
||||
handle.own(() => line.destroy());
|
||||
line.setPositions(f.coordinates.map(xyz));
|
||||
} else {
|
||||
batch ??= meshGroup(handle);
|
||||
addMesh(
|
||||
batch,
|
||||
polygonShape(f.coordinates),
|
||||
{ color: new Color(0x00ffff), opacity: 0.35 },
|
||||
id,
|
||||
null,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (bank) {
|
||||
bank.setCount(index);
|
||||
bank.commit();
|
||||
}
|
||||
if (batch) {
|
||||
const raycaster = new Raycaster();
|
||||
handle.own(
|
||||
engine.picker.registerMeshPicker((at) => {
|
||||
const ray = engine.camera.pickRay(at);
|
||||
if (!ray) return [];
|
||||
raycaster.ray.origin.copy(ray.origin);
|
||||
raycaster.ray.direction.copy(ray.direction).normalize();
|
||||
batch.group.updateWorldMatrix(true, true);
|
||||
const hits = raycaster.intersectObjects(batch.group.children);
|
||||
const earth = engine.picker.pickGlobe(at);
|
||||
const depth = earth
|
||||
? geo.cartesian3.distance(ray.origin, earth)
|
||||
: Infinity;
|
||||
return hits
|
||||
.filter((h) => h.distance <= depth + 1)
|
||||
.map((h) => ({
|
||||
onTop: 0,
|
||||
order: 0,
|
||||
depth: h.distance,
|
||||
result: { owner: 'scene-pack', id: h.object.userData.pickId },
|
||||
}));
|
||||
}),
|
||||
);
|
||||
}
|
||||
engine.render.request('scene-pack');
|
||||
},
|
||||
image(pack, image, handle) {
|
||||
const batch = meshGroup(handle);
|
||||
const texture = new Texture(image);
|
||||
texture.colorSpace = SRGBColorSpace;
|
||||
texture.needsUpdate = true;
|
||||
batch.owned.push(texture);
|
||||
const shape = rectangleShape(
|
||||
pack.placement.bounds,
|
||||
pack.placement.height,
|
||||
);
|
||||
addMesh(batch, shape, { color: 0xffffff, opacity: 1 }, null, texture);
|
||||
engine.render.request('scene-pack');
|
||||
},
|
||||
media(pack, anchor, handle) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 256;
|
||||
canvas.height = 256;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.font = '14px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.fillText(pack.id, 128, 105, 250);
|
||||
ctx.fillStyle = '#ff0';
|
||||
ctx.beginPath();
|
||||
ctx.arc(128, 128, 8, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
const set = engine.movers.createSet({
|
||||
id: 'scene-media-' + pack.id,
|
||||
kind: 'sprite',
|
||||
capacity: 1,
|
||||
pickable: false,
|
||||
depthTest: 'normal',
|
||||
sprite: {
|
||||
sizePx: 256,
|
||||
atlas: { canvas, cells: { icon: [0, 0, 1, 1] } },
|
||||
},
|
||||
});
|
||||
handle.own(() => set.destroy());
|
||||
set.set(0, {
|
||||
position: xyz([anchor.lon, anchor.lat, anchor.alt]),
|
||||
cell: 'icon',
|
||||
color: [1, 1, 1, 1],
|
||||
show: true,
|
||||
});
|
||||
set.setCount(1);
|
||||
set.commit();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { renderDecodedGeometry } from './geometry.js';
|
||||
import { createPackRenderer } from './display.js';
|
||||
import { decodePackGeoJSON } from '../../director/packs/geojson.js';
|
||||
import { useDataPacksStore } from '../../ui/stores/dataPacks.js';
|
||||
import { applicationPinia } from '../../ui/vueRuntime.js';
|
||||
|
||||
const xyz = (p) => Cesium.Cartesian3.fromDegrees(...p);
|
||||
|
||||
// Key every panel and card on the page, so a new one never reuses an old
|
||||
// one's DOM.
|
||||
let panelKeys = 0,
|
||||
@@ -14,17 +11,17 @@ let panelKeys = 0,
|
||||
/**
|
||||
* Create rendering adapters; data acquisition and authored placement stay
|
||||
* independent. Each pack's attribution card is presented to the data packs
|
||||
* store, which its view renders inside the viewer's container.
|
||||
* store, which its view renders inside the engine's container.
|
||||
*/
|
||||
export function createPackPresentations(
|
||||
viewer,
|
||||
engine,
|
||||
targets = new Map(),
|
||||
{
|
||||
store = useDataPacksStore(applicationPinia()),
|
||||
native = null,
|
||||
container = viewer?.container,
|
||||
container = engine?.host?.container,
|
||||
} = {},
|
||||
) {
|
||||
let renderer = engine ? createPackRenderer(engine) : null;
|
||||
const owner = {};
|
||||
const resources = new Set();
|
||||
let key,
|
||||
@@ -56,54 +53,33 @@ export function createPackPresentations(
|
||||
present();
|
||||
}
|
||||
function resource(pack) {
|
||||
const entities = [],
|
||||
primitives = [],
|
||||
materials = [],
|
||||
urls = [];
|
||||
const urls = [];
|
||||
const featureKeys = [];
|
||||
let card,
|
||||
draw,
|
||||
destroyed = false;
|
||||
const nativeRemovers = [];
|
||||
const removers = [];
|
||||
const handle = {
|
||||
own(remove) {
|
||||
nativeRemovers.push(remove);
|
||||
removers.push(remove);
|
||||
},
|
||||
display(render) {
|
||||
draw = render;
|
||||
if (viewer || native) draw(native);
|
||||
if (renderer) draw(renderer);
|
||||
},
|
||||
redraw() {
|
||||
if (!destroyed && (viewer || native)) draw?.(native);
|
||||
if (!destroyed && renderer) draw?.(renderer);
|
||||
},
|
||||
detach() {
|
||||
for (const key of featureKeys.splice(0)) targets.delete(key);
|
||||
for (const remove of nativeRemovers.splice(0).reverse()) remove();
|
||||
for (const entity of entities.splice(0)) viewer.entities.remove(entity);
|
||||
for (const primitive of primitives.splice(0))
|
||||
viewer.scene.primitives.remove(primitive);
|
||||
for (const material of materials.splice(0)) {
|
||||
if (!material.isDestroyed()) material.destroy();
|
||||
}
|
||||
viewer?.scene.requestRender();
|
||||
for (const remove of removers.splice(0).reverse()) remove();
|
||||
engine?.render?.request('scene-pack');
|
||||
},
|
||||
feature(id, pickedId) {
|
||||
const key = JSON.stringify([id.packId, id.featureId]);
|
||||
targets.set(key, pickedId);
|
||||
featureKeys.push(key);
|
||||
},
|
||||
add(spec) {
|
||||
const entity = viewer.entities.add(spec);
|
||||
entities.push(entity);
|
||||
viewer.scene.requestRender();
|
||||
return entity;
|
||||
},
|
||||
primitive(value) {
|
||||
primitives.push(viewer.scene.primitives.add(value));
|
||||
if (value.appearance?.material)
|
||||
materials.push(value.appearance.material);
|
||||
viewer.scene.requestRender();
|
||||
},
|
||||
card() {
|
||||
card ||= addCard(pack);
|
||||
},
|
||||
@@ -149,10 +125,10 @@ export function createPackPresentations(
|
||||
};
|
||||
}
|
||||
return {
|
||||
setScene(nextViewer, nextNative, nextContainer) {
|
||||
setScene(nextEngine, nextContainer) {
|
||||
for (const handle of resources) handle.detach();
|
||||
viewer = nextViewer;
|
||||
native = nextNative;
|
||||
engine = nextEngine;
|
||||
renderer = engine ? createPackRenderer(engine) : null;
|
||||
if (nextContainer) container = nextContainer;
|
||||
for (const handle of resources) handle.redraw();
|
||||
if (cards.length) present();
|
||||
@@ -163,11 +139,7 @@ export function createPackPresentations(
|
||||
)
|
||||
throw new Error('Expected GeoJSON');
|
||||
const features = decodePackGeoJSON(asset.bytes);
|
||||
handle.display((native) =>
|
||||
native
|
||||
? native.geometry(pack, features, handle)
|
||||
: renderDecodedGeometry(features, pack, handle),
|
||||
);
|
||||
handle.display((renderer) => renderer.geometry(pack, features, handle));
|
||||
}),
|
||||
image: guarded(async ({ asset, pack, signal }, handle) => {
|
||||
if (asset.mimeType !== 'image/png') throw new Error('Expected PNG image');
|
||||
@@ -202,35 +174,7 @@ export function createPackPresentations(
|
||||
signal.throwIfAborted();
|
||||
if (image.naturalWidth * image.naturalHeight > 16 * 1024 * 1024)
|
||||
throw new Error('Image dimensions exceed limit');
|
||||
handle.display((native) =>
|
||||
native
|
||||
? native.image(pack, image, handle)
|
||||
: handle.primitive(
|
||||
new Cesium.Primitive({
|
||||
asynchronous: false,
|
||||
geometryInstances: new Cesium.GeometryInstance({
|
||||
id: { packId: pack.id },
|
||||
geometry: new Cesium.RectangleGeometry({
|
||||
rectangle: Cesium.Rectangle.fromDegrees(
|
||||
...pack.placement.bounds,
|
||||
),
|
||||
height: pack.placement.height,
|
||||
vertexFormat:
|
||||
Cesium.MaterialAppearance.MaterialSupport.TEXTURED
|
||||
.vertexFormat,
|
||||
}),
|
||||
}),
|
||||
appearance: new Cesium.MaterialAppearance({
|
||||
material: Cesium.Material.fromType('Image', { image }),
|
||||
materialSupport:
|
||||
Cesium.MaterialAppearance.MaterialSupport.TEXTURED,
|
||||
flat: true,
|
||||
faceForward: true,
|
||||
translucent: true,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
handle.display((renderer) => renderer.image(pack, image, handle));
|
||||
}),
|
||||
media: guarded(({ asset, pack, anchors }, handle) => {
|
||||
if (
|
||||
@@ -248,20 +192,7 @@ export function createPackPresentations(
|
||||
(item) => item.id === pack.placement.anchorId,
|
||||
);
|
||||
if (!anchor) throw new Error('Missing media anchor');
|
||||
handle.display((native) =>
|
||||
native
|
||||
? native.media(pack, anchor, handle)
|
||||
: handle.add({
|
||||
name: pack.id,
|
||||
position: xyz([anchor.lon, anchor.lat, anchor.alt]),
|
||||
point: { pixelSize: 16, color: Cesium.Color.YELLOW },
|
||||
label: {
|
||||
text: pack.id,
|
||||
font: '14px sans-serif',
|
||||
pixelOffset: new Cesium.Cartesian2(0, -24),
|
||||
},
|
||||
}),
|
||||
);
|
||||
handle.display((renderer) => renderer.media(pack, anchor, handle));
|
||||
handle.media({
|
||||
kind: asset.mimeType.startsWith('video/') ? 'video' : 'audio',
|
||||
src: handle.url(new Blob([asset.bytes], { type: asset.mimeType })),
|
||||
|
||||
@@ -19,14 +19,6 @@ test('abort releases an in-flight image URL before the decoder settles or the vi
|
||||
revoked.push(url);
|
||||
priorRevoke(url);
|
||||
};
|
||||
const viewer = {
|
||||
scene: { requestRender() {} },
|
||||
entities: {
|
||||
remove() {
|
||||
assert.fail('No entity should be acquired');
|
||||
},
|
||||
},
|
||||
};
|
||||
const signalOwner = new AbortController();
|
||||
const bytes = new Uint8Array(24);
|
||||
bytes.set([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82]);
|
||||
@@ -34,7 +26,7 @@ test('abort releases an in-flight image URL before the decoder settles or the vi
|
||||
header.setUint32(16, 8);
|
||||
header.setUint32(20, 8);
|
||||
try {
|
||||
const rendering = createPackPresentations(viewer).image({
|
||||
const rendering = createPackPresentations(null).image({
|
||||
pack: {},
|
||||
asset: { bytes, mimeType: 'image/png' },
|
||||
signal: signalOwner.signal,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createApp, nextTick } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import { useDataPacksStore } from '../../ui/stores/dataPacks.js';
|
||||
import DataPacks from '../../ui/views/panels/DataPacks.vue';
|
||||
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
|
||||
import { createPackPresentations } from './presentation.js';
|
||||
|
||||
const ANCHOR = { id: 'here', lat: 30, lon: -97, alt: 10 };
|
||||
@@ -18,16 +19,24 @@ const pack = (id, url) => ({
|
||||
});
|
||||
|
||||
function fixture(t) {
|
||||
const previousContext = HTMLCanvasElement.prototype.getContext;
|
||||
HTMLCanvasElement.prototype.getContext = () => ({
|
||||
measureText: (text) => ({ width: text.length * 8 }),
|
||||
fillText() {},
|
||||
beginPath() {},
|
||||
arc() {},
|
||||
fill() {},
|
||||
});
|
||||
t.after(() => {
|
||||
HTMLCanvasElement.prototype.getContext = previousContext;
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
const container = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(DataPacks).use(pinia);
|
||||
app.mount(document.body.appendChild(document.createElement('div')));
|
||||
const viewer = {
|
||||
container,
|
||||
scene: { requestRender() {} },
|
||||
entities: { add: (spec) => spec, remove() {} },
|
||||
};
|
||||
const viewer = createFakeEngine({ container });
|
||||
t.after(() => viewer.destroy());
|
||||
const store = useDataPacksStore(pinia);
|
||||
const presentations = createPackPresentations(viewer, new Map(), { store });
|
||||
t.after(() => app.unmount());
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
// Pack polygons and images as Navara mesh geometry, without the Cesium SDK (a
|
||||
// page that starts on Navara never loads it). A shape is what Cesium's
|
||||
// Geometry gives the renderer: Earth-fixed positions, triangle indices, an
|
||||
// optional texture coordinate per vertex and a centre to draw relative to.
|
||||
import { ShapeUtils, Vector2 } from 'three';
|
||||
// Pack polygons and image rectangles as indexed world triangles.
|
||||
import earcut from 'earcut';
|
||||
import * as geo from '../../geo/index.js';
|
||||
|
||||
// Cesium's default granularity: no edge or cell spans more than one degree.
|
||||
@@ -24,30 +21,6 @@ function shape(positions, indices, st = null) {
|
||||
};
|
||||
}
|
||||
|
||||
/** A [lon, lat, height?] ring with long edges split along great circles. */
|
||||
function densify(ring) {
|
||||
const out = [];
|
||||
ring.forEach((a, i) => {
|
||||
const b = ring[(i + 1) % ring.length];
|
||||
const steps = Math.max(
|
||||
1,
|
||||
Math.ceil(
|
||||
Math.max(Math.abs(b[0] - a[0]), Math.abs(b[1] - a[1])) /
|
||||
GRANULARITY_DEGREES,
|
||||
),
|
||||
);
|
||||
for (let k = 0; k < steps; k++) {
|
||||
const t = k / steps;
|
||||
out.push([
|
||||
a[0] + (b[0] - a[0]) * t,
|
||||
a[1] + (b[1] - a[1]) * t,
|
||||
(a[2] ?? 0) + ((b[2] ?? 0) - (a[2] ?? 0)) * t,
|
||||
]);
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A filled polygon: `rings` are GeoJSON-style [lon, lat, height?] rings, the
|
||||
* first the outline and the rest holes, each vertex at its own height (as
|
||||
@@ -63,7 +36,7 @@ export function polygonShape(rings) {
|
||||
ring[0][1] === ring.at(-1)[1]
|
||||
? ring.slice(0, -1)
|
||||
: ring;
|
||||
return densify(open);
|
||||
return open;
|
||||
})
|
||||
.filter((ring) => ring.length >= 3);
|
||||
if (!loops.length) return null;
|
||||
@@ -82,23 +55,20 @@ export function polygonShape(rings) {
|
||||
const offset = geo.cartesian3.create();
|
||||
const plane = world.map((p) => {
|
||||
geo.cartesian3.subtract(p, middle, offset);
|
||||
return new Vector2(
|
||||
return [
|
||||
geo.cartesian3.dot(offset, east),
|
||||
geo.cartesian3.dot(offset, north),
|
||||
);
|
||||
];
|
||||
});
|
||||
let start = outline.length;
|
||||
const holePoints = holes.map((hole) => {
|
||||
const ring = plane.slice(start, start + hole.length);
|
||||
const holeIndices = holes.map((hole) => {
|
||||
const index = start;
|
||||
start += hole.length;
|
||||
return ring;
|
||||
return index;
|
||||
});
|
||||
const triangles = ShapeUtils.triangulateShape(
|
||||
plane.slice(0, outline.length),
|
||||
holePoints,
|
||||
);
|
||||
const triangles = earcut(plane.flat(), holeIndices);
|
||||
if (!triangles.length) return null;
|
||||
return shape(world, triangles.flat());
|
||||
return shape(world, triangles);
|
||||
}
|
||||
|
||||
/**
|
||||
+56
-3
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { polygonShape, rectangleShape } from './navaraGeometry.js';
|
||||
import { polygonShape, rectangleShape } from './shapes.js';
|
||||
|
||||
const vertex = (shape, index) => {
|
||||
const v = shape.attributes.position.values;
|
||||
@@ -53,7 +53,7 @@ test('a pack polygon fills its outline less its holes, each vertex at its own he
|
||||
assert.equal(polygonShape([[square[0], square[1]]]), null);
|
||||
});
|
||||
|
||||
test('a long pack edge is split so no step spans more than a degree', () => {
|
||||
test('a raised polygon keeps its authored vertices as on Cesium', () => {
|
||||
const shape = polygonShape([
|
||||
[
|
||||
[0, 0],
|
||||
@@ -62,7 +62,7 @@ test('a long pack edge is split so no step spans more than a degree', () => {
|
||||
[0, 1],
|
||||
],
|
||||
]);
|
||||
assert.equal(shape.attributes.position.values.length / 3, 8);
|
||||
assert.equal(shape.attributes.position.values.length / 3, 4);
|
||||
});
|
||||
|
||||
test('a pack image rectangle maps its texture from the south-west corner', () => {
|
||||
@@ -79,3 +79,56 @@ test('a pack image rectangle maps its texture from the south-west corner', () =>
|
||||
const wide = rectangleShape([10, 0, 13.5, 1]);
|
||||
assert.equal(wide.indices.length, 4 * 1 * 6, 'cells of at most a degree');
|
||||
});
|
||||
|
||||
test('pack polygon triangle area follows Cesium for concave rings, holes and the antimeridian', async () => {
|
||||
const C = await import('cesium');
|
||||
for (const rings of [
|
||||
[
|
||||
[
|
||||
[20, 10, 200],
|
||||
[21, 10, 300],
|
||||
[20.5, 10.5, 200],
|
||||
[21, 11, 300],
|
||||
[20, 11, 200],
|
||||
],
|
||||
],
|
||||
[
|
||||
[
|
||||
[20, 10, 200],
|
||||
[21, 10, 200],
|
||||
[21, 11, 200],
|
||||
[20, 11, 200],
|
||||
],
|
||||
[
|
||||
[20.2, 10.2, 200],
|
||||
[20.8, 10.2, 200],
|
||||
[20.8, 10.8, 200],
|
||||
[20.2, 10.8, 200],
|
||||
],
|
||||
],
|
||||
[
|
||||
[
|
||||
[179.8, 10, 50],
|
||||
[-179.8, 10, 50],
|
||||
[-179.8, 10.2, 50],
|
||||
[179.8, 10.2, 50],
|
||||
],
|
||||
],
|
||||
]) {
|
||||
const hierarchy = (ring) =>
|
||||
new C.PolygonHierarchy(ring.map((p) => C.Cartesian3.fromDegrees(...p)));
|
||||
const outer = hierarchy(rings[0]);
|
||||
outer.holes = rings.slice(1).map(hierarchy);
|
||||
const native = C.PolygonGeometry.createGeometry(
|
||||
new C.PolygonGeometry({
|
||||
polygonHierarchy: outer,
|
||||
perPositionHeight: true,
|
||||
vertexFormat: C.PerInstanceColorAppearance.VERTEX_FORMAT,
|
||||
arcType: C.ArcType.GEODESIC,
|
||||
}),
|
||||
);
|
||||
const shared = polygonShape(rings);
|
||||
assert.ok(Math.abs(area(shared) / area(native) - 1) < 0.00001);
|
||||
assert.equal(shared.indices.length, native.indices.length);
|
||||
}
|
||||
});
|
||||
@@ -106,7 +106,7 @@ export class SceneDirector {
|
||||
this._isMapStackAvailable = isMapStackAvailable;
|
||||
this._scenePacks = scenePacks;
|
||||
this._bundleAssets = createBundleAssets();
|
||||
this._dataPacks = createSceneDataPacks(viewer, {
|
||||
this._dataPacks = createSceneDataPacks(engine, {
|
||||
...dataPacks,
|
||||
container: engine?.host?.container ?? viewer?.container,
|
||||
sources: {
|
||||
@@ -256,18 +256,18 @@ export class SceneDirector {
|
||||
this._cameraHandoffUnsubscribe?.();
|
||||
this._removeCameraInput?.();
|
||||
this._interactions.setScene(null);
|
||||
this._dataPacks.setScene(null, null);
|
||||
this._dataPacks.setScene(null);
|
||||
this._flight?.suspend();
|
||||
this.engine = this.viewer = null;
|
||||
}
|
||||
|
||||
attachScene(viewer, engine, styleManager, native, isAvailable) {
|
||||
attachScene(viewer, engine, styleManager, isAvailable) {
|
||||
this.viewer = viewer;
|
||||
this.engine = engine;
|
||||
this.styleManager = styleManager;
|
||||
this._isMapStackAvailable = isAvailable;
|
||||
try {
|
||||
this._dataPacks.setScene(viewer, native, engine.host.container);
|
||||
this._dataPacks.setScene(engine, engine.host.container);
|
||||
this._interactions.setScene(engine, this._dataPacks.getTargets());
|
||||
} catch (error) {
|
||||
// Release any packs already drawn before the caller parks the failed globe.
|
||||
|
||||
@@ -2090,7 +2090,6 @@ test('a failed scene-pack rebind releases the partial renderer and a stopped sho
|
||||
{},
|
||||
{ host: { container: {} } },
|
||||
{},
|
||||
null,
|
||||
() => true,
|
||||
),
|
||||
/could not attach polygon/,
|
||||
|
||||
@@ -186,9 +186,6 @@ export function createStandaloneApplication({
|
||||
get mapStackController() {
|
||||
return current?.mapStackController ?? null;
|
||||
},
|
||||
get packRenderer() {
|
||||
return current?.packRenderer ?? null;
|
||||
},
|
||||
get catalog() {
|
||||
return current?.catalog ?? null;
|
||||
},
|
||||
|
||||
@@ -74,7 +74,7 @@ export function createCesiumMoversEngine({ engineOptions = {} } = {}) {
|
||||
const primitive = (id) => {
|
||||
for (const root of items(viewer.scene.primitives))
|
||||
for (const child of items(root)) {
|
||||
if (child.gevMoverSet === id) return child;
|
||||
if (child.gevMoverSet === id || child.gevMesh === id) return child;
|
||||
if (child instanceof Cesium.PrimitiveCollection)
|
||||
for (const inner of items(child))
|
||||
if (inner.gevLine === id) return inner;
|
||||
|
||||
Reference in new issue
Block a user