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() {}, }; },