From 910d693b94057ca9a1290aeb7a462d9b365f7b44 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Sat, 7 Nov 2026 14:11:03 +0400 Subject: [PATCH] Keep bounded imagery off Cesium's base layer and pins on drawn 3D tiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cesium treats the first imagery layer shown on the globe as its base layer and stretches that layer's edge texels over the whole globe. With no base map under it, a KMZ ground overlay turned the whole globe its edge colour. The Cesium imagery adapter now keeps a floor under an engine layer that covers less than the globe (a bounded rectangle, or Web Mercator tiles, which stop at ±85.05°) whenever that layer would otherwise be the base layer. The floor is one transparent pixel over the globe at alpha 0, so Cesium draws nothing of it. It is placed when the adapter adds or changes a layer, and again at each scene preUpdate, before Cesium picks the base layer. When base imagery lies under the layer, which is the app's normal state, there is no floor. A bounded layer now draws only inside its rectangle, as the contract says. When the globe is not the drawn surface, the import display now clamps ground pins to the drawn scene, as Cesium's import did whenever the globe was hidden. Before, it did this only for the map's own 3D tiles. Each height query now leaves out the point sets of every imported file on the engine, not only the file's own sets: Cesium's import kept all imported pins out of height queries, so a higher pin from another file never became the ground. The display also gives a footprint with its points at the heights they are drawn at, and flying to a file frames pins on raised tiles as Cesium did. Cesium's import clamped visible pins with clampToHeightMostDetailed, which loads the finest tiles. The display samples the tiles drawn now and samples them again after the camera stops and while tiles load. The KML reader's parity test against KmlDataSource now includes the large-file QA's style case: a shared style with point, line and polygon parts, inline overrides of each part, and named styles and an inline StyleMap pair that are alike. The reader matches Cesium there. QA scripts: - qa-geo-import moves the camera over the files before it reads which pins show. Ground pins are hidden only beyond the horizon. - qa-geo-import-large compares clamped pins by longitude and latitude. Cesium keeps a clamped billboard at its file height, while the import draws its pins on the sampled ground. - qa-geo-import-regressions gives its 3D-tiles fixture a non-zero tileset geometricError, because Cesium never draws a tileset whose own error is 0. Its XYZ check now waits for the import to sample the ground again once the globe's tiles have loaded. Known differences that remain: a KMZ ground overlay is now imagery, so ground fills draw over it where the old ground rectangle drew over them; right after an import on ellipsoid terrain, pins can sit on the coarse globe mesh until the ground is sampled again. The full unit suite and the boundary, format and coupling checks pass. In the browser, keyless: qa-geo-import, qa-geo-import-large and qa-navara-geo-import pass; qa-geo-import-regressions fails only its portrait-flight check, which also fails before the refactor. --- scripts/qa-geo-import-large.mjs | 12 +- scripts/qa-geo-import-regressions.mjs | 20 ++- scripts/qa-geo-import.mjs | 31 ++-- src/data/geoImportDisplay.js | 44 +++++- src/data/geoImportDisplay.test.mjs | 50 ++++++ src/data/geoImportKml.test.mjs | 24 +++ src/globe/cesium/imagery.js | 94 ++++++++++- src/globe/cesium/imagery.test.mjs | 149 ++++++++++++++---- .../recentImagery/render/display.test.mjs | 17 +- src/ui/displayPanel.test.mjs | 1 + src/ui/geoFileImport.js | 5 +- src/ui/geoFileImport.test.mjs | 4 +- 12 files changed, 390 insertions(+), 61 deletions(-) diff --git a/scripts/qa-geo-import-large.mjs b/scripts/qa-geo-import-large.mjs index 038ccffe3..350aff344 100644 --- a/scripts/qa-geo-import-large.mjs +++ b/scripts/qa-geo-import-large.mjs @@ -141,9 +141,15 @@ try { (value(b.verticalOrigin) === C.VerticalOrigin.BOTTOM ? height / 2 : 0); + // A clamped billboard keeps its file height and Cesium clamps it + // while drawing; the import draws its pins at the sampled ground. + // Both compare by longitude and latitude. + const clamped = + (value(b.heightReference) ?? C.HeightReference.NONE) !== + C.HeightReference.NONE; rows.push({ kind: 'pin', - position: xyz(value(e.position)), + position: (clamped ? lonLat : xyz)(value(e.position)), size: 2 * Math.max( @@ -184,7 +190,9 @@ try { for (const item of r.items) rows.push({ kind: 'pin', - position: xyz(item.position), + position: (r.options.depthTest === 'always' ? lonLat : xyz)( + item.position, + ), size: r.options.sprite.sizePx, rotation: item.rotation, color: item.color, diff --git a/scripts/qa-geo-import-regressions.mjs b/scripts/qa-geo-import-regressions.mjs index 6d6819ea7..9bd3b2435 100644 --- a/scripts/qa-geo-import-regressions.mjs +++ b/scripts/qa-geo-import-regressions.mjs @@ -19,7 +19,9 @@ const meshBytes = Buffer.alloc(60); const fixture = { '/__qa_import_surface/tileset.json': { asset: { version: '1.1' }, - geometricError: 0, + // Cesium draws no tileset whose own error is 0: the mesh must draw to be + // the rendered surface. + geometricError: 10000, root: { transform: Cesium.Matrix4.toArray( Cesium.Transforms.eastNorthUpToFixedFrame( @@ -186,14 +188,28 @@ try { }, 'xyz.geojson', ); + // The file's 100 m is dropped: the pin is on the ground sampled under + // it, the globe's tiles drawn (here the ellipsoid, 0 m, once they have + // loaded and the import has read them again). + const pinHeight = () => + C.Cartographic.fromCartesian(pins(r)[0].position).height; + const groundDeadline = performance.now() + 15000; + while ( + Math.abs(pinHeight()) >= 0.01 && + performance.now() < groundDeadline + ) { + viewer.scene.requestRender(); + await new Promise((resolve) => setTimeout(resolve, 100)); + } const pin = pins(r)[0], polygon = ground(r)[0]; check( 'XYZ ground footprint and isolated pick IDs', r.outcome.ok && pin.pickId === undefined && - Math.abs(C.Cartographic.fromCartesian(pin.position).height) < 0.01 && + Math.abs(pinHeight()) < 0.01 && polygon.rings.flat().every((p) => p.length === 2), + { pinHeight: pinHeight() }, ); check( 'ground pins use fixed positions and ignore scene depth', diff --git a/scripts/qa-geo-import.mjs b/scripts/qa-geo-import.mjs index 49b1bc3bf..ab7b0d641 100644 --- a/scripts/qa-geo-import.mjs +++ b/scripts/qa-geo-import.mjs @@ -364,6 +364,21 @@ try { )), `${JSON.stringify(clockBefore)} → ${JSON.stringify(clockAfter)}`, ); + // Over the files: their pins on the ground show unless beyond the + // horizon, so all of them show here. + await page.evaluate((view) => { + const viewer = window.__godsEyeView.viewer; + viewer.camera.cancelFlight?.(); + viewer.camera.setView({ + destination: window.__CESIUM__.Cartesian3.fromDegrees( + view.lon, + view.lat, + view.height, + ), + orientation: { heading: 0, pitch: (view.pitch * Math.PI) / 180, roll: 0 }, + }); + }, VIEW); + await wait(2500); const inside = await page.evaluate(() => { const groups = window.__qaImports.groups(); const pins = (group) => @@ -389,7 +404,8 @@ try { ].some((key) => key in r), ), fallbackPins: pins(kml).length, - datedPinsShown: pins(kml).every((pin) => pin.show), + datedPinsShown: + pins(kml).length > 0 && pins(kml).every((pin) => pin.show), kmlVertices: paths(kml).map((line) => line.positions.length), gpxLines: paths(gpx).length, gpxPoints: pins(gpx).length, @@ -442,19 +458,6 @@ try { 'XYZ polygons and KML/KMZ lines use ground geometry', inside.xyzPolygon && inside.groundedLines, ); - await page.evaluate((view) => { - const viewer = window.__godsEyeView.viewer; - viewer.camera.cancelFlight?.(); - viewer.camera.setView({ - destination: window.__CESIUM__.Cartesian3.fromDegrees( - view.lon, - view.lat, - view.height, - ), - orientation: { heading: 0, pitch: (view.pitch * Math.PI) / 180, roll: 0 }, - }); - }, VIEW); - await wait(2500); const cyanShown = await cyanPixels(); await shot('imported'); check( diff --git a/src/data/geoImportDisplay.js b/src/data/geoImportDisplay.js index 79e7068a1..41aeb5fc5 100644 --- a/src/data/geoImportDisplay.js +++ b/src/data/geoImportDisplay.js @@ -13,11 +13,15 @@ // ignored the depth test; // - ground overlays: one image imagery layer each over its rectangle. // Nothing is pickable: an imported file has no card. Ground heights under -// the points come from engine.surface, sampled again by refreshGround(). +// the points come from engine.surface, sampled again by refreshGround(): +// the terrain where the globe is drawn, else (3D tiles) the scene drawn +// under a low camera, never an imported point (as Cesium's import kept its +// pins out of height queries). import * as geo from '../geo/index.js'; import { normalizeGroundItem } from '../globe/ground.js'; import { composeIcon } from './geoImportPins.js'; +import { recordFootprint } from './geoImportRecords.js'; const POLYGON_ORDER = 0; const LINE_ORDER = 1; @@ -31,6 +35,13 @@ const HORIZON_LIFT_M = 2000; const CLAMP_TILES_BELOW_M = 250_000; const BATCH = 256; let serial = 0; +// Each engine's imported point sets, left out of each other's ground. +const pointSets = new WeakMap(); +const pointSetsOf = (engine) => { + let sets = pointSets.get(engine); + if (!sets) pointSets.set(engine, (sets = new Set())); + return sets; +}; const imageFromUrl = async (url) => { const image = new Image(); @@ -79,6 +90,7 @@ const lookKey = (icon, depth) => * @returns {Promise<{ report: { drawn: number, undrawn: number, * kinds: Object }, setShow: (visible: boolean) => void, * refreshGround: (signal?: AbortSignal) => Promise, + * footprint: () => Array<[number, number, number]>, * destroy: () => void }>} */ export async function drawImportedFile( @@ -111,7 +123,10 @@ export async function drawImportedFile( for (const set of groundSets.splice(0)) set.destroy(); for (const line of lines.splice(0)) line.destroy(); for (const layer of layers.splice(0)) layer.destroy(); - for (const look of looks.values()) look.set?.destroy(); + for (const look of looks.values()) { + pointSetsOf(engine).delete(look.set); + look.set?.destroy(); + } looks.clear(); } @@ -281,6 +296,7 @@ export async function drawImportedFile( : {}), }, }); + pointSetsOf(engine).add(look.set); drawn += look.pins.length; }), ); @@ -294,16 +310,15 @@ export async function drawImportedFile( } request(); - // Where heights come from now: the terrain, or the 3D tiles under a low - // camera when they are the drawn surface. + // Where heights come from now: the terrain, or the scene drawn under a + // low camera when the globe is not (3D tiles). function heightContext() { const surface = engine.surface; const tiles = - engine.imagery?.surface?.() === 'tiles' && + (engine.imagery?.surface?.() ?? 'globe') !== 'globe' && surface.clampToHeightSupported && engine.camera.height() < CLAMP_TILES_BELOW_M; - const exclude = [...looks.values()].map((look) => look.set).filter(Boolean); - return { surface, tiles, exclude }; + return { surface, tiles, exclude: [...pointSetsOf(engine)] }; } // The ground under a pin, falling back to the last known one. @@ -414,6 +429,21 @@ export async function drawImportedFile( syncCull(); request(); }, + /** + * What the file covers (recordFootprint), its points at the heights + * they draw at: on the ground sampled under them. + */ + footprint() { + const out = recordFootprint({ ...records, points: [] }); + for (const look of looks.values()) + for (const pin of look.pins) + out.push([ + pin.lon, + pin.lat, + pin.height ?? (pin.ground ?? 0) + pin.offset, + ]); + return out; + }, /** Sample the ground under the points again (a map or the terrain changed). */ async refreshGround(signal) { const total = [...looks.values()].reduce((n, l) => n + l.pins.length, 0); diff --git a/src/data/geoImportDisplay.test.mjs b/src/data/geoImportDisplay.test.mjs index 63ca7c9b0..c6ae3cc5d 100644 --- a/src/data/geoImportDisplay.test.mjs +++ b/src/data/geoImportDisplay.test.mjs @@ -182,6 +182,56 @@ for (const make of GROUND_HARNESSES) { }, ); + displayTest( + 'off the globe, ground pins sit on the scene drawn, never on an imported point', + async ({ engine, viewer, pins }) => { + // 3D tiles under a low camera: the globe is not drawn. + if (viewer) { + viewer.scene.globe.show = false; + viewer.scene.clampToHeightSupported = true; + } else engine.imagery.surface = () => 'tiles'; + engine.camera.height = () => 1000; + const excluded = []; + engine.surface.clampToHeight = (position, exclude) => { + excluded.push(exclude); + const at = geo.cartographic.fromCartesian(position); + return geo.cartesian3.fromRadians(at.longitude, at.latitude, 2000); + }; + const ground = await drawImportedFile( + engine, + geoJsonRecords({ type: 'Point', coordinates: [1, 2] }, '#39d0ff'), + { createCanvas }, + ); + const raised = geoJsonRecords( + { type: 'Point', coordinates: [1, 2, 5000] }, + '#39d0ff', + ); + raised.points[0].altitude = 'absolute'; + const high = await drawImportedFile(engine, raised, { createCanvas }); + const height = () => + Math.round( + geo.cartographic.fromCartesian(pins[0].items[0].position).height, + ); + assert.equal(height(), 2000); + assert.deepEqual( + ground.footprint().map((p) => p.map(Math.round)), + [[1, 2, 2000]], + 'a flight frames the pin where it draws', + ); + await ground.refreshGround(); + assert.equal(height(), 2000); + assert.deepEqual( + new Set(excluded.at(-1)), + new Set(pins.map(({ set }) => set)), + "another file's point is not ground", + ); + high.destroy(); + await ground.refreshGround(); + assert.deepEqual(excluded.at(-1), [pins[0].set]); + ground.destroy(); + }, + ); + displayTest( 'fills keep holes, raised outlines and plain overlays', async ({ engine, set }) => { diff --git a/src/data/geoImportKml.test.mjs b/src/data/geoImportKml.test.mjs index ee04656e7..c97aed21a 100644 --- a/src/data/geoImportKml.test.mjs +++ b/src/data/geoImportKml.test.mjs @@ -108,6 +108,29 @@ const STYLED = kml(` deduplicated a#a3,3 4,4 deduplicated b#b4,4 5,5`); +// scripts/qa-geo-import-large.mjs's style check: one shared style with +// point, line and polygon parts, each placemark overriding one part inline, +// and named styles and an inline StyleMap pair that are alike. +const SHARED = kml(` + + shared point#base + + -122.49,37.77,0 + shared line#base + + -122.50,37.77,0 -122.49,37.77,0 + shared polygon#base + + -122.50,37.77,0 -122.49,37.77,0 -122.49,37.78,0 -122.50,37.77,0 + + + normal + named-a#named-a-122.49,37.77,0 + named-b#named-b-122.49,37.77,0 + paired#paired-122.49,37.77,0`); + /** A document as the import prepares it for reading. */ function prepared(text) { const doc = parseXml(text, 'KML'); @@ -303,6 +326,7 @@ function ourReading(text) { for (const [name, text] of [ ['the QA file', QA], ['styles, altitudes, multigeometries and overlays', STYLED], + ['shared styles with inline overrides, named and paired', SHARED], ]) { test(`KML records match Cesium's reading: ${name}`, async () => { const cesium = await cesiumReading(text); diff --git a/src/globe/cesium/imagery.js b/src/globe/cesium/imagery.js index 3c9cf0d7c..26eedcd5f 100644 --- a/src/globe/cesium/imagery.js +++ b/src/globe/cesium/imagery.js @@ -14,7 +14,9 @@ // over the drawn one, whose tiles Cesium loads while the drawn one shows; // it takes the drawn one's place once its own requests are in and quiet. // A raised layer is a shell primitive instead (imageryShell.js), on every -// surface. +// surface. Cesium stretches the edge texels of its base layer (the first +// layer shown on the globe) over the whole globe: an engine layer that +// covers less than the globe never becomes it, a floor lies under it then. import * as Cesium from 'cesium'; import { createImageryRegistry, isPixelImage } from '../imagery.js'; @@ -35,6 +37,22 @@ const STAGE_RETRIES = 3; const rectangleOf = ({ west, south, east, north }) => Cesium.Rectangle.fromDegrees(west, south, east, north); +const GLOBE = Object.freeze({ west: -180, south: -90, east: 180, north: 90 }); +// The floor's image: one transparent pixel, as a texture source Cesium +// uploads without a canvas. +const CLEAR_PIXEL = Object.freeze({ + width: 1, + height: 1, + arrayBufferView: new Uint8Array(4), +}); +// Whether a source draws on the whole globe (Web Mercator tiles stop at +// ±85.05°). +const coversGlobe = (source) => + !(source.kind === 'tiles' && source.scheme !== 'geographic') && + Object.entries(GLOBE).every( + ([edge, value]) => source.rectangle[edge] === value, + ); + const defaultCanvas = (width, height) => Object.assign(document.createElement('canvas'), { width, height }); @@ -161,6 +179,11 @@ export function createCesiumImagery( const { scene } = viewer; const entries = new Set(); let removeWatch = null; + let removeGuard = null; + // { layer, collection }: one transparent tile over the globe at alpha 0 + // (Cesium draws nothing of it), the globe's base layer while an engine + // layer covering less than the globe would be. + let floor = null; const now = () => globalThis.performance?.now() ?? Date.now(); function host() { @@ -188,6 +211,60 @@ export function createCesiumImagery( else if (!layer.isDestroyed?.()) layer.destroy?.(); } + // The source an engine layer draws, or null for a layer of others. + function sourceOf(layer) { + for (const entry of entries) { + if (entry.layer === layer) return entry.state.source; + if (entry.staging?.layer === layer) return entry.staging.source; + } + return null; + } + + function dropFloor() { + if (!floor) return; + const { layer, collection } = floor; + floor = null; + if (usable(collection) && collection.contains(layer)) + collection.remove(layer, true); + else if (!layer.isDestroyed?.()) layer.destroy?.(); + } + + // Before Cesium picks the base layer (each frame, and after each change + // here): the floor under the first layer shown on the globe when that is + // an engine layer covering less than the globe, else none. + function guardBase() { + const { collection, surface } = host(); + const globe = + surface === 'globe' && + usable(collection) && + typeof collection.get === 'function' + ? collection + : null; + if (floor && floor.collection !== globe) dropFloor(); + if (!globe) return; + let base = null; + for (let i = 0; i < globe.length && !base; i++) { + const layer = globe.get(i); + if (layer !== floor?.layer && layer.show) base = layer; + } + const source = base && sourceOf(base); + if (!source || coversGlobe(source)) { + dropFloor(); + return; + } + if (floor) { + if (globe.indexOf(floor.layer) > globe.indexOf(base)) + globe.lowerToBottom(floor.layer); + return; + } + const layer = make.layer( + singleImageProvider(CLEAR_PIXEL, rectangleOf(GLOBE), undefined), + { alpha: 0, show: true }, + ); + globe.add(layer, 0); + floor = { layer, collection: globe }; + } + // Above every engine layer under it in draw order, below every one over // it; at the top when none is over it. function place(entry, layer, collection) { @@ -276,6 +353,7 @@ export function createCesiumImagery( layer.show = state.shown; layer.splitDirection = Cesium.SplitDirection?.[SPLIT[state.split]] ?? layer.splitDirection; + guardBase(); } // A raised layer's place among the shells: the engine's draw order. @@ -363,6 +441,7 @@ export function createCesiumImagery( const { collection } = host(); if (!usable(collection) || (source.kind === 'image' && !entry.image)) { detach(entry); + guardBase(); return; } const layer = make.layer(providerFor(entry, source), { @@ -484,6 +563,7 @@ export function createCesiumImagery( if (entry.layer && entry.collection === collection) collection.add(job.layer, collection.indexOf(entry.layer) + 1); else place(entry, job.layer, collection); + guardBase(); let settled = 0; job.offCamera = scene.camera?.moveEnd?.addEventListener(() => { // Tiles abandoned by the last view are no longer the source's work. @@ -537,6 +617,7 @@ export function createCesiumImagery( if (usable(collection) && collection.contains(layer)) collection.remove(layer, true); else if (!layer.isDestroyed?.()) layer.destroy?.(); + guardBase(); } // The staged source takes the drawn one's place. @@ -612,11 +693,17 @@ export function createCesiumImagery( const watch = () => { if (!removeWatch && scene.preRender) removeWatch = scene.preRender.addEventListener(follow); + // Cesium picks the base layer before the frame's preRender. + if (!removeGuard && scene.preUpdate) + removeGuard = scene.preUpdate.addEventListener(guardBase); }; const unwatch = () => { - if (entries.size || !removeWatch) return; - removeWatch(); + if (entries.size) return; + removeWatch?.(); removeWatch = null; + removeGuard?.(); + removeGuard = null; + dropFloor(); }; // One timer per layer: any number of provider deferrals while it is @@ -768,6 +855,7 @@ export function createCesiumImagery( entry.shell = null; entry.detailWaiting = null; detach(entry); + guardBase(); unwatch(); requestRender('imagery-destroy'); }, diff --git a/src/globe/cesium/imagery.test.mjs b/src/globe/cesium/imagery.test.mjs index ba97e29f7..68ce216e8 100644 --- a/src/globe/cesium/imagery.test.mjs +++ b/src/globe/cesium/imagery.test.mjs @@ -57,11 +57,14 @@ function manualTimers() { }; } -function fixture({ requestImage } = {}) { +// `base`: the globe has base imagery under the engine's layers, as the map +// gives it. +function fixture({ requestImage, base = true } = {}) { const preRender = new Cesium.Event(); + const preUpdate = new Cesium.Event(); const viewer = { imageryLayers: new Cesium.ImageryLayerCollection(), - scene: { globe: { show: true }, preRender, splitPosition: 0.5 }, + scene: { globe: { show: true }, preRender, preUpdate, splitPosition: 0.5 }, }; const renders = []; const timers = manualTimers(); @@ -94,8 +97,17 @@ function fixture({ requestImage } = {}) { clearTimeout: timers.clearTimeout, }); const globe = viewer.imageryLayers; - const frame = () => preRender.raiseEvent(); + const baseLayer = base + ? new Cesium.ImageryLayer(new PendingProvider({})) + : null; + if (baseLayer) globe.add(baseLayer); + // A frame: preUpdate, then (Cesium picks the base layer) preRender. + const frame = () => { + preUpdate.raiseEvent(); + preRender.raiseEvent(); + }; return { + base: baseLayer, viewer, imagery, destroy, @@ -111,6 +123,8 @@ function fixture({ requestImage } = {}) { const layersOf = (collection) => Array.from({ length: collection.length }, (_, i) => collection.get(i)); +/** The layers on the globe over the fixture's base imagery. */ +const drawnOn = (f) => layersOf(f.globe).filter((layer) => layer !== f.base); test('a tiles layer is one Web Mercator template provider bounded to its rectangle', () => { const f = fixture(); @@ -120,7 +134,7 @@ test('a tiles layer is one Web Mercator template provider bounded to its rectang alpha: 0.8, split: 'left', }); - const [drawn] = layersOf(f.globe); + const [drawn] = drawnOn(f); const { options } = drawn.imageryProvider; assert.equal(options.url, 'https://gibs-{s}.example/{z}/{y}/{x}.png'); assert.deepEqual(options.subdomains, ['a', 'b', 'c']); @@ -141,7 +155,7 @@ test('a tiles layer is one Web Mercator template provider bounded to its rectang layer.setAlpha(0.25); layer.show(false); layer.setSplit('right'); - assert.deepEqual(layersOf(f.globe), [drawn], 'a restyle keeps the layer'); + assert.deepEqual(drawnOn(f), [drawn], 'a restyle keeps the layer'); assert.deepEqual( [drawn.alpha, drawn.show, drawn.splitDirection], [0.25, false, Cesium.SplitDirection.RIGHT], @@ -149,7 +163,7 @@ test('a tiles layer is one Web Mercator template provider bounded to its rectang // A new source replaces the layer at its place, keeping the look. f.imagery.createLayer({ id: 'over', source: tiles(), order: 1 }); layer.setSource(tiles({ url: 'https://other.example/{z}/{x}/{y}.png' })); - const [replaced, over] = layersOf(f.globe); + const [replaced, over] = drawnOn(f); assert.equal(drawn.isDestroyed(), true); assert.equal( replaced.imageryProvider.options.url, @@ -158,14 +172,14 @@ test('a tiles layer is one Web Mercator template provider bounded to its rectang assert.equal(over.imageryProvider.options.url, tiles().url); assert.deepEqual([replaced.alpha, replaced.show], [0.25, false]); f.destroy(); - assert.equal(f.globe.length, 0); + assert.equal(drawnOn(f).length, 0); }); test('an image layer is one geographic tile of the image itself over its rectangle', async () => { const f = fixture(); const image = pixels(8, 4); f.imagery.createLayer({ id: 'heat', source: { kind: 'image', image } }); - const provider = f.globe.get(0).imageryProvider; + const provider = drawnOn(f)[0].imageryProvider; assert.ok(provider.tilingScheme instanceof Cesium.GeographicTilingScheme); assert.deepEqual( [ @@ -190,7 +204,7 @@ test('an image layer is one geographic tile of the image itself over its rectang }); test('layers sit over the base imagery in their order, then creation; others stay put', () => { - const f = fixture(); + const f = fixture({ base: false }); const base = new Cesium.ImageryLayer(new PendingProvider({})); f.globe.add(base); f.imagery.createLayer({ id: 'high', source: tiles(), order: 5 }); @@ -201,7 +215,7 @@ test('layers sit over the base imagery in their order, then creation; others sta f.imagery.list().map(({ id }) => id), ['low', 'mid', 'mid-later', 'high'], ); - const layers = layersOf(f.globe); + const layers = drawnOn(f); assert.equal(layers[0], base); assert.equal(layers.length, 5); // Each engine layer's provider was made in creation order. @@ -212,6 +226,85 @@ test('layers sit over the base imagery in their order, then creation; others sta assert.deepEqual(drawnOrder, ['low', 'mid', 'mid-later', 'high']); }); +test('a layer covering less than the globe never becomes the stretched base layer', async () => { + const f = fixture({ base: false }); + // Cesium marks the first layer shown as the base layer each frame and + // stretches its edge texels over the globe. + const baseLayer = () => { + f.globe._update(); + return layersOf(f.globe).find((layer) => layer.isBaseLayer()); + }; + const world = f.imagery.createLayer({ + id: 'world', + source: { kind: 'image', image: pixels() }, + }); + assert.equal(f.globe.length, 1, 'a layer over the globe needs no floor'); + assert.equal(baseLayer(), f.globe.get(0)); + world.destroy(); + + const layer = f.imagery.createLayer({ + id: 'overlay', + source: { kind: 'image', image: pixels(), rectangle: BOX }, + }); + const [floor, drawn] = layersOf(f.globe); + assert.equal(f.globe.length, 2); + assert.equal(baseLayer(), floor, 'a floor lies under the bounded layer'); + assert.deepEqual([floor.show, floor.alpha], [true, 0], 'it draws nothing'); + const provider = floor.imageryProvider; + assert.ok( + Cesium.Rectangle.equalsEpsilon( + provider.rectangle, + Cesium.Rectangle.fromDegrees(-180, -90, 180, 90), + 1e-12, + ), + ); + assert.deepEqual([provider.tileWidth, provider.tileHeight], [1, 1]); + assert.deepEqual( + [...(await provider.requestImage(0, 0, 0)).arrayBufferView], + [0, 0, 0, 0], + ); + + // Base imagery under it (the map's) is the base layer: no floor. + const map = new Cesium.ImageryLayer(new PendingProvider({})); + f.globe.add(map, 0); + f.frame(); + assert.deepEqual(layersOf(f.globe), [map, drawn]); + assert.equal(floor.isDestroyed(), true); + // Hidden, the floor is back before Cesium picks the base layer. + map.show = false; + f.viewer.scene.preUpdate.raiseEvent(); + assert.equal(baseLayer(), f.globe.get(0)); + assert.notEqual(baseLayer(), drawn); + assert.equal(f.globe.length, 3); + map.show = true; + f.frame(); + assert.deepEqual(layersOf(f.globe), [map, drawn]); + f.globe.remove(map); + + // Web Mercator tiles stop at ±85.05°: they get a floor too. + layer.destroy(); + assert.equal(f.globe.length, 0, 'the floor goes with the last layer'); + const mercator = f.imagery.createLayer({ + id: 'tiles', + source: tiles({ rectangle: undefined }), + }); + assert.equal(f.globe.length, 2); + assert.equal(baseLayer().alpha, 0); + // A hidden layer is not the base layer: no floor. + mercator.show(false); + assert.equal(f.globe.length, 1); + mercator.show(true); + assert.equal(f.globe.length, 2); + // On 3D tiles the globe keeps no floor. + f.viewer.scene.globe.show = false; + f.tileset.show = true; + f.frame(); + assert.equal(f.globe.length, 0); + assert.equal(f.tileset.imageryLayers.length, 1); + f.destroy(); + assert.equal(f.viewer.scene.preUpdate.numberOfListeners, 0, 'unwatched'); +}); + test('the layers follow the surface: the globe, a shown tileset, none, and back', () => { const f = fixture(); const layer = f.imagery.createLayer({ @@ -225,7 +318,7 @@ test('the layers follow the surface: the globe, a shown tileset, none, and back' f.tileset.show = true; assert.equal(f.imagery.surface(), 'tiles'); f.frame(); - assert.equal(f.globe.length, 0, 'the globe stack is left empty'); + assert.equal(drawnOn(f).length, 0, 'the globe stack is left empty'); const [onTiles] = layersOf(f.tileset.imageryLayers); assert.deepEqual( [onTiles.alpha, onTiles.splitDirection], @@ -238,10 +331,10 @@ test('the layers follow the surface: the globe, a shown tileset, none, and back' assert.equal(layer.ready(), false, 'no surface: nothing drawn'); f.viewer.scene.globe.show = true; f.frame(); - assert.equal(f.globe.length, 1); + assert.equal(drawnOn(f).length, 1); assert.equal(layer.ready(), true); layer.destroy(); - assert.equal(f.globe.length, 0); + assert.equal(drawnOn(f).length, 0); assert.equal(f.viewer.scene.preRender.numberOfListeners, 0, 'unwatched'); }); @@ -260,7 +353,7 @@ test('the split position is the scene’s, set by the engine or the shell', () = test('above maxRequests a tile request defers silently; its settlement asks for one frame', async () => { const f = fixture(); f.imagery.createLayer({ id: 'a', source: tiles({ maxRequests: 1 }) }); - const provider = f.globe.get(0).imageryProvider; + const provider = drawnOn(f)[0].imageryProvider; const first = provider.requestImage(0, 0, 1); assert.ok(first instanceof Promise); for (let i = 1; i <= 120; i += 1) @@ -290,7 +383,7 @@ test('provider deferrals share one delayed retry frame per layer; destroy disarm id: 'a', source: tiles({ maxRequests: 1 }), }); - const provider = f.globe.get(0).imageryProvider; + const provider = drawnOn(f)[0].imageryProvider; for (let i = 0; i < 40; i += 1) assert.equal(provider.requestImage(i, 0, 0), undefined); assert.equal(f.timers.pending.size, 1); @@ -317,6 +410,8 @@ test('an image URL draws once it has loaded; a newer source wins', async () => { imageryLayers: new Cesium.ImageryLayerCollection(), scene: { globe: { show: true }, preRender }, }; + // The map's base imagery. + viewer.imageryLayers.add(new Cesium.ImageryLayer(new PendingProvider({}))); const { imagery } = createCesiumImagery(viewer, { requestRender() {}, factories: { @@ -328,17 +423,17 @@ test('an image URL draws once it has loaded; a newer source wins', async () => { id: 'event', source: { kind: 'image', url: 'before.webp', rectangle: BOX }, }); - assert.equal(viewer.imageryLayers.length, 0); + assert.equal(viewer.imageryLayers.length, 1); assert.equal(layer.ready(), false); layer.setSource({ kind: 'image', url: 'after.webp', rectangle: BOX }); loads[0].resolve({ width: 2, height: 2, name: 'before' }); await Promise.resolve(); - assert.equal(viewer.imageryLayers.length, 0, 'a superseded load is dropped'); + assert.equal(viewer.imageryLayers.length, 1, 'a superseded load is dropped'); loads[1].resolve({ width: 2, height: 2, name: 'after' }); await new Promise((resolve) => setImmediate(resolve)); - assert.equal(viewer.imageryLayers.length, 1); + assert.equal(viewer.imageryLayers.length, 2); const image = await viewer.imageryLayers - .get(0) + .get(1) .imageryProvider.requestImage(0, 0, 0); assert.equal(image.name, 'after'); assert.equal(layer.ready(), true); @@ -357,7 +452,7 @@ test('geographic tiles take Cesium’s 2×1 tiling; a transform draws each tile' }, }), }); - const provider = f.globe.get(0).imageryProvider; + const provider = drawnOn(f)[0].imageryProvider; assert.ok( provider.options.tilingScheme instanceof Cesium.GeographicTilingScheme, ); @@ -380,7 +475,7 @@ function stagingFixture() { test('a staged source loads at alpha 0 over the drawn one and takes its place once its tiles are in', async () => { const f = stagingFixture(); const layer = f.imagery.createLayer({ id: 'a', source: tiles(), alpha: 0.7 }); - const drawn = f.globe.get(0); + const drawn = drawnOn(f)[0]; const staged = layer.setSource( tiles({ url: 'https://next.example/{z}/{x}/{y}.png' }), { staged: true }, @@ -388,8 +483,8 @@ test('a staged source loads at alpha 0 over the drawn one and takes its place on let swapped; staged.then((value) => (swapped = value)); await new Promise((resolve) => setImmediate(resolve)); - assert.equal(f.globe.length, 2); - const incoming = f.globe.get(1); + assert.equal(drawnOn(f).length, 2); + const incoming = drawnOn(f)[1]; assert.equal(incoming.alpha, 0, 'the staged layer loads unseen'); assert.equal(drawn.alpha, 0.7, 'the drawn layer stays'); const request = incoming.imageryProvider.requestImage(0, 0, 1); @@ -404,9 +499,9 @@ test('a staged source loads at alpha 0 over the drawn one and takes its place on assert.equal(swapped, true); assert.equal(incoming.alpha, 0.7); assert.match(layer.source().url, /next/); - assert.equal(f.globe.length, 2, 'the drawn one goes after the next frame'); + assert.equal(drawnOn(f).length, 2, 'the drawn one goes after the next frame'); f.render(); - assert.deepEqual(layersOf(f.globe), [incoming]); + assert.deepEqual(drawnOn(f), [incoming]); assert.equal(drawn.isDestroyed(), true); }); @@ -418,7 +513,7 @@ test('a staged source whose tile fails leaves the drawn one', async () => { { staged: true }, ); await new Promise((resolve) => setImmediate(resolve)); - const incoming = f.globe.get(1); + const incoming = drawnOn(f)[1]; // Throttled tiles retry; a missing one fails the source. const throttled = { x: 0, y: 0, level: 1, error: { statusCode: 429 } }; incoming.imageryProvider.errorEvent.raiseEvent(throttled); @@ -431,7 +526,7 @@ test('a staged source whose tile fails leaves the drawn one', async () => { }); f.render(); assert.equal(await staged, false); - assert.equal(f.globe.length, 1); + assert.equal(drawnOn(f).length, 1); assert.match(layer.source().url, /gibs/); }); diff --git a/src/layers/recentImagery/render/display.test.mjs b/src/layers/recentImagery/render/display.test.mjs index af6af15e8..5e60aa3b1 100644 --- a/src/layers/recentImagery/render/display.test.mjs +++ b/src/layers/recentImagery/render/display.test.mjs @@ -45,6 +45,19 @@ function fakeHarness() { function cesiumHarness() { const { engine, viewer } = createCesiumMoversEngine(); viewer.imageryLayers = new Cesium.ImageryLayerCollection(); + // The map's base imagery, under the slot's layers. + const tilingScheme = new Cesium.WebMercatorTilingScheme(); + const base = new Cesium.ImageryLayer({ + tilingScheme, + rectangle: tilingScheme.rectangle, + tileWidth: 256, + tileHeight: 256, + minimumLevel: 0, + maximumLevel: 18, + errorEvent: new Cesium.Event(), + requestImage: () => undefined, + }); + viewer.imageryLayers.add(base); viewer.scene.globe.show = true; viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection(); const side = (direction) => @@ -57,8 +70,8 @@ function cesiumHarness() { name: 'the Cesium engine', engine, drawn: () => - Array.from({ length: viewer.imageryLayers.length }, (_, i) => { - const layer = viewer.imageryLayers.get(i); + Array.from({ length: viewer.imageryLayers.length - 1 }, (_, i) => { + const layer = viewer.imageryLayers.get(i + 1); const provider = layer.imageryProvider; const { west, south, east, north } = provider.rectangle; return { diff --git a/src/ui/displayPanel.test.mjs b/src/ui/displayPanel.test.mjs index fe8961af7..a668271aa 100644 --- a/src/ui/displayPanel.test.mjs +++ b/src/ui/displayPanel.test.mjs @@ -436,6 +436,7 @@ test('the imported files render what the file import publishes and keep focus', report: { undrawn: 0 }, setShow() {}, refreshGround: async () => {}, + footprint: () => [], destroy() {}, }), load: async (file) => ({ diff --git a/src/ui/geoFileImport.js b/src/ui/geoFileImport.js index d192b1307..1e5acb468 100644 --- a/src/ui/geoFileImport.js +++ b/src/ui/geoFileImport.js @@ -5,7 +5,6 @@ import { loadGeoFile, } from '../data/geoFileImport.js'; import { drawImportedFile } from '../data/geoImportDisplay.js'; -import { recordFootprint } from '../data/geoImportRecords.js'; import { formatNumber, t, tn, translateKnown } from '../i18n/index.js'; import { flyToFootprint } from './flyToDataSource.js'; @@ -29,7 +28,7 @@ function localizedValues(values) { * `load(file, {color, signal})` defaults to `loadGeoFile` (the file's * plain records) and `drawFile(engine, records, {visible})` to * `drawImportedFile` (their drawing: { report, setShow, refreshGround, - * destroy }); tests pass doubles. `publish({rows, hint})` hands the file + * footprint, destroy }); tests pass doubles. `publish({rows, hint})` hands the file * list and hint to the view that renders them. `connect(actions)` answers * the view's pick and row buttons and returns their disconnect. * @@ -194,7 +193,7 @@ export function createGeoFileImport({ const fly = (item) => { if (!scene?.engine || !item.drawing) return false; if (!item.visible) setVisible(item.id, true); - flyToSource(scene.engine, recordFootprint(item.records), { ground }); + flyToSource(scene.engine, item.drawing.footprint(), { ground }); return true; }; diff --git a/src/ui/geoFileImport.test.mjs b/src/ui/geoFileImport.test.mjs index 07e872940..0d60f3986 100644 --- a/src/ui/geoFileImport.test.mjs +++ b/src/ui/geoFileImport.test.mjs @@ -10,7 +10,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import { createGeoFileImport, summarize } from './geoFileImport.js'; import { GeoImportError, MAX_IMPORTED_FILES } from '../data/geoFileImport.js'; -import { emptyRecords } from '../data/geoImportRecords.js'; +import { emptyRecords, recordFootprint } from '../data/geoImportRecords.js'; class FakeClassList { constructor() { @@ -123,6 +123,7 @@ function setup({ report: { drawn: 1, undrawn: records.undrawn?.left ?? 0 }, setShow: (next) => (drawing.show = next), refreshGround: async () => drawing.refreshed++, + footprint: () => recordFootprint(records), destroy: () => removed.push({ ds: drawing }), }; added.push(drawing); @@ -774,6 +775,7 @@ test('a drawing that fails reports the file instead of stopping the batch', asyn report: { undrawn: 0 }, setShow() {}, refreshGround: async () => {}, + footprint: () => [], destroy() {}, }; },