From 605b9490f318cc5521fc6a99a1429a145de99943 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 03:44:07 +0400 Subject: [PATCH] Move Directions routes and active camera flights between globes --- scripts/package-boundaries.json | 1 + scripts/qa-directions-engine-switch.mjs | 165 +++++++++++++++++++ src/app/constructCatalog.js | 2 +- src/app/layers/directions.js | 6 +- src/app/navaraLayers.test.mjs | 1 + src/app/navaraMode.js | 4 + src/cameraHandoff.test.mjs | 2 +- src/cameraVerbs.js | 70 +++++++- src/data/directions.js | 4 +- src/layers/directions/index.js | 165 +++++++++++++------ src/layers/directions/render/navara.js | 209 ++++++++++++++++++++++++ src/main.js | 2 + 12 files changed, 568 insertions(+), 63 deletions(-) create mode 100644 scripts/qa-directions-engine-switch.mjs create mode 100644 src/layers/directions/render/navara.js diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index a76682116..696145e57 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3678,6 +3678,7 @@ "src/layers/infrastructure/render/navara.js", "src/layers/shared/render/navaraStems.js", "src/layers/submarineCables/render/navara.js", + "src/layers/directions/render/navara.js", "src/layers/weather/render/navara.js", "src/layers/weather/render/navaraSurface.js", "src/layers/wind/render/navara.js", diff --git a/scripts/qa-directions-engine-switch.mjs b/scripts/qa-directions-engine-switch.mjs new file mode 100644 index 000000000..6fb95e802 --- /dev/null +++ b/scripts/qa-directions-engine-switch.mjs @@ -0,0 +1,165 @@ +import puppeteer from 'puppeteer'; +import assert from 'node:assert/strict'; +import { blockPaidQaMaps } from './qa-map-policy.mjs'; + +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 new Error('Supply an unreserved local keyless preview URL'); +base.search = '?engine=navara&welcome=0&look=cinematic'; +base.hash = + 'v=2&lat=55.3241&lon=10.7995&alt=25000&heading=0&pitch=-90&roll=0&map=sentinel2&l='; +const b = await puppeteer.launch({ + headless: true, + args: ['--use-angle=metal', '--enable-gpu'], +}); +try { + const p = await b.newPage(); + await blockPaidQaMaps(p); + await p.setViewport({ width: 1024, height: 768 }); + const errors = []; + p.on('pageerror', (e) => { + errors.push(e.message); + console.log('error', e.message.split('?')[0]); + }); + let routeReads = 0, + release; + await p.setRequestInterception(true); + p.on('request', async (req) => { + const u = new URL(req.url()); + const json = (v) => + req.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(v), + }); + if (u.pathname === '/api/terrain/heights') { + const cells = (u.searchParams.get('points') ?? '').split(';'); + return json({ results: cells.map(() => ({ ellipsoid: 34 })) }); + } + if (u.pathname !== '/api/route') return req.continue(); + routeReads++; + const [a, b] = u.searchParams + .get('coords') + .split(';') + .map((p) => p.split(',').map(Number)); + const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; + if (routeReads === 1) + await new Promise((resolve) => { + release = resolve; + }); + return json({ + ok: true, + geometry: [a, mid, b], + distanceM: 9000, + durationS: 800, + steps: [a, mid, b].map(([lon, lat], i) => ({ + lon, + lat, + instruction: 'Route step ' + i, + distanceM: 3000, + durationS: 120, + })), + }); + }); + await p.goto(base.href); + const ready = async (kind) => { + await p.waitForFunction( + (k) => + window.__godsEyeView?.engine.kind === k && + !document.querySelector('#globe-engine')?.disabled && + !document.querySelector('.engine-switch-frame') && + document.querySelector('#loading-screen.hidden'), + { timeout: 90000 }, + kind, + ); + await p.evaluate( + () => window.__godsEyeView.styleManager.initialRestorePromise, + ); + }; + await ready('navara'); + await p.evaluate(async () => { + const { tileset, dataManager } = window.__godsEyeView; + if (tileset || window.__GOOGLE_MAPS_API_KEY__) + throw Error('Keyless preview required'); + window.qaRoute = dataManager.layers.get('directions').module; + await dataManager.setEnabled('directions', true, { origin: 'user' }); + }); + for (const [letter, x] of [ + ['a', 480], + ['b', 645], + ]) { + await p.evaluate((l) => window.qaRoute.setParams({ arm: l }), letter); + await p.mouse.click(x, 440); + await new Promise((resolve) => setTimeout(resolve, 450)); + } + assert.equal(routeReads, 1); + await p.select('#globe-engine', 'cesium'); + await ready('cesium'); + release(); + await p.waitForFunction(() => window.qaRoute.getStats().count === 3); + await p.waitForFunction(() => { + window.qaRoute.setParams({ step: 1 }); + return window.qaRoute.getRowControls().list.items[1]?.active; + }); + const transfer = async (kind) => { + await p.select('#globe-engine', kind); + await ready(kind); + assert.equal( + await p.evaluate( + () => + window.__godsEyeView.dataManager.layers.get('directions').module === + window.qaRoute, + ), + true, + ); + assert.equal(routeReads, 1); + }; + await transfer('navara'); + assert.equal( + await p.evaluate( + () => window.qaRoute.getRowControls().list.items[1].active, + ), + true, + ); + await p.evaluate(() => window.qaRoute.setParams({ fly: true })); + await p.waitForFunction( + () => + window.qaRoute.getRowControls().chips.find((c) => c.id === 'fly')?.active, + ); + const pos = () => + p.evaluate(() => window.__godsEyeView.engine.camera.pose().position); + const d = (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z); + await new Promise((resolve) => setTimeout(resolve, 1800)); + let progress = await p.evaluate(() => window.qaRoute.getFlightProgress()); + for (const kind of ['cesium', 'navara']) { + const before = await pos(); + await transfer(kind); + await p.waitForFunction( + () => + window.qaRoute.getRowControls().chips.find((c) => c.id === 'fly') + ?.active, + ); + const at = await pos(); + await new Promise((resolve) => setTimeout(resolve, 1500)); + const next = await pos(); + const resumed = await p.evaluate(() => window.qaRoute.getFlightProgress()); + assert.equal(resumed.motionId, progress.motionId); + assert.ok(resumed.traveledM > progress.traveledM); + progress = resumed; + console.log(kind, 'moved', d(at, next), 'handoff', d(at, before)); + assert.ok(d(at, next) > 1, 'dolly advances after switch to ' + kind); + } + await p.evaluate(() => window.qaRoute.setParams({ clear: true })); + assert.equal(await p.evaluate(() => window.qaRoute.getStats().count), 0); + assert.deepEqual(errors, []); + console.log( + 'PASS route delivery during handoff, A/B clicks, step selection, ongoing FLY, clear and single route request', + ); +} finally { + await b.close(); +} diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index fae2a2870..51f04cab7 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -255,7 +255,7 @@ export function createApplicationCatalog({ ], [ 'directions', - () => createApplicationDirections({ source: sources.directions }), + () => sessionModel('directions', () => createApplicationDirections({ source: sources.directions, surface })), ], [ 'recent-imagery', diff --git a/src/app/layers/directions.js b/src/app/layers/directions.js index 8b16c5cff..f6a73fd0d 100644 --- a/src/app/layers/directions.js +++ b/src/app/layers/directions.js @@ -1,6 +1,8 @@ import { createApplicationDirectionsLayer } from '../../data/directions.js'; +import { rendererFor } from './renderers.js'; + /** Construct Directions with application scene owners and an explicit route source. */ -export function createApplicationDirections({ source }) { - return createApplicationDirectionsLayer({ source }); +export function createApplicationDirections({ source, surface }) { + return createApplicationDirectionsLayer({ source, groundFloor: surface?.groundFloor, createNativeRenderer: (engine) => rendererFor('directions', engine) }); } diff --git a/src/app/navaraLayers.test.mjs b/src/app/navaraLayers.test.mjs index bed92bb97..fa4effc71 100644 --- a/src/app/navaraLayers.test.mjs +++ b/src/app/navaraLayers.test.mjs @@ -58,6 +58,7 @@ test('the Navara mode registers migrated renderers once', async () => { 'radio', 'transit', 'bikeshare', + 'directions', 'recent-imagery', 'ais-live-vessels', 'military-installations', diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 65a76112d..96d6df97a 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -120,6 +120,10 @@ export async function registerNavaraLayerRenderers() { return (engine, options) => createWeatherNavaraRenderer(engine, navaraViews.get(engine), options); }, ])), + directions: async () => { + const { createDirectionsNavaraRenderer } = await import('../layers/directions/render/navara.js'); + return (engine) => createDirectionsNavaraRenderer(engine, navaraViews.get(engine)); + }, 'telegeography-submarine-cables': async () => { const { createNavaraCables } = await import('../layers/submarineCables/render/navara.js'); return (engine, { overlayHost }) => createNavaraCables(engine, navaraViews.get(engine), overlayHost); diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 6448bda86..b7239f280 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -231,7 +231,7 @@ test('validated voice camera destinations share the UI navigation authority faca const route = body( cameraVerbs, - /export function flyRoute\(\s*annoList,\s*args = \{\},\s*floorFn = null,\s*runNavigation = null,\s*warmFn = null,?\s*\) \{([\s\S]*?)\n\}/, + /export function flyRoute\(\s*annoList,\s*args = \{\},\s*floorFn = null,\s*runNavigation = null,\s*warmFn = null,\s*engine = null,?\s*\) \{([\s\S]*?)\n\}/, 'fly route', ); ordered(route, [ diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index d5ee576e4..bb881eaf1 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -960,6 +960,57 @@ let _active = null; // { kind, motion, direction, speed, mode, motionId, ...stat let _tickRemover = null; let _trackingRemover = null; let _inputRemovers = []; +// A Directions dolly can move between engine instances. Geometry and flight time +// stay with the motion. Input and frame listeners belong only to the active canvas. +let routeEngine = null; +let removeRouteFrame = null; +function unbindRouteEngine() { + removeRouteFrame?.(); + removeRouteFrame = null; + routeEngine = null; +} +function bindRouteEngine(engine) { + unbindRouteEngine(); + routeEngine = engine; + const canvas = engine.host.canvas; + const cancel = () => interruptCameraMotion('manual-input'); + const events = ['pointerdown', 'touchstart', 'wheel']; + for (const event of events) canvas.addEventListener(event, cancel, { passive: true }); + let last = performance.now(); + const remove = engine.frames.preRender.addEventListener(() => { + if (_active?.kind !== 'route') return; + const now = performance.now(); + const frame = advanceRouteFlight(_active, (now - last) / 1000); + last = now; + if (!frame.arming) engine.camera.setView({ + destination: frame.eye, orientation: { direction: frame.direction, up: frame.up }, + }); + if (frame.finished) interruptCameraMotion('route-complete'); + else engine.render.request('route-flight'); + }); + removeRouteFrame = () => { + remove(); + for (const event of events) canvas.removeEventListener(event, cancel); + }; + engine.render.request('route-flight'); +} + +export function suspendRouteFlight(id) { + if (!id || _active?.motionId !== id || !routeEngine) return null; + const state = _active; + _active = null; + unbindRouteEngine(); + releaseContinuousRender('camera-verb'); + return state; +} + +export function resumeRouteFlight(state, engine) { + if (!state || !engine || _active) return false; + _active = state; + bindRouteEngine(engine); + holdContinuousRender('camera-verb'); + return true; +} /** Monotonic id for the motion currently running; 0 means "no motion". */ let _motionSeq = 0; @@ -1048,12 +1099,21 @@ export function interruptCameraMotion(reason = 'interrupt') { if ( wasActive && !tracking && + !routeEngine && (_active.kind === 'orbit' || _active.kind === 'route') ) clearLookAt(); // Instant, not eased: the user grabbed the camera, or the flight is over. // Either way the next thing they hold must be a level horizon. - const leveled = wasRoute && !tracking ? levelCameraRoll() : false; + let leveled = false; + if (wasRoute && routeEngine) { + const p = routeEngine.camera.pose(); + if (Math.abs(geo.math.negativePiToPi(p.roll)) > 1e-5) { + routeEngine.camera.setView({ destination: p.position, orientation: { heading: p.heading, pitch: p.pitch, roll: 0 } }); + leveled = true; + } + unbindRouteEngine(); + } else if (wasRoute && !tracking) leveled = levelCameraRoll(); _active = null; releaseContinuousRender('camera-verb'); return { wasActive, reason, leveled }; @@ -1084,7 +1144,7 @@ export function getActiveCameraMotion() { } function onTick() { - if (!_active || !_viewer) return; + if (!_active || !_viewer || routeEngine) return; // Wall-clock dt: clock.currentTime FREEZES when the app clock isn't // animating, which starved motion to the 1 ms floor (harness catch). const nowMs = performance.now(); @@ -1750,6 +1810,7 @@ export function flyRoute( floorFn = null, runNavigation = null, warmFn = null, + engine = null, ) { const speed = ROUTE_M_S[String(args.speed || 'normal').toLowerCase()] ? String(args.speed || 'normal').toLowerCase() @@ -1816,6 +1877,7 @@ export function flyRoute( } const start = () => { interruptCameraMotion('replaced'); + if (engine) bindRouteEngine(engine); setActiveMotion( createRouteFlight({ pts, @@ -1823,8 +1885,8 @@ export function flyRoute( speed, floorFn, warmFn, - probeFn: (cells) => probeMeshFloorM(_viewer?.scene, cells), - cameraHeightM: _viewer?.camera?.positionCartographic?.height, + probeFn: (cells) => probeMeshFloorM(engine ? routeEngine?.surface : _viewer?.scene, cells), + cameraHeightM: engine?.camera.pose().cartographic.height ?? _viewer?.camera?.positionCartographic?.height, reducedMotion: prefersReducedMotion(), }), ); diff --git a/src/data/directions.js b/src/data/directions.js index 8a871bfbf..40046bc19 100644 --- a/src/data/directions.js +++ b/src/data/directions.js @@ -40,8 +40,10 @@ const directionsServices = Object.freeze({ /** Construct one Directions layer over the application scene owners. */ export function createApplicationDirectionsLayer({ source = createDirectionsSource(), + createNativeRenderer, + groundFloor = ground, } = {}) { - return createDirectionsLayer({ services: directionsServices, source }); + return createDirectionsLayer({ services: { ...directionsServices, ground: groundFloor }, source, createNativeRenderer }); } export { diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index 1dcb9e347..b538b07d6 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -859,7 +859,7 @@ export function normalizeRoutePayload(payload, mode) { * @param {{services: object, source: {getRoute: Function}}} options * @returns {object} The layer module the data manager registers. */ -export function createDirectionsLayer({ services, source }) { +export function createDirectionsLayer({ services, source, createNativeRenderer }) { if (typeof source?.getRoute !== 'function') throw new TypeError('Directions require a route source'); /** @@ -876,6 +876,9 @@ export function createDirectionsLayer({ services, source }) { // --- Module state --- let _viewer = null; + let _engine = null; + let _native = null; + let _suspendedFlight = null; let _enabled = false; let _mode = DEFAULT_DIRECTIONS_MODE; /** @type {null|'a'|'b'} which endpoint the next globe click places */ @@ -967,7 +970,7 @@ export function createDirectionsLayer({ services, source }) { } function pickGround(screenPosition) { - const cartesian = services.scenePick.pickGroundPosition( + const cartesian = _native ? _engine.picker.pickGlobe(screenPosition) : services.scenePick.pickGroundPosition( _viewer, screenPosition, ); @@ -981,6 +984,7 @@ export function createDirectionsLayer({ services, source }) { } 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), @@ -1007,12 +1011,13 @@ export function createDirectionsLayer({ services, source }) { } function removeEntity(entity) { + if (_native) { entity?.destroy(); return; } if (entity && _viewer && !_viewer.isDestroyed?.()) _viewer.entities.remove(entity); } function placeMarker(letter, point) { - if (!_viewer) return; + if (!_viewer && !_native) return; if (letter === 'a') { removeEntity(_markerA); _markerA = markerEntity('A', point, MARKER_A_COLOR); @@ -1023,7 +1028,7 @@ export function createDirectionsLayer({ services, source }) { } function clearRouteGraphics() { - services.credits?.hideOsmCredit?.(_viewer, 'directions'); + services.credits?.hideOsmCredit?.(_viewer ?? _engine, 'directions'); _clearStepSelection(); stopStepAnchoring(); removeEntity(_routeEntity); @@ -1072,6 +1077,7 @@ export function createDirectionsLayer({ services, source }) { ); point.show = true; } + _native?.steps.sync(); // 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'); @@ -1092,23 +1098,26 @@ export function createDirectionsLayer({ services, source }) { function drawRoute(route) { clearRouteGraphics(); - if (!_viewer) return; - 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 (!_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, + }, + }); + } // 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 = []; @@ -1129,13 +1138,13 @@ export function createDirectionsLayer({ services, source }) { disableDepthTestDistance: Number.POSITIVE_INFINITY, }); } - services.credits?.showOsmCredit?.(_viewer, 'directions'); + services.credits?.showOsmCredit?.(_viewer ?? _engine, 'directions'); services.ground.warmGroundFloor(cells); _anchorAttempts = 0; _anchorStartedAt = Date.now(); anchorStepPoints(); syncRenderHold(); - services.sprites.restoreSpriteOrder(_viewer); + if (_viewer) services.sprites.restoreSpriteOrder(_viewer); services.render.governorRequestRender('directions-route'); } @@ -1338,6 +1347,7 @@ export function createDirectionsLayer({ services, source }) { * @returns {boolean} Whether a flight of ours was stopped. */ function cancelOwnedFlight(reason) { + _suspendedFlight = null; stopFlightProgress(); if (!_flightId) return false; const { wasActive } = services.camera.interruptCameraMotionIfActive( @@ -1378,9 +1388,9 @@ export function createDirectionsLayer({ services, source }) { } function flyCurrentRoute() { - if (!_route || !_viewer) return false; + if (!_route || (!_viewer && !_engine)) return false; cancelOwnedFlight('directions-refly'); - services.camera.initCameraVerbs(_viewer); + if (_viewer) services.camera.initCameraVerbs(_viewer); const seams = _shellSeams; const result = services.camera.flyRoute( [ @@ -1401,6 +1411,7 @@ export function createDirectionsLayer({ services, source }) { // runs, it just has nothing to take the camera from. typeof seams?.runNavigation === 'function' ? seams.runNavigation : null, typeof seams?.warmFn === 'function' ? seams.warmFn : null, + _engine, ); if (result?.ok !== true) { console.warn('[Data:Directions] fly refused:', result?.error || result); @@ -1418,6 +1429,7 @@ export function createDirectionsLayer({ services, source }) { if (_selectedStep !== null && _stepPoints) { const point = findStepPoint(_selectedStep); if (point) point.pixelSize = STEP_PIXEL_SIZE; + _native?.steps.sync(); } _selectedStep = null; _overlayHost.clearSource(DIRECTIONS_STEP_OVERLAY_SOURCE_ID); @@ -1496,6 +1508,7 @@ export function createDirectionsLayer({ services, source }) { _selectedStep = index; const point = findStepPoint(index); if (point) point.pixelSize = STEP_SELECTED_PIXEL_SIZE; + _native?.steps.sync(); services.render.governorRequestRender('directions-select'); return true; } @@ -1522,8 +1535,7 @@ export function createDirectionsLayer({ services, source }) { function _installClickHandler(viewer) { if (_clickHandler) return; - _clickHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); - _clickHandler.setInputAction((click) => { + 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 @@ -1539,7 +1551,7 @@ export function createDirectionsLayer({ services, source }) { if (!services.input.isPointerFree()) return; let picked = null; try { - picked = viewer.scene.pick(click.position); + picked = _native ? click.picked : viewer.scene.pick(click.position); } catch { picked = null; } @@ -1549,7 +1561,14 @@ export function createDirectionsLayer({ services, source }) { return; } if (_selectedStep !== null) _clearStepSelection(); - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + }; + 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); + } if (typeof document !== 'undefined') document.addEventListener('keydown', _onKeyDown); } @@ -1579,26 +1598,55 @@ export function createDirectionsLayer({ services, source }) { * Create the maneuver-dot collection. Called once at bootstrap. * @param {Cesium.Viewer} viewer */ - init(viewer) { + init(viewer, options) { + return this.attachRenderer(viewer, options); + }, + attachRenderer(viewer, { engine = null } = {}) { _viewer = viewer; - _stepPoints = new Cesium.PointPrimitiveCollection({ - blendOption: Cesium.BlendOption.TRANSLUCENT, - }); - viewer.scene.primitives.add(_stepPoints); - services.sprites.registerSpriteCollection('directions', _stepPoints); - _stepPoints.show = false; - _enabled = false; - _mode = DEFAULT_DIRECTIONS_MODE; - disarm(); - _a = null; - _b = null; - _route = null; - _status = 'idle'; - _error = null; - _flightStep = 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; + _overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, _enabled); + if (!_enabled) return; + const step = _selectedStep; + this.enable(viewer); + if (_a) placeMarker('a', _a); + if (_b) placeMarker('b', _b); + if (_route) drawRoute(_route); + if (step !== null) _selectStep(step); + }, + suspendCamera() { + if (!_flightId || _suspendedFlight) return; + _suspendedFlight = services.camera.suspendRouteFlight?.(_flightId); + if (_suspendedFlight) stopFlightProgress(); + }, + detachRenderer() { + if (!_suspendedFlight) cancelOwnedFlight('directions-detach'); + 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); + } _overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, false); - services.sprites.restoreSpriteOrder(viewer); - console.log('[Data:Directions] Initialized'); + _stepPoints = _viewer = _engine = _native = null; }, /** @@ -1608,7 +1656,7 @@ export function createDirectionsLayer({ services, source }) { enable(viewer) { _enabled = true; _pointerBlocked = false; - _stepPoints.show = true; + if (_stepPoints) _stepPoints.show = true; _overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, true); _installClickHandler(viewer); services.picking.registerPickOwner( @@ -1617,7 +1665,7 @@ export function createDirectionsLayer({ services, source }) { typeof pickedId === 'string' && pickedId.startsWith('directions:'), ); syncRenderHold(); - services.sprites.restoreSpriteOrder(viewer); + if (viewer) services.sprites.restoreSpriteOrder(viewer); }, /** @@ -1695,6 +1743,10 @@ export function createDirectionsLayer({ services, source }) { * Keep a manager handle so placement and routing can repaint the row. * @param {object} dataManager DataLayerManager instance. */ + getFlightProgress() { + const motion = services.camera.getActiveCameraMotion(); + return motion?.motionId === _flightId ? motion : null; + }, attachDataManager(dataManager) { _dataManager = dataManager; }, @@ -1712,6 +1764,15 @@ export function createDirectionsLayer({ services, source }) { attachShellServices(services) { _shellSeams = services || null; }, + resumeCamera() { + if (_shellSeams && _suspendedFlight && _engine) { + _suspendedFlight.floorFn = _shellSeams.floorFn; + _suspendedFlight.warmFn = _shellSeams.warmFn; + if (services.camera.resumeRouteFlight(_suspendedFlight, _engine)) trackFlightProgress(); + else { _flightId = 0; _flightStep = null; } + _suspendedFlight = null; + } + }, /** * Tear down entirely. @@ -1728,11 +1789,7 @@ export function createDirectionsLayer({ services, source }) { _shellSeams = null; _dataManager = null; _rowControlsListener = null; - if (_stepPoints) { - services.sprites.unregisterSpriteCollection('directions', _stepPoints); - viewer.scene.primitives.remove(_stepPoints); - _stepPoints = null; - } + this.detachRenderer(); _overlayHost.clearSource(DIRECTIONS_STEP_OVERLAY_SOURCE_ID); _viewer = null; }, diff --git a/src/layers/directions/render/navara.js b/src/layers/directions/render/navara.js new file mode 100644 index 000000000..d6e0780ea --- /dev/null +++ b/src/layers/directions/render/navara.js @@ -0,0 +1,209 @@ +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/main.js b/src/main.js index 6483de1ac..d4c57e7c0 100644 --- a/src/main.js +++ b/src/main.js @@ -37,6 +37,7 @@ async function start(next, engineSwitch = false, cockpit = null) { if (engineSwitch) { const style = controls.styleManager; await style.initialRestorePromise; + if (layerSession.handoffLayers) layerSession.manager.layers.get('directions')?.module.resumeCamera?.(); // Only resume the aircraft that actually survived the handoff. A history // entry may select a different subject, and a stale track may disappear. const target = style.getAircraftTrackingTarget(); @@ -137,6 +138,7 @@ async function reconcile(remember = false) { id, { entry: entry.module.attachRenderer ? entry : null, enabled: manager.isEffectivelyEnabled(id) }, ])) : null; + if (layerSession.handoffLayers) manager.layers.get('directions')?.module.suspendCamera?.(); renderers.retain(kind); await application.destroy(); await start(pageEngineKind(), true, cockpit);