Batch infrastructure and cable stems through engine.lines

Add copied, committed segment batches to the contract, Cesium, Navara and
the fake engine. Keep each segment's colour, width and pick identity while
drawing one primitive or overlay mesh per display. Preserve Cesium's stem
sizes, ground sampling, visible cohorts, update cadence and click targets.
Navara draws the batch after tone mapping through the existing overlay.

Cover disconnected geometry, picks, hidden replacement, empty batches and
cleanup in the contract suite and engine tests. Keep the infrastructure
and cable display assertions on fake and headless Cesium engines, and
read batch counts and pick identities in the browser checks.

No coupling count fell or grew: all eleven metrics are unchanged, including
891 viewer lines, three external Cesium import files and 189 layer Navara
render lines.

Checked in the browser, keyless: the contract suite passes on both engines,
with the new segment picking and pixel checks; the infrastructure and
submarine-cable engine switches, the cable overlay and infrastructure LOD
scripts pass.
This commit is contained in:
German Khokhlov committed 2026-11-07 22:47:24 +04:00
1 parent fd65288101
commit fbadf792d8
19 files changed
+677 -141

No files matched your search

+2 -2
View File
@@ -138,8 +138,8 @@ try {
out.tips += set.count; out.tips += set.count;
} }
} }
out.stems = engine.lines.list().filter((line) => out.stems = (engine.lines.list().find((line) =>
line.id.startsWith(`${layerId}-stem-`) && line.shown).length; line.id === `${layerId}-stems` && line.shown)?.points ?? 0) / 2;
const diag = window.__gevWorldOverlay?.getDiagnostics?.() || {}; const diag = window.__gevWorldOverlay?.getDiagnostics?.() || {};
out.hostEntries = diag.entriesBySource?.[layerId] || 0; out.hostEntries = diag.entriesBySource?.[layerId] || 0;
out.hostPainted = diag.paintedBySource?.[layerId] || 0; out.hostPainted = diag.paintedBySource?.[layerId] || 0;
+4 -7
View File
@@ -234,13 +234,10 @@ try {
// ── GATE c: no churn between camera moves ─────────────────────────── // ── GATE c: no churn between camera moves ───────────────────────────
const churn = await page.evaluate(async (infraIds) => { const churn = await page.evaluate(async (infraIds) => {
const gev = window.__godsEyeView; const gev = window.__godsEyeView;
// The layer draws through the globe contract: a reference in view has // A batch lists the visible references' pick ids in drawing order.
// a stem line `${layerId}-stem-${featureId}` (render/display.js drops const snap = () => infraIds.map((id) => (gev.engine.lines.list()
// it when the reference leaves the view or the LOD set). .find((line) => line.id === `${id}-stems` && line.shown)?.pickIds ?? [])
const snap = () => infraIds.map((id) => gev.engine.lines.list() .slice().sort());
.filter((line) => line.id.startsWith(`${id}-stem-`) && line.shown)
.map((line) => line.id)
.sort());
const before = snap(); const before = snap();
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
gev.viewer.scene.requestRender?.(); gev.viewer.scene.requestRender?.();
@@ -563,7 +563,12 @@ test('a real enabled cable layer publishes host entries and draws through the en
collisionCapacity: CABLE_OVERLAY_COLLISION_CAPACITY, collisionCapacity: CABLE_OVERLAY_COLLISION_CAPACITY,
moving: false, moving: false,
}); });
assert.deepEqual(env.drawn(), { ground: 1, movers: 3, lines: 2 }); assert.deepEqual(env.drawn(), { ground: 1, movers: 3, lines: 1 });
assert.equal(
env.engine.lines.list()[0].points,
4,
'both stems share the batch',
);
const stats = env.layer.getStats(); const stats = env.layer.getStats();
assert.equal(stats.referenceLabelCount, 2); assert.equal(stats.referenceLabelCount, 2);
assert.equal(stats.count, 2); assert.equal(stats.count, 2);
+1 -1
View File
@@ -288,7 +288,7 @@ test('the contract suite movers and lines checks pass on the Cesium engine (no p
name, name,
), ),
); );
assert.equal(checks.length, 8); assert.equal(checks.length, 9);
try { try {
for (const { name, run } of checks) await t.test(name, run); for (const { name, run } of checks) await t.test(name, run);
} finally { } finally {
+40
View File
@@ -197,6 +197,39 @@ export function createCesiumLines(
slot.add(primitive); slot.add(primitive);
requestRender('lines'); requestRender('lines');
}, },
setSegments(items) {
drop();
if (items.length) {
primitive = make.primitive({
geometryInstances: items.map(
(item) =>
new Cesium.GeometryInstance({
geometry: new Cesium.PolylineGeometry({
positions: Cesium.Cartesian3.unpackArray(item.positions),
width: item.widthPx,
vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
arcType: Cesium.ArcType.NONE,
}),
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
toColor(item.color),
),
},
id: item.pickId,
}),
),
appearance: new Cesium.PolylineColorAppearance({
translucent: true,
}),
show: shown,
asynchronous: false,
allowPicking: items.some((item) => item.pickId !== undefined),
});
primitive.gevLine = options.id;
slot.add(primitive);
}
requestRender('lines');
},
setColor(rgba) { setColor(rgba) {
color = rgba; color = rgba;
if (appearance) appearance.material.uniforms.color = toColor(rgba); if (appearance) appearance.material.uniforms.color = toColor(rgba);
@@ -245,6 +278,13 @@ export function createCesiumLines(
identify(picked) { identify(picked) {
const owner = picked?.primitive?.gevLine; const owner = picked?.primitive?.gevLine;
const state = owner === undefined ? null : registry.live.get(owner); const state = owner === undefined ? null : registry.live.get(owner);
if (state?.options.segments)
return state.shown &&
state.items.some(
(item) => item.pickId !== undefined && item.pickId === picked.id,
)
? { owner, id: picked.id }
: { owner, id: picked.id, pickable: false };
return state && state.options.pickId !== undefined return state && state.options.pickId !== undefined
? { owner, id: state.options.pickId } ? { owner, id: state.options.pickId }
: null; : null;
+56
View File
@@ -240,3 +240,59 @@ test('engine destroy destroys every line and removes the root', () => {
assert.deepEqual(lines.list(), []); assert.deepEqual(lines.list(), []);
assert.throws(() => line.setPositions(points), /destroyed/); assert.throws(() => line.setPositions(points), /destroyed/);
}); });
test('segments share one primitive with individual widths, colours and pick ids', () => {
const { lines, drawn, identify, primitiveOf, destroy } = setup();
const batch = lines.createSegments({
id: 'stems',
widthPx: 3.5,
color: [1, 0, 0, 0.5],
});
batch.replace([
{ positions: points.slice(0, 2), pickId: 'first' },
{
positions: points.slice(1),
widthPx: 2.4,
color: [0, 1, 0, 0.7],
pickId: 'second',
},
{ positions: points.slice(0, 2) },
]);
batch.commit();
const [primitive] = drawn();
assert.equal(drawn().length, 1);
assert.equal(
primitiveOf(batch).get(0),
primitive,
'surface exclusions resolve the batch',
);
assert.ok(primitive.appearance instanceof Cesium.PolylineColorAppearance);
const [first, second] = primitive.geometryInstances;
assert.equal(first.geometry._width, 3.5);
assert.equal(second.geometry._width, 2.4);
assert.equal(first.geometry._positions.length, 2);
assert.equal(first.geometry._arcType, Cesium.ArcType.NONE);
assert.deepEqual([...first.attributes.color.value], [255, 0, 0, 128]);
assert.deepEqual([...second.attributes.color.value], [0, 255, 0, 179]);
assert.deepEqual(identify({ primitive, id: 'second' }), {
owner: 'stems',
id: 'second',
});
assert.equal(identify({ primitive }).pickable, false);
batch.show(false);
assert.equal(identify({ primitive, id: 'first' }).pickable, false);
batch.replace([{ positions: points.slice(1), pickId: 'next' }]);
batch.commit();
assert.equal(primitive.isDestroyed(), true);
assert.equal(drawn()[0].show, false);
batch.show(true);
assert.equal(
identify({ primitive: drawn()[0], id: 'first' }).pickable,
false,
);
batch.replace([]);
batch.commit();
assert.deepEqual(drawn(), [null]);
destroy();
assert.deepEqual(lines.list(), []);
});
+20 -2
View File
@@ -490,9 +490,23 @@
* @property {() => void} destroy * @property {() => void} destroy
* Every call requests a render; nothing animates on its own. * Every call requests a render; nothing animates on its own.
* *
* @typedef {object} LineSegments
* A batch of independent, straight, solid, depth-tested world segments.
* replace copies the items; commit draws them together. Omitting an item
* removes it. Width and colour default to the batch's options. Each item
* has its own pickId; picks name the batch as owner. Empty batches draw nothing.
* @property {(items: { positions: Cartesian3[] | Float64Array, widthPx?: number, color?: number[], pickId?: any }[]) => void} replace
* Exactly two positions per segment, never connected to other segments.
* @property {() => void} commit
* @property {(visible: boolean) => void} show
* @property {() => void} destroy
*
* @typedef {object} GlobeLines * @typedef {object} GlobeLines
* @property {(options: LineOptions) => Line} createLine * @property {(options: LineOptions) => Line} createLine
* @property {() => { id: string, points: number, shown: boolean }[]} list * @property {(options: { id: string, widthPx: number, color: number[], order?: number }) => LineSegments} createSegments
* Shares the live id namespace and list with createLine. No dash, arc,
* model matrix or depth-fail copy; each segment is two Earth-fixed endpoints.
* @property {() => { id: string, points: number, shown: boolean, pickIds?: any[] }[]} list
* *
* @typedef {object} GroundSetOptions * @typedef {object} GroundSetOptions
* A set of flat fills draped on the ground: on terrain and 3D tiles, * A set of flat fills draped on the ground: on terrain and 3D tiles,
@@ -947,7 +961,11 @@ const SHAPE = {
credits: { addStatic: isFunction }, credits: { addStatic: isFunction },
clock: { now: isFunction, frameTime: isFunction }, clock: { now: isFunction, frameTime: isFunction },
movers: { createSet: isFunction, list: isFunction }, movers: { createSet: isFunction, list: isFunction },
lines: { createLine: isFunction, list: isFunction }, lines: {
createLine: isFunction,
createSegments: isFunction,
list: isFunction,
},
meshes: { createMesh: isFunction, list: isFunction }, meshes: { createMesh: isFunction, list: isFunction },
ground: { ground: {
createSet: isFunction, createSet: isFunction,
+78 -1
View File
@@ -239,11 +239,88 @@ export function createLineRegistry(build) {
live.set(options.id, state); live.set(options.id, state);
return line; return line;
}, },
createSegments(input) {
const options = normalizeLineOptions(input);
if (
options.arc !== 'none' ||
options.dash ||
options.arrow ||
options.modelMatrix ||
options.depthFailAlpha ||
options.pickId !== undefined
)
fail(
'segment batches use straight, solid, depth-tested world segments',
);
options.segments = true;
if (live.has(options.id))
throw new Error(`Line ${options.id} already exists`);
const part = build(options);
const state = {
options,
part,
points: 0,
shown: true,
destroyed: false,
items: [],
};
let pending = null;
const alive = () => {
if (state.destroyed) throw new Error('Line was destroyed');
};
const batch = {
replace(items) {
alive();
if (!Array.isArray(items)) fail('segments must be an array');
pending = items.map((item) => {
const positions = flattenPositions(item.positions);
if (positions.length !== 6)
fail('a segment needs exactly two positions');
const color = item.color ?? options.color;
const widthPx = item.widthPx ?? options.widthPx;
if (!isColor(color)) fail('color must be [r, g, b, a]');
if (!(isFiniteNumber(widthPx) && widthPx > 0))
fail('widthPx must be positive');
return {
positions,
color: [...color],
widthPx,
pickId: item.pickId,
};
});
},
commit() {
alive();
if (!pending) return;
part.setSegments(pending);
state.items = pending;
state.points = pending.length * 2;
pending = null;
},
show(visible) {
alive();
state.shown = Boolean(visible);
part.show(state.shown);
},
destroy() {
if (state.destroyed) return;
state.destroyed = true;
live.delete(options.id);
part.destroy();
},
};
state.line = batch;
live.set(options.id, state);
return batch;
},
list: () => list: () =>
[...live.values()].map(({ options, points, shown }) => ({ [...live.values()].map(({ options, points, shown, items }) => ({
id: options.id, id: options.id,
points, points,
shown, shown,
...(options.segments
? { pickIds: items.map((item) => item.pickId) }
: {}),
})), })),
}; };
return { return {
+47
View File
@@ -122,3 +122,50 @@ test('the tactical dash is the launches mission-orbit pattern', async () => {
dashes: policy.MISSION_ORBIT_DASHES_PER_GROUP, dashes: policy.MISSION_ORBIT_DASHES_PER_GROUP,
}); });
}); });
test('segment batches copy endpoints and styles, commit atomically and share line ids', () => {
const draws = [];
let destroyed = 0;
const registry = createLineRegistry(() => ({
setSegments: (items) => draws.push(items),
show() {},
destroy() {
destroyed++;
},
}));
const options = { id: 'stems', widthPx: 3.5, color: [1, 0, 0, 0.5] };
const batch = registry.lines.createSegments(options);
assert.throws(() => registry.lines.createLine(options), /already exists/);
assert.throws(() => registry.lines.createSegments(options), /already exists/);
const positions = new Float64Array([1, 2, 3, 4, 5, 6]);
const color = [0, 1, 0, 1];
batch.replace([{ positions, color, widthPx: 2, pickId: 'first' }]);
positions[0] = 99;
color[0] = 1;
assert.equal(draws.length, 0);
for (const item of [
{ positions: new Float64Array([1, 2, 3]) },
{ positions, widthPx: 0 },
{ positions, color: [1, 2] },
])
assert.throws(() => batch.replace([item]), TypeError);
batch.commit();
assert.equal(draws[0][0].positions[0], 1);
assert.deepEqual(draws[0][0].color, [0, 1, 0, 1]);
assert.equal(draws[0][0].widthPx, 2);
batch.commit();
assert.equal(draws.length, 1, 'clean commit does not rebuild');
batch.show(false);
batch.replace([]);
batch.commit();
assert.deepEqual(registry.lines.list(), [
{ id: 'stems', points: 0, shown: false, pickIds: [] },
]);
registry.destroyAll();
batch.destroy();
assert.equal(destroyed, 1);
assert.throws(() => batch.commit(), /destroyed/);
assert.throws(() => batch.replace([]), /destroyed/);
assert.throws(() => batch.show(true), /destroyed/);
assert.deepEqual(registry.lines.list(), []);
});
+3
View File
@@ -43,6 +43,9 @@ const NO_MOVERS = Object.freeze({
list: () => [], list: () => [],
}); });
const NO_LINES = Object.freeze({ const NO_LINES = Object.freeze({
createSegments() {
throw new Error("Navara lines need the view's three runtime (lines/)");
},
createLine() { createLine() {
throw new Error("Navara lines need the view's three runtime (lines/)"); throw new Error("Navara lines need the view's three runtime (lines/)");
}, },
+26 -3
View File
@@ -42,6 +42,13 @@ export function createNavaraLines(view, { overlay }) {
line.setPositions(state.flat); line.setPositions(state.flat);
request(); request();
}, },
setSegments(items) {
state.items = items;
const flat = new Float64Array(items.length * 6);
items.forEach((item, index) => flat.set(item.positions, index * 6));
line.setPositions(flat, items);
request();
},
setColor(rgba) { setColor(rgba) {
line.setColor(rgba); line.setColor(rgba);
request(); request();
@@ -68,9 +75,25 @@ export function createNavaraLines(view, { overlay }) {
lines: registry.lines, lines: registry.lines,
/** Shown lines with a pickId: options, drawn positions, matrix. */ /** Shown lines with a pickId: options, drawn positions, matrix. */
pickables: () => pickables: () =>
[...drawn.values()].filter( [...drawn.values()]
(state) => state.shown && state.options.pickId !== undefined, .filter((state) => state.shown)
), .flatMap((state) =>
state.options.segments
? (state.items ?? [])
.filter((item) => item.pickId !== undefined)
.map((item) => ({
options: {
...state.options,
widthPx: item.widthPx,
pickId: item.pickId,
},
flat: item.positions,
modelMatrix: null,
}))
: state.options.pickId !== undefined
? [state]
: [],
),
destroy: () => registry.destroyAll(), destroy: () => registry.destroyAll(),
}; };
} }
+36 -7
View File
@@ -30,6 +30,11 @@ const VERTEX_HEAD = /* glsl */ `
attribute vec3 gevEnd; attribute vec3 gevEnd;
attribute vec2 gevSide; attribute vec2 gevSide;
attribute float gevS; attribute float gevS;
#ifdef GEV_SEGMENTS
attribute vec4 gevColor;
attribute float gevWidth;
varying vec4 vGevColor;
#endif
uniform vec2 uGevViewport; uniform vec2 uGevViewport;
uniform float uGevWidthPx; uniform float uGevWidthPx;
varying float vGevS; varying float vGevS;
@@ -51,7 +56,12 @@ vec2 gevDirection = gevScreenB - gevScreenA;
gevDirection = length(gevDirection) > 1e-6 ? normalize(gevDirection) : vec2(1.0, 0.0); gevDirection = length(gevDirection) > 1e-6 ? normalize(gevDirection) : vec2(1.0, 0.0);
vec2 gevNormal = vec2(-gevDirection.y, gevDirection.x); vec2 gevNormal = vec2(-gevDirection.y, gevDirection.x);
gl_Position = gevSide.x < 0.5 ? gevA : gevB; gl_Position = gevSide.x < 0.5 ? gevA : gevB;
#ifdef GEV_SEGMENTS
vGevColor = gevColor;
gl_Position.xy += gevNormal * gevSide.y * gevWidth / uGevViewport * gl_Position.w;
#else
gl_Position.xy += gevNormal * gevSide.y * uGevWidthPx / uGevViewport * gl_Position.w; gl_Position.xy += gevNormal * gevSide.y * uGevWidthPx / uGevViewport * gl_Position.w;
#endif
vec4 mvPosition = modelViewMatrix * vec4(gevSide.x < 0.5 ? position : gevEnd, 1.0); vec4 mvPosition = modelViewMatrix * vec4(gevSide.x < 0.5 ? position : gevEnd, 1.0);
vGevS = gevS; vGevS = gevS;
vGevT = gevSide.y * 0.5 + 0.5; vGevT = gevSide.y * 0.5 + 0.5;
@@ -61,6 +71,9 @@ vGevDepthFree = 0.0;
const FRAGMENT_HEAD = /* glsl */ ` const FRAGMENT_HEAD = /* glsl */ `
uniform vec4 uGevColor; uniform vec4 uGevColor;
#ifdef GEV_SEGMENTS
varying vec4 vGevColor;
#endif
uniform float uGevAlphaScale; uniform float uGevAlphaScale;
uniform float uGevPixelRatio; uniform float uGevPixelRatio;
${OVERLAY_DEPTH_FRAGMENT} ${OVERLAY_DEPTH_FRAGMENT}
@@ -92,7 +105,11 @@ const FRAGMENT_COLOR = /* glsl */ `
#include <color_fragment> #include <color_fragment>
if (gevOverlayHidden()) discard; if (gevOverlayHidden()) discard;
// sRGB colours (as Cesium takes them) into the linear output. // sRGB colours (as Cesium takes them) into the linear output.
#ifdef GEV_SEGMENTS
diffuseColor = sRGBTransferEOTF(vGevColor);
#else
diffuseColor = sRGBTransferEOTF(uGevColor); diffuseColor = sRGBTransferEOTF(uGevColor);
#endif
diffuseColor.a *= uGevAlphaScale; diffuseColor.a *= uGevAlphaScale;
if (uGevArrow) { if (uGevArrow) {
float head = max(fwidth(vGevS) * 10.0 * uGevPixelRatio, 1e-6); float head = max(fwidth(vGevS) * 10.0 * uGevPixelRatio, 1e-6);
@@ -142,14 +159,16 @@ export function patchLineShader(shader, uniforms) {
* segment 4 vertices (start/end × side) and 2 triangles; `gevS` is the * segment 4 vertices (start/end × side) and 2 triangles; `gevS` is the
* fraction of the line's length at the vertex's end. * fraction of the line's length at the vertex's end.
*/ */
export function buildRibbon(flat, origin) { export function buildRibbon(flat, origin, items = null) {
const points = flat.length / 3; const points = flat.length / 3;
const segments = Math.max(0, points - 1); const segments = items ? items.length : Math.max(0, points - 1);
const start = new Float32Array(segments * 12); const start = new Float32Array(segments * 12);
const end = new Float32Array(segments * 12); const end = new Float32Array(segments * 12);
const side = new Float32Array(segments * 8); const side = new Float32Array(segments * 8);
const along = new Float32Array(segments * 4); const along = new Float32Array(segments * 4);
const index = new Uint32Array(segments * 6); const index = new Uint32Array(segments * 6);
const colors = items ? new Float32Array(segments * 16) : null;
const widths = items ? new Float32Array(segments * 4) : null;
const cumulative = new Float64Array(points); const cumulative = new Float64Array(points);
for (let p = 1; p < points; p++) for (let p = 1; p < points; p++)
cumulative[p] = cumulative[p] =
@@ -161,7 +180,7 @@ export function buildRibbon(flat, origin) {
); );
const total = cumulative[points - 1] || 1; const total = cumulative[points - 1] || 1;
for (let s = 0; s < segments; s++) { for (let s = 0; s < segments; s++) {
const a = s * 3; const a = s * (items ? 6 : 3);
const b = a + 3; const b = a + 3;
for (let v = 0; v < 4; v++) { for (let v = 0; v < 4; v++) {
const vertex = s * 4 + v; const vertex = s * 4 + v;
@@ -172,7 +191,11 @@ export function buildRibbon(flat, origin) {
const atEnd = v >= 2 ? 1 : 0; const atEnd = v >= 2 ? 1 : 0;
side[vertex * 2] = atEnd; side[vertex * 2] = atEnd;
side[vertex * 2 + 1] = v % 2 === 0 ? 1 : -1; side[vertex * 2 + 1] = v % 2 === 0 ? 1 : -1;
along[vertex] = cumulative[s + atEnd] / total; along[vertex] = items ? atEnd : cumulative[s + atEnd] / total;
if (items) {
colors.set(items[s].color, vertex * 4);
widths[vertex] = items[s].widthPx;
}
} }
index.set( index.set(
[s * 4, s * 4 + 1, s * 4 + 2, s * 4 + 2, s * 4 + 1, s * 4 + 3], [s * 4, s * 4 + 1, s * 4 + 2, s * 4 + 2, s * 4 + 1, s * 4 + 3],
@@ -184,6 +207,10 @@ export function buildRibbon(flat, origin) {
geometry.setAttribute('gevEnd', new BufferAttribute(end, 3)); geometry.setAttribute('gevEnd', new BufferAttribute(end, 3));
geometry.setAttribute('gevSide', new BufferAttribute(side, 2)); geometry.setAttribute('gevSide', new BufferAttribute(side, 2));
geometry.setAttribute('gevS', new BufferAttribute(along, 1)); geometry.setAttribute('gevS', new BufferAttribute(along, 1));
if (items) {
geometry.setAttribute('gevColor', new BufferAttribute(colors, 4));
geometry.setAttribute('gevWidth', new BufferAttribute(widths, 1));
}
geometry.setIndex(new BufferAttribute(index, 1)); geometry.setIndex(new BufferAttribute(index, 1));
return geometry; return geometry;
} }
@@ -228,9 +255,11 @@ export function createLineMesh(
uGevAlphaScale: { value: alphaScale }, uGevAlphaScale: { value: alphaScale },
uGevDepthMode: { value: depthMode }, uGevDepthMode: { value: depthMode },
}; };
if (options.segments) value.defines = { GEV_SEGMENTS: 1 };
value.userData.gevDepthMode = depthMode; value.userData.gevDepthMode = depthMode;
value.onBeforeCompile = (shader) => patchLineShader(shader, uniforms); value.onBeforeCompile = (shader) => patchLineShader(shader, uniforms);
value.customProgramCacheKey = () => 'gev-line'; value.customProgramCacheKey = () =>
options.segments ? 'gev-segments' : 'gev-line';
return value; return value;
}; };
const group = new Group(); const group = new Group();
@@ -267,10 +296,10 @@ export function createLineMesh(
group, group,
uniforms: shared, uniforms: shared,
meshes: () => (behind ? [main, behind] : [main]), meshes: () => (behind ? [main, behind] : [main]),
setPositions(flat) { setPositions(flat, items = null) {
const previous = main.geometry; const previous = main.geometry;
origin = flat.length >= 3 ? [flat[0], flat[1], flat[2]] : [0, 0, 0]; origin = flat.length >= 3 ? [flat[0], flat[1], flat[2]] : [0, 0, 0];
const geometry = buildRibbon(flat, origin); const geometry = buildRibbon(flat, origin, items);
main.geometry = geometry; main.geometry = geometry;
if (behind) behind.geometry = geometry; if (behind) behind.geometry = geometry;
previous.dispose(); previous.dispose();
+79
View File
@@ -7,6 +7,7 @@ import {
UniformsUtils, UniformsUtils,
Vector3, Vector3,
} from 'three'; } from 'three';
import { createNavaraLines } from './index.js';
import { normalizeLineOptions } from '../../lines.js'; import { normalizeLineOptions } from '../../lines.js';
import { buildRibbon, createLineMesh, patchLineShader } from './lineMesh.js'; import { buildRibbon, createLineMesh, patchLineShader } from './lineMesh.js';
@@ -87,3 +88,81 @@ test('a line places its group at the first point under its model matrix', () =>
assert.equal(line.group.visible, false); assert.equal(line.group.visible, false);
line.dispose(); line.dispose();
}); });
test('segment batches share an overlay mesh without connecting the endpoints', () => {
const groups = [];
let requests = 0,
disposed = 0;
const overlay = {
uniforms: {
tGevSceneDepth: { value: null },
uGevResolution: { value: [0, 0] },
},
add: (group) => groups.push(group),
remove: (group) => groups.splice(groups.indexOf(group), 1),
};
const part = createNavaraLines(
{
forceUpdate() {
requests++;
},
canvas: { clientWidth: 800, clientHeight: 600 },
},
{ overlay },
);
const batch = part.lines.createSegments({
id: 'stems',
widthPx: 2,
color: [1, 0, 0, 1],
});
const items = [
{
positions: new Float64Array([6378137, 0, 0, 6378137, 10, 0]),
pickId: 'first',
},
{
positions: new Float64Array([6378137, 100, 0, 6378137, 110, 0]),
widthPx: 4,
color: [0, 1, 0, 0.5],
pickId: 'second',
},
];
batch.replace(items);
batch.commit();
assert.equal(groups.length, 1);
assert.equal(groups[0].children.length, 1);
const mesh = groups[0].children[0];
const geometry = mesh.geometry;
assert.equal(geometry.index.count, 12, 'two quads, no bridge');
assert.deepEqual(
[...geometry.getAttribute('position').array.slice(12, 15)],
[0, 100, 0],
);
assert.deepEqual(
[...geometry.getAttribute('gevWidth').array],
[2, 2, 2, 2, 4, 4, 4, 4],
);
assert.deepEqual(
[...geometry.getAttribute('gevColor').array.slice(16, 20)],
[0, 1, 0, 0.5],
);
assert.equal(mesh.material.defines.GEV_SEGMENTS, 1);
assert.deepEqual(
part.pickables().map((item) => item.options.pickId),
['first', 'second'],
);
geometry.addEventListener('dispose', () => disposed++);
batch.show(false);
batch.replace(items.slice(1));
batch.commit();
assert.equal(disposed, 1);
assert.equal(groups[0].visible, false);
assert.deepEqual(part.pickables(), []);
batch.show(true);
assert.equal(part.pickables()[0].options.pickId, 'second');
assert.equal(part.pickables()[0].options.id, 'stems');
part.destroy();
assert.deepEqual(groups, []);
assert.deepEqual(part.lines.list(), []);
assert.ok(requests >= 5);
});
+90
View File
@@ -787,6 +787,96 @@ export function createContractSuite(
); );
}, },
], ],
[
'lines: batched segments stay disconnected and retain individual picks',
async () => {
await nadirView(1e6);
const at = (lon, lat) => cartesian3.fromDegrees(lon, lat, 20_000);
const id = 'contract-segments';
const batch = engine.lines.createSegments({
id,
widthPx: 10,
color: [1, 0, 1, 1],
});
const screen = (lon, lat) =>
engine.projector.worldToWindow(at(lon, lat));
const pick = (lon, lat) => engine.picker.pick(screen(lon, lat));
const magenta = (lon, lat) => {
const p = screen(lon, lat);
const pixels = readRegion(
Math.round(p.x) - 5,
Math.round(p.y) - 5,
10,
10,
);
let count = 0;
for (let i = 0; i < pixels.length; i += 4)
if (Math.min(pixels[i], pixels[i + 2]) - pixels[i + 1] > 30)
count++;
return count;
};
try {
batch.replace([
{ positions: [at(19, 9.5), at(19, 10.5)], pickId: 'west' },
{
positions: [at(21, 9.5), at(21, 10.5)],
widthPx: 12,
pickId: 'east',
},
{ positions: [at(20, 10.8), at(20, 11.2)] },
]);
batch.commit();
ensure(
engine.lines.list().find((entry) => entry.id === id)?.points === 6,
'batch endpoints',
);
if (!pointer && !readRegion) return;
await until(
() => pick(19, 10)?.id === 'west' && pick(21, 10)?.id === 'east',
'both segment picks',
);
ensure(pick(19, 10)?.owner === id, 'batch pick owner');
ensure(
pick(20, 11)?.owner !== id,
'segment without a pick id is ignored',
);
ensure(pick(20, 10)?.owner !== id, 'no connecting segment pick');
if (readRegion) {
await until(
() => magenta(19, 10) > 10 && magenta(21, 10) > 10,
'both segments drawn',
);
ensure(magenta(20, 10) === 0, 'no connecting segment pixels');
}
batch.show(false);
await settle(3);
ensure(pick(19, 10)?.owner !== id, 'hidden batch is not picked');
batch.replace([
{ positions: [at(21, 9.5), at(21, 10.5)], pickId: 'replacement' },
]);
batch.commit();
await settle(3);
ensure(pick(21, 10)?.owner !== id, 'replacement stays hidden');
batch.show(true);
await until(
() => pick(21, 10)?.id === 'replacement',
'replacement pick',
);
ensure(pick(19, 10)?.owner !== id, 'removed segment is not picked');
batch.replace([]);
batch.commit();
await settle(3);
ensure(pick(21, 10)?.owner !== id, 'empty batch is not picked');
} finally {
batch.destroy();
batch.destroy();
}
ensure(
!engine.lines.list().some((entry) => entry.id === id),
'batch released',
);
},
],
[ [
'input: picks on click and move payloads, drill, lines', 'input: picks on click and move payloads, drill, lines',
async () => { async () => {
+23 -5
View File
@@ -232,6 +232,10 @@ export function createFakeEngine(options = {}) {
state.positions = flat; state.positions = flat;
changed(); changed();
}, },
setSegments(items) {
state.items = items;
changed();
},
setColor(rgba) { setColor(rgba) {
state.color = rgba; state.color = rgba;
changed(); changed();
@@ -636,11 +640,25 @@ export function createFakeEngine(options = {}) {
lines: () => lines: () =>
[...fakeLines.values()] [...fakeLines.values()]
.filter((state) => state.shown) .filter((state) => state.shown)
.map((state) => ({ .flatMap((state) =>
options: state.options, state.options.segments
flat: state.positions, ? (state.items ?? []).map((item) => ({
modelMatrix: state.modelMatrix, options: {
})), ...state.options,
widthPx: item.widthPx,
pickId: item.pickId,
},
flat: item.positions,
modelMatrix: null,
}))
: [
{
options: state.options,
flat: state.positions,
modelMatrix: state.modelMatrix,
},
],
),
// Ground items under the picked globe point (src/globe/ground.js). // Ground items under the picked globe point (src/globe/ground.js).
meshes: (at) => { meshes: (at) => {
const ray = engine.camera.pickRay(at); const ray = engine.camera.pickRay(at);
+34 -27
View File
@@ -131,6 +131,8 @@ export function createInfrastructureDisplay(
const owners = new Set(); const owners = new Set();
// record id → its stem, while the record is drawn. // record id → its stem, while the record is drawn.
const stems = new Map(); const stems = new Map();
let stemBatch = null,
stemsDirty = false;
const removers = []; const removers = [];
let dataset = null, let dataset = null,
tips = null, tips = null,
@@ -274,13 +276,12 @@ export function createInfrastructureDisplay(
const globe = !photoreal(); const globe = !photoreal();
if (globe && !engine.surface.tilesLoaded()) return false; if (globe && !engine.surface.tilesLoaded()) return false;
// The stem stands on the sample point: out of the sample. // The stem stands on the sample point: out of the sample.
const stem = stems.get(row.id); stemBatch?.show(false);
stem?.line.show(false);
let sampled; let sampled;
try { try {
sampled = engine.surface.sampleHeight(row.carto, exclude); sampled = engine.surface.sampleHeight(row.carto, exclude);
} finally { } finally {
stem?.line.show(true); stemBatch?.show(true);
} }
if (!finite(sampled) || Math.abs(sampled) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M) if (!finite(sampled) || Math.abs(sampled) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M)
return false; return false;
@@ -353,33 +354,26 @@ export function createInfrastructureDisplay(
} }
function stemOf(row) { function stemOf(row) {
let stem = stems.get(row.id); if (!stemBatch) {
if (!stem) { stemBatch = engine.lines.createSegments({
const lineId = `${id}-stem-${row.id}`; id: `${id}-stems`,
stem = { widthPx: STEM_PX,
id: lineId, color: rgba,
line: engine.lines.createLine({ });
id: lineId, owners.add(`${id}-stems`);
widthPx: STEM_PX,
color: rgba,
pickId: row.pickId,
}),
};
stem.line.setPositions([row.base, row.tip]);
stem.base = geo.cartesian3.clone(row.base);
stem.tip = geo.cartesian3.clone(row.tip);
owners.add(lineId);
stems.set(row.id, stem);
} }
const stem = {
base: geo.cartesian3.clone(row.base),
tip: geo.cartesian3.clone(row.tip),
pickId: row.pickId,
};
stems.set(row.id, stem);
stemsDirty = true;
return stem; return stem;
} }
function dropStem(rowId) { function dropStem(rowId) {
const stem = stems.get(rowId); if (stems.delete(rowId)) stemsDirty = true;
if (!stem) return;
stem.line.destroy();
owners.delete(stem.id);
stems.delete(rowId);
} }
// The LOD selection, from every record's distance and horizon. // The LOD selection, from every record's distance and horizon.
@@ -509,13 +503,23 @@ export function createInfrastructureDisplay(
!geo.cartesian3.equals(stem.tip, row.tip) || !geo.cartesian3.equals(stem.tip, row.tip) ||
!geo.cartesian3.equals(stem.base, row.base) !geo.cartesian3.equals(stem.base, row.base)
) { ) {
stem.line.setPositions([row.base, row.tip]); stemsDirty = true;
geo.cartesian3.clone(row.base, stem.base); geo.cartesian3.clone(row.base, stem.base);
geo.cartesian3.clone(row.tip, stem.tip); geo.cartesian3.clone(row.tip, stem.tip);
} }
if (row.entry) visible.push(row); if (row.entry) visible.push(row);
} }
} }
if (stemsDirty && stemBatch) {
stemBatch.replace(
[...stems.values()].map((stem) => ({
positions: [stem.base, stem.tip],
pickId: stem.pickId,
})),
);
stemBatch.commit();
stemsDirty = false;
}
dirty = false; dirty = false;
if (tipsChanged) tips.commit(); if (tipsChanged) tips.commit();
if (pinsChanged) pins.commit(); if (pinsChanged) pins.commit();
@@ -575,7 +579,10 @@ export function createInfrastructureDisplay(
services.hideOsmCredit?.(engine, id); services.hideOsmCredit?.(engine, id);
clearGroundRetry(); clearGroundRetry();
for (const remove of removers.splice(0)) remove(); for (const remove of removers.splice(0)) remove();
for (const rowId of [...stems.keys()]) dropStem(rowId); stems.clear();
stemBatch?.destroy();
stemBatch = null;
stemsDirty = false;
for (const set of [tips, pins, footprints, corridors]) set?.destroy(); for (const set of [tips, pins, footprints, corridors]) set?.destroy();
tips = pins = footprints = corridors = null; tips = pins = footprints = corridors = null;
exclude = []; exclude = [];
@@ -89,22 +89,44 @@ function watch(engine) {
return set; return set;
}; };
const lines = new Map(); const lines = new Map();
const createLine = engine.lines.createLine.bind(engine.lines); const createSegments = engine.lines.createSegments.bind(engine.lines);
engine.lines.createLine = (options) => { engine.lines.createSegments = (options) => {
const line = createLine(options); const batch = createSegments(options);
const record = { options, positions: null, positionsSet: 0, shows: [] }; const { replace, commit, show, destroy } = batch;
lines.set(options.id, record); let pending = [],
const { setPositions, show } = line; ids = [];
line.setPositions = (positions) => { batch.replace = (items) => {
record.positionsSet++; replace(items);
record.positions = positions.map((p) => geo.cartesian3.clone(p)); pending = items.map((item) => ({
setPositions(positions); ...item,
positions: item.positions.map((p) => geo.cartesian3.clone(p)),
}));
}; };
line.show = (visible) => { batch.commit = () => {
record.shows.push(visible); commit();
const nextIds = [];
for (const item of pending) {
const id = item.pickId.replace(':', '-stem-');
const record = lines.get(id) ?? { positionsSet: 0, shows: [] };
record.options = { ...options, ...item };
record.positions = item.positions;
record.positionsSet++;
record.owner = options.id;
lines.set(id, record);
nextIds.push(id);
}
for (const id of ids) if (!nextIds.includes(id)) lines.delete(id);
ids = nextIds;
};
batch.show = (visible) => {
for (const id of ids) lines.get(id).shows.push(visible);
show(visible); show(visible);
}; };
return line; batch.destroy = () => {
destroy();
for (const id of ids) lines.delete(id);
};
return batch;
}; };
const flights = []; const flights = [];
engine.camera.flyTo = (options) => flights.push(options); engine.camera.flyTo = (options) => flights.push(options);
@@ -125,7 +147,7 @@ function watch(engine) {
requests.push(reason); requests.push(reason);
request(reason); request(reason);
}; };
const liveLines = () => engine.lines.list().map(({ id }) => id); const liveLines = () => [...lines.keys()];
return { return {
groundSet(id) { groundSet(id) {
const listed = engine.ground.list().find((entry) => entry.id === id); const listed = engine.ground.list().find((entry) => entry.id === id);
@@ -217,8 +239,8 @@ function fakeHarness() {
} }
: target?.line !== undefined : target?.line !== undefined
? { ? {
id: engine.fake.line(target.line).options.pickId, id: watched.line(target.line).options.pickId,
owner: target.line, owner: watched.line(target.line).owner,
} }
: target; : target;
engine.fake.script('pick', picked); engine.fake.script('pick', picked);
@@ -321,7 +343,10 @@ function cesiumHarness() {
: target?.mover !== undefined : target?.mover !== undefined
? { primitive: primitive(target.mover).get(target.instance) } ? { primitive: primitive(target.mover).get(target.instance) }
: target?.line !== undefined : target?.line !== undefined
? { primitive: primitive(target.line) } ? {
primitive: primitive(watched.line(target.line).owner),
id: watched.line(target.line).options.pickId,
}
: target; : target;
for (const handler of handlers.handlers) for (const handler of handlers.handlers)
handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, { handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, {
@@ -521,6 +546,7 @@ for (const makeHarness of HARNESSES) {
assert.equal(stem.options.pickId, 'local-dams:real-dam'); assert.equal(stem.options.pickId, 'local-dams:real-dam');
assert.equal(stem.options.depthFailAlpha ?? 0, 0, 'hidden by the scene'); assert.equal(stem.options.depthFailAlpha ?? 0, 0, 'hidden by the scene');
assert.ok(f.line(f.stemId('point-dam'))); assert.ok(f.line(f.stemId('point-dam')));
assert.equal(f.engine.lines.list().length, 1, 'one batch draws both stems');
// The pin sits at the base of the Point feature only. // The pin sits at the base of the Point feature only.
const pin = f.point('local-dams-pins', 0); const pin = f.point('local-dams-pins', 0);
assert.equal(f.point('local-dams-pins', 1), undefined); assert.equal(f.point('local-dams-pins', 1), undefined);
+36 -36
View File
@@ -145,6 +145,7 @@ export function createCablesDisplay(
const owners = new Set(); const owners = new Set();
// record id → its stem, while the record is a label winner. // record id → its stem, while the record is a label winner.
const stems = new Map(); const stems = new Map();
let stemBatch = null;
const tipSets = new Map(); const tipSets = new Map();
const removers = []; const removers = [];
let dataset = null; let dataset = null;
@@ -190,36 +191,6 @@ export function createCablesDisplay(
if (dataset) buildPaths(); if (dataset) buildPaths();
} }
// A winner's stem: its line and the tip it was last drawn to.
function stemFor(record) {
let stem = stems.get(record.id);
if (!stem) {
const { widthPx, color: stemColor } = KINDS[record.kind].stem;
const id = `${ID}-stem-${record.id}`;
stem = {
id,
line: engine.lines.createLine({
id,
widthPx,
color: stemColor,
pickId: record.id,
}),
tip: null,
};
owners.add(id);
stems.set(record.id, stem);
}
return stem;
}
function dropStem(recordId) {
const stem = stems.get(recordId);
if (!stem) return;
stem.line.destroy();
owners.delete(stem.id);
stems.delete(recordId);
}
// The reference sweep, on the gate's cadence: horizon visibility, // The reference sweep, on the gate's cadence: horizon visibility,
// distances, tip heights, the label cohort and the winners' stems. // distances, tip heights, the label cohort and the winners' stems.
function sweep() { function sweep() {
@@ -254,12 +225,39 @@ export function createCablesDisplay(
const winners = selectCableReferenceLabelWinners([...records.values()]); const winners = selectCableReferenceLabelWinners([...records.values()]);
referenceLabelCount = winners.length; referenceLabelCount = winners.length;
const winning = new Set(winners.map((record) => record.id)); const winning = new Set(winners.map((record) => record.id));
for (const id of [...stems.keys()]) if (!winning.has(id)) dropStem(id); let stemsChanged = false;
for (const id of [...stems.keys()])
if (!winning.has(id)) {
stems.delete(id);
stemsChanged = true;
}
for (const record of winners) { for (const record of winners) {
const stem = stemFor(record); const stem = stems.get(record.id);
if (stem.tip && geo.cartesian3.equals(stem.tip, record.tip)) continue; if (stem && geo.cartesian3.equals(stem.tip, record.tip)) continue;
stem.line.setPositions([record.base, record.tip]); stems.set(record.id, {
stem.tip = geo.cartesian3.clone(record.tip, stem.tip ?? undefined); ...KINDS[record.kind].stem,
base: record.base,
tip: geo.cartesian3.clone(record.tip),
pickId: record.id,
});
stemsChanged = true;
}
if (stemsChanged) {
if (!stemBatch) {
stemBatch = engine.lines.createSegments({
id: `${ID}-stems`,
widthPx: 2,
color: [1, 1, 1, 1],
});
owners.add(`${ID}-stems`);
}
stemBatch.replace(
[...stems.values()].map(({ base, tip, ...style }) => ({
...style,
positions: [base, tip],
})),
);
stemBatch.commit();
} }
let changed = winners.length !== lastCount; let changed = winners.length !== lastCount;
const entries = winners.map((record, index) => { const entries = winners.map((record, index) => {
@@ -314,7 +312,9 @@ export function createCablesDisplay(
markers = null; markers = null;
for (const set of tipSets.values()) set.destroy(); for (const set of tipSets.values()) set.destroy();
tipSets.clear(); tipSets.clear();
for (const id of [...stems.keys()]) dropStem(id); stems.clear();
stemBatch?.destroy();
stemBatch = null;
owners.clear(); owners.clear();
records.clear(); records.clear();
pathItems = []; pathItems = [];
@@ -51,17 +51,38 @@ function watch(engine) {
sets.set(options.id, record); sets.set(options.id, record);
return set; return set;
}; };
const positionsSet = new Map(); const segments = new Map();
const createLine = engine.lines.createLine.bind(engine.lines); const createSegments = engine.lines.createSegments.bind(engine.lines);
engine.lines.createLine = (options) => { engine.lines.createSegments = (options) => {
const line = createLine(options); const batch = createSegments(options);
const { setPositions } = line; const { replace, commit, destroy } = batch;
positionsSet.set(options.id, 0); let pending = [];
line.setPositions = (positions) => { batch.replace = (items) => {
positionsSet.set(options.id, positionsSet.get(options.id) + 1); replace(items);
setPositions(positions); pending = items.map((item) => ({
...item,
positions: item.positions.map((p) => geo.cartesian3.clone(p)),
}));
}; };
return line; batch.commit = () => {
commit();
const next = new Map();
for (const item of pending) {
const id = `${CABLE_OVERLAY_SOURCE_ID}-stem-${item.pickId}`;
next.set(id, {
...item,
owner: options.id,
commits: (segments.get(id)?.commits ?? 0) + 1,
});
}
segments.clear();
for (const [id, item] of next) segments.set(id, item);
};
batch.destroy = () => {
destroy();
segments.clear();
};
return batch;
}; };
const flights = []; const flights = [];
engine.camera.flyTo = (options) => flights.push(options); engine.camera.flyTo = (options) => flights.push(options);
@@ -72,9 +93,10 @@ function watch(engine) {
return listed ? sets.get(id) : undefined; return listed ? sets.get(id) : undefined;
}, },
/** How often a line's positions were set. */ /** How often a line's positions were set. */
positionsSet: (id) => positionsSet.get(id) ?? 0, positionsSet: (id) => segments.get(id)?.commits ?? 0,
line: (id) => segments.get(id),
flights, flights,
lineIds: () => engine.lines.list().map(({ id }) => id), lineIds: () => [...segments.keys()],
look(lon, lat, height) { look(lon, lat, height) {
engine.camera.setView({ engine.camera.setView({
destination: geo.cartesian3.fromDegrees(lon, lat, height), destination: geo.cartesian3.fromDegrees(lon, lat, height),
@@ -110,10 +132,6 @@ function fakeHarness() {
}, },
}; };
}, },
line(id) {
const line = engine.fake.line(id);
return line && { positions: line.positions, color: line.color };
},
click(target) { click(target) {
const picked = const picked =
target?.ground !== undefined target?.ground !== undefined
@@ -133,8 +151,8 @@ function fakeHarness() {
} }
: target?.line !== undefined : target?.line !== undefined
? { ? {
id: engine.fake.line(target.line).options.pickId, id: watched.line(target.line).pickId,
owner: target.line, owner: watched.line(target.line).owner,
} }
: target; : target;
engine.fake.script('pick', picked); engine.fake.script('pick', picked);
@@ -197,9 +215,6 @@ function cesiumHarness() {
native: item, native: item,
}; };
}, },
line(id) {
return primitive(id) && { primitive: primitive(id) };
},
click(target) { click(target) {
native = native =
target?.ground !== undefined target?.ground !== undefined
@@ -207,7 +222,10 @@ function cesiumHarness() {
: target?.mover !== undefined : target?.mover !== undefined
? { primitive: primitive(target.mover).get(target.instance) } ? { primitive: primitive(target.mover).get(target.instance) }
: target?.line !== undefined : target?.line !== undefined
? { primitive: primitive(target.line) } ? {
primitive: primitive(watched.line(target.line).owner),
id: watched.line(target.line).pickId,
}
: target; : target;
for (const handler of handlers.handlers) for (const handler of handlers.handlers)
handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, { handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, {
@@ -472,15 +490,18 @@ for (const makeHarness of HARNESSES) {
stems.every((id) => f.line(id)), stems.every((id) => f.line(id)),
'each stem is drawn', 'each stem is drawn',
); );
if (!f.cesium) { assert.equal(
const cableStem = f.engine.fake.line(stems[0]); f.engine.lines.list().length,
assert.equal(cableStem.options.widthPx, 2); 1,
near(cableStem.color, rgba(BASE_CABLE_COLOR, 0.58), 'cable stem'); 'one batch draws both styles',
assert.equal(cableStem.positions.length, 6, 'base and tip'); );
const landingStem = f.engine.fake.line(stems[1]); const cableStem = f.line(stems[0]);
assert.equal(landingStem.options.widthPx, 2.4); assert.equal(cableStem.widthPx, 2);
near(landingStem.color, rgba(BASE_LANDING_COLOR, 0.68), 'landing stem'); near(cableStem.color, rgba(BASE_CABLE_COLOR, 0.58), 'cable stem');
} assert.equal(cableStem.positions.length, 2, 'base and tip');
const landingStem = f.line(stems[1]);
assert.equal(landingStem.widthPx, 2.4);
near(landingStem.color, rgba(BASE_LANDING_COLOR, 0.68), 'landing stem');
// A moveEnd at an unchanged camera re-runs the sweep; the identical // A moveEnd at an unchanged camera re-runs the sweep; the identical
// cohort is not published again. // cohort is not published again.
@@ -595,7 +616,7 @@ for (const makeHarness of HARNESSES) {
f.preRender(); f.preRender();
assert.equal(f.engine.ground.list().length, 1); assert.equal(f.engine.ground.list().length, 1);
assert.equal(f.engine.movers.list().length, 3); assert.equal(f.engine.movers.list().length, 3);
assert.equal(f.engine.lines.list().length, 2); assert.equal(f.engine.lines.list().length, 1);
f.hostCalls.length = 0; f.hostCalls.length = 0;
f.display.hide(); f.display.hide();
assert.deepEqual(f.hostCalls, [ assert.deepEqual(f.hostCalls, [