Enforce engine SDK boundaries and rehearse every layer display

Restrict runtime SDK imports to their engine directories, including package
subpaths, re-exports and dynamic imports. Keep three measured, reasoned
exceptions for the Bhote Koshi property-record models and reject stale
exceptions. Move the Cesium annotation renderers and both wind adapters to
their engine folders, preserving their implementations and lazy loading.

Load all 35 manifest display entries on a fake engine with an unknown kind
and no native extensions. Draw small fixtures and verify resource and
subscription cleanup before destroying the engine. Name the three entries
without display factories as skips. Update the architecture, infrastructure
lifecycle and engine integration documentation to describe the code today.

Cesium behavior is unchanged, and Navara keeps its existing behavior. The
weather comparison harness supplies the Navara flow-mesh factory and accepts
the previous backend location for comparison runs. No user-visible strings
change.

Cesium layer-renderer lines fall from 14 to 0, Navara layer-renderer lines
from 314 to 189, outside-engine Cesium import files from 5 to 3, viewer lines
from 933 to 910, extension reads from 4 to 3, and engine-name lines from 36
to 33. No coupling count grows. Targeted checks pass: 845 tests, three named
rehearsal skips. Import directions, package boundaries, formatting and syntax
checks pass.

Checked in the browser, keyless: the contract suite passes on Navara and on
Cesium apart from the camera-events check that also fails intermittently
before; Navara annotations pass across engine switches; the ground-look
comparison is pixel-identical. qa-draw-tool's hidden-line check and the wind
engine switch fail the same way before this change.
This commit is contained in:
German Khokhlov committed 2026-11-07 16:06:43 +04:00
1 parent 656943641c
commit 52c6f68bd5
31 files changed
+1145 -208

No files matched your search

