mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
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:
1 parent
59a233db51
commit
2213be6566
19 files changed
+1758
-75
No files matched your search
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user