feat(globe): dynamic lines on both engines

engine.lines.createLine({ id, widthPx, color, depthFailAlpha, dash
({ lengthPx, gapPx } | 'tactical'), arc ('none'|'geodesic'), modelMatrix,
pickId }) → a Line: setPositions (Cartesian3[] or a flat Float64Array),
setColor, setModelMatrix, show, destroy; lines.list() for QA. Options,
flat positions, geodesic densification (great circles of the geodetic
normals, height interpolated, one step per degree) and the registry are
engine-neutral (src/globe/lines.js); the fake engine keeps every line.

- Cesium: a Primitive of a PolylineGeometry (synchronous, rebuilt when
  the positions change) with a PolylineMaterialAppearance in an
  engine-owned collection; depthFailAlpha is depthFailAppearance with the
  alpha scaled; pixel dashes are PolylineDash (16-bit pattern); the
  tactical dash is the launches' selected-orbit shader under its own type
  (launches keep theirs until Task 15a moves them onto engine.lines).
- Navara: a public MeshDesc (transparent pass, unlit) holding a ribbon
  of one quad per segment, widened to CSS pixels in the vertex shader;
  a second copy drawn with GreaterDepth and the alpha scaled is the
  depth-fail part; dashes are cut in the fragment shader (pixels along
  the screen direction, or the tactical pattern over the fraction along
  the line); positions relative to the first point, the model matrix on
  the group. Geodesic arcs are densified on the CPU. Not pickable yet.
- The contract suite draws a three-point line on both engines: magenta
  along both segments, nothing off it, 1° east once turned by its model
  matrix, gone when hidden; a line 30 km under the ground shows only its
  depth-fail copy, dimmer than the line.

Main chunk 2,867,499 bytes (+7,197, +0.25 %: the Cesium lines).
This commit is contained in:
German Khokhlov committed 2026-10-16 17:24:36 +04:00
1 parent 0109097440
commit 86aa0bc9ba
16 files changed
+1380 -2

No files matched your search