+40 -3
View File
@@ -30,7 +30,7 @@ export inventory. Use declared exports rather than reaching into internal files.
| `src/standalone/` | Select the default catalog, local sources and setup controls |
| `src/ui/` | Navigation generations, restoration, visual state, panel snapshots and their subscriptions |
| `src/data/` | Lifecycle, context and feed state; legacy default-layer facades |
| `src/layers/<family>/` | Source acquisition, records and Cesium resources with explicit controller/renderer owners |
| `src/layers/<family>/` | Source acquisition, records and shared contract displays with explicit lifecycle owners |
| `src/sources/` | Portable protocols and source contracts; request state belongs to each factory instance |
| `src/geo/` | Engine-neutral WGS84 maths, ported from Cesium, never imports Cesium |
| `src/globe/` | Engine-neutral globe contract and its test support; engines only in `globe/cesium`, `globe/navara` |
@@ -77,6 +77,24 @@ Navara stays out of the Cesium-mode bundle. Common voice controls cannot depend
Realtime protocol implementation. Negative fixtures cover indirect helpers,
self-package imports, symlinks and unreachable files.
Runtime SDK imports are restricted by directory, including unreachable modules:
`cesium` and `@cesium/*` belong in `src/globe/cesium/`; `three`,
`postprocessing` and `@navaramap/*` belong in `src/globe/navara/`. Package
subpaths, re-exports and literal dynamic imports obey the same rule. Tests and
Node test support retain their existing exemptions. The production script
loader is `src/app/loadCesium.js`; it imports no SDK package and needs no
exception. There is no blanket exception for layer `render/` directories.
The temporary `engineImportExceptions` list in `scripts/package-boundaries.json`
contains exact file/package pairs with reasons. The checker reports the used
entries and rejects duplicates, missing reasons and unused entries. Its three
exceptions are the Bhote Koshi event, locator and embedded-media models in
`src/data/`, which still construct Cesium property records. Their drawing uses
shared displays. Exceptions do not relax the portable, contract or cross-engine
graph checks. Cesium annotation renderers live in `src/globe/cesium/annotations/`;
wind backend adapters live in their engine folders. Wind's older injected-SDK
helpers and the remaining annotation migration are separate debt.
Source-only factories use dedicated `layers/<family>/source` exports where
available, preserving the same factory implementation without loading rendering.
Record normalization and endpoint policies have plain owners separate from
@@ -560,12 +578,22 @@ services into each family factory. Standalone provider selection lives in
`src/standalone/layerSources.js`. The construction export has its own checked
dependency graph, excluding standalone setup and compatibility layer instances.
`src/app/layers/renderers.js` is the layer manifest. Its lazy display loaders
resolve the shared factories exported by `displays.js`; `needs` names required
contract primitives. The only display backend options are wind streamlines,
loaded from the engine folders. A missing wind backend leaves the raster field
available. Wildlife Sightings draws itself through the contract; Sea Marks and
Ski Pistes use their overlay controller. No layer display imports an engine SDK.
`displayRehearsal.test.mjs` loads every manifest display on the fake engine with
an unknown kind and no extensions, draws a small fixture and checks cleanup.
Both aircraft layers share the catalog's classification registry; launches use
its satellites and Contacts uses its aircraft, vessels and installations. Data
registration, controls and voice actions read those same instances. Destruction
remains the manager's responsibility; classification also observes application
abort when startup has not reached registration. Scene engines remain page-owned,
so this change does not introduce multiple simultaneous viewers.
abort when startup has not reached registration. The session retains models while
scene attachment builds displays on the active engine. A parked engine can remain
allocated for a warm switch; its layer drawing is detached.
Direct `src/data` compatibility entries retain their old defaults and testing
exports. Browser regression probes use the registered instance's testing surface
@@ -604,6 +632,15 @@ listener cleanup. Application data assembly constructs both owners explicitly;
`data/manager` is the compatibility facade for direct callers. The ordinary
state subscriptions retain their existing event contract.
`data/layerSceneBinding.js` owns renderer attach/detach, asynchronous settlement,
parking and failed-release retries. Lifecycle keeps visibility intentions and
refresh policy. `app/layerSession.js` defines the retained session fields and
handoff predicate. `standalone/sceneSwitcher.js` coordinates page handoff;
`ui/shellScene.js` owns scene-bound shell construction, while `applicationShell`
keeps page controls and policy. Request, host and shell queues are not yet one
queue. Overlay, render-governor, tracked-readout and pick services still include
module singletons; debug access still exposes the native viewer and tileset.
## UI state owners
- `ui/navigation` owns navigation generations, pending search presentation and
+22 -12
View File
@@ -260,25 +260,34 @@ one application, and only the globe-bound parts move to the next engine.
ground passes, Cesium SDK loads and `ScreenSpaceEventHandler` users.
`scripts/engineCoupling.test.mjs` (in `npm test`) fails when a count grows
past `scripts/engine-coupling-baseline.json`.
- **SDK boundary and display rehearsal.** The import-direction gate permits
Cesium packages only in `src/globe/cesium/` and three, postprocessing and
Navara packages only in `src/globe/navara/`. The measured temporary exceptions
in `scripts/package-boundaries.json` are the three Bhote Koshi model modules:
event, locator and embedded media still build Cesium property records. The
gate rejects unused exceptions. `src/app/layers/displayRehearsal.test.mjs`
loads all 35 display entries from the manifest on a fake engine with an
unknown kind and no native extensions, draws fixtures, hides or releases them,
and checks primitives, subscriptions and service records before engine
destruction. The three self-drawing entries are named skips. This is a
display-level rehearsal; application kind registration, scene-service
singletons, queue consolidation and native debug access remain work.
- **The session owns the state.** Catalog, layer models, sources, selection,
camera mode and URL state outlive a switch. A layer that is a session model
gets `attachRenderer(engine)` / `detachRenderer()` instead of being rebuilt,
gets `attachRenderer(viewer, { engine })` / `detachRenderer()` instead of being rebuilt,
so its data is not fetched again. The outgoing globe is parked
(`src/standalone/rendererCache.js`), so switching back within three minutes
is warm (60-90 ms). After that the parked globe is disposed and its WebGL
context lost, which frees about 1.1 GiB for a parked Navara; switching back
then builds it cold (1.5-2 s). `?parkedMs=` shortens the delay for QA
(`scripts/qa-strict-parked-release.mjs`).
- **Renderers per engine.** Each layer keeps its model and display policy in
engine-neutral files and draws through `render/cesium*.js` or
`render/navara*.js`, or through one `render/display.js` that runs on every
engine over the globe contract: the aircraft family (flights, military and
local ADS-B) through `src/layers/aircraft/render/display.js`, and AIS
vessels, satellites, Space Missions, ALPR, bikeshare, OSM Places,
Wildlife Sightings, radio, transit, CCTV, earthquakes, air quality, GNSS
Integrity, fire perimeters, Prohibited & Restricted Airspace, Active Fires,
S2 GhostMaps, cyclone advisories, submarine cables, Mapped Installations,
Global Context, datacenters and dams through their own `render/display.js`;
- **Shared layer displays.** Every manifest display draws through the globe
contract from `render/display.js`; the aircraft family shares its display
implementation. There are 35 display entries (including the three weather
products and two infrastructure datasets). Wildlife Sightings draws through
the contract inside its layer; Sea Marks and Ski Pistes use their overlay
controller. Wind streamlines retain backend adapters in
`src/globe/cesium/windFlow.js` and `src/globe/navara/windFlow.js`;
the fills of the ground layers, installation outlines and the selection fill
are `engine.ground` sets. Global Context's range pulses use
`engine.ground.createPulseField`: the same fronts, halos, inward bands,
@@ -295,7 +304,8 @@ one application, and only the globe-bound parts move to the next engine.
backend per engine. Data packs share `src/scenes/dataPacks/display.js`:
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.
remain on their existing renderers pending bounded classification support;
the Cesium implementations live in `src/globe/cesium/annotations/`.
- **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
+66 -4
View File
@@ -489,8 +489,9 @@ near the camera from one 8×8 atlas over the whole view.
the globe at order 20, faded with the camera's height while streamlines
draw, raised at 5 km on 3D tiles. The streamlines stay each engine's,
behind the display's `flow` backend, loaded by the manifest:
`render/cesiumFlow.js` supplies Cesium's streamlines (`rendering.js`),
and `render/navaraFlow.js` supplies Navara's flow mesh.
`src/globe/cesium/windFlow.js` supplies Cesium's streamlines
(`src/layers/wind/rendering.js`), and `src/globe/navara/windFlow.js`
supplies Navara's flow mesh.
- **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
@@ -587,8 +588,8 @@ culling and no depth writes. Colour, image and visibility changes retain the
primitive; geometry, pick-id and depth-test changes replace it. The engine
requests frames while the primitive or its image is preparing. Navara draws
local coordinates relative to a double-precision world origin in its
scene pass, with scene depth and triangle picking. Its meshes receive the
scene effects, as Cesium primitives do.
transparent scene pass after tone mapping, with scene depth and triangle
picking. Their authored colours are blended in display space.
Both engines own geometry, material and texture disposal. The fake engine
keeps the same state and tests triangle intersections for picks.
@@ -633,3 +634,64 @@ uses the selected colour as well as the visible band. Finished geometry still
goes through the annotation board. Detaching the scene releases the claim,
listeners and preview while retaining the chosen shape and colour. The
page's pointer lease still arbitrates Draw against other tools.
## How to add an engine
Put the implementation in a new `src/globe/<name>/` folder. Keep SDK classes,
shaders, resource disposal and SDK workarounds there. Use
`src/globe/testing/fakeEngine.js` as the contract model, and
`src/globe/contract.js` as the authoritative member and data-shape reference.
Coordinates, colours, picks and display options crossing the boundary are plain
data; displays do not receive native viewers or SDK types.
Implement the required host, camera, picker, projector, frame, render, surface,
input, credit, clock and map interfaces, plus all five drawing primitives:
`movers`, `lines`, `ground`, `imagery` and `meshes`. `engineCapabilities(engine)`
reports these and the optional visualStyles, bloom, cloudCover and contactSonar
groups. An optional group must be complete or absent. Do not advertise a
capability with a no-op implementation. Match Cesium's behavior where the
contract permits both engines to implement it.
Run `createContractSuite(engine, { waitFrames, ... })` from
`src/globe/testing/contractSuite.mjs`. Supply real pointer delivery, canvases and
pixel reads for browser checks; keep the fake-engine tests as fast contract
checks. Test ownership and repeated destruction as well as visible output.
The browser harness is `scripts/qa-contract-suite.mjs`; ground and imagery pixel
checks also run through `scripts/qa-ground-suite.mjs`.
The application currently registers two kinds: adding another still requires
registration in `ENGINE_KINDS`, scene construction/choice and the corresponding
QA harnesses. `assertGlobeEngine` currently validates that registration. The
folder and passing contract suite are the implementation boundary, not a claim
that arbitrary engine folders are auto-discovered today. Layers and their
fixtures should need no new drawing implementation.
The layer manifest (`src/app/layers/renderers.js`) loads shared displays and
lists their `needs`. Today every valid engine must implement all five
primitives; availability is checked against the registered kind's primitive
list. `backends` supplies optional engine-specific display options, currently
only wind's `flow`. Load a backend dynamically from its engine folder. A new
engine can omit wind streamlines and still draw the shared raster field. Do not
copy another engine's extensions into a new engine. The self-drawing map overlay
entries currently list supported kinds explicitly and need registration too.
Run `src/app/layers/displayRehearsal.test.mjs`: it loads every manifest display
on the fake engine with an unknown kind and empty extensions. Its small fixtures
exercise attachment, drawing, hiding or release, and destruction; primitive
registries, frame/camera/input subscriptions, follow state and service records
must be clean before the engine itself is destroyed. New display entries need a
fixture. Wildlife Sightings and the two map overlays are explicit skips because
they have no display factory; their layer tests remain the coverage for those
controllers. Native wind streamlines, DOM rasterization and GPU pixels are not
covered by this rehearsal.
`node scripts/check-import-directions.mjs` enforces SDK ownership for static
imports, re-exports and literal dynamic imports, including package subpaths.
Cesium packages belong in `src/globe/cesium/`; three, postprocessing and Navara
packages belong in `src/globe/navara/`. Add the new SDK's ownership rule when
registering it. Contract graphs cannot reach any engine, and the existing
engines cannot reach one another. The temporary exact file/package exceptions
in `scripts/package-boundaries.json` must have reasons and must still be used;
the checker prints them. They are migration debt, not a pattern for new layers.
Run package boundaries, formatting, the full unit suite and the coupling report
as well; engine coupling counts may not grow.
+40 -23
View File
@@ -3,7 +3,7 @@
`gods-eye-view/infrastructure` exports `createInfrastructureLayers(services)`.
It returns fresh datacenter and dam layers in that order, with the existing IDs,
appearance, label budgets and bundled public datasets. Importing the module or
calling the factory does not load data, create a viewer, or start the application.
calling the factory does not load data, create an engine, or start the application.
The asset URLs resolve relative to the module; Vite rewrites them for production
base paths. This browser source export expects a Vite-compatible asset build.
@@ -21,7 +21,13 @@ const layers = createInfrastructureLayers({
governorRequestRender,
});
// Register these layers with the existing data-layer manager and viewer.
// Attach each layer to the current engine, then enable it when requested.
for (const layer of layers) layer.init(null, { engine });
await layers[0].enable();
// On a scene switch, keep the parsed data and replace only its display.
for (const layer of layers) layer.detachRenderer();
for (const layer of layers) await layer.attachRenderer(null, { engine: nextEngine });
```
| Function | Arguments / responsibility |
@@ -35,28 +41,39 @@ const layers = createInfrastructureLayers({
| `removeEntityContextsForLayer` | `(layerId)`; remove records owned by the layer on destruction or failed setup |
| `governorRequestRender` | `(reason)`; request a frame through the existing render scheduler |
Each returned layer supports `init(viewer)`, `enable(viewer)`, `disable(viewer)`,
`update(viewer)`, `destroy(viewer)`, `getStats()`, `getLodDiagnostics()` and
`getAnalystRecords()`.
Use the same viewer for the instance's entire lifetime. Each viewer/context/overlay
host permits one live instance per layer ID; destroy an old instance before
replacing it. Separate applications may use identical IDs with separate hosts.
Callbacks must be ready before enable and remain usable through destruction.
Each returned session layer supports `init(null, { engine })`,
`attachRenderer(null, { engine, signal })`, `detachRenderer()`, `enable()`,
`disable()`, `update()`, `destroy()`, `getStats()`, `getLodDiagnostics()` and
`getAnalystRecords(limit)`. The first argument is retained for lifecycle
compatibility; drawing uses the engine passed in the options. Attach once per
scene, detach before attaching another engine, and destroy when the session ends.
Separate applications may use identical IDs with separate context/overlay hosts.
Callbacks must remain usable through destruction.
Disable retains the loaded dataset for reuse and clears selection and listeners.
Concurrent enables share one load. Destroy is permanent, aborts pending fetches,
discards late results, and clears the owned data source, context records, overlays,
input handler and listeners. The layer never creates an application context store,
overlay host, viewer, or render scheduler. The consumer must resolve one compatible
Cesium installation shared with its viewer; it must not bundle separate copies.
`src/layers/infrastructure/index.js` owns the parsed dataset, shared in-flight
load, enabled intention and selection across scene switches. Enable loads on
first use. Disable hides the drawing and clears selection but retains parsed
data. Detach destroys scene resources and retains the dataset and selected ID;
reattach redraws enabled data and restores selection when no other selection
owns the context. The attachment signal cancels that scene's wait, not the
session's fetch. Destroy aborts the fetch, discards late results and releases
both data and drawing.
`gods-eye-view/infrastructure/geojson` exports the lower-level
`createLocalGeoJsonLayer(options, services)` and existing infrastructure overlay
helpers. `gods-eye-view/infrastructure/lod` exports the existing LOD policy helpers.
Consumers use the package exports, which do not import standalone application
globals.
`src/layers/infrastructure/render/display.js` draws the same records on every
engine: point movers for tips and pins, lines for stems, ground polygons for
footprints and ground corridors for paths. It owns the LOD walk, surface-height
refresh, picks, context handles and overlay publications. Destruction releases
its primitives and frame, camera and input subscriptions. The layer never
creates an overlay host, engine or render scheduler and does not import Cesium.
`gods-eye-view/infrastructure/geojson` exports the overlay publication,
selection, label and error helpers in `src/data/localGeojsonCore.js`; it no
longer exports `createLocalGeoJsonLayer`. `gods-eye-view/infrastructure/lod`
exports the LOD policy helpers. The package exports do not import standalone
application globals.
Dataset files and source/license notices remain under `src/data/local_data/`.
Their input counts are 4,352 datacenter features and 704 dam features. Cesium may
expand multipart geometries into multiple entities; entity and stem counts are
different measurements. This change does not refresh or relicense the datasets.
Their input counts are 4,352 datacenter features and 704 dam features. The session
splits multipart geometries using Cesium's stable repeated-id suffixes on every
engine. Feature, mover and stem counts are different measurements. These files
and licenses are independent of the renderer implementation.
+41 -65
View File
@@ -30,59 +30,6 @@ const globeEngine = (file) => /^src\/globe\/(?:cesium|navara)\//.test(file);
const cesiumPackage = /^(?:cesium(?:\/|$)|@cesium\/)/;
// Navara renders through three.js and its postprocessing peer.
const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/;
// Layers built on layer renderers keep each engine in their renderers:
// cesium only in render/cesium*.js, three and Navara only in
// render/navara*.js. The list grows with each layer moved onto renderers.
const RENDERED_LAYER_DIRS = [
'src/layers/streetLevel/',
'src/layers/traffic/',
'src/layers/directions/',
'src/layers/infrastructure/',
'src/layers/installations/',
'src/layers/awareness/',
'src/layers/droneAirspace/',
'src/layers/cyclones/',
'src/layers/ghostMaps/',
'src/layers/submarineCables/',
'src/layers/alpr/',
'src/layers/cctv/',
'src/layers/transit/',
'src/layers/airQuality/',
'src/layers/gnss/',
'src/layers/perimeters/',
'src/layers/radio/',
'src/layers/bikeshare/',
'src/layers/earthquakes/',
'src/layers/firms/',
'src/layers/flights/',
'src/layers/military/',
'src/layers/localAdsb/',
'src/layers/vessels/',
'src/layers/satellites/',
'src/layers/launches/',
'src/layers/aircraft/',
'src/layers/shared/',
];
const W1_SHARED_FILES = new Set(
[
'trackedCamera',
'trailRenderer',
'contactTrailRenderer',
'focusDeemphasis',
'spriteOrder',
'iconOrientation',
'modelVisualAnchor',
'trackingClickGesture',
].map((name) => `src/data/${name}.js`),
);
const renderedLayer = (file) =>
W1_SHARED_FILES.has(file) ||
file.startsWith('src/data/render/') ||
file === 'src/celestialRing.js' ||
file.startsWith('src/celestialRing/') ||
RENDERED_LAYER_DIRS.some((dir) => file.startsWith(dir));
const cesiumLayerRenderer = (file) =>
/\/render\/cesium(?:[^/]*|\/[^/]+)\.js$/.test(file);
// Navara renderers outside a layer's render/ directory: like those, they
// reach the app only through the Navara mode's import().
const NAVARA_SCENE_RENDERERS = new Set();
@@ -144,6 +91,25 @@ export function checkImportDirections(root) {
(file) =>
code.test(file) && !tests(file) && existsSync(path.join(root, file)),
);
const boundaryFile = path.join(root, 'scripts/package-boundaries.json');
const { engineImportExceptions = [] } = existsSync(boundaryFile)
? JSON.parse(readFileSync(boundaryFile, 'utf8'))
: {};
const exceptionKeys = new Set();
for (const item of engineImportExceptions) {
const key = `${item.file}:${item.package}`;
if (
typeof item.file !== 'string' ||
!files.includes(item.file) ||
!(cesiumPackage.test(item.package) || navaraPackage.test(item.package)) ||
typeof item.reason !== 'string' ||
!item.reason.trim() ||
exceptionKeys.has(key)
)
throw new Error(`Invalid engine import exception: ${key}`);
exceptionKeys.add(key);
}
const usedExceptions = new Set();
const errors = new Set();
const modules = new Map();
const relative = (file) =>
@@ -185,17 +151,18 @@ export function checkImportDirections(root) {
target = path.resolve(root, exported);
} else {
record.external.push(specifier);
if (renderedLayer(file) && !tests(file)) {
if (cesiumPackage.test(specifier) && !cesiumLayerRenderer(file))
report(
file,
`cesium only in the layer's render/cesium*.js: ${specifier}`,
);
if (navaraPackage.test(specifier) && !navaraLayerRenderer(file))
report(
file,
`three and Navara only in the layer's render/navara*.js: ${specifier}`,
);
const owner = cesiumPackage.test(specifier)
? 'cesium'
: navaraPackage.test(specifier)
? 'navara'
: null;
if (owner && !file.startsWith(`src/globe/${owner}/`)) {
const exception = engineImportExceptions.find(
(item) => item.file === file && item.package === specifier,
);
if (exception) usedExceptions.add(exception);
else
report(file, `${specifier} imports belong in src/globe/${owner}/`);
}
if (
file.startsWith('src/') &&
@@ -395,8 +362,17 @@ export function checkImportDirections(root) {
}
if (existsSync(path.join(root, 'src/voice/sessionCommands.js')))
voice('src/voice/sessionCommands.js');
for (const item of engineImportExceptions) {
if (!usedExceptions.has(item))
report(item.file, `Unused engine import exception: ${item.package}`);
}
if (errors.size) throw new Error([...errors].join('\n'));
return { modules: files.length, portableEntries: roots.size };
return {
modules: files.length,
portableEntries: roots.size,
engineImportExceptionCount: usedExceptions.size,
engineImportExceptions: [...usedExceptions],
};
}
if (
+3 -1
View File
@@ -11,7 +11,9 @@ export async function checkPackageBoundaries(root) {
const readJson = async (name) =>
JSON.parse(await readFile(path.join(root, name), 'utf8'));
const pkg = await readJson('package.json');
const groups = await readJson('scripts/package-boundaries.json');
const { engineImportExceptions, ...groups } = await readJson(
'scripts/package-boundaries.json',
);
const declaredExports = Object.keys(pkg.exports || {}).sort();
const classifiedExports = Object.values(groups)
.flatMap((group) => group.exports)
+7 -7
View File
@@ -1,16 +1,16 @@
{
"metrics": {
"cesiumLayerRendererLines": 14,
"navaraLayerRendererLines": 314,
"cesiumImportFiles": 5,
"viewerLines": 933,
"cesiumLayerRendererLines": 0,
"navaraLayerRendererLines": 189,
"cesiumImportFiles": 3,
"viewerLines": 910,
"navaraKindChecks": 2,
"engineSniffCalls": 0,
"engineExtensionReads": 4,
"engineExtensionReads": 3,
"groundEffectPasses": 1,
"cesiumSdkLoads": 3,
"screenSpaceHandlerFiles": 4,
"engineNameLines": 36
"engineNameLines": 33
},
"measuredAt": "Separate scene attachment and page switching; give Director the scene"
"measuredAt": "Enforce SDK ownership, relocate annotation and wind backends, and rehearse every shared display"
}
+6 -2
View File
@@ -1653,7 +1653,7 @@
"src/layers/shared/render/navaraGroundOrder.test.mjs",
"src/globe/navara/ground/corridors.js",
"scripts/qa-ground-looks.mjs",
"src/layers/wind/render/cesiumFlow.js",
"src/globe/cesium/windFlow.js",
"src/testSupport/imageryDisplayEngines.mjs",
"src/data/geoImportDisplay.js",
"src/data/geoImportDisplay.test.mjs",
@@ -1698,5 +1698,9 @@
"src/app/layerSession.js",
"src/standalone/sceneSwitcher.js",
"src/app/layerSession.test.mjs",
"src/standalone/sceneSwitcher.test.mjs"
"src/standalone/sceneSwitcher.test.mjs",
"src/app/layers/displayRehearsal.test.mjs",
"src/globe/cesium/annotations/worldAnnotationRenderer.js",
"src/globe/cesium/annotations/screenAnnotationRenderer.js",
"src/globe/navara/windFlow.js"
]
+24 -7
View File
@@ -1,4 +1,21 @@
{
"engineImportExceptions": [
{
"file": "src/data/bhoteKoshiEmbeddedMedia.js",
"package": "cesium",
"reason": "Authored story media still uses Cesium positions and property records."
},
{
"file": "src/data/bhoteKoshiEvent.js",
"package": "cesium",
"reason": "Authored event timing and geometry still build Cesium property records for the shared story display."
},
{
"file": "src/data/bhoteKoshiLocator.js",
"package": "cesium",
"reason": "Locator model still builds Cesium property records for the shared story display."
}
],
"infrastructure": {
"exports": [
"./infrastructure",
@@ -2416,9 +2433,9 @@
"src/annotations/hybridAnnotationRenderer.js",
"src/annotations/index.js",
"src/annotations/resolver.js",
"src/annotations/screenAnnotationRenderer.js",
"src/globe/cesium/annotations/screenAnnotationRenderer.js",
"src/annotations/screenScene.js",
"src/annotations/worldAnnotationRenderer.js",
"src/globe/cesium/annotations/worldAnnotationRenderer.js",
"src/app/catalog.js",
"src/app/constructCatalog.js",
"src/app/sourceComposition.js",
@@ -3569,7 +3586,7 @@
"src/layers/recentImagery/render/display.js",
"src/layers/weather/render/display.js",
"src/layers/wind/render/display.js",
"src/layers/wind/render/navaraFlow.js",
"src/globe/navara/windFlow.js",
"src/layers/bhoteKoshi/render/display.js",
"src/globe/navara/imagery/pass.js",
"src/globe/navara/imagery/index.js",
@@ -3577,7 +3594,7 @@
"src/globe/cesium/imagery.js",
"src/globe/cesium/imageryShell.js",
"src/globe/navara/ground/corridors.js",
"src/layers/wind/render/cesiumFlow.js",
"src/globe/cesium/windFlow.js",
"src/globe/cesium/postStages.js",
"src/data/geoImportRecords.js",
"src/data/geoImportKml.js",
@@ -3984,7 +4001,7 @@
"src/layers/vessels/recordPolicy.js",
"src/layers/vessels/snapshotRenderer.js",
"src/data/inputOwnership.js",
"src/annotations/worldAnnotationRenderer.js",
"src/globe/cesium/annotations/worldAnnotationRenderer.js",
"src/cameraGroundGuard.js",
"src/cameraVerbs.js",
"src/globeInView.js",
@@ -4326,14 +4343,14 @@
"src/layers/recentImagery/render/display.js",
"src/layers/weather/render/display.js",
"src/layers/wind/render/display.js",
"src/layers/wind/render/navaraFlow.js",
"src/globe/navara/windFlow.js",
"src/layers/bhoteKoshi/render/display.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/windFlow.js",
"src/globe/cesium/postStages.js",
"src/globe/cesium/sonar.js",
"src/globe/cesium/sonarCommands.js",
+1 -1
View File
@@ -151,7 +151,7 @@ async function draw(origin, variant, out, engineKind) {
served.match(/from\s+["']([^"']*cesium[^"']*)["']/)[1]
);
const { FlowMaterialProperty, ensureFlowFabricRegistered } =
await import('/src/annotations/worldAnnotationRenderer.js');
await import('/src/globe/cesium/annotations/worldAnnotationRenderer.js');
const { viewer } = engine.extensions.cesium;
const color = new Cesium.Color(...jamRgba.slice(0, 3), 0.55);
const jam = viewer.scene.groundPrimitives.add(
+9 -3
View File
@@ -349,13 +349,19 @@ async function shootScene(browser, origin, kind, scene) {
let used;
if (display) {
const navaraFlow = native
? (await import('/src/layers/wind/render/navaraFlow.js'))
.createWindNavaraFlow
? (
(await tryImport('/src/globe/navara/windFlow.js')) ??
(await import('/src/layers/wind/render/navaraFlow.js'))
).createWindNavaraFlow
: null;
const createWindFlowMesh = native
? (await import('/src/globe/navara/windFlowMesh.js'))
.createWindFlowMesh
: null;
renderer = display.createWindDisplay(engine, {
flow: viewer
? () => cesiumFlow({ field: false })
: (e) => navaraFlow(e, native),
: (e) => navaraFlow(e, native, createWindFlowMesh),
});
used = 'display';
} else if (viewer) {
+3 -1
View File
@@ -211,7 +211,9 @@ test('draped marks classify onto terrain as well as 3D tiles', () => {
// globe carries the imagery and there are no tiles to classify against. The
// rendered proof is in scripts/qa-draw-tool.mjs, which counts stroke pixels
// against a keyless server; this stops the constant being narrowed again.
const renderer = read('src/annotations/worldAnnotationRenderer.js');
const renderer = read(
'src/globe/cesium/annotations/worldAnnotationRenderer.js',
);
assert.match(renderer, /const CLASSIFY = Cesium\.ClassificationType\.BOTH;/);
assert.doesNotMatch(
renderer,
+2 -2
View File
@@ -1,6 +1,6 @@
import { recordWorkSlice } from '../sources/phaseTiming.js';
import { createWorldAnnotationRenderer } from './worldAnnotationRenderer.js';
import { createScreenAnnotationRenderer } from './screenAnnotationRenderer.js';
import { createWorldAnnotationRenderer } from '../globe/cesium/annotations/worldAnnotationRenderer.js';
import { createScreenAnnotationRenderer } from '../globe/cesium/annotations/screenAnnotationRenderer.js';
/**
* Hybrid annotation renderer (Direction C — the recommended production blend).
@@ -6,7 +6,7 @@ import { createApp, nextTick } from 'vue';
import { createPinia } from 'pinia';
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
import ScreenAnnotations from '../ui/views/panels/ScreenAnnotations.vue';
import { createScreenAnnotationRenderer } from './screenAnnotationRenderer.js';
import { createScreenAnnotationRenderer } from '../globe/cesium/annotations/screenAnnotationRenderer.js';
// happy-dom does not lay out SVG; every element measures as a 40 x 16 box.
for (const type of [globalThis.SVGElement, globalThis.SVGGraphicsElement]) {
@@ -7,7 +7,7 @@ import * as C from 'cesium';
import {
FlowMaterialProperty,
createWorldAnnotationRenderer,
} from './worldAnnotationRenderer.js';
} from '../globe/cesium/annotations/worldAnnotationRenderer.js';
const noop = () => {};
function viewer(t) {
+715
View File
@@ -0,0 +1,715 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { LAYER_MANIFEST, CONTRACT_PRIMITIVES } from './renderers.js';
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
import * as geo from '../../geo/index.js';
import { rgbaFromCss } from '../../layers/shared/color.js';
import { aircraftAtlasLayout } from '../../layers/aircraft/render/display.js';
import { vesselAtlasLayout } from '../../layers/vessels/render/display.js';
import { transitTestAtlas } from '../../layers/transit/render/testAtlas.mjs';
import { transitIcon } from '../../data/transitIcons.js';
import { createBikeshareLayer } from '../../layers/bikeshare/index.js';
import { createRadioLayer } from '../../layers/radio/index.js';
import { createDisplayHarness } from '../../layers/installations/render/displayHarness.mjs';
import { BOX } from '../../layers/recentImagery/testDoubles.mjs';
const noop = () => {};
const settle = () => new Promise((resolve) => setImmediate(resolve));
const at = (lon = 0, lat = 0, height = 5) =>
geo.cartesian3.fromDegrees(lon, lat, height);
const color = rgbaFromCss('#5ef08a');
const ring = [
[0, 0],
[1, 0],
[1, 1],
[0, 0],
];
const atlas = (layout) => async () => ({
canvas: { width: layout.width, height: layout.height },
cells: layout.cells,
});
const story = () => ({
name: 'story',
entities: {
values: [
{
id: 'point',
position: at(85, 28),
point: { color, pixelSize: 8 },
},
],
},
});
// Small records from the displays' parity tests; transport and image decoding
// are supplied so this rehearsal needs neither a DOM canvas nor a network.
const cases = {
'bhote-koshi-2026': async (make) => {
const d = make();
await d.loadImages(BOX, ['pre.webp', 'post.webp'], 'Vantor');
d.draw(story());
return { hide: () => d.setVisible(false) };
},
'bhote-koshi-locator': (make) => {
const data = story();
make({ data });
return {
hide: () => {
data.show = false;
},
};
},
flights: async (make) => {
const d = make({ drawAtlas: atlas(aircraftAtlasLayout()) });
await d.whenReady();
d.fleet.attach();
d.fleet.add({ id: 'flight', position: at(), show: true, color });
return { hide: () => d.fleet.setVisible(false) };
},
military: async (make) => cases.flights(make),
'local-adsb': async (make) => {
const d = make({ drawAtlas: atlas(aircraftAtlasLayout()) });
await d.whenReady();
d.attach();
d.setVisible(true);
d.markers.add({
id: 'local',
position: () => at(),
rotation: () => 0,
show: () => true,
});
return { hide: () => d.setVisible(false), detach: () => d.detach() };
},
earthquakes: (make) => {
const d = make();
d.replace([
{
stableId: 'quake',
lon: 0,
lat: 0,
baseRadius: 1000,
color,
fillAlpha: 0.4,
},
]);
d.setVisible(true);
return { hide: () => d.setVisible(false) };
},
'air-quality': (make) => {
const d = make();
d.replace([{ id: 'station', lon: 0, lat: 0, color: '#5ef08a' }], 1000);
d.setVisible(true);
return { hide: () => d.setVisible(false) };
},
'fire-perimeters': (make) => {
const d = make();
d.replace([{ id: 'area', containedPct: 20, polygons: [[ring]] }]);
d.onClick(noop);
d.setVisible(true);
return { hide: () => d.setVisible(false) };
},
'drone-airspace': (make) => {
const d = make();
d.replace([{ id: 'zone', typeCode: 'R', polygons: [[ring]] }]);
d.onClick(noop);
d.setVisible(true);
return { hide: () => d.setVisible(false) };
},
'gnss-integrity': (make) => {
const d = make();
d.replace(
[{ west: 30, south: 50, east: 30.5, north: 50.5, level: 'low' }],
{ low: 0.3 },
);
d.setVisible(true);
return { hide: () => d.setVisible(false) };
},
's2-ghostmaps': async (make) => {
const d = make();
const paint = d.draw(
'kinetic-30d',
new Map([
[
1,
{
style: { polyColor: [0, 0, 1, 0.6] },
geometries: [{ type: 'polygon', outer: ring, holes: [] }],
},
],
]),
'#00d4ff',
() => '',
);
await paint.ready;
return { hide: () => paint.destroy() };
},
'alpr-cameras': (make, engine, services) => {
const record = { id: 'alpr:0', latitude: 30, longitude: -97, osmId: 0 };
const state = {
enabled: true,
selectedId: null,
lastAnchorSampleAt: 0,
renderRevision: 0,
records: [record],
recordById: new Map([[record.id, record]]),
};
const d = make({
state,
services,
source: {
label: 'Fixture',
attribution: {
name: 'Fixture cameras',
text: 'Fixture cameras',
href: 'https://example.com/cameras',
},
},
});
engine.camera.setView({ destination: at(-97, 30, 2000) });
d.init(null, { onMove: noop });
d.setVisible(true);
d.renderRecords();
return { hide: () => d.setVisible(false) };
},
'street-level': (make) => {
const d = make();
const lines = d.sequenceLines(
[
{
id: 'a',
parts: [
[
[-121.49, 38.58],
[-121.48, 38.58],
],
],
},
],
{
id: 'coverage:14/1/2',
color: '#00ff00',
alpha: 0.92,
widthPx: 2.5,
pickPrefix: 'mly:seq:',
order: 0,
},
);
return { hide: () => lines.destroy() };
},
satellites: (make) => {
const d = make();
const points = d.createPoints();
points.add({ id: '1', position: at(), color, pixelSize: 5, show: true });
const click = d.listenClick(noop);
return {
hide: () => {
points.show = false;
},
detach: () => click.destroy(),
};
},
'rocket-launches': (make) => {
const d = make();
d.setVisible(true);
d.addPoint({ id: 'launch', position: at(), sizePx: 8, color });
d.onClick(noop);
return { hide: () => d.setVisible(false) };
},
traffic: (make) => {
const d = make();
d.drawDots([
{ point: { position: at(), pixelSize: 5, show: true, color } },
]);
d.setHeat(
'jam',
[
{
coords: [
[0, 0],
[1, 1],
],
},
],
{ color: color.rgba(), widthPx: 3 },
);
return { hide: () => d.show(false) };
},
cctv: (make) => {
const d = make({
state: { _recordById: new Map(), _projectionEntities: [] },
});
d.attach();
d.createMarkers();
const marker = d.createMarker({ id: 'a' }, at());
marker.show = true;
d.bindClick(noop);
return { hide: () => d.setMarkersVisible(false) };
},
radio: (make, engine, services) => {
let context;
const layer = createRadioLayer({
services,
source: {},
rendererFactory(value) {
context = value;
return make(value);
},
});
layer.attachRenderer(null, { engine });
const renderer = context.parts.renderer;
const station = {
id: '00000000-0000-4000-8000-000000000000',
name: 'Station 0',
lat: 0,
lon: 0,
tags: ['news'],
};
context.state._stationById.set(station.id, station);
context.state._renderById.set(
station.id,
renderer.addStation(station, at(), '#44adff'),
);
renderer.setVisible(true);
return {
hide: () => renderer.setVisible(false),
destroy: () => layer.detachRenderer(),
};
},
transit: async (make) => {
const d = make({ state: {}, services: {}, drawAtlas: transitTestAtlas });
d.attach();
const [markers] = d.createMarkers();
markers.show = true;
await d.whenReady();
markers.add({
id: 'bus',
position: at(),
image: transitIcon('bus'),
width: 20,
height: 20,
color,
rotation: 0,
show: true,
});
return {
hide: () => {
markers.show = false;
},
};
},
bikeshare: (make, engine, services) => {
let display;
const layer = createBikeshareLayer({
services,
source: { getStations: async () => ({ data: { stations: [] } }) },
rendererFactory(value) {
display = make(value);
return display;
},
});
layer.init(null, { engine });
display.lifecycle.methods.show();
display.rendering.ensureCityPoints(
'austin-capmetro',
new Map([
[
'central',
{
stationId: 'central',
name: 'Central',
lat: 30.2672,
lon: -97.7431,
capacity: 10,
},
],
]),
);
return { hide: () => layer.disable(), destroy: () => layer.destroy() };
},
directions: (make) => {
const d = make();
d.setMarker('A', { lat: 0, lon: 0 });
d.setRoute({
type: 'LineString',
coordinates: [
[0, 0],
[1, 1],
],
});
const off = d.onClick(noop);
d.show(true);
return { hide: () => d.show(false), detach: off };
},
'recent-imagery': (make) => {
const d = make();
d.rebind(d.resolveHost());
assert.equal(
d.showSlot(
'a',
{ key: 'S30:2026-09-18', product: 'S30', day: '2026-09-18' },
BOX,
),
true,
);
return { hide: () => d.hideSlot('a') };
},
'osm-places': (make) => {
const d = make();
d.replace([{ id: 'node/1', lat: 0, lon: 0, color: '#5ef08a' }]);
d.setVisible(true);
return { hide: () => d.setVisible(false) };
},
'ais-live-vessels': async (make) => {
const d = make({ drawAtlas: atlas(vesselAtlasLayout()) });
await d.whenReady();
d.fleet.attach({ show: true });
d.fleet.add({
id: { mmsi: '123456789' },
position: at(),
show: true,
scale: 0.6,
rotation: 0,
});
return { hide: () => d.fleet.setVisible(false) };
},
'military-installations': (make, engine, services) => {
const h = createDisplayHarness(() => ({ engine, destroy: noop }), {
context: services.context,
displayFactory: (_engine, options) => make(options),
});
h.start();
h.setRecords([
{
id: 'site',
name: 'Site',
latitude: 0,
longitude: 0,
category: 'military',
sources: [],
},
]);
h.parts.rendering.renderRecords();
return {
hide: () => h.parts.lifecycle.methods.disable(),
destroy: () => h.destroy(),
};
},
'military-awareness': (make) => {
const d = make();
const rim = d.createRim(at());
const pulses = d.createPulses(at());
pulses.draw({ radii: [0.5], alphas: [0.5], level: 1 });
return {
hide: () => pulses.draw({ radii: [], alphas: [], level: 0 }),
detach: () => {
rim.destroy();
pulses.destroy();
},
};
},
wind: (make) => {
const d = make({
createCanvas: ({ width, height }) => ({ width, height }),
});
d.attach();
d.setField({
model: 'gfs',
grid: { nx: 8, ny: 5, lo1: 0, la1: 90, dx: 45, dy: 45 },
u: Array.from({ length: 40 }, (_, i) => 5 + (i % 7)),
v: Array.from({ length: 40 }, (_, i) => (i % 3) - 1),
});
d.setOptions({ overlay: 'speed' });
d.start();
return { hide: () => d.stop() };
},
'weather-radar': async (make) => weather(make, 'radar'),
'weather-satellite': async (make) => weather(make, 'clouds-regional'),
'weather-lightning': async (make) => weather(make, 'lightning'),
'weather-cyclones': async (make, engine, services) => {
const d = make({ overlayHost: services.overlayHost });
await d.setSnapshot({
storms: [
{
id: 'ep152026',
name: 'Fifteen-E',
position: { longitude: 179, latitude: 15 },
forecastPoints: [],
track: {
type: 'MultiLineString',
coordinates: [
[
[179, 15],
[-179, 16],
],
],
},
},
],
});
return { hide: () => d.setSnapshot({ storms: [] }) };
},
'local-datacenters': (make, engine, services) =>
infrastructure(make, services, 'local-datacenters'),
'local-dams': (make, engine, services) =>
infrastructure(make, services, 'local-dams'),
'telegeography-submarine-cables': (make, engine, services) => {
const d = make({ overlayHost: services.overlayHost });
d.show({
cables: {
features: [
{
type: 'Feature',
id: 'cable',
properties: { name: 'Cable' },
geometry: {
type: 'LineString',
coordinates: [
[0, 0],
[1, 1],
],
},
},
],
},
landingPoints: { features: [] },
});
return { hide: () => d.hide() };
},
'local-firms': (make) => {
const d = make();
d.addCell({ west: 30, south: 50, east: 30.5, north: 50.5, color });
d.setVisible(true);
return { hide: () => d.setVisible(false) };
},
};
async function weather(make, product) {
const d = make();
const time = '2026-11-07T12:00:00.000Z';
assert.equal(
await d.setFrame(
{
schemaVersion: 1,
product,
bounds: { west: -130, south: 20, east: -60, north: 55 },
times: [time],
latest: time,
tileSize: 256,
maxLevel: 6,
tilingScheme: 'geographic',
},
time,
),
true,
);
return { hide: () => d.setHidden(true), destroy: () => d.clear() };
}
function infrastructure(make, services, id) {
const d = make({
config: {
id,
name: 'Runtime Dam',
source: 'fixture',
color: '#5ef08a',
labels: false,
},
services,
});
const geometry = { type: 'Polygon', coordinates: [ring] };
d.show({
features: [
{
type: 'Feature',
id: 'real-dam',
properties: { name: 'Runtime Dam' },
geometry,
},
],
records: [
{
id: 'real-dam',
lon: 0.5,
lat: 0.5,
properties: { name: 'Runtime Dam' },
geometry,
},
],
});
return { hide: () => d.hide() };
}
const skips = {
'wildlife-sightings':
'The layer draws itself through the contract; no manifest display factory.',
'sea-marks':
'The overlay controller draws imagery itself; no manifest display factory.',
'ski-pistes':
'The overlay controller draws imagery itself; no manifest display factory.',
};
function listenerCounts(engine) {
return [engine.camera, engine.frames].flatMap((part) =>
Object.values(part)
.filter((value) => value && typeof value.addEventListener === 'function')
.map((event) => event.numberOfListeners),
);
}
function servicesFixture() {
const entries = new Map();
const contexts = new Map();
const owners = new Map();
const overlayHost = {
setEntries: (id, value) => entries.set(id, value),
setVisible: noop,
clearSource: (id) => entries.delete(id),
};
const context = {
getSelectedEntityContext: () => null,
clearSelectedEntityContextForLayer: noop,
selectEntityContext: noop,
registerEntityContext: (_entity, metadata) =>
contexts.set(metadata.id, metadata),
removeEntityContextsForLayer: (id) => {
for (const [key, value] of contexts)
if (value.layerId === id) contexts.delete(key);
},
};
return {
entries,
contexts,
owners,
context,
overlayHost,
render: { governorRequestRender: noop },
groundFloor: { cachedGroundFloor: () => 150 },
ground: { cachedGroundFloor: () => 20, warmGroundFloor: noop },
globe: { getKeyholeGeometry: () => ({ radius: 500 }) },
overlays: {
setOverlayEntries: overlayHost.setEntries,
clearOverlaySource: overlayHost.clearSource,
setOverlaySourceVisible: noop,
},
picking: {
registerPickOwner: (id, fn) => owners.set(id, fn),
unregisterPickOwner: (id) => owners.delete(id),
resolvePickId: (pick) => pick?.id,
isOwnedByOtherLayer: () => false,
},
...context,
};
}
test('every manifest display draws and releases its resources on a third engine', async (t) => {
assert.deepEqual(
Object.keys(cases).sort(),
Object.entries(LAYER_MANIFEST)
.filter(([, entry]) => entry.display)
.map(([id]) => id)
.sort(),
);
assert.deepEqual(
Object.keys(skips).sort(),
Object.entries(LAYER_MANIFEST)
.filter(([, entry]) => !entry.display)
.map(([id]) => id)
.sort(),
);
const previousWindow = globalThis.window;
globalThis.window = new EventTarget();
t.after(() => {
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
});
for (const [id, entry] of Object.entries(LAYER_MANIFEST)) {
await t.test(id, { skip: skips[id] }, async () => {
const engine = createFakeEngine({ kind: 'rehearsal' });
engine.extensions = {};
engine.fake.script('sampleHeight', 0);
engine.fake.script('terrainHeight', 0);
const before = listenerCounts(engine);
const credits = [...engine.fake.credits];
const inputs = new Set();
const on = engine.input.on;
engine.input.on = (...args) => {
const off = on(...args);
const token = {};
inputs.add(token);
return () => {
inputs.delete(token);
off();
};
};
const services = servicesFixture();
const factory = await entry.display();
let display;
let lifecycle;
const make = (options = {}) => {
assert.equal(display, undefined, 'one factory call per manifest entry');
return (display = factory(engine, options));
};
try {
lifecycle = (await cases[id](make, engine, services)) ?? {};
await settle();
engine.fake.frame();
assert.ok(
CONTRACT_PRIMITIVES.some((name) =>
engine[name]
.list()
.some(
(item) =>
item.count > 0 ||
name === 'imagery' ||
name === 'lines' ||
item.kind === 'pulse-field',
),
),
'the fixture draws nonempty geometry or imagery',
);
lifecycle.hide?.();
lifecycle.detach?.();
if (lifecycle.destroy) lifecycle.destroy();
else display.destroy();
await settle();
engine.fake.frame();
for (const name of CONTRACT_PRIMITIVES)
assert.deepEqual(
engine[name].list(),
[],
`${name} released before engine destruction`,
);
assert.deepEqual(
listenerCounts(engine),
before,
'frame and camera listeners released',
);
assert.equal(inputs.size, 0, 'input subscriptions released');
assert.deepEqual(engine.fake.credits, credits, 'credits released');
assert.equal(
engine.input.cameraInputsEnabled(),
true,
'camera input restored',
);
assert.equal(
engine.render.isContinuous(),
false,
'continuous rendering released',
);
assert.equal(engine.camera.following(), null, 'no retained follow');
assert.ok(
[...services.entries.values()].every(
(entries) => entries.length === 0,
),
'overlay entries released',
);
assert.equal(services.contexts.size, 0, 'context records released');
assert.equal(services.owners.size, 0, 'pick owners released');
} finally {
lifecycle?.destroy?.();
display?.destroy?.();
engine.destroy();
}
});
}
});
+5 -5
View File
@@ -15,7 +15,7 @@
// default); the entry's `display` is its loader. `backends` is the
// display's engine-specific option, keyed by engine kind: an import()
// loader of `{ option: value }` merged into the display's options on that
// engine (Global Context's pulses on Navara).
// engine (wind streamlines).
// - `drawsItself(needs)`: no renderer; the layer draws itself through the
// contract primitives in `needs` (Wildlife Sightings).
// - `perEngine({ [kind]: loader | DRAWS_ITSELF })`: a layer with no display
@@ -122,12 +122,12 @@ const navaraNative = (engine) => engine.extensions.navara;
// Wind streamlines retain their engine backends.
const cesiumWindFlow = async () => {
const { createWindCesiumFlow } =
await import('../../layers/wind/render/cesiumFlow.js');
await import('../../globe/cesium/windFlow.js');
return { flow: createWindCesiumFlow };
};
const navaraWindFlow = async () => {
const [{ createWindNavaraFlow }, { createWindFlowMesh }] = await Promise.all([
import('../../layers/wind/render/navaraFlow.js'),
import('../../globe/navara/windFlow.js'),
import('../../globe/navara/windFlowMesh.js'),
]);
return {
@@ -194,8 +194,8 @@ export const LAYER_MANIFEST = Object.freeze({
'weather-satellite': display('createWeatherDisplay', ['imagery']),
'weather-lightning': display('createWeatherDisplay', ['imagery']),
'weather-cyclones': display('createCyclonesDisplay', ['movers', 'ground']),
// The open map overlays drape themselves: Cesium imagery on the viewer,
// engine.maps.addOverlay on Navara (src/data/osmOverlays.js).
// The open map overlays draw through imagery, or the optional native
// maps.addOverlay path (src/data/osmOverlays.js).
'sea-marks': perEngine({ cesium: DRAWS_ITSELF, navara: DRAWS_ITSELF }),
'ski-pistes': perEngine({ cesium: DRAWS_ITSELF, navara: DRAWS_ITSELF }),
'local-datacenters': display('createInfrastructureDisplay', [
+5 -3
View File
@@ -8,7 +8,7 @@ import { readFileSync } from 'node:fs';
import * as Cesium from 'cesium';
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
import { createAnnotationResolver } from '../annotations/resolver.js';
import { createWorldAnnotationRenderer } from '../annotations/worldAnnotationRenderer.js';
import { createWorldAnnotationRenderer } from '../globe/cesium/annotations/worldAnnotationRenderer.js';
import { cameraOnlyEngine } from '../testSupport/viewerEngine.mjs';
const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8');
@@ -25,12 +25,14 @@ const DATA_MODULES = [
/** Modules that use Cesium for outlines but must not draw text with it. */
const CESIUM_MODULES = [
'../annotations/resolver.js',
'../annotations/worldAnnotationRenderer.js',
'../globe/cesium/annotations/worldAnnotationRenderer.js',
'../annotations/hybridAnnotationRenderer.js',
];
/** The DOM overlay: callout elements, never Cesium label primitives. */
const OVERLAY_MODULES = ['../annotations/screenAnnotationRenderer.js'];
const OVERLAY_MODULES = [
'../globe/cesium/annotations/screenAnnotationRenderer.js',
];
const LABEL_PRIMITIVES = [
/\bLabelCollection\b/,
+1 -1
View File
@@ -4,7 +4,7 @@ import * as C from 'cesium';
import { showOsmCredit, hideOsmCredit } from './dataCredits.js';
import { createInfrastructureLayers } from './infrastructure.js';
import { createBhoteKoshiLocatorLayer } from './bhoteKoshiLocator.js';
import { createWorldAnnotationRenderer } from '../annotations/worldAnnotationRenderer.js';
import { createWorldAnnotationRenderer } from '../globe/cesium/annotations/worldAnnotationRenderer.js';
import { createDirectionsLayer } from '../layers/directions/index.js';
import { createDirectionsSource } from '../layers/directions/source.js';
import { createLifecycle as createCctvLifecycle } from '../layers/cctv/lifecycle.js';
@@ -1,15 +1,15 @@
import * as Cesium from 'cesium';
import * as geo from '../geo/index.js';
import { getOverlayPaintRect } from '../overlays/worldOverlay.js';
import * as geo from '../../../geo/index.js';
import { getOverlayPaintRect } from '../../../overlays/worldOverlay.js';
import {
getActiveTrackedReadoutId,
TRACKED_OVERLAY_SOURCE_ID,
} from '../data/trackedReadout.js';
import { t } from '../i18n/index.js';
} from '../../../data/trackedReadout.js';
import { t } from '../../../i18n/index.js';
import { nextTick } from 'vue';
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
import { applicationPinia } from '../ui/vueRuntime.js';
import { isScreenScene } from './screenScene.js';
import { useScreenAnnotationsStore } from '../../../ui/stores/screenAnnotations.js';
import { applicationPinia } from '../../../ui/vueRuntime.js';
import { isScreenScene } from '../../../annotations/screenScene.js';
/** A label whose detailed outline could not be resolved. */
function outlineUnavailableLabel(label) {
@@ -1,7 +1,7 @@
import { recordWorkSlice } from '../sources/phaseTiming.js';
import { ringCentroid } from '../sources/featureGeometry.js';
import { recordWorkSlice } from '../../../sources/phaseTiming.js';
import { ringCentroid } from '../../../sources/featureGeometry.js';
import * as Cesium from 'cesium';
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
import { showOsmCredit, hideOsmCredit } from '../../../data/dataCredits.js';
/**
* World-space annotation renderer (Direction A).
+1 -1
View File
@@ -69,7 +69,7 @@ const colorOf = (rgba) => new Cesium.Color(rgba[0], rgba[1], rgba[2], rgba[3]);
// One material for every animated corridor: PolylineGlow's falloff across
// the width (st.t) and taper along it (st.s), an alpha pulse, and the
// annotation route's flowing dashes (src/annotations/worldAnnotationRenderer.js,
// annotation route's flowing dashes (src/globe/cesium/annotations/worldAnnotationRenderer.js,
// GevRouteFlow): its diffuse plus emission and alpha. Each part is off at
// its zero uniform. Fragments are LDR and premultiplied, as Cesium writes
// ground polylines.
@@ -1,7 +1,7 @@
// Cesium's backend of the wind display: the wind renderer's streamlines
// (../rendering.js) over the viewer, read only once a Cesium globe draws.
// (src/layers/wind/rendering.js) over the viewer, read only once a Cesium globe draws.
import * as Cesium from 'cesium';
import { createWindRendering } from '../rendering.js';
import { createWindRendering } from '../../layers/wind/rendering.js';
export function createWindCesiumFlow(engine, { onStatusChange } = {}) {
return createWindRendering({
@@ -3,8 +3,8 @@ import {
WIND_DISPLAY_HEIGHT_METERS,
WIND_NARROW_PATH_LIMIT,
WIND_PATH_LIMIT,
} from '../streamlines.js';
import { sameWind } from '../model.js';
} from '../../layers/wind/streamlines.js';
import { sameWind } from '../../layers/wind/model.js';
const clamp = (n) => Math.max(0, Math.min(1, n));
+1 -1
View File
@@ -11,7 +11,7 @@ const rgba = (css, alpha) => {
return [red, green, blue, alpha];
};
// The route flow material (src/annotations/worldAnnotationRenderer.js): the
// The route flow material (src/globe/cesium/annotations/worldAnnotationRenderer.js): the
// route colour at 0.95, 64 dash cells along the route, each lit for its
// first 46 % (brightened by 0.9 of its colour) and at 18 % alpha between,
// flowing toward the destination at 0.55 cells a second.
@@ -31,7 +31,12 @@ function cesiumCollection(viewer, id) {
*/
export function createDisplayHarness(
makeEngine,
{ context = contextStore, overlayHost = null, warmFloors = null } = {},
{
context = contextStore,
overlayHost = null,
warmFloors = null,
displayFactory = createInstallationsDisplay,
} = {},
) {
// The context store lives on the window.
const ownWindow = globalThis.window === undefined;
@@ -90,7 +95,7 @@ export function createDisplayHarness(
clearUnavailableRetry() {},
};
parts.ingestion = { setInstallationStatus() {} };
Object.assign(parts, createInstallationsDisplay(h.engine, ctx));
Object.assign(parts, displayFactory(h.engine, ctx));
const methods = parts.lifecycle.methods;
return {
...h,
+1 -1
View File
@@ -5,7 +5,7 @@
// carry the wind near the ground). The streamlines are the engine's own,
// behind one seam: the `flow` backend the application passes in for each
// engine (Cesium's wind renderer, rendering.js, on the page's SDK;
// Navara's flow mesh, render/navaraFlow.js). Without one the field draws
// Navara's flow mesh, src/globe/navara/windFlow.js). Without one the field draws
// alone.
import { createFieldRaster, fieldUnavailableText } from '../fields.js';
+126 -44
View File
@@ -53,7 +53,7 @@ test('portable sources can share records, and standalone can assemble rendering'
write('src/layers/demo/source.js', "export { read } from './records.js';");
write('src/layers/demo/records.js', 'export const read = (value) => value;');
write('src/standalone/catalog.js', "import '../layers/demo/index.js';");
write('src/layers/demo/index.js', "import * as Cesium from 'cesium';");
write('src/layers/demo/index.js');
assert.equal(checkImportDirections(root).modules, 5);
});
@@ -83,34 +83,34 @@ for (const [name, files, pattern] of [
/reaches browser globals/,
],
[
'W1 shared focusDeemphasis keeps native imports in render',
'W1 shared focusDeemphasis keeps SDK imports in the engine',
{ 'src/data/focusDeemphasis.js': "import 'cesium';" },
/cesium only in the layer's render/,
/cesium imports belong in src\/globe\/cesium\//,
],
[
'W1 shared trailRenderer keeps native imports in render',
'W1 shared trailRenderer keeps SDK imports in the engine',
{ 'src/data/trailRenderer.js': "import 'cesium';" },
/cesium only in the layer's render/,
/cesium imports belong in src\/globe\/cesium\//,
],
[
'W1 shared contactTrailRenderer keeps native imports in render',
'W1 shared contactTrailRenderer keeps SDK imports in the engine',
{ 'src/data/contactTrailRenderer.js': "import 'cesium';" },
/cesium only in the layer's render/,
/cesium imports belong in src\/globe\/cesium\//,
],
[
'W1 shared iconOrientation keeps native imports in render',
'W1 shared iconOrientation keeps SDK imports in the engine',
{ 'src/data/iconOrientation.js': "import 'cesium';" },
/cesium only in the layer's render/,
/cesium imports belong in src\/globe\/cesium\//,
],
[
'W1 shared modelVisualAnchor keeps native imports in render',
'W1 shared modelVisualAnchor keeps SDK imports in the engine',
{ 'src/data/modelVisualAnchor.js': "import 'cesium';" },
/cesium only in the layer's render/,
/cesium imports belong in src\/globe\/cesium\//,
],
[
'W1 shared trackingClickGesture keeps native imports in render',
'W1 shared trackingClickGesture keeps SDK imports in the engine',
{ 'src/data/trackingClickGesture.js': "import 'cesium';" },
/cesium only in the layer's render/,
/cesium imports belong in src\/globe\/cesium\//,
],
[
'geo module imports cesium',
@@ -326,41 +326,123 @@ test('the app reaches the Navara engine only through a dynamic import', (t) => {
);
});
test('layers moved onto renderers keep each engine in its render file', (t) => {
const { root, write } = fixture(t);
write('src/layers/flights/rendering.js', "import './render/cesium.js';");
write('src/layers/flights/render/cesium.js', "import 'cesium';");
write(
'src/layers/flights/render/navara.js',
"import 'three'; import '@navaramap/three';",
);
write('src/layers/aircraft/render/cesium.js', "import 'cesium';");
write('src/layers/bikeshare/render/cesium/lifecycle.js', "import 'cesium';");
write('src/layers/flights/render/cesium.test.mjs', "import 'cesium';");
write(
'src/app/navaraMode.js',
"export const load = () => import('../layers/flights/render/navara.js');",
);
// A layer not moved yet still imports cesium anywhere (until its *a task).
write('src/layers/wind/rendering.js', "import 'cesium';");
assert.doesNotThrow(() => checkImportDirections(root));
for (const specifier of [
'cesium',
'cesium/Source/Cesium.js',
'@cesium/engine',
'three',
'three/addons/controls.js',
'postprocessing',
'@navaramap/three',
]) {
for (const statement of [
`import '${specifier}';`,
`export * from '${specifier}';`,
`const load = () => import('${specifier}');`,
]) {
test(`SDK ownership rejects ${statement} outside an engine`, (t) => {
const { root, write } = fixture(t);
write('src/layers/new/render/display.js', statement);
assert.throws(
() => checkImportDirections(root),
/imports belong in src\/globe\//,
);
});
}
}
write('src/layers/flights/tracking.js', "import 'cesium';");
assert.throws(
() => checkImportDirections(root),
/src\/layers\/flights\/tracking\.js: cesium only in the layer's render\/cesium\*\.js: cesium/,
test('legacy renderer names grant no SDK import exemption', (t) => {
const { root, write } = fixture(t);
write('src/layers/flights/render/cesium.js', "import 'cesium';");
write('src/layers/flights/render/navara.js', "import 'three';");
assert.throws(() => checkImportDirections(root), /cesium imports belong/);
write('src/layers/flights/render/cesium.js');
assert.throws(() => checkImportDirections(root), /three imports belong/);
});
test('remaining Navara layer helpers still load through the manifest or mode', (t) => {
const { root, write } = fixture(t);
write('src/layers/shared/render/navaraPolygons.js');
write(
'src/app/layers/renderers.js',
"export const load = () => import('../../layers/shared/render/navaraPolygons.js');",
);
write('src/layers/flights/tracking.js', '');
write('src/layers/aircraft/render/cesium.js', "import 'three';");
assert.throws(
() => checkImportDirections(root),
/src\/layers\/aircraft\/render\/cesium\.js: three and Navara only in the layer's render\/navara\*\.js: three/,
assert.doesNotThrow(() => checkImportDirections(root));
write(
'src/app/data.js',
"import '../layers/shared/render/navaraPolygons.js';",
);
write('src/layers/aircraft/render/cesium.js', '');
write('src/app/data.js', "import '../layers/flights/render/navara.js';");
assert.throws(
() => checkImportDirections(root),
/src\/app\/data\.js: A Navara layer renderer loads only through the Navara mode's dynamic import\(\): src\/layers\/flights\/render\/navara\.js/,
/A Navara layer renderer loads only through/,
);
});
test('engine import exceptions are exact, justified and measured', (t) => {
const { root, write } = fixture(t);
const item = {
file: 'src/data/story.js',
package: 'cesium',
reason: 'Authored story records still use SDK properties.',
};
const allow = (items) =>
write(
'scripts/package-boundaries.json',
JSON.stringify({ engineImportExceptions: items }),
);
write(item.file, "import 'cesium';");
allow([item]);
assert.deepEqual(checkImportDirections(root).engineImportExceptions, [item]);
write('src/data/other.js', "import 'cesium';");
assert.throws(
() => checkImportDirections(root),
/other.js: cesium imports belong/,
);
write('src/data/other.js');
write(item.file, "import 'three';");
assert.throws(() => checkImportDirections(root), /three imports belong/);
write(item.file);
assert.throws(
() => checkImportDirections(root),
/Unused engine import exception/,
);
write(item.file, "import 'cesium';");
allow([{ ...item, reason: '' }]);
assert.throws(
() => checkImportDirections(root),
/Invalid engine import exception/,
);
allow([item, item]);
assert.throws(
() => checkImportDirections(root),
/Invalid engine import exception/,
);
allow([{ ...item, file: 'src/missing.js' }]);
assert.throws(
() => checkImportDirections(root),
/Invalid engine import exception/,
);
});
test('an exception cannot bypass the contract or engine graph rules', (t) => {
const { root, write } = fixture(t);
write('src/data/helper.js', "import 'cesium';");
write(
'scripts/package-boundaries.json',
JSON.stringify({
engineImportExceptions: [
{
file: 'src/data/helper.js',
package: 'cesium',
reason: 'Legacy model.',
},
],
}),
);
write('src/globe/contract.js', "import '../data/helper.js';");
assert.throws(
() => checkImportDirections(root),
/globe contract reaches engine dependency/,
);
});
@@ -384,7 +466,7 @@ test('globe engines use their own dependencies, and globe tests may import any e
);
write(
'src/globe/navara/engine.js',
"import 'three'; import '@navaramap/three'; import '../contract.js';",
"import 'three'; import 'postprocessing'; import '@navaramap/three'; import '../contract.js';",
);
assert.doesNotThrow(() => checkImportDirections(root));
});
+1 -1
View File
@@ -3,7 +3,7 @@ import { markRaw, shallowRef } from 'vue';
/**
* The screen-space annotation board as its view renders it. The screen
* annotation renderer (src/annotations/screenAnnotationRenderer.js) presents
* annotation renderer (src/globe/cesium/annotations/screenAnnotationRenderer.js) presents
* every mark's SVG parts after each rendered frame; the view hands back the
* elements the renderer measures and reports when a draw-on stroke finishes.
*/
+1 -1
View File
@@ -1,4 +1,4 @@
/* ── Screen-space annotations (src/annotations/screenAnnotationRenderer.js) ── */
/* ── Screen-space annotations (src/globe/cesium/annotations/screenAnnotationRenderer.js) ── */
.gev-screen-whiteboard { position: fixed; inset: 0; pointer-events: none; z-index: 90; }
.gev-screen-whiteboard-svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
/* No CSS opacity transition here on purpose: group opacity is driven per-frame in JS
+1 -1
View File
@@ -3,7 +3,7 @@ import { storeToRefs } from 'pinia';
import { useScreenAnnotationsStore } from '../../stores/screenAnnotations.js';
// The screen-space annotation board over the page, as the screen annotation
// renderer (src/annotations/screenAnnotationRenderer.js) presents it.
// renderer (src/globe/cesium/annotations/screenAnnotationRenderer.js) presents it.
const { board, controls } = storeToRefs(useScreenAnnotationsStore());
// Each element goes to the renderer it was rendered for, which measures it.