From 18c15d0352e5b4bf6329947cb7819ce17b49cb14 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 8 Nov 2026 13:18:40 -0800 Subject: [PATCH] fix(cockpit): a river bridge keeps its water height through empty and coarse reads A full-detail water read that sampled nothing (tiles not loaded) fell back to the geoid, and while a full read ran, a loaded-tile read with one coarse low probe did the same: on the Seine either dropped the eye ~27 m under the water until the next read. An empty read is now no answer, and once a full-detail read has answered only full-detail reads change the height. The read sequence numbers go: only one full-detail read runs at a time, so a late read can never be older than one already applied. --- docs/CURRENT-STATE.md | 6 ++-- src/ui/cockpitBridge.test.mjs | 53 +++++++++++++++++++++++++++++++++++ src/ui/cockpitCamera.js | 26 ++++++++--------- 3 files changed, 70 insertions(+), 15 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f8f69be47..a5b85c825 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1568,8 +1568,10 @@ eight probes 30 m out, the lowest probe taken as the water when it is 2 m to 4,000 m above sea level. The first read starts from the tiles already loaded, then the full-detail read replaces it; it repeats every 5 s. Only one full-detail read runs at a time: while one is still running, a refresh reads the loaded -tiles instead, and the slow read applies whenever it lands unless a newer -one has. A failed read keeps the sea surface. +tiles instead, and the slow read applies whenever it lands. Once a +full-detail read has answered, loaded-tile reads no longer change the height, +and a read that sampled nothing keeps the height it had. A failed read keeps +the sea surface. The HUD keeps its frame, vision styles, briefing, weather and Display/Radio utilities. It names the ship, reads `{type} · LIVE AIS · EYE {height}`, shows diff --git a/src/ui/cockpitBridge.test.mjs b/src/ui/cockpitBridge.test.mjs index 0eb68022a..b0302b50e 100644 --- a/src/ui/cockpitBridge.test.mjs +++ b/src/ui/cockpitBridge.test.mjs @@ -1261,6 +1261,59 @@ test('a slow water read is never stacked, and lands however late', async (t) => ); }); +test('a river height stands through reads that sample nothing or one coarse probe', async (t) => { + const clock = manualClock(t, START_MS); + const sea = await bridgeAt(t, clock); + frames(sea.cockpit, clock, sea.shots, 60); + const seaEyeM = eyeHeightM(sea.shots.at(-1)); + sea.cockpit.exit(); + + clock.set(START_MS); + const record = { ...RECORD, lastPositionEpoch: START_MS / 1000 }; + const ship = fixture(t, { record }); + const reads = []; + ship.viewer.scene.sampleHeightMostDetailed = (positions) => + new Promise((resolve) => reads.push({ positions, resolve })); + ship.viewer.scene.sampleHeight = () => undefined; + const answer = (read, height) => + read.resolve( + read.positions.map((position) => Object.assign(position, { height })), + ); + ship.cockpit.enter(); + await settle(); + ship.cockpit.update(); + await settle(); + answer(reads[0], 27); + await settle(); + frames(ship.cockpit, clock, ship.shots, 120); + await settle(); + near(eyeHeightM(ship.shots.at(-1)) - seaEyeM, 27, 'on the river', 0.5); + // The next full read lands over tiles not loaded yet: every probe empty. + assert.equal(reads.length, 2); + answer(reads[1], undefined); + await settle(); + frames(ship.cockpit, clock, ship.shots, 120); + await settle(); + near( + eyeHeightM(ship.shots.at(-1)) - seaEyeM, + 27, + 'an empty full read is no answer', + 0.5, + ); + // While the next one runs, coarse loaded tiles read the sea. + assert.equal(reads.length, 3); + ship.viewer.scene.sampleHeight = () => 0.5; + frames(ship.cockpit, clock, ship.shots, 240); + await settle(); + assert.equal(reads.length, 3, 'still the one read in flight'); + near( + eyeHeightM(ship.shots.at(-1)) - seaEyeM, + 27, + 'loaded tiles do not undo a full read', + 0.5, + ); +}); + test('a client clock minutes fast does not age the bridge into a stale feed', async (t) => { // The feed's newest fix is this ship's; the client clock runs 200 s // ahead of the feed and received the refresh just now. diff --git a/src/ui/cockpitCamera.js b/src/ui/cockpitCamera.js index 50a43510f..2e114dd47 100644 --- a/src/ui/cockpitCamera.js +++ b/src/ui/cockpitCamera.js @@ -109,7 +109,10 @@ export function update() { * full-detail read runs at a time (each is nine picks Cesium traverses * every frame until it settles): while one is still running, a refresh * reads the loaded tiles instead, and the slow read applies whenever it - * lands unless a newer one has. + * lands. Once a full-detail read has answered, only full-detail reads + * change the height: a loaded-tile read stands in only until the first + * lands, so one coarse probe cannot drop a river bridge to the sea, and a + * read that sampled nothing keeps the height it had. * @param {{latitude: number, longitude: number}} [at] Where to read the * water: the dead-reckoned position the camera rides. * @returns {number} Ellipsoidal metres. @@ -133,15 +136,14 @@ function vesselSurfaceM(info, nowMs, at = info) { mmsi: info.mmsi, heightM: loadedWaterlineM(scene, probes, seaM), sampledMs: nowMs, - // Full-detail reads by number: the last started, the last applied. - startedSeq: 0, - landedSeq: 0, + // Whether a full-detail read has answered for this ship. + landed: false, inFlight: false, }; this.vesselWaterline = read; } else { read.sampledMs = nowMs; - if (read.inFlight) { + if (read.inFlight && !read.landed) { const loadedM = loadedWaterlineM(scene, probes, seaM); if (Number.isFinite(loadedM)) read.heightM = loadedM; } @@ -155,7 +157,6 @@ function vesselSurfaceM(info, nowMs, at = info) { /** Start one full-detail water read for `read`; its answer lands on it. */ function readWaterline(read, scene, probes, seaM) { - const seq = ++read.startedSeq; read.inFlight = true; Promise.resolve() .then(() => scene.sampleHeightMostDetailed(probes)) @@ -163,13 +164,12 @@ function readWaterline(read, scene, probes, seaM) { (samples) => { if (this.vesselWaterline !== read) return; read.inFlight = false; - // However late it lands, it applies unless a newer read has. - if (seq <= read.landedSeq) return; - read.landedSeq = seq; - read.heightM = vesselWaterlineM( - seaM, - (samples || []).map((sample) => sample?.height), - ); + // However late it lands, it applies; one that sampled nothing (tiles + // not loaded yet) is no answer and keeps the height already read. + const heights = (samples || []).map((sample) => sample?.height); + if (!heights.some(Number.isFinite)) return; + read.landed = true; + read.heightM = vesselWaterlineM(seaM, heights); }, () => { if (this.vesselWaterline === read) read.inFlight = false;