From b98cbeebf2f17c970004259cd90c0bd019fcba24 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 04:13:26 +0400 Subject: [PATCH] Keep street imagery and picked coverage across Cesium and Navara --- scripts/package-boundaries.json | 1 + scripts/qa-street-level-engine-switch.mjs | 171 ++++++++++++++++ scripts/qa-street-level.mjs | 4 +- src/app/constructCatalog.js | 5 +- src/app/layers/streetLevel.js | 3 +- src/app/navaraLayers.test.mjs | 1 + src/app/navaraMode.js | 4 + src/layers/streetLevel/cameraFraming.js | 10 +- src/layers/streetLevel/credits.js | 10 +- src/layers/streetLevel/groundClick.js | 6 +- src/layers/streetLevel/index.js | 79 ++++++-- src/layers/streetLevel/lineCoverage.js | 30 ++- src/layers/streetLevel/marker.js | 10 +- .../streetLevel/providers/kartaview/index.js | 10 + .../streetLevel/providers/mapillary/index.js | 10 + .../streetLevel/providers/panoramax/index.js | 12 +- src/layers/streetLevel/render/navara.js | 183 ++++++++++++++++++ src/layers/streetLevel/selection.js | 9 +- src/layers/streetLevel/sequenceCones.js | 20 +- src/layers/streetLevel/view.js | 45 +++-- 20 files changed, 561 insertions(+), 62 deletions(-) create mode 100644 scripts/qa-street-level-engine-switch.mjs create mode 100644 src/layers/streetLevel/render/navara.js diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index ca1b88e45..1bbb0614a 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3680,6 +3680,7 @@ "src/layers/submarineCables/render/navara.js", "src/layers/directions/render/navara.js", "src/layers/traffic/render/navara.js", + "src/layers/streetLevel/render/navara.js", "src/layers/shared/render/navaraStory.js", "src/layers/bhoteKoshi/render/navara.js", "src/layers/weather/render/navara.js", diff --git a/scripts/qa-street-level-engine-switch.mjs b/scripts/qa-street-level-engine-switch.mjs new file mode 100644 index 000000000..fd7650ca8 --- /dev/null +++ b/scripts/qa-street-level-engine-switch.mjs @@ -0,0 +1,171 @@ +import assert from 'node:assert/strict'; +import puppeteer from 'puppeteer'; +import { blockPaidQaMaps } from './qa-map-policy.mjs'; +import { serveFixtures, newFixture } from './qa-street-level.mjs'; +import { KV_LINE, KV_SEQUENCE_ID } from './fixtures/street-level/kartaView.mjs'; +import { + PNX_PARK, + PNX_SEQUENCES, + panoramaxPictures, +} from './fixtures/street-level/panoramax.mjs'; +const panorama = process.argv.includes('--panoramax'); +const provider = panorama ? 'panoramax' : 'kartaview'; +const base = new URL(process.argv[2]); +if ( + !['localhost', '127.0.0.1'].includes(base.hostname) || + ['', '4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes( + base.port, + ) +) + throw Error('Keyless local preview required'); +base.search = '?engine=navara&welcome=0'; +const at = panorama + ? PNX_PARK + : { lat: 38.5831, lon: KV_LINE.lon, height: 850 }; +base.hash = `v=2&lat=${at.lat}&lon=${at.lon}&alt=${at.height}&heading=0&pitch=-90&roll=0&map=sentinel2&l=`; +const browser = await puppeteer.launch({ + headless: true, + args: ['--use-angle=metal', '--enable-gpu'], +}); +try { + const p = await browser.newPage(); + await blockPaidQaMaps(p); + await p.setViewport({ width: 1280, height: 900 }); + const errors = [], + fixture = newFixture({ configured: true }); + p.on('pageerror', (e) => { + errors.push(e.message); + console.error(e.message.split('?')[0]); + }); + p.on('console', (m) => { + if (/shader|registerMeshPicker/i.test(m.text()) && m.type() === 'error') + errors.push(m.text()); + }); + await serveFixtures(p, fixture); + let photoRequests = 0; + p.on('request', (req) => { + if ( + /\.jpg(?:$|\?)/i.test(new URL(req.url()).pathname) && + /openstreetcam|kartaview|panoramax/.test(new URL(req.url()).hostname) + ) + photoRequests++; + }); + await p.goto(base.href); + const ready = async (k) => { + await p.waitForFunction( + (kind) => + window.__godsEyeView?.engine?.kind === kind && + !document.querySelector('#globe-engine')?.disabled && + !document.querySelector('.engine-switch-frame') && + document.querySelector('#loading-screen.hidden'), + { timeout: 90000 }, + k, + ); + await p.evaluate( + () => window.__godsEyeView.styleManager.initialRestorePromise, + ); + }; + await ready('navara'); + await p.evaluate(async (provider) => { + const { dataManager, tileset } = window.__godsEyeView; + if (tileset || window.__GOOGLE_MAPS_API_KEY__) + throw Error('Expected keyless preview'); + window.qaStreet = dataManager.layers.get('street-level').module; + await dataManager.setEnabled('street-level', true, { origin: 'user' }); + window.qaStreet.setProviderEnabled(provider, true); + }, provider); + const until = async (fn) => { + try { + await p.waitForFunction(fn, { timeout: 20000 }); + } catch (e) { + console.log(await p.evaluate(() => window.qaStreet.getUIState())); + throw e; + } + }; + await until(() => window.qaStreet.getUIState().coverage.count > 0); + // The fixture drive passes under the middle of the canvas. Click its actual picked line. + const xy = await p.evaluate( + (prefix) => { + const { engine } = window.__godsEyeView, + canvas = engine.host.canvas, + rect = canvas.getBoundingClientRect(); + for (let dy = -30; dy <= 30; dy += 3) + for (let dx = -40; dx <= 40; dx++) { + const at = { x: rect.width / 2 + dx, y: rect.height / 2 + dy }, + pick = engine.picker.pick(at); + if (typeof pick?.id === 'string' && pick.id.startsWith(prefix)) + return { x: at.x + rect.x, y: at.y + rect.y, id: pick.id }; + } + return null; + }, + panorama ? 'pnx:seq:' : 'kv:line:', + ); + assert.ok(xy, 'native ground line was pickable'); + console.log('pick', xy); + await p.mouse.click(xy.x, xy.y); + await until( + () => + window.qaStreet.getUIState().street.open && + window.qaStreet.getUIState().sequence.images > 0 && + (document.querySelector('#sl-viewer img')?.naturalWidth > 0 || + document.querySelector('#sl-viewer canvas:not([hidden])')?.width > 0), + ); + if (panorama) { + const expected = panoramaxPictures().filter( + (p) => p.sequenceId === xy.id.slice('pnx:seq:'.length), + ).length; + await p.waitForFunction( + (n) => window.qaStreet.getUIState().sequence.images === n, + { timeout: 20000 }, + expected, + ); + } + const first = await p.evaluate(() => { + window.streetPhoto = + document.querySelector('#sl-viewer canvas:not([hidden])') ?? + document.querySelector('#sl-viewer img'); + return window.qaStreet.getUIState(); + }); + assert.ok( + (panorama ? PNX_SEQUENCES : [KV_SEQUENCE_ID]).includes( + first.street.sequenceId, + ), + ); + const reads = photoRequests; + for (const kind of ['cesium', 'navara']) { + await p.select('#globe-engine', kind); + await ready(kind); + const state = await p.evaluate(() => ({ + model: + window.__godsEyeView.dataManager.layers.get('street-level').module === + window.qaStreet, + samePhoto: + document.querySelector('#sl-viewer')?.contains(window.streetPhoto) && + window.streetPhoto.isConnected, + ui: window.qaStreet.getUIState(), + })); + console.log( + kind, + state.model, + state.samePhoto, + state.ui.street.imageId, + state.ui.sequence.images, + state.ui.coverage.count, + ); + assert.equal(state.model, true); + assert.equal(state.samePhoto, true); + assert.ok(state.ui.street.open); + assert.equal(state.ui.street.imageId, first.street.imageId); + assert.equal(state.ui.sequence.images, first.sequence.images); + assert.deepEqual(state.ui.filter, first.filter); + assert.ok(state.ui.coverage.count > 0); + } + assert.equal(photoRequests, reads); + assert.deepEqual(errors, []); + assert.deepEqual(fixture.unknown, []); + console.log( + 'PASS native line pick, same mounted photo and sequence across two switches', + ); +} finally { + await browser.close(); +} diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 9264f0552..64aa67077 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -125,7 +125,7 @@ const KEYLESS_HOSTS = [ * `fixture`, which steps may change mid-run. `configured: undefined` lets the * server's real status route answer. */ -async function serveFixtures(page, fixture) { +export async function serveFixtures(page, fixture) { // CDP Fetch on just these URLs: page-wide interception stalls Cesium workers. const cdp = await page.createCDPSession(); await cdp.send('Fetch.enable', { @@ -206,7 +206,7 @@ async function serveFixtures(page, fixture) { }); } -function newFixture(overrides = {}) { +export function newFixture(overrides = {}) { return { configured: undefined, rejected: false, diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 7fce273cd..24a0baad5 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -224,11 +224,12 @@ export function createApplicationCatalog({ [ 'street-level', () => - createApplicationStreetLevel({ + sessionModel('street-level', () => createApplicationStreetLevel({ surface, + nativeRenderer:engine=>rendererFor('street-level',engine), sources: { mapillary: sources.mapillary }, providers: streetLevelProviders, - }), + })), ], ['satellites', satellites], [ diff --git a/src/app/layers/streetLevel.js b/src/app/layers/streetLevel.js index 268216eaa..d6b5c273c 100644 --- a/src/app/layers/streetLevel.js +++ b/src/app/layers/streetLevel.js @@ -33,10 +33,11 @@ function defaultProviders(sources) { */ export function createApplicationStreetLevel({ sources = {}, + nativeRenderer, providers = defaultProviders(sources), }) { return createStreetLevelLayer({ providers, - services: { sprites, picking, input, scenePick, render }, + services: { sprites, picking, input, scenePick, render, nativeRenderer }, }); } diff --git a/src/app/navaraLayers.test.mjs b/src/app/navaraLayers.test.mjs index bac376fd5..a1d894e77 100644 --- a/src/app/navaraLayers.test.mjs +++ b/src/app/navaraLayers.test.mjs @@ -54,6 +54,7 @@ test('the Navara mode registers migrated renderers once', async () => { 'gnss-integrity', 's2-ghostmaps', 'alpr-cameras', + 'street-level', 'satellites', 'rocket-launches', 'traffic', diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 09a66aa17..13193d201 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -126,6 +126,10 @@ export async function registerNavaraLayerRenderers() { const { drawStorySource } = await import('../layers/shared/render/navaraStory.js'); return (engine, { data }) => drawStorySource(engine, navaraViews.get(engine), data); }, + 'street-level': async () => { + const { createStreetMap } = await import('../layers/streetLevel/render/navara.js'); + return engine => createStreetMap(engine, navaraViews.get(engine)); + }, traffic: async () => { const { createTrafficNavaraRenderer } = await import('../layers/traffic/render/navara.js'); return engine => createTrafficNavaraRenderer(engine, navaraViews.get(engine)); diff --git a/src/layers/streetLevel/cameraFraming.js b/src/layers/streetLevel/cameraFraming.js index c6bc9327a..572eea5e3 100644 --- a/src/layers/streetLevel/cameraFraming.js +++ b/src/layers/streetLevel/cameraFraming.js @@ -29,7 +29,8 @@ export function createCameraFraming({ state }) { function groundHeightAt(lon, lat, fallback) { const scene = state.viewer?.scene; const carto = Cesium.Cartographic.fromDegrees(lon, lat); - let height = sampledSurfaceHeight(scene, carto); + 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); if (!plausibleSurfaceHeight(height)) height = fallback; return plausibleSurfaceHeight(height) ? height : 0; @@ -53,6 +54,13 @@ 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), diff --git a/src/layers/streetLevel/credits.js b/src/layers/streetLevel/credits.js index 25f584a39..e8189468d 100644 --- a/src/layers/streetLevel/credits.js +++ b/src/layers/streetLevel/credits.js @@ -9,7 +9,12 @@ export function createCredits() { const shown = new Map(); function show(viewer, def) { - if (shown.has(def.id) || !viewer?.creditDisplay) return; + 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; try { const credit = new Cesium.Credit(def.credit.html, true); viewer.creditDisplay.addStaticCredit(credit); @@ -23,7 +28,8 @@ export function createCredits() { const credit = shown.get(def.id); if (!credit) return; try { - viewer?.creditDisplay?.removeStaticCredit?.(credit); + if (typeof credit === 'function') credit(); + else viewer?.creditDisplay?.removeStaticCredit?.(credit); } catch { /* credit display already torn down */ } diff --git a/src/layers/streetLevel/groundClick.js b/src/layers/streetLevel/groundClick.js index 6498c800b..7c5e75ae2 100644 --- a/src/layers/streetLevel/groundClick.js +++ b/src/layers/streetLevel/groundClick.js @@ -1,10 +1,10 @@ import * as geo from '../../geo/index.js'; import { GROUND_CLICK_MAX_HEIGHT_M } from './policy.js'; -import { cameraHeightAboveGround } from './view.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 (!viewer?.camera?.positionCartographic) return false; + if (!cameraLocation(viewer)) return false; return cameraHeightAboveGround(viewer) <= GROUND_CLICK_MAX_HEIGHT_M; } @@ -32,7 +32,7 @@ export function createGroundClick({ state, openNearest, isAvailable }) { ) .map((entry) => entry.def.id); if (!providerIds.length) return false; - const position = scenePick.pickGroundPosition(state.viewer, screenPosition); + const position = state.viewer.engine ? state.viewer.engine.picker.pickGlobe(screenPosition) : scenePick.pickGroundPosition(state.viewer, screenPosition); const carto = position && geo.cartographic.fromCartesian(position); if (!carto) return false; return openNearest( diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index b074a3bf3..926db23db 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -71,6 +71,7 @@ export function createStreetLevelLayer({ isAvailable: (entry) => providerAvailable(entry), }); + let waitingForView = false; let notifyQueued = false; function notify() { if (notifyQueued) return; @@ -96,6 +97,7 @@ export function createStreetLevelLayer({ getFilter: () => resolveFilter(state.filter), /** The layer and this provider's switch are both on. */ isActive: () => state.enabled && entry.on, + waitingForView: () => waitingForView, notify, actions: { /** `{frame: false}` opens it without moving the camera. */ @@ -186,7 +188,7 @@ export function createStreetLevelLayer({ cancelGroundRetry(); const ready = state.enabled && groundClickHeightOk(state.viewer); if ( - state.enabled && + state.enabled && state.viewer && !ready && groundRetries < GROUND_RETRY_LIMIT && groundUnderCamera(state.viewer) === null @@ -257,14 +259,12 @@ export function createStreetLevelLayer({ /** Ground under the screen centre; null for sky, undefined when it cannot aim. */ function screenCentreGround() { - const canvas = state.viewer?.scene?.canvas; + const canvas = state.viewer?.engine?.host.canvas ?? state.viewer?.scene?.canvas; const pick = state.services.scenePick?.pickGroundPosition; if (!pick || !canvas?.clientWidth || !canvas?.clientHeight) return undefined; - const position = pick(state.viewer, { - x: canvas.clientWidth / 2, - y: canvas.clientHeight / 2, - }); + 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); const carto = position && geo.cartographic.fromCartesian(position); return carto ? { @@ -477,24 +477,66 @@ export function createStreetLevelLayer({ }; }, - init(viewer) { - state.viewer = viewer; - parts.marker.ensure(viewer); - parts.marker.setVisible(false); + init(viewer, options) { + this.attachRenderer(viewer,options); + for (const entry of state.providers.values()) checkStatus(entry); + }, + attachRenderer(viewer, {engine = null, waitForCameraRestore = false} = {}) { + const map = viewer ?? services.nativeRenderer?.(engine); + state.viewer = map; + waitingForView = waitForCameraRestore; + if (!map) return; + parts.marker.ensure(map); + parts.marker.setVisible(state.enabled); for (const entry of state.providers.values()) { - entry.instance.init(viewer); - checkStatus(entry); + entry.instance.init(map); + if(state.enabled && entry.on){ + entry.instance.activate(map); + entry.instance.repaintMap?.(); + if(providerAvailable(entry)) parts.credits.show(map,entry.def); + } } - console.log('[Data:StreetLevel] Initialized'); + if (state.enabled) { + parts.selection.install(map); + watchCamera(map); + parts.marker.set(state.street.position,state.street.bearing,state.providers.get(state.street.providerId)?.def.marker); + remeasureGround(); + } + }, + resumeViewport() { + if (!waitingForView) return; + waitingForView=false; + if(state.enabled && state.viewer) { + for(const entry of activeEntries())entry.instance.refreshCoverage?.(); + remeasureGround(); + } + }, + detachRenderer() { + const map=state.viewer; + if(!map)return; + waitingForView=true; + removeCameraListener?.();removeCameraListener=null; + cancelGroundRetry(); + parts.framing.cancel(); + parts.selection.uninstall(); + for(const entry of state.providers.values()){ + if(entry.instance.detachMap)entry.instance.detachMap(); + else if(entry.on)entry.instance.deactivate(); + } + parts.marker.destroy(map); + parts.credits.hideAll(map); + unsubscribeMapStack?.(); + unsubscribeMapStack=null; + mapStack=null; + state.viewer=null; }, - enable(viewer) { state.enabled = true; - state.viewer = viewer; + if (viewer) state.viewer = viewer; parts.marker.setVisible(true); - parts.selection.install(viewer); + parts.selection.install(state.viewer); for (const entry of activeEntries()) activate(entry); - watchCamera(viewer); + watchCamera(state.viewer); remeasureGround(); notify(); }, @@ -518,7 +560,8 @@ export function createStreetLevelLayer({ return state.enabled; }, - destroy(viewer = state.viewer) { + destroy() { + const viewer = state.viewer; layer.disable(); for (const entry of state.providers.values()) entry.instance.destroy(viewer); diff --git a/src/layers/streetLevel/lineCoverage.js b/src/layers/streetLevel/lineCoverage.js index bab1af46d..c3a8b9c71 100644 --- a/src/layers/streetLevel/lineCoverage.js +++ b/src/layers/streetLevel/lineCoverage.js @@ -166,6 +166,7 @@ export function createLineCoverage({ } function ensureTerrainReady() { + if (state.viewer?.engine) return; return (state.coverage.terrainReady ||= Cesium.GroundPolylinePrimitive.initializeTerrainHeights()); } @@ -232,10 +233,11 @@ export function createLineCoverage({ function highlight(sequenceId = state.sequence.selectedId) { const scene = state.viewer?.scene; if (state.coverage.highlight) { - scene?.groundPrimitives?.remove(state.coverage.highlight); + if (state.viewer?.engine) state.coverage.highlight.destroy(); + else scene?.groundPrimitives?.remove(state.coverage.highlight); state.coverage.highlight = null; } - if (!sequenceId || !scene) return; + if (!sequenceId || (!state.viewer?.engine && !scene)) 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(); @@ -244,6 +246,11 @@ 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( [{ id: sequenceId, parts }], Cesium.Color.fromCssColorString(colors.selected), @@ -259,10 +266,15 @@ export function createLineCoverage({ function attachPrimitive(entry) { const scene = state.viewer?.scene; - if (!scene) return; + if (!scene && !state.viewer?.engine) 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); @@ -272,7 +284,8 @@ export function createLineCoverage({ const scene = state.viewer?.scene; for (const primitive of entry.primitives || []) { try { - scene?.groundPrimitives?.remove(primitive); + if (state.viewer?.engine) primitive.destroy(); + else scene?.groundPrimitives?.remove(primitive); } catch { /* already gone */ } @@ -396,7 +409,7 @@ export function createLineCoverage({ */ function wantedTiles(viewer) { const ground = groundUnderCamera(viewer); - const cameraHeight = viewer.camera?.positionCartographic?.height; + const cameraHeight = viewer.engine?.camera.pose().cartographic.height ?? viewer.camera?.positionCartographic?.height; const height = Number.isFinite(cameraHeight) ? cameraHeight - (ground ?? 0) : null; @@ -425,7 +438,7 @@ export function createLineCoverage({ /** Recompute the tile set for the current camera and reconcile primitives. */ function refresh() { const viewer = state.viewer; - if (!viewer || !state.context.isActive() || state.coverage.blocked) return; + if (!viewer || state.context.waitingForView?.() || !state.context.isActive() || state.coverage.blocked) return; const { zoom, tiles, hint } = wantedTiles(viewer); if (hint) { state.coverage.hint = hint; @@ -534,6 +547,11 @@ export function createLineCoverage({ return { attach, detach, + releaseGraphics() { + detach(); + highlight(null); + for(const entry of state.coverage.tiles.values()) detachPrimitive(entry); + }, refresh, clear, unblock, diff --git a/src/layers/streetLevel/marker.js b/src/layers/streetLevel/marker.js index a61036b84..1ab125a44 100644 --- a/src/layers/streetLevel/marker.js +++ b/src/layers/streetLevel/marker.js @@ -38,6 +38,10 @@ export function createMarker({ state }) { 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, }); @@ -80,7 +84,8 @@ export function createMarker({ state }) { state.marker.billboard.position = cartesian; } state.marker.billboard.rotation = -Cesium.Math.toRadians(bearing || 0); - horizon.update([state.marker.billboard]); + if (state.viewer?.engine) collection.changed(); + else horizon.update([state.marker.billboard]); requestRender(); } @@ -94,7 +99,8 @@ export function createMarker({ state }) { const collection = state.marker.collection; if (!collection) return; sprites?.unregisterSpriteCollection?.(SPRITE_ID, collection); - viewer?.scene?.primitives?.remove(collection); + if (viewer?.engine) collection.destroy(); + else viewer?.scene?.primitives?.remove(collection); state.marker.collection = null; state.marker.billboard = null; } diff --git a/src/layers/streetLevel/providers/kartaview/index.js b/src/layers/streetLevel/providers/kartaview/index.js index 6e75f967a..812897cea 100644 --- a/src/layers/streetLevel/providers/kartaview/index.js +++ b/src/layers/streetLevel/providers/kartaview/index.js @@ -457,6 +457,16 @@ export function createKartaViewProvider({ coverage.attach(cesiumViewer); }, + refreshCoverage: () => coverage.refresh(), + detachMap() { + coverage.releaseGraphics(); + cones.releaseCollection(state.viewer); + state.viewer = null; + }, + repaintMap() { + coverage.rebuild(); + cones.rerender(); + }, deactivate() { coverage.detach(); coverage.clear(); diff --git a/src/layers/streetLevel/providers/mapillary/index.js b/src/layers/streetLevel/providers/mapillary/index.js index 928922195..d3666dd31 100644 --- a/src/layers/streetLevel/providers/mapillary/index.js +++ b/src/layers/streetLevel/providers/mapillary/index.js @@ -147,6 +147,16 @@ export function createMapillaryProvider({ source, photoViewer = null }) { }, /** Clear lines and cones, forget refusals. */ + refreshCoverage: () => parts.coverage.refresh(), + detachMap() { + parts.coverage.releaseGraphics(); + parts.sequences.releaseCollection(state.viewer); + state.viewer = null; + }, + repaintMap() { + parts.coverage.rebuild(); + parts.sequences.rerender(); + }, deactivate() { parts.coverage.detach(); parts.coverage.clear(); diff --git a/src/layers/streetLevel/providers/panoramax/index.js b/src/layers/streetLevel/providers/panoramax/index.js index e855c9c72..368d3f751 100644 --- a/src/layers/streetLevel/providers/panoramax/index.js +++ b/src/layers/streetLevel/providers/panoramax/index.js @@ -256,7 +256,7 @@ export function createPanoramaxProvider({ * from high up the click may land tens of metres off the line. */ function lineClickRadius() { - const height = state.viewer?.camera?.positionCartographic?.height; + const height = state.viewer?.engine?.camera.pose().cartographic.height ?? state.viewer?.camera?.positionCartographic?.height; if (!Number.isFinite(height)) return NEAREST_RADIUS_M; return Math.min( LINE_CLICK_RADIUS_MAX_M, @@ -453,6 +453,16 @@ export function createPanoramaxProvider({ coverage.attach(cesiumViewer); }, + refreshCoverage: () => coverage.refresh(), + detachMap() { + coverage.releaseGraphics(); + cones.releaseCollection(state.viewer); + state.viewer = null; + }, + repaintMap() { + coverage.rebuild(); + cones.rerender(); + }, deactivate() { coverage.detach(); coverage.clear(); diff --git a/src/layers/streetLevel/render/navara.js b/src/layers/streetLevel/render/navara.js new file mode 100644 index 000000000..695aabea5 --- /dev/null +++ b/src/layers/streetLevel/render/navara.js @@ -0,0 +1,183 @@ +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 c76c3d9f5..1d98ebbed 100644 --- a/src/layers/streetLevel/selection.js +++ b/src/layers/streetLevel/selection.js @@ -10,7 +10,7 @@ export function createSelection({ state, parts }) { const viewer = state.viewer; if (!viewer || !state.enabled) return; if (input?.isPointerFree && !input.isPointerFree()) return; - const picked = viewer.scene.pick( + const picked = viewer.engine ? click.picked : viewer.scene.pick( click.position, CLICK_PICK_WINDOW_PX, CLICK_PICK_WINDOW_PX, @@ -33,7 +33,7 @@ export function createSelection({ state, parts }) { /** Lon/lat of the ground under a screen point, or null (sky, no picker). */ function groundPointAt(viewer, screenPosition) { - const position = state.services.scenePick?.pickGroundPosition?.( + const position = viewer.engine ? viewer.engine.picker.pickGlobe(screenPosition) : state.services.scenePick?.pickGroundPosition?.( viewer, screenPosition, ); @@ -68,6 +68,10 @@ export function createSelection({ state, parts }) { 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, ); @@ -75,6 +79,7 @@ export function createSelection({ state, parts }) { onClick, Cesium.ScreenSpaceEventType.LEFT_CLICK, ); + } document.addEventListener('keydown', onKeyDown); picking?.registerPickOwner?.(STREET_LEVEL_LAYER_ID, parts.router.ownsPick); } diff --git a/src/layers/streetLevel/sequenceCones.js b/src/layers/streetLevel/sequenceCones.js index b6c068d49..d4c2b7c86 100644 --- a/src/layers/streetLevel/sequenceCones.js +++ b/src/layers/streetLevel/sequenceCones.js @@ -82,6 +82,10 @@ export function createSequenceCones({ 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, }); @@ -128,7 +132,8 @@ export function createSequenceCones({ }); cones.push(billboard); } - horizon.update(); + if (state.viewer?.engine) collection.changed(); + else horizon.update(); requestRender(); } @@ -137,7 +142,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) return; + if (!sequenceId || (!state.viewer && !state.context.waitingForView?.())) return; // Already on screen, or already on its way. if ( !force && @@ -206,22 +211,27 @@ export function createSequenceCones({ requestRender(); } - function destroy(viewer) { - state.sequence.abort?.abort(); + function releaseCollection(viewer) { horizon.stop(); cones = []; const collection = state.sequence.collection; if (collection) { sprites?.unregisterSpriteCollection?.(spriteId, collection); - viewer?.scene?.primitives?.remove(collection); + if (viewer?.engine) collection.destroy(); + else viewer?.scene?.primitives?.remove(collection); state.sequence.collection = null; } + } + function destroy(viewer) { + state.sequence.abort?.abort(); + releaseCollection(viewer); state.sequence.images = []; state.sequence.selectedId = null; } return { ensureCollections, + releaseCollection, select, clearSelection, rerender, diff --git a/src/layers/streetLevel/view.js b/src/layers/streetLevel/view.js index e14cb6493..b20ca72a0 100644 --- a/src/layers/streetLevel/view.js +++ b/src/layers/streetLevel/view.js @@ -1,6 +1,9 @@ import * as Cesium from 'cesium'; import { wrapLon } from './tileMath.js'; +export const cameraLocation = (view) => + view?.engine?.camera.pose().cartographic ?? view?.camera?.positionCartographic; +const canvasOf = (view) => view?.engine?.host.canvas ?? view?.scene?.canvas; /** 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; @@ -36,8 +39,13 @@ export function sampledSurfaceHeight(scene, carto) { * the camera. */ export function groundUnderCamera(viewer) { - const carto = viewer?.camera?.positionCartographic; + 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; + } if (!carto || !scene) return null; const globe = scene.globe; if (globe && globe.show !== false) { @@ -59,26 +67,26 @@ function raisedEllipsoid(ellipsoid, 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) { +function groundHit(camera, point, ellipsoid, maxRange, engine) { let cartesian = null; try { - cartesian = camera.pickEllipsoid(point, ellipsoid); + cartesian = engine ? engine.picker.pickGlobe(point) : camera.pickEllipsoid(point, ellipsoid); } catch { cartesian = null; } if (!cartesian) return null; if ( Number.isFinite(maxRange) && - camera.positionWC && - Cesium.Cartesian3.distance(cartesian, camera.positionWC) > maxRange + (engine?.camera.livePosition || camera.positionWC) && + Cesium.Cartesian3.distance(cartesian, engine?.camera.livePosition ?? camera.positionWC) > maxRange ) return null; - return Cesium.Cartographic.fromCartesian(cartesian, ellipsoid) || null; + return Cesium.Cartographic.fromCartesian(cartesian, engine ? Cesium.Ellipsoid.WGS84 : ellipsoid) || null; } /** Camera height (m) above the ground under it. */ export function cameraHeightAboveGround(viewer) { - const carto = viewer?.camera?.positionCartographic; + const carto = cameraLocation(viewer); if (!carto) return null; return carto.height - (groundUnderCamera(viewer) ?? 0); } @@ -97,13 +105,14 @@ export function visibleBbox( ) { const scene = viewer?.scene; const camera = viewer?.camera; - if (!scene || !camera) return null; - const width = scene.canvas?.clientWidth || scene.canvas?.width || 0; - const height = scene.canvas?.clientHeight || scene.canvas?.height || 0; + 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 hits = []; if (width > 0 && height > 0) { const ellipsoid = raisedEllipsoid( - scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84, + scene?.globe?.ellipsoid || Cesium.Ellipsoid.WGS84, groundHeight, ); const point = new Cesium.Cartesian2(); @@ -111,7 +120,7 @@ export function visibleBbox( 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, ellipsoid, maxRange); + const carto = groundHit(camera, point, ellipsoid, maxRange, viewer.engine); if (carto) hits.push(carto); } } @@ -153,7 +162,7 @@ export function visibleBbox( /** The point on the ground under the camera, as {lon, lat} degrees. */ export function cameraNadir(viewer) { - const carto = viewer?.camera?.positionCartographic; + const carto = cameraLocation(viewer); if (!carto) return null; return { lon: Cesium.Math.toDegrees(carto.longitude), @@ -171,17 +180,19 @@ export function viewFocus( ) { const nadir = cameraNadir(viewer); const scene = viewer?.scene; - const width = scene?.canvas?.clientWidth || scene?.canvas?.width || 0; - const height = scene?.canvas?.clientHeight || scene?.canvas?.height || 0; + const canvas = canvasOf(viewer); + const width = canvas?.clientWidth || canvas?.width || 0; + const height = canvas?.clientHeight || canvas?.height || 0; 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, + scene?.globe?.ellipsoid || Cesium.Ellipsoid.WGS84, groundHeight, ), maxRange, + viewer.engine, ); if (!hit) return nadir; const ahead = { @@ -275,7 +286,7 @@ export function viewCentre(viewer) { if (lon > 180) lon -= 360; return { lat: (bbox[1] + bbox[3]) / 2, lon }; } - const carto = viewer.camera?.positionCartographic; + const carto = cameraLocation(viewer); if (!carto) return null; return { lat: Cesium.Math.toDegrees(carto.latitude),