diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c41e24ade..f12bfd1cd 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -184,8 +184,11 @@ one application, and only the globe-bound parts move to the next engine. on the real Cesium and Navara of a keyless dev server. On November 7, 2026 Cesium passed 57 of 57 and Navara 64 of 67: a resting move raised no `moveEnd`, a click payload never came, and contact sonar lost an angle-only - change inside the app. `movers` and `lines` are optional capabilities: only - Navara has them, and the suite skips their checks elsewhere. + change inside the app. `movers` and `lines` are required of every engine: + Cesium draws them with billboards, points, models and polyline primitives + (`src/globe/cesium/movers.js`, `lines.js`). The 57 Cesium checks above ran + without them; in Node their checks pass on Cesium without pixels or a + pointer, and the browser run with them is still to come. - **Engine coupling.** `node scripts/report-engine-coupling.mjs` counts the engine-specific code outside the engines: renderer lines per engine, Cesium imports, `viewer` mentions, engine-name checks, raw Navara reads, Navara diff --git a/scripts/format-scope.json b/scripts/format-scope.json index d30a2cf57..fbebe97f9 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1301,6 +1301,8 @@ "src/globe/cesium/credits.test.mjs", "src/globe/cesium/maps.test.mjs", "src/globe/cesium/createCesiumEngine.test.mjs", + "src/globe/cesium/movers.test.mjs", + "src/globe/cesium/lines.test.mjs", "src/globe/cesium/engineFromViewer.js", "src/globe/cesium/engineFromViewer.test.mjs", "src/globe/cesium/testing/fakeViewer.mjs", @@ -1344,6 +1346,8 @@ "src/globe/cesium/projector.js", "src/globe/cesium/render.js", "src/globe/cesium/surface.js", + "src/globe/cesium/movers.js", + "src/globe/cesium/lines.js", "src/globe/contract.js", "src/globe/cameraMath.js", "src/globe/cameraMath.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 23a304c03..4aecb5694 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3395,6 +3395,8 @@ "src/globe/cesium/projector.js", "src/globe/cesium/render.js", "src/globe/cesium/surface.js", + "src/globe/cesium/movers.js", + "src/globe/cesium/lines.js", "src/globe/cesium/engineFromViewer.js", "src/globe/cameraMath.js", "src/globe/navara/cameraModel.js", diff --git a/scripts/qa-navara-geo-import.mjs b/scripts/qa-navara-geo-import.mjs index e377841fa..e1de7cef8 100644 --- a/scripts/qa-navara-geo-import.mjs +++ b/scripts/qa-navara-geo-import.mjs @@ -176,8 +176,9 @@ await strict.run( rows: document.querySelectorAll('#geo-import-list .geo-import-row') .length, hint: document.getElementById('geo-import-hint')?.textContent || '', - // Movers are a Navara capability; Cesium draws the pins as entities. - pins: (window.__godsEyeView.engine.movers?.list() ?? []) + // Navara draws the pins as mover sets; Cesium as entities (none). + pins: window.__godsEyeView.engine.movers + .list() .filter(({ id }) => id.startsWith('geo-import-')) .reduce((n, set) => n + set.count, 0), })); diff --git a/src/app/qaProbe.js b/src/app/qaProbe.js index 8edf63377..7eb044963 100644 --- a/src/app/qaProbe.js +++ b/src/app/qaProbe.js @@ -14,7 +14,7 @@ // drawn(id) what the layer draws per object (glyph, model), // as plain data, or null (layers with qaDrawn) // following() { label, native } of engine.camera.following() -// movers() engine.movers.list() ([] without movers: Cesium) +// movers() engine.movers.list() // framesDrawn() frames drawn since the probe started const plain = (position) => @@ -83,7 +83,7 @@ export function createQaProbe({ engine, dataManager, availability }) { native: handle.native?.id ?? null, }; }, - movers: () => engine.movers?.list() ?? [], + movers: () => engine.movers.list(), framesDrawn: () => frames, destroy: () => removeFrames(), }; diff --git a/src/globe/cesium/createCesiumEngine.js b/src/globe/cesium/createCesiumEngine.js index 858e1ac89..5a9d72e32 100644 --- a/src/globe/cesium/createCesiumEngine.js +++ b/src/globe/cesium/createCesiumEngine.js @@ -3,14 +3,18 @@ // appear only when the shell uses them, and destroy() removes them again // (the viewer itself stays owned by the app). The one exception is the // engine clock's frame latch on viewer.clock.onTick (clock.js), attached at -// creation so it runs before onTick listeners added later. +// creation so it runs before onTick listeners added later. Mover sets and +// lines join the scene in one engine-owned collection each, with the first +// set or line. import { createCesiumCamera } from './camera.js'; import { createCesiumClock } from './clock.js'; import { createCesiumCredits } from './credits.js'; import { createCesiumFrames } from './frames.js'; import { createCesiumInput } from './input.js'; +import { createCesiumLines } from './lines.js'; import { createCesiumMaps } from './maps.js'; +import { createCesiumMovers } from './movers.js'; import { createCesiumPicker } from './picker.js'; import { createCesiumProjector } from './projector.js'; import { createCesiumRender } from './render.js'; @@ -25,8 +29,11 @@ import { createCesiumSurface } from './surface.js'; * createImageryLayer?: Function, * loadPhotorealTileset?: Function, * appClock?: import('../../time/clock.js').AppClock, + * moverFactories?: object, + * lineFactories?: object, * }} [options] `requestRender` defaults to the render governor; `appClock` - * to the page's app clock; the `create*`/`load*` options are test seams. + * to the page's app clock; the `create*`/`load*` options and the mover and + * line factories (movers.js, lines.js) are test seams. * * `render.setContinuous` takes a hold on the app's singleton renderGovernor * (src/renderGovernor.js), which governs the one viewer it was installed on; @@ -38,21 +45,38 @@ export function createCesiumEngine(viewer, options = {}) { const { canvas } = viewer; const render = createCesiumRender(viewer, options); const credits = createCesiumCredits(viewer); - const picker = createCesiumPicker(viewer); + const requestRender = (reason) => render.render.request(reason); + const movers = createCesiumMovers(viewer, { + requestRender, + ...(options.moverFactories ? { factories: options.moverFactories } : {}), + }); + const lines = createCesiumLines(viewer, { + requestRender, + ...(options.lineFactories ? { factories: options.lineFactories } : {}), + }); + // Picks name the mover set or line they hit, with its pickId. + const picker = createCesiumPicker(viewer, { + identify: (picked) => movers.identify(picked) ?? lines.identify(picked), + }); const parts = { camera: createCesiumCamera(viewer), picker, projector: createCesiumProjector(viewer), frames: createCesiumFrames(viewer), clock: createCesiumClock(viewer, options), + movers, + lines, render, - surface: createCesiumSurface(viewer), + // Mover sets in a surface `exclude` are hidden for the sample. + surface: createCesiumSurface(viewer, { + engineExclude: (entry) => movers.primitiveOf(entry), + }), input: createCesiumInput(viewer, { ...options, picker: picker.picker }), credits, maps: createCesiumMaps(viewer, { ...options, credits: credits.credits, - requestRender: (reason) => render.render.request(reason), + requestRender, }), }; let destroyed = false; diff --git a/src/globe/cesium/createCesiumEngine.test.mjs b/src/globe/cesium/createCesiumEngine.test.mjs index ccf6f2f24..e4c134ea7 100644 --- a/src/globe/cesium/createCesiumEngine.test.mjs +++ b/src/globe/cesium/createCesiumEngine.test.mjs @@ -7,7 +7,9 @@ import { getRenderGovernorDiagnostics, installRenderGovernor, } from '../../renderGovernor.js'; +import { createContractSuite } from '../testing/contractSuite.mjs'; import { createCesiumEngine } from './createCesiumEngine.js'; +import { createCameraViewer } from './testing/cameraViewer.mjs'; import { createFakeEventHandlers, createFakeViewer, @@ -154,3 +156,133 @@ test('the engine clock is the app clock given, latched on the viewer clock tick' engine.destroy(); assert.equal(viewer.clock.onTick.numberOfListeners, 0); }); + +// The Cesium collections under the engine's root collections. +function collectionsUnder(viewer) { + return viewer.primitiveList.flatMap((root) => + Array.from({ length: root.length }, (_, index) => root.get(index)), + ); +} + +test('movers and lines draw on the scene, answer picks with their pickId and go with the engine', async () => { + const geo = await import('../../geo/index.js'); + const viewer = createFakeViewer(); + const reasons = []; + const engine = createCesiumEngine(viewer, { + requestRender: (reason) => reasons.push(reason), + }); + const set = engine.movers.createSet({ + id: 'horizon-points', + pickable: true, + kind: 'point', + capacity: 2, + point: { sizePx: 8 }, + }); + // The fake camera is at x = 7e6 (over 0°, 0°): 0°E is in front, 180° behind. + set.set(0, { position: geo.cartesian3.fromDegrees(0, 1, 0), pickId: 'a' }); + set.set(1, { position: geo.cartesian3.fromDegrees(180, 1, 0), pickId: 'b' }); + set.setCount(2); + set.commit(); + const line = engine.lines.createLine({ + id: 'orbit', + widthPx: 2, + color: [1, 1, 1, 1], + pickId: 'orbit-1', + }); + assert.deepEqual(engine.movers.list(), [ + { id: 'horizon-points', kind: 'point', count: 2, capacity: 2 }, + ]); + assert.deepEqual(engine.lines.list(), [ + { id: 'orbit', points: 0, shown: true }, + ]); + assert.ok(reasons.includes('movers')); + assert.equal(viewer.primitiveList.length, 2, 'one root each'); + const [points] = collectionsUnder(viewer).filter( + (item) => item instanceof Cesium.PointPrimitiveCollection, + ); + const front = points.get(0); + const back = points.get(1); + const lineSlot = { gevLine: 'orbit' }; + viewer.scene.drillPick = () => [ + { primitive: back, id: 'b' }, + { primitive: front, id: 'stale' }, + { primitive: lineSlot, id: 'orbit-1' }, + ]; + assert.deepEqual( + engine.picker + .drillPick({ x: 1, y: 1 }) + .map(({ id, owner, instance }) => [id, owner, instance]), + [ + ['a', 'horizon-points', 0], + ['orbit-1', 'orbit', undefined], + ], + 'the instance behind the horizon is left out', + ); + viewer.scene.pick = () => ({ primitive: front, id: 'stale' }); + assert.equal(engine.picker.pick({ x: 1, y: 1 }).id, 'a'); + // A mover set in a surface exclusion is hidden for the sample only. + let shownDuring; + viewer.scene.sampleHeight = (position, excluded) => { + shownDuring = [points.show, excluded]; + return 5; + }; + const place = geo.cartographic.fromDegrees(0, 1, 0); + assert.equal(engine.surface.sampleHeight(place, [set]), 5); + assert.deepEqual(shownDuring, [false, []]); + assert.equal(points.show, true); + engine.destroy(); + assert.equal(points.isDestroyed(), true); + assert.deepEqual(viewer.primitiveList, []); + assert.deepEqual(engine.movers.list(), []); + assert.deepEqual(engine.lines.list(), []); + assert.equal(set.isDestroyed(), true); + assert.throws(() => line.show(false), /destroyed/); +}); + +test('the contract suite movers and lines checks pass on the Cesium engine (no pixels or pointer)', async (t) => { + // Cesium's Material sorts uniform values with instanceof checks against + // these DOM classes; Node has none of them. + for (const name of [ + 'HTMLCanvasElement', + 'HTMLImageElement', + 'HTMLVideoElement', + 'ImageBitmap', + 'OffscreenCanvas', + ]) + globalThis[name] ??= class {}; + const handlers = createFakeEventHandlers(); + const { scene, engine, frame } = createCameraViewer({ + engineOptions: { + createEventHandler: handlers.create, + // The atlas crop needs a DOM, the glTF load the network. + moverFactories: { + cropCells: async (atlas, keys) => + new Map(keys.map((key) => [key, { width: 16, height: 16 }])), + loadModel: async () => ({ + ready: true, + modelMatrix: new Cesium.Matrix4(), + isDestroyed: () => false, + destroy() {}, + }), + }, + }, + }); + // The terrain under the surface check's sample. + scene.globe.getHeight = () => 12; + const checks = createContractSuite(engine, { + waitFrames: async (count) => { + for (let index = 0; index < count; index++) frame(); + }, + }).filter(({ name }) => + /^(shape|movers|lines|colours|input: picks|surface\.sampleHeightDetailed)\b/.test( + name, + ), + ); + assert.equal(checks.length, 8); + try { + for (const { name, run } of checks) await t.test(name, run); + } finally { + engine.destroy(); + } + assert.equal(scene.primitives.length, 0, 'no collection left behind'); +}); diff --git a/src/globe/cesium/lines.js b/src/globe/cesium/lines.js new file mode 100644 index 000000000..bcdbcbff0 --- /dev/null +++ b/src/globe/cesium/lines.js @@ -0,0 +1,249 @@ +// engine.lines on Cesium: each line is one Primitive of a PolylineGeometry +// (built synchronously, rebuilt when its positions change) with a +// PolylineMaterialAppearance. Every line has its own slot, a +// PrimitiveCollection placed by `order` inside one engine-owned +// PrimitiveCollection that joins the scene with the first line, so a rebuild +// keeps its place. depthFailAlpha adds Cesium's depthFailAppearance (the +// same material, its alpha scaled) for the part the scene hides; dashes in +// pixels are PolylineDash; the tactical dash is the launches' +// selected-orbit pattern along the whole line. Only a line with a pickId +// takes part in picks; identify() names it. + +import * as Cesium from 'cesium'; +import { createLineRegistry } from '../lines.js'; + +const TACTICAL_TYPE = 'GevLineTactical'; +let tacticalRegistered = false; + +// The launches' GevMissionOrbitTactical shader (layers/launches/render/ +// cesium.js), under the engine's own type. +function registerTactical() { + if (tacticalRegistered) return; + new Cesium.Material({ + fabric: { + type: TACTICAL_TYPE, + uniforms: { color: Cesium.Color.CYAN, groupCount: 4, dashCount: 100 }, + source: ` + czm_material czm_getMaterial(czm_materialInput materialInput) { + czm_material material = czm_getDefaultMaterial(materialInput); + float groupPosition = fract(materialInput.st.s * groupCount); + float markPosition = groupPosition * (dashCount + 1.0); + float markIndex = floor(markPosition); + float localPosition = fract(markPosition); + float centerDistance = abs(localPosition - 0.5); + float edge = max(fwidth(localPosition) * 1.35, 0.012); + float dashAlong = 1.0 - smoothstep(0.27 - edge, 0.27 + edge, centerDistance); + float dashAcross = 1.0 - smoothstep(0.12, 0.24, abs(materialInput.st.t - 0.5)); + float dash = dashAlong * dashAcross; + float dotAlong = (localPosition - 0.5) / 0.32; + float dotAcross = (materialInput.st.t - 0.5) / 0.5; + float dot = 1.0 - smoothstep(0.78, 1.0, length(vec2(dotAlong, dotAcross))); + float isDot = 1.0 - step(0.5, markIndex); + float visible = mix(dash, dot, isDot); + material.diffuse = color.rgb; + material.emission = color.rgb * mix(0.07, 0.65, isDot); + material.alpha = color.a * visible * mix(0.58, 1.0, isDot); + return material; + }`, + }, + }); + tacticalRegistered = true; +} + +/** PolylineDash's 16-bit pattern: the dash first, then the gap. */ +export function dashPattern(lengthPx, gapPx) { + const on = Math.max( + 1, + Math.min(16, Math.round((16 * lengthPx) / (lengthPx + gapPx))), + ); + return (0xffff << (16 - on)) & 0xffff; +} + +const toColor = ([r, g, b, a], alphaScale = 1) => + new Cesium.Color(r, g, b, a * alphaScale); + +/** The Cesium material type and uniforms of a line (pure, for tests). */ +export function materialSpec(options, color, alphaScale = 1) { + if (options.arrow) + return { + type: 'PolylineArrow', + uniforms: { color: toColor(color, alphaScale) }, + }; + const { dash } = options; + if (dash?.kind === 'pixels') + return { + type: 'PolylineDash', + uniforms: { + color: toColor(color, alphaScale), + gapColor: Cesium.Color.TRANSPARENT, + // PolylineDash measures in CSS pixels (dashLength × czm_pixelRatio). + dashLength: dash.lengthPx + dash.gapPx, + dashPattern: dashPattern(dash.lengthPx, dash.gapPx), + }, + }; + if (dash?.kind === 'tactical') + return { + type: TACTICAL_TYPE, + uniforms: { + color: toColor(color, alphaScale), + groupCount: dash.groups, + dashCount: dash.dashes, + }, + }; + return { type: 'Color', uniforms: { color: toColor(color, alphaScale) } }; +} + +function cesiumMaterial({ type, uniforms }) { + if (type === TACTICAL_TYPE) registerTactical(); + return Cesium.Material.fromType(type, uniforms); +} + +const DEFAULT_FACTORIES = { + primitive: (options) => new Cesium.Primitive(options), + appearance: (spec) => + new Cesium.PolylineMaterialAppearance({ material: cesiumMaterial(spec) }), +}; + +/** + * @param {Cesium.Viewer} viewer + * @param {{ requestRender?: (reason: string) => void, factories?: object }} [options] + * `factories` (the primitive and its appearance) is a test seam. + * @returns {{ lines: import('../contract.js').GlobeLines, + * identify: (picked: object) => ({ owner: string, id: any } | null), + * destroy: () => void }} + */ +export function createCesiumLines( + viewer, + { requestRender = () => viewer.scene.requestRender(), factories = {} } = {}, +) { + const make = { ...DEFAULT_FACTORIES, ...factories }; + const { scene } = viewer; + let root = null; + const ordered = []; + const rootCollection = () => { + if (!root) { + root = new Cesium.PrimitiveCollection({ destroyPrimitives: true }); + scene.primitives.add(root); + } + return root; + }; + const registry = createLineRegistry((options) => { + // Later (higher) order draws later: after every line of lower or equal order. + const slot = new Cesium.PrimitiveCollection({ destroyPrimitives: true }); + const at = ordered.filter((entry) => entry.order <= options.order).length; + ordered.splice(at, 0, { order: options.order, slot }); + rootCollection().add(slot, at); + let color = options.color; + let modelMatrix = options.modelMatrix + ? Cesium.Matrix4.fromArray(options.modelMatrix) + : Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY); + let shown = true; + let primitive = null; + let appearance = null; + let depthFail = null; + const drop = () => { + if (primitive) slot.remove(primitive); + primitive = null; + appearance = null; + depthFail = null; + }; + return { + setPositions(flat) { + drop(); + if (flat.length < 6) { + requestRender('lines'); + return; + } + const positions = []; + for (let index = 0; index < flat.length; index += 3) + positions.push( + new Cesium.Cartesian3( + flat[index], + flat[index + 1], + flat[index + 2], + ), + ); + appearance = make.appearance(materialSpec(options, color)); + depthFail = + options.depthFailAlpha > 0 + ? make.appearance( + materialSpec(options, color, options.depthFailAlpha), + ) + : undefined; + primitive = make.primitive({ + geometryInstances: new Cesium.GeometryInstance({ + geometry: new Cesium.PolylineGeometry({ + positions, + width: options.widthPx, + vertexFormat: Cesium.PolylineMaterialAppearance.VERTEX_FORMAT, + arcType: + options.arc === 'geodesic' + ? Cesium.ArcType.GEODESIC + : Cesium.ArcType.NONE, + }), + id: options.pickId, + }), + appearance, + depthFailAppearance: depthFail, + modelMatrix: Cesium.Matrix4.clone(modelMatrix), + show: shown, + asynchronous: false, + // A line without a pickId is not in the pick pass (Navara's CPU + // picks leave it out too). + allowPicking: options.pickId !== undefined, + }); + primitive.gevLine = options.id; + slot.add(primitive); + requestRender('lines'); + }, + setColor(rgba) { + color = rgba; + if (appearance) appearance.material.uniforms.color = toColor(rgba); + if (depthFail) + depthFail.material.uniforms.color = toColor( + rgba, + options.depthFailAlpha, + ); + requestRender('lines'); + }, + setModelMatrix(matrix) { + modelMatrix = Cesium.Matrix4.fromArray(matrix); + if (primitive) + primitive.modelMatrix = Cesium.Matrix4.clone(modelMatrix); + requestRender('lines'); + }, + show(visible) { + shown = visible; + if (primitive) primitive.show = visible; + requestRender('lines'); + }, + destroy() { + drop(); + ordered.splice( + ordered.findIndex((entry) => entry.slot === slot), + 1, + ); + root.remove(slot); + requestRender('lines'); + }, + }; + }); + return { + lines: registry.lines, + /** The live line a Cesium pick result belongs to, with its pickId, or null. */ + identify(picked) { + const owner = picked?.primitive?.gevLine; + const state = owner === undefined ? null : registry.live.get(owner); + return state && state.options.pickId !== undefined + ? { owner, id: state.options.pickId } + : null; + }, + destroy() { + registry.destroyAll(); + if (root) { + scene.primitives.remove(root); + root = null; + } + }, + }; +} diff --git a/src/globe/cesium/lines.test.mjs b/src/globe/cesium/lines.test.mjs new file mode 100644 index 000000000..19963c56f --- /dev/null +++ b/src/globe/cesium/lines.test.mjs @@ -0,0 +1,242 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { createCesiumLines, dashPattern, materialSpec } from './lines.js'; +import { createFakeViewer } from './testing/fakeViewer.mjs'; + +// Cesium's Material sorts uniform values with instanceof checks against these +// DOM classes; Node has none of them. +for (const name of [ + 'HTMLCanvasElement', + 'HTMLImageElement', + 'HTMLVideoElement', + 'ImageBitmap', + 'OffscreenCanvas', +]) + globalThis[name] ??= class {}; + +// Cesium's own Primitive, PolylineGeometry and materials (built, never drawn). +function setup() { + const viewer = createFakeViewer(); + const renders = []; + const part = createCesiumLines(viewer, { + requestRender: (reason) => renders.push(reason), + }); + const root = () => viewer.primitiveList[0]; + const slots = () => + Array.from({ length: root()?.length ?? 0 }, (_, index) => + root().get(index), + ); + // The primitive drawing each line now, in draw order (null: none yet). + const drawn = () => slots().map((slot) => (slot.length ? slot.get(0) : null)); + return { viewer, renders, root, slots, drawn, ...part }; +} + +const points = [ + { x: 6_378_137, y: 0, z: 0 }, + { x: 6_378_137, y: 1000, z: 0 }, + { x: 6_378_137, y: 2000, z: 1000 }, +]; + +test('a dash in pixels becomes PolylineDash with a 16-bit pattern', () => { + assert.equal(dashPattern(16, 16), 0xff00); + assert.equal(dashPattern(12, 4), 0xfff0); + assert.equal(dashPattern(1, 0), 0xffff); + assert.equal(dashPattern(1, 100), 0x8000, 'at least one bit on'); +}); + +test('positions build one polyline primitive; a change rebuilds it', () => { + const { viewer, renders, drawn, lines, destroy } = setup(); + assert.equal(viewer.scene.primitives.length, 0, 'nothing at creation'); + const line = lines.createLine({ + id: 'trail', + widthPx: 3, + color: [1, 0, 0, 1], + depthFailAlpha: 0.4, + arc: 'geodesic', + pickId: 'trail-1', + }); + assert.equal(viewer.scene.primitives.length, 1); + assert.deepEqual(drawn(), [null], 'no primitive without positions'); + line.setPositions(points); + const [first] = drawn(); + assert.ok(first instanceof Cesium.Primitive); + const geometry = first.geometryInstances.geometry; + assert.ok(geometry instanceof Cesium.PolylineGeometry); + assert.equal(geometry._width, 3); + assert.equal(geometry._arcType, Cesium.ArcType.GEODESIC); + assert.equal(geometry._positions.length, 3); + assert.equal(first.geometryInstances.id, 'trail-1'); + assert.equal(first.asynchronous, false); + assert.equal(first.allowPicking, true); + assert.ok(first.appearance instanceof Cesium.PolylineMaterialAppearance); + assert.equal(first.appearance.material.type, 'Color'); + assert.ok(first.depthFailAppearance, 'the dimmer copy behind the scene'); + assert.ok( + Cesium.Color.equals( + first.depthFailAppearance.material.uniforms.color, + new Cesium.Color(1, 0, 0, 0.4), + ), + ); + line.setPositions(points.slice(0, 2)); + const [second] = drawn(); + assert.notEqual(second, first); + assert.equal(first.isDestroyed(), true, 'the old primitive goes'); + assert.equal(second.geometryInstances.geometry._positions.length, 2); + line.setColor([0, 1, 0, 1]); + assert.ok( + Cesium.Color.equals( + second.appearance.material.uniforms.color, + new Cesium.Color(0, 1, 0, 1), + ), + ); + assert.ok( + Cesium.Color.equals( + second.depthFailAppearance.material.uniforms.color, + new Cesium.Color(0, 1, 0, 0.4), + ), + ); + const turn = Cesium.Matrix4.fromRotation(Cesium.Matrix3.fromRotationZ(0.5)); + line.setModelMatrix(Cesium.Matrix4.toArray(turn)); + assert.ok(Cesium.Matrix4.equals(second.modelMatrix, turn)); + line.show(false); + assert.equal(second.show, false); + line.setPositions(points); + const [third] = drawn(); + assert.equal(third.show, false, 'a rebuild keeps show, matrix and colour'); + assert.ok(Cesium.Matrix4.equals(third.modelMatrix, turn)); + assert.ok( + Cesium.Color.equals( + third.appearance.material.uniforms.color, + new Cesium.Color(0, 1, 0, 1), + ), + ); + assert.deepEqual(lines.list(), [{ id: 'trail', points: 3, shown: false }]); + assert.ok(renders.length >= 6 && renders.every((r) => r === 'lines')); + line.setPositions(points.slice(0, 1)); + assert.deepEqual(drawn(), [null], 'one point draws nothing'); + assert.equal(third.isDestroyed(), true); + line.destroy(); + assert.deepEqual(drawn(), []); + assert.deepEqual(lines.list(), []); + destroy(); + assert.equal(viewer.scene.primitives.length, 0); +}); + +test('a model matrix given at creation places the line', () => { + const { drawn, lines } = setup(); + const shift = Cesium.Matrix4.fromTranslation(new Cesium.Cartesian3(0, 0, 42)); + lines + .createLine({ + id: 'ring', + widthPx: 1, + color: [1, 1, 1, 1], + modelMatrix: Cesium.Matrix4.toArray(shift), + }) + .setPositions(points); + const [primitive] = drawn(); + assert.ok(Cesium.Matrix4.equals(primitive.modelMatrix, shift)); + assert.equal( + primitive.geometryInstances.geometry._arcType, + Cesium.ArcType.NONE, + ); +}); + +test('dash kinds and arrows pick their materials', () => { + const { drawn, lines } = setup(); + const make = (options) => + lines + .createLine({ widthPx: 2, color: [1, 1, 1, 1], ...options }) + .setPositions(points); + make({ id: 'd', dash: { lengthPx: 12, gapPx: 4 } }); + make({ id: 't', dash: 'tactical' }); + make({ id: 'c', dash: { tactical: { groups: 2 } } }); + make({ id: 's' }); + make({ id: 'a', arrow: true, depthFailAlpha: 0.45, color: [1, 0, 0, 1] }); + const [dashed, tactical, custom, solid, arrow] = drawn(); + assert.equal(dashed.appearance.material.type, 'PolylineDash'); + assert.equal(dashed.appearance.material.uniforms.dashLength, 16); + assert.equal(dashed.appearance.material.uniforms.dashPattern, 0xfff0); + assert.ok( + Cesium.Color.equals( + dashed.appearance.material.uniforms.gapColor, + Cesium.Color.TRANSPARENT, + ), + ); + assert.equal(tactical.appearance.material.type, 'GevLineTactical'); + assert.equal(tactical.appearance.material.uniforms.groupCount, 4); + assert.equal(tactical.appearance.material.uniforms.dashCount, 100); + assert.equal(custom.appearance.material.uniforms.groupCount, 2); + assert.equal(solid.appearance.material.type, 'Color'); + assert.equal(solid.depthFailAppearance, undefined); + assert.equal(arrow.appearance.material.type, 'PolylineArrow'); + assert.equal(arrow.depthFailAppearance.material.type, 'PolylineArrow'); + assert.equal(arrow.depthFailAppearance.material.uniforms.color.alpha, 0.45); + // The spec is pure: the same for a scaled alpha. + assert.equal( + materialSpec({ dash: null }, [1, 1, 1, 0.5], 0.5).uniforms.color.alpha, + 0.25, + ); +}); + +test('lines draw in their order; a rebuild keeps its place', () => { + const { slots, drawn, lines } = setup(); + const make = (id, order) => { + const line = lines.createLine({ + id, + widthPx: 1, + color: [1, 1, 1, 1], + order, + }); + line.setPositions(points); + return line; + }; + make('top', 5); + const bottom = make('bottom', -1); + make('middle', 0); + const ids = () => drawn().map((primitive) => primitive?.gevLine); + assert.deepEqual(ids(), ['bottom', 'middle', 'top']); + bottom.setPositions(points.slice(0, 2)); + assert.deepEqual(ids(), ['bottom', 'middle', 'top']); + bottom.destroy(); + assert.equal(slots().length, 2); + make('again', -2); + assert.deepEqual(ids(), ['again', 'middle', 'top']); +}); + +test('only a line with a pickId is picked; identify names it and its pickId', () => { + const { drawn, lines, identify } = setup(); + const picked = lines.createLine({ + id: 'orbit-25544', + widthPx: 2, + color: [1, 1, 1, 1], + pickId: 'o', + }); + picked.setPositions(points); + lines + .createLine({ id: 'trail', widthPx: 2, color: [1, 1, 1, 1] }) + .setPositions(points); + const [orbit, trail] = drawn(); + assert.equal(trail.allowPicking, false); + assert.deepEqual(identify({ primitive: orbit, id: 'o' }), { + owner: 'orbit-25544', + id: 'o', + }); + assert.equal(identify({ primitive: trail }), null); + assert.equal(identify({ primitive: {}, id: 'o' }), null); + assert.equal(identify(undefined), null); + picked.destroy(); + assert.equal(identify({ primitive: orbit, id: 'o' }), null); +}); + +test('engine destroy destroys every line and removes the root', () => { + const { viewer, drawn, lines, destroy } = setup(); + const line = lines.createLine({ id: 'x', widthPx: 1, color: [1, 1, 1, 1] }); + line.setPositions(points); + const [primitive] = drawn(); + destroy(); + assert.equal(primitive.isDestroyed(), true); + assert.equal(viewer.scene.primitives.length, 0); + assert.deepEqual(lines.list(), []); + assert.throws(() => line.setPositions(points), /destroyed/); +}); diff --git a/src/globe/cesium/movers.js b/src/globe/cesium/movers.js new file mode 100644 index 000000000..64805b3e9 --- /dev/null +++ b/src/globe/cesium/movers.js @@ -0,0 +1,401 @@ +// engine.movers on Cesium: one primitive per set inside one engine-owned +// PrimitiveCollection (added to the scene with the first set, in `order`): +// sprites are a BillboardCollection (screen-aligned, rotation in screen +// space, an image per atlas cell cropped from the set's atlas), points a +// PointPrimitiveCollection, models one Model per instance in a +// PrimitiveCollection. commit() writes the changed instances and requests a +// render; nothing else renders. Picks resolve through identify(): the +// billboard, point or model a Cesium pick names → its set, instance and +// the pickId the layer gave it. + +import * as Cesium from 'cesium'; +import { horizonVisible } from '../cameraMath.js'; +import { createMoverRegistry } from '../movers.js'; + +let scratchPosition; +let scratchColor; +let scratchQuaternion; +let scratchRotation; + +/** + * The atlas cells as canvases, by key. The atlas is a canvas or an image URL; + * cells are [u0, v0, u1, v1] in 0..1 with v down from the image top. + */ +async function cropAtlasCells(atlas, keys) { + let image = atlas.canvas; + if (!image) { + image = new Image(); + image.crossOrigin = 'anonymous'; + image.src = atlas.url; + await image.decode(); + } + const { width, height } = image; + const cells = new Map(); + for (const key of keys) { + const [u0, v0, u1, v1] = atlas.cells[key]; + const canvas = document.createElement('canvas'); + canvas.width = Math.max(1, Math.round((u1 - u0) * width)); + canvas.height = Math.max(1, Math.round((v1 - v0) * height)); + canvas + .getContext('2d') + .drawImage( + image, + u0 * width, + v0 * height, + canvas.width, + canvas.height, + 0, + 0, + canvas.width, + canvas.height, + ); + cells.set(key, canvas); + } + return cells; +} + +const DEFAULT_FACTORIES = { + billboards: (scene) => new Cesium.BillboardCollection({ scene }), + points: () => new Cesium.PointPrimitiveCollection(), + models: () => new Cesium.PrimitiveCollection({ destroyPrimitives: true }), + loadModel: (options) => Cesium.Model.fromGltfAsync(options), + cropCells: cropAtlasCells, +}; + +/** Cesium's disableDepthTestDistance for a set's depthTest. */ +export const depthTestDistance = (depthTest) => + depthTest === 'always' + ? Number.POSITIVE_INFINITY + : depthTest === 'normal' + ? 0 + : depthTest.disableWithinM; + +const nearFar = (value) => + value ? new Cesium.NearFarScalar(...value) : undefined; + +/** + * A model set's colour blend as Cesium Model options. Without colorBlend the + * instance colour only sets the alpha (MIX by 0), as on Navara. + */ +export function modelBlend(colorBlend) { + if (!colorBlend) + return { colorBlendMode: Cesium.ColorBlendMode.MIX, colorBlendAmount: 0 }; + return colorBlend.mode === 'replace' + ? { colorBlendMode: Cesium.ColorBlendMode.REPLACE, colorBlendAmount: 1 } + : { + colorBlendMode: Cesium.ColorBlendMode.MIX, + colorBlendAmount: colorBlend.amount, + }; +} + +function readPosition(store, index) { + const { positions } = store; + return Cesium.Cartesian3.fromElements( + positions[index * 3], + positions[index * 3 + 1], + positions[index * 3 + 2], + scratchPosition, + ); +} + +function readColor(store, index) { + const { colors } = store; + scratchColor.red = colors[index * 4]; + scratchColor.green = colors[index * 4 + 1]; + scratchColor.blue = colors[index * 4 + 2]; + scratchColor.alpha = colors[index * 4 + 3]; + return scratchColor; +} + +function spritePart(scene, options, store, ports) { + const { factories, requestRender, owners } = ports; + const collection = factories.billboards(scene); + const { sprite, depthTest, id } = options; + const style = { + width: sprite.sizePx, + height: sprite.sizePx, + scaleByDistance: nearFar(sprite.scaleByDistance), + disableDepthTestDistance: depthTestDistance(depthTest), + }; + const billboards = []; + const shownCell = []; + let cells = null; + let destroyed = false; + const apply = (from, to) => { + for (let index = from; index < to; index++) { + let billboard = billboards[index]; + if (index >= store.count) { + if (billboard) billboard.show = false; + continue; + } + if (!billboard) { + billboard = collection.add({ ...style }); + billboards[index] = billboard; + owners.set(billboard, { owner: id, instance: index }); + shownCell[index] = -1; + } + billboard.position = readPosition(store, index); + billboard.rotation = store.rotations[index]; + billboard.scale = store.scales[index]; + billboard.color = readColor(store, index); + billboard.show = store.shows[index] === 1; + billboard.id = store.pickIds[index]; + const cell = store.cells[index]; + if (shownCell[index] !== cell) { + const key = store.cellKeys[cell]; + // One image per cell in the collection's texture atlas. + billboard.setImage(`${id}:${key}`, cells.get(key)); + shownCell[index] = cell; + } + } + }; + // Instances wait for the atlas; then everything written so far is applied. + factories + .cropCells(sprite.atlas, store.cellKeys) + .then((ready) => { + if (destroyed) return; + cells = ready; + apply(0, Math.max(store.count, billboards.length)); + requestRender('movers'); + }) + .catch((error) => console.warn(`[movers] ${id}: atlas failed`, error)); + return { + primitive: collection, + ready: () => cells !== null, + commit(range) { + if (cells && range) apply(range.from, range.to); + requestRender('movers'); + }, + destroy() { + destroyed = true; + billboards.length = 0; + shownCell.length = 0; + cells = null; + }, + }; +} + +function pointPart(options, store, { factories, requestRender, owners }) { + const collection = factories.points(); + const { point, depthTest } = options; + // pixelSize is the filled diameter; Cesium adds outlineWidth on each side. + const style = { + outlineWidth: point.outlinePx, + outlineColor: new Cesium.Color(...point.outlineColor), + scaleByDistance: nearFar(point.scaleByDistance), + translucencyByDistance: nearFar(point.translucencyByDistance), + disableDepthTestDistance: depthTestDistance(depthTest), + }; + const points = []; + return { + primitive: collection, + commit(range) { + if (range) + for (let index = range.from; index < range.to; index++) { + let item = points[index]; + if (index >= store.count) { + if (item) item.show = false; + continue; + } + if (!item) { + item = collection.add({ ...style }); + points[index] = item; + owners.set(item, { owner: options.id, instance: index }); + } + item.position = readPosition(store, index); + item.pixelSize = point.sizePx * store.scales[index]; + item.color = readColor(store, index); + item.show = store.shows[index] === 1; + item.id = store.pickIds[index]; + } + requestRender('movers'); + }, + destroy() { + points.length = 0; + }, + }; +} + +function modelPart(options, store, { factories, requestRender, owners }) { + const collection = factories.models(); + const { model, pickable } = options; + const models = []; + const loading = []; + let destroyed = false; + const place = (item, index) => { + const { orientations } = store; + scratchQuaternion.x = orientations[index * 4]; + scratchQuaternion.y = orientations[index * 4 + 1]; + scratchQuaternion.z = orientations[index * 4 + 2]; + scratchQuaternion.w = orientations[index * 4 + 3]; + // Written in place: Model keeps the matrix it is given (and notices a + // change on its next update); Model.color copies. + Cesium.Matrix4.fromRotationTranslation( + Cesium.Matrix3.fromQuaternion(scratchQuaternion, scratchRotation), + readPosition(store, index), + item.modelMatrix, + ); + item.scale = store.scales[index]; + item.show = index < store.count && store.shows[index] === 1; + item.id = store.pickIds[index]; + item.color = readColor(store, index); + }; + const ensure = (index) => { + if (models[index] || loading[index]) return; + loading[index] = true; + factories + .loadModel({ + url: model.url, + minimumPixelSize: model.minimumPixelSize, + ...modelBlend(model.colorBlend), + allowPicking: pickable, + ...(model.unlit + ? { + customShader: new Cesium.CustomShader({ + lightingModel: Cesium.LightingModel.UNLIT, + }), + } + : {}), + }) + .then((item) => { + if (destroyed) { + item.destroy?.(); + return; + } + models[index] = item; + owners.set(item, { owner: options.id, instance: index }); + place(item, index); + collection.add(item); + requestRender('movers'); + }) + .catch((error) => + console.warn(`[movers] ${options.id}: model failed`, error), + ); + }; + return { + primitive: collection, + // Every drawn instance's model has loaded. + ready() { + for (let index = 0; index < store.count; index++) + if (!models[index]?.ready) return false; + return true; + }, + commit(range) { + if (range) + for (let index = range.from; index < range.to; index++) { + if (index < store.count) ensure(index); + if (models[index]) place(models[index], index); + } + requestRender('movers'); + }, + destroy() { + destroyed = true; + models.length = 0; + loading.length = 0; + }, + }; +} + +/** + * @param {Cesium.Viewer} viewer + * @param {{ requestRender?: (reason: string) => void, factories?: object }} [options] + * `factories` (collections, model loading, atlas cropping) is a test seam. + * @returns {{ movers: import('../contract.js').GlobeMovers, + * identify: (picked: object) => ({ owner: string, instance: number, id: any, pickable: boolean, visible: boolean } | null), + * primitiveOf: (set: object) => (object | null), destroy: () => void }} + */ +export function createCesiumMovers( + viewer, + { requestRender = () => viewer.scene.requestRender(), factories = {} } = {}, +) { + scratchPosition ??= new Cesium.Cartesian3(); + scratchColor ??= new Cesium.Color(); + scratchQuaternion ??= new Cesium.Quaternion(); + scratchRotation ??= new Cesium.Matrix3(); + const ports = { + factories: { ...DEFAULT_FACTORIES, ...factories }, + requestRender, + // Billboard, point or model → its set and instance, for picks. + owners: new WeakMap(), + }; + const { scene } = viewer; + let root = null; + const ordered = []; + const rootCollection = () => { + if (!root) { + root = new Cesium.PrimitiveCollection({ destroyPrimitives: true }); + scene.primitives.add(root); + } + return root; + }; + const registry = createMoverRegistry((options, store) => { + const part = + options.kind === 'sprite' + ? spritePart(scene, options, store, ports) + : options.kind === 'point' + ? pointPart(options, store, ports) + : modelPart(options, store, ports); + const { primitive } = part; + primitive.gevMoverSet = options.id; + // Later (higher) order draws later: after every set of lower or equal order. + const at = ordered.filter((entry) => entry.order <= options.order).length; + ordered.splice(at, 0, { order: options.order, primitive }); + rootCollection().add(primitive, at); + return { + primitive, + ready: part.ready, + commit: () => part.commit(store.dirty()), + destroy() { + part.destroy(); + ordered.splice( + ordered.findIndex((entry) => entry.primitive === primitive), + 1, + ); + root.remove(primitive); + requestRender('movers'); + }, + }; + }); + return { + movers: registry.movers, + /** The primitive drawing a live mover set of this engine, or null. */ + primitiveOf(set) { + const entry = set?.id ? registry.sets.get(set.id) : null; + return entry && entry.set === set ? entry.part.primitive : null; + }, + /** + * The set and instance a Cesium pick result belongs to, with the + * instance's pickId, whether its set is pickable and whether the + * instance is in front of the horizon (billboards drawn without a depth + * test reach Cesium's GPU pick through the globe); null for anything else. + */ + identify(picked) { + const known = picked?.primitive + ? ports.owners.get(picked.primitive) + : null; + const entry = known && registry.sets.get(known.owner); + if (!entry || !(known.instance < entry.store.count)) return null; + const { store, options } = entry; + const at = 3 * known.instance; + const position = { + x: store.positions[at], + y: store.positions[at + 1], + z: store.positions[at + 2], + }; + return { + owner: known.owner, + instance: known.instance, + id: store.pickIds[known.instance], + pickable: options.pickable, + visible: horizonVisible(viewer.camera.positionWC, position), + }; + }, + destroy() { + registry.destroyAll(); + if (root) { + scene.primitives.remove(root); + root = null; + } + }, + }; +} diff --git a/src/globe/cesium/movers.test.mjs b/src/globe/cesium/movers.test.mjs new file mode 100644 index 000000000..1262671af --- /dev/null +++ b/src/globe/cesium/movers.test.mjs @@ -0,0 +1,451 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; +import { createCesiumMovers } from './movers.js'; +import { createFakeViewer } from './testing/fakeViewer.mjs'; + +// Cesium's own collections, billboards and points (they draw nothing +// without a renderer, but hold what the engine writes). What needs the +// network or a DOM is faked: the glTF load and the atlas crop. +function setup({ loadModel } = {}) { + const viewer = createFakeViewer(); + viewer.scene.frameState.mode = Cesium.SceneMode.SCENE3D; + const requests = []; + const crops = []; + const loads = []; + const factories = { + cropCells: async (atlas, keys) => { + crops.push({ atlas, keys }); + return new Map(keys.map((key) => [key, { width: 8, height: 8, key }])); + }, + loadModel: + loadModel ?? + (async (options) => { + loads.push(options); + return fakeModel(options); + }), + }; + const part = createCesiumMovers(viewer, { + factories, + requestRender: (reason) => requests.push(reason), + }); + const root = () => viewer.primitiveList[0]; + return { viewer, requests, crops, loads, root, ...part }; +} + +// What the engine reads and writes of a Cesium Model: its own model matrix +// (kept as given), a colour it copies. +function fakeModel(options) { + let color; + return { + options, + ready: false, + show: true, + modelMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY), + get color() { + return color; + }, + set color(value) { + color = Cesium.Color.clone(value); + }, + isDestroyed: () => false, + destroy() { + this.destroyed = true; + }, + }; +} + +const at = (lon, lat, height = 0) => + geo.cartesian3.fromDegrees(lon, lat, height); +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); +const atlas = { + canvas: { width: 64, height: 32 }, + cells: { jet: [0, 0, 0.5, 1], heli: [0.5, 0, 1, 1] }, +}; +const items = (collection) => + Array.from({ length: collection.length }, (_, index) => + collection.get(index), + ); + +test('creating the movers changes nothing; the first set adds one root collection', () => { + const { viewer, movers, root, destroy } = setup(); + assert.equal(viewer.scene.primitives.length, 0); + const set = movers.createSet({ + id: 'a', + kind: 'point', + capacity: 2, + point: { sizePx: 4 }, + }); + assert.equal(viewer.scene.primitives.length, 1); + assert.ok(root() instanceof Cesium.PrimitiveCollection); + const [collection] = items(root()); + assert.ok(collection instanceof Cesium.PointPrimitiveCollection); + set.destroy(); + assert.equal(root().length, 0); + assert.equal(collection.isDestroyed(), true, 'a destroyed set is released'); + destroy(); + assert.equal(viewer.scene.primitives.length, 0); +}); + +test('sprites are billboards with the set style once the atlas cells are ready', async () => { + const { movers, root, requests, crops } = setup(); + const set = movers.createSet({ + id: 'flights', + kind: 'sprite', + capacity: 3, + depthTest: 'always', + sprite: { atlas, sizePx: 20, scaleByDistance: [1e3, 1, 1e6, 0.5] }, + }); + const [collection] = items(root()); + assert.ok(collection instanceof Cesium.BillboardCollection); + assert.deepEqual(crops[0].keys, ['jet', 'heli']); + assert.equal(crops[0].atlas, atlas); + assert.equal(set.ready(), false, 'not ready before the atlas'); + set.set(0, { + position: at(1, 2), + rotation: 0.25, + cell: 'heli', + pickId: 'x1', + }); + set.set(1, { + position: at(3, 4), + color: [1, 0, 0, 0.5], + scale: 2, + show: false, + }); + set.setCount(2); + set.commit(); + assert.equal(collection.length, 0, 'waits for the atlas'); + await settle(); + assert.equal(set.ready(), true); + assert.equal(collection.length, 2); + const [first, second] = items(collection); + assert.ok(first instanceof Cesium.Billboard); + assert.ok(Cesium.Cartesian3.equalsEpsilon(first.position, at(1, 2), 0, 1e-6)); + assert.equal(first.width, 20); + assert.equal(first.height, 20); + assert.equal(first.rotation, 0.25); + assert.equal(first.id, 'x1'); + assert.equal(first.disableDepthTestDistance, Number.POSITIVE_INFINITY); + assert.ok( + Cesium.NearFarScalar.equals( + first.scaleByDistance, + new Cesium.NearFarScalar(1e3, 1, 1e6, 0.5), + ), + ); + assert.equal(first.image, 'flights:heli', 'the cell image, by set and key'); + assert.equal(first.show, true); + assert.equal(second.image, 'flights:jet', 'the first cell by default'); + assert.equal(second.show, false); + assert.equal(second.scale, 2); + assert.ok(Cesium.Color.equals(second.color, new Cesium.Color(1, 0, 0, 0.5))); + assert.ok(requests.includes('movers')); + // The same cell is not sent again; a new one is. + const sent = []; + const setImage = first.setImage; + first.setImage = (id, image) => { + sent.push(id); + setImage.call(first, id, image); + }; + set.set(0, { cell: 'heli' }); + set.commit(); + assert.deepEqual(sent, []); + set.set(0, { cell: 'jet' }); + set.commit(); + assert.deepEqual(sent, ['flights:jet']); + // Lowering the count hides the instances past it. + set.set(1, { show: true }); + set.commit(); + assert.equal(second.show, true); + set.setCount(1); + set.commit(); + assert.equal(second.show, false); +}); + +test('a sprite set destroyed before its atlas arrives draws nothing', async () => { + const { movers, root } = setup(); + const set = movers.createSet({ + id: 'early', + kind: 'sprite', + capacity: 1, + sprite: { atlas, sizePx: 8 }, + }); + const [collection] = items(root()); + set.set(0, { position: at(0, 0) }); + set.setCount(1); + set.commit(); + set.destroy(); + await settle(); + assert.equal(collection.isDestroyed(), true); + assert.equal(root().length, 0); +}); + +test('points take the size, scale, outline, translucency and depth test of the set', () => { + const { movers, root } = setup(); + const set = movers.createSet({ + id: 'sats', + kind: 'point', + capacity: 2, + depthTest: { disableWithinM: 5000 }, + point: { + sizePx: 4, + outlinePx: 1, + outlineColor: [1, 1, 1, 0.5], + scaleByDistance: [1e3, 2, 1e7, 0.5], + translucencyByDistance: [1e6, 1, 1e8, 0], + }, + }); + set.set(0, { + position: at(5, 6), + scale: 1.5, + color: [0, 1, 0, 1], + pickId: 25544, + }); + set.setCount(1); + set.commit(); + const [collection] = items(root()); + const [point] = items(collection); + assert.ok(point instanceof Cesium.PointPrimitive); + assert.equal(point.pixelSize, 6, 'sizePx × scale'); + assert.equal(point.outlineWidth, 1); + assert.ok( + Cesium.Color.equals(point.outlineColor, new Cesium.Color(1, 1, 1, 0.5)), + ); + assert.ok( + Cesium.NearFarScalar.equals( + point.scaleByDistance, + new Cesium.NearFarScalar(1e3, 2, 1e7, 0.5), + ), + ); + assert.ok( + Cesium.NearFarScalar.equals( + point.translucencyByDistance, + new Cesium.NearFarScalar(1e6, 1, 1e8, 0), + ), + ); + assert.equal(point.disableDepthTestDistance, 5000); + assert.equal(point.id, 25544); + assert.ok(Cesium.Color.equals(point.color, new Cesium.Color(0, 1, 0, 1))); + // 'normal' keeps Cesium's depth test. + const normal = movers.createSet({ + id: 'normal', + kind: 'point', + capacity: 1, + point: { sizePx: 2 }, + }); + normal.set(0, { position: at(0, 0) }); + normal.setCount(1); + normal.commit(); + const [, normalCollection] = items(root()); + assert.equal(items(normalCollection)[0].disableDepthTestDistance, 0); + assert.equal(items(normalCollection)[0].outlineWidth, 0); +}); + +test('sets are ordered in the root by their order', () => { + const { movers, root } = setup(); + const make = (id, order) => + movers.createSet({ + id, + kind: 'point', + capacity: 1, + order, + point: { sizePx: 2 }, + }); + make('top', 5); + make('bottom', -1); + const middle = make('middle', 0); + make('middle-later', 0); + assert.deepEqual( + items(root()).map((collection) => collection.gevMoverSet), + ['bottom', 'middle', 'middle-later', 'top'], + ); + middle.destroy(); + make('again', 0); + assert.deepEqual( + items(root()).map((collection) => collection.gevMoverSet), + ['bottom', 'middle-later', 'again', 'top'], + ); +}); + +test('models load per instance with the set options and follow position, orientation and colour', async () => { + const { movers, root, loads } = setup(); + const make = (id, model, pickable = false) => + movers.createSet({ id, kind: 'model', capacity: 2, pickable, model }); + const set = make( + 'models', + { + url: '/models/airplane.glb', + minimumPixelSize: 24, + colorBlend: { mode: 'mix', amount: 0.4 }, + }, + true, + ); + const position = at(10, 20, 3000); + // A quarter turn about z. + const orientation = { x: 0, y: 0, z: Math.SQRT1_2, w: Math.SQRT1_2 }; + set.set(0, { position, orientation, color: [1, 0.5, 0, 0.8], scale: 3 }); + set.set(1, { position: at(11, 20), pickId: 'second' }); + set.setCount(2); + set.commit(); + assert.equal(set.ready(), false); + await settle(); + const [collection] = items(root()); + assert.ok(collection instanceof Cesium.PrimitiveCollection); + assert.equal(collection.length, 2, 'one model per instance'); + assert.equal(loads.length, 2); + const [options] = loads; + assert.equal(options.url, '/models/airplane.glb'); + assert.equal(options.minimumPixelSize, 24); + assert.equal(options.colorBlendMode, Cesium.ColorBlendMode.MIX); + assert.equal(options.colorBlendAmount, 0.4); + assert.equal(options.allowPicking, true); + assert.equal(options.customShader, undefined, 'lit by default'); + const [first, second] = items(collection); + assert.notEqual(first.modelMatrix, second.modelMatrix); + const translation = Cesium.Matrix4.getTranslation( + first.modelMatrix, + new Cesium.Cartesian3(), + ); + assert.ok(Cesium.Cartesian3.equalsEpsilon(translation, position, 0, 1e-6)); + // The model's x axis turned onto Earth-fixed y. + assert.ok(Math.abs(first.modelMatrix[1] - 1) < 1e-12); + assert.ok(Math.abs(first.modelMatrix[0]) < 1e-12); + assert.equal(first.scale, 3); + assert.ok(first.color.equalsEpsilon(new Cesium.Color(1, 0.5, 0, 0.8), 1e-6)); + assert.equal(second.id, 'second'); + assert.equal(set.ready(), false, 'loaded models not yet ready'); + first.ready = true; + second.ready = true; + assert.equal(set.ready(), true); + // Shrinking hides the instance past the count; a later write moves it. + set.setCount(1); + set.commit(); + assert.equal(second.show, false); + set.set(0, { show: false }); + set.commit(); + assert.equal(first.show, false); + set.destroy(); + assert.equal(collection.isDestroyed(), true); + assert.equal(first.destroyed, true, 'the models go with the set'); +}); + +test('model colour blends and unlit become Cesium model options', async () => { + const { movers, loads } = setup(); + const make = (id, model) => { + const set = movers.createSet({ id, kind: 'model', capacity: 1, model }); + set.set(0, { position: at(0, 0) }); + set.setCount(1); + set.commit(); + }; + make('plain', { url: '/a.glb' }); + make('replace', { url: '/a.glb', colorBlend: { mode: 'replace' } }); + make('unlit', { url: '/a.glb', unlit: true }); + await settle(); + const [plain, replace, unlit] = loads; + // Without colorBlend the colour sets only the alpha. + assert.equal(plain.colorBlendMode, Cesium.ColorBlendMode.MIX); + assert.equal(plain.colorBlendAmount, 0); + assert.equal(plain.allowPicking, false); + assert.equal(plain.minimumPixelSize, 0); + assert.equal(replace.colorBlendMode, Cesium.ColorBlendMode.REPLACE); + assert.ok(unlit.customShader instanceof Cesium.CustomShader); + assert.equal(unlit.customShader.lightingModel, Cesium.LightingModel.UNLIT); +}); + +test('a model that loads after its set is destroyed is released', async () => { + let finish; + const pending = new Promise((resolve) => (finish = resolve)); + const { movers, root } = setup({ loadModel: () => pending }); + const set = movers.createSet({ + id: 'late', + kind: 'model', + capacity: 1, + model: { url: '/a.glb' }, + }); + set.set(0, { position: at(0, 0) }); + set.setCount(1); + set.commit(); + set.destroy(); + const model = fakeModel({}); + finish(model); + await settle(); + assert.equal(model.destroyed, true); + assert.equal(root().length, 0); +}); + +test('identify names the set, instance and pickId of a picked billboard or point', async () => { + const { movers, root, identify, viewer } = setup(); + // The fake camera is over (0°, ~1°), 7000 km from the centre. + viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(0, 0, 7e5); + const set = movers.createSet({ + id: 'sats', + kind: 'point', + capacity: 3, + point: { sizePx: 4 }, + }); + set.set(0, { position: at(0, 1) }); + set.set(1, { position: at(1, 0), pickId: 25544 }); + set.set(2, { position: at(180, 0), pickId: 'far' }); + set.setCount(3); + set.commit(); + const [, second, third] = items(items(root())[0]); + assert.deepEqual(identify({ primitive: second, id: 'stale' }), { + owner: 'sats', + instance: 1, + id: 25544, + pickable: false, + visible: true, + }); + assert.equal( + identify({ primitive: third }).visible, + false, + 'behind the horizon', + ); + // The pickId the layer writes now, not the one Cesium holds. + set.set(1, { pickId: 'renamed' }); + assert.equal(identify({ primitive: second }).id, 'renamed'); + // An instance past the count is not identified. + set.setCount(1); + set.commit(); + assert.equal(identify({ primitive: second }), null); + assert.equal(identify({ primitive: {}, id: 'x' }), null); + assert.equal(identify(undefined), null); + const sprites = movers.createSet({ + id: 'flights', + kind: 'sprite', + capacity: 1, + pickable: true, + sprite: { atlas, sizePx: 8 }, + }); + sprites.set(0, { position: at(0, 0.5), pickId: 'ae01ce' }); + sprites.setCount(1); + sprites.commit(); + await settle(); + const [billboard] = items(items(root())[1]); + assert.deepEqual(identify({ primitive: billboard }), { + owner: 'flights', + instance: 0, + id: 'ae01ce', + pickable: true, + visible: true, + }); + set.destroy(); + sprites.destroy(); + assert.equal(identify({ primitive: billboard }), null); +}); + +test('primitiveOf maps a live set of this engine to its collection', () => { + const { movers, root, primitiveOf } = setup(); + const set = movers.createSet({ + id: 'a', + kind: 'point', + capacity: 1, + point: { sizePx: 2 }, + }); + assert.equal(primitiveOf(set), items(root())[0]); + assert.equal(primitiveOf({ id: 'a' }), null, 'a look-alike is not the set'); + assert.equal(primitiveOf({ primitive: 1 }), null); + set.destroy(); + assert.equal(primitiveOf(set), null); +}); diff --git a/src/globe/cesium/picker.js b/src/globe/cesium/picker.js index 66f91fffc..83d0c2042 100644 --- a/src/globe/cesium/picker.js +++ b/src/globe/cesium/picker.js @@ -1,23 +1,43 @@ // GlobePicker on a Cesium scene. Pick results keep Cesium's `id`, // `primitive` and `content` (3D tile content, read by surface-pick checks); // the untouched Cesium result rides along as `object` for Cesium-only callers. +// A pick of an engine mover or line (`identify`) names its owner and +// instance and returns the pickId the layer gave it as `id`, as on Navara. import { toCesium2, toGeo3 } from './convert.js'; /** * @param {Cesium.Viewer} viewer + * @param {{ + * identify?: (picked: object) => ({ owner: string, instance?: number, id: any, pickable?: boolean, visible?: boolean } | null), + * }} [options] + * `identify` names the mover set or line (and instance) a pick belongs to, + * with its pickId; `pickable: false` (a set made without pickable) and + * `visible: false` (an instance behind the horizon, which Cesium's GPU + * pick sees through the globe when drawn without a depth test) drop it. * @returns {{ picker: object, destroy: () => void }} */ -export function createCesiumPicker(viewer) { +export function createCesiumPicker(viewer, { identify = () => null } = {}) { const normalise = (picked) => { if (picked == null) return undefined; // The follow camera's proxy entity (follow.js) is not an object. if (picked.id?.gevFollowProxy) return undefined; + const known = identify(picked); + if (known && (known.pickable === false || known.visible === false)) + return undefined; return { - id: picked.id, + id: known ? known.id : picked.id, primitive: picked.primitive, content: picked.content, object: picked, + ...(known + ? { + owner: known.owner, + ...(known.instance !== undefined + ? { instance: known.instance } + : {}), + } + : {}), }; }; const { scene } = viewer; @@ -32,8 +52,9 @@ export function createCesiumPicker(viewer) { if (top == null) return undefined; const picked = normalise(top); if (picked) return picked; - // The top result is the follow proxy: what lies under it, the first - // with an id (an object), else the first kept (map tiles). + // The top result is filtered (the follow proxy, an unpickable or + // hidden mover): what lies under it, the first with an id (an + // object), else the first kept (map tiles). const under = drill(screen, undefined, width, height); return under.find((entry) => entry.id !== undefined) ?? under[0]; }, diff --git a/src/globe/cesium/picker.test.mjs b/src/globe/cesium/picker.test.mjs index 6e40298af..124c540fe 100644 --- a/src/globe/cesium/picker.test.mjs +++ b/src/globe/cesium/picker.test.mjs @@ -134,3 +134,71 @@ test('a follow-proxy top pick falls through to the first pick under it with an i viewer.scene.drillPick = () => [proxy]; assert.equal(picker.pick(geo.cartesian2.create(1, 1)), undefined); }); + +test('picks of movers and lines name their owner and instance and return the pickId', () => { + const viewer = createFakeViewer(); + const billboard = { name: 'billboard' }; + const polyline = { name: 'line primitive' }; + // Cesium's own id on the billboard is stale; the pickId comes from identify. + viewer.scene.pick = () => ({ primitive: billboard, id: 'stale' }); + viewer.scene.drillPick = () => [ + { primitive: billboard, id: 'stale' }, + { primitive: polyline, id: 'orbit' }, + { primitive: {}, id: 'other' }, + ]; + const { picker } = createCesiumPicker(viewer, { + identify: (raw) => + raw.primitive === billboard + ? { owner: 'flights', instance: 7, id: 'ae01ce', pickable: true } + : raw.primitive === polyline + ? { owner: 'orbit-25544', id: 'orbit' } + : null, + }); + const picked = picker.pick({ x: 1, y: 2 }); + assert.equal(picked.id, 'ae01ce'); + assert.equal(picked.owner, 'flights'); + assert.equal(picked.instance, 7); + assert.equal(picked.object.id, 'stale', 'the Cesium result rides along'); + const drilled = picker.drillPick({ x: 1, y: 2 }, 5); + assert.deepEqual( + drilled.map(({ id, owner, instance }) => [id, owner, instance]), + [ + ['ae01ce', 'flights', 7], + ['orbit', 'orbit-25544', undefined], + ['other', undefined, undefined], + ], + ); + assert.equal('instance' in drilled[1], false); +}); + +test('picks of an unpickable set or an instance behind the horizon are dropped', () => { + const viewer = createFakeViewer(); + const quiet = { primitive: 'quiet-billboard', id: 'q' }; + const behind = { primitive: 'far-billboard', id: 'f' }; + const proxy = { primitive: 'proxy-point', id: { gevFollowProxy: true } }; + const tile = { primitive: 'tileset', content: {} }; + const flight = { primitive: 'flight-billboard', id: 'raw' }; + const { picker } = createCesiumPicker(viewer, { + identify: (picked) => + picked === quiet + ? { owner: 'quiet', instance: 0, id: 'q', pickable: false } + : picked === behind + ? { owner: 'flights', instance: 1, id: 'f', visible: false } + : picked === flight + ? { owner: 'flights', instance: 3, id: 'ae01ce', visible: true } + : null, + }); + viewer.scene.pick = () => quiet; + viewer.scene.drillPick = () => [quiet, behind, proxy, tile, flight]; + const picked = picker.pick(geo.cartesian2.create(1, 1)); + assert.equal(picked.id, 'ae01ce', 'the first kept pick under it with an id'); + assert.equal(picked.owner, 'flights'); + assert.deepEqual( + picker.drillPick(geo.cartesian2.create(1, 1)).map((p) => p.primitive), + ['tileset', 'flight-billboard'], + ); + // Nothing under it with an id: the first kept result (a tile). + viewer.scene.pick = () => behind; + viewer.scene.drillPick = () => [behind, tile]; + assert.equal(picker.pick(geo.cartesian2.create(1, 1)).primitive, 'tileset'); +}); diff --git a/src/globe/cesium/surface.js b/src/globe/cesium/surface.js index f1b90fa84..4e92c644d 100644 --- a/src/globe/cesium/surface.js +++ b/src/globe/cesium/surface.js @@ -1,5 +1,11 @@ // GlobeSurface on a Cesium scene: the rendered-surface queries that // services/groundSnap and services/meshFloorSampler make. +// +// `exclude` takes the engine's own mover sets (engine.movers) as well as +// Cesium objects: Cesium's sampleHeight matches excluded objects by the +// picked primitive, which for a set is each billboard, point or model, so a +// set is hidden for the sample instead (synchronous: nothing draws in +// between) and shown again after it. import { firstVisibleTileset } from '../../services/groundSnap.js'; import { normalizeSurfaceOptions } from '../surfaceDetail.js'; @@ -9,16 +15,46 @@ const finite = (value) => (Number.isFinite(value) ? value : undefined); /** * @param {Cesium.Viewer} viewer + * @param {{ engineExclude?: (entry: any) => ({ show: boolean } | null) }} [options] + * `engineExclude` maps an engine object (a mover set) to the Cesium + * primitive that draws it, or null for a Cesium object. * @returns {{ surface: import('../contract.js').GlobeSurface, destroy: () => void }} */ -export function createCesiumSurface(viewer) { +export function createCesiumSurface( + viewer, + { engineExclude = () => null } = {}, +) { const { scene } = viewer; + // Runs `query(nativeExclude)` with the engine objects in `exclude` hidden. + function excluding(exclude, query) { + if (!exclude?.length) return query(exclude); + let native = exclude; + const hidden = []; + for (const entry of exclude) { + const primitive = engineExclude(entry); + if (!primitive) continue; + if (native === exclude) + native = exclude.filter((other) => !engineExclude(other)); + if (primitive.show) { + primitive.show = false; + hidden.push(primitive); + } + } + try { + return query(native); + } finally { + for (const primitive of hidden) primitive.show = true; + } + } + const sampleHeight = (cartographic, exclude) => { if (!scene.sampleHeightSupported) return undefined; try { // Throws when unsupported (no depth textures); that is a miss. - return finite(scene.sampleHeight(cartographic, exclude)); + return excluding(exclude, (native) => + finite(scene.sampleHeight(cartographic, native)), + ); } catch { return undefined; } @@ -49,7 +85,9 @@ export function createCesiumSurface(viewer) { clampToHeight(position, exclude) { if (!scene.clampToHeightSupported) return undefined; try { - return toGeo3(scene.clampToHeight(position, exclude)); + return excluding(exclude, (native) => + toGeo3(scene.clampToHeight(position, native)), + ); } catch { return undefined; } diff --git a/src/globe/cesium/surface.test.mjs b/src/globe/cesium/surface.test.mjs index 063e78329..015ac8488 100644 --- a/src/globe/cesium/surface.test.mjs +++ b/src/globe/cesium/surface.test.mjs @@ -102,3 +102,38 @@ test('sampleHeightDetailed: tiles from sampleHeight, else terrain; no tile size' TypeError, ); }); + +test('an engine mover set in exclude is hidden for the sample, native objects pass', () => { + const viewer = createFakeViewer(); + const collection = { show: true }; + const set = { id: 'flights' }; + const { surface } = createCesiumSurface(viewer, { + engineExclude: (entry) => (entry === set ? collection : null), + }); + const shownDuring = []; + viewer.scene.sampleHeight = (position, excluded) => { + shownDuring.push(collection.show, excluded); + return 30; + }; + const native = { primitive: 1 }; + const place = geo.cartographic.fromDegrees(20, 10, 0); + assert.equal(surface.sampleHeight(place, [set, native]), 30); + assert.deepEqual(shownDuring, [false, [native]]); + assert.equal(collection.show, true, 'shown again after the sample'); + shownDuring.length = 0; + surface.sampleHeightDetailed(place, { exclude: [set] }); + assert.deepEqual(shownDuring, [false, []]); + assert.equal(collection.show, true); + // clampToHeight too, and a throwing scene still shows the set again. + viewer.scene.clampToHeight = (position, excluded) => { + shownDuring.push(collection.show, excluded); + throw new Error('no depth texture'); + }; + shownDuring.length = 0; + assert.equal( + surface.clampToHeight(geo.cartesian3.fromDegrees(20, 10, 0), [set]), + undefined, + ); + assert.deepEqual(shownDuring, [false, []]); + assert.equal(collection.show, true); +}); diff --git a/src/globe/cesium/testing/cameraViewer.mjs b/src/globe/cesium/testing/cameraViewer.mjs index 18b81ea82..6b6148460 100644 --- a/src/globe/cesium/testing/cameraViewer.mjs +++ b/src/globe/cesium/testing/cameraViewer.mjs @@ -1,18 +1,25 @@ // A Cesium viewer around a real Cesium.Camera on a stub scene (no renderer, // GPU or providers), with the Cesium engine over it: for tests that check -// what the globe contract does to Cesium's own camera geometry. +// what the globe contract does to Cesium's own camera geometry. The scene's +// primitives are a real PrimitiveCollection, so mover sets and lines build +// their Cesium collections (they draw nothing). import * as Cesium from 'cesium'; import { createCesiumEngine } from '../createCesiumEngine.js'; /** - * @param {{ width?: number, height?: number }} [options] The canvas size. + * @param {{ width?: number, height?: number, engineOptions?: object }} [options] + * The canvas size; `engineOptions` go to createCesiumEngine. * @returns {{ viewer: object, scene: object, camera: Cesium.Camera, * canvas: EventTarget, engine: import('../../contract.js').GlobeEngine, * frame: () => void }} `frame()` runs one turn of CesiumWidget's loop: the * viewer clock's tick, then the scene's frame events. */ -export function createCameraViewer({ width = 1280, height = 800 } = {}) { +export function createCameraViewer({ + width = 1280, + height = 800, + engineOptions = {}, +} = {}) { const canvas = Object.assign(new EventTarget(), { clientWidth: width, clientHeight: height, @@ -32,6 +39,7 @@ export function createCameraViewer({ width = 1280, height = 800 } = {}) { pixelRatio: 1, // Cesium's running camera flights. tweens: [], + primitives: new Cesium.PrimitiveCollection(), preUpdate: new Cesium.Event(), preRender: new Cesium.Event(), postRender: new Cesium.Event(), @@ -61,7 +69,10 @@ export function createCameraViewer({ width = 1280, height = 800 } = {}) { }, isDestroyed: () => false, }; - const engine = createCesiumEngine(viewer, { requestRender: () => {} }); + const engine = createCesiumEngine(viewer, { + requestRender: () => {}, + ...engineOptions, + }); const frame = () => { const time = viewer.clock.currentTime; viewer.clock.onTick.raiseEvent(viewer.clock); diff --git a/src/globe/contract.js b/src/globe/contract.js index d5eaac0f1..f817cf4f3 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -369,7 +369,8 @@ * @property {'normal'|'always'|{ disableWithinM: number }} [depthTest] * 'always' draws over the scene; { disableWithinM } skips the depth test for * instances closer to the camera than that (Cesium's - * disableDepthTestDistance). 'normal' by default. + * disableDepthTestDistance). 'normal' by default. Sprites and points + * only: models are depth-tested in the scene on both engines. * @property {{ atlas: { url?: string, canvas?: object, cells: Object }, * sizePx: number, scaleByDistance?: number[] }} [sprite] An image atlas * whose cells are [u0, v0, u1, v1] in 0..1 (v down from the image top); @@ -382,11 +383,16 @@ * @property {{ url: string, minimumPixelSize?: number, * colorBlend?: { mode: 'mix'|'replace', amount: number }, unlit?: boolean }} [model] * A glTF drawn per instance in metres, at least minimumPixelSize px. - * @property {boolean} [pickable] + * Without colorBlend the instance colour sets only the alpha; 'mix' mixes + * the lit colour toward it by amount, 'replace' replaces it; unlit draws + * the model's own colours without lighting. + * @property {boolean} [pickable] Whether picks return the set's instances + * (false by default: picks pass through to what lies under them). * @property {boolean} [sharpen] Sprites/points: include the glyph in the * sharpen/AA stage after tone mapping, for translucent fields such as FIRMS - * (by default Navara draws them in the later display stage). Does not - * enable sharpening or change its amount. + * (by default Navara draws them in the later display stage; Cesium draws + * billboards and points in its own stage either way). Does not enable + * sharpening or change its amount. * * @typedef {object} MoverFields * One instance; omitted fields keep their value (defaults: white, scale 1, @@ -410,7 +416,8 @@ * @property {() => void} commit Uploads what changed (one buffer write) and * requests a render; nothing else draws a frame. * @property {() => boolean} ready Whether what the set draws has loaded: a - * model set's glTF, a sprite set's atlas. + * model set's glTF (Cesium: every drawn instance's model), a sprite set's + * atlas. * A layer keeps its fallback visual until then. * @property {() => void} destroy * @@ -434,8 +441,9 @@ * straight segments. * @property {Matrix4} [modelMatrix] Applied to the positions (an orbit ring * turned by GMST). - * @property {any} [pickId] What a pick of the line returns (Navara uses - * CPU segment picks; lines do not participate in its GPU pass). + * @property {any} [pickId] What a pick of the line returns; a line without + * one is not picked (Navara uses CPU segment picks; lines do not + * participate in its GPU pass). * * @typedef {object} Line * @property {(positions: Cartesian3[] | Float64Array) => void} setPositions @@ -469,11 +477,9 @@ * @property {{ addStatic: (html: string, options?: { onScreen?: boolean }) => () => void }} credits * @property {GlobeMaps} maps * @property {GlobeClock} clock - * @property {GlobeMovers} [movers] Optional, with lines (capabilities - * `movers` and `lines`): the engine draws the layers' moving objects and - * polylines (Navara). Cesium has none: in Cesium mode the layers draw - * their own Cesium primitives. - * @property {GlobeLines} [lines] + * @property {GlobeMovers} movers The layers' moving objects (sprites, + * points, models), drawn by the engine. + * @property {GlobeLines} lines The layers' dynamic polylines. * @property {() => void} destroy * @property {() => boolean} isDestroyed True once destroy() has run. */ @@ -674,6 +680,8 @@ const SHAPE = { input: { on: isFunction, setCameraInputs: isFunction }, credits: { addStatic: isFunction }, clock: { now: isFunction, frameTime: isFunction }, + movers: { createSet: isFunction, list: isFunction }, + lines: { createLine: isFunction, list: isFunction }, maps: { setBaseImagery: isFunction, setTerrain: isFunction, @@ -710,16 +718,12 @@ const OPTIONAL_SHAPE = { 'render.setContactSonar': isFunction, 'render.contactSonar': isFunction, }, - movers: { - 'movers.createSet': isFunction, - 'movers.list': isFunction, - }, - lines: { - 'lines.createLine': isFunction, - 'lines.list': isFunction, - }, }; +// Required capabilities engineCapabilities reports as well: true for an +// engine that has them whole, as every valid engine does. +const REPORTED_REQUIRED = ['movers', 'lines']; + const memberAt = (engine, path) => path.split('.').reduce((value, key) => value?.[key], engine); @@ -727,18 +731,25 @@ const memberAt = (engine, path) => * Which optional capabilities an engine has: { visualStyles, ... } as * booleans. A caller that refuses a feature on an engine without a Cesium * viewer (voice, say) asks here instead: Cesium draws these through the app's - * own stages and reports false. + * own stages and reports false. `movers` and `lines` are required; they are + * reported too, true for every valid engine. */ export function engineCapabilities(engine) { - return Object.fromEntries( - Object.entries(OPTIONAL_SHAPE).map(([flag, members]) => [ + const whole = (members, prefix = '') => + isObject(engine) && + Object.entries(members).every(([path, valid]) => + valid(memberAt(engine, prefix + path)), + ); + return Object.fromEntries([ + ...Object.entries(OPTIONAL_SHAPE).map(([flag, members]) => [ flag, - isObject(engine) && - Object.entries(members).every(([path, valid]) => - valid(memberAt(engine, path)), - ), + whole(members), ]), - ); + ...REPORTED_REQUIRED.map((capability) => [ + capability, + whole(SHAPE[capability], `${capability}.`), + ]), + ]); } /** diff --git a/src/globe/contract.test.mjs b/src/globe/contract.test.mjs index d847148fc..3e4198e18 100644 --- a/src/globe/contract.test.mjs +++ b/src/globe/contract.test.mjs @@ -293,29 +293,28 @@ test('mergeSharpen keeps omitted fields and rejects bad values', () => { assert.throws(() => mergeSharpen(on, bad), TypeError); }); -test('movers and lines are optional capabilities, whole or absent', () => { +test('movers and lines are required and reported as capabilities', () => { const engine = createFakeEngine(); assert.equal(engineCapabilities(engine).movers, true); assert.equal(engineCapabilities(engine).lines, true); const without = { ...engine }; delete without.movers; delete without.lines; - assert.equal(assertGlobeEngine(without), without); + assert.throws(() => assertGlobeEngine(without), /\bmovers\b.*\blines\b/); assert.equal(engineCapabilities(without).movers, false); - const half = { ...engine, movers: { createSet: 1 } }; - assert.throws( - () => assertGlobeEngine(half), - /movers\.createSet.*movers\.list/, - ); - // Without them the suite leaves out the checks that draw movers and lines. - const names = (target) => - createContractSuite(target, { waitFrames: async () => {} }).map( - ({ name }) => name, + assert.equal(engineCapabilities(without).lines, false); + const half = { ...engine, movers: { createSet: () => {} } }; + assert.throws(() => assertGlobeEngine(half), /movers\.list/); + assert.equal(engineCapabilities(half).movers, false); + // The suite draws movers and lines on every engine. + const names = createContractSuite(engine, { + waitFrames: async () => {}, + }).map(({ name }) => name); + for (const prefix of ['movers:', 'lines:', 'colours:']) + assert.ok( + names.some((name) => name.startsWith(prefix)), + prefix, ); - assert.ok(names(engine).some((name) => name.startsWith('movers:'))); - const plain = names(without); - assert.ok(!plain.some((name) => /^(movers|lines|colours):/.test(name))); - assert.ok(plain.some((name) => name.startsWith('camera.'))); }); test('engineCapabilities names the optional capabilities an engine has whole', () => { diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index 4a7e7edc7..0ac17312f 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -133,8 +133,6 @@ export function createContractSuite( }; const capabilities = engineCapabilities(engine); - // A check may name the optional capabilities it needs (movers, lines) - // after its function; without them it is left out. const checks = [ [ 'shape: the engine has every contract capability', @@ -574,7 +572,6 @@ export function createContractSuite( 'a destroyed set is still listed', ); }, - ['movers'], ], [ 'colours: mid-tone sprite, point and line colours draw as asked (within 3 per channel)', @@ -673,7 +670,6 @@ export function createContractSuite( for (const line of lines) line.destroy(); } }, - ['movers', 'lines'], ], [ 'lines: a line draws between its projections, turns with its model matrix and dims where hidden', @@ -784,7 +780,6 @@ export function createContractSuite( 'a destroyed line is still listed', ); }, - ['lines'], ], [ 'input: picks on click and move payloads, drill, lines', @@ -924,7 +919,6 @@ export function createContractSuite( line.destroy(); } }, - ['movers', 'lines'], ], [ 'lines: pixel dashes are regular, in CSS pixels', @@ -994,7 +988,6 @@ export function createContractSuite( line.destroy(); } }, - ['lines'], ], [ 'movers: depth tests, rotation, cells, scale, count, points and models', @@ -1223,7 +1216,6 @@ export function createContractSuite( for (const set of sets) set.destroy(); } }, - ['movers'], ], [ 'render: continuous mode toggles', @@ -1609,7 +1601,6 @@ export function createContractSuite( 1e-6, 'sampleHeight and the detailed tiles answer', ); - if (!capabilities.movers) return; // A mover set in exclude: the sample works and the set stays. const set = engine.movers.createSet({ id: 'contract-surface-exclude', @@ -2740,7 +2731,7 @@ export function createContractSuite( } }, ], - ].filter(([, , needs = []]) => needs.every((flag) => capabilities[flag])); + ]; if (mapSources?.photoreal && mapSources.imagery?.length >= 2) checks.push(...mapChecks(engine.maps, mapSources)); if (capabilities.visualStyles)