Draw Bhote Koshi and awareness pulses through engine.ground

Render the Bhote Koshi event and locator through one ground and point display.
Keep Cesium's reveal paths, dashed halo, glowing core, polygon classification,
pick identities and independent point outline opacity. Add static pixel dash
masks, synchronous ground reveals after terrain bounds load, and per-point
outline colour so the shared display preserves those behaviours.

Add a ground pulse field with checked state on all three engines. Keep Cesium's
raised material disc, band equations, front widths, wake and rest timing and
reduced-motion behaviour. Navara now follows Cesium's raised pulse surface and
story styling, with ground effects composed after tone mapping. Move the wind
mesh shader into the Navara engine without changing its transparent pass.

Layer renderer lines fall from 210 to 14 for Cesium and from 794 to 314 for
Navara. Viewer lines fall from 953 to 948, extension reads from 6 to 4, and
engine-name lines from 37 to 36. No coupling count grows.

Checked in the browser, keyless: the contract suite, with its new pulse
pixel checks, passes on Navara and on Cesium apart from the camera-events
check that also fails intermittently before; the Nepal locator and Contacts
engine switches pass. The Nepal event and wind engine switches fail the same
way before this change. Side by side at the locator's overview, Cesium draws
the same pixels as before and Navara's river corridor now glows as Cesium's
does, where it was faint.
This commit is contained in:
German Khokhlov committed 2026-11-07 15:06:26 +04:00
1 parent dacfd33f10
commit f385986383
50 files changed
+1438 -520

No files matched your search

