diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d491375a3..45a35f202 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 diff --git a/docs/GLOBE-PRIMITIVES.md b/docs/GLOBE-PRIMITIVES.md index e1754b8fb..8c8556794 100644 --- a/docs/GLOBE-PRIMITIVES.md +++ b/docs/GLOBE-PRIMITIVES.md @@ -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. diff --git a/package-lock.json b/package-lock.json index 9e57d2c4d..edf903edb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 6da9ad760..43967e0ad 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index 0d6deb59d..6d486135a 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -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', diff --git a/scripts/engine-coupling-baseline.json b/scripts/engine-coupling-baseline.json index fa90ecd97..5a824c8b5 100644 --- a/scripts/engine-coupling-baseline.json +++ b/scripts/engine-coupling-baseline.json @@ -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" } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index fd1b20326..ac39036ee 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 930ba108a..9f4c1fc63 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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"] }, diff --git a/scripts/qa-cctv-v2.mjs b/scripts/qa-cctv-v2.mjs index aa491d4f2..8a2747cbd 100644 --- a/scripts/qa-cctv-v2.mjs +++ b/scripts/qa-cctv-v2.mjs @@ -294,8 +294,8 @@ function installRenderFramesHelper(page) { // lines named `cctv--` and `cctv-gizmo-`, 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]), diff --git a/scripts/qa-director-interactions.mjs b/scripts/qa-director-interactions.mjs index 6c04f4b80..829ff06e8 100644 --- a/scripts/qa-director-interactions.mjs +++ b/scripts/qa-director-interactions.mjs @@ -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, diff --git a/src/app/globeEngine.test.mjs b/src/app/globeEngine.test.mjs index 72e05a2d9..34ef813c9 100644 --- a/src/app/globeEngine.test.mjs +++ b/src/app/globeEngine.test.mjs @@ -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,? \};/, ); }); diff --git a/src/app/layers/renderers.js b/src/app/layers/renderers.js index 52e252f1e..559b18b64 100644 --- a/src/app/layers/renderers.js +++ b/src/app/layers/renderers.js @@ -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']), diff --git a/src/app/layers/renderers.test.mjs b/src/app/layers/renderers.test.mjs index a3a82118f..56a58e4a7 100644 --- a/src/app/layers/renderers.test.mjs +++ b/src/app/layers/renderers.test.mjs @@ -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), diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index cd7020616..169676565 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -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); diff --git a/src/app/scene.js b/src/app/scene.js index 90f83dd73..b6bff4d94 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -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, }; } diff --git a/src/app/tools.js b/src/app/tools.js index 714bdec31..c94f0b16b 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -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), ); }, diff --git a/src/data/cctv.test.mjs b/src/data/cctv.test.mjs index 8814171a4..2de406b0d 100644 --- a/src/data/cctv.test.mjs +++ b/src/data/cctv.test.mjs @@ -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], diff --git a/src/globe/cesium/createCesiumEngine.js b/src/globe/cesium/createCesiumEngine.js index bb50f0f6e..38422aae3 100644 --- a/src/globe/cesium/createCesiumEngine.js +++ b/src/globe/cesium/createCesiumEngine.js @@ -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, diff --git a/src/globe/cesium/createCesiumEngine.test.mjs b/src/globe/cesium/createCesiumEngine.test.mjs index 8660676e2..fefe3ff77 100644 --- a/src/globe/cesium/createCesiumEngine.test.mjs +++ b/src/globe/cesium/createCesiumEngine.test.mjs @@ -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(); diff --git a/src/globe/cesium/lines.js b/src/globe/cesium/lines.js index ef7da8bc0..c3aa049d2 100644 --- a/src/globe/cesium/lines.js +++ b/src/globe/cesium/lines.js @@ -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; diff --git a/src/globe/cesium/meshes.js b/src/globe/cesium/meshes.js new file mode 100644 index 000000000..69f8a8b77 --- /dev/null +++ b/src/globe/cesium/meshes.js @@ -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; + }, + }; +} diff --git a/src/globe/cesium/meshes.test.mjs b/src/globe/cesium/meshes.test.mjs new file mode 100644 index 000000000..b8a0d46a6 --- /dev/null +++ b/src/globe/cesium/meshes.test.mjs @@ -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); +}); diff --git a/src/globe/cesium/surface.js b/src/globe/cesium/surface.js index 4e92c644d..a17ef5132 100644 --- a/src/globe/cesium/surface.js +++ b/src/globe/cesium/surface.js @@ -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. diff --git a/src/globe/contract.js b/src/globe/contract.js index 311343f27..73ee45296 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -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} positions Flat Earth-fixed xyz metres, copied. + * @property {ArrayLike} indices Triangle vertex indices, copied. + * @property {ArrayLike} [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) => 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) { diff --git a/src/globe/contract.test.mjs b/src/globe/contract.test.mjs index 90f454907..aa6de0ddb 100644 --- a/src/globe/contract.test.mjs +++ b/src/globe/contract.test.mjs @@ -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']) diff --git a/src/globe/meshes.js b/src/globe/meshes.js new file mode 100644 index 000000000..f268beae3 --- /dev/null +++ b/src/globe/meshes.js @@ -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(); + }, + }; +} diff --git a/src/globe/meshes.test.mjs b/src/globe/meshes.test.mjs new file mode 100644 index 000000000..83b557b61 --- /dev/null +++ b/src/globe/meshes.test.mjs @@ -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, + ); +}); diff --git a/src/globe/navara/createNavaraEngine.js b/src/globe/navara/createNavaraEngine.js index e77b5ea62..374341718 100644 --- a/src/globe/navara/createNavaraEngine.js +++ b/src/globe/navara/createNavaraEngine.js @@ -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 diff --git a/src/globe/navara/index.js b/src/globe/navara/index.js index e9d6ffeaa..43d4fece0 100644 --- a/src/globe/navara/index.js +++ b/src/globe/navara/index.js @@ -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 }), diff --git a/src/globe/navara/meshes.js b/src/globe/navara/meshes.js new file mode 100644 index 000000000..50e00b364 --- /dev/null +++ b/src/globe/navara/meshes.js @@ -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(); + }, + }; +} diff --git a/src/layers/cctv/render/navaraMeshes.test.mjs b/src/globe/navara/meshes.test.mjs similarity index 55% rename from src/layers/cctv/render/navaraMeshes.test.mjs rename to src/globe/navara/meshes.test.mjs index a0b0f07da..003567677 100644 --- a/src/layers/cctv/render/navaraMeshes.test.mjs +++ b/src/globe/navara/meshes.test.mjs @@ -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(); }); diff --git a/src/layers/shared/render/navaraSceneDepth.js b/src/globe/navara/sceneDepth.js similarity index 100% rename from src/layers/shared/render/navaraSceneDepth.js rename to src/globe/navara/sceneDepth.js diff --git a/src/layers/shared/render/navaraSceneDepth.test.mjs b/src/globe/navara/sceneDepth.test.mjs similarity index 87% rename from src/layers/shared/render/navaraSceneDepth.test.mjs rename to src/globe/navara/sceneDepth.test.mjs index b2375b376..971adb861 100644 --- a/src/layers/shared/render/navaraSceneDepth.test.mjs +++ b/src/globe/navara/sceneDepth.test.mjs @@ -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(); }); diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index a4922eb41..3bbe2ace8 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -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 = []; diff --git a/src/globe/testing/fakeEngine.js b/src/globe/testing/fakeEngine.js index fba136cd8..f5c391512 100644 --- a/src/globe/testing/fakeEngine.js +++ b/src/globe/testing/fakeEngine.js @@ -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 diff --git a/src/layers/cctv/render/cesiumMeshes.js b/src/layers/cctv/render/cesiumMeshes.js deleted file mode 100644 index 6bf7b0282..000000000 --- a/src/layers/cctv/render/cesiumMeshes.js +++ /dev/null @@ -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(); - }, - }; -} diff --git a/src/layers/cctv/render/display.js b/src/layers/cctv/render/display.js index b37ec6030..110f68282 100644 --- a/src/layers/cctv/render/display.js +++ b/src/layers/cctv/render/display.js @@ -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), diff --git a/src/layers/cctv/render/display.test.mjs b/src/layers/cctv/render/display.test.mjs index cea76deb0..b3766c361 100644 --- a/src/layers/cctv/render/display.test.mjs +++ b/src/layers/cctv/render/display.test.mjs @@ -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(), []); }); diff --git a/src/layers/cctv/render/meshes.js b/src/layers/cctv/render/meshes.js index 18bd25403..61053a0ac 100644 --- a/src/layers/cctv/render/meshes.js +++ b/src/layers/cctv/render/meshes.js @@ -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(); }, }; } diff --git a/src/layers/cctv/render/meshes.test.mjs b/src/layers/cctv/render/meshes.test.mjs index 384ca30ca..7e4716d00 100644 --- a/src/layers/cctv/render/meshes.test.mjs +++ b/src/layers/cctv/render/meshes.test.mjs @@ -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(); -}); +} diff --git a/src/layers/cctv/render/navaraMeshes.js b/src/layers/cctv/render/navaraMeshes.js deleted file mode 100644 index c3cf2f29a..000000000 --- a/src/layers/cctv/render/navaraMeshes.js +++ /dev/null @@ -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(); - }, - }; -} diff --git a/src/layers/shared/render/navaraStems.js b/src/layers/shared/render/navaraStems.js deleted file mode 100644 index 8246ddc59..000000000 --- a/src/layers/shared/render/navaraStems.js +++ /dev/null @@ -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(), - }; -} diff --git a/src/noCesiumLabels.test.mjs b/src/noCesiumLabels.test.mjs index 051562d38..8c0d7cee1 100644 --- a/src/noCesiumLabels.test.mjs +++ b/src/noCesiumLabels.test.mjs @@ -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) => diff --git a/src/scenes/dataPacks/controller.js b/src/scenes/dataPacks/controller.js index 052267874..c593a9ee9 100644 --- a/src/scenes/dataPacks/controller.js +++ b/src/scenes/dataPacks/controller.js @@ -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({ diff --git a/src/scenes/dataPacks/display.js b/src/scenes/dataPacks/display.js new file mode 100644 index 000000000..4882fe48f --- /dev/null +++ b/src/scenes/dataPacks/display.js @@ -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(); + }, + }; +} diff --git a/src/scenes/dataPacks/display.test.mjs b/src/scenes/dataPacks/display.test.mjs new file mode 100644 index 000000000..b5e853900 --- /dev/null +++ b/src/scenes/dataPacks/display.test.mjs @@ -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(), []); + }); +} diff --git a/src/scenes/dataPacks/geometry.js b/src/scenes/dataPacks/geometry.js deleted file mode 100644 index 2e8300110..000000000 --- a/src/scenes/dataPacks/geometry.js +++ /dev/null @@ -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, - }), - }), - ); -} diff --git a/src/scenes/dataPacks/navara.js b/src/scenes/dataPacks/navara.js deleted file mode 100644 index 59a13976d..000000000 --- a/src/scenes/dataPacks/navara.js +++ /dev/null @@ -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(); - }, - }; -} diff --git a/src/scenes/dataPacks/presentation.js b/src/scenes/dataPacks/presentation.js index 563ad2121..f9069a625 100644 --- a/src/scenes/dataPacks/presentation.js +++ b/src/scenes/dataPacks/presentation.js @@ -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 })), diff --git a/src/scenes/dataPacks/presentation.test.mjs b/src/scenes/dataPacks/presentation.test.mjs index 094516520..61c04bb77 100644 --- a/src/scenes/dataPacks/presentation.test.mjs +++ b/src/scenes/dataPacks/presentation.test.mjs @@ -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, diff --git a/src/scenes/dataPacks/presentationView.test.mjs b/src/scenes/dataPacks/presentationView.test.mjs index c6c173d89..922041537 100644 --- a/src/scenes/dataPacks/presentationView.test.mjs +++ b/src/scenes/dataPacks/presentationView.test.mjs @@ -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()); diff --git a/src/scenes/dataPacks/navaraGeometry.js b/src/scenes/dataPacks/shapes.js similarity index 73% rename from src/scenes/dataPacks/navaraGeometry.js rename to src/scenes/dataPacks/shapes.js index 10fdbac3d..843e98e02 100644 --- a/src/scenes/dataPacks/navaraGeometry.js +++ b/src/scenes/dataPacks/shapes.js @@ -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); } /** diff --git a/src/scenes/dataPacks/navaraGeometry.test.mjs b/src/scenes/dataPacks/shapes.test.mjs similarity index 62% rename from src/scenes/dataPacks/navaraGeometry.test.mjs rename to src/scenes/dataPacks/shapes.test.mjs index d46e3ddad..19733cf18 100644 --- a/src/scenes/dataPacks/navaraGeometry.test.mjs +++ b/src/scenes/dataPacks/shapes.test.mjs @@ -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); + } +}); diff --git a/src/scenes/director.js b/src/scenes/director.js index 0b696eefb..189b111e9 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -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. diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index 4b4636a4d..c266b9fbc 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -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/, diff --git a/src/standalone/application.js b/src/standalone/application.js index 47f07c624..7065f6aae 100644 --- a/src/standalone/application.js +++ b/src/standalone/application.js @@ -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; }, diff --git a/src/testSupport/cesiumMoversEngine.mjs b/src/testSupport/cesiumMoversEngine.mjs index 0ac5ca4ee..af601d555 100644 --- a/src/testSupport/cesiumMoversEngine.mjs +++ b/src/testSupport/cesiumMoversEngine.mjs @@ -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;