diff --git a/scripts/qa-wildlife-switch.mjs b/scripts/qa-wildlife-switch.mjs index 4d4024ff8..86508f37b 100644 --- a/scripts/qa-wildlife-switch.mjs +++ b/scripts/qa-wildlife-switch.mjs @@ -16,7 +16,7 @@ import { // Wildlife Sightings over Cesium → Navara → Cesium → Navara: on each engine // the four fixture sightings are badges in engine.movers at their positions // (each one picks as itself), the density glow is its own sprite set, and -// the obscured record's privacy cell is an outline on engine.lines; a +// the obscured record's privacy cell is a fill and outline on engine.ground; a // pointer click on a badge selects that sighting and opens its card. // // The first two switches are made from the keyboard with the card open (Tab @@ -30,7 +30,7 @@ import { const LAYER = 'wildlife-sightings'; const BADGES = 'wildlife-sightings'; const GLOW = 'wildlife-sightings-density'; -const CELL_LINE = 'wildlife-sightings-cell:'; +const CELL_SET = 'wildlife-sightings-cells'; // One density cell per iNaturalist observation (Acartia has no grid). const GLOW_CELLS = WILDLIFE_SIGHTINGS.filter((s) => s.id.startsWith('inat:'), @@ -113,10 +113,10 @@ await strict.run({}, async (b) => { .filter((set) => set.id.startsWith('wildlife-sightings')) .map((set) => [set.id, { kind: set.kind, count: set.count }]), ); - const cells = engine.lines + const cells = engine.ground .list() - .filter((line) => line.id.startsWith(prefix)) - .map(({ points, shown }) => ({ points, shown })); + .filter((set) => set.id === prefix) + .map(({ kind, count, shown }) => ({ kind, count, shown })); const badges = ids.map((id) => { const position = window.qaWild.getSightingPosition(id); const screen = @@ -149,7 +149,7 @@ await strict.run({}, async (b) => { return { sets, cells, badges }; }, [...byId.keys()], - CELL_LINE, + CELL_SET, ); // Every badge in engine.movers and picked as itself, the glow drawn and // the privacy cell outlined (the engines draw a few frames after the data). @@ -164,7 +164,8 @@ await strict.run({}, async (b) => { const ok = drawn.sets[BADGES]?.count === byId.size && drawn.sets[GLOW]?.count === GLOW_CELLS && - drawn.cells.length === CELLS.length && + drawn.cells.length === 1 && + drawn.cells[0].count === CELLS.length && drawn.badges.every( (badge) => badge.picked?.id === `wildlife:${badge.id}`, ); @@ -417,9 +418,10 @@ await strict.run({}, async (b) => { await strict.check( p, LAYER, - 'the obscured record’s privacy cell is a shown outline on engine.lines', - drawn.cells.length === CELLS.length && - drawn.cells.every((line) => line.shown && line.points === 33), + 'the obscured record’s privacy cell is a shown fill and outline on engine.ground', + drawn.cells.length === 1 && + drawn.cells[0].count === CELLS.length && + drawn.cells.every((set) => set.shown && set.kind === 'cell'), drawn.cells, ); console.log( diff --git a/src/layers/wildlifeSightings/ownership.test.mjs b/src/layers/wildlifeSightings/ownership.test.mjs index c11a67534..e82536cae 100644 --- a/src/layers/wildlifeSightings/ownership.test.mjs +++ b/src/layers/wildlifeSightings/ownership.test.mjs @@ -836,7 +836,22 @@ test('selection subscriptions retain omitted rows and clear on disable and destr unsubscribe(); }); -test('privacy cells draw outlines on the engine, replace changed batches and release them', async () => { +function watchCells(engine) { + let current; + const createSet = engine.ground.createSet.bind(engine.ground); + engine.ground.createSet = (options) => { + const set = createSet(options); + const { replace } = set; + set.replace = (items) => { + replace(items); + current = { set, items }; + }; + return set; + }; + return () => current; +} + +test('privacy cells draw on ground, replace changed batches and release them', async () => { let rows = [ { ...base, @@ -845,29 +860,27 @@ test('privacy cells draw outlines on the engine, replace changed batches and rel }, ]; const h = harness({ getSnapshot: async () => rows }); + const cells = watchCells(h.engine); await h.update(); - const first = h.engine.lines.list(); - assert.equal(first.length, 1); - assert.equal(h.frameWork(), 0, 'outlines need no frame work'); + const first = cells(); + assert.equal(h.engine.ground.list().length, 1); + assert.equal(h.frameWork(), 0, 'static cells need no frame work'); rows = [{ ...rows[0], iconic: 'Fungi' }]; await h.update(); - const second = h.engine.lines.list(); - assert.equal(second.length, 1); - assert.notEqual(second[0].id, first[0].id, 'a new accent replaces the line'); - assert.equal(h.engine.fake.line(first[0].id), undefined); + const second = cells(); + assert.equal(h.engine.ground.list().length, 1); + assert.notEqual(second.set, first.set, 'a new accent replaces the geometry'); + assert.notDeepEqual(second.items[0].fill, first.items[0].fill); + assert.throws(() => first.set.commit(), /destroyed/); h.layer.selectSighting(base.stableId); assert.equal(h.focus.at(-1).kind, 'sighting-area'); const selectedRing = h.badges()[0]; assert.deepEqual(selectedRing.color, [1, 1, 1, 1]); assert.equal(selectedRing.scale, 1); assert.match(h.badgeSet().options.sprite.atlas.url, /^data:image\/svg/); - assert.deepEqual( - h.engine.lines.list(), - second, - 'selection reuses unchanged privacy geometry', - ); + assert.equal(cells(), second, 'selection reuses unchanged privacy geometry'); h.layer.disable(); - assert.deepEqual(h.engine.lines.list(), []); + assert.deepEqual(h.engine.ground.list(), []); h.layer.destroy(); }); @@ -2185,6 +2198,7 @@ test('an engine switch keeps the data and selection and redraws the same badges fetchedAt: NOW, }), }); + const firstCells = watchCells(h.engine); await h.update(); assert.equal(h.layer.selectSighting('inat:1'), true); const drawn = (engine) => @@ -2221,12 +2235,13 @@ test('an engine switch keeps the data and selection and redraws the same badges const beforeStyle = style(first); const beforeGlow = glow(first); assert.equal(beforeGlow.count, 1); - const outline = first.fake.line(first.lines.list()[0].id); + const cell = firstCells(); const stats = h.layer.getStats(); h.layer.detachRenderer(); assert.deepEqual(first.movers.list(), [], 'nothing stays drawn'); assert.deepEqual(first.lines.list(), []); + assert.deepEqual(first.ground.list(), []); assert.equal(first.camera.moveEnd.numberOfListeners, 0); assert.equal(first.frames.preRender.numberOfListeners, 0); assert.equal(first.frames.postRender.numberOfListeners, 0); @@ -2236,14 +2251,17 @@ test('an engine switch keeps the data and selection and redraws the same badges const other = ENGINE_KINDS.find((kind) => kind !== h.engine.kind); const next = h.globe(other); + const nextCells = watchCells(next); assert.equal(h.layer.attachRenderer(null, { engine: next }), true); h.flushIdle(); assert.deepEqual(drawn(next), before, 'the same badges, drawn the same'); assert.deepEqual(style(next), beforeStyle); assert.deepEqual(glow(next), beforeGlow); - const redrawn = next.fake.line(next.lines.list()[0].id); - assert.deepEqual(redrawn.options.color, outline.options.color); - assert.deepEqual([...redrawn.positions], [...outline.positions]); + assert.deepEqual( + nextCells().items, + cell.items, + 'same draped cell colours and bounds', + ); assert.equal(requests, 1, 'the retained view needs no new request'); assert.equal(h.layer.getStats().count, stats.count); assert.equal( diff --git a/src/layers/wildlifeSightings/rendering.js b/src/layers/wildlifeSightings/rendering.js index 43f2caccb..68f439fe4 100644 --- a/src/layers/wildlifeSightings/rendering.js +++ b/src/layers/wildlifeSightings/rendering.js @@ -28,44 +28,9 @@ import { export const WILDLIFE_BADGE_SET_ID = 'wildlife-sightings'; export const WILDLIFE_BADGE_ORDER = SPRITE_LAYER_ORDER.indexOf('wildlife-sightings'); -const CELL_LINE_PREFIX = 'wildlife-sightings-cell:'; -const CELL_LINE_WIDTH_PX = 2; -// Points per privacy-cell edge, so long edges follow the surface. -const CELL_EDGE_STEPS = 8; +export const WILDLIFE_CELL_SET_ID = 'wildlife-sightings-cells'; -/** The closed outline of a privacy cell at `height` metres, as flat ECEF. */ -export function wildlifeCellOutline({ south, west, north, east }, height = 0) { - const corners = [ - [west, south], - [east, south], - [east, north], - [west, north], - [west, south], - ]; - const flat = new Float64Array((4 * CELL_EDGE_STEPS + 1) * 3); - let at = 0; - const scratch = geo.cartesian3.create(); - for (let edge = 0; edge < 4; edge++) { - const [lon0, lat0] = corners[edge]; - const [lon1, lat1] = corners[edge + 1]; - for (let step = 0; step < CELL_EDGE_STEPS + (edge === 3 ? 1 : 0); step++) { - const t = step / CELL_EDGE_STEPS; - geo.cartesian3.fromDegrees( - lon0 + (lon1 - lon0) * t, - lat0 + (lat1 - lat0) * t, - height, - undefined, - scratch, - ); - flat[at++] = scratch.x; - flat[at++] = scratch.y; - flat[at++] = scratch.z; - } - } - return flat; -} - -/** Own sighting badges, density glow and privacy-cell outlines on the engine. */ +/** Own sighting badges, density glow and draped privacy cells on the engine. */ export function createRendering({ state, services, parts }) { const badgeSprites = services.badgeSprites ?? wildlifeSpriteCache(); let batchDepth = 0; @@ -73,7 +38,6 @@ export function createRendering({ state, services, parts }) { let billboards = null; let density = null; let cellsSignature = null; - let cellLineSerial = 0; let ambientAnchorIds = new Set(); const badgeScreens = new Map(); const crowdedBadgeIds = new Set(); @@ -179,14 +143,6 @@ export function createRendering({ state, services, parts }) { point.show = show; changed = true; } - // A depth-free outline shows through the globe; keep only front ones. - for (const cell of state.cells) { - const shown = !horizon || horizon.isPointVisible(cell.center) === true; - if (cell.shown === shown) continue; - cell.shown = shown; - cell.line.show(shown); - changed = true; - } if (changed) requestRender('cull'); return changed; } @@ -326,13 +282,12 @@ export function createRendering({ state, services, parts }) { } function clearCells() { - for (const cell of state.cells) cell.line.destroy(); + for (const cell of state.cells) cell.destroy(); state.cells = []; cellsSignature = null; } - // Privacy cells as outlines in their taxon accent. Outlines sit at the - // cell's sampled ground height and draw through the scene, as the badges do. + // Privacy cells drape their taxon fill and outline on terrain and tiles. function stageCells(rows) { const cellsVisible = state.band === 'cells' || state.band === 'cards'; const unique = new Map(); @@ -349,25 +304,22 @@ export function createRendering({ state, services, parts }) { clearCells(); cellsSignature = signature; if (!unique.size) return true; - const horizon = occluder(); - for (const row of unique.values()) { - const { south, west, north, east } = row.cell; - const lat = (south + north) / 2; - const lon = (west + east) / 2; - const floor = services.groundFloor?.cachedGroundFloor?.(lat, lon); - const height = Number.isFinite(floor) ? floor : 0; - const line = state.engine.lines.createLine({ - id: `${CELL_LINE_PREFIX}${++cellLineSerial}`, - widthPx: CELL_LINE_WIDTH_PX, - color: rgbaFromCss(wildlifeAccent(row.iconic)).withAlpha(0.8).rgba(), - depthFailAlpha: 1, - }); - line.setPositions(wildlifeCellOutline(row.cell, height)); - const center = geo.cartesian3.fromDegrees(lon, lat, height); - const shown = !horizon || horizon.isPointVisible(center) === true; - if (!shown) line.show(false); - state.cells.push({ line, center, shown }); - } + const set = state.engine.ground.createSet({ + id: WILDLIFE_CELL_SET_ID, + kind: 'cell', + }); + state.cells.push(set); + set.replace( + [...unique.values()].map((row) => { + const tint = rgbaFromCss(wildlifeAccent(row.iconic)); + return { + ...row.cell, + fill: tint.withAlpha(0.25).rgba(), + outline: { color: tint.withAlpha(0.8).rgba(), widthPx: 2 }, + }; + }), + ); + set.commit(); return true; } diff --git a/src/layers/wildlifeSightings/rendering.test.mjs b/src/layers/wildlifeSightings/rendering.test.mjs index d19b74a83..232a8f89e 100644 --- a/src/layers/wildlifeSightings/rendering.test.mjs +++ b/src/layers/wildlifeSightings/rendering.test.mjs @@ -1,11 +1,16 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import * as geo from '../../geo/index.js'; +import { + createFakeGroundHarness, + createCesiumGroundHarness, +} from '../../testSupport/groundDisplayEngines.mjs'; import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import { createRendering, WILDLIFE_BADGE_ORDER, WILDLIFE_BADGE_SET_ID, + WILDLIFE_CELL_SET_ID, } from './rendering.js'; import { WILDLIFE_DENSITY_SET_ID } from './density.js'; import { wildlifeNearFarValue } from './display.js'; @@ -33,9 +38,12 @@ const latOf = (position) => (geo.cartographic.fromCartesian(position).latitude * 180) / Math.PI; /** The real rendering on a fake engine above the fixture, 100 px a degree. */ -function harness(rows = [base], { project, ...extra } = {}) { - const engine = createFakeEngine(); - engine.fake.setPose({ +function harness(rows = [base], { project, groundHarness, ...extra } = {}) { + const engine = groundHarness?.engine ?? createFakeEngine(); + engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(base.lon, base.lat, 30_000), + }); + engine.fake?.setPose({ longitude: degrees(base.lon), latitude: degrees(base.lat), height: 30_000, @@ -44,7 +52,7 @@ function harness(rows = [base], { project, ...extra } = {}) { roll: 0, }); let projections = 0; - engine.fake.script('worldToWindow', (position) => { + engine.projector.worldToWindow = (position) => { projections++; return project ? project(position) @@ -52,7 +60,7 @@ function harness(rows = [base], { project, ...extra } = {}) { x: 100 + (lonOf(position) - base.lon) * 100, y: 100 - (latOf(position) - base.lat) * 100, }; - }); + }; let visible = true; engine.projector.horizonVisible = () => visible; const counts = { created: 0, removed: 0, cleared: 0, writes: [] }; @@ -446,56 +454,81 @@ test('a selected observation remains rendered outside the viewport and observati h.rendering.destroy(); }); -test('privacy cells draw accent outlines through the scene and release them', () => { - const cell = { south: 47.6, west: -122.4, north: 47.8, east: -122.2 }; - const h = harness([{ ...base, obscured: true, cell }], { - groundFloor: { cachedGroundFloor: () => 250 }, +for (const make of [createFakeGroundHarness, createCesiumGroundHarness]) { + test(`privacy cells drape accent fills and outlines and release them (${make.name})`, () => { + const cell = { south: 47.6, west: -122.4, north: 47.8, east: -122.2 }; + const groundHarness = make(); + const h = harness( + [ + { ...base, obscured: true, cell }, + { ...base, stableId: 'duplicate', obscured: true, cell }, + ], + { groundHarness, groundFloor: { cachedGroundFloor: () => 250 } }, + ); + h.rendering.rebuild(); + const set = groundHarness.set(WILDLIFE_CELL_SET_ID); + assert.equal(set.options.kind, 'cell'); + assert.equal(set.options.drape, 'both'); + assert.equal(set.options.pickable, false); + assert.equal(set.items.length, 1, 'same bounds and taxon share a cell'); + const item = set.items[0]; + assert.deepEqual( + { + south: item.south, + west: item.west, + north: item.north, + east: item.east, + }, + cell, + ); + const tint = ICONIC_TINTS.Aves; + const rgb = [1, 3, 5].map((offset) => + parseInt(tint.slice(offset, offset + 2), 16), + ); + assert.deepEqual( + item.fill.map((v) => Math.round(v * 255)), + [...rgb, Math.round(0.25 * 255)], + ); + assert.deepEqual( + item.outline.color.map((v) => Math.round(v * 255)), + [...rgb, Math.round(0.8 * 255)], + ); + assert.equal(item.outline.widthPx, 2); + assert.equal(item.pickId, undefined, 'picks pass through the cell'); + assert.equal(set.shown, true); + assert.deepEqual(h.engine.lines.list(), [], 'ground owns the outline'); + if (groundHarness.cesium) { + const { fills, lines } = groundHarness.primitives(WILDLIFE_CELL_SET_ID); + assert.equal(fills.length, 1); + assert.equal(lines.length, 1); + assert.equal(fills[0].options.allowPicking, false); + assert.equal(lines[0].options.allowPicking, false); + } + h.rendering.rebuild(); + assert.equal( + groundHarness.set(WILDLIFE_CELL_SET_ID).commits, + 1, + 'unchanged cells keep their geometry', + ); + h.visible(false); + h.rendering.cull(); + assert.equal( + groundHarness.set(WILDLIFE_CELL_SET_ID).shown, + true, + 'ground handles horizon occlusion', + ); + h.visible(true); + h.state.band = 'regional'; + h.rendering.rebuild(); + assert.deepEqual(h.engine.ground.list(), [], 'wide views draw no cells'); + h.state.band = 'cards'; + h.rendering.rebuild(); + assert.equal(h.engine.ground.list().length, 1); + h.rendering.destroy(); + assert.deepEqual(h.engine.ground.list(), []); + groundHarness.destroy(); }); - h.rendering.rebuild(); - const lines = h.engine.lines.list(); - assert.equal(lines.length, 1); - const line = h.engine.fake.line(lines[0].id); - assert.equal(line.options.widthPx, 2); - assert.equal(line.options.depthFailAlpha, 1); - const tint = ICONIC_TINTS.Aves; - assert.deepEqual( - line.color.map((value) => Math.round(value * 255)), - [ - parseInt(tint.slice(1, 3), 16), - parseInt(tint.slice(3, 5), 16), - parseInt(tint.slice(5, 7), 16), - Math.round(0.8 * 255), - ], - ); - const flat = line.positions; - const first = geo.cartesian3.create(flat[0], flat[1], flat[2]); - const last = geo.cartesian3.create(...flat.slice(-3)); - assert.ok(geo.cartesian3.distance(first, last) < 1e-6, 'a closed ring'); - assert.ok(Math.abs(lonOf(first) - cell.west) < 1e-9); - assert.ok(Math.abs(latOf(first) - cell.south) < 1e-9); - assert.ok( - Math.abs(geo.cartographic.fromCartesian(first).height - 250) < 1e-3, - 'at the sampled ground height', - ); - assert.equal(line.shown, true); - // An unchanged cell set keeps its lines. - h.rendering.rebuild(); - assert.deepEqual(h.engine.lines.list(), lines); - h.visible(false); - h.rendering.cull(); - assert.equal(line.shown, false, 'outlines behind the globe hide'); - h.visible(true); - h.rendering.cull(); - assert.equal(line.shown, true); - h.state.band = 'regional'; - h.rendering.rebuild(); - assert.deepEqual(h.engine.lines.list(), [], 'wide views draw no cells'); - h.state.band = 'cards'; - h.rendering.rebuild(); - assert.equal(h.engine.lines.list().length, 1); - h.rendering.destroy(); - assert.deepEqual(h.engine.lines.list(), []); -}); +} test('font readiness swaps the atlas in place without badge recreation or position work', () => { const listeners = new Set();