Draw movers and lines on Cesium and require them of every engine

Layers are to draw through the globe contract on both engines, so Cesium
gets back the movers and lines removed as unused:

- engine.movers draws sprite sets as a BillboardCollection (atlas cells
  cropped once per set), point sets as a PointPrimitiveCollection and
  model sets as one Model per instance. depthTest, disableWithinM, order,
  scaleByDistance, colorBlend, unlit and minimumPixelSize follow the
  contract; a model's matrix is written in place, so instances no longer
  share one scratch matrix.
- engine.lines draws each line as a polyline Primitive with width in CSS
  px, geodesic arcs, dashes, the tactical and arrow materials,
  depthFailAlpha and modelMatrix; a rebuild keeps its show, matrix,
  colour and place in order.
- The picker returns the pickId a layer wrote last, skips sets that are
  not pickable and instances behind the horizon, and falls through to
  what lies under them. Surface samples can exclude mover sets again.

movers and lines move from the optional capabilities into the required
contract shape, and the contract suite always runs their checks.
This commit is contained in:
German Khokhlov committed 2026-11-07 01:24:59 +04:00
1 parent 59a233db51
commit 2213be6566
19 files changed
+1758 -75

No files matched your search

+5 -2
View File
@@ -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
+4
View File
@@ -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",
+2
View File
@@ -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",
+3 -2
View File
@@ -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),
}));
+2 -2
View File
@@ -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(),
};
+29 -5
View File
@@ -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;
@@ -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');
});
+249
View File
@@ -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;
}
},
};
}
+242
View File
@@ -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/);
});
+401
View File
@@ -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;
}
},
};
}
+451
View File
@@ -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);
});
+25 -4
View File
@@ -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];
},
+68
View File
@@ -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');
});
+41 -3
View File
@@ -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;
}
+35
View File
@@ -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);
});
+15 -4
View File
@@ -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);
+39 -28
View File
@@ -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<string, number[]> },
* 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}.`),
]),
]);
}
/**
+14 -15
View File
@@ -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', () => {
+1 -10
View File
@@ -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)