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/moverMesh.test.mjs",
|
||||
"src/globe/navara/movers/modelMover.test.mjs",
|
||||
"src/bench/syntheticFlights.test.mjs"
|
||||
"src/bench/syntheticFlights.test.mjs",
|
||||
"src/globe/lines.test.mjs",
|
||||
"src/globe/cesium/lines.test.mjs",
|
||||
"src/globe/navara/lines/lineMesh.test.mjs"
|
||||
]
|
||||
@@ -10,6 +10,7 @@ import { createCesiumClock } from './clock.js';
|
||||
import { createCesiumCredits } from './credits.js';
|
||||
import { createCesiumFrames } from './frames.js';
|
||||
import { createCesiumInput } from './input.js';
|
||||
import { createCesiumLines } from './lines.js';
|
||||
import { createCesiumMaps } from './maps.js';
|
||||
import { createCesiumMovers } from './movers.js';
|
||||
import { createCesiumPicker } from './picker.js';
|
||||
@@ -46,6 +47,10 @@ export function createCesiumEngine(viewer, options = {}) {
|
||||
projector: createCesiumProjector(viewer),
|
||||
frames: createCesiumFrames(viewer),
|
||||
clock: createCesiumClock(viewer, options),
|
||||
lines: createCesiumLines(viewer, {
|
||||
requestRender: (reason) => render.render.request(reason),
|
||||
...(options.lineFactories ? { factories: options.lineFactories } : {}),
|
||||
}),
|
||||
movers: createCesiumMovers(viewer, {
|
||||
requestRender: (reason) => render.render.request(reason),
|
||||
...(options.moverFactories ? { factories: options.moverFactories } : {}),
|
||||
|
||||
@@ -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 {() => { id: string, kind: string, count: number, capacity: number }[]} list
|
||||
*
|
||||
* @typedef {object} LineOptions
|
||||
* A dynamic polyline (src/globe/lines.js checks and defaults these).
|
||||
* @property {string} id Unique among the engine's live lines.
|
||||
* @property {number} widthPx Width in CSS pixels.
|
||||
* @property {number[]} color rgba in 0..1.
|
||||
* @property {number} [depthFailAlpha] 0..1: where the scene hides the line, a
|
||||
* copy drawn with its alpha scaled by this (Cesium's depthFailAppearance);
|
||||
* 0 (the default) draws nothing there.
|
||||
* @property {{ lengthPx: number, gapPx: number } | 'tactical'} [dash]
|
||||
* Dashes in pixels along the screen direction (Cesium's PolylineDash), or
|
||||
* the launches' selected-orbit pattern over the whole line.
|
||||
* @property {'none'|'geodesic'} [arc] 'geodesic' follows great circles
|
||||
* between points (Cesium's ArcType.GEODESIC); 'none' (default) draws
|
||||
* straight segments.
|
||||
* @property {Matrix4} [modelMatrix] Applied to the positions (an orbit ring
|
||||
* turned by GMST).
|
||||
* @property {any} [pickId] What a Cesium pick of the line returns (Navara
|
||||
* lines are not pickable yet).
|
||||
*
|
||||
* @typedef {object} Line
|
||||
* @property {(positions: Cartesian3[] | Float64Array) => void} setPositions
|
||||
* @property {(color: number[]) => void} setColor
|
||||
* @property {(matrix: Matrix4) => void} setModelMatrix
|
||||
* @property {(visible: boolean) => void} show
|
||||
* @property {() => void} destroy
|
||||
* Every call requests a render; nothing animates on its own.
|
||||
*
|
||||
* @typedef {object} GlobeLines
|
||||
* @property {(options: LineOptions) => Line} createLine
|
||||
* @property {() => { id: string, points: number, shown: boolean }[]} list
|
||||
*
|
||||
* @typedef {object} GlobeEngine
|
||||
* @property {'cesium'|'navara'} kind
|
||||
* @property {{ container: object, canvas: object, size: () => { width: number, height: number } }} host
|
||||
@@ -317,6 +348,7 @@
|
||||
* @property {GlobeMaps} maps
|
||||
* @property {GlobeClock} clock
|
||||
* @property {GlobeMovers} movers
|
||||
* @property {GlobeLines} lines
|
||||
* @property {() => void} destroy
|
||||
* @property {() => boolean} isDestroyed True once destroy() has run.
|
||||
*/
|
||||
@@ -497,6 +529,7 @@ const SHAPE = {
|
||||
credits: { addStatic: isFunction },
|
||||
clock: { now: isFunction, frameTime: isFunction, ticked: isEngineEvent },
|
||||
movers: { createSet: isFunction, list: isFunction },
|
||||
lines: { createLine: isFunction, list: isFunction },
|
||||
maps: {
|
||||
setBaseImagery: isFunction,
|
||||
setTerrain: isFunction,
|
||||
|
||||
@@ -343,3 +343,15 @@ test('the contract carries instanced movers', () => {
|
||||
/movers\.createSet.*movers\.list/,
|
||||
);
|
||||
});
|
||||
|
||||
test('the contract carries dynamic lines', () => {
|
||||
for (const member of ['lines.createLine', 'lines.list'])
|
||||
assert.ok(GLOBE_ENGINE_MEMBERS.includes(member), member);
|
||||
const engine = createFakeEngine();
|
||||
assert.equal(assertGlobeEngine(engine), engine);
|
||||
const broken = { ...engine, lines: {} };
|
||||
assert.throws(
|
||||
() => assertGlobeEngine(broken),
|
||||
/lines\.createLine.*lines\.list/,
|
||||
);
|
||||
});
|
||||
@@ -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: () => [],
|
||||
});
|
||||
const NO_LINES = Object.freeze({
|
||||
createLine() {
|
||||
throw new Error("Navara lines need the view's three runtime (lines/)");
|
||||
},
|
||||
list: () => [],
|
||||
});
|
||||
|
||||
// Navara's drawn camera (view.camera.raw, a three PerspectiveCamera in
|
||||
// Earth-fixed metres): matrixWorld columns are right, up, back, position.
|
||||
@@ -84,7 +90,8 @@ function rayRaisedEllipsoid({ origin, direction }, height) {
|
||||
* it the engine only keeps the state) and the engine destroys it;
|
||||
* `appClock` (the engine clock's time) defaults to the page's app clock;
|
||||
* `movers` is engine.movers on the view's three runtime (movers/index.js;
|
||||
* without it createSet throws) and the engine destroys it;
|
||||
* without it createSet throws), `lines` engine.lines (lines/index.js;
|
||||
* likewise), both destroyed with the engine;
|
||||
* `fetch` and `clock` (the camera model's timers) are test seams.
|
||||
* @returns {import('../contract.js').GlobeEngine}
|
||||
*/
|
||||
@@ -97,6 +104,7 @@ export function createNavaraEngine(
|
||||
clock,
|
||||
appClock,
|
||||
movers: moversPort = null,
|
||||
lines: linesPort = null,
|
||||
sharpen: sharpenPort = null,
|
||||
} = {},
|
||||
) {
|
||||
@@ -246,6 +254,7 @@ export function createNavaraEngine(
|
||||
maps: maps.maps,
|
||||
clock: engineClock.clock,
|
||||
movers: moversPort?.movers ?? NO_MOVERS,
|
||||
lines: linesPort?.lines ?? NO_LINES,
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
@@ -257,6 +266,7 @@ export function createNavaraEngine(
|
||||
maps.destroy();
|
||||
sharpenPort?.destroy();
|
||||
moversPort?.destroy();
|
||||
linesPort?.destroy();
|
||||
},
|
||||
isDestroyed: () => destroyed,
|
||||
// Navara only, not contract: the ground the floor last sampled under
|
||||
|
||||
@@ -10,6 +10,7 @@ import { forEachLuminanceUniform, navaraHooks } from './internals.js';
|
||||
import { buildNavaraLook } from './look.js';
|
||||
import { createNavaraSharpen } from './sharpen.js';
|
||||
import { createNavaraMovers } from './movers/index.js';
|
||||
import { createNavaraLines } from './lines/index.js';
|
||||
import {
|
||||
NAVARA_CONTEXT_ATTRIBUTES,
|
||||
navaraScenePlan,
|
||||
@@ -117,6 +118,7 @@ export async function createNavaraGlobe({
|
||||
...(appClock ? { appClock } : {}),
|
||||
sharpen: createNavaraSharpen(view),
|
||||
movers: createNavaraMovers(view),
|
||||
lines: createNavaraLines(view),
|
||||
});
|
||||
const camera = engine.camera;
|
||||
let ground = Number.NaN;
|
||||
|
||||
@@ -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',
|
||||
async () => {
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
mergeSharpen,
|
||||
} from '../contract.js';
|
||||
import { createAppClock } from '../../time/clock.js';
|
||||
import { createLineRegistry } from '../lines.js';
|
||||
import { createMoverRegistry } from '../movers.js';
|
||||
|
||||
const { cartesian2, cartesian3, cartographic, ellipsoid, math, matrix4 } = geo;
|
||||
@@ -261,6 +262,42 @@ export function createFakeEngine(options = {}) {
|
||||
let wallMs = options.nowMs ?? FAKE_EPOCH_MS;
|
||||
const appClock = options.appClock ?? createAppClock({ now: () => wallMs });
|
||||
const ownsWall = !options.appClock;
|
||||
// Lines keep what they were given; each change counts as a render request.
|
||||
const fakeLines = new Map();
|
||||
const lineRegistry = createLineRegistry((options) => {
|
||||
const state = {
|
||||
options,
|
||||
positions: new Float64Array(0),
|
||||
color: [...options.color],
|
||||
modelMatrix: options.modelMatrix,
|
||||
shown: true,
|
||||
};
|
||||
fakeLines.set(options.id, state);
|
||||
const changed = () => {
|
||||
fake.renderRequests++;
|
||||
};
|
||||
return {
|
||||
setPositions(flat) {
|
||||
state.positions = flat;
|
||||
changed();
|
||||
},
|
||||
setColor(rgba) {
|
||||
state.color = rgba;
|
||||
changed();
|
||||
},
|
||||
setModelMatrix(matrix) {
|
||||
state.modelMatrix = matrix;
|
||||
changed();
|
||||
},
|
||||
show(visible) {
|
||||
state.shown = visible;
|
||||
changed();
|
||||
},
|
||||
destroy() {
|
||||
fakeLines.delete(options.id);
|
||||
},
|
||||
};
|
||||
});
|
||||
// Movers keep their store for tests; commit counts as a render request.
|
||||
const moverRegistry = createMoverRegistry(() => {
|
||||
const part = {
|
||||
@@ -807,6 +844,7 @@ export function createFakeEngine(options = {}) {
|
||||
horizonVisible,
|
||||
},
|
||||
movers: moverRegistry.movers,
|
||||
lines: lineRegistry.lines,
|
||||
clock: {
|
||||
now: () => appClock.now(),
|
||||
frameTime: () => appClock.frameTime(),
|
||||
@@ -922,6 +960,7 @@ export function createFakeEngine(options = {}) {
|
||||
record('destroy', []);
|
||||
cancelCurrentFlight();
|
||||
moverRegistry.destroyAll();
|
||||
lineRegistry.destroyAll();
|
||||
fake.destroyed = true;
|
||||
},
|
||||
isDestroyed: () => fake.destroyed,
|
||||
@@ -982,6 +1021,8 @@ export function createFakeEngine(options = {}) {
|
||||
scripts.delete(name);
|
||||
},
|
||||
lookAtTarget: () => (lookAtTarget ? copy3(lookAtTarget) : undefined),
|
||||
/** A live line's options, positions, colour, matrix and visibility, by id. */
|
||||
line: (id) => fakeLines.get(id),
|
||||
/** A live mover set's options, store and commit count, by id. */
|
||||
moverSet(id) {
|
||||
const entry = moverRegistry.sets.get(id);
|
||||
|
||||
@@ -474,3 +474,26 @@ test('fake movers keep their sets and instances for layer tests', () => {
|
||||
assert.deepEqual(engine.movers.list(), []);
|
||||
assert.equal(engine.fake.moverSet('flights'), undefined);
|
||||
});
|
||||
|
||||
test('fake lines keep their positions, colour, matrix and visibility', () => {
|
||||
const engine = createFakeEngine();
|
||||
const line = engine.lines.createLine({
|
||||
id: 'trail',
|
||||
widthPx: 2,
|
||||
color: [1, 0, 0, 1],
|
||||
});
|
||||
line.setPositions([
|
||||
{ x: 1, y: 2, z: 3 },
|
||||
{ x: 4, y: 5, z: 6 },
|
||||
]);
|
||||
line.show(false);
|
||||
const stored = engine.fake.line('trail');
|
||||
assert.deepEqual([...stored.positions], [1, 2, 3, 4, 5, 6]);
|
||||
assert.deepEqual(stored.color, [1, 0, 0, 1]);
|
||||
assert.equal(stored.shown, false);
|
||||
assert.deepEqual(engine.lines.list(), [
|
||||
{ id: 'trail', points: 2, shown: false },
|
||||
]);
|
||||
line.destroy();
|
||||
assert.equal(engine.fake.line('trail'), undefined);
|
||||
});
|
||||
Reference in new issue
Block a user