diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index b247a4b84..bad579e72 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -34,6 +34,9 @@ const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/; // cesium only in render/cesium*.js, three and Navara only in // render/navara*.js. The list grows with each layer moved onto renderers. const RENDERED_LAYER_DIRS = [ + 'src/layers/streetLevel/', + 'src/layers/traffic/', + 'src/layers/directions/', 'src/layers/infrastructure/', 'src/layers/installations/', 'src/layers/awareness/', diff --git a/scripts/engine-coupling-baseline.json b/scripts/engine-coupling-baseline.json index 0a9aa18ab..cf9dc25b7 100644 --- a/scripts/engine-coupling-baseline.json +++ b/scripts/engine-coupling-baseline.json @@ -1,16 +1,16 @@ { "metrics": { "cesiumLayerRendererLines": 351, - "navaraLayerRendererLines": 3349, - "cesiumImportFiles": 51, - "viewerLines": 1591, - "navaraKindChecks": 8, + "navaraLayerRendererLines": 2767, + "cesiumImportFiles": 32, + "viewerLines": 1373, + "navaraKindChecks": 7, "engineSniffCalls": 14, - "engineExtensionReads": 18, + "engineExtensionReads": 15, "groundEffectPasses": 4, - "cesiumSdkLoads": 6, - "screenSpaceHandlerFiles": 6, - "engineNameLines": 101 + "cesiumSdkLoads": 4, + "screenSpaceHandlerFiles": 4, + "engineNameLines": 81 }, - "measuredAt": "after the installations, Global Context and infrastructure displays, 2026-11-07" + "measuredAt": "after the Street Level, traffic and directions displays, 2026-11-07" } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 6aa4e6820..1c5814adc 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1640,5 +1640,14 @@ "src/layers/installations/navigation.test.mjs", "src/layers/infrastructure/index.js", "src/layers/infrastructure/session.test.mjs", - "src/data/localGeojson.test.mjs" + "src/data/localGeojson.test.mjs", + "src/layers/directions/render/display.js", + "src/layers/directions/render/display.test.mjs", + "src/layers/directions/render/style.js", + "src/layers/streetLevel/render/display.js", + "src/layers/streetLevel/render/display.test.mjs", + "src/layers/traffic/render/display.js", + "src/layers/traffic/render/display.test.mjs", + "src/layers/directions/edgeCases.test.mjs", + "src/layers/streetLevel/edgeCases.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 7bd8c246e..fe70fad56 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1914,9 +1914,10 @@ "src/i18n/locales/zh-Hant.json", "src/geo/", "src/globe/contract.js", - "src/globe/cesium/engineFromViewer.js" + "src/globe/cesium/engineFromViewer.js", + "src/layers/traffic/render/display.js" ], - "external": ["cesium", "pbf", "@mapbox/vector-tile"] + "external": ["pbf", "@mapbox/vector-tile"] }, "bikeshare-layer": { "exports": ["./layers/bikeshare"], @@ -2085,9 +2086,12 @@ "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/layers/streetLevel/providers/mapillary/policy.js", - "src/geo/" + "src/geo/", + "src/layers/shared/color.js", + "src/globe/cameraMath.js", + "src/layers/streetLevel/render/display.js" ], - "external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"] + "external": ["pbf", "@mapbox/vector-tile", "mapillary-js"] }, "alpr-cameras": { "exports": ["./layers/alpr"], @@ -3463,9 +3467,6 @@ "src/layers/awareness/render/navaraPulses.js", "src/layers/shared/render/navaraStems.js", "src/layers/shared/render/navaraSceneDepth.js", - "src/layers/directions/render/navara.js", - "src/layers/traffic/render/navara.js", - "src/layers/streetLevel/render/navara.js", "src/scenes/dataPacks/navara.js", "src/scenes/dataPacks/navaraGeometry.js", "src/layers/shared/render/navaraStory.js", @@ -3578,7 +3579,11 @@ "src/layers/awareness/render/display.js", "src/layers/installations/render/namedMarkers.js", "src/layers/installations/render/display.js", - "src/layers/infrastructure/render/display.js" + "src/layers/infrastructure/render/display.js", + "src/layers/directions/render/style.js", + "src/layers/directions/render/display.js", + "src/layers/streetLevel/render/display.js", + "src/layers/traffic/render/display.js" ], "external": [ "hls.js", @@ -4315,7 +4320,11 @@ "src/layers/awareness/render/pulseDisc.js", "src/layers/awareness/render/display.js", "src/layers/installations/render/namedMarkers.js", - "src/layers/installations/render/display.js" + "src/layers/installations/render/display.js", + "src/layers/directions/render/style.js", + "src/layers/directions/render/display.js", + "src/layers/streetLevel/render/display.js", + "src/layers/traffic/render/display.js" ], "external": [ "hls.js", @@ -4674,9 +4683,11 @@ "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", - "src/geo/" + "src/geo/", + "src/layers/directions/render/display.js", + "src/layers/directions/render/style.js" ], - "external": ["cesium"] + "external": [] }, "radio-browser-source": { "exports": ["./sources/radio-browser"], diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index efd1d0a47..762c929b3 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -311,7 +311,6 @@ export function createApplicationCatalog({ createApplicationTraffic({ source: sources.traffic, surface, - nativeRenderer: (engine) => rendererFor('traffic', engine), }), ), ], diff --git a/src/app/layers/directions.js b/src/app/layers/directions.js index abcc8cf3d..611d6ad6c 100644 --- a/src/app/layers/directions.js +++ b/src/app/layers/directions.js @@ -2,11 +2,15 @@ import { createApplicationDirectionsLayer } from '../../data/directions.js'; import { rendererFor } from './renderers.js'; -/** Construct Directions with application scene owners and an explicit route source. */ +/** + * Construct Directions with application scene owners and an explicit route + * source; it draws with the engine's renderer from the registry + * (render/display.js on every engine). + */ export function createApplicationDirections({ source, surface }) { return createApplicationDirectionsLayer({ source, groundFloor: surface?.groundFloor, - createNativeRenderer: (engine) => rendererFor('directions', engine), + createRenderer: (engine) => rendererFor('directions', engine), }); } diff --git a/src/app/layers/renderers.js b/src/app/layers/renderers.js index d614d7aaa..d6d9ca86a 100644 --- a/src/app/layers/renderers.js +++ b/src/app/layers/renderers.js @@ -49,6 +49,9 @@ import { createCablesDisplay } from '../../layers/submarineCables/render/display import { createInfrastructureDisplay } from '../../layers/infrastructure/render/display.js'; import { createInstallationsDisplay } from '../../layers/installations/render/display.js'; import { createAwarenessDisplay } from '../../layers/awareness/render/display.js'; +import { createStreetLevelDisplay } from '../../layers/streetLevel/render/display.js'; +import { createTrafficDisplay } from '../../layers/traffic/render/display.js'; +import { createDirectionsDisplay } from '../../layers/directions/render/display.js'; const ENGINE_KINDS = new Set(['cesium', 'navara']); @@ -133,6 +136,9 @@ export const CESIUM_LAYER_RENDERERS = Object.freeze({ cctv: createCctvDisplay, 'military-installations': createInstallationsDisplay, 'military-awareness': createAwarenessDisplay, + 'street-level': createStreetLevelDisplay, + traffic: createTrafficDisplay, + directions: createDirectionsDisplay, }); // The other application layers still draw themselves on Cesium. diff --git a/src/app/layers/renderers.test.mjs b/src/app/layers/renderers.test.mjs index 33aea6d53..6be4379b1 100644 --- a/src/app/layers/renderers.test.mjs +++ b/src/app/layers/renderers.test.mjs @@ -85,6 +85,9 @@ test('the layers with extracted renderers', () => { 'cctv', 'military-installations', 'military-awareness', + 'street-level', + 'traffic', + 'directions', ]); }); @@ -111,6 +114,9 @@ test('the layers drawn through one display get the same factory on every engine' 'local-datacenters': ['infrastructure', 'createInfrastructureDisplay'], 'local-dams': ['infrastructure', 'createInfrastructureDisplay'], 'military-installations': ['installations', 'createInstallationsDisplay'], + 'street-level': ['streetLevel', 'createStreetLevelDisplay'], + traffic: ['traffic', 'createTrafficDisplay'], + directions: ['directions', 'createDirectionsDisplay'], 'telegeography-submarine-cables': [ 'submarineCables', 'createCablesDisplay', diff --git a/src/app/layers/streetLevel.js b/src/app/layers/streetLevel.js index d6b5c273c..e6b9fd71e 100644 --- a/src/app/layers/streetLevel.js +++ b/src/app/layers/streetLevel.js @@ -3,7 +3,7 @@ import { createMapillaryProvider } from '../../layers/streetLevel/providers/mapi import { createGoogleProvider } from '../../layers/streetLevel/providers/google/index.js'; import { createKartaViewProvider } from '../../layers/streetLevel/providers/kartaview/index.js'; import { createPanoramaxProvider } from '../../layers/streetLevel/providers/panoramax/index.js'; -import * as sprites from '../../data/spriteOrder.js'; +import { rendererFor } from './renderers.js'; import * as picking from '../../data/pickRegistry.js'; import * as input from '../../data/inputOwnership.js'; import * as scenePick from '../../data/scenePick.js'; @@ -29,15 +29,22 @@ function defaultProviders(sources) { /** * Inject providers, or use the defaults. An explicit provider list replaces * them; an unused sources.mapillary is neither constructed nor validated, - * including when the list is empty. + * including when the list is empty. The layer draws with the engine's + * renderer from the registry (render/display.js on every engine). */ export function createApplicationStreetLevel({ sources = {}, - nativeRenderer, + nativeRenderer = (engine) => rendererFor('street-level', engine), providers = defaultProviders(sources), }) { return createStreetLevelLayer({ providers, - services: { sprites, picking, input, scenePick, render, nativeRenderer }, + services: { + picking, + input, + scenePick, + render, + createDisplay: nativeRenderer, + }, }); } diff --git a/src/app/layers/traffic.js b/src/app/layers/traffic.js index 9ed2b2feb..deb57d7cc 100644 --- a/src/app/layers/traffic.js +++ b/src/app/layers/traffic.js @@ -2,15 +2,20 @@ import { createTrafficLayer } from '../../layers/traffic/index.js'; import * as credits from '../../data/dataCredits.js'; import * as ground from '../../data/groundFloor.js'; import * as render from '../../renderGovernor.js'; +import { rendererFor } from './renderers.js'; -/** Construct one layer using the application scene owners and a supplied source. */ -export function createApplicationTraffic({ source, surface, nativeRenderer }) { +/** + * Construct one layer using the application scene owners and a supplied + * source; it draws with the engine's renderer from the registry + * (render/display.js on every engine). + */ +export function createApplicationTraffic({ source, surface }) { return createTrafficLayer({ source, services: { credits, render, - nativeRenderer, + createRenderer: (engine) => rendererFor('traffic', engine), ground: surface?.groundFloor || ground, }, }); diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 5c3ca508c..1c7379b9a 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -153,23 +153,15 @@ export async function registerNavaraLayerRenderers() { return (engine, { data }) => drawStorySource(engine, engine.extensions.navara, data); }, - 'street-level': async () => { - const { createStreetMap } = - await import('../layers/streetLevel/render/navara.js'); - return (engine) => createStreetMap(engine, engine.extensions.navara); - }, - traffic: async () => { - const { createTrafficNavaraRenderer } = - await import('../layers/traffic/render/navara.js'); - return (engine) => - createTrafficNavaraRenderer(engine, engine.extensions.navara); - }, - directions: async () => { - const { createDirectionsNavaraRenderer } = - await import('../layers/directions/render/navara.js'); - return (engine) => - createDirectionsNavaraRenderer(engine, engine.extensions.navara); - }, + 'street-level': async () => + (await import('../layers/streetLevel/render/display.js')) + .createStreetLevelDisplay, + traffic: async () => + (await import('../layers/traffic/render/display.js')) + .createTrafficDisplay, + directions: async () => + (await import('../layers/directions/render/display.js')) + .createDirectionsDisplay, 'telegeography-submarine-cables': async () => (await import('../layers/submarineCables/render/display.js')) .createCablesDisplay, diff --git a/src/data/directions.js b/src/data/directions.js index 2bda66af1..805aee483 100644 --- a/src/data/directions.js +++ b/src/data/directions.js @@ -3,21 +3,19 @@ import { createDirectionsSource } from '../layers/directions/source.js'; * @module directions * @description Service bundle for the Directions layer. * - * The layer itself lives in `src/layers/directions/`, which imports nothing - * but Cesium and the pure step formatter. This module is the one place that - * names the application's shared services — the render governor, sprite order, - * the pick registry, the world overlay, the annotation material, the camera - * verbs, the shared ground floor and the pointer arbiter — and it re-exports - * the layer's pure helpers for the tests and the share-link code. + * The layer itself lives in `src/layers/directions/` and draws through the + * globe contract (its render/display.js). This module is the one place that + * names the application's shared services — the render governor, the pick + * registry, the ground pick, the world overlay, the camera verbs, the shared + * ground floor and the pointer arbiter — and it re-exports the layer's pure + * helpers for the tests and the share-link code. */ import * as credits from './dataCredits.js'; import * as render from '../renderGovernor.js'; -import * as sprites from './spriteOrder.js'; import * as picking from './pickRegistry.js'; import * as scenePick from './scenePick.js'; import * as overlays from '../overlays/worldOverlay.js'; -import * as annotations from '../annotations/worldAnnotationRenderer.js'; import * as camera from '../cameraVerbs.js'; import * as ground from './groundFloor.js'; import * as input from './inputOwnership.js'; @@ -27,11 +25,9 @@ import { createDirectionsLayer } from '../layers/directions/index.js'; const directionsServices = Object.freeze({ credits, render, - sprites, picking, scenePick, overlays, - annotations, camera, ground, input, @@ -40,13 +36,13 @@ const directionsServices = Object.freeze({ /** Construct one Directions layer over the application scene owners. */ export function createApplicationDirectionsLayer({ source = createDirectionsSource(), - createNativeRenderer, + createRenderer, groundFloor = ground, } = {}) { return createDirectionsLayer({ services: { ...directionsServices, ground: groundFloor }, source, - createNativeRenderer, + createRenderer, }); } diff --git a/src/data/sharedOsmDisplays.test.mjs b/src/data/sharedOsmDisplays.test.mjs index cce2b2891..60bf0e2f2 100644 --- a/src/data/sharedOsmDisplays.test.mjs +++ b/src/data/sharedOsmDisplays.test.mjs @@ -5,7 +5,6 @@ import { showOsmCredit, hideOsmCredit } from './dataCredits.js'; import { createInfrastructureLayers } from './infrastructure.js'; import { createBhoteKoshiLocatorLayer } from './bhoteKoshiLocator.js'; import { createWorldAnnotationRenderer } from '../annotations/worldAnnotationRenderer.js'; -import * as annotations from '../annotations/worldAnnotationRenderer.js'; import { createDirectionsLayer } from '../layers/directions/index.js'; import { createDirectionsSource } from '../layers/directions/source.js'; import { createLifecycle as createCctvLifecycle } from '../layers/cctv/lifecycle.js'; @@ -69,22 +68,23 @@ test('voice route geometry introduces the shared OSM credit', (t) => { }); test('Directions introduces the shared OSM credit when the requested route draws', async (t) => { - const v = viewer(t); t.mock.method(globalThis, 'fetch', async () => Response.json({ ok: true, geometry: [[0, 0], [0.01, 0.01]], steps: [] })); + const engine = createFakeEngine(); const layer = createDirectionsLayer({ source: createDirectionsSource(), services: { - credits: { showOsmCredit, hideOsmCredit }, annotations, input, scenePick: {}, camera: {}, + credits: { showOsmCredit, hideOsmCredit }, input, scenePick: {}, camera: {}, overlays: { setOverlayEntries: noop, setOverlaySourceVisible: noop, clearOverlaySource: noop }, - render: { governorRequestRender: noop, holdContinuousRender: noop, releaseContinuousRender: noop }, - sprites: { registerSpriteCollection: noop, unregisterSpriteCollection: noop, restoreSpriteOrder: noop }, + render: { governorRequestRender: noop }, picking: { registerPickOwner: noop, unregisterPickOwner: noop }, ground: { warmGroundFloor: noop }, } }); - layer.init(v); layer.enable(v); + layer.init(null, { engine }); layer.enable(); layer.placeEndpoint('a', { lat: 0, lon: 0 }); layer.placeEndpoint('b', { lat: 0.01, lon: 0.01 }); for (let i = 0; i < 30; i++) await Promise.resolve(); assert.equal(layer.getStats().error, null); - assertCredit(v); await layer.destroy(v); - assert.equal(v.credits.filter(c => c.showOnScreen).length, 0); + assert.ok(engine.fake.credits.some((credit) => credit.onScreen && credit.html.includes('>© OpenStreetMap<'))); + layer.destroy(); + assert.equal(engine.fake.credits.filter((credit) => credit.onScreen).length, 0); + engine.destroy(); }); test('Warendorf webcam display introduces the shared OSM credit', (t) => { diff --git a/src/data/trafficTiming.test.mjs b/src/data/trafficTiming.test.mjs index 808fb1300..93fb6b253 100644 --- a/src/data/trafficTiming.test.mjs +++ b/src/data/trafficTiming.test.mjs @@ -141,7 +141,6 @@ test('traffic timing pairs real ordering to the scheduling change and guards re- let server; const cacheDir = mkdtempSync(path.join(tmpdir(), 'gev-traffic-timing-vite-')); let trafficLayer; - let viewer; const pendingDebounce = () => { const matches = [...timeouts].filter(([, timer]) => timer.delay === 320); @@ -206,15 +205,18 @@ test('traffic timing pairs real ordering to the scheduling change and guards re- const preRender = eventChannel(); const postRender = eventChannel(); + const preUpdate = eventChannel(); const moveEnd = eventChannel(); const changed = eventChannel(); let longitude = -97.74; + const cartographic = { latitude: Math.PI / 6, longitude: 0, height: 5000 }; const camera = { moveEnd, changed, percentageChanged: 0.5, - positionCartographic: { latitude: Math.PI / 6, longitude: 0, height: 5000 }, - computeViewRectangle() { + livePosition: { x: 0, y: 0, z: 0 }, + pose: () => ({ cartographic: { ...cartographic }, heading: 0, pitch: -Math.PI / 2 }), + viewRectangle() { const west = longitude * Math.PI / 180; return { south: 30 * Math.PI / 180, @@ -223,31 +225,63 @@ test('traffic timing pairs real ordering to the scheduling change and guards re- east: west + 0.02 * Math.PI / 180, }; }, + pickEllipsoid: () => undefined, }; const setLongitude = (next) => { longitude = next; - camera.positionCartographic.longitude = next * Math.PI / 180; + cartographic.longitude = next * Math.PI / 180; }; setLongitude(longitude); - viewer = { - camera, - scene: { + const noSet = () => ({ + set() {}, + setCount() {}, + commit() {}, + replace() {}, + show() {}, + destroy() {}, + }); + // An engine on a zero-size canvas: no pick ray (no reticle footprint), + // so the fetch is bounded by the view rectangle. + const engine = { + kind: 'cesium', + host: { canvas: { clientWidth: 0, clientHeight: 0, width: 0, height: 0 }, - preRender, - postRender, - primitives: { add: (primitive) => primitive, remove: () => true }, + size: () => ({ width: 0, height: 0 }), }, + camera, + frames: { preUpdate, preRender, postRender }, + projector: { + worldToWindow: () => undefined, + viewProjection: (result = new Array(16).fill(0)) => result, + horizonVisible: () => true, + }, + picker: { pickPositionSupported: false }, + surface: { + supported: false, + terrainHeight: () => 0, + sampleHeight: () => undefined, + tilesLoaded: () => true, + }, + maps: { + photorealActive: () => false, + current: () => ({ baseImagery: null, photoreal: null }), + }, + render: { request() {} }, + credits: { addStatic: () => () => {} }, + movers: { createSet: noSet }, + ground: { createSet: noSet }, + destroy() {}, }; performance.clearMarks(); performance.clearMeasures(); - trafficLayer.init(viewer); + trafficLayer.init(null, { engine }); performance.mark('traffic:stale:mark'); performance.measure('traffic:stale:measure', { start: 'traffic:stale:mark', end: 'traffic:stale:mark', }); - trafficLayer.enable(viewer); + trafficLayer.enable(); assert.equal(traffic.getTrafficTimingDiagnostics().marksInstalled, 1); assert.equal(moveEnd.listenerCount(), 2, 'one timing listener plus the production arrival check'); assert.equal(performance.getEntriesByName('traffic:stale:mark').length, 0); @@ -325,7 +359,7 @@ test('traffic timing pairs real ordering to the scheduling change and guards re- entry.detail?.interactionId !== anchorB.interactionId )), 'the canceled/stale B load must never emit a correlated trace'); } finally { - trafficLayer?.disable(viewer); + trafficLayer?.disable(); await server?.close(); globalThis.window = originalWindow; globalThis.document = originalDocument; diff --git a/src/layers/directions/edgeCases.test.mjs b/src/layers/directions/edgeCases.test.mjs index 2205e59a5..04c6a8a1f 100644 --- a/src/layers/directions/edgeCases.test.mjs +++ b/src/layers/directions/edgeCases.test.mjs @@ -1,9 +1,15 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; +import { + GROUND_HARNESSES, + createFakeGroundHarness, +} from '../../testSupport/groundDisplayEngines.mjs'; import { createApplicationDirectionsLayer } from '../../data/directions.js'; import { getActiveCameraMotion } from '../../cameraVerbs.js'; -import { createDirectionsNavaraRenderer } from './render/navara.js'; +import { + createDirectionsDisplay, + DIRECTIONS_SET_IDS, +} from './render/display.js'; const A = { lat: 60.1699, lon: 24.9384 }; const B = { lat: 60.2055, lon: 24.6559 }; @@ -33,44 +39,7 @@ const payload = (from, to) => ({ ], }); -function installDocument() { - const saved = globalThis.document; - const context = () => - new Proxy( - {}, - { get: (target, key) => (key in target ? target[key] : () => {}) }, - ); - globalThis.document = { - createElement: () => ({ getContext: context }), - addEventListener() {}, - removeEventListener() {}, - }; - return () => { - if (saved === undefined) delete globalThis.document; - else globalThis.document = saved; - }; -} - -function nativeView() { - const live = new Set(); - return { - live, - view: { - addSource(options) { - const source = { - data: options.data, - delete: () => live.delete(source), - }; - live.add(source); - return source; - }, - addLayer: () => ({ on() {}, forceUpdate() {}, delete() {} }), - }, - }; -} - -function fixture() { - const restoreDocument = installDocument(); +function fixture(makeHarness = createFakeGroundHarness) { let clock = 0; const realNow = performance.now; performance.now = () => clock; @@ -79,7 +48,7 @@ function fixture() { getRoute: (request, { signal }) => new Promise((resolve) => requests.push({ request, signal, resolve })), }; - const natives = new Map(); + const harnesses = new Map(); const layer = createApplicationDirectionsLayer({ source, groundFloor: { @@ -87,38 +56,39 @@ function fixture() { cachedGroundFloor: () => 0, warmGroundFloor() {}, }, - createNativeRenderer(engine) { - const native = nativeView(); - natives.set(engine, native); - return createDirectionsNavaraRenderer(engine, native); - }, + createRenderer: createDirectionsDisplay, }); layer._setOverlayHostForTest({ setEntries() {}, setVisible() {}, clearSource() {}, }); - const makeEngine = () => - createFakeEngine({ kind: 'navara', canvas: new EventTarget() }); + const makeEngine = (make = makeHarness) => { + const harness = make(); + // The stub Cesium scene has no credit display; the OSM credit goes there. + if (harness.viewer) + harness.viewer.creditDisplay ??= { + addStaticCredit() {}, + removeStaticCredit() {}, + }; + harnesses.set(harness.engine, harness); + return harness.engine; + }; const engine = makeEngine(); layer.attachShellServices({}); layer.init(null, { engine }); - layer.enable(null); + layer.enable(); return { layer, engine, makeEngine, requests, - natives, - // The route each engine draws: its live sources' first and last point. + // The route each engine draws: the first and last point of its route + // set, once per live set. drawn(target) { - return [...natives.get(target).live].map(({ data }) => { - const features = data.features; - return [ - features[0].geometry.coordinates[0], - features.at(-1).geometry.coordinates.at(-1), - ]; - }); + const set = harnesses.get(target).set(DIRECTIONS_SET_IDS.route); + if (!set?.items.length) return []; + return [[set.items[0].positions[0], set.items.at(-1).positions.at(-1)]]; }, frames(target, count) { for (let i = 0; i < count; i++) { @@ -127,56 +97,57 @@ function fixture() { } }, cleanup() { - layer.destroy(null); + layer.destroy(); performance.now = realNow; - restoreDocument(); + for (const harness of harnesses.values()) harness.destroy(); }, }; } -test('edge:directions: a late answer for the route before A/B swapped never replaces the swapped route', async () => { - for (const lateFirst of [false, true]) { - const f = fixture(); - try { - f.layer.placeEndpoint('a', A); - f.layer.placeEndpoint('b', B); - f.layer.setParams({ swap: true }); - assert.equal(f.requests.length, 2); - const [before, after] = f.requests; - assert.equal( - before.signal.aborted, - true, - 'the pre-swap request is aborted', - ); - assert.deepEqual(after.request.a, B); - assert.deepEqual(after.request.b, A); - // A source that ignores the abort still answers, early or late. - if (lateFirst) { - before.resolve(payload(A, B)); - await settle(); +for (const makeHarness of GROUND_HARNESSES) + test(`edge:directions: a late answer for the route before A/B swapped never replaces the swapped route (${makeHarness().name})`, async () => { + for (const lateFirst of [false, true]) { + const f = fixture(makeHarness); + try { + f.layer.placeEndpoint('a', A); + f.layer.placeEndpoint('b', B); + f.layer.setParams({ swap: true }); + assert.equal(f.requests.length, 2); + const [before, after] = f.requests; assert.equal( - f.drawn(f.engine).length, - 0, - 'the stale route is not drawn', + before.signal.aborted, + true, + 'the pre-swap request is aborted', ); - } - after.resolve(payload(B, A)); - await settle(); - if (!lateFirst) { - before.resolve(payload(A, B)); + assert.deepEqual(after.request.a, B); + assert.deepEqual(after.request.b, A); + // A source that ignores the abort still answers, early or late. + if (lateFirst) { + before.resolve(payload(A, B)); + await settle(); + assert.equal( + f.drawn(f.engine).length, + 0, + 'the stale route is not drawn', + ); + } + after.resolve(payload(B, A)); await settle(); + if (!lateFirst) { + before.resolve(payload(A, B)); + await settle(); + } + assert.deepEqual(f.drawn(f.engine), [ + [ + [B.lon, B.lat], + [A.lon, A.lat], + ], + ]); + } finally { + f.cleanup(); } - assert.deepEqual(f.drawn(f.engine), [ - [ - [B.lon, B.lat], - [A.lon, A.lat], - ], - ]); - } finally { - f.cleanup(); } - } -}); + }); test('edge:directions: an engine switch mid-FLY continues the same flight on the new engine and the old camera stays still', async () => { const f = fixture(); diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index 666d3fc77..e6e0223c0 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -6,8 +6,9 @@ * again). With both ends placed the layer asks the existing `/api/route` * proxy (OSRM on the FOSSGIS servers, © OpenStreetMap contributors) for the * street-following route with turn-by-turn steps, drapes it on the terrain - * and 3D tiles with the same flowing dashes the voice "route from A to B" - * annotation uses, and drops one dot per maneuver. Click a dot for the + * and 3D tiles in the dashes of the voice "route from A to B" annotation, + * and drops one dot per maneuver (render/display.js draws them on the globe + * contract). Click a dot for the * instruction; "FLY" rides the camera along the route through the shared * route-flight cinematic (`flyRoute`), the way voice's `fly_route` does. * @@ -16,9 +17,9 @@ * route. */ -import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js'; -import { WHITE, rgbaFromCss } from '../shared/color.js'; +import { createDirectionsDisplay } from './render/display.js'; +import { DIRECTIONS_ROUTE_COLOR } from './render/style.js'; import { roadLabel } from '../../data/routeSteps.js'; import { t, @@ -68,16 +69,8 @@ export function presentRoutingFailure(error) { return error?.message || msg('Routing unavailable'); } -/** Route colour — the annotation palette's cyan, so voice routes and Directions match. */ -export const DIRECTIONS_ROUTE_COLOR = '#39d0ff'; -// Engine-neutral colours (src/layers/shared/color.js): a Navara page has no -// Cesium SDK. -const MARKER_A_COLOR = () => rgbaFromCss('#5dff9f'); -const MARKER_B_COLOR = () => rgbaFromCss('#ff6b6b'); -const STEP_COLOR = () => WHITE.withAlpha(0.95); -const STEP_OUTLINE = () => rgbaFromCss(DIRECTIONS_ROUTE_COLOR); -const STEP_PIXEL_SIZE = 8; -const STEP_SELECTED_PIXEL_SIZE = 13; +/** Route colour: the annotation palette's cyan, so voice routes and Directions match. */ +export { DIRECTIONS_ROUTE_COLOR }; /** Route request timeout (ms) — the proxy itself gives OSRM 12 s. */ const ROUTE_TIMEOUT_MS = 15_000; @@ -770,7 +763,7 @@ export function directionsStepCopy(steps, index) { /** * Shared-host card for the selected maneuver. * @param {number} index - * @param {Cesium.Cartesian3} position + * @param {{x:number, y:number, z:number}} position src/geo Cartesian3. * @param {{title:string, details:string[]}} copy * @returns {object|null} */ @@ -859,14 +852,17 @@ export function normalizeRoutePayload(payload, mode) { * Each catalog builds its own, so two applications on a page never share the * route, the markers, the pointer claim or the flight this layer owns. The * shared services are injected, which is what keeps this module's own imports - * down to Cesium and the pure step formatter. - * @param {{services: object, source: {getRoute: Function}}} options + * down to the globe contract display and the pure step formatter. + * @param {{services: object, source: {getRoute: Function}, + * createRenderer?: (engine: object) => object|null}} options + * `createRenderer` builds the display on an engine (render/display.js by + * default). * @returns {object} The layer module the data manager registers. */ export function createDirectionsLayer({ services, source, - createNativeRenderer, + createRenderer = createDirectionsDisplay, }) { if (typeof source?.getRoute !== 'function') throw new TypeError('Directions require a route source'); @@ -883,9 +879,9 @@ export function createDirectionsLayer({ let _overlayHost = DEFAULT_OVERLAY_HOST; // --- Module state --- - let _viewer = null; let _engine = null; - let _native = null; + /** The display on the bound engine (render/display.js), or null. */ + let _display = null; let _suspendedFlight = null; let _enabled = false; let _mode = DEFAULT_DIRECTIONS_MODE; @@ -903,13 +899,11 @@ export function createDirectionsLayer({ let _routeSeq = 0; let _routeAbort = null; let _lastUpdate = null; - let _markerA = null; - let _markerB = null; - let _routeEntity = null; - let _stepPoints = null; + /** Maneuver dot index → its anchored position, or null while unresolved. */ + const _stepAnchors = new Map(); let _selectedStep = null; - let _clickHandler = null; - let _renderHeld = false; + let _listening = false; + let _removeClick = null; let _rowControlsListener = null; let _dataManager = null; /** Set when arming failed because another tool holds the pointer. */ @@ -966,21 +960,10 @@ export function createDirectionsLayer({ _dataManager?.refreshLayerStats?.(); } - function syncRenderHold() { - const shouldHold = _enabled && Boolean(_routeEntity); - if (shouldHold && !_renderHeld) { - services.render.holdContinuousRender('directions'); - _renderHeld = true; - } else if (!shouldHold && _renderHeld) { - services.render.releaseContinuousRender('directions'); - _renderHeld = false; - } - } - function pickGround(screenPosition) { - const cartesian = _native - ? _engine.picker.pickGlobe(screenPosition) - : services.scenePick.pickGroundPosition(_viewer, screenPosition); + const cartesian = _engine + ? services.scenePick.pickGroundPosition(_engine, screenPosition) + : null; if (!cartesian) return null; const carto = geo.cartographic.fromCartesian(cartesian); if (!carto) return null; @@ -990,61 +973,27 @@ export function createDirectionsLayer({ }; } - function markerEntity(letter, point, color) { - if (_native) return _native.marker(letter, point, color.toCssColorString()); - return _viewer.entities.add({ - id: `directions:marker:${letter}`, - position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat, 0), - point: { - pixelSize: 14, - color, - outlineColor: Cesium.Color.BLACK, - outlineWidth: 2, - heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, - label: { - text: letter, - font: 'bold 13px "JetBrains Mono", "SF Mono", monospace', - fillColor: Cesium.Color.WHITE, - outlineColor: Cesium.Color.BLACK, - outlineWidth: 3, - style: Cesium.LabelStyle.FILL_AND_OUTLINE, - pixelOffset: new Cesium.Cartesian2(0, -20), - heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, - }); - } - - function removeEntity(entity) { - if (_native) { - entity?.destroy(); - return; - } - if (entity && _viewer && !_viewer.isDestroyed?.()) - _viewer.entities.remove(entity); - } - function placeMarker(letter, point) { - if (!_viewer && !_native) return; - if (letter === 'a') { - removeEntity(_markerA); - _markerA = markerEntity('A', point, MARKER_A_COLOR()); - } else { - removeEntity(_markerB); - _markerB = markerEntity('B', point, MARKER_B_COLOR()); - } + _display?.setMarker(letter, point); + } + + /** The maneuver dots as the display draws them: anchored or hidden. */ + function pushSteps() { + _display?.setSteps( + stepMarkerIndices(_route?.steps).map((index) => ({ + index, + position: _stepAnchors.get(index) ?? null, + })), + ); } function clearRouteGraphics() { - services.credits?.hideOsmCredit?.(_viewer ?? _engine, 'directions'); + services.credits?.hideOsmCredit?.(_engine, 'directions'); _clearStepSelection(); stopStepAnchoring(); - removeEntity(_routeEntity); - _routeEntity = null; - _stepPoints?.removeAll(); - syncRenderHold(); + _display?.setRoute(null); + _stepAnchors.clear(); + _display?.setSteps([]); } function stopStepAnchoring() { @@ -1062,32 +1011,28 @@ export function createDirectionsLayer({ */ function anchorStepPoints() { _anchorTimer = null; - if (!_stepPoints || !_route || !_enabled) return; + if (!_display || !_route || !_enabled) return; const steps = _route.steps; const pending = []; let unresolved = 0; for (const index of stepMarkerIndices(steps)) { const step = steps[index]; - const point = findStepPoint(index); - if (!point) continue; const height = stepMarkerHeightM( services.ground.cachedGroundFloor(step.lat, step.lon), services.ground.GROUND_FLOOR_LIFT_M, ); if (height === null) { unresolved += 1; - point.show = false; + _stepAnchors.set(index, null); pending.push({ lat: step.lat, lon: step.lon }); continue; } - point.position = Cesium.Cartesian3.fromDegrees( - step.lon, - step.lat, - height, + _stepAnchors.set( + index, + geo.cartesian3.fromDegrees(step.lon, step.lat, height), ); - point.show = true; } - _native?.steps.sync(); + pushSteps(); // A card already open on a step that has just been anchored moves with it. if (_selectedStep !== null) refreshStepCard(_selectedStep); services.render.governorRequestRender('directions-anchor'); @@ -1108,56 +1053,22 @@ export function createDirectionsLayer({ function drawRoute(route) { clearRouteGraphics(); - if (!_viewer && !_native) return; - if (_native) - _routeEntity = _native.route(route.geometry, DIRECTIONS_ROUTE_COLOR); - else { - services.annotations.ensureFlowFabricRegistered(); - const positions = Cesium.Cartesian3.fromDegreesArray( - route.geometry.flat(), - ); - _routeEntity = _viewer.entities.add({ - id: 'directions:route', - polyline: { - positions, - width: 9, - material: new services.annotations.FlowMaterialProperty( - DIRECTIONS_ROUTE_COLOR, - ), - clampToGround: true, - // BOTH: drape on 3D tiles when they are up and on terrain when they are - // not, so the keyless globe shows the route too. - classificationType: Cesium.ClassificationType.BOTH, - }, - }); - } + if (!_display) return; + _display.setRoute(route.geometry); // One dot per decision; A and B already mark departure and arrival. The dots // start hidden and appear as anchorStepPoints resolves each ground cell. const cells = []; for (const index of stepMarkerIndices(route.steps)) { const step = route.steps[index]; cells.push({ lat: step.lat, lon: step.lon }); - _stepPoints.add({ - id: `directions:step:${index}`, - position: Cesium.Cartesian3.fromDegrees(step.lon, step.lat, 0), - color: STEP_COLOR(), - pixelSize: STEP_PIXEL_SIZE, - outlineColor: STEP_OUTLINE(), - outlineWidth: 2, - show: false, - // The shared ground floor is a coarse cell, and on the photoreal stack - // the rendered mesh can stand above it. Keeping the dot always visible - // is the same choice every other anchored sprite in the app makes. - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }); + _stepAnchors.set(index, null); } - services.credits?.showOsmCredit?.(_viewer ?? _engine, 'directions'); + pushSteps(); + services.credits?.showOsmCredit?.(_engine, 'directions'); services.ground.warmGroundFloor(cells); _anchorAttempts = 0; _anchorStartedAt = Date.now(); anchorStepPoints(); - syncRenderHold(); - if (_viewer) services.sprites.restoreSpriteOrder(_viewer); services.render.governorRequestRender('directions-route'); } @@ -1265,8 +1176,8 @@ export function createDirectionsLayer({ * Place one endpoint at a picked ground point and reroute when both are set. * * The globe click handler is the only caller in the running app; it is - * exported because a ScreenSpaceEventHandler needs a real canvas, and the - * ownership timing this function decides is exactly what has to be tested. + * exported because the ownership timing this function decides is exactly + * what has to be tested without a click. * @param {'a'|'b'} which Which endpoint to place. * @param {{lat: number, lon: number}} point Ground point in degrees. * @returns {boolean} Whether the endpoint was placed. @@ -1343,10 +1254,8 @@ export function createDirectionsLayer({ _route = null; _status = 'idle'; _error = null; - removeEntity(_markerA); - removeEntity(_markerB); - _markerA = null; - _markerB = null; + placeMarker('a', null); + placeMarker('b', null); clearRouteGraphics(); services.render.governorRequestRender('directions-clear'); } @@ -1419,7 +1328,7 @@ export function createDirectionsLayer({ typeof seams?.floorFn === 'function' ? seams.floorFn : null, // One camera owner: the UI shell's immediate-navigation facade stamps the // navigation generation and releases whatever held the camera before the - // flight starts. Without the shell (tests, a bare viewer) the flight still + // flight starts. Without the shell (tests, a bare engine) the flight still // runs, it just has nothing to take the camera from. typeof seams?.runNavigation === 'function' ? seams.runNavigation : null, typeof seams?.warmFn === 'function' ? seams.warmFn : null, @@ -1438,25 +1347,11 @@ export function createDirectionsLayer({ // --- Step selection --- function _clearStepSelection() { - if (_selectedStep !== null && _stepPoints) { - const point = findStepPoint(_selectedStep); - if (point) point.pixelSize = STEP_PIXEL_SIZE; - _native?.steps.sync(); - } + if (_selectedStep !== null) _display?.selectStep(null); _selectedStep = null; _overlayHost.clearSource(DIRECTIONS_STEP_OVERLAY_SOURCE_ID); } - function findStepPoint(index) { - if (!_stepPoints) return null; - const id = `directions:step:${index}`; - for (let i = 0; i < _stepPoints.length; i += 1) { - const point = _stepPoints.get(i); - if (point.id === id) return point; - } - return null; - } - /** * Where a step's card should hang, or null while that is not yet known. * @@ -1466,13 +1361,13 @@ export function createDirectionsLayer({ * the same shared floor, and a step whose cell is still cold has no anchor at * all rather than one at sea level. * @param {number} index Step index. - * @returns {Cesium.Cartesian3|null} + * @returns {{x:number, y:number, z:number}|null} src/geo Cartesian3. */ function stepAnchorPosition(index) { const step = _route?.steps?.[index]; if (!step) return null; - const point = findStepPoint(index); - if (point?.show) return Cesium.Cartesian3.clone(point.position); + const drawn = _display?.stepPosition(index); + if (drawn) return drawn; const height = stepMarkerHeightM( services.ground.cachedGroundFloor(step.lat, step.lon), services.ground.GROUND_FLOOR_LIFT_M, @@ -1481,7 +1376,7 @@ export function createDirectionsLayer({ services.ground.warmGroundFloor([{ lat: step.lat, lon: step.lon }]); return null; } - return Cesium.Cartesian3.fromDegrees(step.lon, step.lat, height); + return geo.cartesian3.fromDegrees(step.lon, step.lat, height); } function refreshStepCard(index) { @@ -1518,21 +1413,16 @@ export function createDirectionsLayer({ return false; } _selectedStep = index; - const point = findStepPoint(index); - if (point) point.pixelSize = STEP_SELECTED_PIXEL_SIZE; - _native?.steps.sync(); + _display?.selectStep(index); services.render.governorRequestRender('directions-select'); return true; } function stepIndexFromPick(picked) { - const candidates = [picked?.primitive?.id, picked?.id]; - for (const candidate of candidates) { - if (typeof candidate !== 'string') continue; - const match = /^directions:step:(\d+)$/.exec(candidate); - if (match) return Number(match[1]); - } - return null; + const match = + typeof picked?.id === 'string' && + /^directions:step:(\d+)$/.exec(picked.id); + return match ? Number(match[1]) : null; } function _onKeyDown(event) { @@ -1545,58 +1435,40 @@ export function createDirectionsLayer({ } } - function _installClickHandler(viewer) { - if (_clickHandler) return; - const handle = (click) => { - if (!_enabled) return; - // Placement acts only while this layer actually HOLDS the pointer, and the - // maneuver-dot selection below is an ambient handler like any other: it - // yields the moment a tool (this one included) owns the click. - // The lease, not the owner name: a superseded instance of this layer must - // not act on a click its replacement owns. - if (_armed && services.input.isLeaseCurrent(_pointerClaim)) { - const point = pickGround(click.position); - if (!point) return; - placeDirectionsEndpoint(_armed, point); - return; - } - if (!services.input.isPointerFree()) return; - let picked = null; - try { - picked = _native ? click.picked : viewer.scene.pick(click.position); - } catch { - picked = null; - } - const index = stepIndexFromPick(picked); - if (index !== null) { - _selectStep(index); - return; - } - if (_selectedStep !== null) _clearStepSelection(); - }; - if (_native) { - const off = _engine.input.on( - 'click', - (event) => handle({ position: event.position, picked: event.picked }), - { pick: true }, - ); - _clickHandler = { destroy: off }; - } else { - _clickHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); - _clickHandler.setInputAction( - handle, - Cesium.ScreenSpaceEventType.LEFT_CLICK, - ); + function _onClick(click) { + if (!_enabled) return; + // Placement acts only while this layer actually HOLDS the pointer, and the + // maneuver-dot selection below is an ambient handler like any other: it + // yields the moment a tool (this one included) owns the click. + // The lease, not the owner name: a superseded instance of this layer must + // not act on a click its replacement owns. + if (_armed && services.input.isLeaseCurrent(_pointerClaim)) { + const point = pickGround(click.position); + if (!point) return; + placeDirectionsEndpoint(_armed, point); + return; } + if (!services.input.isPointerFree()) return; + const index = stepIndexFromPick(click.picked); + if (index !== null) { + _selectStep(index); + return; + } + if (_selectedStep !== null) _clearStepSelection(); + } + + function _installClickHandler() { + if (_listening) return; + _listening = true; + _removeClick = _display?.onClick(_onClick) ?? null; if (typeof document !== 'undefined') document.addEventListener('keydown', _onKeyDown); } function _removeClickHandler() { - if (_clickHandler) { - _clickHandler.destroy(); - _clickHandler = null; - } + _listening = false; + _removeClick?.(); + _removeClick = null; // Teardown can outlive the document (page unload, and every headless test). if (typeof document !== 'undefined') document.removeEventListener('keydown', _onKeyDown); @@ -1614,31 +1486,21 @@ export function createDirectionsLayer({ updateInterval: 0, /** - * Create the maneuver-dot collection. Called once at bootstrap. - * @param {Cesium.Viewer} viewer + * Bind the engine the display draws on. Called once at bootstrap. + * @param {unknown} _scene Unused: the layer draws through `engine`. + * @param {{engine?: object|null}} [options] */ - init(viewer, options) { - return this.attachRenderer(viewer, options); + init(_scene, options) { + return this.attachRenderer(_scene, options); }, - attachRenderer(viewer, { engine = null } = {}) { - _viewer = viewer; + attachRenderer(_scene, { engine = null } = {}) { _engine = engine; - if (engine?.kind === 'navara') { - _native = createNativeRenderer(engine); - _stepPoints = _native.steps; - } else { - _stepPoints = new Cesium.PointPrimitiveCollection({ - blendOption: Cesium.BlendOption.TRANSLUCENT, - }); - viewer.scene.primitives.add(_stepPoints); - services.sprites.registerSpriteCollection('directions', _stepPoints); - services.sprites.restoreSpriteOrder(viewer); - } - _stepPoints.show = _enabled; + _display = engine ? createRenderer(engine) : null; + _display?.show(_enabled); _overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, _enabled); if (!_enabled) return; const step = _selectedStep; - this.enable(viewer); + this.enable(); if (_a) placeMarker('a', _a); if (_b) placeMarker('b', _b); if (_route) drawRoute(_route); @@ -1654,52 +1516,35 @@ export function createDirectionsLayer({ const selected = _selectedStep; clearRouteGraphics(); _selectedStep = selected; - removeEntity(_markerA); - removeEntity(_markerB); - _markerA = _markerB = null; _removeClickHandler(); services.picking.unregisterPickOwner('directions'); - if (_native) _native.destroy(); - else if (_stepPoints && _viewer) { - services.sprites.unregisterSpriteCollection('directions', _stepPoints); - _viewer.scene.primitives.remove(_stepPoints); - } + _display?.destroy(); _overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, false); - _stepPoints = _viewer = _engine = _native = null; + _display = _engine = null; }, - /** - * Show the row chips and listen for placement clicks. - * @param {Cesium.Viewer} viewer - */ - enable(viewer) { + /** Show the row chips and listen for placement clicks. */ + enable() { _enabled = true; _pointerBlocked = false; - if (_stepPoints) _stepPoints.show = true; + _display?.show(true); _overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, true); - _installClickHandler(viewer); + _installClickHandler(); services.picking.registerPickOwner( 'directions', (pickedId) => typeof pickedId === 'string' && pickedId.startsWith('directions:'), ); - syncRenderHold(); - if (viewer) services.sprites.restoreSpriteOrder(viewer); }, - /** - * Remove the route, markers and listeners. - * @param {Cesium.Viewer} viewer - */ - disable(viewer) { + /** Remove the route, markers and listeners. */ + disable() { _enabled = false; clearAll(); _overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, false); _removeClickHandler(); services.picking.unregisterPickOwner('directions'); - if (_stepPoints) _stepPoints.show = false; - syncRenderHold(); - void viewer; + _display?.show(false); }, /** Nothing to poll: routes are requested on placement and mode change. */ @@ -1797,12 +1642,9 @@ export function createDirectionsLayer({ } }, - /** - * Tear down entirely. - * @param {Cesium.Viewer} viewer - */ - destroy(viewer) { - if (_enabled) this.disable(viewer); + /** Tear down entirely. */ + destroy() { + if (_enabled) this.disable(); // disable() has already run these, but destroy() must also be safe on a // layer that was never enabled: a claim or a flight left behind would // outlive the whole application. @@ -1814,14 +1656,13 @@ export function createDirectionsLayer({ _rowControlsListener = null; this.detachRenderer(); _overlayHost.clearSource(DIRECTIONS_STEP_OVERLAY_SOURCE_ID); - _viewer = null; }, /** * Place one endpoint at a picked ground point. The globe click handler is - * the only caller in the running app; it is on the instance because a - * ScreenSpaceEventHandler needs a real canvas, and the ownership timing - * this decides is exactly what has to be tested. + * the only caller in the running app; it is on the instance because the + * ownership timing this decides is exactly what has to be tested without + * a click. */ placeEndpoint: placeDirectionsEndpoint, diff --git a/src/layers/directions/render/display.js b/src/layers/directions/render/display.js new file mode 100644 index 000000000..9bdf300d1 --- /dev/null +++ b/src/layers/directions/render/display.js @@ -0,0 +1,344 @@ +// Directions on the globe contract, the same on every engine: +// - the route: one engine.ground corridor set (9 px, draped on terrain and +// 3D tiles), its dash cells lit and dim as the route flow material draws +// them at rest; +// - the maneuver dots: one point mover set over the scene, white with a +// route-colour outline, picked as `directions:step:`; a dot without +// a ground anchor yet is hidden; +// - the A and B markers: a point mover set for the discs and a sprite set +// for their letters, over the scene, at the rendered surface under each +// marker (sampled again whenever the camera comes to rest); +// - clicks through engine.input with the engine's pick. +// Placement, routing, anchoring, selection and the route flight stay in the +// layer. + +import * as geo from '../../../geo/index.js'; +import { + MARKER_COLORS, + MARKER_LETTER_FONT, + MARKER_LETTER_OFFSET_PX, + MARKER_LETTER_OUTLINE_PX, + MARKER_LETTER_SPRITE_PX, + MARKER_OUTLINE_COLOR, + MARKER_OUTLINE_PX, + MARKER_PIXEL_SIZE, + ORDER, + ROUTE_DASH_CELLS, + ROUTE_DASH_COLOR, + ROUTE_DASH_DUTY, + ROUTE_GAP_COLOR, + ROUTE_WIDTH_PX, + STEP_COLOR, + STEP_OUTLINE_COLOR, + STEP_OUTLINE_PX, + STEP_PIXEL_SIZE, + STEP_SELECTED_PIXEL_SIZE, +} from './style.js'; + +const ID = 'directions'; +export const DIRECTIONS_SET_IDS = Object.freeze({ + route: `${ID}-route`, + steps: `${ID}-steps`, + markers: `${ID}-markers`, + letters: `${ID}-marker-letters`, +}); +const LETTERS = ['a', 'b']; +const FIRST_STEP_CAPACITY = 16; + +/** The pickId of maneuver dot `index`. */ +export const stepPickId = (index) => `${ID}:step:${index}`; + +const wrapLon = (lon) => ((lon + 540) % 360) - 180; + +/** + * The route cut into its dash cells, lit and dim in turn: each of + * ROUTE_DASH_CELLS equal lengths along the route is lit for its first + * ROUTE_DASH_DUTY and dim for the rest. Every original vertex is kept. + * @param {number[][]} path [lon, lat] pairs. + * @returns {{ positions: number[][], lit: boolean }[]} + */ +export function routeDashPieces(path) { + const vertices = path.map(([lon, lat]) => + geo.cartesian3.fromDegrees(lon, lat), + ); + const arc = [0]; + for (let i = 1; i < vertices.length; i++) + arc.push( + arc[i - 1] + geo.cartesian3.distance(vertices[i - 1], vertices[i]), + ); + const total = arc.at(-1); + if (!(total > 0)) return []; + // The point at arc length `at` on segment i - 1 .. i. + const pointAt = (i, at) => { + const f = Math.max( + 0, + Math.min(1, (at - arc[i - 1]) / (arc[i] - arc[i - 1] || 1)), + ); + const [lon, lat] = path[i - 1]; + const [nextLon, nextLat] = path[i]; + const delta = wrapLon(nextLon - lon); + return [wrapLon(lon + f * delta), lat + f * (nextLat - lat)]; + }; + const cuts = []; + for (let cell = 0; cell < ROUTE_DASH_CELLS; cell++) { + cuts.push({ at: (total * (cell + ROUTE_DASH_DUTY)) / ROUTE_DASH_CELLS }); + cuts.push({ at: (total * (cell + 1)) / ROUTE_DASH_CELLS }); + } + const pieces = []; + let start = path[0]; + let i = 1; + cuts.forEach(({ at }, index) => { + const last = index === cuts.length - 1; + const positions = [start]; + while (i < path.length - 1 && arc[i] < at) positions.push(path[i++]); + start = last ? path.at(-1) : pointAt(i, at); + positions.push(start); + pieces.push({ positions, lit: index % 2 === 0 }); + }); + return pieces; +} + +/** The letters' atlas: one cell per marker, the glyph above its centre. */ +function letterAtlas() { + const document = globalThis.document; + const canvas = document?.createElement?.('canvas'); + const ctx = canvas?.getContext?.('2d'); + if (!ctx) return null; + const size = MARKER_LETTER_SPRITE_PX; + canvas.width = size * LETTERS.length; + canvas.height = size; + ctx.font = MARKER_LETTER_FONT; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.lineJoin = 'round'; + ctx.lineWidth = MARKER_LETTER_OUTLINE_PX * 2; + ctx.strokeStyle = 'black'; + ctx.fillStyle = 'white'; + const cells = {}; + LETTERS.forEach((letter, index) => { + const x = size * index + size / 2; + const y = size / 2 - MARKER_LETTER_OFFSET_PX; + ctx.strokeText(letter.toUpperCase(), x, y); + ctx.fillText(letter.toUpperCase(), x, y); + cells[letter] = [ + index / LETTERS.length, + 0, + (index + 1) / LETTERS.length, + 1, + ]; + }); + return { canvas, cells }; +} + +/** + * @param {import('../../../globe/contract.js').GlobeEngine} engine + */ +export function createDirectionsDisplay(engine) { + if (!engine?.ground || !engine?.movers || !engine?.input) + throw new TypeError('The Directions display needs a globe engine'); + const markers = engine.movers.createSet({ + id: DIRECTIONS_SET_IDS.markers, + kind: 'point', + order: ORDER.markers, + capacity: LETTERS.length, + depthTest: 'always', + pickable: false, + point: { + sizePx: MARKER_PIXEL_SIZE, + outlinePx: MARKER_OUTLINE_PX, + outlineColor: MARKER_OUTLINE_COLOR, + }, + }); + const atlas = letterAtlas(); + const letters = atlas + ? engine.movers.createSet({ + id: DIRECTIONS_SET_IDS.letters, + kind: 'sprite', + order: ORDER.letters, + capacity: LETTERS.length, + depthTest: 'always', + pickable: false, + sprite: { sizePx: MARKER_LETTER_SPRITE_PX, atlas }, + }) + : null; + let steps = null; + let stepCapacity = 0; + let route = null; + let visible = true; + let destroyed = false; + /** Marker letter → { lon, lat } or null. */ + const ends = { a: null, b: null }; + /** Dot rows: { index, position | null }. */ + let stepRows = []; + let selected = null; + + const own = () => [markers, letters, steps].filter(Boolean); + + // The rendered surface under a marker (tiles, else terrain), without the + // display's own markers. + function groundPosition({ lon, lat }) { + const carto = geo.cartographic.fromDegrees(lon, lat); + let height; + try { + height = engine.surface.sampleHeightDetailed(carto, { + exclude: own(), + })?.height; + } catch { + height = undefined; + } + return geo.cartesian3.fromDegrees( + lon, + lat, + Number.isFinite(height) ? height : 0, + ); + } + + function placeMarkers() { + if (destroyed) return; + LETTERS.forEach((letter, index) => { + const point = ends[letter]; + const show = visible && Boolean(point); + const position = point ? groundPosition(point) : undefined; + markers.set(index, { + ...(position ? { position } : {}), + color: MARKER_COLORS[letter], + show, + }); + letters?.set(index, { + ...(position ? { position } : {}), + cell: letter, + show, + }); + }); + markers.setCount(LETTERS.length); + markers.commit(); + if (letters) { + letters.setCount(LETTERS.length); + letters.commit(); + } + } + + function writeSteps() { + if (stepRows.length > stepCapacity) { + steps?.destroy(); + stepCapacity = Math.max(FIRST_STEP_CAPACITY, stepRows.length); + steps = engine.movers.createSet({ + id: DIRECTIONS_SET_IDS.steps, + kind: 'point', + order: ORDER.steps, + capacity: stepCapacity, + // The shared ground floor is a coarse cell, and on the photoreal + // stack the rendered mesh can stand above it: the dots draw over + // the scene, as every other anchored sprite in the app does. + depthTest: 'always', + pickable: true, + point: { + sizePx: STEP_PIXEL_SIZE, + outlinePx: STEP_OUTLINE_PX, + outlineColor: STEP_OUTLINE_COLOR, + }, + }); + } + if (!steps) return; + stepRows.forEach((row, i) => + steps.set(i, { + ...(row.position ? { position: row.position } : {}), + color: STEP_COLOR, + scale: + row.index === selected + ? STEP_SELECTED_PIXEL_SIZE / STEP_PIXEL_SIZE + : 1, + show: visible && Boolean(row.position), + pickId: stepPickId(row.index), + }), + ); + steps.setCount(stepRows.length); + steps.commit(); + } + + const removeMoveEnd = engine.camera.moveEnd.addEventListener(() => { + if (ends.a || ends.b) placeMarkers(); + }); + placeMarkers(); + + return { + /** Places marker `letter` ('a' or 'b') at { lon, lat }, or removes it (null). */ + setMarker(letter, point) { + if (destroyed || !LETTERS.includes(letter)) return; + ends[letter] = point ? { lon: point.lon, lat: point.lat } : null; + placeMarkers(); + }, + /** Draws the route ([lon, lat] pairs), or removes it (null). */ + setRoute(geometry) { + if (destroyed) return; + route?.destroy(); + route = null; + const pieces = geometry ? routeDashPieces(geometry) : []; + if (!pieces.length) return; + route = engine.ground.createSet({ + id: DIRECTIONS_SET_IDS.route, + kind: 'corridor', + drape: 'both', + pickable: false, + }); + route.replace( + pieces.map(({ positions, lit }) => ({ + positions, + color: lit ? ROUTE_DASH_COLOR : ROUTE_GAP_COLOR, + widthPx: ROUTE_WIDTH_PX, + })), + ); + route.commit(); + route.show(visible); + }, + /** + * The maneuver dots, in order: { index, position } with position a + * src/geo Cartesian3, or null while the dot has no anchor (hidden). + */ + setSteps(rows) { + if (destroyed) return; + stepRows = rows.map(({ index, position }) => ({ + index, + position: position ? geo.cartesian3.clone(position) : null, + })); + if (!stepRows.some((row) => row.index === selected)) selected = null; + writeSteps(); + }, + /** Draws dot `index` at its selected size (null: none). */ + selectStep(index) { + if (destroyed) return; + selected = Number.isInteger(index) ? index : null; + writeSteps(); + }, + /** Where dot `index` is drawn, or null while it is hidden or absent. */ + stepPosition(index) { + const row = stepRows.find((entry) => entry.index === index); + return row?.position && visible + ? geo.cartesian3.clone(row.position) + : null; + }, + /** Shows or hides everything the display draws. */ + show(next) { + if (destroyed) return; + visible = Boolean(next); + route?.show(visible); + placeMarkers(); + writeSteps(); + }, + /** Listens for clicks with the engine's pick; returns the remover. */ + onClick(listener) { + return engine.input.on('click', listener, { pick: true }); + }, + destroy() { + if (destroyed) return; + destroyed = true; + removeMoveEnd(); + route?.destroy(); + steps?.destroy(); + markers.destroy(); + letters?.destroy(); + route = steps = null; + stepRows = []; + }, + }; +} diff --git a/src/layers/directions/render/display.test.mjs b/src/layers/directions/render/display.test.mjs new file mode 100644 index 000000000..d7e3ed265 --- /dev/null +++ b/src/layers/directions/render/display.test.mjs @@ -0,0 +1,389 @@ +// The Directions display on the engine contract, on the fake engine and on +// the Cesium engine (real Cesium point and billboard collections and the +// engine's ground batches over fake primitives): the route's dash cells, +// width, colours and drape; the maneuver dots' style, anchoring, selection +// and picks; the A and B markers at the surface with their letters; show and +// hide through the layer; and what teardown leaves. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import * as geo from '../../../geo/index.js'; +import { GROUND_HARNESSES } from '../../../testSupport/groundDisplayEngines.mjs'; +import { createApplicationDirectionsLayer } from '../../../data/directions.js'; +import { + DIRECTIONS_SET_IDS as IDS, + createDirectionsDisplay, + routeDashPieces, + stepPickId, +} from './display.js'; +import { + DIRECTIONS_ROUTE_COLOR, + ROUTE_DASH_CELLS, + ROUTE_DASH_DUTY, +} from './style.js'; +import { rgbaFromCss } from '../../shared/color.js'; + +const near = (actual, expected, label, tolerance = 1e-6) => + assert.ok( + Math.abs(actual - expected) < tolerance, + `${label}: ${actual} vs ${expected}`, + ); +const nearAll = (actual, expected, label, tolerance) => + expected.forEach((value, index) => + near(actual[index], value, `${label}[${index}]`, tolerance), + ); +const ROUTE = rgbaFromCss(DIRECTIONS_ROUTE_COLOR); +const ROUTE_RGB = [ROUTE.red, ROUTE.green, ROUTE.blue]; +const A = { lat: 60.1699, lon: 24.9384 }; +const B = { lat: 60.2055, lon: 24.6559 }; +const MID = { lat: (A.lat + B.lat) / 2, lon: (A.lon + B.lon) / 2 }; +const payload = { + ok: true, + distanceM: 21479, + durationS: 1478, + geometry: [ + [A.lon, A.lat], + [MID.lon, MID.lat], + [B.lon, B.lat], + ], + steps: [ + { instruction: 'Head out', distanceM: 8, durationS: 1, ...A }, + { instruction: 'Turn right', distanceM: 236, durationS: 39, ...MID }, + { instruction: 'Arrive', distanceM: 0, durationS: 0, ...B }, + ], +}; +const settle = async () => { + for (let i = 0; i < 5; i++) + await new Promise((resolve) => setImmediate(resolve)); +}; + +/** A document whose canvases draw nothing (for the letters' atlas). */ +function installDocument(t) { + const saved = globalThis.document; + const context = new Proxy({}, { get: () => () => {}, set: () => true }); + globalThis.document = { + createElement: () => ({ getContext: () => context }), + addEventListener() {}, + removeEventListener() {}, + }; + t.after(() => { + if (saved === undefined) delete globalThis.document; + else globalThis.document = saved; + }); +} + +// What the display gave each mover set: its options and every instance's +// fields, merged as set() merges them, up to setCount. +function recordMovers(engine) { + const sets = new Map(); + const createSet = engine.movers.createSet.bind(engine.movers); + engine.movers.createSet = (options) => { + const set = createSet(options); + const record = { options, rows: [], count: 0, live: true }; + sets.set(options.id, record); + const { set: write, setCount, destroy } = set; + set.set = (index, fields) => { + write(index, fields); + record.rows[index] = { ...record.rows[index], ...fields }; + }; + set.setCount = (count) => { + setCount(count); + record.count = count; + }; + set.destroy = () => { + destroy(); + record.live = false; + }; + return set; + }; + return (id) => { + const record = sets.get(id); + return record?.live ? record : undefined; + }; +} + +// The Cesium point drawing instance `index` of mover set `id`. +function cesiumPoint(viewer, id, index) { + const items = (collection) => + Array.from({ length: collection?.length ?? 0 }, (_, i) => + collection.get(i), + ); + for (const root of items(viewer.scene.primitives)) + for (const child of items(root)) + if (child.gevMoverSet === id) return child.get(index); + return undefined; +} + +function fixture(t, makeHarness, { document = true } = {}) { + if (document) installDocument(t); + const h = makeHarness(); + if (h.viewer) + h.viewer.creditDisplay ??= { + addStaticCredit() {}, + removeStaticCredit() {}, + }; + const movers = recordMovers(h.engine); + let floor = 0; + const requests = []; + const layer = createApplicationDirectionsLayer({ + source: { + getRoute: () => new Promise((resolve) => requests.push(resolve)), + }, + groundFloor: { + GROUND_FLOOR_LIFT_M: 2, + cachedGroundFloor: () => floor, + warmGroundFloor() {}, + }, + createRenderer: createDirectionsDisplay, + }); + const cards = []; + layer._setOverlayHostForTest({ + setEntries: (id, entries) => cards.push(...entries), + setVisible() {}, + clearSource() {}, + }); + layer.init(null, { engine: h.engine }); + layer.enable(); + t.after(() => { + layer.destroy(); + h.destroy(); + }); + return { + ...h, + layer, + movers, + cards, + setFloor(value) { + floor = value; + }, + async route() { + layer.placeEndpoint('a', A); + layer.placeEndpoint('b', B); + requests.at(-1)(payload); + await settle(); + }, + }; +} + +test('the display needs a globe engine', () => { + assert.throws(() => createDirectionsDisplay(null), TypeError); +}); + +test('the route is cut into lit and dim dash cells that keep every turn', () => { + const path = payload.geometry; + const pieces = routeDashPieces(path); + assert.equal(pieces.length, ROUTE_DASH_CELLS * 2); + pieces.forEach((piece, index) => + assert.equal(piece.lit, index % 2 === 0, `piece ${index}`), + ); + assert.deepEqual(pieces[0].positions[0], path[0]); + assert.deepEqual(pieces.at(-1).positions.at(-1), path.at(-1)); + // Pieces join end to end, and the middle vertex is kept. + for (let i = 1; i < pieces.length; i++) + assert.deepEqual(pieces[i].positions[0], pieces[i - 1].positions.at(-1)); + assert.ok( + pieces.some((piece) => + piece.positions.some(([lon, lat]) => lon === MID.lon && lat === MID.lat), + ), + ); + // A lit cell is the duty fraction of its cell's length. + const length = (positions) => + positions + .slice(1) + .reduce( + (sum, [lon, lat], i) => + sum + + geo.cartesian3.distance( + geo.cartesian3.fromDegrees(...positions[i]), + geo.cartesian3.fromDegrees(lon, lat), + ), + 0, + ); + const lit = length(pieces[2].positions); + const dim = length(pieces[3].positions); + near(lit / (lit + dim), ROUTE_DASH_DUTY, 'duty', 1e-3); + assert.deepEqual(routeDashPieces([[0, 0]]), []); +}); + +for (const makeHarness of GROUND_HARNESSES) { + const { name } = makeHarness(); + + test(`the route is one 9 px corridor set draped on terrain and tiles, its cells at 0.95 and 0.171 (${name})`, async (t) => { + const f = fixture(t, makeHarness); + await f.route(); + const set = f.set(IDS.route); + assert.equal(set.options.kind, 'corridor'); + assert.equal(set.options.drape, 'both'); + assert.equal(set.options.pickable, false); + assert.equal(set.shown, true); + assert.equal(set.items.length, ROUTE_DASH_CELLS * 2); + set.items.forEach((item, index) => { + assert.equal(item.widthPx, 9); + nearAll( + item.color, + [...ROUTE_RGB, index % 2 === 0 ? 0.95 : 0.95 * 0.18], + `colour ${index}`, + ); + }); + assert.deepEqual(set.items[0].positions[0], payload.geometry[0]); + assert.deepEqual( + set.items.at(-1).positions.at(-1), + payload.geometry.at(-1), + ); + if (f.cesium) { + const { fills, lines } = f.primitives(IDS.route); + assert.equal(fills.length, 0); + assert.equal(lines.length, 1, 'one ground polyline batch'); + assert.equal( + lines[0].options.classificationType, + Cesium.ClassificationType.BOTH, + ); + } + }); + + test(`maneuver dots are 8 px white over the scene with a 2 px route outline, hidden until anchored (${name})`, async (t) => { + const f = fixture(t, makeHarness); + f.setFloor(null); + await f.route(); + const steps = f.movers(IDS.steps); + assert.equal(steps.options.kind, 'point'); + assert.equal(steps.options.depthTest, 'always'); + assert.equal(steps.options.pickable, true); + assert.equal(steps.options.order, 0); + assert.equal(steps.options.point.sizePx, 8); + assert.equal(steps.options.point.outlinePx, 2); + nearAll(steps.options.point.outlineColor, [...ROUTE_RGB, 1], 'outline'); + // Departure and arrival have no dot; the turn's cell is still cold. + assert.equal(steps.count, 1); + assert.equal(steps.rows[0].show, false); + assert.equal(steps.rows[0].pickId, stepPickId(1)); + // Once the ground floor answers, the dot stands on it. + f.setFloor(30); + await new Promise((resolve) => setTimeout(resolve, 450)); + const anchored = f.movers(IDS.steps).rows[0]; + assert.equal(anchored.show, true); + nearAll(anchored.color, [1, 1, 1, 0.95], 'dot colour'); + const at = geo.cartographic.fromCartesian(anchored.position); + near(at.height, 32, 'floor + lift', 1e-3); + near(geo.math.toDegrees(at.longitude), MID.lon, 'dot lon'); + if (f.cesium) { + const point = cesiumPoint(f.viewer, IDS.steps, 0); + assert.equal(point.pixelSize, 8); + assert.equal(point.outlineWidth, 2); + assert.equal(point.disableDepthTestDistance, Number.POSITIVE_INFINITY); + assert.equal(point.show, true); + } + }); + + test(`a click on a dot selects it at 13 px and opens its card; a click elsewhere clears it (${name})`, async (t) => { + const f = fixture(t, makeHarness); + await f.route(); + f.click({ id: stepPickId(1) }); + await settle(); + assert.equal(f.layer.getRowControls().list.items[1].active, true); + near(f.movers(IDS.steps).rows[0].scale, 13 / 8, 'selected scale'); + assert.equal(f.cards.at(-1).id, 'directions-step-1'); + if (f.cesium) + assert.equal(cesiumPoint(f.viewer, IDS.steps, 0).pixelSize, 13); + f.click(undefined); + await settle(); + assert.equal(f.layer.getRowControls().list.items[1].active, false); + assert.equal(f.movers(IDS.steps).rows[0].scale, 1); + }); + + test(`A and B are 14 px discs with a black outline and their letters, at the surface (${name})`, async (t) => { + const f = fixture(t, makeHarness); + if (!f.cesium) f.engine.fake.script('sampleHeight', () => 120); + f.layer.placeEndpoint('a', A); + const markers = f.movers(IDS.markers); + assert.equal(markers.options.kind, 'point'); + assert.equal(markers.options.depthTest, 'always'); + assert.equal(markers.options.order, 1); + assert.equal(markers.options.point.sizePx, 14); + assert.equal(markers.options.point.outlinePx, 2); + assert.deepEqual(markers.options.point.outlineColor, [0, 0, 0, 1]); + const a = rgbaFromCss('#5dff9f'); + nearAll(markers.rows[0].color, [a.red, a.green, a.blue, 1], 'A colour'); + assert.equal(markers.rows[0].show, true); + assert.equal(markers.rows[1].show, false, 'B is not placed yet'); + if (!f.cesium) + near( + geo.cartographic.fromCartesian(markers.rows[0].position).height, + 120, + 'A on the sampled surface', + 1e-3, + ); + const letters = f.movers(IDS.letters); + assert.equal(letters.options.kind, 'sprite'); + assert.equal(letters.options.order, 2); + assert.equal(letters.options.depthTest, 'always'); + assert.equal(letters.rows[0].cell, 'a'); + assert.equal(letters.rows[0].show, true); + assert.deepEqual(letters.rows[0].position, markers.rows[0].position); + f.layer.placeEndpoint('b', B); + const b = rgbaFromCss('#ff6b6b'); + nearAll( + f.movers(IDS.markers).rows[1].color, + [b.red, b.green, b.blue, 1], + 'B colour', + ); + assert.equal(f.movers(IDS.letters).rows[1].cell, 'b'); + // CLEAR takes both away. + f.layer.setParams({ clear: true }); + assert.equal(f.movers(IDS.markers).rows[0].show, false); + assert.equal(f.movers(IDS.markers).rows[1].show, false); + }); + + test(`disabling hides and clears everything; destroying leaves no sets and no listeners (${name})`, async (t) => { + const f = fixture(t, makeHarness); + await f.route(); + assert.equal(f.listeners(), 1, 'one click listener'); + f.layer.disable(); + assert.equal(f.set(IDS.route), undefined, 'the route is gone'); + assert.equal(f.movers(IDS.steps).count, 0); + assert.equal(f.listeners(), 0); + f.layer.enable(); + assert.equal(f.listeners(), 1); + f.layer.detachRenderer(); + assert.equal(f.engine.movers.list().length, 0); + assert.equal(f.sets(), 0); + assert.equal(f.listeners(), 0); + }); + + test(`without a document the markers draw without letters (${name})`, (t) => { + const saved = globalThis.document; + delete globalThis.document; + t.after(() => { + if (saved !== undefined) globalThis.document = saved; + }); + const f = fixture(t, makeHarness, { document: false }); + f.layer.placeEndpoint('a', A); + assert.equal(f.movers(IDS.letters), undefined); + assert.equal(f.movers(IDS.markers).rows[0].show, true); + }); +} + +test('an engine switch redraws the route, dots and markers on the new engine only', async (t) => { + const [makeFake, makeCesium] = GROUND_HARNESSES; + const f = fixture(t, makeFake); + await f.route(); + const next = makeCesium(); + next.viewer.creditDisplay ??= { + addStaticCredit() {}, + removeStaticCredit() {}, + }; + const movers = recordMovers(next.engine); + t.after(() => next.destroy()); + f.layer.suspendCamera(); + f.layer.detachRenderer(); + f.layer.attachRenderer(null, { engine: next.engine }); + f.layer.resumeCamera(); + assert.equal(f.engine.movers.list().length, 0); + assert.equal(f.sets(), 0); + assert.equal(next.set(IDS.route).items.length, ROUTE_DASH_CELLS * 2); + assert.equal(movers(IDS.steps).count, 1); + assert.equal(movers(IDS.markers).rows[0].show, true); + assert.equal(movers(IDS.markers).rows[1].show, true); + f.layer.detachRenderer(); +}); diff --git a/src/layers/directions/render/navara.js b/src/layers/directions/render/navara.js deleted file mode 100644 index d6e0780ea..000000000 --- a/src/layers/directions/render/navara.js +++ /dev/null @@ -1,209 +0,0 @@ -import * as geo from '../../../geo/index.js'; - -function endpointAtlas(letter, css) { - const canvas = document.createElement('canvas'); - canvas.width = canvas.height = 64; - const ctx = canvas.getContext('2d'); - ctx.beginPath(); - ctx.arc(32, 32, 10, 0, 2 * Math.PI); - ctx.fillStyle = css; - ctx.fill(); - ctx.lineWidth = 2; - ctx.strokeStyle = 'black'; - ctx.stroke(); - ctx.font = 'bold 17px monospace'; - ctx.textAlign = 'center'; - ctx.lineWidth = 3; - ctx.strokeText(letter, 32, 17); - ctx.fillStyle = 'white'; - ctx.fillText(letter, 32, 17); - return { canvas, cells: { pin: [0, 0, 1, 1] } }; -} - -// Bake the route into bounded pieces once. Each keeps every original turn. -// Features share one clamped vector layer. Only their opacity changes per tick. -function flowFeatures(path) { - const vertices = path.map(([lon, lat]) => - geo.cartesian3.fromDegrees(lon, lat), - ); - const arc = [0]; - for (let i = 1; i < vertices.length; i++) - arc.push( - arc[i - 1] + geo.cartesian3.distance(vertices[i - 1], vertices[i]), - ); - const total = arc.at(-1), - features = []; - if (!total) return features; - const n = 64 * 6; - let start = path[0], - i = 1; - for (let j = 1; j <= n; j++) { - const endAt = (total * j) / n, - points = [start]; - while (i < path.length - 1 && arc[i] < endAt) points.push(path[i++]); - const f = Math.max( - 0, - Math.min(1, (endAt - arc[i - 1]) / (arc[i] - arc[i - 1] || 1)), - ); - const [lon, lat] = path[i - 1], - [nextLon, nextLat] = path[i]; - const delta = ((nextLon - lon + 540) % 360) - 180; - start = - j === n - ? path.at(-1) - : [((lon + f * delta + 540) % 360) - 180, lat + f * (nextLat - lat)]; - points.push(start); - features.push({ - type: 'Feature', - geometry: { type: 'LineString', coordinates: points }, - properties: { phase: (j - 0.5) / 6 }, - }); - } - return features; -} -export function createDirectionsNavaraRenderer(engine, { view }) { - const assets = new Map(); - let steps = [], - bank, - capacity = 0; - const syncSteps = () => { - if (steps.length > capacity) { - bank?.destroy(); - capacity = steps.length; - bank = engine.movers.createSet({ - id: 'directions-steps', - kind: 'point', - capacity, - depthTest: 'always', - pickable: true, - point: { sizePx: 8, outlinePx: 2, outlineColor: [0.22, 0.82, 1, 1] }, - }); - } - if (!bank) return; - steps.forEach((p, i) => - bank.set(i, { - position: p.position, - show: p.show, - scale: p.pixelSize / 8, - color: [1, 1, 1, 0.95], - pickId: p.id, - }), - ); - bank.setCount(steps.length); - bank.commit(); - engine.render.request('directions-steps'); - }; - return { - steps: { - get length() { - return steps.length; - }, - get: (i) => steps[i], - add(p) { - steps.push(p); - return p; - }, - removeAll() { - steps = []; - syncSteps(); - }, - sync: syncSteps, - }, - marker(letter, point, css) { - const bank = engine.movers.createSet({ - id: 'directions-marker-' + letter, - kind: 'sprite', - capacity: 1, - depthTest: 'always', - pickable: false, - sprite: { sizePx: 48, atlas: endpointAtlas(letter, css) }, - }); - const carto = geo.cartographic.fromDegrees(point.lon, point.lat); - const place = () => { - const h = engine.surface.terrainHeight(carto) ?? 0; - bank.set(0, { - position: geo.cartesian3.fromDegrees(point.lon, point.lat, h), - cell: 'pin', - color: [1, 1, 1, 1], - show: true, - }); - bank.setCount(1); - bank.commit(); - }; - let remove; - try { - place(); - remove = engine.camera.moveEnd.addEventListener(place); - } catch (e) { - bank.destroy(); - throw e; - } - const handle = { - destroy() { - remove(); - bank.destroy(); - assets.delete(handle); - }, - }; - assets.set(handle, true); - return handle; - }, - route(path, css) { - const features = flowFeatures(path); - const source = view.addSource({ - type: 'geojson', - data: { type: 'FeatureCollection', features }, - }); - let layer, remove; - try { - layer = view.addLayer({ - type: 'vector', - source, - polyline: { - color: parseInt(css.slice(1), 16), - width: 9, - lit: false, - transparent: true, - clampToGround: true, - }, - }); - layer.on('featureUpdated', ({ evaluator }) => { - const t = (performance.now() / 1000) * 0.55; - evaluator.evaluate(({ properties }) => { - const x = (properties.phase - t) % 1; - return { opacity: (x + 1) % 1 < 0.46 ? 0.95 : 0.18 }; - }); - }); - let frame = -1; - remove = engine.frames.preRender.addEventListener(() => { - const n = Math.floor(performance.now() / 60); - if (n !== frame) { - frame = n; - layer.forceUpdate(); - } - }); - } catch (e) { - layer?.delete(); - source.delete(); - throw e; - } - const handle = { - destroy() { - remove?.(); - layer.delete(); - source.delete(); - assets.delete(handle); - }, - }; - assets.set(handle, true); - return handle; - }, - destroy() { - for (const h of assets.keys()) h.destroy(); - bank?.destroy(); - bank = null; - capacity = 0; - steps = []; - }, - }; -} diff --git a/src/layers/directions/render/style.js b/src/layers/directions/render/style.js new file mode 100644 index 000000000..88c1651a8 --- /dev/null +++ b/src/layers/directions/render/style.js @@ -0,0 +1,49 @@ +// What the Directions display draws, as Cesium drew it: colours rgba in +// 0..1, sizes in CSS pixels. + +import { rgbaFromCss } from '../../shared/color.js'; + +/** Route colour: the annotation palette's cyan, so voice routes and Directions match. */ +export const DIRECTIONS_ROUTE_COLOR = '#39d0ff'; + +const rgba = (css, alpha) => { + const { red, green, blue } = rgbaFromCss(css); + return [red, green, blue, alpha]; +}; + +// The route flow material (src/annotations/worldAnnotationRenderer.js): the +// route colour at 0.95, 64 dash cells along the route, each lit for its +// first 46 % and at 18 % alpha between. +export const ROUTE_WIDTH_PX = 9; +export const ROUTE_DASH_CELLS = 64; +export const ROUTE_DASH_DUTY = 0.46; +export const ROUTE_DASH_COLOR = rgba(DIRECTIONS_ROUTE_COLOR, 0.95); +export const ROUTE_GAP_COLOR = rgba(DIRECTIONS_ROUTE_COLOR, 0.95 * 0.18); + +// Maneuver dots: white at 0.95 with a 2 px route-colour outline, 8 px, 13 px +// while selected. +export const STEP_COLOR = [1, 1, 1, 0.95]; +export const STEP_OUTLINE_COLOR = rgba(DIRECTIONS_ROUTE_COLOR, 1); +export const STEP_OUTLINE_PX = 2; +export const STEP_PIXEL_SIZE = 8; +export const STEP_SELECTED_PIXEL_SIZE = 13; + +// The A and B markers: a 14 px disc with a 2 px black outline, and its +// letter (bold 13 px monospace, white with a 3 px black outline) 20 px above. +export const MARKER_COLORS = Object.freeze({ + a: rgba('#5dff9f', 1), + b: rgba('#ff6b6b', 1), +}); +export const MARKER_PIXEL_SIZE = 14; +export const MARKER_OUTLINE_PX = 2; +export const MARKER_OUTLINE_COLOR = [0, 0, 0, 1]; +export const MARKER_LETTER_FONT = + 'bold 13px "JetBrains Mono", "SF Mono", monospace'; +export const MARKER_LETTER_OUTLINE_PX = 3; +export const MARKER_LETTER_OFFSET_PX = 20; +// The letter's sprite: a square this size centred on the marker, the glyph +// drawn MARKER_LETTER_OFFSET_PX above its centre. +export const MARKER_LETTER_SPRITE_PX = 64; + +// Draw order among the display's mover sets: dots, marker discs, letters. +export const ORDER = Object.freeze({ steps: 0, markers: 1, letters: 2 }); diff --git a/src/layers/streetLevel/cameraFraming.js b/src/layers/streetLevel/cameraFraming.js index 642ecd108..9c514ef1f 100644 --- a/src/layers/streetLevel/cameraFraming.js +++ b/src/layers/streetLevel/cameraFraming.js @@ -1,5 +1,9 @@ -import * as Cesium from 'cesium'; -import { plausibleSurfaceHeight, sampledSurfaceHeight } from './view.js'; +import * as geo from '../../geo/index.js'; +import { + plausibleSurfaceHeight, + sampledSurfaceHeight, + terrainSurfaceHeight, +} from './view.js'; /** Fly the globe camera once to frame a photo after it opens. */ export function createCameraFraming({ state }) { @@ -8,7 +12,7 @@ export function createCameraFraming({ state }) { * nothing newer took the camera. Without it, framing flies directly. */ let navigation = null; - /** Our framing flight while it is current; Cesium calls `cancel` when another flight starts. */ + /** Our framing flight while it is current; the engine calls `cancel` when another flight starts. */ let flight = null; function attachNavigation(next) { @@ -27,14 +31,10 @@ export function createCameraFraming({ state }) { * ellipsoid before tiles load, so only a plausible height counts. */ function groundHeightAt(lon, lat, fallback) { - const scene = state.viewer?.scene; - const carto = Cesium.Cartographic.fromDegrees(lon, lat); - const engine = state.viewer?.engine; - let height = engine - ? (engine.surface.sampleHeight(carto) ?? - engine.surface.terrainHeight(carto)) - : sampledSurfaceHeight(scene, carto); - if (height === null) height = scene?.globe?.getHeight?.(carto); + const engine = state.map?.engine; + const carto = geo.cartographic.fromDegrees(lon, lat); + let height = sampledSurfaceHeight(engine, carto); + if (height === null) height = terrainSurfaceHeight(engine, carto); if (!plausibleSurfaceHeight(height)) height = fallback; return plausibleSurfaceHeight(height) ? height : 0; } @@ -45,7 +45,8 @@ export function createCameraFraming({ state }) { */ function frame(ticket) { const { position, bearing, altitude } = state.street; - if (!ticket || !position || !state.viewer) return; + const engine = state.map?.engine; + if (!ticket || !position || !engine) return; // Detached (shell teardown) since the ticket was issued: nothing to frame for. if (ticket.generation !== null && !navigation?.reassert(ticket.generation)) return; @@ -57,38 +58,19 @@ export function createCameraFraming({ state }) { const release = () => { if (flight === current) flight = null; }; - if (state.viewer.engine) { - state.viewer.engine.camera.flyToSphere( - { - center: Cesium.Cartesian3.fromDegrees( - position.lon, - position.lat, - ground + 2, - ), - radius: 4, - }, - { - heading: Cesium.Math.toRadians(bearing || 0), - pitch: Cesium.Math.toRadians(-32), - range: 140, - duration: 1.6, - complete: release, - cancel: release, - }, - ); - return; - } - state.viewer.camera.flyToBoundingSphere( - new Cesium.BoundingSphere( - Cesium.Cartesian3.fromDegrees(position.lon, position.lat, ground + 2), - 4, - ), + engine.camera.flyToSphere( { - offset: new Cesium.HeadingPitchRange( - Cesium.Math.toRadians(bearing || 0), - Cesium.Math.toRadians(-32), - 140, + center: geo.cartesian3.fromDegrees( + position.lon, + position.lat, + ground + 2, ), + radius: 4, + }, + { + heading: geo.math.toRadians(bearing || 0), + pitch: geo.math.toRadians(-32), + range: 140, duration: 1.6, complete: release, cancel: release, @@ -101,7 +83,7 @@ export function createCameraFraming({ state }) { if (!flight) return; // Give up ownership first: cancelFlight delivers `cancel` synchronously. flight = null; - state.viewer?.camera?.cancelFlight?.(); + state.map?.engine?.camera.cancelFlight(); } return { attachNavigation, begin, frame, cancel }; diff --git a/src/layers/streetLevel/cameraFraming.test.mjs b/src/layers/streetLevel/cameraFraming.test.mjs index c1a17989e..1fb60d869 100644 --- a/src/layers/streetLevel/cameraFraming.test.mjs +++ b/src/layers/streetLevel/cameraFraming.test.mjs @@ -1,24 +1,21 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; import { createCameraFraming } from './cameraFraming.js'; -import { fakeCesiumViewer } from '../../testSupport/streetLevelFakes.mjs'; +import { streetLevelMap } from '../../testSupport/streetLevelFakes.mjs'; /** Height (m) of a Cartesian above the WGS84 ellipsoid. */ const heightOf = (cartesian) => - Cesium.Cartographic.fromCartesian(cartesian).height; + geo.cartographic.fromCartesian(cartesian).height; /** - * A viewer whose scene sample is `sampled` (e.g. −14,886 m before the tiles - * under the photo load) and whose globe terrain is `globe`. + * A display whose surface sample is `sampled` (e.g. −14,886 m before the + * tiles under the photo load) and whose terrain is `globe`. */ function setup({ sampled, globe = null, altitude = 149 }) { - const viewer = fakeCesiumViewer(); - Object.assign(viewer.scene, { - sampleHeightSupported: true, - sampleHeight: () => sampled, - globe: { getHeight: () => globe }, - }); + const harness = streetLevelMap(); + harness.engine.fake.script('sampleHeight', () => sampled); + harness.engine.fake.script('terrainHeight', () => globe ?? undefined); const state = { services: {}, street: { @@ -26,13 +23,15 @@ function setup({ sampled, globe = null, altitude = 149 }) { bearing: 105, altitude, }, - viewer, + map: harness.map, + /** What the camera follows (an aircraft), released by a reassert. */ + tracked: undefined, }; return { framing: createCameraFraming({ state }), state, - viewer, - flights: viewer.flights.list, + harness, + flights: harness.flights.list, }; } @@ -41,6 +40,11 @@ test('framing a photo stands the sphere on a plausible scene sample', () => { framing.frame(framing.begin()); assert.equal(flights.length, 1); assert.ok(Math.abs(heightOf(flights[0].sphere.center) - 123) < 0.01); + assert.equal(flights[0].sphere.radius, 4); + const { heading, pitch, range, duration } = flights[0].options; + assert.ok(Math.abs(heading - geo.math.toRadians(105)) < 1e-12); + assert.ok(Math.abs(pitch - geo.math.toRadians(-32)) < 1e-12); + assert.deepEqual([range, duration], [140, 1.6]); }); test('a sample kilometres underground falls back to the globe, then the image altitude', () => { @@ -57,7 +61,8 @@ test('a sample kilometres underground falls back to the globe, then the image al }); test('cancel stops the framing flight while it is still ours', () => { - const { framing, viewer } = setup({ sampled: 121 }); + const { framing, harness } = setup({ sampled: 121 }); + const viewer = harness; framing.frame(framing.begin()); framing.cancel(); assert.equal( @@ -76,18 +81,21 @@ test('cancel stops the framing flight while it is still ours', () => { }); test('cancel leaves a landed flight and a newer navigation flight alone', () => { - const { framing, viewer, flights } = setup({ sampled: 121 }); + const { framing, harness, flights } = setup({ sampled: 121 }); framing.frame(framing.begin()); - viewer.flights.land(); + harness.flights.land(); framing.cancel(); - assert.equal(viewer.flights.cancelled, 0, 'landed: nothing to stop'); + assert.equal(harness.flights.cancelled, 0, 'landed: nothing to stop'); framing.frame(framing.begin()); - // A search result flies the globe elsewhere; Cesium cancels ours first. - viewer.camera.flyToBoundingSphere(null, {}); + // A search result flies the globe elsewhere; the engine cancels ours first. + harness.engine.camera.flyToSphere( + { center: geo.cartesian3.fromDegrees(0, 0), radius: 1 }, + {}, + ); assert.equal(flights.length, 3); framing.cancel(); - assert.equal(viewer.flights.cancelled, 0, 'the newer flight keeps going'); + assert.equal(harness.flights.cancelled, 0, 'the newer flight keeps going'); }); /** @@ -107,7 +115,7 @@ function navigationFor(state, { refuse = false } = {}) { }, reassert(ticket) { if (ticket !== generation) return false; - state.viewer.trackedEntity = undefined; + state.tracked = undefined; return true; }, /** Another feature takes the camera. */ @@ -118,14 +126,14 @@ function navigationFor(state, { refuse = false } = {}) { test('a photo claims the camera when it starts opening and frames once loaded, releasing tracking', () => { const { framing, state, flights } = setup({ sampled: 160 }); - state.viewer.trackedEntity = { id: 'aircraft' }; + state.tracked = { id: 'aircraft' }; const nav = navigationFor(state); framing.attachNavigation(nav); const ticket = framing.begin(); assert.deepEqual(nav.begins, ['photo']); - assert.ok(state.viewer.trackedEntity, 'nothing is released while it loads'); + assert.ok(state.tracked, 'nothing is released while it loads'); framing.frame(ticket); - assert.equal(state.viewer.trackedEntity, undefined); + assert.equal(state.tracked, undefined); assert.equal(flights.length, 1); }); @@ -136,10 +144,10 @@ test('a photo that finishes loading after newer navigation does not frame', () = const ticket = framing.begin(); // The user flies to Sacramento, and then tracks an aircraft, while it loads. nav.handOff(); - state.viewer.trackedEntity = { id: 'aircraft' }; + state.tracked = { id: 'aircraft' }; framing.frame(ticket); assert.equal(flights.length, 0, 'no flight back to the photo'); - assert.deepEqual(state.viewer.trackedEntity, { id: 'aircraft' }); + assert.deepEqual(state.tracked, { id: 'aircraft' }); }); test('a refused claim (cockpit) does not frame the photo', () => { diff --git a/src/layers/streetLevel/credits.js b/src/layers/streetLevel/credits.js index 66be7dcb6..e218d162f 100644 --- a/src/layers/streetLevel/credits.js +++ b/src/layers/streetLevel/credits.js @@ -1,46 +1,36 @@ -import * as Cesium from 'cesium'; - /** - * One static Cesium credit per active provider: CC BY-SA imagery needs - * visible attribution that goes away with the imagery. + * One static on-screen credit per active provider (engine.credits): CC + * BY-SA imagery needs visible attribution that goes away with the imagery. + * `view` is the layer's display (render/display.js). */ export function createCredits() { - /** @type {Map} provider id → Cesium.Credit */ + /** @type {Map void>} provider id → its credit's remover */ const shown = new Map(); - function show(viewer, def) { + function show(view, def) { if (shown.has(def.id)) return; - if (viewer?.engine) { - shown.set( - def.id, - viewer.engine.credits.addStatic(def.credit.html, { onScreen: true }), - ); - return; - } - if (!viewer?.creditDisplay) return; + const credits = view?.engine?.credits; + if (!credits) return; try { - const credit = new Cesium.Credit(def.credit.html, true); - viewer.creditDisplay.addStaticCredit(credit); - shown.set(def.id, credit); + shown.set(def.id, credits.addStatic(def.credit.html, { onScreen: true })); } catch { /* credit display unavailable */ } } - function hide(viewer, def) { - const credit = shown.get(def.id); - if (!credit) return; + function hide(_view, def) { + const remove = shown.get(def.id); + if (!remove) return; try { - if (typeof credit === 'function') credit(); - else viewer?.creditDisplay?.removeStaticCredit?.(credit); + remove(); } catch { /* credit display already torn down */ } shown.delete(def.id); } - function hideAll(viewer) { - for (const id of [...shown.keys()]) hide(viewer, { id }); + function hideAll(view) { + for (const id of [...shown.keys()]) hide(view, { id }); } return { show, hide, hideAll }; diff --git a/src/layers/streetLevel/edgeCases.test.mjs b/src/layers/streetLevel/edgeCases.test.mjs index 446d61c9c..534e827eb 100644 --- a/src/layers/streetLevel/edgeCases.test.mjs +++ b/src/layers/streetLevel/edgeCases.test.mjs @@ -3,11 +3,10 @@ // from is ignored and the lit provider keeps the panel. import assert from 'node:assert/strict'; import test from 'node:test'; -import * as Cesium from 'cesium'; import { createStreetLevelLayer } from './index.js'; import { - fakeCesiumViewer, fakeStreetLevelProvider as fakeProvider, + streetLevelMap, } from '../../testSupport/streetLevelFakes.mjs'; const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); @@ -25,11 +24,6 @@ async function twoProviders(t) { globalThis.document = Object.assign(new EventTarget(), { createElement: () => ({ getContext: () => drawing }), }); - t.mock.method( - Cesium.GroundPolylinePrimitive, - 'initializeTerrainHeights', - async () => {}, - ); const lookups = []; const previous = fakeProvider({ nearestImage: (point, { signal } = {}) => { @@ -43,10 +37,13 @@ async function twoProviders(t) { pickPrefix: 'pmx:', nearestImage: async () => 'pmx-1', }); - const viewer = fakeCesiumViewer(); - const layer = createStreetLevelLayer({ providers: [previous, lit] }); - layer.init(viewer); - layer.enable(viewer); + const harness = streetLevelMap(); + const layer = createStreetLevelLayer({ + providers: [previous, lit], + services: { createDisplay: () => harness.map }, + }); + layer.init(null, { engine: harness.engine }); + layer.enable(); layer.attachViewerHost({}); t.after(() => { layer.destroy(); diff --git a/src/layers/streetLevel/groundClick.js b/src/layers/streetLevel/groundClick.js index 4331d21e7..51c01c441 100644 --- a/src/layers/streetLevel/groundClick.js +++ b/src/layers/streetLevel/groundClick.js @@ -3,9 +3,9 @@ import { GROUND_CLICK_MAX_HEIGHT_M } from './policy.js'; import { cameraHeightAboveGround, cameraLocation } from './view.js'; /** Whether the camera is low enough for a click on the ground to mean a street. */ -export function groundClickHeightOk(viewer) { - if (!cameraLocation(viewer)) return false; - return cameraHeightAboveGround(viewer) <= GROUND_CLICK_MAX_HEIGHT_M; +export function groundClickHeightOk(map) { + if (!cameraLocation(map)) return false; + return cameraHeightAboveGround(map) <= GROUND_CLICK_MAX_HEIGHT_M; } /** @@ -18,9 +18,9 @@ export function groundClickHeightOk(viewer) { export function createGroundClick({ state, openNearest, isAvailable }) { return function openAtGround(screenPosition, picked) { const scenePick = state.services.scenePick; - if (!state.enabled || !state.viewer || !scenePick?.pickGroundPosition) + if (!state.enabled || !state.map || !scenePick?.pickGroundPosition) return false; - if (!scenePick.isSurfacePick(picked) || !groundClickHeightOk(state.viewer)) + if (!scenePick.isSurfacePick(picked) || !groundClickHeightOk(state.map)) return false; const providerIds = [...state.providers.values()] .filter( @@ -32,9 +32,10 @@ export function createGroundClick({ state, openNearest, isAvailable }) { ) .map((entry) => entry.def.id); if (!providerIds.length) return false; - const position = state.viewer.engine - ? state.viewer.engine.picker.pickGlobe(screenPosition) - : scenePick.pickGroundPosition(state.viewer, screenPosition); + const position = scenePick.pickGroundPosition( + state.map.engine, + screenPosition, + ); const carto = position && geo.cartographic.fromCartesian(position); if (!carto) return false; return openNearest( diff --git a/src/layers/streetLevel/groundClick.test.mjs b/src/layers/streetLevel/groundClick.test.mjs index b7cfd2b7e..ba9b21fba 100644 --- a/src/layers/streetLevel/groundClick.test.mjs +++ b/src/layers/streetLevel/groundClick.test.mjs @@ -1,10 +1,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; import { createGroundClick, groundClickHeightOk } from './groundClick.js'; import * as scenePick from '../../data/scenePick.js'; +import { cameraEngine } from '../../testSupport/streetLevelFakes.mjs'; -const GROUND = Cesium.Cartesian3.fromDegrees(-121.4944, 38.5816, 10); +const GROUND = geo.cartesian3.fromDegrees(-121.4944, 38.5816, 10); const TILESET = { isCesium3DTileset: true }; /** @@ -22,6 +23,15 @@ function harness({ ellipsoid = undefined, } = {}) { const opened = []; + // The display's engine: the camera `height` m up, the depth buffer and the + // ellipsoid as scripted. + const engine = cameraEngine({ + lon: -121.4944, + lat: 38.5816, + altitude: height, + }); + engine.fake.script('pickPosition', () => picked); + engine.fake.script('pickEllipsoid', () => ellipsoid); const state = { enabled, services: { scenePick }, @@ -39,17 +49,7 @@ function harness({ ], ), ), - viewer: { - scene: { pickPositionSupported: true, pickPosition: () => picked }, - camera: { - pickEllipsoid: () => ellipsoid, - positionCartographic: Cesium.Cartographic.fromDegrees( - -121.4944, - 38.5816, - height, - ), - }, - }, + map: { engine }, }; const openAtGround = createGroundClick({ state, @@ -81,7 +81,7 @@ test('a click on the map opens the click-to-open providers there, without moving test('where the depth buffer names no place, the ellipsoid answers', () => { const { opened, openAtGround } = harness({ - picked: new Cesium.Cartesian3(500, 0, 0), + picked: geo.cartesian3.create(500, 0, 0), ellipsoid: GROUND, }); openAtGround({ x: 1, y: 1 }); @@ -120,14 +120,13 @@ test('nothing opens over another layer’s object, the sky, from high up, off it }); test('street zoom is measured above the ground under the camera', () => { - const viewer = (height) => ({ - camera: { - positionCartographic: Cesium.Cartographic.fromDegrees(0, 0, height), - }, - scene: { globe: { getHeight: () => 1500 } }, - }); + const map = (height) => { + const engine = cameraEngine({ lon: 0, lat: 0, altitude: height }); + engine.fake.script('terrainHeight', () => 1500); + return { engine }; + }; // 1,500 m up a plateau, 2,000 m above it: still a street. - assert.equal(groundClickHeightOk(viewer(3500)), true); - assert.equal(groundClickHeightOk(viewer(5000)), false); + assert.equal(groundClickHeightOk(map(3500)), true); + assert.equal(groundClickHeightOk(map(5000)), false); assert.equal(groundClickHeightOk(null), false); }); diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index 3a73e901b..f09d0802c 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -1,5 +1,4 @@ import * as geo from '../../geo/index.js'; -import { whenCesiumSdk } from '../../app/loadCesium.js'; import { createState } from './state.js'; import { createMarker } from './marker.js'; import { createCameraFraming } from './cameraFraming.js'; @@ -17,6 +16,7 @@ import { } from './uiState.js'; import { groundUnderCamera, viewCentre } from './view.js'; import { createGroundClick, groundClickHeightOk } from './groundClick.js'; +import { createStreetLevelDisplay } from './render/display.js'; import { msg, t, translateKnown } from '../../i18n/index.js'; import { AIM_AT_GROUND_MESSAGE, @@ -39,7 +39,9 @@ const GROUND_RETRY_MS = 1000; * The core owns what they share: the filter and switches, the photo viewer * host, the position marker, the click handler and nearest-image lookups. * Providers own coverage, sequences and their viewer. One source is lit at - * a time. + * a time. Everything on the globe goes through the display on the engine it + * is attached to: `services.createDisplay(engine)` (the app's renderer + * registry), else render/display.js; a display of null draws nothing. * @param {{providers: Array, services?: object}} options */ export function createStreetLevelLayer({ @@ -179,9 +181,9 @@ export function createStreetLevelLayer({ for (const entry of state.providers.values()) { if (!entry.def.requiresMapStack) continue; if (state.enabled && entry.on && providerAvailable(entry)) - parts.credits.show(state.viewer, entry.def); + parts.credits.show(state.map, entry.def); else { - parts.credits.hide(state.viewer, entry.def); + parts.credits.hide(state.map, entry.def); parts.viewerHost.unmount(entry.def.id); } } @@ -203,13 +205,13 @@ export function createStreetLevelLayer({ */ function syncGroundClickReady() { cancelGroundRetry(); - const ready = state.enabled && groundClickHeightOk(state.viewer); + const ready = state.enabled && groundClickHeightOk(state.map); if ( state.enabled && - state.viewer && + state.map && !ready && groundRetries < GROUND_RETRY_LIMIT && - groundUnderCamera(state.viewer) === null + groundUnderCamera(state.map) === null ) { groundRetries++; groundRetry = setTimeout(syncGroundClickReady, GROUND_RETRY_MS); @@ -226,10 +228,10 @@ export function createStreetLevelLayer({ } let removeCameraListener = null; - function watchCamera(viewer) { + function watchCamera(view) { removeCameraListener?.(); removeCameraListener = - viewer?.camera?.moveEnd?.addEventListener(remeasureGround) || null; + view?.engine?.camera.moveEnd.addEventListener(remeasureGround) || null; } const activeEntries = () => @@ -253,18 +255,18 @@ export function createStreetLevelLayer({ } function activate(entry) { - if (!state.viewer) return; - entry.instance.activate(state.viewer); + if (!state.map) return; + entry.instance.activate(state.map); // Without a known key, a switch-on asks again: the server may be back, // or have a key now. if (entry.status?.configured !== true) checkStatus(entry); - if (providerAvailable(entry)) parts.credits.show(state.viewer, entry.def); + if (providerAvailable(entry)) parts.credits.show(state.map, entry.def); } function deactivate(entry) { parts.viewerHost.unmount(entry.def.id); entry.instance.deactivate(); - parts.credits.hide(state.viewer, entry.def); + parts.credits.hide(state.map, entry.def); } /** The nearest-image lookup in flight; any newer user action aborts it. */ @@ -277,15 +279,11 @@ export function createStreetLevelLayer({ /** Ground under the screen centre; null for sky, undefined when it cannot aim. */ function screenCentreGround() { - const canvas = - state.viewer?.engine?.host.canvas ?? state.viewer?.scene?.canvas; + const engine = state.map?.engine; + const size = engine?.host.size(); const pick = state.services.scenePick?.pickGroundPosition; - if (!pick || !canvas?.clientWidth || !canvas?.clientHeight) - return undefined; - const center = { x: canvas.clientWidth / 2, y: canvas.clientHeight / 2 }; - const position = state.viewer.engine - ? state.viewer.engine.picker.pickGlobe(center) - : pick(state.viewer, center); + if (!pick || !size?.width || !size?.height) return undefined; + const position = pick(engine, { x: size.width / 2, y: size.height / 2 }); const carto = position && geo.cartographic.fromCartesian(position); return carto ? { @@ -498,16 +496,18 @@ export function createStreetLevelLayer({ }; }, - init(viewer, options) { - this.attachRenderer(viewer, options); + init(scene, options) { + this.attachRenderer(scene, options); for (const entry of state.providers.values()) checkStatus(entry); }, + /** Draws on `engine` through its display; the first argument is not read. */ attachRenderer( - viewer, + _scene, { engine = null, waitForCameraRestore = false } = {}, ) { - const map = viewer ?? services.nativeRenderer?.(engine); - state.viewer = map; + const createDisplay = services.createDisplay ?? createStreetLevelDisplay; + const map = engine ? (createDisplay(engine) ?? null) : null; + state.map = map; waitingForView = waitForCameraRestore; if (!map) return; parts.marker.ensure(map); @@ -534,13 +534,13 @@ export function createStreetLevelLayer({ resumeViewport() { if (!waitingForView) return; waitingForView = false; - if (state.enabled && state.viewer) { + if (state.enabled && state.map) { for (const entry of activeEntries()) entry.instance.refreshCoverage?.(); remeasureGround(); } }, detachRenderer() { - const map = state.viewer; + const map = state.map; if (!map) return; waitingForView = true; removeCameraListener?.(); @@ -552,26 +552,22 @@ export function createStreetLevelLayer({ if (entry.instance.detachMap) entry.instance.detachMap(); else if (entry.on) entry.instance.deactivate(); } - parts.marker.destroy(map); + parts.marker.destroy(); parts.credits.hideAll(map); + map.destroy?.(); unsubscribeMapStack?.(); unsubscribeMapStack = null; mapStack = null; - state.viewer = null; + state.map = null; }, - enable(viewer, { signal } = {}) { - // The marker, cones, coverage and ground picks use Cesium's math and - // primitives on either globe: a Navara page loads the Cesium SDK first. - return whenCesiumSdk(() => { - state.enabled = true; - if (viewer) state.viewer = viewer; - parts.marker.setVisible(true); - parts.selection.install(state.viewer); - for (const entry of activeEntries()) activate(entry); - watchCamera(state.viewer); - remeasureGround(); - notify(); - }, signal); + enable() { + state.enabled = true; + parts.marker.setVisible(true); + parts.selection.install(state.map); + for (const entry of activeEntries()) activate(entry); + watchCamera(state.map); + remeasureGround(); + notify(); }, disable() { @@ -583,7 +579,7 @@ export function createStreetLevelLayer({ parts.viewerHost.unmount(); for (const entry of state.providers.values()) entry.instance.deactivate(); syncGroundClickReady(); - parts.credits.hideAll(state.viewer); + parts.credits.hideAll(state.map); parts.selection.uninstall(); parts.marker.setVisible(false); notify(); @@ -594,17 +590,17 @@ export function createStreetLevelLayer({ }, destroy() { - const viewer = state.viewer; + const map = state.map; layer.disable(); - for (const entry of state.providers.values()) - entry.instance.destroy(viewer); - parts.marker.destroy(viewer); + for (const entry of state.providers.values()) entry.instance.destroy(); + parts.marker.destroy(); + map?.destroy?.(); parts.framing.attachNavigation(null); unsubscribeMapStack?.(); unsubscribeMapStack = null; mapStack = null; state.listeners.clear(); - state.viewer = null; + state.map = null; }, getStats() { @@ -716,7 +712,7 @@ export function createStreetLevelLayer({ return false; } } - view ||= viewCentre(state.viewer); + view ||= viewCentre(state.map); if (!Number.isFinite(view?.lat) || !Number.isFinite(view?.lon)) return false; abortNearest(); diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index 0a26df1f1..10e7e10ba 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -1,21 +1,34 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; import { createStreetLevelLayer } from './index.js'; import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js'; import { createMapillaryProvider } from './providers/mapillary/index.js'; import { createKartaViewProvider } from './providers/kartaview/index.js'; import { - fakeCesiumViewer, fakeMapillarySource, fakePhotoViewer, fakeStreetLevelProvider as fakeProvider, + streetLevelMap, } from '../../testSupport/streetLevelFakes.mjs'; import { setLocale } from '../../i18n/index.js'; /** - * A layer over `source` and `photoViewer`, initialised on a stand-in viewer - * and (unless `enable` is false) enabled. + * A layer drawing on `harness` (the display on the fake engine, + * streetLevelMap), initialised on its engine. + */ +function mountLayer(harness, providers, services = {}) { + const layer = createStreetLevelLayer({ + providers, + services: { createDisplay: () => harness.map, ...services }, + }); + layer.init(null, { engine: harness.engine }); + return layer; +} + +/** + * A layer over `source` and `photoViewer`, initialised on the display of a + * fake engine and (unless `enable` is false) enabled. */ async function startLayer( t, @@ -30,22 +43,15 @@ async function startLayer( const saved = globalThis.document; // The click handler listens for Esc on the document. globalThis.document = new EventTarget(); - // Skip Cesium's one-time terrain table download for draped lines. - t.mock.method( - Cesium.GroundPolylinePrimitive, - 'initializeTerrainHeights', - async () => {}, - ); - const viewer = fakeCesiumViewer({ view }); - const layer = createStreetLevelLayer({ providers }); - layer.init(viewer); - if (enable) layer.enable(viewer); + const harness = streetLevelMap({ view }); + const layer = mountLayer(harness, providers); + if (enable) layer.enable(); t.after(() => { layer.destroy(); globalThis.document = saved; }); await settle(); - return { layer, viewer, source, photoViewer }; + return { layer, harness, source, photoViewer }; } const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); @@ -65,8 +71,8 @@ const posingViewer = () => fakePhotoViewer({ pose: { position: { lon: -121.49, lat: 38.58 } } }); test('the CC BY-SA credit shows while Mapillary is on and goes with it', async (t) => { - const { layer, viewer } = await startLayer(t); - const shown = () => viewer.credits.map((credit) => credit.html); + const { layer, harness } = await startLayer(t); + const shown = () => harness.credits(); assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML], 'shown on enable'); assert.match(shown()[0], /CC BY-SA 4\.0/); layer.setParams({ mapillary: false }); @@ -114,19 +120,19 @@ test('a key status that failed is asked again on the next switch-on', async (t) return { configured: true }; }, }); - const { layer, viewer } = await startLayer(t, { source, view: true }); + const { layer, harness } = await startLayer(t, { source, view: true }); assert.equal(asked, 1, 'one check for init and the first switch-on'); assert.equal(layer.getUIState().keyRequired, true); up = true; // the server is reachable again layer.disable(); - layer.enable(viewer); + layer.enable(); await settle(); assert.equal(asked, 2); assert.equal(layer.getUIState().keyRequired, false); assert.ok(source.calls.tiles.length > 0, 'coverage loads'); // A known key is not asked again. layer.disable(); - layer.enable(viewer); + layer.enable(); await settle(); assert.equal(asked, 2); }); @@ -148,7 +154,7 @@ test('a key status that never answers gives up, so the next switch-on asks again signal?.addEventListener('abort', () => reject(signal.reason)); }), }); - const { layer, viewer } = await startLayer(t, { source, view: true }); + const { layer, harness } = await startLayer(t, { source, view: true }); assert.equal(asked, 1); assert.equal(timeouts[0].ms, 10_000, 'the check can be cut short'); assert.equal(layer.getUIState().coverage.loading, true, 'LOADING meanwhile'); @@ -157,7 +163,7 @@ test('a key status that never answers gives up, so the next switch-on asks again assert.equal(layer.getUIState().coverage.loading, false); assert.equal(layer.getUIState().keyRequired, true, 'no token: KEY REQUIRED'); layer.disable(); - layer.enable(viewer); + layer.enable(); assert.equal(asked, 2, 'asked again, not stuck on the first'); }); @@ -170,7 +176,7 @@ test('a rejected key gates the layer as KEY REJECTED until it goes off', async ( throw rejected; }, }); - const { layer, viewer } = await startLayer(t, { source, view: true }); + const { layer, harness } = await startLayer(t, { source, view: true }); await settle(); const stats = layer.getStats(); assert.equal(stats.keyRequired, true); @@ -179,7 +185,7 @@ test('a rejected key gates the layer as KEY REJECTED until it goes off', async ( const ui = layer.getUIState(); assert.deepEqual([ui.keyRequired, ui.keyRejected], [true, true]); layer.disable(); - layer.enable(viewer); + layer.enable(); assert.equal(layer.getUIState().keyRejected, false, 'asked again once on'); }); @@ -218,23 +224,23 @@ test('a sequence that fails to load reports it apart from the photo, and the err }); test('closing the photo, switching the layer off or destroying it stops the framing flight', async (t) => { - const { layer, viewer } = await startLayer(t, { + const { layer, harness } = await startLayer(t, { photoViewer: posingViewer(), }); layer.attachViewerHost({}); assert.equal(await layer.openImage('mapillary', 'img1'), true); - assert.equal(viewer.flights.started, 1, 'the photo is framed'); + assert.equal(harness.flights.started, 1, 'the photo is framed'); layer.closeViewer(); - assert.equal(viewer.flights.cancelled, 1, 'closed'); + assert.equal(harness.flights.cancelled, 1, 'closed'); await layer.openImage('mapillary', 'img2'); layer.disable(); - assert.equal(viewer.flights.cancelled, 2, 'layer off'); + assert.equal(harness.flights.cancelled, 2, 'layer off'); - layer.enable(viewer); + layer.enable(); await layer.openImage('mapillary', 'img3'); layer.destroy(); - assert.equal(viewer.flights.cancelled, 3, 'destroyed'); + assert.equal(harness.flights.cancelled, 3, 'destroyed'); }); test('setParams takes "any date" (0 days) and the switch over the current values (share-link defaults)', async (t) => { @@ -270,7 +276,7 @@ test('setParams ignores unknown keys and malformed values', async (t) => { }); test('switching Mapillary on while the layer is off draws nothing', async (t) => { - const { layer, viewer, source, photoViewer } = await startLayer(t, { + const { layer, harness, source, photoViewer } = await startLayer(t, { view: true, enable: false, }); @@ -279,18 +285,18 @@ test('switching Mapillary on while the layer is off draws nothing', async (t) => layer.setParams({ mapillary: true }); await settle(); assert.equal(source.calls.tiles.length, 0, 'no coverage drawn'); - assert.deepEqual(viewer.credits, [], 'no credit shown'); + assert.deepEqual(harness.credits(), [], 'no credit shown'); assert.equal(photoViewer.calls.mount, 0, 'no viewer stood up'); assert.equal(layer.getUIState().providers[0].on, true, 'the switch is kept'); // Enabling the layer is what draws it. - layer.enable(viewer); + layer.enable(); await settle(); assert.ok(source.calls.tiles.length > 0); - assert.equal(viewer.credits.length, 1); + assert.equal(harness.credits().length, 1); }); test('an empty provider composition enables and tears down without acquiring imagery', async (t) => { - const { layer, viewer } = await startLayer(t, { providers: [] }); + const { layer, harness } = await startLayer(t, { providers: [] }); assert.deepEqual(layer.providerIds, []); assert.deepEqual(layer.getUIState().providers, []); assert.equal(layer.getUIState().keyRequired, false); @@ -299,69 +305,13 @@ test('an empty provider composition enables and tears down without acquiring ima /No street-level imagery providers/, ); layer.disable(); - layer.enable(viewer); + layer.enable(); assert.deepEqual(layer.getUIState().providers, []); }); // ── Several providers: one source at a time, Google 3D, nearest lookups ── -/** - * A stand-in Cesium viewer the layer can be enabled on: a canvas for the - * click handler, camera events, a primitive list and a credit display. - */ -function fakeViewer() { - const credits = []; - /** Cesium's flight bookkeeping: a new flight cancels the current one. */ - let flight = null; - const flights = { started: 0, cancelled: 0 }; - const stopFlight = () => { - const current = flight; - flight = null; - current?.cancel?.(); - return Boolean(current); - }; - const canvas = Object.assign(new EventTarget(), { - style: {}, - // Keep Cesium's handler on the canvas; there is no real document here. - disableRootEvents: true, - onwheel: null, - }); - return { - credits, - flights, - scene: { - canvas, - primitives: { add: (p) => p, remove() {} }, - // Enough of a scene for the position marker to clamp to the ground. - frameState: { mode: Cesium.SceneMode.SCENE3D }, - updateHeight: () => () => {}, - getHeight: () => undefined, - }, - camera: { - changed: new Cesium.Event(), - moveStart: new Cesium.Event(), - moveEnd: new Cesium.Event(), - flyToBoundingSphere(sphere, options = {}) { - stopFlight(); - flight = options; - flights.started++; - }, - cancelFlight() { - if (stopFlight()) flights.cancelled++; - }, - }, - creditDisplay: { - addStaticCredit: (credit) => credits.push(credit), - removeStaticCredit: (credit) => { - // Cesium ignores a credit it does not hold. - const at = credits.indexOf(credit); - if (at !== -1) credits.splice(at, 1); - }, - }, - }; -} - -/** A layer over `providers`, initialised and enabled on a stand-in viewer. */ +/** A layer over `providers`, initialised and enabled on the fake engine. */ async function enabledLayer(t, providers = [fakeProvider()]) { const saved = globalThis.document; // A pose moves the position marker, whose glyph is drawn on a canvas. @@ -369,16 +319,15 @@ async function enabledLayer(t, providers = [fakeProvider()]) { globalThis.document = Object.assign(new EventTarget(), { createElement: () => ({ getContext: () => drawing }), }); - const viewer = fakeViewer(); - const layer = createStreetLevelLayer({ providers }); - layer.init(viewer); - layer.enable(viewer); + const harness = streetLevelMap(); + const layer = mountLayer(harness, providers); + layer.enable(); t.after(() => { layer.destroy(); globalThis.document = saved; }); await settle(); - return { layer, viewer }; + return { layer, harness }; } function posingProvider(options) { @@ -536,7 +485,7 @@ test('an image picked during a nearest lookup wins over its late answer', async test('closing the viewer or switching the layer off retires a nearest lookup', async (t) => { const { lookups, nearestImage } = slowLookups(); const provider = fakeProvider({ nearestImage }); - const { layer, viewer } = await enabledLayer(t, [provider]); + const { layer, harness } = await enabledLayer(t, [provider]); layer.attachViewerHost({}); const closed = layer.openNearest({ lat: 38.58, lon: -121.49 }); @@ -553,7 +502,7 @@ test('closing the viewer or switching the layer off retires a nearest lookup', a const disabled = layer.openNearest({ lat: 38.58, lon: -121.49 }); layer.disable(); - layer.enable(viewer); + layer.enable(); lookups[1].answer.resolve('img1'); assert.equal(await disabled, false, 'nor does it open once back on'); assert.deepEqual(provider.calls.open, []); @@ -571,14 +520,13 @@ function initialisedLayer(t, providers) { globalThis.document = Object.assign(new EventTarget(), { createElement: () => ({ getContext: () => drawing }), }); - const viewer = fakeViewer(); - const layer = createStreetLevelLayer({ providers }); - layer.init(viewer); + const harness = streetLevelMap(); + const layer = mountLayer(harness, providers); t.after(() => { layer.destroy(); globalThis.document = saved; }); - return { layer, viewer }; + return { layer, harness }; } test('switching a provider on while the layer is off activates nothing (M01)', async (t) => { @@ -590,7 +538,7 @@ test('switching a provider on while the layer is off activates nothing (M01)', a instance.viewer.prewarm = async (host) => prewarmed.push(host); return instance; }; - const { layer, viewer } = initialisedLayer(t, [provider]); + const { layer, harness } = initialisedLayer(t, [provider]); layer.attachViewerHost({}); layer.setProviderEnabled('mapillary', false); layer.setProviderEnabled('mapillary', true); @@ -598,13 +546,13 @@ test('switching a provider on while the layer is off activates nothing (M01)', a layer.setParams({ mapillary: true }); await new Promise((resolve) => setTimeout(resolve, 40)); // past whenIdle assert.equal(provider.calls.activate, 0, 'no coverage drawn'); - assert.deepEqual(viewer.credits, [], 'no credit shown'); + assert.deepEqual(harness.credits(), [], 'no credit shown'); assert.deepEqual(prewarmed, [], 'no viewer stood up'); assert.equal(layer.getUIState().providers[0].on, true, 'the switch is kept'); // Enabling the layer is what activates it, once. - layer.enable(viewer); + layer.enable(); assert.equal(provider.calls.activate, 1); - assert.equal(viewer.credits.length, 1); + assert.equal(harness.credits().length, 1); }); /** The real Google provider over the Street View fakes, beside a Mapillary stand-in. */ @@ -642,11 +590,11 @@ async function withStreetView( getApiKey: () => 'browser-key', loader, }); - const { layer, viewer } = await enabledLayer(t, [mapillary, google]); + const { layer, harness } = await enabledLayer(t, [mapillary, google]); const stack = fakeMapStack(mapStack); layer.attachMapStackController(stack); layer.attachViewerHost(fakeHost()); - return { layer, viewer, stack, fake, loader, mapillaryLookups }; + return { layer, harness, stack, fake, loader, mapillaryLookups }; } test('Street View starts off, and the layer row names the key of the providers that are on', async (t) => { @@ -758,7 +706,7 @@ test('one imagery source at a time: lighting one darkens the other, either way', }); test('Street View appears only on the Google 3D map: chip, credit, source and images', async (t) => { - const { layer, viewer, stack } = await withStreetView(t, { + const { layer, harness, stack } = await withStreetView(t, { mapStack: 'esri-imagery', }); layer.setProviderEnabled('google', true); @@ -766,7 +714,7 @@ test('Street View appears only on the Google 3D map: chip, credit, source and im const ui = () => layer.getUIState(); const listed = () => ui().providers.map((p) => p.id); const credited = () => - viewer.credits.some((credit) => /Street View imagery/.test(credit.html)); + harness.credits().some((html) => /Street View imagery/.test(html)); assert.deepEqual(listed(), ['mapillary'], 'no Street View chip'); assert.equal(credited(), false, 'no Google credit on another map'); assert.equal(layer.source, 'Mapillary'); @@ -795,17 +743,16 @@ test('under Google 3D the ground is measured again once its tiles load, without t.after(() => { globalThis.document = saved; }); + const harness = streetLevelMap(); + // Google 3D (the globe hidden), 4 km up the ellipsoid, 2 km over a city + // whose tiles are still loading. + await harness.engine.maps.setPhotorealTiles({ kind: 'photoreal' }); t.mock.timers.enable({ apis: ['setTimeout'] }); - const viewer = fakeViewer(); - // 4 km up the ellipsoid, 2 km over a city whose tiles are still loading. let ground; - viewer.camera.positionCartographic = { height: 4000 }; - viewer.scene.globe = { show: false }; - viewer.scene.sampleHeightSupported = true; - viewer.scene.sampleHeight = () => ground; - const layer = createStreetLevelLayer({ providers: [fakeProvider()] }); - layer.init(viewer); - layer.enable(viewer); + harness.engine.fake.setPose({ height: 4000 }); + harness.engine.fake.script('sampleHeight', () => ground); + const layer = mountLayer(harness, [fakeProvider()]); + layer.enable(); t.after(() => layer.destroy()); assert.equal(layer.getUIState().groundClickReady, false); @@ -814,31 +761,29 @@ test('under Google 3D the ground is measured again once its tiles load, without assert.equal(layer.getUIState().groundClickReady, true); }); -test('an unknown ground is measured only a few times, and not after the layer is off', (t) => { +test('an unknown ground is measured only a few times, and not after the layer is off', async (t) => { const saved = globalThis.document; globalThis.document = new EventTarget(); t.after(() => { globalThis.document = saved; }); + const harness = streetLevelMap(); + await harness.engine.maps.setPhotorealTiles({ kind: 'photoreal' }); t.mock.timers.enable({ apis: ['setTimeout'] }); - const viewer = fakeViewer(); let samples = 0; - viewer.camera.positionCartographic = { height: 4000 }; - viewer.scene.globe = { show: false }; - viewer.scene.sampleHeightSupported = true; - viewer.scene.sampleHeight = () => { + harness.engine.fake.setPose({ height: 4000 }); + harness.engine.fake.script('sampleHeight', () => { samples++; return undefined; - }; - const layer = createStreetLevelLayer({ providers: [fakeProvider()] }); - layer.init(viewer); - layer.enable(viewer); + }); + const layer = mountLayer(harness, [fakeProvider()]); + layer.enable(); t.after(() => layer.destroy()); t.mock.timers.tick(60_000); const measured = samples; assert.ok(measured > 1 && measured <= 12, `measured ${measured} times`); - viewer.camera.moveEnd.raiseEvent(); + harness.engine.fake.emit('camera.moveEnd'); layer.disable(); const atDisable = samples; t.mock.timers.tick(60_000); @@ -887,7 +832,7 @@ test('the nearest-image refusal is written in the language of the page', async ( }); test('an image opened from a ground click leaves the camera where it is', async (t) => { - const { layer, viewer } = await withStreetView(t); + const { layer, harness } = await withStreetView(t); layer.setProviderEnabled('google', true); await settle(); await layer.openNearest( @@ -895,7 +840,7 @@ test('an image opened from a ground click leaves the camera where it is', async { providerIds: ['google'], frame: false }, ); assert.equal(layer.getUIState().street.imageId, 'pano-a'); - assert.equal(viewer.flights.started, 0, 'no framing flight'); + assert.equal(harness.flights.started, 0, 'no framing flight'); }); test('off Google 3D not even Google\u2019s script is loaded for Street View', async (t) => { @@ -923,17 +868,18 @@ test('the button’s “centre of the view” is the ground under the middle of globalThis.document = Object.assign(new EventTarget(), { createElement: () => ({ getContext: () => drawing }), }); - const viewer = fakeViewer(); - const centre = Cesium.Cartesian3.fromDegrees(-121.4937, 38.5799, 0); - Object.assign(viewer.scene, { - pickPositionSupported: true, - pickPosition: (at) => (at.x === 500 && at.y === 300 ? centre : undefined), + const harness = streetLevelMap(); + const centre = geo.cartesian3.fromDegrees(-121.4937, 38.5799, 0); + harness.engine.fake.script('pickPosition', (at) => + at.x === 500 && at.y === 300 ? centre : undefined, + ); + harness.engine.fake.setSize(1000, 600); + const layer = create({ + providers: [provider], + services: { scenePick, createDisplay: () => harness.map }, }); - viewer.scene.canvas.clientWidth = 1000; - viewer.scene.canvas.clientHeight = 600; - const layer = create({ providers: [provider], services: { scenePick } }); - layer.init(viewer); - layer.enable(viewer); + layer.init(null, { engine: harness.engine }); + layer.enable(); t.after(() => { layer.destroy(); globalThis.document = saved; @@ -959,18 +905,17 @@ test('the button aimed at the sky opens nothing, and says to aim at a street', a globalThis.document = Object.assign(new EventTarget(), { createElement: () => ({ getContext: () => drawing }), }); - const viewer = fakeViewer(); + const harness = streetLevelMap(); // Every ground pick misses: the middle of the screen is sky. - Object.assign(viewer.scene, { - pickPositionSupported: true, - pickPosition: () => undefined, + harness.engine.fake.script('pickPosition', undefined); + harness.engine.fake.script('pickEllipsoid', undefined); + harness.engine.fake.setSize(1000, 600); + const layer = create({ + providers: [provider], + services: { scenePick, createDisplay: () => harness.map }, }); - viewer.camera.pickEllipsoid = () => undefined; - viewer.scene.canvas.clientWidth = 1000; - viewer.scene.canvas.clientHeight = 600; - const layer = create({ providers: [provider], services: { scenePick } }); - layer.init(viewer); - layer.enable(viewer); + layer.init(null, { engine: harness.engine }); + layer.enable(); t.after(() => { layer.destroy(); globalThis.document = saved; @@ -1009,7 +954,7 @@ test('a failed lookup’s error goes when another source is lit; a provider can }, }); const other = fakeProvider({ id: 'other', pickPrefix: 'oth:' }); - const { layer, viewer } = await enabledLayer(t, [failing, other]); + const { layer, harness } = await enabledLayer(t, [failing, other]); layer.attachViewerHost({}); await layer.openNearest({ lat: 1, lon: 1 }); assert.match(layer.getUIState().street.error, /did not answer/); @@ -1019,18 +964,18 @@ test('a failed lookup’s error goes when another source is lit; a provider can // A provider's own open (KartaView's ‹ ›) can leave the camera alone. await other.context().actions.openImage('img-1', { frame: false }); assert.deepEqual(other.calls.open, ['img-1']); - assert.equal(viewer.flights.started, 0); + assert.equal(harness.flights.started, 0); }); test('a provider’s own open can leave the camera where it is; a default open frames', async (t) => { const provider = posingProvider(); - const { layer, viewer } = await enabledLayer(t, [provider]); + const { layer, harness } = await enabledLayer(t, [provider]); layer.attachViewerHost({}); await provider.context().actions.openImage('img-1', { frame: false }); assert.deepEqual(provider.calls.open, ['img-1']); - assert.equal(viewer.flights.started, 0, 'frame: false keeps the camera'); + assert.equal(harness.flights.started, 0, 'frame: false keeps the camera'); await provider.context().actions.openImage('img-2'); - assert.equal(viewer.flights.started, 1, 'a default open frames the image'); + assert.equal(harness.flights.started, 1, 'a default open frames the image'); }); test('a provider’s openNearest (a line click) asks only that provider and leaves the camera', async (t) => { @@ -1040,7 +985,7 @@ test('a provider’s openNearest (a line click) asks only that provider and leav pickPrefix: 'oth:', nearestImage: async () => 'other-near', }); - const { layer, viewer } = await enabledLayer(t, [lit, other]); + const { layer, harness } = await enabledLayer(t, [lit, other]); layer.attachViewerHost({}); const point = { lat: 38.58, lon: -121.49 }; // The other source is off: its click asks nobody else. @@ -1050,7 +995,7 @@ test('a provider’s openNearest (a line click) asks only that provider and leav assert.deepEqual(other.calls.open, []); await lit.context().actions.openNearest(point); assert.deepEqual(lit.calls.open, ['lit-near']); - assert.equal(viewer.flights.started, 0, 'the user picked the spot'); + assert.equal(harness.flights.started, 0, 'the user picked the spot'); }); test('KartaView through the core: its lines counted as streets', async (t) => { @@ -1183,3 +1128,92 @@ test('filter-only updates keep the lit source, even a chosen keyless Mapillary', ); } }); + +// ── The whole layer on the Cesium engine ──────────────────────────────── + +test('on the Cesium engine the layer drapes coverage, selects a line on a click and draws its cones', async (t) => { + const { encodeCoverageTile } = + await import('./providers/mapillary/coverageFixture.mjs'); + const { tileBounds } = await import('./tileMath.js'); + const { cesiumStreetLevelMap } = + await import('../../testSupport/streetLevelFakes.mjs'); + const saved = globalThis.document; + globalThis.document = new EventTarget(); + const harness = cesiumStreetLevelMap(); + // One sequence across the middle of every tile asked for. + const source = fakeMapillarySource({ + getTile: async (_layer, z, x, y) => { + const { west, east, south, north } = tileBounds(x, y, z); + const lat = (south + north) / 2; + return encodeCoverageTile( + { x, y, z }, + { + sequences: [ + { + id: 'seq-1', + parts: [ + [ + [west + (east - west) * 0.25, lat], + [west + (east - west) * 0.75, lat], + ], + ], + }, + ], + }, + ); + }, + getSequenceImages: async () => [ + { + id: 'img-1', + geometry: { coordinates: [-121.4944, 38.5816] }, + compass_angle: 90, + captured_at: 1_700_000_000_000, + }, + ], + }); + const layer = mountLayer(harness, [ + createMapillaryProvider({ source, photoViewer: fakePhotoViewer() }), + ]); + layer.enable(); + t.after(() => { + layer.destroy(); + globalThis.document = saved; + }); + await settle(); + await settle(); + const lines = harness.groundSets('mapillary-coverage:'); + assert.ok(lines.length > 0, 'coverage drawn'); + assert.ok( + harness.engine.ground + .list() + .some((entry) => entry.id === lines[0].id && entry.count > 0), + 'through the engine’s ground sets', + ); + assert.equal(lines[0].options.drape, 'both'); + assert.ok(lines[0].items.every((item) => item.pickId === 'mly:seq:seq-1')); + // Cesium answers the click's pick in a 9 px window. + const windows = []; + const pick = harness.viewer.scene.pick; + harness.viewer.scene.pick = (position, width, height) => { + windows.push([width, height]); + return pick(position, width, height); + }; + harness.click({ id: 'mly:seq:seq-1', primitive: {} }); + assert.deepEqual(windows, [[9, 9]]); + await settle(); + assert.equal(layer.getUIState().sequence.selectedId, 'seq-1'); + assert.ok( + harness.groundSets('mapillary-coverage:selected').length > 0, + 'highlighted', + ); + harness.frame(); + assert.deepEqual( + harness.spritesShown('street-level:mapillary-cones').map((s) => s.pickId), + ['mly:img:img-1'], + 'its cone drawn', + ); + layer.disable(); + assert.equal(harness.groundSets('mapillary-coverage:').length, 0); + harness.frame(); + assert.equal(harness.spritesShown('street-level:').length, 0); +}); diff --git a/src/layers/streetLevel/lineCoverage.js b/src/layers/streetLevel/lineCoverage.js index 619a09219..83797f77f 100644 --- a/src/layers/streetLevel/lineCoverage.js +++ b/src/layers/streetLevel/lineCoverage.js @@ -1,11 +1,13 @@ -import * as Cesium from 'cesium'; import { passesImageryFilter } from './filter.js'; import { groundUnderCamera, viewFocus, visibleBbox } from './view.js'; import { tilesForBbox } from './tileMath.js'; import { msg } from '../../i18n/index.js'; -/** Sequences per primitive: smaller batches build, and show, sooner. */ -const SEQUENCE_PRIMITIVE_BATCH = 120; +/** Line alpha of the coverage; the selected sequence is drawn opaque. */ +const COVERAGE_ALPHA = 0.92; +/** Draw order of the ground sets: the selected sequence over the coverage. */ +const COVERAGE_ORDER = 0; +const HIGHLIGHT_ORDER = 1; /** Hint above the street-zoom ceiling, where no coverage is drawn. */ export const ZOOM_IN_HINT = msg('Zoom in to see street-level coverage'); @@ -45,48 +47,10 @@ export function freshCoverage({ keyless = false } = {}) { blockTimer: null, debounceTimer: null, removeCameraListener: null, - terrainReady: null, hint: '', }; } -/** Line instances for sequences' parts, every part picking its sequence. */ -function lineInstances(sequences, color, width, prefix) { - const attributes = { - color: Cesium.ColorGeometryInstanceAttribute.fromColor(color), - }; - const instances = []; - for (const sequence of sequences) - for (const coordinates of sequence.parts) { - let positions; - try { - positions = Cesium.Cartesian3.fromDegreesArray(coordinates.flat()); - } catch { - continue; - } - if (positions.length < 2) continue; - instances.push( - new Cesium.GeometryInstance({ - geometry: new Cesium.GroundPolylineGeometry({ positions, width }), - id: `${prefix}${sequence.id}`, - attributes, - }), - ); - } - return instances; -} - -/** Draped lines that follow terrain and 3D tiles (Google 3D) alike. */ -function linePrimitive(geometryInstances, asynchronous) { - return new Cesium.GroundPolylinePrimitive({ - geometryInstances, - appearance: new Cesium.PolylineColorAppearance(), - classificationType: Cesium.ClassificationType.BOTH, - asynchronous, - allowPicking: true, - }); -} - function pointsOf(sequence) { let count = 0; for (const part of sequence.parts) count += part.length; @@ -126,11 +90,11 @@ export function coverageZoom(level) { /** * Camera-driven sequence lines from any XYZ line-tile source, near the - * ground only, draped on terrain and 3D tiles alike. Nothing is drawn from - * orbit. + * ground only, draped on terrain and 3D tiles alike (the display's ground + * corridors, render/display.js). Nothing is drawn from orbit. * * @param {object} options - * @param {object} options.state Provider state: `context`, `services`, `viewer`, `coverage` (freshCoverage), `sequence`. + * @param {object} options.state Provider state: `context`, `services`, `map` (the display), `coverage` (freshCoverage), `sequence`. * @param {(tile: {x: number, y: number, z: number}, init: {signal: AbortSignal}) => Promise<{sequences: Array}>} options.fetchTile * A decoded tile; sequences are `{id, parts, capturedAt, isPano}`. A * rejection may carry `keyRequired`, `keyRejected` or `retryAfterSec`. @@ -165,12 +129,6 @@ export function createLineCoverage({ render?.governorRequestRender?.(renderKey); } - function ensureTerrainReady() { - if (state.viewer?.engine) return; - return (state.coverage.terrainReady ||= - Cesium.GroundPolylinePrimitive.initializeTerrainHeights()); - } - function tileKey({ x, y, z }) { return `${z}/${x}/${y}`; } @@ -209,35 +167,25 @@ export function createLineCoverage({ return drawn; } - /** Batched draped primitives for a tile's sequences. */ - function buildSequencePrimitives(sequences) { - const instances = lineInstances( - sequences, - Cesium.Color.fromCssColorString(colors.coverage).withAlpha(0.92), - tuning.lineWidthPx, - pickPrefix, + /** The display's lines for `sequences` (`color` CSS, at `alpha`), or null. */ + function drawLines(sequences, id, style) { + return ( + state.map?.sequenceLines?.(sequences, { + id: `${renderKey}:${id}`, + pickPrefix, + ...style, + }) ?? null ); - const primitives = []; - for (let i = 0; i < instances.length; i += SEQUENCE_PRIMITIVE_BATCH) - primitives.push( - linePrimitive(instances.slice(i, i + SEQUENCE_PRIMITIVE_BATCH), true), - ); - return primitives; } /** * Draw the selected sequence again over the coverage, in the selection * colour, from every loaded tile it crosses; none when nothing is selected. - * Built at once: its tiles loaded, so the terrain table has too. */ function highlight(sequenceId = state.sequence.selectedId) { - const scene = state.viewer?.scene; - if (state.coverage.highlight) { - if (state.viewer?.engine) state.coverage.highlight.destroy(); - else scene?.groundPrimitives?.remove(state.coverage.highlight); - state.coverage.highlight = null; - } - if (!sequenceId || (!state.viewer?.engine && !scene)) return; + state.coverage.highlight?.destroy(); + state.coverage.highlight = null; + if (!sequenceId || !state.map) return; // From every loaded line, drawn or not: a sequence selected after its // tiles were drawn may have been left out by their caps. const current = filter(); @@ -246,56 +194,36 @@ export function createLineCoverage({ const sequence = entry.sequenceList.find(({ id }) => id === sequenceId); if (sequence && passes(sequence, current)) parts.push(...sequence.parts); } - if (state.viewer?.engine) { - state.coverage.highlight = state.viewer.lines( - [{ id: sequenceId, parts }], - colors.selected, - tuning.lineWidthPx + 1, - pickPrefix, - ); - requestRender(); - return; - } - const instances = lineInstances( + state.coverage.highlight = drawLines( [{ id: sequenceId, parts }], - Cesium.Color.fromCssColorString(colors.selected), - tuning.lineWidthPx + 1, - pickPrefix, - ); - if (!instances.length) return; - state.coverage.highlight = scene.groundPrimitives.add( - linePrimitive(instances, false), + 'selected', + { + color: colors.selected, + widthPx: tuning.lineWidthPx + 1, + order: HIGHLIGHT_ORDER, + }, ); requestRender(); } - function attachPrimitive(entry) { - const scene = state.viewer?.scene; - if (!scene && !state.viewer?.engine) return; + function attachPrimitive(entry, key) { + if (!state.map) return; // Lookup, picking and counts follow what is drawn, not the whole tile. const sequences = drawnSequences(entry); entry.sequences = new Map(sequences.map((s) => [s.id, s])); - if (state.viewer?.engine) { - const group = state.viewer.lines( - sequences, - colors.coverage, - tuning.lineWidthPx, - pickPrefix, - ); - entry.primitives = group ? [group] : []; - return; - } - entry.primitives = buildSequencePrimitives(sequences); - for (const primitive of entry.primitives) - scene.groundPrimitives.add(primitive); + const lines = drawLines(sequences, key, { + color: colors.coverage, + alpha: COVERAGE_ALPHA, + widthPx: tuning.lineWidthPx, + order: COVERAGE_ORDER, + }); + entry.primitives = lines ? [lines] : []; } function detachPrimitive(entry) { - const scene = state.viewer?.scene; for (const primitive of entry.primitives || []) { try { - if (state.viewer?.engine) primitive.destroy(); - else scene?.groundPrimitives?.remove(primitive); + primitive.destroy(); } catch { /* already gone */ } @@ -318,12 +246,7 @@ export function createLineCoverage({ state.coverage.pending.set(key, controller); notify(); try { - // Fetch and the one-time terrain-height table load run side by side, - // both handled from the start so an early tile failure is not unhandled. - const [decoded] = await Promise.all([ - fetchTile(tile, { signal: controller.signal }), - ensureTerrainReady(), - ]); + const decoded = await fetchTile(tile, { signal: controller.signal }); // Only an abort, a zoom change, a clear or a newer request for this tile // discards it; a refresh that still wants the tile keeps it. if ( @@ -341,7 +264,7 @@ export function createLineCoverage({ ? sequences : sequences.sort((a, b) => (b.capturedAt || 0) - (a.capturedAt || 0)); entry.total = sequences.length; - attachPrimitive(entry); + attachPrimitive(entry, key); state.coverage.tiles.set(key, entry); state.coverage.lastError = null; // Over the new lines, and along any of the selection's parts they add. @@ -417,11 +340,9 @@ export function createLineCoverage({ * The tiles for the camera: what the screen shows within range of it, at * the zoom for its height above ground, nearest the camera first. */ - function wantedTiles(viewer) { - const ground = groundUnderCamera(viewer); - const cameraHeight = - viewer.engine?.camera.pose().cartographic.height ?? - viewer.camera?.positionCartographic?.height; + function wantedTiles(map) { + const ground = groundUnderCamera(map); + const cameraHeight = map.engine?.camera.height(); const height = Number.isFinite(cameraHeight) ? cameraHeight - (ground ?? 0) : null; @@ -436,28 +357,28 @@ export function createLineCoverage({ height * tuning.viewRangePerHeight, ), }; - const bbox = visibleBbox(viewer, ranged); + const bbox = visibleBbox(map, ranged); if (!bbox) return { hint: NO_GROUND_HINT }; // Ranked from between the camera's ground point and the screen centre's: // a tilted view's box centre can sit kilometres past both. const tiles = tilesForBbox(bbox, zoom, { limit: tuning.maxTiles, - from: viewFocus(viewer, ranged), + from: viewFocus(map, ranged), }); return { zoom, tiles }; } /** Recompute the tile set for the current camera and reconcile primitives. */ function refresh() { - const viewer = state.viewer; + const map = state.map; if ( - !viewer || + !map || state.context.waitingForView?.() || !state.context.isActive() || state.coverage.blocked ) return; - const { zoom, tiles, hint } = wantedTiles(viewer); + const { zoom, tiles, hint } = wantedTiles(map); if (hint) { state.coverage.hint = hint; clear(); @@ -485,10 +406,11 @@ export function createLineCoverage({ state.coverage.debounceTimer = setTimeout(refresh, tuning.moveDebounceMs); } - function attach(viewer) { + function attach(map) { detach(); - const remove = viewer.camera.changed.addEventListener(scheduleRefresh); - const removeEnd = viewer.camera.moveEnd.addEventListener(scheduleRefresh); + const { camera } = map.engine; + const remove = camera.changed.addEventListener(scheduleRefresh); + const removeEnd = camera.moveEnd.addEventListener(scheduleRefresh); state.coverage.removeCameraListener = () => { remove(); removeEnd(); @@ -515,9 +437,9 @@ export function createLineCoverage({ /** Rebuild every loaded tile from its decoded lines (after a filter change). */ function rebuild() { - for (const entry of state.coverage.tiles.values()) { + for (const [key, entry] of state.coverage.tiles) { detachPrimitive(entry); - attachPrimitive(entry); + attachPrimitive(entry, key); } highlight(); requestRender(); diff --git a/src/layers/streetLevel/lineCoverage.test.mjs b/src/layers/streetLevel/lineCoverage.test.mjs index 60bae7eb3..02a4b0c81 100644 --- a/src/layers/streetLevel/lineCoverage.test.mjs +++ b/src/layers/streetLevel/lineCoverage.test.mjs @@ -1,6 +1,5 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import * as Cesium from 'cesium'; import { coverageZoom, createLineCoverage, @@ -10,7 +9,11 @@ import { import { resolveFilter } from './filter.js'; import { encodeCoverageTile } from './providers/mapillary/coverageFixture.mjs'; import { decodeCoverageTile } from './providers/mapillary/decode.js'; -import { rayCamera } from '../../testSupport/streetLevelFakes.mjs'; +import { + cameraEngine, + coverageMap, + streetLevelMap, +} from '../../testSupport/streetLevelFakes.mjs'; import { COLORS, COVERAGE_TUNING, @@ -50,26 +53,9 @@ function createCoverage({ state, source, zoomFor = coverageZoomForHeight }) { } const RAD = Math.PI / 180; -// Node has no WebGL context to report line-width limits; a browser's -// context sets these on startup, and appearances validate against them. -Cesium.ContextLimits._minimumAliasedLineWidth = 1; -Cesium.ContextLimits._maximumAliasedLineWidth = 10; const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); - -/** A scene collection that remembers what is on the globe. */ -function collection() { - const items = new Set(); - return { - items, - add(primitive) { - items.add(primitive); - return primitive; - }, - remove(primitive) { - return items.delete(primitive); - }, - }; -} +/** Ground sets of the Mapillary coverage (and its highlight). */ +const PREFIX = 'mapillary-coverage:'; /** The centre of the z14 tile holding a point, so a small view is one tile. */ function tileCentre(lon, lat) { @@ -83,34 +69,6 @@ function tileCentre(lon, lat) { }; } -/** A viewer looking straight down on a 0.004° square around `view`. */ -function fakeViewer(view) { - return { - view, - scene: { - canvas: { clientWidth: 100, clientHeight: 100 }, - globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 }, - groundPrimitives: collection(), - primitives: collection(), - }, - camera: { - get positionCartographic() { - return { - longitude: view.lon * RAD, - latitude: view.lat * RAD, - height: view.height, - }; - }, - pickEllipsoid(point) { - return Cesium.Cartesian3.fromDegrees( - view.lon - 0.002 + (point.x / 100) * 0.004, - view.lat + 0.002 - (point.y / 100) * 0.004, - ); - }, - }, - }; -} - /** A source whose tile requests the test resolves, one call at a time. */ function deferredSource() { const calls = []; @@ -124,12 +82,17 @@ function deferredSource() { }; } -function setup({ zoomFor } = {}) { +/** + * Coverage over a display looking straight down on a 0.004° square at a z14 + * tile centre (`harness`: another display), its tile requests deferred. + */ +function setup({ zoomFor, harness = null } = {}) { const centre = tileCentre(-121.4944, 38.5816); - const viewer = fakeViewer({ lon: centre.lon, lat: centre.lat, height: 900 }); + const viewer = + harness ?? coverageMap({ lon: centre.lon, lat: centre.lat, height: 900 }); const source = deferredSource(); const state = { - viewer, + map: viewer.map, services: {}, enabled: true, providerOn: true, @@ -141,9 +104,8 @@ function setup({ zoomFor } = {}) { }, coverage: { ...freshCoverage(), - // The key status is in; skip Cesium's one-time terrain table download. + // The key status is in. blocked: null, - terrainReady: Promise.resolve(), }, sequence: { selectedId: null }, }; @@ -162,9 +124,7 @@ function setup({ zoomFor } = {}) { ], }); const tileKey = `14/${centre.tile.x}/${centre.tile.y}`; - const onGlobe = () => - viewer.scene.groundPrimitives.items.size + - viewer.scene.primitives.items.size; + const onGlobe = () => viewer.groundSets().length + viewer.moverSets().length; return { viewer, source, state, coverage, bytes, tileKey, onGlobe, centre }; } @@ -205,18 +165,47 @@ test('a superseded tile request cannot strand lines on the globe', async () => { }); test('sequence lines are draped on the globe and on 3D tiles alike', async () => { - const { viewer, source, coverage, bytes } = setup(); + const { viewer, source, coverage, bytes, centre, tileKey } = setup(); coverage.refresh(); source.calls[0].resolve(bytes); await settle(); - const { groundPrimitives, primitives } = viewer.scene; - assert.equal(primitives.items.size, 0); - assert.equal(groundPrimitives.items.size, 1); - const [line] = groundPrimitives.items; - assert.ok(line instanceof Cesium.GroundPolylinePrimitive); - assert.equal(line.classificationType, Cesium.ClassificationType.BOTH); + assert.equal(viewer.moverSets().length, 0); + const sets = viewer.groundSets(PREFIX); + assert.equal(sets.length, 1); + const [line] = sets; + assert.ok(line.id.startsWith(`${PREFIX}${tileKey}#`), line.id); + assert.deepEqual(line.options, { + id: line.id, + kind: 'corridor', + order: 0, + pickable: true, + drape: 'both', + }); + // The provider colour at 0.92, 2.5 px, every part picking its sequence. + const [r, g, b] = [0x05 / 255, 0xcb / 255, 0x63 / 255]; + assert.equal(line.items.length, 1); + const [item] = line.items; + assert.deepEqual( + { ...item, positions: undefined }, + { + positions: undefined, + color: [r, g, b, 0.92], + widthPx: COVERAGE_TUNING.lineWidthPx, + pickId: `${PICK_PREFIX.sequence}seq-1`, + }, + ); + // The decoded line, as the tile encodes it (to its quantisation). + const expected = [ + [centre.lon - 0.001, centre.lat], + [centre.lon + 0.001, centre.lat], + ]; + assert.equal(item.positions.length, 2); + item.positions.forEach(([lon, lat], index) => { + assert.ok(Math.abs(lon - expected[index][0]) < 1e-5); + assert.ok(Math.abs(lat - expected[index][1]) < 1e-5); + }); coverage.clear(); - assert.equal(groundPrimitives.items.size, 0); + assert.equal(viewer.groundSets().length, 0); }); test('panning off a tile that is still loading stops counting it as loading', async () => { @@ -254,7 +243,7 @@ test('a zoomFor in the older {zoom, kind} shape still draws its lines', async () assert.equal(source.calls[0].z, 14); source.calls[0].resolve(bytes); await settle(); - assert.equal(viewer.scene.groundPrimitives.items.size, 1, 'its line drawn'); + assert.equal(viewer.groundSets(PREFIX).length, 1, 'its line drawn'); assert.equal(coverage.stats().hint, ''); coverage.clear(); }); @@ -276,11 +265,11 @@ test('from orbit nothing is requested or drawn, and the hint says to zoom in', a coverage.refresh(); source.calls[0].resolve(bytes); await settle(); - assert.ok(viewer.scene.groundPrimitives.items.size > 0, 'street lines'); + assert.ok(viewer.groundSets(PREFIX).length > 0, 'street lines'); viewer.view.height = 20_000_000; coverage.refresh(); assert.equal(source.calls.length, 1, 'no tile is asked for'); - assert.equal(viewer.scene.groundPrimitives.items.size, 0, 'nothing drawn'); + assert.equal(viewer.groundSets(PREFIX).length, 0, 'nothing drawn'); assert.equal(state.coverage.zoom, null); assert.equal(coverage.stats().hint, ZOOM_IN_HINT); viewer.view.height = 900; @@ -447,24 +436,10 @@ test('a sequence that crosses a tile edge is counted once', async () => { coverage.clear(); }); -/** A camera over a 100×100 canvas with a 60°×40° view (see rayCamera). */ -function tiltedCamera({ lon, lat, height, heading, pitch }) { - return rayCamera({ - lon, - lat, - altitude: height, - heading, - pitch, - width: 100, - height: 100, - fovX: 60, - fovY: 40, - }); -} - /** * Denver: 300 m above a street 1,600 m up, at a z14 tile centre, looking east - * at ground `aheadM` metres out; the globe terrain knows the ground. + * at ground `aheadM` metres out, with a 60°×40° view; the globe terrain knows + * the ground. */ function denverStreetView(aheadM) { const z = 14; @@ -475,20 +450,21 @@ function denverStreetView(aheadM) { const lat = (tile.south + tile.north) / 2; const ground = 1600; const above = 300; - const context = setup(); - context.viewer.scene.globe = { - show: true, - ellipsoid: Cesium.Ellipsoid.WGS84, - getHeight: () => ground, - }; const tileWidthM = (tile.east - tile.west) * 111_320 * Math.cos(lat * RAD); - context.viewer.camera = tiltedCamera({ + // 40° high; the width keeps 60° across. + const aspect = Math.tan(30 * RAD) / Math.tan(20 * RAD); + const engine = cameraEngine({ lon, lat, - height: ground + above, + altitude: ground + above, heading: 90, pitch: -Math.atan(above / aheadM(tileWidthM)) / RAD, + width: Math.round(1000 * aspect), + height: 1000, + fovY: 40, }); + engine.fake.script('terrainHeight', () => ground); + const context = setup({ harness: streetLevelMap({ engine }) }); context.coverage.refresh(); const keys = context.source.calls.map((call) => call.key); /** Requested tiles as [dx, dy] from the tile under the camera. */ @@ -531,13 +507,22 @@ test('the selected sequence is drawn over the coverage, and goes with the select coverage.refresh(); source.calls[0].resolve(bytes); await settle(); - const lines = () => [...viewer.scene.groundPrimitives.items]; + const lines = () => viewer.groundSets(PREFIX); assert.equal(lines().length, 1, 'the coverage'); coverage.highlight('seq-1'); assert.equal(lines().length, 2); const highlight = lines().at(-1); - assert.equal(state.coverage.highlight, highlight, 'drawn last, on top'); - assert.equal(highlight.classificationType, Cesium.ClassificationType.BOTH); + assert.deepEqual(state.coverage.highlight.ids(), [highlight.id]); + assert.ok(highlight.id.startsWith(`${PREFIX}selected#`)); + // Over the coverage, in the selection colour, a pixel wider. + assert.ok(highlight.options.order > lines()[0].options.order, 'on top'); + assert.equal(highlight.options.drape, 'both'); + assert.deepEqual( + highlight.items.map((item) => item.color), + [[0, 0xd4 / 255, 1, 1]], + ); + assert.equal(highlight.items[0].widthPx, COVERAGE_TUNING.lineWidthPx + 1); + assert.equal(highlight.items[0].pickId, `${PICK_PREFIX.sequence}seq-1`); coverage.highlight('not-drawn'); assert.equal(lines().length, 1, 'nothing to highlight off the map'); coverage.highlight(null); @@ -554,10 +539,9 @@ test('a tile that lands with the selected sequence draws the highlight again, on source.calls[0].resolve(bytes); await settle(); assert.ok(state.coverage.highlight, 'highlighted once its tile is in'); - assert.equal( - [...viewer.scene.groundPrimitives.items].at(-1), - state.coverage.highlight, - ); + const [coverageSet, highlightSet] = viewer.groundSets(PREFIX); + assert.deepEqual(state.coverage.highlight.ids(), [highlightSet.id]); + assert.ok(highlightSet.options.order > coverageSet.options.order); coverage.clear(); }); @@ -566,12 +550,12 @@ test("a zoom change drops the old zoom's lines", async () => { coverage.refresh(); source.calls[0].resolve(bytes); await settle(); - const [old] = viewer.scene.groundPrimitives.items; + const [old] = viewer.groundSets(PREFIX); assert.ok(old, 'the z14 lines are drawn'); viewer.view.height = 3000; // zoom out to z13 coverage.refresh(); assert.equal(state.coverage.zoom, 13); - assert.equal(viewer.scene.groundPrimitives.items.has(old), false); + assert.equal(old.live, false); assert.ok(state.coverage.pending.size > 0, 'the new zoom is loading'); coverage.clear(); }); @@ -600,12 +584,8 @@ test('a rate limit holds every refresh inside its wait, not only the first', asy coverage.unblock(); }); -test('a tile that fails before the terrain table loads is reported at once, never unhandled', async () => { +test('a tile that fails is reported at once, never unhandled', async () => { const { source, state, coverage, tileKey } = setup(); - let terrainLoaded; - state.coverage.terrainReady = new Promise((resolve) => { - terrainLoaded = resolve; - }); const unhandled = []; const onUnhandled = (reason) => unhandled.push(reason); process.on('unhandledRejection', onUnhandled); @@ -618,8 +598,6 @@ test('a tile that fails before the terrain table loads is reported at once, neve assert.equal(state.coverage.lastError, 'Mapillary tiles HTTP 500'); assert.deepEqual(unhandled, []); } finally { - terrainLoaded(); - await settle(); process.off('unhandledRejection', onUnhandled); } }); diff --git a/src/layers/streetLevel/marker.js b/src/layers/streetLevel/marker.js index 1ab125a44..75e6fcaaf 100644 --- a/src/layers/streetLevel/marker.js +++ b/src/layers/streetLevel/marker.js @@ -1,7 +1,5 @@ -import * as Cesium from 'cesium'; import { positionMarkerGlyph, viewConeMarkerGlyph } from './glyphs.js'; -import { COLORS, POSITION_PICK_ID } from './policy.js'; -import { createHorizonCull } from './view.js'; +import { COLORS, MARKER_SPRITE_ORDER, POSITION_PICK_ID } from './policy.js'; const SPRITE_ID = 'street-level:marker'; @@ -21,32 +19,22 @@ function markerLook(style) { }; } -/** On-globe marker for the open image, clamped to the ground. */ +/** + * On-globe marker for the open image, on the ground, turned to its bearing + * (a sprite of the display, render/display.js). + */ export function createMarker({ state }) { - const { render, sprites } = state.services; + const { render } = state.services; function requestRender() { render?.governorRequestRender?.('street-level-marker'); } - // It skips the depth test: hide it while it is behind the globe. - const horizon = createHorizonCull({ - getViewer: () => state.viewer, - items: () => (state.marker.billboard ? [state.marker.billboard] : []), - onChange: requestRender, - }); - - function ensure(viewer) { - if (state.marker.collection) return; - if (viewer?.engine) { - state.marker.collection = viewer.billboards(SPRITE_ID); - return; - } - state.marker.collection = new Cesium.BillboardCollection({ - scene: viewer.scene, + function ensure(view) { + if (state.marker.collection || !view?.sprites) return; + state.marker.collection = view.sprites(SPRITE_ID, { + order: MARKER_SPRITE_ORDER, }); - viewer.scene.primitives.add(state.marker.collection); - sprites?.registerSpriteCollection?.(SPRITE_ID, state.marker.collection); } /** Move (or create) the marker; null removes it. `style`: the provider's `marker`. */ @@ -56,51 +44,39 @@ export function createMarker({ state }) { if (!position) { collection.removeAll(); state.marker.billboard = null; - horizon.stop(); requestRender(); return; } - const cartesian = Cesium.Cartesian3.fromDegrees(position.lon, position.lat); const look = markerLook(style); if (state.marker.billboard && state.marker.look !== look.key) { - collection.remove?.(state.marker.billboard); + collection.remove(state.marker.billboard); state.marker.billboard = null; } + const place = { + lon: position.lon, + lat: position.lat, + bearing: bearing || 0, + }; if (!state.marker.billboard) { state.marker.look = look.key; state.marker.billboard = collection.add({ - id: POSITION_PICK_ID, - position: cartesian, + pickId: POSITION_PICK_ID, image: look.image, - imageId: `sl-position:${look.key}`, - width: look.size, - height: look.size, - alignedAxis: Cesium.Cartesian3.UNIT_Z, - heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - verticalOrigin: Cesium.VerticalOrigin.CENTER, + imageKey: `sl-position:${look.key}`, + sizePx: look.size, + ...place, }); - } else { - state.marker.billboard.position = cartesian; - } - state.marker.billboard.rotation = -Cesium.Math.toRadians(bearing || 0); - if (state.viewer?.engine) collection.changed(); - else horizon.update([state.marker.billboard]); + } else collection.move(state.marker.billboard, place); requestRender(); } function setVisible(visible) { - if (state.marker.collection) state.marker.collection.show = visible; + state.marker.collection?.show(visible); requestRender(); } - function destroy(viewer) { - horizon.stop(); - const collection = state.marker.collection; - if (!collection) return; - sprites?.unregisterSpriteCollection?.(SPRITE_ID, collection); - if (viewer?.engine) collection.destroy(); - else viewer?.scene?.primitives?.remove(collection); + function destroy() { + state.marker.collection?.destroy(); state.marker.collection = null; state.marker.billboard = null; } diff --git a/src/layers/streetLevel/marker.test.mjs b/src/layers/streetLevel/marker.test.mjs index d0a1e486a..b81178835 100644 --- a/src/layers/streetLevel/marker.test.mjs +++ b/src/layers/streetLevel/marker.test.mjs @@ -1,112 +1,124 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; import { createMarker } from './marker.js'; +import { MARKER_SPRITE_ORDER, POSITION_PICK_ID } from './policy.js'; +import { STREET_LEVEL_MAPS } from '../../testSupport/streetLevelFakes.mjs'; -/** A viewer whose camera is 20,000 km over `view`, and its pre-render listeners. */ -function setup() { - const view = { lon: -121.49, lat: 38.58, height: 20_000_000 }; - const preRender = new Set(); - const viewer = { - camera: { - get positionWC() { - return Cesium.Cartesian3.fromDegrees(view.lon, view.lat, view.height); - }, - }, - scene: { - primitives: { add: (p) => p, remove() {} }, - preRender: { - addEventListener(listener) { - preRender.add(listener); - return () => preRender.delete(listener); - }, - }, - }, - }; +const SET = 'street-level:marker'; + +/** The marker on the display of `harness` (an engine looking down on Sacramento). */ +function setup(harness) { const state = { services: {}, - viewer, - marker: { - // Stands in for the billboard collection; ensure() keeps it. - collection: { - show: true, - add: (options) => ({ ...options }), - remove() {}, - removeAll() {}, - }, - billboard: null, - }, + map: harness.map, + marker: { collection: null, billboard: null, look: null }, }; const marker = createMarker({ state }); - const frame = () => { - for (const listener of [...preRender]) listener(); + marker.ensure(harness.map); + /** The marker's drawn instance after a frame, or undefined. */ + const drawn = () => { + harness.frame(); + return harness.spritesShown(SET)[0]; }; - return { state, view, preRender, marker, frame }; + return { state, marker, drawn }; } -test('the photo marker is hidden behind the globe, and shows from its side', () => { - const { state, view, preRender, marker, frame } = setup(); - // Placed on the far side of the Earth from the camera. - marker.set({ lon: 58.51, lat: -38.58 }, 90); - const billboard = state.marker.billboard; - assert.equal(billboard.show, false, 'not drawn through the globe'); - // Fly round to its side: it shows. - view.lon = 58.51; - view.lat = -38.58; - frame(); - assert.equal(billboard.show, true); - // Moved to the far side again, without the camera moving. - marker.set({ lon: -121.49, lat: 38.58 }, 0); - assert.equal(billboard.show, false, 'culled where it now stands'); - marker.clear(); - assert.equal(preRender.size, 0, 'the cull listener is gone'); -}); +const degreesOf = (position) => { + const carto = geo.cartographic.fromCartesian(position); + return { + lon: geo.math.toDegrees(carto.longitude), + lat: geo.math.toDegrees(carto.latitude), + height: carto.height, + }; +}; -test('destroying the marker stops the horizon cull', () => { - const { marker, preRender } = setup(); - marker.set({ lon: -121.49, lat: 38.58 }, 0); - assert.equal(preRender.size, 1); - marker.destroy(); - assert.equal(preRender.size, 0); -}); +for (const create of STREET_LEVEL_MAPS) { + const name = create().name; -const SPOT = { lon: -121.49, lat: 38.58 }; + test(`the photo marker is hidden behind the globe, and shows from its side (${name})`, () => { + const harness = create(); + const { marker, drawn } = setup(harness); + // Placed on the far side of the Earth from the camera. + marker.set({ lon: 58.51, lat: -38.58 }, 90); + assert.equal(drawn(), undefined, 'not drawn through the globe'); + assert.equal(harness.moverSets(SET).length, 1, 'but placed'); + // Moved to the camera's side: it shows. + marker.set({ lon: -121.4944, lat: 38.5816 }, 0); + assert.ok(drawn()); + marker.clear(); + assert.equal(drawn(), undefined); + marker.destroy(); + assert.equal(harness.moverSets(SET).length, 0, 'nothing left drawn'); + }); -test('the marker is clamped to the ground and turned to the bearing', () => { - const { state, marker } = setup(); - marker.set(SPOT, 45); - const billboard = state.marker.billboard; - assert.equal( - billboard.heightReference, - Cesium.HeightReference.CLAMP_TO_GROUND, - ); - assert.equal(billboard.rotation, -Cesium.Math.toRadians(45)); - // Moving it keeps the same billboard. - marker.set({ lon: -121.5, lat: 38.6 }, 90); - assert.equal(state.marker.billboard, billboard); - const placed = Cesium.Cartographic.fromCartesian(billboard.position); - assert.ok(Math.abs(Cesium.Math.toDegrees(placed.longitude) + 121.5) < 1e-9); - assert.equal(billboard.rotation, -Cesium.Math.toRadians(90)); -}); + test(`the marker sits on the ground, over the scene, turned to the bearing (${name})`, () => { + const harness = create(); + const { state, marker, drawn } = setup(harness); + marker.set({ lon: -121.4944, lat: 38.5816 }, 90); + let sprite = drawn(); + const [set] = harness.moverSets(SET); + assert.equal(set.options.kind, 'sprite'); + assert.equal(set.options.depthTest, 'always'); + assert.equal(set.options.order, MARKER_SPRITE_ORDER); + assert.equal(set.options.pickable, true); + assert.equal(set.options.sprite.sizePx, 40); + assert.equal(sprite.pickId, POSITION_PICK_ID); + const placed = degreesOf(sprite.position); + assert.ok(Math.abs(placed.lon + 121.4944) < 1e-9); + assert.ok(Math.abs(placed.height) < 1e-6, 'on the terrain (0 m here)'); + // Looking straight down, north up: east is a quarter turn clockwise. + assert.ok(Math.abs(sprite.rotation + Math.PI / 2) < 1e-6); + // Moving it keeps the same sprite. + const row = state.marker.billboard; + marker.set({ lon: -121.4945, lat: 38.5817 }, 180); + assert.equal(state.marker.billboard, row); + sprite = drawn(); + assert.ok(Math.abs(degreesOf(sprite.position).lon + 121.4945) < 1e-9); + assert.ok(Math.abs(Math.abs(sprite.rotation) - Math.PI) < 1e-4); + marker.setVisible(false); + assert.equal(drawn(), undefined, 'hidden with the layer'); + marker.setVisible(true); + assert.ok(drawn()); + }); +} -test('a Street View photo gets a blue view cone; a Mapillary photo GEV\u2019s pin', () => { - const { state, marker } = setup(); - const removed = []; - state.marker.collection.remove = (billboard) => removed.push(billboard); +test('a Street View photo gets a blue view cone; a Mapillary photo GEV’s pin', () => { + const harness = STREET_LEVEL_MAPS[0](); + const { state, marker, drawn } = setup(harness); const streetView = { kind: 'view-cone', color: '#4285f4' }; - marker.set({ lon: -121.49, lat: 38.58 }, 45, streetView); + marker.set({ lon: -121.4944, lat: 38.5816 }, 45, streetView); const cone = state.marker.billboard; - assert.equal(cone.imageId, 'sl-position:view-cone:#4285f4'); - assert.equal( - cone.rotation, - -Cesium.Math.toRadians(45), + assert.equal(cone.imageKey, 'sl-position:view-cone:#4285f4'); + assert.equal(cone.sizePx, 48); + assert.ok( + Math.abs(drawn().rotation + Math.PI / 4) < 1e-6, 'it turns with the view', ); - // Dragging the panorama keeps the look and the billboard. - marker.set({ lon: -121.49, lat: 38.58 }, 90, streetView); + // Dragging the panorama keeps the look and the sprite. + marker.set({ lon: -121.4944, lat: 38.5816 }, 90, streetView); assert.equal(state.marker.billboard, cone); // A Mapillary photo next (its provider has no marker): the pin. - marker.set({ lon: -121.49, lat: 38.58 }, 90, undefined); - assert.equal(state.marker.billboard.imageId, 'sl-position:pin'); - assert.deepEqual(removed, [cone]); + marker.set({ lon: -121.4944, lat: 38.5816 }, 90, undefined); + assert.equal(state.marker.billboard.imageKey, 'sl-position:pin'); + drawn(); + assert.deepEqual( + harness.moverSets(SET).map((set) => set.id), + [`${SET}:sl-position:pin`], + 'the cone is gone', + ); +}); + +test('without a display the marker draws nothing and does not throw', () => { + const state = { + services: {}, + map: null, + marker: { collection: null, billboard: null, look: null }, + }; + const marker = createMarker({ state }); + marker.ensure(null); + marker.set({ lon: 0, lat: 0 }, 0); + marker.setVisible(true); + marker.destroy(); + assert.equal(state.marker.collection, null); }); diff --git a/src/layers/streetLevel/policy.js b/src/layers/streetLevel/policy.js index 4bf7f953d..948458c3c 100644 --- a/src/layers/streetLevel/policy.js +++ b/src/layers/streetLevel/policy.js @@ -14,6 +14,13 @@ export const CLICK_PICK_WINDOW_PX = 9; /** Pick id of the viewer position marker; the core owns it, not a provider. */ export const POSITION_PICK_ID = 'sl:pos'; +/** + * Sprite order (engine.movers): under the moving contacts, as map data; a + * sequence's image cones over the position marker. + */ +export const MARKER_SPRITE_ORDER = -1; +export const CONE_SPRITE_ORDER = -0.9; + /** One colour per imagery source: chip, coverage lines and cones. */ export const PROVIDER_COLORS = Object.freeze({ mapillary: '#05cb63', diff --git a/src/layers/streetLevel/providers/kartaview/index.js b/src/layers/streetLevel/providers/kartaview/index.js index 812897cea..4c00db1c1 100644 --- a/src/layers/streetLevel/providers/kartaview/index.js +++ b/src/layers/streetLevel/providers/kartaview/index.js @@ -94,7 +94,7 @@ export function createKartaViewProvider({ const state = { context, services: context.services, - viewer: null, + map: null, coverage: freshCoverage({ keyless: true }), sequence: { selectedId: null, @@ -445,23 +445,23 @@ export function createKartaViewProvider({ return { configured: true }; }, - init(cesiumViewer) { - state.viewer = cesiumViewer; - cones.ensureCollections(cesiumViewer); + init(map) { + state.map = map; + cones.ensureCollections(map); cones.setVisible(false); }, - activate(cesiumViewer) { - state.viewer = cesiumViewer; + activate(map) { + state.map = map; cones.setVisible(true); - coverage.attach(cesiumViewer); + coverage.attach(map); }, refreshCoverage: () => coverage.refresh(), detachMap() { coverage.releaseGraphics(); - cones.releaseCollection(state.viewer); - state.viewer = null; + cones.releaseCollection(); + state.map = null; }, repaintMap() { coverage.rebuild(); @@ -479,11 +479,11 @@ export function createKartaViewProvider({ masks.clear(); }, - destroy(cesiumViewer) { + destroy() { coverage.detach(); coverage.clear(); coverage.unblock(); - cones.destroy(cesiumViewer); + cones.destroy(); tiles.clear(); masks.clear(); }, diff --git a/src/layers/streetLevel/providers/kartaview/provider.test.mjs b/src/layers/streetLevel/providers/kartaview/provider.test.mjs index d992313bb..f0f47013f 100644 --- a/src/layers/streetLevel/providers/kartaview/provider.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/provider.test.mjs @@ -15,7 +15,8 @@ import { import { validateProviders } from '../../registry.js'; import { fakeHost, until } from '../../../../testSupport/kartaViewFakes.mjs'; import { - coverageViewer, + coverageMap, + streetLevelMap, tileCentre, } from '../../../../testSupport/streetLevelFakes.mjs'; @@ -402,33 +403,35 @@ test('archived photos are skipped: by the nearest lookup, and by › and ‹', a ); }); -/** A stand-in Cesium viewer the cones can draw on; `cones()` lists their photo indexes. */ +/** The display the cones draw on; `cones()` lists their first and last photo indexes. */ async function withCones(t, env) { - const Cesium = await import('cesium'); const drawing = new Proxy({}, { get: () => () => {}, set: () => true }); const saved = globalThis.document; globalThis.document = { createElement: () => ({ getContext: () => drawing }), }; t.after(() => (globalThis.document = saved)); - let collection = null; - env.instance.init({ - scene: { - frameState: { mode: Cesium.SceneMode.SCENE3D }, - updateHeight: () => () => {}, - getHeight: () => undefined, - primitives: { add: (c) => (collection = c), remove() {} }, - preRender: { addEventListener: () => () => {} }, - }, - camera: { positionWC: new Cesium.Cartesian3(0, 0, 1e8) }, - }); + const harness = streetLevelMap(); + // The cones are drawn hidden until the provider is switched on; they are + // read whether shown or not. + let group = null; + const sprites = harness.map.sprites; + harness.map.sprites = (...args) => (group = sprites(...args)); + env.instance.init(harness.map); + // The cones paint on the next frame. + const ids = () => { + group?.show(true); + harness.frame(); + return harness + .moverSets('street-level:kartaview-cones') + .flatMap((set) => set.instances) + .map((cone) => cone.pickId); + }; const cones = () => { - const indexes = []; - for (let i = 0; i < collection.length; i++) - indexes.push(Number(collection.get(i).id.split('-').at(-1))); + const indexes = ids().map((id) => Number(id.split('-').at(-1))); return [Math.min(...indexes), Math.max(...indexes)]; }; - cones.count = () => collection?.length ?? 0; + cones.count = () => ids().length; return cones; } @@ -733,10 +736,7 @@ function coverageSetup( return { query }; }; const centre = tileCentre(-83.0458, 42.3314); - const viewer = coverageViewer( - { lon: centre.lon, lat: centre.lat, height }, - t, - ); + const harness = coverageMap({ lon: centre.lon, lat: centre.lat, height }); const tiles = []; let hold = null; env.source.getTile = (tile) => { @@ -751,11 +751,12 @@ function coverageSetup( }; return hold ? hold.then(() => answer) : Promise.resolve(answer); }; - env.instance.init(viewer); - t.after(() => env.instance.destroy(viewer)); + env.instance.init(harness.map); + t.after(() => env.instance.destroy()); return { ...env, - viewer, + harness, + map: harness.map, centre, tiles, masks, @@ -779,8 +780,8 @@ function coverageSetup( } /** The camera came to rest: coverage refreshes once its debounce is over. */ -async function moved(viewer) { - viewer.camera.moveEnd.raiseEvent(); +async function moved(harness) { + harness.moveEnd(); await new Promise((resolve) => setTimeout(resolve, COVERAGE_TUNING.moveDebounceMs + 20), ); @@ -799,7 +800,7 @@ test('a filter thins the lines to those its PNG mask draws; without one they all // 360° photos lie on the middle line only. drawnRows: (query) => (query === 'fieldOfView=360' ? [[254, 258]] : []), }); - env.instance.activate(env.viewer); + env.instance.activate(env.map); assert.ok(await until(() => env.instance.coverageStats().count === 1)); assert.deepEqual(env.masks, ['fieldOfView=360']); // Unfiltered: every line, and the tile's lines are not asked for again. @@ -815,7 +816,7 @@ test('a filter thins the lines to those its PNG mask draws; without one they all env.instance.setFilter(); assert.ok(await until(() => env.instance.coverageStats().loading)); assert.equal( - env.viewer.scene.groundPrimitives.items.size, + env.harness.groundSets('kartaview-coverage:').length, 0, 'the unfiltered lines are not shown as filtered ones', ); @@ -832,7 +833,7 @@ test('a filter thins the lines to those its PNG mask draws; without one they all `startDate=${new Date(since).toISOString().slice(0, 10)}`, ); assert.equal(env.instance.coverageStats().hint, NO_COVERAGE_HINT); - assert.equal(env.viewer.scene.groundPrimitives.items.size, 0); + assert.equal(env.harness.groundSets('kartaview-coverage:').length, 0); // The same filter again asks KartaView for nothing. env.instance.setFilter(); for (let i = 0; i < 5; i++) await settle(); @@ -841,12 +842,12 @@ test('a filter thins the lines to those its PNG mask draws; without one they all test('coverage hints: zoom in from too high, “no photos” only once an empty view has loaded', async (t) => { const env = coverageSetup(t, { height: 7_000 }); - env.instance.activate(env.viewer); + env.instance.activate(env.map); assert.equal(env.instance.coverageStats().hint, TOO_HIGH_HINT); assert.deepEqual(env.tiles, [], 'nothing asked from up there'); const release = env.holdTiles(); - env.viewer.view.height = 900; - await moved(env.viewer); + env.harness.view.height = 900; + await moved(env.harness); assert.ok(await until(() => env.instance.coverageStats().loading)); assert.equal(env.instance.coverageStats().hint, '', 'not while it loads'); release(); @@ -864,14 +865,14 @@ test('coverage tiles are kept while KartaView is on and dropped once it is switc ], ], }); - env.instance.activate(env.viewer); + env.instance.activate(env.map); assert.ok(await until(() => env.instance.coverageStats().count === 1)); - await moved(env.viewer); + await moved(env.harness); assert.ok(await until(() => env.instance.coverageStats().count === 1)); assert.equal(env.tiles.length, 1, 'kept while on'); // Switched off: the decoded lines are not kept, so on again asks again. env.instance.deactivate(); - env.instance.activate(env.viewer); + env.instance.activate(env.map); assert.ok(await until(() => env.instance.coverageStats().count === 1)); assert.equal(env.tiles.length, 2); }); diff --git a/src/layers/streetLevel/providers/mapillary/index.js b/src/layers/streetLevel/providers/mapillary/index.js index 0ec83aeee..8d5b78129 100644 --- a/src/layers/streetLevel/providers/mapillary/index.js +++ b/src/layers/streetLevel/providers/mapillary/index.js @@ -40,7 +40,7 @@ function createProviderState(context) { return { context, services: context.services, - viewer: null, + map: null, coverage: freshCoverage(), sequence: { selectedId: null, @@ -137,24 +137,24 @@ export function createMapillaryProvider({ source, photoViewer = null }) { return { configured }; }, - init(viewer) { - state.viewer = viewer; - parts.sequences.ensureCollections(viewer); + init(map) { + state.map = map; + parts.sequences.ensureCollections(map); parts.sequences.setVisible(false); }, - activate(viewer) { - state.viewer = viewer; + activate(map) { + state.map = map; parts.sequences.setVisible(true); - parts.coverage.attach(viewer); + parts.coverage.attach(map); }, /** Clear lines and cones, forget refusals. */ refreshCoverage: () => parts.coverage.refresh(), detachMap() { parts.coverage.releaseGraphics(); - parts.sequences.releaseCollection(state.viewer); - state.viewer = null; + parts.sequences.releaseCollection(); + state.map = null; }, repaintMap() { parts.coverage.rebuild(); @@ -168,10 +168,10 @@ export function createMapillaryProvider({ source, photoViewer = null }) { parts.sequences.setVisible(false); }, - destroy(viewer) { + destroy() { parts.coverage.detach(); parts.coverage.clear(); - parts.sequences.destroy(viewer); + parts.sequences.destroy(); }, /** The filter changed: redraw. Each draw reads it from the context. */ diff --git a/src/layers/streetLevel/providers/mapillary/provider.test.mjs b/src/layers/streetLevel/providers/mapillary/provider.test.mjs index ce2f0bb0c..d8d00e962 100644 --- a/src/layers/streetLevel/providers/mapillary/provider.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/provider.test.mjs @@ -1,14 +1,15 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import * as Cesium from 'cesium'; import { createMapillaryProvider } from './index.js'; import { mapillaryImageUrl } from './policy.js'; import { sequenceIdFromPick } from '../../lineCoverage.js'; import { thinImages } from '../../sequenceCones.js'; import { validateProviders } from '../../registry.js'; import { + SACRAMENTO_VIEW, + cameraEngine, fakeMapillarySource, - rayCamera, + streetLevelMap, } from '../../../../testSupport/streetLevelFakes.mjs'; function fakeContext(filter = { pano: 'all', sinceMs: null }) { @@ -83,49 +84,11 @@ test('cone thinning across the date line drops images metres apart', () => { ); }); -/** A viewer looking straight down on downtown Sacramento from 900 m. */ -function streetViewer() { - const collection = () => ({ - add: (item) => item, - remove: () => true, - contains: () => false, - get length() { - return 0; - }, - }); - const event = () => ({ addEventListener: () => () => {} }); - const camera = Object.assign( - rayCamera({ - lon: -121.4944, - lat: 38.5816, - altitude: 900, - pitch: -90, - width: 100, - height: 100, - }), - { changed: event(), moveEnd: event() }, - ); - return { - camera, - scene: { - canvas: { clientWidth: 100, clientHeight: 100 }, - globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 }, - primitives: collection(), - groundPrimitives: collection(), - preRender: event(), - postRender: event(), - requestRender() {}, - }, - }; -} +/** The display looking straight down on downtown Sacramento from 900 m. */ +const streetMap = () => + streetLevelMap({ engine: cameraEngine(SACRAMENTO_VIEW) }).map; -test('coverage asks for no tiles before the key status is known, and none without a key', async (t) => { - // Skip Cesium's one-time terrain table download for draped lines. - t.mock.method( - Cesium.GroundPolylinePrimitive, - 'initializeTerrainHeights', - async () => {}, - ); +test('coverage asks for no tiles before the key status is known, and none without a key', async () => { for (const configured of [false, true]) { let answer; const tiles = []; @@ -138,10 +101,10 @@ test('coverage asks for no tiles before the key status is known, and none withou }, }); const instance = createMapillaryProvider({ source }).create(fakeContext()); - const viewer = streetViewer(); - instance.init(viewer); + const map = streetMap(); + instance.init(map); const status = instance.status(); - instance.activate(viewer); + instance.activate(map); assert.equal(tiles.length, 0, 'nothing before the status answers'); answer({ configured }); assert.deepEqual(await status, { configured }); @@ -151,7 +114,7 @@ test('coverage asks for no tiles before the key status is known, and none withou assert.equal(tiles.length, 0, 'a key-less install never asks'); assert.equal(instance.coverageStats().keyRequired, true); } - instance.destroy(viewer); + instance.destroy(); } }); diff --git a/src/layers/streetLevel/providers/panoramax/index.js b/src/layers/streetLevel/providers/panoramax/index.js index de71595fc..7a53cdfc5 100644 --- a/src/layers/streetLevel/providers/panoramax/index.js +++ b/src/layers/streetLevel/providers/panoramax/index.js @@ -164,7 +164,7 @@ export function createPanoramaxProvider({ const state = { context, services: context.services, - viewer: null, + map: null, coverage: freshCoverage({ keyless: true }), sequence: { selectedId: null, @@ -256,9 +256,7 @@ export function createPanoramaxProvider({ * from high up the click may land tens of metres off the line. */ function lineClickRadius() { - const height = - state.viewer?.engine?.camera.pose().cartographic.height ?? - state.viewer?.camera?.positionCartographic?.height; + const height = state.map?.engine?.camera.height(); if (!Number.isFinite(height)) return NEAREST_RADIUS_M; return Math.min( LINE_CLICK_RADIUS_MAX_M, @@ -443,23 +441,23 @@ export function createPanoramaxProvider({ return { configured: true }; }, - init(cesiumViewer) { - state.viewer = cesiumViewer; - cones.ensureCollections(cesiumViewer); + init(map) { + state.map = map; + cones.ensureCollections(map); cones.setVisible(false); }, - activate(cesiumViewer) { - state.viewer = cesiumViewer; + activate(map) { + state.map = map; cones.setVisible(true); - coverage.attach(cesiumViewer); + coverage.attach(map); }, refreshCoverage: () => coverage.refresh(), detachMap() { coverage.releaseGraphics(); - cones.releaseCollection(state.viewer); - state.viewer = null; + cones.releaseCollection(); + state.map = null; }, repaintMap() { coverage.rebuild(); @@ -478,11 +476,11 @@ export function createPanoramaxProvider({ pictureLists.clear(); }, - destroy(cesiumViewer) { + destroy() { coverage.detach(); coverage.clear(); coverage.unblock(); - cones.destroy(cesiumViewer); + cones.destroy(); }, /** The filter changed: redraw. Each draw reads it from the context. */ diff --git a/src/layers/streetLevel/providers/panoramax/provider.test.mjs b/src/layers/streetLevel/providers/panoramax/provider.test.mjs index 628805ce1..5e0caa22c 100644 --- a/src/layers/streetLevel/providers/panoramax/provider.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/provider.test.mjs @@ -16,7 +16,9 @@ import { validateProviders } from '../../registry.js'; import { tileBounds } from '../../tileMath.js'; import { fakeHost, until } from '../../../../testSupport/kartaViewFakes.mjs'; import { - coverageViewer, + cameraEngine, + coverageMap, + streetLevelMap, tileCentre, } from '../../../../testSupport/streetLevelFakes.mjs'; import { @@ -347,9 +349,8 @@ test('a picture Panoramax does not know, or an id it refuses, fails the open wit await assert.rejects(env.instance.viewer.open('busy'), /busy/); }); -/** Cones on a stand-in Cesium viewer; `count()` reads how many are drawn. */ +/** Cones on the display; `count()` reads how many are drawn, shown or not. */ async function withCones(t, env) { - const Cesium = await import('cesium'); // The cone glyph draws on a canvas: any drawing call is fine. const drawing = new Proxy({}, { get: () => () => {}, set: () => true }); const saved = globalThis.document; @@ -357,26 +358,33 @@ async function withCones(t, env) { createElement: () => ({ getContext: () => drawing }), }; t.after(() => (globalThis.document = saved)); - let collection = null; - env.instance.init({ - scene: { - frameState: { mode: Cesium.SceneMode.SCENE3D }, - updateHeight: () => () => {}, - getHeight: () => undefined, - primitives: { add: (c) => (collection = c), remove() {} }, - preRender: { addEventListener: () => () => {} }, - }, - camera: { positionWC: new Cesium.Cartesian3(0, 0, 1e8) }, - }); + const harness = streetLevelMap(); + // The cones are drawn hidden until the provider is switched on; they are + // read whether shown or not. + let group = null; + const sprites = harness.map.sprites; + harness.map.sprites = (...args) => (group = sprites(...args)); + env.instance.init(harness.map); + // The cones paint on the next frame. + const pickIds = () => { + group?.show(true); + harness.frame(); + return harness + .moverSets('street-level:panoramax-cones') + .flatMap((set) => set.instances) + .map((cone) => String(cone.pickId)); + }; return { - count: () => collection?.length ?? 0, - ids: () => - Array.from({ length: collection?.length ?? 0 }, (_, i) => - String(collection.get(i).id).replace(PICK_PREFIX.image, ''), - ), + count: () => pickIds().length, + ids: () => pickIds().map((id) => id.replace(PICK_PREFIX.image, '')), }; } +/** The display with the camera `height` m up. */ +const mapAt = (height) => + streetLevelMap({ engine: cameraEngine({ lon: 0, lat: 0, altitude: height }) }) + .map; + /** z15 tiles holding every item of `items` as a picture. */ const pictureTiles = (items) => (tile) => { if (tile.z !== 15) return new Uint8Array(0); @@ -454,14 +462,7 @@ test('a line clicked from high up looks for its sequence further off; the radius ...sequence('line', 1, { offset: 220 }), ]; const { instance, source } = setup({ items }); - const height = (h) => - instance.init({ - camera: { positionCartographic: { height: h } }, - scene: { - primitives: { add() {}, remove() {} }, - preRender: { addEventListener: () => () => {} }, - }, - }); + const height = (h) => instance.init(mapAt(h)); const click = () => instance.handlePick(`${PICK_PREFIX.sequence}line`, { groundPoint: () => east(0), @@ -473,7 +474,8 @@ test('a line clicked from high up looks for its sequence further off; the radius 'line-0', '220 m off, from 25 km', ); - assert.equal(source.calls.nearest.at(-1).radius, 250); + // The camera height round-trips through the globe: to the millimetre. + assert.equal(Math.round(source.calls.nearest.at(-1).radius), 250); height(1_000); click(); assert.equal( @@ -636,12 +638,13 @@ function coverageSetup(t, { sequences = [], filter, height = 900 } = {}) { }, { filter }, ); - const viewer = coverageViewer({ ...centre, height }, t); - env.instance.init(viewer); - t.after(() => env.instance.destroy(viewer)); + const harness = coverageMap({ ...centre, height }); + env.instance.init(harness.map); + t.after(() => env.instance.destroy()); return { ...env, - viewer, + harness, + map: harness.map, centre, holdTiles() { let release; @@ -672,7 +675,7 @@ test('sequence lines draw near the ground, and the imagery filter thins them', a line('old-flat', { date: '2019-06-01' }), ], }); - env.instance.activate(env.viewer); + env.instance.activate(env.map); assert.ok(await until(() => env.instance.coverageStats().count === 2)); assert.equal(env.instance.coverageStats().keyRequired, false); assert.equal(env.instance.coverageStats().error, null); @@ -685,26 +688,26 @@ test('sequence lines draw near the ground, and the imagery filter thins them', a line('old-flat'), ], }); - filtered.instance.activate(filtered.viewer); + filtered.instance.activate(filtered.map); assert.ok(await until(() => filtered.instance.coverageStats().count === 1)); }); test('the selected sequence’s line is drawn again over the coverage, and goes with it', async (t) => { const env = coverageSetup(t, { sequences: [line('a'), line('b')] }); - env.instance.activate(env.viewer); + env.instance.activate(env.map); assert.ok(await until(() => env.instance.coverageStats().count === 2)); - const ground = env.viewer.scene.groundPrimitives.items; - const drawn = ground.size; + const ground = () => env.harness.groundSets('panoramax-coverage:').length; + const drawn = ground(); await env.instance.selectSequence('a'); - assert.equal(ground.size, drawn + 1, 'one highlight over the lines'); + assert.equal(ground(), drawn + 1, 'one highlight over the lines'); env.instance.clearSequence(); - assert.equal(ground.size, drawn); + assert.equal(ground(), drawn); }); test('coverage hints: nothing while loading, “no pictures” once an empty view has loaded', async (t) => { const env = coverageSetup(t); const release = env.holdTiles(); - env.instance.activate(env.viewer); + env.instance.activate(env.map); assert.ok(await until(() => env.instance.coverageStats().loading)); assert.equal(env.instance.coverageStats().hint, ''); release(); @@ -714,14 +717,14 @@ test('coverage hints: nothing while loading, “no pictures” once an empty vie test('from high up nothing is drawn or fetched, and the panel says to zoom in', async (t) => { const env = coverageSetup(t, { height: 200_000, sequences: [line('a')] }); - env.instance.activate(env.viewer); + env.instance.activate(env.map); assert.ok( await until(() => env.instance.coverageStats().hint === ZOOM_IN_HINT), ); assert.equal(env.instance.coverageStats().count, 0); assert.equal(env.instance.coverageStats().loading, false); assert.deepEqual(env.source.calls.tiles, []); - assert.equal(env.viewer.scene.groundPrimitives.items.size, 0); + assert.equal(env.harness.groundSets('panoramax-coverage:').length, 0); }); test('switching off clears the cones and forgets a pending line click', async (t) => { @@ -747,7 +750,7 @@ test('lines in view that the filter hides all of say so, not that there is nothi filter: { pano: 'pano', sinceMs: null }, sequences: [line('flat-only')], }); - env.instance.activate(env.viewer); + env.instance.activate(env.map); assert.ok( await until(() => env.instance.coverageStats().hint === FILTERED_HINT), ); @@ -821,13 +824,7 @@ test('a line clicked from low down still searches its own sequence within 50 m', ...sequence('clicked', 1, { offset: 30 }), ]; const { instance, source } = setup({ items }); - instance.init({ - camera: { positionCartographic: { height: 300 } }, - scene: { - primitives: { add() {}, remove() {} }, - preRender: { addEventListener: () => () => {} }, - }, - }); + instance.init(mapAt(300)); instance.handlePick(`${PICK_PREFIX.sequence}clicked`, { groundPoint: () => east(0), }); @@ -876,25 +873,25 @@ test('a coverage tile decoded a moment ago is not fetched again; switched off, t asked.push(`${tile.z}/${tile.x}/${tile.y}`); return getTile(tile, options); }; - env.instance.activate(env.viewer); + env.instance.activate(env.map); assert.ok(await until(() => env.instance.coverageStats().count === 1)); const z14 = asked.length; // Up a zoom band and back down: the z14 tile comes from memory. // (Activating again refreshes at once, without the camera debounce.) - env.viewer.view.height = 3_000; - env.instance.activate(env.viewer); + env.harness.view.height = 3_000; + env.instance.activate(env.map); assert.ok(await until(() => asked.some((key) => key.startsWith('13/')))); assert.ok(await until(() => env.instance.coverageStats().count === 1)); const withZ13 = asked.length; - env.viewer.view.height = 900; - env.instance.activate(env.viewer); + env.harness.view.height = 900; + env.instance.activate(env.map); assert.ok(await until(() => env.instance.coverageStats().count === 1)); await new Promise((resolve) => setTimeout(resolve, 10)); assert.equal(asked.length, withZ13, 'nothing fetched again'); assert.ok(z14 > 0); // Off and on: the memory went back, so the tile is fetched again. env.instance.deactivate(); - env.instance.activate(env.viewer); + env.instance.activate(env.map); assert.ok(await until(() => env.instance.coverageStats().count === 1)); assert.ok(asked.length > withZ13); }); diff --git a/src/layers/streetLevel/render/display.js b/src/layers/streetLevel/render/display.js new file mode 100644 index 000000000..6ff846be2 --- /dev/null +++ b/src/layers/streetLevel/render/display.js @@ -0,0 +1,381 @@ +// Street Level on the globe contract (engine.ground, engine.movers, +// engine.surface, engine.projector, engine.camera), the same on every +// engine. What the layer core and its providers draw through it: +// - coverage: sequence lines as engine.ground corridors in CSS pixels, +// draped on terrain and 3D tiles alike, every part of a sequence picking +// it; sets of at most 120 lines, so a dense tile shows in parts; +// - the position marker and the selected sequence's image cones: sprite +// movers, one set per glyph, on the ground under them, drawn over the +// scene, hidden behind the horizon of an ellipsoid 1 km under WGS84 +// (ground can lie below WGS84, and the camera with it) and turned to +// their compass bearing on screen. +// Picks and the camera stay with the core (../selection.js, ../view.js), +// on the same engine (`display.engine`). +// +// The contract has no ground clamping for sprites: a sprite sits on the +// terrain, or under Google 3D on the rendered tiles (one sample each), and +// is placed again whenever the surface settles. + +import * as geo from '../../../geo/index.js'; +import { rgbaFromCss } from '../../shared/color.js'; +import { plausibleSurfaceHeight } from '../view.js'; + +/** Lines per ground set: smaller sets build, and show, sooner. */ +export const SEQUENCE_LINE_BATCH = 120; +/** Below this the projected bearing is noise (straight into the screen). */ +const MIN_SCREEN_COMPONENT = 1e-3; +const WHITE = Object.freeze([1, 1, 1, 1]); +/** + * Horizon occluder depth below WGS84 (m): ground can lie below the ellipsoid + * (NYC −22 m), and an occluder at 0 m would hide the cones around it. + */ +const HORIZON_CULL_DEPTH_M = 1000; +const CULL_RADII = Object.freeze({ + x: geo.ellipsoid.WGS84.radii.x - HORIZON_CULL_DEPTH_M, + y: geo.ellipsoid.WGS84.radii.y - HORIZON_CULL_DEPTH_M, + z: geo.ellipsoid.WGS84.radii.z - HORIZON_CULL_DEPTH_M, +}); + +const finitePoint = (point) => + Array.isArray(point) && + Number.isFinite(point[0]) && + Number.isFinite(point[1]) && + Math.abs(point[1]) <= 90; + +/** `css` (#rrggbb[aa]) as rgba in 0..1, its alpha replaced by `alpha` when given. */ +export function lineColor(css, alpha) { + const rgba = rgbaFromCss(css); + return [rgba.red, rgba.green, rgba.blue, alpha ?? rgba.alpha]; +} + +/** + * Whether `position` is in front of the horizon seen from `eye`, for an + * ellipsoid HORIZON_CULL_DEPTH_M under WGS84 (Cesium's + * EllipsoidalOccluder.isPointVisible on that ellipsoid). + */ +export function aboveHorizon(eye, position) { + const cx = eye.x / CULL_RADII.x; + const cy = eye.y / CULL_RADII.y; + const cz = eye.z / CULL_RADII.z; + const limbSquared = cx * cx + cy * cy + cz * cz - 1; + const vx = position.x / CULL_RADII.x - cx; + const vy = position.y / CULL_RADII.y - cy; + const vz = position.z / CULL_RADII.z - cz; + const vtDotVc = -(vx * cx + vy * cy + vz * cz); + const occluded = + limbSquared < 0 + ? vtDotVc > 0 + : vtDotVc > limbSquared && + (vtDotVc * vtDotVc) / (vx * vx + vy * vy + vz * vz) > limbSquared; + return !occluded; +} + +const scratchEnu = geo.matrix4.create(); +const scratchLocal = geo.cartesian3.create(); +const scratchWorld = geo.cartesian3.create(); + +/** + * A sprite's screen rotation (radians, counter-clockwise from screen up) + * that points it along `bearingDeg` (clockwise from north) at `position`: + * the local course vector on the camera's right and up axes, or `previous` + * when it points into the screen. + */ +export function screenRotation(position, bearingDeg, pose, previous = 0) { + const course = geo.math.toRadians(bearingDeg || 0); + geo.transforms.eastNorthUpToFixedFrame( + position, + geo.ellipsoid.WGS84, + scratchEnu, + ); + geo.cartesian3.fromElements( + Math.sin(course), + Math.cos(course), + 0, + scratchLocal, + ); + geo.matrix4.multiplyByPointAsVector(scratchEnu, scratchLocal, scratchWorld); + const x = geo.cartesian3.dot(scratchWorld, pose.right); + const y = geo.cartesian3.dot(scratchWorld, pose.up); + if (x * x + y * y < MIN_SCREEN_COMPONENT * MIN_SCREEN_COMPONENT) + return previous; + return Math.atan2(-x, y); +} + +/** + * @param {import('../../../globe/contract.js').GlobeEngine} engine + */ +export function createStreetLevelDisplay(engine) { + if (!engine?.ground || !engine?.movers) + throw new TypeError('The Street Level display needs a globe engine'); + /** Live sprite groups and line handles, for destroy(). */ + const live = new Set(); + let serial = 0; + + const tilesShown = () => engine.maps?.photorealActive?.() === true; + + /** + * Ground height (m) under a point and whether it came from the surface + * shown: the terrain on the globe; under Google 3D the rendered tiles + * (`exclude`: the sprites themselves), else the terrain. Null when + * neither answers. + */ + function groundHeight(lon, lat, exclude) { + const carto = geo.cartographic.fromDegrees(lon, lat); + let terrain; + try { + terrain = engine.surface.terrainHeightSupported + ? engine.surface.terrainHeight(carto) + : undefined; + } catch { + terrain = undefined; + } + const known = Number.isFinite(terrain); + if (!tilesShown()) return { height: known ? terrain : null, exact: known }; + let sampled; + try { + sampled = engine.surface.supported + ? engine.surface.sampleHeight(carto, exclude) + : undefined; + } catch { + sampled = undefined; + } + if (plausibleSurfaceHeight(sampled)) + return { height: sampled, exact: true }; + return { height: known ? terrain : null, exact: false }; + } + + /** + * A group of sprites, each `{ pickId, lon, lat, bearing, image, imageKey, + * sizePx, scaleByDistance? }`: one sprite set per `imageKey`, at `order` + * among the engine's sets. + * @param {string} id + * @param {{ order?: number }} [options] + */ + function sprites(id, { order = 0 } = {}) { + const rows = new Set(); + /** imageKey → { bank, capacity } */ + const banks = new Map(); + let visible = true; + let dirty = true; + let lastPose = ''; + let settled = null; + let tiles = tilesShown(); + let destroyed = false; + + const wake = () => { + if (destroyed) return; + dirty = true; + engine.render.request(id); + }; + const ownSets = () => [...banks.values()].map(({ bank }) => bank); + + function place(row) { + const { height, exact } = groundHeight(row.lon, row.lat, ownSets()); + row.position = geo.cartesian3.fromDegrees(row.lon, row.lat, height ?? 0); + row.exact = exact; + } + + function bankFor(key, first, count) { + let record = banks.get(key); + if (record && record.capacity >= count) return record.bank; + record?.bank.destroy(); + const capacity = Math.max(1, count); + const bank = engine.movers.createSet({ + id: `${id}:${key}`, + kind: 'sprite', + capacity, + order, + depthTest: 'always', + pickable: true, + sprite: { + sizePx: first.sizePx, + atlas: { url: first.image, cells: { glyph: [0, 0, 1, 1] } }, + ...(first.scaleByDistance + ? { scaleByDistance: first.scaleByDistance } + : {}), + }, + }); + banks.set(key, { bank, capacity }); + return bank; + } + + function paint() { + if (destroyed) return; + // The surface settled (or the map stack changed): place every sprite + // again on the globe, where terrain is cheap; under tiles only the + // ones not on them yet. + const loaded = engine.surface.tilesLoaded(); + const nowTiles = tilesShown(); + if ((loaded && settled === false) || nowTiles !== tiles) { + for (const row of rows) + if (!nowTiles || !row.exact || nowTiles !== tiles) place(row); + dirty = true; + } + settled = loaded; + tiles = nowTiles; + if (!rows.size && !dirty) return; + const pose = engine.camera.pose(); + const eye = pose.position; + const signature = [ + pose.position.x, + pose.position.y, + pose.position.z, + pose.heading, + pose.pitch, + pose.roll, + ].join('/'); + if (!dirty && signature === lastPose) return; + dirty = false; + lastPose = signature; + const groups = new Map(); + if (visible) + for (const row of rows) { + if (!groups.has(row.imageKey)) groups.set(row.imageKey, []); + groups.get(row.imageKey).push(row); + } + // Hidden, a set draws nothing and keeps its atlas; a glyph no row + // uses any more goes. + for (const [key, record] of banks) { + if (groups.has(key)) continue; + if (visible) { + record.bank.destroy(); + banks.delete(key); + } else if (record.bank.count()) { + record.bank.setCount(0); + record.bank.commit(); + } + } + for (const [key, group] of groups) { + const bank = bankFor(key, group[0], group.length); + group.forEach((row, index) => { + row.rotation = screenRotation( + row.position, + row.bearing, + pose, + row.rotation, + ); + bank.set(index, { + position: row.position, + rotation: row.rotation, + color: WHITE, + cell: 'glyph', + pickId: row.pickId, + show: aboveHorizon(eye, row.position), + }); + }); + bank.setCount(group.length); + bank.commit(); + } + } + + const removePaint = engine.frames.preRender.addEventListener(paint); + const group = { + /** Adds a sprite; the returned row is its handle. */ + add(spec) { + const row = { ...spec, rotation: 0, position: null, exact: false }; + place(row); + rows.add(row); + wake(); + return row; + }, + /** Moves or turns a sprite (`lon`, `lat`, `bearing`). */ + move(row, { lon = row.lon, lat = row.lat, bearing = row.bearing } = {}) { + if (!rows.has(row)) return; + const moved = lon !== row.lon || lat !== row.lat; + Object.assign(row, { lon, lat, bearing }); + if (moved) place(row); + wake(); + }, + remove(row) { + if (rows.delete(row)) wake(); + }, + removeAll() { + rows.clear(); + wake(); + }, + show(value) { + visible = value !== false; + wake(); + }, + shown: () => visible, + count: () => rows.size, + destroy() { + if (destroyed) return; + destroyed = true; + removePaint(); + for (const { bank } of banks.values()) bank.destroy(); + banks.clear(); + rows.clear(); + live.delete(group); + engine.render.request(id); + }, + }; + live.add(group); + return group; + } + + /** + * Sequence lines draped on the ground: every part of every sequence as a + * corridor `widthPx` wide in the CSS `color` (at `alpha` when given), + * picking `pickPrefix` + its id. Null when nothing is drawable; else a + * handle to destroy. + * @param {Array<{id: string, parts: Array>}>} sequences + * @param {{ id: string, color: string, alpha?: number, widthPx: number, pickPrefix: string, order?: number }} style + */ + function sequenceLines( + sequences, + { id, color: css, alpha, widthPx, pickPrefix, order }, + ) { + const color = lineColor(css, alpha); + const items = []; + for (const sequence of sequences) + for (const part of sequence.parts || []) { + // A part with a bad point is skipped; the others still draw. + if (!Array.isArray(part) || part.length < 2) continue; + if (!part.every(finitePoint)) continue; + items.push({ + positions: part, + color, + widthPx, + pickId: `${pickPrefix}${sequence.id}`, + }); + } + if (!items.length) return null; + const sets = []; + const base = `${id}#${serial++}`; + for (let at = 0; at < items.length; at += SEQUENCE_LINE_BATCH) { + const set = engine.ground.createSet({ + id: `${base}:${sets.length}`, + kind: 'corridor', + order, + pickable: true, + drape: 'both', + }); + sets.push(set); + set.replace(items.slice(at, at + SEQUENCE_LINE_BATCH)); + set.commit(); + } + const handle = { + /** Ground set ids, in draw order. */ + ids: () => sets.map((set) => set.id), + count: () => items.length, + destroy() { + if (!live.delete(handle)) return; + for (const set of sets) set.destroy(); + engine.render.request(id); + }, + }; + live.add(handle); + return handle; + } + + return { + engine, + sprites, + sequenceLines, + /** Everything still drawn goes. */ + destroy() { + for (const item of [...live]) item.destroy(); + }, + }; +} diff --git a/src/layers/streetLevel/render/display.test.mjs b/src/layers/streetLevel/render/display.test.mjs new file mode 100644 index 000000000..d62cfff0f --- /dev/null +++ b/src/layers/streetLevel/render/display.test.mjs @@ -0,0 +1,351 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as geo from '../../../geo/index.js'; +import { + aboveHorizon, + createStreetLevelDisplay, + lineColor, + screenRotation, + SEQUENCE_LINE_BATCH, +} from './display.js'; +import { + STREET_LEVEL_MAPS, + streetLevelMap, +} from '../../../testSupport/streetLevelFakes.mjs'; + +const line = (n) => [ + [-121.49, 38.58 + n * 1e-5], + [-121.48, 38.58 + n * 1e-5], +]; +const STYLE = Object.freeze({ + id: 'test-coverage:14/1/2', + color: '#00ff00', + alpha: 0.92, + widthPx: 2.5, + pickPrefix: 'mly:seq:', + order: 0, +}); + +test('the display needs a globe engine', () => { + assert.throws(() => createStreetLevelDisplay(null), /globe engine/); + assert.throws(() => createStreetLevelDisplay({}), /globe engine/); +}); + +test('a line colour is the CSS colour, its alpha replaced when asked', () => { + assert.deepEqual(lineColor('#00d4ff'), [0, 0xd4 / 255, 1, 1]); + assert.deepEqual(lineColor('#05cb63', 0.92), [ + 0x05 / 255, + 0xcb / 255, + 0x63 / 255, + 0.92, + ]); +}); + +for (const create of STREET_LEVEL_MAPS) { + const name = create().name; + + test(`sequence lines are pickable pixel corridors draped on terrain and tiles (${name})`, () => { + const harness = create(); + const lines = harness.map.sequenceLines( + [ + { id: 'a', parts: [line(0), line(1)] }, + { id: 'b', parts: [line(2)] }, + ], + STYLE, + ); + const [set] = harness.groundSets(STYLE.id); + assert.deepEqual(lines.ids(), [set.id]); + assert.deepEqual(set.options, { + id: set.id, + kind: 'corridor', + order: 0, + pickable: true, + drape: 'both', + }); + // Every part of a sequence picks it. + assert.deepEqual(set.items, [ + { + positions: line(0), + color: [0, 1, 0, 0.92], + widthPx: 2.5, + pickId: 'mly:seq:a', + }, + { + positions: line(1), + color: [0, 1, 0, 0.92], + widthPx: 2.5, + pickId: 'mly:seq:a', + }, + { + positions: line(2), + color: [0, 1, 0, 0.92], + widthPx: 2.5, + pickId: 'mly:seq:b', + }, + ]); + assert.equal( + harness.engine.ground.list().find((entry) => entry.id === set.id).count, + 3, + 'committed to the engine', + ); + lines.destroy(); + lines.destroy(); + assert.equal(harness.engine.ground.list().length, 0, 'gone, once'); + }); + + test(`a dense tile is drawn in sets of ${SEQUENCE_LINE_BATCH} lines (${name})`, () => { + const harness = create(); + const sequences = Array.from({ length: 250 }, (_, i) => ({ + id: `s${i}`, + parts: [line(i)], + })); + const lines = harness.map.sequenceLines(sequences, STYLE); + const sets = harness.groundSets(STYLE.id); + assert.deepEqual( + sets.map((set) => set.items.length), + [SEQUENCE_LINE_BATCH, SEQUENCE_LINE_BATCH, 10], + ); + assert.deepEqual( + lines.ids(), + sets.map((set) => set.id), + ); + assert.equal(lines.count(), 250); + harness.map.destroy(); + assert.equal(harness.engine.ground.list().length, 0); + }); +} + +test('a part with a bad point is skipped; nothing drawable draws nothing', () => { + const harness = streetLevelMap(); + const lines = harness.map.sequenceLines( + [ + { + id: 'a', + parts: [ + line(0), + [[-121.49, 38.58]], + [ + [-121.49, Number.NaN], + [-121.48, 38.58], + ], + [ + [-121.49, 95], + [-121.48, 38.58], + ], + null, + ], + }, + { id: 'b', parts: [] }, + { id: 'c' }, + ], + STYLE, + ); + assert.deepEqual( + harness.corridors(STYLE.id).map((item) => item.pickId), + ['mly:seq:a'], + ); + lines.destroy(); + assert.equal( + harness.map.sequenceLines([{ id: 'b', parts: [] }], STYLE), + null, + ); + assert.equal(harness.engine.ground.list().length, 0); +}); + +/** One sprite of a group on `harness`'s display, and its drawn instance. */ +function oneSprite(harness, spec = {}) { + const group = harness.map.sprites('test-sprites', { order: -1 }); + const row = group.add({ + pickId: 'p:1', + lon: -121.4944, + lat: 38.5816, + bearing: 0, + image: 'data:image/svg+xml;utf8,', + imageKey: 'glyph', + sizePx: 26, + ...spec, + }); + const drawn = () => { + harness.frame(); + return harness.spritesShown('test-sprites')[0]; + }; + return { group, row, drawn }; +} + +const heightOf = (position) => geo.cartographic.fromCartesian(position).height; + +test('a sprite sits on the terrain; under Google 3D on the tiles, placed again when they settle', async () => { + const harness = streetLevelMap({ view: true }); + const { fake } = harness.engine; + fake.script('terrainHeight', () => 12); + const globe = oneSprite(harness); + assert.ok(Math.abs(heightOf(globe.drawn().position) - 12) < 1e-6); + globe.group.destroy(); + + await harness.engine.maps.setPhotorealTiles({ kind: 'photoreal' }); + let tiles; + const excluded = []; + fake.script('sampleHeight', (_position, exclude) => { + excluded.push(exclude); + return tiles; + }); + fake.script('tilesLoaded', false); + const google = oneSprite(harness); + // Not loaded yet: the terrain until the tiles answer. + assert.ok(Math.abs(heightOf(google.drawn().position) - 12) < 1e-6); + tiles = 31; + harness.frame(); + assert.ok( + Math.abs(heightOf(google.drawn().position) - 12) < 1e-6, + 'not placed again before the surface settles', + ); + fake.script('tilesLoaded', true); + assert.ok(Math.abs(heightOf(google.drawn().position) - 31) < 1e-6); + assert.ok( + excluded.every((list) => Array.isArray(list)), + 'its own sprites are left out of the sample', + ); + // On the tiles now: a later settle samples nothing again. + const samples = excluded.length; + fake.script('tilesLoaded', false); + harness.frame(); + fake.script('tilesLoaded', true); + harness.frame(); + assert.equal(excluded.length, samples); + // An implausible sample (tiles still streaming) is not ground. + tiles = -14_886; + google.group.move(google.row, { lon: -121.4945 }); + assert.ok(Math.abs(heightOf(google.drawn().position) - 12) < 1e-6); +}); + +test('a sprite without any ground sits on the ellipsoid', () => { + const harness = streetLevelMap({ view: true }); + harness.engine.fake.script('terrainHeight', () => undefined); + assert.ok(Math.abs(heightOf(oneSprite(harness).drawn().position)) < 1e-6); +}); + +test('a group shows and hides, removes and moves its sprites, and goes for good', () => { + const harness = streetLevelMap({ view: true }); + const { group, row, drawn } = oneSprite(harness); + assert.equal(drawn().pickId, 'p:1'); + group.show(false); + assert.equal(group.shown(), false); + assert.equal(drawn(), undefined); + assert.equal( + harness.moverSets('test-sprites').length, + 1, + 'its set kept, with its atlas', + ); + group.show(true); + assert.ok(drawn()); + group.move(row, { bearing: 90 }); + assert.ok(Math.abs(drawn().rotation + Math.PI / 2) < 1e-6); + group.remove(row); + assert.equal(drawn(), undefined); + assert.equal(group.count(), 0); + // A removed row does not come back with a move. + group.move(row, { lon: 0 }); + assert.equal(group.count(), 0); + group.add({ ...row }); + group.removeAll(); + assert.equal(drawn(), undefined); + group.destroy(); + assert.equal(harness.moverSets('test-sprites').length, 0); + // Destroyed: nothing paints again. + group.add({ ...row }); + harness.frame(); + assert.equal(harness.moverSets('test-sprites').length, 0); +}); + +test('a group grows its sprite set as rows arrive, one set per glyph', () => { + const harness = streetLevelMap({ view: true }); + const group = harness.map.sprites('test-sprites'); + const add = (n, imageKey) => + group.add({ + pickId: `p:${imageKey}:${n}`, + lon: -121.4944 + n * 1e-5, + lat: 38.5816, + bearing: 0, + image: 'data:,', + imageKey, + sizePx: 26, + }); + add(0, 'flat'); + harness.frame(); + for (let n = 1; n < 5; n++) add(n, 'flat'); + add(0, 'pano'); + harness.frame(); + const sets = harness.moverSets('test-sprites'); + assert.deepEqual( + sets.map((set) => [set.id, set.options.capacity, set.instances.length]), + [ + ['test-sprites:flat', 5, 5], + ['test-sprites:pano', 1, 1], + ], + ); + assert.equal(sets[0].options.order, 0, 'order 0 unless asked'); + assert.equal(sets[0].options.sprite.scaleByDistance, undefined); +}); + +test('a sprite turns with the camera, keeping its compass bearing on screen', () => { + const harness = streetLevelMap({ view: true }); + const { drawn } = oneSprite(harness, { bearing: 0 }); + assert.ok(Math.abs(drawn().rotation) < 1e-6, 'north is up'); + // The camera turned to face east: north is now to the left. + harness.engine.fake.setPose({ + position: harness.engine.camera.pose().position, + heading: Math.PI / 2, + pitch: -Math.PI / 2, + roll: 0, + }); + assert.ok(Math.abs(drawn().rotation - Math.PI / 2) < 1e-6); +}); + +test('a bearing straight into the screen keeps the last rotation', () => { + const position = geo.cartesian3.fromDegrees(0, 0); + // Looking north along the ground: the local north is the view direction. + const pose = { + right: geo.cartesian3.create(0, 1, 0), + up: geo.cartesian3.create(1, 0, 0), + }; + assert.equal(screenRotation(position, 0, pose, 0.7), 0.7); + assert.ok( + Math.abs(screenRotation(position, 90, pose, 0.7) + Math.PI / 2) < 1e-9, + ); +}); + +const at = (lon, lat, height) => geo.cartesian3.fromDegrees(lon, lat, height); + +test('cones and the marker stay visible on ground below the WGS84 ellipsoid', () => { + // NYC at street level: eye 2.4 m above ground at -22 m, a cone 10 m away. + assert.equal( + aboveHorizon(at(-74.006, 40.7128, -19.6), at(-74.006, 40.71289, -22)), + true, + ); + // Colombo framing view: camera at -21 m, cones at -95 m about 80 m away. + const colombo = at(79.8612, 6.9271, -21); + assert.equal(aboveHorizon(colombo, at(79.8612, 6.92782, -95)), true); + assert.equal(aboveHorizon(colombo, at(79.86192, 6.9271, -95)), true); + // Austin, ground well above the ellipsoid. + assert.equal( + aboveHorizon(at(-97.7431, 30.2672, 132), at(-97.7431, 30.2681, 130)), + true, + ); +}); + +test('the horizon cull still hides the far side of the Earth from orbit', () => { + const orbit = at(0, 0, 20_000_000); + assert.equal(aboveHorizon(orbit, at(10, 10, 0)), true); + assert.equal(aboveHorizon(orbit, at(180, 0, 0)), false); +}); + +test('destroying the display takes everything it still draws', () => { + const harness = streetLevelMap({ view: true }); + harness.map.sequenceLines([{ id: 'a', parts: [line(0)] }], STYLE); + const { drawn } = oneSprite(harness); + assert.ok(drawn()); + harness.map.destroy(); + assert.equal(harness.engine.ground.list().length, 0); + assert.equal(harness.engine.movers.list().length, 0); + harness.map.destroy(); +}); diff --git a/src/layers/streetLevel/render/navara.js b/src/layers/streetLevel/render/navara.js deleted file mode 100644 index 695aabea5..000000000 --- a/src/layers/streetLevel/render/navara.js +++ /dev/null @@ -1,183 +0,0 @@ -import * as geo from '../../../geo/index.js'; -import { screenProjectedRotation } from '../../../data/iconOrientation.js'; -import { bindGroundShapePicks } from '../../shared/render/navaraGroundPicks.js'; - -export function createStreetMap(engine, { view }) { - const { camera } = engine; - function billboards(id) { - const rows = new Set(), - sets = new Map(); - let visible = true, - dirty = true, - lastCamera = '', - settled = false; - const wake = () => { - dirty = true; - engine.render.request(id); - }; - function paint() { - const pose = camera.pose(); - const signature = [ - pose.position.x, - pose.position.y, - pose.position.z, - pose.heading, - pose.pitch, - pose.roll, - ].join('/'); - const loaded = engine.surface.tilesLoaded(); - if (!dirty && signature === lastCamera && loaded === settled) return; - settled = loaded; - dirty = false; - lastCamera = signature; - const scenes = { camera: { rightWC: pose.right, upWC: pose.up } }; - const groups = new Map(); - if (visible) - for (const row of rows) { - const key = row.imageId ?? row.image; - if (!groups.has(key)) groups.set(key, []); - groups.get(key).push(row); - } - for (const [key, record] of sets) - if (!groups.has(key)) { - record.bank.destroy(); - sets.delete(key); - } - for (const [key, group] of groups) { - const first = group[0], - scale = first.scaleByDistance; - let rec = sets.get(key); - if (!rec || rec.capacity < group.length) { - rec?.bank.destroy(); - const capacity = Math.max(1, group.length); - const bank = engine.movers.createSet({ - id: id + '-' + key, - kind: 'sprite', - capacity, - depthTest: 'always', - pickable: true, - sprite: { - sizePx: first.width, - atlas: { url: first.image, cells: { glyph: [0, 0, 1, 1] } }, - scaleByDistance: scale - ? [scale.near, scale.nearValue, scale.far, scale.farValue] - : null, - }, - }); - rec = { capacity, bank }; - sets.set(key, rec); - } - let i = 0; - for (const row of group) { - const pos = row.position, - carto = geo.cartographic.fromCartesian(pos); - const h = engine.surface.terrainHeight(carto) ?? carto.height; - const ground = geo.cartesian3.fromRadians( - carto.longitude, - carto.latitude, - h, - ); - rec.bank.set(i++, { - position: ground, - rotation: screenProjectedRotation( - scenes, - ground, - -geo.math.toDegrees(row.rotation || 0), - 0, - ), - color: [1, 1, 1, 1], - cell: 'glyph', - pickId: row.id, - show: row.show !== false && engine.projector.horizonVisible(ground), - }); - } - rec.bank.setCount(i); - rec.bank.commit(); - } - } - const off = engine.frames.preRender.addEventListener(paint); - return { - add(spec) { - const row = { ...spec }; - rows.add(row); - wake(); - return row; - }, - remove(row) { - rows.delete(row); - wake(); - }, - removeAll() { - rows.clear(); - wake(); - }, - set show(value) { - visible = value; - wake(); - }, - get show() { - return visible; - }, - changed: wake, - destroy() { - off(); - for (const rec of sets.values()) rec.bank.destroy(); - sets.clear(); - rows.clear(); - }, - }; - } - function lines(sequences, color, width, prefix) { - const features = [], - shapes = []; - for (const seq of sequences) - for (const path of seq.parts) { - if (path.length < 2) continue; - const id = prefix + seq.id; - features.push({ - type: 'Feature', - geometry: { type: 'LineString', coordinates: path }, - properties: { id }, - }); - shapes.push({ - id, - geometry: { type: 'line', positions: path }, - style: { lineWidth: width }, - }); - } - if (!features.length) return null; - const source = view.addSource({ - type: 'geojson', - data: { type: 'FeatureCollection', features }, - }); - let layer, unbind; - try { - layer = view.addLayer({ - type: 'vector', - source, - polyline: { - color: parseInt(color.slice(1), 16), - width, - opacity: 0.92, - clampToGround: true, - lit: false, - transparent: true, - }, - }); - unbind = bindGroundShapePicks(engine, 'street-level', shapes); - } catch (e) { - layer?.delete(); - source.delete(); - throw e; - } - return { - destroy() { - unbind(); - layer.delete(); - source.delete(); - engine.render.request('street-level-line'); - }, - }; - } - return { engine, camera, billboards, lines }; -} diff --git a/src/layers/streetLevel/selection.js b/src/layers/streetLevel/selection.js index 5798053a0..da203e3d4 100644 --- a/src/layers/streetLevel/selection.js +++ b/src/layers/streetLevel/selection.js @@ -1,22 +1,24 @@ -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; import { CLICK_PICK_WINDOW_PX, STREET_LEVEL_LAYER_ID } from './policy.js'; -/** One click handler for every provider's lines and cones, and the marker. */ +/** + * One click handler for every provider's lines and cones, and the marker, + * on the display's engine (engine.input for the click, engine.picker for + * the pick: the input's own pick has no pick window). + */ export function createSelection({ state, parts }) { const { picking, input } = state.services; /** The id carries a provider's prefix: that provider handles the pick. */ function onClick(click) { - const viewer = state.viewer; - if (!viewer || !state.enabled) return; + const engine = state.map?.engine; + if (!engine || !state.enabled || !click?.position) return; if (input?.isPointerFree && !input.isPointerFree()) return; - const picked = viewer.engine - ? click.picked - : viewer.scene.pick( - click.position, - CLICK_PICK_WINDOW_PX, - CLICK_PICK_WINDOW_PX, - ); + const picked = engine.picker.pick( + click.position, + CLICK_PICK_WINDOW_PX, + CLICK_PICK_WINDOW_PX, + ); const id = picking?.resolvePickId ? picking.resolvePickId(picked) : picked?.id; @@ -29,20 +31,21 @@ export function createSelection({ state, parts }) { // The position marker is the core's own: nothing to select. if (!route.instance) return; route.instance.handlePick(route.id, { - groundPoint: () => groundPointAt(viewer, click.position), + groundPoint: () => groundPointAt(engine, click.position), }); } /** Lon/lat of the ground under a screen point, or null (sky, no picker). */ - function groundPointAt(viewer, screenPosition) { - const position = viewer.engine - ? viewer.engine.picker.pickGlobe(screenPosition) - : state.services.scenePick?.pickGroundPosition?.(viewer, screenPosition); - const carto = position && Cesium.Cartographic.fromCartesian(position); + function groundPointAt(engine, screenPosition) { + const position = state.services.scenePick?.pickGroundPosition?.( + engine, + screenPosition, + ); + const carto = position && geo.cartographic.fromCartesian(position); return carto ? { - lon: Cesium.Math.toDegrees(carto.longitude), - lat: Cesium.Math.toDegrees(carto.latitude), + lon: geo.math.toDegrees(carto.longitude), + lat: geo.math.toDegrees(carto.latitude), } : null; } @@ -67,20 +70,9 @@ export function createSelection({ state, parts }) { parts.clearSequences(); } - function install(viewer) { - if (state.clickHandler) return; - if (viewer.engine) { - const remove = viewer.engine.input.on('click', onClick, { pick: true }); - state.clickHandler = { isDestroyed: () => false, destroy: remove }; - } else { - state.clickHandler = new Cesium.ScreenSpaceEventHandler( - viewer.scene.canvas, - ); - state.clickHandler.setInputAction( - onClick, - Cesium.ScreenSpaceEventType.LEFT_CLICK, - ); - } + function install(map) { + if (state.clickHandler || !map?.engine) return; + state.clickHandler = map.engine.input.on('click', onClick); document.addEventListener('keydown', onKeyDown); picking?.registerPickOwner?.(STREET_LEVEL_LAYER_ID, parts.router.ownsPick); } @@ -88,7 +80,7 @@ export function createSelection({ state, parts }) { function uninstall() { // Nothing to undo for a layer that was never enabled. if (!state.clickHandler) return; - if (!state.clickHandler.isDestroyed()) state.clickHandler.destroy(); + state.clickHandler(); state.clickHandler = null; document.removeEventListener('keydown', onKeyDown); picking?.unregisterPickOwner?.(STREET_LEVEL_LAYER_ID); diff --git a/src/layers/streetLevel/selection.test.mjs b/src/layers/streetLevel/selection.test.mjs index 9fb0362a1..06ffcc7b6 100644 --- a/src/layers/streetLevel/selection.test.mjs +++ b/src/layers/streetLevel/selection.test.mjs @@ -1,11 +1,21 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; import { createSelection } from './selection.js'; import { createPickRouter } from './pickRouter.js'; +import { CLICK_PICK_WINDOW_PX } from './policy.js'; +import { STREET_LEVEL_MAPS } from '../../testSupport/streetLevelFakes.mjs'; -/** The selection installed on a stand-in viewer, with what its clicks reach. */ -function harness({ selected = false, services = {}, providers = [] } = {}) { +/** + * The selection installed on the display of `map` (a STREET_LEVEL_MAPS + * harness, the fake engine's by default), with what its clicks reach. + */ +function harness({ + selected = false, + services = {}, + providers = [], + map = STREET_LEVEL_MAPS[0](), +} = {}) { const saved = globalThis.document; globalThis.document = new EventTarget(); // The document's keydown listeners, so a test can hand them any target. @@ -29,16 +39,8 @@ function harness({ selected = false, services = {}, providers = [] } = {}) { const groundClicks = []; /** What the pointer is over: a pick id, or nothing. */ const scene = { under: null }; - const canvas = Object.assign(new EventTarget(), { - // Keep Cesium's handler on the canvas; there is no real document here. - disableRootEvents: true, - onwheel: null, - }); - const viewer = { - scene: { canvas, pick: () => scene.under && { id: scene.under } }, - }; const state = { - viewer, + map: map.map, enabled: true, clickHandler: null, services: { picking: null, input: null, ...services }, @@ -72,16 +74,19 @@ function harness({ selected = false, services = {}, providers = [] } = {}) { }, }; const selection = createSelection({ state, parts }); - selection.install(viewer); - const action = (type) => - state.clickHandler.getInputAction(Cesium.ScreenSpaceEventType[type]); + selection.install(map.map); return { state, + map, sequences, opened, groundClicks, scene, - click: (x = 10) => action('LEFT_CLICK')({ position: { x, y: 10 } }), + click: (x = 10) => + map.click(scene.under ? { id: scene.under, primitive: {} } : undefined, { + x, + y: 10, + }), /** A keydown reaching the document from `target`; the event as handled. */ key(key, target, { defaultPrevented = false } = {}) { const event = { @@ -102,25 +107,73 @@ function harness({ selected = false, services = {}, providers = [] } = {}) { }; } -test('a click on a line selects its sequence, on a cone opens its image, elsewhere does nothing', () => { +for (const create of STREET_LEVEL_MAPS) { + const name = create().name; + test(`a click on a line selects its sequence, on a cone opens its image, elsewhere does nothing (${name})`, () => { + const h = harness({ map: create() }); + try { + h.scene.under = 'mly:seq:abc'; + h.click(); + assert.deepEqual(h.sequences.selects, ['abc']); + h.scene.under = 'mly:img:42'; + h.click(); + assert.deepEqual(h.opened, ['42']); + for (const under of [null, 'cctv:7', 'sl:pos']) { + h.scene.under = under; + h.click(); + } + assert.deepEqual([h.sequences.selects, h.opened], [['abc'], ['42']]); + // The marker is ours; nothing and another layer's pick go to the ground. + assert.deepEqual( + h.groundClicks.map(({ picked }) => picked?.id), + [undefined, 'cctv:7'], + ); + } finally { + h.restore(); + } + }); +} + +test('a click picks in a window a few pixels wide, through engine.input and engine.picker', () => { const h = harness(); + try { + const { fake } = h.map.engine; + assert.deepEqual( + fake.callsOf('input.on').map(({ args }) => args), + [['click', {}]], + 'one click listener, without its own pick', + ); + h.scene.under = 'mly:seq:abc'; + h.click(30); + assert.deepEqual(fake.callsOf('picker.pick').at(-1).args, [ + { x: 30, y: 10 }, + CLICK_PICK_WINDOW_PX, + CLICK_PICK_WINDOW_PX, + ]); + assert.deepEqual(h.sequences.selects, ['abc']); + h.restore(); + h.scene.under = 'mly:seq:def'; + h.click(); + assert.deepEqual(h.sequences.selects, ['abc'], 'uninstalled: no clicks'); + } finally { + h.restore(); + } +}); + +test('a tool that owns the pointer, or a layer that is off, takes no click', () => { + let free = false; + const h = harness({ services: { input: { isPointerFree: () => free } } }); try { h.scene.under = 'mly:seq:abc'; h.click(); - assert.deepEqual(h.sequences.selects, ['abc']); - h.scene.under = 'mly:img:42'; + assert.deepEqual(h.sequences.selects, [], 'the tool keeps it'); + free = true; + h.state.enabled = false; h.click(); - assert.deepEqual(h.opened, ['42']); - for (const under of [null, 'cctv:7', 'sl:pos']) { - h.scene.under = under; - h.click(); - } - assert.deepEqual([h.sequences.selects, h.opened], [['abc'], ['42']]); - // The marker is ours; nothing and another layer's pick go to the ground. - assert.deepEqual( - h.groundClicks.map(({ picked }) => picked?.id), - [undefined, 'cctv:7'], - ); + assert.deepEqual(h.sequences.selects, [], 'the layer is off'); + h.state.enabled = true; + h.click(); + assert.deepEqual(h.sequences.selects, ['abc']); } finally { h.restore(); } @@ -228,9 +281,9 @@ test('a click on a provider line hands it the ground under the click, measured o ], services: { scenePick: { - pickGroundPosition: (viewer, position) => { - groundPicks.push(position); - return Cesium.Cartesian3.fromDegrees(-83.0458, 42.3314, 190); + pickGroundPosition: (engine, position) => { + groundPicks.push({ engine, position }); + return geo.cartesian3.fromDegrees(-83.0458, 42.3314, 190); }, }, }, @@ -248,7 +301,11 @@ test('a click on a provider line hands it the ground under the click, measured o const point = handled[0].click.groundPoint(); assert.ok(Math.abs(point.lon + 83.0458) < 1e-9); assert.ok(Math.abs(point.lat - 42.3314) < 1e-9); - assert.deepEqual(groundPicks, [{ x: 40, y: 10 }]); + assert.deepEqual( + groundPicks.map(({ position }) => position), + [{ x: 40, y: 10 }], + ); + assert.equal(groundPicks[0].engine, h.map.engine, 'on the display engine'); } finally { h.restore(); } diff --git a/src/layers/streetLevel/sequenceCones.js b/src/layers/streetLevel/sequenceCones.js index 00eb2a502..691456e0c 100644 --- a/src/layers/streetLevel/sequenceCones.js +++ b/src/layers/streetLevel/sequenceCones.js @@ -1,12 +1,16 @@ -import * as Cesium from 'cesium'; import { imageConeGlyph } from './glyphs.js'; import { passesImageryFilter } from './filter.js'; -import { createHorizonCull, metresBetween } from './view.js'; +import { metresBetween } from './view.js'; +import { CONE_SPRITE_ORDER } from './policy.js'; import { msg } from '../../i18n/index.js'; /** Cones closer than this to the previous kept one are dropped. */ const IMAGE_CONE_MIN_SPACING_M = 3; const IMAGE_CONE_SIZE_PX = 26; +/** NearFarScalar [nearM, nearScale, farM, farScale]: cones shrink with distance. */ +export const IMAGE_CONE_SCALE_BY_DISTANCE = Object.freeze([ + 200, 1.1, 6000, 0.35, +]); /** * @typedef {object} ConeImage One image of a sequence, as the cones draw it. @@ -32,8 +36,8 @@ export function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) { } /** - * Image cones for one selected sequence, any provider's, clamped to the - * ground. A cone's pick id is `pickPrefix` + the image id; 360° images draw + * Image cones for one selected sequence, any provider's, on the ground + * (sprites of the display, render/display.js). A cone's pick id is `pickPrefix` + the image id; 360° images draw * as rings. Why a sequence could not load stays in `state.sequence.error` * until it goes. * @@ -43,7 +47,7 @@ export function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) { * The sequence's images in capture order, thinned (see `thinImages`). * @param {{coverage: string}} options.colors * @param {string} options.pickPrefix Pick-id prefix of the cones. - * @param {string} options.spriteId Sprite-order id of the cone collection. + * @param {string} options.spriteId Id of the cone sprites. * @param {string} options.renderKey Render-governor reason. * @param {(sequenceId: string|null) => void} [options.onHighlight] * Highlight the sequence's coverage line (null: none). @@ -60,7 +64,7 @@ export function createSequenceCones({ onHighlight = () => {}, passes = passesImageryFilter, }) { - const { render, sprites } = state.services; + const { render } = state.services; function requestRender() { render?.governorRequestRender?.(renderKey); @@ -70,41 +74,22 @@ export function createSequenceCones({ state.context.notify(); } - /** Drawn cone billboards. */ - let cones = []; - - // The cones skip the depth test: hide the ones behind the globe. - const horizon = createHorizonCull({ - getViewer: () => state.viewer, - items: () => cones, - onChange: requestRender, - }); - - function ensureCollections(viewer) { - if (state.sequence.collection) return; - if (viewer?.engine) { - state.sequence.collection = viewer.billboards(spriteId); - return; - } - state.sequence.collection = new Cesium.BillboardCollection({ - scene: viewer.scene, + function ensureCollections(view) { + if (state.sequence.collection || !view?.sprites) return; + state.sequence.collection = view.sprites(spriteId, { + order: CONE_SPRITE_ORDER, }); - viewer.scene.primitives.add(state.sequence.collection); - sprites?.registerSpriteCollection?.(spriteId, state.sequence.collection); } function clearCones() { state.sequence.collection?.removeAll(); state.sequence.images = []; - cones = []; - horizon.stop(); } function renderCones(images) { const collection = state.sequence.collection; if (!collection) return; collection.removeAll(); - cones = []; // One colour per source; 360° images keep the ring shape. const cone = imageConeGlyph({ size: 32, color: colors.coverage }); const ring = imageConeGlyph({ @@ -116,24 +101,17 @@ export function createSequenceCones({ const filter = state.context.getFilter(); for (const image of images) { if (!passes(image, filter)) continue; - const billboard = collection.add({ - id: `${pickPrefix}${image.id}`, - position: Cesium.Cartesian3.fromDegrees(image.lon, image.lat), - heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, + collection.add({ + pickId: `${pickPrefix}${image.id}`, + lon: image.lon, + lat: image.lat, + bearing: image.compassAngle, image: image.isPano ? ring : cone, - imageId: image.isPano ? `${spriteId}:pano` : `${spriteId}:flat`, - width: IMAGE_CONE_SIZE_PX, - height: IMAGE_CONE_SIZE_PX, - rotation: -Cesium.Math.toRadians(image.compassAngle), - alignedAxis: Cesium.Cartesian3.UNIT_Z, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - verticalOrigin: Cesium.VerticalOrigin.CENTER, - scaleByDistance: new Cesium.NearFarScalar(200, 1.1, 6000, 0.35), + imageKey: image.isPano ? 'pano' : 'flat', + sizePx: IMAGE_CONE_SIZE_PX, + scaleByDistance: IMAGE_CONE_SCALE_BY_DISTANCE, }); - cones.push(billboard); } - if (state.viewer?.engine) collection.changed(); - else horizon.update(); requestRender(); } @@ -142,7 +120,7 @@ export function createSequenceCones({ * reloads a selected one (a provider that loads part of a sequence moved on). */ async function select(sequenceId, { force = false } = {}) { - if (!sequenceId || (!state.viewer && !state.context.waitingForView?.())) + if (!sequenceId || (!state.map && !state.context.waitingForView?.())) return; // Already on screen, or already on its way. if ( @@ -208,24 +186,17 @@ export function createSequenceCones({ } function setVisible(visible) { - if (state.sequence.collection) state.sequence.collection.show = visible; + state.sequence.collection?.show(visible); requestRender(); } - function releaseCollection(viewer) { - horizon.stop(); - cones = []; - const collection = state.sequence.collection; - if (collection) { - sprites?.unregisterSpriteCollection?.(spriteId, collection); - if (viewer?.engine) collection.destroy(); - else viewer?.scene?.primitives?.remove(collection); - state.sequence.collection = null; - } + function releaseCollection() { + state.sequence.collection?.destroy(); + state.sequence.collection = null; } - function destroy(viewer) { + function destroy() { state.sequence.abort?.abort(); - releaseCollection(viewer); + releaseCollection(); state.sequence.images = []; state.sequence.selectedId = null; } diff --git a/src/layers/streetLevel/sequenceCones.test.mjs b/src/layers/streetLevel/sequenceCones.test.mjs index 0e301cffc..5d29d2718 100644 --- a/src/layers/streetLevel/sequenceCones.test.mjs +++ b/src/layers/streetLevel/sequenceCones.test.mjs @@ -1,26 +1,20 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; import { resolveFilter } from './filter.js'; -import { createSequenceCones, thinImages } from './sequenceCones.js'; +import { CONE_SPRITE_ORDER } from './policy.js'; +import { + createSequenceCones, + IMAGE_CONE_SCALE_BY_DISTANCE, + thinImages, +} from './sequenceCones.js'; +import { + STREET_LEVEL_MAPS, + streetLevelMap, +} from '../../testSupport/streetLevelFakes.mjs'; const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); - -/** A billboard collection that remembers the cones on the globe. */ -function cones() { - const items = []; - return { - items, - show: true, - add(options) { - items.push(options); - return options; - }, - removeAll() { - items.length = 0; - }, - }; -} +const SPRITES = 'street-level:test-cones'; /** A source whose sequence lookups the test answers, one call at a time. */ function deferredSource() { @@ -49,13 +43,14 @@ function records(sequenceId, count = 2) { })); } -function setup() { +/** Cones on the display of `harness` (the fake engine over Sacramento). */ +function setup(harness = streetLevelMap({ view: true })) { const source = deferredSource(); /** Every highlight asked of the coverage: a sequence id, or null for none. */ const highlights = []; const state = { services: {}, - viewer: {}, + map: harness.map, filter: { pano: 'all', sinceDays: 0 }, context: { getFilter: () => resolveFilter(state.filter), @@ -64,7 +59,7 @@ function setup() { sequence: { selectedId: null, images: [], - collection: cones(), + collection: null, loading: false, error: null, abort: null, @@ -87,13 +82,18 @@ function setup() { }, colors: { coverage: '#05cb63' }, pickPrefix: 'mly:img:', - spriteId: 'street-level:test-cones', + spriteId: SPRITES, renderKey: 'test-sequence', onHighlight: (id) => highlights.push(id), }); - const drawn = () => - state.sequence.collection.items.map((cone) => cone.id.split(':').at(-1)); - return { state, source, sequences, highlights, drawn }; + sequences.ensureCollections(harness.map); + /** The cones drawn after a frame, in drawing order. */ + const shown = () => { + harness.frame(); + return harness.spritesShown(SPRITES); + }; + const drawn = () => shown().map((cone) => cone.pickId.split(':').at(-1)); + return { state, source, sequences, highlights, drawn, shown, harness }; } test('a failed sequence load leaves no stale cones and can be retried', async () => { @@ -160,38 +160,61 @@ test('clearSelection cancels the lookup and drops the highlight', async () => { assert.deepEqual(drawn(), [], 'the late answer draws nothing'); }); -test('cones are clamped to the ground and turned to their compass angle', async () => { - const { state, source, sequences } = setup(); - sequences.select('A'); - source.last('A').resolve(records('A', 2)); - await settle(); - const items = state.sequence.collection.items; - assert.equal(items.length, 2); - for (const cone of items) { - assert.equal(cone.heightReference, Cesium.HeightReference.CLAMP_TO_GROUND); - assert.equal(cone.rotation, -Cesium.Math.toRadians(90)); - } -}); +for (const create of STREET_LEVEL_MAPS) { + const name = create().name; + + test(`cones sit on the ground, over the scene, turned to their compass angle (${name})`, async () => { + const harness = create(); + const { source, sequences, shown } = setup(harness); + sequences.select('A'); + source.last('A').resolve([ + ...records('A', 2), + { + id: 'A-pano', + geometry: { coordinates: [-121.488, 38.58] }, + compass_angle: 0, + captured_at: 1_800_000_000_000, + is_pano: true, + }, + ]); + await settle(); + const cones = shown(); + assert.equal(cones.length, 3); + // Flat images and 360° rings: one sprite set each. + const sets = harness.moverSets(SPRITES); + assert.deepEqual(sets.map((set) => set.id).sort(), [ + `${SPRITES}:flat`, + `${SPRITES}:pano`, + ]); + for (const set of sets) { + assert.equal(set.options.depthTest, 'always'); + assert.equal(set.options.order, CONE_SPRITE_ORDER); + assert.equal(set.options.pickable, true); + assert.equal(set.options.sprite.sizePx, 26); + assert.deepEqual( + set.options.sprite.scaleByDistance, + IMAGE_CONE_SCALE_BY_DISTANCE, + ); + } + const flat = cones.filter((cone) => !cone.pickId.endsWith('pano')); + for (const cone of flat) { + const height = geo.cartographic.fromCartesian(cone.position).height; + assert.ok(Math.abs(height) < 1e-6, 'on the terrain (0 m here)'); + // Compass 90° from straight down, north up: a quarter turn clockwise. + assert.ok(Math.abs(cone.rotation + Math.PI / 2) < 1e-3); + } + sequences.setVisible(false); + assert.equal(shown().length, 0, 'hidden with the provider'); + sequences.setVisible(true); + assert.equal(shown().length, 3); + sequences.releaseCollection(); + assert.equal(harness.moverSets(SPRITES).length, 0, 'nothing left drawn'); + }); +} test('cones behind the globe are hidden, and show again from the other side', async () => { - const { state, source, sequences } = setup(); - const view = { lon: -121.49, lat: 38.58, height: 20_000_000 }; - const preRender = new Set(); - state.viewer = { - camera: { - get positionWC() { - return Cesium.Cartesian3.fromDegrees(view.lon, view.lat, view.height); - }, - }, - scene: { - preRender: { - addEventListener(listener) { - preRender.add(listener); - return () => preRender.delete(listener); - }, - }, - }, - }; + const harness = streetLevelMap({ view: true }); + const { source, sequences, shown } = setup(harness); sequences.select('A'); // One image under the camera, one near its antipode. source.last('A').resolve([ @@ -203,15 +226,63 @@ test('cones behind the globe are hidden, and show again from the other side', as }, ]); await settle(); - const [near, far] = state.sequence.collection.items; - assert.equal(near.show, true, 'the near side stays visible'); - assert.equal(far.show, false, 'the far side does not show through'); + const ids = () => shown().map((cone) => cone.pickId); + assert.deepEqual( + ids(), + ['mly:img:A-0'], + 'the far side does not show through', + ); // Fly round to the other hemisphere: the two swap. - view.lon = 58.51; - view.lat = -38.58; - for (const listener of [...preRender]) listener(); - assert.equal(near.show, false); - assert.equal(far.show, true); + harness.engine.fake.setPose({ + longitude: geo.math.toRadians(58.51), + latitude: geo.math.toRadians(-38.58), + height: 20_000_000, + }); + assert.deepEqual(ids(), ['mly:img:A-far']); sequences.clearSelection(); - assert.equal(preRender.size, 0, 'the cull listener is gone'); + assert.deepEqual(ids(), []); +}); + +test('the cones paint once per camera move, not every frame', async () => { + const harness = streetLevelMap({ view: true }); + const { source, sequences, shown } = setup(harness); + sequences.select('A'); + source.last('A').resolve(records('A', 2)); + await settle(); + assert.equal(shown().length, 2); + const commits = () => + harness.moverSets(SPRITES).reduce((sum, set) => sum + set.commits, 0); + const before = commits(); + harness.engine.fake.frames(5); + assert.equal(commits(), before, 'a still camera repaints nothing'); + // Turned where it stands. + harness.engine.fake.setPose({ + position: harness.engine.camera.pose().position, + heading: 1, + pitch: -Math.PI / 2, + roll: 0, + }); + harness.frame(); + assert.equal(commits(), before + 1, 'a moved camera repaints once'); + harness.engine.fake.frames(3); + assert.equal(commits(), before + 1, 'and then rests again'); +}); + +test('without a display the cones draw nothing and do not throw', async () => { + const { state, source, sequences } = setup({ + map: null, + frame() {}, + spritesShown: () => [], + }); + state.map = null; + sequences.ensureCollections(null); + state.context.waitingForView = () => true; + sequences.select('A'); + source.last('A').resolve(records('A')); + await settle(); + assert.equal(state.sequence.selectedId, 'A'); + sequences.rerender(); + sequences.setVisible(true); + sequences.destroy(); + assert.equal(state.sequence.collection, null); }); diff --git a/src/layers/streetLevel/state.js b/src/layers/streetLevel/state.js index f9e5e4c88..f5425ed0c 100644 --- a/src/layers/streetLevel/state.js +++ b/src/layers/streetLevel/state.js @@ -36,7 +36,7 @@ export function freshStreet() { export function createState({ services }) { return { services, - viewer: null, + map: null, enabled: false, listeners: new Set(), notify: null, diff --git a/src/layers/streetLevel/view.js b/src/layers/streetLevel/view.js index 66f1408b0..9816729ff 100644 --- a/src/layers/streetLevel/view.js +++ b/src/layers/streetLevel/view.js @@ -1,10 +1,28 @@ -import * as Cesium from 'cesium'; +// What Street Level reads of the camera and the ground, on the globe +// contract: `view` is the layer's display (render/display.js), whose +// `engine` it asks. Screen rays meet an ellipsoid raised to the ground +// under the camera, as Cesium's camera.pickEllipsoid does with that +// ellipsoid, so a hidden globe (Google 3D) still has a ground to aim at. + +import * as geo from '../../geo/index.js'; +import { intersectEllipsoid } from '../../globe/cameraMath.js'; import { wrapLon } from './tileMath.js'; +const engineOf = (view) => view?.engine ?? null; + +/** The camera's Cartographic (radians, metres), or null without an engine. */ export const cameraLocation = (view) => - view?.engine?.camera.pose().cartographic ?? - view?.camera?.positionCartographic; -const canvasOf = (view) => view?.engine?.host.canvas ?? view?.scene?.canvas; + engineOf(view)?.camera.pose().cartographic ?? null; + +/** The canvas size in CSS px ({0, 0} without an engine). */ +function canvasSize(view) { + const size = engineOf(view)?.host?.size?.(); + return { width: size?.width || 0, height: size?.height || 0 }; +} + +/** Whether Google 3D tiles are the surface shown (the globe is hidden). */ +const tilesShown = (engine) => engine?.maps?.photorealActive?.() === true; + /** Equirectangular metres between {lon, lat} points, the short way round ±180°; street scale. */ export function metresBetween(a, b) { const lat = (((a.lat + b.lat) / 2) * Math.PI) / 180; @@ -24,82 +42,83 @@ export function plausibleSurfaceHeight(height) { * when unsampleable or implausible (`sampleHeight` reads kilometres * underground before tiles load). */ -export function sampledSurfaceHeight(scene, carto) { +export function sampledSurfaceHeight(engine, carto) { let height = null; try { - if (scene?.sampleHeightSupported) height = scene.sampleHeight(carto); + if (engine?.surface?.supported) height = engine.surface.sampleHeight(carto); } catch { /* not sampleable yet */ } return plausibleSurfaceHeight(height) ? height : null; } +/** Height (m) of the terrain at `carto`, or null. */ +export function terrainSurfaceHeight(engine, carto) { + let height; + try { + if (engine?.surface?.terrainHeightSupported) + height = engine.surface.terrainHeight(carto); + } catch { + height = undefined; + } + return Number.isFinite(height) ? height : null; +} + /** * Ellipsoidal height (m) of the ground under the camera, or null. Under * Google 3D (globe hidden) the rendered surface counts, minus any roof above * the camera. */ -export function groundUnderCamera(viewer) { - const carto = cameraLocation(viewer); - const scene = viewer?.scene; - if (carto && viewer?.engine) { - const surface = viewer.engine.surface; - const h = surface.sampleHeight(carto) ?? surface.terrainHeight(carto); - return plausibleSurfaceHeight(h) && h < carto.height ? h : null; +export function groundUnderCamera(view) { + const engine = engineOf(view); + const carto = cameraLocation(view); + if (!carto) return null; + if (!tilesShown(engine)) { + const ground = terrainSurfaceHeight(engine, carto); + if (ground !== null) return ground; } - if (!carto || !scene) return null; - const globe = scene.globe; - if (globe && globe.show !== false) { - const ground = globe.getHeight?.(carto); - if (Number.isFinite(ground)) return ground; - } - const sampled = sampledSurfaceHeight(scene, carto); + const sampled = sampledSurfaceHeight(engine, carto); return sampled !== null && sampled < carto.height ? sampled : null; } -/** The ellipsoid `height` metres above `ellipsoid` (the ground at that height). */ -function raisedEllipsoid(ellipsoid, height) { - if (!Number.isFinite(height) || Math.abs(height) < 1) return ellipsoid; - const { x, y, z } = ellipsoid.radii; - return new Cesium.Ellipsoid(x + height, y + height, z + height); +/** WGS84 raised by `height` metres (the ground at that height), else WGS84. */ +function raisedEllipsoid(height) { + const wgs84 = geo.ellipsoid.WGS84; + if (!Number.isFinite(height) || Math.abs(height) < 1) + return { ellipsoid: wgs84, height: 0 }; + const { x, y, z } = wgs84.radii; + return { + ellipsoid: geo.ellipsoid.create(x + height, y + height, z + height), + height, + }; } /** * Where a screen point's ray meets the ground, as a Cartographic, or null. * `maxRange` (metres from the camera) drops hits near the horizon. */ -function groundHit(camera, point, ellipsoid, maxRange, engine) { +function groundHit(engine, point, { ellipsoid, height }, maxRange) { let cartesian = null; try { - cartesian = engine - ? engine.picker.pickGlobe(point) - : camera.pickEllipsoid(point, ellipsoid); + const { origin, direction } = engine.camera.pickRay(point); + cartesian = intersectEllipsoid(origin, direction, height); } catch { cartesian = null; } if (!cartesian) return null; if ( Number.isFinite(maxRange) && - (engine?.camera.livePosition || camera.positionWC) && - Cesium.Cartesian3.distance( - cartesian, - engine?.camera.livePosition ?? camera.positionWC, - ) > maxRange + geo.cartesian3.distance(cartesian, engine.camera.livePosition) > maxRange ) return null; - return ( - Cesium.Cartographic.fromCartesian( - cartesian, - engine ? Cesium.Ellipsoid.WGS84 : ellipsoid, - ) || null - ); + return geo.cartographic.fromCartesian(cartesian, ellipsoid) || null; } /** Camera height (m) above the ground under it. */ -export function cameraHeightAboveGround(viewer) { - const carto = cameraLocation(viewer); +export function cameraHeightAboveGround(view) { + const carto = cameraLocation(view); if (!carto) return null; - return carto.height - (groundUnderCamera(viewer) ?? 0); + return carto.height - (groundUnderCamera(view) ?? 0); } /** Screen rays per side, less one, that `visibleBbox` casts. */ @@ -111,32 +130,22 @@ const BBOX_GRID = 5; * to the ground (Denver is 1,600 m up); `maxRange` (m) drops far hits. */ export function visibleBbox( - viewer, + view, { groundHeight = null, maxRange = null } = {}, ) { - const scene = viewer?.scene; - const camera = viewer?.camera; - if ((!scene && !viewer?.engine) || !camera) return null; - const canvas = canvasOf(viewer); - const width = canvas?.clientWidth || canvas?.width || 0; - const height = canvas?.clientHeight || canvas?.height || 0; + const engine = engineOf(view); + if (!engine) return null; + const { width, height } = canvasSize(view); const hits = []; if (width > 0 && height > 0) { - const ellipsoid = raisedEllipsoid( - scene?.globe?.ellipsoid || Cesium.Ellipsoid.WGS84, - groundHeight, - ); - const point = new Cesium.Cartesian2(); + const ellipsoid = raisedEllipsoid(groundHeight); for (let i = 0; i <= BBOX_GRID; i++) { for (let j = 0; j <= BBOX_GRID; j++) { - point.x = (width * i) / BBOX_GRID; - point.y = (height * j) / BBOX_GRID; const carto = groundHit( - camera, - point, + engine, + { x: (width * i) / BBOX_GRID, y: (height * j) / BBOX_GRID }, ellipsoid, maxRange, - viewer.engine, ); if (carto) hits.push(carto); } @@ -148,8 +157,8 @@ export function visibleBbox( let east = -Infinity; let north = -Infinity; for (const carto of hits) { - const lon = Cesium.Math.toDegrees(carto.longitude); - const lat = Cesium.Math.toDegrees(carto.latitude); + const lon = geo.math.toDegrees(carto.longitude); + const lat = geo.math.toDegrees(carto.latitude); west = Math.min(west, lon); east = Math.max(east, lon); south = Math.min(south, lat); @@ -162,7 +171,7 @@ export function visibleBbox( let west360 = Infinity; let east360 = -Infinity; for (const carto of hits) { - const lon = Cesium.Math.toDegrees(carto.longitude); + const lon = geo.math.toDegrees(carto.longitude); const shifted = lon < 0 ? lon + 360 : lon; west360 = Math.min(west360, shifted); east360 = Math.max(east360, shifted); @@ -178,12 +187,12 @@ export function visibleBbox( } /** The point on the ground under the camera, as {lon, lat} degrees. */ -export function cameraNadir(viewer) { - const carto = cameraLocation(viewer); +export function cameraNadir(view) { + const carto = cameraLocation(view); if (!carto) return null; return { - lon: Cesium.Math.toDegrees(carto.longitude), - lat: Cesium.Math.toDegrees(carto.latitude), + lon: geo.math.toDegrees(carto.longitude), + lat: geo.math.toDegrees(carto.latitude), }; } @@ -191,30 +200,20 @@ export function cameraNadir(viewer) { * Where coverage tiles are ranked from: halfway between the nadir and the * screen centre's ground, or the nadir when that is out of `maxRange`. */ -export function viewFocus( - viewer, - { groundHeight = null, maxRange = null } = {}, -) { - const nadir = cameraNadir(viewer); - const scene = viewer?.scene; - const canvas = canvasOf(viewer); - const width = canvas?.clientWidth || canvas?.width || 0; - const height = canvas?.clientHeight || canvas?.height || 0; +export function viewFocus(view, { groundHeight = null, maxRange = null } = {}) { + const nadir = cameraNadir(view); + const { width, height } = canvasSize(view); if (!nadir || !width || !height) return nadir; const hit = groundHit( - viewer.camera, - new Cesium.Cartesian2(width / 2, height / 2), - raisedEllipsoid( - scene?.globe?.ellipsoid || Cesium.Ellipsoid.WGS84, - groundHeight, - ), + engineOf(view), + { x: width / 2, y: height / 2 }, + raisedEllipsoid(groundHeight), maxRange, - viewer.engine, ); if (!hit) return nadir; const ahead = { - lon: Cesium.Math.toDegrees(hit.longitude), - lat: Cesium.Math.toDegrees(hit.latitude), + lon: geo.math.toDegrees(hit.longitude), + lat: geo.math.toDegrees(hit.latitude), }; return { lon: wrapLon(nadir.lon + wrapLon(ahead.lon - nadir.lon) / 2), @@ -222,82 +221,10 @@ export function viewFocus( }; } -/** - * Horizon occluder depth below WGS84 (m): ground can lie below the ellipsoid - * (NYC −22 m), and an occluder at 0 m would hide the cones around it. - */ -const HORIZON_CULL_DEPTH_M = 1000; -let HORIZON_CULL_ELLIPSOID; - -/** - * Hide billboards and points behind the horizon: they skip the depth test, so - * nothing else stops the far side drawing through the globe. Re-culls on - * pre-render after the camera moves; stops when nothing is left to cull. - * @param {{getViewer: () => object|null, items: () => Iterable<{position: object, show: boolean}>, onChange?: () => void}} options - */ -export function createHorizonCull({ getViewer, items, onChange }) { - let occluder; - let from = null; - let stopListening = null; - - /** Show what is in front of the horizon, hide the rest; the count seen. */ - function cull(list, position) { - HORIZON_CULL_ELLIPSOID ??= new Cesium.Ellipsoid( - Cesium.Ellipsoid.WGS84.radii.x - HORIZON_CULL_DEPTH_M, - Cesium.Ellipsoid.WGS84.radii.y - HORIZON_CULL_DEPTH_M, - Cesium.Ellipsoid.WGS84.radii.z - HORIZON_CULL_DEPTH_M, - ); - occluder ??= new Cesium.EllipsoidalOccluder(HORIZON_CULL_ELLIPSOID); - occluder.cameraPosition = position; - let seen = 0; - let changed = false; - for (const item of list) { - seen++; - const show = occluder.isPointVisible(item.position); - if (item.show !== show) { - item.show = show; - changed = true; - } - } - if (changed) onChange?.(); - return seen; - } - - /** Cull everything from the camera at `position`; the count seen. */ - function cullAll(position) { - from = Cesium.Cartesian3.clone(position, from); - return cull(items(), position); - } - - function onPreRender() { - const position = getViewer()?.camera?.positionWC; - if (!position || (from && Cesium.Cartesian3.equals(from, position))) return; - if (!cullAll(position)) stop(); - } - - /** Cull `added` items (or all) now, then all again whenever the camera moves. */ - function update(added) { - const viewer = getViewer(); - const position = viewer?.camera?.positionWC; - if (position && !(added ? cull(added, position) : cullAll(position))) - return; - stopListening ||= - viewer?.scene?.preRender?.addEventListener(onPreRender) || null; - } - - function stop() { - stopListening?.(); - stopListening = null; - from = null; - } - - return { update, stop }; -} - /** The middle of the visible ground, else the point under the camera; null without a camera. */ -export function viewCentre(viewer) { - if (!viewer) return null; - const bbox = visibleBbox(viewer); +export function viewCentre(view) { + if (!engineOf(view)) return null; + const bbox = visibleBbox(view); if (bbox) { // Across the date line (west > east) the middle is on the far side of 0°. const span = bbox[2] - bbox[0] + (bbox[0] > bbox[2] ? 360 : 0); @@ -305,10 +232,5 @@ export function viewCentre(viewer) { if (lon > 180) lon -= 360; return { lat: (bbox[1] + bbox[3]) / 2, lon }; } - const carto = cameraLocation(viewer); - if (!carto) return null; - return { - lat: Cesium.Math.toDegrees(carto.latitude), - lon: Cesium.Math.toDegrees(carto.longitude), - }; + return cameraNadir(view); } diff --git a/src/layers/streetLevel/view.test.mjs b/src/layers/streetLevel/view.test.mjs index 8f5a4dd99..4aaddf20c 100644 --- a/src/layers/streetLevel/view.test.mjs +++ b/src/layers/streetLevel/view.test.mjs @@ -1,108 +1,97 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import * as Cesium from 'cesium'; import { cameraNadir, - createHorizonCull, groundUnderCamera, metresBetween, + viewCentre, viewFocus, visibleBbox, } from './view.js'; import { latToTileY, lonToTileX, tilesForBbox } from './tileMath.js'; -import { rayCamera } from '../../testSupport/streetLevelFakes.mjs'; +import { cameraEngine } from '../../testSupport/streetLevelFakes.mjs'; -const RAD = Math.PI / 180; -/** A viewer 300 m above a street 1,600 m up (Denver-like). */ -function viewer({ globeShown, globeHeight }) { - return { - camera: { - positionCartographic: { - longitude: -104.99 * RAD, - latitude: 39.74 * RAD, - height: 1900, - }, - }, - scene: { globe: { show: globeShown, getHeight: () => globeHeight } }, - }; +/** + * The display's view of a fake engine (view.js reads `engine` only): 300 m + * above a street 1,600 m up (Denver-like), the terrain at `terrain` and the + * rendered surface at `sample`; `tiles` shows Google 3D (the globe hidden). + */ +async function denver({ terrain, sample, tiles = false }) { + const engine = cameraEngine({ lon: -104.99, lat: 39.74, altitude: 1900 }); + engine.fake.script('terrainHeight', () => terrain); + engine.fake.script('sampleHeight', () => sample); + if (tiles) await engine.maps.setPhotorealTiles({ kind: 'photoreal' }); + return { engine }; } -test('a shown globe answers the ground under the camera', () => { - assert.equal( - groundUnderCamera(viewer({ globeShown: true, globeHeight: 1600 })), - 1600, - ); +test('a shown globe answers the ground under the camera', async () => { + assert.equal(groundUnderCamera(await denver({ terrain: 1600 })), 1600); }); -test('a hidden globe (Google 3D) or a missing sample gives no ground', () => { - // A hidden globe's getHeight is ignored even when it returns a number. +test('a hidden globe (Google 3D) or a missing sample gives no ground', async () => { + // A hidden globe's terrain is ignored even when it returns a number. assert.equal( - groundUnderCamera(viewer({ globeShown: false, globeHeight: 0 })), - null, - ); - assert.equal( - groundUnderCamera(viewer({ globeShown: true, globeHeight: undefined })), + groundUnderCamera(await denver({ terrain: 0, tiles: true })), null, ); + assert.equal(groundUnderCamera(await denver({ terrain: undefined })), null); assert.equal(groundUnderCamera({}), null); + assert.equal(groundUnderCamera(null), null); }); -/** The Denver viewer with Google 3D: no globe, a sampleable rendered surface. */ -function google3d(sample) { - const view = viewer({ globeShown: false, globeHeight: 0 }); - view.scene.sampleHeightSupported = true; - view.scene.sampleHeight = () => sample; - return view; -} +/** The Denver view with Google 3D: no globe, a sampleable rendered surface. */ +const google3d = (sample) => denver({ terrain: 0, sample, tiles: true }); -test('on Google 3D the rendered surface answers the ground under the camera', () => { - assert.equal(groundUnderCamera(google3d(1612)), 1612); +test('on Google 3D the rendered surface answers the ground under the camera', async () => { + assert.equal(groundUnderCamera(await google3d(1612)), 1612); }); -test('an implausible or above-camera surface sample gives no ground', () => { +test('an implausible or above-camera surface sample gives no ground', async () => { // Before tiles stream in, sampleHeight can read kilometres underground. - assert.equal(groundUnderCamera(google3d(-14_886)), null); + assert.equal(groundUnderCamera(await google3d(-14_886)), null); // A roof above the camera (1,900 m) would put the camera underground. - assert.equal(groundUnderCamera(google3d(1950)), null); - assert.equal(groundUnderCamera(google3d(undefined)), null); + assert.equal(groundUnderCamera(await google3d(1950)), null); + assert.equal(groundUnderCamera(await google3d(undefined)), null); }); -/** A camera whose screen rays land on a grid of lon/lat points. */ -function gridViewer(lons, lats) { - const points = []; - for (const lat of lats) - for (const lon of lons) - points.push(Cesium.Cartesian3.fromDegrees(lon, lat)); - let next = 0; - return { - scene: { canvas: { clientWidth: 100, clientHeight: 100 }, globe: {} }, - camera: { - pickEllipsoid: () => points[next++ % points.length], - positionCartographic: { longitude: 0, latitude: 0, height: 1000 }, - }, - }; -} +/** Straight down from `altitude` over `lon`/`lat` on a 100 px canvas. */ +const downAt = (lon, lat, altitude) => ({ + engine: cameraEngine({ lon, lat, altitude, width: 100, height: 100 }), +}); test('a view across the date line is a narrow box with west > east', () => { - const viewer = gridViewer([178, 179, -179, -178], [-1, 0, 1]); - const bbox = visibleBbox(viewer); - assert.ok(bbox[0] > 177 && bbox[0] < 179, `west ${bbox[0]}`); - assert.ok(bbox[2] < -177 && bbox[2] > -179, `east ${bbox[2]}`); + // About ±1° around 180° from 200 km up. + const bbox = visibleBbox(downAt(180, 0, 200_000)); + assert.ok(bbox[0] > 178 && bbox[0] < 180, `west ${bbox[0]}`); + assert.ok(bbox[2] < -178 && bbox[2] > -180, `east ${bbox[2]}`); + // Its middle is on the date line, not at 0°. + assert.ok( + Math.abs(Math.abs(viewCentre(downAt(180, 0, 200_000)).lon) - 180) < 0.01, + ); }); test('an ordinary view keeps west < east', () => { - const bbox = visibleBbox(gridViewer([10, 11, 12], [50, 51])); + const bbox = visibleBbox(downAt(11, 50.5, 50_000)); assert.ok(bbox[0] < bbox[2]); + assert.ok(bbox[0] < 11 && bbox[2] > 11 && bbox[1] < 50.5 && bbox[3] > 50.5); }); -/** A pinhole viewer `agl` m above ground `ground` m up, looking north. */ +test('without an engine or a canvas there is no box, and the centre is the nadir', () => { + assert.equal(visibleBbox(null), null); + assert.equal(viewCentre(null), null); + const view = downAt(11, 50.5, 50_000); + view.engine.fake.setSize(0, 0); + assert.equal(visibleBbox(view), null); + const centre = viewCentre(view); + assert.ok( + Math.abs(centre.lon - 11) < 1e-9 && Math.abs(centre.lat - 50.5) < 1e-9, + ); +}); + +/** A pinhole view `agl` m above ground `ground` m up, looking north. */ function pinhole({ lon, lat, ground, agl, pitch, w = 1600, h = 900 }) { return { - scene: { - canvas: { clientWidth: w, clientHeight: h }, - globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 }, - }, - camera: rayCamera({ + engine: cameraEngine({ lon, lat, altitude: ground + agl, @@ -233,155 +222,3 @@ test('metresBetween measures the short way round the date line', () => { ) < 1e-9, ); }); - -/** - * A horizon cull over `items` for a camera the test moves, with the frame's - * pre-render listeners and a count of the points tested against the horizon. - */ -function cullHarness(t, items) { - const visible = t.mock.method( - Cesium.EllipsoidalOccluder.prototype, - 'isPointVisible', - ); - const preRender = new Set(); - const camera = { positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 1e7) }; - const viewer = { - camera, - scene: { - preRender: { - addEventListener(listener) { - preRender.add(listener); - return () => preRender.delete(listener); - }, - }, - }, - }; - let changes = 0; - const cull = createHorizonCull({ - getViewer: () => viewer, - items: () => items, - onChange: () => changes++, - }); - return { - cull, - preRender, - tested: () => visible.mock.callCount(), - changes: () => changes, - frame: () => { - for (const listener of [...preRender]) listener(); - }, - // Moved in place, as Cesium updates positionWC. - moveTo: (lon, lat) => - Cesium.Cartesian3.fromDegrees( - lon, - lat, - 1e7, - undefined, - camera.positionWC, - ), - }; -} - -const point = (lon, lat) => ({ - position: Cesium.Cartesian3.fromDegrees(lon, lat), - show: true, -}); - -test('the horizon cull does no work while the camera stands still', (t) => { - const near = point(0, 0); - const far = point(180, 0); - const h = cullHarness(t, [near, far]); - h.cull.update(); - assert.equal(h.tested(), 2); - assert.deepEqual([near.show, far.show], [true, false]); - assert.equal(h.preRender.size, 1, 'listening for camera moves'); - for (let i = 0; i < 5; i++) h.frame(); - assert.equal(h.tested(), 2, 'a still camera re-culls nothing'); - h.moveTo(180, 0); - h.frame(); - assert.equal(h.tested(), 4, 'a moved camera re-culls everything once'); - assert.deepEqual([near.show, far.show], [false, true]); - h.frame(); - h.frame(); - assert.equal(h.tested(), 4, 'and then rests again'); - h.cull.stop(); - assert.equal(h.preRender.size, 0); -}); - -test('the horizon cull stops listening once nothing is left, and starts again for new items', (t) => { - const items = [point(0, 0)]; - const h = cullHarness(t, items); - h.cull.update(); - assert.equal(h.preRender.size, 1); - // Every item goes (the tiles are dropped); the next camera move finds none. - items.length = 0; - h.moveTo(10, 0); - h.frame(); - assert.equal(h.preRender.size, 0, 'the listener is removed'); - // New items arrive: they are culled now, and camera moves are watched again. - const far = point(-170, 0); - items.push(far); - h.cull.update([far]); - assert.equal(far.show, false, 'culled on arrival'); - assert.equal(h.preRender.size, 1, 'the listener is back'); - h.moveTo(-170, 0); - h.frame(); - assert.equal(far.show, true, 're-culled when the camera moves'); - assert.equal(h.changes(), 2, 'a redraw for each change'); - h.cull.stop(); -}); - -/** A cull over `points` ([lon, lat, height]) seen from a camera at `camera`. */ -function cullFrom(camera, points) { - const items = points.map(([lon, lat, height]) => ({ - position: Cesium.Cartesian3.fromDegrees(lon, lat, height), - show: true, - })); - const horizon = createHorizonCull({ - getViewer: () => ({ - camera: { positionWC: Cesium.Cartesian3.fromDegrees(...camera) }, - scene: { preRender: { addEventListener: () => () => {} } }, - }), - items: () => items, - }); - horizon.update(); - horizon.stop(); - return items.map((item) => item.show); -} - -test('cones and the marker stay visible on ground below the WGS84 ellipsoid', () => { - // NYC at street level: eye 2.4 m above ground at -22 m, a cone 10 m away. - assert.deepEqual( - cullFrom([-74.006, 40.7128, -19.6], [[-74.006, 40.71289, -22]]), - [true], - ); - // Colombo framing view: camera at -21 m, cones at -95 m about 80 m away. - assert.deepEqual( - cullFrom( - [79.8612, 6.9271, -21], - [ - [79.8612, 6.92782, -95], - [79.86192, 6.9271, -95], - ], - ), - [true, true], - ); - // Austin, ground well above the ellipsoid. - assert.deepEqual( - cullFrom([-97.7431, 30.2672, 132], [[-97.7431, 30.2681, 130]]), - [true], - ); -}); - -test('the horizon cull still hides the far side of the Earth from orbit', () => { - assert.deepEqual( - cullFrom( - [0, 0, 20_000_000], - [ - [10, 10, 0], - [180, 0, 0], - ], - ), - [true, false], - ); -}); diff --git a/src/layers/traffic/animation.js b/src/layers/traffic/animation.js index 5ab522262..c8b1f794e 100644 --- a/src/layers/traffic/animation.js +++ b/src/layers/traffic/animation.js @@ -1,7 +1,8 @@ import { DOT_FADE_MS } from './retention.js'; import { flowBucket, flowSpeedScale } from '../../data/trafficFlowStyle.js'; import { presetDotOutline } from '../../data/trafficPresetStyle.js'; -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; +import { Rgba, nearFarScale } from '../shared/color.js'; import { queuePlatoons, locateAlongRoad } from '../../data/trafficQueue.js'; import { SPEED_MPS, @@ -10,8 +11,7 @@ import { CREEP_MOVE_MS, CREEP_STOP_MS, CREEP_BURST, - HEAT_JAM_BASE_ALPHA, - HEAT_JAM_PULSE_ALPHA, + SIM_DOT_COLOR, } from './policy.js'; export function createAnimation({ @@ -21,10 +21,7 @@ export function createAnimation({ source, }) { function syncRenderDemand() { - const needed = Boolean( - layerState._enabled && - (layerState._dots.length || layerState._heatJamPrim), - ); + const needed = Boolean(layerState._enabled && layerState._dots.length); // Height easing only runs in a held frame. Settle it before releasing, so // a population emptied mid-correction cannot report loading forever. if (!needed && layerState._heightPending) parts.retention.finishHeights(); @@ -33,11 +30,15 @@ export function createAnimation({ if (needed) { layerState._lastAnimTime = 0; services.render.holdContinuousRender('traffic'); - if (layerState._engine) layerState._engine.render.request('traffic'); - else layerState._viewer?.scene?.requestRender?.(); + layerState._engine?.render.request('traffic'); } else services.render.releaseContinuousRender('traffic'); } + /** Writes the dots to the engine's display (once a frame, and on a clear). */ + function drawDots() { + layerState._display?.drawDots(layerState._dots); + } + /** * Spawn animated dot primitives along a single road. * @@ -45,7 +46,7 @@ export function createAnimation({ * randomized speed (base +/-30%), and given a direction (alternating * forward/backward to simulate two-way traffic). * - * @param {{waypoints:Cesium.Cartesian3[], segmentDist:number[], type:string, coords:number[][]}} road + * @param {{waypoints:object[], segmentDist:number[], type:string, coords:number[][]}} road * Parsed road object with pre-computed waypoints. * @param {number} altitude - Camera altitude (used if budgetCount is null). * @param {number|null} [budgetCount=null] - Pre-allocated dot count. Falls back @@ -82,7 +83,7 @@ export function createAnimation({ ? presetDotOutline(layerState._stylePreset, bucket) : null; const outlineColor = outlineSpec - ? new Cesium.Color( + ? new Rgba( outlineSpec.rgba[0] / 255, outlineSpec.rgba[1] / 255, outlineSpec.rgba[2] / 255, @@ -140,7 +141,7 @@ export function createAnimation({ : -1; // Compute initial Cartesian3 position via linear interpolation - Cesium.Cartesian3.lerp( + geo.cartesian3.lerp( road.waypoints[segIdx], road.waypoints[segIdx + 1], t, @@ -151,31 +152,30 @@ export function createAnimation({ // depth override made distant roads look like vehicles crossing roofs. const jamProminent = bucket === 'jam' && parts.style.jamDensityOn(); const id = layerState._nextDotId++; - const point = layerState._pointCollection.add({ - position: Cesium.Cartesian3.clone(layerState._scratchLerp), + // The dot's look, which the display draws (depth-tested: it respects + // the sampled mesh at every viewing distance). + const point = { + position: geo.cartesian3.clone(layerState._scratchLerp), pixelSize, // No flow data → today's exact simulated white. - color: flowColor || Cesium.Color.WHITE.withAlpha(0.85), - scaleByDistance: new Cesium.NearFarScalar( + color: flowColor || SIM_DOT_COLOR, + show: true, + scaleByDistance: nearFarScale( 100, 1.5, layerState._fadeScaleFar, jamProminent ? JAM_DOT_FAR_SCALE : 0.3, ), - translucencyByDistance: new Cesium.NearFarScalar( + translucencyByDistance: nearFarScale( 100, 1.0, layerState._fadeTransFar, 0.0, ), - // Respect the sampled mesh at every viewing distance. - disableDepthTestDistance: 0, - // Preset dark halo (spread only when present — the keyless/normal - // path passes the exact shipped option set). - ...(outlineSpec - ? { outlineColor, outlineWidth: outlineSpec.width } - : {}), - }); + // Preset dark halo (none on the keyless/normal path). + outlineColor: outlineSpec ? outlineColor : null, + outlineWidth: outlineSpec ? outlineSpec.width : 0, + }; layerState._bucketCounts[bucket || 'sim'] += 1; layerState._motion.added++; @@ -210,7 +210,7 @@ export function createAnimation({ } /** - * Per-frame animation callback registered on `scene.preRender`. + * Per-frame animation callback registered on `engine.frames.preRender`. * * For every active dot: * 1. Ease heights and fade arrivals/departures, including paused dots. @@ -219,7 +219,7 @@ export function createAnimation({ * 3. Advance t in the dot's travel direction, handling segment boundary * crossings and faded end-of-road departures. * 4. Linearly interpolate between the two bounding waypoints and update the - * point primitive's position. + * dot's position; the display draws the dots once the frame is done. * * Delta time is capped at 100 ms to prevent large jumps after background tabs. */ @@ -306,27 +306,12 @@ export function createAnimation({ if (now < dot.stoppedUntil) break; } - // Lerp between pre-computed Cartesian3 waypoints (no trig needed). - // Pass the scratch directly: PointPrimitive's position setter clones the - // value into its own storage (and skips the VBO dirty flag when equal), so - // the extra defensive clone here allocated 360–720k Cartesian3/s of pure - // garbage across 6000 dots. (perf item 5) + // Lerp between pre-computed waypoints (no trig needed), in place: a + // fresh position per dot per frame is 360–720k objects a second across + // 6000 dots. const a = dot.waypoints[dot.segIdx]; const b = dot.waypoints[dot.segIdx + 1]; - Cesium.Cartesian3.lerp(a, b, dot.t, layerState._scratchLerp); - dot.point.position = layerState._scratchLerp; - } - - // Jam heat-lines throb (~1.6 s period) — one shared material uniform for - // the whole jam batch, no geometry rebuild. Null outside live heatline mode. - if (layerState._native && layerState._heatJamPrim) { - layerState._heatJamPrim.setAlpha( - HEAT_JAM_BASE_ALPHA + HEAT_JAM_PULSE_ALPHA * Math.sin(now / 260), - ); - } - if (layerState._heatJamPrim?.appearance) { - layerState._heatJamPrim.appearance.material.uniforms.color.alpha = - HEAT_JAM_BASE_ALPHA + HEAT_JAM_PULSE_ALPHA * Math.sin(now / 260); + geo.cartesian3.lerp(a, b, dot.t, dot.point.position); } layerState._animFrame++; @@ -355,24 +340,22 @@ export function createAnimation({ /** Remove all point primitives and reset dot/road arrays and counters. */ function clearDots() { - services.credits?.hideOsmCredit?.( - layerState._engine ?? layerState._viewer, - 'traffic', - ); + services.credits?.hideOsmCredit?.(layerState._engine, 'traffic'); parts.retention.clear(); if (layerState._dots.length) layerState._motion.rebuilds++; - if (layerState._pointCollection) layerState._pointCollection.removeAll(); parts.rendering.removeHeatLines(); layerState._dots = []; layerState._roads = []; layerState._count = 0; layerState._bucketCounts = { free: 0, slow: 0, jam: 0, sim: 0 }; layerState._closedRoads = 0; + drawDots(); syncRenderDemand(); } return { spawnDotsForRoad, animate, + drawDots, maybeStopLight, clearDots, syncRenderDemand, diff --git a/src/layers/traffic/controls.js b/src/layers/traffic/controls.js index 6570c7958..76d2fa365 100644 --- a/src/layers/traffic/controls.js +++ b/src/layers/traffic/controls.js @@ -208,7 +208,7 @@ export function createControls({ state: layerState, services, parts, source }) { * @param {Object} [options] * @param {number} [options.maxCount] - Maximum objects to return (defaults to all). * @param {number} [options.seed] - Integer seed to offset the sampling start. - * @returns {Array<{position:Cesium.Cartesian3, id:string, type:string}>} + * @returns {Array<{position:{x:number,y:number,z:number}, id:string, type:string}>} */ getDetectableObjects(options = {}) { if (!layerState._enabled || layerState._dots.length === 0) return []; diff --git a/src/layers/traffic/deadline.test.mjs b/src/layers/traffic/deadline.test.mjs index bff9d0d67..c154ebae7 100644 --- a/src/layers/traffic/deadline.test.mjs +++ b/src/layers/traffic/deadline.test.mjs @@ -6,6 +6,29 @@ import { createFlow } from './flow.js'; import { createRetention } from './retention.js'; import { createControls } from './controls.js'; import { createState } from './state.js'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; + +// An engine-shaped view of a Cesium camera: the canvas size, and worldToWindow +// through Cesium's own projection (undefined behind the camera or in front of +// the near plane, as the engines answer). +function cameraEngine(scene) { + const eye = new C.Cartesian3(); + return { + host: { + size: () => ({ + width: scene.canvas.clientWidth, + height: scene.canvas.clientHeight, + }), + }, + projector: { + worldToWindow(point, result) { + C.Matrix4.multiplyByPoint(scene.camera.viewMatrix, point, eye); + if (eye.z >= -scene.camera.frustum.near) return undefined; + return C.SceneTransforms.worldToWindowCoordinates(scene, point, result); + }, + }, + }; +} const bounds = { west: 0, east: 0.01, south: 0, north: 0.01 }; const flush = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); }; @@ -87,7 +110,7 @@ for (const y of [-175, -200, -300]) test(`off-screen camera-plane height correct }; const point = new C.Cartesian3(0, y, 100), target = new C.Cartesian3(0, y, 197); const state = createState({ services: {} }); - Object.assign(state, { _viewer: { scene }, _roads: [{ heightMoving: true, waypoints: [point], targetWaypoints: [target], segmentDist: [] }] }); + Object.assign(state, { _engine: cameraEngine(scene), _roads: [{ heightMoving: true, waypoints: [point], targetWaypoints: [target], segmentDist: [] }] }); const retention = createRetention({ state, parts: {} }); retention.easeHeights(1 / 60); const controls = createControls({ state, services: {}, source: { getFlowSessionStats: () => ({}) }, parts: { model: { trafficFeedPresentation: () => ({}) } } }); @@ -111,7 +134,7 @@ test('visible corrections use the pixel limit without the off-screen world-speed frustum: new C.PerspectiveFrustum({ fov: Math.PI / 3, aspectRatio: 1.6, near: 1, far: 1e7 }) }, }; const point = new C.Cartesian3(0, 1000, 0), target = new C.Cartesian3(0, 1000, 300); - const state = { _viewer: { scene }, _roads: [{ heightMoving: true, waypoints: [point], targetWaypoints: [target], segmentDist: [] }] }; + const state = { _engine: cameraEngine(scene), _roads: [{ heightMoving: true, waypoints: [point], targetWaypoints: [target], segmentDist: [] }] }; const retention = createRetention({ state, parts: {} }); let loadingFrames = 0, maxPixels = 0; for (let frame = 0; frame < 180; frame++) { @@ -137,7 +160,7 @@ for (const targetHeight of [200, 2000]) test(`visible height steps stay pixel-li frustum: new C.PerspectiveFrustum({ fov: Math.PI / 3, aspectRatio: 1.6, near: 1, far: 1e7 }) }, }; const point = new C.Cartesian3(0, 50, 400), target = new C.Cartesian3(0, 50, targetHeight); - const state = { _viewer: { scene }, _roads: [{ heightMoving: true, waypoints: [point], targetWaypoints: [target], segmentDist: [] }] }; + const state = { _engine: cameraEngine(scene), _roads: [{ heightMoving: true, waypoints: [point], targetWaypoints: [target], segmentDist: [] }] }; const before = C.SceneTransforms.worldToWindowCoordinates(scene, point); assert.ok(before.y > 0 && before.y < 800); const trial = C.Cartesian3.lerp(point, target, 0.2, new C.Cartesian3()); @@ -173,6 +196,24 @@ function holdFlowTasks(t) { }; } +// A globe without photoreal tiles 450 m over the road, whose terrain +// answers 100 m (counted), with no rendered depth. +function terrainEngine(calls) { + const engine = createFakeEngine(); + engine.fake.maps.imagery = { kind: 'osm' }; + engine.fake.setPose({ + longitude: (0.0005 * Math.PI) / 180, + latitude: 0, + height: 450, + }); + engine.fake.script('pickPosition', undefined); + engine.fake.script('terrainHeight', () => { + calls.samples++; + return 100; + }); + return engine; +} + function flowFixture(source) { const state = createState({ services: {} }); const calls = { samples: 0, selections: 0 }; @@ -194,17 +235,7 @@ function flowFixture(source) { _enabled: true, _roadMode: 'osm', _parseRoads: (data) => data.roads, - _viewer: { - scene: { - globe: { - show: true, - getHeight: () => { - calls.samples++; - return 100; - }, - }, - }, - }, + _engine: terrainEngine(calls), }); const parts = { viewport: { diff --git a/src/layers/traffic/flow.js b/src/layers/traffic/flow.js index f1784b839..5f717ec43 100644 --- a/src/layers/traffic/flow.js +++ b/src/layers/traffic/flow.js @@ -1,5 +1,5 @@ import { roadIdentity } from './retention.js'; -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; import { prepareRoadSurfaces, roadHeightStations, @@ -20,17 +20,18 @@ import { nextTask } from '../../sources/vectorTiles.js'; * the traffic layer's kilometre range). Null without a camera. */ function groundDistanceFrom(camera, groundHeight = 0) { - const view = camera && Cesium.Cartographic.fromCartesian(camera); + const view = camera && geo.cartographic.fromCartesian(camera); if (!view) return null; const clearance = view.height - (groundHeight || 0) - DOT_HEIGHT_OFFSET; - const lat = Cesium.Math.toDegrees(view.latitude); - const lon = Cesium.Math.toDegrees(view.longitude); + const lat = geo.math.toDegrees(view.latitude); + const lon = geo.math.toDegrees(view.longitude); const east = 111320 * Math.cos(view.latitude); return ([roadLon, roadLat]) => Math.hypot((roadLon - lon) * east, (roadLat - lat) * 111320, clearance); } export function createFlow({ state: layerState, services, parts, source }) { + const scratchScreen = geo.cartesian2.create(); const { registerDynamicCredit, TOMTOM_CREDIT } = services.credits; const { fetchFlowForBounds } = source; @@ -95,10 +96,7 @@ export function createFlow({ state: layerState, services, parts, source }) { layerState._flowStatusKnown = true; if (layerState._liveMode) { console.log('[Data:Traffic] TomTom key present — live flow mode'); - registerDynamicCredit( - layerState._engine ?? layerState._viewer, - TOMTOM_CREDIT, - ); + registerDynamicCredit(layerState._engine, TOMTOM_CREDIT); } }) .catch((e) => { @@ -291,15 +289,16 @@ export function createFlow({ state: layerState, services, parts, source }) { // Separate whole-graph preparation from tile completion and publication. await nextTask(); if (!current()) return false; - const scene = layerState._trafficScene?.() ?? layerState._viewer?.scene; - if (!scene) return false; + const engine = layerState._engine; + if (!engine) return false; + const size = engine.host.size(); // The enable-time estimate can predate the first local mesh (especially // after an intercontinental jump). Once previews have measured roads, use // that local evidence to select the detail view at its real elevation. const localHeights = []; for (const prepared of preparedRoads.values()) { const height = - Cesium.Cartographic.fromCartesian(prepared.waypoints[0]).height - + geo.cartographic.fromCartesian(prepared.waypoints[0]).height - DOT_HEIGHT_OFFSET; if (Number.isFinite(height) && Math.abs(height) <= 9000) localHeights.push(height); @@ -313,14 +312,11 @@ export function createFlow({ state: layerState, services, parts, source }) { // A rendered center-depth pick follows the current city's elevation even // while older coarse samples are being replaced. It is selection-only. let depthHeight; - if (scene.pickPositionSupported && scene.canvas?.clientWidth) { - const probe = new Cesium.Cartesian2( - scene.canvas.clientWidth / 2, - scene.canvas.clientHeight * 0.6, - ); + if (engine.picker.pickPositionSupported && size.width) { + const probe = geo.cartesian2.create(size.width / 2, size.height * 0.6); try { - const hit = scene.pickPosition(probe); - const height = hit && Cesium.Cartographic.fromCartesian(hit).height; + const hit = engine.picker.pickPosition(probe); + const height = hit && geo.cartographic.fromCartesian(hit).height; if (Number.isFinite(height) && Math.abs(height) <= 9000) depthHeight = height; } catch { @@ -333,7 +329,7 @@ export function createFlow({ state: layerState, services, parts, source }) { const prepared = !refining && preparedRoads.get(road.key); if (prepared) { for (let i = 0; i < road.waypoints.length; i++) - Cesium.Cartesian3.clone(prepared.waypoints[i], road.waypoints[i]); + geo.cartesian3.clone(prepared.waypoints[i], road.waypoints[i]); } else if ( (!road.surfaceReady || refining) && (localHeights.length || depthHeight !== undefined) @@ -342,7 +338,7 @@ export function createFlow({ state: layerState, services, parts, source }) { // Opposite source directions may share waypoint arrays. Provisional // selection must not overwrite an already measured sibling. road.waypoints = road.waypoints.map((point) => - Cesium.Cartesian3.clone(point), + geo.cartesian3.clone(point), ); for (let i = 0; i < road.coords.length; i++) { const [lon, lat] = road.coords[i]; @@ -351,7 +347,7 @@ export function createFlow({ state: layerState, services, parts, source }) { Number.isFinite(floor) && Math.abs(floor) <= 9000 ? Math.max(floor, layerState._viewHeightEstimate) : layerState._viewHeightEstimate; - Cesium.Cartesian3.fromDegrees( + geo.cartesian3.fromDegrees( lon, lat, height + DOT_HEIGHT_OFFSET, @@ -367,9 +363,7 @@ export function createFlow({ state: layerState, services, parts, source }) { // last: after a globe switch the outgoing globe's, often taken from its // coarse first tiles (about -2 200 m at Austin on keyless Cesium). Weighted // by those, a returning globe's population fell from 620 to 260. - const viewCamera = - layerState._engine?.camera.livePosition ?? - layerState._viewer?.camera?.positionWC; + const viewCamera = engine.camera.livePosition; const groundDistance = groundDistanceFrom( viewCamera, layerState._viewHeightEstimate, @@ -390,22 +384,20 @@ export function createFlow({ state: layerState, services, parts, source }) { ) return false; // Spend surface work on roads crossing the canvas, with a small pan margin. - if (!scene.canvas?.clientWidth) return true; + if (!size.width) return true; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const point of road.waypoints) { - const screen = scene.project - ? scene.project(point, {}) - : Cesium.SceneTransforms.worldToWindowCoordinates(scene, point); + const screen = engine.projector.worldToWindow(point, scratchScreen); if (!screen) continue; minX = Math.min(minX, screen.x); maxX = Math.max(maxX, screen.x); minY = Math.min(minY, screen.y); maxY = Math.max(maxY, screen.y); } - road.coverageBand = maxY < scene.canvas.clientHeight / 2 ? 1 : 0; + road.coverageBand = maxY < size.height / 2 ? 1 : 0; if (clamped.coverage && groundDistance) { road.baseDensityWeight ??= road.densityWeight || 1; road.densityWeight = @@ -414,9 +406,9 @@ export function createFlow({ state: layerState, services, parts, source }) { } return ( maxX >= -100 && - minX <= scene.canvas.clientWidth + 100 && + minX <= size.width + 100 && maxY >= -100 && - minY <= scene.canvas.clientHeight + 100 + minY <= size.height + 100 ); }); const budgets = parts.model.allocateRoadDotBudgets( @@ -425,14 +417,12 @@ export function createFlow({ state: layerState, services, parts, source }) { roadDotBudget(altitude), ); const admitted = selected.filter((r, i) => budgets[i] > 0); - const camera = - layerState._engine?.camera.livePosition ?? - layerState._viewer?.camera?.positionWC; + const camera = engine.camera.livePosition; if (camera) admitted.sort( (a, b) => - Cesium.Cartesian3.distanceSquared(camera, a.waypoints[0]) - - Cesium.Cartesian3.distanceSquared(camera, b.waypoints[0]), + geo.cartesian3.distanceSquared(camera, a.waypoints[0]) - + geo.cartesian3.distanceSquared(camera, b.waypoints[0]), ); // The wide pass is a fast preview when detailed tiles follow. Preparing // the entire coarse graph only to replace it doubles surface work. @@ -479,7 +469,7 @@ export function createFlow({ state: layerState, services, parts, source }) { const prepared = !refining && preparedRoads.get(road.key); if (prepared) { for (let i = 0; i < road.waypoints.length; i++) - Cesium.Cartesian3.clone(prepared.waypoints[i], road.waypoints[i]); + geo.cartesian3.clone(prepared.waypoints[i], road.waypoints[i]); for (let i = 0; i < road.segmentDist.length; i++) road.segmentDist[i] = prepared.segmentDist[i]; road.surfaceReady = true; @@ -530,9 +520,9 @@ export function createFlow({ state: layerState, services, parts, source }) { if (!current()) return false; let prepared = await prepareRoadSurfaces( unresolved, - scene, + engine, services.ground, - [layerState._pointCollection], + layerState._display?.excluded() ?? [], signal, options, ); @@ -552,9 +542,9 @@ export function createFlow({ state: layerState, services, parts, source }) { if (!current()) return false; prepared = await prepareRoadSurfaces( prepared.pending, - scene, + engine, services.ground, - [layerState._pointCollection], + layerState._display?.excluded() ?? [], signal, options, ); @@ -566,8 +556,7 @@ export function createFlow({ state: layerState, services, parts, source }) { current() && !preview && !label.includes('refined') && - scene.postRender && - (!trafficSurfaceReady(scene) || + (!trafficSurfaceReady(engine) || prepared.pending.length || ready.some((road) => road.surfaceSettled === false)) ) { @@ -578,12 +567,12 @@ export function createFlow({ state: layerState, services, parts, source }) { if (layerState._surfaceRefineRemove === stop) layerState._surfaceRefineRemove = null; }; - const remove = scene.postRender.addEventListener(() => { + const remove = engine.frames.postRender.addEventListener(() => { if (!current()) { stop(); return; } - if (!trafficSurfaceReady(scene)) return; + if (!trafficSurfaceReady(engine)) return; stop(); // Revalidate only local samples taken at a coarser rendered LOD. This // background upgrade never holds road acquisition or first paint. diff --git a/src/layers/traffic/footprint.js b/src/layers/traffic/footprint.js index 1beb629fc..aa7e8a656 100644 --- a/src/layers/traffic/footprint.js +++ b/src/layers/traffic/footprint.js @@ -1,4 +1,3 @@ -import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js'; import { tilesForBounds, tileToBBox } from '../../data/tomtomTiles.js'; @@ -66,15 +65,13 @@ export function footprintTiles(polygon, zoom, near) { /** Reusable camera scratch; sampled only on camera events, never in the animation loop. */ export function createReticleFootprint() { const pixels = Array.from({ length: 25 }, () => geo.cartesian2.create()); - const hits = Array.from({ length: 25 }, () => geo.cartesian3.create()); const carto = geo.cartographic.create(); - let ray; - return (viewer, altitude, engine) => { - const camera = engine?.camera ?? viewer?.camera; - if (!camera?.getPickRay && !camera?.pickRay) return null; - const pose = engine?.camera.pose(); - const nadir = pose?.cartographic ?? camera.positionCartographic; - const canvas = engine?.host.canvas ?? viewer.scene.canvas; + return (engine, altitude) => { + const camera = engine?.camera; + if (!camera?.pickRay) return null; + const pose = camera.pose(); + const nadir = pose.cartographic; + const canvas = engine.host.canvas; const near = { lat: geo.math.toDegrees(nadir.latitude), lon: geo.math.toDegrees(nadir.longitude), @@ -91,9 +88,7 @@ export function createReticleFootprint() { p = pixels[i]; p.x = left + (side * col) / 4; p.y = top + (side * row) / 4; - const hit = engine - ? camera.pickEllipsoid(p) - : camera.pickEllipsoid(p, Cesium.Ellipsoid.WGS84, hits[i]); + const hit = camera.pickEllipsoid(p); let dx, dy; if (hit) { geo.cartographic.fromCartesian(hit, geo.ellipsoid.WGS84, carto); @@ -102,9 +97,7 @@ export function createReticleFootprint() { dlon -= 360 * Math.round(dlon / 360); dx = dlon * 111.195 * Math.cos(nadir.latitude); } else { - const picked = engine - ? camera.pickRay(p) - : camera.getPickRay(p, (ray ??= new Cesium.Ray())) || ray; + const picked = camera.pickRay(p); if (!picked) continue; const d = picked.direction; dx = @@ -162,8 +155,8 @@ export function createReticleFootprint() { nadir.latitude, nadir.longitude, nadir.height / 10000, - pose?.heading ?? camera.heading, - pose?.pitch ?? camera.pitch, + pose.heading, + pose.pitch, ] .map((v) => Number(v || 0).toFixed(7)) .join(','), diff --git a/src/layers/traffic/footprint.test.mjs b/src/layers/traffic/footprint.test.mjs index 158496ae2..c5b868a40 100644 --- a/src/layers/traffic/footprint.test.mjs +++ b/src/layers/traffic/footprint.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; import { convexFootprint, footprintTiles, @@ -33,33 +33,35 @@ test('oblique horizon bearings are bounded and tile caps hold at city latitudes' [51.507, -0.128], [70, 10], ]) { - const camera = { - positionCartographic: Cesium.Cartographic.fromDegrees(lon, lat, 300), - heading: 0, - pitch: -Math.PI / 12, - pickEllipsoid(p, e, result) { - if (p.y < 300) return undefined; - return Cesium.Cartesian3.fromDegrees( - lon + (p.x - 640) / 100000, - lat + (800 - p.y) / 50000, - 0, - e, - result, - ); - }, - getPickRay(p, ray) { - ray.direction.x = - -Math.sin((lat * Math.PI) / 180) * Math.cos((lon * Math.PI) / 180); - ray.direction.y = - -Math.sin((lat * Math.PI) / 180) * Math.sin((lon * Math.PI) / 180); - ray.direction.z = Math.cos((lat * Math.PI) / 180); - return ray; + // An engine-shaped stub: the camera's pose and its ellipsoid and ray + // picks (the sky above the 300 px line), and the canvas. + const engine = { + camera: { + pose: () => ({ + cartographic: geo.cartographic.fromDegrees(lon, lat, 300), + heading: 0, + pitch: -Math.PI / 12, + }), + pickEllipsoid(p) { + if (p.y < 300) return undefined; + return geo.cartesian3.fromDegrees( + lon + (p.x - 640) / 100000, + lat + (800 - p.y) / 50000, + 0, + ); + }, + pickRay: () => ({ + origin: geo.cartesian3.create(), + direction: geo.cartesian3.create( + -Math.sin((lat * Math.PI) / 180) * Math.cos((lon * Math.PI) / 180), + -Math.sin((lat * Math.PI) / 180) * Math.sin((lon * Math.PI) / 180), + Math.cos((lat * Math.PI) / 180), + ), + }), }, + host: { canvas: { width: 1280, height: 800 } }, }; - const result = createReticleFootprint()( - { camera, scene: { canvas: { width: 1280, height: 800 } } }, - 300, - ); + const result = createReticleFootprint()(engine, 300); assert.ok(result.coverage.coarse.length <= 16); assert.ok(result.coverage.detail.length <= 16); assert.ok( diff --git a/src/layers/traffic/handoff.test.mjs b/src/layers/traffic/handoff.test.mjs index 5e5f84e53..bb7049748 100644 --- a/src/layers/traffic/handoff.test.mjs +++ b/src/layers/traffic/handoff.test.mjs @@ -1,10 +1,12 @@ // Traffic republished on the incoming globe after a switch. import test from 'node:test'; import assert from 'node:assert/strict'; -import * as C from 'cesium'; +import * as geo from '../../geo/index.js'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import { createState } from './state.js'; import { createFlow } from './flow.js'; import { createLifecycle } from './lifecycle.js'; +import { observeTrafficSurface } from './surface.js'; import { DOT_HEIGHT_OFFSET } from './policy.js'; import { nextTask } from '../../sources/vectorTiles.js'; @@ -29,43 +31,35 @@ function road(offset, height) { key: `road:${offset}`, surfaceReady: true, waypoints: coords.map(([lon, lat]) => - C.Cartesian3.fromDegrees(lon, lat, height + DOT_HEIGHT_OFFSET), + geo.cartesian3.fromDegrees(lon, lat, height + DOT_HEIGHT_OFFSET), ), segmentDist: [190], }; } -/** An incoming Navara-like surface whose finer tiles arrive later. */ -function navaraSurface() { - const listeners = new Set(); - const surface = { - ready: false, - height: COARSE, - samples: 0, - surfaceKey: 'terrain', - canvas: { clientWidth: 1000, clientHeight: 800 }, - project: () => ({ x: 500, y: 500 }), - sampleHeightSupported: true, - sampleHeight: () => { - surface.samples++; - return surface.height; - }, - postRender: { - addEventListener(listener) { - listeners.add(listener); - return () => listeners.delete(listener); - }, - }, - frame: () => [...listeners].forEach((listener) => listener()), - engine: { - camera: { - pose: () => ({ - cartographic: C.Cartographic.fromDegrees(LON, LAT, 1800), - }), - livePosition: C.Cartesian3.fromDegrees(LON, LAT, 1800), - }, - }, - }; +/** + * An incoming globe (terrain, no photoreal tiles) whose finer tiles arrive + * later: the camera 1 800 m over Austin, every road on screen, no rendered + * depth, and the surface observed as the layer's bound scene observes it. + */ +function incomingGlobe(t) { + const engine = createFakeEngine({ width: 1000, height: 800 }); + engine.fake.maps.imagery = { kind: 'osm' }; + engine.fake.setPose({ + longitude: geo.math.toRadians(LON), + latitude: geo.math.toRadians(LAT), + height: 1800, + }); + const surface = { engine, ready: false, height: COARSE, samples: 0 }; + engine.fake.script('tilesLoaded', () => surface.ready); + engine.fake.script('terrainHeight', () => { + surface.samples++; + return surface.height; + }); + engine.fake.script('worldToWindow', () => ({ x: 500, y: 500 })); + engine.fake.script('pickPosition', undefined); + t.after(observeTrafficSurface(engine)); + surface.frame = () => engine.fake.frame(); return surface; } @@ -80,7 +74,6 @@ function fixture(surface) { _roadSource: 'OpenStreetMap', _viewHeightEstimate: GROUND, _engine: surface.engine, - _trafficScene: () => surface, _activeFetchAbort: new AbortController(), }); const flow = createFlow({ @@ -105,7 +98,7 @@ function fixture(surface) { return { state, flow, weights, paints }; } -const height = (point) => C.Cartographic.fromCartesian(point).height; +const height = (point) => geo.cartographic.fromCartesian(point).height; // The refinement runs in tasks the flow yields between (nextTask). Wait for // its outcome task by task instead of for a fixed time, which a loaded test // runner can outlast. @@ -113,12 +106,13 @@ const settle = async (done) => { for (let task = 0; task < 500 && !done(); task++) await nextTask(); }; -test('a republish weights roads by distance to the local ground, not by heights another globe sampled', async () => { +test('a republish weights roads by distance to the local ground, not by heights another globe sampled', async (t) => { const offsets = [0.004, 0.012, 0.02]; const weightsFor = async (sampledHeight) => { - const surface = navaraSurface(); + const surface = incomingGlobe(t); surface.ready = true; surface.height = GROUND; + surface.frame(); const { flow, weights } = fixture(surface); const roads = offsets.map((offset) => road(offset, sampledHeight)); await flow.applyFlowThenRender( @@ -142,8 +136,9 @@ test('a republish weights roads by distance to the local ground, not by heights assert.ok(fromGround[0] > fromGround[2]); }); -test('the handoff pass on a loading globe refines once its surface is ready', async () => { - const surface = navaraSurface(); +test('the handoff pass on a loading globe refines once its surface is ready', async (t) => { + const surface = incomingGlobe(t); + surface.frame(); const { flow, paints } = fixture(surface); const roads = [road(0.004, GROUND), road(0.008, GROUND)]; await flow.applyFlowThenRender( diff --git a/src/layers/traffic/index.js b/src/layers/traffic/index.js index 1a7201281..a88e23262 100644 --- a/src/layers/traffic/index.js +++ b/src/layers/traffic/index.js @@ -11,9 +11,14 @@ import { createControls } from './controls.js'; import { createLifecycle } from './lifecycle.js'; import { createRetention } from './retention.js'; import { createState } from './state.js'; +import { createTrafficDisplay } from './render/display.js'; -/** Construct one layer with its own scene state and supplied application services. */ -export function createTrafficLayer({ services, source }) { +/** + * Construct one layer with its own scene state and supplied application + * services. `services.createRenderer(engine)` gives the engine's traffic + * display (render/display.js by default, the same on every engine). + */ +export function createTrafficLayer({ services: given, source }) { if ( ![ 'requestRoads', @@ -24,6 +29,10 @@ export function createTrafficLayer({ services, source }) { ].every((key) => typeof source?.[key] === 'function') ) throw new TypeError('A traffic source is required'); + const services = { + ...given, + createRenderer: given?.createRenderer ?? createTrafficDisplay, + }; const state = createState({ services }); const parts = {}; const context = { state, services, parts, source }; diff --git a/src/layers/traffic/ingestion.js b/src/layers/traffic/ingestion.js index bf5168e2d..174d96b27 100644 --- a/src/layers/traffic/ingestion.js +++ b/src/layers/traffic/ingestion.js @@ -270,18 +270,19 @@ export function createIngestion({ layerState._viewHeightEstimate = 0; const centerLat = (clamped.south + clamped.north) / 2; const centerLon = (clamped.west + clamped.east) / 2; - const scene = layerState._trafficScene?.() ?? layerState._viewer?.scene; + const engine = layerState._engine; const location = geo.cartographic.fromDegrees(centerLon, centerLat); let estimated = services.ground?.cachedGroundFloor?.(centerLat, centerLon); - if (!Number.isFinite(estimated)) { + if (!Number.isFinite(estimated) && engine) { try { - estimated = scene?.engine - ? scene.sampleHeight(location) - : scene?.globe?.show - ? scene.globe.getHeight?.(location) - : scene?.sampleHeightSupported - ? scene.sampleHeight(location, [layerState._pointCollection]) - : undefined; + estimated = !engine.maps.photorealActive() + ? engine.surface.terrainHeight(location) + : engine.surface.supported + ? engine.surface.sampleHeight( + location, + layerState._display?.excluded() ?? [], + ) + : undefined; } catch { /* the local mesh can still be loading */ } diff --git a/src/layers/traffic/lifecycle.js b/src/layers/traffic/lifecycle.js index 5458fe882..a69d3a3b9 100644 --- a/src/layers/traffic/lifecycle.js +++ b/src/layers/traffic/lifecycle.js @@ -1,11 +1,9 @@ import { observeTrafficSurface } from './surface.js'; -import * as Cesium from 'cesium'; import { TRAFFIC_TIMING_ENABLED } from './policy.js'; import { claimCameraSensitivity, releaseCameraSensitivity, } from '../../data/cameraSensitivity.js'; -import { whenCesiumSdk } from '../../app/loadCesium.js'; export function createLifecycle({ state: layerState, @@ -22,19 +20,19 @@ export function createLifecycle({ } function bindScene() { - const viewer = layerState._viewer, - engine = layerState._engine; - const camera = engine?.camera ?? viewer.camera; - const frames = engine?.frames ?? viewer.scene; - if (viewer) - layerState._surfaceFrameRemover = observeTrafficSurface(viewer.scene); - layerState._pointCollection.show = true; + const engine = layerState._engine; + if (!engine) return; + const camera = engine.camera; + layerState._surfaceFrameRemover = observeTrafficSurface(engine); + layerState._display?.show(true); layerState._lastAnimTime = 0; parts.animation.syncRenderDemand(); - layerState._preRenderRemover = frames.preRender.addEventListener(() => { - parts.animation.animate(); - layerState._native?.points.flush(); - }); + layerState._preRenderRemover = engine.frames.preRender.addEventListener( + () => { + parts.animation.animate(); + parts.animation.drawDots(); + }, + ); if (TRAFFIC_TIMING_ENABLED) { parts.timing.clearTrafficTimingEntries(); layerState._trafficTimingCurrentAnchor = null; @@ -49,11 +47,11 @@ export function createLifecycle({ layerState._arrivalRemover = camera.moveEnd.addEventListener(() => parts.viewport.onCameraChanged({ immediate: true }), ); - claimCameraSensitivity(engine ?? camera, 'traffic', 0.05); + claimCameraSensitivity(engine, 'traffic', 0.05); } function unbindScene() { - const camera = layerState._engine?.camera ?? layerState._viewer?.camera; + const camera = layerState._engine?.camera; layerState._surfaceFrameRemover?.(); layerState._preRenderRemover?.(); layerState._trafficTimingMoveEndRemover?.(); @@ -72,19 +70,20 @@ export function createLifecycle({ layerState._arrivalRemover = layerState._trafficTimingMoveEndRemover = null; - releaseCameraSensitivity(layerState._engine ?? camera, 'traffic'); + if (layerState._engine) + releaseCameraSensitivity(layerState._engine, 'traffic'); } const methods = { /** - * One-time initialisation. Creates the PointPrimitiveCollection and adds it - * to the scene (hidden). The collection is never removed and re-added — only - * toggled via `.show` to avoid destroy-on-remove errors. + * One-time initialisation. Attaches the engine's traffic display (hidden + * until the layer is enabled). * - * @param {Cesium.Viewer} viewer - The Cesium viewer instance. + * @param {null} _viewer - Unused: the layer draws through `options.engine`. + * @param {{ engine?: object, waitForCameraRestore?: boolean }} [options] */ - init(viewer, options) { - this.attachRenderer(viewer, options); + init(_viewer, options) { + this.attachRenderer(_viewer, options); layerState._dots = []; layerState._roads = []; layerState._count = 0; @@ -129,38 +128,28 @@ export function createLifecycle({ }, attachRenderer( - viewer, + _viewer, { engine = null, waitForCameraRestore = false } = {}, ) { layerState._restoringViewport = waitForCameraRestore; - layerState._viewer = viewer; layerState._engine = engine; - layerState._native = - !viewer && engine ? services.nativeRenderer(engine) : null; - layerState._heatSupported = null; - const points = - layerState._native?.points ?? - new Cesium.PointPrimitiveCollection({ - blendOption: Cesium.BlendOption.TRANSLUCENT, - }); - if (viewer) viewer.scene.primitives.add(points); - layerState._pointCollection = points; - for (const dot of layerState._dots) dot.point = points.add(dot.point); - points.show = layerState._enabled; + layerState._display = engine ? services.createRenderer(engine) : null; + layerState._display?.show(layerState._enabled); if (!waitForCameraRestore) readyForSurfaceWork(); if (layerState._enabled) { bindScene(); + parts.animation.drawDots(); parts.rendering.rebuildHeatLines(layerState._roads); if ( layerState._dots.length && layerState._roads.some((r) => !r.directFlow) ) - services.credits?.showOsmCredit?.(engine ?? viewer, 'traffic', { + services.credits?.showOsmCredit?.(engine, 'traffic', { openMapTiles: true, }); if (layerState._liveMode) services.credits?.registerDynamicCredit?.( - engine ?? viewer, + engine, services.credits.TOMTOM_CREDIT, ); parts.viewport.onCameraChanged({ immediate: true }); @@ -198,7 +187,7 @@ export function createLifecycle({ parts.viewport.onCameraChanged({ immediate: true }); }, detachRenderer() { - if (!layerState._pointCollection) return; + if (!layerState._engine) return; unbindScene(); clearTimeout(layerState._fetchTimeout); layerState._surfaceRefineRemove?.(); @@ -206,95 +195,62 @@ export function createLifecycle({ layerState._surfaceRevision = (layerState._surfaceRevision || 0) + 1; layerState._preparedRoads?.clear(); parts.rendering.removeHeatLines(); - services.credits?.hideOsmCredit?.( - layerState._engine ?? layerState._viewer, - 'traffic', - ); - for (const dot of layerState._dots) { - const p = dot.point; - dot.point = { - position: Cesium.Cartesian3.clone(p.position), - pixelSize: p.pixelSize, - color: p.color, - show: p.show, - outlineColor: p.outlineColor, - outlineWidth: p.outlineWidth, - scaleByDistance: p.scaleByDistance, - translucencyByDistance: p.translucencyByDistance, - disableDepthTestDistance: 0, - }; - } - layerState._viewer?.scene.primitives.remove(layerState._pointCollection); - layerState._native?.destroy(); + services.credits?.hideOsmCredit?.(layerState._engine, 'traffic'); + // The dots keep their look (`dot.point`) for the next engine. + layerState._display?.destroy(); if (layerState._renderHeld) services.render.releaseContinuousRender('traffic'); layerState._renderHeld = false; - layerState._pointCollection = - layerState._viewer = - layerState._engine = - layerState._native = - null; + layerState._engine = layerState._display = null; layerState._waitForScene ??= new Promise((resolve) => { layerState._sceneReady = resolve; }); }, /** - * Enable the traffic layer. Shows the point collection, subscribes to the + * Enable the traffic layer. Shows the vehicles, subscribes to the * preRender animation loop and camera-change events, and kicks off an * initial viewport check. - * - * @param {Cesium.Viewer} viewer - The Cesium viewer instance. */ - enable(viewer, { signal } = {}) { - // The simulation's math is Cesium's on either globe: a Navara page - // loads the Cesium SDK first. - return whenCesiumSdk(() => { - if (layerState._enabled) return; - if (viewer) layerState._viewer = viewer; - layerState._enabled = true; - if (layerState._roadMode !== 'tomtom') source.prefetch?.(); - bindScene(); - // Enabling is explicit intent, not a camera gesture that needs settling. - parts.viewport.onCameraChanged({ immediate: true }); + enable() { + if (layerState._enabled) return; + layerState._enabled = true; + if (layerState._roadMode !== 'tomtom') source.prefetch?.(); + bindScene(); + // Enabling is explicit intent, not a camera gesture that needs settling. + parts.viewport.onCameraChanged({ immediate: true }); - // Boot-order guard (otherwise the layer sits empty until the user - // moves): when the persisted layer state re-enables traffic during the - // intro flyTo, the initial check bails at high altitude — and a camera - // that then parks never re-fires camera.changed. Retry cheaply until the - // first load commits, then self-clear. Also acts as a safety kick if a - // failed first fetch left the viewport unloaded while parked. - clearInterval(layerState._enableKickTimer); - layerState._enableKickTimer = setInterval(() => { - if ( - !layerState._enabled || - layerState._lastUpdate || - layerState._roadRetryStopped || - layerState._retryAttempts >= 3 - ) { - clearInterval(layerState._enableKickTimer); - layerState._enableKickTimer = null; - return; - } - if (!layerState._fetching && !layerState._retryTimer) - parts.viewport.onCameraChanged(); - }, 1500); - }, signal); + // Boot-order guard (otherwise the layer sits empty until the user + // moves): when the persisted layer state re-enables traffic during the + // intro flyTo, the initial check bails at high altitude — and a camera + // that then parks never re-fires camera.changed. Retry cheaply until the + // first load commits, then self-clear. Also acts as a safety kick if a + // failed first fetch left the viewport unloaded while parked. + clearInterval(layerState._enableKickTimer); + layerState._enableKickTimer = setInterval(() => { + if ( + !layerState._enabled || + layerState._lastUpdate || + layerState._roadRetryStopped || + layerState._retryAttempts >= 3 + ) { + clearInterval(layerState._enableKickTimer); + layerState._enableKickTimer = null; + return; + } + if (!layerState._fetching && !layerState._retryTimer) + parts.viewport.onCameraChanged(); + }, 1500); }, /** * Disable the traffic layer. Cancels pending fetches, clears all dots, - * unsubscribes from events, and hides the point collection. - * - * @param {Cesium.Viewer} viewer - The Cesium viewer instance. + * unsubscribes from events, and hides the vehicles. */ - disable(viewer) { + disable() { layerState._enabled = false; layerState._surfaceInput = null; - services.credits?.hideOsmCredit?.( - layerState._engine ?? layerState._viewer, - 'traffic', - ); + services.credits?.hideOsmCredit?.(layerState._engine, 'traffic'); parts.animation.syncRenderDemand(); clearTimeout(layerState._fetchTimeout); clearInterval(layerState._enableKickTimer); @@ -317,26 +273,18 @@ export function createLifecycle({ layerState._flowOldestTileAt = null; unbindScene(); - if (layerState._pointCollection) layerState._pointCollection.show = false; + layerState._display?.show(false); }, /** - * Permanently tear down the layer. Disables it, removes the point collection - * from the scene, and clears the tile cache. - * - * @param {Cesium.Viewer} viewer - The Cesium viewer instance. + * Permanently tear down the layer. Disables it, destroys the display and + * clears the tile cache. */ - destroy(viewer) { - this.disable(viewer); - if (layerState._pointCollection) { - layerState._viewer?.scene.primitives.remove( - layerState._pointCollection, - ); - layerState._native?.destroy(); - layerState._native = null; - layerState._pointCollection = null; - } + destroy() { + this.disable(); parts.rendering.removeHeatLines(); + layerState._display?.destroy(); + layerState._display = null; layerState._tileCache.clear(); if (layerState._styleListenerBound && typeof window !== 'undefined') { window.removeEventListener( diff --git a/src/layers/traffic/model.js b/src/layers/traffic/model.js index ff1c89ddb..bd148a810 100644 --- a/src/layers/traffic/model.js +++ b/src/layers/traffic/model.js @@ -1,4 +1,5 @@ -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; +import { nearFarScale } from '../shared/color.js'; import { roadSurfaceChunks } from './surface.js'; import { t, translateKnown } from '../../i18n/index.js'; import { @@ -10,6 +11,7 @@ import { DOT_HEIGHT_OFFSET, DENSITY_MULT, JAM_DOT_FAR_SCALE, + SIM_DOT_COLOR, } from './policy.js'; export function createModel({ state: layerState, services, parts, source }) { @@ -33,14 +35,14 @@ export function createModel({ state: layerState, services, parts, source }) { (Number.isFinite(floor) && Math.abs(floor) <= 9000 ? floor : layerState._viewHeightEstimate || 0) + DOT_HEIGHT_OFFSET; - return Cesium.Cartesian3.fromDegrees(lng, lat, h); + return geo.cartesian3.fromDegrees(lng, lat, h); }); // Pre-compute segment distances in meters for speed-to-t conversion const segmentDist = []; for (let i = 0; i < waypoints.length - 1; i++) { segmentDist.push( - Cesium.Cartesian3.distance(waypoints[i], waypoints[i + 1]), + geo.cartesian3.distance(waypoints[i], waypoints[i + 1]), ); } @@ -367,7 +369,7 @@ export function createModel({ state: layerState, services, parts, source }) { dot.bucket = bucket; dot.point.color = bucket ? layerState._activeBucketColors[bucket] - : Cesium.Color.WHITE.withAlpha(0.85); + : SIM_DOT_COLOR; dot.nominalSize = parts.style.baseDotSize(dot.road?.type, bucket) + (bucket === 'jam' ? 1 : 0) + @@ -386,7 +388,7 @@ export function createModel({ state: layerState, services, parts, source }) { moving: Math.random() < 0.4, until: now + Math.random() * 2000, }; - dot.point.scaleByDistance = new Cesium.NearFarScalar( + dot.point.scaleByDistance = nearFarScale( 100, 1.5, layerState._fadeScaleFar, diff --git a/src/layers/traffic/motion.test.mjs b/src/layers/traffic/motion.test.mjs index 23bf84165..d2633130e 100644 --- a/src/layers/traffic/motion.test.mjs +++ b/src/layers/traffic/motion.test.mjs @@ -10,6 +10,33 @@ import { DOT_CHANGE_BUDGET, roadIdentity, } from './retention.js'; +import { createTrafficDisplay, TRAFFIC_SET_IDS } from './render/display.js'; +import { observeTrafficSurface } from './surface.js'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; + +/** + * A fake engine with the camera `altitude` metres over (0, 0). A zero-size + * canvas projects nothing, so screen-space limits stay out of a test unless + * it sizes the canvas. One rendered frame of the still camera certifies its + * surface, as a completed render does. + */ +function engineAt(altitude, { width = 0, height = 0 } = {}) { + const engine = createFakeEngine({ width, height, altitude }); + observeTrafficSurface(engine); + engine.fake.frame(); + return engine; +} +/** + * A surface pass's engine: a sized canvas looking straight down, whose + * rendered depth answers nothing (heights come from the scripted terrain). + */ +function flowEngineAt(altitude) { + const engine = engineAt(altitude, { width: 400, height: 300 }); + engine.fake.script('pickPosition', undefined); + return engine; +} +const fillCount = (engine) => + engine.fake.moverSet(TRAFFIC_SET_IDS.fill).store.count; function setup(t) { const frames = []; @@ -28,8 +55,9 @@ function setup(t) { const state = createState({ services: {} }); state._enabled = true; state._liveMode = true; - state._pointCollection = new C.PointPrimitiveCollection(); - state._viewer = { scene: {} }; + const engine = engineAt(450); + state._engine = engine; + state._display = createTrafficDisplay(engine); const parts = { rendering: { removeHeatLines() {}, @@ -78,15 +106,17 @@ function setup(t) { const fn = frames.shift(); fn?.(); parts.animation.animate(); + parts.animation.drawDots(); }; const settle = () => { for (let i = 0; i < 100 && frames.length; i++) frame(); }; t.after(() => { parts.animation.clearDots(); - state._pointCollection.destroy(); + state._display.destroy(); + engine.destroy(); }); - return { state, parts, roads, frame, settle, frames }; + return { state, parts, roads, frame, settle, frames, engine }; } test('incremental tiles, reordered snapshots and flow changes retain existing dot identities and progress', (t) => { @@ -126,6 +156,13 @@ test('incremental tiles, reordered snapshots and flow changes retain existing do assert.equal(target.point.show, true); assert.equal(target.id, before[0].id); assert.equal(h.state._motion.rebuilds, 0); + h.frame(); + assert.ok(fillCount(h.engine) > 0); + assert.equal( + fillCount(h.engine), + h.state._dots.filter((d) => d.point.show && d.point.pixelSize > 0).length, + 'the display draws every shown dot', + ); }); test('height refinement eases retained waypoints vertically without changing road or dot progress', (t) => { @@ -171,8 +208,9 @@ test('density and replacement changes are bounded; removed roads fade out and te assert.ok(h.state._dots.every((d) => !oldIds.has(d.id))); assert.equal(new Set(h.state._dots.map((d) => d.id)).size, 3000); h.parts.animation.clearDots(); + assert.equal(fillCount(h.engine), 0, 'a clear is drawn without a frame'); h.frame(); - assert.equal(h.state._pointCollection.length, 0); + assert.equal(fillCount(h.engine), 0); assert.equal(h.state._dots.length, 0); assert.equal(h.frames.length, 0); }); @@ -276,16 +314,11 @@ test('major previews merge and never retry missing mesh ahead of the detail pass for (const measured of [true, false]) { let samples = 0; const paints = []; - const scene = { - globe: { - show: true, - tilesLoaded: true, - getHeight: () => { - samples++; - return measured ? 100 : undefined; - }, - }, - }; + const engine = flowEngineAt(450); + engine.fake.script('terrainHeight', () => { + samples++; + return measured ? 100 : undefined; + }); const coords = [ [0, 0], [0.001, 0], @@ -301,7 +334,7 @@ test('major previews merge and never retry missing mesh ahead of the detail pass _dots: [], _detailRoadsReady: true, _motion: { added: 0 }, - _viewer: { scene }, + _engine: engine, _enabled: true, _loadGeneration: 1, _roadSource: 'OpenStreetMap', @@ -431,12 +464,11 @@ test('measured preview heights select elevated detail roads before surface admis ], segmentDist: [111], }); - const scene = { - globe: { show: true, tilesLoaded: true, getHeight: () => 750 }, - }; + const engine = flowEngineAt(1250); + engine.fake.script('terrainHeight', 750); const state = { _dots: [], - _viewer: { scene }, + _engine: engine, _enabled: true, _loadGeneration: 1, _roadSource: 'OpenStreetMap', @@ -510,16 +542,14 @@ test('provisional opposite-direction heights cannot overwrite a measured sibling segmentDist: [111], }); const selections = []; - const scene = { - globe: { - show: true, - tilesLoaded: true, - getHeight: (c) => 750 + C.Math.toDegrees(c.longitude) * 10000, - }, - }; + const engine = flowEngineAt(1250); + engine.fake.script( + 'terrainHeight', + (c) => 750 + C.Math.toDegrees(c.longitude) * 10000, + ); const state = { _dots: [], - _viewer: { scene }, + _engine: engine, _enabled: true, _loadGeneration: 1, _roadSource: 'OpenStreetMap', @@ -581,7 +611,8 @@ test('a closure that empties the population mid-correction settles heights and c corrected[0].flow = { level: 0, closure: true }; for (let i = 0; i < 20 && h.state._dots.length; i++) h.frame(); assert.equal(h.state._dots.length, 0); - assert.equal(h.state._heatJamPrim, null); + assert.equal(fillCount(h.engine), 0, 'the last removal is drawn'); + assert.equal(h.state._heatLineCount, 0); assert.equal(h.state._renderHeld, false); assert.equal(h.state._heightPending, false); assert.equal(h.state._populationPending, false); @@ -598,22 +629,14 @@ test('a closure that empties the population mid-correction settles heights and c test('large height corrections obey a screen-pixel bound and reuse projection scratch objects', (t) => { const h = setup(t); - h.state._viewer.scene = { - canvas: { clientWidth: 1280, clientHeight: 2000 }, - camera: {}, - frameState: {}, - }; + h.engine.fake.setSize(1280, 2000); const scratch = new Set(); - t.mock.method( - C.SceneTransforms, - 'worldToWindowCoordinates', - (_scene, point, result) => { - scratch.add(result); - result.x = 0; - result.y = C.Cartographic.fromCartesian(point).height * 10; - return result; - }, - ); + h.engine.fake.script('worldToWindow', (point, result) => { + scratch.add(result); + result.x = 0; + result.y = C.Cartographic.fromCartesian(point).height * 10; + return result; + }); h.parts.retention.reconcile(h.roads(), 450, true); h.settle(); const dot = h.state._dots[0], @@ -640,10 +663,24 @@ test('grounded vehicles respect mesh occlusion even after live congestion change h.parts.retention.reconcile(roads, 450, true); h.settle(); assert.ok(h.state._dots.length > 0); - assert.ok(h.state._dots.every((d) => d.point.disableDepthTestDistance === 0)); + // The vehicle sets are depth-tested against the scene (Cesium's + // disableDepthTestDistance 0); no dot carries an override of its own. + const depthTests = () => + Object.values({ + outline: TRAFFIC_SET_IDS.outline, + fill: TRAFFIC_SET_IDS.fill, + }).map((id) => h.engine.fake.moverSet(id).options.depthTest); + assert.deepEqual(depthTests(), ['normal', 'normal']); + assert.ok( + h.state._dots.every((d) => !('disableDepthTestDistance' in d.point)), + ); for (const road of roads) road.flow = { level: 0.01, closure: false }; h.parts.model.recolorDotsInPlace('late jam'); - assert.ok(h.state._dots.every((d) => d.point.disableDepthTestDistance === 0)); + h.frame(); + assert.deepEqual(depthTests(), ['normal', 'normal']); + assert.ok( + h.state._dots.every((d) => !('disableDepthTestDistance' in d.point)), + ); }); test('retained road density follows the settled view without requiring a toggle', (t) => { diff --git a/src/layers/traffic/navigation.test.mjs b/src/layers/traffic/navigation.test.mjs index c1fe58e93..5bce49f2b 100644 --- a/src/layers/traffic/navigation.test.mjs +++ b/src/layers/traffic/navigation.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import { createTrafficLayer } from './index.js'; function deferred() { @@ -13,42 +14,35 @@ function deferred() { function setup(t, requestRoads, getStatus = async () => ({ hasKey: false })) { t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); - const camera = { - positionCartographic: Cesium.Cartographic.fromDegrees( - -97.744, - 30.267, - 3200, - ), - get positionWC() { - return Cesium.Cartesian3.fromRadians( - this.positionCartographic.longitude, - this.positionCartographic.latitude, - this.positionCartographic.height, - ); - }, - changed: new Cesium.Event(), - moveEnd: new Cesium.Event(), - percentageChanged: 0.5, - computeViewRectangle() { - const { longitude, latitude } = this.positionCartographic; - return new Cesium.Rectangle( - longitude - 0.0002, - latitude - 0.0002, - longitude + 0.0002, - latitude + 0.0002, - ); - }, - pickEllipsoid: () => null, - }; - const viewer = { - camera, - scene: { - canvas: { width: 100, height: 100 }, - globe: { show: true, tilesLoaded: true, getHeight: () => 0 }, - preRender: new Cesium.Event(), - primitives: { add: (value) => value, remove: () => true }, - }, - }; + const engine = createFakeEngine({ + width: 100, + height: 100, + canvas: { width: 100, height: 100 }, + }); + const camera = engine.camera; + // Without a pick ray the layer bounds its fetch by the view rectangle + // (no reticle footprint); a test that wants the footprint puts it back. + const pickRay = camera.pickRay; + camera.pickRay = undefined; + const place = (lon, lat, height) => + engine.fake.setPose({ + longitude: geo.math.toRadians(lon), + latitude: geo.math.toRadians(lat), + height, + }); + place(-97.744, 30.267, 3200); + // A view rectangle of ±0.0002 rad around the camera. + engine.fake.script('viewRectangle', () => { + const { longitude, latitude } = camera.pose().cartographic; + return { + west: longitude - 0.0002, + south: latitude - 0.0002, + east: longitude + 0.0002, + north: latitude + 0.0002, + }; + }); + engine.fake.script('pickEllipsoid', undefined); + engine.fake.script('terrainHeight', 0); const layer = createTrafficLayer({ services: { credits: {}, @@ -62,16 +56,12 @@ function setup(t, requestRoads, getStatus = async () => ({ hasKey: false })) { resetFlowTileCache() {}, }, }); - layer.init(viewer); - t.after(() => layer.destroy(viewer)); + layer.init(null, { engine }); + t.after(() => layer.destroy()); const move = (lon, lat, height = 3200) => { - camera.positionCartographic = Cesium.Cartographic.fromDegrees( - lon, - lat, - height, - ); - camera.changed.raiseEvent(); - camera.moveEnd.raiseEvent(); + place(lon, lat, height); + engine.fake.emit('camera.changed'); + engine.fake.emit('camera.moveEnd'); }; const tick = async (ms) => { t.mock.timers.tick(ms); @@ -81,7 +71,7 @@ function setup(t, requestRoads, getStatus = async () => ({ hasKey: false })) { t.mock.timers.tick(0); for (let i = 0; i < 30; i++) await Promise.resolve(); }; - return { layer, viewer, move, tick }; + return { layer, engine, camera, pickRay, place, move, tick }; } function roads(bounds) { return { @@ -103,12 +93,12 @@ function roads(bounds) { test('traffic recovers a failed destination request after another city has loaded', async (t) => { let londonCalls = 0; - const { layer, viewer, move, tick } = setup(t, async (bounds) => { + const { layer, move, tick } = setup(t, async (bounds) => { if (bounds.south > 50 && ++londonCalls === 1) throw new Error('temporary Overpass outage'); return roads(bounds); }); - layer.enable(viewer); + layer.enable(); await tick(400); await tick(2000); assert.ok(layer.getStats().count > 0); @@ -127,12 +117,12 @@ test('traffic recovers a failed destination request after another city has loade test('a superseded road response cannot release the current request controller', async (t) => { const pending = []; - const { layer, viewer, move, tick } = setup(t, (bounds, { signal }) => { + const { layer, move, tick } = setup(t, (bounds, { signal }) => { const result = deferred(); pending.push({ ...result, bounds, signal }); return result.promise; }); - layer.enable(viewer); + layer.enable(); await tick(400); move(-0.1276, 51.5072); await tick(400); @@ -140,7 +130,7 @@ test('a superseded road response cannot release the current request controller', assert.equal(pending[0].signal.aborted, true); pending[0].resolve(roads(pending[0].bounds)); await tick(0); - layer.disable(viewer); + layer.disable(); assert.equal( pending[1].signal.aborted, true, @@ -154,12 +144,12 @@ test('a superseded road response cannot release the current request controller', test('leaving traffic altitude cancels queued and in-flight work', async (t) => { const pending = []; - const { layer, viewer, move, tick } = setup(t, (bounds, { signal }) => { + const { layer, move, tick } = setup(t, (bounds, { signal }) => { const result = deferred(); pending.push({ ...result, bounds, signal }); return result.promise; }); - layer.enable(viewer); + layer.enable(); move(-40, 40, 1000000); await tick(400); assert.equal( @@ -180,32 +170,28 @@ test('leaving traffic altitude cancels queued and in-flight work', async (t) => test('arrival below the camera change threshold loads the final city and unsubscribes on disable', async (t) => { const seen = []; - const { layer, viewer, tick } = setup(t, async (bounds) => { + const { layer, engine, place, tick } = setup(t, async (bounds) => { seen.push(bounds); return roads(bounds); }); - layer.enable(viewer); + layer.enable(); await tick(400); - viewer.camera.positionCartographic = Cesium.Cartographic.fromDegrees( - -0.1276, - 51.5072, - 3200, - ); - viewer.camera.moveEnd.raiseEvent(); + place(-0.1276, 51.5072, 3200); + engine.fake.emit('camera.moveEnd'); await tick(400); assert.ok(seen.some((bounds) => bounds.south > 50)); - layer.disable(viewer); - assert.equal(viewer.camera.moveEnd.numberOfListeners, 0); - assert.equal(viewer.camera.changed.numberOfListeners, 0); + layer.disable(); + assert.equal(engine.camera.moveEnd.numberOfListeners, 0); + assert.equal(engine.camera.changed.numberOfListeners, 0); }); test('parked failures back off and disabling cancels the scheduled retry', async (t) => { let calls = 0; - const { layer, viewer, tick } = setup(t, async () => { + const { layer, tick } = setup(t, async () => { calls++; throw new Error('temporary Overpass outage'); }); - layer.enable(viewer); + layer.enable(); await tick(400); assert.equal(calls, 1); assert.equal(layer.getStats().loading, false); @@ -216,7 +202,7 @@ test('parked failures back off and disabling cancels the scheduled retry', async await tick(1500); await tick(400); assert.equal(calls, 2, 'second failure waits longer than the first'); - layer.disable(viewer); + layer.disable(); await tick(60000); assert.equal(calls, 2); assert.equal(layer.getStats().error, null); @@ -233,7 +219,7 @@ test('not-configured roads stop both parked retries and the enable-time kick', a json: async () => ({ code: 'OVERPASS_NOT_CONFIGURED', retryable: false }), }; }); - h.layer.enable(h.viewer); + h.layer.enable(); await h.tick(400); await h.tick(60_000); assert.equal(requests, 1); @@ -243,8 +229,8 @@ test('not-configured roads stop both parked retries and the enable-time kick', a test('a declined road request reaches the row as the reason, not as a shrug', async (t) => { let status = 406; - const { layer, viewer, tick } = setup(t, async () => ({ ok: false, status })); - layer.enable(viewer); + const { layer, tick } = setup(t, async () => ({ ok: false, status })); + layer.enable(); await tick(400); assert.equal( layer.getStats().error, @@ -261,17 +247,17 @@ test('a declined road request reaches the row as the reason, not as a shrug', as }); test('an unclassified road failure keeps the general line', async (t) => { - const { layer, viewer, tick } = setup(t, async () => { + const { layer, tick } = setup(t, async () => { throw new Error('socket hang up'); }); - layer.enable(viewer); + layer.enable(); await tick(400); assert.equal(layer.getStats().error, 'OpenFreeMap tiles unavailable'); }); test('a stalled TomTom status never blocks road acquisition or first simulated dots', async (t) => { let calls = 0; - const { layer, viewer, tick } = setup( + const { layer, tick } = setup( t, async (box) => { calls++; @@ -281,7 +267,7 @@ test('a stalled TomTom status never blocks road acquisition or first simulated d ); // The source contract is exercised independently of status by the production // load path; the real source status cancellation case is in source.test. - layer.enable(viewer); + layer.enable(); await tick(400); await tick(2000); assert.ok(calls > 0); @@ -290,22 +276,22 @@ test('a stalled TomTom status never blocks road acquisition or first simulated d test('explicit enable starts roads without the camera debounce', async (t) => { let calls = 0; - const { layer, viewer, tick } = setup(t, async (box) => { + const { layer, tick } = setup(t, async (box) => { calls++; return roads(box); }); - layer.enable(viewer); + layer.enable(); await tick(0); assert.ok(calls > 0, 'the first load starts before 320 ms'); }); test('moveEnd starts the destination request without another gesture debounce', async (t) => { let calls = 0; - const { layer, viewer, move, tick } = setup(t, async (bounds) => { + const { layer, move, tick } = setup(t, async (bounds) => { calls++; return roads(bounds); }); - layer.enable(viewer); + layer.enable(); await tick(400); const before = calls; move(-0.1276, 51.5072); @@ -314,49 +300,44 @@ test('moveEnd starts the destination request without another gesture debounce', }); test('traffic surface observers belong to enable and leave no listener after disable or destroy', async (t) => { - const { layer, viewer, tick } = setup(t, async (bounds) => roads(bounds)); - viewer.camera.viewMatrix = Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY); - viewer.camera.frustum = { - projectionMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY), - }; - viewer.scene.camera = viewer.camera; - viewer.scene.postRender = new Cesium.Event(); - assert.equal(viewer.scene.postRender.numberOfListeners, 0); + const { layer, engine, tick } = setup(t, async (bounds) => roads(bounds)); + const { postRender, preRender } = engine.frames; + assert.equal(postRender.numberOfListeners, 0); for (let i = 0; i < 3; i++) { - layer.enable(viewer); - layer.enable(viewer); - assert.equal(viewer.scene.postRender.numberOfListeners, 1); - viewer.scene.postRender.raiseEvent(); + layer.enable(); + layer.enable(); + assert.equal(postRender.numberOfListeners, 1); + engine.fake.emit('frames.postRender'); await tick(1); - layer.disable(viewer); - assert.equal(viewer.scene.postRender.numberOfListeners, 0); + layer.disable(); + assert.equal(postRender.numberOfListeners, 0); await tick(5000); assert.equal( - viewer.scene.postRender.numberOfListeners, + postRender.numberOfListeners, 0, 'late work cannot resubscribe', ); } - layer.enable(viewer); - assert.equal(viewer.scene.postRender.numberOfListeners, 1); - layer.destroy(viewer); - assert.equal(viewer.scene.postRender.numberOfListeners, 0); - assert.equal(viewer.scene.preRender.numberOfListeners, 0); + layer.enable(); + assert.equal(postRender.numberOfListeners, 1); + layer.destroy(); + assert.equal(postRender.numberOfListeners, 0); + assert.equal(preRender.numberOfListeners, 0); }); test('coverage changes at the same center reload and disabling invalidates coverage without dropping source caches', async (t) => { const calls = []; - const { layer, viewer, tick } = setup(t, async (bounds) => { + const { layer, tick } = setup(t, async (bounds) => { calls.push(bounds); return roads(bounds); }); - layer.enable(viewer); + layer.enable(); await tick(1000); await tick(2000); assert.ok(layer.getStats().count > 0); const before = calls.length; - layer.disable(viewer); - layer.enable(viewer); + layer.disable(); + layer.enable(); await tick(1000); await tick(2000); assert.ok(layer.getStats().count > 0, 'enable must repaint the same view'); @@ -365,7 +346,7 @@ test('coverage changes at the same center reload and disabling invalidates cover test('a settled footprint loads new tiles even when the camera centre stays put', async (t) => { const seen = []; - const { layer, viewer, tick } = setup( + const { layer, engine, camera, pickRay, tick } = setup( t, async (bounds, { majorOnly, coverage }) => { if (!majorOnly) seen.push({ ...bounds, coverage }); @@ -374,26 +355,26 @@ test('a settled footprint loads new tiles even when the camera centre stays put' ); layer.setParams({ roadMode: 'osm' }); let reach = 0.005; - const camera = viewer.camera; - camera.getPickRay = () => null; - camera.heading = 0; - camera.pitch = -Math.PI / 2; - camera.pickEllipsoid = (pixel, ellipsoid, result) => - Cesium.Cartesian3.fromDegrees( + camera.pickRay = pickRay; + // Angles only: the camera stays where it is. + const turn = (angles) => + engine.fake.setPose({ position: camera.pose().position, ...angles }); + turn({ heading: 0, pitch: -Math.PI / 2 }); + engine.fake.script('pickEllipsoid', (pixel) => + geo.cartesian3.fromDegrees( -97.744 + (pixel.x - 50) / 10000, 30.267 + (pixel.y / 100) * reach, 0, - ellipsoid, - result, - ); - layer.enable(viewer); + ), + ); + layer.enable(); await tick(400); assert.equal(seen.length, 1); const firstKey = seen[0].coverage.key; - camera.changed.raiseEvent(); + engine.fake.emit('camera.changed'); // The final flight step occurs below the changed-event threshold. reach = 0.08; - camera.pitch = -Math.PI / 12; + turn({ pitch: -Math.PI / 12 }); await tick(400); assert.equal(seen.length, 2); assert.notEqual(seen[1].coverage.key, firstKey); @@ -401,7 +382,7 @@ test('a settled footprint loads new tiles even when the camera centre stays put' seen[1].north > 30.33, 'sample the final view when the debounce fires', ); - camera.moveEnd.raiseEvent(); + engine.fake.emit('camera.moveEnd'); await tick(400); assert.equal( seen.length, @@ -412,13 +393,13 @@ test('a settled footprint loads new tiles even when the camera centre stays put' test('partial coverage retries while coarse roads render, then stops after three retries', async (t) => { let calls = 0; - const { layer, viewer, tick } = setup(t, async (bounds) => { + const { layer, tick } = setup(t, async (bounds) => { calls++; const response = roads(bounds); const data = await response.json(); return { ok: true, json: async () => ({ ...data, partial: true }) }; }); - layer.enable(viewer); + layer.enable(); await tick(400); await tick(0); assert.ok(layer.getStats().count > 0); diff --git a/src/layers/traffic/policy.js b/src/layers/traffic/policy.js index fd99d3b85..71c4a0af3 100644 --- a/src/layers/traffic/policy.js +++ b/src/layers/traffic/policy.js @@ -1,4 +1,4 @@ -import { rgbaFromCss } from '../shared/color.js'; +import { WHITE, rgbaFromCss } from '../shared/color.js'; /** * @file Street Traffic selects TomTom, OpenMapTiles or Hybrid motor roads, @@ -108,7 +108,7 @@ export const SIZE_BY_TYPE = { * Live-flow bucket colors (thresholds live in `trafficFlowStyle.js`): * green free flow / amber slow / red jam, all at 0.9 alpha. * Roads without flow data (`road.flow == null`) keep the sim's white. - * @const {Object} + * @const {Object} */ export const FLOW_BUCKET_COLORS = { @@ -117,6 +117,9 @@ export const FLOW_BUCKET_COLORS = { jam: rgbaFromCss('#e05252').withAlpha(0.9), }; +/** Simulated vehicles (and roads without flow data): white at 0.85. */ +export const SIM_DOT_COLOR = WHITE.withAlpha(0.85); + // ─── Jam-viz prototype (live mode only — see 2026-07-21 design doc) ──────── /** @const {number} Max congestion heat-line polylines per render (jam first). */ @@ -138,11 +141,11 @@ export const HEAT_JAM_BASE_ALPHA = 0.55; export const HEAT_JAM_PULSE_ALPHA = 0.2; -/** @const {Cesium.Color} Jam corridor color (bucket red, alpha pulsed live). */ +/** @const {import('../shared/color.js').Rgba} Jam corridor color (bucket red, alpha pulsed live). */ export const HEAT_JAM_COLOR = rgbaFromCss('#e05252'); -/** @const {Cesium.Color} Slow corridor color (bucket amber, faint + static). */ +/** @const {import('../shared/color.js').Rgba} Slow corridor color (bucket amber, faint + static). */ export const HEAT_SLOW_COLOR = rgbaFromCss('#f0b23e').withAlpha(0.2); diff --git a/src/layers/traffic/render/display.js b/src/layers/traffic/render/display.js new file mode 100644 index 000000000..eb2765d50 --- /dev/null +++ b/src/layers/traffic/render/display.js @@ -0,0 +1,181 @@ +// Street Traffic on the globe contract, the same on every engine: +// - vehicles: two engine.movers point sets written from the layer's dots once +// a frame, an outline disc under each fill disc (the outline of Cesium's +// PointPrimitive, pixelSize plus outlineWidth on each side); each dot is +// sized and faded by its camera distance as a PointPrimitive's +// scaleByDistance and translucencyByDistance are (over the squared +// distance, eased by ^0.2), and depth-tested against the scene; +// - congestion heat lines: two engine.ground corridor sets (jam, then slow), +// draped on 3D tiles, as the GroundPolylinePrimitives Cesium drew with +// ClassificationType.CESIUM_3D_TILE. +// The layer keeps each dot's look as plain data on `dot.point` (position, +// pixelSize, color, outlineColor, outlineWidth, show, scaleByDistance, +// translucencyByDistance); the display only reads it. + +import * as geo from '../../../geo/index.js'; +import { MAX_DOTS } from '../policy.js'; + +export const TRAFFIC_SET_IDS = Object.freeze({ + outline: 'traffic-outline', + fill: 'traffic-fill', + jam: 'traffic-heat-jam', + slow: 'traffic-heat-slow', +}); +// Cesium culls a point whose translucency falls under this. +const MIN_TRANSLUCENCY = 0.004; +// Draw order of the heat-line sets: the jam batch was added first. +const HEAT_ORDER = Object.freeze({ jam: 0, slow: 1 }); + +/** + * A NearFarScalar `{ near, nearValue, far, farValue }` at `distance` metres: + * Cesium's interpolation over the squared distance, eased by ^0.2; 1 without + * one. + */ +export function nearFarValue(scalar, distance) { + if (!scalar) return 1; + const nearSq = scalar.near * scalar.near; + const span = scalar.far * scalar.far - nearSq; + const t = + span > 0 + ? Math.min(1, Math.max(0, (distance * distance - nearSq) / span)) + : distance > scalar.near + ? 1 + : 0; + return ( + scalar.nearValue + (scalar.farValue - scalar.nearValue) * Math.pow(t, 0.2) + ); +} + +/** + * @param {import('../../../globe/contract.js').GlobeEngine} engine + * @returns {{ drawDots: (dots: object[]) => void, show: (visible: boolean) => void, + * setHeat: (bucket: 'jam'|'slow', roads: object[], style: { color: number[], widthPx: number }) => void, + * clearHeat: () => void, excluded: () => object[], destroy: () => void }} + */ +export function createTrafficDisplay(engine) { + if (!engine?.movers || !engine?.ground) + throw new TypeError('The traffic display needs a globe engine'); + const roles = ['outline', 'fill']; + const sets = roles.map((role, order) => + engine.movers.createSet({ + id: TRAFFIC_SET_IDS[role], + kind: 'point', + order, + capacity: MAX_DOTS, + depthTest: 'normal', + pickable: false, + point: { sizePx: 1, outlinePx: 0 }, + }), + ); + const heat = new Map(); + const color = [0, 0, 0, 0]; + const fields = { position: null, scale: 1, color, show: true }; + const counts = [0, 0]; + let visible = true; + let drawn = false; + let destroyed = false; + + function commit() { + for (let i = 0; i < sets.length; i++) { + sets[i].setCount(counts[i]); + sets[i].commit(); + } + } + + function write(index, point, size, alpha, tint) { + color[0] = tint.red; + color[1] = tint.green; + color[2] = tint.blue; + color[3] = tint.alpha * alpha; + fields.position = point.position; + fields.scale = size; + sets[index].set(counts[index]++, fields); + } + + function drawDots(dots) { + if (destroyed) return; + counts[0] = counts[1] = 0; + const camera = engine.camera.livePosition; + if (visible && camera) + for (const dot of dots) { + const point = dot.point; + // A dot at size 0 (fading in, recycled at a road end) draws nothing, + // its outline included. + if (!point?.show || !(point.pixelSize > 0) || !point.position) continue; + if (!engine.projector.horizonVisible(point.position)) continue; + const distance = geo.cartesian3.distance(point.position, camera); + const alpha = nearFarValue(point.translucencyByDistance, distance); + if (alpha < MIN_TRANSLUCENCY) continue; + const scale = nearFarValue(point.scaleByDistance, distance); + const outline = point.outlineWidth > 0 && point.outlineColor; + if (outline && counts[0] < MAX_DOTS) + write( + 0, + point, + Math.max(0, (point.pixelSize + 2 * point.outlineWidth) * scale), + alpha, + point.outlineColor, + ); + if (counts[1] < MAX_DOTS) + write( + 1, + point, + Math.max(0, point.pixelSize * scale), + alpha, + point.color, + ); + } + fields.position = null; + // An empty population is written once, not every frame. + if (!counts[0] && !counts[1] && !drawn) return; + drawn = counts[0] > 0 || counts[1] > 0; + commit(); + } + + function dropHeat(bucket) { + heat.get(bucket)?.destroy(); + heat.delete(bucket); + } + + return { + drawDots, + /** Shows or hides the vehicles (the heat lines go with the layer's roads). */ + show(next) { + visible = next !== false; + if (!visible && drawn && !destroyed) { + counts[0] = counts[1] = 0; + drawn = false; + commit(); + } + }, + /** One bucket's heat lines: a corridor per road, along its coordinates. */ + setHeat(bucket, roads, { color: rgba, widthPx }) { + if (destroyed) return; + dropHeat(bucket); + if (!roads.length) return; + const set = engine.ground.createSet({ + id: TRAFFIC_SET_IDS[bucket], + kind: 'corridor', + order: HEAT_ORDER[bucket], + pickable: false, + drape: 'tiles', + }); + heat.set(bucket, set); + set.replace( + roads.map((road) => ({ positions: road.coords, color: rgba, widthPx })), + ); + set.commit(); + }, + clearHeat() { + for (const bucket of [...heat.keys()]) dropHeat(bucket); + }, + /** The vehicle sets, which surface height samples look through. */ + excluded: () => (destroyed ? [] : sets.slice()), + destroy() { + if (destroyed) return; + destroyed = true; + for (const bucket of [...heat.keys()]) dropHeat(bucket); + for (const set of sets) set.destroy(); + }, + }; +} diff --git a/src/layers/traffic/render/display.test.mjs b/src/layers/traffic/render/display.test.mjs new file mode 100644 index 000000000..20eadefaa --- /dev/null +++ b/src/layers/traffic/render/display.test.mjs @@ -0,0 +1,411 @@ +// The traffic display on the engine contract, on the fake engine and on the +// Cesium engine: the vehicle sets (outline under fill, depth-tested, not +// picked), each dot's size and fade by camera distance as Cesium's +// NearFarScalar, what is skipped, show and hide; the heat-line corridors +// (colours, widths, order, drape on 3D tiles) and the layer's jam and slow +// styles; the sets heights are sampled through; teardown. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import * as geo from '../../../geo/index.js'; +import { GROUND_HARNESSES } from '../../../testSupport/groundDisplayEngines.mjs'; +import { Rgba, nearFarScale } from '../../shared/color.js'; +import { createState } from '../state.js'; +import { createStyle } from '../style.js'; +import { createRendering } from '../rendering.js'; +import { + HEAT_JAM_BASE_ALPHA, + HEAT_JAM_COLOR, + HEAT_LINE_JAM_WIDTH, + HEAT_LINE_SLOW_WIDTH, + HEAT_SLOW_COLOR, + MAX_DOTS, + SIM_DOT_COLOR, +} from '../policy.js'; +import { + TRAFFIC_SET_IDS as IDS, + createTrafficDisplay, + nearFarValue, +} from './display.js'; + +// GroundPolylinePrimitive checks its render state's line width against the +// context's limits, which a stub scene has none of. +Cesium.ContextLimits._minimumAliasedLineWidth = 1; +Cesium.ContextLimits._maximumAliasedLineWidth = 10; + +const CAMERA_M = 1000; +const near = (actual, expected, label, epsilon = 1e-6) => + assert.ok( + Math.abs(actual - expected) < epsilon, + `${label}: ${actual} vs ${expected}`, + ); +const nearAll = (actual, expected, label, epsilon = 1e-6) => + expected.forEach((value, index) => + near(actual[index], value, `${label}[${index}]`, epsilon), + ); + +// A mover set's drawn instances on either engine: size in px, rgba, shown. +function drawn(h, id) { + const listed = h.engine.movers.list().find((set) => set.id === id); + if (!listed) return undefined; + const rows = []; + if (h.cesium) { + const items = (collection) => + Array.from({ length: collection?.length ?? 0 }, (_, i) => + collection.get(i), + ); + let collection; + for (const root of items(h.viewer.scene.primitives)) + for (const child of items(root)) + if (child.gevMoverSet === id) collection = child; + for (let i = 0; i < listed.count; i++) { + const point = collection.get(i); + rows.push({ + sizePx: point.pixelSize, + color: [ + point.color.red, + point.color.green, + point.color.blue, + point.color.alpha, + ], + shown: point.show, + outlineWidth: point.outlineWidth, + depthTestDistance: point.disableDepthTestDistance, + scaleByDistance: point.scaleByDistance, + }); + } + } else { + const { store, options } = h.engine.fake.moverSet(id); + for (let i = 0; i < store.count; i++) + rows.push({ + sizePx: options.point.sizePx * store.scales[i], + color: [...store.colors.slice(i * 4, i * 4 + 4)], + shown: store.shows[i] === 1, + }); + } + return rows; +} + +function fixture(t, makeHarness) { + const h = makeHarness(); + t.after(() => h.destroy()); + // The camera straight above the dots, CAMERA_M up. + h.engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(0, 0, CAMERA_M), + }); + const display = createTrafficDisplay(h.engine); + t.after(() => display.destroy()); + return { h, display }; +} + +// A dot's look as the layer writes it (animation.js spawnDotsForRoad). +function dot(fields = {}) { + return { + point: { + position: geo.cartesian3.fromDegrees(0, 0, 0), + pixelSize: 5, + color: SIM_DOT_COLOR, + show: true, + scaleByDistance: nearFarScale(100, 1.5, 8000, 0.3), + translucencyByDistance: nearFarScale(100, 1, 10000, 0), + outlineColor: null, + outlineWidth: 0, + ...fields, + }, + }; +} + +test('distance scalars interpolate over the squared distance, eased by ^0.2, and clamp', () => { + const scalar = nearFarScale(100, 1.5, 8000, 0.3); + assert.equal(nearFarValue(null, 5000), 1); + assert.equal(nearFarValue(scalar, 50), 1.5); + assert.equal(nearFarValue(scalar, 100), 1.5); + near(nearFarValue(scalar, 8000), 0.3, 'far'); + near(nearFarValue(scalar, 20000), 0.3, 'beyond far'); + const t = Math.pow((1000 ** 2 - 100 ** 2) / (8000 ** 2 - 100 ** 2), 0.2); + near(nearFarValue(scalar, 1000), 1.5 + (0.3 - 1.5) * t, 'between'); +}); + +for (const makeHarness of GROUND_HARNESSES) { + const name = makeHarness().name; + + test(`vehicles are two depth-tested point sets, outline under fill, never picked (${name})`, (t) => { + const { h } = fixture(t, makeHarness); + const sets = h.engine.movers.list(); + assert.deepEqual( + sets.map(({ id }) => id), + [IDS.outline, IDS.fill], + ); + for (const [index, id] of [IDS.outline, IDS.fill].entries()) { + const { options } = h.cesium + ? { options: null } + : h.engine.fake.moverSet(id); + assert.equal( + sets.find((set) => set.id === id).capacity, + MAX_DOTS, + `${id} capacity`, + ); + if (options) { + assert.equal(options.kind, 'point'); + assert.equal(options.order, index); + assert.equal(options.depthTest, 'normal'); + assert.equal(options.pickable, false); + assert.equal(options.point.sizePx, 1); + assert.equal(options.point.outlinePx, 0); + } + } + }); + + test(`each dot is sized and faded by its camera distance, its outline a wider disc under it (${name})`, (t) => { + const { h, display } = fixture(t, makeHarness); + const outline = new Rgba(0, 0, 0, 0.9); + const jam = new Rgba(1, 0.2, 0.2, 0.9); + display.drawDots([ + dot(), + dot({ + pixelSize: 7, + color: jam, + outlineColor: outline, + outlineWidth: 2, + scaleByDistance: nearFarScale(100, 1.5, 8000, 0.55), + }), + ]); + const distance = geo.cartesian3.distance( + h.engine.camera.livePosition, + geo.cartesian3.fromDegrees(0, 0, 0), + ); + near(distance, CAMERA_M, 'camera distance', 1e-3); + const fade = nearFarValue(nearFarScale(100, 1, 10000, 0), distance); + const simScale = nearFarValue(nearFarScale(100, 1.5, 8000, 0.3), distance); + const jamScale = nearFarValue(nearFarScale(100, 1.5, 8000, 0.55), distance); + const fills = drawn(h, IDS.fill); + assert.equal(fills.length, 2); + near(fills[0].sizePx, 5 * simScale, 'sim size', 1e-4); + nearAll( + fills[0].color, + [1, 1, 1, 0.85 * fade], + 'sim colour (white 0.85, faded)', + 1e-6, + ); + near(fills[1].sizePx, 7 * jamScale, 'jam size', 1e-4); + nearAll(fills[1].color, [1, 0.2, 0.2, 0.9 * fade], 'jam colour', 1e-6); + // Cesium: pixelSize plus outlineWidth on each side, scaled as a whole. + const outlines = drawn(h, IDS.outline); + assert.equal(outlines.length, 1, 'only the outlined dot has an outline'); + near(outlines[0].sizePx, (7 + 2 * 2) * jamScale, 'outline size', 1e-4); + nearAll(outlines[0].color, [0, 0, 0, 0.9 * fade], 'outline colour', 1e-6); + if (h.cesium) { + for (const row of [...fills, ...outlines]) { + assert.equal(row.shown, true); + assert.equal(row.outlineWidth, 0); + assert.equal(row.depthTestDistance, 0, 'depth-tested'); + } + } + }); + + test(`hidden, zero-size, faded-out and over-the-horizon dots draw nothing (${name})`, (t) => { + const { h, display } = fixture(t, makeHarness); + const outline = { outlineColor: new Rgba(0, 0, 0, 0.9), outlineWidth: 2 }; + display.drawDots([ + dot({ show: false, ...outline }), + dot({ pixelSize: 0, ...outline }), + dot({ translucencyByDistance: nearFarScale(100, 1, 200, 0) }), + dot({ position: geo.cartesian3.fromDegrees(180, 0, 0), ...outline }), + dot(), + ]); + assert.equal(drawn(h, IDS.fill).length, 1); + assert.equal(drawn(h, IDS.outline).length, 0); + // The population empties: one write of nothing. + display.drawDots([]); + assert.equal(drawn(h, IDS.fill).length, 0); + }); + + test(`hiding the vehicles clears them until shown and drawn again (${name})`, (t) => { + const { h, display } = fixture(t, makeHarness); + const dots = [dot(), dot()]; + display.drawDots(dots); + assert.equal(drawn(h, IDS.fill).length, 2); + display.show(false); + assert.equal(drawn(h, IDS.fill).length, 0); + display.drawDots(dots); + assert.equal(drawn(h, IDS.fill).length, 0, 'hidden: nothing draws'); + display.show(true); + display.drawDots(dots); + assert.equal(drawn(h, IDS.fill).length, 2); + }); + + test(`heat lines are corridors draped on 3D tiles, jam under slow, one per road (${name})`, (t) => { + const { h, display } = fixture(t, makeHarness); + const roads = [ + { + coords: [ + [0, 0], + [0.001, 0], + ], + }, + { + coords: [ + [0, 0.001], + [0.001, 0.001], + [0.002, 0.002], + ], + }, + ]; + display.setHeat('jam', roads, { color: [1, 0, 0, 0.55], widthPx: 9 }); + display.setHeat('slow', roads.slice(1), { + color: [1, 0.7, 0.2, 0.2], + widthPx: 4, + }); + display.setHeat('slow', [], { color: [1, 0.7, 0.2, 0.2], widthPx: 4 }); + assert.equal(h.set(IDS.slow), undefined, 'no roads: no set'); + display.setHeat('slow', roads.slice(1), { + color: [1, 0.7, 0.2, 0.2], + widthPx: 4, + }); + const jam = h.set(IDS.jam); + const slow = h.set(IDS.slow); + for (const [set, order] of [ + [jam, 0], + [slow, 1], + ]) { + assert.equal(set.options.kind, 'corridor'); + assert.equal(set.options.drape, 'tiles'); + assert.equal(set.options.pickable, false); + assert.equal(set.options.order, order); + assert.equal(set.shown, true); + } + assert.equal(jam.items.length, 2); + assert.deepEqual(jam.items[1].positions, roads[1].coords); + assert.equal(jam.items[0].widthPx, 9); + assert.deepEqual(jam.items[0].color, [1, 0, 0, 0.55]); + assert.equal(slow.items.length, 1); + assert.equal(slow.items[0].widthPx, 4); + if (h.cesium) { + const { fills, lines } = h.primitives(IDS.jam); + assert.equal(fills.length, 0); + assert.equal(lines.length, 1, 'one ground polyline batch'); + assert.equal( + lines[0].options.classificationType, + Cesium.ClassificationType.CESIUM_3D_TILE, + ); + assert.equal( + lines[0].options.geometryInstances[0].geometry.width, + HEAT_LINE_JAM_WIDTH, + ); + } + display.clearHeat(); + assert.equal(h.set(IDS.jam), undefined); + assert.equal(h.set(IDS.slow), undefined); + }); + + test(`the layer's heat lines: jam red at its base alpha, slow amber; mono presets in grey (${name})`, (t) => { + const { h, display } = fixture(t, makeHarness); + const state = createState({ services: {} }); + Object.assign(state, { + _display: display, + _liveMode: true, + _jamViz: 'heatline', + }); + const parts = {}; + const context = { state, services: {}, parts, source: {} }; + parts.style = createStyle(context); + parts.rendering = createRendering(context); + const road = (level, offset, metres) => ({ + flow: { level, closure: false }, + coords: [ + [offset, 0], + [offset + 0.001, 0], + ], + segmentDist: [metres], + }); + const roads = [ + road(0.9, 0, 100), + road(0.7, 0.01, 100), + road(0.1, 0.02, 50), + road(0.1, 0.03, 300), + { ...road(0.1, 0.04, 100), flow: { level: 0.1, closure: true } }, + ]; + parts.rendering.rebuildHeatLines(roads); + const jam = h.set(IDS.jam); + const slow = h.set(IDS.slow); + assert.equal(jam.items.length, 2, 'jam roads, the closure left out'); + assert.deepEqual( + jam.items.map((item) => item.positions[0][0]), + [0.03, 0.02], + 'longest first', + ); + nearAll( + jam.items[0].color, + [...HEAT_JAM_COLOR.rgba().slice(0, 3), HEAT_JAM_BASE_ALPHA], + 'jam colour', + ); + assert.equal(jam.items[0].widthPx, HEAT_LINE_JAM_WIDTH); + assert.equal(slow.items.length, 1); + nearAll(slow.items[0].color, HEAT_SLOW_COLOR.rgba(), 'slow colour'); + assert.equal(slow.items[0].widthPx, HEAT_LINE_SLOW_WIDTH); + assert.equal(state._heatLineCount, 3); + // NVG and the other mono presets re-encode in luminance. + state._stylePreset = 'surveillance'; + parts.rendering.rebuildHeatLines(roads); + nearAll( + h.set(IDS.jam).items[0].color, + [1, 1, 1, HEAT_JAM_BASE_ALPHA], + 'mono jam', + ); + nearAll( + h.set(IDS.slow).items[0].color, + [0.7, 0.7, 0.7, HEAT_SLOW_COLOR.alpha], + 'mono slow', + ); + // Heat lines off: none drawn. + state._jamViz = 'density'; + parts.rendering.rebuildHeatLines(roads); + assert.equal(h.set(IDS.jam), undefined); + assert.equal(state._heatLineCount, 0); + }); + + test(`heights are sampled through the vehicle sets, and destroy removes every set (${name})`, (t) => { + const { h, display } = fixture(t, makeHarness); + const excluded = display.excluded(); + assert.equal(excluded.length, 2); + display.setHeat( + 'jam', + [ + { + coords: [ + [0, 0], + [0.001, 0], + ], + }, + ], + { + color: [1, 0, 0, 0.55], + widthPx: 9, + }, + ); + display.drawDots([dot()]); + display.destroy(); + display.destroy(); + assert.equal(h.engine.movers.list().length, 0); + assert.equal(h.sets(), 0); + assert.deepEqual(display.excluded(), []); + display.drawDots([dot()]); + display.setHeat( + 'slow', + [ + { + coords: [ + [0, 0], + [0.001, 0], + ], + }, + ], + { + color: [1, 1, 1, 1], + widthPx: 4, + }, + ); + assert.equal(h.sets(), 0, 'nothing draws after destroy'); + }); +} diff --git a/src/layers/traffic/render/navara.js b/src/layers/traffic/render/navara.js deleted file mode 100644 index b5f8819f8..000000000 --- a/src/layers/traffic/render/navara.js +++ /dev/null @@ -1,187 +0,0 @@ -import * as geo from '../../../geo/index.js'; -import { MAX_DOTS } from '../policy.js'; - -// Point handles keep the same mutable fields the shared traffic motion loop uses. -// The GPU gets two packed draws, including the preset's optional colored outlines. -function createTrafficPoints(engine) { - const points = new Set(), - renderSets = []; - for (const [role, order] of [ - ['outline', 0], - ['fill', 1], - ]) { - renderSets.push( - engine.movers.createSet({ - id: 'traffic-' + role, - kind: 'point', - order, - capacity: MAX_DOTS, - depthTest: 'normal', - pickable: false, - point: { sizePx: 1, outlinePx: 0 }, - }), - ); - } - const color = [0, 0, 0, 0]; - let empty = true; - const range = (p, d) => { - if (!p) return 1; - // The engine's distance scalar interpolates in squared range. - const x = Math.min( - 1, - Math.max( - 0, - (d * d - p.near * p.near) / (p.far * p.far - p.near * p.near), - ), - ); - return p.nearValue + (p.farValue - p.nearValue) * Math.pow(x, 0.2); - }; - return { - show: true, - add(spec) { - const pos = geo.cartesian3.clone(spec.position); - const p = { - ...spec, - show: spec.show ?? true, - get position() { - return pos; - }, - set position(v) { - geo.cartesian3.clone(v, pos); - }, - }; - points.add(p); - return p; - }, - remove(point) { - return points.delete(point); - }, - removeAll() { - points.clear(); - this.flush(); - }, - flush() { - if (!points.size && empty) return; - empty = points.size === 0; - const center = engine.camera.livePosition; - const sizes = [0, 0]; - if (this.show && center) - for (const p of points) { - if ( - !p.show || - p.pixelSize <= 0 || - !engine.projector.horizonVisible(p.position) - ) - continue; - const distance = geo.cartesian3.distance(p.position, center), - scale = range(p.scaleByDistance, distance), - alpha = range(p.translucencyByDistance, distance); - if (alpha <= 0) continue; - for ( - let i = p.outlineWidth > 0 && p.outlineColor ? 0 : 1; - i < 2; - i++ - ) { - const c = i ? p.color : p.outlineColor; - color[0] = c.red; - color[1] = c.green; - color[2] = c.blue; - color[3] = c.alpha * alpha; - renderSets[i].set(sizes[i]++, { - position: p.position, - scale: Math.max( - 0, - (p.pixelSize + (i ? 0 : 2 * p.outlineWidth)) * scale, - ), - color, - show: true, - }); - } - } - for (let i = 0; i < 2; i++) { - renderSets[i].setCount(sizes[i]); - renderSets[i].commit(); - } - }, - destroy() { - for (const set of renderSets) set.destroy(); - points.clear(); - }, - }; -} -export function createTrafficNavaraRenderer(engine, native) { - const { view } = native; - const points = createTrafficPoints(engine); - const surface = { - engine, - canvas: engine.host.canvas, - postRender: engine.frames.postRender, - get sampleHeightSupported() { - return engine.surface.supported; - }, - sampleHeight: (p) => - engine.maps.photorealActive() - ? engine.surface.sampleHeight(p) - : engine.surface.terrainHeight(p), - project: (world, out) => engine.projector.worldToWindow(world, out), - get ready() { - return engine.surface.tilesLoaded(); - }, - get surfaceKey() { - return engine.maps.photorealActive() ? 'tiles' : 'terrain'; - }, - }; - const makeHeat = (roads, tint, width, alpha) => { - if (!roads.length) return null; - const source = view.addSource({ - type: 'geojson', - data: { - type: 'FeatureCollection', - features: roads.map((r) => ({ - type: 'Feature', - properties: {}, - geometry: { type: 'LineString', coordinates: r.coords }, - })), - }, - }); - const css = - (Math.round(tint.red * 255) << 16) | - (Math.round(tint.green * 255) << 8) | - Math.round(tint.blue * 255); - let layer; - try { - layer = view.addLayer({ - type: 'vector', - source, - polyline: { - color: css, - width, - opacity: alpha, - clampToGround: true, - transparent: true, - lit: false, - }, - }); - } catch (e) { - source.delete(); - throw e; - } - let current = alpha; - layer.on('featureUpdated', ({ evaluator }) => - evaluator.evaluate(() => ({ opacity: current })), - ); - return { - setAlpha(a) { - if (Math.abs(a - current) > 0.015) { - current = a; - layer.forceUpdate(); - } - }, - destroy() { - layer.delete(); - source.delete(); - }, - }; - }; - return { points, surface, makeHeat, destroy: () => points.destroy() }; -} diff --git a/src/layers/traffic/renderDemand.test.mjs b/src/layers/traffic/renderDemand.test.mjs index df5965145..5f0d7f056 100644 --- a/src/layers/traffic/renderDemand.test.mjs +++ b/src/layers/traffic/renderDemand.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createAnimation } from './animation.js'; -test('traffic render demand follows animated population and jam lines across empty, disable and re-enable', () => { +test('traffic render demand follows the animated population across empty, disable and re-enable', () => { const calls = []; const state = { _enabled: true, _dots: [], _motion: { rebuilds: 0 } }; const animation = createAnimation({ @@ -17,7 +17,7 @@ test('traffic render demand follows animated population and jam lines across emp retention: { clear() {} }, rendering: { removeHeatLines() { - state._heatJamPrim = null; + state._heatLineCount = 0; }, }, }, @@ -30,7 +30,12 @@ test('traffic render demand follows animated population and jam lines across emp assert.deepEqual(calls, [['hold', 'traffic']]); animation.clearDots(); assert.deepEqual(calls.at(-1), ['release', 'traffic']); - state._heatJamPrim = {}; + // Heat lines are static ground fills: they alone hold no continuous render. + state._heatLineCount = 3; + animation.syncRenderDemand(); + assert.deepEqual(calls.at(-1), ['release', 'traffic']); + assert.equal(state._renderHeld, false); + state._dots.push({}); animation.syncRenderDemand(); assert.deepEqual(calls.at(-1), ['hold', 'traffic']); state._enabled = false; @@ -39,6 +44,7 @@ test('traffic render demand follows animated population and jam lines across emp state._enabled = true; animation.clearDots(); assert.equal(state._renderHeld, false); + assert.equal(state._heatLineCount, 0, 'a clear removes the heat lines'); state._dots.push({}); animation.syncRenderDemand(); assert.equal(state._renderHeld, true); diff --git a/src/layers/traffic/rendering.js b/src/layers/traffic/rendering.js index b38452060..6dc153b34 100644 --- a/src/layers/traffic/rendering.js +++ b/src/layers/traffic/rendering.js @@ -1,4 +1,5 @@ -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; +import { Rgba, WHITE } from '../shared/color.js'; import { flowBucket } from '../../data/trafficFlowStyle.js'; import { trafficStyleProfile } from '../../data/trafficPresetStyle.js'; import { @@ -34,38 +35,20 @@ export function createRendering({ : roads; } - /** Remove both heat-line ground primitives from the scene. */ + /** Remove both heat-line sets from the display. */ function removeHeatLines() { - if (layerState._native) { - layerState._heatJamPrim?.destroy(); - layerState._heatSlowPrim?.destroy(); - layerState._heatJamPrim = layerState._heatSlowPrim = null; - } - if (layerState._heatJamPrim) { - layerState._viewer?.scene.groundPrimitives.remove( - layerState._heatJamPrim, - ); - layerState._heatJamPrim = null; - } - if (layerState._heatSlowPrim) { - layerState._viewer?.scene.groundPrimitives.remove( - layerState._heatSlowPrim, - ); - layerState._heatSlowPrim = null; - } + layerState._display?.clearHeat(); layerState._heatLineCount = 0; - parts.animation.syncRenderDemand?.(); } /** * Rebuild the congestion heat-line underlay (jam-viz heatline prototype): - * slow/jam roads drape a corridor line onto the rendered 3D tiles — glowing - * pulsing red for jam, faint flat amber for slow — with the dots animating - * on top. Two batched GroundPolylinePrimitives (one per bucket) so the jam - * batch pulses through one shared material. Capped at HEAT_LINE_CAP (jam - * first, longest first); overflow is logged. No-op in sim mode, when the - * heatline mode is off, or without ground-primitive support. + * slow/jam roads drape a corridor line onto the rendered 3D tiles — red for + * jam, faint flat amber for slow — with the dots animating on top. One + * display set per bucket. Capped at HEAT_LINE_CAP (jam first, longest + * first); overflow is logged. No-op in sim mode, when the heatline mode is + * off, or without a display. * * @param {Array} roads - Road objects visible in the current render. */ @@ -73,22 +56,11 @@ export function createRendering({ function rebuildHeatLines(roads) { removeHeatLines(); if ( - (!layerState._viewer && !layerState._native) || + !layerState._display || !layerState._liveMode || !parts.style.heatlineOn() ) return; - if (layerState._native) layerState._heatSupported = true; - if (layerState._heatSupported === null) { - layerState._heatSupported = Cesium.GroundPolylinePrimitive.isSupported( - layerState._viewer.scene, - ); - if (!layerState._heatSupported) - console.warn( - '[Data:Traffic] GroundPolylinePrimitive unsupported — heat-lines disabled', - ); - } - if (!layerState._heatSupported) return; const candidates = []; for (const road of roads) { @@ -105,80 +77,27 @@ export function createRendering({ ); const kept = candidates.slice(0, HEAT_LINE_CAP); - const instancesFor = (bucket, width) => - kept - .filter((c) => c.bucket === bucket) - .map( - (c) => - new Cesium.GeometryInstance({ - geometry: new Cesium.GroundPolylineGeometry({ - positions: c.road.waypoints, - width, - }), - }), - ); - // Mono presets (NVG/FLIR/noir) discard hue — heat-lines re-encode in - // luminance like the dots: jam = white glow, slow = faint gray. + // luminance like the dots: jam = white, slow = faint gray. const monoHeat = layerState._presetDots === 'on' && trafficStyleProfile(layerState._stylePreset) === 'mono'; - const jamLineColor = monoHeat ? Cesium.Color.WHITE : HEAT_JAM_COLOR; + const jamLineColor = monoHeat ? WHITE : HEAT_JAM_COLOR; const slowLineColor = monoHeat - ? new Cesium.Color(0.7, 0.7, 0.7, HEAT_SLOW_COLOR.alpha) + ? new Rgba(0.7, 0.7, 0.7, HEAT_SLOW_COLOR.alpha) : HEAT_SLOW_COLOR; - - if (layerState._native) { - const roadsFor = (bucket) => - kept.filter((item) => item.bucket === bucket).map((item) => item.road); - layerState._heatJamPrim = layerState._native.makeHeat( - roadsFor('jam'), - jamLineColor, - HEAT_LINE_JAM_WIDTH, - HEAT_JAM_BASE_ALPHA, - ); - layerState._heatSlowPrim = layerState._native.makeHeat( - roadsFor('slow'), - slowLineColor, - HEAT_LINE_SLOW_WIDTH, - slowLineColor.alpha, - ); - layerState._heatLineCount = kept.length; - parts.animation.syncRenderDemand(); - return; - } - const jamInstances = instancesFor('jam', HEAT_LINE_JAM_WIDTH); - if (jamInstances.length) { - layerState._heatJamPrim = layerState._viewer.scene.groundPrimitives.add( - new Cesium.GroundPolylinePrimitive({ - geometryInstances: jamInstances, - classificationType: Cesium.ClassificationType.CESIUM_3D_TILE, - appearance: new Cesium.PolylineMaterialAppearance({ - material: Cesium.Material.fromType('PolylineGlow', { - color: jamLineColor.withAlpha(HEAT_JAM_BASE_ALPHA), - glowPower: 0.25, - }), - }), - }), - ); - } - const slowInstances = instancesFor('slow', HEAT_LINE_SLOW_WIDTH); - if (slowInstances.length) { - layerState._heatSlowPrim = layerState._viewer.scene.groundPrimitives.add( - new Cesium.GroundPolylinePrimitive({ - geometryInstances: slowInstances, - classificationType: Cesium.ClassificationType.CESIUM_3D_TILE, - appearance: new Cesium.PolylineMaterialAppearance({ - material: Cesium.Material.fromType('Color', { - color: slowLineColor, - }), - }), - }), - ); - } + const roadsFor = (bucket) => + kept.filter((item) => item.bucket === bucket).map((item) => item.road); + layerState._display.setHeat('jam', roadsFor('jam'), { + color: jamLineColor.withAlpha(HEAT_JAM_BASE_ALPHA).rgba(), + widthPx: HEAT_LINE_JAM_WIDTH, + }); + layerState._display.setHeat('slow', roadsFor('slow'), { + color: slowLineColor.rgba(), + widthPx: HEAT_LINE_SLOW_WIDTH, + }); layerState._heatLineCount = kept.length; - parts.animation.syncRenderDemand?.(); if (candidates.length > kept.length) { console.log( `[Data:Traffic] Heat-lines capped at ${HEAT_LINE_CAP} (${candidates.length} congested roads in view)`, @@ -227,7 +146,7 @@ export function createRendering({ // nadir view: oblique pitches put the loaded roads many km away even at // low altitude. Probe three roads and stretch the curves accordingly. let areaDist = altitude; - if ((layerState._viewer || layerState._engine) && filteredRoads.length) { + if (layerState._engine && filteredRoads.length) { const probes = [ filteredRoads[0], filteredRoads[Math.floor(filteredRoads.length / 2)], @@ -238,11 +157,7 @@ export function createRendering({ if (wp) areaDist = Math.max( areaDist, - Cesium.Cartesian3.distance( - layerState._engine?.camera.livePosition ?? - layerState._viewer.camera.positionWC, - wp, - ), + geo.cartesian3.distance(layerState._engine.camera.livePosition, wp), ); } } @@ -259,18 +174,10 @@ export function createRendering({ : null; parts.retention.reconcile(filteredRoads, altitude, replace); if (layerState._dots.length && filteredRoads.some((r) => !r.directFlow)) - services.credits?.showOsmCredit?.( - layerState._engine ?? layerState._viewer, - 'traffic', - { - openMapTiles: true, - }, - ); - else - services.credits?.hideOsmCredit?.( - layerState._engine ?? layerState._viewer, - 'traffic', - ); + services.credits?.showOsmCredit?.(layerState._engine, 'traffic', { + openMapTiles: true, + }); + else services.credits?.hideOsmCredit?.(layerState._engine, 'traffic'); parts.model.recolorDotsInPlace(label); const renderMetrics = state diff --git a/src/layers/traffic/retention.js b/src/layers/traffic/retention.js index c6cb3a519..73bd63c3b 100644 --- a/src/layers/traffic/retention.js +++ b/src/layers/traffic/retention.js @@ -1,4 +1,4 @@ -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; import { MAX_DOTS, roadDotBudget } from './policy.js'; export const DOT_CHANGE_BUDGET = 768; @@ -15,28 +15,22 @@ export function createRetention({ state, parts }) { const heightStep = { x: 0, y: 0, z: 0 }; const screenFrom = { x: 0, y: 0 }; const screenTo = { x: 0, y: 0 }; - const eye = { x: 0, y: 0, z: 0 }; - function projectSafely(scene, point, screen) { - if (scene.project) return Boolean(scene.project(point, screen)); - if (scene.camera.viewMatrix) { - Cesium.Matrix4.multiplyByPoint(scene.camera.viewMatrix, point, eye); - if (eye.z >= -(scene.camera.frustum?.near || 0)) return false; - } + // The window position of a point in front of the camera (undefined behind + // it or in front of the near plane, as Cesium's worldToWindowCoordinates). + function projectSafely(engine, point, screen) { return ( - Boolean( - Cesium.SceneTransforms.worldToWindowCoordinates(scene, point, screen), - ) && + Boolean(engine.projector.worldToWindow(point, screen)) && Number.isFinite(screen.x) && Number.isFinite(screen.y) ); } - function nearCanvas(scene, point, screen) { + function nearCanvas(engine, size, point, screen) { return ( - projectSafely(scene, point, screen) && + projectSafely(engine, point, screen) && screen.x >= -100 && - screen.x <= scene.canvas.clientWidth + 100 && + screen.x <= size.width + 100 && screen.y >= -100 && - screen.y <= scene.canvas.clientHeight + 100 + screen.y <= size.height + 100 ); } let timer = null; @@ -51,9 +45,10 @@ export function createRetention({ state, parts }) { }; function pump() { timer = null; - if (!state._enabled || !state._pointCollection) return; + if (!state._enabled || !state._display) return; let budget = DOT_CHANGE_BUDGET; let pending = false; + let removed = false; const now = Date.now(); // Removing a road or reducing density fades only surplus dots. A retained // dot never changes road, direction, segment or progress. @@ -69,7 +64,6 @@ export function createRetention({ state, parts }) { for (let i = record.dots.length - 1; i >= 0 && budget > 0; i--) { const dot = record.dots[i]; if (!dot.retiring || now - dot.retiring < DOT_FADE_MS) continue; - state._pointCollection.remove(dot.point); const last = state._dots.pop(); if (last !== dot) { state._dots[dot.index] = last; @@ -77,6 +71,7 @@ export function createRetention({ state, parts }) { } record.dots.splice(i, 1); budget--; + removed = true; state._motion.removed++; } let active = 0; @@ -112,6 +107,9 @@ export function createRetention({ state, parts }) { if (!record.target && !record.dots.length) records.delete(record.road.key); } + // A removal outside a frame is drawn now: the population may have + // emptied, and then no frame follows. + if (removed) parts.animation.drawDots?.(); refreshCounts(); state._populationPending = pending; if (pending) schedule(); @@ -140,11 +138,11 @@ export function createRetention({ state, parts }) { // so an asynchronous height pass cannot move half a road in one frame. const road = { ...candidate, - waypoints: candidate.waypoints.map((p) => Cesium.Cartesian3.clone(p)), + waypoints: candidate.waypoints.map((p) => geo.cartesian3.clone(p)), segmentDist: candidate.segmentDist.slice(), source: candidate, targetWaypoints: candidate.waypoints.map((p) => - Cesium.Cartesian3.clone(p), + geo.cartesian3.clone(p), ), heightMoving: false, }; @@ -156,7 +154,7 @@ export function createRetention({ state, parts }) { record.road.densityWeight = candidate.densityWeight; for (let i = 0; i < candidate.waypoints.length; i++) { if ( - !Cesium.Cartesian3.equalsEpsilon( + !geo.cartesian3.equalsEpsilon( record.road.targetWaypoints[i], candidate.waypoints[i], 0, @@ -164,7 +162,7 @@ export function createRetention({ state, parts }) { ) ) record.road.heightMoving = state._heightPending = true; - Cesium.Cartesian3.clone( + geo.cartesian3.clone( candidate.waypoints[i], record.road.targetWaypoints[i], ); @@ -215,10 +213,9 @@ export function createRetention({ state, parts }) { } function easeHeights(dt) { const blend = Math.min(1, dt * 12); - const scene = state._trafficScene?.() ?? state._viewer?.scene; - const canProject = - scene?.canvas?.clientHeight && - (scene.project || (scene.camera && scene.frameState)); + const engine = state._engine; + const size = engine?.host.size(); + const canProject = Boolean(size?.height); state._heightPending = false; for (let r = 0; r < state._roads.length; r++) { const road = state._roads[r]; @@ -227,25 +224,26 @@ export function createRetention({ state, parts }) { for (let i = 0; i < road.waypoints.length; i++) { const point = road.waypoints[i], target = road.targetWaypoints[i]; - if (Cesium.Cartesian3.distanceSquared(point, target) < 0.0001) - Cesium.Cartesian3.clone(target, point); + if (geo.cartesian3.distanceSquared(point, target) < 0.0001) + geo.cartesian3.clone(target, point); else { - const visible = canProject && nearCanvas(scene, point, screenFrom); + const visible = + canProject && nearCanvas(engine, size, point, screenFrom); let step = visible ? blend : Math.min( blend, - (120 * dt) / Cesium.Cartesian3.distance(point, target), + (120 * dt) / geo.cartesian3.distance(point, target), ); if (visible) { let safe = false; for (let attempt = 0; attempt < 16; attempt++) { - Cesium.Cartesian3.lerp(point, target, step, heightStep); - if (!projectSafely(scene, heightStep, screenTo)) { + geo.cartesian3.lerp(point, target, step, heightStep); + if (!projectSafely(engine, heightStep, screenTo)) { step *= 0.5; continue; } - const pixels = Cesium.Cartesian2.distance(screenFrom, screenTo); + const pixels = geo.cartesian2.distance(screenFrom, screenTo); if (pixels <= 12) { safe = true; break; @@ -255,12 +253,12 @@ export function createRetention({ state, parts }) { if (!safe) step = 0; } state._heightPending ||= !canProject || visible; - Cesium.Cartesian3.lerp(point, target, step, point); + geo.cartesian3.lerp(point, target, step, point); moving = true; } } for (let i = 0; i < road.segmentDist.length; i++) - road.segmentDist[i] = Cesium.Cartesian3.distance( + road.segmentDist[i] = geo.cartesian3.distance( road.waypoints[i], road.waypoints[i + 1], ); @@ -280,9 +278,9 @@ export function createRetention({ state, parts }) { for (const road of roads) { if (!road.heightMoving) continue; for (let i = 0; i < road.waypoints.length; i++) - Cesium.Cartesian3.clone(road.targetWaypoints[i], road.waypoints[i]); + geo.cartesian3.clone(road.targetWaypoints[i], road.waypoints[i]); for (let i = 0; i < road.segmentDist.length; i++) - road.segmentDist[i] = Cesium.Cartesian3.distance( + road.segmentDist[i] = geo.cartesian3.distance( road.waypoints[i], road.waypoints[i + 1], ); diff --git a/src/layers/traffic/state.js b/src/layers/traffic/state.js index 50b19c535..a770bbb0f 100644 --- a/src/layers/traffic/state.js +++ b/src/layers/traffic/state.js @@ -13,19 +13,20 @@ export function createState({ services }) { layerState._roadModeUrlOverride = layerState._roadMode; // ─── Module State ────────────────────────────────────────── - /** @type {Cesium.Viewer|null} */ + /** @type {import('../../globe/contract.js').GlobeEngine|null} */ - layerState._viewer = null; layerState._engine = null; - layerState._native = null; - layerState._trafficScene = () => - layerState._native?.surface ?? layerState._viewer?.scene; - /** @type {Cesium.PointPrimitiveCollection|null} */ + /** @type {ReturnType|null} The engine's traffic display. */ - layerState._pointCollection = null; + layerState._display = null; - /** @type {Array<{point:Cesium.PointPrimitive, waypoints:Cesium.Cartesian3[], segmentDist:number[], numSegments:number, segIdx:number, t:number, mps:number, direction:number, stoppedUntil:number}>} Active animated dots */ + /** + * Active animated dots. `point` is the dot's look as plain data (position, + * pixelSize, color, outlineColor, outlineWidth, show, scaleByDistance, + * translucencyByDistance), which the display draws every frame. + * @type {Array<{point:object, waypoints:object[], segmentDist:number[], numSegments:number, segIdx:number, t:number, mps:number, direction:number, stoppedUntil:number}>} + */ layerState._dots = []; layerState._nextDotId = 1; @@ -37,7 +38,7 @@ export function createState({ services }) { recycled: 0, }; - /** @type {Array<{coords:number[][], type:string, waypoints:Cesium.Cartesian3[], segmentDist:number[]}>} Parsed roads with pre-computed Cartesian3 waypoints */ + /** @type {Array<{coords:number[][], type:string, waypoints:object[], segmentDist:number[]}>} Parsed roads with pre-computed Earth-fixed waypoints */ layerState._roads = []; @@ -171,27 +172,13 @@ export function createState({ services }) { layerState._jamViz = 'density'; /** - * Congestion heat-lines are GroundPolylinePrimitive batches draped onto the - * rendered 3D tiles (ClassificationType.CESIUM_3D_TILE). One - * primitive per bucket so the jam batch can pulse via a single shared - * material uniform. - * @type {Cesium.GroundPolylinePrimitive|null} + * Congestion heat-lines are ground corridors draped onto the rendered 3D + * tiles, one display set per bucket (jam, slow). + * @type {number} Heat-lines in the current render (stats). */ - layerState._heatJamPrim = null; - - /** @type {Cesium.GroundPolylinePrimitive|null} Slow-bucket heat-line batch. */ - - layerState._heatSlowPrim = null; - - /** @type {number} Heat-lines in the current render (stats). */ - layerState._heatLineCount = 0; - /** @type {boolean|null} GroundPolylinePrimitive.isSupported, checked once. */ - - layerState._heatSupported = null; - /** @type {number} Altitude of the last render, for late-flow heat rebuilds. */ layerState._lastRenderAltitude = 0; @@ -220,7 +207,7 @@ export function createState({ services }) { * Effective per-bucket dot colors: preset override when one applies, else * the shipped FLOW_BUCKET_COLORS. Rebuilt on style/param change only — * spawn/recolor/restyle all read from here, no per-dot allocation. - * @type {{free:Cesium.Color, slow:Cesium.Color, jam:Cesium.Color}} + * @type {{free:import('../shared/color.js').Rgba, slow:import('../shared/color.js').Rgba, jam:import('../shared/color.js').Rgba}} */ layerState._activeBucketColors = { ...FLOW_BUCKET_COLORS }; diff --git a/src/layers/traffic/style.js b/src/layers/traffic/style.js index 6d236da92..dd27e73d2 100644 --- a/src/layers/traffic/style.js +++ b/src/layers/traffic/style.js @@ -4,7 +4,7 @@ import { presetDotRgba, presetDotOutline, } from '../../data/trafficPresetStyle.js'; -import * as Cesium from 'cesium'; +import { Rgba } from '../shared/color.js'; import { SIZE_BY_TYPE, STYLED_MIN_BASE_PX, @@ -58,7 +58,7 @@ export function createStyle({ state: layerState, services, parts, source }) { ? presetDotRgba(layerState._stylePreset, bucket) : null; layerState._activeBucketColors[bucket] = rgba - ? new Cesium.Color(rgba[0] / 255, rgba[1] / 255, rgba[2] / 255, rgba[3]) + ? new Rgba(rgba[0] / 255, rgba[1] / 255, rgba[2] / 255, rgba[3]) : FLOW_BUCKET_COLORS[bucket]; } } @@ -68,7 +68,7 @@ export function createStyle({ state: layerState, services, parts, source }) { * it back to the shipped no-outline state). NVG's auto-gain saturates the * scene, so brightness alone cannot separate a dot from a bright road — * the dark ring restores local contrast through every luma-mapping shader. - * @param {Cesium.PointPrimitive} point - The dot primitive. + * @param {object} point - The dot's look (`dot.point`). * @param {'free'|'slow'|'jam'|null} bucket - Flow bucket (null = sim). */ @@ -78,7 +78,7 @@ export function createStyle({ state: layerState, services, parts, source }) { ? presetDotOutline(layerState._stylePreset, bucket) : null; if (spec) { - point.outlineColor = new Cesium.Color( + point.outlineColor = new Rgba( spec.rgba[0] / 255, spec.rgba[1] / 255, spec.rgba[2] / 255, diff --git a/src/layers/traffic/surface.js b/src/layers/traffic/surface.js index 9e91ae7ef..18482cc6e 100644 --- a/src/layers/traffic/surface.js +++ b/src/layers/traffic/surface.js @@ -1,40 +1,25 @@ import { phaseTiming } from '../../sources/phaseTiming.js'; -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; import { DOT_HEIGHT_OFFSET, MAX_WAYPOINTS_PER_ROAD } from './policy.js'; -function surfaceTilesReady(scene) { - if (scene.globe?.show) return scene.globe.tilesLoaded !== false; - let found = false; - for (let i = 0; i < (scene.primitives?.length || 0); i++) { - const primitive = scene.primitives.get(i); - if (!primitive.show || typeof primitive.tilesLoaded !== 'boolean') continue; - found = true; - if (!primitive.tilesLoaded) return false; - } - return found; -} +// Every surface read here goes through the globe contract (engine.surface, +// engine.picker, engine.projector, engine.maps), the same on every engine. const renderedFrames = new WeakMap(); /** Observe completed frames only while an enabled traffic consumer owns them. */ -export function observeTrafficSurface(scene) { - if (!scene.postRender?.addEventListener || !scene.camera?.viewMatrix) - return () => {}; - let frame = renderedFrames.get(scene); +export function observeTrafficSurface(engine) { + if (!engine?.frames?.postRender?.addEventListener) return () => {}; + let frame = renderedFrames.get(engine); if (!frame) { frame = { - view: new Cesium.Matrix4(), - projection: new Cesium.Matrix4(), + viewProjection: geo.matrix4.create(), owners: 0, }; - renderedFrames.set(scene, frame); - frame.remove = scene.postRender.addEventListener(() => { - Cesium.Matrix4.clone(scene.camera.viewMatrix, frame.view); - Cesium.Matrix4.clone( - scene.camera.frustum.projectionMatrix, - frame.projection, - ); - frame.surface = trafficSurfaceKey(scene); - frame.ready = surfaceTilesReady(scene); + renderedFrames.set(engine, frame); + frame.remove = engine.frames.postRender.addEventListener(() => { + engine.projector.viewProjection(frame.viewProjection); + frame.surface = trafficSurfaceKey(engine); + frame.ready = engine.surface.tilesLoaded(); }); } frame.owners++; @@ -44,30 +29,27 @@ export function observeTrafficSurface(scene) { released = true; if (--frame.owners === 0) { frame.remove(); - renderedFrames.delete(scene); + renderedFrames.delete(engine); } }; } -function currentRenderedFrame(scene) { - if (scene.engine) return { ready: scene.ready }; - if (!scene.postRender?.addEventListener || !scene.camera?.viewMatrix) - return { ready: surfaceTilesReady(scene) }; - const frame = renderedFrames.get(scene); +const scratchViewProjection = geo.matrix4.create(); +function currentRenderedFrame(engine) { + const frame = renderedFrames.get(engine); return frame && - frame.surface === trafficSurfaceKey(scene) && - Cesium.Matrix4.equals(frame.view, scene.camera.viewMatrix) && - Cesium.Matrix4.equals( - frame.projection, - scene.camera.frustum.projectionMatrix, + frame.surface === trafficSurfaceKey(engine) && + geo.matrix4.equals( + frame.viewProjection, + engine.projector.viewProjection(scratchViewProjection), ) ? frame : null; } /** Only a completed render of this camera can certify cached surface heights. */ -export function trafficSurfaceReady(scene) { - return currentRenderedFrame(scene)?.ready === true; +export function trafficSurfaceReady(engine) { + return currentRenderedFrame(engine)?.ready === true; } /** Preserve bends and insert height samples at most 150 m apart, splitting long roads. */ @@ -141,55 +123,54 @@ const validHeight = (height) => * Reuse the rendered depth buffer instead of drawing a new pick frustum per * station. Iteration corrects the projection's height estimate. A depth hit * is accepted only at this road coordinate (2 m), never on a foreground roof - * or hillside projected onto a different geographic point. Translucent traffic - * is absent from Cesium's default pickPosition depth pass. + * or hillside projected onto a different geographic point. Traffic itself is + * translucent and absent from the depth that pickPosition reads. */ -export function renderedRoadHeight(scene, lon, lat, estimate) { +export function renderedRoadHeight(engine, lon, lat, estimate) { + const size = engine.host.size(); if ( - !scene.pickPositionSupported || - scene.pickTranslucentDepth || - !currentRenderedFrame(scene) || - !scene.canvas?.clientWidth || - !scene.frameState + !engine.picker.pickPositionSupported || + !currentRenderedFrame(engine) || + !size.width ) return undefined; let height = validHeight(estimate) ? estimate : 0; - const point = new Cesium.Cartesian3(); - const screen = new Cesium.Cartesian2(); + const point = geo.cartesian3.create(); + const screen = geo.cartesian2.create(); for (let attempt = 0; attempt < 4; attempt++) { - Cesium.Cartesian3.fromDegrees(lon, lat, height, undefined, point); + geo.cartesian3.fromDegrees(lon, lat, height, undefined, point); if ( - !Cesium.SceneTransforms.worldToWindowCoordinates(scene, point, screen) || + !engine.projector.worldToWindow(point, screen) || screen.x < 0 || screen.y < 0 || - screen.x >= scene.canvas.clientWidth || - screen.y >= scene.canvas.clientHeight + screen.x >= size.width || + screen.y >= size.height ) return undefined; let hit; try { - hit = scene.pickPosition(screen); + hit = engine.picker.pickPosition(screen); } catch { return undefined; } if (!hit) return undefined; - const location = Cesium.Cartographic.fromCartesian(hit); + const location = geo.cartographic.fromCartesian(hit); if (!location || !validHeight(location.height)) return undefined; const dx = - (Cesium.Math.toDegrees(location.longitude) - lon) * + (geo.math.toDegrees(location.longitude) - lon) * Math.cos((lat * Math.PI) / 180) * 111320; - const dy = (Cesium.Math.toDegrees(location.latitude) - lat) * 111320; + const dy = (geo.math.toDegrees(location.latitude) - lat) * 111320; if (Math.hypot(dx, dy) <= 2) return location.height; height = location.height; } return undefined; } -// Per-scene, coordinate-keyed LRU. Never writes raw samples into shared ground floors. -const sceneCaches = new WeakMap(); +// Per-engine, coordinate-keyed LRU. Never writes raw samples into shared ground floors. +const heightCaches = new WeakMap(); -// Stable ids for the objects heights are sampled from (tilesets, terrain). +// Stable ids for the map sources heights are sampled from. const surfaceIds = new WeakMap(); let nextSurfaceId = 1; const surfaceId = (object) => { @@ -199,45 +180,26 @@ const surfaceId = (object) => { }; /** - * Name what heights are sampled against: the terrain provider when the globe - * is shown, otherwise the set of visible 3D tilesets. A map-provider switch + * Name what heights are sampled against: the photoreal tiles shown, or the + * globe under its base imagery (the contract does not name the terrain; a map + * stack switch replaces the base imagery with it). A map-provider switch * changes it and invalidates every cached height. */ const surfaceKeys = new WeakMap(); -export function trafficSurfaceKey(scene) { - if (scene.engine) return scene.surfaceKey; - let state = surfaceKeys.get(scene); +export function trafficSurfaceKey(engine) { + let state = surfaceKeys.get(engine); if (!state) { - state = { globe: null, terrain: null, tiles: [], key: '' }; - surfaceKeys.set(scene, state); + state = { tiles: false, source: null, key: '' }; + surfaceKeys.set(engine, state); } - const globe = Boolean(scene.globe?.show); - const terrain = globe ? scene.globe.terrainProvider : null; - let changed = state.globe !== globe || state.terrain !== terrain; - let count = 0; - if (!globe) { - for (let i = 0; i < (scene.primitives?.length || 0); i++) { - const primitive = scene.primitives.get(i); - if (!primitive?.show || typeof primitive.tilesLoaded !== 'boolean') - continue; - if (state.tiles[count] !== primitive) { - state.tiles[count] = primitive; - changed = true; - } - count++; - } - } - if (count !== state.tiles.length) { - state.tiles.length = count; - changed = true; - } - if (changed) { - state.globe = globe; - state.terrain = terrain; + const tiles = engine.maps.photorealActive(); + const current = engine.maps.current(); + const source = tiles ? current.photoreal : current.baseImagery; + if (state.tiles !== tiles || state.source !== source || !state.key) { + state.tiles = tiles; + state.source = source; // Allocate only when the actual map surface changes, never per frame. - state.key = globe - ? `globe:${surfaceId(terrain)}` - : `tiles:${state.tiles.map(surfaceId).join(',')}`; + state.key = `${tiles ? 'tiles' : 'globe'}:${surfaceId(source)}`; } return state.key; } @@ -265,7 +227,7 @@ const nextSlice = (hasSurface) => /** Prepare only admitted roads in bounded slices; locally missing surfaces defer that road. */ export async function prepareRoadSurfaces( roads, - scene, + engine, ground, excluded, signal, @@ -274,21 +236,15 @@ export async function prepareRoadSurfaces( // Cached heights are only as good as the surface and detail they came // from: a provider switch drops them all, and a point now seen from a // closer band than its sample is re-sampled against the finer mesh. - const surface = trafficSurfaceKey(scene); - let cache = sceneCaches.get(scene); + const surface = trafficSurfaceKey(engine); + let cache = heightCaches.get(engine); if (!cache || cache.surface !== surface) { cache = { surface, heights: new Map() }; - sceneCaches.set(scene, cache); + heightCaches.set(engine, cache); } - const cameraCarto = - scene.engine?.camera.pose().cartographic ?? - scene.camera?.positionCartographic; - const cameraLon = cameraCarto - ? Cesium.Math.toDegrees(cameraCarto.longitude) - : 0; - const cameraLat = cameraCarto - ? Cesium.Math.toDegrees(cameraCarto.latitude) - : 0; + const cameraCarto = engine.camera.pose().cartographic; + const cameraLon = cameraCarto ? geo.math.toDegrees(cameraCarto.longitude) : 0; + const cameraLat = cameraCarto ? geo.math.toDegrees(cameraCarto.latitude) : 0; const cameraHeight = cameraCarto ? cameraCarto.height : NaN; const start = performance.now(); const metrics = { @@ -301,7 +257,7 @@ export async function prepareRoadSurfaces( maxSliceMs: 0, }; let sliceStart = performance.now(); - const carto = new Cesium.Cartographic(); + const carto = geo.cartographic.create(); const ready = [], pending = []; for (const road of roads) { @@ -314,7 +270,7 @@ export async function prepareRoadSurfaces( const [lon, lat] = road.coords[i]; const key = `${lon.toFixed(6)},${lat.toFixed(6)}`; const cached = cache.heights.get(key); - const settled = trafficSurfaceReady(scene); + const settled = trafficSurfaceReady(engine); const band = detailBand(cameraLon, cameraLat, cameraHeight, lon, lat); let height = cached && @@ -330,38 +286,31 @@ export async function prepareRoadSurfaces( cache.heights.set(key, cached); } else { if (!settled) settledRoad = false; - carto.longitude = Cesium.Math.toRadians(lon); - carto.latitude = Cesium.Math.toRadians(lat); + carto.longitude = geo.math.toRadians(lon); + carto.latitude = geo.math.toRadians(lat); carto.height = 0; const floor = ground?.cachedGroundFloor?.(lat, lon); let sampled; - // sampleHeight reads the locally rendered mesh, not unresolved/offscreen - // tiles. Global tilesLoaded can stay false while this street is usable. - if (scene.engine) sampled = scene.sampleHeight(carto); - else if (scene.globe?.show) sampled = scene.globe.getHeight?.(carto); + // The globe answers from its terrain. Photoreal tiles answer from the + // rendered depth first, then from sampleHeight, which reads the + // locally rendered mesh, not unresolved/offscreen tiles: global + // tilesLoaded can stay false while this street is usable. + const photoreal = engine.maps.photorealActive(); + if (!photoreal) sampled = engine.surface.terrainHeight(carto); else { - const estimate = Cesium.Cartographic.fromCartesian( + const estimate = geo.cartographic.fromCartesian( road.waypoints[i], )?.height; - sampled = renderedRoadHeight(scene, lon, lat, estimate); + sampled = renderedRoadHeight(engine, lon, lat, estimate); if (validHeight(sampled)) metrics.depthHits++; } - if ( - !scene.engine && - !scene.globe?.show && - !validHeight(sampled) && - scene.sampleHeightSupported - ) { + if (photoreal && !validHeight(sampled) && engine.surface.supported) { const sampleStart = performance.now(); metrics.sampleCount++; - const shown = (excluded || []).filter((p) => p?.show); - for (const primitive of shown) primitive.show = false; try { - sampled = scene.sampleHeight(carto, excluded); + sampled = engine.surface.sampleHeight(carto, excluded); } catch { /* local mesh missing */ - } finally { - for (const primitive of shown) primitive.show = true; } metrics.sampleMs += performance.now() - sampleStart; } @@ -408,7 +357,7 @@ export async function prepareRoadSurfaces( const height = heights[a] + (heights[b] - heights[a]) * t; const [lon, lat] = road.coords[i]; const floor = ground?.cachedGroundFloor?.(lat, lon); - Cesium.Cartesian3.fromDegrees( + geo.cartesian3.fromDegrees( lon, lat, (validHeight(floor) ? Math.max(height, floor) : height) + @@ -418,7 +367,7 @@ export async function prepareRoadSurfaces( ); } for (let i = 0; i < road.segmentDist.length; i++) - road.segmentDist[i] = Cesium.Cartesian3.distance( + road.segmentDist[i] = geo.cartesian3.distance( road.waypoints[i], road.waypoints[i + 1], ); diff --git a/src/layers/traffic/surface.test.mjs b/src/layers/traffic/surface.test.mjs index f5b77d843..1a8ebf205 100644 --- a/src/layers/traffic/surface.test.mjs +++ b/src/layers/traffic/surface.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import * as C from 'cesium'; +import * as geo from '../../geo/index.js'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import { detailBand, renderedRoadHeight, @@ -13,9 +14,26 @@ import { } from './surface.js'; const road = (coords) => ({ coords, - waypoints: coords.map(() => new C.Cartesian3()), + waypoints: coords.map(() => geo.cartesian3.create()), segmentDist: coords.slice(1).map(() => 0), }); +const heightOf = (point) => geo.cartographic.fromCartesian(point).height; +const toDegrees = geo.math.toDegrees; + +/** + * A fake engine over photoreal tiles (or the globe), with no rendered depth + * unless a test scripts one. + */ +function surfaceEngine({ photoreal = true } = {}) { + const engine = createFakeEngine(); + if (photoreal) engine.fake.maps.photoreal = { kind: 'photoreal' }; + else engine.fake.maps.imagery = { kind: 'osm' }; + engine.fake.script('pickPosition', undefined); + return engine; +} +/** The camera straight down over (0, 0) at `height` metres. */ +const lookFrom = (engine, height) => + engine.fake.setPose({ longitude: 0, latitude: 0, height }); test('surface points preserve bends, bound spacing, and split rather than decimate long roads', () => { const chunks = roadSurfaceChunks([ [0, 0], @@ -46,7 +64,7 @@ test('surface points preserve bends, bound spacing, and split rather than decima 150.01, ); }); -test('per-vertex mesh heights follow terrain, reject invalid samples, and leave shared cache unmodified', async () => { +test('per-vertex mesh heights follow terrain, reject invalid samples, and leave shared cache unmodified', async (t) => { const heights = [100, 200, NaN, 9001, -9001]; let samples = 0; const r = road([ @@ -56,125 +74,134 @@ test('per-vertex mesh heights follow terrain, reject invalid samples, and leave [0.003, 0], [0.004, 0], ]); - const tileset = { show: true, tilesLoaded: true }; - const scene = { - globe: { show: false }, - primitives: { length: 1, get: () => tileset }, - sampleHeightSupported: true, - sampleHeight: () => heights[samples++], - }; + const engine = surfaceEngine(); + let loaded = true; + engine.fake.script('tilesLoaded', () => loaded); + engine.fake.script('sampleHeight', () => heights[samples++]); + t.after(observeTrafficSurface(engine)); + engine.fake.frame(); const ground = { cachedGroundFloor: () => 50, reportMeshFloorCell: () => assert.fail('raw sample poisoned shared floor'), }; let metrics; - const prepared = await prepareRoadSurfaces([r], scene, ground, [], null, { + const prepared = await prepareRoadSurfaces([r], engine, ground, [], null, { onMetrics: (value) => (metrics = value), }); assert.equal(metrics.sampleCount, 5); assert.equal(prepared.pending.length, 1); assert.equal(samples, 5); - const actual = r.waypoints.map((p) => - Math.round(C.Cartographic.fromCartesian(p).height), - ); + const actual = r.waypoints.map((p) => Math.round(heightOf(p))); assert.deepEqual(actual, [103, 203, 53, 53, 53]); - tileset.tilesLoaded = false; - assert.equal(trafficSurfaceReady(scene), false); - scene.sampleHeight = () => 75; - const resumed = await prepareRoadSurfaces([r], scene, ground, []); + loaded = false; + engine.fake.frame(); + assert.equal(trafficSurfaceReady(engine), false); + engine.fake.script('sampleHeight', () => 75); + const resumed = await prepareRoadSurfaces([r], engine, ground, []); assert.equal( resumed.ready.length, 1, 'other loading tiles do not block a local mesh', ); let picks = 0; - scene.sampleHeight = () => { + engine.fake.script('sampleHeight', () => { picks++; return 75; - }; - await prepareRoadSurfaces([r], scene, ground, []); + }); + await prepareRoadSurfaces([r], engine, ground, []); assert.equal(picks, 0, 'validated coordinate heights survive cache hits'); const abort = new AbortController(); abort.abort(); await assert.rejects( - prepareRoadSurfaces([r], scene, ground, [], abort.signal), + prepareRoadSurfaces([r], engine, ground, [], abort.signal), { name: 'AbortError' }, ); }); +test('the vehicle sets are excluded from mesh samples', async () => { + const engine = surfaceEngine(); + const excluded = [{ id: 'traffic-fill' }]; + const seen = []; + engine.fake.script('sampleHeight', (_position, exclude) => { + seen.push(exclude); + return 10; + }); + await prepareRoadSurfaces( + [ + road([ + [0, 0], + [0.001, 0], + ]), + ], + engine, + null, + excluded, + ); + assert.deepEqual(seen, [excluded, excluded]); +}); test('visible-globe roads floor each vertex without mesh picks', async () => { const r = road([ [0, 0], [0.001, 0], ]); let calls = 0; - await prepareRoadSurfaces( - [r], - { - globe: { show: true, tilesLoaded: true, getHeight: () => ++calls * 100 }, - sampleHeight: () => assert.fail('mesh sampled'), - }, - { cachedGroundFloor: () => 20 }, - [], - ); + const engine = surfaceEngine({ photoreal: false }); + engine.fake.script('terrainHeight', () => ++calls * 100); + engine.fake.script('sampleHeight', () => assert.fail('mesh sampled')); + await prepareRoadSurfaces([r], engine, { cachedGroundFloor: () => 20 }, []); assert.equal(calls, 2); assert.deepEqual( - r.waypoints.map((p) => Math.round(C.Cartographic.fromCartesian(p).height)), + r.waypoints.map((p) => Math.round(heightOf(p))), [103, 203], ); }); -test('cached heights follow detail and provider; same-detail revisits sample nothing', async () => { +test('cached heights follow detail and provider; same-detail revisits sample nothing', async (t) => { const r = road([ [0, 0], [0.001, 0], ]); - const tileset = { show: true, tilesLoaded: true }; let mesh = 40, samples = 0; - const camera = (height) => ({ - positionCartographic: { longitude: 0, latitude: 0, height }, - }); - const scene = { - globe: { show: false }, - primitives: { length: 1, get: () => tileset }, - sampleHeightSupported: true, - sampleHeight: () => (samples++, mesh), - camera: camera(3000), + const engine = surfaceEngine(); + engine.fake.script('sampleHeight', () => (samples++, mesh)); + t.after(observeTrafficSurface(engine)); + const camera = (height) => { + lookFrom(engine, height); + engine.fake.frame(); }; - const heightAt = () => - Math.round(C.Cartographic.fromCartesian(r.waypoints[0]).height); + const heightAt = () => Math.round(heightOf(r.waypoints[0])); // Settled but coarse: sampled from 3 km. - await prepareRoadSurfaces([r], scene, null, []); + camera(3000); + await prepareRoadSurfaces([r], engine, null, []); assert.equal(samples, 2); assert.equal(heightAt(), 43); // Same detail band on a revisit: zero samples. - await prepareRoadSurfaces([r], scene, null, []); + await prepareRoadSurfaces([r], engine, null, []); assert.equal(samples, 2); // Zooming out never re-samples a finer height either. - scene.camera = camera(6000); - await prepareRoadSurfaces([r], scene, null, []); + camera(6000); + await prepareRoadSurfaces([r], engine, null, []); assert.equal(samples, 2); // Closer view, finer mesh: re-sampled and the waypoint follows it. mesh = 47; - scene.camera = camera(250); - await prepareRoadSurfaces([r], scene, null, []); + camera(250); + await prepareRoadSurfaces([r], engine, null, []); assert.equal(samples, 4); assert.equal(heightAt(), 50); - await prepareRoadSurfaces([r], scene, null, []); + await prepareRoadSurfaces([r], engine, null, []); assert.equal(samples, 4, 'the finer sample is now the cached one'); - // A different photoreal tileset (map provider switch) invalidates all, - // even from a coarser view that would otherwise reuse the finer sample. - const other = { show: true, tilesLoaded: true }; - scene.primitives.get = () => other; - scene.camera = camera(3000); + // Different photoreal tiles (map provider switch) invalidate all, even + // from a coarser view that would otherwise reuse the finer sample. + engine.fake.maps.photoreal = { kind: 'photoreal' }; + camera(3000); mesh = 30; - await prepareRoadSurfaces([r], scene, null, []); + await prepareRoadSurfaces([r], engine, null, []); assert.equal(samples, 6); assert.equal(heightAt(), 33); // A finer sample that fails keeps the coarser measured height. - scene.camera = camera(250); + camera(250); mesh = NaN; - const kept = await prepareRoadSurfaces([r], scene, null, []); + const kept = await prepareRoadSurfaces([r], engine, null, []); assert.equal(samples, 8); assert.equal(kept.ready.length, 1); assert.equal(heightAt(), 33); @@ -189,27 +216,18 @@ test('detail bands double with distance and ignore a missing camera', () => { }); test('a camera jump cannot certify old rendered depth or cache coarse heights as settled', async () => { - const tileset = { show: true, tilesLoaded: true }; let height = 20, samples = 0; - const scene = { - postRender: new C.Event(), - camera: { - viewMatrix: C.Matrix4.clone(C.Matrix4.IDENTITY), - frustum: { projectionMatrix: C.Matrix4.clone(C.Matrix4.IDENTITY) }, - }, - globe: { show: false }, - primitives: { length: 1, get: () => tileset }, - sampleHeightSupported: true, - sampleHeight: () => (samples++, height), - }; - assert.equal(trafficSurfaceReady(scene), false); - const removeSurfaceObserver = observeTrafficSurface(scene); - scene.postRender.raiseEvent(); - assert.equal(trafficSurfaceReady(scene), true); - scene.camera.viewMatrix[12] = 100; + const engine = surfaceEngine(); + engine.fake.script('sampleHeight', () => (samples++, height)); + lookFrom(engine, 3000); + assert.equal(trafficSurfaceReady(engine), false); + const removeSurfaceObserver = observeTrafficSurface(engine); + engine.fake.frame(); + assert.equal(trafficSurfaceReady(engine), true); + lookFrom(engine, 3100); assert.equal( - trafficSurfaceReady(scene), + trafficSurfaceReady(engine), false, 'old camera readiness is stale', ); @@ -217,18 +235,16 @@ test('a camera jump cannot certify old rendered depth or cache coarse heights as [0, 0], [0.001, 0], ]); - await prepareRoadSurfaces([r], scene, null, []); + await prepareRoadSurfaces([r], engine, null, []); assert.equal(r.surfaceSettled, false); height = 80; - scene.postRender.raiseEvent(); - await prepareRoadSurfaces([r], scene, null, []); + engine.fake.frame(); + await prepareRoadSurfaces([r], engine, null, []); assert.equal(samples, 4, 'the first fresh render revalidates coarse heights'); assert.equal(r.surfaceSettled, true); - assert.ok( - Math.abs(C.Cartographic.fromCartesian(r.waypoints[0]).height - 83) < 0.01, - ); + assert.ok(Math.abs(heightOf(r.waypoints[0]) - 83) < 0.01); height = 100; - await prepareRoadSurfaces([r, r], scene, null, [], null, { + await prepareRoadSurfaces([r, r], engine, null, [], null, { revalidate: true, }); assert.equal( @@ -237,12 +253,10 @@ test('a camera jump cannot certify old rendered depth or cache coarse heights as 'the bounded refinement replaces initially settled coarse samples too', ); removeSurfaceObserver(); - assert.ok( - Math.abs(C.Cartographic.fromCartesian(r.waypoints[0]).height - 103) < 0.01, - ); - await prepareRoadSurfaces([r], scene, null, []); + assert.ok(Math.abs(heightOf(r.waypoints[0]) - 103) < 0.01); + await prepareRoadSurfaces([r], engine, null, []); assert.equal(samples, 6, 'ordinary same-band revisits still reuse the cache'); - await prepareRoadSurfaces([r], scene, null, [], null, { revalidate: true }); + await prepareRoadSurfaces([r], engine, null, [], null, { revalidate: true }); assert.equal( samples, 6, @@ -261,103 +275,78 @@ test('height stations preserve bends while bounding sample spacing along the roa assert.ok(distances[indices[i]] - distances[indices[i - 1]] <= 150.001); const r = road(coords); let picks = 0; - await prepareRoadSurfaces( - [r], - { - globe: { - show: true, - tilesLoaded: true, - getHeight: (c) => { - picks++; - return C.Math.toDegrees(c.longitude) * 1000; - }, - }, - }, - null, - [], - ); + const engine = surfaceEngine({ photoreal: false }); + engine.fake.script('terrainHeight', (c) => { + picks++; + return toDegrees(c.longitude) * 1000; + }); + await prepareRoadSurfaces([r], engine, null, []); assert.equal(picks, indices.length); for (let i = 0; i < coords.length; i++) { - const c = C.Cartographic.fromCartesian(r.waypoints[i]); - assert.ok(Math.abs(C.Math.toDegrees(c.longitude) - coords[i][0]) < 1e-9); - assert.ok(Math.abs(C.Math.toDegrees(c.latitude) - coords[i][1]) < 1e-9); + const c = geo.cartographic.fromCartesian(r.waypoints[i]); + assert.ok(Math.abs(toDegrees(c.longitude) - coords[i][0]) < 1e-9); + assert.ok(Math.abs(toDegrees(c.latitude) - coords[i][1]) < 1e-9); assert.ok(Math.abs(c.height - (coords[i][0] * 1000 + 3)) < 0.01); } }); test('rendered road depth is geographically checked, bounded, and avoids mesh redraws', async (t) => { - t.mock.method( - C.SceneTransforms, - 'worldToWindowCoordinates', - (scene, point, out) => { - out.x = 100; - out.y = 100; - return out; - }, + const engine = surfaceEngine(); + engine.fake.script('worldToWindow', () => ({ x: 100, y: 100 })); + engine.fake.script('pickPosition', () => + geo.cartesian3.fromDegrees(-97, 30, 100), ); - const scene = { - globe: { show: false }, - frameState: {}, - canvas: { clientWidth: 800, clientHeight: 600 }, - pickPositionSupported: true, - sampleHeightSupported: true, - pickPosition: () => C.Cartesian3.fromDegrees(-97, 30, 100), - sampleHeight: () => - assert.fail('valid depth must not draw another pick frustum'), - }; - assert.ok(Math.abs(renderedRoadHeight(scene, -97, 30, 0) - 100) < 0.001); + engine.fake.script('sampleHeight', () => + assert.fail('valid depth must not draw another pick frustum'), + ); + assert.equal( + renderedRoadHeight(engine, -97, 30, 0), + undefined, + 'no rendered frame of this camera yet', + ); + t.after(observeTrafficSurface(engine)); + engine.fake.frame(); + assert.ok(Math.abs(renderedRoadHeight(engine, -97, 30, 0) - 100) < 0.001); const r = road([ [-97, 30], [-97, 30.000005], ]); r.waypoints = r.coords.map(([lon, lat]) => - C.Cartesian3.fromDegrees(lon, lat, 50), + geo.cartesian3.fromDegrees(lon, lat, 50), ); - const result = await prepareRoadSurfaces([r], scene, null, []); + const result = await prepareRoadSurfaces([r], engine, null, []); assert.equal(result.metrics.depthHits, 2); assert.equal(result.metrics.sampleCount, 0); let calls = 0; - scene.pickPosition = () => { + engine.fake.script('pickPosition', () => { calls++; - return C.Cartesian3.fromDegrees(-97.001, 30, 150); - }; + return geo.cartesian3.fromDegrees(-97.001, 30, 150); + }); assert.equal( - renderedRoadHeight(scene, -97, 30, 0), + renderedRoadHeight(engine, -97, 30, 0), undefined, 'foreground roof is not the road coordinate', ); assert.equal(calls, 4); - scene.pickPosition = () => C.Cartesian3.fromDegrees(-97, 30, 10000); - assert.equal(renderedRoadHeight(scene, -97, 30, 0), undefined); - scene.pickPosition = () => { + engine.fake.script('pickPosition', () => + geo.cartesian3.fromDegrees(-97, 30, 10000), + ); + assert.equal(renderedRoadHeight(engine, -97, 30, 0), undefined); + engine.fake.script('pickPosition', () => { throw new Error('depth unavailable'); - }; - assert.equal(renderedRoadHeight(scene, -97, 30, 0), undefined); - scene.pickTranslucentDepth = true; - assert.equal(renderedRoadHeight(scene, -97, 30, 0), undefined); + }); + assert.equal(renderedRoadHeight(engine, -97, 30, 0), undefined); + engine.fake.script('pickPosition', () => + geo.cartesian3.fromDegrees(-97, 30, 100), + ); + engine.picker.pickPositionSupported = false; + assert.equal(renderedRoadHeight(engine, -97, 30, 0), undefined); }); -test('surface keys reuse identity without building arrays or strings on unchanged frames', (t) => { - const tiles = [ - { show: true, tilesLoaded: true }, - { show: true, tilesLoaded: false }, - ]; - const scene = { - globe: { show: false, terrainProvider: {} }, - primitives: { - get: (i) => tiles[i], - get length() { - return tiles.length; - }, - }, - camera: { - viewMatrix: C.Matrix4.clone(C.Matrix4.IDENTITY), - frustum: { projectionMatrix: C.Matrix4.clone(C.Matrix4.IDENTITY) }, - }, - postRender: new C.Event(), - }; - const first = trafficSurfaceKey(scene); - const remove = observeTrafficSurface(scene); +test('surface keys reuse identity without building arrays or strings on unchanged frames', () => { + const engine = surfaceEngine(); + const first = trafficSurfaceKey(engine); + const remove = observeTrafficSurface(engine); const originalMap = Array.prototype.map; const originalJoin = Array.prototype.join; let maps = 0, @@ -372,8 +361,8 @@ test('surface keys reuse identity without building arrays or strings on unchange }; try { for (let i = 0; i < 1000; i++) { - scene.postRender.raiseEvent(); - assert.equal(trafficSurfaceKey(scene), first); + engine.fake.emit('frames.postRender', i); + assert.equal(trafficSurfaceKey(engine), first); } } finally { Array.prototype.map = originalMap; @@ -381,24 +370,27 @@ test('surface keys reuse identity without building arrays or strings on unchange } assert.equal(maps, 0); assert.equal(joins, 0); - tiles[1].show = false; - const hidden = trafficSurfaceKey(scene); - assert.notEqual(hidden, first); - tiles[1] = { show: true, tilesLoaded: true }; + engine.fake.maps.photoreal = { kind: 'photoreal' }; assert.notEqual( - trafficSurfaceKey(scene), + trafficSurfaceKey(engine), first, - 'same-count tileset replacement is a new surface', + 'replacement photoreal tiles are a new surface', + ); + engine.fake.maps.photoreal = null; + engine.fake.maps.imagery = { kind: 'osm' }; + const globe = trafficSurfaceKey(engine); + assert.notEqual(globe, first, 'the globe is another surface'); + engine.fake.maps.imagery = { kind: 'esri-imagery' }; + assert.notEqual( + trafficSurfaceKey(engine), + globe, + 'a map stack switch on the globe is a new surface', ); - scene.globe.show = true; - const terrain = trafficSurfaceKey(scene); - scene.globe.terrainProvider = {}; - assert.notEqual(trafficSurfaceKey(scene), terrain); remove(); remove(); - assert.equal(scene.postRender.numberOfListeners, 0); + assert.equal(engine.frames.postRender.numberOfListeners, 0); assert.equal( - trafficSurfaceReady(scene), + trafficSurfaceReady(engine), false, 'readiness cannot recreate a subscription', ); diff --git a/src/layers/traffic/timing.js b/src/layers/traffic/timing.js index 0be95a330..ceff79f9f 100644 --- a/src/layers/traffic/timing.js +++ b/src/layers/traffic/timing.js @@ -1,4 +1,4 @@ -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; import { roadSurfaceChunks } from './surface.js'; import { TRAFFIC_TIMING_ENABLED, DOT_HEIGHT_OFFSET } from './policy.js'; @@ -162,8 +162,8 @@ export function createTiming({ state: layerState, services, parts, source }) { } /** - * Mark Cesium's diagnostic moveEnd notification. Cesium normally emits this - * about 500 ms after stillness, so fetch has typically already started and + * Mark the camera's diagnostic moveEnd notification. The engine emits this + * once the camera has been still a while (Cesium: 500 ms), so fetch has typically already started and * never waits for this boundary. */ @@ -250,12 +250,12 @@ export function createTiming({ state: layerState, services, parts, source }) { (Number.isFinite(floor) && Math.abs(floor) <= 9000 ? floor : layerState._viewHeightEstimate || 0) + DOT_HEIGHT_OFFSET; - return Cesium.Cartesian3.fromDegrees(lng, lat, h); + return geo.cartesian3.fromDegrees(lng, lat, h); }); const segmentDist = []; for (let i = 0; i < waypoints.length - 1; i++) { segmentDist.push( - Cesium.Cartesian3.distance(waypoints[i], waypoints[i + 1]), + geo.cartesian3.distance(waypoints[i], waypoints[i + 1]), ); } /* TRACE_ONLY_BEGIN */ @@ -316,7 +316,7 @@ export function createTiming({ state: layerState, services, parts, source }) { return trafficTimingPass(trace, pass, source); } - /** Record the first Cesium postRender following a completed dot render. */ + /** Record the first engine postRender following a completed dot render. */ function scheduleTrafficTimingPostRender( state, @@ -324,9 +324,9 @@ export function createTiming({ state: layerState, services, parts, source }) { renderId, renderMetrics, ) { - if (!layerState._viewer?.scene) return; + if (!layerState._engine) return; let remove = null; - remove = layerState._viewer.scene.postRender.addEventListener(() => { + remove = layerState._engine.frames.postRender.addEventListener(() => { remove?.(); layerState._trafficTimingPostRenderRemovers?.delete(remove); const visibleTime = performance.now(); diff --git a/src/layers/traffic/viewport.js b/src/layers/traffic/viewport.js index f96b49110..9c4296b87 100644 --- a/src/layers/traffic/viewport.js +++ b/src/layers/traffic/viewport.js @@ -1,5 +1,4 @@ import { createReticleFootprint } from './footprint.js'; -import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js'; import { deriveFetchCenter, @@ -24,9 +23,7 @@ export function createViewport({ state: layerState, services, parts, source }) { */ function getCameraAltitude() { - const carto = - layerState._engine?.camera.pose().cartographic ?? - layerState._viewer?.camera.positionCartographic; + const carto = layerState._engine?.camera.pose().cartographic; return carto ? carto.height : Infinity; } @@ -37,9 +34,7 @@ export function createViewport({ state: layerState, services, parts, source }) { */ function getViewBounds() { - const rect = - layerState._engine?.camera.viewRectangle() ?? - layerState._viewer?.camera.computeViewRectangle(); + const rect = layerState._engine?.camera.viewRectangle(); if (!rect) return null; return { south: geo.math.toDegrees(rect.south), @@ -64,25 +59,19 @@ export function createViewport({ state: layerState, services, parts, source }) { */ function getFetchCenter() { - const carto = - layerState._engine?.camera.pose().cartographic ?? - layerState._viewer?.camera.positionCartographic; + const carto = layerState._engine?.camera.pose().cartographic; if (!carto) return null; const nadirLat = geo.math.toDegrees(carto.latitude); const nadirLon = geo.math.toDegrees(carto.longitude); let hitLat; let hitLon; - const canvas = - layerState._engine?.host.canvas ?? layerState._viewer.scene.canvas; + const canvas = layerState._engine.host.canvas; const width = canvas.clientWidth || canvas.width; const height = canvas.clientHeight || canvas.height; if (width > 0 && height > 0) { - const hit = ( - layerState._engine?.camera ?? layerState._viewer.camera - ).pickEllipsoid( + const hit = layerState._engine.camera.pickEllipsoid( geo.cartesian2.create(width / 2, height / 2), - Cesium.Ellipsoid.WGS84, ); if (hit) { const hitCarto = geo.cartographic.fromCartesian(hit); @@ -219,11 +208,7 @@ export function createViewport({ state: layerState, services, parts, source }) { return; const alt = getCameraAltitude(); if (alt > ACTIVATION_ALTITUDE) return onCameraChanged(); - const footprint = reticleFootprint( - layerState._viewer, - alt, - layerState._engine, - ); + const footprint = reticleFootprint(layerState._engine, alt); const bounds = footprint || getViewBounds(); if (!bounds) return; const center = footprint ? null : getFetchCenter(); diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index 3733b6a4f..2c673815c 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -3592,8 +3592,17 @@ test('actual Traffic, Bikeshare and Transit lifecycles retain sensitivity in eve await import('../bikeshare/render/display.js'); const app = harness(t); const noop = () => {}; + // Traffic claims through its engine's camera, as bikeshare does: the + // viewer's. The stub has what binding its scene subscribes to. + const listeners = { addEventListener: () => noop }; + const trafficEngine = { + camera: app.viewer.camera, + frames: { preRender: listeners, postRender: listeners }, + projector: { viewProjection: noop }, + surface: { tilesLoaded: () => false }, + }; const traffic = trafficLifecycle({ - state: { _pointCollection: {}, _loadGeneration: 0 }, + state: { _engine: trafficEngine, _loadGeneration: 0 }, services: { render: { holdContinuousRender: noop, releaseContinuousRender: noop }, }, diff --git a/src/noCesiumLabels.test.mjs b/src/noCesiumLabels.test.mjs index a8105b4eb..051562d38 100644 --- a/src/noCesiumLabels.test.mjs +++ b/src/noCesiumLabels.test.mjs @@ -5,10 +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', - 'src/layers/directions/index.js', -]); +const legacyFiles = new Set(['src/scenes/dataPacks/presentation.js']); const root = fileURLToPath(new URL('../', import.meta.url)); const propertyName = (node) => node?.name ?? node?.value; const memberName = (node) => diff --git a/src/testSupport/streetLevelFakes.mjs b/src/testSupport/streetLevelFakes.mjs index f6d5a6d70..0665d08b9 100644 --- a/src/testSupport/streetLevelFakes.mjs +++ b/src/testSupport/streetLevelFakes.mjs @@ -1,5 +1,15 @@ -/** Shared Street Level test stand-ins: provider snapshot and provider, Mapillary source, photo viewer, Cesium viewer, ray-casting camera. */ +/** + * Shared Street Level test stand-ins: provider snapshot and provider, + * Mapillary source, photo viewer, and the layer's display on the fake + * engine or on the Cesium engine over a stub scene, with what each drew. + */ import * as Cesium from 'cesium'; +import * as geo from '../geo/index.js'; +import { normalizeGroundSetOptions } from '../globe/ground.js'; +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; +import { createFakeEventHandlers } from '../globe/cesium/testing/fakeViewer.mjs'; +import { createCesiumMoversEngine } from './cesiumMoversEngine.mjs'; +import { createStreetLevelDisplay } from '../layers/streetLevel/render/display.js'; import { MAPILLARY_CREDIT_HTML } from '../layers/streetLevel/providers/mapillary/policy.js'; import { latToTileY, @@ -131,19 +141,144 @@ export function fakePhotoViewer({ pose = null } = {}) { } /** - * A stand-in Cesium viewer a layer can be enabled on: a canvas for the click - * handler, camera events with Cesium's flight bookkeeping (`flights` counts - * them, lists them and can `land` the current one), primitive lists and a - * credit display. With `view`, the camera looks straight down on Sacramento - * from 900 m, so coverage asks for tiles. + * What an engine's ground sets and mover sets were given, by id: the + * options (with the contract's defaults), a ground set's committed items, a + * mover set's committed instances (the fields of every set() before its + * last commit), and whether each is still live. */ -export function fakeCesiumViewer({ view = false } = {}) { - const credits = []; +export function watchDrawing(engine) { + const ground = new Map(); + const createGround = engine.ground.createSet.bind(engine.ground); + engine.ground.createSet = (options) => { + const set = createGround(options); + const record = { + id: options.id, + options: normalizeGroundSetOptions(options), + pending: [], + items: [], + commits: 0, + live: true, + }; + const { replace, commit, destroy } = set; + set.replace = (items) => { + replace(items); + record.pending = items; + }; + set.commit = () => { + commit(); + record.items = record.pending; + record.commits++; + }; + set.destroy = () => { + destroy(); + record.live = false; + }; + ground.set(options.id, record); + return set; + }; + const movers = new Map(); + const createMovers = engine.movers.createSet.bind(engine.movers); + engine.movers.createSet = (options) => { + const set = createMovers(options); + const record = { + id: options.id, + options, + pending: [], + count: 0, + instances: [], + commits: 0, + live: true, + }; + const { commit, destroy } = set; + const write = set.set; + const setCount = set.setCount; + set.set = (index, fields) => { + write(index, fields); + record.pending[index] = { ...record.pending[index], ...fields }; + }; + set.setCount = (count) => { + setCount(count); + record.count = count; + }; + set.commit = () => { + commit(); + record.instances = record.pending.slice(0, record.count); + record.commits++; + }; + set.destroy = () => { + destroy(); + record.live = false; + }; + movers.set(options.id, record); + return set; + }; + const live = (records, prefix) => + [...records.values()].filter( + (record) => record.live && record.id.startsWith(prefix), + ); + return { + /** Live ground sets whose id starts with `prefix`, in creation order. */ + groundSets: (prefix = '') => live(ground, prefix), + /** Live mover sets whose id starts with `prefix`, in creation order. */ + moverSets: (prefix = '') => live(movers, prefix), + /** Every corridor item drawn by live sets under `prefix`. */ + corridors: (prefix = '') => + live(ground, prefix).flatMap((record) => record.items), + /** Every shown sprite instance drawn by live sets under `prefix`. */ + spritesShown: (prefix = '') => + live(movers, prefix).flatMap((record) => + record.instances.filter((instance) => instance.show !== false), + ), + }; +} + +/** + * The fake engine as a pinhole camera `altitude` m above WGS84 at + * `lon`/`lat`, turned to `heading` and `pitch` (degrees, negative down), + * with a vertical field of view of `fovY` degrees. + */ +export function cameraEngine({ + lon, + lat, + altitude, + heading = 0, + pitch = -90, + width = 1600, + height = 900, + fovY = 60, +}) { + const engine = createFakeEngine({ width, height, fovy: fovY * RAD }); + engine.fake.setPose({ + longitude: lon * RAD, + latitude: lat * RAD, + height: altitude, + heading: heading * RAD, + pitch: pitch * RAD, + roll: 0, + }); + return engine; +} + +/** Sacramento from 900 m, looking straight down, on a 100 px canvas. */ +export const SACRAMENTO_VIEW = Object.freeze({ + lon: -121.4944, + lat: 38.5816, + altitude: 900, + width: 100, + height: 100, +}); + +/** + * Cesium's flight bookkeeping on an engine: `started` counts framing + * flights, `cancelled` the cancelFlight calls that stopped one, `list` + * holds every flight asked for (`{ sphere, options }`), `land()` completes + * the current one. + */ +function watchFlights(engine) { let flight = null; const flights = { started: 0, cancelled: 0, - /** Every flight asked for, oldest first: `{ sphere, options }`. */ list: [], land() { const current = flight; @@ -151,127 +286,139 @@ export function fakeCesiumViewer({ view = false } = {}) { current?.options.complete?.(); }, }; - const stopFlight = () => { + const stop = () => { const current = flight; flight = null; current?.options.cancel?.(); return Boolean(current); }; - const canvas = Object.assign(new EventTarget(), { - style: {}, - // Keep Cesium's handler on the canvas; there is no real document here. - disableRootEvents: true, - onwheel: null, - ...(view ? { clientWidth: 100, clientHeight: 100 } : {}), - }); - const camera = Object.assign( - view - ? rayCamera({ - lon: -121.4944, - lat: 38.5816, - altitude: 900, - pitch: -90, - width: 100, - height: 100, - }) - : {}, - { - changed: new Cesium.Event(), - moveStart: new Cesium.Event(), - moveEnd: new Cesium.Event(), - flyToBoundingSphere(sphere, options = {}) { - stopFlight(); - flight = { sphere, options }; - flights.list.push(flight); - flights.started++; - }, - cancelFlight() { - if (stopFlight()) flights.cancelled++; - }, - }, - ); + engine.camera.flyToSphere = (sphere, options = {}) => { + stop(); + flight = { sphere, options }; + flights.list.push(flight); + flights.started++; + }; + engine.camera.cancelFlight = () => { + if (stop()) flights.cancelled++; + }; + return flights; +} + +/** + * The Street Level display on the fake engine (`view`: over Sacramento, so + * coverage asks for tiles), with what it drew (watchDrawing), its credits' + * HTML, flight bookkeeping and `frame()` (one engine frame: sprites paint + * on preRender). `engine` replaces the fake one. + */ +export function streetLevelMap({ view = false, engine = null } = {}) { + const globe = + engine ?? (view ? cameraEngine(SACRAMENTO_VIEW) : createFakeEngine()); + const drawing = watchDrawing(globe); + const flights = watchFlights(globe); + const map = createStreetLevelDisplay(globe); return { - credits, + engine: globe, + map, flights, - scene: { - canvas, - primitives: { add: (p) => p, remove() {} }, - groundPrimitives: { add: (p) => p, remove() {} }, - // Enough of a scene for the position marker to clamp to the ground. - frameState: { mode: Cesium.SceneMode.SCENE3D }, - updateHeight: () => () => {}, - getHeight: () => undefined, - }, - camera, - creditDisplay: { - addStaticCredit: (credit) => credits.push(credit), - removeStaticCredit: (credit) => { - // Cesium ignores a credit it does not hold. - const at = credits.indexOf(credit); - if (at !== -1) credits.splice(at, 1); - }, + ...drawing, + credits: () => (globe.fake?.credits ?? []).map((credit) => credit.html), + frame: () => globe.fake.frame(), + /** A click at `position`, with `picked` under it (undefined: the map). */ + click(picked, position = { x: 50, y: 50 }) { + globe.fake.script('pick', picked); + globe.fake.input('click', { position }); + globe.fake.unscript('pick'); }, }; } /** - * A pinhole camera `altitude` m above WGS84, at `heading` (deg from north) and - * `pitch` (deg, negative down); `pickEllipsoid` hits whatever ellipsoid it gets. + * The Cesium engine over a real Cesium camera on a stub scene, with fake + * ground primitives (they draw nothing) and fake billboards' atlas crops: + * `frame()` runs a turn of Cesium's loop, `click(picked)` a left click with + * `scene.pick` answering `picked` (a Cesium pick result or undefined). */ -export function rayCamera({ - lon, - lat, - altitude, - heading = 0, - pitch, - width = 1600, - height = 900, - fovY = 60, - fovX = null, -}) { - const position = Cesium.Cartesian3.fromDegrees(lon, lat, altitude); - const frame = Cesium.Transforms.eastNorthUpToFixedFrame(position); - const h = heading * RAD; - const p = pitch * RAD; - // East-north-up axes of the view: forward, right and up. - const forward = [ - Math.sin(h) * Math.cos(p), - Math.cos(h) * Math.cos(p), - Math.sin(p), - ]; - const right = [Math.cos(h), -Math.sin(h), 0]; - const up = [ - -Math.sin(h) * Math.sin(p), - -Math.cos(h) * Math.sin(p), - Math.cos(p), - ]; - const halfY = Math.tan((fovY / 2) * RAD); - const halfX = - fovX == null ? halfY * (width / height) : Math.tan((fovX / 2) * RAD); +export function cesiumStreetLevelEngine({ width = 100, height = 100 } = {}) { + const handlers = createFakeEventHandlers(); + const primitive = (type) => (options) => { + const value = { + type, + options, + ready: false, + show: true, + destroyed: false, + destroy() { + value.destroyed = true; + }, + isDestroyed: () => value.destroyed, + }; + return value; + }; + const { engine, viewer, frame } = createCesiumMoversEngine({ + engineOptions: { + createEventHandler: handlers.create, + scheduleFrame: (run) => run(), + groundFactories: { fills: primitive('fills'), lines: primitive('lines') }, + }, + }); + viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection(); + viewer.scene.canvas.clientWidth = width; + viewer.scene.canvas.clientHeight = height; + let native; + viewer.scene.pick = () => native; + viewer.scene.drillPick = () => (native ? [native] : []); return { - positionWC: position, - positionCartographic: Cesium.Cartographic.fromDegrees(lon, lat, altitude), - pickEllipsoid(point, ellipsoid = Cesium.Ellipsoid.WGS84) { - const sx = ((2 * point.x) / width - 1) * halfX; - const sy = (1 - (2 * point.y) / height) * halfY; - const local = [0, 1, 2].map( - (i) => forward[i] + sx * right[i] + sy * up[i], - ); - const direction = Cesium.Cartesian3.normalize( - Cesium.Matrix4.multiplyByPointAsVector( - frame, - new Cesium.Cartesian3(...local), - new Cesium.Cartesian3(), - ), - new Cesium.Cartesian3(), - ); - const ray = new Cesium.Ray(position, direction); - const hit = Cesium.IntersectionTests.rayEllipsoid(ray, ellipsoid); - return hit ? Cesium.Ray.getPoint(ray, hit.start) : undefined; + engine, + viewer, + frame, + /** Poses the camera over `lon`/`lat` (degrees), looking straight down. */ + lookDown({ lon, lat, altitude }) { + engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(lon, lat, altitude), + }); + }, + click(picked, position = { x: 50, y: 50 }) { + native = picked; + for (const handler of handlers.handlers) + handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, { + position: new Cesium.Cartesian2(position.x, position.y), + }); + native = undefined; }, }; } +/** + * The Street Level display on the Cesium engine over a stub scene + * (cesiumStreetLevelEngine), shaped as streetLevelMap's: over Sacramento + * when `view`, the terrain at `terrain` m. + */ +export function cesiumStreetLevelMap({ view = true, terrain = 0 } = {}) { + const cesium = cesiumStreetLevelEngine(); + cesium.viewer.scene.globe.getHeight = () => terrain; + if (view) cesium.lookDown(SACRAMENTO_VIEW); + const drawing = watchDrawing(cesium.engine); + const flights = watchFlights(cesium.engine); + const map = createStreetLevelDisplay(cesium.engine); + return { + name: 'the Cesium engine', + engine: cesium.engine, + viewer: cesium.viewer, + map, + flights, + ...drawing, + credits: () => [], + frame: cesium.frame, + click: cesium.click, + }; +} + +/** The display on each engine: the fake one and Cesium's, over Sacramento. */ +export const STREET_LEVEL_MAPS = Object.freeze([ + () => ({ name: 'the fake engine', ...streetLevelMap({ view: true }) }), + () => cesiumStreetLevelMap(), +]); + /** The centre of the tile holding a point at zoom `z`, so a small view is one tile. */ export function tileCentre(lon, lat, z = 14) { const x = lonToTileX(lon, z); @@ -284,79 +431,49 @@ export function tileCentre(lon, lat, z = 14) { }; } -/** A scene collection that remembers what is on the globe. */ -function sceneCollection() { - const items = new Set(); - return { - items, - add(primitive) { - items.add(primitive); - return primitive; - }, - remove(primitive) { - return items.delete(primitive); - }, - }; -} - /** - * A viewer looking straight down on a 0.004° square around `view` - * (`{lon, lat, height}`, mutable), enough for line coverage to attach, - * refresh and draw. Cesium's one-time terrain-height download is skipped. + * The display on a fake engine looking straight down on a 0.004° square + * around `view` (`{lon, lat, height}`, mutable: a change moves the camera), + * enough for line coverage to attach, refresh and draw: its screen rays + * land on that square at any height, as the old stand-in camera's did. + * `moveEnd()` ends a camera move. */ -export function coverageViewer(view, t) { - const initialize = Cesium.GroundPolylinePrimitive.initializeTerrainHeights; - Cesium.GroundPolylinePrimitive.initializeTerrainHeights = () => - Promise.resolve(); - t?.after(() => { - Cesium.GroundPolylinePrimitive.initializeTerrainHeights = initialize; +export function coverageMap(start) { + const engine = createFakeEngine({ width: 100, height: 100 }); + const at = { ...start }; + const pose = () => + engine.fake.setPose({ + longitude: at.lon * RAD, + latitude: at.lat * RAD, + height: at.height, + heading: 0, + pitch: -Math.PI / 2, + roll: 0, + }); + const view = new Proxy(at, { + set(target, key, value) { + target[key] = value; + pose(); + return true; + }, }); - // Node has no WebGL context to report line-width limits. - Cesium.ContextLimits._minimumAliasedLineWidth = 1; - Cesium.ContextLimits._maximumAliasedLineWidth = 10; - const listeners = () => { - const set = new Set(); - return { - set, - addEventListener(listener) { - set.add(listener); - return () => set.delete(listener); - }, - }; + pose(); + engine.camera.pickRay = (point) => { + const origin = geo.cartesian3.fromDegrees(at.lon, at.lat, at.height); + const target = geo.cartesian3.fromDegrees( + at.lon - 0.002 + (point.x / 100) * 0.004, + at.lat + 0.002 - (point.y / 100) * 0.004, + ); + const direction = geo.cartesian3.normalize( + geo.cartesian3.subtract(target, origin, geo.cartesian3.create()), + geo.cartesian3.create(), + ); + return { origin, direction }; }; + const harness = streetLevelMap({ engine }); return { + ...harness, view, - scene: { - canvas: { clientWidth: 100, clientHeight: 100 }, - globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 }, - frameState: { mode: Cesium.SceneMode.SCENE3D }, - updateHeight: () => () => {}, - getHeight: () => undefined, - groundPrimitives: sceneCollection(), - primitives: sceneCollection(), - postRender: listeners(), - preRender: listeners(), - }, - camera: { - changed: new Cesium.Event(), - moveEnd: new Cesium.Event(), - get positionWC() { - return Cesium.Cartesian3.fromDegrees(view.lon, view.lat, view.height); - }, - get positionCartographic() { - return { - longitude: view.lon * RAD, - latitude: view.lat * RAD, - height: view.height, - }; - }, - pickEllipsoid(point) { - return Cesium.Cartesian3.fromDegrees( - view.lon - 0.002 + (point.x / 100) * 0.004, - view.lat + 0.002 - (point.y / 100) * 0.004, - ); - }, - computeViewRectangle: () => undefined, - }, + moveEnd: () => engine.fake.emit('camera.moveEnd'), }; }