From 005254fcd15454cedac2bc5a52f6e4894b78b8c6 Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu <106619546+bilawalsidhu@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:49:39 -0500 Subject: [PATCH 1/7] fix(weather): upright N. America cloud tiles; lift shells over coarse 3D tiles (#730) --- src/layers/weather/infraredImage.js | 12 +++++-- src/layers/weather/infraredImage.test.mjs | 12 +++++++ src/layers/weather/rendering.js | 8 ++++- src/layers/weather/shellFixture.mjs | 2 ++ src/layers/weather/shellRendering.js | 25 ++++++++++++++ src/layers/weather/shellRendering.test.mjs | 39 ++++++++++++++++++++++ src/layers/weather/weather.test.mjs | 35 +++++++++++++++++++ 7 files changed, 130 insertions(+), 3 deletions(-) diff --git a/src/layers/weather/infraredImage.js b/src/layers/weather/infraredImage.js index 5b2db50b3..ccbb8e991 100644 --- a/src/layers/weather/infraredImage.js +++ b/src/layers/weather/infraredImage.js @@ -4,13 +4,21 @@ import { infraredAlpha } from './infraredAlpha.js'; export const MAX_MOSAIC_BYTES = 4 * 1024 * 1024; -/** Apply the display transfer once to a decoded image. */ -export function processInfraredImage(image, mode, createCanvas) { +/** Apply the display transfer once to a decoded image; `flipY` draws a + * bottom-up image back upright. */ +export function processInfraredImage( + image, + mode, + createCanvas, + { flipY = false } = {}, +) { const canvas = createCanvas(); canvas.width = image.width; canvas.height = image.height; const context = canvas.getContext('2d'); + if (flipY) context.setTransform(1, 0, 0, -1, 0, image.height); context.drawImage(image, 0, 0); + if (flipY) context.setTransform(1, 0, 0, 1, 0, 0); const pixels = context.getImageData(0, 0, canvas.width, canvas.height); pixels.data.set(infraredAlpha(pixels.data, mode)); context.putImageData(pixels, 0, 0); diff --git a/src/layers/weather/infraredImage.test.mjs b/src/layers/weather/infraredImage.test.mjs index f93a7816a..f3937abc3 100644 --- a/src/layers/weather/infraredImage.test.mjs +++ b/src/layers/weather/infraredImage.test.mjs @@ -10,8 +10,12 @@ import { function canvas() { const output = {}; output.getContext = () => ({ + setTransform: (...matrix) => { + output.transforms = [...(output.transforms ?? []), matrix]; + }, drawImage: (image) => { output.image = image; + output.drawnWith = output.transforms?.at(-1) ?? null; }, getImageData: () => ({ data: new Uint8ClampedArray([194, 0, 0, 200]) }), putImageData: (pixels) => { @@ -34,6 +38,14 @@ test('decoded pixels are processed once on a same-sized canvas', () => { ); }); +test('flipY draws a bottom-up image upright, then restores the transform', () => { + const image = { width: 4, height: 8 }; + assert.equal(processInfraredImage(image, 'full', canvas).drawnWith, null); + const output = processInfraredImage(image, 'full', canvas, { flipY: true }); + assert.deepEqual(output.drawnWith, [1, 0, 0, -1, 0, 8]); + assert.deepEqual(output.transforms.at(-1), [1, 0, 0, 1, 0, 0]); +}); + test('mosaic fetch is capped at 4 MiB, forwards cancellation, and releases decoded images', async () => { const controller = new AbortController(); let closes = 0, diff --git a/src/layers/weather/rendering.js b/src/layers/weather/rendering.js index 34224ecc8..8d195f302 100644 --- a/src/layers/weather/rendering.js +++ b/src/layers/weather/rendering.js @@ -381,8 +381,14 @@ function createGlobeRendering({ if (request) frame.requests.add(request); return Promise.resolve(result) .then((image) => { + // Cesium decodes tiles as ImageBitmaps already flipped, since WebGL + // ignores UNPACK_FLIP_Y for them; a canvas upload flips again. if (!frame.closed && snapshot.product === 'clouds-regional') - image = processInfraredImage(image, infrared, createCanvas); + image = processInfraredImage(image, infrared, createCanvas, { + flipY: + typeof ImageBitmap !== 'undefined' && + image instanceof ImageBitmap, + }); frame.retries.delete(tileKey); frame.loaded++; return image; diff --git a/src/layers/weather/shellFixture.mjs b/src/layers/weather/shellFixture.mjs index 685c72c56..18e5de788 100644 --- a/src/layers/weather/shellFixture.mjs +++ b/src/layers/weather/shellFixture.mjs @@ -71,6 +71,8 @@ export function createShellCesium({ maximumTextureSize = 0 } = {}) { GeometryInstance, BlendingState: Cesium.BlendingState, Cartesian4: Cesium.Cartesian4, + Ellipsoid: Cesium.Ellipsoid, + Matrix4: Cesium.Matrix4, Math: Cesium.Math, Rectangle: Cesium.Rectangle, ContextLimits: { maximumTextureSize }, diff --git a/src/layers/weather/shellRendering.js b/src/layers/weather/shellRendering.js index 42a8ed521..cc8b32035 100644 --- a/src/layers/weather/shellRendering.js +++ b/src/layers/weather/shellRendering.js @@ -25,6 +25,12 @@ const NO_WINDOW = Object.freeze({ west: 0, south: 0, east: -1, north: -1 }); const DETAIL_MIN_WIDTH = 6; const DETAIL_GRID = 0.5; const shellHeights = new WeakMap(); +// From far away Google's coarse 3D tiles rise tens of kilometres above the +// ground and hide the shells, so the shells rise with the camera: 4 m per +// kilometre of camera height, at most 60 km, in 500 m steps. +const LIFT_PER_METRE = 0.004; +const MAX_LIFT = 60_000; +const LIFT_STEP = 500; // Cesium binds a 1×1 white texture to an image uniform until its first upload: // draw nothing until the full-extent image has arrived, and sample the detail // image only once it has arrived. The window is west, south, east, north in the @@ -71,6 +77,13 @@ const sameEdges = (a, b) => a.east === b.east && a.north === b.north; +/** Metres every shell rises for a camera this high above the ellipsoid. */ +export function shellLift(cameraHeight) { + if (!(cameraHeight > 0)) return 0; + const lift = Math.min(MAX_LIFT, cameraHeight * LIFT_PER_METRE); + return Math.round(lift / LIFT_STEP) * LIFT_STEP; +} + /** Keep weather shells first in the primitive collection, in ascending height: * higher shells draw over lower ones and other opaque content draws over both. */ export function orderWeatherShells(primitives, primitive, height) { @@ -135,6 +148,17 @@ export function createShellSurface({ }); primitives.add(primitive); orderWeatherShells(primitives, primitive, height); + // A uniform scale about the Earth's centre lifts every shell alike and keeps + // their order. + let lift = 0; + const offLift = scene.preRender?.addEventListener(() => { + const next = shellLift(scene.camera?.positionCartographic?.height); + if (next === lift) return; + lift = next; + primitive.modelMatrix = cesium.Matrix4.fromUniformScale( + 1 + lift / cesium.Ellipsoid.WGS84.maximumRadius, + ); + }); let image = null; let frames = 0; // The detail image, its own upload count and a window waiting for it to draw. @@ -265,6 +289,7 @@ export function createShellSurface({ destroyed = true; offRender?.(); offRender = null; + offLift?.(); if (!primitives.isDestroyed?.() && primitives.contains(primitive)) primitives.remove(primitive); if (!primitive.isDestroyed()) primitive.destroy(); diff --git a/src/layers/weather/shellRendering.test.mjs b/src/layers/weather/shellRendering.test.mjs index 3fcfdf28e..ad65dcef2 100644 --- a/src/layers/weather/shellRendering.test.mjs +++ b/src/layers/weather/shellRendering.test.mjs @@ -6,6 +6,7 @@ import { createWeatherShell, detailWindow, orderWeatherShells, + shellLift, WEATHER_SHELL_CACHE_BYTES, WEATHER_SHELL_HEIGHTS, } from './shellRendering.js'; @@ -297,6 +298,44 @@ test('a surface applies a new detail window only once its image has drawn; the s assert.equal(scene.postRender.size, 0); }); +test('shells rise with the camera over coarse 3D tiles, in steps, up to 60 km', () => { + assert.equal(shellLift(undefined), 0); + assert.equal(shellLift(1_200), 0, 'city views keep the shell heights'); + assert.equal(shellLift(3_000_000), 12_000); + assert.equal(shellLift(8_000_000), 32_000); + assert.equal(shellLift(18_500_000), 60_000); + const camera = { positionCartographic: { height: 1_200 } }; + const scene = { + primitives: new Cesium.PrimitiveCollection(), + preRender: new Cesium.Event(), + postRender: new Cesium.Event(), + camera, + requestRender() {}, + }; + const surface = createShellSurface({ + viewer: { scene }, + cesium: Cesium, + rectangle: Cesium.Rectangle.fromDegrees(-130, 20, -60, 55), + height: 5_800, + }); + const primitive = scene.primitives.get(0); + const identity = primitive.modelMatrix; + scene.preRender.raiseEvent(); + assert.equal(primitive.modelMatrix, identity, 'no lift, no new matrix'); + camera.positionCartographic.height = 8_000_000; + scene.preRender.raiseEvent(); + const radius = Cesium.Ellipsoid.WGS84.maximumRadius; + assert.ok( + Math.abs(primitive.modelMatrix[0] * radius - (radius + 32_000)) < 1e-3, + ); + const lifted = primitive.modelMatrix; + camera.positionCartographic.height = 8_010_000; + scene.preRender.raiseEvent(); + assert.equal(primitive.modelMatrix, lifted, 'the same step keeps the matrix'); + surface.destroy(); + assert.equal(scene.preRender.numberOfListeners, 0); +}); + test('real Cesium builds the two-texture material, the opaque-pass appearance and the rectangle primitive', () => { const scene = { primitives: new Cesium.PrimitiveCollection(), diff --git a/src/layers/weather/weather.test.mjs b/src/layers/weather/weather.test.mjs index abdc01c7a..a80ad6560 100644 --- a/src/layers/weather/weather.test.mjs +++ b/src/layers/weather/weather.test.mjs @@ -75,9 +75,13 @@ function target(extra = {}) { function mockCanvas() { const canvas = { width: 0, height: 0 }; canvas.getContext = () => ({ + setTransform(...matrix) { + canvas.transforms = [...(canvas.transforms ?? []), matrix]; + }, drawImage(source, ...crop) { canvas.source = source; canvas.crop = crop; + canvas.drawnWith = canvas.transforms?.at(-1) ?? null; }, getImageData: () => ({ data: new Uint8ClampedArray([194, 100, 0, 200]) }), putImageData(pixels) { @@ -1510,6 +1514,37 @@ test('regional transfer preserves deferrals, original Request, rejection and can assert.equal(await stage, false); }); +test('regional tiles decoded as flipped ImageBitmaps are drawn back upright', async () => { + const hadBitmap = Object.hasOwn(globalThis, 'ImageBitmap'); + const previous = globalThis.ImageBitmap; + globalThis.ImageBitmap = class ImageBitmap { + width = 256; + height = 256; + }; + try { + const h = renderingHarness(); + void h.rendering.setFrame( + { ...snapshot, product: 'clouds-regional' }, + times[0], + ); + const provider = h.providers[0]; + // Cesium decodes with imageOrientation 'flipY'; the canvas upload flips again. + provider.response = { promise: Promise.resolve(new ImageBitmap()) }; + const bitmapTile = await provider.requestImage(0, 0, 0, {}); + assert.deepEqual(bitmapTile.drawnWith, [1, 0, 0, -1, 0, 256]); + // An HTMLImageElement fallback is not pre-flipped. + provider.response = { + promise: Promise.resolve({ width: 256, height: 256 }), + }; + const imageTile = await provider.requestImage(1, 0, 0, {}); + assert.equal(imageTile.drawnWith, null); + h.rendering.clear(); + } finally { + if (hadBitmap) globalThis.ImageBitmap = previous; + else delete globalThis.ImageBitmap; + } +}); + for (const kind of ['globe']) { test(`infrared mode restages the same time on ${kind}, retaining the old layer for one rendered frame`, async () => { let host, From 143cffd7ca92eab4f894f41ac4888636d20ee98c Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu <106619546+bilawalsidhu@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:55:41 -0500 Subject: [PATCH 2/7] fix(ui): keep panel scroll position through rail layout passes (#731) The right-rail layout pass lifts the Context, CCTV, Weather and Recent Imagery panel scrollers to measure their natural heights, which clamps their scroll offset to zero, but it only restored scrollers tagged data-rail-scroller. A layer that refreshes its Layers-panel row every second snapped the Context panel back to the top while scrolling. Tag every lifted scroller and pin the pairing with a test that reads the measuring CSS. Co-authored-by: Bilawal Sidhu --- scripts/format-scope.json | 3 ++- src/ui/railScrollers.test.mjs | 30 ++++++++++++++++++++++++++++++ src/ui/templates/context.html | 6 +++--- src/ui/templates/layer-panels.html | 2 +- 4 files changed, 36 insertions(+), 5 deletions(-) create mode 100644 src/ui/railScrollers.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 97bb0b8f2..c4b1ce72d 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -808,5 +808,6 @@ "src/ui/imageryBoxTool.test.mjs", "src/ui/recentImagery.test.mjs", "src/layers/recentImagery/testDoubles.mjs", - "scripts/qa-recent-imagery.mjs" + "scripts/qa-recent-imagery.mjs", + "src/ui/railScrollers.test.mjs" ] diff --git a/src/ui/railScrollers.test.mjs b/src/ui/railScrollers.test.mjs new file mode 100644 index 000000000..cf549be35 --- /dev/null +++ b/src/ui/railScrollers.test.mjs @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8'); + +// The rail's measuring pass lifts these panel scrollers to their natural +// height, which clamps their scroll offset to zero. Only scrollers tagged +// `data-rail-scroller` get their offset restored afterwards, so every lifted +// scroller must carry the tag or a layout pass snaps it back to the top. +test('every scroller the rail measuring pass lifts restores its scroll offset', () => { + const css = read('./styles/layers.css'); + const templates = [ + read('./templates/context.html'), + read('./templates/layer-panels.html'), + ].join('\n'); + const lifted = [ + ...css.matchAll( + /#right-context-rail\[data-rail-measuring\]\s*>\s*\[data-panel-id\]:not\(\.collapsed\)\s*>\s*\.([a-z-]+)/g, + ), + ].map((match) => match[1]); + assert.ok(lifted.length > 0, 'measuring CSS lists lifted scrollers'); + for (const className of lifted) { + const tag = templates.match( + new RegExp(`
]*>`), + )?.[0]; + if (!tag) continue; + assert.match(tag, /data-rail-scroller/, `${className} restores its scroll`); + } +}); diff --git a/src/ui/templates/context.html b/src/ui/templates/context.html index 6ce5562a6..fbed0cf7c 100644 --- a/src/ui/templates/context.html +++ b/src/ui/templates/context.html @@ -2,7 +2,7 @@