+11 -13
View File
@@ -280,11 +280,14 @@ one application, and only the globe-bound parts move to the next engine.
S2 GhostMaps, cyclone advisories, submarine cables, Mapped Installations,
Global Context, datacenters and dams through their own `render/display.js`;
the fills of the ground layers, installation outlines and the selection fill
are `engine.ground` sets. Global Context's range pulses, an animated disc the
contract has no primitive for yet, draw behind one seam,
`src/layers/awareness/render/pulseDisc.js` (`render/cesiumPulses.js`, a
material disc, on Cesium; `render/navaraPulses.js`, a surface pass that the
layer manifest loads and passes in).
are `engine.ground` sets. Global Context's range pulses use
`engine.ground.createPulseField`: the same fronts, halos, inward bands,
wake/rest timing and raised ellipsoid on every engine. Cesium retains its
material disc; Navara's shader is owned by `src/globe/navara/ground` and
draws after tone mapping. Bhote Koshi's event and locator share
`src/layers/bhoteKoshi/render/story.js`, using ground polygons and corridors
with Cesium's dashed halo and glowing core, plus point movers with animated
outline opacity. Their authored property records remain in the story model.
The Active Fires far-zoom heatmap is an `engine.imagery` layer, and Recent
Imagery draws its days through `engine.imagery` from its own
`render/display.js`. CCTV's monitor plane and viewshed volume use
@@ -293,18 +296,13 @@ one application, and only the globe-bound parts move to the next engine.
movers, lines and world meshes retain authored heights, polygon holes,
images and picking across scene detach and reattach. Annotation drapes
remain on their existing renderers pending bounded classification support.
Navara ground effects (heat, pulses, trails, pads, drapes) are declared
with `defineGroundEffect` (`src/layers/shared/render/navaraGroundEffect.js`);
their insert order is pinned by `navaraGroundOrder.test.mjs`.
- **One layer manifest.** `LAYER_MANIFEST` in `src/app/layers/renderers.js`
says how each directory layer draws: its display with the contract
primitives it needs (and an engine-specific backend option keyed by engine
kind: Global Context's pulses on Navara), a layer that
kind: wind streamlines), a layer that
draws itself through the contract (Wildlife Sightings), or, without a
display, its drawing per engine kind (wind, the weather rasters and the
Bhote Koshi event and locator draw themselves on Cesium and through a
Navara renderer on Navara; Sea Marks and Ski Pistes drape themselves on
both). A layer is available on an engine when that engine has the
display, its drawing per engine kind. Weather, wind imagery and Bhote Koshi
use shared displays; Sea Marks and Ski Pistes drape themselves on both. A layer is available on an engine when that engine has the
primitives it needs, or, without a display, when its entry lists that
kind; today every layer shows on both. The manifest's Navara backends
and renderers load through `import()` (`loadLayerRenderers(kind)`, awaited
+48 -5
View File
@@ -31,6 +31,9 @@ There are three kinds:
Use movers for aircraft, vessels, satellites, cameras, stations: anything
that has a position and is drawn the same size on screen (or as a model).
Point instances may also set `outlineColor` independently of the fill,
so an expanding callout can fade its outline without changing its width.
Instances move every frame without a rebuild. `depthTest` decides whether
the scene hides them. A pick returns the instance's `pickId` when the set is
`pickable`.
@@ -112,6 +115,46 @@ commit never rebuilds them:
Traffic's jam heat lines glow (power 0.25) and pulse (alpha 0.35 to 0.75,
1.63 s). The Directions route's 64 dashes flow at 0.55 cells a second.
A pixel corridor may instead use `dashPattern: { lengthPx, pattern: 255 }`,
a static 16-bit screen-space dash mask with transparent gaps, as Cesium's
PolylineDash material. It cannot combine with the other corridor looks.
Bhote Koshi uses an 18 px dash behind its 5 px glowing flood core.
Use `dynamic: true` on a set whose path changes each frame. Cesium waits
for its shared terrain-height bounds, then builds these small reveals
synchronously so successive frames do not discard unfinished worker jobs.
Other sets retain asynchronous building.
### Radial pulse fields
`engine.ground.createPulseField({ id, center, radiusM, heightM, color,
lineColor, haloColor, depth, lineWidth, haloWidth })` draws up to four
concentric pulse fronts. The centre is Earth-fixed metres; the radius is
metres along the surface. The disc lies on the ellipsoid raised by
`heightM` (default 0), with normal scene depth testing. Colours are rgba in
0..1, widths are CSS pixels, and `depth` is the inward band depth as a
fraction of the radius. A band's opacity falls quadratically to zero.
Call `draw({ radii, alphas, level })` with up to four radius fractions and
opacities in 0..1. Missing fronts are zero. `level` controls visibility;
the caller includes it in the alphas. The field starts hidden. `move(center)`
follows a new centre; `destroy()` releases it and is safe to repeat.
`ground.list()` includes it as `kind: 'pulse-field'`. The caller owns its
clock and render requests; the field starts no animation loop.
Global Context keeps its 2,500 m pulse height, fixed-width front and halo,
wake and rest windows, reduced-motion behaviour and 80 ms frame cadence.
Cesium retains one material disc, updating its uniforms and rotating for
small moves. Navara evaluates the same bands after tone mapping against a
raised ellipsoid and scene depth; its shader lives in
`src/globe/navara/ground/pulse.js`. The fake engine records the same state.
Bhote Koshi's event and locator share `src/layers/bhoteKoshi/render/story.js`:
the authored reveal properties feed ground corridors and polygons, and
point movers retain their independent fill and outline opacity. No story
backend is selected by engine. The authored model still uses its existing
property records; removing that model dependency is separate work.
### How each engine draws the ground
**Cesium** (`src/globe/cesium/ground.js`) builds at most two batches per set:
@@ -451,8 +494,8 @@ near the camera from one 8×8 atlas over the whole view.
- **The Bhote Koshi event imagery** (`src/layers/bhoteKoshi/render/display.js`):
two `url` images over the event's rectangle at alpha 0.96, split left
and right at the engine's split position, order 15. The event's and the
locator's story entities still draw on Cesium itself and through
Navara's story backend (`src/layers/shared/render/navaraStory.js`).
locator's story graphics use the shared ground and mover display described
above.
- **Imported KML/KMZ ground images** (`src/data/geoImportDisplay.js`): one
image layer per rectangle at order 30, with the authored alpha capped at
@@ -518,9 +561,9 @@ sharpening on both engines, following Cesium.
- a line on the ground: `corridor`, with `widthM` when its width is a
real width (an outline in metres) and `widthPx` otherwise.
- Group items that change together into one set, and replace them in bulk.
A ground set is rebuilt on every commit, so a fill that changes over time
is not a ground set, except a pixel corridor's glow, pulse and dash, which
the engine animates.
A ground set rebuilds on commit. Small paths revealed each frame use
`dynamic: true`; corridor looks animate without a rebuild, and radial
fronts use `ground.createPulseField`.
- **Pixels on the ground** (tiles, a raster, a photo): an imagery layer.
Fade it with `setAlpha`, and give a new raster to `setSource`.
+6 -6
View File
@@ -1,16 +1,16 @@
{
"metrics": {
"cesiumLayerRendererLines": 210,
"navaraLayerRendererLines": 794,
"cesiumLayerRendererLines": 14,
"navaraLayerRendererLines": 314,
"cesiumImportFiles": 5,
"viewerLines": 953,
"viewerLines": 948,
"navaraKindChecks": 2,
"engineSniffCalls": 0,
"engineExtensionReads": 6,
"engineExtensionReads": 4,
"groundEffectPasses": 1,
"cesiumSdkLoads": 3,
"screenSpaceHandlerFiles": 4,
"engineNameLines": 37
"engineNameLines": 36
},
"measuredAt": "Move shared follow offsets and Draw input and previews onto the globe contract"
"measuredAt": "Draw Bhote Koshi story graphics and range pulse fields through engine.ground"
}
+11 -4
View File
@@ -1222,7 +1222,7 @@
"src/layers/vessels/renderer.test.mjs",
"src/layers/vessels/testFixtures.mjs",
"src/layers/shared/render/engineView.js",
"src/layers/shared/render/navaraGroundEffect.js",
"src/globe/navara/ground/effect.js",
"src/layers/shared/render/navaraMoverBank.js",
"src/layers/shared/render/moverBank.js",
"src/layers/shared/render/imageAtlas.js",
@@ -1610,10 +1610,9 @@
"src/layers/submarineCables/render/display.js",
"src/layers/submarineCables/render/display.test.mjs",
"src/testSupport/groundDisplayEngines.mjs",
"src/layers/awareness/render/cesiumPulses.js",
"src/globe/cesium/groundPulse.js",
"src/layers/awareness/render/display.js",
"src/layers/awareness/render/display.test.mjs",
"src/layers/awareness/render/pulseDisc.js",
"src/layers/infrastructure/render/display.js",
"src/layers/infrastructure/render/display.test.mjs",
"src/layers/installations/render/display.js",
@@ -1685,5 +1684,13 @@
"src/globe/cesium/followOffset.js",
"src/annotations/drawSurface.js",
"src/annotations/drawSurface.test.mjs",
"src/annotations/drawToolBehaviour.test.mjs"
"src/annotations/drawToolBehaviour.test.mjs",
"src/globe/navara/ground/pulse.js",
"src/globe/navara/ground/surface.js",
"src/layers/bhoteKoshi/render/story.js",
"src/globe/groundPulse.js",
"src/globe/groundPulse.test.mjs",
"src/globe/navara/ground/pulse.test.mjs",
"src/layers/bhoteKoshi/render/story.test.mjs",
"src/globe/navara/windFlowMesh.js"
]
+14 -14
View File
@@ -3457,10 +3457,10 @@
"src/globe/cesium/celestial.js",
"src/celestialRing/view.js",
"src/layers/shared/render/navaraPolygons.js",
"src/layers/awareness/render/navaraPulses.js",
"src/globe/navara/ground/pulse.js",
"src/globe/navara/sceneDepth.js",
"src/layers/shared/render/navaraStory.js",
"src/layers/wind/render/flowMesh.js",
"src/layers/bhoteKoshi/render/story.js",
"src/globe/navara/windFlowMesh.js",
"src/layers/shared/render/navaraGroundPicks.js",
"src/layers/shared/render/groundRing.js",
"src/layers/bikeshare/stationPoints.js",
@@ -3470,8 +3470,8 @@
"src/layers/shared/render/moverBank.js",
"src/layers/radio/screenClusters.js",
"src/layers/shared/render/imageAtlas.js",
"src/layers/shared/render/navaraGroundSurface.js",
"src/layers/shared/render/navaraGroundEffect.js",
"src/globe/navara/ground/surface.js",
"src/globe/navara/ground/effect.js",
"src/annotations/outlineRungs.js",
"src/sources/openFreeMapOutlines.js",
"src/sources/nominatimOutlines.js",
@@ -3532,6 +3532,7 @@
"src/layers/cctv/render/style.js",
"src/layers/radio/render/display.js",
"src/globe/ground.js",
"src/globe/groundPulse.js",
"src/globe/cesium/ground.js",
"src/globe/navara/ground/pass.js",
"src/globe/navara/ground/pack.js",
@@ -3551,8 +3552,7 @@
"src/layers/gnss/render/display.js",
"src/layers/firms/render/display.js",
"src/layers/installations/render/style.js",
"src/layers/awareness/render/cesiumPulses.js",
"src/layers/awareness/render/pulseDisc.js",
"src/globe/cesium/groundPulse.js",
"src/layers/awareness/render/display.js",
"src/layers/installations/render/namedMarkers.js",
"src/layers/installations/render/display.js",
@@ -4305,12 +4305,12 @@
"src/layers/perimeters/render/display.js",
"src/layers/droneAirspace/render/display.js",
"src/globe/ground.js",
"src/globe/groundPulse.js",
"src/layers/ghostMaps/render/display.js",
"src/layers/firms/render/display.js",
"src/layers/infrastructure/render/display.js",
"src/layers/installations/render/style.js",
"src/layers/awareness/render/cesiumPulses.js",
"src/layers/awareness/render/pulseDisc.js",
"src/globe/cesium/groundPulse.js",
"src/layers/awareness/render/display.js",
"src/layers/installations/render/namedMarkers.js",
"src/layers/installations/render/display.js",
@@ -4323,11 +4323,11 @@
"src/layers/wind/render/display.js",
"src/layers/wind/render/navaraFlow.js",
"src/layers/bhoteKoshi/render/display.js",
"src/layers/shared/render/navaraStory.js",
"src/layers/shared/render/navaraGroundSurface.js",
"src/layers/wind/render/flowMesh.js",
"src/layers/shared/render/navaraGroundEffect.js",
"src/layers/awareness/render/navaraPulses.js",
"src/layers/bhoteKoshi/render/story.js",
"src/globe/navara/ground/surface.js",
"src/globe/navara/windFlowMesh.js",
"src/globe/navara/ground/effect.js",
"src/globe/navara/ground/pulse.js",
"src/layers/wind/render/cesiumFlow.js",
"src/globe/cesium/postStages.js",
"src/globe/cesium/sonar.js",
+1 -2
View File
@@ -19,8 +19,7 @@ const PALETTE = {
/**
* A map colour (0xRRGGBB) on `view`: the planet's photographic exposure is
* compensated before its tone-mapping pass, as for the story overlays
* (src/layers/shared/render/navaraStory.js), so the palette stays readable.
* compensated before its tone-mapping pass so the palette stays readable.
*/
function displayColor({ view, NavaraColor }, hex) {
const c = new NavaraColor().setHex(hex);
+17 -33
View File
@@ -119,28 +119,20 @@ const perEngine = (engines) =>
/** Navara's own view and classes, for the renderers that still use them. */
const navaraNative = (engine) => engine.extensions.navara;
// Navara's backends of the Bhote Koshi and wind displays, loaded only
// here, through import(), when a page loads Navara's renderers: the story
// entities sampled into Navara's view (Cesium draws them itself) and the
// wind's flow mesh.
const navaraStory = async () => {
const { drawStorySource } =
await import('../../layers/shared/render/navaraStory.js');
return {
drawStory: (engine, data) =>
drawStorySource(engine, navaraNative(engine), data),
};
};
// Wind streamlines retain their engine backends.
const cesiumWindFlow = async () => {
const { createWindCesiumFlow } =
await import('../../layers/wind/render/cesiumFlow.js');
return { flow: createWindCesiumFlow };
};
const navaraWindFlow = async () => {
const { createWindNavaraFlow } =
await import('../../layers/wind/render/navaraFlow.js');
const [{ createWindNavaraFlow }, { createWindFlowMesh }] = await Promise.all([
import('../../layers/wind/render/navaraFlow.js'),
import('../../globe/navara/windFlowMesh.js'),
]);
return {
flow: (engine) => createWindNavaraFlow(engine, navaraNative(engine)),
flow: (engine) =>
createWindNavaraFlow(engine, navaraNative(engine), createWindFlowMesh),
};
};
@@ -149,15 +141,15 @@ const displays = () => import('./displays.js');
/** How every application layer draws, by id, in directory order. */
export const LAYER_MANIFEST = Object.freeze({
// The Bhote Koshi event's imagery on engine.imagery; the event's and the
// locator's story entities draw on Cesium itself, or through Navara's
// story backend.
'bhote-koshi-2026': display('createBhoteKoshiDisplay', ['imagery'], {
navara: navaraStory,
}),
'bhote-koshi-locator': display('createBhoteKoshiLocatorDisplay', ['movers'], {
navara: navaraStory,
}),
'bhote-koshi-2026': display('createBhoteKoshiDisplay', [
'imagery',
'ground',
'movers',
]),
'bhote-koshi-locator': display('createBhoteKoshiLocatorDisplay', [
'ground',
'movers',
]),
// The aircraft family.
flights: display('createFlightsDisplay', ['movers', 'lines']),
military: display('createMilitaryDisplay', ['movers', 'lines']),
@@ -190,15 +182,7 @@ export const LAYER_MANIFEST = Object.freeze({
'movers',
'ground',
]),
// Global Context's display, with the Navara backend of its range pulses
// (render/pulseDisc.js).
'military-awareness': display('createAwarenessDisplay', ['lines'], {
navara: async () => ({
navaraPulses: (
await import('../../layers/awareness/render/navaraPulses.js')
).createAwarenessPulseDisc,
}),
}),
'military-awareness': display('createAwarenessDisplay', ['lines', 'ground']),
// The wind field and the weather rasters on engine.imagery; the wind's
// streamlines are each engine's backend (Cesium's wind renderer, Navara's
// flow mesh).
+1 -1
View File
@@ -91,7 +91,7 @@ test('the layers drawn through one display, and the ones that draw without one',
// Engine-specific options of a display: the Navara backends.
assert.deepEqual(
kinds((entry) => Object.keys(entry.backends ?? {}).length),
['bhote-koshi-2026', 'bhote-koshi-locator', 'military-awareness', 'wind'],
['wind'],
);
assert.deepEqual(Object.keys(LAYER_MANIFEST.cctv.backends), []);
assert.ok(LAYER_MANIFEST.cctv.needs.includes('meshes'));
+3 -2
View File
@@ -1046,8 +1046,9 @@ export function createBhoteKoshiLocatorLayer({
function connectDisplay() {
if (!_dataSource) return;
if (_viewer) _viewer.dataSources.add(_dataSource);
else if (_engine) _native = createNativeRenderer(_engine, _dataSource);
if (_engine && createNativeRenderer)
_native = createNativeRenderer(_engine, _dataSource);
else if (_viewer) _viewer.dataSources.add(_dataSource);
registerDynamicCredit(_engine, BHOTE_KOSHI_LOCATOR_CREDIT);
showOsmCredit(_engine, BHOTE_KOSHI_LOCATOR_LAYER_ID);
}
+58 -11
View File
@@ -18,6 +18,7 @@
// under it); identify() names a picked item.
import * as Cesium from 'cesium';
import { createCesiumPulseField } from './groundPulse.js';
import {
areaRings,
createGroundRegistry,
@@ -119,14 +120,21 @@ export function corridorUniforms(item) {
}
const corridorMaterial = (item) =>
new Cesium.Material({
fabric: {
type: CORRIDOR_MATERIAL,
uniforms: corridorUniforms(item),
source: CORRIDOR_SOURCE,
},
translucent: true,
});
item.animation.pattern
? Cesium.Material.fromType('PolylineDash', {
color: colorOf(item.fill),
gapColor: Cesium.Color.TRANSPARENT,
dashLength: item.animation.pattern.lengthPx,
dashPattern: item.animation.pattern.pattern,
})
: new Cesium.Material({
fabric: {
type: CORRIDOR_MATERIAL,
uniforms: corridorUniforms(item),
source: CORRIDOR_SOURCE,
},
translucent: true,
});
// Items of one look and colour share a primitive.
const lookKey = (item) =>
@@ -270,6 +278,10 @@ export function createCesiumGround(
) {
const make = { ...DEFAULT_FACTORIES, ...factories };
const { scene } = viewer;
let heightsReady =
make.lines !== DEFAULT_FACTORIES.lines &&
make.fills !== DEFAULT_FACTORIES.fills;
let heightsPromise = null;
let root = null;
const ordered = [];
const building = new Set();
@@ -322,7 +334,7 @@ export function createCesiumGround(
}
return root;
};
const registry = createGroundRegistry((options) => {
const build = (options) => {
const slot = new Cesium.PrimitiveCollection({ destroyPrimitives: true });
const at = ordered.filter((entry) => entry.order <= options.order).length;
ordered.splice(at, 0, { order: options.order, slot });
@@ -331,14 +343,37 @@ export function createCesiumGround(
let looks = [];
const classificationType = classificationOf(options.drape);
const allowPicking = options.pickable;
let pending = null;
let waiting = false;
let destroyed = false;
const forget = () => {
for (const primitive of drawn) building.delete(primitive);
for (const material of looks) materials.delete(material);
drawn = [];
looks = [];
};
return {
const part = {
commit(items) {
pending = items;
if (options.dynamic && !heightsReady) {
if (!waiting) {
waiting = true;
heightsPromise ??=
Cesium.GroundPrimitive.initializeTerrainHeights();
heightsPromise.then(
() => {
heightsReady = true;
waiting = false;
if (!destroyed) part.commit(pending);
},
() => {
heightsPromise = null;
waiting = false;
},
);
}
return;
}
forget();
slot.removeAll();
const { fills, lines, animated } = groundInstances(options, items);
@@ -352,6 +387,7 @@ export function createCesiumGround(
}),
classificationType,
allowPicking,
asynchronous: !options.dynamic,
}),
);
if (lines.length)
@@ -361,6 +397,7 @@ export function createCesiumGround(
appearance: new Cesium.PolylineColorAppearance(),
classificationType,
allowPicking,
asynchronous: !options.dynamic,
}),
);
for (const { item, instances } of animated) {
@@ -374,6 +411,7 @@ export function createCesiumGround(
appearance: new Cesium.PolylineMaterialAppearance({ material }),
classificationType,
allowPicking,
asynchronous: !options.dynamic,
}),
);
}
@@ -391,8 +429,13 @@ export function createCesiumGround(
animate();
requestRender('ground');
},
ready: () => drawn.every((primitive) => primitive.ready === true),
ready: () =>
(!options.dynamic || heightsReady) &&
!waiting &&
drawn.every((primitive) => primitive.ready === true),
destroy() {
destroyed = true;
pending = null;
forget();
animate();
ordered.splice(
@@ -403,6 +446,10 @@ export function createCesiumGround(
requestRender('ground');
},
};
return part;
};
const registry = createGroundRegistry(build, {
pulseField: (options) => createCesiumPulseField(viewer, options),
});
return {
ground: registry.ground,
+70
View File
@@ -19,6 +19,76 @@ for (const name of [
const items = (kind, list) =>
list.map((item, index) => normalizeGroundItem(kind, item, index));
test('dynamic reveals wait for height bounds, draw the latest path and cancel on release', async (t) => {
const viewer = createFakeViewer();
viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
let resolve;
let loads = 0;
t.mock.method(Cesium.GroundPrimitive, 'initializeTerrainHeights', () => {
loads++;
return new Promise((done) => {
resolve = done;
});
});
const part = createCesiumGround(viewer, {
factories: {
lines: (options) => ({
...options,
ready: false,
destroy() {},
isDestroyed: () => false,
}),
},
});
t.after(() => part.destroy());
const reveal = part.ground.createSet({
id: 'reveal',
kind: 'corridor',
dynamic: true,
});
const cancelled = part.ground.createSet({
id: 'cancelled',
kind: 'corridor',
dynamic: true,
});
const path = (lon) => [
{
positions: [
[0, 0],
[lon, 1],
],
color: [0, 1, 1, 1],
widthPx: 3,
},
];
reveal.replace(path(1));
reveal.commit();
cancelled.replace(path(1));
cancelled.commit();
assert.equal(loads, 1, 'sets share the pending height bounds');
assert.equal(reveal.ready(), false);
reveal.replace(path(2));
reveal.commit();
cancelled.destroy();
resolve();
await Promise.resolve();
const root = viewer.scene.groundPrimitives.get(0);
assert.equal(
root.length,
1,
'released set does not return when bounds arrive',
);
const primitive = root.get(0).get(0);
assert.equal(primitive.asynchronous, false);
const end = primitive.geometryInstances[0].geometry._positions.at(-1);
assert.ok(
Math.abs(
Cesium.Math.toDegrees(Cesium.Cartographic.fromCartesian(end).longitude) -
2,
) < 1e-9,
);
});
test('fills batch into GroundPrimitive instances, outlines into ground polylines', () => {
const options = { id: 'zones', kind: 'polygon' };
const { fills, lines } = groundInstances(
@@ -1,14 +1,8 @@
// The range pulses' Cesium backend (render/pulseDisc.js): one flat,
// translucent, unpickable disc the size of the range, CONTEXT_RIM_HEIGHT_M
// above the ellipsoid, whose material draws each pulse line and the band
// behind it. Moving pulses only change its uniforms. The disc follows the
// subject by a rotation about the Earth's centre and is rebuilt only past
// `gradientRebuildM` from where it was built.
// A ground pulse field: one unpickable ellipse with uniform-only pulse updates.
import * as Cesium from 'cesium';
import { AWARENESS_RANGE_PULSE, CONTEXT_RIM_HEIGHT_M } from '../policy.js';
/** The pulse disc's Cesium material type, one cached template per page. */
const MATERIAL_TYPE = 'GevAwarenessPulseGradient';
const MATERIAL_TYPE = 'GevGroundPulseField';
// Ellipse texture coordinates put the centre at (0.5, 0.5) and the rim at
// distance 0.5, so `r` is the fraction of the range. Each pulse is a line
@@ -96,17 +90,15 @@ export function discFollowMatrix(from, to, result = new Cesium.Matrix4()) {
);
}
function createDisc(center, radiusM, material) {
function createDisc(center, radiusM, heightM, material) {
return new Cesium.Primitive({
geometryInstances: new Cesium.GeometryInstance({
geometry: new Cesium.EllipseGeometry({
center,
semiMajorAxis: radiusM,
semiMinorAxis: radiusM,
height: CONTEXT_RIM_HEIGHT_M,
granularity: Cesium.Math.toRadians(
AWARENESS_RANGE_PULSE.gradientGranularityDeg,
),
height: heightM,
granularity: Cesium.Math.toRadians(0.25),
vertexFormat:
Cesium.MaterialAppearance.MaterialSupport.TEXTURED.vertexFormat,
}),
@@ -126,11 +118,20 @@ function createDisc(center, radiusM, material) {
* The pulse disc on a Cesium viewer, or null when its scene has no
* primitive collection: `draw({ radii, alphas, level })`, `move(center)`,
* `destroy()`. Colours are rgba arrays in 0..1.
* @param {{ viewer: object }} cesium The engine's Cesium extension.
*/
export function createAwarenessCesiumPulses(
{ viewer },
{ center, radiusM, color, lineColor, haloColor, depth, lineWidth, haloWidth },
export function createCesiumPulseField(
viewer,
{
center,
radiusM,
heightM,
color,
lineColor,
haloColor,
depth,
lineWidth,
haloWidth,
},
) {
const primitives = viewer?.scene?.primitives;
if (!primitives?.add) return null;
@@ -154,7 +155,7 @@ export function createAwarenessCesiumPulses(
});
Object.assign(material.uniforms, uniforms);
const built = Cesium.Cartesian3.clone(center);
let disc = primitives.add(createDisc(built, radiusM, material));
let disc = primitives.add(createDisc(built, radiusM, heightM, material));
let destroyed = false;
return {
draw({ radii, alphas, level }) {
@@ -164,23 +165,20 @@ export function createAwarenessCesiumPulses(
Cesium.Cartesian4.fromArray(alphas, 0, material.uniforms.alphas);
},
/**
* Centres the disc on `next`: within `gradientRebuildM` of where it was
* Centres the disc on `next`: within 20 km of where it was
* built by its model matrix (the ellipsoid curves a little differently
* from that sphere, tens of metres in height at 20 km), further away by
* a rebuild, which costs a few milliseconds.
*/
move(next) {
if (destroyed) return;
if (
Cesium.Cartesian3.distance(built, next) <
AWARENESS_RANGE_PULSE.gradientRebuildM
) {
if (Cesium.Cartesian3.distance(built, next) < 20000) {
disc.modelMatrix = discFollowMatrix(built, next, disc.modelMatrix);
return;
}
const previous = disc;
Cesium.Cartesian3.clone(next, built);
disc = primitives.add(createDisc(built, radiusM, material));
disc = primitives.add(createDisc(built, radiusM, heightM, material));
disc.show = previous.show;
// Primitive.destroy leaves the material alone, so the new disc keeps
// sharing it.
+4
View File
@@ -205,6 +205,10 @@ function pointPart(options, store, { factories, requestRender, owners }) {
}
item.position = readPosition(store, index);
item.pixelSize = point.sizePx * store.scales[index];
item.outlineColor = Cesium.Color.unpack(
store.outlineColors,
index * 4,
);
item.color = readColor(store, index);
item.show = store.shows[index] === 1;
item.id = store.pickIds[index];
+18
View File
@@ -227,6 +227,24 @@ test('points take the size, scale, outline, translucency and depth test of the s
assert.equal(point.disableDepthTestDistance, 5000);
assert.equal(point.id, 25544);
assert.ok(Cesium.Color.equals(point.color, new Cesium.Color(0, 1, 0, 1)));
set.set(0, { outlineColor: [0.5, 0, 1, 0.25] });
set.commit();
assert.deepEqual(
Cesium.Color.pack(point.outlineColor, []),
[0.5, 0, 1, 0.25],
);
assert.ok(Cesium.Color.equals(point.color, new Cesium.Color(0, 1, 0, 1)));
assert.equal(point.outlineWidth, 1);
assert.throws(
() => set.set(0, { scale: 9, outlineColor: [1, 0, 0, 2] }),
TypeError,
);
set.commit();
assert.equal(
point.pixelSize,
6,
'an invalid outline update leaves the point unchanged',
);
// 'normal' keeps Cesium's depth test.
const normal = movers.createSet({
id: 'normal',
+41 -2
View File
@@ -437,10 +437,12 @@
* Models: the rotation of the glTF's own axes (as the file stores them,
* +Y up) into Earth-fixed axes; no engine adds an axis correction.
* @property {number[]} [color] rgba in 0..1; multiplies the sprite's texels.
* @property {number[]} [outlineColor] Points: rgba in 0..1, initially the set
* outlineColor. Updates the outline independently of the fill.
* @property {number} [scale]
* @property {string} [cell] An atlas cell key.
* @property {boolean} [show]
* @property {string|number} [pickId] What a pick of this instance returns.
* @property {any} [pickId] Opaque caller-owned identity returned by a pick.
*
* @typedef {object} MoverSet
* @property {(index: number, fields: MoverFields) => void} set Stored only;
@@ -502,6 +504,9 @@
* set (GroundItem).
* @property {number} [order] Draw order among ground sets (higher draws
* later, on top); 0 by default. Within a set a later item draws on top.
* @property {boolean} [dynamic] Changing paths: build synchronously once
* terrain-height bounds are loaded, so a reveal does not replace worker
* geometry faster than it can become ready. False by default.
* @property {boolean} [pickable] Whether picks return the set's items (those
* with a pickId); false by default: picks pass through to what lies under.
* @property {'both'|'terrain'|'tiles'} [drape] What the fills drape on:
@@ -532,6 +537,9 @@
* alpha times `gapAlpha`; a lit dash brightened by `emission` times its
* colour). A shown set with a pulse or a dash keeps the engine drawing
* a frame after every frame, as Cesium's continuous render did.
* A corridor may instead take `dashPattern` ({ lengthPx, pattern = 255 }):
* a static 16-bit mask repeated every lengthPx CSS pixels, with clear
* gaps, as PolylineDash. It cannot combine with the other looks.
* Polygons, discs and cells take `fill` (omitted: none) and `outline`
* ({ color, widthPx }, a ground line on the boundary of every ring;
* omitted: none). Every kind takes `pickId`: what a pick of the item
@@ -551,8 +559,35 @@
* builds ground geometry in workers, a few frames later).
* @property {() => void} destroy
*
* @typedef {object} GroundPulseOptions
* An unpickable radial field on the ellipsoid, raised by heightM. Up to
* four fronts have a CSS-pixel line and halo with a quadratic band fading
* inward. The caller owns timing and frame requests; updates do not
* rebuild geometry or start a render loop.
* @property {string} id Unique among ground sets and pulse fields.
* @property {Cartesian3} center Earth-fixed metres on the ellipsoid.
* @property {number} radiusM Radius along the surface, metres.
* @property {number} [heightM] Height above the ellipsoid, default 0.
* @property {number[]} color Band rgba in 0..1.
* @property {number[]} lineColor Front rgba in 0..1.
* @property {number[]} haloColor Halo rgba in 0..1.
* @property {number} depth Band depth as a fraction of radiusM, in (0, 1].
* @property {number} lineWidth Front width including its halo, CSS pixels.
* @property {number} haloWidth Halo width, half on each side, CSS pixels.
*
* @typedef {object} GroundPulseField
* @property {string} id
* @property {(state: { radii: number[], alphas: number[], level: number }) => void} draw
* Copies up to four front radii (fractions of radiusM) and opacities in
* 0..1; absent slots are zero. Level in 0..1 controls visibility (zero
* hides); alphas already include the level. Initially hidden.
* @property {(center: Cartesian3) => void} move
* @property {() => void} destroy Idempotent; later draw/move calls do nothing.
* @property {() => boolean} isDestroyed
*
* @typedef {object} GlobeGround
* @property {(options: GroundSetOptions) => GroundSet} createSet
* @property {(options: GroundPulseOptions) => GroundPulseField} createPulseField
* @property {() => { id: string, kind: string, count: number, shown: boolean, order: number, animated: boolean }[]} list
* `animated`: the last commit has a pulse or a dash.
*
@@ -914,7 +949,11 @@ const SHAPE = {
movers: { createSet: isFunction, list: isFunction },
lines: { createLine: isFunction, list: isFunction },
meshes: { createMesh: isFunction, list: isFunction },
ground: { createSet: isFunction, list: isFunction },
ground: {
createSet: isFunction,
createPulseField: isFunction,
list: isFunction,
},
imagery: {
createLayer: isFunction,
list: isFunction,
+4 -1
View File
@@ -325,7 +325,10 @@ test('ground is required and reported as a capability', () => {
assert.throws(() => assertGlobeEngine(without), /\bground\b/);
assert.equal(engineCapabilities(without).ground, false);
const half = { ...engine, ground: { createSet: () => {} } };
assert.throws(() => assertGlobeEngine(half), /ground\.list/);
assert.throws(
() => assertGlobeEngine(half),
/ground\.(?:list|createPulseField)/,
);
});
test('imagery is required and reported as a capability', () => {
+48 -13
View File
@@ -7,6 +7,7 @@
// as GeoJSON stores them; colours rgba in 0..1. No engine imports.
import * as geo from '../geo/index.js';
import { createGroundPulseRegistry } from './groundPulse.js';
export const GROUND_KINDS = Object.freeze([
'polygon',
@@ -44,10 +45,19 @@ export function normalizeGroundSetOptions(options) {
if (!isFiniteNumber(order)) fail(`order must be a number: ${order}`);
if (options.pickable !== undefined && typeof options.pickable !== 'boolean')
fail('pickable must be a boolean');
if (options.dynamic !== undefined && typeof options.dynamic !== 'boolean')
fail('dynamic must be a boolean');
const drape = options.drape ?? 'both';
if (!GROUND_DRAPES.includes(drape))
fail(`drape must be one of ${GROUND_DRAPES.join(', ')}: ${drape}`);
return { id, kind, order, pickable: options.pickable === true, drape };
return {
id,
kind,
order,
pickable: options.pickable === true,
drape,
...(options.dynamic ? { dynamic: true } : {}),
};
}
function lonLat(value, label) {
@@ -234,8 +244,27 @@ const unit = (value) => isFiniteNumber(value) && value >= 0 && value <= 1;
// alpha going up and down, `dash` the annotation route's flowing dashes.
// Null without any.
function animationOf(item, index) {
const { glow, pulse, dash } = item;
if (glow == null && pulse == null && dash == null) return null;
const { glow, pulse, dash, dashPattern } = item;
if (glow == null && pulse == null && dash == null && dashPattern == null)
return null;
if (dashPattern != null) {
if (
!isObject(dashPattern) ||
!isFiniteNumber(dashPattern.lengthPx) ||
dashPattern.lengthPx <= 0
)
fail(`item ${index} dashPattern.lengthPx must be positive`);
const pattern = dashPattern.pattern ?? 255;
if (!Number.isInteger(pattern) || pattern < 0 || pattern > 65535)
fail(`item ${index} dashPattern.pattern must be a 16-bit integer`);
if (glow != null || pulse != null || dash != null)
fail(
`item ${index} dashPattern cannot combine with other corridor looks`,
);
return Object.freeze({
pattern: Object.freeze({ lengthPx: dashPattern.lengthPx, pattern }),
});
}
const label = `item ${index}`;
let glowOut = null;
if (glow != null) {
@@ -377,12 +406,14 @@ export function areaRings(kind, item, { discSegments = 64, cellStepDeg } = {}) {
* ready?() }), called with checked items. Set ids are unique while the set
* lives; destroyAll() is for the engine's destroy.
*/
export function createGroundRegistry(build) {
export function createGroundRegistry(build, { pulseField } = {}) {
const live = new Map();
const pulses = createGroundPulseRegistry(pulseField, (id) => live.has(id));
const ground = {
createPulseField: pulses.create,
createSet(input) {
const options = normalizeGroundSetOptions(input);
if (live.has(options.id))
if (live.has(options.id) || pulses.live.has(options.id))
throw new Error(`Ground set ${options.id} already exists`);
const part = build(options);
const state = {
@@ -439,18 +470,21 @@ export function createGroundRegistry(build) {
return set;
},
list: () =>
[...live.values()].map(({ options, items, shown, animated }) => ({
id: options.id,
kind: options.kind,
count: items.length,
shown,
order: options.order,
animated,
})),
[...live.values()]
.map(({ options, items, shown, animated }) => ({
id: options.id,
kind: options.kind,
count: items.length,
shown,
order: options.order,
animated,
}))
.concat(pulses.list()),
};
return {
ground,
live,
pulses: pulses.live,
/**
* Whether a shown set has a pulse or a dash: the engine draws a frame
* after every frame while it does, as Cesium's continuous render did.
@@ -461,6 +495,7 @@ export function createGroundRegistry(build) {
return false;
},
destroyAll() {
pulses.destroyAll();
for (const state of [...live.values()]) state.set.destroy();
},
};
+23
View File
@@ -236,6 +236,29 @@ test("a pixel corridor's glow, pulse and dash are checked and defaulted", () =>
false,
'a glow alone is still',
);
const patterned = normalizeGroundItem(
'corridor',
line({ dashPattern: { lengthPx: 18 } }),
);
assert.deepEqual(patterned.animation, {
pattern: { lengthPx: 18, pattern: 255 },
});
assert.equal(
animates(patterned),
false,
'a screen-space pattern does not keep rendering',
);
for (const extra of [
{ dashPattern: { lengthPx: 0 } },
{ dashPattern: { lengthPx: 18, pattern: 65536 } },
{ dashPattern: { lengthPx: 18, pattern: 1.5 } },
{ dashPattern: { lengthPx: 18 }, glow: { power: 0.2 } },
{ dashPattern: { lengthPx: 18 }, widthPx: undefined, widthM: 20 },
])
assert.throws(
() => normalizeGroundItem('corridor', line(extra)),
TypeError,
);
assert.equal(normalizeGroundItem('corridor', line()).animation, null);
for (const extra of [
{ glow: { power: 0.6 } },
+114
View File
@@ -0,0 +1,114 @@
// The checked, engine-neutral state of a radial ground pulse field.
// The caller supplies fronts and opacity; the field owns no clock or timer.
const finite = (value) => typeof value === 'number' && Number.isFinite(value);
const unit = (value) => finite(value) && value >= 0 && value <= 1;
const list = (value) => Array.isArray(value) || ArrayBuffer.isView(value);
const fail = (message) => {
throw new TypeError(`Ground pulse field: ${message}`);
};
function centerOf(value) {
if (
!value ||
![value.x, value.y, value.z].every(finite) ||
Math.hypot(value.x, value.y, value.z) === 0
)
fail('center must be an Earth-fixed position');
return { x: value.x, y: value.y, z: value.z };
}
function rgba(value, name) {
if (!list(value) || value.length !== 4 || !Array.from(value).every(unit))
fail(`${name} must be rgba in 0..1`);
return Array.from(value);
}
function fronts(value, name) {
if (!list(value) || value.length > 4 || !Array.from(value).every(unit))
fail(`${name} must hold up to four values in 0..1`);
return Array.from({ length: 4 }, (_, index) => value[index] ?? 0);
}
export function createGroundPulseRegistry(
build = () => ({}),
hasSet = () => false,
) {
const live = new Map();
return {
live,
create(input) {
if (!input || typeof input.id !== 'string' || !input.id)
fail('id must be a non-empty string');
if (live.has(input.id) || hasSet(input.id))
throw new Error(`Ground set ${input.id} already exists`);
const options = {
...input,
center: centerOf(input.center),
heightM: input.heightM ?? 0,
};
for (const name of ['radiusM', 'depth', 'lineWidth'])
if (!finite(options[name]) || options[name] <= 0)
fail(`${name} must be positive`);
if (options.depth > 1) fail('depth must be at most 1');
if (!finite(options.heightM) || options.heightM < 0)
fail('heightM must be non-negative');
if (
!finite(options.haloWidth) ||
options.haloWidth < 0 ||
options.haloWidth > options.lineWidth
)
fail('haloWidth must be between 0 and lineWidth');
for (const name of ['color', 'lineColor', 'haloColor'])
options[name] = rgba(input[name], name);
const part = build(options);
const state = {
options,
center: options.center,
radii: [0, 0, 0, 0],
alphas: [0, 0, 0, 0],
level: 0,
destroyed: false,
};
const handle = {
id: options.id,
draw(input) {
if (state.destroyed) return;
if (!input || !unit(input.level)) fail('level must be in 0..1');
const radii = fronts(input.radii, 'radii');
const alphas = fronts(input.alphas, 'alphas');
Object.assign(state, { radii, alphas, level: input.level });
part.draw?.(state);
},
move(center) {
if (state.destroyed) return;
state.center = centerOf(center);
part.move?.(state.center);
},
destroy() {
if (state.destroyed) return;
state.destroyed = true;
live.delete(options.id);
part.destroy?.();
},
isDestroyed: () => state.destroyed,
};
state.handle = handle;
live.set(options.id, state);
part.draw?.(state);
return handle;
},
list: () =>
[...live.values()].map(({ options, level }) => ({
id: options.id,
kind: 'pulse-field',
count: 1,
shown: level > 0,
order: 0,
animated: false,
})),
destroyAll() {
for (const state of [...live.values()]) state.handle.destroy();
},
};
}
+103
View File
@@ -0,0 +1,103 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as geo from '../geo/index.js';
import { createFakeEngine } from './testing/fakeEngine.js';
import { createCesiumMoversEngine } from '../testSupport/cesiumMoversEngine.mjs';
const options = () => ({
id: 'field',
center: geo.cartesian3.fromDegrees(3, 27),
radiusM: 250000,
heightM: 2500,
color: [0, 1, 1, 0.25],
lineColor: [0, 1, 1, 1],
haloColor: [0, 0, 0, 0.8],
depth: 0.35,
lineWidth: 3,
haloWidth: 1,
});
for (const [name, make] of [
['fake', () => ({ engine: createFakeEngine() })],
['Cesium', createCesiumMoversEngine],
]) {
test(`pulse field validation, snapshots and disposal (${name})`, () => {
const { engine, viewer } = make();
const input = options();
for (const patch of [
{ center: { x: 0, y: 0, z: 0 } },
{ radiusM: 0 },
{ heightM: -1 },
{ depth: 0 },
{ depth: 2 },
{ haloWidth: 4 },
{ lineColor: [1, 0, 0, 2] },
])
assert.throws(
() => engine.ground.createPulseField({ ...input, ...patch }),
TypeError,
);
assert.deepEqual(engine.ground.list(), []);
const field = engine.ground.createPulseField(input);
const listed = () => engine.ground.list().find(({ id }) => id === field.id);
const native = () =>
viewer &&
Array.from({ length: viewer.scene.primitives.length }, (_, i) =>
viewer.scene.primitives.get(i),
).find((p) => p.appearance);
const first = native();
assert.equal(listed().shown, false);
assert.equal(listed().animated, false);
assert.throws(
() => engine.ground.createPulseField(input),
/already exists/,
);
assert.throws(
() => engine.ground.createSet({ id: field.id, kind: 'disc' }),
/already exists/,
);
const radii = [0.2, 0.6];
const alphas = [0.5, 0.25];
field.draw({ radii, alphas, level: 1 });
radii[0] = 0.9;
alphas[0] = 0;
input.lineColor[0] = 1;
assert.equal(listed().shown, true);
if (first) {
assert.equal(first.appearance.material.uniforms.radii.x, 0.2);
assert.equal(first.appearance.material.uniforms.alphas.x, 0.5);
assert.equal(first.appearance.material.uniforms.lineColor.red, 0);
assert.equal(first.geometryInstances.geometry._height, 2500);
assert.equal(first.allowPicking, false);
} else {
const state = engine.fake.groundPulse(field.id);
assert.deepEqual(state.radii, [0.2, 0.6, 0, 0]);
assert.deepEqual(state.alphas, [0.5, 0.25, 0, 0]);
assert.equal(state.options.lineColor[0], 0);
}
for (const patch of [{ radii: [NaN] }, { alphas: [2] }, { level: -1 }])
assert.throws(
() => field.draw({ radii: [0.5], alphas: [1], level: 0, ...patch }),
TypeError,
);
assert.equal(listed().shown, true, 'invalid updates are atomic');
field.move(geo.cartesian3.fromDegrees(3.01, 27));
assert.equal(
native(),
first,
'small movement does not rebuild the material disc',
);
field.draw({ radii: [], alphas: [], level: 0 });
assert.equal(listed().shown, false);
field.destroy();
field.destroy();
field.move({ x: NaN });
field.draw({});
assert.equal(field.isDestroyed(), true);
assert.equal(listed(), undefined);
if (first) assert.ok(first.isDestroyed());
engine.ground.createPulseField(input);
engine.destroy();
assert.deepEqual(engine.ground.list(), []);
});
}
+22
View File
@@ -165,6 +165,10 @@ export function createMoverStore(options) {
let positions = new Float64Array(capacity * 3);
let orientations = new Float64Array(capacity * 4);
let colors = new Float32Array(capacity * 4).fill(1);
let outlineColors = kind === 'point' ? new Float32Array(capacity * 4) : null;
if (outlineColors)
for (let index = 0; index < capacity; index++)
outlineColors.set(options.point.outlineColor, index * 4);
let rotations = new Float32Array(capacity);
let scales = new Float32Array(capacity).fill(1);
let cells = new Uint16Array(capacity);
@@ -221,6 +225,19 @@ export function createMoverStore(options) {
)
)
throw new TypeError('Mover color must be [r, g, b, a]');
if (
fields.outlineColor !== undefined &&
(kind !== 'point' ||
!(
Array.isArray(fields.outlineColor) ||
ArrayBuffer.isView(fields.outlineColor)
) ||
fields.outlineColor.length !== 4 ||
!Array.from(fields.outlineColor).every(
(v) => isFiniteNumber(v) && v >= 0 && v <= 1,
))
)
throw new TypeError('Mover outlineColor must be point rgba in 0..1');
if (rotation !== undefined && !isFiniteNumber(rotation))
throw new TypeError('Mover rotation must be a number');
if (scale !== undefined && !(isFiniteNumber(scale) && scale >= 0))
@@ -234,6 +251,7 @@ export function createMoverStore(options) {
positions,
orientations,
colors,
outlineColors,
rotations,
scales,
cells,
@@ -261,6 +279,8 @@ export function createMoverStore(options) {
if (color)
for (let channel = 0; channel < 4; channel++)
colors[index * 4 + channel] = color[channel];
if (fields.outlineColor)
outlineColors.set(fields.outlineColor, index * 4);
if (rotation !== undefined) rotations[index] = rotation;
if (scale !== undefined) scales[index] = scale;
if (cell !== undefined) cells[index] = cellIndex.get(cell);
@@ -284,6 +304,7 @@ export function createMoverStore(options) {
positions = new Float64Array(0);
orientations = new Float64Array(0);
colors = new Float32Array(0);
outlineColors = outlineColors ? new Float32Array(0) : null;
rotations = new Float32Array(0);
scales = new Float32Array(0);
cells = new Uint16Array(0);
@@ -293,6 +314,7 @@ export function createMoverStore(options) {
positions,
orientations,
colors,
outlineColors,
rotations,
scales,
cells,
+3
View File
@@ -55,6 +55,9 @@ const NO_MESHES = Object.freeze({
list: () => [],
});
const NO_GROUND = Object.freeze({
createPulseField() {
throw new Error('Navara ground needs the view runtime');
},
createSet() {
throw new Error("Navara ground needs the view's three runtime (ground/)");
},
+8 -5
View File
@@ -44,7 +44,7 @@ const E2 =
1 - (ellipsoid.WGS84.minimumRadius / ellipsoid.WGS84.maximumRadius) ** 2;
/** Item looks as the shader reads them: glow, pulse and dash bits. */
export const LOOK = Object.freeze({ glow: 1, pulse: 2, dash: 4 });
export const LOOK = Object.freeze({ glow: 1, pulse: 2, dash: 4, pattern: 8 });
/** RGBA32F texels per corridor item. */
export const ITEM_TEXELS = 5;
@@ -56,9 +56,12 @@ const geodetic = (latcDeg) =>
/** One item's look: five texels (colour, widths, glow/pulse, dash). */
function itemTexels(item, lengthM) {
const { glow, pulse, dash } = item.animation ?? {};
const { glow, pulse, dash, pattern } = item.animation ?? {};
const flags =
(glow ? LOOK.glow : 0) | (pulse ? LOOK.pulse : 0) | (dash ? LOOK.dash : 0);
(glow ? LOOK.glow : 0) |
(pulse ? LOOK.pulse : 0) |
(dash ? LOOK.dash : 0) |
(pattern ? LOOK.pattern : 0);
return [
...item.fill,
item.widthPx ?? 0,
@@ -75,8 +78,8 @@ function itemTexels(item, lengthM) {
dash?.speed ?? 0,
dash?.gapAlpha ?? 0,
dash?.emission ?? 0,
0,
0,
pattern?.lengthPx ?? 0,
pattern?.pattern ?? 0,
];
}
@@ -1,4 +1,5 @@
import { Effect, EffectAttribute } from 'postprocessing';
import { withFrameUpdates, withPassDisposal } from '../frameDescriptor.js';
/**
* A screen-space pass on a Navara view, registered once per view under `key`.
@@ -20,7 +21,10 @@ export function defineGroundEffect({
}) {
const registered = new WeakSet();
const descriptor = (EffectDesc, NavaraEffect) =>
class GroundEffectDesc extends EffectDesc {
class GroundEffectDesc extends withPassDisposal(
withFrameUpdates(EffectDesc),
) {
static allowDuplication = true;
static key = key;
static insertAfter = insertAfter;
static insertBefore = insertBefore;
+4 -2
View File
@@ -35,6 +35,7 @@ export function createNavaraGround(
view,
{
createPass,
pulseField,
nextFrame = (run) =>
globalThis.requestAnimationFrame
? globalThis.requestAnimationFrame(run)
@@ -89,7 +90,7 @@ export function createNavaraGround(
if (changed || registry.animating()) askNextFrame();
};
view.on('postRender', retry);
const registry = createGroundRegistry((options) => {
const build = (options) => {
const entry = {
options,
shown: true,
@@ -119,7 +120,8 @@ export function createNavaraGround(
compose();
},
};
});
};
const registry = createGroundRegistry(build, { pulseField });
return {
ground: registry.ground,
/** Every live set's state ({ options, items, shown }) for CPU picks. */
+20
View File
@@ -460,3 +460,23 @@ test('each bin of a packed area points at its own segments', () => {
}
assert.equal(pack.segments.length / 4, expected);
});
test('screen-space dash masks keep their CSS length and all sixteen bits in the pass', () => {
const pack = packSet(
'corridor',
items('corridor', [
{
positions: [
[85, 28],
[85.1, 28.1],
],
color: [0.8, 0.5, 0.3, 0.38],
widthPx: 18,
dashPattern: { lengthPx: 18, pattern: 0xa55a },
},
]),
);
assert.equal(pack.items[7], LOOK.pattern);
assert.deepEqual(Array.from(pack.items.slice(18, 20)), [18, 0xa55a]);
assert.equal(pack.items.length, ITEM_TEXELS * 4);
});
+11
View File
@@ -55,6 +55,7 @@ uniform mat4 uInverseProjection;
uniform mat4 uViewToWorld;
uniform float uLogDepthFC;
uniform float uTime;
uniform float uPixelRatio;
// Rows of ${ROW} texels (pack.js texelStorage).
vec4 atSlot(highp sampler2D image, int slot) {
return texelFetch(image, ivec2(slot & ${ROW - 1}, slot >> ${Math.log2(ROW)}), 0);
@@ -95,6 +96,13 @@ vec3 blendCorridor(vec3 color, int item, float nearest, vec2 normal, float along
rgb = max(vec3(glow - 1.0) + rgb, rgb);
alpha *= clamp(glow, 0.0, 1.0);
}
if ((look & ${LOOK.pattern}) != 0) {
// A screen-aligned 16-bit mask, as the ground PolylineDash material.
vec2 tangent = normalize(vec2(-dot(normal, localDy), dot(normal, localDx)));
float across = dot(gl_FragCoord.xy, tangent);
float bit = floor(fract(across / (dash.z * uPixelRatio)) * 16.0);
alpha *= mod(floor(dash.w / exp2(bit)), 2.0);
}
if ((look & ${LOOK.dash}) != 0) {
float flow = fract(s * pulseDash.y - uTime * pulseDash.w);
float on = 1.0 - smoothstep(pulseDash.z - ${float(DASH_EDGE)}, pulseDash.z + ${float(DASH_EDGE)}, flow);
@@ -400,6 +408,7 @@ class GroundEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
['uViewToWorld', new Uniform(new Matrix4())],
['uLogDepthFC', new Uniform(1)],
['uTime', new Uniform(0)],
['uPixelRatio', new Uniform(1)],
]),
});
return new NavaraEffect(camera, this.effect);
@@ -439,6 +448,8 @@ class GroundEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
u.get('uViewToWorld').value.copy(camera.matrixWorld);
u.get('uLogDepthFC').value = 2 / Math.log2(camera.far + 1);
u.get('uTime').value = groundSeconds();
const cssWidth = this.view.canvas?.clientWidth;
u.get('uPixelRatio').value = cssWidth > 0 ? input.width / cssWidth : 1;
u.get('tFrames').value = owner.textures.frames;
u.get('tStyles').value = owner.textures.styles;
u.get('tSegments').value = owner.textures.segments;
@@ -1,18 +1,9 @@
// The Global Context range pulses on Navara: the pulse disc the Cesium globe
// draws as a translucent ellipse primitive (rendering.js), drawn here over
// visible terrain and 3D tiles by one non-pickable classification pass.
// Each surface pixel's distance from the subject along the Earth sets where
// it lies within the range, and the same pulse lines, halos and inward band
// as Cesium's material are drawn there. Nothing above the surface is touched.
// Ground pulse fields over the visible globe, after tone mapping.
import { Matrix4, Uniform, Vector3, Vector4 } from 'three';
import {
groundSurfaceUniforms,
updateGroundSurface,
} from '../../shared/render/navaraGroundSurface.js';
import { defineGroundEffect } from '../../shared/render/navaraGroundEffect.js';
import { groundSurfaceUniforms, updateGroundSurface } from './surface.js';
import { defineGroundEffect } from './effect.js';
const KEY = 'gevAwarenessPulses';
const MEAN_EARTH_RADIUS_M = 6_371_000;
const KEY = 'gevGroundPulses';
const fragmentShader = /* glsl */ `
uniform sampler2D tAllDepth;
@@ -22,6 +13,7 @@ uniform mat4 uInverseView;
uniform float uLogDepthFC;
uniform vec3 uCenter;
uniform float uRadiusAngle;
uniform vec3 uRadiiM;
uniform vec4 uColor;
uniform vec4 uLineColor;
uniform vec4 uHaloColor;
@@ -48,16 +40,22 @@ void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) {
float distance=exp2(2.0*depth/uLogDepthFC)-1.0;
vec4 ray=uInverseProjection*vec4(uv*2.0-1.0,-1.0,1.0);
vec3 point=ray.xyz*(distance/-ray.z);
vec3 world=(uInverseView*vec4(point,1.0)).xyz;
vec3 origin=uInverseView[3].xyz;
vec3 direction=normalize((uInverseView*vec4(ray.xyz,0.0)).xyz);
vec3 o=origin/uRadiiM;
vec3 d=direction/uRadiiM;
float a=dot(d,d);
float b=dot(o,d);
float c=dot(o,o)-1.0;
float discriminant=b*b-a*c;
float hit=(-b-sqrt(max(discriminant,0.0)))/a;
vec3 world=origin+direction*hit;
vec3 up=normalize(world);
// The angle to the subject along the Earth, well conditioned near it too;
// r is the fraction of the range, as the Cesium disc's texture radius.
float r=atan(length(cross(up,uCenter)),dot(up,uCenter))/uRadiusAngle;
// Change in r per CSS pixel, taken before any branch.
float r=length(cross(up,uCenter))/sin(uRadiusAngle);
float cssPx=max(length(vec2(dFdx(r),dFdy(r))),1e-7)*uPixelRatio;
if(depth>=1.0||r>1.0) return;
float surface=texture2D(tSurfaceDepth,uv).r;
if(depth<surface-2e-7) return;
if(discriminant<0.0||hit<=0.0||dot(up,uCenter)<=0.0||r>1.0) return;
// The raised ellipsoid is depth tested, as the material disc is.
if(depth<1.0 && hit>length(point)+1.0) return;
float band=0.0;
for(int i=0;i<4;i++) {
float inside=uRadii[i]-r;
@@ -85,7 +83,7 @@ void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) {
const pulseEffect = defineGroundEffect({
key: KEY,
name: 'GevAwarenessPulses',
name: 'GevGroundPulses',
fragmentShader,
insertAfter: [
'gevGround',
@@ -107,6 +105,7 @@ const pulseEffect = defineGroundEffect({
['uInverseView', new Uniform(new Matrix4())],
['uCenter', new Uniform(new Vector3(1, 0, 0))],
['uRadiusAngle', new Uniform(1)],
['uRadiiM', new Uniform(new Vector3())],
['uColor', new Uniform(new Vector4())],
['uLineColor', new Uniform(new Vector4())],
['uHaloColor', new Uniform(new Vector4())],
@@ -126,6 +125,11 @@ const pulseEffect = defineGroundEffect({
u.get('uInverseView').value.copy(camera.matrixWorld);
u.get('uCenter').value.copy(owner.center);
u.get('uRadiusAngle').value = owner.radiusAngle;
u.get('uRadiiM').value.set(
6378137 + owner.heightM,
6378137 + owner.heightM,
6356752.314245179 + owner.heightM,
);
u.get('uColor').value.fromArray(owner.color);
u.get('uLineColor').value.fromArray(owner.lineColor);
u.get('uHaloColor').value.fromArray(owner.haloColor);
@@ -150,16 +154,26 @@ const pulseEffect = defineGroundEffect({
* `radiusM` and their opacities, `move(center)`, and `destroy()`. Colors are
* rgba arrays in 0..1: the band, the pulse line and its halo.
*/
export function createAwarenessPulseDisc(
engine,
{ view, EffectDesc, NavaraEffect },
{ center, radiusM, color, lineColor, haloColor, depth, lineWidth, haloWidth },
export function createNavaraPulseField(
{ view, EffectDesc, NavaraEffect, canvas },
{
center,
radiusM,
heightM,
color,
lineColor,
haloColor,
depth,
lineWidth,
haloWidth,
},
) {
pulseEffect.register(view, { EffectDesc, NavaraEffect });
const owner = {
cssWidth: () => engine.host?.canvas?.clientWidth ?? 0,
cssWidth: () => canvas?.clientWidth ?? 0,
center: new Vector3(),
radiusAngle: radiusM / MEAN_EARTH_RADIUS_M,
radiusAngle: radiusM / Math.hypot(center.x, center.y, center.z),
heightM,
color,
lineColor,
haloColor,
@@ -196,7 +210,7 @@ export function createAwarenessPulseDisc(
destroyed = true;
handle?.delete();
handle = null;
engine.render.request('awareness-pulses-destroy');
view.forceUpdate();
},
};
}
+53
View File
@@ -0,0 +1,53 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as geo from '../../../geo/index.js';
import { createNavaraPulseField } from './pulse.js';
test('the Navara pulse disc draws only while the pulses run', () => {
const effects = [];
const view = {
registerEffect() {},
forceUpdate() {},
addEffect(config) {
const handle = {
config,
visible: config.visible,
deleted: false,
update(next) {
handle.visible = next.visible;
},
delete() {
handle.deleted = true;
},
};
effects.push(handle);
return handle;
},
};
const disc = createNavaraPulseField(
{ view, EffectDesc: class {}, NavaraEffect: class {} },
{
center: geo.cartesian3.fromDegrees(3, 27, 0),
radiusM: 250_000,
heightM: 2500,
color: [0, 0, 1, 0.2],
lineColor: [0, 0, 1, 1],
haloColor: [0, 0, 0, 0.8],
depth: 0.35,
lineWidth: 3,
haloWidth: 1,
},
);
disc.draw({ level: 0, radii: [0.1, 0.4, 0.7], alphas: [0, 0, 0] });
assert.equal(effects.length, 0, 'nothing drawn at rest');
disc.draw({ level: 1, radii: [0.1, 0.4, 0.7], alphas: [0.5, 0.3, 0.2] });
assert.equal(effects.length, 1);
const owner = effects[0].config.gevGroundPulses.owner;
assert.deepEqual(owner.radii, [0.1, 0.4, 0.7, 0]);
assert.deepEqual(owner.alphas, [0.5, 0.3, 0.2, 0]);
assert.ok(Math.abs(owner.center.length() - 1) < 1e-9, 'a unit direction');
disc.draw({ level: 0, radii: [0, 0, 0], alphas: [0, 0, 0] });
assert.equal(effects[0].visible, false);
disc.destroy();
assert.equal(effects[0].deleted, true);
});
+1 -1
View File
@@ -182,7 +182,7 @@ class ImageryEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
'gevNepalEvent',
'gevObservedWeather',
'gevEarthquakeGround',
'gevAwarenessPulses',
'gevGroundPulses',
'gevGround',
'gevPreSharpenOverlay',
'gevSharpen',
+9 -1
View File
@@ -36,6 +36,7 @@ import { createNavaraMovers } from './movers/index.js';
import { createNavaraLines } from './lines/index.js';
import { createNavaraMeshes } from './meshes.js';
import { createNavaraGround } from './ground/index.js';
import { createNavaraPulseField } from './ground/pulse.js';
import { createGroundPass } from './ground/pass.js';
import { createNavaraImagery } from './imagery/index.js';
import { createImageryPass } from './imagery/pass.js';
@@ -176,7 +177,14 @@ export async function createNavaraGlobe({ container, search, hd = false }) {
}),
lines: createNavaraLines(view, { overlay }),
meshes: (parts) => createNavaraMeshes(view, { MeshDesc, ...parts }),
ground: createNavaraGround(view, { createPass: createGroundPass }),
ground: createNavaraGround(view, {
createPass: createGroundPass,
pulseField: (options) =>
createNavaraPulseField(
{ view, EffectDesc, NavaraEffect, canvas },
options,
),
}),
imagery: (parts) =>
createNavaraImagery(view, { ...parts, createPass: createImageryPass }),
});
+13 -6
View File
@@ -35,6 +35,10 @@ import { DETACHED_SONAR, SONAR_VERTEX } from '../sonar.js';
const VERTEX_HEAD = /* glsl */ `
attribute vec4 gevStyle;
attribute vec4 gevColor;
#ifdef GEV_POINT
attribute vec4 gevOutlineColor;
varying vec4 vGevOutlineColor;
#endif
attribute vec4 gevUv;
uniform float uGevSizePx;
uniform float uGevFootprint;
@@ -84,6 +88,7 @@ float gevSizePx = uGevSizePx * gevScale;
vGevOutline = 0.0;
vGevPixelDistance = 0.0;
#ifdef GEV_POINT
vGevOutlineColor = gevOutlineColor;
float gevBorderPx = 2.0 * uGevOutlinePx * gevDistanceScale;
float gevOutlinedSize = gevSizePx + gevBorderPx;
vGevOutline = gevBorderPx / max(gevOutlinedSize, 1e-3);
@@ -118,7 +123,9 @@ vMapUv = mix(gevUv.xy, gevUv.zw, uv);
`;
const FRAGMENT_HEAD = /* glsl */ `
uniform vec4 uGevOutlineColor;
#ifdef GEV_POINT
varying vec4 vGevOutlineColor;
#endif
varying vec4 vGevColor;
varying vec2 vGevLocal;
varying float vGevOutline;
@@ -143,7 +150,7 @@ float gevDistanceToCenter = gevRadius * 0.5;
float gevWholeAlpha = 1.0 - smoothstep(gevMaxDistance, 0.5, gevDistanceToCenter);
float gevInner = 1.0 - vGevOutline;
float gevInnerAlpha = 1.0 - smoothstep(gevMaxDistance * gevInner, 0.5 * gevInner, gevDistanceToCenter);
vec4 gevOutline = vec4(uGevOutlineColor.rgb, uGevOutlineColor.a * vGevSonarFactor);
vec4 gevOutline = vec4(vGevOutlineColor.rgb, vGevOutlineColor.a * vGevSonarFactor);
diffuseColor *= sRGBTransferEOTF(mix(gevOutline, vGevColor, gevInnerAlpha));
diffuseColor.a *= gevWholeAlpha * vGevTranslucency;
if (diffuseColor.a < 0.005) discard;
@@ -183,7 +190,7 @@ export function patchMoverShader(
let fragment = replace(
shader.fragmentShader,
'void main() {',
`${FRAGMENT_HEAD}\nvoid main() {`,
`${point ? '#define GEV_POINT\n' : ''}${FRAGMENT_HEAD}\nvoid main() {`,
);
let colorFragment = fragmentColor(point);
if (alwaysVisible)
@@ -268,6 +275,7 @@ export function createMoverMesh(
const styleAttribute = attribute('gevStyle', 4);
const colorAttribute = attribute('gevColor', 4);
const uvAttribute = attribute('gevUv', 4);
const outlineAttribute = point ? attribute('gevOutlineColor', 4) : null;
const uvRects = point
? null
: atlasUvRects(options.sprite.atlas.cells, options.cellKeys);
@@ -284,9 +292,6 @@ export function createMoverMesh(
value: typeof depthTest === 'object' ? depthTest.disableWithinM : -1,
},
uGevOutlinePx: { value: point ? options.point.outlinePx : 0 },
uGevOutlineColor: {
value: point ? [...options.point.outlineColor] : [0, 0, 0, 1],
},
uGevPixelRatio: { value: 1 },
uGevDepthMode: {
value:
@@ -347,6 +352,7 @@ export function createMoverMesh(
matrices: mesh.instanceMatrix.array,
style: styleAttribute.array,
colors: colorAttribute.array,
outlineColors: outlineAttribute?.array,
uvs: uvAttribute.array,
};
const upload = (from, to) => {
@@ -356,6 +362,7 @@ export function createMoverMesh(
[styleAttribute, 4],
[colorAttribute, 4],
[uvAttribute, 4],
...(outlineAttribute ? [[outlineAttribute, 4]] : []),
]) {
// Ranges add up until three uploads them (and clears them): a
// partial commit after a full rewrite in the same frame (an origin
+29 -1
View File
@@ -156,6 +156,7 @@ test('the shader patch sizes the quad in pixels and colours it per instance', ()
);
assert.doesNotMatch(shader.fragmentShader, /gevRadius > 1\.0\) discard/);
const point = patchMoverShader(basicShader(), {}, { point: true });
assert.match(point.fragmentShader, /#define GEV_POINT/);
assert.match(point.fragmentShader, /if \(gevRadius > 1\.0\) discard;/);
});
@@ -223,12 +224,39 @@ test('points have no atlas and draw a disc', () => {
depthTest: 'always',
point: { sizePx: 6, outlinePx: 1 },
});
const mover = createMoverMesh(options, createMoverStore(options));
const store = createMoverStore(options);
const mover = createMoverMesh(options, store);
assert.equal(mover.mesh.material.map, null);
assert.equal(mover.mesh.material.depthTest, false);
assert.equal(mover.mesh.material.side, FrontSide);
assert.equal(mover.uniforms.uGevDepthMode.value, 0, "'always': no test");
assert.equal(mover.uniforms.uGevOutlinePx.value, 1);
store.set(0, {
position: { x: 6378137, y: 0, z: 0 },
color: [0, 1, 1, 0.5],
outlineColor: [1, 0, 0, 0.25],
});
store.setCount(1);
mover.commit(store.dirty());
assert.deepEqual(
Array.from(mover.mesh.geometry.getAttribute('gevOutlineColor').array),
[1, 0, 0, 0.25],
);
assert.deepEqual(
Array.from(mover.mesh.geometry.getAttribute('gevColor').array),
[0, 1, 1, 0.5],
);
store.clean();
store.set(0, { outlineColor: [0, 0, 1, 0.75] });
mover.commit(store.dirty());
assert.deepEqual(
Array.from(mover.mesh.geometry.getAttribute('gevOutlineColor').array),
[0, 0, 1, 0.75],
);
assert.deepEqual(
Array.from(mover.mesh.geometry.getAttribute('gevColor').array),
[0, 1, 1, 0.5],
);
mover.dispose();
});
+2
View File
@@ -63,6 +63,8 @@ export function packInstances(store, from, to, origin, uvRects, out) {
colorOut[s + 1] = colors[s + 1];
colorOut[s + 2] = colors[s + 2];
colorOut[s + 3] = colors[s + 3];
if (out.outlineColors && store.outlineColors)
out.outlineColors.set(store.outlineColors.subarray(s, s + 4), s);
if (uvs && uvRects) {
const r = cells[index] * 4;
uvs[s] = uvRects[r];
+1 -1
View File
@@ -106,7 +106,7 @@ test('the mover shaders treat contacts by their anchor and keep their position',
if (point)
assert.match(
shader.fragmentShader,
/uGevOutlineColor\.a \* vGevSonarFactor/,
/vGevOutlineColor\.a \* vGevSonarFactor/,
);
}
});
@@ -7,8 +7,7 @@ import {
Vector2,
DoubleSide,
} from 'three';
import { fromDegrees } from '../../../geo/cartesian3.js';
import { WIND_DISPLAY_HEIGHT_METERS } from '../streamlines.js';
import { fromDegrees } from '../../geo/cartesian3.js';
const KEY = 'gevWindFlow';
const registered = new WeakSet();
@@ -47,7 +46,7 @@ void main() {
gl_FragColor=vec4(mix(vec3(0.50,0.79,0.90),vec3(0.91,0.99,1.0),tip),alpha);
}`;
export function createWindFlowMesh({ view, MeshDesc }, paths) {
export function createWindFlowMesh({ view, MeshDesc }, paths, heightM) {
if (!registered.has(view)) {
view.registerMesh(
KEY,
@@ -81,7 +80,7 @@ export function createWindFlowMesh({ view, MeshDesc }, paths) {
const n = path.coordinates.length - 1;
for (let j = 0; j <= n; j++) {
const [lon, lat] = path.coordinates[j];
const next = fromDegrees(lon, lat, WIND_DISPLAY_HEIGHT_METERS);
const next = fromDegrees(lon, lat, heightM);
if (previous) {
begin.set([previous.x, previous.y, previous.z], i);
end.set([next.x, next.y, next.z], i);
+97
View File
@@ -2938,6 +2938,103 @@ function groundChecks(
ensure(error instanceof TypeError, `${label} did not throw a TypeError`);
};
return [
[
'ground: pulse fields update fronts, move, hide and release without a clock',
async () => {
const options = {
id: 'contract-ground-pulses',
center: cartesian3.fromDegrees(20, 10),
radiusM: 250000,
heightM: 2500,
color: [0, 1, 1, 0.25],
lineColor: [0, 1, 1, 1],
haloColor: [0, 0, 0, 0.8],
depth: 0.35,
lineWidth: 3,
haloWidth: 1,
};
throwsType(
() => ground.createPulseField({ ...options, radiusM: -1 }),
'negative radius',
);
const field = ground.createPulseField(options);
const listed = () => ground.list().find(({ id }) => id === options.id);
try {
ensure(!listed().shown, 'new field is visible');
field.draw({ radii: [0.2, 0.6], alphas: [0.4, 0.2], level: 1 });
ensure(
listed().shown && !listed().animated,
'caller-driven field starts no clock',
);
throwsType(
() => field.draw({ radii: [2], alphas: [1], level: 1 }),
'invalid front',
);
ensure(listed().shown, 'invalid update changed visibility');
field.move(cartesian3.fromDegrees(21, 10));
await settle();
field.draw({ radii: [], alphas: [], level: 0 });
ensure(!listed().shown, 'resting field is visible');
} finally {
field.destroy();
}
field.destroy();
field.draw({ radii: [0.5], alphas: [1], level: 1 });
ensure(field.isDestroyed() && !listed(), 'destroyed field retained');
},
],
[
'ground: pulse bands change pixels and rest clears them',
async () => {
if (!readRegion) return;
await nadirView(1e6);
const at = engine.projector.worldToWindow(
cartesian3.fromDegrees(20.9, 10, 2500),
);
const sample = () =>
readRegion(Math.round(at.x) - 3, Math.round(at.y) - 3, 6, 6);
const base = sample();
const difference = (a, b) => {
let sum = 0;
for (let i = 0; i < a.length; i++)
if (i % 4 !== 3) sum += Math.abs(a[i] - b[i]);
return sum / (a.length * 0.75);
};
const field = ground.createPulseField({
id: 'contract-pulse-pixels',
center: cartesian3.fromDegrees(20, 10),
radiusM: 200000,
heightM: 2500,
color: [1, 0, 1, 1],
lineColor: [1, 0, 1, 1],
haloColor: [0, 0, 0, 1],
depth: 0.5,
lineWidth: 3,
haloWidth: 1,
});
try {
field.draw({ radii: [0.6], alphas: [1], level: 1 });
await until(
() => difference(sample(), base) > 25,
'pulse band draws',
);
field.draw({ radii: [0.2], alphas: [1], level: 1 });
await until(
() => difference(sample(), base) < 10,
'front moves past the sampled band',
);
field.draw({ radii: [0.6], alphas: [1], level: 1 });
await until(() => difference(sample(), base) > 25, 'front returns');
field.draw({ radii: [0.6], alphas: [1], level: 0 });
await until(
() => difference(sample(), base) < 10,
'rest clears the field',
);
} finally {
field.destroy();
}
},
],
[
'ground: sets are created, committed, listed, shown, hidden and destroyed',
async () => {
+3
View File
@@ -980,6 +980,9 @@ export function createFakeEngine(options = {}) {
/** 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. */
groundPulse(id) {
return groundRegistry.pulses.get(id);
},
moverSet(id) {
const entry = moverRegistry.sets.get(id);
return entry
@@ -10,7 +10,6 @@ import { getAwarenessEchoField } from '../../awarenessEcho.js';
import { createRendering } from './rendering.js';
import { createState } from './state.js';
import { AWARENESS_RANGE_PULSE, CONTEXT_RIM_HEIGHT_M } from './policy.js';
import { createAwarenessPulseDisc } from './render/navaraPulses.js';
function event() {
const listeners = new Set();
@@ -216,51 +215,3 @@ for (const [name, options] of [
assert.equal(h.frame.size, 0);
assert.equal(getAwarenessEchoField(), null);
});
test('the Navara pulse disc draws only while the pulses run', () => {
const effects = [];
const view = {
registerEffect() {},
addEffect(config) {
const handle = {
config,
visible: config.visible,
deleted: false,
update(next) {
handle.visible = next.visible;
},
delete() {
handle.deleted = true;
},
};
effects.push(handle);
return handle;
},
};
const disc = createAwarenessPulseDisc(
{ render: { request() {} } },
{ view, EffectDesc: class {}, NavaraEffect: class {} },
{
center: geo.cartesian3.fromDegrees(3, 27, 0),
radiusM: 250_000,
color: [0, 0, 1, 0.2],
lineColor: [0, 0, 1, 1],
haloColor: [0, 0, 0, 0.8],
depth: 0.35,
lineWidth: 3,
haloWidth: 1,
},
);
disc.draw({ level: 0, radii: [0.1, 0.4, 0.7], alphas: [0, 0, 0] });
assert.equal(effects.length, 0, 'nothing drawn at rest');
disc.draw({ level: 1, radii: [0.1, 0.4, 0.7], alphas: [0.5, 0.3, 0.2] });
assert.equal(effects.length, 1);
const owner = effects[0].config.gevAwarenessPulses.owner;
assert.deepEqual(owner.radii, [0.1, 0.4, 0.7, 0]);
assert.deepEqual(owner.alphas, [0.5, 0.3, 0.2, 0]);
assert.ok(Math.abs(owner.center.length() - 1) < 1e-9, 'a unit direction');
disc.draw({ level: 0, radii: [0, 0, 0], alphas: [0, 0, 0] });
assert.equal(effects[0].visible, false);
disc.destroy();
assert.equal(effects[0].deleted, true);
});
+8 -4
View File
@@ -970,7 +970,7 @@ test('the pulse level fades in, holds, fades out and keeps its level when woken'
assert.equal(awarenessPulseLevel(at + activeMs, wake), 0);
});
test('an engine without a pulse backend gets the static ring only', (t) => {
test('the fake engine carries the same pulse field and echo lifecycle', (t) => {
const engine = createFakeEngine();
const preRender = engine.frames.preRender.numberOfListeners;
const ring = ringRendering(t, { engine });
@@ -978,10 +978,14 @@ test('an engine without a pulse backend gets the static ring only', (t) => {
rendering.renderVisual(flight(3));
assert.ok(engine.fake.line(RING_ID));
assert.ok(engine.fake.line(RING_HALO_ID));
assert.equal(ring.state.visual.disc, null);
assert.equal(engine.frames.preRender.numberOfListeners, preRender);
assert.equal(getAwarenessEchoField(), null);
assert.ok(ring.state.visual.disc);
assert.ok(engine.fake.groundPulse('gev-awareness-pulses'));
assert.ok(engine.frames.preRender.numberOfListeners > preRender);
assert.ok(getAwarenessEchoField());
rendering.clearVisual();
assert.equal(engine.frames.preRender.numberOfListeners, preRender);
assert.equal(engine.fake.groundPulse('gev-awareness-pulses'), undefined);
assert.equal(getAwarenessEchoField(), null);
assert.equal(pulseFrames(), 0);
});
+19 -24
View File
@@ -4,7 +4,7 @@
// contact and a contact flight) and the 250 km range ring, a core line over
// a wider dark halo at the rim height, with straight chords between
// positions that already follow the surface. The pulses inside the ring sit
// behind ./pulseDisc.js. Nothing here takes picks.
// through engine.ground. Nothing here takes picks.
//
// Cesium's ring was one polyline in its outline material: a 4 px line whose
// inner 3 px are the core and whose outer pixel is the halo, half on each
@@ -21,7 +21,6 @@ import {
CONTEXT_RIM_HEIGHT_M,
} from '../policy.js';
import { awarenessRangeRingPositions } from '../rendering.js';
import { createAwarenessPulseField } from './pulseDisc.js';
export const RING_ID = 'gev-awareness-rim';
export const RING_HALO_ID = 'gev-awareness-rim-halo';
@@ -38,10 +37,8 @@ const withAlpha = (rgba, alpha) => [rgba[0], rgba[1], rgba[2], rgba[3] * alpha];
/**
* The layer's view of `engine`.
* @param {import('../../../globe/contract.js').GlobeEngine} engine
* @param {{ navaraPulses?: Function }} [options] The pulses' Navara backend
* (render/navaraPulses.js), which the Navara mode passes in.
*/
export function createAwarenessDisplay(engine, { navaraPulses } = {}) {
export function createAwarenessDisplay(engine) {
if (!engine?.lines)
throw new TypeError('The awareness display needs a globe engine');
const binding = createEngineLayerView(engine, {
@@ -114,26 +111,24 @@ export function createAwarenessDisplay(engine, { navaraPulses } = {}) {
};
},
/** The pulse disc around `center`, or null on an engine without one. */
/** The pulse field around `center`. */
createPulses: (center) =>
createAwarenessPulseField(
engine,
{
center: geo.cartesian3.clone(center),
radiusM: AWARENESS_RADIUS_M,
// The band's own opacity, as the pulse material sets it.
color: [
...RING_CORE.slice(0, 3),
AWARENESS_RANGE_PULSE.gradientOpacity,
],
lineColor: RING_CORE,
haloColor: RING_HALO,
depth: AWARENESS_RANGE_PULSE.gradientDepthFraction,
lineWidth: AWARENESS_RANGE_PULSE.width,
haloWidth: AWARENESS_RANGE_PULSE.outlineWidth,
},
{ navaraPulses },
),
engine.ground.createPulseField({
id: 'gev-awareness-pulses',
heightM: CONTEXT_RIM_HEIGHT_M,
center: geo.cartesian3.clone(center),
radiusM: AWARENESS_RADIUS_M,
// The band's own opacity, as the pulse material sets it.
color: [
...RING_CORE.slice(0, 3),
AWARENESS_RANGE_PULSE.gradientOpacity,
],
lineColor: RING_CORE,
haloColor: RING_HALO,
depth: AWARENESS_RANGE_PULSE.gradientDepthFraction,
lineWidth: AWARENESS_RANGE_PULSE.width,
haloWidth: AWARENESS_RANGE_PULSE.outlineWidth,
}),
destroy: binding.destroy,
};
}
+12 -4
View File
@@ -1,7 +1,6 @@
// The Global Context display on the engine contract, on the fake engine and
// on the Cesium engine: the camera it reads, the followed contact, a contact
// flight, its frames, the ring lines and the pulse disc only where the
// engine has a backend for it.
// flight, its frames, the ring lines and the pulse field.
import test from 'node:test';
import assert from 'node:assert/strict';
@@ -103,7 +102,7 @@ for (const makeHarness of HARNESSES) {
]);
});
test(`the ring is a 3 px core over a 4 px halo; pulses only with a backend (${name})`, (t) => {
test(`the ring is a 3 px core over a 4 px halo with a ground pulse field (${name})`, (t) => {
const h = makeHarness();
const display = createAwarenessDisplay(h.engine);
t.after(() => {
@@ -138,7 +137,16 @@ for (const makeHarness of HARNESSES) {
} else {
assert.equal(h.engine.fake.line(RING_ID).options.widthPx, 3);
assert.equal(h.engine.fake.line(RING_HALO_ID).options.widthPx, 4);
assert.equal(disc, null, 'the fake engine has no pulse backend');
assert.ok(disc);
disc.draw({ radii: [0.5], alphas: [0.4], level: 1 });
const field = h.engine.fake.groundPulse('gev-awareness-pulses');
assert.deepEqual(field.radii, [0.5, 0, 0, 0]);
assert.deepEqual(field.alphas, [0.4, 0, 0, 0]);
disc.destroy();
assert.equal(
h.engine.fake.groundPulse('gev-awareness-pulses'),
undefined,
);
}
rim.setAlpha(0.4);
const alpha = h.cesium
-34
View File
@@ -1,34 +0,0 @@
// The Global Context range pulses behind one seam: a disc the size of the
// range whose shader draws up to four pulse lines, their halos and the band
// fading inward behind each, with fronts and opacities that change every
// frame. The globe contract has no primitive for an animated fill (a ground
// set rebuilds on every commit), so each engine draws it through its own
// backend: render/cesiumPulses.js (a material disc at the rim height) and
// render/navaraPulses.js (a surface pass in Navara's view), which the Navara
// mode loads and passes in, as Navara modules reach the app only through
// its import(). This is the only awareness module that reads
// engine.extensions.
//
// An engine with no backend (the fake engine) draws no pulses, and the
// layer keeps its range ring static there.
import { createAwarenessCesiumPulses } from './cesiumPulses.js';
/**
* The pulse disc around `center` on `engine`, or null when the engine has no
* backend: `draw({ radii, alphas, level })` each frame with the fronts as
* fractions of `radiusM` and their opacities, `move(center)`, `destroy()`.
* @param {import('../../../globe/contract.js').GlobeEngine} engine
* @param {object} disc The disc: `center`, `radiusM`, rgba `color` (the
* band), `lineColor`, `haloColor`, `depth`, `lineWidth`, `haloWidth`.
* @param {{ navaraPulses?: (engine: object, navara: object, disc: object) => object }} [backends]
* `navaraPulses` is the Navara backend's factory (render/navaraPulses.js).
*/
export function createAwarenessPulseField(engine, disc, { navaraPulses } = {}) {
const cesium = engine.extensions?.cesium;
const navara = engine.extensions?.navara;
if (cesium) return createAwarenessCesiumPulses(cesium, disc);
if (navara?.view && navara.EffectDesc && navara.NavaraEffect && navaraPulses)
return navaraPulses(engine, navara, disc);
return null;
}
+19 -25
View File
@@ -1,13 +1,5 @@
// The Bhote Koshi flood event and its locator on the globe contract, the
// same on every engine. The event's before and after imagery are two
// engine.imagery layers of the event's image URLs over one rectangle, at
// alpha 0.96, the before image left of the engine's split position and the
// after image right of it. The story graphics (the flood corridor, the
// surge front, the locator's border, path and rings) are Cesium entities
// the layers sample (src/data/bhoteKoshiEvent.js, bhoteKoshiLocator.js):
// Cesium draws them itself; another engine draws them through its
// story backend (`drawStory`, src/layers/shared/render/navaraStory.js on
// Navara), which the manifest passes in.
// The story's imagery, ground graphics and points on the globe contract.
import { drawStorySource } from './story.js';
const IMAGERY_ALPHA = 0.96;
// Over Recent Imagery (10), under the weather (20 and up).
@@ -24,13 +16,13 @@ let events = 0;
/**
* The event layer's renderer (bhoteKoshiEvent.js `createNativeRenderer`).
* @param {import('../../../globe/contract.js').GlobeEngine} engine
* @param {{ drawStory?: (engine: object, dataSource: object) => { destroy: () => void } }} [options]
*/
export function createBhoteKoshiDisplay(engine, { drawStory = null } = {}) {
export function createBhoteKoshiDisplay(engine) {
if (!engine?.imagery)
throw new TypeError('The Bhote Koshi display needs a globe engine');
const id = `bhote-koshi-imagery-${++events}`;
let layers = [];
const stories = new Set();
let visible = true;
let loads = 0;
let destroyed = false;
@@ -41,9 +33,16 @@ export function createBhoteKoshiDisplay(engine, { drawStory = null } = {}) {
engine.render.request('nepal-imagery');
}
return {
/** The story's entities through the engine's backend, or null: the
* layer adds them to Cesium. */
draw: (dataSource) => drawStory?.(engine, dataSource) ?? null,
draw(dataSource) {
const story = drawStorySource(engine, dataSource);
stories.add(story);
return {
destroy() {
story.destroy();
stories.delete(story);
},
};
},
/**
* The before and after images over `box` (degrees); resolves once both
* draw, rejects when one would not load (unless replaced or cleared
@@ -102,19 +101,14 @@ export function createBhoteKoshiDisplay(engine, { drawStory = null } = {}) {
clear,
destroy() {
destroyed = true;
for (const story of stories) story.destroy();
stories.clear();
clear();
},
};
}
/**
* The locator layer's renderer (bhoteKoshiLocator.js `createNativeRenderer`):
* its story source through the engine's backend; null where Cesium
* draws it.
*/
export function createBhoteKoshiLocatorDisplay(
engine,
{ data, drawStory = null } = {},
) {
return drawStory ? drawStory(engine, data) : null;
/** The locator's border, flood path and callouts on the same display. */
export function createBhoteKoshiLocatorDisplay(engine, { data } = {}) {
return drawStorySource(engine, data);
}
+15 -22
View File
@@ -1,6 +1,4 @@
// The Bhote Koshi displays on the fake engine: the event's before and after
// images split at the engine's split position; the story through the
// engine's backend, or none.
// The Bhote Koshi imagery and story lifetimes on the fake engine.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
@@ -33,12 +31,12 @@ test('the before and after images split left and right at alpha 0.96', async ()
.imageryLayers()
.every((id) => engine.fake.imageryLayer(id).shown === false),
);
assert.equal(display.draw({ entities: { values: [] } }), null, 'no backend');
assert.ok(display.draw({ entities: { values: [] } }));
display.destroy();
assert.deepEqual(engine.fake.imageryLayers(), []);
});
test('an image that will not load rejects; the story draws through the backend', async () => {
test('an image that will not load rejects; story displays release their frame listeners', async () => {
const engine = createFakeEngine();
const stage = engine.imagery.createLayer;
// A layer whose staged source fails, as an engine's whose image 404s.
@@ -47,25 +45,20 @@ test('an image that will not load rejects; the story draws through the backend',
layer.setSource = async () => false;
return layer;
};
const drawn = [];
const display = createBhoteKoshiDisplay(engine, {
drawStory: (target, data) => {
drawn.push([target, data]);
return { destroy() {} };
},
});
const display = createBhoteKoshiDisplay(engine);
await assert.rejects(
display.loadImages(BOX, ['pre.webp', 'post.webp']),
/imagery unavailable/,
);
const data = { name: 'flood' };
assert.ok(display.draw(data));
assert.deepEqual(drawn, [[engine, data]]);
assert.equal(createBhoteKoshiLocatorDisplay(engine, { data }), null);
assert.ok(
createBhoteKoshiLocatorDisplay(engine, {
data,
drawStory: () => ({ destroy() {} }),
}),
);
const data = { name: 'flood', entities: { values: [] } };
const before = engine.frames.preRender.numberOfListeners;
const story = display.draw(data);
const locator = createBhoteKoshiLocatorDisplay(engine, { data });
assert.equal(engine.frames.preRender.numberOfListeners, before + 2);
story.destroy();
locator.destroy();
assert.equal(engine.frames.preRender.numberOfListeners, before);
display.draw(data);
display.destroy();
assert.equal(engine.frames.preRender.numberOfListeners, before);
});
+201
View File
@@ -0,0 +1,201 @@
// Samples the authored story graphics into ground sets and point movers.
// The story owns reveal time; this display owns only its attached drawing.
import * as geo from '../../../geo/index.js';
const value = (property) =>
property?.getValue ? property.getValue() : property;
const rgba = (color) => [color.red, color.green, color.blue, color.alpha];
const lonLat = (position) => {
const place = geo.cartographic.fromCartesian(position);
return [
geo.math.toDegrees(place.longitude),
geo.math.toDegrees(place.latitude),
];
};
const drape = (graphics) => {
const classification = value(graphics.classificationType);
return classification === 0
? 'terrain'
: classification === 1
? 'tiles'
: 'both';
};
export function drawStorySource(engine, data) {
const entries = new Map();
let order = 0;
let destroyed = false;
function point(entity) {
const graphics = entity.point;
const distance = value(graphics.scaleByDistance);
const depth = value(graphics.disableDepthTestDistance);
const set = engine.movers.createSet({
id: `${data.name}-${entity.id}`,
kind: 'point',
capacity: 1,
pickable: true,
depthTest:
depth === Infinity
? 'always'
: depth > 0
? { disableWithinM: depth }
: 'normal',
point: {
sizePx: 1,
outlinePx: value(graphics.outlineWidth) ?? 0,
scaleByDistance: distance
? [distance.near, distance.nearValue, distance.far, distance.farValue]
: null,
},
});
let previous;
return {
paint(show) {
let position = value(entity.position);
show = Boolean(show && value(graphics.show) !== false && position);
if (show && value(graphics.heightReference) === 1) {
const place = geo.cartographic.fromCartesian(position);
const height =
engine.surface.sampleHeight(place, [set]) ??
engine.surface.terrainHeight(place);
if (Number.isFinite(height))
position = geo.cartesian3.fromRadians(
place.longitude,
place.latitude,
height,
);
}
const fields = show
? {
position,
pickId: entity,
color: rgba(value(graphics.color)),
outlineColor: rgba(
value(graphics.outlineColor) ?? {
red: 0,
green: 0,
blue: 0,
alpha: 1,
},
),
scale: value(graphics.pixelSize) ?? 1,
show: true,
}
: { show: false };
const snapshot = JSON.stringify(fields, (key, item) =>
key === 'pickId' ? undefined : item,
);
if (snapshot === previous) return;
if (show) set.set(0, fields);
set.setCount(show ? 1 : 0);
set.commit();
previous = snapshot;
},
destroy: () => set.destroy(),
};
}
function geometry(entity) {
const polygon = Boolean(entity.polygon);
const graphics = polygon ? entity.polygon : entity.polyline;
const set = engine.ground.createSet({
id: `${data.name}-${entity.id}`,
kind: polygon ? 'polygon' : 'corridor',
order: order++,
drape: drape(graphics),
pickable: true,
dynamic: !polygon && graphics.positions?.isConstant === false,
});
let previous;
let visible = true;
return {
paint(show) {
const hierarchy = polygon ? value(graphics.hierarchy) : null;
const path = polygon ? hierarchy?.positions : value(graphics.positions);
show = Boolean(
show &&
value(graphics.show) !== false &&
path?.length >= (polygon ? 3 : 2),
);
if (visible !== show) set.show((visible = show));
if (!show) return;
const material = value(graphics.material);
const color = rgba(material?.color ?? material);
let item;
if (polygon)
item = {
rings: [
path.map(lonLat),
...(hierarchy.holes ?? []).map((hole) =>
hole.positions.map(lonLat),
),
],
fill: color,
};
else
item = {
positions: path.map(lonLat),
color,
widthPx: value(graphics.width) ?? 1,
...(material.glowPower !== undefined
? {
glow: {
power: material.glowPower,
taper: material.taperPower,
},
}
: {}),
...(material.dashLength !== undefined
? {
dashPattern: {
lengthPx: material.dashLength,
pattern: material.dashPattern,
},
}
: {}),
};
item.pickId = entity;
const snapshot = JSON.stringify(item, (key, value) =>
key === 'pickId' ? undefined : value,
);
if (snapshot === previous) return;
set.replace([item]);
set.commit();
previous = snapshot;
},
destroy: () => set.destroy(),
};
}
function frame() {
if (destroyed) return;
const live = new Set(data.entities.values);
for (const [entity, item] of entries) {
if (live.has(entity)) continue;
item.destroy();
entries.delete(entity);
}
for (const entity of live) {
let item = entries.get(entity);
if (!item) {
if (!entity.point && !entity.polyline && !entity.polygon) continue;
item = entity.point ? point(entity) : geometry(entity);
entries.set(entity, item);
}
item.paint(data.show !== false && value(entity.show) !== false);
}
}
const off = engine.frames.preRender.addEventListener(frame);
engine.render.request(data.name);
return {
destroy() {
if (destroyed) return;
destroyed = true;
off();
for (const item of entries.values()) item.destroy();
entries.clear();
engine.render.request(data.name);
},
};
}
+216
View File
@@ -0,0 +1,216 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { GROUND_HARNESSES } from '../../../testSupport/groundDisplayEngines.mjs';
import { drawStorySource } from './story.js';
for (const make of GROUND_HARNESSES) {
const name = make.name;
test(`story reveal, materials, holes and lifecycle (${name})`, (t) => {
const h = make();
t.after(() => h.destroy());
const source = new Cesium.CustomDataSource('story');
let path = Cesium.Cartesian3.fromDegreesArray([85, 28, 85.1, 28.1]);
let alpha = 0.38;
const halo = source.entities.add({
id: 'halo',
polyline: {
positions: new Cesium.CallbackProperty(() => path, false),
width: 18,
classificationType: Cesium.ClassificationType.TERRAIN,
material: new Cesium.PolylineDashMaterialProperty({
color: new Cesium.CallbackProperty(
() => new Cesium.Color(0.8, 0.5, 0.3, alpha),
false,
),
dashLength: 18,
}),
},
});
source.entities.add({
id: 'core',
polyline: {
positions: new Cesium.CallbackProperty(() => path, false),
width: 5,
material: new Cesium.PolylineGlowMaterialProperty({
color: Cesium.Color.CYAN.withAlpha(0.96),
glowPower: 0.24,
taperPower: 0.7,
}),
},
});
const ring = Cesium.Cartesian3.fromDegreesArray([
85, 28, 86, 28, 86, 29, 85, 29,
]);
const hole = Cesium.Cartesian3.fromDegreesArray([
85.2, 28.2, 85.8, 28.2, 85.8, 28.8,
]);
source.entities.add({
id: 'fill',
polygon: {
hierarchy: new Cesium.PolygonHierarchy(ring, [
new Cesium.PolygonHierarchy(hole),
]),
material: Cesium.Color.CYAN.withAlpha(0.055),
},
});
const before = h.engine.frames.preRender.numberOfListeners;
const display = drawStorySource(h.engine, source);
const frame = () =>
h.cesium ? h.viewer.scene.preRender.raiseEvent() : h.engine.fake.frame();
frame();
assert.equal(h.sets(), 3);
const haloSet = h.set('story-halo');
assert.equal(haloSet.options.dynamic, true);
assert.equal(haloSet.options.drape, 'terrain');
assert.equal(haloSet.options.pickable, true);
assert.equal(haloSet.items[0].pickId, halo);
let picked;
const offPick = h.engine.input.on(
'click',
(event) => {
picked = event.picked?.id;
},
{ pick: true },
);
h.click({ set: 'story-halo', instance: 0 });
assert.equal(picked, halo);
offPick();
assert.equal(haloSet.items[0].widthPx, 18);
assert.deepEqual(haloSet.items[0].dashPattern, {
lengthPx: 18,
pattern: 255,
});
assert.deepEqual(h.set('story-core').items[0].glow, {
power: 0.24,
taper: 0.7,
});
assert.equal(h.set('story-fill').items[0].rings.length, 2);
assert.equal(h.set('story-fill').items[0].fill[3], 0.055);
if (h.cesium) {
const native = h.primitives('story-halo').lines[0].options;
assert.equal(
native.asynchronous,
false,
'reveal geometry builds within the frame',
);
assert.equal(native.appearance.material.type, 'PolylineDash');
assert.equal(native.appearance.material.uniforms.dashLength, 18);
const core =
h.primitives('story-core').lines[0].options.appearance.material
.uniforms;
assert.equal(core.glowPower, 0.24);
assert.equal(core.taperPower, 0.7);
}
frame();
assert.equal(
h.set('story-halo').commits,
1,
'settled paths do not rebuild',
);
path.push(Cesium.Cartesian3.fromDegrees(85.2, 28.2));
frame();
assert.equal(
h.set('story-halo').items[0].positions.length,
3,
'in-place reveal advances',
);
alpha = 0.2;
frame();
assert.equal(
h.set('story-halo').items[0].color[3],
0.2,
'style-only changes draw',
);
source.show = false;
frame();
assert.ok(h.engine.ground.list().every((set) => !set.shown));
source.show = true;
halo.show = false;
frame();
assert.equal(h.set('story-halo').shown, false);
assert.equal(h.set('story-core').shown, true);
path = [];
frame();
assert.equal(
h.set('story-core').shown,
false,
'empty reveal hides the corridor',
);
source.entities.removeAll();
frame();
assert.equal(h.sets(), 0);
display.destroy();
display.destroy();
assert.equal(h.engine.frames.preRender.numberOfListeners, before);
});
test(`story points keep independent fill and outline animation (${name})`, (t) => {
const h = make();
t.after(() => h.destroy());
const source = new Cesium.CustomDataSource('points');
let size = 11;
let outline = 0.85;
source.entities.add({
id: 'front',
position: Cesium.Cartesian3.fromDegrees(85, 28, 900),
point: {
pixelSize: new Cesium.CallbackProperty(() => size, false),
color: Cesium.Color.CYAN.withAlpha(0.2),
outlineColor: new Cesium.CallbackProperty(
() => Cesium.Color.BLACK.withAlpha(outline),
false,
),
outlineWidth: 3,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Infinity,
scaleByDistance: new Cesium.NearFarScalar(100, 1, 100000, 0.8),
},
});
const writes = [];
const create = h.engine.movers.createSet;
let options, pointSet;
h.engine.movers.createSet = (input) => {
options = input;
const set = create(input);
pointSet = set;
const write = set.set;
set.set = (index, fields) => {
writes.push(fields);
write(index, fields);
};
return set;
};
h.engine.surface.sampleHeight = (_place, exclude) => {
assert.deepEqual(exclude, [pointSet]);
return 1200;
};
const display = drawStorySource(h.engine, source);
const frame = () =>
h.cesium ? h.viewer.scene.preRender.raiseEvent() : h.engine.fake.frame();
frame();
assert.equal(options.kind, 'point');
assert.equal(options.depthTest, 'always');
assert.deepEqual(options.point.scaleByDistance, [100, 1, 100000, 0.8]);
assert.equal(options.point.outlinePx, 3);
assert.ok(
Math.abs(
Cesium.Cartographic.fromCartesian(writes[0].position).height - 1200,
) < 1e-6,
);
frame();
assert.equal(writes.length, 1);
size = 38;
outline = 0.12;
frame();
assert.equal(writes.at(-1).scale, 38);
assert.deepEqual(writes.at(-1).outlineColor, [0, 0, 0, 0.12]);
assert.equal(writes.at(-1).color[3], 0.2);
display.destroy();
assert.equal(h.engine.movers.list().length, 0);
const attached = drawStorySource(h.engine, source);
frame();
assert.equal(h.engine.movers.list().length, 1);
attached.destroy();
});
}
-173
View File
@@ -1,173 +0,0 @@
import * as geo from '../../../geo/index.js';
const val = (p) => (p?.getValue ? p.getValue() : p);
const rgba = (c) => [c.red, c.green, c.blue, c.alpha];
const tint = (c) =>
(Math.round(c.red * 255) << 16) |
(Math.round(c.green * 255) << 8) |
Math.round(c.blue * 255);
function samePath(old, points) {
return (
old?.length === points.length &&
points.every(
(p, i) => p.x === old[i].x && p.y === old[i].y && p.z === old[i].z,
)
);
}
function circleAtlas() {
const canvas = document.createElement('canvas');
canvas.width = 128;
canvas.height = 64;
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'white';
ctx.beginPath();
ctx.arc(32, 32, 29, 0, Math.PI * 2);
ctx.fill();
ctx.lineWidth = 5;
ctx.strokeStyle = 'white';
ctx.beginPath();
ctx.arc(96, 32, 27, 0, Math.PI * 2);
ctx.stroke();
return { canvas, cells: { fill: [0, 0, 0.5, 1], rim: [0.5, 0, 1, 1] } };
}
// The authored story owns the clock and geographic graphics. This adapter only samples them
// while its scene is attached. Neither a Cesium viewer nor an additional globe is needed.
export function drawStorySource(engine, { view, NavaraColor }, data) {
const entries = new Map();
let atlas;
// Story graphics encode map colors. Compensate the planet's photographic
// exposure before its tone-mapping pass so a cyan overlay stays readable.
function displayColor(hex) {
const c = new NavaraColor().setHex(hex);
const exposure = Math.max(1, view.toneMappingExposure ?? 1);
const [r, g, b] = c.toArray();
return c.setRGBLinear(r / exposure, g / exposure, b / exposure);
}
function point(entity) {
const p = entity.point,
nearFar = val(p.scaleByDistance),
depth = val(p.disableDepthTestDistance);
const set = engine.movers.createSet({
id: data.name + '-' + entity.id,
kind: 'sprite',
capacity: 2,
pickable: false,
depthTest: depth === Infinity ? 'always' : 'normal',
sprite: {
sizePx: 64,
atlas: (atlas ??= circleAtlas()),
scaleByDistance: nearFar
? [nearFar.near, nearFar.nearValue, nearFar.far, nearFar.farValue]
: null,
},
});
return {
paint(show) {
const position = val(entity.position);
if (!show || !position) {
set.setCount(0);
set.commit();
return;
}
let at = position;
if (val(p.heightReference) === 1) {
const carto = geo.cartographic.fromCartesian(position);
const h = engine.surface.terrainHeight(carto);
if (Number.isFinite(h))
at = geo.cartesian3.fromRadians(carto.longitude, carto.latitude, h);
}
const scale = Number(val(p.pixelSize)) / 58;
const color = val(p.color),
outline = val(p.outlineColor);
set.set(0, {
position: at,
show: true,
cell: 'fill',
color: rgba(color),
scale,
});
const hasOutline = Boolean(outline && val(p.outlineWidth));
if (hasOutline)
set.set(1, {
position: at,
show: true,
cell: 'rim',
color: rgba(outline),
scale,
});
set.setCount(hasOutline ? 2 : 1);
set.commit();
},
destroy: () => set.destroy(),
};
}
function geometry(entity) {
const key = entity.polyline ? 'polyline' : 'polygon',
g = entity[key];
let handle,
previous = null,
visible = null;
return {
paint(show) {
const path =
key === 'polygon' ? val(g.hierarchy)?.positions : val(g.positions);
show = Boolean(show && path?.length >= (key === 'polygon' ? 3 : 2));
if (!show) {
if (handle && visible) handle.update({ visible: false });
visible = false;
return;
}
if (!handle || !samePath(previous, path)) {
const color = val(g.material)?.color ?? val(g.material?.color);
previous = path.map(({ x, y, z }) => ({ x, y, z }));
const geom = {
points: previous,
clampToGround: true,
color: displayColor(color ? tint(color) : 0x20e7f2),
opacity: color?.alpha ?? 1,
transparent: true,
...(key === 'polyline' ? { width: val(g.width) ?? 2 } : {}),
};
if (handle) handle.update({ [key]: geom, visible: true });
else
handle = view.addMesh({
[key]: geom,
visible: true,
lit: false,
pickable: false,
});
} else if (!visible) handle.update({ visible: true });
visible = true;
},
destroy: () => handle?.delete(),
};
}
function frame() {
const live = new Set(data.entities.values);
for (const [e, item] of entries)
if (!live.has(e)) {
item.destroy();
entries.delete(e);
}
for (const e of live) {
let item = entries.get(e);
if (!item) {
if (!e.point && !e.polyline && !e.polygon) continue;
item = e.point ? point(e) : geometry(e);
entries.set(e, item);
}
item.paint(data.show !== false && val(e.show) !== false);
}
}
const off = engine.frames.preRender.addEventListener(frame);
engine.render.request(data.name);
return {
destroy() {
off();
for (const item of entries.values()) item.destroy();
entries.clear();
engine.render.request(data.name);
},
};
}
+4 -3
View File
@@ -1,10 +1,10 @@
import {
bakeWindStreamlines,
WIND_DISPLAY_HEIGHT_METERS,
WIND_NARROW_PATH_LIMIT,
WIND_PATH_LIMIT,
} from '../streamlines.js';
import { sameWind } from '../model.js';
import { createWindFlowMesh } from './flowMesh.js';
const clamp = (n) => Math.max(0, Math.min(1, n));
@@ -14,7 +14,7 @@ const clamp = (n) => Math.max(0, Math.min(1, n));
* in with the camera's height and moved by the page clock while the scene
* is shown and motion is allowed. The colour field is the display's.
*/
export function createWindNavaraFlow(engine, native) {
export function createWindNavaraFlow(engine, native, createWindFlowMesh) {
const canvas = engine.host.canvas,
media = matchMedia('(prefers-reduced-motion: reduce)');
let snapshot = null,
@@ -37,7 +37,8 @@ export function createWindNavaraFlow(engine, native) {
budget = limit();
const baked = bakeWindStreamlines(field, { count: budget });
paths = baked.length;
if (paths) mesh = createWindFlowMesh(native, baked);
if (paths)
mesh = createWindFlowMesh(native, baked, WIND_DISPLAY_HEIGHT_METERS);
};
const frame = () => {
if (!running || document.hidden) {