diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 6614e0062..2ef7c4ea3 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -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//` | Source acquisition, records and Cesium resources with explicit controller/renderer owners | +| `src/layers//` | 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//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 diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0ac2278c3..433500ed7 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 diff --git a/docs/GLOBE-PRIMITIVES.md b/docs/GLOBE-PRIMITIVES.md index 9cf0eb079..c43fa718e 100644 --- a/docs/GLOBE-PRIMITIVES.md +++ b/docs/GLOBE-PRIMITIVES.md @@ -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//` 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. diff --git a/docs/INFRASTRUCTURE-LAYERS.md b/docs/INFRASTRUCTURE-LAYERS.md index aa8cd0558..2052545ee 100644 --- a/docs/INFRASTRUCTURE-LAYERS.md +++ b/docs/INFRASTRUCTURE-LAYERS.md @@ -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. diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index 6d486135a..423d59b9d 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -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 ( diff --git a/scripts/check-package-boundaries.mjs b/scripts/check-package-boundaries.mjs index 7a0883207..f5c92763e 100644 --- a/scripts/check-package-boundaries.mjs +++ b/scripts/check-package-boundaries.mjs @@ -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) diff --git a/scripts/engine-coupling-baseline.json b/scripts/engine-coupling-baseline.json index 09f9ae3ec..61f646732 100644 --- a/scripts/engine-coupling-baseline.json +++ b/scripts/engine-coupling-baseline.json @@ -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" } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 512bb59b3..0021058c4 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index da486ec20..5d3b4960d 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/scripts/qa-ground-looks.mjs b/scripts/qa-ground-looks.mjs index 2f3a3ee37..c04460370 100644 --- a/scripts/qa-ground-looks.mjs +++ b/scripts/qa-ground-looks.mjs @@ -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( diff --git a/scripts/qa-weather-compare.mjs b/scripts/qa-weather-compare.mjs index 23c45057f..10ecf939a 100644 --- a/scripts/qa-weather-compare.mjs +++ b/scripts/qa-weather-compare.mjs @@ -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) { diff --git a/src/annotations/drawTool.test.mjs b/src/annotations/drawTool.test.mjs index 77cf920c1..e196ef0bd 100644 --- a/src/annotations/drawTool.test.mjs +++ b/src/annotations/drawTool.test.mjs @@ -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, diff --git a/src/annotations/hybridAnnotationRenderer.js b/src/annotations/hybridAnnotationRenderer.js index 7a50e249d..83c154223 100644 --- a/src/annotations/hybridAnnotationRenderer.js +++ b/src/annotations/hybridAnnotationRenderer.js @@ -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). diff --git a/src/annotations/screenAnnotationRenderer.test.mjs b/src/annotations/screenAnnotationRenderer.test.mjs index ce1fb4058..376ff81fb 100644 --- a/src/annotations/screenAnnotationRenderer.test.mjs +++ b/src/annotations/screenAnnotationRenderer.test.mjs @@ -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]) { diff --git a/src/annotations/worldAnnotationRenderer.test.mjs b/src/annotations/worldAnnotationRenderer.test.mjs index a05c52373..0e15ec95b 100644 --- a/src/annotations/worldAnnotationRenderer.test.mjs +++ b/src/annotations/worldAnnotationRenderer.test.mjs @@ -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) { diff --git a/src/app/layers/displayRehearsal.test.mjs b/src/app/layers/displayRehearsal.test.mjs new file mode 100644 index 000000000..131abf9e1 --- /dev/null +++ b/src/app/layers/displayRehearsal.test.mjs @@ -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(); + } + }); + } +}); diff --git a/src/app/layers/renderers.js b/src/app/layers/renderers.js index 1bb40e726..73ebee2d8 100644 --- a/src/app/layers/renderers.js +++ b/src/app/layers/renderers.js @@ -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', [ diff --git a/src/data/boundaryLabels.test.mjs b/src/data/boundaryLabels.test.mjs index 06bee75c8..852b249c4 100644 --- a/src/data/boundaryLabels.test.mjs +++ b/src/data/boundaryLabels.test.mjs @@ -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/, diff --git a/src/data/sharedOsmDisplays.test.mjs b/src/data/sharedOsmDisplays.test.mjs index 963ab7b2d..30905ba7b 100644 --- a/src/data/sharedOsmDisplays.test.mjs +++ b/src/data/sharedOsmDisplays.test.mjs @@ -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'; diff --git a/src/annotations/screenAnnotationRenderer.js b/src/globe/cesium/annotations/screenAnnotationRenderer.js similarity index 98% rename from src/annotations/screenAnnotationRenderer.js rename to src/globe/cesium/annotations/screenAnnotationRenderer.js index 0f3208834..b5e95e6d2 100644 --- a/src/annotations/screenAnnotationRenderer.js +++ b/src/globe/cesium/annotations/screenAnnotationRenderer.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) { diff --git a/src/annotations/worldAnnotationRenderer.js b/src/globe/cesium/annotations/worldAnnotationRenderer.js similarity index 99% rename from src/annotations/worldAnnotationRenderer.js rename to src/globe/cesium/annotations/worldAnnotationRenderer.js index 083f475b4..3d7dfa8cf 100644 --- a/src/annotations/worldAnnotationRenderer.js +++ b/src/globe/cesium/annotations/worldAnnotationRenderer.js @@ -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). diff --git a/src/globe/cesium/ground.js b/src/globe/cesium/ground.js index 1e79f1220..bd1aeabbf 100644 --- a/src/globe/cesium/ground.js +++ b/src/globe/cesium/ground.js @@ -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. diff --git a/src/layers/wind/render/cesiumFlow.js b/src/globe/cesium/windFlow.js similarity index 68% rename from src/layers/wind/render/cesiumFlow.js rename to src/globe/cesium/windFlow.js index 0c743e4ca..f14467ca1 100644 --- a/src/layers/wind/render/cesiumFlow.js +++ b/src/globe/cesium/windFlow.js @@ -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({ diff --git a/src/layers/wind/render/navaraFlow.js b/src/globe/navara/windFlow.js similarity index 97% rename from src/layers/wind/render/navaraFlow.js rename to src/globe/navara/windFlow.js index 5c4dce7a5..793813242 100644 --- a/src/layers/wind/render/navaraFlow.js +++ b/src/globe/navara/windFlow.js @@ -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)); diff --git a/src/layers/directions/render/style.js b/src/layers/directions/render/style.js index a0659d2bd..c12f1fa66 100644 --- a/src/layers/directions/render/style.js +++ b/src/layers/directions/render/style.js @@ -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. diff --git a/src/layers/installations/render/displayHarness.mjs b/src/layers/installations/render/displayHarness.mjs index 61e8367c0..dd6de90db 100644 --- a/src/layers/installations/render/displayHarness.mjs +++ b/src/layers/installations/render/displayHarness.mjs @@ -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, diff --git a/src/layers/wind/render/display.js b/src/layers/wind/render/display.js index 5f2b3f2b0..1fe5ddbd8 100644 --- a/src/layers/wind/render/display.js +++ b/src/layers/wind/render/display.js @@ -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'; diff --git a/src/tooling/importDirections.test.mjs b/src/tooling/importDirections.test.mjs index 8cdf13f10..58c456db0 100644 --- a/src/tooling/importDirections.test.mjs +++ b/src/tooling/importDirections.test.mjs @@ -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)); }); diff --git a/src/ui/stores/screenAnnotations.js b/src/ui/stores/screenAnnotations.js index 8cc08864a..b9cc9da25 100644 --- a/src/ui/stores/screenAnnotations.js +++ b/src/ui/stores/screenAnnotations.js @@ -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. */ diff --git a/src/ui/styles/annotations.css b/src/ui/styles/annotations.css index a1b48c80c..e59678554 100644 --- a/src/ui/styles/annotations.css +++ b/src/ui/styles/annotations.css @@ -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 diff --git a/src/ui/views/panels/ScreenAnnotations.vue b/src/ui/views/panels/ScreenAnnotations.vue index 8f30da400..f1f49ee6a 100644 --- a/src/ui/views/panels/ScreenAnnotations.vue +++ b/src/ui/views/panels/ScreenAnnotations.vue @@ -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.