diff --git a/scripts/qa-cables-overlay.mjs b/scripts/qa-cables-overlay.mjs index 530640514..6627e0ed5 100644 --- a/scripts/qa-cables-overlay.mjs +++ b/scripts/qa-cables-overlay.mjs @@ -138,8 +138,8 @@ try { out.tips += set.count; } } - out.stems = engine.lines.list().filter((line) => - line.id.startsWith(`${layerId}-stem-`) && line.shown).length; + out.stems = (engine.lines.list().find((line) => + line.id === `${layerId}-stems` && line.shown)?.points ?? 0) / 2; const diag = window.__gevWorldOverlay?.getDiagnostics?.() || {}; out.hostEntries = diag.entriesBySource?.[layerId] || 0; out.hostPainted = diag.paintedBySource?.[layerId] || 0; diff --git a/scripts/qa-infra-lod.mjs b/scripts/qa-infra-lod.mjs index 732144470..df6107190 100644 --- a/scripts/qa-infra-lod.mjs +++ b/scripts/qa-infra-lod.mjs @@ -234,13 +234,10 @@ try { // ── GATE c: no churn between camera moves ─────────────────────────── const churn = await page.evaluate(async (infraIds) => { const gev = window.__godsEyeView; - // The layer draws through the globe contract: a reference in view has - // a stem line `${layerId}-stem-${featureId}` (render/display.js drops - // it when the reference leaves the view or the LOD set). - const snap = () => infraIds.map((id) => gev.engine.lines.list() - .filter((line) => line.id.startsWith(`${id}-stem-`) && line.shown) - .map((line) => line.id) - .sort()); + // A batch lists the visible references' pick ids in drawing order. + const snap = () => infraIds.map((id) => (gev.engine.lines.list() + .find((line) => line.id === `${id}-stems` && line.shown)?.pickIds ?? []) + .slice().sort()); const before = snap(); for (let i = 0; i < 5; i++) { gev.viewer.scene.requestRender?.(); diff --git a/src/data/telegeographySubmarineCables.test.mjs b/src/data/telegeographySubmarineCables.test.mjs index a4cc69c06..4a31dabd5 100644 --- a/src/data/telegeographySubmarineCables.test.mjs +++ b/src/data/telegeographySubmarineCables.test.mjs @@ -563,7 +563,12 @@ test('a real enabled cable layer publishes host entries and draws through the en collisionCapacity: CABLE_OVERLAY_COLLISION_CAPACITY, 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(); assert.equal(stats.referenceLabelCount, 2); assert.equal(stats.count, 2); diff --git a/src/globe/cesium/createCesiumEngine.test.mjs b/src/globe/cesium/createCesiumEngine.test.mjs index c5adde0c6..53edf00cb 100644 --- a/src/globe/cesium/createCesiumEngine.test.mjs +++ b/src/globe/cesium/createCesiumEngine.test.mjs @@ -288,7 +288,7 @@ test('the contract suite movers and lines checks pass on the Cesium engine (no p name, ), ); - assert.equal(checks.length, 8); + assert.equal(checks.length, 9); try { for (const { name, run } of checks) await t.test(name, run); } finally { diff --git a/src/globe/cesium/lines.js b/src/globe/cesium/lines.js index c3aa049d2..5a69aa072 100644 --- a/src/globe/cesium/lines.js +++ b/src/globe/cesium/lines.js @@ -197,6 +197,39 @@ export function createCesiumLines( slot.add(primitive); 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) { color = rgba; if (appearance) appearance.material.uniforms.color = toColor(rgba); @@ -245,6 +278,13 @@ export function createCesiumLines( identify(picked) { const owner = picked?.primitive?.gevLine; 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 ? { owner, id: state.options.pickId } : null; diff --git a/src/globe/cesium/lines.test.mjs b/src/globe/cesium/lines.test.mjs index 19963c56f..7cd2cd6d1 100644 --- a/src/globe/cesium/lines.test.mjs +++ b/src/globe/cesium/lines.test.mjs @@ -240,3 +240,59 @@ test('engine destroy destroys every line and removes the root', () => { assert.deepEqual(lines.list(), []); 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(), []); +}); diff --git a/src/globe/contract.js b/src/globe/contract.js index 448e20156..04adaa133 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -490,9 +490,23 @@ * @property {() => void} destroy * 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 * @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 * A set of flat fills draped on the ground: on terrain and 3D tiles, @@ -947,7 +961,11 @@ const SHAPE = { credits: { addStatic: isFunction }, clock: { now: isFunction, frameTime: isFunction }, movers: { createSet: isFunction, list: isFunction }, - lines: { createLine: isFunction, list: isFunction }, + lines: { + createLine: isFunction, + createSegments: isFunction, + list: isFunction, + }, meshes: { createMesh: isFunction, list: isFunction }, ground: { createSet: isFunction, diff --git a/src/globe/lines.js b/src/globe/lines.js index 880accba6..361db28eb 100644 --- a/src/globe/lines.js +++ b/src/globe/lines.js @@ -239,11 +239,88 @@ export function createLineRegistry(build) { live.set(options.id, state); 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: () => - [...live.values()].map(({ options, points, shown }) => ({ + [...live.values()].map(({ options, points, shown, items }) => ({ id: options.id, points, shown, + ...(options.segments + ? { pickIds: items.map((item) => item.pickId) } + : {}), })), }; return { diff --git a/src/globe/lines.test.mjs b/src/globe/lines.test.mjs index d7980cafe..07aafd34b 100644 --- a/src/globe/lines.test.mjs +++ b/src/globe/lines.test.mjs @@ -122,3 +122,50 @@ test('the tactical dash is the launches mission-orbit pattern', async () => { 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(), []); +}); diff --git a/src/globe/navara/createNavaraEngine.js b/src/globe/navara/createNavaraEngine.js index 663ec1f3f..4c5cbda17 100644 --- a/src/globe/navara/createNavaraEngine.js +++ b/src/globe/navara/createNavaraEngine.js @@ -43,6 +43,9 @@ const NO_MOVERS = Object.freeze({ list: () => [], }); const NO_LINES = Object.freeze({ + createSegments() { + throw new Error("Navara lines need the view's three runtime (lines/)"); + }, createLine() { throw new Error("Navara lines need the view's three runtime (lines/)"); }, diff --git a/src/globe/navara/lines/index.js b/src/globe/navara/lines/index.js index ccd30b1cd..41b098568 100644 --- a/src/globe/navara/lines/index.js +++ b/src/globe/navara/lines/index.js @@ -42,6 +42,13 @@ export function createNavaraLines(view, { overlay }) { line.setPositions(state.flat); 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) { line.setColor(rgba); request(); @@ -68,9 +75,25 @@ export function createNavaraLines(view, { overlay }) { lines: registry.lines, /** Shown lines with a pickId: options, drawn positions, matrix. */ pickables: () => - [...drawn.values()].filter( - (state) => state.shown && state.options.pickId !== undefined, - ), + [...drawn.values()] + .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(), }; } diff --git a/src/globe/navara/lines/lineMesh.js b/src/globe/navara/lines/lineMesh.js index 98413212c..0c5a67b36 100644 --- a/src/globe/navara/lines/lineMesh.js +++ b/src/globe/navara/lines/lineMesh.js @@ -30,6 +30,11 @@ const VERTEX_HEAD = /* glsl */ ` attribute vec3 gevEnd; attribute vec2 gevSide; attribute float gevS; +#ifdef GEV_SEGMENTS +attribute vec4 gevColor; +attribute float gevWidth; +varying vec4 vGevColor; +#endif uniform vec2 uGevViewport; uniform float uGevWidthPx; varying float vGevS; @@ -51,7 +56,12 @@ vec2 gevDirection = gevScreenB - gevScreenA; gevDirection = length(gevDirection) > 1e-6 ? normalize(gevDirection) : vec2(1.0, 0.0); vec2 gevNormal = vec2(-gevDirection.y, gevDirection.x); 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; +#endif vec4 mvPosition = modelViewMatrix * vec4(gevSide.x < 0.5 ? position : gevEnd, 1.0); vGevS = gevS; vGevT = gevSide.y * 0.5 + 0.5; @@ -61,6 +71,9 @@ vGevDepthFree = 0.0; const FRAGMENT_HEAD = /* glsl */ ` uniform vec4 uGevColor; +#ifdef GEV_SEGMENTS +varying vec4 vGevColor; +#endif uniform float uGevAlphaScale; uniform float uGevPixelRatio; ${OVERLAY_DEPTH_FRAGMENT} @@ -92,7 +105,11 @@ const FRAGMENT_COLOR = /* glsl */ ` #include if (gevOverlayHidden()) discard; // sRGB colours (as Cesium takes them) into the linear output. +#ifdef GEV_SEGMENTS +diffuseColor = sRGBTransferEOTF(vGevColor); +#else diffuseColor = sRGBTransferEOTF(uGevColor); +#endif diffuseColor.a *= uGevAlphaScale; if (uGevArrow) { 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 * 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 segments = Math.max(0, points - 1); + const segments = items ? items.length : Math.max(0, points - 1); const start = new Float32Array(segments * 12); const end = new Float32Array(segments * 12); const side = new Float32Array(segments * 8); const along = new Float32Array(segments * 4); 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); for (let p = 1; p < points; p++) cumulative[p] = @@ -161,7 +180,7 @@ export function buildRibbon(flat, origin) { ); const total = cumulative[points - 1] || 1; for (let s = 0; s < segments; s++) { - const a = s * 3; + const a = s * (items ? 6 : 3); const b = a + 3; for (let v = 0; v < 4; v++) { const vertex = s * 4 + v; @@ -172,7 +191,11 @@ export function buildRibbon(flat, origin) { const atEnd = v >= 2 ? 1 : 0; side[vertex * 2] = atEnd; 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( [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('gevSide', new BufferAttribute(side, 2)); 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)); return geometry; } @@ -228,9 +255,11 @@ export function createLineMesh( uGevAlphaScale: { value: alphaScale }, uGevDepthMode: { value: depthMode }, }; + if (options.segments) value.defines = { GEV_SEGMENTS: 1 }; value.userData.gevDepthMode = depthMode; value.onBeforeCompile = (shader) => patchLineShader(shader, uniforms); - value.customProgramCacheKey = () => 'gev-line'; + value.customProgramCacheKey = () => + options.segments ? 'gev-segments' : 'gev-line'; return value; }; const group = new Group(); @@ -267,10 +296,10 @@ export function createLineMesh( group, uniforms: shared, meshes: () => (behind ? [main, behind] : [main]), - setPositions(flat) { + setPositions(flat, items = null) { const previous = main.geometry; 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; if (behind) behind.geometry = geometry; previous.dispose(); diff --git a/src/globe/navara/lines/lineMesh.test.mjs b/src/globe/navara/lines/lineMesh.test.mjs index 130d1e650..93aebaac7 100644 --- a/src/globe/navara/lines/lineMesh.test.mjs +++ b/src/globe/navara/lines/lineMesh.test.mjs @@ -7,6 +7,7 @@ import { UniformsUtils, Vector3, } from 'three'; +import { createNavaraLines } from './index.js'; import { normalizeLineOptions } from '../../lines.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); 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); +}); diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index 518faa27d..b85ee08ff 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -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', async () => { diff --git a/src/globe/testing/fakeEngine.js b/src/globe/testing/fakeEngine.js index c6d820556..5bb7539a8 100644 --- a/src/globe/testing/fakeEngine.js +++ b/src/globe/testing/fakeEngine.js @@ -232,6 +232,10 @@ export function createFakeEngine(options = {}) { state.positions = flat; changed(); }, + setSegments(items) { + state.items = items; + changed(); + }, setColor(rgba) { state.color = rgba; changed(); @@ -636,11 +640,25 @@ export function createFakeEngine(options = {}) { lines: () => [...fakeLines.values()] .filter((state) => state.shown) - .map((state) => ({ - options: state.options, - flat: state.positions, - modelMatrix: state.modelMatrix, - })), + .flatMap((state) => + state.options.segments + ? (state.items ?? []).map((item) => ({ + 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). meshes: (at) => { const ray = engine.camera.pickRay(at); diff --git a/src/layers/infrastructure/render/display.js b/src/layers/infrastructure/render/display.js index 67bdd281d..bef0d9563 100644 --- a/src/layers/infrastructure/render/display.js +++ b/src/layers/infrastructure/render/display.js @@ -131,6 +131,8 @@ export function createInfrastructureDisplay( const owners = new Set(); // record id → its stem, while the record is drawn. const stems = new Map(); + let stemBatch = null, + stemsDirty = false; const removers = []; let dataset = null, tips = null, @@ -274,13 +276,12 @@ export function createInfrastructureDisplay( const globe = !photoreal(); if (globe && !engine.surface.tilesLoaded()) return false; // The stem stands on the sample point: out of the sample. - const stem = stems.get(row.id); - stem?.line.show(false); + stemBatch?.show(false); let sampled; try { sampled = engine.surface.sampleHeight(row.carto, exclude); } finally { - stem?.line.show(true); + stemBatch?.show(true); } if (!finite(sampled) || Math.abs(sampled) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M) return false; @@ -353,33 +354,26 @@ export function createInfrastructureDisplay( } function stemOf(row) { - let stem = stems.get(row.id); - if (!stem) { - const lineId = `${id}-stem-${row.id}`; - stem = { - id: lineId, - line: engine.lines.createLine({ - id: lineId, - 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); + if (!stemBatch) { + stemBatch = engine.lines.createSegments({ + id: `${id}-stems`, + widthPx: STEM_PX, + color: rgba, + }); + owners.add(`${id}-stems`); } + 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; } function dropStem(rowId) { - const stem = stems.get(rowId); - if (!stem) return; - stem.line.destroy(); - owners.delete(stem.id); - stems.delete(rowId); + if (stems.delete(rowId)) stemsDirty = true; } // 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.base, row.base) ) { - stem.line.setPositions([row.base, row.tip]); + stemsDirty = true; geo.cartesian3.clone(row.base, stem.base); geo.cartesian3.clone(row.tip, stem.tip); } 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; if (tipsChanged) tips.commit(); if (pinsChanged) pins.commit(); @@ -575,7 +579,10 @@ export function createInfrastructureDisplay( services.hideOsmCredit?.(engine, id); clearGroundRetry(); 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(); tips = pins = footprints = corridors = null; exclude = []; diff --git a/src/layers/infrastructure/render/display.test.mjs b/src/layers/infrastructure/render/display.test.mjs index 162f076d6..7a806105c 100644 --- a/src/layers/infrastructure/render/display.test.mjs +++ b/src/layers/infrastructure/render/display.test.mjs @@ -89,22 +89,44 @@ function watch(engine) { return set; }; const lines = new Map(); - const createLine = engine.lines.createLine.bind(engine.lines); - engine.lines.createLine = (options) => { - const line = createLine(options); - const record = { options, positions: null, positionsSet: 0, shows: [] }; - lines.set(options.id, record); - const { setPositions, show } = line; - line.setPositions = (positions) => { - record.positionsSet++; - record.positions = positions.map((p) => geo.cartesian3.clone(p)); - setPositions(positions); + const createSegments = engine.lines.createSegments.bind(engine.lines); + engine.lines.createSegments = (options) => { + const batch = createSegments(options); + const { replace, commit, show, destroy } = batch; + let pending = [], + ids = []; + batch.replace = (items) => { + replace(items); + pending = items.map((item) => ({ + ...item, + positions: item.positions.map((p) => geo.cartesian3.clone(p)), + })); }; - line.show = (visible) => { - record.shows.push(visible); + batch.commit = () => { + 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); }; - return line; + batch.destroy = () => { + destroy(); + for (const id of ids) lines.delete(id); + }; + return batch; }; const flights = []; engine.camera.flyTo = (options) => flights.push(options); @@ -125,7 +147,7 @@ function watch(engine) { requests.push(reason); request(reason); }; - const liveLines = () => engine.lines.list().map(({ id }) => id); + const liveLines = () => [...lines.keys()]; return { groundSet(id) { const listed = engine.ground.list().find((entry) => entry.id === id); @@ -217,8 +239,8 @@ function fakeHarness() { } : target?.line !== undefined ? { - id: engine.fake.line(target.line).options.pickId, - owner: target.line, + id: watched.line(target.line).options.pickId, + owner: watched.line(target.line).owner, } : target; engine.fake.script('pick', picked); @@ -321,7 +343,10 @@ function cesiumHarness() { : target?.mover !== undefined ? { primitive: primitive(target.mover).get(target.instance) } : target?.line !== undefined - ? { primitive: primitive(target.line) } + ? { + primitive: primitive(watched.line(target.line).owner), + id: watched.line(target.line).options.pickId, + } : target; for (const handler of handlers.handlers) 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.depthFailAlpha ?? 0, 0, 'hidden by the scene'); 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. const pin = f.point('local-dams-pins', 0); assert.equal(f.point('local-dams-pins', 1), undefined); diff --git a/src/layers/submarineCables/render/display.js b/src/layers/submarineCables/render/display.js index bc3d93f0a..cc671bcef 100644 --- a/src/layers/submarineCables/render/display.js +++ b/src/layers/submarineCables/render/display.js @@ -145,6 +145,7 @@ export function createCablesDisplay( const owners = new Set(); // record id → its stem, while the record is a label winner. const stems = new Map(); + let stemBatch = null; const tipSets = new Map(); const removers = []; let dataset = null; @@ -190,36 +191,6 @@ export function createCablesDisplay( 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, // distances, tip heights, the label cohort and the winners' stems. function sweep() { @@ -254,12 +225,39 @@ export function createCablesDisplay( const winners = selectCableReferenceLabelWinners([...records.values()]); referenceLabelCount = winners.length; 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) { - const stem = stemFor(record); - if (stem.tip && geo.cartesian3.equals(stem.tip, record.tip)) continue; - stem.line.setPositions([record.base, record.tip]); - stem.tip = geo.cartesian3.clone(record.tip, stem.tip ?? undefined); + const stem = stems.get(record.id); + if (stem && geo.cartesian3.equals(stem.tip, record.tip)) continue; + stems.set(record.id, { + ...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; const entries = winners.map((record, index) => { @@ -314,7 +312,9 @@ export function createCablesDisplay( markers = null; for (const set of tipSets.values()) set.destroy(); tipSets.clear(); - for (const id of [...stems.keys()]) dropStem(id); + stems.clear(); + stemBatch?.destroy(); + stemBatch = null; owners.clear(); records.clear(); pathItems = []; diff --git a/src/layers/submarineCables/render/display.test.mjs b/src/layers/submarineCables/render/display.test.mjs index a322eb512..7b4d2cc3d 100644 --- a/src/layers/submarineCables/render/display.test.mjs +++ b/src/layers/submarineCables/render/display.test.mjs @@ -51,17 +51,38 @@ function watch(engine) { sets.set(options.id, record); return set; }; - const positionsSet = new Map(); - const createLine = engine.lines.createLine.bind(engine.lines); - engine.lines.createLine = (options) => { - const line = createLine(options); - const { setPositions } = line; - positionsSet.set(options.id, 0); - line.setPositions = (positions) => { - positionsSet.set(options.id, positionsSet.get(options.id) + 1); - setPositions(positions); + const segments = new Map(); + const createSegments = engine.lines.createSegments.bind(engine.lines); + engine.lines.createSegments = (options) => { + const batch = createSegments(options); + const { replace, commit, destroy } = batch; + let pending = []; + batch.replace = (items) => { + replace(items); + 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 = []; engine.camera.flyTo = (options) => flights.push(options); @@ -72,9 +93,10 @@ function watch(engine) { return listed ? sets.get(id) : undefined; }, /** 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, - lineIds: () => engine.lines.list().map(({ id }) => id), + lineIds: () => [...segments.keys()], look(lon, lat, height) { engine.camera.setView({ 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) { const picked = target?.ground !== undefined @@ -133,8 +151,8 @@ function fakeHarness() { } : target?.line !== undefined ? { - id: engine.fake.line(target.line).options.pickId, - owner: target.line, + id: watched.line(target.line).pickId, + owner: watched.line(target.line).owner, } : target; engine.fake.script('pick', picked); @@ -197,9 +215,6 @@ function cesiumHarness() { native: item, }; }, - line(id) { - return primitive(id) && { primitive: primitive(id) }; - }, click(target) { native = target?.ground !== undefined @@ -207,7 +222,10 @@ function cesiumHarness() { : target?.mover !== undefined ? { primitive: primitive(target.mover).get(target.instance) } : target?.line !== undefined - ? { primitive: primitive(target.line) } + ? { + primitive: primitive(watched.line(target.line).owner), + id: watched.line(target.line).pickId, + } : target; for (const handler of handlers.handlers) handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, { @@ -472,15 +490,18 @@ for (const makeHarness of HARNESSES) { stems.every((id) => f.line(id)), 'each stem is drawn', ); - if (!f.cesium) { - const cableStem = f.engine.fake.line(stems[0]); - assert.equal(cableStem.options.widthPx, 2); - near(cableStem.color, rgba(BASE_CABLE_COLOR, 0.58), 'cable stem'); - assert.equal(cableStem.positions.length, 6, 'base and tip'); - const landingStem = f.engine.fake.line(stems[1]); - assert.equal(landingStem.options.widthPx, 2.4); - near(landingStem.color, rgba(BASE_LANDING_COLOR, 0.68), 'landing stem'); - } + assert.equal( + f.engine.lines.list().length, + 1, + 'one batch draws both styles', + ); + const cableStem = f.line(stems[0]); + assert.equal(cableStem.widthPx, 2); + 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 // cohort is not published again. @@ -595,7 +616,7 @@ for (const makeHarness of HARNESSES) { f.preRender(); assert.equal(f.engine.ground.list().length, 1); 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.display.hide(); assert.deepEqual(f.hostCalls, [