+4 -1
View File
@@ -1074,5 +1074,8 @@
"src/globe/navara/movers/pickIds.test.mjs", "src/globe/navara/movers/pickIds.test.mjs",
"src/globe/navara/movers/moverMesh.test.mjs", "src/globe/navara/movers/moverMesh.test.mjs",
"src/globe/navara/movers/modelMover.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"
] ]
+5
View File
@@ -10,6 +10,7 @@ import { createCesiumClock } from './clock.js';
import { createCesiumCredits } from './credits.js'; import { createCesiumCredits } from './credits.js';
import { createCesiumFrames } from './frames.js'; import { createCesiumFrames } from './frames.js';
import { createCesiumInput } from './input.js'; import { createCesiumInput } from './input.js';
import { createCesiumLines } from './lines.js';
import { createCesiumMaps } from './maps.js'; import { createCesiumMaps } from './maps.js';
import { createCesiumMovers } from './movers.js'; import { createCesiumMovers } from './movers.js';
import { createCesiumPicker } from './picker.js'; import { createCesiumPicker } from './picker.js';
@@ -46,6 +47,10 @@ export function createCesiumEngine(viewer, options = {}) {
projector: createCesiumProjector(viewer), projector: createCesiumProjector(viewer),
frames: createCesiumFrames(viewer), frames: createCesiumFrames(viewer),
clock: createCesiumClock(viewer, options), clock: createCesiumClock(viewer, options),
lines: createCesiumLines(viewer, {
requestRender: (reason) => render.render.request(reason),
...(options.lineFactories ? { factories: options.lineFactories } : {}),
}),
movers: createCesiumMovers(viewer, { movers: createCesiumMovers(viewer, {
requestRender: (reason) => render.render.request(reason), requestRender: (reason) => render.render.request(reason),
...(options.moverFactories ? { factories: options.moverFactories } : {}), ...(options.moverFactories ? { factories: options.moverFactories } : {}),
+214
View File
@@ -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;
}
},
};
}
+127
View File
@@ -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);
});
+33
View File
@@ -296,6 +296,37 @@
* @property {(options: MoverSetOptions) => MoverSet} createSet * @property {(options: MoverSetOptions) => MoverSet} createSet
* @property {() => { id: string, kind: string, count: number, capacity: number }[]} list * @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 * @typedef {object} GlobeEngine
* @property {'cesium'|'navara'} kind * @property {'cesium'|'navara'} kind
* @property {{ container: object, canvas: object, size: () => { width: number, height: number } }} host * @property {{ container: object, canvas: object, size: () => { width: number, height: number } }} host
@@ -317,6 +348,7 @@
* @property {GlobeMaps} maps * @property {GlobeMaps} maps
* @property {GlobeClock} clock * @property {GlobeClock} clock
* @property {GlobeMovers} movers * @property {GlobeMovers} movers
* @property {GlobeLines} lines
* @property {() => void} destroy * @property {() => void} destroy
* @property {() => boolean} isDestroyed True once destroy() has run. * @property {() => boolean} isDestroyed True once destroy() has run.
*/ */
@@ -497,6 +529,7 @@ const SHAPE = {
credits: { addStatic: isFunction }, credits: { addStatic: isFunction },
clock: { now: isFunction, frameTime: isFunction, ticked: isEngineEvent }, clock: { now: isFunction, frameTime: isFunction, ticked: isEngineEvent },
movers: { createSet: isFunction, list: isFunction }, movers: { createSet: isFunction, list: isFunction },
lines: { createLine: isFunction, list: isFunction },
maps: { maps: {
setBaseImagery: isFunction, setBaseImagery: isFunction,
setTerrain: isFunction, setTerrain: isFunction,
+12
View File
@@ -343,3 +343,15 @@ test('the contract carries instanced movers', () => {
/movers\.createSet.*movers\.list/, /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/,
);
});
+255
View File
@@ -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();
},
};
}
+124
View File
@@ -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,
});
});
+11 -1
View File
@@ -30,6 +30,12 @@ const NO_MOVERS = Object.freeze({
}, },
list: () => [], 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 // Navara's drawn camera (view.camera.raw, a three PerspectiveCamera in
// Earth-fixed metres): matrixWorld columns are right, up, back, position. // 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; * 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; * `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; * `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. * `fetch` and `clock` (the camera model's timers) are test seams.
* @returns {import('../contract.js').GlobeEngine} * @returns {import('../contract.js').GlobeEngine}
*/ */
@@ -97,6 +104,7 @@ export function createNavaraEngine(
clock, clock,
appClock, appClock,
movers: moversPort = null, movers: moversPort = null,
lines: linesPort = null,
sharpen: sharpenPort = null, sharpen: sharpenPort = null,
} = {}, } = {},
) { ) {
@@ -246,6 +254,7 @@ export function createNavaraEngine(
maps: maps.maps, maps: maps.maps,
clock: engineClock.clock, clock: engineClock.clock,
movers: moversPort?.movers ?? NO_MOVERS, movers: moversPort?.movers ?? NO_MOVERS,
lines: linesPort?.lines ?? NO_LINES,
destroy() { destroy() {
if (destroyed) return; if (destroyed) return;
destroyed = true; destroyed = true;
@@ -257,6 +266,7 @@ export function createNavaraEngine(
maps.destroy(); maps.destroy();
sharpenPort?.destroy(); sharpenPort?.destroy();
moversPort?.destroy(); moversPort?.destroy();
linesPort?.destroy();
}, },
isDestroyed: () => destroyed, isDestroyed: () => destroyed,
// Navara only, not contract: the ground the floor last sampled under // Navara only, not contract: the ground the floor last sampled under
+2
View File
@@ -10,6 +10,7 @@ import { forEachLuminanceUniform, navaraHooks } from './internals.js';
import { buildNavaraLook } from './look.js'; import { buildNavaraLook } from './look.js';
import { createNavaraSharpen } from './sharpen.js'; import { createNavaraSharpen } from './sharpen.js';
import { createNavaraMovers } from './movers/index.js'; import { createNavaraMovers } from './movers/index.js';
import { createNavaraLines } from './lines/index.js';
import { import {
NAVARA_CONTEXT_ATTRIBUTES, NAVARA_CONTEXT_ATTRIBUTES,
navaraScenePlan, navaraScenePlan,
@@ -117,6 +118,7 @@ export async function createNavaraGlobe({
...(appClock ? { appClock } : {}), ...(appClock ? { appClock } : {}),
sharpen: createNavaraSharpen(view), sharpen: createNavaraSharpen(view),
movers: createNavaraMovers(view), movers: createNavaraMovers(view),
lines: createNavaraLines(view),
}); });
const camera = engine.camera; const camera = engine.camera;
let ground = Number.NaN; let ground = Number.NaN;
+81
View File
@@ -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() };
}
+259
View File
@@ -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 <color_fragment>
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 <project_vertex>',
VERTEX_PROJECT,
);
shader.fragmentShader = replace(
replace(
shader.fragmentShader,
'void main() {',
`${FRAGMENT_HEAD}\nvoid main() {`,
),
'#include <color_fragment>',
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<import('../../lines.js').normalizeLineOptions>} 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();
},
};
}
+79
View File
@@ -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();
});
+110
View File
@@ -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', 'render: continuous mode toggles',
async () => { async () => {
+41
View File
@@ -26,6 +26,7 @@ import {
mergeSharpen, mergeSharpen,
} from '../contract.js'; } from '../contract.js';
import { createAppClock } from '../../time/clock.js'; import { createAppClock } from '../../time/clock.js';
import { createLineRegistry } from '../lines.js';
import { createMoverRegistry } from '../movers.js'; import { createMoverRegistry } from '../movers.js';
const { cartesian2, cartesian3, cartographic, ellipsoid, math, matrix4 } = geo; const { cartesian2, cartesian3, cartographic, ellipsoid, math, matrix4 } = geo;
@@ -261,6 +262,42 @@ export function createFakeEngine(options = {}) {
let wallMs = options.nowMs ?? FAKE_EPOCH_MS; let wallMs = options.nowMs ?? FAKE_EPOCH_MS;
const appClock = options.appClock ?? createAppClock({ now: () => wallMs }); const appClock = options.appClock ?? createAppClock({ now: () => wallMs });
const ownsWall = !options.appClock; 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. // Movers keep their store for tests; commit counts as a render request.
const moverRegistry = createMoverRegistry(() => { const moverRegistry = createMoverRegistry(() => {
const part = { const part = {
@@ -807,6 +844,7 @@ export function createFakeEngine(options = {}) {
horizonVisible, horizonVisible,
}, },
movers: moverRegistry.movers, movers: moverRegistry.movers,
lines: lineRegistry.lines,
clock: { clock: {
now: () => appClock.now(), now: () => appClock.now(),
frameTime: () => appClock.frameTime(), frameTime: () => appClock.frameTime(),
@@ -922,6 +960,7 @@ export function createFakeEngine(options = {}) {
record('destroy', []); record('destroy', []);
cancelCurrentFlight(); cancelCurrentFlight();
moverRegistry.destroyAll(); moverRegistry.destroyAll();
lineRegistry.destroyAll();
fake.destroyed = true; fake.destroyed = true;
}, },
isDestroyed: () => fake.destroyed, isDestroyed: () => fake.destroyed,
@@ -982,6 +1021,8 @@ export function createFakeEngine(options = {}) {
scripts.delete(name); scripts.delete(name);
}, },
lookAtTarget: () => (lookAtTarget ? copy3(lookAtTarget) : undefined), 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. */ /** A live mover set's options, store and commit count, by id. */
moverSet(id) { moverSet(id) {
const entry = moverRegistry.sets.get(id); const entry = moverRegistry.sets.get(id);
+23
View File
@@ -474,3 +474,26 @@ test('fake movers keep their sets and instances for layer tests', () => {
assert.deepEqual(engine.movers.list(), []); assert.deepEqual(engine.movers.list(), []);
assert.equal(engine.fake.moverSet('flights'), undefined); 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);
});