mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
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:
1 parent
0109097440
commit
86aa0bc9ba
16 files changed
+1380
-2
No files matched your search
@@ -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"
|
||||||
]
|
]
|
||||||
@@ -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 } : {}),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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() };
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user