From c8a4f3ed9890cb204720a82e18d9ccdbd7abf1d6 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 22 Oct 2026 12:31:59 +0400 Subject: [PATCH] fix(vessels): upload Navara chevrons and restore click focus Fix delayed and growing mover bank commits, reject stale hidden model picks, and route world-focus through the engine. Strengthen vessel parity and performance checks without changing thresholds. --- scripts/qa-vessel-cards.mjs | 4 +-- scripts/qa-vessel-datum.mjs | 2 +- src/cameraHandoff.test.mjs | 2 +- src/globe/cpuPicks.js | 3 +- src/globe/navara/picking.test.mjs | 23 ++++++++++++++ src/layers/aircraft/render/navara.js | 1 - src/layers/vessels/renderer.test.mjs | 45 ++++++++++++++++++++++++++++ src/ui/applicationShell.js | 1 + src/ui/layerBindings.js | 4 ++- src/ui/shellLifetime.test.mjs | 18 +++++++++++ 10 files changed, 96 insertions(+), 7 deletions(-) diff --git a/scripts/qa-vessel-cards.mjs b/scripts/qa-vessel-cards.mjs index bb29285d8..4f8889b5c 100644 --- a/scripts/qa-vessel-cards.mjs +++ b/scripts/qa-vessel-cards.mjs @@ -215,7 +215,7 @@ async function main() { console.log(` Tag : ${TAG}`); console.log(` Engine : ${ENGINE ?? 'viewer (Cesium)'}`); console.log(` Data : ${DATA_MODE}`); - console.log(` Browser : ${HEADFUL ? 'headful (hardware renderer requested)' : 'headless (SwiftShader)'}`); + console.log(` Browser : ${HEADFUL ? 'headful (hardware renderer requested)' : 'headless (Metal)'}`); console.log(` Ports : ${PORT_KEYS.join(', ')}\n`); for (const line of vesselCardProvenanceLines(DATA_MODE)) console.log(` ${line}`); console.log(''); @@ -238,7 +238,7 @@ async function main() { args: [ '--no-sandbox', '--disable-setuid-sandbox', - ...(!HEADFUL ? ['--use-gl=angle', '--use-angle=swiftshader'] : []), + '--use-angle=metal', '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', diff --git a/scripts/qa-vessel-datum.mjs b/scripts/qa-vessel-datum.mjs index d0e73198e..940b457bf 100644 --- a/scripts/qa-vessel-datum.mjs +++ b/scripts/qa-vessel-datum.mjs @@ -223,7 +223,7 @@ async function main() { '--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', - '--use-angle=swiftshader', + '--use-angle=metal', '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 5fe04b0ab..ec0e08624 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -378,7 +378,7 @@ test('refused canned destinations commit no location or POI state', () => { test('world-focus listener lifecycle is symmetric and idempotent', () => { assert.match(ui, /registerWorldFocusRequestListener\(\s*window,/); assert.match(ui, /routeWorldFocusRequest\(/); - assert.match(ui, /flyToWorldTarget\(this\.viewer, detail\)/); + assert.match(ui, /flyToWorldTarget\(this\.engine \?\? this\.viewer, detail\)/); assert.match(ui, /this\._removeWorldRequestFocusListener\?\.\(\);/); assert.match(ui, /this\._removeWorldRequestFocusListener = null;/); }); diff --git a/src/globe/cpuPicks.js b/src/globe/cpuPicks.js index 7ca47dc09..7f0bc0cb6 100644 --- a/src/globe/cpuPicks.js +++ b/src/globe/cpuPicks.js @@ -196,10 +196,11 @@ export function createCpuPicker({ projector, camera, movers, lines }) { const found = movers().find(({ options }) => options.id === owner); if (!found) return true; const { options, store, pickRadiusPx } = found; + if (instance < 0 || instance >= store.count || store.shows[instance] !== 1) + return false; const modelRadius = options.kind === 'model' ? (pickRadiusPx?.(instance) ?? null) : null; if (options.kind === 'model' && modelRadius == null) return true; - if (instance >= store.count) return false; geo.cartesian3.fromElements( store.positions[instance * 3], store.positions[instance * 3 + 1], diff --git a/src/globe/navara/picking.test.mjs b/src/globe/navara/picking.test.mjs index cd750edfc..fa60d037d 100644 --- a/src/globe/navara/picking.test.mjs +++ b/src/globe/navara/picking.test.mjs @@ -411,3 +411,26 @@ test('a model hit is checked against its projected position within its drawn rad assert.equal(picking.drillPick({ x: 121, y: 100 })[0]?.id, 'jet'); assert.equal(picking.drillPick({ x: 125, y: 100 }).length, 0); }); + +test('stale GPU model hits cannot select hidden or removed instances', async () => { + const view = fakeView(); + const model = modelSet('models', [[100, 100, 50, 'jet']], null); + const picking = createNavaraPicking({ + view, + projector, + camera, + movers: () => [model], + lines: () => [], + resolveBatch: () => ({ set: 'models', instance: 0, pickId: 'jet' }), + ...timers(), + }); + model.store.set(0, { show: false }); + const hidden = picking.clickPick({ x: 100, y: 100 }); + view.emit('featureClick', { batchId: 1 }); + assert.equal(await hidden, undefined); + model.store.setCount(0); + const removed = picking.clickPick({ x: 100, y: 100 }); + view.emit('featureClick', { batchId: 1 }); + assert.equal(await removed, undefined); + picking.destroy(); +}); diff --git a/src/layers/aircraft/render/navara.js b/src/layers/aircraft/render/navara.js index e5dab38dc..a8830dddc 100644 --- a/src/layers/aircraft/render/navara.js +++ b/src/layers/aircraft/render/navara.js @@ -83,7 +83,6 @@ export function createMoverBank(engine, { options, write, onCommit }) { capacity = wanted; handles.forEach((handle, index) => write(handle, set, index)); set.setCount(visible ? handles.length : 0); - dirty = true; }; const bank = { handles, diff --git a/src/layers/vessels/renderer.test.mjs b/src/layers/vessels/renderer.test.mjs index fd8dd3fce..e85fe44d7 100644 --- a/src/layers/vessels/renderer.test.mjs +++ b/src/layers/vessels/renderer.test.mjs @@ -286,3 +286,48 @@ test('vessels on the Navara renderer: one always-on-top chevron bank, MMSI picks globalThis.document = previousDocument; } }); + +test('a vessel bank commits rows arriving after its empty visibility commit and after growth', async () => { + const { createMoverBank } = await import('../aircraft/render/navara.js'); + let commits = 0; + let pending = null; + const bank = createMoverBank( + { + movers: { + createSet: () => ({ + set() {}, + setCount() {}, + destroy() {}, + commit() { + commits++; + }, + }), + }, + }, + { + options: (capacity) => ({ capacity }), + write() {}, + onCommit: (next) => { + pending = next; + }, + }, + ); + const flush = () => { + const next = pending; + pending = null; + next?.commit(); + }; + bank.setVisible(true); + flush(); + bank.add({}); + assert.equal(pending, bank, 'first rows schedule their GPU upload'); + flush(); + assert.equal(commits, 1); + for (let i = 1; i < 1024; i++) bank.add({}); + flush(); + bank.add({}); + assert.equal(pending, bank, 'capacity growth schedules its GPU upload'); + flush(); + assert.equal(commits, 3); + bank.destroy(); +}); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 338e07387..68fd61358 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -264,6 +264,7 @@ export class StyleManager extends ShellFacade { this._layerBindings = new LayerBindings({ viewer, + engine, services: { imageryHost: () => resolveImageryHost({ diff --git a/src/ui/layerBindings.js b/src/ui/layerBindings.js index 6100e7420..b9b3d7d3a 100644 --- a/src/ui/layerBindings.js +++ b/src/ui/layerBindings.js @@ -13,6 +13,7 @@ import { registerNavigationAuthorityListener } from '../navigationPolicy.js'; export class LayerBindings { constructor({ viewer, + engine, services, readControls, operations, @@ -23,6 +24,7 @@ export class LayerBindings { this, { viewer, + engine, services, readControls, _feedback: feedback, @@ -71,7 +73,7 @@ export class LayerBindings { routeWorldFocusRequest( event, (detail, fly) => this._runExplicitWorldFocus(detail, fly), - (detail) => flyToWorldTarget(this.viewer, detail), + (detail) => flyToWorldTarget(this.engine ?? this.viewer, detail), ); this._removeWorldRequestFocusListener = registerWorldFocusRequestListener( window, diff --git a/src/ui/shellLifetime.test.mjs b/src/ui/shellLifetime.test.mjs index bee8d5ea2..d64015494 100644 --- a/src/ui/shellLifetime.test.mjs +++ b/src/ui/shellLifetime.test.mjs @@ -152,3 +152,21 @@ test('weather shell supplies live imagery hosts to wind and observations and rel owner.disconnect(); for (const id of ids) assert.equal(attached.get(id), null); }); + + +test('world focus uses the engine when the shell has no Cesium viewer', async (t) => { + const { requestWorldFocus } = await import('../worldFocus.js'); + replaceWindow(t, new EventTarget()); + const engine = createFakeEngine(); + let target; + engine.camera.flyToSphere = (sphere) => { target = sphere.center; }; + const owner = new LayerBindings({ + viewer: null, engine, services: {}, readControls: () => ({}), + operations: { _runExplicitWorldFocus: (_detail, fly) => fly() }, + }); + owner.observeCamera(); + const position = { x: 6378137, y: 0, z: 0 }; + requestWorldFocus({ kind: 'vessel', id: '111', position }); + assert.deepEqual(target, position); + owner.stop(); +});