diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 0c83359ac..80901f747 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1074,5 +1074,8 @@ "src/globe/navara/movers/pickIds.test.mjs", "src/globe/navara/movers/moverMesh.test.mjs", "src/globe/navara/movers/modelMover.test.mjs", - "src/bench/syntheticFlights.test.mjs" + "src/bench/syntheticFlights.test.mjs", + "src/globe/lines.test.mjs", + "src/globe/cesium/lines.test.mjs", + "src/globe/navara/lines/lineMesh.test.mjs" ] diff --git a/src/globe/cesium/createCesiumEngine.js b/src/globe/cesium/createCesiumEngine.js index f49ecae44..bac207d27 100644 --- a/src/globe/cesium/createCesiumEngine.js +++ b/src/globe/cesium/createCesiumEngine.js @@ -10,6 +10,7 @@ import { createCesiumClock } from './clock.js'; import { createCesiumCredits } from './credits.js'; import { createCesiumFrames } from './frames.js'; import { createCesiumInput } from './input.js'; +import { createCesiumLines } from './lines.js'; import { createCesiumMaps } from './maps.js'; import { createCesiumMovers } from './movers.js'; import { createCesiumPicker } from './picker.js'; @@ -46,6 +47,10 @@ export function createCesiumEngine(viewer, options = {}) { projector: createCesiumProjector(viewer), frames: createCesiumFrames(viewer), clock: createCesiumClock(viewer, options), + lines: createCesiumLines(viewer, { + requestRender: (reason) => render.render.request(reason), + ...(options.lineFactories ? { factories: options.lineFactories } : {}), + }), movers: createCesiumMovers(viewer, { requestRender: (reason) => render.render.request(reason), ...(options.moverFactories ? { factories: options.moverFactories } : {}), diff --git a/src/globe/cesium/lines.js b/src/globe/cesium/lines.js new file mode 100644 index 000000000..b97b832a2 --- /dev/null +++ b/src/globe/cesium/lines.js @@ -0,0 +1,214 @@ +// engine.lines on Cesium: each line is one Primitive of a PolylineGeometry +// (built synchronously, rebuilt when its positions change) with a +// PolylineMaterialAppearance, inside one engine-owned PrimitiveCollection +// added to the scene with the first line. depthFailAlpha adds Cesium's +// depthFailAppearance (the same material, its alpha scaled) for the part +// the scene hides; dashes in pixels are PolylineDash; the tactical dash is +// the launches' selected-orbit pattern along the whole line. + +import * as Cesium from 'cesium'; +import { createLineRegistry } from '../lines.js'; + +const TACTICAL_TYPE = 'GevLineTactical'; +let tacticalRegistered = false; + +// The launches' GevMissionOrbitTactical shader (layers/launches/ +// orbitRendering.js), under the engine's own type until Task 15a moves +// launches onto engine.lines. +function registerTactical() { + if (tacticalRegistered) return; + new Cesium.Material({ + fabric: { + type: TACTICAL_TYPE, + uniforms: { color: Cesium.Color.CYAN, groupCount: 4, dashCount: 100 }, + source: ` + czm_material czm_getMaterial(czm_materialInput materialInput) { + czm_material material = czm_getDefaultMaterial(materialInput); + float groupPosition = fract(materialInput.st.s * groupCount); + float markPosition = groupPosition * (dashCount + 1.0); + float markIndex = floor(markPosition); + float localPosition = fract(markPosition); + float centerDistance = abs(localPosition - 0.5); + float edge = max(fwidth(localPosition) * 1.35, 0.012); + float dashAlong = 1.0 - smoothstep(0.27 - edge, 0.27 + edge, centerDistance); + float dashAcross = 1.0 - smoothstep(0.12, 0.24, abs(materialInput.st.t - 0.5)); + float dash = dashAlong * dashAcross; + float dotAlong = (localPosition - 0.5) / 0.32; + float dotAcross = (materialInput.st.t - 0.5) / 0.5; + float dot = 1.0 - smoothstep(0.78, 1.0, length(vec2(dotAlong, dotAcross))); + float isDot = 1.0 - step(0.5, markIndex); + float visible = mix(dash, dot, isDot); + material.diffuse = color.rgb; + material.emission = color.rgb * mix(0.07, 0.65, isDot); + material.alpha = color.a * visible * mix(0.58, 1.0, isDot); + return material; + }`, + }, + }); + tacticalRegistered = true; +} + +/** PolylineDash's 16-bit pattern: the dash first, then the gap. */ +export function dashPattern(lengthPx, gapPx) { + const on = Math.max( + 1, + Math.min(16, Math.round((16 * lengthPx) / (lengthPx + gapPx))), + ); + return (0xffff << (16 - on)) & 0xffff; +} + +const toColor = ([r, g, b, a], alphaScale = 1) => + new Cesium.Color(r, g, b, a * alphaScale); + +/** The Cesium material type and uniforms of a line (pure, for tests). */ +export function materialSpec(options, color, alphaScale = 1) { + const { dash } = options; + if (dash?.kind === 'pixels') + return { + type: 'PolylineDash', + uniforms: { + color: toColor(color, alphaScale), + gapColor: Cesium.Color.TRANSPARENT, + dashLength: dash.lengthPx + dash.gapPx, + dashPattern: dashPattern(dash.lengthPx, dash.gapPx), + }, + }; + if (dash?.kind === 'tactical') + return { + type: TACTICAL_TYPE, + uniforms: { + color: toColor(color, alphaScale), + groupCount: dash.groups, + dashCount: dash.dashes, + }, + }; + return { type: 'Color', uniforms: { color: toColor(color, alphaScale) } }; +} + +function cesiumMaterial({ type, uniforms }) { + if (type === TACTICAL_TYPE) registerTactical(); + return Cesium.Material.fromType(type, uniforms); +} + +const DEFAULT_FACTORIES = { + primitive: (options) => new Cesium.Primitive(options), + appearance: (spec) => + new Cesium.PolylineMaterialAppearance({ material: cesiumMaterial(spec) }), +}; + +/** + * @param {Cesium.Viewer} viewer + * @param {{ requestRender?: (reason: string) => void, factories?: object }} [options] + * @returns {{ lines: import('../contract.js').GlobeLines, destroy: () => void }} + */ +export function createCesiumLines( + viewer, + { requestRender = () => viewer.scene.requestRender(), factories = {} } = {}, +) { + const make = { ...DEFAULT_FACTORIES, ...factories }; + const { scene } = viewer; + let root = null; + const rootCollection = () => { + if (!root) { + root = new Cesium.PrimitiveCollection({ destroyPrimitives: true }); + scene.primitives.add(root); + } + return root; + }; + const registry = createLineRegistry((options) => { + rootCollection(); + let color = options.color; + let modelMatrix = options.modelMatrix + ? Cesium.Matrix4.fromArray(options.modelMatrix) + : Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY); + let shown = true; + let primitive = null; + let appearance = null; + let depthFail = null; + const drop = () => { + if (primitive) root.remove(primitive); + primitive = null; + }; + return { + setPositions(flat) { + drop(); + if (flat.length < 6) { + requestRender('lines'); + return; + } + const positions = []; + for (let index = 0; index < flat.length; index += 3) + positions.push( + new Cesium.Cartesian3( + flat[index], + flat[index + 1], + flat[index + 2], + ), + ); + appearance = make.appearance(materialSpec(options, color)); + depthFail = + options.depthFailAlpha > 0 + ? make.appearance( + materialSpec(options, color, options.depthFailAlpha), + ) + : undefined; + primitive = make.primitive({ + geometryInstances: new Cesium.GeometryInstance({ + geometry: new Cesium.PolylineGeometry({ + positions, + width: options.widthPx, + vertexFormat: Cesium.PolylineMaterialAppearance.VERTEX_FORMAT, + arcType: + options.arc === 'geodesic' + ? Cesium.ArcType.GEODESIC + : Cesium.ArcType.NONE, + }), + id: options.pickId, + }), + appearance, + depthFailAppearance: depthFail, + modelMatrix: Cesium.Matrix4.clone(modelMatrix), + show: shown, + asynchronous: false, + }); + root.add(primitive); + requestRender('lines'); + }, + setColor(rgba) { + color = rgba; + if (appearance) appearance.material.uniforms.color = toColor(rgba); + if (depthFail) + depthFail.material.uniforms.color = toColor( + rgba, + options.depthFailAlpha, + ); + requestRender('lines'); + }, + setModelMatrix(matrix) { + modelMatrix = Cesium.Matrix4.fromArray(matrix); + if (primitive) + primitive.modelMatrix = Cesium.Matrix4.clone(modelMatrix); + requestRender('lines'); + }, + show(visible) { + shown = visible; + if (primitive) primitive.show = visible; + requestRender('lines'); + }, + destroy() { + drop(); + requestRender('lines'); + }, + }; + }); + return { + lines: registry.lines, + destroy() { + registry.destroyAll(); + if (root) { + scene.primitives.remove(root); + root = null; + } + }, + }; +} diff --git a/src/globe/cesium/lines.test.mjs b/src/globe/cesium/lines.test.mjs new file mode 100644 index 000000000..ff7723a49 --- /dev/null +++ b/src/globe/cesium/lines.test.mjs @@ -0,0 +1,127 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { createCesiumLines, dashPattern } from './lines.js'; +import { createFakeViewer } from './testing/fakeViewer.mjs'; + +function fakePrimitives() { + const made = []; + return { + made, + factories: { + primitive: (options) => { + const primitive = { + ...options, + isDestroyed: () => false, + destroy() { + primitive.destroyed = true; + }, + }; + made.push(primitive); + return primitive; + }, + appearance: (spec) => ({ + material: { type: spec.type, uniforms: { ...spec.uniforms } }, + }), + }, + }; +} + +const points = [ + { x: 6_378_137, y: 0, z: 0 }, + { x: 6_378_137, y: 1000, z: 0 }, + { x: 6_378_137, y: 2000, z: 1000 }, +]; + +test('a dash in pixels becomes PolylineDash with a 16-bit pattern', () => { + assert.equal(dashPattern(16, 16), 0xff00); + assert.equal(dashPattern(12, 4), 0xfff0); + assert.equal(dashPattern(1, 0), 0xffff); +}); + +test('positions build one polyline primitive; a change rebuilds it', () => { + const viewer = createFakeViewer(); + const { factories, made } = fakePrimitives(); + const renders = []; + const { lines, destroy } = createCesiumLines(viewer, { + factories, + requestRender: (reason) => renders.push(reason), + }); + assert.equal(viewer.scene.primitives.length, 0, 'nothing at creation'); + const line = lines.createLine({ + id: 'trail', + widthPx: 3, + color: [1, 0, 0, 1], + depthFailAlpha: 0.4, + arc: 'geodesic', + pickId: 'trail-1', + }); + assert.equal(viewer.scene.primitives.length, 1); + assert.equal(made.length, 0, 'no primitive without positions'); + line.setPositions(points); + assert.equal(made.length, 1); + const [first] = made; + const geometry = first.geometryInstances.geometry; + assert.equal(geometry._width, 3); + assert.equal(geometry._arcType, Cesium.ArcType.GEODESIC); + assert.equal(geometry._positions.length, 3); + assert.equal(first.geometryInstances.id, 'trail-1'); + assert.equal(first.asynchronous, false); + assert.ok(first.depthFailAppearance, 'the dimmer copy behind the scene'); + assert.ok( + Cesium.Color.equals( + first.depthFailAppearance.material.uniforms.color, + new Cesium.Color(1, 0, 0, 0.4), + ), + ); + line.setPositions(points.slice(0, 2)); + assert.equal(made.length, 2); + assert.equal(first.destroyed, true, 'the old primitive goes'); + line.setColor([0, 1, 0, 1]); + assert.ok( + Cesium.Color.equals( + made[1].appearance.material.uniforms.color, + new Cesium.Color(0, 1, 0, 1), + ), + ); + const turn = Cesium.Matrix4.fromRotation(Cesium.Matrix3.fromRotationZ(0.5)); + line.setModelMatrix(Cesium.Matrix4.toArray(turn)); + assert.ok(Cesium.Matrix4.equals(made[1].modelMatrix, turn)); + line.show(false); + assert.equal(made[1].show, false); + line.setPositions(points); + assert.equal(made[2].show, false, 'a rebuild keeps show and matrix'); + assert.ok(Cesium.Matrix4.equals(made[2].modelMatrix, turn)); + assert.ok(renders.length >= 4); + line.destroy(); + assert.equal(made[2].destroyed, true); + destroy(); + assert.equal(viewer.scene.primitives.length, 0); +}); + +test('dash kinds pick their materials', () => { + const viewer = createFakeViewer(); + const { factories, made } = fakePrimitives(); + const { lines } = createCesiumLines(viewer, { factories }); + lines + .createLine({ + id: 'd', + widthPx: 2, + color: [1, 1, 1, 1], + dash: { lengthPx: 12, gapPx: 4 }, + }) + .setPositions(points); + lines + .createLine({ id: 't', widthPx: 3, color: [0, 1, 1, 1], dash: 'tactical' }) + .setPositions(points); + lines + .createLine({ id: 's', widthPx: 1, color: [1, 1, 1, 1] }) + .setPositions(points); + assert.equal(made[0].appearance.material.type, 'PolylineDash'); + assert.equal(made[0].appearance.material.uniforms.dashLength, 16); + assert.equal(made[0].appearance.material.uniforms.dashPattern, 0xfff0); + assert.equal(made[1].appearance.material.type, 'GevLineTactical'); + assert.equal(made[1].appearance.material.uniforms.groupCount, 4); + assert.equal(made[2].appearance.material.type, 'Color'); + assert.equal(made[2].depthFailAppearance, undefined); +}); diff --git a/src/globe/contract.js b/src/globe/contract.js index 83130c8be..47be16fcf 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -296,6 +296,37 @@ * @property {(options: MoverSetOptions) => MoverSet} createSet * @property {() => { id: string, kind: string, count: number, capacity: number }[]} list * + * @typedef {object} LineOptions + * A dynamic polyline (src/globe/lines.js checks and defaults these). + * @property {string} id Unique among the engine's live lines. + * @property {number} widthPx Width in CSS pixels. + * @property {number[]} color rgba in 0..1. + * @property {number} [depthFailAlpha] 0..1: where the scene hides the line, a + * copy drawn with its alpha scaled by this (Cesium's depthFailAppearance); + * 0 (the default) draws nothing there. + * @property {{ lengthPx: number, gapPx: number } | 'tactical'} [dash] + * Dashes in pixels along the screen direction (Cesium's PolylineDash), or + * the launches' selected-orbit pattern over the whole line. + * @property {'none'|'geodesic'} [arc] 'geodesic' follows great circles + * between points (Cesium's ArcType.GEODESIC); 'none' (default) draws + * straight segments. + * @property {Matrix4} [modelMatrix] Applied to the positions (an orbit ring + * turned by GMST). + * @property {any} [pickId] What a Cesium pick of the line returns (Navara + * lines are not pickable yet). + * + * @typedef {object} Line + * @property {(positions: Cartesian3[] | Float64Array) => void} setPositions + * @property {(color: number[]) => void} setColor + * @property {(matrix: Matrix4) => void} setModelMatrix + * @property {(visible: boolean) => void} show + * @property {() => void} destroy + * Every call requests a render; nothing animates on its own. + * + * @typedef {object} GlobeLines + * @property {(options: LineOptions) => Line} createLine + * @property {() => { id: string, points: number, shown: boolean }[]} list + * * @typedef {object} GlobeEngine * @property {'cesium'|'navara'} kind * @property {{ container: object, canvas: object, size: () => { width: number, height: number } }} host @@ -317,6 +348,7 @@ * @property {GlobeMaps} maps * @property {GlobeClock} clock * @property {GlobeMovers} movers + * @property {GlobeLines} lines * @property {() => void} destroy * @property {() => boolean} isDestroyed True once destroy() has run. */ @@ -497,6 +529,7 @@ const SHAPE = { credits: { addStatic: isFunction }, clock: { now: isFunction, frameTime: isFunction, ticked: isEngineEvent }, movers: { createSet: isFunction, list: isFunction }, + lines: { createLine: isFunction, list: isFunction }, maps: { setBaseImagery: isFunction, setTerrain: isFunction, diff --git a/src/globe/contract.test.mjs b/src/globe/contract.test.mjs index 20ca77778..f64f4616a 100644 --- a/src/globe/contract.test.mjs +++ b/src/globe/contract.test.mjs @@ -343,3 +343,15 @@ test('the contract carries instanced movers', () => { /movers\.createSet.*movers\.list/, ); }); + +test('the contract carries dynamic lines', () => { + for (const member of ['lines.createLine', 'lines.list']) + assert.ok(GLOBE_ENGINE_MEMBERS.includes(member), member); + const engine = createFakeEngine(); + assert.equal(assertGlobeEngine(engine), engine); + const broken = { ...engine, lines: {} }; + assert.throws( + () => assertGlobeEngine(broken), + /lines\.createLine.*lines\.list/, + ); +}); diff --git a/src/globe/lines.js b/src/globe/lines.js new file mode 100644 index 000000000..470a7de98 --- /dev/null +++ b/src/globe/lines.js @@ -0,0 +1,255 @@ +// engine.lines, the engine-neutral part: dynamic polylines (trails, orbit +// rings, launch paths) with a width in CSS pixels, an optional dimmer +// copy where the scene hides them (depthFailAlpha), dashes (in pixels, or +// the launches' tactical pattern along the whole line), geodesic arcs and a +// model matrix (an orbit ring turned by GMST). This checks options, keeps +// positions as flat Float64Arrays and densifies geodesic arcs the same way +// for every engine that draws straight segments. No engine imports. + +import * as geo from '../geo/index.js'; + +const isFiniteNumber = (value) => + typeof value === 'number' && Number.isFinite(value); +const isObject = (value) => value != null && typeof value === 'object'; + +function fail(message) { + throw new TypeError(`Line: ${message}`); +} + +/** + * The launches' selected-orbit pattern (layers/launches/policy.js + * MISSION_ORBIT_PATTERN_GROUPS and MISSION_ORBIT_DASHES_PER_GROUP): along the + * whole line, `groups` groups of one round dot and `dashes` short dashes. + */ +export const TACTICAL_DASH = Object.freeze({ groups: 4, dashes: 100 }); + +export const LINE_ARCS = Object.freeze(['none', 'geodesic']); + +const isColor = (value) => + (Array.isArray(value) || ArrayBuffer.isView(value)) && + value.length === 4 && + Array.prototype.every.call(value, isFiniteNumber); + +const isMatrix = (value) => + (Array.isArray(value) || ArrayBuffer.isView(value)) && + value.length === 16 && + Array.prototype.every.call(value, isFiniteNumber); + +function dashOf(dash) { + if (dash == null) return null; + if (dash === 'tactical') return { kind: 'tactical', ...TACTICAL_DASH }; + if (isObject(dash) && dash.tactical) + return { + kind: 'tactical', + groups: dash.tactical.groups ?? TACTICAL_DASH.groups, + dashes: dash.tactical.dashes ?? TACTICAL_DASH.dashes, + }; + if ( + isObject(dash) && + isFiniteNumber(dash.lengthPx) && + dash.lengthPx > 0 && + isFiniteNumber(dash.gapPx) && + dash.gapPx >= 0 + ) + return { kind: 'pixels', lengthPx: dash.lengthPx, gapPx: dash.gapPx }; + return fail("dash must be { lengthPx, gapPx }, 'tactical' or null"); +} + +/** createLine options with defaults, or a TypeError. */ +export function normalizeLineOptions(options) { + if (!isObject(options)) fail('options must be an object'); + const { id, widthPx, color } = options; + if (typeof id !== 'string' || !id) fail('id must be a non-empty string'); + if (!(isFiniteNumber(widthPx) && widthPx > 0)) + fail(`widthPx must be a positive number: ${widthPx}`); + if (!isColor(color)) fail('color must be [r, g, b, a]'); + const arc = options.arc ?? 'none'; + if (!LINE_ARCS.includes(arc)) fail(`arc must be 'none' or 'geodesic'`); + const depthFailAlpha = options.depthFailAlpha ?? 0; + if (!( + isFiniteNumber(depthFailAlpha) && + depthFailAlpha >= 0 && + depthFailAlpha <= 1 + )) + fail('depthFailAlpha must be in 0..1'); + const modelMatrix = options.modelMatrix ?? null; + if (modelMatrix !== null && !isMatrix(modelMatrix)) + fail('modelMatrix must be 16 numbers'); + return { + id, + widthPx, + color: [...color], + arc, + depthFailAlpha, + dash: dashOf(options.dash), + modelMatrix: modelMatrix ? [...modelMatrix] : null, + pickId: options.pickId, + order: isFiniteNumber(options.order) ? options.order : 0, + }; +} + +/** Positions as one flat Float64Array [x, y, z, …]. */ +export function flattenPositions(positions) { + if (ArrayBuffer.isView(positions)) { + if (positions.length % 3 !== 0) fail('a flat position array needs x, y, z'); + if (!Array.prototype.every.call(positions, isFiniteNumber)) + fail('positions must be finite'); + return Float64Array.from(positions); + } + if (!Array.isArray(positions)) fail('positions must be an array'); + const flat = new Float64Array(positions.length * 3); + positions.forEach((position, index) => { + if ( + !isObject(position) || + !isFiniteNumber(position.x) || + !isFiniteNumber(position.y) || + !isFiniteNumber(position.z) + ) + fail(`position ${index} is not a finite Cartesian3`); + flat[index * 3] = position.x; + flat[index * 3 + 1] = position.y; + flat[index * 3 + 2] = position.z; + }); + return flat; +} + +const scratchA = geo.cartesian3.create(); +const scratchNormal = geo.cartesian3.create(); + +/** + * Densifies straight segments into geodesic arcs: between each pair of + * points, steps of at most `granularity` radians along the great circle of + * their geodetic normals, the height interpolated linearly (Cesium's + * PolylineGeometry GEODESIC arcs, to within its own sampling). + */ +export function densifyGeodesic(flat, granularity = Math.PI / 180) { + const count = flat.length / 3; + if (count < 2) return Float64Array.from(flat); + const out = []; + const ellipsoid = geo.ellipsoid.WGS84; + const carto = (index) => + geo.cartographic.fromCartesian( + geo.cartesian3.fromElements( + flat[index * 3], + flat[index * 3 + 1], + flat[index * 3 + 2], + scratchA, + ), + ellipsoid, + ); + const normal = (place) => { + const cosLat = Math.cos(place.latitude); + return [ + cosLat * Math.cos(place.longitude), + cosLat * Math.sin(place.longitude), + Math.sin(place.latitude), + ]; + }; + let from = carto(0); + for (let index = 1; index < count; index++) { + const to = carto(index); + const a = normal(from); + const b = normal(to); + const cos = Math.min( + 1, + Math.max(-1, a[0] * b[0] + a[1] * b[1] + a[2] * b[2]), + ); + const angle = Math.acos(cos); + const steps = Math.max(1, Math.ceil(angle / granularity - 1e-9)); + const sin = Math.sin(angle); + for (let step = index === 1 ? 0 : 1; step <= steps; step++) { + const t = step / steps; + let n; + if (sin < 1e-12) n = a; + else { + const wa = Math.sin((1 - t) * angle) / sin; + const wb = Math.sin(t * angle) / sin; + n = [ + wa * a[0] + wb * b[0], + wa * a[1] + wb * b[1], + wa * a[2] + wb * b[2], + ]; + } + const length = Math.hypot(n[0], n[1], n[2]); + const latitude = Math.asin(n[2] / length); + const longitude = Math.atan2(n[1], n[0]); + const height = from.height + (to.height - from.height) * t; + geo.cartesian3.fromRadians( + longitude, + latitude, + height, + ellipsoid, + scratchNormal, + ); + out.push(scratchNormal.x, scratchNormal.y, scratchNormal.z); + } + from = to; + } + return Float64Array.from(out); +} + +/** + * engine.lines over an engine's drawing: `build(options)` returns the + * engine part ({ setPositions(flat), setColor(rgba), setModelMatrix(m), + * show(visible), destroy() }). Ids are unique while a line lives. + */ +export function createLineRegistry(build) { + const live = new Map(); + const lines = { + createLine(input) { + const options = normalizeLineOptions(input); + 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 }; + const alive = () => { + if (state.destroyed) throw new Error('Line was destroyed'); + }; + const line = { + setPositions(positions) { + alive(); + const flat = flattenPositions(positions); + state.points = flat.length / 3; + part.setPositions(flat); + }, + setColor(color) { + alive(); + if (!isColor(color)) fail('color must be [r, g, b, a]'); + part.setColor([...color]); + }, + setModelMatrix(matrix) { + alive(); + if (!isMatrix(matrix)) fail('modelMatrix must be 16 numbers'); + part.setModelMatrix([...matrix]); + }, + 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 = line; + live.set(options.id, state); + return line; + }, + list: () => + [...live.values()].map(({ options, points, shown }) => ({ + id: options.id, + points, + shown, + })), + }; + return { + lines, + live, + destroyAll() { + for (const state of [...live.values()]) state.line.destroy(); + }, + }; +} diff --git a/src/globe/lines.test.mjs b/src/globe/lines.test.mjs new file mode 100644 index 000000000..d7980cafe --- /dev/null +++ b/src/globe/lines.test.mjs @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as geo from '../geo/index.js'; +import { + createLineRegistry, + densifyGeodesic, + flattenPositions, + normalizeLineOptions, + TACTICAL_DASH, +} from './lines.js'; + +test('line options get the contract defaults and refuse bad values', () => { + const line = normalizeLineOptions({ + id: 'trail', + widthPx: 2, + color: [1, 0, 0, 1], + }); + assert.equal(line.arc, 'none'); + assert.equal(line.depthFailAlpha, 0); + assert.equal(line.dash, null); + assert.equal(line.modelMatrix, null); + const tactical = normalizeLineOptions({ + id: 'orbit', + widthPx: 3, + color: [0, 1, 1, 0.95], + dash: 'tactical', + arc: 'geodesic', + depthFailAlpha: 0.3, + modelMatrix: [...geo.matrix4.IDENTITY], + }); + assert.deepEqual(tactical.dash, { kind: 'tactical', ...TACTICAL_DASH }); + const dashed = normalizeLineOptions({ + id: 'path', + widthPx: 2, + color: [1, 1, 1, 1], + dash: { lengthPx: 16, gapPx: 8 }, + }); + assert.deepEqual(dashed.dash, { kind: 'pixels', lengthPx: 16, gapPx: 8 }); + for (const options of [ + {}, + { id: 'a', widthPx: 0, color: [1, 1, 1, 1] }, + { id: 'a', widthPx: 1, color: [1, 1, 1] }, + { id: 'a', widthPx: 1, color: [1, 1, 1, 1], arc: 'rhumb' }, + { id: 'a', widthPx: 1, color: [1, 1, 1, 1], depthFailAlpha: 2 }, + { id: 'a', widthPx: 1, color: [1, 1, 1, 1], dash: 'dotted' }, + { id: 'a', widthPx: 1, color: [1, 1, 1, 1], modelMatrix: [1, 2] }, + ]) + assert.throws(() => normalizeLineOptions(options), TypeError); +}); + +test('positions flatten from Cartesian3 lists or flat arrays', () => { + assert.deepEqual( + [ + ...flattenPositions([ + { x: 1, y: 2, z: 3 }, + { x: 4, y: 5, z: 6 }, + ]), + ], + [1, 2, 3, 4, 5, 6], + ); + assert.deepEqual( + [...flattenPositions(new Float64Array([1, 2, 3]))], + [1, 2, 3], + ); + assert.throws(() => flattenPositions([{ x: 1, y: 2 }]), TypeError); + assert.throws(() => flattenPositions(new Float64Array([1, 2])), TypeError); +}); + +test('geodesic densification follows the great circle with interpolated height', () => { + const a = geo.cartesian3.fromDegrees(0, 0, 1000); + const b = geo.cartesian3.fromDegrees(10, 0, 3000); + const flat = densifyGeodesic(flattenPositions([a, b]), Math.PI / 180); + const count = flat.length / 3; + assert.equal(count, 11, 'one point per degree'); + for (let index = 0; index < count; index++) { + const point = geo.cartographic.fromCartesian( + geo.cartesian3.create( + flat[index * 3], + flat[index * 3 + 1], + flat[index * 3 + 2], + ), + ); + assert.ok(Math.abs(geo.math.toDegrees(point.latitude)) < 1e-9); + assert.ok(Math.abs(geo.math.toDegrees(point.longitude) - index) < 1e-6); + assert.ok(Math.abs(point.height - (1000 + 200 * index)) < 1e-3); + } +}); + +test('the registry builds engine lines, lists them and refuses duplicates', () => { + const calls = []; + const { lines, destroyAll } = createLineRegistry((options) => ({ + setPositions: (flat) => calls.push(['positions', options.id, flat.length]), + setColor: (rgba) => calls.push(['color', rgba]), + setModelMatrix: (m) => calls.push(['matrix', m.length]), + show: (visible) => calls.push(['show', visible]), + destroy: () => calls.push(['destroy', options.id]), + })); + const line = lines.createLine({ id: 't', widthPx: 2, color: [1, 1, 1, 1] }); + line.setPositions([ + { x: 1, y: 2, z: 3 }, + { x: 4, y: 5, z: 6 }, + ]); + line.setColor([0, 0, 0, 1]); + line.setModelMatrix([...geo.matrix4.IDENTITY]); + line.show(false); + assert.deepEqual(lines.list(), [{ id: 't', points: 2, shown: false }]); + assert.throws( + () => lines.createLine({ id: 't', widthPx: 1, color: [1, 1, 1, 1] }), + /already/, + ); + assert.throws(() => line.setColor([1, 1]), TypeError); + destroyAll(); + assert.deepEqual(lines.list(), []); + assert.deepEqual(calls.at(-1), ['destroy', 't']); + assert.throws(() => line.show(true), /destroyed/); +}); + +test('the tactical dash is the launches mission-orbit pattern', async () => { + const policy = await import('../layers/launches/policy.js'); + assert.deepEqual(TACTICAL_DASH, { + groups: policy.MISSION_ORBIT_PATTERN_GROUPS, + dashes: policy.MISSION_ORBIT_DASHES_PER_GROUP, + }); +}); diff --git a/src/globe/navara/createNavaraEngine.js b/src/globe/navara/createNavaraEngine.js index 387f84aa8..4d792f38b 100644 --- a/src/globe/navara/createNavaraEngine.js +++ b/src/globe/navara/createNavaraEngine.js @@ -30,6 +30,12 @@ const NO_MOVERS = Object.freeze({ }, list: () => [], }); +const NO_LINES = Object.freeze({ + createLine() { + throw new Error("Navara lines need the view's three runtime (lines/)"); + }, + list: () => [], +}); // Navara's drawn camera (view.camera.raw, a three PerspectiveCamera in // Earth-fixed metres): matrixWorld columns are right, up, back, position. @@ -84,7 +90,8 @@ function rayRaisedEllipsoid({ origin, direction }, height) { * it the engine only keeps the state) and the engine destroys it; * `appClock` (the engine clock's time) defaults to the page's app clock; * `movers` is engine.movers on the view's three runtime (movers/index.js; - * without it createSet throws) and the engine destroys it; + * without it createSet throws), `lines` engine.lines (lines/index.js; + * likewise), both destroyed with the engine; * `fetch` and `clock` (the camera model's timers) are test seams. * @returns {import('../contract.js').GlobeEngine} */ @@ -97,6 +104,7 @@ export function createNavaraEngine( clock, appClock, movers: moversPort = null, + lines: linesPort = null, sharpen: sharpenPort = null, } = {}, ) { @@ -246,6 +254,7 @@ export function createNavaraEngine( maps: maps.maps, clock: engineClock.clock, movers: moversPort?.movers ?? NO_MOVERS, + lines: linesPort?.lines ?? NO_LINES, destroy() { if (destroyed) return; destroyed = true; @@ -257,6 +266,7 @@ export function createNavaraEngine( maps.destroy(); sharpenPort?.destroy(); moversPort?.destroy(); + linesPort?.destroy(); }, isDestroyed: () => destroyed, // Navara only, not contract: the ground the floor last sampled under diff --git a/src/globe/navara/index.js b/src/globe/navara/index.js index 0e78dea95..2600ca908 100644 --- a/src/globe/navara/index.js +++ b/src/globe/navara/index.js @@ -10,6 +10,7 @@ import { forEachLuminanceUniform, navaraHooks } from './internals.js'; import { buildNavaraLook } from './look.js'; import { createNavaraSharpen } from './sharpen.js'; import { createNavaraMovers } from './movers/index.js'; +import { createNavaraLines } from './lines/index.js'; import { NAVARA_CONTEXT_ATTRIBUTES, navaraScenePlan, @@ -117,6 +118,7 @@ export async function createNavaraGlobe({ ...(appClock ? { appClock } : {}), sharpen: createNavaraSharpen(view), movers: createNavaraMovers(view), + lines: createNavaraLines(view), }); const camera = engine.camera; let ground = Number.NaN; diff --git a/src/globe/navara/lines/index.js b/src/globe/navara/lines/index.js new file mode 100644 index 000000000..07b171fc3 --- /dev/null +++ b/src/globe/navara/lines/index.js @@ -0,0 +1,81 @@ +// engine.lines on Navara: each line is one Navara mesh descriptor (public +// MeshDesc API, the transparent pass, unlit) holding its ribbon +// (lineMesh.js). Geodesic arcs are densified on the CPU (src/globe/lines.js) +// since the ribbon draws straight segments. Every change asks for one frame; +// nothing animates on its own, so an idle view stays idle. Lines are not +// pickable on Navara yet (trails never take a click; see phase 3 Task 4). + +import { MeshDesc } from '@navaramap/three'; +import { createLineRegistry, densifyGeodesic } from '../../lines.js'; +import { createLineMesh } from './lineMesh.js'; + +const DESC_NAME = 'gevLine'; +const registered = new WeakSet(); + +class GevLineDesc extends MeshDesc { + constructor(view, ctx, config) { + super(view, ctx, config); + this.gev = config[DESC_NAME]; + } + + getPassKey() { + return 'transparent'; + } + + createMesh() { + return this.gev.line.group; + } + + update() { + const canvas = this.ctx.getRenderer().domElement; + this.gev.line.update(canvas.clientWidth, canvas.clientHeight); + } + + onDestroy() { + this.gev.line.dispose(); + super.onDestroy(); + } +} + +/** + * @param {object} view an initialised Navara ThreeView + * @returns {{ lines: import('../../contract.js').GlobeLines, destroy: () => void }} + */ +export function createNavaraLines(view) { + if (!registered.has(view)) { + view.registerMesh(DESC_NAME, GevLineDesc); + registered.add(view); + } + const request = () => view.forceUpdate(); + const registry = createLineRegistry((options) => { + const line = createLineMesh(options); + const canvas = view.canvas; + if (canvas) line.update(canvas.clientWidth || 1, canvas.clientHeight || 1); + const handle = view.addMesh({ lit: false, [DESC_NAME]: { line } }); + return { + setPositions(flat) { + line.setPositions( + options.arc === 'geodesic' ? densifyGeodesic(flat) : flat, + ); + request(); + }, + setColor(rgba) { + line.setColor(rgba); + request(); + }, + setModelMatrix(matrix) { + line.setModelMatrix(matrix); + request(); + }, + show(visible) { + line.show(visible); + request(); + }, + destroy() { + handle.delete(); + request(); + }, + }; + }); + return { lines: registry.lines, destroy: () => registry.destroyAll() }; +} diff --git a/src/globe/navara/lines/lineMesh.js b/src/globe/navara/lines/lineMesh.js new file mode 100644 index 000000000..042799a81 --- /dev/null +++ b/src/globe/navara/lines/lineMesh.js @@ -0,0 +1,259 @@ +// The three side of a Navara line: a ribbon of one quad per segment whose +// vertices are pushed apart in the vertex shader to the line's width in CSS +// pixels (projected ends, the screen-space normal), so the width holds at any +// distance. A second copy of the ribbon, drawn only where the scene hides it +// (depthFunc GreaterDepth) with the alpha scaled, is depthFailAlpha. Dashes +// in pixels are cut in the fragment shader along the segment's screen +// direction (as Cesium's PolylineDash); the tactical dash is the launches' +// pattern over the fraction along the whole line. Positions are relative to +// the first point (the group sits there, in doubles), the model matrix +// multiplies the group's transform (an orbit ring turned by GMST). + +import { + BufferAttribute, + BufferGeometry, + DoubleSide, + GreaterDepth, + Group, + LessEqualDepth, + Matrix4, + Mesh, + MeshBasicMaterial, +} from 'three'; + +const VERTEX_HEAD = /* glsl */ ` +attribute vec3 gevEnd; +attribute vec2 gevSide; +attribute float gevS; +uniform vec2 uGevViewport; +uniform float uGevWidthPx; +varying float vGevS; +varying float vGevT; +varying vec2 vGevDirection; +`; + +const VERTEX_PROJECT = /* glsl */ ` +vec4 gevA = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +vec4 gevB = projectionMatrix * modelViewMatrix * vec4(gevEnd, 1.0); +// Ends behind the camera move along the segment to just in front of it. +const float GEV_NEAR_W = 1e-3; +if (gevA.w < GEV_NEAR_W) gevA = mix(gevA, gevB, (GEV_NEAR_W - gevA.w) / (gevB.w - gevA.w)); +if (gevB.w < GEV_NEAR_W) gevB = mix(gevB, gevA, (GEV_NEAR_W - gevB.w) / (gevA.w - gevB.w)); +vec2 gevScreenA = gevA.xy / gevA.w * 0.5 * uGevViewport; +vec2 gevScreenB = gevB.xy / gevB.w * 0.5 * uGevViewport; +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; +gl_Position.xy += gevNormal * gevSide.y * uGevWidthPx / uGevViewport * gl_Position.w; +vec4 mvPosition = modelViewMatrix * vec4(gevSide.x < 0.5 ? position : gevEnd, 1.0); +vGevS = gevS; +vGevT = gevSide.y * 0.5 + 0.5; +vGevDirection = gevDirection; +`; + +const FRAGMENT_HEAD = /* glsl */ ` +uniform vec4 uGevColor; +uniform float uGevAlphaScale; +uniform float uGevDash; +uniform vec2 uGevDashPx; +uniform vec2 uGevTactical; +varying float vGevS; +varying float vGevT; +varying vec2 vGevDirection; +float gevTactical() { + float groupPosition = fract(vGevS * uGevTactical.x); + float markPosition = groupPosition * (uGevTactical.y + 1.0); + float markIndex = floor(markPosition); + float localPosition = fract(markPosition); + float centerDistance = abs(localPosition - 0.5); + float edge = max(fwidth(localPosition) * 1.35, 0.012); + float dashAlong = 1.0 - smoothstep(0.27 - edge, 0.27 + edge, centerDistance); + float dashAcross = 1.0 - smoothstep(0.12, 0.24, abs(vGevT - 0.5)); + float dotAlong = (localPosition - 0.5) / 0.32; + float dotAcross = (vGevT - 0.5) / 0.5; + float dot = 1.0 - smoothstep(0.78, 1.0, length(vec2(dotAlong, dotAcross))); + float isDot = 1.0 - step(0.5, markIndex); + return mix(dashAlong * dashAcross, dot, isDot) * mix(0.58, 1.0, isDot); +} +`; + +const FRAGMENT_COLOR = /* glsl */ ` +#include +diffuseColor = uGevColor; +diffuseColor.a *= uGevAlphaScale; +if (uGevDash > 1.5) { + diffuseColor.a *= gevTactical(); +} else if (uGevDash > 0.5) { + float gevAlong = dot(gl_FragCoord.xy, vGevDirection); + if (mod(gevAlong, uGevDashPx.x + uGevDashPx.y) > uGevDashPx.x) discard; +} +`; + +/** Patches three's MeshBasicMaterial shaders into the pixel-width ribbon. */ +export function patchLineShader(shader, uniforms) { + Object.assign(shader.uniforms, uniforms); + const replace = (source, from, to) => { + if (!source.includes(from)) + throw new Error(`Line shader: three has no ${from}`); + return source.replace(from, to); + }; + shader.vertexShader = replace( + replace( + shader.vertexShader, + 'void main() {', + `${VERTEX_HEAD}\nvoid main() {`, + ), + '#include ', + VERTEX_PROJECT, + ); + shader.fragmentShader = replace( + replace( + shader.fragmentShader, + 'void main() {', + `${FRAGMENT_HEAD}\nvoid main() {`, + ), + '#include ', + FRAGMENT_COLOR, + ); + return shader; +} + +/** + * The ribbon geometry of a flat position list, relative to `origin`: per + * 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) { + const points = flat.length / 3; + const segments = 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 cumulative = new Float64Array(points); + for (let p = 1; p < points; p++) + cumulative[p] = + cumulative[p - 1] + + Math.hypot( + flat[p * 3] - flat[p * 3 - 3], + flat[p * 3 + 1] - flat[p * 3 - 2], + flat[p * 3 + 2] - flat[p * 3 - 1], + ); + const total = cumulative[points - 1] || 1; + for (let s = 0; s < segments; s++) { + const a = s * 3; + const b = a + 3; + for (let v = 0; v < 4; v++) { + const vertex = s * 4 + v; + for (let k = 0; k < 3; k++) { + start[vertex * 3 + k] = flat[a + k] - origin[k]; + end[vertex * 3 + k] = flat[b + k] - origin[k]; + } + 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; + } + index.set( + [s * 4, s * 4 + 1, s * 4 + 2, s * 4 + 2, s * 4 + 1, s * 4 + 3], + s * 6, + ); + } + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new BufferAttribute(start, 3)); + geometry.setAttribute('gevEnd', new BufferAttribute(end, 3)); + geometry.setAttribute('gevSide', new BufferAttribute(side, 2)); + geometry.setAttribute('gevS', new BufferAttribute(along, 1)); + geometry.setIndex(new BufferAttribute(index, 1)); + return geometry; +} + +/** @param {ReturnType} options */ +export function createLineMesh(options) { + const dash = options.dash; + const shared = { + uGevViewport: { value: [1, 1] }, + uGevWidthPx: { value: options.widthPx }, + uGevColor: { value: [...options.color] }, + uGevDash: { value: dash?.kind === 'tactical' ? 2 : dash ? 1 : 0 }, + uGevDashPx: { + value: dash?.kind === 'pixels' ? [dash.lengthPx, dash.gapPx] : [1, 0], + }, + uGevTactical: { + value: dash?.kind === 'tactical' ? [dash.groups, dash.dashes] : [1, 1], + }, + }; + const material = (alphaScale, depthFunc) => { + const value = new MeshBasicMaterial({ + transparent: true, + depthWrite: false, + depthFunc, + side: DoubleSide, + }); + const uniforms = { ...shared, uGevAlphaScale: { value: alphaScale } }; + value.onBeforeCompile = (shader) => patchLineShader(shader, uniforms); + value.customProgramCacheKey = () => 'gev-line'; + return value; + }; + const group = new Group(); + group.matrixAutoUpdate = false; + group.frustumCulled = false; + const main = new Mesh(new BufferGeometry(), material(1, LessEqualDepth)); + main.frustumCulled = false; + main.renderOrder = options.order; + group.add(main); + let behind = null; + if (options.depthFailAlpha > 0) { + behind = new Mesh( + main.geometry, + material(options.depthFailAlpha, GreaterDepth), + ); + behind.frustumCulled = false; + behind.renderOrder = options.order; + group.add(behind); + } + let origin = [0, 0, 0]; + const model = new Matrix4(); + if (options.modelMatrix) model.fromArray(options.modelMatrix); + const place = () => { + group.matrix + .copy(model) + .multiply(new Matrix4().makeTranslation(origin[0], origin[1], origin[2])); + group.matrixWorldNeedsUpdate = true; + }; + place(); + return { + group, + uniforms: shared, + meshes: () => (behind ? [main, behind] : [main]), + setPositions(flat) { + const previous = main.geometry; + origin = flat.length >= 3 ? [flat[0], flat[1], flat[2]] : [0, 0, 0]; + const geometry = buildRibbon(flat, origin); + main.geometry = geometry; + if (behind) behind.geometry = geometry; + previous.dispose(); + place(); + }, + setColor(rgba) { + shared.uGevColor.value = [...rgba]; + }, + setModelMatrix(matrix) { + model.fromArray(matrix); + place(); + }, + show(visible) { + group.visible = visible; + }, + /** Each drawn frame: the viewport the pixel width is measured in. */ + update(width, height) { + shared.uGevViewport.value = [width, height]; + }, + dispose() { + main.geometry.dispose(); + for (const mesh of [main, behind]) mesh?.material.dispose(); + }, + }; +} diff --git a/src/globe/navara/lines/lineMesh.test.mjs b/src/globe/navara/lines/lineMesh.test.mjs new file mode 100644 index 000000000..3dc232bb6 --- /dev/null +++ b/src/globe/navara/lines/lineMesh.test.mjs @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + GreaterDepth, + Matrix4, + ShaderLib, + UniformsUtils, + Vector3, +} from 'three'; +import { normalizeLineOptions } from '../../lines.js'; +import { buildRibbon, createLineMesh, patchLineShader } from './lineMesh.js'; + +const flat = new Float64Array([ + 6_378_137, 0, 0, 6_378_137, 300, 0, 6_378_137, 300, 400, +]); + +test('the ribbon has a quad per segment relative to the first point', () => { + const geometry = buildRibbon(flat, [6_378_137, 0, 0]); + assert.equal(geometry.getAttribute('position').count, 8); + assert.equal(geometry.index.count, 12); + assert.deepEqual( + [...geometry.getAttribute('gevEnd').array.subarray(0, 3)], + [0, 300, 0], + ); + // The fraction along the line: 0 at the start, 300/700 at the joint, 1 at the end. + const s = geometry.getAttribute('gevS').array; + assert.deepEqual([s[0], s[2]], [0, 300 / 700].map(Math.fround)); + assert.equal(s[7], 1); + const side = geometry.getAttribute('gevSide').array; + assert.deepEqual([...side.subarray(0, 8)], [0, 1, 0, -1, 1, 1, 1, -1]); +}); + +test('the shader patch widens the ribbon in pixels and cuts dashes', () => { + const shader = patchLineShader( + { + uniforms: UniformsUtils.clone(ShaderLib.basic.uniforms), + vertexShader: ShaderLib.basic.vertexShader, + fragmentShader: ShaderLib.basic.fragmentShader, + }, + { uGevWidthPx: { value: 3 } }, + ); + assert.match( + shader.vertexShader, + /uGevWidthPx \/ uGevViewport \* gl_Position\.w/, + ); + assert.match( + shader.fragmentShader, + /mod\(gevAlong, uGevDashPx\.x \+ uGevDashPx\.y\)/, + ); + assert.match(shader.fragmentShader, /float gevTactical\(\)/); +}); + +test('a line places its group at the first point under its model matrix', () => { + const turn = new Matrix4().makeRotationZ(Math.PI / 2); + const line = createLineMesh( + normalizeLineOptions({ + id: 'orbit', + widthPx: 3, + color: [0, 1, 1, 1], + depthFailAlpha: 0.3, + dash: 'tactical', + modelMatrix: turn.toArray(), + }), + ); + line.setPositions(flat); + const origin = new Vector3().setFromMatrixPosition(line.group.matrix); + // (6378137, 0, 0) turned 90° about z. + assert.ok(Math.abs(origin.x) < 1e-6 && Math.abs(origin.y - 6_378_137) < 1e-6); + const [main, behind] = line.meshes(); + assert.equal(behind.material.depthFunc, GreaterDepth); + assert.equal(main.geometry, behind.geometry); + assert.equal(line.uniforms.uGevDash.value, 2); + assert.deepEqual(line.uniforms.uGevTactical.value, [4, 100]); + line.setColor([1, 0, 0, 0.5]); + assert.deepEqual(line.uniforms.uGevColor.value, [1, 0, 0, 0.5]); + line.show(false); + assert.equal(line.group.visible, false); + line.dispose(); +}); diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index 371c4d19b..8dc4588ab 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -591,6 +591,116 @@ export function createContractSuite( ); }, ], + [ + 'lines: a line draws between its projections, turns with its model matrix and dims where hidden', + async () => { + await nadirView(1e6); + // 20 km up: above any ground the scene draws. + const at = (lon, lat, height = 20_000) => + cartesian3.fromDegrees(lon, lat, height); + const line = engine.lines.createLine({ + id: 'contract-line', + widthPx: 8, + color: [1, 0, 1, 1], + }); + const hidden = engine.lines.createLine({ + id: 'contract-line-hidden', + widthPx: 10, + color: [1, 0, 1, 1], + depthFailAlpha: 0.6, + }); + try { + line.setPositions([at(19, 9), at(20, 9), at(21, 9)]); + ensure( + engine.lines + .list() + .some(({ id, points }) => id === 'contract-line' && points === 3), + 'lines.list() does not show the line', + ); + if (!readRegion) return; + // Magenta-leaning pixels (red and blue over green; a dimmed copy + // over a light map stays above 30) near a point. + // The strongest magenta lean near a point (for dimmer-than). + const strength = (position) => { + const screen = engine.projector.worldToWindow(position); + const data = readRegion( + Math.round(screen.x) - 6, + Math.round(screen.y) - 6, + 12, + 12, + ); + let best = -255; + for (let i = 0; i < data.length; i += 4) + best = Math.max( + best, + Math.min(data[i], data[i + 2]) - data[i + 1], + ); + return best; + }; + const magentaAt = (position) => { + const screen = engine.projector.worldToWindow(position); + if (!screen) return 0; + const data = readRegion( + Math.round(screen.x) - 6, + Math.round(screen.y) - 6, + 12, + 12, + ); + let count = 0; + for (let i = 0; i < data.length; i += 4) + if ( + Math.min(data[i], data[i + 2]) - data[i + 1] > 30 && + data[i] > 120 + ) + count++; + return count; + }; + await until( + () => magentaAt(at(19.5, 9)) > 10 && magentaAt(at(20.5, 9)) > 10, + 'the line drawn along both segments', + ); + ensure(magentaAt(at(19.5, 10.5)) === 0, 'line pixels off the line'); + // Turned 1° about the Earth's axis: the line moves 1° east. + const turn = (radians) => { + const c = Math.cos(radians); + const s = Math.sin(radians); + return [c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + }; + line.setModelMatrix(turn(math.toRadians(1))); + await until( + () => magentaAt(at(21.5, 9)) > 10 && magentaAt(at(19.25, 9)) === 0, + 'the turned line drawn 1° east', + ); + const shownStrength = strength(at(20.5, 9)); + line.show(false); + await until(() => magentaAt(at(20.5, 9)) === 0, 'the line hidden'); + // 30 km under the ground: only the depth-fail copy shows, dimmed. + const deep = (lon) => at(lon, 11, -30_000); + hidden.setPositions([deep(19), deep(21)]); + await until( + () => magentaAt(deep(20)) > 10, + 'the hidden line drawn by its depth-fail copy', + ); + const hiddenStrength = strength(deep(20)); + ensure( + hiddenStrength < shownStrength, + `the depth-fail copy (${hiddenStrength}) is not dimmer than the line (${shownStrength})`, + ); + hidden.destroy(); + await until( + () => magentaAt(deep(20)) === 0, + 'the destroyed line gone', + ); + } finally { + line.destroy(); + hidden.destroy(); + } + ensure( + !engine.lines.list().some(({ id }) => id.startsWith('contract-line')), + 'a destroyed line is still listed', + ); + }, + ], [ 'render: continuous mode toggles', async () => { diff --git a/src/globe/testing/fakeEngine.js b/src/globe/testing/fakeEngine.js index 541e55995..1b1bad131 100644 --- a/src/globe/testing/fakeEngine.js +++ b/src/globe/testing/fakeEngine.js @@ -26,6 +26,7 @@ import { mergeSharpen, } from '../contract.js'; import { createAppClock } from '../../time/clock.js'; +import { createLineRegistry } from '../lines.js'; import { createMoverRegistry } from '../movers.js'; const { cartesian2, cartesian3, cartographic, ellipsoid, math, matrix4 } = geo; @@ -261,6 +262,42 @@ export function createFakeEngine(options = {}) { let wallMs = options.nowMs ?? FAKE_EPOCH_MS; const appClock = options.appClock ?? createAppClock({ now: () => wallMs }); const ownsWall = !options.appClock; + // Lines keep what they were given; each change counts as a render request. + const fakeLines = new Map(); + const lineRegistry = createLineRegistry((options) => { + const state = { + options, + positions: new Float64Array(0), + color: [...options.color], + modelMatrix: options.modelMatrix, + shown: true, + }; + fakeLines.set(options.id, state); + const changed = () => { + fake.renderRequests++; + }; + return { + setPositions(flat) { + state.positions = flat; + changed(); + }, + setColor(rgba) { + state.color = rgba; + changed(); + }, + setModelMatrix(matrix) { + state.modelMatrix = matrix; + changed(); + }, + show(visible) { + state.shown = visible; + changed(); + }, + destroy() { + fakeLines.delete(options.id); + }, + }; + }); // Movers keep their store for tests; commit counts as a render request. const moverRegistry = createMoverRegistry(() => { const part = { @@ -807,6 +844,7 @@ export function createFakeEngine(options = {}) { horizonVisible, }, movers: moverRegistry.movers, + lines: lineRegistry.lines, clock: { now: () => appClock.now(), frameTime: () => appClock.frameTime(), @@ -922,6 +960,7 @@ export function createFakeEngine(options = {}) { record('destroy', []); cancelCurrentFlight(); moverRegistry.destroyAll(); + lineRegistry.destroyAll(); fake.destroyed = true; }, isDestroyed: () => fake.destroyed, @@ -982,6 +1021,8 @@ export function createFakeEngine(options = {}) { scripts.delete(name); }, lookAtTarget: () => (lookAtTarget ? copy3(lookAtTarget) : undefined), + /** A live line's options, positions, colour, matrix and visibility, by id. */ + line: (id) => fakeLines.get(id), /** A live mover set's options, store and commit count, by id. */ moverSet(id) { const entry = moverRegistry.sets.get(id); diff --git a/src/globe/testing/fakeEngine.test.mjs b/src/globe/testing/fakeEngine.test.mjs index 44275e3eb..e28ce982b 100644 --- a/src/globe/testing/fakeEngine.test.mjs +++ b/src/globe/testing/fakeEngine.test.mjs @@ -474,3 +474,26 @@ test('fake movers keep their sets and instances for layer tests', () => { assert.deepEqual(engine.movers.list(), []); assert.equal(engine.fake.moverSet('flights'), undefined); }); + +test('fake lines keep their positions, colour, matrix and visibility', () => { + const engine = createFakeEngine(); + const line = engine.lines.createLine({ + id: 'trail', + widthPx: 2, + color: [1, 0, 0, 1], + }); + line.setPositions([ + { x: 1, y: 2, z: 3 }, + { x: 4, y: 5, z: 6 }, + ]); + line.show(false); + const stored = engine.fake.line('trail'); + assert.deepEqual([...stored.positions], [1, 2, 3, 4, 5, 6]); + assert.deepEqual(stored.color, [1, 0, 0, 1]); + assert.equal(stored.shown, false); + assert.deepEqual(engine.lines.list(), [ + { id: 'trail', points: 2, shown: false }, + ]); + line.destroy(); + assert.equal(engine.fake.line('trail'), undefined); +});