Compare commits

..
Author SHA1 Message Date
German Khokhlov a9b703f3ea Raise the main-chunk budget to main's growth
Main's own main chunk grew 232 KB since the baseline (the News layer and
the Swedish catalog), measured on keyless builds of main; this branch
still adds 72 KB on top of it.
2026-11-11 16:27:28 +04:00
German Khokhlov a757beba77 Merge origin/main into german/navara
Brings in the Swedish locale. Swedish gains the 14 messages this branch
adds and drops the 7 it no longer uses.
2026-11-11 16:14:44 +04:00
German Khokhlov ab5c593ab6 Launch QA browsers on SwiftShader where there is no Metal
The shared launcher always asked for ANGLE's Metal backend, which exists
only on macOS; on CI's Linux runners WebGL failed and the Street Level gate
never saw the app start. The default is now Metal on macOS and SwiftShader
elsewhere; QA_ANGLE still overrides it.
2026-11-11 15:55:12 +04:00
German Khokhlov 02ad2d045a Compare the 3D Tiles fixture's numbers within floating-point tolerance
The tileset's transform and probe points come from trigonometry whose last
bits differ between arm64 and x64, so a fixture written on one failed the
byte comparison on the other. The numbers now match to 1e-12 of their size;
the meshes and the served files are still compared byte for byte.
2026-11-11 15:55:12 +04:00
German Khokhlov ed47aeedbb Show Navara imagery at full detail and discard Esri's empty tiles
The tile budget left 768 MiB for tiles over the screen buffers, while an
oblique view needs about 0.9 GiB at 960x640 and 1.2 GiB at 1440x900; over
budget Navara coarsens the view and stops loading, so the start view stayed
at imagery level 15 where Cesium shows 17. The room for tiles is now
Navara's device default (a quarter of device memory, at most 2 GiB).

Terrain tiles also split past the keyless mesh's level 14, so imagery can
refine beyond level 15, and OSM stops at its level 19.

Esri answers a tile it has no imagery for with a grey "Map data not yet
available" image (over open ocean from level 14). Cesium discards it and
keeps the parent level; on Navara that tile now fails the same way.
2026-11-11 14:54:50 +04:00
German Khokhlov 6a7fed6cb7 Let waitForEngine take any engine picker value
The check added for a missing engine kind rejected navara-hd, a picker
value the HD scenario waits for.
2026-11-11 14:54:50 +04:00
German Khokhlov 2d0aa25e35 Fix the trail and decoded-weather probes on both engines
Both probes called waitForEngine without the engine kind, which can never
match and timed out after 120 s; waitForEngine now rejects a missing kind.
The trail probe passed no result vectors to the geo helpers, and drew its
trail below the ground, where only the dimmed occluded pass showed it; it
now draws halfway down to the ground and both engines count the same
pixels.
2026-11-11 14:01:29 +04:00
German Khokhlov 1856477e0a Merge origin/main into german/navara
Brings in 190 commits from main. Integration on the engine contract:

- News and Shipwrecks draw, pick and keep their selection through the
  contract and shared map markers, so they follow engine switches.
- Aircraft tracking uses main's segmented trails with contract frame
  numbers.
- Decoded weather tiles move into the Cesium adapter and are injected
  through the renderer registry; Navara hides those sources on globe
  surfaces (docs/KNOWN-ISSUES.md).
- Street-vehicle GPU depth occlusion stays Cesium-only; Navara keeps
  the geometric visibility checks (docs/KNOWN-ISSUES.md).
- Transit includes the heading-neutral atlas icons.
2026-11-11 13:41:30 +04:00
German Khokhlov af0dc2e2ec Wait for the traffic state the layer reaches in an oblique view
Roads the view cannot sample may keep their pending surface heights, as
before the refactoring; the oblique wait now asks for current coverage,
drawn traffic, finished loading and no scheduled refinement, and prints
every loading field when it times out.
2026-11-11 02:27:40 +04:00
German Khokhlov ed68be0610 Share the traffic refinement deadline across passes
Each refinement pass renewed the ten-second deadline, so changing
surface readiness could keep the layer refining, and loading, without
end. The passes now share one deadline, and the scheduled surface work is
reported as surfaceWaiting.
2026-11-11 02:27:40 +04:00
German Khokhlov b870f446c6 Let the cold-start comparison excuse a field within a bound
Field entries in the ledger allowlist now apply to every difference row,
in the ledger and in the probe alike, and may carry maxDelta. The traffic
fill gets one with a bound of three: its simulated vehicles enter and
leave every frame, so two pages snapshotted moments apart differ by a
vehicle or two.
2026-11-11 02:15:52 +04:00
German Khokhlov 49b2849528 Wait for the directions route flight instead of three seconds
The FLY check slept a fixed 3 s and then expected a route flight, which
passed or failed on the same commit. It now waits, up to 15 s, for the
layer to report the route motion and the camera to move.
2026-11-11 02:07:36 +04:00
German Khokhlov 00cb44ad2c Stop refining traffic surfaces that make no progress
An oblique view kept roads whose samples could not resolve from it, and
the refinement retried them without end, leaving the layer loading with
work pending. A refinement pass that resolves nothing new stops, and work
still pending at the existing deadline is dropped.
2026-11-11 02:07:36 +04:00
German Khokhlov ed6b6114cf Enable the Bhote Koshi event on Cesium while 3D tiles are shown
Under photoreal tiles Cesium hides the globe, so its imagery layers never
request tiles and the event's imagery install waited for a frame that
never came: turning the story on with 3D tiles on never finished. The
decoded images are installed hidden and shown afterwards by the same rule
as the presentation mode.
2026-11-11 02:07:36 +04:00
German Khokhlov c1795c7dc3 Sample the released-tileset check over open ocean
Under the camera and at 20°E 10°N other contract checks leave meshes and
models; Cesium's scene sample hits them, so the check failed in the full
suite and passed alone. It now samples two places none of them draws at.
2026-11-11 01:51:34 +04:00
German Khokhlov 69617af7ef Count only the placer's samples against its budget in the tiles scenario
The scenario counted every sampleHeight call in a task as the placer's
pass, so a single sample from elsewhere in the app that landed in the
same task pushed a full pass to 17. Calls that are neither a fixture
point nor the one camera estimate are now counted apart.
2026-11-11 00:45:36 +04:00
German Khokhlov 9e2c424030 Share the surface sample budget between placements in one task
Two triggers firing together (tiles loaded and a camera move end) ran two
passes in the same task, each within 16 samples but 17 or more together.
Placements in one task now share the budget; a placement that finds it
spent continues in the next task, and that continuation starts a fresh
budget.
2026-11-11 00:40:45 +04:00
German Khokhlov 982d197111 Check the surface pass's time budget right before each tile sample
The 4 ms budget was checked at the top of each point, but projecting the
point and reading the terrain came before its sample, so on Cesium a pick
render could start after the deadline. The time is checked again just
before the sample once the pass has taken one.
2026-11-11 00:25:31 +04:00
German Khokhlov 3ab0bbf7c1 Hide a selected radio station beyond the horizon, also after a switch
Re-adding the selected station's marker reset it to visible, and the
horizon check skips a still camera, so a station selected on the far side
of the Earth showed through the globe until the camera moved. The marker
now starts with the station's current horizon visibility, and the layer
recomputes it once the view is restored after an engine switch.
2026-11-11 00:21:45 +04:00
German Khokhlov 697dc91e81 Check a released tileset by height, not by the source label
Cesium labels every scene hit, the globe included, as tiles, so asking
that no answer is labelled tiles after leaving photoreal failed on the
reference engine. The check now asks that no tile geometry answers above
the terrain: a tile-labelled height must match the terrain height.
2026-11-10 23:39:36 +04:00
German Khokhlov e98bf9c485 Merge branch 'german/refactor-traffic-surface-pending' into german/navara-refactor 2026-11-10 22:31:21 +04:00
German Khokhlov be395f0f52 Finish traffic surface sampling with the camera still
Road samples that were not ready, or left over when a pass ran out of its
slice, waited for the next camera move; with the camera at rest they
stayed pending and part of the network never settled on the ground. The
flow now requests the frame it needs and continues the unresolved samples
within the same 12 ms slice and station budget.
2026-11-10 22:31:20 +04:00
German Khokhlov d83f06755f Merge branch 'german/refactor-cold-start-fixes-2' into german/navara-refactor 2026-11-10 22:31:14 +04:00
German Khokhlov 34b4fdce57 Release hidden CCTV geometry and excuse level-of-detail path counts
CCTV kept the coverage and projection geometry of every camera the
opening view passed on its way to the restored one; after each visibility
update it now releases the coverage of cameras out of view and the planes
of inactive cameras, keeping decoded media for reselection.

The cold-start comparison accepts a field entry in the ledger allowlist
that excuses one field of one set with a reason, and uses it for the
infrastructure footprint counts: that level of detail keeps features
chosen on the way to a view so they do not flicker while panning, which a
cold start cannot reproduce. Presence checks ignore field entries. The
fixtures give recent imagery a matching scene and date and traffic its
deterministic starts, and traffic counts as drawn without every loaded
vehicle visible.
2026-11-10 22:31:13 +04:00
German Khokhlov ed47de6a2c Let the tiles scenario allow the page's web fonts
The check for requests to Google and Cesium ion caught the Google Fonts
stylesheet the page loads on every run, which the map policy allows; it
now looks only for tiles, credits and ion.
2026-11-10 22:28:15 +04:00
German Khokhlov ee87482f07 Count the camera ground estimate within the surface sample budget
A pass checked its budget of 16 tile samples before each point, but a
point that needed the estimate of the ground under the camera took two
samples, so a pass could reach 17. The point is now left for the next
pass when the estimate used the last sample.
2026-11-10 22:28:14 +04:00
German Khokhlov 1d10ba242d Merge branch 'german/refactor-navara-photoreal-release' into german/navara-refactor 2026-11-10 22:15:50 +04:00
German Khokhlov cd571dfb32 Remove a released 3D tileset from Navara's scene and surface queries
With its cache budget on, Navara 0.2.1 deletes a source without emitting
the model-removal events, so the meshes of a released photoreal tileset
stayed in the scene: after switching to another map the buildings were
still drawn, and sampleHeight, clampToHeight, the shared surface
placement and ground drapes still answered from them. The adapter now
disposes the deleted layer's meshes, registry entries and retained
geometry itself, also for tiles that finish decoding afterwards, and
WORKAROUNDS.md records it. A contract check fails when a surface query
still reports tiles after leaving photoreal, with a mutant.
2026-11-10 22:15:50 +04:00
German Khokhlov ab41c765cc Merge branch 'german/refactor-e12-script-regressions-2' into german/navara-refactor 2026-11-10 22:05:58 +04:00
German Khokhlov 6b0d3f4675 Fix the camera picker and oblique traffic waits in two QA scripts
The CCTV camera check lost camera B from the picker when the flight to
camera A changed the cameras in view; the picker now keeps focus while A
is held. The oblique traffic wait compared exact seven-decimal view keys
that a settled camera never reproduced; it compares the coverage with the
current view within small tolerances and prints both keys on timeout.
2026-11-10 21:51:54 +04:00
German Khokhlov 4bbf9949d5 Merge branch 'german/refactor-navara-tiles-drape-2' into german/navara-refactor 2026-11-10 21:36:39 +04:00
German Khokhlov 88f434ded7 Merge branch 'german/refactor-cold-start-fixes' into german/navara-refactor 2026-11-10 21:36:39 +04:00
German Khokhlov 7c03d68a2f Report the depths behind each drape sample in the tiles scenario
With --report the scenario prints, on Navara, the scene, globe and
combined depths at the sample pixel with the drape hidden, shown and
hidden again, the buffer the ground pass classifies it as, and whether a
shown tile lies on the ray, so a drape on the wrong surface explains
itself.
2026-11-10 21:36:38 +04:00
German Khokhlov 705b7a5314 Make the cold-start comparison wait for the same data and attribute it
Both pages now get the same deterministic SATCAT, NASA image and wildlife
data and advance animation clocks alike; the scenario waits until each
display has published and the registries stay unchanged for two seconds,
and late-created content is attributed to its registered owner. Known
allocation counters are normalised; every slot, count and visibility is
still compared.

Infrastructure drew on attach before the camera was restored, seeding its
level of detail at the incoming engine's default view; it now draws when
the view is restored.
2026-11-10 21:36:38 +04:00
German Khokhlov 97e192ebce Request frames again while waiting for tiles and transitions
A request-mode globe updates its tile state only on drawn frames, so tile
and UI readiness that never asked for one waited until something else
drew: imagery, weather and ground scenarios took several times longer.
The idle checks measure through their own passive observer and are not
affected.
2026-11-10 21:25:08 +04:00
German Khokhlov 555906da6b Merge branch 'german/refactor-e12-script-regressions' into german/navara-refactor 2026-11-10 20:58:16 +04:00
German Khokhlov b5cc4feacb Restore the conditions eight converted QA scripts were waiting for
The harness conversion replaced pauses that stood in for real conditions
with camera readiness: the cables and infrastructure idle checks started
measuring while the overlay was still fading in after moveEnd, directions
sampled before its maneuver dots had ground cells, three scripts clicked
before the loading cover was gone, the credits check hit-tested during UI
transitions, and the oblique traffic flight lost its height cap. Each now
waits for its condition; idle checks observe without requesting frames
and record the reasons of the frames they count; readiness waits for the
startup cover and no longer requests frames while polling.
2026-11-10 20:14:50 +04:00
German Khokhlov 2c62e1e378 Merge branch 'german/refactor-navara-tiles-drape' into german/navara-refactor 2026-11-10 19:12:25 +04:00
German Khokhlov b3daed4a9b Drape a 'tiles' ground set only on 3D tiles on Navara
Navara's ground pass took a pixel for a tile whenever its depth lay in
front of the saved globe depth, without checking which buffer the visible
depth came from; the scene buffer can be cleared apart from the globe
depth, so a set draped on 'tiles' also drew on bare terrain, which Cesium
never does. The pass now matches the visible depth to the globe or the
tiles before it drapes. A contract check compares a 'tiles' set hidden,
shown and hidden again over terrain, with a mutant.
2026-11-10 19:12:25 +04:00
German Khokhlov 087adc6dc9 Count a running mission replay as the camera's owner in readiness
A Space Missions replay flies the camera until it ends, so waiting for a
still camera after a switch let the replay finish before the scenario
looked at it. While a replay runs, readiness no longer asks for enabled
inputs or a still pose, as for the cockpit and a followed target.
2026-11-10 19:11:08 +04:00
German Khokhlov d3433f66c9 Merge branch 'german/refactor-e8-replay-switch' into german/navara-refactor 2026-11-10 19:11:07 +04:00
German Khokhlov 2dcec2195e Check that a running mission replay survives an engine switch
The session test now switches engines in both directions with the replay
running, paused and resumed, and checks it stays active, unpaused and
advancing.
2026-11-10 19:11:07 +04:00
German Khokhlov facc9541ab Merge branch 'german/refactor-e1-readiness-fixes' into german/navara-refactor 2026-11-10 18:41:42 +04:00
German Khokhlov f288b6ba1e Merge branch 'german/refactor-e8-switch-regressions' into german/navara-refactor 2026-11-10 18:41:42 +04:00
German Khokhlov b73fe82e27 Let readiness waits know when the app owns the camera
While the cockpit holds camera inputs or the camera follows a target,
whenSettled no longer waits for enabled inputs or a still pose; it still
waits for the engine, the restored view, drawn frames and no flight, and
its timeout now prints what it saw (inputs, following, cockpit, last pose
change). Scenarios that waited for the camera where they needed the
picture wait for it instead: weather and Nepal imagery for their tiles
and frames, imagery capture while a box drag holds inputs, and pixel and
pick polling for drawn frames. The vessel click retries at the vessel's
rendered position.
2026-11-10 18:31:45 +04:00
German Khokhlov 39206ef4c3 Keep Space Missions and the cockpit through an engine switch
Retaining a disabled layer that was never initialised replayed its
parameters, and the change notice it sent restored the Context snapshot,
which ended the Space Missions mode on every switch. Such layers are
retained as they are again.

The tracked aircraft's handle is rebuilt only once the view is restored,
so the share restore's earlier trackById found nothing to follow and the
cockpit did not come back after a failed switch rolled back. The scene
switcher now takes the aircraft up again after the camera owners resume
and then restores the cockpit and its vision mode. Both are covered by
tests on the real session modules.
2026-11-10 18:29:03 +04:00
German Khokhlov 417890b862 Merge branch 'german/refactor-e8-cold-start' into german/navara-refactor 2026-11-10 17:47:30 +04:00
German Khokhlov 8caff876cb Merge branch 'german/refactor-e12b-fake-engine-tests' into german/navara-refactor 2026-11-10 17:47:29 +04:00
German Khokhlov 560b72c3f7 Merge branch 'german/refactor-e6-tiles-heights' into german/navara-refactor 2026-11-10 17:47:29 +04:00
German Khokhlov 00153acbeb Keep Navara's 3D Tiles where the tileset puts them
Navara's 3D Tiles source defaults to model.height 1, which moves a
transformed tileset by a metre along Earth-fixed X: on the synthetic set
flat roofs read 0.12 m high and a sloping roof 0.68 m off, and the drawn
meshes move with them. The adapter sets the height to 0 and records the
package behaviour in WORKAROUNDS.md, with a test that loads the real
package. The downward surface ray now takes its direction from the
place's own normal, so it no longer drifts sideways.

qa-tiles-fixture gains --report (heights and drape pixels per engine,
without asserting), starts on Cesium, and checks each drape hidden, shown
and hidden again; qa-surface-heights uses the plan's 0.5 m tolerance under
tiles and names the point that misses.
2026-11-10 17:16:23 +04:00
German Khokhlov 7c5ebf4814 Check that an engine switch draws what a cold start from the share link draws
qa-switch-equals-cold-start switches engines in both directions with each
layer on deterministic fixtures, then opens the share link of the same
state in a new page on the target engine, and compares the draw ledgers
and the probe's view of the layer. It runs as 19 acceptance groups
covering all 38 layers.

State a layer keeps through a switch but the share link does not carry is
declared as sessionState (the directions route, Radio's selected station
and audio state, the wind reading, local ADS-B's and OSM Places' local
toggles and results); a test checks each declaration against what the
layer really retains, so a declaration cannot hide a regression.
2026-11-10 17:08:05 +04:00
German Khokhlov 225c1c25c7 Build test engines from the shared fake instead of literals
Tests that made their own engine object now use createFakeEngine() with
its scripting hooks or an override of the one member under test, so they
follow the contract when it changes. 125 such literals in 74 test files
go down to two that are not globe engines on purpose; report-qa-harness
counts them, and the baseline keeps the number from growing.
2026-11-10 17:07:27 +04:00
German Khokhlov 3c304502a0 Merge branch 'german/refactor-e12-qa-harness-2' into german/navara-refactor
# Conflicts:
#	scripts/qa-harness-baseline.json
#	src/layers/satellites/cockpitSubject.test.mjs
2026-11-10 16:45:38 +04:00
German Khokhlov df02033465 Share copied test fixtures through src/testSupport
Environment overrides, manual timers, a cancellable streaming response,
GhostMaps KML/KMZ fixtures and the satellite service doubles move into
five shared helpers; 21 tests drop 418 lines of copies.
2026-11-10 16:43:58 +04:00
German Khokhlov 6c416f3fe6 Move QA scripts to the shared harness, batch 4 of 4
As in the previous batches. Scripts launching Chrome themselves go from
99 to 8 (all outside this conversion), fixed pauses from 86 to 69 and
Cesium-only reads from 75 to 46; the QA harness baseline records them.
2026-11-10 16:43:58 +04:00
German Khokhlov 53b07ba91f Move QA scripts to the shared harness, batch 3 of 4
Each script launches and closes Chrome through scripts/qa/browser.mjs,
waits on the application through scripts/qa/ready.mjs instead of the
loading screen and fixed pauses, and reads the contract instead of
Cesium where the check is not about Cesium itself.
2026-11-10 16:43:58 +04:00
German Khokhlov 373edf2989 Move QA scripts to the shared harness, batch 2 of 4
Each script launches and closes Chrome through scripts/qa/browser.mjs,
waits on the application through scripts/qa/ready.mjs instead of the
loading screen and fixed pauses, and reads the contract instead of
Cesium where the check is not about Cesium itself.
2026-11-10 16:43:58 +04:00
German Khokhlov ee697422c9 Move QA scripts to the shared harness, batch 1 of 4
Each script launches and closes Chrome through scripts/qa/browser.mjs,
waits on the application through scripts/qa/ready.mjs instead of the
loading screen and fixed pauses, and reads the contract instead of
Cesium where the check is not about Cesium itself.
2026-11-10 16:43:58 +04:00
German Khokhlov 731eadc34a Give QA scripts one launch, readiness and geometry helper
scripts/qa/browser.mjs launches headless Chrome with QA_ANGLE and the
shared Chrome lookup; scripts/qa/ready.mjs adds frame, tile and transition
readiness and bounded polling; scripts/qa/geometry.mjs hands page
evaluations the engine-neutral geometry without dev-only source URLs.
CODE-BOUNDARIES gains the rule for new QA scripts.
2026-11-10 16:43:58 +04:00
German Khokhlov 08d5c86e80 Merge branch 'german/refactor-e7-height-datum' into german/navara-refactor 2026-11-10 16:42:50 +04:00
German Khokhlov dd56cba2f3 Measure the heights the surface-bound layers place their points at
qa-surface-heights loads deterministic fixtures for import pins,
infrastructure bases, ALPR markers, directions A/B, bikeshare stations
and Bhote Koshi story points, waits for the app to settle on each engine
and records every placed point through the mover contract. --compare
fails when a point moved by more than 0.1 m on terrain; --tiles checks
the points land on the synthetic tileset's 50 m block.
2026-11-10 16:42:50 +04:00
German Khokhlov 59ff0b38a2 Merge branch 'german/refactor-e8-switch-protocol' into german/navara-refactor
# Conflicts:
#	scripts/engine-coupling-baseline.json
#	src/layers/wind/index.test.mjs
2026-11-10 16:35:39 +04:00
German Khokhlov a2905eb700 Move the engine switch protocol from the layers into the scene binding
attachRenderer takes one context { engine, signal, viewRestored, ... } and
bindCatalog no longer takes a viewer; no layer module receives Cesium's
viewer any more, and a rehearsal check fails if one reads it.

The handoff between engines is one typed object (layers, camera owners,
selection, director) created when the old scene leaves, replacing the
session's handoffLayers, restoringView and selectionContext. viewRestored
resolves where the camera is restored, and the nine layers that kept
their own restoring-view flag wait on it instead. After attach the
binding applies a disabled layer's visibility, registers the picks each
module declares, and resumes the camera owners, so engineSwitch.js no
longer names directions or satellites.
2026-11-10 16:33:29 +04:00
German Khokhlov d51e4140b7 Merge branch 'german/refactor-e10-layers-without-viewer' into german/navara-refactor
# Conflicts:
#	scripts/package-boundaries.json
2026-11-10 16:16:32 +04:00
German Khokhlov bcff542b16 Merge branch 'german/refactor-e7-surface-placement' into german/navara-refactor
# Conflicts:
#	scripts/package-boundaries.json
2026-11-10 16:15:09 +04:00
German Khokhlov aac65c21c4 Open the orbital cockpit on Navara
The orbital view, sensor zoom, ground focus and satellite switching run on
the contract: camera.setFovy sets the vertical lens on both engines and the
fake, including sub-degree angles on Navara through the guarded core
bridge, with a contract check and mutants. Cesium's imagery look-ahead
stays inside its adapter. KNOWN-ISSUES keeps the two parts Navara still
lacks: imagery prefetch for the orbital view and finest-detail meshes for
the ship bridge waterline.
2026-11-10 16:14:14 +04:00
German Khokhlov 6727aaadcd Fly, follow and draw the celestial ring through the contract
The Bhote Koshi event flies with engine.camera instead of the viewer's
camera, tools listens to camera.followChanged instead of the viewer's
tracked entity, and the celestial ring uses the engine's host, frames,
pose and render requests. The embedded story media no longer imports
Cesium, which removes one of the three import exceptions.
2026-11-10 16:14:14 +04:00
German Khokhlov 4f654df3c0 Inspect the wind field through the engine contract
The inspection marker and the centre probe use the engine's host,
projection, frame events, ellipsoid pick and render requests instead of
Cesium's scene, so they behave the same on Navara.
2026-11-10 16:14:14 +04:00
German Khokhlov e0d3e93a28 Move the wind's Cesium rendering into the Cesium adapter
The particle canvas, its GPU path and the relief shading move unchanged
from src/layers/wind/ to src/globe/cesium/; the adapter reads the Cesium
viewer itself, so the layer no longer hands one in.
2026-11-10 16:14:14 +04:00
German Khokhlov 82ed68f443 Place every surface-bound layer through the shared placement
Import pins, infrastructure, ALPR, directions markers, bikeshare and the
Bhote Koshi story use createSurfacePoints instead of their own sampling,
budgets and partial triggers; ground snap, the mesh floor sampler,
traffic, transit, Street Level, the camera ground guard, wildlife and the
cockpit host read heights through readSurface or surfaceTriggers.
Per-layer offsets and floor policies stay as options. The import no
longer re-places pins on every camera move from the UI.

Calls to engine.surface outside src/globe go from 41 sampling calls to
10, each a raycast, a one-shot fly-to height or an asynchronous detailed
sample.
2026-11-10 16:13:06 +04:00
German Khokhlov ef104aaac3 Move surface placement into the globe and give it the fourth trigger
src/globe/surfacePlacement.js takes over the shared surface placer and
adds readSurface (tiles through sampleHeight, otherwise terrain, with a
plausibility range), surfaceTriggers (one subscription to camera moves,
tile loads, the photoreal switch and terrain revisions) and a forced full
pass. Points now re-place when the terrain provider changes, which they
did not before. GhostMaps, cables, OSM Places and Street Level move to the
new module unchanged.
2026-11-10 16:13:06 +04:00
German Khokhlov 50e486ceec Merge branch 'german/refactor-e11-boundary-catalogs' into german/navara-refactor
# Conflicts:
#	scripts/package-boundaries.json
2026-11-10 16:08:08 +04:00
German Khokhlov 75cf0aeaf9 Merge branch 'german/refactor-e4-model-retry' into german/navara-refactor 2026-11-10 16:04:06 +04:00
German Khokhlov a07d9f5d85 Retry a failed 3D model inside the engine instead of in the layers
A mover set whose glTF or atlas fails to load retries by itself after
retryAfterMs (1,500 ms by default), at most three attempts, on Cesium,
Navara and the fake engine alike: failed() is true while the latest
attempt failed and stays true after the last, ready() turns true when a
retry loads, and an instance given another pickId loads again. Healthy
models of the same class no longer load again when one fails.

The layers drop what they did instead: moverBank's rebuilds, the loops
that dropped failed handles in flights, military and local ADS-B, and the
tracked-model backoff in the aircraft display. The contract suite checks
the one behaviour on both engines, with a mutant that never retries.
2026-11-10 16:04:01 +04:00
German Khokhlov 27939a2d84 Own shared foundations by directory and check the manifest both ways
src/i18n/, src/geo/ and the engine-neutral part of src/globe/ are owned by
directory entries, and aggregate groups use a directory wherever their
list covered it, so a new shared module no longer needs a manifest edit.
Small groups keep explicit lists, now checked as a snapshot: the check
fails on a reached module that is not declared and on a declared one no
longer reached, and prints both per group. --write keeps directory
entries as they are.

The manifest goes from 4,737 entries to 3,494; src/i18n accounts for 87
of them instead of 666.
2026-11-10 15:59:43 +04:00
German Khokhlov 1ac9f28c4a Merge branch 'german/refactor-e6-tiles-fixture' into german/navara-refactor
# Conflicts:
#	scripts/qa-harness-baseline.json
2026-11-10 15:54:13 +04:00
German Khokhlov e9ba5e9859 Check the photoreal paths in the browser on a synthetic 3D Tiles set
A generated tileset (a plate at ellipsoid level, 50 m and 200 m blocks and
a pitched roof, 27 KB) replaces Google's tiles when a QA page passes
?qaTileset=/path; only a root-relative path on the page origin is
accepted, and the Google credit is not shown for it. The strict harness
serves the set from disk, so nothing reaches Google.

qa-tiles-fixture checks on both engines that sampleHeight reads the block
at 50 m, clampToHeight lands on the roof, tiles and terrain drapes draw
where they should, tilesLoaded turns true, surface points land on the
roof within their budget, and pick renders per pass stay within it.
2026-11-10 15:49:52 +04:00
German Khokhlov 37263a3e2b Give the draw ledger test a drawn mover instance
A mover set lists as shown only once one of its drawn instances is.
2026-11-10 15:48:49 +04:00
German Khokhlov 18da76e8d8 Merge branch 'german/refactor-e1-qa-readiness' into german/navara-refactor
# Conflicts:
#	scripts/qa-harness-baseline.json
#	scripts/qa-strict-harness.mjs
2026-11-10 15:47:21 +04:00
German Khokhlov 60f9de8511 Wait for the application, not the clock, in the strict QA set
The probe exposes settled() and the first-frame outcome; scripts/qa/ready.mjs
waits for drawn frames with the camera at rest and for a pick at the click
position, with a deadline and its reason in the error. The strict scenarios
use these instead of fixed pauses and loading-screen polling, and the
hidden-switch scenario checks how the loading screen was lifted instead of
how long it took.

Each switch scenario now records a draw ledger per engine and runs a
contract monitor; both write to the scenario's evidence file only, with an
empty allowlist until browser runs show what differs on purpose.
2026-11-10 15:46:26 +04:00
German Khokhlov 5b27f26e14 Merge branch 'german/refactor-e5-navara-mesh-colour' into german/navara-refactor 2026-11-10 15:45:37 +04:00
German Khokhlov 28f3030c31 Draw Navara world meshes in the overlay pass after tone mapping
Meshes now go through the same overlay as sprites and lines, so their
colours are the ones asked for without undoing the view's exposure. The
contract suite no longer carries the mesh colour check as a known
failure on Navara.
2026-11-10 15:45:26 +04:00
German Khokhlov a90407d547 Count the Navara canary script in the QA harness baseline
It drives a bare Navara view outside the app to test the package itself, so it launches Chrome and paces its fixture on its own.
2026-11-10 15:30:09 +04:00
German Khokhlov 33ccef9662 Merge branch 'german/refactor-e3-contract-behaviour' into german/navara-refactor 2026-11-10 15:27:12 +04:00
German Khokhlov 49b180f1fd Merge branch 'german/refactor-e9-import-plan' into german/navara-refactor 2026-11-10 15:27:12 +04:00
German Khokhlov 233c4def5b Merge branch 'german/refactor-e2-layer-lifecycle' into german/navara-refactor 2026-11-10 15:27:12 +04:00
German Khokhlov 0953287c3d Merge branch 'german/refactor-e13-navara-workarounds' into german/navara-refactor 2026-11-10 15:27:12 +04:00
German Khokhlov b03edaaf2b Check the globe contract by behaviour on every engine
The contract suite is split into one module per area and grows from 87
to 114 checks, each named after the member it exercises: a gesture during
a flight does not cut it, a render request inside a frame draws the next
one, a pure rotation raises changed, ground corridors are as wide in CSS
pixels at device scale 2, imagery replans on resize, drapes draw only on
their surface, sampleTerrainMostDetailed keeps order and passes on a
rejection, terrainRevision changes on a new terrain and on fallback,
follows raise followChanged on retarget, and a mesh, a ground fill and an
imagery pixel draw the colour asked for. In the browser Cesium passes all
113 it runs at device scale 1 and 2; Navara passes 110 of 111 and reports
its single-colour mesh, drawn before tone mapping, as a known failure.

scripts/contract-mutants.test.mjs breaks one promise of the fake engine
at a time, 30 mutants in all, and names the check that catches each; the
list of contract members the suite never calls is down to five.

maps.errors reports tile and terrain errors where an engine has them
(the tileErrors capability: Cesium yes, Navara no), and the map
controller falls back to another map only where it does; movers.list()
rows tell whether a set is shown, as the other families do; FollowHandle
.name and PickResult.primitive are marked deprecated. qa-contract-suite
takes --dpr and the ANGLE backend from QA_ANGLE.
2026-11-10 15:26:55 +04:00
German Khokhlov 9ae7dea918 Plan KML and KMZ drawing in one pure function, checked against Cesium
src/data/geoImportPlan.js decides what an imported file draws and with
what: raised or clamped geometry, rings and holes, rotation near the
poles and across 360 degrees, absolute and relative altitudes, draw
order, canvas budgets and the vertex and ring limits, with no engine and
no DOM. drawImportedFile draws that plan through the globe contract and
keeps pin placement, yielding and cancellation. Every limit of the import
lives in src/data/geoImportLimits.js with what it protects.

scripts/fixtures/geo-import holds 13 files covering those cases, and
reference.json what Cesium's own KML and GeoJSON data sources make of
them; scripts/qa-geo-import-reference.mjs records it and compares the
plan with it. Property tests on seeded random polygons check area,
bounds, ring closure and winding. Writing the plan fixed three cases:
overlays below the ellipsoid kept a negative height, raised plain-colour
overlays now draw, and the vertex limit no longer depends on whether a
ring repeats its first point.
2026-11-10 15:26:10 +04:00
German Khokhlov ffbceb977c Register the Navara package workarounds and check them against the package
src/globe/navara/WORKAROUNDS.md lists every place the adapter works
around @navaramap/three 0.2.1: what the package does wrong or lacks, how
it is detected and what to delete once it is fixed.
scripts/qa-navara-canaries.mjs confirms three of the bugs on the real
package in the browser (batch ids never released, events of a deleted
vector layer kept queued, idle reported before a raster tile arrives) and
names the workaround to delete when one is gone.

The private members the adapter reads are probed when the engine starts,
with an error naming the member and the pinned version instead of a
silent fallback, and the test that only internals.js reaches private
members now scans the adapter's subfolders too.
2026-11-10 15:22:43 +04:00
German Khokhlov 20ad9b41b3 Rehearse every catalog layer across two engines in the unit tests
lifecycleRehearsal.test.mjs takes each of the 38 catalog layers from
attach on one fake engine through enable, detach and that engine's
destruction to attach on a second engine, resume and disable. It checks
that the engine left keeps no drawn sets, listeners, credits, input
suspensions, continuous renders or follows; that nothing writes to it
afterwards; that a layer waiting for the camera restore reads no source
before resumeViewport; that a disabled layer stays hidden after attach;
and which layers still read the viewer argument, a list that may only
shrink.

The catalog takes the displays and scene services a test supplies, and
the pick registry lists its owners. Two defects it found are fixed:
Active Fires loaded its heatmap before the camera restore it was told to
wait for, and the wind inspection marker asked Cesium's scene for a
render on any engine instead of the engine's render request.
2026-11-10 15:01:59 +04:00
German Khokhlov bd4f9eaee4 Keep the strict scenarios in step with main's photos and satellite groups
Main's flight photos ask Planespotters for every tracked aircraft, which
the strict network policy counted as a violation; the harness now answers
those lookups with no photos, as the other QA scripts already do. Main
also reads five more TLE groups (resource, weather, planet, SAR and
science): the satellite fixture serves them, and the space switch expects
all twelve groups and derives its catalog sizes from the fixture.
2026-11-10 15:01:39 +04:00
German Khokhlov 9c2a2d6951 Measure engine coupling, QA harness use and contract coverage
Counters that may only fall, each with a committed baseline and a test
that fails on growth:

- report-engine-coupling.mjs gains layer modules that take the viewer
  in attachRenderer (33), layer folders with their own "view being
  restored" flag (9), engine.surface calls outside src/globe (41) and
  layer ids named in the engine switch (3).
- report-qa-harness.mjs counts QA scripts that launch Chrome themselves
  (98), wait with fixed sleeps (117), take the hidden loading screen as
  readiness (9), read Cesium-only objects (75) or define their own
  waitFor helpers (19).
- withCallLog records every contract member the contract suite calls on
  the fake engine; contractCoverage.test.mjs fails when a member outside
  contract-uncalled-baseline.json goes uncalled, and when a listed one
  is called now, so the list of 12 only shrinks.
2026-11-10 14:23:29 +04:00
German Khokhlov ba04ceaa32 Describe the strict page clock as the nearest 18:30 UTC 2026-11-10 14:23:29 +04:00
German Khokhlov ed6ae54ef6 Stop the strict acceptance from waiting for itself
A scenario whose body ran past about 19 s idled until 45 s after its
start, and twice one never exited and hit the runner's 300 s timeout
after it had passed: something of Chrome outlived Browser.close and kept
its stdout and stderr open.

- scripts/qa/browser.mjs closes Chrome within 5 s, kills its process
  group and closes its pipes; the harness then finishes and exits with
  the scenario's code. QA_EXIT_DIAGNOSTICS=1 keeps the old ending and
  prints the open handles and Chrome's process tree 5 s after finish.
- Every strict page starts with its clock at the 18:30 UTC nearest to
  the real time, so Navara's sun is up over the test cities whenever the
  acceptance runs; a scenario can ask for its own instant or the real
  clock (Wildlife does, for its freshness fixtures).
- The runner records a scenario that passed but did not exit as hung,
  kills the whole process group on a timeout, runs a failed scenario
  once more and reports one that passes then as flaky, and appends each
  run to --history=FILE; scripts/report-acceptance-flake.mjs reads it.
2026-11-10 14:10:46 +04:00
German Khokhlov a913e5466e Fill raised KML polygons up to 32,768 vertices
A raised polygon past 4,096 vertices lost its fill, which main drew.
Triangulating 32,768 vertices takes about 58 ms on a fast laptop, so the
fill limit moves there; 128 rings stay the hole limit, since holes make
earcut quadratic. The polygon loop yields to the event loop after 8 ms of
work, and the import cancels a draw whose globe went away while it
yielded, without reporting the file as unreadable.

KNOWN-ISSUES names the fill limit and two Navara limits: an expired
terrain token is not refreshed while the page is open, and a failing map
source does not fall back to another map.
2026-11-10 14:03:35 +04:00
German Khokhlov 2a761aa5eb Merge origin/main into german/navara
Main's work since the last merge runs on the globe contract where this
branch draws through it:

- Satellites take main's classes, sensors, ground-track bearings and
  world time through contract points and rings; launches take the world
  timeline. The satellite's cockpit subject dims, copies positions and
  refocuses through the contract.
- Flights, military and vessels keep main's photos, retry cool-downs,
  world-clock motion and the ship bridge. Trail seeding in the shared
  aircraft tracking reads each layer's clock.
- Traffic's flow lines and single build draw as ground sets; transit
  keeps dated credits and the new cities; CCTV opens the nearest camera,
  and the Quebec overlay projects and tests the horizon through the
  contract. Bikeshare's manager cleanup moved into its current lifecycle.
- Draw keeps main's mark actions across engine switches.
- The world clock in src/time is plain numbers again: Julian dates come
  from src/geo, the frame loop is injected, and the binding to Cesium's
  viewer clock lives in src/globe/cesium/worldTime.js. Navara's sky and
  sun follow the world time; live feeds keep the real time.
- The cockpit's orbital view, with its sensor zoom, ground focus and
  satellite switching, stays Cesium-only for now: Navara hides its entry.
  Its Cesium calls live in src/globe/cesium/orbital.js. The ship bridge's
  finest-detail waterline sampling is Cesium-only too; Navara samples
  loaded geometry and detailed terrain. Both are in KNOWN-ISSUES.
- Main's tests that built a Cesium scene by hand run on the fake engine.
- The boundary manifest is regenerated, the coupling baseline records
  the orbital and bridge code, and the main-chunk baseline is raised to
  the merged size: main itself grew its main chunk by 93,959 bytes
  (gzip -9) since the previous merge base.
2026-11-10 13:52:52 +04:00
German Khokhlov f437c615a0 Add a --write mode to the package boundary check
`node scripts/check-package-boundaries.mjs --write` regenerates each
group's module list in scripts/package-boundaries.json from what the
group's build reaches: entries that own nothing reached are removed, newly
reached modules are appended in sorted order, and the order of kept
entries stays. The check without the flag is unchanged.
2026-11-10 12:56:36 +04:00
German Khokhlov 60fb8fee07 Fix the follow-up review findings in import, layers, startup and engines
- KML/KMZ import: a per-file canvas budget and cache for ground overlays,
  limits on rings and vertices of raised polygons, overlays across 360° and
  near the poles, degenerate holes, polar and single-latitude fills,
  absolute overlays at their altitude, overlay order by drawOrder as on
  Cesium, and pins on tiles keep their measured height.
- Points on the surface: OSM Places and Street Level cones share the
  surface sampler and sit on the terrain before they are measured, within
  a sampling budget per pass; Cockpit's contact-dot bank keeps the dot
  atlas, and a failed fleet model loads again after a pause.
- Startup: the loading screen lifts after the share-link view is restored,
  the retry screen names the failed engine and takes focus, and the
  fallback toast stays for 6 s.
- Engines: an engine switch lands a running camera flight at its
  destination (camera.completeFlight on both engines), the sighting card
  leaves focus on the engine picker after a switch, and a Cesium model
  error stays with the instance that failed.
- The MCP panel builds again: its wind-mesh alias resolves to the stub.
- QA: the wildlife switch waits for the camera to rest, GBFS --w2 says it
  needs the dev server, and test fixtures are shared instead of copied.
2026-11-10 01:57:11 +04:00
German Khokhlov 0ef29781e5 Fix the review findings across the engines, layers, data and QA
Navara now matches Cesium where the review found it diverging: a flight
holds the camera inputs, camera.changed counts turns as Cesium does,
render requests made during a frame survive it, follow pinches zoom,
ground widths follow the pixel ratio and Cyber sonar reaches glyph caches
and models. On Cesium, a staged imagery swap keeps loading tiles, setView
replaces a running flight, and a cleared weather detail goes away.

The contract gains MoverSet.failed, maps.terrainRevision,
render.maxTextureSize and surface.sampleTerrainMostDetailed on every
engine, so layers retry failed models, key road heights by terrain,
size weather textures to the GPU and place Bhote Koshi cards on detailed
terrain.

Engine switches keep the live view, follows, focus, layer options and
cards: a pick hands over the exact pose, applies no address options and
pans no carried-over card; a history visit still applies its entry.

Layers put GhostMaps pins, cable landings, OSM Places and directions on
the measured surface without per-frame sampling, draw the Cockpit contact
dot, hide disabled CCTV on attach, and keep ALPR running outside secure
contexts. KML imports draw raised polygon fills, rotated and coloured
ground overlays and north-referenced icon headings, and report files
with nothing to draw.

QA scripts read the contract instead of Cesium internals (test:track is
109/109 again), wait for the camera to rest before clicking, and give
cold switches and parked engines realistic time. Docs follow the code.
2026-11-09 21:34:42 +04:00
German Khokhlov 69fbf5b38d Check hidden lines behind the Earth and open the picker panel by keyboard
Cesium culls a line the globe hides whole and does not depth-test lines
against the near side of a keyless globe, so the contract check runs the
hidden line round the back of the Earth to a visible end. The wildlife
switch scenario opens VISUAL PRESETS from the keyboard before tabbing to
the engine picker and reports the page state when no switch follows.
2026-11-09 13:03:13 +04:00
German Khokhlov 68845a4977 Start Navara's camera model from a placed pose only
Navara's camera sits at the Earth's centre until it is first placed. The
model took that as the pose, so camera.height() threw while a layer
attached to a starting Navara (wildlife sightings asks for it), and
switching to Navara with that layer on rolled back to Cesium.
2026-11-09 13:03:13 +04:00
German Khokhlov b4556f9763 Test layers on one engine and share selection and shader helpers
Layer display tests no longer re-check Cesium primitives in every layer:
they run on the fake engine, and one Cesium translation check per ground
kind lives in the Cesium engine's ground tests. Their harnesses are shared,
and the fake-engine, Cesium-engine and flights tests that repeated the
contract suite are gone, 340 tests in all.

The press-travel measurement is shared by the selection dispatcher and
CCTV, which loses its ScreenSpaceEventHandler shim and world-click module.
ALPR, installations and bikeshare share one selection keeper and selected
marker write. Layers with their own click rules keep their handlers.

Navara's pulse is a class like the ground pass, the ground and imagery
passes share the surface uniforms, and the colour, log-depth and occlusion
GLSL lives in one chunk module, each caller keeping its depth bias. The
ground suite runs inside the contract suite (--only=ground:), and fixture
and script names say what they test.

Behaviour is unchanged on both engines.
2026-11-08 17:06:10 +04:00
German Khokhlov 7f478e3815 Remove always-true layer availability and leftover viewer paths
Every layer draws on both engines, so the per-engine availability
machinery always answered yes. The renderer manifest is now the id to
display map plus the wind flow backend; the catalog filter, the voice and
QA-probe refusals and the engine-kind checks go with it. The Navara
notice lists the same layers in the same words.

Paths that read the Cesium viewer behind an engine that is always there
are gone: the Recent Imagery viewer fallbacks, the shell's imagery-host
service (one shared host module remains), the engineless map controller,
viewer gates in voice actions, world packets and camera verbs, the
orientation controls' tracked-entity fallback, and the Bhote Koshi and
FIRMS Cesium-shaped paths. setCameraInputs, which nothing called, leaves
the contract; suspensions remain.

Smaller duplicates go too: one tracker-release helper for navigation and
location, the no-op frame latch, missing-engine guards, one name for the
injected display, flat bikeshare and installations displays, one overlay
host and binder, one name per world-overlay method, and stub-viewer
guards in the Cesium engine. Layers that still read the viewer keep the
shared init(viewer, context) entry.

Behaviour is unchanged on both engines. Viewer lines fall from 849 to
750 and engine-name literals from 32 to 27.
2026-11-08 17:06:01 +04:00
German Khokhlov 371d848eec Delete dead render code and consolidate QA helpers
Remove code that nothing in the app, the runners or CI uses:
- the one-off migration compare, look and bench scripts and the vector
  ground prototype they measured, with the bench tables in the docs;
- the unused Navara-named shared render modules (polygons, ground picks,
  mover bank, ground ring);
- the Cesium-shaped aircraft and vessel test renderers, and the trail,
  engineFromViewer and horizon-occluder helpers only they kept alive.
  Their tests now run on the production displays over the fake engine;
- Navara's unread diagnostics, fallback ports, dead exports and
  parameters, stale effect-chain anchors and the unread capability flags.

The QA scripts share one keyless guard and readiness wait, the session
harness reuses the strict harness's browser flags and block list, and the
harness honours QA_ANGLE and QA_RESERVED_PORTS. The bikeshare fixture
keeps only what its scenario reads. The docs name the engine picker where
it is (VISUAL PRESETS) and drop dated run results.

No behaviour changes on either engine. Navara layer renderer lines fall
from 189 to 0 and viewer lines from 891 to 849.
2026-11-08 17:05:38 +04:00
German Khokhlov 18f69c2699 Follow, credits, embed views and FPS through the engine on Navara
Five places still read the Cesium viewer and so did nothing, or the wrong
thing, on Navara. Each now goes through the globe contract, and Cesium
behaves as before.

- The Navara ground floor stands aside while the camera inputs are
  suspended, not only while they are switched off. The cockpit suspends
  them, so its eye 12 m up no longer jumps to the floor's 30 m between
  updates. A follow's own suspension keeps the floor on, as before.
- Follow state comes from engine.camera.following() and followChanged
  instead of viewer.trackedEntity. Enabling CCTV during a follow no longer
  flies away and ends it, a new follow stamps navigation, Director cards
  wait while a subject is followed, and an explicit navigation stops the
  follow on both engines.
- Navara registers every data credit, as Cesium does. The allowlist left
  out the layers that moved to Navara later (CCTV agencies, street-level
  imagery, GIBS, routing, bike feeds and others).
- Annotation links and embed mode attach on every engine; their camera
  flights use engine.camera.flyTo. Only Cesium adds the timer that keeps
  an inline panel drawing.
- The FPS readout counts engine.frames.postRender, so it shows on Navara.

Each fix has a test that fails without it.
2026-11-08 17:05:27 +04:00
German Khokhlov e1095ee553 Document radial pads, draped trail reveals and input options
Describe the radial disc envelope, corridor backing and reveal control,
rectangular input pick windows and optional horizon ellipsoid. Record the
launch-pad and Transit restoration and the Street Level and CCTV callers.
Explain synchronous Transit and reveal construction and surface readiness
for classified ground materials after camera changes.
Cesium remains the appearance and interaction reference; this documentation
changes no behaviour. All eleven coupling counts remain unchanged.
2026-11-08 00:30:36 +04:00
German Khokhlov aa6959c97b Restore ground, picking and horizon parity through the globe contract
Draw launch-pad zones as radial ground discs with the original fill, rim,
transitions, emission and depth bias. Drape Transit history and its backing
on ground corridors. Keep the current age bands and once-per-second updates;
advance the active head with a reveal fraction and rebuild only at segment
crossings. Its wider dark edge and colour core blend once, as on Cesium.
Navara composes these looks after tone mapping.

Build reveal corridors and dynamic Transit history synchronously once shared
terrain bounds are loaded. Keep requesting frames until the visible globe
tiles and committed primitives are ready: classification needs current
surface depth after a camera move. Retain the reference radial material,
its alpha envelope and the head's material-only reveal updates.

Remove the empty Navara ground effect from the render chain when the last
shown set is hidden or destroyed. It otherwise remains as the insertion
anchor for later pulse effects after its packed textures have been disposed.
Cover reveal, destruction and replacement with fresh-state comparisons for
corridors, polygons, discs and cells. Cesium drawing is unchanged by this fix.

Keep the radial pixel check at a 500 metre radius and 4 kilometre view,
sample the brightest of six inner points around the native fill gap, and
sample across the rim width. Restore its view during arrival flights and
run it last among ground checks. Both suite launchers wait for the linked
view to arrive before checking. Keep these checks with the radial look.

Pass rectangular pick windows and drill limits through input subscriptions.
Street Level uses its 9 pixel window and CCTV uses its 14 pixel window and
six-result drill. Down picks stay synchronous, moves coalesce per frame and
Navara clicks keep the GPU answer from their gesture. Add a projector
ellipsoid option and use WGS84 radii reduced by 1,000 metres for Street Level.

These changes restore the original Cesium pad fill and ground trail rather
than the temporary ring and raised lines. Navara follows the same appearance
and horizon policy, and now honours explicit CPU pick windows. Existing
callers without these options retain their defaults. The four debts share
the contract, suite and engine implementations, so keep them together.

No coupling count grew or fell: renderer lines remain 0 and 189, external
Cesium imports 3, external viewer lines 891, engine-kind checks 1, engine
resolution calls 0, extension reads 3, ground passes 1, SDK loads 3, screen
handler files 4 and engine-name lines 32.

The contract and ground suites now wait until the camera has reached the
view in their link before checking, because the app's restore flight could
land in the middle of a check; that was also the cause of the intermittent
camera-events failure. The radial check uses a launch pad's size and samples
the fill at several inner points: Cesium's own old pad leaves the same gap in
a small ground disc's fill. The pulse-band check takes its base frame once
the surface has settled.

Checked in the browser, keyless: the contract suite passes on both engines
(84 and 85 checks), the ground suite passes on both, CCTV v2 passes 58
checks, and the space, transit, Street Level and CCTV engine switches pass.
The transit and Street Level scripts fail as they do before this change.
2026-11-08 00:30:36 +04:00
German Khokhlov be08163caa Document segment batches and draped Wildlife privacy cells
Describe the segment batch lifecycle, per-item picks and styles, and the
single-primitive engine implementations. Document the restored Wildlife
fill and outline and replace the stale no-fill description.

The documentation follows Cesium's stem behavior and original draped
privacy appearance. No coupling count fell or grew; all eleven metrics
remain unchanged.
2026-11-07 22:47:24 +04:00
German Khokhlov db4bdba64f Restore Wildlife privacy fills through engine.ground
Draw distinct privacy cells in one ground set with taxon-coloured fills
at 25 percent opacity and two-pixel outlines at 80 percent opacity.
Restore the original Cesium ground appearance on both engines, replacing
the shared display's raised, depth-free outline. Ground classification
handles terrain, tiles and horizon occlusion; picks pass through.

Retain bounds-and-accent deduplication, unchanged-geometry reuse, selection,
wide-view removal and detach cleanup. Check the fill and outline on fake
and headless Cesium engines, and keep engine-switch ownership coverage.
Update the Wildlife switch check to read ground cells.

No coupling count fell or grew: all eleven metrics are unchanged, including
891 viewer lines and three external Cesium import files.

Checked in the browser, keyless: the Wildlife engine switch draws the cell
set on both engines; its keyboard step still meets the known Tab issue.
2026-11-07 22:47:24 +04:00
German Khokhlov fbadf792d8 Batch infrastructure and cable stems through engine.lines
Add copied, committed segment batches to the contract, Cesium, Navara and
the fake engine. Keep each segment's colour, width and pick identity while
drawing one primitive or overlay mesh per display. Preserve Cesium's stem
sizes, ground sampling, visible cohorts, update cadence and click targets.
Navara draws the batch after tone mapping through the existing overlay.

Cover disconnected geometry, picks, hidden replacement, empty batches and
cleanup in the contract suite and engine tests. Keep the infrastructure
and cable display assertions on fake and headless Cesium engines, and
read batch counts and pick identities in the browser checks.

No coupling count fell or grew: all eleven metrics are unchanged, including
891 viewer lines, three external Cesium import files and 189 layer Navara
render lines.

Checked in the browser, keyless: the contract suite passes on both engines,
with the new segment picking and pixel checks; the infrastructure and
submarine-cable engine switches, the cable overlay and infrastructure LOD
scripts pass.
2026-11-07 22:47:24 +04:00
German Khokhlov fd65288101 Cover raised imagery shells on the headless Cesium engine
Exercise the real shell primitives and materials over the headless engine.
Cover raised rectangle geometry, alpha and depth policy, camera lift, shell
ordering, upload readiness, hidden frames, detail replacement and moved
windows, render idling and resource cleanup. Drive the public imagery API
through detail bounds and staged source settlement as well. Supply only the
geometry and texture completion signals that require a renderer.

Preserve Cesium's reference behavior without changing runtime code. Navara
and the fake engine are unchanged. Include the new test in the format scope.

All eleven coupling counts remain unchanged; none fell or grew. Renderer
lines remain 0 and 189, external Cesium import files 3, viewer lines 891,
Navara kind checks 1, engine-sniff calls 0, extension reads 3, ground passes
1, SDK loads 3, screen-handler files 4 and engine-name lines 32.
2026-11-07 22:24:46 +04:00
German Khokhlov 7f3754d812 Serialize engine and shell transitions through the scene host
Move engine request ordering and the single rollback decision into the scene
host. Run handoff, renderer release, candidate acquisition, attachment,
restoration and settling in one transaction. Pass that transaction to nested
parts so unrelated calls wait without putting nested work behind itself.
Keep the engine-switch entry point as a delegating adapter.

Route direct shell attachment and detachment through the same host and remove
the shell queue. Serialize late part registration with attachment and release.
Keep failed-attachment cleanup and renderer release inside the transaction,
including failed restoration. Replacement applications share the transition
owner with fresh parts, and retired applications release their part registry.
A shell used alone creates its own scene host.

Preserve Cesium's picker and history ordering, single recovery, retained layer
session, cockpit restoration, hidden-tab settling and parked-renderer policy.
Navara keeps the same behavior. No user-visible change is intended on either
engine. Keep the existing behavioral assertions and cover concurrent operations,
failed cleanup followed by recovery, retired transaction handles, replacement
startup failure and parked expiry during a switch.

All eleven coupling counts remain unchanged; none fell or grew. Renderer lines
remain 0 and 189, external Cesium import files 3, viewer lines 891, Navara kind
checks 1, engine-sniff calls 0, extension reads 3, ground passes 1, SDK loads 3,
screen-handler files 4 and engine-name lines 32.

Checked on the keyless production build: the engine, Director and layer
handoff switches, app-once, and the rapid, hidden-tab, context-loss,
parked-release, failure-matrix and soak scenarios pass.
2026-11-07 22:16:37 +04:00
German Khokhlov c1e857f798 Scope tracked readouts and selection context to each scene
Give each scene attachment its own tracked readout, follow subscription,
selected context, active card identity and label-subject cache. Bind layer
context publishers to that attachment and preserve the page selection slot
and existing selection and awareness event payloads. Mirror a clear to the
page only when it clears the page selection. Capture display context
services so retired callbacks cannot publish into a replacement attachment.
Route aircraft, satellite, Local ADS-B and ALPR label refreshes and annotation
card lookup to the attached readout. Remove Local ADS-B contexts before
dropping its outgoing renderer, and mirror context pruning to the page.

Carry the retained page selection through the layer session when an attachment
is released. Adopt it only if the page still selects the same record, seed the
incoming readout on initialization, and replace its retained carrier when the
new display registers its marker. Keep the original page selection timestamp
and avoid a duplicate page selection event. A clear between attachments wins,
and page teardown still clears the selection. Seed the incoming readout from
that retained record without validating the outgoing display's visibility:
FIRMS waits for camera restoration before registering its new display. Keep a
matching selected fire in the refresh set before pruning top-N contexts, so
weaker selected detections retain the page selection too.

Preserve Cesium's label text, selected-card policy, visual anchors and
follow-camera anti-jitter. Navara retains the same shared behavior. There is
no intended user-visible change on either engine. Tests cover simultaneous
follows, identical selection IDs, independent refresh and clear, stale
notifications, destruction, parked reattachment and real layer publication
on the fake and headless Cesium engines. The production aircraft tests also
cover grounded fleet and tracked models, the display floor on both sides of
the zoom boundary, follow-label refresh and cached position reads. The fire
regression exercises the application scene handoff, delayed viewport resume,
top-N pruning, unchanged selection timestamp and deliberate clear on both
engines. These fixes preserve the reference Cesium selection behavior.

All eleven coupling counts remain unchanged; none fell or grew. The counts
remain 0 and 189 renderer lines, 3 external Cesium import files, 891 viewer
lines, 1 Navara kind check, 0 engine-sniff calls, 3 extension reads, 1 ground
pass, 3 SDK loads, 4 screen-handler files and 32 engine-name lines.

Checked in the browser, keyless: the overlay baselines on both engines, the
ALPR and FIRMS fire-camera switches, and the layer handoff, Local ADS-B,
Contacts, space, infrastructure, CCTV, urban, transit, directions and
annotation engine switches pass. The tracking regression passes every check
that passes before this change.
2026-11-07 21:14:09 +04:00
German Khokhlov 3814221cbc Scope world overlays to each scene attachment
Move the world overlay into scene.services.overlay. Each attachment owns its
sources, collision domains, paint lanes, canvases, observers, timers and
accessible action subscribers. Keep both canvases inside the engine container,
with detection beside the card root so its screen blend still reaches the
scene. Release shared font measurements only after the last host detaches.

Resolve retained layer publications against their attached engine, clear the
outgoing source before releasing its renderer, and reconnect the shell's action
subscription on attachment. Keep destroyed hosts inert and give parked engines
fresh services. Bind annotation rectangles and the existing tracked bridge to
their engine's overlay. The tracked bridge itself remains page-owned.

Preserve Cesium's projection, card placement, arbitration, fades, hit tests,
blend order and render demand. Navara keeps the same shared behaviour. No
user-visible change is intended. Exercise simultaneous hosts, either teardown,
stale handles, reattachment and a retained selected card on the fake and
headless Cesium engines. Update QA reads and package ownership for the service.

No coupling metric falls or grows: renderer lines remain 0 Cesium and 189
Navara; external Cesium import files 3; viewer lines 891; Navara kind checks 1;
engine sniff calls 0; extension reads 3; ground passes 1; SDK loads 3;
ScreenSpaceEventHandler files 4; engine-name lines 32.

Each host now takes the page's overlay ids from the host it replaces and gives
them back to a remaining host when destroyed, and the chrome's static overlay
placeholders are gone. The new hosts had left the ids on those empty
placeholders, so the voice pointer policy, the id selectors and the QA probes
that read #world-overlay-canvas missed the drawn canvas.

Checked in the browser, keyless: the overlay baselines on both engines, CCTV
v2 (58 checks), military names, and the engine, voice, ground-layer and Ghost
Maps switches pass. The transit script fails the same 25 checks as before.
2026-11-07 19:27:31 +04:00
German Khokhlov 26597ad25c Decode the Bhote Koshi images once for every engine
The event's before and after images went to each engine's display as URLs,
so every engine switch fetched and decoded both again: the strict Nepal event
scenario saw three reads of each image where Cesium's kept providers made one.
The event now decodes the pair once with its own loader and hands the images
to each display, which takes a URL or a decoded image. The pair is dropped
with the rest of the event state on disable, and a failed load is retried on
the next attachment.

The wind engine-switch scenario waited for a diagnostics field the wind layer
lost when it moved onto engine.imagery; it now waits for imageryActive.

Checked in the browser, keyless: the Nepal event, wind and submarine-cable
engine switches pass.
2026-11-07 17:02:49 +04:00
German Khokhlov 0459ee6d58 Scope render demand to each scene attachment
Give each scene its own render governor, owner holds and recent idle requests.
Make the Cesium render backend own its request mode directly, so the governor
drives engine.render on both engines without a dependency back from Cesium.
Route layer requests through their attached engine and capture the attachment's
governor for camera motions, orbit, annotations and shell controls. Release
retained-layer holds before clearing the outgoing engine, and destroy the
governor after scene consumers have detached.

Preserve Cesium's owner-keyed holds, pre-install buffering, last-release settling
frame and one-shot requests during continuous rendering. Navara keeps the same
render policy. There is no intended visible change on either engine. Simultaneous
scenes no longer share holds, and an old captured service cannot release a
replacement attachment's hold. Cover both rules on fake and headless Cesium
engines, including an old orbit ending after reattachment.

Viewer-reference lines fall from 910 to 891, Navara-kind checks from 2 to 1,
and engine-name lines from 33 to 32. All other coupling counts stay unchanged.

Checked in the browser, keyless: qa-perf passes on both engines with the
same idle, active and release counts as before (24 checks on Cesium, 22 on
Navara).
2026-11-07 16:56:34 +04:00
German Khokhlov 4252ed0798 Scope pick ownership and selection dispatch to each scene
Create pick registries and selection dispatchers for each scene attachment,
publish them through scene.services, and release their predicates and input
subscriptions before parking the renderer. Resolve layer ownership with the
attached engine so identical layer IDs in simultaneous scenes cannot overwrite
or remove one another. Re-register retained ALPR and cyclone picks on attachment,
and resolve voice pointer picks against the engine that supplied the pick.

Preserve Cesium's selection, sibling ownership, inert trails, touch expansion,
drag and long-press rules on both engines. Extract the unchanged gesture
predicates so portable picking does not acquire a native input dependency.
Exercise scene isolation, teardown and reattachment on fake and headless Cesium
engines, retaining the existing layer assertions.

No coupling count falls or grows. Cesium and Navara renderer lines remain 0 and
189; external Cesium imports 3; viewer lines 910; Navara kind checks 2; engine
sniff calls 0; extension reads 3; ground passes 1; SDK loads 3; screen-handler
files 4; engine-name lines 33.
2026-11-07 16:27:43 +04:00
German Khokhlov 52c6f68bd5 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.
2026-11-07 16:06:43 +04:00
German Khokhlov 656943641c Separate scene handoff and attachment from the page shell
Move the page handoff sequence into SceneSwitcher and include restoration
in the application's candidate attachment. Release a failed candidate once
before the request coordinator recovers the working engine. Keep address
ordering, parked renderers, retained layers and cockpit resumption intact.

Define the layer session and its field owners. Share its retention decision
across teardown paths. Separate layer scene binding from lifecycle policy,
and shell scene composition from page controls. Keep failed renderer
releases in the binding owner, with the existing retry behavior.

Give Director the scene and use the engine host for its canvas and pack
container. Keep the existing tracked-subject interaction guard so neither
engine's behavior changes. Pass startup chrome the first backend's explicit
welcome policy instead of testing for a viewer. Cesium remains the behavior
reference; this restructuring intentionally changes no visible behavior on
either engine.

Viewer references outside the Cesium engine fall from 948 to 933. All other
coupling counts stay unchanged. Scene service ownership, raw debug access,
and the remaining shell and host queues still need consolidation.

Checked in the browser, keyless, on the dev server: the engine switch,
Director engine switch, layer panel and layer visibility on both engines
pass, and 14 of 15 strict scenarios pass (rapid, hidden-tab, context-loss,
parked-release, Navara HD, failure matrix and soak among them). app-once
and the Director next-shot step fail the same way before this change.
2026-11-07 15:44:03 +04:00
German Khokhlov f385986383 Draw Bhote Koshi and awareness pulses through engine.ground
Render the Bhote Koshi event and locator through one ground and point display.
Keep Cesium's reveal paths, dashed halo, glowing core, polygon classification,
pick identities and independent point outline opacity. Add static pixel dash
masks, synchronous ground reveals after terrain bounds load, and per-point
outline colour so the shared display preserves those behaviours.

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

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

Checked in the browser, keyless: the contract suite, with its new pulse
pixel checks, passes on Navara and on Cesium apart from the camera-events
check that also fails intermittently before; the Nepal locator and Contacts
engine switches pass. The Nepal event and wind engine switches fail the same
way before this change. Side by side at the locator's overview, Cesium draws
the same pixels as before and Navara's river corridor now glows as Cesium's
does, where it was faint.
2026-11-07 15:06:26 +04:00
German Khokhlov dacfd33f10 Move shared follow offsets and Draw onto the globe contract
Share links read the current follow handle and displayed position through
engine.camera and restore their ENU offset with follow's swingTo option.
Cesium retains its first-frame wait, displayed-position cache, 60-degree
frame limit and range interpolation. Navara and the fake engine apply the
same swing. Cancellation, camera handoff and destruction release it; an
externally tracked Cesium entity keeps its existing handle.

Draw uses one surface with engine input, geodesic lines and point movers.
An exclusive pointer claim suppresses ambient subscriptions and deferred
picks across ownership changes. Cesium owns suspension and exact restoration
of its stock select and track actions. Camera gestures remain enabled.
Detaching releases the sketch and preview and retains shape and colour.
The separate Navara surface and Cesium data-source preview are removed.

Cesium remains the reference for clicked heights, line width and dash,
vertex size and outline, keyboard handling and completed annotations.
One deliberate visual fix is that the occluded rubber band uses the selected
colour instead of retaining the initial amber: it now identifies the same
sketch as its visible segment. Navara adopts Cesium's geodesic preview edges.
Finished annotation drapes are unchanged.

External Cesium import files fall from 7 to 5, viewer lines from 1005 to
953, and engine-name lines from 39 to 37. No coupling metric grows.

The full unit suite, import directions, package boundaries, formatting
and coupling 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; qa-navara-draw passes; qa-draw-tool passes apart from its hidden-line
pixel check, which fails the same way before this change. Its destroy check
now expects no preview data source, since the preview has none.
2026-11-07 14:31:59 +04:00
German Khokhlov 910d693b94 Keep bounded imagery off Cesium's base layer and pins on drawn 3D tiles
Cesium treats the first imagery layer shown on the globe as its base
layer and stretches that layer's edge texels over the whole globe. With
no base map under it, a KMZ ground overlay turned the whole globe its
edge colour. The Cesium imagery adapter now keeps a floor under an
engine layer that covers less than the globe (a bounded rectangle, or
Web Mercator tiles, which stop at ±85.05°) whenever that layer would
otherwise be the base layer. The floor is one transparent pixel over the
globe at alpha 0, so Cesium draws nothing of it. It is placed when the
adapter adds or changes a layer, and again at each scene preUpdate,
before Cesium picks the base layer. When base imagery lies under the
layer, which is the app's normal state, there is no floor. A bounded
layer now draws only inside its rectangle, as the contract says.

When the globe is not the drawn surface, the import display now clamps
ground pins to the drawn scene, as Cesium's import did whenever the
globe was hidden. Before, it did this only for the map's own 3D tiles.
Each height query now leaves out the point sets of every imported file
on the engine, not only the file's own sets: Cesium's import kept all
imported pins out of height queries, so a higher pin from another file
never became the ground. The display also gives a footprint with its
points at the heights they are drawn at, and flying to a file frames
pins on raised tiles as Cesium did. Cesium's import clamped visible pins
with clampToHeightMostDetailed, which loads the finest tiles. The
display samples the tiles drawn now and samples them again after the
camera stops and while tiles load.

The KML reader's parity test against KmlDataSource now includes the
large-file QA's style case: a shared style with point, line and polygon
parts, inline overrides of each part, and named styles and an inline
StyleMap pair that are alike. The reader matches Cesium there.

QA scripts:
- qa-geo-import moves the camera over the files before it reads which
  pins show. Ground pins are hidden only beyond the horizon.
- qa-geo-import-large compares clamped pins by longitude and latitude.
  Cesium keeps a clamped billboard at its file height, while the import
  draws its pins on the sampled ground.
- qa-geo-import-regressions gives its 3D-tiles fixture a non-zero
  tileset geometricError, because Cesium never draws a tileset whose own
  error is 0. Its XYZ check now waits for the import to sample the ground
  again once the globe's tiles have loaded.

Known differences that remain: a KMZ ground overlay is now imagery, so
ground fills draw over it where the old ground rectangle drew over them;
right after an import on ellipsoid terrain, pins can sit on the coarse
globe mesh until the ground is sampled again.

The full unit suite and the boundary, format and coupling checks pass.
In the browser, keyless: qa-geo-import, qa-geo-import-large and
qa-navara-geo-import pass; qa-geo-import-regressions fails only its
portrait-flight check, which also fails before the refactor.
2026-11-07 14:11:03 +04:00
German Khokhlov f4b83a3a31 Draw CCTV and data packs through engine.meshes
Add indexed world triangles to the required globe contract on Cesium,
Navara and the fake engine. Own geometry, textures, visibility, depth tests,
pick identities and teardown inside each engine. Reused canvas images
request another frame until their new pixels upload. Move obstruction rays
and owned mesh, line and mover exclusions into the Cesium surface adapter.

Draw CCTV monitor planes and viewshed volumes through shared mesh handles.
Draw data-pack points, straight lines, raised polygons, image rectangles
and media captions through movers, lines and meshes. Delete the feature
renderers for each engine and retain decoded assets across scene rebinds.
Register the modules, dependency, format scope and reduced coupling baseline.

Cesium is the reference for geometry, colours, image placement, marker sizes,
caption offsets, picking and resource ownership. Navara now uses straight
pack lines and per-position polygon heights without edge densification.
Navara meshes draw in the transparent pass after the effects, with the
scene-depth test and the view's exposure undone, as the pack meshes did:
in the scene pass the landscape look's exposure washed a red pack image to
white. The scene-depth helper moves into the Navara engine, and the stems
helper that only its test still used is deleted. The mesh picker takes the
globe when Navara's depth read answers with the camera itself, which it
does right after another click and which hid a polygon from a quick second
click. The 256 px media caption is not pickable: its transparent square took
clicks meant for nearby pack features, and no action targets it. Annotation drapes remain on their existing
renderers because bounded building classification, animated fills and wire
cages need a separate contract extension to preserve Cesium behaviour.

Coupling falls from 344 to 210 Cesium layer-renderer lines, 1294 to 794 Navara
layer-renderer lines, 9 to 7 external Cesium import files, 1026 to 1005 viewer
lines, 9 to 6 extension reads and 42 to 39 engine-name lines. Other counts do
not grow. The full unit suite, import directions, package boundaries,
formatting and coupling checks pass. No user-visible strings change.

In the browser, keyless: the contract suite passes on Navara and on Cesium
apart from the camera-events check that also fails intermittently before
this change; CCTV v2 (58 checks), the CCTV and director engine switches pass,
with the pack image at the same pixel as before on both engines. The mesh
contract fixture now sits at 3 km, above the terrain Navara draws there. The
director interactions script starts at its view in the link so the app's
arrival flight no longer lands after the shot, and waits for the click's
asynchronous pick; its last step, the next-shot action, fails the same way
before this change.
2026-11-07 13:57:29 +04:00
German Khokhlov 3907b7084e Read QA drawing evidence through the globe contract
Port the layer probes in twenty-six browser scripts to engine primitive
lists, drawn rows and layer diagnostics. Preserve Cesium as the reference
and keep the supported Navara paths on the same observations. This
changes QA observation only, with no application behaviour change.

Review the interrupted infrastructure group and retain its readiness,
drape, selection, label and floor checks. Match both installation outline
buffers. Frame each CCTV city before reading its visible coverage. Read
Radio colours and selected identities from actual mover writes. Retain
transit's GPU upload budgets using engine-owned line primitives rather
than substituting rebuild counts. Keep native inspection where a check
specifically measures GPU uploads, loaded meshes or ground readiness.

The preceding combined B/C commit owns all shared manifests, docs,
locale catalogs and the coupling baseline; this commit changes only its
own twenty-six QA scripts. Coupling remains unchanged at 344 Cesium
renderer lines, 1294 Navara renderer lines, 9 Cesium import files, 1026
viewer lines, 2 Navara kind checks, 0 engine sniff calls, 9 extension
reads, 1 ground pass, 3 SDK loads, 4 screen-handler files and 42 engine-name
lines.

The full unit suite and the boundary, format and coupling checks pass.
Browser validation remains to be run.

Browser checks to run
GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4291
node scripts/qa-alpr-journey.mjs http://localhost:4291
node scripts/qa-alpr-journey.mjs 'http://localhost:4291/?engine=navara'
node scripts/qa-cables-overlay.mjs --url http://localhost:4291
node scripts/qa-cctv-v2.mjs --url http://localhost:4291 --fixture --terrain-globe
node scripts/qa-context-arrivals.mjs http://localhost:4291
node scripts/qa-directions.mjs --url http://localhost:4291
QA_BASE_URL=http://localhost:4291 node scripts/qa-draw-tool.mjs
QA_BASE_URL=http://localhost:4291 node scripts/qa-floor-hold.mjs
node scripts/qa-ghostmaps.mjs --url http://localhost:4291
node scripts/qa-gnss.mjs --url http://localhost:4291
node scripts/qa-height-datum.mjs --url http://localhost:4291
node scripts/qa-infra-lod.mjs --url http://localhost:4291
node scripts/qa-installation-navigation.mjs http://localhost:4291
node scripts/qa-military-names.mjs http://localhost:4291 --no-video
node scripts/qa-overpass-offload.mjs --url http://localhost:4291
node scripts/qa-radio.mjs --url http://localhost:4291
node scripts/qa-traffic-motion.mjs http://localhost:4291 --mode=osm
QA_BASE_URL=http://localhost:4291 node scripts/qa-transit-recovery.mjs
QA_BASE_URL=http://localhost:4291 node scripts/qa-transit.mjs
node scripts/qa-weather-teardown.mjs --url http://localhost:4291 --headless
for engine in cesium navara; do
  node scripts/qa-enrich-ambient.mjs --url http://localhost:4291 --engine=$engine
  QA_BASE_URL=http://localhost:4291 node scripts/qa-floor-verify.mjs --engine=$engine
  node scripts/qa-heading-b3.mjs --url http://localhost:4291 --engine=$engine
  node scripts/qa-overlay-baseline.mjs --url http://localhost:4291 --engine=$engine
  node scripts/qa-sprites-b5.mjs --url http://localhost:4291 --engine=$engine
  node scripts/qa-vessel-datum.mjs --url 'http://localhost:4291/?qaFeed=vessels:fixture' --engine $engine
done

The military-names driver exercises installation-polish; the transit
driver exercises transit-scenes. Use only the keyless server. Cases that
require photoreal tiles or unavailable providers remain unverified.
2026-11-07 12:45:21 +04:00
German Khokhlov 97c4dc76ce Move scene effects and imported geometry behind the globe contract
Move Cesium viewer, map, sky, projection, trail and sonar internals into
its engine. Drive styles, bloom, sharpening and contact sonar through
engine.render. Count camera-input suspensions across all three engines
and migrate the feature callers. Preserve Cesium's effects and input
behaviour; fix transit lens clicks on Cesium and make zero sharpening on
Navara disable sharpening, as it does on Cesium.

Parse imported files into plain records and draw them through movers,
lines, ground and imagery on both engines. Follow Cesium's KML style
precedence, preserve local KMZ images and frame the drawable records.
Open overlays use imagery when the engine has no native map-overlay API.
Run every imported-display case on fake and headless Cesium engines.

The shared import path retains these known differences from Cesium's old
DataSources: wall fills, extruded prisms and gx:LatLonQuad are undrawn;
raised polygon fills drape while outlines retain altitude; GroundOverlay
rotation, altitude and RGB tint are not applied; rhumb lines become great
circles. Ground heights use the loaded surface synchronously instead of
waiting for most-detailed sampling. Supported KMZ ground overlays now
draw on Navara too. Document the limits rather than claiming full parity.

Combine batches B and C because their disjoint path manifests cannot
place the shared boundary and format files in a later commit while
keeping B independently valid. This combined commit owns the shared
docs, package boundaries, format scope, locale catalogs and coupling
baseline. Remove five obsolete import strings from all eleven catalogs.
Make the load-time source scan include new files and skip deleted paths
so the same checks work before and after recording the moves.

Coupling: Navara renderer lines fall from 1695 to 1294, Cesium import
files from 25 to 9, viewer lines from 1177 to 1026, SDK loads from 4 to 3
and engine-name lines from 62 to 42. No count grows.

The full unit suite and the boundary, format and coupling checks pass.
Browser validation remains to be run.

Browser checks to run
GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4291
node scripts/qa-contract-suite.mjs http://localhost:4291
node scripts/qa-navara-visual-styles.mjs http://localhost:4291
node scripts/qa-navara-bloom.mjs http://localhost:4291
node scripts/qa-navara-sonar.mjs http://localhost:4291
QA_BASE_URL=http://localhost:4291 node scripts/qa-visual-effects.mjs
QA_BASE_URL=http://localhost:4291 node scripts/qa-zoom-out-guard.mjs
node scripts/qa-sky-model-lighting.mjs --url http://localhost:4291
node scripts/qa-geo-import.mjs http://localhost:4291
node scripts/qa-geo-import-regressions.mjs http://localhost:4291
node scripts/qa-geo-import-large.mjs http://localhost:4291
node scripts/qa-navara-geo-import.mjs http://localhost:4291

Use the keyless server for every check. Photoreal-only cases remain
unverified; do not enable paid tiles to run them.
2026-11-07 12:45:21 +04:00
German Khokhlov c079b329d0 Remove the weather and wind imagery fallbacks
Require engine.imagery for weather and wind and load the wind flow backend
from the layer manifest. Preserve Cesium's staging, frame retention, shell
ordering, wind fade and streamlines; Navara follows the same display policy.
Remove obsolete rendering modules and their three translated status keys.
Run every weather and wind display case on both the fake engine and the
headless Cesium engine, keeping the existing behavioural assertions.

Coupling: Cesium import files fall from 27 to 25, viewer lines from 1232 to
1177 and engine-name lines from 63 to 62. The explicit Cesium wind flow
backend deliberately raises renderer lines from 330 to 344 and extension
reads from 8 to 9; every other count stays unchanged.

Browser checks to run
GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4291
node scripts/qa-contract-suite.mjs http://localhost:4291
node scripts/qa-weather-compare.mjs shoot http://localhost:4291 --out=qa-shots/weather-after

Compare weather and wind against the previous capture on both engines.
2026-11-07 12:16:27 +04:00
German Khokhlov c73c5382d4 Draw weather, wind and Bhote Koshi through engine.imagery, one display each
engine.imagery gains what these rasters need, on every engine:
- geographic (EPSG:4326) tile schemes and a transform applied to each
  decoded tile (the satellite infrared mask);
- staged source changes, which keep the drawn source until the new one
  has loaded (the weather frame swap), and credit-only changes in place;
- raised layers that draw on a shell above terrain and 3D tiles, rising
  with the camera, with a detail image inside a rectangle. On Cesium this
  is the weather shell primitive, moved into the engine; on Navara the
  pixel's ray meets the ellipsoid grown by the height.

Rain radar, satellite clouds, lightning, the wind field and the Bhote
Koshi event and locator each draw through one render/display.js on both
engines; their Navara renderers and Navara's weather surface pass go.
Cesium's pixels are unchanged in every compared scene. On Navara the
Bhote Koshi images are no longer upside down. Two engine-specific parts
stay behind one backend option each: the wind's streamlines and the
Bhote Koshi story entities.

The coupling report now counts defineGroundEffect calls, not its own
declaration. Coupling: Navara renderer lines fall from 2304 to 1695,
Navara ground passes from 3 to 1 (the Global Context pulses), engine
kind checks from 5 to 2. The contract suite passes on both engines
(Cesium 71/71, Navara 74/74).
2026-11-07 11:14:50 +04:00
German Khokhlov b53b6a48a7 Load the layer displays lazily and rebase the main-chunk budget
Every layer display was imported statically from the manifest, so all of
them sat in the Cesium-mode main chunk. They are re-exported from one
module, src/app/layers/displays.js, which the manifest loads with one
import() before the catalog is built, on every engine; rendererFor stays
synchronous after that. One chunk beats per-family chunks here: every
start loads every display, and split chunks compress worse. CCTV's world
click gesture moves out of its display, which the app imported only for
it. Tests that build layers without the application await the loads.

Keyless, gzip-9: the main chunk falls from 2,113,641 to 2,066,560
bytes, below the 2,090,274 of the merge with main this branch carries.
A cold Cesium start loads about 7 KB more JS in total over four requests,
with the same time to a ready page. The budget's baseline moves to the
new size; it had never absorbed main's own growth.
2026-11-07 10:40:50 +04:00
German Khokhlov 47d63128ff Bin Navara ground corridors set-wide and give corridors glow, pulse and dash
Navara's ground pass cut each corridor into chunks and tested them per
screen cell, so dense corridor sets were slow (10,000 cost about 150 ms a
frame). A corridor set is now cut into pieces along its great circles,
sorted into lon/lat tiles, each with a pyramid of bins listing the
segment runs within its margin; a pixel takes its tile and the finest
bin that covers its reach and tests only those runs. At 10,000 corridors
a frame now takes 11.8 ms still (Cesium 23 ms), at 15,000 17.9 ms. A
pixel corridor's width is measured across the line, so diagonal lines no
longer draw up to 1.4 times wider.

Pixel corridors take three looks, evaluated on the GPU from a time
uniform: Cesium's PolylineGlow falloff (glow), an alpha pulse and the
annotation route's flowing dash. While a shown set pulses or flows the
engine draws continuously, as Cesium did. Traffic's jam lines take the
glow and pulse again and the Directions route its flowing dash and render
hold, so on Cesium both match the drawing before they moved to the
contract pixel for pixel. The contract suite passes on both engines
(Cesium 69/69, Navara 72/72).
2026-11-07 09:50:49 +04:00
German Khokhlov 48885d9e61 Build the catalog from one layer manifest and attach one tools tree
src/app/layers/renderers.js now holds LAYER_MANIFEST, one entry per
layer in directory order: a display (the same render/display.js on every
engine, with the contract primitives it needs and, for CCTV and Global
Context, a Navara backend loaded by import()), a layer that draws itself
through the contract, or per-engine renderers for the layers not on a
display yet (weather, wind, Bhote Koshi, sea marks, ski pistes). A layer
is available where the engine has the primitives it needs. The Cesium
renderer table, CESIUM_BUILTIN, the registry and the Navara mode's map of
about 35 loaders go; the application loads a kind's renderers before it
builds the catalog.

The tools attach through one tree over the engine, in Cesium's order.
Each engine kind adds its own tools from one backend that reads its
extension once: the Cesium backend holds the viewer-only tools, the
Navara backend the annotation renderer, draw surface and file import
drawing.

Coupling: engine.extensions reads fall from 12 to 8, engine-name literal
lines from 73 to 66.
2026-11-07 09:41:02 +04:00
German Khokhlov f1ec9d4a22 Hand features the engine instead of guessing it from a viewer
No code outside src/globe calls isGlobeEngine or engineFromViewer any
more (14 calls left before this). The features that still sniffed take
the engine from their caller:

- Projection and surface helpers (focus de-emphasis, icon orientation,
  ground snap, the mesh floor sampler, ground picks) take the engine; the
  engine view's scene carries its projector instead of registering itself
  as a viewer.
- Credits go only through engine.credits, so annotations and layers
  share one OSM credit state on Cesium too (it could show twice).
- The annotation board and the resolver work on the engine: camera
  assists, visibility and picks go through engine.camera,
  engine.projector and src/geo, without Cesium.
- The Recent Imagery box tool has one implementation for both engines,
  on capture-phase canvas pointer events with the layer's engine.ground
  preview; the Navara copy goes. On Cesium the preview outline is solid
  now, as on Navara.
- Scene interactions take the engine, and the render governor has an
  engine variant beside the Cesium one.

Remaining engine-specific features stay behind explicit seams until the
contract covers them: the draw tool's Cesium surface (stock click actions
cannot be suspended), the annotation drapes (animated materials), share
follow, Cesium post effects and cyber sonar, and data packs (meshes).

Coupling: Cesium-importing files fall from 31 to 27, `viewer` lines
from 1367 to 1234, engine sniffing calls from 14 to 0.
2026-11-07 08:46:30 +04:00
German Khokhlov 406dd9122e Add raster imagery to the globe contract and draw FIRMS heat and Recent Imagery through it
engine.imagery draws raster layers over the globe: Web Mercator tile
templates and single images or canvases stretched over a rectangle, with
alpha, show, a left/right split against the engine's split position and
an order. A layer's source can be replaced in place.

- Cesium puts one ImageryLayer per layer on the globe's imagery stack,
  or on the shown 3D tileset's when the globe is hidden, and moves it
  when the surface changes; the Recent Imagery request gate moved here.
- Navara draws them in one screen pass after tone mapping that samples up
  to eight layers by geodetic position from mipmapped textures and a tile
  atlas, under the other drapes and engine.ground.
- The fake engine keeps each layer's state.

The FIRMS far-zoom heat raster and Recent Imagery now draw through it, so
FIRMS' heat seam and both its backends, and Recent Imagery's Cesium and
Navara renderers and tile plan, go. Against HEAD, Cesium's pixels are
identical in all four compared scenes; Navara's orbital heat changes by a
mean of 3.8 from new mipmapping, slightly towards Cesium. The contract
suite passes on both engines (Cesium 68/68, Navara 71/71).

engine.maps.addOverlay stays a Navara-only extension for Sea Marks and
Ski Pistes until imagery has a finer tile path for oblique global views.
2026-11-07 08:07:57 +04:00
German Khokhlov 1559bd57f4 Draw Street Level, traffic and directions through one display each
- Street Level: coverage sequences are ground corridor sets of up to
  120, draped on terrain and tiles as Cesium's ground polylines were;
  the marker and view cones are sprite movers turned to their compass
  bearing on screen and culled 1 km under WGS84. The layer no longer
  loads the Cesium SDK on Navara.
- Traffic: vehicles are two point mover sets (an outline disc under each
  fill) scaled and faded with Cesium's NearFarScalar maths; congestion
  heat lines are ground corridor sets draped on tiles. Heights, depth,
  readiness and the surface key go through the engine, and Navara no
  longer loads the Cesium SDK for traffic.
- Directions: the route is one ground corridor set in the annotation
  route's dash pattern, maneuvers are point movers and the A/B markers a
  disc and a letter sprite; the in-layer Navara branch and the Cesium
  point collection go.

Known gaps against Cesium, to close in the contract: the traffic heat
lines lose their glow and pulse and the route its flowing dashes
(no animated ground fills yet), and dense Street Level coverage on
Navara is costly as ground corridors (about 80 to 190 ms a frame at
6,000 to 14,400 sequences) until corridors are binned set-wide.

Coupling: Navara renderer lines fall from 3349 to 2767, Cesium-importing
files from 51 to 32, `viewer` lines from 1591 to 1373, Cesium SDK loads
on Navara from 6 to 4.
2026-11-07 08:03:25 +04:00
German Khokhlov 2fbdca62be Name the installations, Global Context and infrastructure displays in the current state 2026-11-07 07:14:06 +04:00
German Khokhlov 30c86a6001 Draw Mapped Installations, Global Context and the infrastructure layers through one display each
- Mapped Installations: one display holds the single copy of the logic
  the Cesium and Navara renderers each had (renderable records, painting,
  the selection fill, floors, named markers, selection). Discs are point
  movers, outlines ground corridors and the selection fill a ground
  polygon. Cesium's behaviour wins where they drifted: named areas draw
  their outlines (Navara dropped them), discs sit under the moving
  contacts, and outlines and the fill pick their site.
- Global Context: the range ring is a halo and a core line on
  engine.lines and the awareness logic no longer imports Cesium. The
  animated pulse disc, which no primitive draws yet, sits behind one
  seam, render/pulseDisc.js, with a Cesium and a Navara backend.
- Datacenters and dams: one display on movers, lines and ground sets
  with Cesium's LOD, motion probe, floor sampling and card cohort;
  localGeojsonCore.js keeps only the engine-free card and record code
  (1381 to 329 lines).

Coupling: layer renderer lines fall from 1011/4832 to 351/3349
(Cesium/Navara), `viewer` lines from 1745 to 1591,
ScreenSpaceEventHandler files from 8 to 6, Navara ground passes from 5
to 4.
2026-11-07 07:13:56 +04:00
German Khokhlov e18f12d34c Keep street maps legible on Navara after dusk
Navara lights the map tiles with the real sun and sky, and only the
landscape maps took the night ambient fill, so an OSM street map turned
black at local night where Cesium keeps it lit. Every base map now takes
the night fill; the landscape maps keep their orbital fill as well.
Daylight is unchanged.

This is why the strict acceptance's Navara pixel and first-frame checks
over Austin failed in evening runs: navara-hd, parked-release,
hidden-switch, context-loss, geo-import and scope saw a black frame, and
the soak and failure matrix waited for a first frame that never came.
At night with the fix all nine pass.
2026-11-07 06:29:33 +04:00
German Khokhlov 54fe0aea40 Draw nine layers' fills through engine.ground, one display each
Earthquakes, air quality, GNSS Integrity, fire perimeters, Prohibited &
Restricted Airspace, Active Fires, S2 GhostMaps, cyclone advisories and
submarine cables each have one render/display.js on the globe contract;
their render/cesium* and render/navara* files go. Their discs, cells,
polygons and corridors are engine.ground sets, so on Navara they come from
the one ground screen pass with exact colours instead of nine hand-written
classification passes and lit SDK vector layers. Cesium's look stays the
reference:

- Earthquake and air-quality discs have no outline (Cesium drew none on
  clamped ellipses); earthquake discs can now be picked on Navara too.
- Perimeters, drone zones and GhostMaps polygons outline their outer
  ring only, as Cesium did.
- GNSS cells stay out of the pick pass; cyclone cones, tracks and centres
  are culled by a port of Cesium's horizon occluder.
- Submarine cables are one ground corridor set, as Cesium batched them;
  landing points, tips and stems are movers and lines.
- FIRMS glows are a sprite set and its cells a ground set. The far-zoom
  heat raster, which no primitive draws yet, sits behind one seam,
  render/heatField.js, with a Cesium and a Navara backend.

The shared Navara ground helpers that only these layers used go
(navaraGroundCells, the cell and ground geometry, the zone renderers), as
does the ground prototype script.

Coupling: layer renderer lines fall from 1636/6953 to 1011/4832
(Cesium/Navara), Navara ground passes from 7 to 5, Cesium-importing files
from 65 to 53, engine.extensions reads from 26 to 19.
2026-11-07 06:20:48 +04:00
German Khokhlov 974a0b6361 Add ground fills to the globe contract
engine.ground draws sets of polygons (with holes), discs, cells and
corridors draped on the surface, with fill and outline colours, order and
picks, written in bulk and committed. Every engine implements it:

- Cesium batches each set into one GroundPrimitive for fills and one
  GroundPolylinePrimitive for outlines and pixel corridors, classified
  against terrain and 3D tiles; sets that are not pickable stay out of the
  pick pass.
- Navara draws them in one screen pass after tone mapping that finds each
  pixel's ground from the scene depth and tests the items in its screen
  cell; long rings are split into edge bins. Measured at Dubai against
  SDK vector layers, the pass matched Cesium's frame time up to 10,000
  polygons with a few megabytes instead of 70 to 146, drew exact colours
  and outlines, and hid reliably across an engine switch, where vector
  layers hidden with update({ show: false }) never hid. Dense corridors
  remain its limit (10,000 cost 135 ms a frame).
- The fake engine keeps the same registry and CPU picks.

The contract suite checks it on both engines (Cesium 66/66, Navara 69/69).
Earthquakes and fire perimeters have unwired ground prototypes whose
drawing matches today's Cesium one (0.998 on Cesium, 0.976 on Navara).
docs/GLOBE-PRIMITIVES.md describes the primitives and when to use each.
2026-11-07 05:14:42 +04:00
German Khokhlov 6dc643182e Count OSM Places on the movers set on both engines in the open layers scenario
OSM Places draws through one display on every engine now, so on Cesium its
points are a movers set too, not a data source's entities.
2026-11-07 04:36:31 +04:00
German Khokhlov dc1144b947 Draw radio, transit and CCTV through one display each
- Radio: stations, clusters, the selection and its brackets are point
  and sprite sets; clusters come from the layer's engine-neutral screen
  clusterer with Cesium's EntityCluster rules, and clicks go through the
  selection dispatcher. One style module holds the sizes and colours.
- Transit: vehicles are two sprite sets from an up-front atlas, rotated
  with Cesium's projected-rotation maths. The selected vehicle's trail is
  engine.lines in two lanes and 29 age bands, so trails are no longer
  draped over terrain until the contract has ground corridors. The
  Cesium contact trail renderer and its re-export go.
- CCTV: markers, wedges, the plane outline and the calibration gizmo
  (rings, arrows, handles, drags through engine.input) draw on the
  contract. The textured monitor plane and the viewshed volume, which no
  primitive expresses yet, sit behind one seam, render/meshes.js, with a
  Cesium backend and a Navara backend the Navara mode passes in.

Known gaps against Cesium: transit trails are not draped, and the
surveillance-lens click correction runs only where the engine reports
its style stage (Navara).

Coupling: layer renderer lines fall from 2638/8735 to 1636/6953
(Cesium/Navara), ScreenSpaceEventHandler files from 15 to 10, `viewer`
lines from 2014 to 1901.
2026-11-07 04:29:50 +04:00
German Khokhlov fc75e245a6 Assert Wildlife Sightings on both engines in the strict acceptance
qa-wildlife-switch.mjs serves four Salish Sea sightings (three from
iNaturalist, one obscured, and an Acartia orca) through the app's own
iNaturalist and Acartia proxies over faked upstream feeds, then on Cesium,
Navara, Cesium and Navara again checks the badge set and its positions,
picks, the density glow, the privacy cell outline and a clicked card.
Two of the switches are made from the keyboard and must keep the
selection; the last is made with the pointer on the Globe Engine picker,
whose click closes the card like any click outside it.
2026-11-07 03:52:50 +04:00
German Khokhlov 145adc08e0 Keep the tracked aircraft through a failed engine switch on Cesium
Cesium follows a display's sprite through a proxy Entity that mirrors the
native's readout fields, and gevRestoring was not one of them. A follow
the flights layer resumed on attach therefore read as new navigation to
the shell's tracked-entity watcher, which made the share restore of the
same attach count itself superseded and clear the selection. That is why
the aircraft was lost after a failed switch's rollback onto Cesium, and
after the retry. The proxy now shows the restoring mark while its follow
starts.

The flights renderer test runs a detach, a failed attach to a candidate,
the rollback and a retry on headless Cesium engines, and checks that the
aircraft stays tracked and no navigation is stamped.
2026-11-07 03:44:38 +04:00
German Khokhlov f06b93fbc9 Draw vessels, satellites, Space Missions, ALPR, bikeshare and OSM Places through one display each
Each layer now has one render/display.js on the globe contract, the same
factory on both engines; its render/cesium* and render/navara* files go.
Cesium's behaviour is the reference, and the logic that was written twice
is written once:

- AIS vessels: atlas chevrons and engine.lines trails. The display
  registers the gev-trail pick claim itself and its trailing visibility
  pass now also runs on Navara.
- Satellites: point banks per look, orbit rings as lines turned by their
  model matrix, and selection and follow through engine.camera.follow.
  The emulated _viewer is gone, and the tracked readout republishes card
  text on Navara too.
- Space Missions: dots, reticle, trajectories and tactical orbits as
  movers and lines. The layer keeps its own records instead of Entities
  and DataSources, and camera work goes through engine.camera. Clicks go
  through the selection dispatcher, and hover keeps Cesium's throttle. The
  pad zone is its rim only: the contract has no surface fill yet.
- ALPR, bikeshare and OSM Places: one display and, for bikeshare, one
  style module each; clicks through engine.input, no
  ScreenSpaceEventHandler or EntityCluster. ALPR keeps Navara's horizon
  cull, and OSM Places points sample the surface where Cesium clamped them.

Coupling: layer renderer lines fall from 4546/11106 to 2638/8735
(Cesium/Navara), `viewer` lines from 2209 to 2014, ScreenSpaceEventHandler
files from 20 to 15.
2026-11-07 03:34:06 +04:00
German Khokhlov 3e261d1baf Give the contract suite a clear pointer path and real canvases in the app
In the app the world overlay draws on a canvas of its own above the
globe, so letting every canvas take the pointer sent the suite's clicks
there. Only the engine's canvas takes it now. Without makeCanvas the
pick check's sprite atlas was a plain object no real engine draws, so its
sprites could not be picked; the page now hands the suite real 2D
canvases. --only=prefix,... runs a subset of the checks. With both, Cesium
passes 63 of 63 checks and Navara 66 of 66.
2026-11-07 02:39:02 +04:00
German Khokhlov 35e70ec280 Draw the aircraft family, Wildlife Sightings and the Cockpit through the contract
The layers that still had one Cesium and one Navara implementation, or
drew on Cesium only, now draw through engine.movers, engine.lines and
engine.camera on both engines, and application code holds the engine
instead of guessing it from a viewer.

- Aircraft family: flights, military and local ADS-B share one display
  (src/layers/aircraft/render/display.js). The Cesium and Navara
  renderers are gone. Where they differed, the display follows Cesium:
  - attach and visibility tracking, live disableDepthTestDistance
    changes, the tracked glyph's distance scale, model ids that change,
    local ADS-B markers hidden until shown and its UAT ring as a point;
  - the trail head's hidden alpha is 0.45, the contract's
    depthFailAlpha scaled by the head's 0.9.
  The tracked glyph stays over the fleet, as on Navara. The movers
  contract reports readiness per model and reads model axes as the
  glTF's own on every engine. Cesium's follow takes any native target
  through a proxy entity in the ENU frame and mirrors its readout fields.
- Wildlife Sightings draws badges and the density glow as sprite sets
  and its privacy cells as line outlines (no fill yet). It takes clicks
  from engine.input and its card anchors to the engine's canvas and
  camera, so the layer is in both engines' catalogs again and a switch
  keeps the session's layer set.
- Cockpit: cockpitHost takes the engine only. The ridden aircraft is the
  engine follow's target, hidden through its sprite; only the Cesium
  Viewer's stock double-click is still routed around.
- About forty camera, navigation, share-link, HUD, voice, detection and
  layer-lifecycle callers take the engine. isGlobeEngine and
  engineFromViewer calls outside src/globe fall from 51 to 14.

The coupling baseline drops accordingly: layer renderer lines from
5239/12331 to 4546/11106 (Cesium/Navara), `viewer` lines from 2424 to
2209, Cesium-importing files from 69 to 66.
2026-11-07 02:34:34 +04:00
German Khokhlov 48988015f1 Give each engine its own extensions in place of the navaraViews map
What only one engine has now travels with the engine instead of a
WeakMap in navaraMode.js: engine.extensions.navara holds Navara's view,
its SDK classes and the daylight reset, engine.extensions.cesium the
Cesium viewer. The contract documents the field as outside the contract,
and the coupling report counts its reads outside src/globe in place of
the navaraViews reads (27, unchanged).
2026-11-07 01:28:51 +04:00
German Khokhlov 2213be6566 Draw movers and lines on Cesium and require them of every engine
Layers are to draw through the globe contract on both engines, so Cesium
gets back the movers and lines removed as unused:

- engine.movers draws sprite sets as a BillboardCollection (atlas cells
  cropped once per set), point sets as a PointPrimitiveCollection and
  model sets as one Model per instance. depthTest, disableWithinM, order,
  scaleByDistance, colorBlend, unlit and minimumPixelSize follow the
  contract; a model's matrix is written in place, so instances no longer
  share one scratch matrix.
- engine.lines draws each line as a polyline Primitive with width in CSS
  px, geodesic arcs, dashes, the tactical and arrow materials,
  depthFailAlpha and modelMatrix; a rebuild keeps its show, matrix,
  colour and place in order.
- The picker returns the pickId a layer wrote last, skips sets that are
  not pickable and instances behind the horizon, and falls through to
  what lies under them. Surface samples can exclude mover sets again.

movers and lines move from the optional capabilities into the required
contract shape, and the contract suite always runs their checks.
2026-11-07 01:24:59 +04:00
German Khokhlov 59a233db51 Skip the contract checks the running app drives itself
The app sets contact sonar on every frame (src/cyberSonarEngine.js), so a
value the contract suite sets never survives to its next frame inside the
app. qa-contract-suite.mjs reports such checks as skipped, naming their
owner, instead of failing them.
2026-11-07 01:24:50 +04:00
German Khokhlov 9daa4bd2db Measure engine coupling and run the contract suite on the real engines
scripts/report-engine-coupling.mjs counts the engine-specific code left
outside src/globe/cesium and src/globe/navara: layer renderer lines per
engine, Cesium imports, `viewer` mentions, engine-name checks, raw Navara
reads, Navara ground passes, Cesium SDK loads and ScreenSpaceEventHandler
users. engineCoupling.test.mjs fails npm test when a count grows past
scripts/engine-coupling-baseline.json, so each step towards drawing
through the globe contract can only lower them.

scripts/qa-contract-suite.mjs runs src/globe/testing/contractSuite.mjs in
headless Chrome on the Cesium and Navara of a keyless dev server, which
until now only ran on the fake engine. The page imports the suite from the
dev server, starts at a fixed view, waits for the camera to rest and lets
the pointer through the panels. Cesium passes 57 of 57 checks, Navara 64
of 67; docs/CURRENT-STATE.md names the three that fail.
2026-11-07 01:08:15 +04:00
German Khokhlov 92c8f8d9da Merge origin/main into german/navara
Main's camera, voice, Cockpit and layer work now runs on the globe
contract where this branch draws through it:

- The voice tilt to an angle, its pivot about the place under the
  crosshair and the frame limit from orbit run through the camera rig;
  src/globeInView.js measures a camera pose with src/geo instead of a
  Cesium camera. The free-look height limit and the globe-in-view
  recovery stay Cesium-only for now.
- Cockpit takes main's own-layer entry rule. Double-clicking the
  followed track goes through cockpitHost.routeDoubleClick, so it works
  on both engines.
- Space Missions' failed-entry rollback and GhostMaps' age filter run
  in the engine-neutral layer code. On Cesium an expired report is
  dropped in place; on Navara its product is redrawn.
- Wildlife Sightings joins the catalog Cesium-only: Navara leaves it
  out, and the shell builds its card only for the real layer, so a
  Navara page no longer fails to start on its stand-in.
- One instruction sentence is shorter, which keeps the Realtime prompt
  within its 55,500-character budget with the Sentinel-2 basemap.
2026-11-07 01:07:26 +04:00
German Khokhlov 90166024e9 Keep the cockpit camera steady near the ground on both engines
Cesium lifts a camera below its globe every frame, between the cockpit's 20 Hz updates, so an eye placed under the terrain (an aircraft reading below it on a cold floor cell) jumped up and down. The cockpit now also keeps its eye above the engine's own surface. Navara's ground floor stands aside while the camera inputs are off, since the cockpit places the camera itself.
2026-11-06 16:52:35 +04:00
German Khokhlov 9825b02aab Add a Navara HD renderer choice, release a parked engine after three minutes, and cut Navara's memory
Navara now draws at CSS pixels without MSAA, like Cesium; Navara HD keeps the device pixel ratio and MSAA 4. Only one Navara variant is ever kept. A parked engine is released after three minutes and its WebGL context is lost. Three leaks that kept a released Navara alive are fixed, the canvas context drops its unused antialias buffer, cloud shadows use two 256 px cascades, and the tile cache is budgeted from the screen buffers.
2026-11-06 14:41:16 +04:00
German Khokhlov a6c64713ee Stop Navara's memory growth on engine switches, hide polygon zones reliably, and drop the Cesium camera rig
- Navara 0.2.1 never frees pick batch ids, and the mover banks rebuilt
  their instanced meshes on each attach, leaking about 260 KiB of
  WebAssembly memory per switch. A per-view pool hands released ids back
  out; the long soak now asserts native growth of at most 32 KiB per switch.
- Navara 0.2.1 sometimes drops the first show:false update on a new
  vector layer, so a zone turned off right after a switch back stayed
  drawn. Polygon displays hide by deleting their layers and rebuild them
  when shown.
- Camera verbs drive both engines through the globe contract; the Cesium
  viewer rig and the tests of states it can no longer reach are gone.
- Flights and military tracking share src/layers/aircraft/tracking.js.
- The geo import scenario reads movers only where the engine has them.
- Add the Navara section to docs/CURRENT-STATE.md and a changelog fragment.
2026-11-05 21:18:23 +04:00
German Khokhlov 7abf74518f Cut duplicated and dead code across the engines, the layers and the QA scripts
- src/geo drops the ported debug argument checks and the tests that
  bitExact already covers; package boundaries accept a directory entry,
  so src/geo/ is listed once per group.
- Cesium no longer carries movers and lines nobody draws with: they are
  optional engine capabilities. The fake engine and the Cesium camera use
  the shared cameraMath; unused contract tables, clock.ticked, the
  'finest' surface detail and Navara click statistics are gone.
- ALPR keeps one overlay core and one presentation model for both
  engines, bikeshare one station bookkeeping, the Navara ground effects
  one definition helper; image atlases, view centres, mover banks and
  colours are shared.
- The legacy switch route, the clock offset and rate controls and other
  unused exports are removed.
- The acceptance runs the Navara draw, geo import and scope checks;
  scenarios use the harness switch and page helpers; the main chunk
  check keeps the size budget only and runs once in CI.
2026-11-05 13:32:39 +04:00
German Khokhlov c502404e1d Show installation and ALPR camera labels on Navara the way Cesium does
An installation card on Navara now names an unnamed feature and its
class in the UI language, and an ALPR camera keeps its context label as
English data that is translated when shown.
2026-11-05 12:29:45 +04:00
German Khokhlov 350a7f5abb Stop pointing comments and test headers at notes and QA scripts the repository does not carry 2026-11-05 12:08:35 +04:00
German Khokhlov 0ee92eb864 Answer the Nepal event's Facebook embeds with inert stubs in its strict scenario, as its YouTube ones 2026-11-05 04:07:42 +04:00
German Khokhlov 909be62ae7 Assert the long soak's JS object growth from heap snapshots, and report compiled code, native memory and the used heap's slope apart 2026-11-05 03:38:23 +04:00
German Khokhlov 8bea1419f6 Cover drone airspace, OSM places, sea marks and ski pistes on both engines in the strict acceptance 2026-11-05 01:27:43 +04:00
German Khokhlov e1d3a588fc Drop the late feature changes of deleted Navara vector layers, which kept the view from going idle 2026-11-05 01:11:39 +04:00
German Khokhlov 843c4a111d Show drone airspace, OSM places, sea marks and ski pistes on Navara, and keep them across engine switches 2026-11-05 00:47:08 +04:00
German Khokhlov 43b4e9348b Run the Global Context range pulses and their echoes on Navara, sharing the pulse clock with Cesium 2026-11-05 00:19:30 +04:00
German Khokhlov 727bb29cae Drape the Active Fires heatmap on Navara too, with the same hand-off fade to the markers 2026-11-05 00:11:32 +04:00
German Khokhlov 0c0df50a40 Merge origin/main into german/navara
Brings the Active Fires intensity work: the FRP filter, the confidence
switch, the ember-to-white-hot ramp and the far-zoom heatmap.

The filter, switch and ramp work on both engines. The heatmap drapes on
Cesium through the layer's Cesium renderer, which also fades the markers
across the hand-off; it goes with that renderer on an engine switch and is
built again on the next Cesium globe. Navara keeps heat cells for the far
bands, now coloured on the FRP ramp.
2026-11-04 23:20:12 +04:00
German Khokhlov db9bf64821 Merge origin/main into german/navara
Main's new layers (drone airspace, OSM places, sea marks, ski pistes) are
Cesium-only for now: they join the layer directory and the Cesium catalog,
are built afresh for each Cesium catalog, and show as unavailable on Navara.

The zoom-out guard is a scene part that attaches to Cesium globes only. The
awareness range pulses and their echoes draw on Cesium through the awareness
display's viewer; Navara keeps the plain range ring. Camera-input wakes go
through the display, and the analyst keeps the session-owned memory with
main's area presentation. A disposed voice runner aborts work in flight, and
an engine switch still keeps the last answer.

Modules main added that read Cesium while they load (zoomOutGuard,
awarenessEcho, the ground-sampling scratch in localGeojsonCore) now build
those values on first use or use src/geo, so a Navara page still starts
without the SDK. The OSM view box reads the engine's view rectangle.

Main grew its own main chunk by 93,873 bytes gzip-9 since the merge base, so
the baseline is raised to the merged size.
2026-11-04 23:11:31 +04:00
German Khokhlov a2f5b8fca3 Point the cited commits at the rewritten history and drop the registry rows for the local agent files 2026-11-04 22:47:46 +04:00
German Khokhlov 06ae95ceb3 Keep scaling the shared atmosphere vectors in place, and take a rebuilt globe's base from the value they had before any scaling
Pointing the scaled uniforms at the scaler's own vectors (bc1b56b6) left the atmosphere unscaled: takram's materials read the AtmosphereParameters.DEFAULT vectors themselves, so the frame dimmed (luma 161 against 214) and qa-navara-bloom found nothing to bloom. The scaler writes those vectors in place again, and a page-wide map keeps each one's value from before it was first scaled: a globe built later on the page takes its base from there instead of from the scaled value. Bloom measures as before (214.4 → 240.3), and qa-strict-context-loss passes 3 of 3 under eight busy threads.
2026-11-04 19:53:33 +04:00
German Khokhlov bc1b56b63a Scale the Navara atmosphere luminance without writing into takram's shared vectors, so a rebuilt globe is not drawn nine times too bright
The sky, aerial perspective and clouds materials hold the vectors of takram's AtmosphereParameters.DEFAULT, one per page. The look's scaler multiplied them in place and took its base from the first value it saw, so a Navara globe built later on the same page (after the parked one lost its WebGL context) could record an already scaled value as its base and draw the atmosphere scale² bright: a washed-out, nearly uniform frame. The fourth case of qa-strict-context-loss failed this way under CPU load (3 of 3 with eight busy threads; 3 of 3 pass now). Scaled uniforms now point at the scaler's own vectors, which it puts back each frame if a material wrote into them; the page's vectors are never written.
2026-11-04 19:04:48 +04:00
German Khokhlov b3efcfb7a2 Build the Navara weather shell without the Cesium SDK: angles from geo, the surface extent in radians
Since the weather layer stopped handing Navara the Cesium global, the shared shell read ContextLimits, Math and Rectangle from an undefined SDK and drew nothing on Navara (qa-weather-engine-switch timed out). On Navara the texture limit stays unset, as Cesium's never-created context left it.
2026-11-04 18:12:17 +04:00
German Khokhlov 1100cdcff5 Let the flights' fix history fill before the long soak measures the heap, and say why a context-loss case drew no frame
The long soak's heap slope counted the per-aircraft dead-reckoning history (POSITION_HISTORY_LIMIT fixes for 10 000 aircraft, about 14 MiB over the first 2.5 min of polls) as switch growth: a run with the same workload and no switches grows as much and then levels off. With --assert the soak now waits until the flights feed has been read more times than the history keeps before it switches; over 500 switches the heap then grows 1.07 KiB per switch.

qa-strict-context-loss records, only when no non-blank frame came, how many frames the running and the watched engine drew in 3 s and how much of the last one differs from its first pixel.
2026-11-04 17:48:46 +04:00
German Khokhlov f89aee1eac Load the Cesium SDK and its widget styles only when a Cesium globe or a feature that needs it first runs
index.html no longer loads /cesium/Cesium.js and widgets.css: the Cesium scene loads both before it builds its viewer. A cold Navara start requests nothing of the Cesium library again, and qa-strict-cold-chunks asserts that without the known-gap allowance. The strict harness puts an accessor in place of the Cesium global on every page and fails a scenario that reads it before Cesium.js has run; GEV_QA_CESIUM_TRAP=record lists every such read site instead.
2026-11-04 17:15:27 +04:00
German Khokhlov b8305de14b Run the Navara paths without the Cesium SDK, and load it on demand for the features that still build on it
Every Cesium read that a Navara page made was surveyed with the strict acceptance (GEV_QA_CESIUM_TRAP=record). Math on those paths now uses src/geo: screen annotations, weather and wind center readings, cyclone picks, scene data pack polygons and images (new navaraGeometry.js), the awareness subject, the orbit offset of camera verbs, voice frame_overhead, the agent world packet and Directions colours and ground picks. The celestial ring's Sun and Moon come from a bit-identical port of Cesium's Simon1994 model, JulianDate and TEME rotation (src/geo/simon1994.js, julianDate.js, transforms.js); a Cesium globe still applies its ICRF rotation once loaded.

Traffic, Street Level, the Nepal event and locator and geo import build on Cesium's math or entity model on either globe; their enable (or the import) now loads the SDK first through src/app/loadCesium.js, synchronously when it is already there. A production page guards the global so a missed read is reported and starts the load. No module reads Cesium while it loads (scripts/check-cesium-load-time.mjs, run by npm test). Cesium.js is still loaded by index.html here.
2026-11-04 17:15:21 +04:00
German Khokhlov f87da8523e Pin the page clock to an Austin afternoon in the Navara bloom and style checks
The look's sun follows the page clock, so the frame these checks compare was as bright as the hour they ran at: in the acceptance run the half-strength bloom changed the frame by 0.29 against 3.5 earlier, and at dusk the full bloom changes nothing (luma 13).
2026-11-04 00:59:58 +04:00
German Khokhlov 91685c94d9 Assert the soak's heap slope from switch 20 again
Measured over the second half the heap still grew about 20 KiB per switch in the acceptance run (117.8 to 123.8 MiB from switch 250 to 500), so the narrower window did not separate warm-up from growth; the original check stays.
2026-11-04 00:58:32 +04:00
German Khokhlov a56f2f9352 Assert the soak's heap slope over the second half of the run and report the warm-up slope beside it
Under the soak workload V8's compiled code and the layers' caches grow for about 250 switches and then the heap is flat (121.4, 121.7, 120.4, 119.1 MiB after 250 to 400 switches), so a line fitted from switch 20 measured the warm-up. Over the second half the threshold stays 10 KiB per switch; the run before the layer effect fix measures 19 KiB there.
2026-11-04 00:24:47 +04:00
German Khokhlov 43ab3ecaaf Run the Navara visual style, bloom, sonar, cockpit cloud and annotation checks in the strict acceptance 2026-11-04 00:20:13 +04:00
German Khokhlov ebc717df8b Dispose a Navara layer effect's pass when the layer detaches so engine switches stop retaining its material and textures
Layers re-create their ground effects on every attach. Navara's removePass leaves the EffectPass undisposed, so each switch kept its EffectMaterial in three's program and material caches, together with the uniforms' DataTextures and their Float32Array data (earthquakes: about 47 KB of buffers and one WebGL program per switch). engine.LayerEffectDesc now disposes the raw pass on delete, as the app's own effects do.
2026-11-04 00:07:15 +04:00
German Khokhlov 726e201314 Read a parked globe's WebGL context from the one it was built with instead of asking its canvas during a switch
Asking the canvas again during the switch made the reuse depend on getContext at that moment: with new contexts refused (qa-strict-failure-matrix, cold Navara to Cesium) the rollback to the parked Navara threw and left no globe.
2026-11-03 23:33:50 +04:00
German Khokhlov 90d64e6916 Mark a resumed or share-restored follow only while it starts so a later follow of the same subject is navigation again
The aircraft scene binding re-tracked with origin share-restore, which stayed on the entity: a later trackedEntityChanged for it (cockpit exit, refocus) never stamped navigation, and the aircraft lost its selection origin. Flights, military and satellites now set a transient gevRestoring mark around follow.start, and the aircraft binding resumes with the origin the aircraft was selected by.
2026-11-03 23:30:10 +04:00
German Khokhlov cbe1d841ea Dispose the Navara bloom and sharpen passes when their effect is deleted
EffectComposer.removePass does not dispose a pass. One withPassDisposal mixin now does it for the bloom, sharpen, styles and overlay effects.
2026-11-03 23:30:10 +04:00
German Khokhlov b49f9dddf0 Fail the acceptance when a production build throws on a cold start of either engine
qa-strict-prod-smoke opens the keyless vite preview on each engine, switches once and fails on any page error. qa-strict-cold-chunks reports Cesium.js at a cold Navara start as a known gap instead of a failure.
2026-11-03 23:21:10 +04:00
German Khokhlov 125fbc5a3b Load the Cesium SDK from index.html again, since engine-neutral modules read its global when they load
This reverts ae13a56f. In the production build every 'cesium' import is the global that Cesium.js defines; with the script loaded only when a Cesium globe started, modules evaluated at boot (cesiumConstants, annotationEngine) threw ReferenceError and neither engine started. A cold Navara start loading Cesium.js stays a known gap.
2026-11-03 23:21:00 +04:00
German Khokhlov 9cc108835e Bring comments in line with the single application and the Navara capabilities 2026-11-03 23:02:04 +04:00
German Khokhlov 8d7f055572 Format the files this branch wrote or broke with Prettier 2026-11-03 23:02:04 +04:00
German Khokhlov 227e3c2051 Serialize shell attach and detach so recovery waits for a failed attachment's cleanup 2026-11-03 22:56:24 +04:00
German Khokhlov ae13a56f6d Load the Cesium SDK and its widget styles only when a Cesium globe first starts 2026-11-03 22:56:24 +04:00
German Khokhlov 9f8381ff71 Check a parked renderer's real WebGL context before reuse and rebuild it when the context is lost 2026-11-03 22:56:24 +04:00
German Khokhlov 01ae045346 Keep a restored tracked aircraft when Cesium starts or resumes instead of treating it as new navigation 2026-11-03 22:56:24 +04:00
German Khokhlov 59a2e31aa8 Offer voice actions on whichever globe has the capability they need instead of refusing them without a Cesium viewer 2026-11-03 22:42:25 +04:00
German Khokhlov b20579b06a Register the Navara Draw input in the package boundaries again 2026-11-03 22:03:25 +04:00
German Khokhlov a9282b30bb Expect the session's marks and files drawn on Navara as on Cesium in the one-application switch check 2026-11-03 21:52:42 +04:00
German Khokhlov 1a3f6f2486 Treat contacts with the Cyber sonar sweep on Navara behind an optional render.setContactSonar capability
The sprite and point mover shaders rate each contact by its anchor against the sweep with the cyberSonar.js sampler maths; the engine sonar scene feeds the theme, settings and shared sweep angle each drawn frame and keeps the fleet cadence only while the sweep runs over contacts.
2026-11-03 21:49:56 +04:00
German Khokhlov c2d7a6dbb0 Register the Navara style, bloom and cloud cover modules in the package boundaries and keep VisualEffects free of the globe contract 2026-11-03 21:49:50 +04:00
German Khokhlov 89339cdd96 Leave the scope mask off a globe whose container holds no DOM children 2026-11-03 21:47:13 +04:00
German Khokhlov 5983c663bd Put the DISPLAY scope mask over Navara, its terminus following the engine camera's height 2026-11-03 21:46:37 +04:00
German Khokhlov ef174b0ff7 Keep local directories out of the acceptance report 2026-11-03 21:45:20 +04:00
German Khokhlov 6ba67d01af Draw by hand on Navara: clicks, moves and the double click from the engine input, a dashed engine line and vertex dots as the preview, and depth picks at the camera treated as misses 2026-11-03 21:41:30 +04:00
German Khokhlov d8ef366ab1 Classify the Navara launch pad zone onto terrain and 3D tiles with Cesium's fill, rim and emission
The zone was a flat disc at ellipsoid height in Navara's opaque scene: it floated over low pads and sank under raised ground or 3D tiles, with hard edges and an unlit rim. A screen-space pass now paints the ground pixels within the radius of the pad from the scene depth, as Cesium's GroundPrimitive classifies them, using one style shared by both engines (LAUNCH_PAD_ZONE_STYLE).
2026-11-03 21:30:36 +04:00
German Khokhlov f0d38897f4 Drive the Navara volumetric clouds from the cockpit weather behind an optional render.setCloudCover capability
The cockpit's weather feed moves out of the Cesium cloud pass and serves both: Cesium keeps its own cloud canvas, an engine with its own clouds takes their cover from the same observations, with no second WebGL canvas.
2026-11-03 21:22:34 +04:00
German Khokhlov d8c63c69d8 Import GeoJSON, KML, KMZ and GPX files on Navara: shapes as clamped vector layers, pins as sprites, and the graphics it cannot draw counted and reported 2026-11-03 21:19:55 +04:00
German Khokhlov eee4f48745 Hide Navara infrastructure stems and data pack meshes behind walls, terrain and the Earth
Navara draws its transparent scene after the post effects into a target without the scene depth, so stems and pack polygons and images painted through 3D tiles. A scene-depth test now drops each of their fragments behind the depth Navara packs after its opaque pass; they stay in the transparent pass, so their look in the open is unchanged (scripts/qa-edge-occlusion.mjs compares it with the previous build). Wind flow was already hidden and is kept as a guard in the same scenario.
2026-11-03 21:15:03 +04:00
German Khokhlov c392d6fb50 Glow the Navara frame with the Display bloom behind an optional render.setBloom capability
postprocessing's BloomEffect runs after the overlays and before the visual styles, follows Cesium's eased strength and dead zone with its threshold set in display terms, and VisualEffects drives it when the engine has the bloom capability.
2026-11-03 21:11:53 +04:00
German Khokhlov 074f4af10e Release a running camera verb when a Navara scene is parked or torn down, as the Cesium scene does 2026-11-03 21:11:30 +04:00
German Khokhlov dcb8de9d2c Run the camera verbs on the globe contract so orbit, pan, tilt, rotate, globe rotation and the route dolly move a Navara camera as they move Cesium's
A motion drives one camera through a rig: a Cesium viewer's own camera, or any globe engine through camera.pose/setView/lookAt/release/transform/flightActive/following and frames.preRender. An engine rig binds its frame, canvas-input and follow listeners only while its motion runs. A finished once orbit now releases its lookAt lock, and an orbit armed through a flight keeps its nudge budget until it starts.
2026-11-03 21:11:30 +04:00
German Khokhlov 4c28cc0212 Draw the visual styles on Navara as one post pass behind an optional render.setStyle capability
CRT, NVG, FLIR, Anime and Noir run their Cesium shaders through a GLSL prelude with the exact sRGB transfer, crossfade in the stage order, fade the Navara stars with Anime, and report the visualStyles capability through engineCapabilities.
2026-11-03 21:02:50 +04:00
German Khokhlov 00dfed7959 Run the scenarios from the served clean checkout 2026-11-03 21:01:17 +04:00
German Khokhlov f7945d6844 Stop the runner's preview at the end and record only .env names and credential leaks in the served build 2026-11-03 21:00:58 +04:00
German Khokhlov 96b5b0bb1f Draw the session annotation board on Navara: draped areas and routes as clamped meshes, every other mark on the shared screen board over the engine's projector 2026-11-03 20:59:10 +04:00
German Khokhlov 5b7171f485 Pin that push-to-talk starts from the Navara canvas inside its renderer host 2026-11-03 20:40:34 +04:00
German Khokhlov f5c14ec30d Move the session annotation board, Draw and Import out of the Cesium tools into sessionTools.js and give Navara its tools seam 2026-11-03 20:37:59 +04:00
German Khokhlov dea10cac4f Give Local ADS-B the selectById hook the other selectable layers expose
It takes the ICAO a position row carries, in any case, or the marker id, and selects through the same path as a click; selectAircraft now delegates to it.
2026-11-03 20:36:10 +04:00
German Khokhlov 60a230b2ff Keep a bikeshare city's last known stations, marked stale, when it comes back into range during a source outage 2026-11-03 20:33:13 +04:00
German Khokhlov 057dbe24af Reframe a mission after a late active-satellite answer only while nobody has left its orbit view
A flyover, a launch-site view, a cancelled flyover or another mission now clears the pending orbit framing, so a late TLE lookup followed by a data refresh never takes the camera back.
2026-11-03 20:31:49 +04:00
German Khokhlov f0d7def9c7 Report an empty wind field from get_wind as no surface reading instead of a NaN speed 2026-11-03 20:29:44 +04:00
German Khokhlov 1773494116 Run the layer acceptance under a default-deny network policy with per-layer assertion manifests and strict switch contracts
Every scenario runs through scripts/qa-strict-harness.mjs: each page, frame and worker gets an ordered block list before it runs (paid hosts and routes first, then only the preview, declared fixtures, keyless probes and cached free tile hosts), switches with real input on the picker, and records its assertions per layer and engine. The runner reads the catalog from the live page, fails any layer without an assertion on either engine, can build and serve a keyless checkout itself, and adds rapid, hidden-tab, context-loss, failure-injection, cold-chunk and soak scenarios. The main chunk gets a 1% size budget.
2026-11-03 20:15:50 +04:00
German Khokhlov 3144169057 Draw the Navara CCTV frame and viewshed in the depth-tested opaque scene so walls hide them
Navara's transparent pass runs after the post effects without the scene depth, so a camera frame clamped in front of a wall still painted through it. scripts/qa-edge-cctv-occlusion.mjs serves a one-box tileset as a local fixture with no network and checks on both engines that the frame ends at the wall and is not visible behind it.
2026-11-03 20:11:23 +04:00
German Khokhlov 37ea4e1f93 Keep the six map-source tiles on one row and follow the engine picker in the map tray gate
Sentinel-2 made the desktop source row wrap to two rows; the row now lays out every tile in one row (three columns on a phone, as before). scripts/qa-map-source-tray.mjs read the controller's removed imagery internals and counted five tiles between OSM and the styles; it now reads the live base provider and counts the section's tiles and engine picker. The five failures left also fail on origin/main e5d15f24.
2026-11-03 20:00:55 +04:00
German Khokhlov 31b1f27a2e Wait for the traffic refinement task by task instead of for five milliseconds
The handoff test failed under a loaded test runner when the refined pass needed more yields than a fixed 5 ms timer allowed; it now yields until the refined paint lands (bounded by a task count, not wall time).
2026-11-03 19:55:33 +04:00
German Khokhlov f2117a98f2 Ask a parked Navara globe for no frames and guard parked engines in the switch gate
The Navara look stopped requesting frames once its globe is parked. scripts/qa-app-once.mjs now fails if anything moves a parked engine's camera or asks it to draw (its own resume aside).
2026-11-03 19:54:45 +04:00
German Khokhlov 40a81c58e6 Hold the shell's inputs between two engines instead of reaching released controllers
A visual choice made while no globe is attached (a style key, a Display control, a voice command) is queued and applied to the next engine's settings after it restored the address's view, so the person's choice wins; reads answer from the released settings. The globe reset and a location lookup started without a globe do nothing, and the map-source chips and camera buttons are unbound until the next globe binds them.
2026-11-03 19:54:45 +04:00
German Khokhlov df73c5b21a Break Contacts distance ties by identity so navigation reaches the same ID in any feed order 2026-11-03 19:50:25 +04:00
German Khokhlov c96d4ecfb1 Check that a superseded or cancelled launch flyover never takes the Navara camera back 2026-11-03 19:50:24 +04:00
German Khokhlov d5218c4722 Check that a late route answer and the previous engine's camera never take over a Directions route 2026-11-03 19:48:15 +04:00
German Khokhlov 4f8a9f2615 Cover a scene switch at a stopped satellite epoch with an edge-case test 2026-11-03 19:48:05 +04:00
German Khokhlov 1c09c90e13 Test that a late Nepal boundary after cancel leaves the live locator scene alone on both engines 2026-11-03 19:47:29 +04:00
German Khokhlov dae4011604 Pin that bikeshare systems sharing a station ID stay apart and an outage keeps the last status card 2026-11-03 19:47:04 +04:00
German Khokhlov 299a5943a0 Ignore a late image or error from a street-level provider the user switched away from 2026-11-03 19:46:31 +04:00
German Khokhlov f7cd6797ec Cover a vessel crossing an area cell during an AIS gap with an edge-case test 2026-11-03 19:46:13 +04:00
German Khokhlov 1910a3b2d5 Check that fires keep their observation sum and draw one marker kind across Navara LOD changes 2026-11-03 19:46:10 +04:00
German Khokhlov c45ec634ee Pin that an ALPR camera on a tile edge stays one record with its tagged azimuth 2026-11-03 19:45:59 +04:00
German Khokhlov d86047fe8f Release the shell from a candidate engine that fails after it attached
The shell's scene part detaches it too, so a candidate failing in a later part (a layer's drawing, Cockpit clouds) leaves the shell free for the working engine instead of refusing every later attach. StyleManager.attachScene releases what it built when it throws, and one detach runs at a time. The controls phase awaits its parts, and a failed attachment no longer forces handoff off for the parts it releases. scripts/qa-app-once.mjs now also rolls back from a Navara candidate whose layer attachment fails.
2026-11-03 19:45:34 +04:00
German Khokhlov 58c8a481ed Report an empty wind field as no reading instead of a NaN speed 2026-11-03 19:45:16 +04:00
German Khokhlov 63b2590b9f Cover an unplugged local receiver and local IDs in share links with edge-case tests 2026-11-03 19:44:55 +04:00
German Khokhlov 4a9b95e31b Pin that an empty installation answer is never reported as an unavailable source 2026-11-03 19:44:50 +04:00
German Khokhlov b977ef29e3 Split submarine cables at the antimeridian and keep their reference beside the crossing 2026-11-03 19:44:33 +04:00
German Khokhlov 293ffb2bd3 Pin that a stale GNSS zone and its refreshed neighbour stay apart and edge positions bin once 2026-11-03 19:44:16 +04:00
German Khokhlov b62f60482e Pin that a zero air-quality reading and a missing one give different cards 2026-11-03 19:43:42 +04:00
German Khokhlov b29e20632b Test that transit heading, data age and selection stay continuous through a stop, a turn and a feed outage 2026-11-03 19:43:37 +04:00
German Khokhlov 736ee8d694 Drop earthquakes older than the USGS feed window and pin duplicate and horizon edge cases 2026-11-03 19:43:07 +04:00
German Khokhlov 03cd379c0e Cover a military ICAO reported by both feeds with edge-case tests 2026-11-03 19:42:46 +04:00
German Khokhlov 57f7edc693 Test that a GhostMaps edition or engine change mid-parse shows only the selected edition 2026-11-03 19:42:10 +04:00
German Khokhlov c34a2c38fc Keep the analyst's last answer when the voice runner moves to another engine
A runner retired by an engine switch ends its in-flight writes but leaves the shared follow-up memory for the next engine's runner; ending the voice conversation still forgets it.
2026-11-03 19:41:12 +04:00
German Khokhlov 8e1a3bab3e Keep the debug handle off window between engines and recover the page after a failed recovery
The application's debug handle is one object, present on window only while a globe is attached, so scripts reading __godsEyeView?.engine.kind see no null engine mid-switch. A detached application has no live components: settle no longer waits for a frame of a missing engine, and an engine that runs again after a failed recovery hides the loading screen the error showed.
2026-11-03 19:10:47 +04:00
German Khokhlov a99b862580 Create the application once per page and switch engines through the scene host
createStandaloneApplication opens its first globe, attaches the scene host and returns attachScene/detachScene: a switch hands the session off, parks the outgoing renderer and attaches the incoming one with its catalog to the parts the phases registered once. A failed candidate releases what it attached; the working engine attaches again on the same shell, voice, tools and stores. ?switch=legacy keeps the route that builds an application per engine for one release. scripts/qa-app-once.mjs now asserts identity, UI, tool and listener continuity, the rollback and the legacy route.
2026-11-03 18:19:51 +04:00
German Khokhlov 2bac200c01 Attach the tools, data and controls bound to one globe as scene parts
Each phase builds its session objects once (shell, layer manager and presentation, Director, annotation board, Draw, Import, voice, debug handle) and registers the parts bound to one globe: the shell's later attachments, Cockpit clouds, the catalog binding and sonar, the Director's scene, the Cesium or Navara tools and the debug handle's globe fields. A page with a scene host attaches them to every engine; a host that never switches attaches them once, as before. The shell releases its globe before the data layers detach, and the Navara notice goes with its engine.
2026-11-03 18:17:23 +04:00
German Khokhlov 061ed1b682 Attach the shell to another globe without disposing it
StyleManager.attachScene/detachScene rebuild only the globe-bound parts (share restoration, visual settings, layer bindings, Cockpit, the celestial ring, the overlay host, detection, map-source and camera-orientation controls) and point navigation, location search, HUD, share link and the display bindings at the next globe. dispose() revokes input synchronously, detaches the scene and only then nulls the stores and unmounts the views. The radio tuner listens on the globe container, and an engine's persistent notice can be withdrawn.
2026-11-03 18:02:46 +04:00
German Khokhlov d1fdb9eb45 Let the shell's share link, HUD, orbit and navigation follow another globe engine
The shell reads its viewer, engine and map stack from the attached scene. ShareLinkManager, IntelHUD and OrbitController gain attachEngine, NavigationController setScene and LocationNavigation attachScene/detachScene, so a switch can keep them and move only their camera listeners; between engines they write and read nothing. The voice runner and session voice modules are owned by their packages, which check:boundaries needs since the voice commit.
2026-11-03 17:48:08 +04:00
German Khokhlov 741b6f67eb Keep the Draw control and its chosen shape across engine switches
The session Draw handle keeps the shape and colour and the marks stay on the annotation board; the drawing tool (preview, click handler, Escape and Enter keys) belongs to the attached viewer, so a switch ends an open sketch and gives the pointer back.
2026-11-03 17:38:05 +04:00
German Khokhlov 17eea266f1 Keep imported files across engine switches and read them again on the next Cesium viewer
The Import list and each file's bytes belong to the page session; the data sources, pins, drop and gesture listeners belong to the attached viewer. Without a viewer the list stays and import or fly-to is refused.
2026-11-03 17:30:27 +04:00
German Khokhlov 111ba1626c Keep the annotation board across engine switches and draw it again on Cesium
The marks belong to the page session; only their drawing belongs to the attached viewer. A switch takes the drawing off, keeps the marks, and the next Cesium attachment draws them again. Without a viewer the board refuses new annotations instead of failing in the resolver.
2026-11-03 17:24:39 +04:00
German Khokhlov 05578c61b9 Keep an active voice session through engine switches
The page creates the voice once; each engine attachment only gives it that scene's action runner, pointer canvas and annotation events. Calls made during a switch wait for the incoming engine and are refused with ENGINE_SWITCHING when none attaches in time; results name the engine that answered, and the session hears engine-changed after a switch. The analyst's follow-up memory and layer enable times outlive one engine's runner. scripts/qa-voice-engine-switch.mjs drives a fake microphone and Realtime transport through the switches.
2026-11-03 17:20:40 +04:00
German Khokhlov 35ed499ae4 Keep the place search, language listener, logo gaze and Google key for the page session
An engine switch no longer recreates them: with a layer session they live until the page lifetime aborts; a host without one keeps the per-application lifetime.
2026-11-03 17:13:07 +04:00
German Khokhlov f0c2523ac4 Add the scene host that attaches engine-bound parts in order and detaches them in reverse 2026-11-03 17:09:53 +04:00
German Khokhlov b2cc00833d Measure session object identity and listener counts across engine switches
window.__gevSession names each session object by a stable instance id and counts applications, attachments and detaches. scripts/qa-app-once.mjs (report-only with --report for now) runs three round trips and a parked-renderer rollback on a keyless preview with paid map, model and voice endpoints blocked.
2026-11-03 17:08:22 +04:00
German Khokhlov 5564156c09 Assert that the traffic population after a globe round trip stays near the population before it
Both are read after the same settle delay; the population is checked again once the vehicle comparison is taken.
2026-11-03 16:24:08 +04:00
German Khokhlov eed8acc3f7 Keep the traffic population and vehicle heights after a globe round trip
The republish after a globe switch ran as a refinement ('Handoff refined'):
it sampled the incoming globe while it still showed coarse tiles and,
being a refinement, never refined again. Keyless Cesium's first terrain
tiles put Austin's streets near -2 200 m, so vehicles stayed there, and
the next globe weighted each road by the camera distance to those
waypoints (1.9 km became 4.1 km), cutting the population from about 620
to 260 until the camera moved.

The handoff is now a completed pass that refines once the incoming
surface has loaded, road density and the coverage radius are measured to
the road's coordinates on the local ground estimate instead of to the
last sampled waypoints, and flow that lands after a publication
re-allocates the retained roads' budgets instead of only recolouring
them.
2026-11-03 16:24:08 +04:00
German Khokhlov f4879fc24b Keep the Utilities panel's Directions row mounted across a globe switch 2026-11-03 15:36:11 +04:00
German Khokhlov 7f0572cf4c Run upstream's HUD summary and standalone startup tests against the engine-based HUD and application 2026-11-03 15:36:11 +04:00
German Khokhlov 1a1eef0e5c Merge origin/main into german/navara
Conflict resolutions:
- src/main.js: upstream's PHOTON_URL place-search setting is read once at
  module level and passed as geospatial: { endpoints: { photon } } by
  createApplication(), so every engine start of the switch flow gets it; the
  old single top-level createStandaloneApplication() call stays removed.
- src/layers/bikeshare/render/cesium/rendering.js: upstream's
  publishedCapacity fix applied with this branch's
  resolveCapacity(model, capacity, status) signature. The same fix is ported
  to src/layers/bikeshare/render/navara.js (record.publishedCapacity, status
  polls resolve from it), with a Navara regression test; upstream's
  proxyRoute test imports the moved render/cesium/rendering.js.
- src/layers/traffic/flow.js: upstream's nextTask() yield and current()
  check kept before the scene is taken from this branch's
  _trafficScene?.() ?? _viewer?.scene with its missing-scene return.
- scripts/package-boundaries.json, scripts/format-scope.json: union of both
  sides' file lists.
- src/voice/actionSchemas.test.mjs: both history comments kept, payload hash
  recomputed for the merged schemas (Sentinel-2 map stack enum plus the
  shorter layer alias hint).
- src/hudAltitudeDatum.test.mjs: this branch's engine-based IntelHUD
  construction with upstream's placeLabels summary context.
2026-11-03 15:36:06 +04:00
German Khokhlov a066a2d1ca Keep history entries untouched by unfinished globe picks and restore each visited entry's layers
A picked engine now lives in the switch until it runs; only then is the
address written, so a pick superseded by Back or one that fails rewrites no
entry. An engine-changing history visit holds the outgoing share link, which
neither restores nor writes the visited entry, and the running engine takes
the address back once the switch settles, restoring any entry visited while
it was held or not yet listening. Handed-off layers keep running where the
incoming address agrees with the outgoing one and follow a visited entry
where it differs. A failed recovery keeps the error on the loading screen
with an engine picker for a retry, and a failing reveal of the new globe no
longer blocks the cover removal or later requests.
2026-11-03 15:24:20 +04:00
German Khokhlov 9cc34e324f Stop scene attachments of Bhote Koshi, GhostMaps, cables and infrastructure waiting once their scene is released 2026-11-03 15:23:21 +04:00
German Khokhlov cf6d04b7db End the data startup wait when the application is torn down during a slow layer attachment 2026-11-03 15:23:21 +04:00
German Khokhlov 7fb11507d5 Bound the wait for layer attachments, park enables and explicit refreshes until the next scene binds 2026-11-03 15:23:20 +04:00
German Khokhlov 53c114965d Write a machine acceptance report and stop early when no browser or preview is available
qa-all-layer-switches.mjs now checks once that Puppeteer can start Chrome and that the preview answers, and names PUPPETEER_EXECUTABLE_PATH and npx puppeteer browsers install chrome when it cannot. Each run writes <output>/acceptance.json with the commit, dirty files at both ends, Chrome, Node, base URL, times and, per scenario, status, exit code, duration, covered layer ids and the sha256 of its log. It replaces summary.json.
2026-11-03 15:05:37 +04:00
German Khokhlov 65d41b971c Check traffic vehicles one by one against a fixed dark ground after the globe round trip
Live Sentinel-2 imagery is bright at this altitude, so a fixed crop of white two-pixel vehicles changed anywhere from 14 to 300 pixels. The scenario now serves a uniform dark ground tile, waits for the layer to settle with vehicles in view, projects each vehicle to the window and requires 90% of those above the drawn terrain to change the picture where the layer places them; in simulation nothing else may change. Vehicles below the terrain and the population left after the round trip are reported, not asserted.
2026-11-03 15:05:36 +04:00
German Khokhlov 4d2b061e3b Hand the session off on Back/Forward and roll failed globe switches back to the last working engine
The engine switch moves out of main.js into src/standalone/engineSwitch.js: every
engine change in the page hands the session off (layers, Director, Contacts,
directions and satellite cameras), whether it came from the picker or from
history. Each attempt has a generation; a failure rolls back to the last engine
that started, requests made during a switch or recovery are reconciled once it
settles, and a picked engine adds its history entry only after it starts. The
first-frame wait only lifts the cover and never fails a started engine; a
hidden tab does not wait. Nothing starts after pagehide, and a failed recovery
leaves the picker usable.
2026-11-03 14:51:17 +04:00
German Khokhlov 64c5cd25e6 Document which inputs a warm cached renderer keeps from the application that built it 2026-11-03 14:47:36 +04:00
German Khokhlov 64759c552e Release the cockpit cloud WebGL context when its controller is destroyed 2026-11-03 14:47:36 +04:00
German Khokhlov 12cd73bed9 Skip CCTV scene work when its renderer was released during a health refresh 2026-11-03 14:47:35 +04:00
German Khokhlov baabff6acf Stop a Bhote Koshi attachment that resumes after its scene was released 2026-11-03 14:47:35 +04:00
German Khokhlov 2853938df7 Detach every layer from a scene even when one fails, and let in-flight attachments stop first 2026-11-03 14:47:35 +04:00
German Khokhlov dd040b4f87 Let a failed application teardown be retried instead of repeating its rejection 2026-11-03 14:47:35 +04:00
German Khokhlov 0c190c3a15 Format the layer lifecycle, standalone application and Bhote Koshi event with Prettier 2026-11-03 14:36:31 +04:00
German Khokhlov 62296a833c Exercise every catalog layer on both planets and verify live switch continuity 2026-11-03 05:22:02 +04:00
German Khokhlov 05b646daa3 Unsubscribe removed Navara effects and models from warm view frames 2026-11-03 05:16:20 +04:00
German Khokhlov 14f7b32537 Release Navara overlay targets and sprite images on repeated globe switches 2026-11-03 05:04:38 +04:00
German Khokhlov 160e445677 Keep authored scene playback, pack media and geographic interactions across globes 2026-11-03 04:49:46 +04:00
German Khokhlov a49671a391 Preserve satellite tracking, orbit solutions and mission replay across globes 2026-11-03 04:36:49 +04:00
German Khokhlov 133ca14119 Keep CCTV playback and calibration while replacing globe projection surfaces 2026-11-03 04:25:02 +04:00
German Khokhlov 24190a3e35 Keep city stations, vehicle tracks and trail history across globe switches 2026-11-03 04:21:48 +04:00
German Khokhlov c4a97a653c Keep fire and camera selections bound to their source records 2026-11-03 04:18:53 +04:00
German Khokhlov b01f1ee7dc Preserve radio playback and imagery comparisons when switching planets 2026-11-03 04:16:00 +04:00
German Khokhlov b98cbeebf2 Keep street imagery and picked coverage across Cesium and Navara 2026-11-03 04:13:26 +04:00
German Khokhlov 75df3389f3 Keep traffic vehicles and road flow across globe switches 2026-11-03 04:09:21 +04:00
German Khokhlov 4e09559337 Carry the Nepal event images, playback and media across globes 2026-11-03 03:57:18 +04:00
German Khokhlov 811bcaff85 Retain the Nepal locator presentation and draw it on either globe 2026-11-03 03:49:39 +04:00
German Khokhlov 605b9490f3 Move Directions routes and active camera flights between globes 2026-11-03 03:44:07 +04:00
German Khokhlov 5cea3e8cbb Retain the TeleGeography dataset and render cables and landings on Navara 2026-11-03 03:38:15 +04:00
German Khokhlov 2b4955c817 Draw and inspect the same wind forecast when switching globes 2026-11-03 03:34:25 +04:00
German Khokhlov d7b3d45135 Keep local ADS-B receivers and aircraft history alive across engine changes 2026-11-03 03:30:19 +04:00
German Khokhlov c1d829ef51 Draw local infrastructure on either globe from one retained dataset 2026-11-03 03:28:26 +04:00
German Khokhlov 868d061856 Preserve cyclone bulletins, forecast geometry and storm selection across engines 2026-11-03 03:21:58 +04:00
German Khokhlov cb350fa58d Share observed weather clocks and render radar, infrared and lightning on Navara 2026-11-03 03:18:59 +04:00
German Khokhlov 11992828fb Keep GhostMaps KMZ editions and selections across native globe renderers 2026-11-03 03:13:36 +04:00
German Khokhlov 87bbe14748 Keep Contacts, active layer intents and mapped sites across globe switches 2026-11-03 03:05:47 +04:00
German Khokhlov 81bcae907f Preserve AIS observations, selection and trail across globe changes 2026-11-03 02:53:10 +04:00
German Khokhlov e08ddd787b Preserve military aircraft and classification across renderer handoffs 2026-11-03 02:47:32 +04:00
German Khokhlov 1bda760763 Preserve fire incident geometry and selection across both globes 2026-11-03 02:40:36 +04:00
German Khokhlov b7431d7823 Use cockpit readiness for voice entry on either globe 2026-11-03 02:39:27 +04:00
German Khokhlov d7ab85f5c4 Keep Vue layer rows mounted through globe switches and rollback 2026-11-03 02:38:25 +04:00
German Khokhlov 2ee09c5911 Verify renderer startup rollback keeps the flight cockpit usable 2026-11-03 02:32:19 +04:00
German Khokhlov 1930ca247a Keep GNSS history across globe changes and share the Navara ground cell pass 2026-11-03 02:30:52 +04:00
German Khokhlov 6fe5bda028 Draw air quality on both globes from one retained station model 2026-11-03 02:25:10 +04:00
German Khokhlov 99bda8a35e Exercise camera handoff behavior in the location readout regression 2026-11-03 02:23:45 +04:00
German Khokhlov c0062c4d3d Retain live flights and earthquake observations across globe switches 2026-11-03 02:14:55 +04:00
German Khokhlov a0e5f04c30 Separate flight observations and tracking from the scene renderer 2026-11-03 02:04:41 +04:00
German Khokhlov 4bd7f97cc4 Isolate globe renderer suspension from the switch cache 2026-11-02 22:42:40 +04:00
German Khokhlov 73ea608881 Hide map panels when entering the cockpit 2026-11-02 22:21:45 +04:00
German Khokhlov 2087b55b47 Open the cockpit on a tracked Navara aircraft 2026-11-02 22:04:23 +04:00
German Khokhlov 8b179a7e01 Keep Navara cinematic clouds visible at flight altitudes 2026-11-02 21:27:29 +04:00
German Khokhlov 96ce795f8c Keep Navara terrain visible on night-side descents 2026-11-02 20:58:17 +04:00
German Khokhlov 3a2a53aef9 Anchor Navara inspection sunlight to the restored view 2026-11-02 17:26:34 +04:00
German Khokhlov 0d15e80e28 Keep Navara satellite daylight through orbital zoom 2026-11-02 17:11:42 +04:00
German Khokhlov 3cbc17a926 Match Navara satellite landscapes to the Re:Earth Fuji view 2026-11-02 14:05:58 +04:00
German Khokhlov 5e658d8c9d Keep globe renderers warm across live engine switches 2026-11-02 13:45:23 +04:00
German Khokhlov f71c76c306 Restore engine-specific Cinematic sharpness across live switches 2026-11-02 13:19:21 +04:00
German Khokhlov b8f4d097c4 Merge main and switch Cesium and Navara in place through Vue
Preserve the camera, map source and layer selection across engine sessions. Restore the previous engine if startup fails and retain local history navigation.
2026-11-02 13:10:36 +04:00
German Khokhlov 1da65dc264 feat(ui): switch between Cesium and Navara in Visual Presets
Remember the chosen engine and restore the current view after navigation. Keep the standalone JavaScript UI and retire the Dioxus migration and Cesium removal milestones.
2026-11-02 11:29:55 +04:00
German Khokhlov dc7a7c4d53 perf(navara): skip redundant empty-scene resolves
Preserve pending clears, backgrounds, callbacks, shadows and real geometry. Verify exact pixels and the underground depth-clear path; add controlled original-render comparison modes.
2026-10-30 17:24:29 +04:00
German Khokhlov 15f1f9f5b6 perf(navara): submit stencil stages in one render
Preserve the native stencil draws and geometry MSAA while avoiding two intermediate MRT resolves per volume. Verify original pixels, holes, overlaps, ownership and controlled comparison modes.
2026-10-30 14:42:31 +04:00
German Khokhlov c76872dfaa perf(navara): resolve MSAA at geometry stages
Keep four-sample geometry and depth while using single-sample postprocessing buffers. Preserve forward and overlay coverage, skip empty transfers, and add matched pixel and regression checks.
2026-10-30 12:52:50 +04:00
German Khokhlov 8279af2e6d test(navara): record keyless W2 comparison and acceptance gaps 2026-10-29 21:39:20 +04:00
German Khokhlov 0df0fb8261 fix(navara): separate always-visible shader programs 2026-10-29 20:41:13 +04:00
German Khokhlov 8328aab779 fix(navara): batch contours and order ground overlays
Batch consecutive disjoint installation outlines to avoid three stencil draws
per feature. Preserve overlapping volumes, material order and local precision.
Delete the public effect handle correctly during teardown.

Keep imagery below ground classifications and glyphs in every activation order,
including minimal mode without tone mapping. Exclude fully offscreen ground
volumes from the screen candidate grid.

Full unit/allocation, boundaries, formatting and keyless builds pass. Browser
checks cover contour coverage, bounded colour differences, a missing-contour
negative control, real pass order, sprite alpha and FIRMS glows. Whole-wave
performance acceptance remains separate from this implementation checkpoint.
2026-10-29 20:27:24 +04:00
German Khokhlov 9ba5a3aba9 fix(navara): invalidate cached glyphs after context loss
Clear the display-resource signature used by static sprite reuse when the WebGL context is lost or restored. Cover both events and verify that caching resumes after the redraw.
2026-10-29 19:26:47 +04:00
German Khokhlov f59d134928 feat(navara): render clipped recent imagery comparisons 2026-10-29 18:30:49 +04:00
German Khokhlov 5a8bc33fbd feat(navara): render transit vehicles and ground trails 2026-10-29 17:51:17 +04:00
German Khokhlov b8560e5b26 refactor(transit): isolate native rendering from playback 2026-10-29 17:12:18 +04:00
German Khokhlov 694616d5c2 feat(navara): render CCTV media and calibration handles
Port camera markers, coverage, media planes, volumes and calibration to the
Navara renderer. Preserve shared source/media policy and add exact plane
picking, owned resource cleanup and idle hold handling.

Validate paired keyless dev/preview media and physical drags, native legacy,
engine contracts and full CPU/build gates. Record remaining shell, viewshed
browser and W2 performance gates; defer Dioxus and plan the transit port.
2026-10-29 16:53:27 +04:00
German Khokhlov 798d6636ab feat(navara): add finite visible-surface obstruction rays
Reuse retained tile triangles for CCTV's upcoming activation probe, with finite range, exclusions and honest misses. Correct nearest-hit ordering when the ray starts inside a mesh bound.

Fifteen focused surface tests and all full keyless unit/allocation, boundary, format and build gates pass. CCTV renderer integration and existing acceptance gaps remain documented.
2026-10-29 00:13:23 +04:00
German Khokhlov 93c71ffc86 refactor(cctv): isolate native rendering resources
Keep shared CCTV logic independent of Cesium, release the original renderer's resources, and guard retired initialization/media work. Preserve native marker, plane, volume, calibration and input behavior.

Record keyless dev/preview validation and retain unresolved shell idle/terrain and photoreal acceptance gaps. Routine QA builds use empty Google and Cesium credentials and block paid maps before navigation.
2026-10-29 00:06:22 +04:00
German Khokhlov f72bf6a774 fix(qa): keep routine map checks keyless
Record keyless launch instructions without changing app defaults.
Block Google tile and token fallback requests in routine CCTV checks;
add a guarded startup check for both engines.
2026-10-28 23:33:15 +04:00
German Khokhlov 311e178d0c refactor(cctv): isolate calibration handles and stop idle poll frames
Move native gizmo entities and input into an owned renderer. Restore the
captured camera-input flag, handle cancellation and duplicate presses, and
reject foreign handle identities. Preserve native geometry and picking.

Avoid no-op sync-chip mutations and let unchanged CCTV polls complete
without publishing scene changes. Keep health/UI polling and real geometry,
auto-hop and media updates. Extend browser idle samples across two polls.

Validated full Node24 unit/allocation, boundaries, scoped format, production,
panel and QA builds; native legacy parity, real media/GPU picks on dev and
preview, engine contracts and production shell. Retain failed evidence.
2026-10-28 22:44:27 +04:00
German Khokhlov a72cfc4098 fix(cctv): let unchanged image projections idle
Prove native geometry, real image and video pixels on dev and preview before renderer extraction. Scope fixture interception to API routes so geometry workers remain live.
2026-10-28 21:53:19 +04:00
German Khokhlov 46b3aceafc feat(alpr): render camera markers on Navara
Separate native scene ownership from shared ALPR source and selection state,
and add bounded Navara sprites with the existing direction/card overlay.
Preserve marker artwork, picking, floor retries and source attribution.

Fix lost post-render wakeups and redundant mobile rail mutations, and keep
HUD typewriter dimensions stable so static views settle without extra frames.
Add parser-fed dev/preview and legacy evidence, including retained failures.
Common W2 terrain and combined performance gates remain open.
2026-10-28 21:06:08 +04:00
German Khokhlov 9d587c1d37 feat(radio): render stations and clusters on Navara
Preserve native clustering, selection and direct-action playback through renderer ports. Record both-engine dev/preview, legacy, contract, shell and build evidence with remaining W2 limits.
2026-10-28 14:49:18 +04:00
German Khokhlov d82a6f1550 feat(bikeshare): render GBFS stations on Navara 2026-10-28 13:12:19 +04:00
German Khokhlov 1a694baf5a feat(installations): add Navara renderer 2026-10-28 12:18:03 +04:00
German Khokhlov 965a82144c refactor(installations): inject engine renderer adapter 2026-10-28 09:54:05 +04:00
German Khokhlov f4f0088814 refactor(installations): use globe camera contract 2026-10-28 09:50:17 +04:00
German Khokhlov c58bc9864b refactor(installations): make model engine neutral 2026-10-28 09:47:40 +04:00
German Khokhlov 5dd05a4a81 feat(firms): add Navara renderer checkpoint 2026-10-28 09:36:50 +04:00
German Khokhlov a9b3125a99 Extract the native FIRMS renderer and own its settle timer
Keep fire normalization, LOD, anchors, cards and selection portable while the native renderer owns ground cells, sprites, input and scene disposal. Cancel superseded/removed settle timers and unregister the original sprite collection.

Preserve all ten native raster pairs exactly in dev and preview; record source/click/focus/lifecycle, legacy Metal and full regression evidence with strict comparator sensitivity checks.
2026-10-27 14:40:08 +04:00
German Khokhlov a3152ab719 Add depth-classified earthquakes on Navara
Match native ground footprints, encoded alpha and source ordering on real terrain and photoreal tiles while excluding forward meshes. Cache static screen data and reduce large-disc edge work without changing GPU pixels.

Add deterministic lifecycle, source/share, foreground/horizon and three-context performance proof; restore credits for available Navara feeds and retain failure history in migration evidence.
2026-10-27 13:42:07 +04:00
German Khokhlov c89185f352 refactor(earthquakes): extract native renderer with identical ground-disc output
Keep refresh, cancellation and label policy engine-neutral; inject the original native entities at composition. Prove static geometry, analyst records, owner disposal and last-good refresh behavior. Native dev and QA-preview pass 18 checks each; all eight baseline PNGs remain byte-identical.
2026-10-27 10:56:41 +04:00
German Khokhlov e8870a4766 test(migration): establish W2 native oracle and release removed overlay identities
Add the sequential W2/completion plan and deterministic earthquake ground-raster, geometry, lifecycle and strict runtime QA. Prove the comparator rejects incomplete evidence and release deleted overlay entries from the collision arbiter. Record native dev/preview acceptance and full checkpoint gates.
2026-10-27 10:48:26 +04:00
German Khokhlov 0600964249 Complete phase 3 W1 acceptance and close T16 findings
Fix Navara model display tint, overlay ordering, follow gestures and resource cleanup.
Reduce repeated aircraft work and correct fixture enrichment responses.
Strengthen dev/preview QA, preserve failure history and document verified W1 results.

Validation: 7171 ordinary tests pass, one Windows-only skip; 15 allocation probes;
37 W1 acceptance steps; contracts, shell, four performance gates; 18 identical
Cesium baseline PNGs. Known pre-existing CCTV and live-source limits are recorded.
2026-10-26 23:23:25 +04:00
German Khokhlov 766c786093 feat(launches): render on Navara 2026-10-23 01:19:57 +04:00
German Khokhlov 4baefeb8f9 refactor(launches): Cesium rendering behind a renderer interface 2026-10-23 00:32:10 +04:00
German Khokhlov 1839d62160 refactor(launches): geodesics on src/geo
Port native Vincenty geodesics and launch-path math with exact Cesium oracles.
Keep all existing launch path-test expectations unchanged.
2026-10-22 23:48:50 +04:00
German Khokhlov 6bdb995fe3 feat(satellites): render on Navara
Render satellite fleets and selected targets through mover banks and orbit lines.
Enable the celestial HUD on Navara and keep native follow explicitly in ENU.
Add deterministic interaction, share, orbit, motion and cleanup acceptance QA.
Record complete targeted acceptance and remaining shared cross-shell tile failures.
2026-10-22 23:41:14 +04:00
German Khokhlov 000ef99464 refactor(satellites): Cesium rendering behind a renderer interface
Move native points, synchronous orbit primitives, tracked entities, follow and picking into the Cesium renderer. Use appClock for satellite physical epochs and isolate the unchanged CelestialRing ephemeris. Synthetic core/dense and ISS/MEO/GEO snapshots match the baseline exactly; all gates pass. The pointer-ownership source assertion follows scene.pick into render/cesium.js; registry and import-boundary assertions now reflect the migrated satellite layer.
2026-10-22 22:37:09 +04:00
German Khokhlov 83fbe6949f fix(navara): disable redundant sun shadows in minimal look
Minimal inherited the sun light default of castShadow=true although the photoreal tiles carry baked lighting. Use the same no-shadow sun configuration as cinematic. The focused 3-run city gate improved from C4.6/N5.1 ms (1.1087) to C4.6/N4.9 ms (1.0652) with 12000 vessels. Full Node 24 tests and allocation gates, boundaries, format, build and panel build pass.
2026-10-22 13:38:22 +04:00
German Khokhlov c8a4f3ed98 fix(vessels): upload Navara chevrons and restore click focus
Fix delayed and growing mover bank commits, reject stale hidden model picks, and route world-focus through the engine. Strengthen vessel parity and performance checks without changing thresholds.
2026-10-22 12:31:59 +04:00
German Khokhlov 69a78710cc feat(vessels): render on Navara (WIP: browser parity not yet run)
render/navara.js: one always-on-top engine.movers chevron bank (atlas of
every chevron icon at 32 px), MMSI pick ids, trail on engine.lines,
camera reads through the shared Navara layer view (extracted from the
aircraft renderer to src/layers/shared/render/navaraView.js, + camera
pickEllipsoid and canvas clientWidth). Registered in navaraMode.
QA: vessel qaDrawn; probe selected() falls back to getSelectedInfo;
qa-w1-layers vessel section; qa-vessels-engines side by side; --engine
for qa-vessel-cards/datum; qa-flights-engines military near pose 100 km.
Unit tests on the fake engine pass; browser runs NOT done (see progress).
2026-10-17 14:06:11 +04:00
German Khokhlov 620ada6df3 fix(globe): Navara picks rank as drawn and check model hits at their drawn radius
T11 review follow-up to ed9ea7e6. CPU hits rank as the engine draws them:
sets without a depth test over depth-tested ones, then by set order
(the tracked bank over the fleet, flights over local ADS-B), then depth —
so the CPU's top hit that checks a GPU answer is the instance drawn on
top. A model hit is no longer taken as answered: the model mover keeps
each instance's drawn diameter (after minimumPixelSize/maximumScale) and
the CPU checks the hit against its projected position within that radius
(+3 px); the CPU pick sees models at that radius too.
2026-10-17 13:26:42 +04:00
German Khokhlov 1fe8267550 refactor(vessels): Cesium rendering behind a renderer interface
src/layers/vessels/render/cesium.js holds the Cesium objects the layer
built itself, one for one: the translucent BillboardCollection of
chevrons (centred, screen-aligned, never depth-tested), the horizon
occluder, window projection, canvas size, viewer.trackedEntityChanged,
and the trail (data/trailRenderer's entity polyline, kept on Cesium: the
engine.lines Primitive path did not draw below the photoreal mesh in
round 6 — the plan's T13/T15 blocker; flights does the same).
The logic imports no Cesium: geo math, Rgba WHITE, the chevron artwork in
icons.js, clicks through the engine's selection dispatcher (own-rule
owner, same rule). Registered as CESIUM_LAYER_RENDERERS['ais-live-vessels'];
src/layers/vessels joins the boundary rule.

W1 replay vs 7c7422e2: vessels/flights/military/localAdsb counts, known
objects, screen points, requests and shot sha256 identical.
qa-vessel-cards --data synthetic 2/2 and qa-vessel-datum (?qaFeed=
vessels:fixture) 4/4, the same as 6eec8f6c.

Tests moved with the code (same meaning): cameraHandoff (the click body
is handleClick), inputOwnership (the guard precedes the click rule's
read of the pick), renderers.test (vessels in the extracted list),
importDirections (an unmoved example layer is satellites), the
ownership services fixture (renderer service).
2026-10-17 12:47:14 +04:00
German Khokhlov 6b27b21cc4 feat(data): an own-rule selection owner keeps its whole old click handler
Vessels' click rule (sibling ownership, cyclone background, card hit
test, unkeyed records) does not map onto the plain or look-under rules:
an owner with onClick(ctx) gets every click with the top pick, no gesture
rule (its old LEFT_CLICK handler), and is another layer to the rest.
2026-10-17 12:47:14 +04:00
German Khokhlov 6eec8f6cb0 fix(qa): floor verification finds a contact's model by its icao24
getNearby returns the contact's label as `id` (the callsign when it has
one) and its key as `icao24`; the drawn rows are keyed by icao24, so the
model lookup missed and every contact was measured at its sprite. Under a
3D model that sprite is hidden and keeps the pre-floor height by design
(T7): the "buried at -27 m" contacts on Cesium (the geoid at AUS) were
those hidden sprites, at HEAD and on 7c7422e2, whose script measured every
sprite. The Cesium report also gives each contact's model state and the
floor cell the layer reads.

AUS, 100 s after enabling flights, both engines: 3 low contacts, 0 buried;
N3752 / DAL848 drawn by their models at 130.2 / 135.4 m over a 123.5 /
128.6 m mesh, the DEM-prior cell warm (123.5 / 128.6 m).
2026-10-17 12:12:00 +04:00
German Khokhlov 71b7177dc0 fix(globe): Navara model tints are decoded from sRGB before mixing
The instance colour (sRGB, as Cesium takes colours: the military amber
#FFB800) was mixed into the linear material colour as is, so a tinted
model drew paler and brighter than Cesium's: the military footprint gate
read 124 % (more pixels cleared the contrast threshold over water) and
the median luminance +9 %. White (flights) is the same either way, which
is why the flights gate passed. The tint is now sRGBTransferEOTF-decoded,
as the sprite sets already decode theirs.

qa-flights-engines' model gate adds the highlights: the p90 luminance of
the model pixels within ±10 % of Cesium, with the near-white pixel count
reported (10b review low 2).

Models on, near pose (computed scale ratio ~1.0000 on both):
- military (from 100 km): footprint 9,902 vs 8,610 px (115 %), median
  luminance 158.9 vs 155.3, p90 190.9 vs 184.5 — 18/18;
- flights: footprint 104 %, median 189.8 vs 196.7, p90 223.1 vs 247.8
  (near-white px 83 vs 510: Cesium's white models saturate where Navara's
  tone mapping rolls them off) — 20/20.
Models off: flights 16/16, military 15/15. Side-by-sides regenerated.
2026-10-17 11:28:57 +04:00
German Khokhlov 5ff8c5d7a6 test(qa): floor verification reads the app's ground floor and measures models where drawn
- The DEM prior column read a fresh import of src/data/groundFloor.js, a
  module the app does not use for its layers (they read the scene's
  surface services), so it was null for every contact. It now reads
  window.__godsEyeView.surfaceServices.groundFloor. The terrain proxy
  answers 200 and the AUS cells resolve within one or two polls (123-128 m);
  the "proxy 503" reading in the task-11 report was this misread.
- A contact a 3D model draws is measured at the model's translation (the
  probe's drawn() reports it now): its sprite is hidden and keeps the
  pre-floor height by design (T7), so measuring the sprite reported a
  buried aircraft that is not drawn.
2026-10-17 11:02:52 +04:00
German Khokhlov ed9ea7e679 fix(globe): a Navara click never takes a stale GPU pick of a neighbour
Navara 0.2.1 can answer a click with the previous gesture's pick. The
check let that answer through when its instance was drawn within twice its
pick radius of the click — the followed aircraft at the centre beside the
clicked one (the 20/24 flights checklist run: a click selected the
previously followed aircraft). A GPU answer for a sprite or point set now
has to be drawn under the click (no slack) and be the instance the CPU
pick finds on top there (lines left out: they are not in the GPU pick);
otherwise the CPU pick answers. Models, which the CPU pick does not see,
are still taken as answered. The CPU drill takes `includeLines`.

Test: 20 gestures alternating a click on A and a click on its neighbour B
answered with A's stale pick — B every time. In the app, 40 scripted
click/follow/release gestures on Navara selected the clicked aircraft
every time with and without the fix (the stale answer is rare).
2026-10-17 11:02:52 +04:00
German Khokhlov ea1e2f9c04 feat(localAdsb): render on Navara
Task 12b. src/layers/localAdsb/render/navara.js implements the Local
ADS-B renderer interface of 12a on Navara:
- markers: the shared aircraft renderer's glyph sets in magenta #ff4fd8
  (class silhouette and scale, ×3 near / ×0.5 far, always on top), drawn
  under every sprite layer as Cesium draws the entities; each frame writes
  what Cesium's CallbackProperties gave (position, screen rotation,
  visibility) and the 2,000 km display distance;
- the 978 MHz UAT ring: its own sprite set, a white ring atlas tinted
  #ffb3ef, Cesium's 24 px point plus its 1.5 px outline (27 px), the same
  distance scale and distance, pickable as its aircraft;
- models (the Flights GLBs, magenta MIX tint), trail and head: the shared
  aircraft renderer's; the camera never follows a local aircraft.
Registered by registerNavaraLayerRenderers (import()); the Navara catalog
now offers flights, military and local ADS-B.

- ?qaFeed=local-adsb:fixture: the local receivers' answer in the server's
  format (src/bench/w1Feeds syntheticLocalReceivers: 40 aircraft near JFK,
  10 on 978 MHz UAT) through the layer's own decoder-feed session
  (sources.localAdsbFeeds, null by default: the layer makes its own).
- qa-w1-layers --layer=local-adsb: a click selects a marker and shows its
  card with the receiver line, the camera does not follow, Escape leaves
  the selection (it never answered Escape), empty map releases it.

qa-w1-layers local-adsb: Cesium 12/12, Navara 13/13, --compare parity
(global pose: the 2.27 px camera offset only). qa-flights-engines
--layer=local-adsb: 14/14 (fills exact; UAT rings side by side in
docs/migration/phase3/localAdsb).
2026-10-17 06:50:15 +04:00
German Khokhlov 65e30546dd fix(globe): Navara sprites keep a full rewrite when a commit follows it
A sprite/point set uploads its instances by update range. When the camera
went far enough to move the set's origin, update() rewrote every instance
(relative to the new origin) — and a commit later in the same frame
cleared the ranges and kept only its own, so the GPU never got the
rewrite: the instances not committed again drew offset by the origin move
(a military glyph at the orbit pose drew 45 px from its point after the
global → city → orbit sequence; the CPU picks, the probe and Cesium had it
in place). Ranges now add up until three uploads them (it clears them
after the upload).

Found by the military side-by-side (11b); flights' sprites had the same
exposure since 10b.
2026-10-17 06:49:53 +04:00
German Khokhlov 0b8cb855fa refactor(localAdsb): Cesium rendering behind a renderer interface
Task 12a. The Local ADS-B logic (index.js, models.js) no longer imports
Cesium. src/layers/localAdsb/render/cesium.js holds what it built, one
for one: the CustomDataSource of marker entities (billboard with
per-frame position/rotation/visibility CallbackProperties, the 2,000 km
DistanceDisplayCondition, the 978 MHz UAT ring as a PointGraphics), the
model PrimitiveCollection (models load, pose and leave through the shared
aircraft renderer's model part, adopting that collection), the selected
aircraft's trail (data/trailRenderer, as before) and live head segment,
and the camera reads. Registered for 'local-adsb' in the renderer
registry, created at init(viewer, {engine}).

- Clicks go through the engine's selection dispatcher (drill 8) instead of
  the layer's ScreenSpaceEventHandler, with the layer's own rule kept as is
  — a new `lookUnder` owner kind: its marker on top, or under picks no
  layer owns and under trail lines, is selected (and re-selected);
  another layer's contact on top, or empty map, releases only its own
  selection; a trail line alone does nothing; no gesture rule. To the
  other owners it is another layer, as its pick-owner registration made
  it before. (The dispatcher's `drillThrough` would have taken the marker
  from under another layer's contact, which the old handler never did.)
- models.js reads the camera through the renderer's view (frustum and
  horizon tests, ground snap owner) and draws through renderer.models
  (loadFleet/add/remove/destroy/setPose: the same MIX-tinted glTF options,
  the same HPR frame with the Flights nose offset). The shared aircraft
  renderer gains a loadModel seam (default Cesium.Model.fromGltfAsync).
- Cesium math → src/geo; the model tint is an Rgba equal to the old
  Cesium.Color; check-import-directions: src/layers/localAdsb/ joins the
  rendered layers.

Tests moved with the code, same meaning:
- index.test: the services fixture creates the layer's Cesium renderer and
  the selection dispatcher; the click harness scripts the picks on a fake
  engine registered for the fake viewer (was a fake ScreenSpaceEventHandler)
  and the app's pick owners (trails, a Flights contact) are registered in
  the pick registry (was an injected picking service).
- cockpitModels.test: the GLB loads are the aircraft renderer's loadModel
  calls (Cesium.Model.fromGltfAsync by default), each asynchronous:false.
- trackingClickGesture.test: the dispatcher's empty click goes to the
  plain owners (filter(plain)).
- renderers.test: local-adsb has an extracted Cesium renderer.
New: the dispatcher's look-under rule beside a plain owner (7 click cases).

W1 replay (flights, military, localAdsb) vs a fresh 7c7422e2 run: counts,
known objects, screen points, tracking poses, requests and every shot's
sha256 identical.
2026-10-17 05:50:30 +04:00
German Khokhlov 941b308c29 test(qa): floor verification logs each contact's probe answer and DEM prior (10b review 6)
On Navara each contact row now carries the surface probe's source and tile
size and the coarse DEM floor (cachedGroundFloor). The buried contacts
(SWA4871 60.5 m and AAL2592 -27 m against a 121-126 m mesh) have no DEM
prior (null: the terrain proxy answers 503 here) and the mesh floor sampler
latches nothing without one, so their sprite heights stay the barometric
fallback; the same rows bury on Cesium when the same contacts are low.
2026-10-17 05:34:41 +04:00
German Khokhlov 10d0e49ddb fix(globe): Navara aircraft models face, size and light as Cesium's (10b review 3)
The near-pose finding (Navara models faint, Cesium ~23 bright aircraft)
came from the side-by-side's frozen page clock: the fleet tick ran once per
pose, so Cesium never handed its aircraft to their models and drew 3×
glyphs, against real models on Navara. With the hand-off driven (the move
ends every 10 frames while a pose loads), both engines draw models, and
the comparison showed three real differences, all fixed:

- Orientation: Navara's glTF loader is three's GLTFLoader, which keeps
  the scene Y-up; the instance turn is now Cesium's whole axis correction
  (Y_UP_TO_Z_UP × Z_UP_TO_X_UP), not Z_UP_TO_X_UP alone. Every fleet model
  drew 90° off its heading.
- Size: minimumPixelSize is taken at Cesium's distance (Camera
  distanceToBoundingSphere: view depth less the radius at the model's own
  scale), in the mover and in ModelHandle.computedScale; the straight-line
  distance drew off-centre models up to 16 % larger.
- Colour: the instance colour tints by the set's blend amount (Cesium's
  colorBlendAmount) and its alpha is the model's opacity (Cesium: a
  model colour's alpha makes it translucent), not a blend weight; a model
  is translucent only while faded. An ambient share of the tinted surface
  (MODEL_AMBIENT 0.5, Cesium's image-based lighting) keeps a tinted
  aircraft near-white at a low sun (median luminance 139 → 190; Cesium 197).

qa-flights-engines (now --layer=flights|military) gates the near pose
with --models=on: computed scale within ±5 % per model (ratio 1.000007),
footprint against the same view with the layer off within ±20 % (1553 vs
1496 px, 104 %), median luminance within ±10 % (189.8 vs 196.7). The probe's
drawn() reports a model's computedScale. Flights models on: 19/19.
2026-10-17 05:23:50 +04:00
German Khokhlov 9dc446bd24 feat(military): render on Navara
Task 11b. src/layers/military/render/navara.js: the shared Navara aircraft
renderer with the military's sprite slot (SPRITE_LAYER_ORDER 'military',
under flights) and jet.glb's nose offset; registered by
registerNavaraLayerRenderers (now a table of lazily imported factories,
flights and military), so the Navara catalog offers military and the main
chunk stays without three/Navara.

- The follow passes between aircraft layers on Navara as on Cesium: the
  follow-handle → tracked-handle map and the "follow being started" handle
  are shared by every aircraft renderer of the page, so a layer whose follow
  another layer took reads the new owner's tracked handle (and its
  gevSelectionOrigin) — before, it read a bare follow handle and reported
  'programmatic'.
- trackById on a selected-but-not-followed contact follows it again (as
  flights since 10b; on Navara any camera command ends a follow).
- qaDrawn (the probe's drawn()) for military, shared with flights
  (src/layers/aircraft/qaDrawn.js).
- ?qaFeed=military:synthetic[:count]: seeded readsb rows (1 000 by
  default, 150 near JFK, 12 on the ground, 8 types), the first 25 hexes
  shared with the synthetic civil flights.
- qa-w1-layers: sections 4–6 read the --layer's module (were 'flights');
  section 5 keeps the queries beside the voice answers (the voice block
  overwrote them, so --compare never compared queries); for military: the
  follow passes military → flights → military, and flights leaves the 25
  shared aircraft to military while it is on (9 975 / 10 000 / 9 975).

qa-w1-layers --layer=military: Cesium 25/25, Navara 26/26, --compare parity
(queries included; the known 2.3 px camera offset at the global pose only).
2026-10-17 05:05:40 +04:00
German Khokhlov 3a0a3b0551 test(military): the colour constants equal the Cesium.Color values they replaced 2026-10-17 04:52:33 +04:00
German Khokhlov 7c0891deda refactor(military): Cesium rendering behind a renderer interface
Task 11a. The military flights logic no longer imports Cesium: it draws
through the shared aircraft renderer (src/layers/aircraft/render/cesium.js)
with the military's parameters in src/layers/military/render/cesium.js
(sprite-order slot 'military', jet.glb's 180° nose offset), registered for
'cesium' in the renderer registry and created at init(viewer, {engine}).
The same pure move as flights in 10a:

- Fleet: the BillboardCollection and its sprite-order slot (enable keeps
  restoreSpriteOrder, as before); sprites stay Billboard handles.
- Models: fleet/tracked/preload glTF loads (asynchronous:false, MIX tint at
  MODEL_COLOR_BLEND_AMOUNT, IR boost as `unlit` → the UNLIT CustomShader),
  the model collection, setPose. The per-class specs keep their own nose
  offset: setPose takes an optional offsetDeg (default the layer's), on
  both aircraft renderers (same arithmetic, test added).
- Tracked: the ENU billboard Entity with its position/colour/rotation
  callbacks (#FFD166, transparent amber once the model owns the visual),
  viewFrom, followed through viewer.trackedEntity + the one exact tracked
  frame; refocusTrackedById through follow.refocus.
- Trail: the entity trail and the floored live head segment.
- View: camera/scene reads, frame number, frustum and horizon tests,
  render requests, frame events.
- Clicks and Escape go through the engine's selection dispatcher (the
  ScreenSpaceEventHandler and keydown listener are gone); the cross-layer
  untrack watches the renderer's follow. Same rules: cockpit clicks are
  inert, the tracked visual/model is a no-op, another layer's pick is not
  empty space, an unowned pick is.
- Colours are engine-neutral Rgba (MIL_ICON_COLOR #FFB800,
  TRACKED_ICON_COLOR #FFD166, AMBER_TRANSPARENT), NearFarScalar →
  nearFarScale; Cesium math → src/geo.
- check-import-directions: src/layers/military/ joins the rendered layers
  (cesium only in render/cesium*.js).
- The military registry and the flights' duplicate suppression are
  untouched.

Source-text tests updated with the moved code, same meaning:
- militaryAwareness.test: military's hasContact guard reads fleet.visible().
- trackingClickGesture.test: the military gesture rules are the
  dispatcher's (selection gesture before select, click classifier before
  the empty-space clear), military answers the empty click with a
  user-origin clear — the same assertion flights already had.
- ownership.test: the services fixture names selection and renderer; the
  app-composed services build the layer's Cesium renderer.
- renderers.test: flights and military have extracted Cesium renderers.

New tests: the military logic over a plain-data renderer and a fake engine
(amber fleet, click-select, #FFD166 tracked glyph, no-op on the tracked
visual, empty-click and Escape release, the keyboard left on destroy); the
plain-data renderer moved to src/testSupport for both aircraft layers;
setPose with a per-model offset.

W1 replay vs 7c7422e2 (the 10a reference run): every layer's counts, known
objects, screen points, tracking pose after 2 s, requests and the
city/global/orbit shots (sha256) identical. qa-military-names (it checks
the installations layer): the same verdict as 7c7422e2 (the zoom-markers
assertion fails on both). Main chunk 2,890,763 B (−2.9 KB).
2026-10-17 04:52:15 +04:00
German Khokhlov e007350645 fix(ui): explicit navigation releases flights when an engine leaves out military awareness
On Navara the military-awareness module has no controls, so its
releaseCameraOwnership was skipped silently and a city pill, voice
fly_to_location or reset ended the follow but kept the aircraft selected.
The trackers it would release are released directly instead, as on
Cesium (selection and follow both cleared).
2026-10-17 03:59:51 +04:00
German Khokhlov 2da7b6d0d9 fix(ui): north-up and tilt keep an engine's own follow (Navara)
Inside a Navara follow the orientation controls frame the follow's target
and retarget its viewFrom instead of moving the camera, and the release
cancels only a running flight (a cancel is a camera command, which ends
the follow). Cesium's trackedEntity path is unchanged. Updated test:
cameraOrientationControls.test.mjs (orientation navigation starts a
flight before expecting its cancel; no flight, no cancel).
2026-10-17 03:48:12 +04:00
German Khokhlov dcfb1219d5 feat(globe): a follow handle names its target
FollowHandle.target() returns the followed point, checked by the contract
on both engines. The contract's model check now waits for ready() before
looking for the model's pixels: the magenta point and sprites could fill
the region before the glTF loaded (qa-globe-contract cesium 73/73,
navara 79/79).
2026-10-17 03:48:11 +04:00
German Khokhlov ba1191b2e5 test(qa): floor verification on both engines
qa-floor-verify --engine=cesium|navara: on an engine without a viewer the
camera pin, the tile readiness, the mesh probes (engine.surface
sampleHeightDetailed, tiles only, excluding the layer's mover sets) and the
sprite census (the probe's drawn()) go through the engine. The Cesium path
is unchanged. Live AUS traffic: Cesium FAIL as on 7c7422e2 (13 contacts at
the -27 m geoid fallback); Navara FAIL with 1 of 15 low contacts under the
mesh (68.6 m against 121.4 m). Task 10b, checklist item 7.
2026-10-17 03:20:44 +04:00
German Khokhlov 132a941e9a test(qa): sprite and enrichment checks on both engines through the probe
The QA probe gains drawn(layerId): what a layer draws per object (glyph
image, scale, shown, screen rotation, alpha, position; its 3D model's
scale, shown, ready), from the flights layer's qaDrawn(), which reads the
renderer's handles, so it is the same on every engine.

qa-sprites-b5 and qa-enrich-ambient take --engine=cesium|navara: without a
viewer they read the layer through the probe and move the engine camera;
the military rows/cluster are skipped where the engine has no military
layer. Cesium verdicts unchanged (sprites 9/9; enrich 9/2, the same E8/E10
failures as on 7c7422e2); Navara: sprites 7/7 (flights rows and 3D cluster),
enrich 9/2 (the same two). Task 10b, checklist item 7.
2026-10-17 03:15:12 +04:00
German Khokhlov 03a0ff3a01 test(qa): flights parity checklist on both engines, side by side
Task 10b:
- qa-w1-layers (flights): 3 focus (the selected object is the focus target,
  at the followed centre ≤ 3 px), 4 tracking (held ≤ 3 px for 5 s; wheeled
  in, ≥ 149 m), 5 the layer's queries (tracked info, contacts, nearby,
  detectable; compared by --compare), 6 share links (the followed object
  restored on the same and on the other engine). Clicks only on points the
  canvas takes (the Navara attribution card covered the old empty point).
  --compare reports the global pose's 2.3 px as the cameras' (Cesium
  projects its own nadir 2.27 px off centre at 15,000 km).
  Cesium 19/19, Navara 20/20, parity.
- qa-flights-engines (new): the same frozen synthetic fleet at the global,
  city, orbit and near (JFK, oblique) poses on both engines, UI hidden;
  side-by-side JPEGs (docs/migration/phase3/flights, models off and on),
  the same objects in the frame, screen points ≤ 2 px, glyph fill colours
  (brightest pixel at the glyph) within 3. 12/12 with models off and on.
- qa-labels: on an engine with flights but not military/satellites, the
  deterministic 12,000 / 5,200 field is all aircraft, so detection is
  gated on Navara too: 14/14 (Cesium 14/14 unchanged).
2026-10-17 02:35:32 +04:00
German Khokhlov b14bf7e013 feat(flights): render on Navara
Task 10b. src/layers/aircraft/render/navara.js implements the aircraft
renderer contract on the engine contract alone (no three, no Cesium):
- Fleet glyphs: banks of engine.movers sprite sets (one per distance scale
  and depth mode the handles ask for; flights: one, 'always', Cesium's
  NearFarScalar), growing by doubling, from a glyph atlas of every
  aircraftIcon() kind at the fleet (64 px) and tracked (192 px) rasters
  (data/aircraftIcons: AIRCRAFT_ICON_KINDS, aircraftIconCells,
  aircraftIconAtlasLayout, drawAircraftIconAtlas). Sprite handles keep the
  Billboard field vocabulary as setters; a frame's writes commit once at
  the end of the frame listener that made them (a microtask outside one).
- Models: banks of model mover sets per glTF and look; handles report
  ready from MoverSet.ready() and computedScale from minimumPixelSize kept
  on the CPU (radiusM in the spec, the class's nativeRadiusM); poses are
  Cesium's HPR frame (modelMatrix) turned by Navara's glTF axes
  (Z_UP_TO_X_UP: its loader is already Z-up; chosen side by side with
  Cesium among six candidates).
- Tracked glyph: a sprite whose position/colour/rotation callbacks are read
  every frame in preRender; follow = engine.camera.follow with the tracked
  readout's card as its label (gevFollowHandle); current() maps the follow
  back to the tracked handle, also while follow() raises followChanged.
- Trail and live head: engine.lines (alpha 0.85, depth-fail 0.4, geodesic)
  in the gev-trail pick namespace.
- View: camera/scene adapters over engine.camera.pose() (registered with
  the engine so shared helpers project through it), frustum and horizon
  tests, frame number, engine frame events.
flights/render/navara.js registers it for 'navara' from the Navara mode's
import(); the Navara catalog now offers Live Flights.

Also: tracking a contact that is selected but no longer followed follows it
again (a camera command ends a Navara follow; on Cesium a search flight
releases trackedEntity): trackById/select re-follows instead of only
re-publishing. Behaviour change on Cesium only in that case.

Tests: the renderer on the fake engine (contract, atlas, banks, per-frame
commits, growth, tracked callbacks, follow label, model pose, trails) and
the flights logic over it (fleet, click-select, follow, re-follow after a
camera command, release, no set left behind).
2026-10-17 02:15:04 +04:00
German Khokhlov 20e9d4a0fa fix(globe): Navara sprite atlases are sampled as Cesium samples billboards
Linear, without mipmaps: Cesium's billboard atlas has none, and a mipmapped
atlas greyed small glyphs Cesium draws at full colour (a 5 px aircraft at
15,000 km: 239 against Cesium's 255; now 255 on both). Task 10b colour
parity.
2026-10-17 02:15:04 +04:00
German Khokhlov 1dd3220812 fix(voice): Navara refusals reach speech, analyst queries and stop_tracking
Review of 10a (low):
- The spoken answer to set_layer_visibility on a layer this engine lacks is
  the refusal's own sentence (why, and the switch to ?engine=cesium), not
  only "Couldn't turn on".
- analyst_query naming layers the catalog left out (no viewer) refuses with
  UNAVAILABLE_ON_ENGINE and the layer ids instead of answering zero.
- stop_tracking skips layers the engine does not have, so with nothing
  tracked it is a no-op success on Navara (it reported the absent military
  and satellites layers as failures).
Tests for each (the old code fails the last two); Cesium unchanged.
2026-10-17 01:18:12 +04:00
German Khokhlov 443c581280 refactor(data): focus projects through the engine; readout follows a tracked visual's follow
Task 10b foundation.
- focusDeemphasis projects through the engine registered for the scene it
  is given (engineFromViewer(scene).projector), as iconOrientation already
  does, else Cesium's SceneTransforms. On Cesium the engine projector is
  SceneTransforms, so nothing changes there; on Navara the aircraft
  renderer's scene adapter is registered with the engine. Test with a fake
  engine.
- refreshTrackedReadout accepts a layer's tracked visual that names its
  follow handle (gevFollowHandle): engines without tracked entities
  republish the card through it. Test.
2026-10-17 01:16:59 +04:00
German Khokhlov 4dfcba8989 feat(globe): mover sets report ready(); Navara scales by distance as Cesium
Task 10b foundation.
- MoverSet.ready(): whether what the set draws has loaded (Navara: the
  model set's glTF, a URL atlas; Cesium: every instance's Model, the cropped
  atlas; parts without it draw at once). The aircraft renderer keeps the 2D
  glyph until a model set is ready, as the Cesium path waits on Model.ready.
  Contract suite: a drawn model set is ready() (both engines); unit test of
  the neutral store.
- Navara sprites and points interpolate scaleByDistance as Cesium's
  NearFarScalar (over the squared camera distance, eased by ^0.2) instead
  of linearly in distance, so aircraft glyphs have Cesium's size at every
  range; the CPU pick radius follows (distanceScale, tested equal to
  focusDeemphasis' port of czm_nearFarScalar). The contract text says so.
2026-10-17 01:16:59 +04:00
German Khokhlov d81ebf1268 build: the panel build leaves out the initial-chunk guard
The MCP panel build inlines every dynamic import into its one script, the
Navara mode included, so the guard that keeps Navara out of the app's
initial chunks failed it (npm run build:panel was red since d483e589). The
panel config filters the guard out, as phase 4 does; a test pins it.
build:panel is green and joins the gates. Phase-4 merge review item 2.
2026-10-17 01:09:58 +04:00
German Khokhlov 2ce1c55f69 build: the QA probe stays out of production builds
src/app/qaProbe.js was statically imported by standalone/application.js
and shipped in the main chunk (+639 B gzip). It now loads with import()
behind import.meta.env.DEV at the import site: production builds drop it
(main chunk 2,890,982 -> 2,889,432 B), the dev server still installs
window.__godsEyeView.layers (checked headless). Phase-4 merge review item 1.
2026-10-17 01:09:58 +04:00
German Khokhlov 7f13ef4a87 refactor(flights): Cesium rendering behind a renderer interface
Task 10a. The flights logic no longer imports Cesium: every Cesium object it
built moved one for one into the shared aircraft renderer
(src/layers/aircraft/render/cesium.js; contract in render/contract.js), with
the flights' parameters in src/layers/flights/render/cesium.js, registered
for 'cesium' in the renderer registry and created at init(viewer, {engine}).

- Fleet: BillboardCollection + sprite-order slot; sprites stay Billboard
  handles (field vocabulary documented for the Navara renderer).
- Models: fleet/tracked/preload glTF loads (asynchronous:false, MIX tint, IR
  boost as `unlit` → the UNLIT CustomShader), the model collection, setPose
  (same HPR frame with airplane.glb's 180° offset).
- Tracked: the ENU billboard Entity with its position/colour/rotation
  callbacks, viewFrom, followed through viewer.trackedEntity + the one exact
  tracked frame (kept as is; engine.camera.follow on Cesium would also end
  the follow on any camera command, a behaviour change).
- Trail: the entity trail (data/trailRenderer, unchanged: its Primitive
  predecessor failed below the mesh) and the live head segment.
- View: camera/scene reads, frame number, frustum and horizon tests,
  projection, render requests, frame events, evidence render-loop hold.
- Clicks and Escape go through the engine's selection dispatcher (the
  ScreenSpaceEventHandler and keydown listener are gone); the cross-layer
  untrack watches the renderer's follow.
- Colours are engine-neutral Rgba (src/layers/shared/color.js), bit-equal to
  the Cesium.Color constants they replace; NearFarScalar → nearFarScale.
- Cesium math in the logic → src/geo.
- check-import-directions: cesium only in render/cesium*.js and three/Navara
  only in render/navara*.js for flights, aircraft and shared; a layer's
  Navara renderer only through navaraMode's import().

Source-text tests updated with the moved code, same meaning:
- readLayerSource reads a layer's render/*.js and, for the aircraft layers,
  aircraft/render/*.js (GLB-load, tracked-entity, CallbackProperty checks).
- modelScale.test: _normalBillboardScaleByDistance reads nearFarScale(...).
- spriteOrder.test: flights' enable calls fleet.restoreOrderOnEnable(); the
  renderer wires it to restoreSpriteOrderOnEnable('flights', viewer).
- trackingClickGesture.test: the flights gesture rules are the dispatcher's
  (selection gesture before select, click classifier before the empty-space
  clear); flights answers the empty click with a user-origin clear.
- militaryAwareness.test: flights' hasContact guard reads fleet.visible().
- ownership.test: the services fixture names selection and renderer.

New tests: color, the Cesium aircraft renderer (contract, collections, pose,
loads, frustum/horizon, tracked entity, follow, trail head, adopted
collections), the flights logic over a plain-data renderer and a fake engine
(fleet, click-select, no-op on the tracked visual, empty-click release), the
boundary rule.

W1 replay vs 7c7422e2: counts, known objects, screen points (0 px), tracking
poses (0 m), requests and shots (same sha256) identical. Main chunk
2,890,982 B (+6.2 KB, +0.21 %: the renderer indirection).
2026-10-17 01:04:25 +04:00
German Khokhlov 8e702f0c3b feat(data): the selection dispatcher treats an unowned pick as empty space
Task 10a moves flights onto the dispatcher; its old handler deselected on a
pick nobody owns (an unclaimed entity), and only another layer's pick was not
empty space. The dispatcher now does the same (after drill-through owners had
their chance), and selectionDispatcherFor(engine) gives the application one
dispatcher per engine. Tests for both.
2026-10-17 00:20:18 +04:00
German Khokhlov 9f1a0611fa fix(ui): the "kept in the link" notice is readable on Navara
On Navara the engine's persistent notice came up right after the share
restore's NOT ON THIS ENGINE notice and replaced it at once, so the names
of the linked layers this engine lacks were never seen (and a later finite
notice would in turn have wiped the engine notice for good).

- ShellFeedback keeps a persistent notice that a finite one covers and
  brings it back when the finite one ends; a persistent notice that arrives
  under a live finite one waits for it.
- Notices take a dwellMs; "Kept in the link: …" stays 15 s.
- scripts/qa-engine-notice.mjs (headless): a Cesium share link with a layer
  Navara lacks, opened with ?engine=navara: notice shown with the layer
  name, still up 14 s later, then the engine notice is back. 4/4 (before
  the fix the notice never appeared).

T8-T9 review item 2.
2026-10-16 23:27:47 +04:00
German Khokhlov c493a8303b build: the QA fixture sources stay out of production builds
The ?qaFeed import is now gated by import.meta.env.DEV at its import site,
so Rollup drops the qaSources chunk (4.7 KB) from production builds instead
of shipping it unreachable. Dev behaviour is unchanged. (T8-T9 review item 3)
2026-10-16 23:18:33 +04:00
German Khokhlov bf46eb6d97 fix(voice): Navara refusals name the feature and the way back to Cesium
Review of T8-T9:
- The UNAVAILABLE_ON_ENGINE refusal now names the feature (UNAVAILABLE_FEATURES)
  and suggests the Cesium engine (?engine=cesium): "The cockpit is not
  available on the Navara engine yet. Switch to the Cesium engine
  (?engine=cesium) to use it." New fields: feature, suggestion.
- set_visual_style and set_post_processing refuse without a viewer
  (CESIUM_LOOK_ACTIONS): Navara has no visual styles, so they used to answer
  ok, change nothing and record a style get_current_view_state then reported.
- set_layer_visibility on a known layer the engine's catalog left out refuses
  with the same structured result instead of throwing "Unknown data layer".
  A name no engine knows still throws.
- Removed the dead summarizeEntity and the two helpers only it used.

Cesium (viewer present) is unchanged; tests cover each refusal and that the
style still applies with a viewer.
2026-10-16 23:17:36 +04:00
German Khokhlov 907cb2092d fix(globe): Navara redraws after a map change while its tiles arrive
Phase-4 QA: after a map-stack switch at the whole-globe view Navara
sometimes showed the new map only seconds later. Navara 0.2.1 reports idle
and stops drawing before a raster tile it fetched arrives, so the last
frame stays stale until something else asks for one. Every engine.maps
change (base imagery, terrain, photoreal tiles, their release) now asks for
a frame and keeps asking every 250 ms for 6 s, then the engine idles again.

scripts/qa-map-switch.mjs switches the stack (photoreal / Esri / OSM) at
18 000 km on each engine, waits for the tiles, hashes the picture, forces
frames and hashes again: a change means a stale picture. Before: Navara 3/8
switches drew the new picture by themselves; after: Navara 8/8, Cesium 8/8.
2026-10-16 23:00:15 +04:00
German Khokhlov ba039f7974 fix(app): the Navara notice no longer calls voice unavailable
Voice runs on Navara since 18b5475b (the viewer-only actions refuse with
UNAVAILABLE_ON_ENGINE); the notice still listed it as unavailable.
2026-10-16 22:53:35 +04:00
German Khokhlov 18b5475b8a feat(app): voice on the Navara engine
createViewerlessTools creates the voice commands with viewer: null on the
engine (tools.js passes it the voice options and place search), so
window.__gevVoiceCommands exists in ?engine=navara: the W1 tools drive the
engine and the viewer-only tools answer UNAVAILABLE_ON_ENGINE. The Cesium
path hands its engine to initGevVoiceCommands explicitly.

viewerlessTools.test.mjs: the Navara tools now return voice commands
(created without DOM through injected control and session stand-ins),
whose runner answers get_current_view_state on the engine and refuses
control_scene; the debug handle carries them and teardown removes them.
2026-10-16 22:52:21 +04:00
German Khokhlov bc281ab246 refactor(voice): W1 actions on the engine
The voice runner drives the globe engine (an `engine` option, by default
the one registered for the viewer) instead of the Cesium viewer:
track_entity, stop_tracking (camera.following().stop()), frame_overhead
(camera.flyToSphere with the pose heading), fly_to_location, zoom_to_globe
(follow released through camera.following()), adjust_camera_zoom,
next_iss_pass / next_satellite_pass, select_nearest_aircraft and the
analyst view context, get_current_view_state, get_entity_context
(projector.worldToWindow), the view target (picker.pickPosition, then
camera.pickEllipsoid, then picker.pickGlobe) and its moveEnd prewarm, and
the viewport samples. getBasemapLabelContext takes the engine or a viewer
with a registered engine (the HUD).

Without a viewer the tools that still need one (VIEWER_ONLY_ACTIONS:
control_scene, annotate_map, clear_annotations, fly_route, move_camera,
control_cockpit, set_context_mode, control_radio, control_cctv) answer a
structured refusal (code UNAVAILABLE_ON_ENGINE) instead of throwing.

Point-and-ask picks with engine.picker and keys the camera pose from
engine.camera.pose(); the viewport capture reads engine.host.canvas and
waits for the engine's postRender (both engines keep their drawing
buffer: Cesium's contextOptions, Navara's own WebGL2 canvas, S4).

Tests: gevActionsEngine.test.mjs runs each moved action on the fake
engine. gevActions.test.mjs keeps its expectations; its runner helper now
registers an engine for the test viewer (the Cesium engine over it, or a
fake engine at its positionCartographic) and the two harness cameras gain
roll and frustum for camera.pose().
2026-10-16 22:52:21 +04:00
German Khokhlov ec7dcc944e test(qa): an engine-neutral layer probe
- src/app/qaProbe.js → window.__godsEyeView.layers: list() (with
  `available`), unavailable(), available(id), enable/disable(id) (a user
  toggle through the layer manager), objects(id) → [{ id, label, position,
  screen }], screenOf(id, objectId), selected(id), following(), movers(),
  framesDrawn() - all through the engine and the catalog's availability, the
  same on Cesium and Navara. Installed after the tools.
- src/app/layers/qaSources.js: `?qaFeed=flights:synthetic:10000,
  vessels:fixture` (dev server only, allowQaRegistration) replaces those
  sources with seeded synthetic answers served through the live sources'
  own parsers (same snapshot shape; loaded with import() only then). The
  pure generators moved from src/tooling/w1Feeds.js to src/bench/w1Feeds.js
  (the browser graph may not reach src/tooling); the W1 recordings
  re-export them.
- scripts/qa-w1-layers.mjs: the parity checklist's frame, --layer
  --engine=cesium|navara with the fixture feeds, JSON per run and
  --compare (objects on screen per pose, 5 known objects within 2 px).
  Filled: 1 view, 8 idle, 9 errors/no leftover mover sets after 10
  on/off; 2-7 report TODO for the layer tasks.
- qa-perf, qa-labels, qa-overlay-baseline and qa-label-readability take
  --engine= and use the engine (camera, frames, render requests, tiles) and
  the probe (layers, following, movers) instead of the viewer; checks whose
  layers an engine lacks are SKIPPED with the reason (Navara today), as are
  qa-perf's animated-style checks there (Cesium post stages).

Verdicts, Cesium, before → after: qa-perf 24/24 → 24/24, qa-labels 14/14 →
14/14, qa-overlay-baseline (datacenters, firms, detection-50) 2 measured +
1 skipped → the same. Navara: qa-perf 14 passed, 3 skipped; qa-labels 3/3,
1 skipped; qa-overlay-baseline 3 skipped; qa-w1-layers flights: Cesium 5/5
(10 000 synthetic aircraft), Navara skipped (no flights renderer yet).
2026-10-16 22:52:18 +04:00
German Khokhlov 834d34296f test(overlays): an allocation workload through the Navara projector
Review of Task 7: every allocation workload ran the Cesium engine on a mock
viewer with identity matrices and the horizon test off, so the per-label
horizon test and the Navara projector were never in the gate. The new
`navara-projector` profile gives the host the Navara engine's projector
(cameraMath worldToWindow, viewProjection, horizonVisible, as
createNavaraEngine wires it) over a real WGS84 camera 2 000 km up, with 250
moving labels on a lon/lat grid under it and the horizon test on.

Node 24.14 (the calibrated runtime; CI's 24.14.0 matrix leg runs the gate
with GEV_REQUIRE_ALLOCATION_GATE=1): 10 905 B/frame median (generic 250
labels: 9 681), 43.6 B per candidate; budget 13 000 B/frame as the generic
saturated workload. All 14 workloads pass on 24.14.
2026-10-16 22:26:04 +04:00
German Khokhlov d6fe0f3805 fix(globe): the floor takes no tile coarser than 1 km
Review of Task 6: from 15 km with cold tiles Navara answers from 3.5-4 km
tiles (13.9 km at AUS), below the 4 km floor limit, 6-16 m off; the mesh
floor samples up to 25 km, so those would latch. MAX_FLOOR_TILE_SIZE_M is
now 1 km: from 800 m the answering tiles are 112-314 m. The 1.8 km tile
that read the Austin ground right under a 600 m camera is now a miss too,
which only leaves the DEM prior in place.

qa-surface-heights runs the 15 km pass first in a fresh page (cold), then
the 800 m pass in another, and keeps every answer the floor rule would
take while the tiles stream. Measured: 800 m 19/20 within 2 m of Cesium;
15 km cold 7/20 (the plan's gate of 15 assumed warm tiles - lod 'finest'
can only use what Navara has loaded, and at 15 km it loads 3.5-14 km
tiles), and the floor rule takes 0/20 of those; while streaming it took
answers more than 2 m off at 3 places: the Empire State spire (39 m, also
the settled answer) and two street points 2.2-2.3 m off.
2026-10-16 22:19:57 +04:00
German Khokhlov f0ce1b04c7 fix(globe): the Navara follow turns and dollies as Cesium's tracked camera
Review of Tasks 6-7: a 240 px drag right turned Navara's follow by -67.5°
where Cesium turns +119.5°, and a right drag did nothing (Cesium dollies,
1.95 Mm → 0.91 Mm). followGestures.js now follows Cesium's
ScreenSpaceCameraController for a tracked camera:
- left drag (rotate3D): heading += 1.77 × dx / width × 2π (clockwise for a
  drag right; 1.77 is Cesium's maximum rotate rate, which a tracked camera
  beyond a few metres always reaches) and the camera rises by
  1.77 × -dy / height × π; each move counts at most a tenth of the canvas
  (maximumMovementRatio);
- right drag (zoom3D): follow.js dollyOffset moves 5 × dy / height of the
  distance above the floor (closing) or of the range (opening), never
  inside it; no context menu while following.

The contract follow check turns the camera with a 240 px drag right and
asserts the heading turn against Cesium's rate (±15 %; the harness holds the
pointer before releasing it so Cesium adds no inertia), and dollies with a
right drag down (range below 80 %, never under 149 m). Cesium 73/73, Navara
79/79.
2026-10-16 22:14:02 +04:00
German Khokhlov b0cbed081b feat(app): layer catalog per engine
- src/app/layers/renderers.js: the layer renderer registry
  (registerLayerRenderers(engineKind, { [layerId]: factory }),
  rendererFor(layerId, engine), layerAvailableOn(layerId, engineKind)).
  Every application layer is registered for Cesium as CESIUM_BUILTIN (it
  still draws itself until its render/cesium.js); Navara factories come with
  the layer tasks. On it, the stable availability API phase 4 injects into
  its layer list: createLayerAvailability(engineKind) →
  { engine, isAvailable(id), available(), unavailable() } with { id, name }
  rows (src/app/layers/directory.js holds the ids and names in catalog
  order; a test keeps it equal to the constructed catalog).
- createApplicationCatalog({ engineKind, availability }) constructs only the
  layers the engine can show, in the same order (shared instances made
  once, on demand); the catalog carries `availability` and the others'
  metadata (`unavailableMetadata`). Cesium builds the same 30 layers in the
  same order as before; Navara builds none yet (no Navara renderers) - the
  standalone app now asks the catalog for it instead of making an empty one.
- catalogControlServices: a role whose layer the engine leaves out gets an
  empty stand-in (absentLayerControlServices folded in); any other missing
  layer is still a wiring error.
- LayerLifecycle(viewer, { engine }): every module call (init, enable,
  update, disable, destroy) gets { signal, engine } as its second argument;
  viewer is null on Navara.
- Links: a link that turns on layers this engine leaves out keeps them in
  the durable state (re-saved links carry them; back on Cesium they turn
  on) and activates nothing; a notice names them ("NOT ON THIS ENGINE · Kept
  in the link: …"). The Navara notice lists the engine's data layers from
  the registry.
- The dev launcher's CCTV wiring check matches the new catalog form.
2026-10-16 22:07:55 +04:00
German Khokhlov e1be2eac19 feat(app): detection and readout on Navara
The shell creates detection and the tracked readout on the engine, so the
Navara mode has them too (before: not created without a viewer). Detection
reads the camera height from the engine. The tracked readout follows
engine.camera.following(): a follow's plain-data label
(FollowOptions.label: { id, model, position }, the readout card's model
and anchor) or, on Cesium, the followed Entity and its gevLabelModel as
before (Cesium layers keep publishing the same). refreshTrackedReadout
takes a follow handle too. A bare viewer without a registered engine
(tests) is still read for its trackedEntity.

In the app on Navara: "[Detection] Initialized", "[TrackedReadout]
Initialized", and a follow with a label paints its card.
2026-10-16 21:14:59 +04:00
German Khokhlov 7d5a484796 refactor(overlays): layer labels through the engine
The world overlay host already projects through engine.projector and tests
the horizon with projector.horizonVisible; the last Cesium value on its
path, the snapshot of an entry's cullPosition, is now a geo Cartesian3.

Navara's horizonVisible is Cesium's EllipsoidalOccluder.isPointVisible:
tested against Cesium's own on 8 000 eye/point pairs (600 m to 20 000 km,
points under, on and over the ellipsoid; no disagreement) and at the limb
(a label 40 m under the ellipsoid just past the horizon is hidden, with its
cullPosition lifted 100 m it is shown). It ran per label per frame with
three new vectors each call; it now uses scratch vectors. Micro-measure
(500 labels per frame, no scavenge during the run): 7 941 B per frame
before, 51 after.

The W1 sources that projected with Cesium's SceneTransforms themselves -
the icon orientation's exact projection and the launch replay overlay - go
through the engine's projector when their scene or viewer has a registered
engine. Satellites, vessel labels and aircraft recession project nothing
themselves (the host does).

scripts/qa-overlay-engines.mjs publishes the same labels on both engines at
a city, an orbital and a limb pose and compares the painted rectangles:
worst 0 / 2 / 0 px; on both, the far-side label is not painted, the lifted
limb label is and the buried one is not.
2026-10-16 21:14:52 +04:00
German Khokhlov 311722e6de test(globe): the mover shader test expects decoded sRGB colours
68ff4e1c decodes instance colours in the fragment shader; its shader
test still matched the undecoded line.
2026-10-16 21:04:22 +04:00
German Khokhlov d7193e3b9e fix(globe): the CPU pick radius follows scaleByDistance
Review of Task 4: the CPU picks (Navara hover, drill, the stale-answer
check) took a sprite or point at its full sizePx however far it was, so a
set shrunk with scaleByDistance was picked well off its drawn edge. The
radius now scales by the set's scaleByDistance at the instance's camera
distance, linear between near and far as the Navara shader draws it.
2026-10-16 21:00:01 +04:00
German Khokhlov a4e753d550 fix(globe): Cesium picks fall through filtered results; no picks behind the horizon
Review of Task 4:
- a Cesium pick whose top result is filtered (a pickable: false set, the
  follow camera's proxy entity) took nothing; it now drills and answers the
  first result under it with an id (an object), else the first kept one
  (map tiles);
- billboards and points drawn without a depth test reach Cesium's GPU pick
  through the globe: mover instances behind the horizon (projector
  horizonVisible on the instance position) are left out of picks and
  drills, as Navara's CPU drill already did;
- the follow proxy entity (1 px point, cesium/follow.js) carries
  gevFollowProxy and is never a pick (review item 6).
2026-10-16 20:59:10 +04:00
German Khokhlov 4e195dac6b fix(globe): the Navara follow orbits on drag and zooms to the floor
Review of Task 5: while following on Navara a left drag panned the globe
about the ground point under the cursor (range 2,154 m → 54,136 m) and the
wheel zoomed toward that point (462 m after 40 notches, Cesium stops at
the 150 m floor). While a follow runs, Navara's spin, zoom and tilt are
now suspended (input.suspendCameraInputs, which gives the app's own
setCameraInputs choice back after the release) and followGestures.js turns
a drag into heading and pitch of the ENU offset (a canvas width is one
turn, its height half a turn; elevation kept within 2°-89°) and a wheel
notch into 20 % of its range, never inside the minimum range, as Cesium's
EntityView orbits the target. The offset is no longer read back from a
camera the follow alone moves.

Also (review item 7): a follow requests a render only when the target, the
offset or a gesture changed, so a still target lets Navara idle.

The contract follow check drags (range within ±5 %, target within 3 px)
and turns the wheel 40 notches (never below 149 m, ends within 5 % of
150 m) on both engines with real input (qa-globe-contract's pointer gains
drag and wheel); after stop it waits for Cesium's gesture inertia to end
before checking that the camera is free. Contract: Cesium 73/73, Navara
79/79.
2026-10-16 20:57:10 +04:00
German Khokhlov 68ff4e1c25 fix(globe): Navara overlay colours are sRGB
Review of Tasks 4-5: the overlay pass encodes linear to sRGB, and the
instance and line colours went in as if linear: grey 0.5 drew 187 (Cesium
128) and [1, 0.6, 0.2] drew 255/203/124 (Cesium 255/153/52). The sprite,
point and line shaders now decode them (sRGBTransferEOTF), as the sRGB
atlas texture already was.

A contract check draws mid-tone sprites, points and lines (grey 0.5 and
[1, 0.6, 0.2]) and reads their centres: within 3 per channel on every
engine. Navara 79/79.
2026-10-16 20:51:36 +04:00
German Khokhlov 645f042450 feat(globe): surface height quality
engine.surface.sampleHeightDetailed(place, { exclude, lod }) answers
{ height, source: 'tiles' | 'terrain', tileSizeM? } (src/globe/surfaceDetail.js).
On Navara tileSizeM is the extent of the tile that answered and
lod 'finest' takes the finest loaded tile, drawn or not; Cesium answers
tiles without a size, else the globe's terrain. acceptedSurfaceHeight()
turns an answer into a height or a miss: tiles larger than
MAX_FLOOR_TILE_SIZE_M (4 km) are too coarse, terrain is not the mesh.

The mesh floor sampler and the ground snap are engine-only now (a viewer
or scene reaches its engine through engineFromViewer, which also registers
the scene) and treat a coarse answer as a miss, as they treat undefined.
Measured on Navara at Austin: while the tiles stream, a 27.9 km tile reads
143.2 m where the ground is 122.5 m (a 1.8 km tile).

`exclude` takes the engine's own mover sets (MoverSet.id): Cesium hides a
set's primitive for the synchronous sample (Cesium matches excluded objects
per billboard, not per collection); Navara's ray only meets tiles.

"sampleHeight undefined in the app on Navara" did not reproduce: the
startup Austin sample is 121.9 m (tile 889 m) on Navara and 122.4 m on
Cesium; one ModelMesh class is patched.

S2 QA (scripts/qa-surface-heights.mjs, 20 places at JFK, SFO, AUS, roofs
and streets): Navara within 2 m of Cesium at 19/20 from 800 m and 18/20
from 15 km with lod 'finest' (gates 18 and 15); offsets per region
-0.21 / +0.4 / +0.1 m; the Empire State roof differs by 39 m (spire
geometry). Navara tile sizes at 800 m: 112-314 m.
2026-10-16 20:51:29 +04:00
German Khokhlov f640890cf6 feat(globe): follow camera on both engines
camera.follow({ position, viewFrom, minRange = 150, native? }) keeps a
moving target centred at an east-north-up offset the user may turn and
zoom; camera.following() and camera.followChanged report it; stop() frees
the camera and a camera command (setView, flyTo, flyToSphere, lookAt,
cancelFlight) ends it.

Probe ruling: Navara 0.2.1's cameraFollow holds the target but ignores the
user's drag and wheel, the aircraft sits ~13 px off centre with 7.6 px of
spread, and calling it without an offset each frame gives a NaN camera.
So Navara (and the fake engine) follow with src/globe/follow.js: each
frame, before Navara draws, the offset is re-read from where the user put
the camera relative to the previous target, kept outside the minimum
range and carried to the new target.

Cesium follows with viewer.trackedEntity: the layer's own Entity
(`native`) or a proxy Entity at position() with a 1 px transparent point,
since EntityView moves the camera only for an entity with a bounding
sphere. applyTrackedCameraFrame and the zoom limits moved to
src/globe/cesium/follow.js; src/data/trackedCamera.js keeps the pure parts
(the range clamp now lives in src/globe/follow.js). following() also
reports an entity the app tracked itself. Camera sensitivity claims made
on a viewer's Cesium camera now share the engine camera's ledger, and the
orientation controls and the world jump read the follow from the engine.

Contract (both engines): a target flying at 250 m/s stays within 3 px of
the centre for 2 s, zoomIn through it stops at 150 m, stop() and setView
end the follow. Cesium 71/71, Navara 77/77; shell 23/25/4.
2026-10-16 20:26:43 +04:00
German Khokhlov a89719aba9 build: package boundaries own the engine modules added since Task 1
check:boundaries failed from 76851ee9 on: vite-build did not own the
initial-chunk guard, and the layer and application packages did not own
src/time/clock.js and the movers, lines, picks and Navara overlay modules
they now reach.
2026-10-16 20:06:12 +04:00
German Khokhlov 39d522c9a9 perf(globe): less churn in movers and lines
Review of Tasks 2–3:
- the mover store's instance checks no longer build arrays or closures
  per set() (orientation and colour checked field by field), and dirty()
  returns one reused range object;
- Cesium models get their matrix and colour through scratch objects
  (Model.modelMatrix and Model.color copy what they are given) instead
  of a new Matrix4 and Color per placement;
- a destroyed MoverSet releases its store (count 0, empty instance
  arrays, no pick ids) so a set object a layer still holds keeps no
  memory, and the Cesium parts drop their billboard, point and model
  lists;
- (with the overlay commit) the Navara line viewport is updated in
  place.

Node micro-measure, 10 000 instances set + packed per frame: 0.27-0.29
ms, about 1.7 KB per frame (0.17 B per instance), as before. Contract:
Cesium 70/70, Navara 76/76.
2026-10-16 19:33:46 +04:00
German Khokhlov d483e5897e build: the initial-chunk guard covers Navara's whole dependency closure
Review of Task 0:
- build/navaraDependencies.js reads the dependency closure of
  @navaramap/three and @navaramap/three-default-plugin from node_modules
  (dependencies and peers) and drops what the app's other bundled
  dependencies reach too (Cesium is served as a script, so draco3d, which
  only Cesium shares, stays engine-only), plus three and postprocessing:
  88 packages today (the @navaramap/* packages, @takram/*, @react-three/
  fiber, @sparkjsdev/spark, astronomy-engine, d3*, draco3d, n8ao,
  three-stdlib, …). The browser build's initialChunkGuard fails on a
  module from any of them in an initial chunk. Tested on a fixture tree.
- check-main-chunk's budget measures the entry the page loads (the same
  chunk the composition check reads), falling back to phase 4's largest
  index-*.js only for a dist without a page.

Build clean: no Navara-only module in the initial chunks; main chunk
2,869,565 bytes (gzip 889,041).
2026-10-16 19:30:23 +04:00
German Khokhlov ae52695b15 test(qa): seeded synthetic feeds for the W1 baseline; requests compared
Review of Task 0: the recorded live feeds left vessels (no AIS key here)
and local ADS-B (no receiver) empty, and flights held one adsb.lol
viewport answer. src/tooling/w1Feeds.js builds seeded answers relative to
one frozen instant (2026-10-16T12:00Z): a global /api/flights OpenSky
snapshot (8 000 aircraft, 2 000 within 60 km of JFK), /api/vessels with
12 000 AIS rows worldwide and the rows within the city area (the area
answer first, the global one last as the replay's fallback), and
/api/local-receivers/aircraft with 40 aircraft near JFK on 1090 MHz and
978 MHz UAT. Tests read them back through the app's own parsers.
qa-w1-baseline --synthesize=DIR writes them beside the recorded
military, satellites and launches; a replay now logs every feed request
and how it was answered (exact / as another key / none) into
summary.json, and --compare diffs those too.

Replayed on d50eaa28 and HEAD: flights 8 000, military 154, local ADS-B
40, vessels 12 000, satellites 824, launches 24; the runs agree (counts,
known objects, screen points, tracking poses, requests). The Cesium
reference is re-recorded this way before Tasks 10a, 12a and 13a.
2026-10-16 19:28:12 +04:00
German Khokhlov 6436f580d9 test(globe): mover coverage on both engines; pickable: false honoured
Review of Tasks 2 and 4:
- A contract check draws, on both engines: an atlas cell switch, a
  rotation of π (a half-white cell flips from above to below the
  centre), scale 2 (about four times the area), scaleByDistance past
  `far` (about a quarter), setCount shrinking the set, depth modes for
  sprites 30 km under the ground ('always' and a disableWithinM past the
  camera draw), a point with its dark outline, and a glTF model
  (airplane.glb, minimumPixelSize 80, colorBlend replace) in magenta.
  'normal' and a 10 m disableWithinM hide the buried sprite on Navara;
  Cesium's billboards are not occluded by the app's photoreal tiles
  (globe hidden; measured), so those two are asserted on Navara only.
- pickable: false: Cesium's GPU pick sees every primitive, so the
  picker drops picks (and drill entries) of a set created with
  pickable: false; Navara only registers pickable sets. The contract's
  click check now clicks a pickable: false sprite on both engines.
- Navara resolves a GPU answer's batch id to the pick id when Navara
  answers (the pick's own frame), not when the engine's click consumes
  it: a set that reuses the instance in between still yields the
  instance that was under the pointer.

Contract: Cesium 70/70, Navara 76/76.
2026-10-16 19:20:47 +04:00
German Khokhlov 0957d9712b test(globe): pixel dashes at DPR 1 and 2
Review of Task 3 (DPI-independent dashes; the shader change landed with
the overlay commit): qa-globe-contract --dpr=2 runs the suite on a
high-DPI page, and a new check measures a 16/16 px dashed line along its
middle row in CSS pixels: the dashes must be regular, and on Navara
16/16 within 2 px. The harness prints what each engine draws.

Measured: Navara 16/16 (period 32) at DPR 1 and 2. Cesium draws
16 on / 16 off (PolylineDash dashLength 32, pattern 0xff00, uniforms
checked on the primitive) as 14 on / 2 off with a 16 px period at both
ratios — a Cesium-side difference to look at before a layer relies on
pixel dashes (none does yet). At DPR 2 the Navara harness's
stars-at-midnight check counts 4 star pixels (its detector is tuned for
DPR 1); every suite check passes at both ratios.
2026-10-16 19:14:52 +04:00
German Khokhlov 93866c42fc fix(globe): Navara lines are picked within 8 px of their segments
Review of Task 4: the CPU line pick (shared with the sprite drill, hover
and the synchronous pick) took half the drawn width + 3 px, so a 1-2 px
trail needed a 3.5-4 px aim. Now within 8 px of the projected segments,
or half the width + 3 px for wider lines. Tested at 7.5 and 8.5 px from
a 1 px line; the contract suite's line click passes on both engines.
2026-10-16 19:05:20 +04:00
German Khokhlov e804351205 fix(globe): Navara sprites, points and lines drawn after tone mapping
Review of Tasks 2–3: on Navara the overlays went through the look's
tone mapping and grading (magenta came out as 247/40/228, green as
147/228/89 in the cinematic look), where Cesium draws billboards and
polylines in the colours asked for.

src/globe/navara/overlay.js: a public Navara EffectDesc (gevOverlay)
inserted after toneMapping and before the gev sharpening, SMAA/FXAA and
final, whose pass renders one three scene over the tone-mapped input
buffer (in Navara's own Pass wrapper: Navara inserts a bare pass only
when it is an instance of its own postprocessing copy); it costs nothing
while the scene is empty. The chain has no depth buffer, so each overlay
material tests the scene depth itself (overlayDepth.js): its log depth
against Navara's MRT depth (RGBA packed), modes always / normal /
depth-fail, disableWithinM as a per-vertex escape. Sprites and points
(moverMesh.js) and line ribbons (lineMesh.js) live in that scene with
their per-frame work as overlay hooks; models stay Navara meshes, lit
and graded like the tiles. The sharpening now sits after the overlay
(Cesium's sharpen stage sharpens billboards too).

Also (review): Navara pixel dashes are measured in CSS pixels
(gl_FragCoord over uGevPixelRatio), and the line viewport uniform is
updated in place.

Measured: a magenta sprite draws 255/0/255. The contract suite's colour
checks are now within 40 of the asked colour on every channel on both
engines (were: dominant channels); Cesium 68/68, Navara 74/74 (its
sharpen-order check accepts the overlay between tone mapping and the
sharpening). Shell: cesium 23/23, cross 4/4; navara 23/25 — the two map
stack pictures from space fail on stddev 7.2/7.6 against 8, as on the
Task 3 commit run at the same hour (the cinematic sun follows the clock
into night over the reset view), not from this change.
2026-10-16 19:04:57 +04:00
German Khokhlov 3c2e924363 test(qa): headless by default
qa-directions, qa-transit, qa-transit-recovery and qa-wind-canvas
launched a visible Chrome window. They now run headless unless
QA_HEADFUL=1 (`headless: process.env.QA_HEADFUL === '1' ? false : true`,
the same change as on the phase-4 branch).
2026-10-16 18:59:37 +04:00
German Khokhlov 8812117013 fix(globe): Navara's shadowed sun leaked a light per frame
Navara 0.2.1's transparent pass refreshes its copy of every scene light
each frame with copy(light, true). For the sun's cascaded shadow lights
(three-csm CascadedDirectionalLights) that copy clones every cascade, so
each frame brought new DirectionalLights with new ids and three's
WebGLLights uniforms cache (keyed by id) grew by about 1.2 KB a frame in
the looks whose sun casts shadows (minimal, photoreal).

src/globe/navara/cascadedLightCopy.js patches the copy to update the
cascades it has in place (pose, colour, intensity, target, shadow
settings and camera) when the counts match, and keeps the rebuild
otherwise; internals.js finds the class among the view's scene lights
(private view._scenes.light) and applies it when the globe is built.
Tests on three-csm's own class: the original copy changes the light ids,
the patched one keeps the lights and takes the values.

Post-GC JS heap (CDP HeapProfiler.collectGarbage every 10 s, the app on
?engine=navara rendering continuously at 60 fps): minimal 4.33 → 0.20
MB/min over 70 s and 0.06 MB/min over 180 s; photoreal 4.34 → 0.19
MB/min; cinematic (no shadow) 0.34. Added to the upstream asks.
2026-10-16 18:41:20 +04:00
German Khokhlov c327a25744 feat(data): one selection dispatcher
src/data/selectionDispatcher.js: one engine click subscription with
picks (pick, drill 8), the press gesture (travel and duration, as
bindTrackingClickGesture) and one Escape listener for the layers that
move onto the engine. Owners register owns(id) (also in pickRegistry, so
layers still on their own handlers see the ownership) and onSelect /
onEmptyClick / onEscape. Rules of the existing handlers: another layer's
pick is not empty space; passThrough picks (trails) never take the
click; a drillThrough owner (local ADS-B) takes its object from under
another layer's; a drag selects nothing; deselection needs a clean click;
a pointer owned by a tool selects nothing. Tested on the fake engine:
own / foreign / empty / trail clicks, drill-through, drag and long press,
Escape, an owner without handlers, unregister and destroy. No layer uses
it yet (the *a tasks move them).
2026-10-16 18:25:12 +04:00
German Khokhlov b0cb8ce881 feat(globe): picks on input payloads
input.on('click'|'doubleClick'|'move', fn, { pick, drill }) delivers
{ …event, picked, drilled }: PickResult { id, owner, instance, primitive
(Cesium) } names the mover set (and instance) or line hit. One pick (and
one drill, as deep as the deepest listener) per event, shared by its
listeners; moves with picks are coalesced to one delivery per frame with
the latest position (src/globe/pickDelivery.js). Option checks in the
contract (normalizeInputOptions).

- Cesium: scene.pick / drillPick, and the picker names the owner: movers
  map billboards, points and models to their set and instance, lines
  their primitives to the line.
- Navara: a click with pick takes Navara's GPU pick of the same gesture
  (featureClick; its pick helper answers on the same pointerup, usually
  before the engine's click, so a fresh answer waits for it; otherwise
  the click waits up to 250 ms, then the CPU pick). Navara 0.2.1 answers
  clicks in quick succession with the first click's pick: a GPU answer
  whose instance is not drawn near the click is stale and the CPU pick
  answers. Hover, drill and the synchronous picker.pick/drillPick are CPU
  picks (src/globe/cpuPicks.js) of pickable mover sets and pickable lines
  (within half the width + 3 px), behind the globe excluded, nearest
  first; lines are picked on the CPU after an empty GPU answer.
  picker.clickStats() (Navara only) counts the answers and latencies.
- The fake engine picks its own movers and lines on the CPU unless
  scripted.
- The contract suite clicks with real pointer events (puppeteer) on both
  engines: a sprite is picked with its owner and instance, the map picks
  nothing, two sprites in one place drill both, a line is picked, a
  hover picks the sprite under it. Navara, 20 clicks: payload p50 2.8 ms,
  p95 4.1 ms after the click (puppeteer dispatch included), 20/20 GPU
  answers. The Cesium harness keeps the first-run card closed.
2026-10-16 18:25:12 +04:00
German Khokhlov 86aa0bc9ba feat(globe): dynamic lines on both engines
engine.lines.createLine({ id, widthPx, color, depthFailAlpha, dash
({ lengthPx, gapPx } | 'tactical'), arc ('none'|'geodesic'), modelMatrix,
pickId }) → a Line: setPositions (Cartesian3[] or a flat Float64Array),
setColor, setModelMatrix, show, destroy; lines.list() for QA. Options,
flat positions, geodesic densification (great circles of the geodetic
normals, height interpolated, one step per degree) and the registry are
engine-neutral (src/globe/lines.js); the fake engine keeps every line.

- Cesium: a Primitive of a PolylineGeometry (synchronous, rebuilt when
  the positions change) with a PolylineMaterialAppearance in an
  engine-owned collection; depthFailAlpha is depthFailAppearance with the
  alpha scaled; pixel dashes are PolylineDash (16-bit pattern); the
  tactical dash is the launches' selected-orbit shader under its own type
  (launches keep theirs until Task 15a moves them onto engine.lines).
- Navara: a public MeshDesc (transparent pass, unlit) holding a ribbon
  of one quad per segment, widened to CSS pixels in the vertex shader;
  a second copy drawn with GreaterDepth and the alpha scaled is the
  depth-fail part; dashes are cut in the fragment shader (pixels along
  the screen direction, or the tactical pattern over the fraction along
  the line); positions relative to the first point, the model matrix on
  the group. Geodesic arcs are densified on the CPU. Not pickable yet.
- The contract suite draws a three-point line on both engines: magenta
  along both segments, nothing off it, 1° east once turned by its model
  matrix, gone when hidden; a line 30 km under the ground shows only its
  depth-fail copy, dimmer than the line.

Main chunk 2,867,499 bytes (+7,197, +0.25 %: the Cesium lines).
2026-10-16 17:24:36 +04:00
German Khokhlov 0109097440 feat(globe): instanced movers on both engines
engine.movers.createSet({ id, kind: sprite|point|model, capacity, order,
depthTest, sprite|point|model, pickable }) → a MoverSet: set(index,
fields) stores an instance (position, screen rotation, orientation, rgba,
scale, atlas cell, show, pickId) in flat arrays, commit() uploads the
changed range once and requests one render, positionsView() reads the
positions, destroy() removes it; movers.list() for QA. The options, the
store and the registry are engine-neutral (src/globe/movers.js); the fake
engine keeps them for layer tests.

- Cesium: one primitive per set in an engine-owned PrimitiveCollection
  added with the first set, in `order`: sprites a BillboardCollection
  (one image per atlas cell, NearFarScalar, disableDepthTestDistance),
  points a PointPrimitiveCollection, models a Model per instance.
- Navara: a public MeshDesc per set. Sprites and points are one
  InstancedMesh of a quad, faced to the screen and sized in CSS pixels in
  the vertex shader from uniforms (the pick pass draws the same
  footprint), atlas uv, rotation, colour and scale per instance, drawn in
  the transparent pass unlit; offsets from an origin that follows the
  camera past max(1 km, 10 % of its height). Models: the glTF once, one
  InstancedMesh per mesh, minimumPixelSize/maximumScale on the CPU per
  drawn frame, colorBlend in the shader. Pickable sets register a
  PickableInstancedMeshWrapper; pickIds.js maps batch ids back to
  instances (used by Task 4).
- The contract suite draws three 24 px sprites on both engines: centred
  within 2 px of worldToWindow, hidden by show:false, recoloured, gone on
  destroy, and no frames added once the commit has drawn.
- qa-engine-bench --scenario=movers: 10 000 synthetic aircraft
  (src/bench/syntheticFlights.js, moved from the Navara spike with its
  test), every instance moved and committed every frame. p95 Navara
  (look=minimal) / Cesium: global 5.2/7.0 ms, city 5.6/9.9, orbit 6.0/7.4
  (0.74, 0.57, 0.81; gate 1.1). Cesium is measured without its 60 fps cap.

Main chunk 2,860,302 bytes (+9,706, +0.34 %: the Cesium movers).
2026-10-16 17:15:24 +04:00
German Khokhlov 4623c8b616 test(qa): a comparable W1 baseline from recorded feeds and a frozen clock
Review of Task 0: the live baseline could not be compared between
commits. qa-w1-baseline now records every /api response of each layer's
run (--record=DIR, DIR/<layer>.json), replays them through puppeteer
request interception (--replay=DIR; an /api request outside the
recording answers 503) with the page clock frozen at the recording's time
(evaluateOnNewDocument: Date.now and new Date(), hence JulianDate.now and
the app clock), and diffs two runs (--compare=A,B: counts, known objects'
positions within 1e-7° and 5 cm, screen points within 0.5 px, tracking
poses within 1e-6 relative). The rules live in src/tooling/w1Baseline.js
with tests.

Recorded once (qa-artifacts/w1-feeds, git-ignored), replayed on
d50eaa28 and on HEAD: the runs agree within the tolerances for flights,
military, satellites (counts, five known objects each, tracking poses)
and launches (count). Local ADS-B has no receiver and vessels no AIS key
here: both stay empty. This is the reference for the extraction tasks.
2026-10-16 16:37:43 +04:00
German Khokhlov 76851ee949 build: guard the initial chunks by module id; main-chunk check in CI
Review of Task 0:
- build/initialChunkGuard.js, a Rollup plugin in the browser build, fails
  the build when an initial chunk (an entry and what it imports
  statically) holds a module from node_modules/three, @navaramap/* or
  postprocessing. Checked by forcing it on satellite.js, which the main
  chunk does hold: the build fails naming the chunk and module.
  navaraAssets.test now pins both build plugins.
- scripts/check-main-chunk.mjs is one script for both main-chunk checks:
  the composition (text markers in the initial chunks, sizes) and, when
  scripts/main-chunk-baseline.json exists, the phase-4 branch's gzip-9
  budget, with its exports (checkMainChunk, measureMainChunk) so the two
  branches merge into this file. The composition test moved to
  src/tooling/mainChunkComposition.test.mjs to leave phase 4's
  mainChunk.test.mjs its name.
- CI runs npm run check:main-chunk after each npm run build (unit,
  browser-gate and Windows jobs).
2026-10-16 16:22:49 +04:00
German Khokhlov f7f4e9a45c fix(time): latch the Cesium clock at creation; document the clock's limits
Review of Tasks 0–1:
- The Cesium engine clock attaches its frame latch to viewer.clock.onTick
  when the engine is created, so it runs before onTick listeners added
  later (cameraVerbs installs its own after the engine exists). Before, it
  attached on first use and would have run after them. A viewer without a
  clock still latches lazily on the scene's preUpdate.
- GlobeClock lists what is not on the app clock yet: Cesium's viewer clock
  (its lighting and property reads at currentTime), the launch replay
  timeline and the mission orbit's GMST frame (Task 15a), the satellite
  cloud's SGP4 and orbit bake (Task 14a), and wall-clock throttles. The
  plan's T14a/T15a now carry those moves.
- freeze/offset/rate change now() at once and frameTime() from the next
  frame; QA helpers request a render after moving the clock.
2026-10-16 16:18:08 +04:00
German Khokhlov 7c7422e281 Merge migration/phase2-engine (4aaf9746) into migration/phase3-layers
The phase-2 review fixes: look internals in internals.js, the groundFloor
resample throttle, tile height selection, the sharpen mirror, QA tile
cache limits, engine.groundFloor diagnostics. In navara/index.js both
sides are kept: the look gets internals.js's luminance uniforms and reads
the sun's time from the engine clock's frame time, so engine.clock stays
the one time source for the sun.
2026-10-16 15:15:24 +04:00
German Khokhlov 4aaf974600 docs(migration): the review fixes, their measurements and QA 2026-10-16 15:06:15 +04:00
German Khokhlov ce985768bc test(qa): wheel floor against the floor's own ground; polite tile cache
The Navara wheel check measures clearance against engine.groundFloor's
ground (threshold 29 m: the floor keeps 30 m), so tiles streaming under a
still camera cannot flake it. The tile cache identifies itself with the
repository URL, keeps at most two misses in flight and merges identical
ones.
2026-10-16 15:06:15 +04:00
German Khokhlov da6141be4b perf(globe): lazy hidden tiles by tile extent; floor resample interval
sampleHeight collects hidden tiles only after the shown ones miss, with
no per-node allocation, and picks the finest by tile extent (Navara
exposes no geometric error), not per-mesh radius. The ground floor
resamples on a move only after 100 ms, and reports its own ground and
margin (engine.groundFloor, Navara-only diagnostics).
2026-10-16 15:06:15 +04:00
German Khokhlov 6dad3894bd fix(globe): sharpen sky mask past depth 0.99999, not >= 1
RGBA8 depth packing rounds near the far plane.
2026-10-16 15:06:15 +04:00
German Khokhlov 5188e8869d fix(globe): the cinematic look's atmosphere scale, warm-up and sunset clouds
- The takram uniform paths move to internals.js (forEachLuminanceUniform,
  loud in dev when a path is gone); look.js sets each factor to scale x
  the recorded base value, so shared or copied vectors are never scaled
  twice, and the stars keep the base value (own vector when shared).
  Stars at intensity 120: a starfield shows from space.
- Both night-side shader variants compile on the first two frames.
- The clouds hide once the sun sets at the camera (dark puffs at dusk).
- No allocation on look frames that change nothing (frozen updates, a
  scratch camera place from livePosition).
Gallery regenerated.
2026-10-16 15:06:15 +04:00
German Khokhlov bb4e6bafa4 perf(time): the app clock's frame latch without closure boxing
The clock keeps its numbers in object fields (stored in place) instead of
closure variables, and its default wall clock is one module-level
function. Micro-measure (Node, --expose-gc, 200k calls): the flights
render-time arithmetic allocates about 25-35 B per call with the app
clock against 66-102 B with JulianDate.now/addSeconds; an engine tick
(beginFrame + ticked) about 20-30 B.
2026-10-16 15:03:23 +04:00
German Khokhlov c33ee31efc refactor(layers): W1 timestamps without JulianDate
Flights and military keep their fix history in plain milliseconds
(timeMs, whole ms as the Date the JulianDate came from held) and read
"now" from the app clock: the render-delay time, bracketing,
extrapolation, the warm-up check and the trail seed are number
arithmetic (motion.js, tracking.js, snapshotRenderer.js), as is the turn
rate and the forward synthetic fix in data/motionModel.js, which no
longer imports Cesium. The JulianDate scratch fields in state.js and the
unused JulianDate.now() in queries.js go. Launches (mission frame, panel,
selection), the tracked satellite's SGP4 epoch and the celestial ring's
sun and moon read the app clock; where a Cesium property still takes a
JulianDate it is built from the app clock's now (as JulianDate.now did
from Date.now). Wall-clock throttles and feed receipt stamps stay on
Date.now; property reads at viewer.clock.currentTime (render, frozen at
boot) move with the render code in the *a tasks.

Tests that built JulianDate history fixtures now build ms ones with the
same instants (motionModel.test, flights.test, militaryFlights.test); new
tests show the military delayed position and the tracked satellite follow
the app clock (both fail on the previous code).
2026-10-16 15:01:46 +04:00
German Khokhlov f1a2df5cfe feat(time): the app clock on the engine
src/time/clock.js is the app's own time in ms since the epoch: by default
Date.now exactly (read at each call), with freeze/offset/rate/reset for QA
and tests, and one frame time latched per engine frame (beginFrame). The
contract gains engine.clock { now, frameTime, ticked }, in the fake engine
(a controllable wall clock, fake.advanceTime, fake.appClock), the contract
suite (one frame time for every listener of a frame, latched before
preUpdate, never decreasing), Cesium and Navara.

Cesium latches on the viewer clock's onTick, which CesiumWidget runs right
before scene.render, so scene listeners the app added before the engine
existed read the same frame time; the subscription appears on first use
and destroy removes it. The viewer clock itself is untouched: in the app
it never animates (shouldAnimate false, multiplier 1, its time frozen at
boot), and its time still drives Cesium's lighting as before. Navara
latches in the engine's handler of the view's preUpdate, and the look
reads the engine clock's frame time for the sun, so the app clock is the
one time source on Navara too.

src/time is a portable root in check-import-directions: no engine
packages, no browser globals.
2026-10-16 15:01:36 +04:00
German Khokhlov 27e28373d0 test(qa): W1 baseline and the main-chunk check
scripts/check-main-chunk.mjs (npm run check:main-chunk) finds the main
chunk of a build (the Vite manifest's entry, or the module script
dist/index.html loads), follows its static imports and fails when any of
them carries three or Navara markers; it prints the sizes. Base build:
main chunk 2,850,229 bytes (gzip 881,783), no static imports, clean.

scripts/qa-w1-baseline.mjs records Cesium mode for the W1 layers (flights,
military, local ADS-B, vessels, satellites, launches): counts, five known
objects with positions and screen points, JPEGs (path, size, sha256) at
the global / New York (JFK) / orbit poses, a tracked object's camera pose
after 2 s and what its share link restores. Live sources until the
fixture sources of Task 8.
2026-10-16 14:49:24 +04:00
German Khokhlov 26968f4903 fix(ui): the Cesium engine's render.sharpen() mirrors the sharpen stage
VisualEffects sends its sharpen state to engine.render.setSharpen when a
viewer exists too; the Cesium engine keeps it as state, and the Cesium
stage itself is unchanged.
2026-10-16 14:34:17 +04:00
German Khokhlov d50eaa28af test(qa): noon sky and stars, surface heights across engines, tile cache
qa-globe-contract checks the noon sky facing the horizon (daylight blue,
no star pixels) and stars at midnight; qa-engine-shell compares
sampleHeight at the Austin startup pose across engines and reports the
wheel floor's lowest frame. QA pages load OSM and keyless terrain tiles
through a disk cache with backoff on 429. Report updated.
2026-10-16 14:29:31 +04:00
German Khokhlov 1cca89adf2 fix(globe): Navara sampleHeight under a camera looking ahead
The ground under a tilted camera is outside the frustum, where Navara
hides its tiles, so the ray found nothing. When the shown tiles miss,
the loaded hidden ones answer, the finest first (within 1.5 times its
radius, which keeps out the quadtree parent). At the Austin startup
pose it agrees with Cesium within 0.12-1.04 m.
2026-10-16 14:29:31 +04:00
German Khokhlov a8a2ad5582 refactor(ui): engine-specific sharpen defaults instead of reading 49 as 80
The baseline and the style presets name ENGINE_SHARPEN_DEFAULT, which
resolves to 49 % on Cesium and 80 % on Navara. Share links and explicit
calls restore their value unchanged.
2026-10-16 14:29:31 +04:00
German Khokhlov abf16ae8af fix(globe): a daylight sky and no daytime stars in the cinematic look
The atmosphere draws physical radiance scaled for an exposure near 10,
while the baked Google tiles need exposure 1, so the noon sky came out
dark navy with the stars showing through. The cinematic look now scales
the atmosphere's radiance-to-luminance factors (sky, aerial perspective,
clouds) by 3, leaving the tiles alone, and shows its stars only from
space or with the sun below -6 degrees at the camera. Gallery
regenerated.
2026-10-16 14:29:31 +04:00
German Khokhlov bb8c0b83c4 feat(globe): Sharpen on Navara, on at 80 % by default
The contract gains render.setSharpen({ enabled, amount }) and
render.sharpen(). Navara draws it as a custom EffectDesc: Cesium's unsharp
mask (3x3 box blur, gamma 2.2 round trip so one amount matches Cesium)
right after tone mapping and before SMAA/FXAA, skipping sky and cloud
pixels by scene depth. The Cesium engine keeps the state only; Cesium mode
still sharpens with its own post stage, unchanged. The fake engine records
it and the contract suite checks it.

Without a viewer the Sharpen slider and toggle drive the engine with the
same slider-to-amount mapping. On Navara the defaults, presets and share
links read Cesium's 49 % as 80 %.
2026-10-16 13:30:56 +04:00
German Khokhlov ac081a008d feat(globe): Navara's cinematic look by default
navaraScenePlan gains a pure `cinematic` plan, now the default of the app's
Navara mode (`?look=photoreal|minimal` picks the others): photoreal plus
volumetric clouds (low quality, layers from 1,500 m, no ground shadows),
ACES tone mapping, brighter stars and no sun shadow maps. From space, at
rest, the aerial perspective lights the globe from the atmosphere for a
real night side and terminator; it switches off below 1,000 km so the
Google tiles keep their baked light.

look.js builds the plan and keeps it current on frames Navara draws
anyway: the atmosphere's sun date follows the wall clock (the HUD's SUN
elevation), and the clouds hide above 4 km or over high ground in view,
parking their last image so the aerial perspective shows no ghosts.
2026-10-16 02:29:46 +04:00
German Khokhlov 177446c0be feat(globe): Navara's photoreal look by default
The Navara engine now builds its own photoreal scene (aerial perspective,
tone mapping, SMAA) instead of the minimal sky-and-sun look. It costs about
5 ms a frame (S5); minimal stays available for benches.
2026-10-15 22:55:31 +04:00
German Khokhlov 179c846f3e docs(globe): pickPosition reads the last drawn frame
Also corrects the separate drape pass cost in the phase 2 report to
0.8–0.95 ms, as measured in the spike.
2026-10-15 22:36:00 +04:00
German Khokhlov 80cc0b536e style: format the phase 2 files and add them to the format scope
Prettier on the 13 new test files that failed --check; format-scope.json
now lists every file phase 2 added (src/globe/**, engineChoice, navaraMode,
cameraAngles, the QA scripts and pages, build/navaraAssets and tests).
2026-10-15 22:34:25 +04:00
German Khokhlov 312ed416e9 test(globe): pick round-trip and setTerrain checks in the contract suite
Two engine-neutral checks: pickPosition then worldToWindow lands within
1.5 px of the screen point (over New York, straight down and tilted;
points with no surface are skipped, at least one must hit), and
maps.setTerrain resolves for keyless terrain and rejects an unknown kind.
The fake engine now rejects unknown terrain kinds, as both engines do.
The Navara contract page shows OSM on keyless terrain before the suite,
since depth picks need a drawn surface.
2026-10-15 22:34:25 +04:00
German Khokhlov b65dbab709 fix(app): Navara attributions clear of the panels; QA waits for tiles
Navara's attribution list is capped at 120 px, and the shell's data
credits register after the map's, so Google's copyright lines come first
and stay visible while the card no longer covers the left panels.

qa-engine-shell polls engine.surface.tilesLoaded() (true for 1.5 s, up to
90 s) before judging the picture and prints the load time per engine;
"wheel zoom stops above the ground" is a hard check on both engines
(height >= 20 m; on Navara >= 20 m above the sampled surface), and the
Navara known gap is gone.
2026-10-15 21:27:57 +04:00
German Khokhlov 8270172086 build: name the lazy Navara engine chunk navara-engine
chunkFileNames names the chunk holding src/globe/navara
navara-engine-[hash].js; chunking is unchanged (manualChunks would pull
modules shared with the Cesium entry into it and make the entry import
it). The Cesium main chunk stays 2,849,217 B.
2026-10-15 21:27:57 +04:00
German Khokhlov 64d3297301 fix(globe): keep the Navara camera above the ground
Navara 0.2.1's camera controller has no terrain or tile collision; its
only zoom limit, minimumZoomDistance, is a distance from the Earth's
centre, so a wheel zoom near the ground went through it (to -5.9 km).

groundFloor.js runs on the engine's preRender after the camera model
reads Navara's camera. While the camera is free (no flight, no lookAt
target) and below 20 km it samples the ground under it (tiles, else
terrain, else the ellipsoid; at most every 250 ms or 20 m), feeds
Navara's clamp (minimumZoomDistance = geocentric radius of the ground
point + 30 m, sent on changes over 10 m, reset to Navara's default
during flights and above 20 km) so the wheel stops smoothly, and lifts a
camera still below ground + 30 m straight up without camera.commanded.
It only lifts, so it cannot oscillate with the shell's ground guard,
whose lift is a flight the floor stands aside for.
2026-10-15 21:27:57 +04:00
German Khokhlov 4ded3fbf15 feat(app): run gev on Navara behind ?engine=navara
The page's engine is ?engine=cesium|navara, else localStorage gev.engine,
else Cesium (src/app/engineChoice.js). Cesium mode is unchanged.

In Navara mode (src/app/navaraMode.js, loaded with import() only then,
and loading the Navara globe the same way) the scene builds the Navara
globe on the app container with no viewer; map stacks go through
engine.maps (Google 3D from the browser key, else Esri imagery); only the
shell's own data credits are registered. The layer catalog is empty and
the controls get empty layer stand-ins. The shell does not create its
viewer-bound parts (cockpit view, celestial ring, detection, tracked
readout, post-process stages); the tools install the render governor on
the engine, skip the director, annotations, drawing, imagery box, scope
mask, voice and embedded views, skip the first-run welcome, and show
"Experimental Navara engine: layers, cockpit, director and voice are
unavailable". window.__godsEyeView.viewer is null.

Navara's attribution card is kept clear of the location tray. A few
shell paths became null-safe for the missing viewer, layers or stages
without changing Cesium behaviour.

scripts/qa-engine-shell.mjs checks both modes headless: boot, viewer,
console errors, map drawn, search and fly, north/tilt/zoom/reset, orbit,
HUD, share link restore and map stacks.
2026-10-15 20:56:16 +04:00
German Khokhlov 306e3e9467 build: reach Navara only through import() and ship its worker wasm
The import-direction check now tells dynamic imports apart
(analyzeModule(source, { dynamic: true })) and rejects any static import
or re-export of src/globe/navara/** from outside that directory, so the
Navara engine can only be a lazily loaded chunk of the app.

Navara's prebuilt workers load their wasm by file name next to
themselves, which the bundler cannot see; a build-only Rollup plugin
copies the missing ones (draco decoder, font worker) into assets/ when
the bundle includes Navara, and adds nothing to a build without it.
2026-10-15 20:56:06 +04:00
German Khokhlov 72fec292d4 feat(maps): map sources for the Navara engine
createDefaultMapSources takes the engine kind. On Navara, Google 3D is
available from the browser key alone (Navara builds the tiles itself and
has no ion route to them), so it is the default and the recovery stack
when a key is set; without one the default is Esri imagery. Bing (Cesium
ion imagery) has no Navara path and is unavailable with a reason. World
terrain still needs an ion token and falls back to the keyless terrain.
MapStackController passes the kind of the engine it is given; Cesium
sources are unchanged.
2026-10-15 20:55:59 +04:00
German Khokhlov d112ecf296 fix(globe): render holds and commanded on Navara
The render governor installs on a non-Cesium globe engine as its backend:
owner-counted holds drive engine.render.setContinuous and requests go to
engine.render.request, so orbit and every other per-frame owner keep
Navara drawing while held and let it idle once the last hold goes. Cesium
stays on the viewer path its engine delegates to.

A user gesture on Navara now ends a flight without raising
camera.commanded (the user is not a new camera owner). Headless Navara QA
checks both.
2026-10-15 20:06:13 +04:00
German Khokhlov c6e1b65c67 refactor(scenes): the director needs no viewer for its camera
Camera-gesture listeners attach to the engine's canvas and the
interaction availability check tolerates a missing viewer, so a director
built on an engine alone places, flies and stops scene cameras.
2026-10-15 19:49:24 +04:00
German Khokhlov 0b9deddd70 refactor(scenes): director flights use the globe engine
Scene flights, authored placement, flight cancellation and arrival
ownership run on the engine the composition hands the director (or the
one registered for its viewer). Tracking, interactions and data packs keep
the viewer until phase 1c.
2026-10-15 19:47:51 +04:00
German Khokhlov ec38e6ef16 refactor(ui): navigation, orientation, share links and HUD use the globe engine
The application shell keeps the page's globe engine and hands it to the
navigation controller, location navigation (search, city and POI flights,
orbit, globe reset), the tilt and north-up controls, share-link save and
restore, share restoration's first-gesture listeners and the HUD. The
viewer is passed only where entity tracking still lives on it (phase 1c)
and to the HUD's basemap label context (voice); every such use is null-safe.

Share links and captured shots write heading and roll as Cesium's camera
reports them (src/cameraAngles.js): the contract's [0, 2π) pose maps an
exact 0 back to Cesium's 2π, except the roll of a vertical view, so
links stay byte-identical (heading=360, roll=360) on every engine.

Source-reading tests follow the moved code with the same checks.
2026-10-15 19:47:51 +04:00
German Khokhlov 16e63f4c50 refactor(camera): use the globe engine
The startup fly-in, orbit, world-target focus, the arrival ground guard,
arrival ownership, camera sensitivity claims, landmark/viewport/globe
flights and place search (with its view bias and near-view recovery) take
the globe engine. Callers that still hold a viewer (voice, layers until
phase 1c) reach the same engine through engineFromViewer, and arrival
ownership is keyed by the engine whichever one a caller names.

The ground guard yields on camera.commanded instead of patching the
viewer camera itself; on Cesium that event hooks the same command methods
and tracking changes, so direct Cesium callers still end it. Landmark
framing reads the frustum fov from the pose's fovy and aspectRatio.

Tests that built viewer doubles now run on the fake engine (or on the
Cesium engine over the same double), with the same expectations.
2026-10-15 19:47:43 +04:00
German Khokhlov a3e929870d feat(globe): camera.commanded, picker.pickGlobe, pose.aspectRatio and isDestroyed
Shell navigation needs to know when a new owner takes the camera (the
arrival ground guard), where the globe surface is under a window position
(orientation pivot, view-centred place search), the frustum aspect ratio
(landmark framing) and whether the engine is gone. Each member has a
contract suite check and is implemented by the Cesium engine, the Navara
engine and the fake. On Cesium, commanded hooks the viewer camera's
command methods and trackedEntityChanged only while it has listeners, so
direct Cesium calls from layers still announce a new owner.
2026-10-15 19:19:41 +04:00
German Khokhlov 840e34a881 feat(globe): bring the Navara engine up to the shell contract
- camera.livePosition: one engine-owned Cartesian3, refreshed on read
  (the model replaces its state on every move); camera.height()
- render.request(reason) takes the diagnostic reason and ignores it
- maps.current() returns the sources as passed: the base imagery on the
  globe (none while tiles replace it) and the tiles shown
- maps.releasePhotorealTiles forgets a source and takes its tiles away
  if they show
- base imagery and photoreal share one switch order; tiles off brings
  back the last base imagery with its credit; imagery chosen while
  tiles show waits hidden, credit included
- Navara QA runs the contract's maps checks with a Google 3D tiles
  source on the dev server's browser key: 58/58
2026-10-15 19:08:47 +04:00
German Khokhlov a4ac0b9b0d fix(globe): harden the Navara engine after review
- maps: photoreal hides the base imagery layer instead of deleting it,
  and a new base layer goes in before the old one goes: deleting the
  last raster layer stops Navara 0.2.1 loading terrain at the current
  view, which left terrainHeight undefined under the tiles
- Google 3D tiles open Navara's attribution card so the tiles'
  copyright lines are on screen, as Cesium shows them; onScreen
  credits do the same
- setCameraInputs merges into the camera options set through the
  engine (new setCameraOptions extension) instead of resetting them
- createNavaraGlobe disposes the view and canvas if the engine throws
- the pin test checks the installed Navara version and catches
  bracketed private access
- QA: draw-match checks at the nadir clamp, after a Navara flight and
  after a mouse drag; terrain under photoreal set right away; the
  copyright lines on screen; create/destroy/create leaves no canvas,
  frames, animation frames, workers or console errors (54/54)
2026-10-15 19:04:07 +04:00
German Khokhlov 90c8e7763a feat(globe): implement the engine on Navara
src/globe/navara builds the GlobeEngine on a Navara 0.2.1 ThreeView. The
engine keeps the camera pose in src/geo values and pushes it to Navara's
setCamera in degrees (Cesium's heading/pitch/roll convention, S4);
whatever Navara moves itself (user input, its flyTo arc) is read back
from the drawn camera each frame. Projection, picks against the
ellipsoid and the view rectangle are geo math on that pose, with
Navara's fov kept on Cesium's 60° across the wider side.

- flights: Navara flyTo in ms with mapped easings, cancelFlight via an
  empty setCamera, user input cancels a flight as in Cesium
- events: moveStart/moveEnd (500 ms still)/changed from the pose
- render: forceUpdate and animation (render on demand, S5)
- surface: S2 CPU ray over 3D-tile meshes, Navara terrain fallback
- maps: OSM/Esri raster, Re:Earth or ion quantized-mesh terrain,
  Google 3D tiles with a browser key and Google's logo
- input: DOM pointer events with ScreenSpaceEventHandler semantics
- the three private Navara members used live in internals.js, pinned
  to 0.2.1 by a test

index.js builds the product view (MSAA 4, native pixel ratio, preserved
drawing buffer, effects=minimal) for a dynamic import. The contract
QA script gains --engine=navara on a dev-only harness page: 46/46
contract and 4/4 Navara checks; Cesium stays 53/53.
2026-10-15 19:03:58 +04:00
German Khokhlov b6ac0e9c99 build: add Navara for the experimental engine
Pins @navaramap/three and @navaramap/three-default-plugin 0.2.1 with
their peers three 0.185.1 and postprocessing 6.39.4 (mapillary-js keeps
its own nested three 0.134). The dev server leaves the Navara packages
and their wasm workers out of dependency pre-bundling so wasm-bindgen
resolves its .wasm next to the package. Nothing imports Navara yet: the
Cesium-mode main chunk is byte-identical (2,837,992 bytes, same hash).
2026-10-15 19:03:32 +04:00
German Khokhlov 6dbd9c983e refactor(maps): describe map sources to the engine
The map source controller always hands engine.maps an engine-neutral
description of the registry source: imagery kind/style/accessToken and
credit, photoreal kind and credentials, terrain kind/accessToken. The
default registry carries those beside its Cesium factories. Only a
Cesium engine also gets the prebuilt provider, tileset and cached terrain
(Cesium-mode behaviour, ordering and same-provider guard unchanged); for
any other engine the controller builds nothing, falls back by description
when a setter rejects, and has no tile-failure watch.
2026-10-15 19:01:00 +04:00
German Khokhlov ab0acb0e09 feat(globe): engine-agnostic maps checks and maps.current
The contract suite gains maps checks (opt-in with mapSources: two imagery
sources and a photoreal one the engine can show): setPhotorealTiles(null)
brings back the last base imagery, the last imagery or photoreal call
decides what shows, and a released photoreal source no longer shows or
counts. They observe the new maps.current(), the source objects on screen
as passed. The fake engine follows the same semantics; qa-globe-contract
runs the checks on the app engine with the startup tileset and reapplies
the app's map stack afterwards. The contract documents map sources as
engine-neutral descriptions whose prebuilt members only the engine kind
that built them uses.
2026-10-15 19:01:00 +04:00
German Khokhlov 12c9651a67 refactor(overlays): hand the shell's overlay host the engine, not the viewer
The controls pass the scene engine to the shell, which initializes the
world overlay on it; initWorldOverlay takes only a globe engine (a viewer
or null is refused), so the shell no longer needs a viewer for its
overlay. engineFromViewer only returns the engine registered for a viewer
(the scene engine until teardown) and never builds one, so no engine can
outlive its viewer without a destroy path. Overlay and detection tests
and the allocation worker build a Cesium engine on their mock viewers and
destroy it with them; frame allocation is unchanged.
2026-10-15 18:53:57 +04:00
German Khokhlov 3e3aa0bd9f refactor(maps): use the globe engine
MapSourceController (and MapStackController) take the page's globe
engine: a globe stack becomes engine.maps.setBaseImagery with the
controller's ready provider and credit, then setPhotorealTiles(null),
then setTerrain with the cached terrain; a 3D stack becomes
setPhotorealTiles with its tileset and credit; owned tilesets are
released through maps.releasePhotorealTiles before disposal. The
controller keeps its provider/terrain/tileset caches, construction and
tile-failure fallbacks, switch generations and error watch. The scene
builds its map stack controller with its engine; without an engine the
controller applies sources to the viewer as before. The photoreal-to-
globe sequence (imagery, credit, globe, tileset) is tested end to end on
the Cesium engine.
2026-10-15 18:40:19 +04:00
German Khokhlov 83c13f330d refactor(credits): use the globe engine
Map credits (maps/credits.js) are shown through a credits capability
(engine.credits.addStatic) instead of the scene's credit display; the
shared map scene takes the engine's credits, so the Cesium engine's maps
and its credits part share one owner. Data credits (registerDataCredits,
registerDynamicCredit, show/hideOsmCredit) take the globe engine and keep
accepting a viewer from layers that still hold one; the scene registers
the data credits on its engine. The Cesium credits part reads
Viewer.creditDisplay, else the scene frame state display it aliases.
2026-10-15 18:37:02 +04:00
German Khokhlov be56144d75 refactor(services): use the globe engine
groundSnap.heightFor and meshFloorSampler.sampleMeshFloorCells take the
globe engine: engine.surface.sampleHeight for the probe,
engine.surface.tilesLoaded (and, for mesh cells, maps.photorealActive)
for readiness, camera.height for the mesh sampler's altitude gate. Callers
that still hold a viewer or scene (the layers until phase 1c) keep the
unchanged Cesium path.
2026-10-15 18:34:39 +04:00
German Khokhlov c2a0d20f3d refactor(pick): use the globe engine
pickGroundPosition takes the globe engine (picker.pickPosition, then
camera.pickEllipsoid) and still accepts a Cesium viewer from callers that
hold one (layers until phase 1c), with one shared ground-or-ellipsoid
rule. isSurfacePick classifies engine pick results exactly as the Cesium
picks they wrap.
2026-10-15 18:32:45 +04:00
German Khokhlov aaa6074663 refactor(overlays): use the globe engine
The world overlay host takes its frames (frames.postRender), camera events
(camera.moveEnd), canvas size (host.size), projection matrix
(projector.viewProjection), horizon test (projector.horizonVisible) and
render requests (render.request('world-overlay')) from the globe engine.
initWorldOverlay accepts an engine or, for callers that still hold a
viewer, resolves the viewer's engine through engineFromViewer.

Contract: camera.position(result) becomes camera.livePosition, one
engine-owned object holding the current position, so per-frame readers
and paint lanes keep a live camera position without allocating; engine
events gain raiseValue for argument-list-free frame dispatch. Steady-state
frame allocation is unchanged (worker probe, 30 chunks: 2696/9536/38593
B/frame before and after; detection 473749 -> 408085).
2026-10-15 18:32:40 +04:00
German Khokhlov 58a196374f feat(globe): engine members and the viewer bridge for the shell
Contract: camera.position(result?) and camera.height() for per-frame
readers, render.request(reason?), maps.releasePhotorealTiles, sources
with a caller-owned ready provider, isGlobeEngine. engine.maps now hides
every tileset it has shown, lets the last imagery/photoreal call win, and
setPhotorealTiles(null) restores the last base imagery and its credit
instead of leaving the photoreal credit on a bare globe.

Cesium engine: reads the Viewer camera/canvas aliases, detaches bridged
events through the remover Cesium returns, raises frame timestamps
without allocating, re-aims the horizon occluder only when the camera
moved, and renders the scene directly when the governor governs another
viewer (or none yet). engineFromViewer returns the registered scene
engine of a viewer, else builds and caches one. The render governor stays
the Cesium render backend under engine.render and gains
isRenderGovernorInstalled.
2026-10-15 18:18:19 +04:00
German Khokhlov a5c98c6b07 feat(app): construct the globe engine with the viewer
The scene builds the page's Cesium engine right after the viewer
(src/app/globeEngine.js), names the startup Google tileset to it once
loaded, returns it beside the viewer and destroys it before the viewer.
The debug handle exposes it as window.__godsEyeView.engine. Nothing
consumes it yet; render holds taken before the governor is installed
apply at install.

qa-globe-contract now runs on the app's engine (self-built only when the
app has none); a throwaway engine checks creation and destroy leave the
scene untouched.
2026-10-15 18:02:11 +04:00
German Khokhlov ab2368f3f3 fix(globe): tighten the Cesium engine after review
Map sources are applied through one src/maps/sceneApply.js helper shared by MapSourceController and engine.maps (live layer kept for an unchanged provider, providers cached per source). The contract gains flyTo endTransform and rectangle destinations, flyToSphere easing, drillPick width/height and pick content, with Cesium's own flyToSphere range. Input skips listeners removed during dispatch and stays inert after destroy.
2026-10-15 17:55:00 +04:00
German Khokhlov d831cbd23a feat(globe): implement the engine on Cesium
createCesiumEngine wraps an existing viewer without changing it: camera, picker, projector, frames, render (through the render governor), surface, input, credits and maps, with geo values out and Cesium values in at the boundary. Map and surface code is shared with src/maps and services/groundSnap instead of copied. scripts/qa-globe-contract.mjs runs the contract suite against the engine in headless Chrome; the camera-events check now waits for the previous move to end, as Cesium's moveStart marks a move from rest.
2026-10-15 17:43:05 +04:00
German Khokhlov cd94a26b9f fix(globe): document Cesium's default view angles and check them
FlightOptions and setView now state Cesium's defaults for missing angles
(heading 0, pitch -pi/2, roll 0), enforced by a contract-suite check. The
suite also checks a horizon-bounded view rectangle and an undefined one
when the camera looks away from the Earth.
2026-10-15 17:21:31 +04:00
German Khokhlov 708c96b6f6 fix(globe): tighten the engine contract after review
Engine events get their own documented semantics (single registration,
branded instances); the fake follows Cesium's roll sign, angle ranges,
synchronous zero-duration flights, flyToSphere defaults and
computeViewRectangle. The contract adds viewProjection, horizonVisible,
flightActive, lookAtTransform/transform, zoomIn/zoomOut, input modifiers,
terrainHeight and clampToHeight, with geometric suite checks. Engine
import rules now follow each engine graph transitively.
2026-10-15 17:15:35 +04:00
German Khokhlov 528ec75f32 feat(globe): add the engine contract and its test suite
src/globe/contract.js defines the engine-neutral GlobeEngine capabilities
(JSDoc), createEngineEvent with Cesium.Event semantics and
assertGlobeEngine. src/globe/testing adds a geo-based fake engine for shell
tests and an engine-agnostic contract suite that the fake passes in Node.
check-import-directions keeps the contract free of cesium, three and
@navaramap, and each engine directory free of the other engine.
2026-10-15 17:01:08 +04:00
German Khokhlov f2fcd82290 test(qa): click street-level coverage at a verified pixel
The draped coverage line is about one pixel wide and can sit on a pixel
boundary: the probe hit at a fractional x while the click at the integer
pixel missed. Wait for tiles, click only at an integer pixel that picks the
line right before the click (off the centre column first), retry up to 4x.
2026-10-15 00:19:52 +04:00
German Khokhlov e3061872cc refactor(streetLevel,traffic,transit,firms,installations,radio,bikeshare,cables,detection): take vector maths from src/geo
Street level view/cameraFollow/groundClick/index, traffic footprint/viewport/ingestion, transit viewport/trails/state/rendering, FIRMS state/viewport, installations controls/viewport/state, radio queries/navigation/state, bikeshare viewport, submarine cable overlay and data/detection now use src/geo for Math, Cartesian2/3, Cartographic and WGS84 maths. Cesium stays for Rectangle, Ray, BoundingSphere, HeadingPitchRange, EasingFunction, EllipsoidGeodesic, EllipsoidalOccluder and the Ellipsoids passed into Cesium (pickEllipsoid, occluders). traffic/state.js keeps its Cesium lerp scratch (written per frame by Cesium.Cartesian3.lerp in animation.js; a geo scratch cost +4-7% there) and traffic/timing.js stays on Cesium (a test keeps it source-equivalent to model.parseRoads, outside this batch). street-level-layer, traffic-layer, installations-layer, bikeshare-layer and submarine-cables own the src/geo files.
2026-10-15 00:19:13 +04:00
German Khokhlov a915bdb3e5 refactor(launches,cctv,alpr): take vector maths from src/geo
Launches selection/replay/panel/policy/policyHelpers/state/camera, CCTV geometry/model/projection/navigation/cards/geometryQueue/state and ALPR model/visuals now use src/geo for Math, Cartesian2/3, Cartographic, Matrix3/4, Quaternion and ENU frames. launches/paths.js keeps Cesium (EllipsoidGeodesic). In camera.js, EllipsoidGeodesic, BoundingSphere.fromPoints/union and the BoundingSphere constructor stay on Cesium; the geo Cartographics passed into EllipsoidGeodesic are only read field by field and cloned there (computeProperties). The CCTV plane orientation stays a Cesium Quaternion and plane dimensions a Cesium Cartesian2 (entity ConstantProperty values). launches-layer and alpr-cameras own the src/geo files.
2026-10-15 00:18:35 +04:00
German Khokhlov ee8b94d987 refactor(flights,military,localAdsb): take vector maths from src/geo
Dead reckoning, the grounded display floor, snapshot rendering, queries and layer scratch state now use src/geo. Outputs are bit-identical to the previous batch (frozen-clock probe over flights, military, local ADS-B and orbits); per-frame paths are as fast as base 132b9e96 or faster (see docs/migration/phase1a-report.md).
2026-10-15 00:17:56 +04:00
German Khokhlov 2b83606765 refactor(vessels,satellites,awareness): take vector maths from src/geo
Moves the Cartesian3/Cartographic/Matrix3/Matrix4/Math calls of the vessel, satellite and Contacts (awareness) layers, plus flights controller/enrichment, to src/geo. The satellites-layer package boundary now owns the src/geo modules. Awareness keeps Cesium.Cartesian3.clone for the resolved subject position, which reaches the installations anchor (anchor.test pins a Cesium instance).
2026-10-15 00:17:17 +04:00
German Khokhlov 1a69368ae6 refactor(ui,voice,annotations,scenes): take vector maths from src/geo
Move the Cartesian3/Cartographic/Matrix4/Transforms/Ellipsoid/Math calls of
the second batch (cockpit camera, context and controller; camera orientation
controls; application shell; location navigation; the annotation resolver
and engine; voice actions and pointer pick; the scene director; the embed
app) to src/geo. Behaviour is unchanged: same argument order, same `result`
semantics.

Kept on Cesium: out-of-scope classes (HeadingPitchRange, BoundingSphere,
Intersect, EllipsoidalOccluder, EasingFunction, SceneTransforms, JulianDate),
WGS84 where it goes into pickEllipsoid or EllipsoidalOccluder,
Matrix4.IDENTITY handed to lookAtTransform, and the Cartesian2 window
positions that reach scene.pickPosition: Cesium keys its per-frame pick
cache on windowPosition.toString(), which a plain object would collapse to
one "[object Object]" key.

The Contacts-subject harness in gevActions.test stubbed
Cesium.Cartographic.fromCartesian to place its subject; it now builds a real
subject position at the same coordinates with geo.cartesian3.fromDegrees.
The cockpit-controls and scenes package-boundary groups now own the src/geo
files.
2026-10-15 00:16:38 +04:00
German Khokhlov d886be68a8 refactor(camera,data): take vector maths from src/geo
Move the Cartesian3/Cartographic/Matrix/Quaternion/Transforms/Math calls of
the first batch (src/data motion, anchor, tracked-camera and icon helpers;
the root camera, locations, HUD, share-link, world-focus, ground-guard,
orbit and cloud modules; services/groundSnap) to src/geo. Behaviour is
unchanged: same argument order, same `result` semantics.

Kept on Cesium: out-of-scope classes (JulianDate, Axis, HeadingPitchRange,
BoundingSphere, Rectangle, EasingFunction, EllipsoidalOccluder with its
WGS84 and seed vector, SceneTransforms) and Matrix4.IDENTITY wherever it is
handed straight to a Cesium camera call (locations.test pins its identity).

synthesizeForwardKinematicsFix now returns a plain position, so the two
snapshot renderers clone it with the static Cesium.Cartesian3.clone instead
of the instance method. The package-boundary groups that reach these
modules now own the src/geo files.
2026-10-15 00:15:59 +04:00
German Khokhlov 4a92ed8377 feat(geo): add src/geo, Cesium's vector maths without Cesium
An engine-neutral port of the CesiumJS 1.138 core maths gev uses: angle
maths, the WGS84 ellipsoid and geodetic coordinates, 2D/3D vectors, 3x3/4x4
matrices, quaternions and local east-north-up / heading-pitch-roll frames.
Formulas and operation order match Cesium, so outputs are bit-identical
(golden tests pin the centre, pole, -0 and quaternion branches).

- Values are plain-data instances of module-private constructors (own
  enumerable fields only), so geo's hot functions keep their own V8 hidden
  classes; matrices are plain column-major arrays Cesium accepts.
- Cesium's debug argument checks are ported with the same conditions, order
  and DeveloperError messages, gated on DEBUG (src/geo/debug.js): on in Node
  and the dev server, folded away by vite build, as Cesium's Build strips
  them. A release-parity test compares DEBUG=false geo with Build/Cesium.
- The import-direction check forbids cesium and @cesium/* in src/geo;
  CODE-BOUNDARIES.md and THIRD_PARTY_NOTICES.md document the port.
2026-10-15 00:15:20 +04:00
1637 changed files with 296454 additions and 63474 deletions

No files matched your search

+4
View File
@@ -59,6 +59,10 @@ jobs:
- name: Build production bundle - name: Build production bundle
run: npm run build run: npm run build
- name: Main chunk size budget
if: matrix.node == '24.22.2'
run: npm run check:main-chunk
street-level-browser: street-level-browser:
# Hermetic: every Mapillary request is answered from fixtures, so the # Hermetic: every Mapillary request is answered from fixtures, so the
# dummy token below never leaves the page. # dummy token below never leaves the page.
+2 -1
View File
@@ -5,7 +5,7 @@ God's Eye View's **code** is [MIT](LICENSE)-licensed. **The MIT grant covers the
How to read this: How to read this:
- **The non-permissive datasets are carved out, not omitted.** Some bundled data (e.g. TeleGeography, CC BY-NC-SA) isn't MIT-compatible. Rather than hide it, we **bundle it with a clear license carve-out** so the app works out of the box — but it stays under the provider's terms. - **The non-permissive datasets are carved out, not omitted.** Some bundled data (e.g. TeleGeography, CC BY-NC-SA) isn't MIT-compatible. Rather than hide it, we **bundle it with a clear license carve-out** so the app works out of the box — but it stays under the provider's terms.
- **If your use doesn't fit a dataset's license, remove that dataset.** Most importantly: TeleGeography is **NonCommercial** — commercial users must delete it (or license it from TeleGeography). It's one self-contained folder. - **If your use doesn't fit a dataset's license, remove that dataset.** Most importantly: TeleGeography is **NonCommercial** — commercial users must delete it (or license it from TeleGeography). It's one self-contained folder. **EOX Sentinel-2 cloudless 2020** is also **NonCommercial** (CC BY-NC-SA 4.0): remove that basemap or obtain a commercial licence from EOX.
- **Attribution is shown in-app** and listed here. Keep it intact. The required Google/Cesium credit renders on the on-globe credit line (bottom-left, `#cesium-credits`), and every per-layer credit below is registered into the expandable **"Data attribution"** lightbox on that line (`src/data/dataCredits.js` → `viewer.creditDisplay.addStaticCredit`). Both stay visible in clean-view and recording modes. - **Attribution is shown in-app** and listed here. Keep it intact. The required Google/Cesium credit renders on the on-globe credit line (bottom-left, `#cesium-credits`), and every per-layer credit below is registered into the expandable **"Data attribution"** lightbox on that line (`src/data/dataCredits.js` → `viewer.creditDisplay.addStaticCredit`). Both stay visible in clean-view and recording modes.
- **Bundled model attribution lives beside the model files.** [`public/models/README.md`](public/models/README.md) records each shipped model's creator, source, license, and modification status. - **Bundled model attribution lives beside the model files.** [`public/models/README.md`](public/models/README.md) records each shipped model's creator, source, license, and modification status.
@@ -34,6 +34,7 @@ How to read this:
| **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) | | **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) |
| **CelesTrak** | Satellite TLEs (SGP4) and SATCAT records (object type, operational status, owner) that file each satellite under a class | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" | | **CelesTrak** | Satellite TLEs (SGP4) and SATCAT records (object type, operational status, owner) that file each satellite under a class | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" |
| **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) | | **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) |
| **EOX Sentinel-2 cloudless 2020** | Optional satellite basemap in Cesium and Navara. Navara uses daylight for terrain inspection. | [EOX Maps](https://maps.eox.at/); [CC BY-NC-SA 4.0](https://cloudless.eox.at/license-non-commercial). Commercial use requires a commercial licence from EOX. | [EOxCloudless](https://cloudless.eox.at/) by [EOX IT Services GmbH](https://eox.at/) (Contains modified Copernicus Sentinel data 2020) |
| **Esri World Imagery** (ArcGIS Online tile service) | The keyless satellite basemap — the default landing when no Google/ion credential is configured, and the "Esri Satellite" map stack | [Esri Master Agreement](https://www.esri.com/en-us/legal/terms/full-master-agreement): the public World Imagery service is usable in public-facing apps with attribution; no key is required for this classic endpoint, but Esri governs and can change access — an app at scale should review current ArcGIS Location Platform terms | "Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community" (provider carries the service's own credit line) | | **Esri World Imagery** (ArcGIS Online tile service) | The keyless satellite basemap — the default landing when no Google/ion credential is configured, and the "Esri Satellite" map stack | [Esri Master Agreement](https://www.esri.com/en-us/legal/terms/full-master-agreement): the public World Imagery service is usable in public-facing apps with attribution; no key is required for this classic endpoint, but Esri governs and can change access — an app at scale should review current ArcGIS Location Platform terms | "Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community" (provider carries the service's own credit line) |
| **USGS** | Earthquakes | U.S. public domain | "Data courtesy of the U.S. Geological Survey" | | **USGS** | Earthquakes | U.S. public domain | "Data courtesy of the U.S. Geological Survey" |
| **Environment and Climate Change Canada** (MSC GeoMet OGC API) | Air quality (AQHI) — Canadian station observations | [ECCC Data Servers End-use Licence](https://eccc-msc.github.io/open-data/licence/readme_en/) (open, attribution required, no endorsement implied) | "Contains information licensed under the Environment and Climate Change Canada Data Servers End-use Licence" | | **Environment and Climate Change Canada** (MSC GeoMet OGC API) | Air quality (AQHI) — Canadian station observations | [ECCC Data Servers End-use Licence](https://eccc-msc.github.io/open-data/licence/readme_en/) (open, attribution required, no endorsement implied) | "Contains information licensed under the Environment and Climate Change Canada Data Servers End-use Licence" |
+3 -1
View File
@@ -340,6 +340,8 @@ Choose a first-run mission, or try these in order. The GIFs show Google Photorea
7. **Talk to it** _(needs an OpenAI key)_: _"Take me to LAX and select the nearest airborne aircraft."_ 7. **Talk to it** _(needs an OpenAI key)_: _"Take me to LAX and select the nearest airborne aircraft."_
8. **Come home.** Hit **Reset Globe** — or just say _"zoom out to a globe view."_ 8. **Come home.** Hit **Reset Globe** — or just say _"zoom out to a globe view."_
Open **Visual Presets → Globe Engine** to switch between Cesium and experimental Navara. The globe switches in place at the current view and remembers your choice. A link with `?engine=cesium` or `?engine=navara` overrides the saved choice. A fresh visit starts with Cesium.
**Keyboard:** `1`–`6` visual styles · `H` HUD · `D` detection · `C` CCTV · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe. **Keyboard:** `1`–`6` visual styles · `H` HUD · `D` detection · `C` CCTV · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe.
--- ---
@@ -384,7 +386,7 @@ Click **GEV MIC**, grant the microphone, and just talk. This is more than a voic
- **🎬 Cinematic framing.** _"Show me the planes overhead"_ pulls the camera back, angles it, and frames the live traffic like a director. - **🎬 Cinematic framing.** _"Show me the planes overhead"_ pulls the camera back, angles it, and frames the live traffic like a director.
- **🔒 Honest and secure.** The agent only confirms actions that succeeded. Your `OPENAI_API_KEY` never touches the browser; the client only gets a short-lived session token. If you choose **USE CHATGPT OAUTH** in Provider Settings, the local server uses that sign-in only to mint the same kind of short-lived Realtime credential, and refuses the OAuth path for non-loopback clients. - **🔒 Honest and secure.** The agent only confirms actions that succeeded. Your `OPENAI_API_KEY` never touches the browser; the client only gets a short-lived session token. If you choose **USE CHATGPT OAUTH** in Provider Settings, the local server uses that sign-in only to mint the same kind of short-lived Realtime credential, and refuses the OAuth path for non-loopback clients.
Thirty-five tools (thirty-four unless an Overpass server is set), four jobs — the commands below come straight from the product's voice test suite and tool playbook: Thirty tools, four jobs — the commands below come straight from the product's voice test suite and tool playbook:
**🎥 Direct it** — drone-operator camera verbs: **🎥 Direct it** — drone-operator camera verbs:
-1
View File
@@ -132,7 +132,6 @@ God's Eye View's tools are also served to MCP clients: over stdio (`npm run mcp`
- **What the panel's requests can reach.** In clients that display MCP Apps, the panel loads the app through the `panel_request` tool, which requests paths on the app's server. It is marked for the panel only and requires a key that each MCP server puts in its panel page, which keeps it from clients that list tools to the model without loading the panel. That key is not access control: any MCP client can read the panel page, key included, and then reach the app's files and data routes through `panel_request`, much as its other tools reach the data. Provider Settings, credential and model endpoints, `/mcp` and the development server's internal routes are refused to every caller. - **What the panel's requests can reach.** In clients that display MCP Apps, the panel loads the app through the `panel_request` tool, which requests paths on the app's server. It is marked for the panel only and requires a key that each MCP server puts in its panel page, which keeps it from clients that list tools to the model without loading the panel. That key is not access control: any MCP client can read the panel page, key included, and then reach the app's files and data routes through `panel_request`, much as its other tools reach the data. Provider Settings, credential and model endpoints, `/mcp` and the development server's internal routes are refused to every caller.
- **The panel carries the two browser keys.** The panel build (`npm run build:panel`) includes the same Google Maps key and Cesium ion token as the app, and they run inside the client's panel page, on the client's site. A key restricted by HTTP referrer must also allow that page, or the panel cannot load tiles. To keep the app's own keys narrower, build the panel with separately restricted keys in the environment of `npm run build:panel`. - **The panel carries the two browser keys.** The panel build (`npm run build:panel`) includes the same Google Maps key and Cesium ion token as the app, and they run inside the client's panel page, on the client's site. A key restricted by HTTP referrer must also allow that page, or the panel cannot load tiles. To keep the app's own keys narrower, build the panel with separately restricted keys in the environment of `npm run build:panel`.
- **Embed mode is not framable by default.** `?embed=1` documents keep `X-Frame-Options: DENY` and `frame-ancestors 'none'` unless `GEV_EMBED_FRAME_ANCESTORS` names the pages that may frame them; a framing page can change what the app shows. Other documents can never be framed. - **Embed mode is not framable by default.** `?embed=1` documents keep `X-Frame-Options: DENY` and `frame-ancestors 'none'` unless `GEV_EMBED_FRAME_ANCESTORS` names the pages that may frame them; a framing page can change what the app shows. Other documents can never be framed.
- **A plain link never loads a billed Street View panorama without a click.** A share link with `sl=google,<lat>,<lon>` flies there, switches to Google 3D and lights Street View, then stops: the panel is armed and the photo loads only when the visitor presses OPEN GOOGLE STREET VIEW (one lookup and one panorama load on the browser Maps key, no retries, no geocoding or server call). A view posted into the embedded app (the God's Eye View panel, or a page `GEV_EMBED_FRAME_ANCESTORS` allows), and the panel page's own opening link for a tool result issued in the last five minutes, are that client's own request and open the photo; freshness is judged only by the result's `issued_at`, so a result older than five minutes only arms, whether the panel was mounted again (reopening a conversation) or got it later on the same mount, while one re-sent or remounted within five minutes of issue opens the photo again; adding `?embed=1` to a published link does not lift the gate (in a frame the link only arms; the panel that holds OPEN is hidden there, so a posted view is the way to open a photo). The OPEN press uses the link's point only while the ground under the screen centre is within 1 km of it, or when the screen centre cannot be picked at all (no ground picking, or a globe with no size yet); with the sky in the middle it opens nothing. The armed lookup dies when its source is switched off, when its map leaves, or when any other photo opens or closes. A link pasted into an open tab follows the same rule, and a link's source switch and layer are never saved as the recipient's own preferences. A link that follows an entity ignores `sl`. Mapillary, KartaView and Panoramax links open their photo at once; none of them bills per image.
## Scope & expectations ## Scope & expectations
+22 -2
View File
@@ -2,8 +2,8 @@
The project depends on third-party npm packages whose own licenses apply. This The project depends on third-party npm packages whose own licenses apply. This
file records the packages added for the browser-local SDR feature and the file records the packages added for the browser-local SDR feature and the
Street Level viewer; the complete resolved dependency inventory remains in Street Level viewer, and the CesiumJS code ported into `src/geo/`; the complete
`package-lock.json`. resolved dependency inventory remains in `package-lock.json`.
## Web RTL-SDR ## Web RTL-SDR
@@ -45,3 +45,23 @@ Street Level viewer; the complete resolved dependency inventory remains in
- Source: <https://github.com/mrdoob/three.js> - Source: <https://github.com/mrdoob/three.js>
- License: MIT - License: MIT
- License text: <https://github.com/mrdoob/three.js/blob/dev/LICENSE> - License text: <https://github.com/mrdoob/three.js/blob/dev/LICENSE>
## CesiumJS (portions ported to src/geo)
- Package: `cesium` 1.138.0 (`@cesium/engine` 22.3.0); the files in `src/geo/`
are adapted from `@cesium/engine/Source/Core` (`Math.js`, `Ellipsoid.js`,
`scaleToGeodeticSurface.js`, `Cartographic.js`, `Cartesian2.js`,
`Cartesian3.js`, `Matrix3.js`, `Matrix4.js`, `Quaternion.js`,
`Transforms.js`, `BoundingSphere.js`, `EllipsoidGeodesic.js`, `JulianDate.js`,
`Simon1994PlanetaryPositions.js`) and say so in their first
line. The changes: the classes' static functions became module functions,
vectors, geodetic positions and quaternions became plain-data instances of
module-private constructors (own enumerable fields only, no methods),
matrices plain arrays, and the ellipsoid a frozen object; the debug
argument checks were left out, as in Cesium's release build; only the subset
gev uses was kept.
- Author: Copyright 2011-2024 CesiumJS Contributors
- Source: <https://github.com/CesiumGS/cesium>
- License: Apache License 2.0
- License text: `node_modules/@cesium/engine/LICENSE.md` (shipped with the
dependency) and <https://www.apache.org/licenses/LICENSE-2.0>
+58
View File
@@ -0,0 +1,58 @@
// Build guard: the Cesium-mode page must not load three or Navara. The
// initial chunks are every entry chunk and what it imports statically;
// navaraMode.js reaches Navara only through import(), so three and
// @navaramap/* belong to lazy chunks. This checks the bundle's module ids
// (what Rollup put in each chunk), and fails the build otherwise.
/**
* Module ids that only the Navara engine may load, when no package list is
* given (the browser build passes the full dependency closure,
* navaraDependencies.js).
*/
export const ENGINE_ONLY_MODULES =
/[\\/]node_modules[\\/](?:three|@navaramap[\\/][^\\/]+|postprocessing)[\\/]/;
/**
* Engine-only modules in initial chunks of a Rollup bundle, sorted by chunk.
* @param {Record<string, { type: string, isEntry?: boolean, imports?: string[], moduleIds?: string[] }>} bundle
* @returns {{ chunk: string, module: string }[]}
*/
export function initialChunkViolations(bundle, pattern = ENGINE_ONLY_MODULES) {
const initial = new Set();
const visit = (fileName) => {
const chunk = bundle[fileName];
if (!chunk || chunk.type !== 'chunk' || initial.has(fileName)) return;
initial.add(fileName);
for (const imported of chunk.imports ?? []) visit(imported);
};
for (const [fileName, chunk] of Object.entries(bundle))
if (chunk.type === 'chunk' && chunk.isEntry) visit(fileName);
const violations = [];
for (const fileName of [...initial].sort())
for (const module of bundle[fileName].moduleIds ?? [])
if (pattern.test(module)) violations.push({ chunk: fileName, module });
return violations;
}
/** The Rollup plugin: fails generateBundle when an initial chunk holds three or Navara. */
export function initialChunkGuard({
log = console.log,
pattern = ENGINE_ONLY_MODULES,
} = {}) {
return {
name: 'gev-initial-chunk-guard',
generateBundle(_options, bundle) {
const violations = initialChunkViolations(bundle, pattern);
if (violations.length) {
this.error(
'Navara-only modules in the Cesium-mode initial chunks:\n' +
violations
.map(({ chunk, module }) => ` ${chunk}: ${module}`)
.join('\n'),
);
return;
}
log('[initial-chunks] no Navara-only modules in the initial chunks');
},
};
}
+65
View File
@@ -0,0 +1,65 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
/** Where @navaramap/three keeps its prebuilt worker assets, or null. */
function defaultAssetsDir() {
try {
const entry = fileURLToPath(import.meta.resolve('@navaramap/three'));
return path.join(path.dirname(entry), 'assets');
} catch {
return null;
}
}
/**
* Navara's prebuilt workers (@navaramap/three/dist/assets) load their wasm by
* file name next to themselves (`new URL('x-hash.wasm', self.location)`), a
* reference the bundler cannot see. When a build includes Navara (the
* ?engine=navara chunk), copy every such wasm the bundle lacks into
* `assets/` under its own name. A build without Navara gets none.
* @param {{ assetsDir?: string | null }} [options]
*/
export function navaraWorkerAssets({ assetsDir } = {}) {
return {
name: 'navara-worker-assets',
generateBundle(_options, bundle) {
const usesNavara = Object.values(bundle).some(
(file) => file.type === 'chunk' && file.code.includes('navara_wasm'),
);
if (!usesNavara) return;
const dir = assetsDir === undefined ? defaultAssetsDir() : assetsDir;
if (!dir || !existsSync(dir)) return;
for (const name of readdirSync(dir).sort()) {
if (!name.endsWith('.wasm')) continue;
const fileName = `assets/${name}`;
if (bundle[fileName]) continue;
this.emitFile({
type: 'asset',
fileName,
source: readFileSync(path.join(dir, name)),
});
}
},
};
}
const NAVARA_MODULE = /\/src\/globe\/navara\//;
/**
* Rollup chunkFileNames: the lazy chunk holding the Navara engine
* (src/globe/navara, with the Navara packages it pulls in) is named
* `navara-engine-[hash].js`; every other chunk keeps Vite's default name.
* Naming only (not manualChunks, which would pull modules shared with the
* Cesium entry into the Navara chunk), so the chunking is unchanged.
* @param {{ isEntry?: boolean, moduleIds?: string[] }} chunk
* @returns {string}
*/
export function navaraChunkFileNames(chunk) {
const navara =
!chunk.isEntry &&
(chunk.moduleIds ?? []).some((id) =>
NAVARA_MODULE.test(id.replaceAll('\\', '/')),
);
return navara ? 'assets/navara-engine-[hash].js' : 'assets/[name]-[hash].js';
}
+73
View File
@@ -0,0 +1,73 @@
// The packages only the Navara engine brings: the dependency closure of
// @navaramap/three and @navaramap/three-default-plugin (dependencies and
// peer dependencies, read from node_modules), without the packages the
// app's other bundled dependencies reach too (Cesium is external), plus
// three and postprocessing, which
// the Cesium-mode page never loads statically either. The initial-chunk
// guard (initialChunkGuard.js) fails the build when one of them is in an
// initial chunk.
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
export const NAVARA_ROOTS = Object.freeze([
'@navaramap/three',
'@navaramap/three-default-plugin',
]);
const ALWAYS_ENGINE_ONLY = Object.freeze(['three', 'postprocessing']);
// Not bundled: vite-plugin-cesium serves Cesium as a script, so what only
// Cesium reaches (draco3d, …) is not in the app's bundled graph.
const EXTERNAL = Object.freeze(['cesium']);
function readManifest(root, name) {
const file = path.join(root, 'node_modules', name, 'package.json');
return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null;
}
/** Every package reachable from `names` through installed manifests. */
export function dependencyClosure(root, names) {
const seen = new Set();
const queue = [...names];
while (queue.length) {
const name = queue.pop();
if (seen.has(name)) continue;
const manifest = readManifest(root, name);
if (!manifest) continue;
seen.add(name);
for (const next of Object.keys({
...manifest.dependencies,
...manifest.peerDependencies,
}))
queue.push(next);
}
return seen;
}
/** The engine-only package names, sorted. */
export function navaraOnlyPackages(root) {
const app = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'));
const navara = dependencyClosure(root, NAVARA_ROOTS);
const appRoots = Object.keys(app.dependencies ?? {}).filter(
(name) =>
!navara.has(name) &&
!ALWAYS_ENGINE_ONLY.includes(name) &&
!EXTERNAL.includes(name),
);
const shared = dependencyClosure(root, appRoots);
return [
...new Set([
...[...navara].filter((name) => !shared.has(name)),
...ALWAYS_ENGINE_ONLY,
]),
].sort();
}
/** A module-id pattern matching node_modules/<package>/ for the names. */
export function packagePattern(names) {
const escaped = names.map((name) =>
name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace('/', '[\\\\/]'),
);
return new RegExp(
`[\\\\/]node_modules[\\\\/](?:${escaped.join('|')})[\\\\/]`,
);
}
+28
View File
@@ -6,6 +6,9 @@
* PANEL_BASE next to the app. * PANEL_BASE next to the app.
*/ */
import cesium from 'vite-plugin-cesium';
import { fileURLToPath } from 'node:url';
// The panel loads these paths (src/tools/globePanel.js); a test keeps the // The panel loads these paths (src/tools/globePanel.js); a test keeps the
// two in step. // two in step.
export const PANEL_BASE = '/panel/'; export const PANEL_BASE = '/panel/';
@@ -18,6 +21,27 @@ export function panelBuildConfig(config) {
return { return {
...config, ...config,
base: PANEL_BASE, base: PANEL_BASE,
plugins: (config.plugins ?? []).map((plugin) =>
plugin?.name === 'vite-plugin-cesium' ? cesium() : plugin,
),
resolve: {
...config.resolve,
alias: [
// The panel runs Cesium: the Navara mode and Navara's wind mesh
// (the panel's only paths to Navara and three) become stubs.
{
find: /^(?:\.\/|.*\/app\/)navaraMode\.js$|^.*\/globe\/navara\/windFlowMesh\.js$/,
replacement: fileURLToPath(
new URL('./panelNavara.js', import.meta.url),
),
},
...(Array.isArray(config.resolve?.alias)
? config.resolve.alias
: Object.entries(config.resolve?.alias ?? {}).map(
([find, replacement]) => ({ find, replacement }),
)),
],
},
build: { build: {
...config.build, ...config.build,
outDir: PANEL_OUT_DIR, outDir: PANEL_OUT_DIR,
@@ -26,6 +50,10 @@ export function panelBuildConfig(config) {
cssCodeSplit: false, cssCodeSplit: false,
rollupOptions: { rollupOptions: {
...config.build?.rollupOptions, ...config.build?.rollupOptions,
// The panel is a single chunk; its alias excludes the Navara engine.
plugins: (config.build?.rollupOptions?.plugins ?? []).filter(
(plugin) => plugin?.name !== 'gev-initial-chunk-guard',
),
output: { inlineDynamicImports: true }, output: { inlineDynamicImports: true },
}, },
}, },
+45
View File
@@ -0,0 +1,45 @@
// A Vite build through the panel alias resolves renderers.js's relative
// import of Navara's wind mesh to the stub (scripts/build-panel.mjs uses the
// same alias).
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { build } from 'vite';
import { panelBuildConfig } from './panel.js';
test('a Vite build resolves the relative wind-mesh import through the panel alias', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'panel-alias-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(join(root, 'src/app/layers'), { recursive: true });
mkdirSync(join(root, 'src/globe/navara'), { recursive: true });
writeFileSync(
join(root, 'src/app/layers/renderers.js'),
"export const load = () => import('../../globe/navara/windFlowMesh.js');\n",
);
writeFileSync(
join(root, 'src/globe/navara/windFlowMesh.js'),
'export const real = true;\n',
);
const { resolve } = panelBuildConfig({ plugins: [] });
const [output] = await build({
configFile: false,
root,
logLevel: 'silent',
resolve,
build: {
outDir: join(root, 'out'),
write: false,
lib: {
entry: join(root, 'src/app/layers/renderers.js'),
formats: ['es'],
fileName: 'x',
},
rollupOptions: { output: { inlineDynamicImports: true } },
},
});
const [chunk] = output.output;
assert.match(chunk.code, /Navara is unavailable in the panel/);
assert.doesNotMatch(chunk.code, /real = true/);
});
+12
View File
@@ -0,0 +1,12 @@
// The panel host transports Cesium assets; Navara workers need a web page.
export function createNavaraScene() {
throw new Error('Navara is unavailable in the panel');
}
export function navaraToolBackend() {
throw new Error('Navara is unavailable in the panel');
}
export function createWindFlowMesh() {
throw new Error('Navara is unavailable in the panel');
}
+44
View File
@@ -0,0 +1,44 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createBrowserViteConfig } from './vite.js';
import { panelBuildConfig } from './panel.js';
test('panel HTML lists Cesium assets for the host resource loader', () => {
const config = panelBuildConfig(createBrowserViteConfig());
const plugin = config.plugins.find(
({ name }) => name === 'vite-plugin-cesium',
);
plugin.config(config, { command: 'build' });
assert.equal(typeof plugin.transformIndexHtml, 'function');
const tags = plugin.transformIndexHtml();
assert.ok(tags.some(({ attrs }) => attrs.src === '/panel/cesium/Cesium.js'));
assert.ok(
tags.some(
({ attrs }) => attrs.href === '/panel/cesium/Widgets/widgets.css',
),
);
});
test('the panel replaces Navara entry points and retains browser aliases', () => {
const browser = createBrowserViteConfig();
const config = panelBuildConfig(browser);
const alias = config.resolve.alias.find(({ find }) =>
find.test('./navaraMode.js'),
);
assert.ok(alias.replacement.endsWith('/build/panelNavara.js'));
// Navara's wind mesh imports three; the Cesium wind backend stays.
assert.equal(
'../../globe/navara/windFlowMesh.js'.replace(alias.find, alias.replacement),
alias.replacement,
);
assert.equal(alias.find.test('../../globe/cesium/windFlow.js'), false);
assert.ok(config.resolve.alias.includes(browser.resolve.alias[0]));
});
test('Three imports and requires share its ESM entry', () => {
const { resolve } = createBrowserViteConfig();
assert.deepEqual(resolve.dedupe, ['three']);
const alias = resolve.alias.find(({ find }) => find.test('three'));
assert.ok(alias.replacement.endsWith('/three/build/three.module.js'));
assert.equal(alias.find.test('three/addons/loaders/GLTFLoader.js'), false);
});
+50 -2
View File
@@ -5,6 +5,9 @@ import { VUE_PLUGIN_OPTIONS } from './vueOptions.js';
import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js'; import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js';
import { embedFramingPlugin } from './embed-framing.js'; import { embedFramingPlugin } from './embed-framing.js';
import { panelBuildPlugin } from './panel.js'; import { panelBuildPlugin } from './panel.js';
import { navaraChunkFileNames, navaraWorkerAssets } from './navaraAssets.js';
import { initialChunkGuard } from './initialChunkGuard.js';
import { navaraOnlyPackages, packagePattern } from './navaraDependencies.js';
/** /**
* The repository root (this file lives at `<root>/build/vite.js`), as a * The repository root (this file lives at `<root>/build/vite.js`), as a
@@ -183,7 +186,9 @@ export function createBrowserViteConfig({
apiResponseHeadersPlugin(), apiResponseHeadersPlugin(),
// Next, so no provider route answers a Host the server does not allow. // Next, so no provider route answers a Host the server does not allow.
hostCheckPlugin(), hostCheckPlugin(),
cesium(), // Keep the SDK asset serving/copying and external imports, but let the
// scene load the script and stylesheet only when Cesium is requested.
{ ...cesium(), transformIndexHtml: undefined },
// Application views are Vue single-file components. // Application views are Vue single-file components.
vue(VUE_PLUGIN_OPTIONS), vue(VUE_PLUGIN_OPTIONS),
...plugins, ...plugins,
@@ -194,6 +199,20 @@ export function createBrowserViteConfig({
// A production build must not clean the dependency cache a running dev // A production build must not clean the dependency cache a running dev
// server is still serving optimized module URLs from. // server is still serving optimized module URLs from.
...(command === 'build' ? { cacheDir: 'node_modules/.vite-build' } : {}), ...(command === 'build' ? { cacheDir: 'node_modules/.vite-build' } : {}),
resolve: {
dedupe: ['three'],
alias: [
{
find: /^three$/,
replacement: fileURLToPath(
new URL(
'../node_modules/three/build/three.module.js',
import.meta.url,
),
),
},
],
},
optimizeDeps: { optimizeDeps: {
// First reached through the SDR worker or a dynamic import. Pre-bundle // First reached through the SDR worker or a dynamic import. Pre-bundle
// them at startup so first use cannot invalidate already-transformed // them at startup so first use cannot invalidate already-transformed
@@ -206,6 +225,18 @@ export function createBrowserViteConfig({
// Loaded on demand when the first street-level image opens. // Loaded on demand when the first street-level image opens.
'mapillary-js', 'mapillary-js',
], ],
// Navara's wasm-bindgen glue loads new URL('x_bg.wasm', import.meta.url)
// and its workers by URL; both must resolve next to the package's own
// files, not inside .vite/deps. Dev server only: the experimental
// Navara engine is a dynamic import outside the Cesium-mode bundle.
exclude: [
'@navaramap/three',
'@navaramap/three-default-plugin',
'@navaramap/engine',
'@navaramap/engine-api',
'@navaramap/engine-worker',
'@navaramap/engine-font-worker',
],
}, },
server: { server: {
host: host || 'localhost', host: host || 'localhost',
@@ -258,6 +289,23 @@ export function createBrowserViteConfig({
mapillaryToken ?? '', mapillaryToken ?? '',
), ),
}, },
build: { chunkSizeWarningLimit: 1500 }, build: {
chunkSizeWarningLimit: 1500,
// Build only: the wasm Navara's workers load by name (the Navara
// engine is a separate chunk loaded on ?engine=navara).
// The lazy chunk with the Navara engine is named navara-engine.
rollupOptions: {
// initialChunkGuard: no three/Navara module in the initial chunks.
plugins: [
navaraWorkerAssets(),
initialChunkGuard({
pattern: packagePattern(
navaraOnlyPackages(fileURLToPath(new URL('..', import.meta.url))),
),
}),
],
output: { chunkFileNames: navaraChunkFileNames },
},
},
}; };
} }
-18
View File
@@ -1,18 +0,0 @@
- "Show me X on Street View" (or on Mapillary, KartaView or Panoramax): a
`show_street_level` voice and typed action flies to the place, switches to
Google 3D when the source needs it, lights that source in Street Level,
turns the layer on and opens the nearest photo, saying which step failed.
With no place named it looks under the screen centre, as OPEN does. Views
and share links carry the same request (`street_level`, `sl=`). A plain
`sl=google` link never loads a billed panorama by itself: it arms Street
View and leaves the load to OPEN GOOGLE STREET VIEW; embedded views open it,
and so does the God's Eye View panel for a result issued in the last five
minutes; a link whose source has no working key says so. A request stops
when the user navigates, or enters cockpit, clean view, recording or scene
playback, before its photo loads (collapsing the panel does not stop it: the
photo reveals it); a new photo replaces the open one in place, so EXPAND
stays. When nothing is found and a 360°, flat or SINCE filter is on, the
answer names the filter. Failure cards are in the interface language. An
unavailable map stack now reports the
controller's own reason instead of always asking for a Cesium ion token
(Said Turksever, @asturksever, #1113).
+10
View File
@@ -0,0 +1,10 @@
- Add Navara, an experimental three.js/WASM globe, as a second engine next to
Cesium, switchable live under VISUAL PRESETS → Globe Engine or with
`?engine=navara`. Navara draws at CSS pixels like Cesium; Navara HD
(`?engine=navara-hd`) draws at the screen's full resolution and needs about
1 GiB more on a Retina screen. Layers keep their data across a switch and
draw on both engines; Cesium loads only when a Cesium globe or a
Cesium-only feature needs it (German, @gekh, #1555).
- Keep the cockpit camera steady near the ground: it no longer jumps up and
down when an aircraft's reported altitude sits below the globe's terrain,
on either engine (German, @gekh, #1555).
@@ -1,6 +0,0 @@
- When the globe stops drawing after a render error or a lost WebGL context,
an alert dialog says so instead of leaving a frozen or black globe behind
working controls. Retry restarts drawing (Reload after a lost context), and
Reset view reopens the app without the view restored from the address bar.
Cesium's "Rendering has stopped" panel no longer appears in the app
(German, @gekh, #1579).
+4
View File
@@ -195,6 +195,10 @@ signal })` returns one tile or null for a transparent tile, each as an
`ImageBitmap`, `ImageData`, canvas or `VideoFrame` of exactly the requested `ImageBitmap`, `ImageData`, canvas or `VideoFrame` of exactly the requested
size, which the layer draws once and closes. Without them the layers read the size, which the layer draws once and closes. Without them the layers read the
image and tile routes (`WeatherFrameFeed` in `src/layers/weather/source.js`). image and tile routes (`WeatherFrameFeed` in `src/layers/weather/source.js`).
On globe surfaces, a source with `getFrameTile` currently requires Cesium for
radar, regional infrared and lightning. Navara pauses those products with
`This weather source requires Cesium`; decoded global infrared images and
the whole-frame images used over 3D tiles draw through the shared display.
`application/services` accepts boundary, terrain, regional-context, weather and `application/services` accepts boundary, terrain, regional-context, weather and
summary services; its weather service supplies cockpit local conditions, not the summary services; its weather service supplies cockpit local conditions, not the
+120 -12
View File
@@ -19,6 +19,17 @@ excluded. The formatter validates every entry before writing any file.
New world text labels must use the world-overlay host; Cesium text labels are forbidden. New world text labels must use the world-overlay host; Cesium text labels are forbidden.
## QA scripts
New `scripts/qa-*.mjs` scenarios use `scripts/qa/browser.mjs` for headless launch
and bounded shutdown, and `scripts/qa/ready.mjs` for application, camera and
frame readiness. Select the GPU backend with `QA_ANGLE`. Keep timed pauses only
when elapsed time is part of the assertion, and explain that measurement.
Read scene state through the globe contract or the QA probe; native reads belong
only in explicit engine-adapter checks. The counters in
`node scripts/report-qa-harness.mjs` must not grow; lower the baseline with
`--write-baseline` after removing duplication.
## Current component ownership ## Current component ownership
Package imports use `gods-eye-view`; `package.json` is the authoritative Package imports use `gods-eye-view`; `package.json` is the authoritative
@@ -30,8 +41,10 @@ export inventory. Use declared exports rather than reaching into internal files.
| `src/standalone/` | Select the default catalog, local sources and setup controls | | `src/standalone/` | Select the default catalog, local sources and setup controls |
| `src/ui/` | Navigation generations, restoration, visual state, panel snapshots and their subscriptions | | `src/ui/` | Navigation generations, restoration, visual state, panel snapshots and their subscriptions |
| `src/data/` | Lifecycle, context and feed state; legacy default-layer facades | | `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/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` |
| `src/services/` | Supplied application operations; scene construction owns caches and cancellation | | `src/services/` | Supplied application operations; scene construction owns caches and cancellation |
| `src/voice/` | Portable action schemas/session, common controls, action execution and separate protocol adapters | | `src/voice/` | Portable action schemas/session, common controls, action execution and separate protocol adapters |
| `server/providers/` | Node route factories, process-scoped provider caches and shutdown cleanup | | `server/providers/` | Node route factories, process-scoped provider caches and shutdown cleanup |
@@ -53,18 +66,70 @@ See [application construction](APPLICATION.md) and the
2. `scripts/check-package-boundaries.mjs` builds every declared export without 2. `scripts/check-package-boundaries.mjs` builds every declared export without
app Vite configuration or environment files. `scripts/package-boundaries.json` app Vite configuration or environment files. `scripts/package-boundaries.json`
assigns each export exactly once and lists its owned modules and external assigns each export exactly once and lists its owned modules and external
dependencies. Unused imports still count. Node exports have only a `node` dependencies. Shared foundations use directory entries: `src/i18n/`,
`src/geo/` and `src/globe/`. An entry ending in `/` owns the files below it;
`src/globe/` excludes `cesium/`, `navara/` and `testing/`, which need separate
ownership. The import-direction gate still enforces engine rules.
Aggregate groups use directories where their module lists cover the directory;
small groups retain explicit snapshots outside the shared foundations.
Unused imports still count. Node exports have only a `node`
condition; browser groups cannot use build-only dependency exceptions. condition; browser groups cannot use build-only dependency exceptions.
Run `node scripts/check-package-boundaries.mjs --write` to regenerate each
group's `modules` list in `scripts/package-boundaries.json`, retaining useful
entries in order and appending newly reached paths in sorted order. Directory
entries stay directories while they own reached modules; they never expand
into file lists. Without `--write`, the check rejects both undeclared reached
modules and declarations no longer reached, printing additions and removals
for each changed group. A directory becomes stale only when no reached module
belongs to it. Review the manifest diff like a lockfile diff before accepting
changes to ownership.
Portable source graphs cannot reach application/rendering, Node, Cesium or Portable source graphs cannot reach application/rendering, Node, Cesium or
browser globals. This includes `sources/*`, dedicated `layers/*/source` exports, browser globals. This includes `sources/*`, dedicated `layers/*/source` exports,
flight/military/vessel record and ingestion exports, action schemas, the session flight/military/vessel record and ingestion exports, action schemas, the session
interface, lifecycle and feed state. The browser-global rule reserves platform interface, lifecycle and feed state, and every runtime file in `src/geo/` (the
names such as `document` and `window` in these modules; it is an architectural engine-neutral geometry ported from Cesium). The browser-global rule reserves
check, not a JavaScript sandbox. Common voice controls cannot depend on a platform names such as `document` and `window` in these modules; it is an
architectural check, not a JavaScript sandbox. The globe contract
(`src/globe/*.js` and `src/globe/testing/**`) cannot reach `cesium`,
`@cesium/*`, `three`, `postprocessing` or `@navaramap/*`, directly or through
helpers, nor import an engine implementation; the `src/globe/cesium/**` graph
reaches no Navara/three package or `src/globe/navara/**` module, and the
`src/globe/navara/**` graph no Cesium package or `src/globe/cesium/**` module,
through helpers too. Outside its own directory, `src/globe/navara/**` is
reached only through a literal `import()` (the app's `?engine=navara` mode), so
Navara stays out of the Cesium-mode bundle. Common voice controls cannot depend on a
Realtime protocol implementation. Negative fixtures cover indirect helpers, Realtime protocol implementation. Negative fixtures cover indirect helpers,
self-package imports, symlinks and unreachable files. 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, and the
`<script>` blocks of Vue components count as runtime modules. A module outside
the Navara engine reaches it only through `import()`: a static import of the
same specifier fails even beside a dynamic one. 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, and
it adds no `widgets.css` when the page's markup has already run `Cesium.js`
(the MCP panel). The panel build replaces `src/app/navaraMode.js` and
`src/globe/navara/windFlowMesh.js` with stubs (`build/panelNavara.js`), so the
panel carries no Navara or three. 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 two
exceptions are the Bhote Koshi event and locator 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's
Cesium rendering (the particle canvas, its GPU path and the relief shading)
lives in `src/globe/cesium/`; `src/layers/wind/` keeps the data, model,
presentation and engine-neutral inspection. The remaining annotation migration
is separate debt.
Source-only factories use dedicated `layers/<family>/source` exports where Source-only factories use dedicated `layers/<family>/source` exports where
available, preserving the same factory implementation without loading rendering. available, preserving the same factory implementation without loading rendering.
Record normalization and endpoint policies have plain owners separate from Record normalization and endpoint policies have plain owners separate from
@@ -391,10 +456,12 @@ the caller.
## Visual effects ## Visual effects
`ui/effects` exports the effects controller and existing preset definitions. `ui/effects` exports the effects controller and existing preset definitions.
It owns shader stages and their clock, with explicit render ownership callbacks. It owns transitions and their clock, with explicit render ownership callbacks,
Construction installs no stages or frame callbacks. Stop animation before and writes styles, bloom and sharpening through `engine.render`. Construction
releasing UI consumers, then destroy to remove owned stages and restore the installs no stages or frame callbacks. Stop animation before releasing UI
borrowed bloom state. `ui/effects/bloom` exposes the pure intensity/version helpers consumers; controller destruction disables its effects. Cesium's
`src/globe/cesium/postStages.js` owns stage creation and destruction and restores
the borrowed bloom state when the engine is destroyed. `ui/effects/bloom` exposes the pure intensity/version helpers
without loading the renderer. UI presentation and product-action coordination without loading the renderer. UI presentation and product-action coordination
remain in their callers. remain in their callers.
@@ -506,7 +573,7 @@ perform no network requests and discover no additional modules.
`maps/controller` coordinates scene changes and lifetimes. `maps/imagery`, `maps/controller` coordinates scene changes and lifetimes. `maps/imagery`,
`maps/terrain` and `maps/3d` supply constructors; `maps/defaults` selects the `maps/terrain` and `maps/3d` supply constructors; `maps/defaults` selects the
standard sources and their setup/fallback policy. The standalone facade wires standard sources and their setup/fallback policy. The standalone facade wires
these to the application's render governor. these to their engine's render requests.
A registry supplies `sources`, `defaultId`, `unknownId` and optional `recoveryId`. A registry supplies `sources`, `defaultId`, `unknownId` and optional `recoveryId`.
Each source has a user-facing `descriptor`, availability/reason, and either an Each source has a user-facing `descriptor`, availability/reason, and either an
@@ -550,12 +617,22 @@ services into each family factory. Standalone provider selection lives in
`src/standalone/layerSources.js`. The construction export has its own checked `src/standalone/layerSources.js`. The construction export has its own checked
dependency graph, excluding standalone setup and compatibility layer instances. 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` through `display`. All
five drawing primitives are required by the globe contract. 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 Both aircraft layers share the catalog's classification registry; launches use
its satellites and Contacts uses its aircraft, vessels and installations. Data its satellites and Contacts uses its aircraft, vessels and installations. Data
registration, controls and voice actions read those same instances. Destruction registration, controls and voice actions read those same instances. Destruction
remains the manager's responsibility; classification also observes application remains the manager's responsibility; classification also observes application
abort when startup has not reached registration. Scene engines remain page-owned, abort when startup has not reached registration. The session retains models while
so this change does not introduce multiple simultaneous viewers. 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 Direct `src/data` compatibility entries retain their old defaults and testing
exports. Browser regression probes use the registered instance's testing surface exports. Browser regression probes use the registered instance's testing surface
@@ -594,6 +671,23 @@ listener cleanup. Application data assembly constructs both owners explicitly;
`data/manager` is the compatibility facade for direct callers. The ordinary `data/manager` is the compatibility facade for direct callers. The ordinary
state subscriptions retain their existing event contract. 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. `app/sceneHost.js` owns the transition queue,
request ordering and recovery decision. Application acquisition, attachment,
restoration and renderer release run in that queue, and shell calls use it too.
Nested work receives an explicit transaction; outside calls wait for the whole
transition. A replacement application shares the transition owner with fresh
parts, and a shell used alone creates its own host. Pick ownership, selection
dispatch and render demand belong to
`scene.services`; render demand drives the engine contract, with no dependency
from the Cesium render backend to the governor. Overlay and tracked-readout
services still include module singletons; debug access still exposes the native
viewer and tileset.
## UI state owners ## UI state owners
- `ui/navigation` owns navigation generations, pending search presentation and - `ui/navigation` owns navigation generations, pending search presentation and
@@ -768,3 +862,17 @@ CSS rather than mapping machine states to universal severity. CCTV owns
frame/source and calibration rules and their data attributes, while Weather frame/source and calibration rules and their data attributes, while Weather
owns coverage labels. The rail card keeps node identity, header focus, owns coverage labels. The rail card keeps node identity, header focus,
disclosure and teardown, and does not announce every card update. disclosure and teardown, and does not announce every card update.
## Engine implementation modules
Cesium's viewer construction, atmosphere compatibility, zoom-out guard, starry
sky, map scene application, imagery and terrain live in `src/globe/cesium/`.
The public viewer export still resolves through `package.json`. Celestial
rendering, screen-event constants, scene projection and the legacy trail
backend also live there. Sonar commands and post stages are engine internals;
feature controllers call `engine.render`.
Imported-file parsing (`geoFileImport.js`, `geoImportRecords.js`,
`geoImportKml.js`) returns plain records. `geoImportDisplay.js` consumes them
through ground, movers, lines and imagery. The UI owns file bytes, visibility
and attachment lifetime, with no DataSource or engine-specific import backend.
+368 -226
View File
@@ -167,6 +167,305 @@ across the antimeridian, so they cannot use the strict ±180 check as-is, and
`src/sources/adsbRecords.js` and `server/providers/common/geo.js` keep their own `src/sources/adsbRecords.js` and `server/providers/common/geo.js` keep their own
implementations. implementations.
## Navara globe engine — November 5, 2026
Navara, an experimental three.js/WASM globe (`@navaramap/three` 0.2.1), runs
next to Cesium. It is picked under VISUAL PRESETS → Globe Engine or with
`?engine=navara`; Cesium stays the default. Switching is live: the page keeps
one application, and only the globe-bound parts move to the next engine.
- **Two Navara choices** (`src/app/engineChoice.js`). **Navara** draws at CSS
pixels without MSAA, as Cesium does; SMAA still smooths edges. **Navara HD**
(`?engine=navara-hd`) draws at the device pixel ratio with MSAA 4. Both are
the `navara` engine to the layers; a pick between them builds the other
variant cold and releases the first, so only one Navara holds memory.
Measured in headless Chrome (Dubai, 1280×800): Navara 1.6 GiB at DPR 1 and
1.8 GiB at DPR 2, Navara HD 1.8 and 2.7 GiB, Cesium 0.93 and 1.08 GiB
(Navara used 1.95 and 3.0 GiB before).
- **Choosing the engine.** A choice read from storage (no `?engine=`) that
fails to start opens Cesium instead, stores Cesium in `gev.engine` and the
address, and says so in a toast ("Globe engine failed to start. Running
Cesium: …") once the shared view is restored; the toast stays 6 s. An
explicit `?engine=` that fails keeps the loading screen with the error
naming the engine ("Could not start Navara: …") and an engine picker
labelled "Choose an engine to try again.", focused on its first choice, so
another choice retries without a reload; a retry that starts keeps the
loading screen until its view is restored. The loading screen lifts only
after the start-up share-link restore (the camera has landed) and at least
1 s. Back and Forward to an address without `?engine=` use the engine
the page started with, not a `gev.engine` another tab has changed since.
A switch that fails and leaves the previous engine running names that
engine in its toast. The Globe Engine picker is a compact field that keeps
keyboard focus while a switch runs (`aria-disabled`, `aria-busy`); a
hidden status region announces "Initializing systems..." and the new
engine.
- **Memory.** The canvas context has no antialias (Navara draws into its own
targets and copies the result), the cloud shadow maps use the low preset's
256² and 2 cascades, and the tile cache budget is the screen-sized targets
plus 768 MiB instead of Navara's default of up to 2 GiB, which a long session
filled. A released Navara keeps no JS: catalog closures capture the
operations, not the scene; a released globe aborts with a reason made at
module load (a fresh one keeps the stack, and with it the scene); and
`Pass.fullscreenGeometry` from postprocessing is disposed after the view,
which otherwise pins the canvas through a three listener.
- **The engine contract** is `src/globe/contract.js`; its drawing primitives
(movers, lines, ground, imagery, meshes), scene services and how to add an
engine are in [Globe primitives](GLOBE-PRIMITIVES.md). `src/globe/cesium/*`
and `src/globe/navara/*` implement it, `src/globe/testing/fakeEngine.js`
stands in for tests, and `src/globe/testing/contractSuite.mjs` holds the
checks every engine must pass. `npm test` runs them on the fake engine;
`node scripts/qa-contract-suite.mjs <origin>` runs them in headless Chrome
on the real Cesium and Navara of a keyless dev server, with a real pointer
and real canvases (`--only=ground:` and `--only=imagery:` check ground and
imagery pixels); checks of state the app drives itself (contact sonar) are
skipped and named. Among its members: `surface.sampleTerrainMostDetailed`
(terrain heights at the finest level, fetched where not loaded),
`maps.terrainRevision()` (changes when the terrain shown is replaced),
`render.maxTextureSize()`, `MoverSet.failed()` (a model or atlas that
will not load) and `camera.completeFlight()` (a running flight lands at
once, as Cesium's). Navara also has Cesium's `contactSonarDiagnostics()`,
outside the contract.
- **Navara's ground and imagery passes.** Navara draws ground fills and
corridors in one screen pass classified on the scene depth
(`src/globe/navara/ground/`): a corridor set is tiles of binned segments
(`corridors.js`), and 10,000 corridors draw in about 12 ms still and 14 ms
moving, where Cesium takes 23 ms (the chunks before took 151 and 196 ms).
Imagery layers draw in one screen pass after tone mapping, classified on
the scene depth (`src/globe/navara/imagery/`); the Active Fires heatmap is
mipmapped at orbit scale. Sea Marks and Ski Pistes keep Navara's native
raster path for tile detail and use `engine.imagery` on engines without
`maps.addOverlay`.
- **Ground and input parity.** Space Missions draws its 500 m launch-pad
zone with the radial ground material: faint fill, bright soft rim and
emission, classified on terrain and tiles. Transit draws its age-banded
trail and dark backing as ground corridors. The active segment uses a
shader reveal, retaining its geometry between segment crossings. Navara
composes both in the ground pass after tone mapping. Street Level's 9 px
pick window and CCTV's custom window and drill limit go through input
subscriptions. Street Level's lowered horizon ellipsoid is a projector
option on every engine; default callers retain the WGS84 horizon.
- **Shared follow and Draw.** Share links read the follow target, identity and
camera pose through the engine; their saved offset uses `camera.follow`'s
`swingTo` option on both engines. Cesium keeps its guarded 60-degree frame
swing. Draw uses one contract surface with lines, point movers and an
exclusive `engine.input` pointer claim. Cesium's stock select/track actions
are suspended by the engine while drawing and restored on release. Scene
detach ends the sketch and removes the preview, retaining shape and colour.
- **Engine-owned effects and camera input.** Cesium's viewer, atmosphere
compatibility, zoom guard, starry sky and map implementation live in
`src/globe/cesium/`. Styles, bloom and contact sonar use `engine.render`.
Camera input suspension is counted on every engine; the last resume restores
the state before the first suspension. Cockpit, imagery-box drawing, CCTV
calibration and infrastructure flights use that shared ownership API.
- **Engine coupling.** `node scripts/report-engine-coupling.mjs` counts the
engine-specific code outside the engines: renderer lines per engine, Cesium
imports, `viewer` mentions, engine-name checks, raw Navara reads, Navara
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`.
- **Display rehearsal.** `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 and native debug access remain
work.
- **Scene transition ownership.** `src/app/sceneHost.js` serializes engine
requests, application attachment and direct shell calls. One transaction
covers handoff, renderer release, candidate acquisition, attachment, view
restoration and the single recovery attempt. Nested parts receive that
transaction explicitly; unrelated calls wait for it. Picker/history ordering,
hidden-tab settling and parked-renderer reuse retain their existing behavior.
A replacement application registers fresh parts on the same transition owner;
a shell used alone creates its own scene host.
- **Scene services per attachment.** Each attachment owns its picking,
selection, render demand, world overlay, tracked readout and context
services (`src/data/sceneServices.js`; described under Input ownership,
Render demand and World overlays in [Globe primitives](GLOBE-PRIMITIVES.md)).
On a switch, detach releases them before the renderer is parked and a
reattachment, parked engines included, creates fresh ones; retained layers
register their picks again and resolve their attached engine, an old
service cannot release a replacement's render hold, and destroyed services
stay inert. The shell reconnects its overlay action subscription; shared
font measurements live until the last overlay host detaches. Engine handoff
carries the retained page selection into the incoming context, and readout
initialization leaves it intact while the outgoing display is hidden and
the incoming layer waits for camera restoration. Label text, live visual
anchors and follow-camera anti-jitter are unchanged.
- **The session owns the state.** Catalog, layer models, sources, selection,
camera mode and URL state outlive a switch. A picker switch hands the
outgoing camera's exact pose to the incoming one (the address rounds it to
1e-4°, whole metres and whole degrees); a pick made during a camera flight
first lands it (`camera.completeFlight`, Cesium's completeFlight), so the
incoming engine stands at its destination. A history visit takes the
entry's view. A layer that is a session model
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`).
- **Navara camera and drawing parity.** A render requested during a frame
draws the next frame, so the wind animates under a still camera. A flight
turns camera input off until it ends; a gesture no longer interrupts it.
`setView` cancels a running flight on both engines. Heading and roll
changes raise `camera.changed`, as on Cesium. 3D models take the Cyber
sonar, which draws at its cadence only while contacts are on screen. Ground
items are picked at the shown 3D tiles under Google 3D, else at the
terrain, by a CPU ray (Navara's depth read answers with the near plane
right after a click). Ground corridor and outline widths are CSS pixels at any device
pixel ratio (Navara HD). Tiled imagery draws across the antimeridian and
replans its tiles on a resize. A follow takes a two-finger pinch. Navara
does not yet refresh an ion terrain token on a 401, nor fall back to
OpenStreetMap when base imagery tiles fail after the source was built
(Cesium does both); its package exposes no hook for either.
- **Layer heights and recovery after a switch.** S2 GhostMaps icons are
sprite movers without depth test that stand on the surface: the terrain on
the globe, a sample of the 3D tiles under Google 3D. Only icons on screen
are measured, at most 16 and 4 ms a pass, again after the camera moves and
tiles load; a height that holds is not written again, so a parked camera
asks for no frames. Submarine cable landing markers stand on the surface by
the same rule and draw over the scene within 2.5 km of the camera. OSM
Places (2 m over the surface) and Street Level's cones and position marker
follow the same rule; a point not measured yet is screen-tested on the
loaded terrain (under Google 3D on Cesium, whose hidden globe loads none,
at the ground under the camera), and a Street Level sprite waits on the
loaded terrain until it is measured. Switching Google 3D on or off measures
these points again. Directions' A/B markers move again once tiles load,
under Google 3D too. Traffic's road heights are
sampled again when the terrain changes (`maps.terrainRevision`), and cars
on roads below the ellipsoid are not culled by the WGS84 horizon. The
weather shell halves its images (to 1024 px wide) when the GPU's texture
limit is under 4096 (`render.maxTextureSize`). A CCTV layer that was off
stays hidden after a switch, and the active camera's obstruction probe runs
again once the new engine's surface is ready. A tracked aircraft's glTF
that the engine did not load is loaded again in a new mover set after a
backoff and given up after a failure budget; a fleet model of Flights,
Military or Local ADS-B that failed gives its slot back at once (the
contact stays a glyph), and its glTF is fetched again in a new mover set
no sooner than 1.5 s later, when the next aircraft of that class is
admitted; far contacts in Cockpit draw
as dots from their own sprite set.
- **Layer state and cards after a switch.** A history visit across engines
applies the visited entry's layer options that differ from the live
layer (Wind's model, say); equal options are left alone, so a plain switch
writes no layer parameters. The Bhote Koshi evidence cards are published
to the incoming engine's overlay. On Navara their anchors stand on the
terrain sampled at its finest level; where that fails they start at the
pack's elevations and follow the terrain loaded under a camera within
25 km. A followed aircraft's card anchors to the model drawn
(`gevVisualPosition`), on both engines. Turning a layer off ends its camera
arrival correction on every engine. North-up and tilt during a follow work
on both engines and keep the follow: Cesium sets the frame through
`lookAtTransform` in its EntityView frame, Navara through the follow's
`retarget`.
- **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,
wake/rest timing and raised ellipsoid on every engine. Cesium retains its
material disc; Navara's shader is owned by `src/globe/navara/ground` and
draws after tone mapping. Bhote Koshi's event and locator share
`src/layers/bhoteKoshi/render/story.js`, using ground polygons and corridors
with Cesium's dashed halo and glowing core, plus point movers with animated
outline opacity. Their authored property records remain in the story model.
The Active Fires far-zoom heatmap is an `engine.imagery` layer, and Recent
Imagery draws its days through `engine.imagery` from its own
`render/display.js`. CCTV's monitor plane and viewshed volume use
`engine.meshes` through `src/layers/cctv/render/meshes.js`, with no layer
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;
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 shared `display` (with an optional
engine-specific `backends` option keyed by engine kind: wind streamlines), or
a layer that draws itself through the contract (Wildlife Sightings). Every
engine implements all five drawing primitives, so the manifest lists no
primitives a display needs and registers no supported engine kinds; Sea
Marks and Ski Pistes drape themselves through their overlay controller.
Every layer shows on both engines. The displays and wind backend load
through `loadLayerRenderers(kind)`, awaited before the engine's catalog.
`rendererFor(id, engine, options)` answers synchronously after that.
- **One tools tree.** `src/app/tools.js` attaches the same tools to every
engine (`attachEngineTools`): the annotation board, Draw, Import, the
Recent Imagery box and panel, the render governor, the scope mask,
hidden-tab handling and voice. Each engine kind's tool backend says where
they draw; Cesium's also holds the tools that still drive its viewer
(release trim, tracked-entity hold, the render-loop suspension, the
embedded views), which read it through `engine.extensions.cesium?.viewer`
and so stay absent on Navara. Navara's backend (`navaraToolBackend` in
`src/app/navaraMode.js`, loaded with its mode) adds the daylight of a
restored view and the engine notice.
- **Cesium only on demand.** The production build loads `/cesium/Cesium.js`
when a Cesium globe starts or a Navara user turns on a feature that still
uses Cesium's maths or entity model (the Bhote Koshi event and locator;
`src/app/loadCesium.js`). Imported files use plain records and load no SDK. App code does its maths with `src/geo`, a Cesium-free
port of the vector, ellipsoid, transform and time functions that
`bitExact.test.mjs` compares with Cesium bit for bit.
**Invariant: no module reads Cesium while it loads**
(`scripts/check-cesium-load-time.mjs`, run by `npm test`).
- **Boundaries.** The import-direction gate keeps Cesium packages in
`src/globe/cesium/` and three, postprocessing and Navara packages in
`src/globe/navara/`, which loads only through `import()`; the rules are in
[Code boundaries](CODE-BOUNDARIES.md). 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),
and the gate rejects unused exceptions. `build/initialChunkGuard.js` fails
the build when one of them reaches an initial chunk, and
`npm run check:main-chunk` holds the main chunk to its gzip budget
(`scripts/main-chunk-baseline.json`). Only `src/globe/navara/internals.js`
touches Navara's private members.
- **Navara 0.2.1 workaround.** The SDK keeps feature-change events of deleted
vector layers in its queue, so the view never reaches idle. They are dropped
each frame (`src/globe/navara/featureQueue.js`).
- **Strict acceptance.** `node scripts/qa-all-layer-switches.mjs <origin> <out>
--serve` builds the commit keyless in a clean worktree and runs every layer
and switch scenario under a deny-by-default network policy, with free map
tiles from a disk cache and a trap that fails any read of Cesium before it
has loaded. Every catalog layer must be asserted on both engines.
Readiness uses the restored view, enabled camera inputs and an unchanged
camera pose over three drawn frames (`scripts/qa/ready.mjs`); timeouts name
the unmet conditions. The dev probe exposes the same state through
`layers.settled()`. Production checks inject their observers through the
existing debug handle, without shipping the probe. Evidence also records
the five drawing registries before and after picker switches and contract
violations for each observed engine; both remain diagnostic-only. Engine-only
registry ids need a reason in `scripts/qa-ledger-allow.json`. Hidden switches
report the actual first-frame, hidden-tab or timeout uncover path.
Page clocks start at the 18:30 UTC nearest to the real time, so the sun is
up over the test cities whenever the run happens, unless a scenario supplies
`time` (an ISO instant or `'real'`); `QA_EXIT_DIAGNOSTICS=1` preserves
shutdown resources and prints them five seconds after completion.
A timed-out scenario with passing evidence is `hung`; non-passing scenarios
retry once unless `--no-retry` is set, and a passing retry is reported as
`flaky` without failing the run.
`--history=FILE` appends run summaries, and
`node scripts/report-acceptance-flake.mjs FILE` reports each scenario's
share of non-pass runs, including flaky runs. Point
`PUPPETEER_EXECUTABLE_PATH` at an installed Chrome if Puppeteer's pinned
build is missing. Navara lights the globe by the real sun; every base map,
street maps included, takes the night fill after dusk, so a scenario over
Austin or San Francisco at local night still sees a legible map, as on
Cesium (`src/globe/navara/look.js`).
- **Navara 0.2.1 workaround.** The SDK never frees pick batch ids, and the
mover banks rebuild their instanced meshes on each attach, so every switch
leaked one id per instance of capacity (about 260 KiB of WebAssembly memory).
A per-view pool hands released ids back out
(`src/globe/navara/movers/batchIds.js`); the long soak asserts both JS
objects and native memory stay flat.
- **Ground visibility.** Polygon displays show and hide engine ground sets
through the screen pass on Navara.
## Shipwrecks — EMODnet and Wikidata ## Shipwrecks — EMODnet and Wikidata
The Events group includes **Shipwrecks** (`shipwrecks`, share-link token `01`), The Events group includes **Shipwrecks** (`shipwrecks`, share-link token `01`),
@@ -248,17 +547,25 @@ snapshot, including one whose rows all fall outside coverage or the species
checks, is a fresh success that clears earlier rows; only a malformed or failed checks, is a fresh success that clears earlier rows; only a malformed or failed
upstream reply serves stale and starts a retry cooldown. WHALES accepts only upstream reply serves stale and starts a retry cooldown. WHALES accepts only
recognized cetaceans; other identified marine mammals appear under ALL or recognized cetaceans; other identified marine mammals appear under ALL or
MAMMALS. Sensitive iNaturalist locations remain 0.2° privacy cells; Acartia MAMMALS. Sensitive iNaturalist locations remain 0.2° privacy cells, drawn as
positions are rounded to 0.01° on the server. Acartia comments are never retained. taxon-accent fills at 25% opacity with 2 px outlines at 80% opacity through
`engine.ground`, draped on terrain and tiles. Cells are not pickable and
identical bounds and accents share one item. Acartia positions are rounded to
0.01° on the server. Acartia comments are never retained.
Observer identity is retained only for photo attribution. Observer identity is retained only for photo attribution.
`/api/inaturalist/grid` supplies a dedicated, nonpickable billboard density glow, The layer draws on both engines through one display
(`src/layers/wildlifeSightings/display.js`): taxon badges and the density glow
are `engine.movers` sprite sets.
`/api/inaturalist/grid` supplies a dedicated, nonpickable sprite density glow,
with a view-relative density ramp from cool teal to warm amber. Overlapping with a view-relative density ramp from cool teal to warm amber. Overlapping
Gaussian splats span four logical cell widths; mono and CRT styles follow map Gaussian splats span four logical cell widths; mono and CRT styles follow map
and cockpit vision changes. Empty snapshots paint nothing. Full footprints and cockpit vision changes. Empty snapshots paint nothing. Full footprints
stay inside the globe limb with a 5% radius margin; opacity fades from full at stay inside the globe limb with a 5% radius margin; opacity fades from full at
3,000 km to zero at 8,000 km camera altitude. Culling and cached coordinates 3,000 km to zero at 8,000 km camera altitude. Splats are sized in metres on
add no per-frame work and leave sighting counts intact. the CPU, only on frames where the camera moved; culling and cached coordinates
leave sighting counts intact.
Only real sampled observation rows produce taxon badges at their public Only real sampled observation rows produce taxon badges at their public
positions. The badges use 28 px screen buckets, a 22 px overlap check and a positions. The badges use 28 px screen buckets, a 22 px overlap check and a
@@ -308,7 +615,6 @@ Disable/destroy releases the glow collection, texture atlas, timers, queues and
style listeners. Synthetic fixtures cover privacy, acquisition, rendering, style listeners. Synthetic fixtures cover privacy, acquisition, rendering,
selection and card placement. Deterministic operation checks run in `npm test`; selection and card placement. Deterministic operation checks run in `npm test`;
wall-clock budgets run in `npm run test:perf`. All 11 locales are supported. wall-clock budgets run in `npm run test:perf`. All 11 locales are supported.
## Prohibited & Restricted Airspace — FAA special-use airspace ## Prohibited & Restricted Airspace — FAA special-use airspace
The Infrastructure group includes **Prohibited & Restricted Airspace** (`drone-airspace`, The Infrastructure group includes **Prohibited & Restricted Airspace** (`drone-airspace`,
@@ -411,7 +717,10 @@ style, map, marks and an aircraft or satellite to follow, written in the
share-link format (`gods-eye-view/view`). `show_in_gods_eye_view` shows a view as live share-link format (`gods-eye-view/view`). `show_in_gods_eye_view` shows a view as live
God's Eye View inside clients that display MCP Apps, and as a link everywhere. God's Eye View inside clients that display MCP Apps, and as a link everywhere.
The panel runs the app's panel build (`npm run build:panel`, served at The panel runs the app's panel build (`npm run build:panel`, served at
`/panel/`) and loads it, with its data, through the MCP server, so it works `/panel/`), a Cesium-only build: its HTML loads `Cesium.js` and
`widgets.css`, and stubs keep Navara and three out of its scripts
(`build/panelNavara.js`); its size after that change has not been measured.
The MCP server loads it, with its data, so it works
in Claude Desktop and in the Codex and ChatGPT desktop apps with a local in Claude Desktop and in the Codex and ChatGPT desktop apps with a local
server and no HTTPS. `?embed=1` shows the app as the globe alone and takes server and no HTTPS. `?embed=1` shows the app as the globe alone and takes
new views from the page that frames it; framing is off unless new views from the page that frames it; framing is off unless
@@ -795,7 +1104,7 @@ forecast lead-hour points and cones render only when all source parts match the
status advisory; a newer status shows its position with geometry pending rather status advisory; a newer status shows its position with geometry pending rather
than relabeling older geometry. The cone represents forecast center uncertainty, than relabeling older geometry. The cone represents forecast center uncertainty,
not storm size or the full hazard area. Successful empty and unavailable states not storm size or the full hazard area. Successful empty and unavailable states
are distinct. One owned native Cesium data source preserves polygon holes and are distinct. The display draws centres and forecast points as point movers and each storm's track and cone as ground sets, keeping polygon holes and
geographic seams and releases on disable; forecast animation is not implied. geographic seams and releases on disable; forecast animation is not implied.
NHC movement speed is converted from miles per hour to rounded knots by the NHC movement speed is converted from miles per hour to rounded knots by the
proxy; advisory wind intensity is already in knots and is passed through. proxy; advisory wind intensity is already in knots and is passed through.
@@ -816,7 +1125,7 @@ Keyless terrain tiles retry when Re:Earth throttles them. The browser fetches
`terrain.reearth.land/cesium-mesh/ellipsoid/{z}/{x}/{y}.terrain` directly; no `terrain.reearth.land/cesium-mesh/ellipsoid/{z}/{x}/{y}.terrain` directly; no
proxy in this repository sees those requests (`/api/terrain/heights` is the proxy in this repository sees those requests (`/api/terrain/heights` is the
separate point-height endpoint), and the upstream edge answers zoom-out bursts separate point-height endpoint), and the upstream edge answers zoom-out bursts
with HTTP 429 under load. `src/maps/terrain.js` hands Cesium a `Resource` with HTTP 429 under load. `src/globe/cesium/terrain.js` hands Cesium a `Resource`
whose retry policy (`src/maps/terrainRetry.js`) every derived tile fetch whose retry policy (`src/maps/terrainRetry.js`) every derived tile fetch
inherits: 429, 502, 503 and 504 replies retry up to three times, 750 ms inherits: 429, 502, 503 and 504 replies retry up to three times, 750 ms
doubling to a 15 s cap, with every retry waiting behind one shared cooldown doubling to a 15 s cap, with every retry waiting behind one shared cooldown
@@ -1137,7 +1446,7 @@ Voice controls bind to a protocol-independent session factory. The default WebRT
The standalone app offers OpenAI and Gemini Live through the **AI Provider** setting, shared by voice and typed chat. Gemini keeps its permanent API key on the local server and mints a short-lived, single-use credential with server-owned instructions and the standalone action catalog. With no Gemini key the token route answers `503` with code `API_KEY_MISSING`, and the voice control says `Add a Gemini API key in Provider Settings` without the microphone-permission hint. Voice status and error messages keep the message as given on the session's `state` event (`sourceDetail`, English catalog text when it is the app's own, with `sourceParams` for its placeholders), beside the worded `detail`, so the voice control words them again after a language change. Typed-chat errors and the chat panel are worded once. Its native WebSocket adapter supports open microphone and push-to-talk input, streaming audio, captions and the existing action runner. In push-to-talk, a tool call that arrives with no input, reply or tool result outstanding is answered with a failure and never runs, and neither does any other call in the reply it starts; open-microphone turns start on Gemini's server-side activity detection, which reports no boundary to check. Switching providers ends the old conversation and releases its microphone and connection; only the input mode carries over. Gemini uses bounded shutdown draining and explicit manual restart, without automatic reconnection or a promise of uninterrupted session duration. Hosts that do not opt into the provider facade retain their existing default or custom adapter. The standalone app offers OpenAI and Gemini Live through the **AI Provider** setting, shared by voice and typed chat. Gemini keeps its permanent API key on the local server and mints a short-lived, single-use credential with server-owned instructions and the standalone action catalog. With no Gemini key the token route answers `503` with code `API_KEY_MISSING`, and the voice control says `Add a Gemini API key in Provider Settings` without the microphone-permission hint. Voice status and error messages keep the message as given on the session's `state` event (`sourceDetail`, English catalog text when it is the app's own, with `sourceParams` for its placeholders), beside the worded `detail`, so the voice control words them again after a language change. Typed-chat errors and the chat panel are worded once. Its native WebSocket adapter supports open microphone and push-to-talk input, streaming audio, captions and the existing action runner. In push-to-talk, a tool call that arrives with no input, reply or tool result outstanding is answered with a failure and never runs, and neither does any other call in the reply it starts; open-microphone turns start on Gemini's server-side activity detection, which reports no boundary to check. Switching providers ends the old conversation and releases its microphone and connection; only the input mode carries over. Gemini uses bounded shutdown draining and explicit manual restart, without automatic reconnection or a promise of uninterrupted session duration. Hosts that do not opt into the provider facade retain their existing default or custom adapter.
Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 35 tools using separate description-only metadata; a session is offered 34 of them unless `OVERPASS_UPSTREAMS` is set, which adds `osm_query` and its one prompt sentence (`server/providers/openai/tools.js`). Description customization cannot replace argument types, enum values or required fields. Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 33 tools using separate description-only metadata; a session is offered 32 of them unless `OVERPASS_UPSTREAMS` is set, which adds `osm_query` and its one prompt sentence (`server/providers/openai/tools.js`). Description customization cannot replace argument types, enum values or required fields.
Voice areas live in a store owned by the action runner for the page (`src/data/areaStore.js`); the model gets an `areaId`, a rounded bounding box and a summary, never geometry. `resolve_area` (`src/voice/areaActions.js`, `src/data/areaResolver.js`) checks the store, bundled Natural Earth regions, then bundled countries and states by name — a name several prominent units share returns up to four candidates unless the view is inside exactly one — and then the annotation outline ladder (`resolveAnnotationTarget`: bundled counties, Census places and neighborhoods, the guarded `/api/geocode/outline`, and Overpass only when configured). Administrative geometry is stored only when its source identity agrees with the requested unit, by name or by a form the bundled matcher reads as that unit at its level ("State of Texas", "Province of Bagmati", "USA", "UK"; not "Great Britain" or "Korea"); stale, mismatched, synthesized or otherwise unverified administrative boundaries are refused rather than relabelled. Clarification IDs use the bounded `ne:<kind>:<slug>` grammar and are accepted only from the current resolver's candidate memory. A lookup past 8 s answers `AREA_TIMEOUT` and keeps filling the caches. `around=true` asks the same ladder for the grounds enclosing a landmark (Nominatim, then the OpenFreeMap area holding it) and otherwise stores a 250 m buffer marked approximate. `analyst_query` scopes add `area`, `drawn` (newest draw-tool outline) and `annotation` (newest or named outline); region scopes use the same resolver within 3 s. Drawn outlines are a simplified copy (≤1,200 vertices); counting reads the stored geometry (`src/data/areaGeometry.js`). `find_imagery` (`src/voice/imageryActions.js`) sets the Recent Imagery box from an area, the view or the turn's pointer snapshot and pins the best acquisition; any hand change to the panel or explicit layer OFF while it searches ends the request (`DISPLACED`) without pinning or success narration. `osm_query` asks `/api/osm/features` for one curated preset in a bounded box; the route answers `OVERPASS_NOT_CONFIGURED` without an operator Overpass. Results go to the session-only `osm-places` layer and become the analyst's last answer; a list capped at its limit stays incomplete in follow-ups, which keep its "listed the first N" caveat and report counts as floors. An explicit OSM Places OFF while the provider is pending aborts the work and prevents re-enable, result, credit and follow-up-memory commits. The voice answer deadline also aborts the client provider request, so the caller does not leave a timed-out request without a cancellation owner. The server's whole search (22 s across every configured endpoint, with Overpass told 20 s) ends before that 25 s deadline, because a browser that stops waiting cancels the upstream query and leaves nothing cached for a retry. Voice areas live in a store owned by the action runner for the page (`src/data/areaStore.js`); the model gets an `areaId`, a rounded bounding box and a summary, never geometry. `resolve_area` (`src/voice/areaActions.js`, `src/data/areaResolver.js`) checks the store, bundled Natural Earth regions, then bundled countries and states by name — a name several prominent units share returns up to four candidates unless the view is inside exactly one — and then the annotation outline ladder (`resolveAnnotationTarget`: bundled counties, Census places and neighborhoods, the guarded `/api/geocode/outline`, and Overpass only when configured). Administrative geometry is stored only when its source identity agrees with the requested unit, by name or by a form the bundled matcher reads as that unit at its level ("State of Texas", "Province of Bagmati", "USA", "UK"; not "Great Britain" or "Korea"); stale, mismatched, synthesized or otherwise unverified administrative boundaries are refused rather than relabelled. Clarification IDs use the bounded `ne:<kind>:<slug>` grammar and are accepted only from the current resolver's candidate memory. A lookup past 8 s answers `AREA_TIMEOUT` and keeps filling the caches. `around=true` asks the same ladder for the grounds enclosing a landmark (Nominatim, then the OpenFreeMap area holding it) and otherwise stores a 250 m buffer marked approximate. `analyst_query` scopes add `area`, `drawn` (newest draw-tool outline) and `annotation` (newest or named outline); region scopes use the same resolver within 3 s. Drawn outlines are a simplified copy (≤1,200 vertices); counting reads the stored geometry (`src/data/areaGeometry.js`). `find_imagery` (`src/voice/imageryActions.js`) sets the Recent Imagery box from an area, the view or the turn's pointer snapshot and pins the best acquisition; any hand change to the panel or explicit layer OFF while it searches ends the request (`DISPLACED`) without pinning or success narration. `osm_query` asks `/api/osm/features` for one curated preset in a bounded box; the route answers `OVERPASS_NOT_CONFIGURED` without an operator Overpass. Results go to the session-only `osm-places` layer and become the analyst's last answer; a list capped at its limit stays incomplete in follow-ups, which keep its "listed the first N" caveat and report counts as floors. An explicit OSM Places OFF while the provider is pending aborts the work and prevents re-enable, result, credit and follow-up-memory commits. The voice answer deadline also aborts the client provider request, so the caller does not leave a timed-out request without a cancellation owner. The server's whole search (22 s across every configured endpoint, with Overpass told 20 s) ends before that 25 s deadline, because a browser that stops waiting cancels the upstream query and leaves nothing cached for a retry.
@@ -1281,13 +1590,14 @@ screen. Cyan camera badges use Manjunath's camera artwork; selection switches to
larger coral badge with corner brackets and an animated tactical card. Numeric larger coral badge with corner brackets and an animated tactical card. Numeric
mapped bearings draw illustrative 90 m direction wedges (not measured coverage). mapped bearings draw illustrative 90 m direction wedges (not measured coverage).
The shared overlay paints gradients for at most 64 nearby cameras, promoting the The shared overlay paints gradients for at most 64 nearby cameras, promoting the
selected camera; farther cameras keep native Cesium badges without wedges. selected camera; farther cameras keep native badges without wedges.
Native ground-clamped wedges (a ground polyline plus a classification polygon per Native ground-clamped wedges (a ground polyline plus a classification polygon per
camera) cost about 40 ms per frame for 200 downtown cameras on photoreal tiles camera) cost about 40 ms per frame for 200 downtown cameras on photoreal tiles
and were removed. Badges sit on the cached ground floor (validated mesh or DEM, and were removed. Badges sit on the cached ground floor (validated mesh or DEM,
+1.5 m) rather than ground clamping, which re-sampled every badge whenever a +1.5 m) rather than ground clamping, which re-sampled every badge whenever a
tile streamed in; floors resolve before new badges appear (bounded wait), and a tile streamed in; floors resolve before new badges appear (bounded wait), and a
badge whose cell has not resolved clamps until it does, then moves in place. badge whose cell has not resolved sits on the terrain (hidden while none is
known) until it does, then moves in place.
Overlay anchors start on the same floor and write marker properties only on Overlay anchors start on the same floor and write marker properties only on
change. Once the camera is still and tiles have settled, each overlay anchor is change. Once the camera is still and tiles have settled, each overlay anchor is
replaced once by the rendered surface under it (two depth samples per paint, replaced once by the rendered surface under it (two depth samples per paint,
@@ -1494,8 +1804,9 @@ waypoint with no altitude; a ground waypoint is drawn on the ground. The head jo
aircraft only within 150 km. Each trail keeps up to 2,000 points, a full day of history aircraft only within 150 km. Each trail keeps up to 2,000 points, a full day of history
at one fix per minute plus the live session; past that the oldest recorded points go at one fix per minute plus the live session; past that the oldest recorded points go
first, and the oldest reports only once none are left. The renderer first, and the oldest reports only once none are left. The renderer
(`src/data/trailRenderer.js`) freezes every chunk of 128 points except the newest into (`src/data/trailRenderer.js`) draws through `engine.lines`, keeping every chunk
static geometry, so a long trail costs about as much per frame as a short one; of 128 points except the newest unchanged, so a long trail costs about as much
per frame as a short one;
`scripts/qa-trail-freeze.mjs` checks that a freeze never shows a blank frame. `scripts/qa-trail-freeze.mjs` checks that a freeze never shows a blank frame.
While an aircraft is tracked, its trail between reports is the path its icon was While an aircraft is tracked, its trail between reports is the path its icon was
drawn along: the displayed position is recorded about once a second or every 150 m drawn along: the displayed position is recorded about once a second or every 150 m
@@ -2182,7 +2493,7 @@ provides its confirmed toggle, taxa presets and fixed-order, nonempty legend.
## Sky background ## Sky background
`src/sky/starrySky.js` fills Cesium's `Scene#skyBox` slot with 9,096 Yale `src/globe/cesium/starrySky.js` fills Cesium's `Scene#skyBox` slot with 9,096 Yale
Bright Star Catalogue stars and a soft ESO/S. Brunier Milky Way. Both layers Bright Star Catalogue stars and a soft ESO/S. Brunier Milky Way. Both layers
rotate from ICRF/J2000 into the Earth-fixed frame at the scene clock, with rotate from ICRF/J2000 into the Earth-fixed frame at the scene clock, with
a TEME fallback while Cesium's IAU 2006 tables load. Bundled assets and a TEME fallback while Cesium's IAU 2006 tables load. Bundled assets and
@@ -2217,70 +2528,6 @@ future driver fix restores the effect with no code change; iOS/iPadOS platform
detection is the backstop for when no probe context can be created. Applied detection is the backstop for when no probe context can be created. Applied
during viewer construction, before any tile builds a draw command. during viewer construction, before any tile builds a draw command.
## Render failure notice
When the globe stops drawing, the scene chrome says so instead of leaving a
black globe behind controls that still respond. `installRenderFailureNotice()`
(`src/ui/renderFailure.js`) listens for Cesium's `renderError`, after which
Cesium stops its render loop, and for the canvas' `webglcontextlost`. It logs
the error and publishes a notice to the status store, which
`RenderFailure.vue` renders as an alert dialog that takes focus. When the
globe draws again, focus goes back to the element that had it, or, if that
element is gone or cannot take focus, to the globe's canvas, which the map's
keys (Enter to finish a drawing, Space to talk) read as the map. The canvas
gets `tabindex="-1"` only until focus moves on or the globe is pressed, and a
press leaves focus on the page, as Cesium has it. Focus the user moved
meanwhile stays put. The
application scene creates its viewer with `showRenderLoopErrors: false`, so
Cesium's own "Rendering has stopped" panel, which offers no way back, no longer
appears; `applicationViewerOptions()` and `createApplicationViewer()` keep
Cesium's panel by default for other callers. An error thrown outside Scene's
own error handling (a `preRender` or `postRender` listener, the clock's
`onTick`, a resize) also stops the loop without a `renderError`, so the notice
wraps the widget's `render()` and `resize()` and raises such an error as a
render error before Cesium's loop handles it as before; one Scene has raised
already and throws on (`rethrowRenderErrors`) is not raised again. A lost
context fails every later frame, and those errors are only logged, so the
notice keeps saying why. The notice sits above floating panels such as the
Québec camera viewer, and its buttons are 44 px tall on touch screens.
- **Retry** after a render error restarts the render loop. The notice stays
until a frame starts and ends without a render error (Cesium raises
`postRender` after a failed frame too), and a loop that fails again keeps it.
A listener that throws leaves its Cesium event mid-raise, with listeners
added or removed since only queued, so the `render()` and `resize()` wrapper
finishes the events it cut short (`finishRaise()`) before it raises the
render error. These are the events Cesium raises outside Scene's error
handling (`frameEvents()`): Scene's frame events, the camera's and the
clock's, the globe's tile load progress, and the load events of the scene's
top-level tilesets. So the notice's frame watchers start with the next
frame, a listener removed while the loop is stopped, for a layer turned off,
is not called again, and one turned off and on again keeps running.
- **Reload** after a lost context reloads the page: Cesium cannot draw into a
context it has lost, and the address keeps the view.
- **Reset view** opens the app's root address (`appRootUrl()`), without the
hash or `/share/<code>` path the view was restored from; the query, such as
`?lang=`, is kept.
An inline conversation panel never reloads or navigates its host's page, so it
offers Retry after a render error only, and nothing after a lost context, where
the panel runtime's own notice applies. The panel's timer
(`keepPanelRendering()`) draws only while Cesium's render loop is on, so it
stops after a render error and draws again after Retry. An error a frame of
the timer throws stops there: it has been raised as a render error, and
thrown on it would only reach the host page.
A render error marks Cesium's loop stopped, but a frame drawn while the loop
ran leaves its next animation frame queued: an error Scene catches itself
returns to the loop, which queues the frame, and a frame the panel's timer
draws leaves it queued. The `render()` and `resize()` wrapper keeps the loop
marked running after such a call, so a Retry before that frame, or a tab shown
again (`src/app/tools.js`), does not start a second loop that would draw every
frame twice. The queued frame stops the loop itself if nothing restarted it.
The `webglcontextlost` listener does not `preventDefault()` the context loss or
restore the context; it only reports it.
## Map Source control ownership ## Map Source control ownership
Map Source controls own chip listeners, source-state subscriptions and selection Map Source controls own chip listeners, source-state subscriptions and selection
@@ -2292,11 +2539,15 @@ remain unchanged.
## Visual effects ownership ## Visual effects ownership
VisualEffects owns style-stage creation, crossfades, animation scheduling, bloom VisualEffects owns crossfades and animation scheduling and drives styles, bloom
and sharpen. Display supplies actions and renders settings; effect execution has and sharpening through `engine.render` on both engines. Cesium's post stages,
no DOM dependency. Existing presets and the 500 ms transition remain unchanged. including the Anime Milky Way fade, live in `src/globe/cesium/postStages.js`;
Stopping the controller revokes animation before asynchronous UI teardown; final its sonar lives in `sonar.js` and `sonarCommands.js`. Display supplies actions
destruction removes its stages and restores the borrowed bloom configuration. and renders settings; effect execution has no DOM dependency. Existing presets
and the 500 ms transition remain unchanged. Stopping the controller revokes
animation before asynchronous UI teardown; controller destruction disables the
effects. Engine destruction removes stages and restores borrowed bloom.
Cesium advertises visualStyles, bloom and contactSonar, but not cloudCover.
Snow is no longer a visual preset. A share link that names it, in either the Snow is no longer a visual preset. A share link that names it, in either the
long or compact form, opens on Normal and drops the Snow shader parameters. A long or compact form, opens on Normal and drops the Snow shader parameters. A
view passed to embed mode or `show_in_gods_eye_view`, and a saved scene shot, view passed to embed mode or `show_in_gods_eye_view`, and a saved scene shot,
@@ -2589,7 +2840,8 @@ translation entries do not interrupt loading of the other stations. This covers
station names, not complete GBFS v3 support. station names, not complete GBFS v3 support.
`scripts/qa-gbfs-names.mjs` checks synthetic localized names through the real layer, `scripts/qa-gbfs-names.mjs` checks synthetic localized names through the real layer,
station selection, keyboard dismissal, mobile cards and bike-share tool against station selection, keyboard dismissal, mobile cards and bike-share tool against
either a dev or preview server (`--url` selects the target). either a dev or preview server (`--url` selects the target); the `--w2`
raster checks import source modules and need the dev server.
The station reader addresses the proxy as `/api/gbfs/<encoded feed URL>`; the The station reader addresses the proxy as `/api/gbfs/<encoded feed URL>`; the
feed address is read from the path, and a query-string form is refused with a 400. GBFS refuses upstream redirects and enforces its 5 MiB response cap while feed address is read from the path, and a query-string form is refused with a 400. GBFS refuses upstream redirects and enforces its 5 MiB response cap while
@@ -2915,7 +3167,7 @@ retaining rooftop and below-sea-level elevations. A hidden globe does not
constrain photoreal geometry, and retry work remains bounded. Already sampled constrain photoreal geometry, and retry work remains bounded. Already sampled
nearby markers follow higher settled terrain on existing bounded walks, without nearby markers follow higher settled terrain on existing bounded walks, without
additional GPU samples or timers, when close-up tile detail refines the floor. additional GPU samples or timers, when close-up tile detail refines the floor.
The submarine-cable renderer is unchanged. These limits bound active stem work; Submarine cables draw through `render/display.js` on every engine. These limits bound active stem work;
they do not reduce the materialized entity count or establish an FPS gain. they do not reduce the materialized entity count or establish an FPS gain.
## Keyboard interaction and focus ## Keyboard interaction and focus
@@ -3071,7 +3323,7 @@ same hues, from a bright red edge through orange and gold to a pale
white-hot core. The edge starts more than half opaque, so regions far below white-hot core. The edge starts more than half opaque, so regions far below
the hottest ones still read over forest and savanna imagery. Nothing is painted where the hottest ones still read over forest and savanna imagery. Nothing is painted where
nothing burns (`src/data/firmsHeatRaster.js`). It is one full-globe imagery nothing burns (`src/data/firmsHeatRaster.js`). It is one full-globe imagery
tile on the active imagery host (`src/layers/firms/heatImagery.js`), rebuilt tile on the active imagery host (`src/layers/firms/render/display.js`), rebuilt
on each data refresh or filter change (the first far-band paint at once, on each data refresh or filter change (the first far-band paint at once,
later ones 150 ms after the last change). Heatmap and markers are both kept later ones 150 ms after the last change). Heatmap and markers are both kept
built at every zoom and cross-fade by camera height between 3,600 and built at every zoom and cross-fade by camera height between 3,600 and
@@ -5321,111 +5573,6 @@ core's lookups alone. The MCP globe panel allows the meta-catalogue and the
three biggest instances; a picture on another instance shows only in the three biggest instances; a picture on another instance shows only in the
app. app.
**"Show me X on Street View."** `show_street_level` (voice and typed;
`src/voice/gevActions.js`) is one call whose stages fail separately
(`location`, `view`, `map`, `provider`, `layer`, `key`, `imagery`):
- **Key first.** A keyless source explains itself and changes nothing, as a
chip does.
- **Place.** A name, coordinates, a pointed spot or a numbered item: it flies
there at street framing and waits for arrival. With no place it looks at the
ground under the screen centre, as OPEN does (`aim: 'screen-centre'`,
`armed: false`: sky refuses, and a link's armed point is not used and is kept
until a photo opens). Coordinates off the globe are refused.
- **Map and source.** It switches to the map the source needs (Google Street
View: Google 3D), lights the source through `setLayerParams`, one at a time
and kept like a chip click (origin `voice`; a link runs the action with
origin `programmatic`, so it never writes the recipient's saved layer state),
turns the layer on and calls `openNearest`. `provider` is `google` for
"Street View", `mapillary`, `kartaview` or `panoramax` by name, else the lit
source.
- **Hidden panel.** Cockpit, clean view, recording or scene playback end it at
`view`, before the flight and after every await; embed mode's own clean view
is exempt, since the photo is lifted over the globe.
- **Superseded.** After every await (flight, map switch, layer on, lookup) a
newer turn, a shell navigation stamp (`_navigationGeneration`, re-taken where
its own flight lands) or a press or wheel on the globe stops it
(`superseded: true`). The same checks run before a found photo loads
(`proceed`), so nothing billed loads for a request that was given up. A
lookup given up leaves the panel as it was when the request kept the photo's
source; one that had switched the source off closes that source's photo with
it. A photo that opened after either is closed again.
- **Replacing a photo.** An open photo stays up while the new request looks
(`openNearest` `replace: true`; the viewer is replaced, not closed, so EXPAND
stays, also across sources through `holdViewer`, which holds only the action's
own switch-off of the photo's source: a source switched by hand meanwhile
still stops the load) and closes if nothing opens, so a place with no imagery
shows its own error; a link that only arms closes it at once. A replacement
from another source drops the old photo's details, and the request's error or
armed OPEN outlives the old viewer. A replacement stops a playing Mapillary
sequence.
- **Outcome.** `noImagery` marks an empty result, `imageryError` a source or
viewer failure and `aim: 'sky'` a sky centre (from `street.nearestOutcome`);
an empty result names a narrowing 360°/flat or SINCE filter the source
honours, as `filter`. Every photo the action opens reveals the panel, also
one the user had collapsed.
- **Search.** A source without a coverage layer searches up to 250 m
(`NEAREST_RADIUS_MAX_M`; Google honours it); clicks keep 50 m. Mapillary
asks for 100 photos within 50 m, its API's most (the API returns them
unordered and cuts at the limit), and gives up after 12 s, body included
(`NEAREST_TIMEOUT_MS`).
- **Phone.** The panel sits in the Cameras tab sheet: a photo that opens, or a
link that arms, opens the sheet and scrolls the panel into view
(`revealPanel`, which does nothing above 720px).
The layer also exposes `requiredMapStack(id)` and `providerStatus(id)` for
callers that decide before the panel lists a source, and `holdViewer()`. A
caller's abort is honoured through the viewer's mount and
load, and only the lookup that still owns the panel changes its spinner or
error.
Views carry the request as `street_level` (`src/view/index.js`): a source and
a point, which defaults to where the camera looks (below 20 km, at a pitch
steeper than -5° and within 10 km ahead; else the camera's own point); half a
point is not mixed with the camera's: both coordinates come from that default. A `google` source brings `map: photoreal` and refuses
another map; `street_level` does not combine with `follow`.
`show_in_gods_eye_view`, and the embedded app for a posted view with no
point, measure the view's altitude from the terrain under the camera
(`groundedLookAt` with the terrain heights service) before taking the look-at
point, and keep the view's own estimate without a terrain answer (the embedded
app waits 2 s for one). Links carry
`sl=<source>,<lat>,<lon>`; the globe panel applies views through the action,
and a plain link runs it once restored (one shot: the rewritten hash drops
`sl`). A link that follows an entity ignores `sl`.
- **Billing gate.** A plain link never loads a photo from a source that bills
every load (`billedPerImage`; Google): the action runs with
`autoOpen: false` and arms the layer (`armNearest`: the link's point and
radius, for the next OPEN press only, and only while the ground under the
screen centre is within 1 km of it (`ARMED_REACH_M`) or cannot be picked; a
sky centre refuses; any other lookup, open, close, disable, the source
switched off or its map leaving drops it), opens the panel and shows a toast
pointing at OPEN GOOGLE STREET VIEW. A link whose source has no working
key here changes nothing and says so in a toast (not in a frame).
- **Who may open.** Posted views, and the panel page's own link (inline embed)
for a tool result issued in the last 5 minutes, open the photo:
`show_in_gods_eye_view` stamps a street-level result `issued_at`, every
`gev:view` the panel posts carries `fresh`, and the inline app applies a
view without it with `autoOpen: false` (a framing page's posted views still
open). `?embed=1` on a published link only arms, without the panel nudge or
toast.
- **Who owns the camera.** A link's photo step is skipped when a newer link
superseded its restore, when its restore flight never landed
(`share.camera !== 'applied'`: the visitor dragged or searched), or after
any navigation since the generation that flight owned (`navigationToken` on
the settled result), also while its layers still load. A link pasted into
the open tab runs its step after its restore settles
(`gev:share-navigation-restore-settled`).
- **Embed.** The rail is hidden, so an open photo moves to an overlay on the
globe (`sl-viewer-wrap-embed`) and back when it closes; Esc closes it (in
full screen, Esc leaves full screen first). EXPAND stays under embed mode's
clean view; cockpit, recording and scene playback hide the overlay. Loading
errors render inside the overlay, and inside the viewer while EXPAND is on.
A posted view whose photo step fails before the imagery stage closes the
earlier photo before its camera flies; one the user overtook (a click on the
map, a newer request) leaves their photo and camera alone; one that found a
photo it could not load keeps that error.
Street Level has two browser gates. `npm run qa:street-level -- --url <server>` Street Level has two browser gates. `npm run qa:street-level -- --url <server>`
runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
qa:street-level:fixtures -- --url <server>` answers every Mapillary request qa:street-level:fixtures -- --url <server>` answers every Mapillary request
@@ -5560,22 +5707,14 @@ zero-duration journey or immediately relocate a visible vehicle.
**Selected history.** Selecting one vehicle draws its available recent path, **Selected history.** Selecting one vehicle draws its available recent path,
clipped to the displayed time. The browser retains up to 15 minutes and 128 clipped to the displayed time. The browser retains up to 15 minutes and 128
fixes per vehicle, under a global 32 MiB history budget. Capacity pressure may fixes per vehicle, under a global 32 MiB history budget. Capacity pressure may
shorten that history. Only the selected vehicle has a trail: completed segments shorten that history. Only the selected vehicle has a trail, drawn as
are batched primitives, and a small moving head ends at the marker. Trail `engine.ground` corridors draped on terrain and 3D tiles on every engine:
geometry is bounded to 640 prepared vertices, prioritizing the active corridor completed segments draw in two lanes (a 5 px dark edge under a 3 px mode
and shortening the oldest geometry first. Active and future marker corridors colour), one corridor per lane for each run of joined segments in one of 29
retain their own 640-vertex budget; long report intervals use coarser subdivisions age bands, re-banded once per displayed second or style change. The active
than 25 m instead of dropping an admitted vehicle. Supported scenes use ground polylines segment is one corridor with its dark backing; its geometry is written once
classified against both terrain and 3D tiles, including after map-stack changes, per segment and a shader reveal (`setReveal`) draws it up to the marker, so it
with per-segment age colours. History changes rebuild the body. The head is one is not rewritten while the marker moves. Trail geometry is bounded to 640 prepared vertices, prioritizing the active corridor and shortening the oldest geometry first. Active and future marker corridors retain their own 640-vertex budget; long report intervals use coarser subdivisions than 25 m instead of dropping an admitted vehicle. Deselecting releases selection geometry; marker paths retain only
short draped corridor, clipped to displayed time by a material uniform; only
subdivision crossings rebuild it. Ordinary frames upload no head geometry.
Without ground-polyline support, ordinary primitives retain a mode-coloured
0.55-alpha depth-fail path, including the head, without dimming it to near-black.
Instance attribute updates write back the same typed array returned by Cesium;
its getters return copies, so reading a second time discards a visibility or colour edit.
Normal instance colours follow the age ramp once per display second or style change. Subdivision crossings update only changed visibility
attributes. Deselecting releases selection geometry; marker paths retain only
active and future corridors. active and future corridors.
MBTA history is also retained in the running proxy: up to 15 minutes and 128 MBTA history is also retained in the running proxy: up to 15 minutes and 128
@@ -5698,7 +5837,7 @@ heap growth ≤24/48 MiB. The 3,000-vehicle CPU budget is 4 ms (recorded browser
measurement 3.4 ms); the 800-vehicle budget stays at 1 ms (measured 0.9 ms). measurement 3.4 ms); the 800-vehicle budget stays at 1 ms (measured 0.9 ms).
Transit-authored frame allocation is ≤8 KiB/frame at both fleets, attributed Transit-authored frame allocation is ≤8 KiB/frame at both fleets, attributed
by source stack to `src/layers/transit/**`, `src/data/contactPlayback.js`, by source stack to `src/layers/transit/**`, `src/data/contactPlayback.js`,
`src/data/contactTrailRenderer.js` and `src/data/transit*.js`. Whole-frame `src/data/transit*.js`. Whole-frame
allocation and its three largest allocators are reported without gating: allocation and its three largest allocators are reported without gating:
Cesium billboard buffer uploads and the shared DETECT painter are outside Cesium billboard buffer uploads and the shared DETECT painter are outside
this layer's allocation budget. **Follow-up: pool shared DETECT placement and this layer's allocation budget. **Follow-up: pool shared DETECT placement and
@@ -5780,17 +5919,17 @@ height still choose their own unit.
At global scale, ambient Radio cluster badges are hard-opacity shared-host At global scale, ambient Radio cluster badges are hard-opacity shared-host
entries: count/category updates and identity replacement do not run keyhole or entries: count/category updates and identity replacement do not run keyhole or
enter/exit ramps. Shared collision, viewport rejection, allocation, and horizon enter/exit ramps. Shared collision, viewport rejection, allocation, and horizon
culling can still remove an invalid placement, and Cesium still owns the cluster culling can still remove an invalid placement, and the radio display's cluster
point geometry and picks. Their 50,000 km line-of-sight range covers the points (screen clusters with Cesium's EntityCluster rules) carry the picks. Their 50,000 km line-of-sight range covers the
supported full-globe camera above 24,000 km, including farther horizon clusters. supported full-globe camera above 24,000 km, including farther horizon clusters.
Unclustered visible stations publish nearest-first ambient labels through that Unclustered visible stations publish nearest-first ambient labels through that
same host, capped at 16 labels globally, 32 at intermediate zoom, and 48 nearby; same host, capped at 16 labels globally, 32 at intermediate zoom, and 48 nearby;
cluster and singleton candidates still share the Radio source's 64-entry ambient cluster and singleton candidates still share the Radio source's 64-entry ambient
cohort. Cesium continues to own each 13 px station point, horizon visibility, cohort. The display draws each 13 px station point, horizon visibility,
and direct/nearby picking. Native points and shared-host text use one 50,000 km and direct/nearby picking. Points and shared-host text use one 50,000 km
interaction limit, so a painted singleton or cluster label always retains a interaction limit, so a painted singleton or cluster label always retains a
pickable point at its anchor throughout the supported high-global view. No pickable point at its anchor throughout the supported high-global view. No
Radio entity uses native Cesium label text. Radio point uses engine label text.
Delivery constraint: PR #10 is stack-only and unsafe standalone. PR #11 owns Delivery constraint: PR #10 is stack-only and unsafe standalone. PR #11 owns
the required Radio lifecycle/authority repair, so PR #10 must not merge or ship the required Radio lifecycle/authority repair, so PR #10 must not merge or ship
@@ -6221,7 +6360,7 @@ silently demoting every later lookup for the session.
When no mission is selected, the Context panel presents a scrollable newest-first roster of every launch in the rolling window, including the smaller 5 px operator-colored marker, provider, and launch date. Hovering or keyboard-focusing a row shows four compact cyan corner brackets on both that roster row and its corresponding globe dot, rotates the globe at the current zoom to center it, and gives its label declutter priority without selecting it; the globe label remains unbracketed. Selecting a roster row invokes the same mission isolation and full-orbit framing as clicking its globe marker. The replay vehicle is one screen-space SVG/CSS HUD overlay rather than separate Cesium billboard, label, and reticle graphics. It is hidden during ordinary Focus and manual close views, where the standard selected launch-site label remains visible, and exists only while ascent replay is active. Its fixed pixel scale is shared by ascent and insertion, so camera range never resizes the rocket on screen before the phase boundary. Generic Launch Library pad names are reduced to their identifying suffix, and replay timestamps use a cyan state title over unprefixed white UTC date/time values. `REPLAY ASCENT` holds the unframed cyan/white rocket at the pad for a real-time `T−10` countdown, transitions through `LIFTOFF`, and attaches six tapered cyan/white ellipse waves directly below it from liftoff through insertion to convey thrust without adding scene geometry. While replay is active, the single start button is replaced by compact Play, Pause, and Cancel icon controls. Pause freezes countdown or mission time, vehicle/stage positions, camera target, labels, and thrust-wave animation; Play resumes from that exact frame, and Cancel releases replay camera ownership. The rocket and thrust group rotates from the path's live screen-space tangent, so its nose follows the visible ascent curve while the adjacent text remains upright. The camera begins as a close oblique launch chase, then smoothly widens between roughly 120 and 420 km vehicle altitude into a higher oblique context view that keeps the moving rocket targeted while exposing the ascent bend and orbit connection. At insertion the rocket/thrust glyph is replaced by the fixed-size cyan orbit dot, which the camera follows through the existing globe-scale orbit pullback. The chase camera limits per-frame yaw changes across heading wraps so it cannot abruptly cross in front of the vehicle and make ascent read in reverse; the replay-speed slider affects mission playback but not countdown duration. When no mission is selected, the Context panel presents a scrollable newest-first roster of every launch in the rolling window, including the smaller 5 px operator-colored marker, provider, and launch date. Hovering or keyboard-focusing a row shows four compact cyan corner brackets on both that roster row and its corresponding globe dot, rotates the globe at the current zoom to center it, and gives its label declutter priority without selecting it; the globe label remains unbracketed. Selecting a roster row invokes the same mission isolation and full-orbit framing as clicking its globe marker. The replay vehicle is one screen-space SVG/CSS HUD overlay rather than separate Cesium billboard, label, and reticle graphics. It is hidden during ordinary Focus and manual close views, where the standard selected launch-site label remains visible, and exists only while ascent replay is active. Its fixed pixel scale is shared by ascent and insertion, so camera range never resizes the rocket on screen before the phase boundary. Generic Launch Library pad names are reduced to their identifying suffix, and replay timestamps use a cyan state title over unprefixed white UTC date/time values. `REPLAY ASCENT` holds the unframed cyan/white rocket at the pad for a real-time `T−10` countdown, transitions through `LIFTOFF`, and attaches six tapered cyan/white ellipse waves directly below it from liftoff through insertion to convey thrust without adding scene geometry. While replay is active, the single start button is replaced by compact Play, Pause, and Cancel icon controls. Pause freezes countdown or mission time, vehicle/stage positions, camera target, labels, and thrust-wave animation; Play resumes from that exact frame, and Cancel releases replay camera ownership. The rocket and thrust group rotates from the path's live screen-space tangent, so its nose follows the visible ascent curve while the adjacent text remains upright. The camera begins as a close oblique launch chase, then smoothly widens between roughly 120 and 420 km vehicle altitude into a higher oblique context view that keeps the moving rocket targeted while exposing the ascent bend and orbit connection. At insertion the rocket/thrust glyph is replaced by the fixed-size cyan orbit dot, which the camera follows through the existing globe-scale orbit pullback. The chase camera limits per-frame yaw changes across heading wraps so it cannot abruptly cross in front of the vehicle and make ascent read in reverse; the replay-speed slider affects mission playback but not countdown duration.
Mission world text has no native `LabelGraphics`: overview launch markers publish at most 48 ambient candidates for a 24-winner budget, while selecting a mission clears that overview source and publishes its launch-site callout, stage re-entry annotations, live/estimated payload-position readout, and orbit annotation as protected selected-lane entries. The source retains the exact former strings and colors. Static anchors reuse the Cartesian values used to build their mission geometry; the moving payload entry reads the layer's per-frame live-position cache; catalog-backed orbit annotation positions are cached in the same one-second matrix update that realigns the orbit primitive. Keyhole edge fade, horizon culling, final collision placement, and UI exclusion are owned by the shared host. Deselect restores the bounded overview, and refresh, disable, and destroy replace or clear both mission sources. Mission world text has no native `LabelGraphics`: overview launch markers publish at most 48 ambient candidates for a 24-winner budget, while selecting a mission clears that overview source and publishes its launch-site callout, stage re-entry annotations, live/estimated payload-position readout, and orbit annotation as protected selected-lane entries. The source retains the exact former strings and colors. Static anchors reuse the Cartesian values used to build their mission geometry; the moving payload entry reads the layer's per-frame live-position cache; catalog-backed orbit annotation positions are cached in the same one-second matrix update that realigns the orbit primitive. Keyhole edge fade, horizon culling, final collision placement, and UI exclusion are owned by the shared host. Deselect restores the bounded overview, and refresh, disable, and destroy replace or clear both mission sources.
A selected mission renders its orbit as four repeating tactical sectors, each containing one prominent cyan dot followed by one hundred thin translucent dashes. The bright dots act as orbit anchors while the subdued dash field remains depth-tested against the globe and is shown only for the selected mission. A selected mission renders its orbit as four repeating tactical sectors, each containing one prominent cyan dot followed by one hundred thin translucent dashes. The bright dots act as orbit anchors while the subdued dash field remains depth-tested against the globe and is shown only for the selected mission.
Close selected-pad views add one static 500 m-radius cyan launch-zone ring with a low-opacity translucent fill over the sampled photoreal launch-site surface. The single scene primitive is created only for the visible selected site and is otherwise dormant. It appears during Focus, sufficiently close manual zoom, and the replay countdown, but is suppressed above 120 km camera altitude, beyond 180 km direct camera-to-pad range, for unselected missions, and whenever Space Missions is inactive. Focus establishes a launch-site-centered camera transform once; subsequent manual heading and pitch changes remain centered on that site without an automated per-frame correction. Surface mission markers and labels use an additional conservative globe-limb margin before the exact ellipsoid occluder boundary, preventing near-horizon visibility from alternating between frames. Close selected-pad views add one static 500 m-radius cyan launch zone, an `engine.ground` disc with the radial material (a faint fill, a bright soft rim and emission) draped on terrain and 3D tiles. The zone is created only for the visible selected site and is otherwise dormant. It appears during Focus, sufficiently close manual zoom, and the replay countdown, but is suppressed above 120 km camera altitude, beyond 180 km direct camera-to-pad range, for unselected missions, and whenever Space Missions is inactive. Focus establishes a launch-site-centered camera transform once; subsequent manual heading and pitch changes remain centered on that site without an automated per-frame correction. Surface mission markers and labels use an additional conservative globe-limb margin before the exact ellipsoid occluder boundary, preventing near-horizon visibility from alternating between frames.
- **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`; a vessel's callout fades by the painted opacity of its own ambient card, so a card dropped by budget, collisions or UI chrome, or faded at the keyhole rim, leaves the callout readable), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted. - **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`; a vessel's callout fades by the painted opacity of its own ambient card, so a card dropped by budget, collisions or UI chrome, or faded at the keyhole rim, leaves the callout readable), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted.
- **Track trails**: server accumulates per-MMSI ring buffers (`/api/vessels/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/flights/track` (OAuth, own credit bucket). Military aircraft keep only their locally accumulated trails: `/api/military/track` validates the hex, then answers `503 history_unavailable` without an upstream request. - **Track trails**: server accumulates per-MMSI ring buffers (`/api/vessels/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/flights/track` (OAuth, own credit bucket). Military aircraft keep only their locally accumulated trails: `/api/military/track` validates the hex, then answers `503 history_unavailable` without an upstream request.
- Shared `src/data/pickRegistry.js` stops the two flight layers' click handlers from fighting over the camera. - Shared `src/data/pickRegistry.js` stops the two flight layers' click handlers from fighting over the camera.
@@ -6481,7 +6620,7 @@ and unreachable upstream (502/504) separately from road geometry.
- **Token flow**: browser fetches a short-lived client secret from `/api/realtime/token`; the Vite middleware uses `OPENAI_API_KEY` by default, or the signed-in local ChatGPT/Codex OAuth access token when OAuth was explicitly selected in Provider Settings, and posts the full session config (instructions, tool schemas, VAD, truncation) to `api.openai.com/v1/realtime/client_secrets`. Both long-lived credentials stay server-side; OAuth minting is loopback-only. SDP exchange goes directly to `api.openai.com/v1/realtime/calls` with the ephemeral token. With no OpenAI key the token route answers `503` with code `API_KEY_MISSING`, and the voice control shows `Needs OPENAI_API_KEY — add it in Provider Settings` in the interface language, reworded on a language change, without the microphone-permission hint. - **Token flow**: browser fetches a short-lived client secret from `/api/realtime/token`; the Vite middleware uses `OPENAI_API_KEY` by default, or the signed-in local ChatGPT/Codex OAuth access token when OAuth was explicitly selected in Provider Settings, and posts the full session config (instructions, tool schemas, VAD, truncation) to `api.openai.com/v1/realtime/client_secrets`. Both long-lived credentials stay server-side; OAuth minting is loopback-only. SDP exchange goes directly to `api.openai.com/v1/realtime/calls` with the ephemeral token. With no OpenAI key the token route answers `503` with code `API_KEY_MISSING`, and the voice control shows `Needs OPENAI_API_KEY — add it in Provider Settings` in the interface language, reworded on a language change, without the microphone-permission hint.
- **Session defaults** (env-tunable): model `gpt-realtime-2` (or `gpt-realtime-2.1-mini` when the MINI tier is selected — see the model-tier entry below), voice `marin`, reasoning effort `low`, semantic VAD with low eagerness, no response interruption, context window truncated to ~3,000 post-instruction tokens with 0.5 retention ratio — the conversational window stays short because map state is fetched live per turn. - **Session defaults** (env-tunable): model `gpt-realtime-2` (or `gpt-realtime-2.1-mini` when the MINI tier is selected — see the model-tier entry below), voice `marin`, reasoning effort `low`, semantic VAD with low eagerness, no response interruption, context window truncated to ~3,000 post-instruction tokens with 0.5 retention ratio — the conversational window stays short because map state is fetched live per turn.
- **Thirty-five tools** (thirty-four in a session without `OVERPASS_UPSTREAMS`, which adds `osm_query`; argument schemas in `src/voice/actionSchemas.js`, served with their descriptions by `server/providers/openai/tools.js`, executed client-side in `src/voice/gevActions.js`): `fly_to_location`, `select_nearest_aircraft`, `show_street_level`, `adjust_camera_zoom`, `zoom_to_globe`, `set_layer_visibility`, `show_data_layers_menu`, `set_panel_open`, `set_voice_inactivity_timeout`, `set_visual_style`, `get_entity_context`, `get_current_view_state`, `set_hud`, `set_cyber_sonar`, `set_detection`, `set_map_stack`, `set_post_processing`, `control_scene`, `control_cctv`, `set_context_mode`, `control_cockpit`, `control_radio`, `track_entity`, `stop_tracking`, `frame_overhead`, `annotate_map`, `clear_annotations`, `move_camera`, `fly_route`, `analyst_query`, `resolve_area`, `find_imagery`, `osm_query`, `next_iss_pass`, and `next_satellite_pass`. - **Thirty tools** (argument schemas in `src/voice/actionSchemas.js`, served with their descriptions by `server/providers/openai/tools.js`, executed client-side in `src/voice/gevActions.js`): `fly_to_location`, `select_nearest_aircraft`, `adjust_camera_zoom`, `zoom_to_globe`, `set_layer_visibility`, `show_data_layers_menu`, `set_panel_open`, `set_visual_style`, `get_entity_context`, `get_current_view_state`, `set_hud`, `set_cyber_sonar`, `set_detection`, `set_map_stack`, `set_post_processing`, `control_scene`, `control_cctv`, `set_context_mode`, `control_cockpit`, `control_radio`, `track_entity`, `stop_tracking`, `frame_overhead`, `annotate_map`, `clear_annotations`, `move_camera`, `fly_route`, `analyst_query`, `next_iss_pass`, and `next_satellite_pass`.
> **Reading `npm test` totals:** the count depends on the Node major. The two > **Reading `npm test` totals:** the count depends on the Node major. The two
> GC-bracketed allocation microbenchmarks (`src/data/focusAllocations.test.mjs` > GC-bracketed allocation microbenchmarks (`src/data/focusAllocations.test.mjs`
@@ -6491,7 +6630,7 @@ and unreachable upstream (502/504) separately from road geometry.
> matrix entry is calibrated and therefore executes them. Historical totals recorded from unsupported Node 25 > matrix entry is calibrated and therefore executes them. Historical totals recorded from unsupported Node 25
> runs can therefore remain lower because their 14 allocation tests were skipped. > runs can therefore remain lower because their 14 allocation tests were skipped.
- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. A zoom during a pivoting tilt scales the range it holds, as for orbit, but a zoom in stops at the range that keeps that 150 m clearance; a zoom that can move no further (that floor, the 80 m floor, or the cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) is reported as not zoomed. A zoom during a tilt to an angle re-judges the place's horizon limit at the new range: when that moves where the tilt ends, the tilt eases from where it is to the new end (holding the camera's pitch through the range change; a camera the zoom leaves past the new range's limit steps down to it with the zoom, since past it the place would drop behind the Earth), and the zoom's result names that angle (`pitchDeg`) with a `note`. Once the tilt continues in place, a zoom moves the camera like any zoom. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the user grabs, touches or scrolls the map during it, or another navigation (a location-panel search, share link or file import, which leave the flight flying) takes the camera during it, the motion is not run and says so. A motion that runs after a fly-to of the same reply has already found nothing (Gemini runs one turn's calls one by one, and passes its turn as the reply) is refused the same way rather than moving the old view; a motion in a later reply runs. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid aLine truncated - **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per frame of the globe engine (Cesium or Navara) through the globe contract. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. A zoom during a pivoting tilt scales the range it holds, as for orbit, but a zoom in stops at the range that keeps that 150 m clearance; a zoom that can move no further (that floor, the 80 m floor, or the cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) is reported as not zoomed. A zoom during a tilt to an angle re-judges the place's horizon limit at the new range: when that moves where the tilt ends, the tilt eases from where it is to the new end (holding the camera's pitch through the range change; a camera the zoom leaves past the new range's limit steps down to it with the zoom, since past it the place would drop behind the Earth), and the zoom's result names that angle (`pitchDeg`) with a `note`. Once the tilt continues in place, a zoom moves the camera like any zoom. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the user grabs, touches or scrolls the map during it, or another navigation (a location-panel search, share link or file import, which leave the flight flying) takes the camera during it, the motion is not run and says so. A motion that runs after a fly-to of the same reply has already found nothing (Gemini runs one turn's calls one by one, and passes its turn as the reply) is refused the same way rather than moving the old view; a motion in a later reply runs. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck currentLine truncated
- **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing. - **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing.
- **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm cell says nothing about the ground under the other seven, and treating it as an answer let the dolly descend to 460 m over a 1,600 m rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped. - **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm cell says nothing about the ground under the other seven, and treating it as an answer let the dolly descend to 460 m over a 1,600 m rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped.
- Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet). - Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet).
@@ -6787,14 +6926,17 @@ request viewbox and continues searching without that geographic preference.
### Import Your Own Files (October 2026) ### Import Your Own Files (October 2026)
- UTILITIES ▸ **Import** reads GeoJSON (`.geojson`/`.json`), KML, KMZ and GPX from the picker or a file drop anywhere in the app. Each file gets show/hide, Fly-to and Remove controls. GeoJSON and GPX use the row's color; KML/KMZ retain their styles and show a hatched swatch. The file bytes remain on the device for the page session. Rendered overlays can appear in map screenshots sent through voice or other existing screenshot tools. - UTILITIES ▸ **Import** reads GeoJSON (`.geojson`/`.json`), KML, KMZ and GPX from the picker or a file drop anywhere in the app. Each file gets show/hide, Fly-to and Remove controls. GeoJSON and GPX use the row's color; KML/KMZ retain their styles and show a hatched swatch. The file bytes remain on the device for the page session. Rendered overlays can appear in map screenshots sent through voice or other existing screenshot tools.
- **Ground and height.** GeoJSON XYZ coordinates become ground footprints. KML ground lines/rings are tessellated; static recorded tracks preserve their original vertices, segment gaps and explicit altitude modes. KML with absolute/relative altitude keeps that meaning. Ground pins resolve in batches of 256 against loaded terrain and, in the nearby camera footprint on photoreal maps, `clampToHeightMostDetailed`, then draw in one owned native BillboardCollection per file, with static positions and no per-frame point Entity visualizer or clamping callbacks. Point metadata remains in `dataSource.importedEntities` for framing/style inspection; non-point geometry stays in the normal source collection. Pin collections mount only after source admission, follow file show/hide, and are destroyed on removal. Settled camera/map-source changes and completed terrain loads refresh visible files through the same serial queue; distant points retain their latest available height. No detailed worldwide terrain sweep is started. Nearby mesh sampling can add requests/time; configured photoreal providers require separate validation. The depth test is disabled for ground pins, matching Draw's marker visibility policy. - **Planning and limits.** `geoImportPlan.js` turns plain records and supplied image dimensions into drawing plans and undrawn counts without a DOM or engine. `geoImportDisplay.js` loads images, applies the plan through the globe contract and owns the drawings; `geoImportLimits.js` holds the shared reader, image, drawing and scheduling budgets.
- **Navigation.** Row Fly-to uses `runImmediateNavigation('file', …)`; Cockpit can refuse it and existing follow/track ownership is released first. Automatic Fly-to is stamped when a batch is submitted and reasserted before flying. Pointer/wheel gestures and newer navigation supersede it, including time spent in the queue. Framing uses every finite visible point/path/polygon-hole/wall vertex plus rotated overlay bounds, with viewport-aware range and a nadir view. The target uses available terrain/cached ground height and the ground guard on arrival. Empty footprints never fall back to deferred `viewer.flyTo`. - **Ground and height.** GeoJSON XYZ coordinates become ground footprints. `geoFileImport.js` and `geoImportKml.js` produce plain records; one `geoImportDisplay.js` draws them on ground, movers, lines and imagery on every engine, without loading Cesium. Ground paths follow great circles and retain segment gaps. Pins use square sprite canvases preserving Cesium's icon placement and styles; a file's icon looks share a budget of 16 million canvas pixels, so many distinct styles draw at the same size from lower-resolution textures. A KML heading turns with the camera's heading, staying measured from north. Ground and relative pins sample loaded terrain and, at camera heights below 250 km with a tile surface, `engine.surface.clampToHeight` for the pins in the window; absolute pins keep their height. The first sampling and every refresh run in batches of 256 that yield between them; refreshes go through the controller's serial queue after camera/map changes and terrain completion, retaining the last available height on misses; on a tile surface a pin outside the window keeps its last tile height, which the window test also uses. Ground pins ignore scene depth and are horizon-culled. Removal destroys every owned drawing set.
- **Drawing limits.** Raised polygons draw their fills (meshes triangulated in the plane tangent to the Earth, as Cesium's perPositionHeight) and outlines at their vertices' heights; a raised polygon of more than 128 rings is not drawn, and one of more than 32,768 vertices draws only its outlines (both counted as undrawn). Repeated closing vertices do not consume the fill budget. Ground overlays take their colour's tint and alpha and stack by KML `drawOrder`, then document order (a file imported later draws over); a rotated one is turned on a canvas over the box around it; a file's tinted and turned canvases share a budget of 16 million pixels and equal ones are drawn once; an absolute image or plain-colour overlay is a mesh fixed at its altitude, including negative heights, as Cesium's rectangle at a height (a relative one drapes, as on Cesium). Wall fills, extruded prisms, `gx:LatLonQuad` and a rotated overlay whose box passes a pole are counted as undrawn; wall top edges and polygon top geometry remain. Rhumb edges become great circles. Packed image overlays draw on both engines. A GeoJSON line of one point or a polygon of fewer than three distinct corners is not a record, so a file of only those is refused as having nothing to draw. A hole of fewer than three distinct corners is dropped; a ground polygon the ground contract still refuses is counted as undrawn.
- **Navigation.** Row Fly-to uses `runImmediateNavigation('file', …)`; Cockpit can refuse it and existing follow/track ownership is released first. Automatic Fly-to is stamped when a batch is submitted and reasserted before flying. Pointer/wheel gestures and newer navigation supersede it, including time spent in the queue. Framing uses every finite visible point/path/polygon-hole/wall vertex plus rectangular overlay bounds, with viewport-aware range and a nadir view. The target uses available terrain/cached ground height and the ground guard on arrival. Empty footprints never fall back to deferred `viewer.flyTo`.
- **Clock.** Imports expose no source clock and no entity availability. KML times/view metadata are removed before loading; recorded tracks become static lines. GPX segments remain separate and GPX times are ignored. The shared clock stays unchanged. - **Clock.** Imports expose no source clock and no entity availability. KML times/view metadata are removed before loading; recorded tracks become static lines. GPX segments remain separate and GPX times are ignored. The shared clock stays unchanged.
- **Input handling.** Coordinates and GeoJSON geometry types are checked before Cesium allocation. Only short plain names reach GeoJSON property bags; file IDs, custom CRS and styling properties are removed. KML feature/geometry IDs are removed while Style/StyleMap references remain. Local style references are trimmed, incomplete style pairs removed, hidden-folder flags normalized to the import's show control, and unsupported prototype-named elements removed. UTF-16 BOMs and common KML namespace export defects are handled. Empty drawable content is refused. A loader failure or timeout does not hold the next file indefinitely; teardown aborts pending waits. - **Input handling.** Coordinates and GeoJSON geometry types are checked before drawing allocation. Only short plain names reach the record model; file IDs, custom CRS and styling properties are removed. KML feature/geometry IDs are removed while Style/StyleMap references remain. Local style references are trimmed, incomplete style pairs removed, hidden-folder flags normalized to the import's show control, and unsupported prototype-named elements removed. UTF-16 BOMs and common KML namespace export defects are handled. Empty drawable content is refused. A loader failure or timeout does not hold the next file indefinitely; teardown aborts pending waits.
- **References and images.** File-authored remote links/icons/styles, network links, models and screen/photo overlays are removed. Packed KMZ and embedded PNG/JPEG/GIF/WebP/BMP images remain local; decoded dimensions are checked before image decoding against a total 16-megapixel and 8,192-pixel-side budget per file. Blocked IconStyle images fall back to local pins; authored empty icons remain empty. Colored overlays without an Icon survive; overlays with a removed external image are dropped. Metadata, native labels and descriptions are removed, and generated pins share one lossless image URL per canvas. - **References and images.** File-authored remote links/icons/styles, network links, models and screen/photo overlays are removed. Packed KMZ and embedded PNG/JPEG/GIF/WebP/BMP images remain local; decoded dimensions are checked before image decoding against a total 16-megapixel and 8,192-pixel-side budget per file. Blocked IconStyle images fall back to local pins; authored empty icons remain empty. Colored overlays without an Icon survive; overlays with a removed external image are dropped. Metadata, native labels and descriptions are removed, and points with the same icon look share a sprite set and atlas canvas.
- **Bounds.** File size ≤50 MiB; inflated KMZ ≤120 MiB; ≤20,000 entities (including multipart children and Folder containers); ≤250,000 positions; ≤2,000 normalized style definitions and distinct image links; ≤12 files per session. Exact repeated styles share definitions without changing inheritance or inline overrides. Files of 20–30 MB fit the byte budget, subject to geometry/image limits. Refusals name a reason and batches continue; with multiple failures the hint shows the first reason. - **Bounds.** File size ≤50 MiB; inflated KMZ ≤120 MiB; ≤20,000 drawable records (including multipart children); ≤250,000 positions; ≤2,000 normalized style definitions and distinct image links; ≤12 files per session. Exact repeated styles share definitions without changing inheritance or inline overrides. Files of 20–30 MB fit the byte budget, subject to geometry/image limits. Refusals name a reason and batches continue; with multiple failures the hint shows the first reason.
- **Lifecycle and keyboard.** `createApplicationTools` wires `initGeoFileImport` with viewer, navigation, ground services and map-source subscription. Destroy aborts pending work, unsubscribes, removes data sources, revokes blob URLs, removes document/canvas listeners and releases `window.__gevGeoImport`. List rebuilds retain keyboard focus; removing a row hands focus to the next row, else the previous row, else Import. - **Lifecycle and keyboard.** `createApplicationTools` wires `initGeoFileImport` with the scene engine, navigation, ground services and map-source subscription. Detach destroys drawings and revokes packed-image URLs; reattach reparses the retained file bytes and restores visibility. Destroy aborts pending work, unsubscribes, removes drawings, revokes blob URLs, removes document/canvas listeners and releases `window.__gevGeoImport`. List rebuilds retain keyboard focus; removing a row hands focus to the next row, else the previous row, else Import.
- **Validation.** `node scripts/qa-geo-import.mjs` covers all four formats and real painted show/hide. Run `node scripts/qa-geo-import-regressions.mjs` against both dev and preview for XYZ/track heights, failure recovery, namespace/BOM support, portrait framing and panel drops. `node scripts/qa-geo-import-large.mjs [--file /absolute/path/sample.kml] [--expect-local-pins]` checks a 27.6 MB repeated-style file, native style/image equivalence, large-file painted output and frame times. It records the WebGL renderer; software-rendered runs do not establish GPU performance. Unit tests live beside the data/controller/framing modules. - **Validation.** `node scripts/qa-geo-import.mjs` covers all four formats and real painted show/hide. Run `node scripts/qa-geo-import-regressions.mjs` against both dev and preview for XYZ/track heights, failure recovery, namespace/BOM support, portrait framing and panel drops. `node scripts/qa-geo-import-large.mjs [--file /absolute/path/sample.kml] [--expect-local-pins]` checks a 27.6 MB repeated-style file, effective style equivalence against a native Cesium control, large-file painted output and frame times. It records the WebGL renderer; software-rendered runs do not establish GPU performance. Unit tests live beside the data/controller/framing modules.
- **Reference corpus.** `scripts/fixtures/geo-import/` holds 13 KML/KMZ/GeoJSON fixtures covering heights, rotations, poles, the dateline, holes, ordering, styles, packed images and a 20,000-vertex polygon. `node scripts/qa-geo-import-reference.mjs` compares planned geometry and styles with Cesium 1.138.0 DataSources in Node; `--record` recreates the reference offline with canvas pins stubbed and KMZ images resolved locally. Pixel composition and ordering between primitive families remain browser checks.
### Recent Imagery (September 2026) ### Recent Imagery (September 2026)
@@ -6809,7 +6951,7 @@ pins and share links already supply complete boxes.
- DATA ▸ **Recent Imagery** (`recent-imagery`, `src/layers/recentImagery/`) lists the last 30 UTC days of imagery for a selected box and drapes the days you pick: HLS S30 (Sentinel-2) and L30 (Landsat 8/9) at 30 m, and the VIIRS NOAA-21 daily overview at 250 m. Keyless and browser-direct: NASA CMR for which days have HLS granules in the box (`catalog.js`, both collections concurrently, paged with `CMR-Search-After` up to 2,000 records each, `truncated` against CMR `hits`), GIBS WMTS for tiles, Worldview Snapshots for thumbnails and exports. Worldview's `Data-Present` header is the only availability signal: GIBS answers an empty tile with HTTP 200, and the JPEG overview would drape black, so a day confirmed empty never drapes. - DATA ▸ **Recent Imagery** (`recent-imagery`, `src/layers/recentImagery/`) lists the last 30 UTC days of imagery for a selected box and drapes the days you pick: HLS S30 (Sentinel-2) and L30 (Landsat 8/9) at 30 m, and the VIIRS NOAA-21 daily overview at 250 m. Keyless and browser-direct: NASA CMR for which days have HLS granules in the box (`catalog.js`, both collections concurrently, paged with `CMR-Search-After` up to 2,000 records each, `truncated` against CMR `hits`), GIBS WMTS for tiles, Worldview Snapshots for thumbnails and exports. Worldview's `Data-Present` header is the only availability signal: GIBS answers an empty tile with HTTP 200, and the JPEG overview would drape black, so a day confirmed empty never drapes.
- **Box.** SELECT BOX (`src/ui/imageryBoxTool.js`) claims the pointer like Draw, borrows the stock click actions and freezes the camera only mid-drag; USE VIEW takes the camera rectangle; `boxFromPinAt` grows a 10 km box. Boxes must be ≤ 1,000 km a side, on one side of the dateline and inside the Web-Mercator limit (`validateBox`). A refusal (`boxError`, including the tool's own via `reportBoxRefusal`) stays at the top of the panel until a box succeeds; a view over the cap is refused with its size, never shrunk. An oversized box or view (`Box is … km wide · limit 1,000 km`, `View is … wide · …`) sets the hint to `Zoom in or draw a smaller box` and enables ZOOM IN: `zoomToFit()` flies top-down (1.2 s) to the refused box's centre, or the ground under the canvas centre for a view, at the height whose view is about 400 km wide for the canvas aspect and the camera's vertical FOV (`fitViewHeightM`, clamped 5–400 km), and returns that height. - **Box.** SELECT BOX (`src/ui/imageryBoxTool.js`) claims the pointer like Draw, borrows the stock click actions and freezes the camera only mid-drag; USE VIEW takes the camera rectangle; `boxFromPinAt` grows a 10 km box. Boxes must be ≤ 1,000 km a side, on one side of the dateline and inside the Web-Mercator limit (`validateBox`). A refusal (`boxError`, including the tool's own via `reportBoxRefusal`) stays at the top of the panel until a box succeeds; a view over the cap is refused with its size, never shrunk. An oversized box or view (`Box is … km wide · limit 1,000 km`, `View is … wide · …`) sets the hint to `Zoom in or draw a smaller box` and enables ZOOM IN: `zoomToFit()` flies top-down (1.2 s) to the refused box's centre, or the ground under the canvas centre for a view, at the height whose view is about 400 km wide for the canvas aspect and the camera's vertical FOV (`fitViewHeightM`, clamped 5–400 km), and returns that height.
- **Modes, pins and the preview.** The mode is explicit: `image` (one pinned day, slot `a`), `basemap` (that day on the left of a swipe, the basemap on the right: slot `a` splits LEFT and there is no second layer) and `ab` (day `a` left, day `b` right). `setAssignment(slot, key | null)` / `toggleAssignment(slot, key)` pin and unpin; a day is in one slot at most (pinning it in the other slot moves it) and an empty day cannot be pinned. `setMode()` keeps both pins; `b` only drapes in `ab`. While the mode's first empty slot exists the focused day previews in it: a click at once, arrow keys after 250 ms of quiet (`FOCUS_DEBOUNCE_MS`, one timer, last day wins); an unprobed day is followed and previews when its probe says present, an empty or unknown day never drapes and the previous preview stays. A pin change drops the preview until focus moves again, and unpinning takes that layer off at once. `rankLatest` picks START HERE coverage first (a present HLS day whose footprints cover the box, or have none, beats a newer sliver), then newest with all granules ≤ 20% known cloud, then newest; slivers only as `partial`; else the newest non-empty VIIRS day; it is focused and previewed after each search when nothing is pinned. Escape clears the preview (`clearPreview()`), then cancels the box tool, then blurs — with SELECT BOX armed too, through the tool's `onEscape` interceptor wired in `createApplicationTools`. Pins, mode, box and split changes made in the panel are published with `adoptLayerParams`, never while a share link is being applied. - **Modes, pins and the preview.** The mode is explicit: `image` (one pinned day, slot `a`), `basemap` (that day on the left of a swipe, the basemap on the right: slot `a` splits LEFT and there is no second layer) and `ab` (day `a` left, day `b` right). `setAssignment(slot, key | null)` / `toggleAssignment(slot, key)` pin and unpin; a day is in one slot at most (pinning it in the other slot moves it) and an empty day cannot be pinned. `setMode()` keeps both pins; `b` only drapes in `ab`. While the mode's first empty slot exists the focused day previews in it: a click at once, arrow keys after 250 ms of quiet (`FOCUS_DEBOUNCE_MS`, one timer, last day wins); an unprobed day is followed and previews when its probe says present, an empty or unknown day never drapes and the previous preview stays. A pin change drops the preview until focus moves again, and unpinning takes that layer off at once. `rankLatest` picks START HERE coverage first (a present HLS day whose footprints cover the box, or have none, beats a newer sliver), then newest with all granules ≤ 20% known cloud, then newest; slivers only as `partial`; else the newest non-empty VIIRS day; it is focused and previewed after each search when nothing is pinned. Escape clears the preview (`clearPreview()`), then cancels the box tool, then blurs — with SELECT BOX armed too, through the tool's `onEscape` interceptor wired in `createApplicationTools`. Pins, mode, box and split changes made in the panel are published with `adoptLayerParams`, never while a share link is being applied.
- **Rendering.** `rendering.js` owns at most two GIBS layers on the host `src/maps/imageryHost.js` resolves: `viewer.imageryLayers` while the globe shows, the photoreal tileset's `imageryLayers` while it is hidden, or none (the panel says so). Layers are rebuilt on a new host, never re-parented. `MapSourceController.subscribe()` fires after every settled stack switch (silent switches and fallbacks included) and the layer rebinds on it. Tile requests pass a six-slot semaphore of our own (Cesium's global limits are untouched). Under `requestRenderMode` a deferred tile needs a later frame, but a deferral must never request one itself or a stalled tile keeps the scene rendering every frame: a request that finds the semaphore full asks for nothing (the settling request asks for one frame), and a tile Cesium itself deferred goes through one coalesced 250 ms retry. Thumbnails: three fetches at once, sixteen decoded images, least-recently-used where a `get` read is never a use; eviction keeps the probe result. The strip's visible window is probed around the focused card; the pinned and previewed days are probed wherever they sit, at the focused card's priority, so a pin restored or assigned off-screen still drapes. - **Rendering.** `render/display.js` owns at most two GIBS layers on the surface reported by `engine.imagery.surface()`: the globe, a shown tileset, or none (the panel says so). Layers are rebuilt on a new host, never re-parented. `MapSourceController.subscribe()` fires after every settled stack switch (silent switches and fallbacks included) and the layer rebinds on it. Tile requests pass a six-slot semaphore of our own (Cesium's global limits are untouched). Under `requestRenderMode` a deferred tile needs a later frame, but a deferral must never request one itself or a stalled tile keeps the scene rendering every frame: a request that finds the semaphore full asks for nothing (the settling request asks for one frame), and a tile Cesium itself deferred goes through one coalesced 250 ms retry. Thumbnails: three fetches at once, sixteen decoded images, least-recently-used where a `get` read is never a use; eviction keeps the probe result. The strip's visible window is probed around the focused card; the pinned and previewed days are probed wherever they sit, at the focused card's priority, so a pin restored or assigned off-screen still drapes.
- **Esri surface and the swipe.** While any image is on the map (a preview included) the layer holds the map lease (`acquireImageryComparison`, owner `recent-imagery`, `esri-if-photoreal`): on Google 3D the map switches to Esri, because 30 m pixels over 3D buildings do not read, and Cesium's split only works on the globe. The lease is handed back, and Google 3D restored unless the operator changed maps meanwhile, when the last image leaves the map or the layer is disabled; the notice line says so while it holds. While a day is shown the imagery keeps Esri: a manual switch that lands on Google 3D is answered at once with a new lease under the same policy (`keepEsri`), the notice reads `Imagery stays on Esri · CLEAR to use Google 3D`, and the divider never suspends because of a map switch. It re-leases once per switch generation and only for a manual one (`MapSourceController#getSwitchOrigin()` is `manual` for an outside `setStack`, `automatic` for the controller's own fallback or recovery), never for the lease's own switch and never while releasing, so CLEAR and disable still return to Google 3D; a lease another owner holds stays refused. When Esri fails and the controller falls back to Google 3D on its own, the layer lets the lease go without restoring anything, both days drape on the tileset without a swipe and the guidance reads `Esri map unavailable · no swipe`; only the next manual switch to Google 3D takes Esri again, so a failing Esri map is never retried in a loop. Switching between globe stacks (Esri, OSM, Bing) only rebinds. On a tileset host both images drape without a swipe. The split control (`createImagerySplit`, labels A / B, or IMAGE / BASEMAP) exists only while a swipe is live, recentres on every new comparison (not while scrubbing the second day), except once for a split restored from a link. There is no keyboard shortcut (Space is the app's push-to-talk): SWAP (`#ri-swap`, in the controls row, its slot reserved and the button disabled while no swipe is live) trades the two sides, A to the right and B to the left, or the image to the right of the basemap, and a second click restores; the hint reads `Drag the divider · SWAP trades sides`. The swap is not in the share link, and every new comparison starts unswapped. One opacity applies to both drapes. Another owner holding the lease is reported and the images still drape. - **Esri surface and the swipe.** While any image is on the map (a preview included) the layer holds the map lease (`acquireImageryComparison`, owner `recent-imagery`, `esri-if-photoreal`): on Google 3D the map switches to Esri, because 30 m pixels over 3D buildings do not read, and Cesium's split only works on the globe. The lease is handed back, and Google 3D restored unless the operator changed maps meanwhile, when the last image leaves the map or the layer is disabled; the notice line says so while it holds. While a day is shown the imagery keeps Esri: a manual switch that lands on Google 3D is answered at once with a new lease under the same policy (`keepEsri`), the notice reads `Imagery stays on Esri · CLEAR to use Google 3D`, and the divider never suspends because of a map switch. It re-leases once per switch generation and only for a manual one (`MapSourceController#getSwitchOrigin()` is `manual` for an outside `setStack`, `automatic` for the controller's own fallback or recovery), never for the lease's own switch and never while releasing, so CLEAR and disable still return to Google 3D; a lease another owner holds stays refused. When Esri fails and the controller falls back to Google 3D on its own, the layer lets the lease go without restoring anything, both days drape on the tileset without a swipe and the guidance reads `Esri map unavailable · no swipe`; only the next manual switch to Google 3D takes Esri again, so a failing Esri map is never retried in a loop. Switching between globe stacks (Esri, OSM, Bing) only rebinds. On a tileset host both images drape without a swipe. The split control (`createImagerySplit`, labels A / B, or IMAGE / BASEMAP) exists only while a swipe is live, recentres on every new comparison (not while scrubbing the second day), except once for a split restored from a link. There is no keyboard shortcut (Space is the app's push-to-talk): SWAP (`#ri-swap`, in the controls row, its slot reserved and the button disabled while no swipe is live) trades the two sides, A to the right and B to the left, or the image to the right of the basemap, and a second click restores; the hint reads `Drag the divider · SWAP trades sides`. The swap is not in the share link, and every new comparison starts unswapped. One opacity applies to both drapes. Another owner holding the lease is reported and the images still drape.
- **Panel.** `#recent-imagery-panel` is rail markup in the Context view (`src/ui/views/ContextView.vue`) beside `#weather-panel`, registered in `PanelLayoutController`, `PanelChrome` and the share panel state (`ui` token `i`), hidden while the layer is off and opened on first appearance unless a stored or shared collapse choice exists (`dataset.collapsedPreference`). `LayerPanel.attachRecentImagery(factory)` mounts the readout (`createRecentImageryPanel` in `src/ui/recentImagery.js`) into its `data-rail-scroller` body, as the weather readout is mounted. The body is a fixed stack in which no block changes height and no control moves in any state: `#ri-actions` (SELECT BOX · USE VIEW · CLEAR, rail-card actions), `#ri-notice` (one line: `#ri-notice-text` with the refusal, error, CLEAR notice or the Esri note, and `#ri-zoom-in` in a fixed slot at its right end, reserved unless an oversized box or view can be fitted), `#ri-strip` (day cards newest first: thumbnail, date, sensor, cloud, START HERE, PREVIEW, and a SHOW chip or A and B chips; ← → Home End move, S / A / B pin, Enter previews), `#ri-hint` (one line: the next step), `#ri-selection` (`#ri-mode` IMAGE · VS BASEMAP · A / B, then `#ri-slot-a` and `#ri-slot-b` with `#ri-unpin-a` / `#ri-unpin-b` kept in place when empty), `#ri-controls` (`#ri-opacity`, EXPORT / EXPORT A and EXPORT B) and `#ri-details`, a collapsed rail card holding the focused day's acquisition, coverage and granules, catalog notes, the empty-days toggle and the imagery credit. Absent controls are reserved (invisible), never removed. Every render preserves the body's `scrollTop`; opening DETAILS then scrolls its card into view once. - **Panel.** `#recent-imagery-panel` is rail markup in the Context view (`src/ui/views/ContextView.vue`) beside `#weather-panel`, registered in `PanelLayoutController`, `PanelChrome` and the share panel state (`ui` token `i`), hidden while the layer is off and opened on first appearance unless a stored or shared collapse choice exists (`dataset.collapsedPreference`). `LayerPanel.attachRecentImagery(factory)` mounts the readout (`createRecentImageryPanel` in `src/ui/recentImagery.js`) into its `data-rail-scroller` body, as the weather readout is mounted. The body is a fixed stack in which no block changes height and no control moves in any state: `#ri-actions` (SELECT BOX · USE VIEW · CLEAR, rail-card actions), `#ri-notice` (one line: `#ri-notice-text` with the refusal, error, CLEAR notice or the Esri note, and `#ri-zoom-in` in a fixed slot at its right end, reserved unless an oversized box or view can be fitted), `#ri-strip` (day cards newest first: thumbnail, date, sensor, cloud, START HERE, PREVIEW, and a SHOW chip or A and B chips; ← → Home End move, S / A / B pin, Enter previews), `#ri-hint` (one line: the next step), `#ri-selection` (`#ri-mode` IMAGE · VS BASEMAP · A / B, then `#ri-slot-a` and `#ri-slot-b` with `#ri-unpin-a` / `#ri-unpin-b` kept in place when empty), `#ri-controls` (`#ri-opacity`, EXPORT / EXPORT A and EXPORT B) and `#ri-details`, a collapsed rail card holding the focused day's acquisition, coverage and granules, catalog notes, the empty-days toggle and the imagery credit. Absent controls are reserved (invisible), never removed. Every render preserves the body's `scrollTop`; opening DETAILS then scrolls its card into view once.
- **Share links.** Layer options (token `1`): box edges as integers at degrees × 100000, pins `a` and `b` as compact days (`S20260918`), `m` mode (0 image, 1 basemap, 2 A / B), `p` split percent (share-link only: never stored locally) and `v` for the daily overview. Row chips: `MORE DETAIL · 30 m` (on) and `DAILY OVERVIEW · 250 m` (off). - **Share links.** Layer options (token `1`): box edges as integers at degrees × 100000, pins `a` and `b` as compact days (`S20260918`), `m` mode (0 image, 1 basemap, 2 A / B), `p` split percent (share-link only: never stored locally) and `v` for the daily overview. Row chips: `MORE DETAIL · 30 m` (on) and `DAILY OVERVIEW · 250 m` (off).
@@ -7392,7 +7534,7 @@ easier to meet (detection is now on more often), but does not create it.
counters, and timing labels. counters, and timing labels.
- Voice debug log: `tail -f .gev-logs/realtime-conversations.jsonl` (gitignored). - Voice debug log: `tail -f .gev-logs/realtime-conversations.jsonl` (gitignored).
Replay chase-camera updates run in Cesium `preUpdate` before scene traversal, preventing 3D-tile refinement stutter when the mission replay speed is reduced. Replay Ascent first gives the selected launch site a five-second tile-preparation hold before displaying the T-minus countdown. Replay ascent duration is mission-specific: disclosed insertion, SECO, or separation timing is compressed into the replay, while sparse records use reconstructed path length with bounded fallback timing instead of a universal fixed duration. Replay transitions directly from ascent to orbit; stage re-entry/recovery remains static contextual linework and is never a camera-tracked playback phase. The screen-space rocket/thrust symbol renders at 50% of its 92 × 138 px design box, while the separate callout text remains unchanged. Successful/upcoming selected missions show a current orbit marker: green for a reliable TLE match or amber and explicitly estimated when no live match exists. Failed launches show their source status and suppress live/estimated orbit markers and fallback rings. A retained Launch Library orbit is labeled as the planned target, an absent ascent is reported as unavailable, and replay-only controls remain hidden unless the selected mission has a rendered track; authoritative supplied trajectory points remain visible when present. Mission orbit primitives realign by model matrix each tick from the same current-GMST frame as their marker, preventing ring/marker drift; their host annotation reads the position cache updated in that same tick. During depth-dominant ascent segments, the replay rocket retains its last valid path-facing rotation rather than snapping toward the camera. Mission selection and replay overlays never call photoreal `sampleHeight()` from the render loop; the launch-zone ground primitive and precomputed surface-safe replay path avoid remote tile-refinement probes that previously caused a one-second globe texture pulse. The shared host replaces the former mission-label visibility churn and quadratic overlap loop; the layer's remaining frame sweep only culls native point/billboard geometry and refreshes selected UTC copy when its displayed second changes. Replay samples uneven path vertices by cumulative distance and normalizes camera-yaw easing to frame time. Replay chase-camera updates run in Cesium `preUpdate` before scene traversal, preventing 3D-tile refinement stutter when the mission replay speed is reduced. Replay Ascent first gives the selected launch site a five-second tile-preparation hold before displaying the T-minus countdown. Replay ascent duration is mission-specific: disclosed insertion, SECO, or separation timing is compressed into the replay, while sparse records use reconstructed path length with bounded fallback timing instead of a universal fixed duration. Replay transitions directly from ascent to orbit; stage re-entry/recovery remains static contextual linework and is never a camera-tracked playback phase. The screen-space rocket/thrust symbol renders at 50% of its 92 × 138 px design box, while the separate callout text remains unchanged. Successful/upcoming selected missions show a current orbit marker: green for a reliable TLE match or amber and explicitly estimated when no live match exists. Failed launches show their source status and suppress live/estimated orbit markers and fallback rings. A retained Launch Library orbit is labeled as the planned target, an absent ascent is reported as unavailable, and replay-only controls remain hidden unless the selected mission has a rendered track; authoritative supplied trajectory points remain visible when present. Mission orbit primitives realign by model matrix each tick from the same current-GMST frame as their marker, preventing ring/marker drift; their host annotation reads the position cache updated in that same tick. During depth-dominant ascent segments, the replay rocket retains its last valid path-facing rotation rather than snapping toward the camera. Mission selection and replay overlays never call photoreal `sampleHeight()` from the render loop; the launch zone (a radial ground disc draped on the scene) and precomputed surface-safe replay path avoid remote tile-refinement probes that previously caused a one-second globe texture pulse. The shared host replaces the former mission-label visibility churn and quadratic overlap loop; the layer's remaining frame sweep only culls native point/billboard geometry and refreshes selected UTC copy when its displayed second changes. Replay samples uneven path vertices by cumulative distance and normalizes camera-yaw easing to frame time.
Orbit replay framing uses one combined bounding sphere for Earth and every sample of the selected orbit. The camera derives its final range from that full envelope, while its look-at target retains a radial bias toward the moving vehicle rather than collapsing onto the singular Earth-center frame. Compact-orbit launchers therefore remain tracked during camera rotation, and highly eccentric transfer orbits still keep both the globe and their distant apogee arc visible. The orbital camera stays on one side of the mission's 3D orbit plane and uses the vehicle radial as visual up, so forward motion remains screen-left through polar/local-heading wraps instead of alternating left and right. The fixed-size cyan orbit dot retains one pixel scale throughout the pullback. While replay owns the camera, the selected launch-site host label is suppressed so it cannot duplicate or overlap the replay vehicle's DOM callout; cancel/completion restores it. Orbit replay framing uses one combined bounding sphere for Earth and every sample of the selected orbit. The camera derives its final range from that full envelope, while its look-at target retains a radial bias toward the moving vehicle rather than collapsing onto the singular Earth-center frame. Compact-orbit launchers therefore remain tracked during camera rotation, and highly eccentric transfer orbits still keep both the globe and their distant apogee arc visible. The orbital camera stays on one side of the mission's 3D orbit plane and uses the vehicle radial as visual up, so forward motion remains screen-left through polar/local-heading wraps instead of alternating left and right. The fixed-size cyan orbit dot retains one pixel scale throughout the pullback. While replay owns the camera, the selected launch-site host label is suppressed so it cannot duplicate or overlap the replay vehicle's DOM callout; cancel/completion restores it.
+1 -1
View File
@@ -90,7 +90,7 @@ load, removes partial results and reports a stable error without exposing a URL.
Successful loading leaves the attribution cards visible with the assets. Successful loading leaves the attribution cards visible with the assets.
`createApplicationTools({ sceneDataPacks: { sources } })` and `createApplicationTools({ sceneDataPacks: { sources } })` and
`new SceneDirector(viewer, style, data, { dataPacks: { sources } })` accept a `new SceneDirector(scene, style, data, { dataPacks: { sources } })` accept a
registry of source functions. Each receives `{ path, signal, maxBytes }` and registry of source functions. Each receives `{ path, signal, maxBytes }` and
returns `{ bytes: Uint8Array, mimeType }`. Custom transports must honor the returns `{ bytes: Uint8Array, mimeType }`. Custom transports must honor the
signal, bound reads and return the real MIME type. They remain application code, signal, bound reads and return the real MIME type. They remain application code,
+660
View File
@@ -0,0 +1,660 @@
# Globe primitives
A layer draws through the globe-engine contract (`src/globe/contract.js`),
not through Cesium or Navara. The contract has five drawing primitives:
- `engine.movers`: things at points;
- `engine.lines`: lines in the air;
- `engine.ground`: fills on the ground;
- `engine.imagery`: rasters on the ground;
- `engine.meshes`: textured or coloured world triangles.
Each engine implements all five. Cesium's are in `src/globe/cesium/`,
Navara's in `src/globe/navara/`, and the fake engine's in
`src/globe/testing/fakeEngine.js`. The checks every engine must pass are in
`src/globe/testing/contractSuite/index.mjs`.
A new contract member lands in one change with its implementation on Cesium,
Navara and the fake engine, a behavioral check in the contract suite, and a
mutant that the check kills. Keep check names prefixed with the member they
exercise; optional behavior belongs in `engineCapabilities`.
## Movers: things at a point
`engine.movers.createSet({ id, kind, capacity, ... })` makes a set of up to
`capacity` instances drawn as one batch. Write them with
`set(index, fields)` and `setCount(n)`, then upload the changes with one
`commit()`.
There are three kinds:
- `sprite`: an image from an atlas, a square of `sizePx`, turned by
`rotation`;
- `point`: a filled disc with an outline;
- `model`: a glTF in metres, at least `minimumPixelSize` on screen.
Use movers for aircraft, vessels, satellites, cameras, stations: anything
that has a position and is drawn the same size on screen (or as a model).
Point instances may also set `outlineColor` independently of the fill,
so an expanding callout can fade its outline without changing its width.
Instances move every frame without a rebuild. `depthTest` decides whether
the scene hides them. A pick returns the instance's `pickId` when the set is
`pickable`. `ready(index?)` says whether a model's glTF or a sprite atlas has
loaded and `failed(index?)` whether it failed to: a failed set is not loaded
again, so a layer that retries makes a new set. Cesium loads a model per
instance: an index written with another `pickId` loads its own model, so an
instance moved into a failed index does not inherit the failure.
## Lines: polylines in the air
`engine.lines.createLine({ id, widthPx, color, ... })` makes one polyline.
You can change its positions, colour and model matrix at any time.
The width is in CSS pixels. The line follows the 3D positions it is given;
it does not follow the ground. Options:
- `arc: 'geodesic'` bends it along great circles;
- `dash` draws dashes;
- `depthFailAlpha` draws a dimmer copy where the scene hides it.
Use lines for trails, orbits, launch paths, a ring at a fixed height, and
leader lines.
## Ground: fills on the ground
`engine.ground.createSet({ id, kind, order, pickable, drape })` makes a set
of flat fills that drape over the terrain and the 3D tiles. Nothing is
extruded.
Work with a set like this:
- `replace(items)` checks and stores all its items at once;
- `commit()` draws them in place of the last ones;
- `show(visible)` shows or hides the whole set;
- `list()` names the sets.
Positions are `[longitude, latitude]` in degrees, as GeoJSON stores them.
Edges follow great circles.
There are four kinds:
| kind | item | for |
| ---------- | ------------------------------------------------------- | --------------------------------------------------------- |
| `polygon` | `rings: [outer, ...holes]`, `fill`, `outline` | perimeters, airspace, footprints, cones, KML polygons |
| `disc` | `center`, `radiusM` (metres along the ground) | earthquakes, air-quality stations, a pad zone |
| `cell` | `west`, `south`, `east`, `north` (meridians, parallels) | GNSS and FIRMS cells, privacy cells |
| `corridor` | `positions`, `color`, `widthPx` or `widthM` | routes, cables, roads, coverage lines, outlines in metres |
Item options:
- Every item has its own colours, rgba in 0..1; the alpha is the opacity.
- `fill` and `outline` (`{ color, widthPx }`) are optional; an outline runs
along every ring.
- Within a set, a later item draws on top. Between sets, a higher `order`
draws on top. All ground fills draw under movers, lines and models.
- `pickable: true` makes picks return an item's `pickId`, with `owner` (the
set) and `instance` (the item's index).
- `drape` is `'both'` (the default), `'terrain'` or `'tiles'`. Both engines
honour it: Navara tells 3D tiles from terrain by its globe depth, and its
CPU picks skip a set draped only on the surface not shown (terrain while
photoreal tiles show, tiles otherwise).
Discs with a fill may use `radial: { fillAlpha, rimAlpha, rimStart, rimEnd,
emission }`. Alpha multipliers are in 0..1; the radius fractions satisfy
`0 <= rimStart < rimEnd < 1`; emission is nonnegative. The rim grows with
smoothstep between the two radii, then fades smoothly to zero at the outer
edge. Space Missions uses a 500 m disc with alpha 0.105 inside and 0.72 at
the rim, transitions at 0.952 and 0.985, and emission 0.35. Cesium retains
its textured ground material and polygon depth bias. Navara composes the
same radial envelope in its ground pass after tone mapping.
An unpickable pixel corridor set with `reveal: true` accepts
`setReveal(fraction)` in 0..1. Each path is clipped at that fraction of its
length. Geometry stays intact: Cesium updates material uniforms and Navara
updates the corridor item texture. Hiding, showing and committing a new
path retain the fraction. A corridor may have `backing: { widthPx, color }`:
a wider edge with its own colour, with the core blended only once.
Transit drapes its history and active head through ground corridors. Its
completed runs retain their age bands, 5 px dark backing and 3 px colour,
with band updates once per displayed second. The active head is one backed
corridor revealed along the whole current segment; only a segment crossing
rebuilds its geometry. Transit uses dynamic sets so new history and head
corridors build synchronously once Cesium's terrain bounds are available.
The ground pass handles occlusion on both engines.
Cesium also builds reveal sets synchronously after loading those bounds.
For nonempty sets, `ready()` includes the visible globe's tile readiness:
classification needs the current surface depth as well as built geometry.
A `widthPx` corridor can also take the looks Cesium's ground polyline
materials gave it. The GPU works them out every frame from the time, so a
commit never rebuilds them:
| look | fields | Cesium's |
| ------- | ------------------------------------------------ | ---------------------------------------------------------------- |
| `glow` | `power`, `taper` (1: none) | `PolylineGlow`'s `glowPower` and `taperPower` |
| `pulse` | `periodMs`, `min`, `max` | the alpha times a factor from `min` to `max` and back, a sine |
| `dash` | `cells`, `duty`, `speed`, `gapAlpha`, `emission` | the annotation route's `GevRouteFlow`: dashes flowing to the end |
- `glow` brightens the line toward white along its centre and fades it to
nothing at its edges.
- `pulse` follows the page's monotonic clock (`performance.now()`), as the
old materials did, not the app clock.
- `dash` lays `cells` dashes along the whole line. Each is lit for its first
`duty`, with soft edges of 0.08 of a cell, and brightened by `emission`
times its colour. Between them the alpha is times `gapAlpha`. The dashes
move toward the line's end at `speed` cells a second.
- A shown set with a `pulse` or a `dash` makes the engine draw a frame after
every frame, as Cesium's continuous render did. `list()` reports it as
`animated`. A `glow` alone does not animate.
Traffic's jam heat lines glow (power 0.25) and pulse (alpha 0.35 to 0.75,
1.63 s). The Directions route's 64 dashes flow at 0.55 cells a second.
A pixel corridor may instead use `dashPattern: { lengthPx, pattern: 255 }`,
a static 16-bit screen-space dash mask with transparent gaps, as Cesium's
PolylineDash material. It cannot combine with the other corridor looks.
Bhote Koshi uses an 18 px dash behind its 5 px glowing flood core.
Use `dynamic: true` on a set whose path changes each frame. Cesium waits
for its shared terrain-height bounds, then builds these small reveals
synchronously so successive frames do not discard unfinished worker jobs.
Other sets retain asynchronous building.
### Radial pulse fields
`engine.ground.createPulseField({ id, center, radiusM, heightM, color,
lineColor, haloColor, depth, lineWidth, haloWidth })` draws up to four
concentric pulse fronts. The centre is Earth-fixed metres; the radius is
metres along the surface. The disc lies on the ellipsoid raised by
`heightM` (default 0), with normal scene depth testing. Colours are rgba in
0..1, widths are CSS pixels, and `depth` is the inward band depth as a
fraction of the radius. A band's opacity falls quadratically to zero.
Call `draw({ radii, alphas, level })` with up to four radius fractions and
opacities in 0..1. Missing fronts are zero. `level` controls visibility;
the caller includes it in the alphas. The field starts hidden. `move(center)`
follows a new centre; `destroy()` releases it and is safe to repeat.
`ground.list()` includes it as `kind: 'pulse-field'`. The caller owns its
clock and render requests; the field starts no animation loop.
Global Context keeps its 2,500 m pulse height, fixed-width front and halo,
wake and rest windows, reduced-motion behaviour and 80 ms frame cadence.
Cesium retains one material disc, updating its uniforms and rotating for
small moves. Navara evaluates the same bands after tone mapping against a
raised ellipsoid and scene depth; its shader lives in
`src/globe/navara/ground/pulse.js`. The fake engine records the same state.
Bhote Koshi's event and locator share `src/layers/bhoteKoshi/render/story.js`:
the authored reveal properties feed ground corridors and polygons, and
point movers retain their independent fill and outline opacity. No story
backend is selected by engine. The authored model still uses its existing
property records; removing that model dependency is separate work.
### Ground drawing guarantees
Both engines preserve item order, fill and outline colours, pixel and metre
widths, visibility, and pick ownership. Non-pickable sets let picks through.
`ready()` reports when committed geometry is ready to draw. Animated corridor
looks use the shared ground time and request frames while shown.
Cesium translates ground items to classified fills and polylines. Navara
classifies visible scene depth and blends the ground in display space.
Navara picks ground items at the surface under the pointer. Packing, tiling
and bin sizes are implementation details in `src/globe/navara/ground/`.
## Imagery: rasters on the ground
`engine.imagery.createLayer({ id, source, alpha, show, split, order, raise })`
makes one raster layer draped over the terrain and the 3D tiles, under
every ground fill, mover, line and model. Its colours are the image's own,
blended by the texel's alpha times the layer's `alpha`.
There are two kinds of source:
| kind | source | for |
| ------- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
| `tiles` | `url` with `{z}`, `{x}`, `{y}` (and `{s}` of `subdomains`), `minLevel`, `maxLevel`, `tileSize` | Recent Imagery's GIBS days, weather tiles, open overlays |
| `image` | `image`: a canvas, an ImageBitmap or image element, or `{ width, height, data }` RGBA bytes; or `url` | the FIRMS heat raster, a field image, event imagery |
Both take:
- `rectangle` (`{ west, south, east, north }` in degrees; west > east
crosses the antimeridian): where the layer draws, the globe when omitted;
- `credit`: HTML shown while the layer is.
Tiles are Web Mercator XYZ tiles, which stop at ±85.05°, or with
`scheme: 'geographic'` EPSG:4326 tiles (2×1 at level 0, y down from 90°N,
Cesium's `GeographicTilingScheme`). `opaque` tiles have no alpha channel,
and `maxRequests` caps the layer's tile requests in flight.
`transform(image)` turns each decoded tile (row 0 on the north edge) into
the canvas or image that draws: the weather's infrared mask. An image is
stretched over its rectangle in longitude and latitude, row 0 on the north
edge; one over the whole globe wraps across 180°.
`raise: heightM` puts an image layer on a shell that high above the
ellipsoid instead of draping it: it draws over terrain, 3D tiles and the
sky's edge, hidden only by what is nearer. The shell rises with the camera
(4 m per kilometre of camera height, at most 60 km, in 500 m steps) so the
coarse 3D tiles seen from far never hide it. Raised layers take image
sources only.
Work with a layer like this:
- `setSource(source)` draws another source in its place, at its place in
the order; the same canvas or bytes again draw their new pixels, and a
source that differs only in its `credit` keeps its pixels;
- `setSource(source, { staged: true, signal })` keeps the drawn source
until the new one is complete (the image loaded; the tiles in view in),
then swaps them. It resolves true then, and false when the new source
failed, `signal` aborted it, or another `setSource` replaced it first. A
hidden layer takes it at once. `staging()` says one is on its way;
- `setDetail({ image, rectangle } | null)` (raised layers): inside the
rectangle the layer draws the detail image in place of its own (a
sharper view of the area in view). The last detail draws until the new
one is uploaded; it resolves true once it draws. null clears it;
- `setAlpha(alpha)` fades it: at 0 it draws nothing but stays on its
surface, so it comes back without a gap (the FIRMS hand-off);
- `show(visible)` shows or hides it (Cesium unmaps a hidden layer's tiles);
- `setSplit('left' | 'right' | 'none')` puts it on one side of the split;
- `ready()` says the source is built (an image URL loaded); tiles arrive
after it;
- `destroy()`.
On the engine:
- `list()` names the layers in draw order, bottom first: a higher `order`
draws on top, then the later layer;
- `setSplitPosition(fraction)` sets where the split runs, a fraction of the
canvas width from the left (one per engine, Cesium's
`scene.splitPosition`); `splitPosition()` reads it;
- `surface()` says where imagery drapes now: `'globe'`, `'tiles'` (3D tiles)
or `'none'`.
### Imagery drawing guarantees
Both engines draw imagery below ground shapes, movers, lines and models,
in layer order. Each texel blends by its alpha times the layer alpha.
Visibility, split position, rectangle bounds and raised-shell height follow
the imagery contract. Replacing or destroying a source releases its owned
resources; stale loads cannot replace the current source.
Cesium translates sources to imagery providers and raised shells. Navara
samples imagery in its surface pass, including raised ellipsoid intersections.
The adapters own texture storage, tile selection and depth reconstruction.
Cesium moves draped layers between the visible globe and tileset surfaces;
without either surface, layers wait. Its split applies to globe imagery,
not raised shells. Navara draws the top eight visible imagery slots, with
detail images consuming a slot. Staged sources keep the current image visible
until their replacement is ready; a failed source leaves it intact.
Open map overlays use `engine.imagery` when `maps.addOverlay` is absent.
Navara's optional native raster path retains terrain detail, but does not
provide rectangle bounds, split, alpha, order, or drawing on 3D tiles.
### Who draws through imagery
- **FIRMS heat** (`src/layers/firms/render/display.js`): one image.
- **Recent Imagery** (`src/layers/recentImagery/render/display.js`): two
Web Mercator tile layers under a split.
- **Weather radar, satellite clouds and lightning**
(`src/layers/weather/render/display.js`): on the globe, the product's
geographic tiles (the regional infrared through its mask, a tile
transform), or the global infrared mosaic as one image; each new frame a
staged source; clouds, radar and lightning at orders 21, 22, 23. On 3D
tiles, a raised layer at the product's shell height
(`shellRendering.js` `WEATHER_SHELL_HEIGHTS`) with the detail window
around the view as its detail.
- **The wind field** (`src/layers/wind/render/display.js`): one image over
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:
`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
locator's story graphics use the shared ground and mover display described
above.
- **Imported KML/KMZ ground images** (`src/data/geoImportDisplay.js`): one
image layer per rectangle at order 30, with the authored alpha capped at
0.99 as on Cesium; a tinted or rotated image is drawn on a canvas first,
and an absolute one is raised to its altitude.
- **Sea Marks and Ski Pistes** (`src/data/osmOverlays.js`): tile layers at
order 10 on engines without `maps.addOverlay`.
## Imported files
`geoFileImport.js` reads GeoJSON, GPX, KML and KMZ into the plain records in
`src/data/geoImportRecords.js`. `geoImportKml.js` resolves KML styles with
Cesium's precedence; its tests compare the records with `KmlDataSource`.
No import loads the Cesium SDK. One `geoImportDisplay.js` draws the records
on every engine: points on movers, ground paths and polygon fills on ground,
raised paths and outlines on lines, raised polygon fills on meshes, and packed
ground images on imagery.
Every owned id starts with `geo-import-`; the file controller owns visibility,
framing, refresh and destruction across engine switches.
Pins use composed square sprites to retain Cesium's pin tip, hot spot,
subregion, colour, scale and rotation; a KML heading turns with the camera's
heading, so it stays measured from north. Icon textures share a per-file
budget of 16 million pixels: many distinct styles get lower-resolution
textures at the same size. Ground heights come from the loaded surface,
refreshed after the camera or map settles, in batches of 256; on 3D tiles
only pins in the window are clamped to the tiles drawn. Ground pins ignore
scene depth and are culled beyond the horizon; absolute pins retain their
height.
Raised polygons (KML absolute or relative) draw their fills as meshes and
their outlines as lines at the vertices' heights. A ground overlay's colour
tints its image and its alpha fades it; a rotated overlay's image is turned
on a canvas over the box around it, as Cesium turns its rectangle in degrees,
and an absolute image or plain-colour overlay is a mesh at its altitude,
including negative heights (Cesium raises only absolute ones).
The shared display has known limits against Cesium: wall fills and extruded
prisms are omitted (their top geometry remains), and
`gx:LatLonQuad` and a rotated overlay whose box passes a pole are omitted.
These are counted as undrawn. Ground edges use great circles rather than
rhumb lines.
## Input ownership
`engine.input.on(type, listener, { pick, drill, pickWindow })` accepts
`pickWindow: { width, height }` with positive integer dimensions passed to
the picker. Omitted dimensions keep the engine default. Listeners share a
pick only when their windows match, and each receives its requested drill
limit. Pointer-down picks are synchronous. Moves, including drills, coalesce
to the latest position per frame. Navara clicks still use one GPU answer
from the gesture; a missing hit falls back to the requested CPU window.
Street Level requests 9 × 9 px; CCTV's gizmo requests its own window and
limit for presses and hover. Pointer ownership and removal still suppress
delivery while a pick is pending.
`engine.projector.horizonVisible(position, { ellipsoid })` accepts a plain
ellipsoid with positive finite `radii: { x, y, z }` in metres. Without the
option it uses WGS84. Street Level supplies WGS84 radii reduced by 1,000 m
so nearby cones remain visible below sea level. The shared math is tested
against the native ellipsoidal occluder, including points near the limb.
`engine.input.suspendCameraInputs()` disables camera gestures and returns an
idempotent resume function. Suspensions are counted: only the last resume
restores the state captured before the first suspension. This preserves an
already-disabled camera. `cameraInputsEnabled()` reads the current state.
Cockpit, imagery-box drawing, CCTV calibration and infrastructure flights use
these methods on every engine. Pointer claims remain separate from camera
input suspension.
Pick ownership belongs to the scene attachment, not to an engine implementation.
`scene.services.picking` owns its predicates and `scene.services.selection` owns
its shared click dispatcher. Layer entry points resolve these services using
their attached engine (`sceneServicesFor(engine)`); the pick-registry helper
functions take that engine as their final argument. Standalone entries acquire
the same services on first use. The application destroys them on detach and
creates fresh services when reattaching a parked engine. Identical layer IDs in
simultaneous scenes cannot replace or unregister one another's picks.
## Render demand
`scene.services.render` owns continuous-render holds and the last 16 idle
request reasons for one attachment. `holdContinuousRender(id)` and
`releaseContinuousRender(id)` are idempotent per owner; the last release
requests one settling frame. `governorRequestRender(reason)` forwards a
one-shot request even while another owner holds continuous rendering.
The tools call `install()` after scene consumers have registered their holds.
The governor drives `engine.render` on every engine. Cesium implements
`setContinuous` with its scene's `requestRenderMode` and disables automatic
simulation-time renders with `maximumRenderTimeChange = Infinity`. Engine
internals request their own scene directly and never import the governor.
Layer service helpers take their attached engine as the final argument;
attachment-bound callbacks can capture `renderGovernorFor(engine)` directly.
Destroying the scene clears its holds and makes the captured service inert.
A parked engine gets a fresh service when reattached.
## World overlays
`scene.services.overlay` owns one attachment's world labels and cards. It
keeps its sources, collision domains, paint lanes, canvases, frame listeners,
observers, timers and accessible action subscribers. `init()` installs its
DOM inside `engine.host.container`; the detection blend surface stays beside
the card root so it can blend against the scene.
Publish with `setEntries(sourceId, entries, options)`, `setVisible(sourceId,
visible)` and `clearSource(sourceId)`. Read painted rectangles with
`getPaintRect(sourceId, entryId)` and register custom painters with
`registerPaintLane(laneId, painter, options)`. `subscribeActions(listener)`
provides the accessible mirror; `activateAction(owner, key)` rejects an old
host's actions. The shell reconnects these operations on every attachment.
`worldOverlayFor(engine)` resolves the same service for standalone consumers.
The module helper functions take the engine first. Retained layer models use
`bindOverlayHost` with their attached-engine getter; injected hosts without a
`forEngine` resolver keep their own operations. Publications can buffer before
`init()`. Destruction is terminal, including before initialization; a parked
engine needs fresh scene services. An old paint-lane handle cannot mutate its
replacement. Font measurements are shared until the last live host releases
them.
`scene.services.trackedReadout` bridges the follow handle's label and selected
ALPR or Local ADS-B card into this overlay. `init()` subscribes to that engine's
follow changes and that attachment's context events; `refresh(entity)` republishes
changed label text, and `activeId()` resolves the card for annotation placement.
Positions stay live cached visual anchors. `trackedReadoutFor(engine)` resolves
the service; destruction removes its listeners and clears only its own source.
`scene.services.context` owns selection records and notifications. Layer publishers
use `bindOverlayHost` with their current engine, or a context service captured by
their display. It mirrors records to the page context slot and preserves the
`gev:entity-selected`, `gev:entity-selection-cleared` and aircraft awareness event
payloads for page consumers. A clear reaches the page only when that attachment
owns the page's selected record. Readouts listen only to their scene's event target.
Identical IDs in different scenes cannot share a card or clear one another's
selection. Retired context services cannot publish into a replacement attachment.
An engine handoff carries the page-selected record through the layer session.
The incoming context adopts it only while the page still selects that record;
a clear between attachments wins. Rebuilt markers replace the retained carrier
in the incoming readout without announcing another page selection.
Readout initialization reads the retained record without testing the outgoing
carrier's visibility; layers rebuild their carriers after camera restoration.
The page chrome and detection controller still have page lifetimes.
## Render effects
The optional `visualStyles`, `bloom` and `contactSonar` capabilities are
available on Cesium and Navara through `engine.render`. Cesium implements
styles, sharpening, borrowed bloom and the Anime Milky Way fade in
`src/globe/cesium/postStages.js`; contact sonar is in `sonar.js` and
`sonarCommands.js`. Cesium does not advertise `cloudCover`.
`VisualEffects` drives transitions through the render contract. UI teardown
stops animation and disables effects; engine destruction removes its stages
and restores the borrowed bloom state. Sharpen intensity zero disables
sharpening on both engines, following Cesium.
## Wildlife privacy cells
Wildlife privacy cells use one `cell` ground set for distinct bounds and taxon
accents. The fill is 25% opaque and the 2 px outline is 80% opaque, matching
Cesium's original ground geometry. Both drape on terrain and tiles and pass
picks through to the badges. Wide views and detach release the set.
## Batched line segments
`engine.lines.createSegments({ id, widthPx, color, order })` groups independent
straight segments in one batch. `replace(items)` copies pairs of Earth-fixed
endpoints and optional per-item `widthPx`, `color` and `pickId`; `commit()`
rebuilds the drawing once. Segments never connect to one another. `show()`
and `destroy()` affect the whole batch; an empty replacement clears it.
The batch shares `createLine`'s id namespace and `list()`, which includes its
endpoint count and committed `pickIds` in item order.
Cesium draws one synchronous primitive with per-instance colours and widths.
Navara draws one ribbon mesh in the overlay after tone mapping, with scene
depth and independent segment picks. Batches are solid and depth-tested;
curved, dashed, transformed and depth-fail lines still use `createLine`.
Infrastructure and cable references each use one batch for their visible
stems, preserving Cesium's colours, widths, label winners and update cadence.
Infrastructure excludes the whole stem batch while sampling its bases.
### Choosing a primitive
- **A position and an on-screen size:** a mover.
- **A line at given heights, or one that moves or is restyled every frame**
(trails, orbits, a ring at the terrain height): a line.
- **Anything that lies on the ground** and follows its relief:
- an area: `polygon`, or `disc` or `cell` when that is its shape (they
are exact and cheaper);
- a line on the ground: `corridor`, with `widthM` when its width is a
real width (an outline in metres) and `widthPx` otherwise.
- Group items that change together into one set, and replace them in bulk.
A ground set rebuilds on commit. Small paths revealed each frame use
`dynamic: true`; corridor looks animate without a rebuild, and radial
fronts use `ground.createPulseField`.
- **Pixels on the ground** (tiles, a raster, a photo): an imagery layer.
Fade it with `setAlpha`, and give a new raster to `setSource`.
## Meshes: world triangles
`engine.meshes.createMesh({ id, positions, indices, uv, image, color, show,
depthTest, pickId })` draws indexed triangles on both sides. Positions are
flat Earth-fixed xyz metres and indices name their vertices. Optional UVs run
from (0, 0) at the image's bottom left to (1, 1) at its top right. The engine
copies geometry arrays; the caller owns the canvas, image or video.
`update(patch)` replaces only supplied fields. Repeating `image` uploads new
canvas pixels; videos update on rendered frames. RGBA `color` multiplies the
image, white by default. `show()` reads visibility and `show(boolean)` changes
it. `depthTest` is `normal` or `always`; null `pickId` disables picking. Picks
return the authored id and the mesh id as owner. `destroy()` is idempotent;
`engine.meshes.list()` returns live ids in creation order.
Cesium draws a synchronous Primitive with a flat material, no back-face
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 the shared
overlay pass after tone mapping and anti-aliasing, the pass that draws sprites
and lines, 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.
CCTV's `render/meshes.js` describes its live monitor quad and viewshed pyramid
through this primitive. Its markers and outlines remain movers and lines.
Obstruction probes use `engine.surface.raycast`; exclusions accept owned mesh,
line and mover handles on Cesium and restore their visibility after the query.
Data packs use one `src/scenes/dataPacks/display.js`: points on movers,
straight raised paths on lines, raised polygons and image rectangles on
meshes, and media markers with captions in a composed sprite. Polygon holes
and authored vertex heights are retained. Shared triangulation uses `earcut`,
with tests comparing its area to Cesium's polygon geometry. Images retain
Cesium's one-degree rectangle grid. Scene detach releases drawn resources and
pick targets; reattach redraws the retained decoded assets.
Annotation drapes still use their existing renderers. Their bounded building
classification volumes, animated fills and wire cages are not ordinary world
triangles; they need a classification contract before that migration can
preserve Cesium's behaviour.
## Shared follow offsets and manual drawing
`engine.camera.follow({ position, viewFrom, swingTo })` can swing from the
initial follow frame to `swingTo`, an east/north/up offset in metres. Each
frame turns at most 60 degrees and interpolates range, preserving Cesium's
tracked-camera crossing guard. `handle.retarget({ swingTo })` changes only
the pending swing; `null` cancels it. Stopping or replacing the follow, a
camera command or engine destruction ends it. Share links read the current
handle's target and label identity and the camera pose through the contract.
`engine.input.acquirePointer()` returns an exclusive claim, or `null` if
one is held. Pass it to `input.on(type, listener, { pointer: claim })` and
call `claim.release()` when done. Other screen subscriptions yield, including
picks that settle after ownership changes. Camera gestures stay enabled.
Cesium suspends its stock select and track clicks and restores the original
functions when the claim is released or the engine is destroyed.
The Draw tool uses one `drawSurface.js` on every engine: a dashed geodesic
line and a point mover set at the clicked heights. The dim, occluded band
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`. `assertGlobeEngine(engine)`
checks these required interfaces. `engineCapabilities(engine)` reports only 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`; use
`--only=ground.,imagery.` for ground and imagery pixel checks.
Three reports keep migration debt from growing: `scripts/report-engine-coupling.mjs`
counts application coupling, `scripts/report-qa-harness.mjs` counts browser QA
duplication, and `src/globe/testing/contractCoverage.test.mjs` reports contract
members the fake-engine suite leaves uncalled. After removing coupling or moving
QA work to `scripts/qa-strict-harness.mjs` helpers, run the matching report with
`--write-baseline` and keep only decreases in its baseline. After adding behavioral
contract checks, remove the newly exercised names from
`src/globe/testing/contract-uncalled-baseline.json`; keep it sorted, and remove
members that no longer exist too. Run the matching tests with
`node --import=./build/registerVue.js --test` followed by their paths; the numeric
baselines reject growth, and the contract baseline also rejects stale entries.
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 factories through
`display`. Every valid engine implements all five primitives; the manifest has
no per-kind primitive lists or `needs` declarations. `backends` supplies optional
engine-specific display options, currently only wind's `flow`, loaded dynamically
from its engine folder. A missing wind backend leaves the shared raster field
available. Self-drawing layers and map overlays use the same contract and do not
register supported engine kinds in the manifest.
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)`. `gods-eye-view/infrastructure` exports `createInfrastructureLayers(services)`.
It returns fresh datacenter and dam layers in that order, with the existing IDs, 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 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 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. base paths. This browser source export expects a Vite-compatible asset build.
@@ -21,7 +21,13 @@ const layers = createInfrastructureLayers({
governorRequestRender, 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 | | Function | Arguments / responsibility |
@@ -35,28 +41,39 @@ const layers = createInfrastructureLayers({
| `removeEntityContextsForLayer` | `(layerId)`; remove records owned by the layer on destruction or failed setup | | `removeEntityContextsForLayer` | `(layerId)`; remove records owned by the layer on destruction or failed setup |
| `governorRequestRender` | `(reason)`; request a frame through the existing render scheduler | | `governorRequestRender` | `(reason)`; request a frame through the existing render scheduler |
Each returned layer supports `init(viewer)`, `enable(viewer)`, `disable(viewer)`, Each returned session layer supports `init(null, { engine })`,
`update(viewer)`, `destroy(viewer)`, `getStats()`, `getLodDiagnostics()` and `attachRenderer(null, { engine, signal })`, `detachRenderer()`, `enable()`,
`getAnalystRecords()`. `disable()`, `update()`, `destroy()`, `getStats()`, `getLodDiagnostics()` and
Use the same viewer for the instance's entire lifetime. Each viewer/context/overlay `getAnalystRecords(limit)`. The first argument is retained for lifecycle
host permits one live instance per layer ID; destroy an old instance before compatibility; drawing uses the engine passed in the options. Attach once per
replacing it. Separate applications may use identical IDs with separate hosts. scene, detach before attaching another engine, and destroy when the session ends.
Callbacks must be ready before enable and remain usable through destruction. 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. `src/layers/infrastructure/index.js` owns the parsed dataset, shared in-flight
Concurrent enables share one load. Destroy is permanent, aborts pending fetches, load, enabled intention and selection across scene switches. Enable loads on
discards late results, and clears the owned data source, context records, overlays, first use. Disable hides the drawing and clears selection but retains parsed
input handler and listeners. The layer never creates an application context store, data. Detach destroys scene resources and retains the dataset and selected ID;
overlay host, viewer, or render scheduler. The consumer must resolve one compatible reattach redraws enabled data and restores selection when no other selection
Cesium installation shared with its viewer; it must not bundle separate copies. 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 `src/layers/infrastructure/render/display.js` draws the same records on every
`createLocalGeoJsonLayer(options, services)` and existing infrastructure overlay engine: point movers for tips and pins, lines for stems, ground polygons for
helpers. `gods-eye-view/infrastructure/lod` exports the existing LOD policy helpers. footprints and ground corridors for paths. It owns the LOD walk, surface-height
Consumers use the package exports, which do not import standalone application refresh, picks, context handles and overlay publications. Destruction releases
globals. 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/`. 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 Their input counts are 4,352 datacenter features and 704 dam features. The session
expand multipart geometries into multiple entities; entity and stem counts are splits multipart geometries using Cesium's stable repeated-id suffixes on every
different measurements. This change does not refresh or relicense the datasets. engine. Feature, mover and stem counts are different measurements. These files
and licenses are independent of the renderer implementation.
+63 -1
View File
@@ -1,6 +1,6 @@
# KNOWN ISSUES # KNOWN ISSUES
Updated: November 5, 2026 Updated: November 10, 2026
This file tracks active runtime issues only. This file tracks active runtime issues only.
@@ -10,6 +10,68 @@ For the roadmap and open backlog, see the repository issue tracker.
## Open ## Open
### Supplied decoded weather tiles require Cesium on globe surfaces
Status: Open (Cesium only)
Custom sources supplying individual decoded radar, regional infrared or
lightning tiles use Cesium's imagery provider scheduler
(`src/globe/cesium/weatherFrames.js`). Navara hides these controls on globe
surfaces and requests no supplied tiles there. Full-extent cloud images and
raised weather shells remain available on both engines. A full port needs
decoded tile callbacks in the imagery contract and the Navara adapter.
### Street-vehicle label depth occlusion requires Cesium
Status: Open (Cesium only)
GPU depth occlusion for street-vehicle brackets and callouts uses Cesium's
depth probe (`src/data/detection.js`). Navara retains the existing geometric
visibility checks, without the GPU depth result. The contract needs an
asynchronous depth-probe operation before this can run on both engines.
### Large raised KML polygons omit their fill
Status: Open (bounded triangulation)
On both engines, raised polygons with more than 32,768 vertices draw only
their outlines. Those with more than 128 rings draw neither fill nor
outlines. These limits bound main-thread triangulation and outline work;
the omitted geometry is counted as undrawn in the import result.
### Navara terrain tokens are not refreshed while the page stays open
Status: Open (reload required)
An expired terrain token is not refreshed during an open Navara session.
Reload the page to request a new terrain token.
### Navara does not fall back when a map source fails
Status: Open (manual map selection)
Navara does not report tile errors, so a failing map source does not trigger
fallback to another map. Select another map manually when tiles fail to load.
### Orbital imagery look-ahead is unavailable on Navara
Status: Open (Cesium only)
The orbital cockpit works on both engines. On Navara it loads imagery for
its current view but does not prefetch imagery ahead of the ground track.
At high optical zoom, imagery can arrive later than on Cesium. Navara's
package does not expose a raster prefetch operation sharing its tile cache
and request priorities.
### Ship bridge waterline uses loaded geometry on Navara
Status: Open (terrain fallback)
Navara's ship bridge samples loaded mesh geometry and detailed terrain for
the waterline. It does not request the finest mesh detail around the ship,
so the bridge height can differ while nearby tiles load. Cesium retains its
finest-detail mesh sampling.
### Wildlife grid relies on the iNaturalist UTFGrid cell schema ### Wildlife grid relies on the iNaturalist UTFGrid cell schema
Status: Open (guarded fallback) Status: Open (guarded fallback)
+1 -11
View File
@@ -68,16 +68,6 @@ cannot name still makes a place view. Opening a vessel thing view with a
world time waits for the vessel layer's snapshot at that time and selects the world time waits for the vessel layer's snapshot at that time and selects the
vessel once it has it. vessel once it has it.
## Street-level photos (`sl`)
Views built by `show_in_gods_eye_view` or `src/view/index.js` can add
`sl=<source>,<lat>,<lon>` to the long form. Once the link has restored, the
app opens the nearest photo there, or, for a source that bills every load,
arms the panel so OPEN loads it (see `docs/TOOLS.md`). The app's own links do
not carry it: the address bar's long form drops `sl` when the camera next
moves, and the short form has no field for it. A reload or the app's Copy
Link keeps the lit source, the layer and the map, but not the photo request.
## Payload ## Payload
The code is unpadded base64url (`A–Z a–z 0–9 - _`). The decoded bytes are: The code is unpadded base64url (`A–Z a–z 0–9 - _`). The decoded bytes are:
@@ -133,7 +123,7 @@ probability of about 255 in 256 (many of them already fail as base64url).
| 12 | `panelState` | LEN | none | packed panel entries, see panels | | 12 | `panelState` | LEN | none | packed panel entries, see panels |
| 13 | thing | LEN | none | sub-message, see thing views | | 13 | thing | LEN | none | sub-message, see thing views |
| 14 | `style` | enum | `normal` | normal, retro, surveillance, thermal, anime, noir, snow (retired) | | 14 | `style` | enum | `normal` | normal, retro, surveillance, thermal, anime, noir, snow (retired) |
| 15 | `mapStack` | enum | `photoreal` | photoreal, bing-aerial, bing-labels, esri-imagery, osm | | 15 | `mapStack` | enum | `photoreal` | photoreal, bing-aerial, bing-labels, esri-imagery, osm, sentinel2 |
| 16 | `hudVariant` | enum | style | tactical, operator (retired), minimal, cyber | | 16 | `hudVariant` | enum | style | tactical, operator (retired), minimal, cyber |
| 17 | `bloomIntensity` | uint | style | | | 17 | `bloomIntensity` | uint | style | |
| 18 | `bloomVersion` | uint | 2 | | | 18 | `bloomVersion` | uint | 2 | |
+5 -23
View File
@@ -102,29 +102,11 @@ action draws (pins, highlights, areas, arrows, routes and labels at a place
name or coordinates). Links carry them in the `an` parameter, and the app name or coordinates). Links carry them in the `an` parameter, and the app
draws them once the link has been restored. draws them once the link has been restored.
A view can open street-level photos too: `street_level` names a source
(`google` for Street View, `mapillary`, `kartaview`, `panoramax`, or none for
the lit one) and a point, by default the spot the camera looks at. Street View
shows only on the Google 3D map, so a `google` source sets `map` to
`photoreal` and refuses another; a view cannot both follow an entity and open
photos. Links carry it as `sl=<source>,<lat>,<lon>`: once restored, the app
runs `show_street_level`, which flies there and opens the nearest photo. A
plain link with a `google` source only arms the panel for OPEN GOOGLE STREET
VIEW; a view posted into the embedded app opens it, and so does the panel
page's own opening link, but only for a tool result issued in the last five
minutes (an older one, such as a host remounting the panel, only arms);
`?embed=1` on a published link does not. Mapillary,
KartaView and Panoramax, which do not bill per image, open at once. A link
that follows an entity ignores `sl`.
Tools take a view as `VIEW_ARGUMENTS`: an `area` to frame from above, or a Tools take a view as `VIEW_ARGUMENTS`: an `area` to frame from above, or a
`camera`, plus `layers`, `style`, `map`, `follow`, `annotations` and `camera`, plus `layers`, `style`, `map`, `follow` and `annotations`. A
`street_level`. A tilted camera over an area looks at its center from behind; tilted camera over an area looks at its center from behind; camera fields
camera fields given with an area override its framing. A passed-through given with an area override its framing. `resolveViewArguments` turns them
view's `street_level` point moves with a new `area` or camera position (a into a view.
turned camera keeps it). `resolveViewArguments` turns them into a view. The
app's own links (the address bar, Copy Link, `/share/<code>`) do not carry
`sl`; see `docs/SHARE-LINK-FORMAT.md`.
### The God's Eye View panel ### The God's Eye View panel
@@ -317,5 +299,5 @@ and the development server's internal routes. See SECURITY.md.
| `situation_brief` | `weather` | Weather, earthquakes, fires, aircraft, ships and cyclones for an area, by section | | `situation_brief` | `weather` | Weather, earthquakes, fires, aircraft, ships and cyclones for an area, by section |
| `military_awareness` | `military` | Military and other aircraft, ships and military installations within 250 km of a point, by section | | `military_awareness` | `military` | Military and other aircraft, ships and military installations within 250 km of a point, by section |
| `get_hud_caption` | `weather`, `summary` | The app's heads-up display caption for an area | | `get_hud_caption` | `weather`, `summary` | The app's heads-up display caption for an area |
| `show_in_gods_eye_view` | `app` | A view in God's Eye View: the live panel in clients with MCP Apps, and a link everywhere; takes another answer's view or an area or camera, layers, style, map, marks, something to follow, or street-level photos (Street View or Mapillary) at a place | | `show_in_gods_eye_view` | `app` | A view in God's Eye View: the live panel in clients with MCP Apps, and a link everywhere; takes another answer's view or an area or camera, layers, style, map, marks and something to follow |
| `panel_request` | `app` | Panel only: loads a path from the app's server for the God's Eye View panel | | `panel_request` | `app` | Panel only: loads a path from the app's server for the God's Eye View panel |
+2 -2
View File
@@ -270,9 +270,9 @@ a feature supplying only its accent.
the tracked-entity fade is measured once the frame is in the document; the tracked-entity fade is measured once the frame is in the document;
the styles it injected now live in `src/ui/styles/annotations.css`), the the styles it injected now live in `src/ui/styles/annotations.css`), the
visible map target list (the scene chrome renders the buttons and status visible map target list (the scene chrome renders the buttons and status
the world overlay presents through `subscribeWorldOverlayActions()`, which the world overlay presents through `subscribeActions()`, which
the application shell publishes to the `gev-world-overlay-actions` store the application shell publishes to the `gev-world-overlay-actions` store
and answers with `activateWorldOverlayAction()`; the module needs no Vue and answers with `activateAction()`; the module needs no Vue
for the portable transit entry, and its card and detection canvases stay for the portable transit entry, and its card and detection canvases stay
painting surfaces it draws on, like the Cesium canvas), the Global painting surfaces it draws on, like the Cesium canvas), the Global
Context compass and contact bearings (`AwarenessDirection` teleports into Context compass and contact bearings (`AwarenessDirection` teleports into
+1113 -5
View File
File diff suppressed because it is too large. Load diff
+11 -4
View File
@@ -54,21 +54,28 @@
"qa:street-level:fixtures": "node scripts/qa-street-level.mjs --fixtures", "qa:street-level:fixtures": "node scripts/qa-street-level.mjs --fixtures",
"check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs", "check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs",
"qa:transit": "node scripts/qa-transit.mjs", "qa:transit": "node scripts/qa-transit.mjs",
"mcp": "node server/mcp/stdio.js" "mcp": "node server/mcp/stdio.js",
"check:main-chunk": "node scripts/check-main-chunk.mjs"
}, },
"dependencies": { "dependencies": {
"@jtarrio/signals": "^0.10.0", "@jtarrio/signals": "^0.10.0",
"@jtarrio/webrtlsdr": "^3.0.6", "@jtarrio/webrtlsdr": "^3.0.6",
"@mapbox/vector-tile": "^3.0.0", "@mapbox/vector-tile": "^3.0.0",
"@meri-imperiumi/eccodes-wasm": "^2.48.2", "@meri-imperiumi/eccodes-wasm": "^2.48.2",
"@navaramap/three": "0.2.1",
"@navaramap/three-default-plugin": "0.2.1",
"cesium": "^1.124.0", "cesium": "^1.124.0",
"earcut": "^3.0.2",
"egm96-universal": "^1.1.1", "egm96-universal": "^1.1.1",
"hls.js": "^1.7.3", "hls.js": "^1.7.3",
"kdbush": "4.0.2",
"mapillary-js": "^4.1.2", "mapillary-js": "^4.1.2",
"mgrs": "^2.1.0", "mgrs": "^2.1.0",
"pbf": "^5.1.2", "pbf": "^5.1.2",
"pinia": "^2.3.1", "pinia": "^2.3.1",
"postprocessing": "6.39.4",
"satellite.js": "^6.0.2", "satellite.js": "^6.0.2",
"three": "0.185.1",
"vue": "^3.5.44", "vue": "^3.5.44",
"vue-i18n": "^11.4.15" "vue-i18n": "^11.4.15"
}, },
@@ -90,7 +97,7 @@
"./infrastructure/geojson": "./src/data/localGeojsonCore.js", "./infrastructure/geojson": "./src/data/localGeojsonCore.js",
"./infrastructure/lod": "./src/data/localGeojsonLod.js", "./infrastructure/lod": "./src/data/localGeojsonLod.js",
"./application": "./src/app/application.js", "./application": "./src/app/application.js",
"./application/viewer": "./src/app/viewer.js", "./application/viewer": "./src/globe/cesium/viewer.js",
"./time": "./src/time/worldClock.js", "./time": "./src/time/worldClock.js",
"./build/vite": { "./build/vite": {
"node": "./build/vite.js" "node": "./build/vite.js"
@@ -204,8 +211,8 @@
"./geography/neighborhoods": "./src/data/neighborhoodPolygons.js", "./geography/neighborhoods": "./src/data/neighborhoodPolygons.js",
"./maps/controller": "./src/maps/controller.js", "./maps/controller": "./src/maps/controller.js",
"./maps/catalog": "./src/maps/catalog.js", "./maps/catalog": "./src/maps/catalog.js",
"./maps/imagery": "./src/maps/imagery.js", "./maps/imagery": "./src/globe/cesium/mapImagery.js",
"./maps/terrain": "./src/maps/terrain.js", "./maps/terrain": "./src/globe/cesium/terrain.js",
"./maps/3d": "./src/maps/google3d.js", "./maps/3d": "./src/maps/google3d.js",
"./maps/defaults": "./src/maps/defaultSources.js", "./maps/defaults": "./src/maps/defaultSources.js",
"./voice/controller": "./src/voice/realtimeController.js", "./voice/controller": "./src/voice/realtimeController.js",
+257
View File
@@ -0,0 +1,257 @@
import assert from 'node:assert/strict';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
import * as geo from '../src/geo/index.js';
const directory = new URL('./fixtures/tiles3d/', import.meta.url);
const origin = { lon: -97.7431, lat: 30.2672 };
const transform = geo.transforms.eastNorthUpToFixedFrame(
geo.cartesian3.fromDegrees(origin.lon, origin.lat),
);
const box = (west, south, east, north, bottom, top) => [
[
[west, south, top],
[east, south, top],
[east, north, top],
[west, north, top],
],
[
[west, north, bottom],
[east, north, bottom],
[east, south, bottom],
[west, south, bottom],
],
[
[west, south, bottom],
[east, south, bottom],
[east, south, top],
[west, south, top],
],
[
[east, south, bottom],
[east, north, bottom],
[east, north, top],
[east, south, top],
],
[
[east, north, bottom],
[west, north, bottom],
[west, north, top],
[east, north, top],
],
[
[west, north, bottom],
[west, south, bottom],
[west, south, top],
[west, north, top],
],
];
// glTF is Y-up; the tileset transform is east/north/up after the 3D Tiles
// loader's Y-to-Z rotation. Untextured flat faces need no external assets.
function glb(faces) {
const positions = [],
normals = [];
for (const face of faces) {
const vertices = face.map(([x, y, z]) => [x, z, -y]);
const [a, b, c] = vertices;
const u = b.map((v, i) => v - a[i]);
const v = c.map((v, i) => v - a[i]);
const normal = [
u[1] * v[2] - u[2] * v[1],
u[2] * v[0] - u[0] * v[2],
u[0] * v[1] - u[1] * v[0],
];
const length = Math.hypot(...normal);
for (let i = 1; i < vertices.length - 1; i++)
for (const vertex of [a, vertices[i], vertices[i + 1]]) {
positions.push(...vertex);
normals.push(...normal.map((n) => n / length));
}
}
const bytes = Buffer.from(
new Float32Array([...positions, ...normals]).buffer,
);
const bounds = (fn) =>
[0, 1, 2].map((axis) => fn(...positions.filter((_, i) => i % 3 === axis)));
const json = Buffer.from(
JSON.stringify({
asset: { version: '2.0' },
scene: 0,
scenes: [{ nodes: [0] }],
nodes: [{ mesh: 0 }],
meshes: [
{
primitives: [{ attributes: { POSITION: 0, NORMAL: 1 }, material: 0 }],
},
],
materials: [
{
pbrMetallicRoughness: {
baseColorFactor: [0.5, 0.5, 0.5, 1],
metallicFactor: 0,
roughnessFactor: 1,
},
},
],
buffers: [{ byteLength: bytes.length }],
bufferViews: [0, 1].map((i) => ({
buffer: 0,
byteOffset: i * positions.length * 4,
byteLength: positions.length * 4,
target: 34962,
})),
accessors: [
{
bufferView: 0,
componentType: 5126,
count: positions.length / 3,
type: 'VEC3',
min: bounds(Math.min),
max: bounds(Math.max),
},
{
bufferView: 1,
componentType: 5126,
count: positions.length / 3,
type: 'VEC3',
},
],
}),
);
const padded = Buffer.alloc(Math.ceil(json.length / 4) * 4, 0x20);
json.copy(padded);
const result = Buffer.alloc(12 + 8 + padded.length + 8 + bytes.length);
result.writeUInt32LE(0x46546c67, 0);
result.writeUInt32LE(2, 4);
result.writeUInt32LE(result.length, 8);
result.writeUInt32LE(padded.length, 12);
result.writeUInt32LE(0x4e4f534a, 16);
padded.copy(result, 20);
result.writeUInt32LE(bytes.length, 20 + padded.length);
result.writeUInt32LE(0x004e4942, 24 + padded.length);
bytes.copy(result, 28 + padded.length);
return result;
}
function probe(x, y, z) {
const position = geo.matrix4.multiplyByPoint(transform, { x, y, z }, {});
const cartographic = geo.cartographic.fromCartesian(position);
return {
position,
cartographic,
lon: geo.math.toDegrees(cartographic.longitude),
lat: geo.math.toDegrees(cartographic.latitude),
height: cartographic.height,
};
}
export function buildTilesFixture() {
const roof = box(110, -60, 210, 60, 0, 60);
roof.shift();
roof.push(
[
[110, -60, 60],
[160, -60, 100],
[160, 60, 100],
[110, 60, 60],
],
[
[160, -60, 100],
[210, -60, 60],
[210, 60, 60],
[160, 60, 100],
],
[
[110, -60, 60],
[210, -60, 60],
[160, -60, 100],
],
[
[210, 60, 60],
[110, 60, 60],
[160, 60, 100],
],
);
const tiles = [
[
'plate',
box(-300, -300, 300, 300, -2, 0),
[0, 0, -1, 300, 0, 0, 0, 300, 0, 0, 0, 1],
],
[
'block50',
box(-210, -60, -110, 60, 0, 50),
[-160, 0, 25, 50, 0, 0, 0, 60, 0, 0, 0, 25],
],
[
'block200',
box(-50, 120, 50, 220, 0, 200),
[0, 170, 100, 50, 0, 0, 0, 50, 0, 0, 0, 100],
],
['roof', roof, [160, 0, 50, 50, 0, 0, 0, 60, 0, 0, 0, 50]],
];
const tileset = {
asset: { version: '1.1' },
geometricError: 1000,
root: {
transform: Array.from(transform),
boundingVolume: { box: [0, 0, 99, 300, 0, 0, 0, 300, 0, 0, 0, 101] },
geometricError: 1000,
refine: 'ADD',
children: tiles.map(([name, , box]) => ({
boundingVolume: { box },
geometricError: 0,
content: { uri: `${name}.glb` },
})),
},
extras: {
origin,
datum: 'WGS84 ellipsoid; local ENU heights in metres',
probes: {
plate: probe(0, -170, 0),
block50: probe(-160, 0, 50),
block200: probe(0, 170, 200),
roof: probe(180, 0, 84),
},
// More than one pass of surfacePoints, all away from the roof edges.
points: Array.from({ length: 40 }, (_, i) => {
const x = 140 + (i % 8) * 5;
return probe(
x,
-40 + Math.floor(i / 8) * 20,
100 - 0.8 * Math.abs(x - 160),
);
}),
},
};
const files = new Map(
tiles.map(([name, faces]) => [`${name}.glb`, glb(faces)]),
);
files.set(
'tileset.json',
Buffer.from(JSON.stringify(tileset, null, 2) + '\n'),
);
assert.ok(
[...files.values()].reduce((sum, b) => sum + b.length, 0) < 200_000,
);
return files;
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
const check = process.argv.includes('--check');
const files = buildTilesFixture();
if (!check) mkdirSync(directory, { recursive: true });
for (const [name, bytes] of files) {
const file = new URL(name, directory);
if (check)
assert.deepEqual(readFileSync(file), bytes, `${name} is reproducible`);
else writeFileSync(file, bytes);
}
console.log(
`${check ? 'Checked' : 'Generated'} ${files.size} fixture files (${[...files.values()].reduce((sum, b) => sum + b.length, 0)} bytes)`,
);
}
+185
View File
@@ -0,0 +1,185 @@
// The production build replaces every `cesium` import with the global that
// /cesium/Cesium.js defines, and that script loads only when a Cesium globe
// first starts (src/app/loadCesium.js). A module that reads a Cesium binding
// while it loads would therefore throw on a page that starts on Navara. This
// check lists such reads: a reference to a binding imported from `cesium`, or
// to the `Cesium` global, that runs when its module is evaluated (top level,
// class heritage, static members, immediately invoked functions) rather than
// inside a function that runs later.
//
// node scripts/check-cesium-load-time.mjs [--json]
import { existsSync, readFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { parse } from '@babel/parser';
import { vueScripts } from './vue-scripts.mjs';
const root = new URL('..', import.meta.url);
/** Files under src/ that the app ships (tests and test support excluded). */
export function sourceFiles() {
return execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', 'src'], {
cwd: root,
encoding: 'utf8',
})
.split('\n')
.filter(
(path) =>
/\.(?:js|mjs|vue)$/.test(path) &&
!/\.test\.m?js$/.test(path) &&
!path.startsWith('src/testSupport/') &&
!path.includes('/testing/') &&
existsSync(new URL(path, root)),
);
}
const FUNCTION_TYPES = new Set([
'FunctionDeclaration',
'FunctionExpression',
'ArrowFunctionExpression',
'ObjectMethod',
'ClassMethod',
'ClassPrivateMethod',
]);
/** Names a module binds to `cesium` imports. */
function cesiumBindings(program) {
const names = new Set();
for (const node of program.body) {
if (node.type !== 'ImportDeclaration' || node.source.value !== 'cesium')
continue;
for (const specifier of node.specifiers) names.add(specifier.local.name);
}
return names;
}
function isReference(node, parent, key) {
if (node.type !== 'Identifier') return false;
if (!parent) return true;
if (
(parent.type === 'MemberExpression' ||
parent.type === 'OptionalMemberExpression') &&
key === 'property' &&
!parent.computed
)
return false;
if (
(parent.type === 'ObjectProperty' ||
parent.type === 'ClassProperty' ||
parent.type === 'ObjectMethod' ||
parent.type === 'ClassMethod') &&
key === 'key' &&
!parent.computed
)
return false;
if (
parent.type === 'ImportSpecifier' ||
parent.type === 'ImportNamespaceSpecifier' ||
parent.type === 'ImportDefaultSpecifier' ||
(parent.type === 'ExportSpecifier' && key !== 'local')
)
return false;
if (parent.type === 'LabeledStatement' || parent.type === 'BreakStatement')
return false;
return true;
}
/**
* Load-time references in one module. `deferred` is true below a function
* body that runs later; an immediately invoked function and a class's static
* members and heritage run at load and stay not deferred.
*/
export function loadTimeReferences(code, path = '<module>') {
if (path.endsWith('.vue'))
return vueScripts(code, path).flatMap((script) =>
loadTimeReferences(script).map((reference) => ({ ...reference, path })),
);
const program = parse(code, {
sourceType: 'module',
plugins: ['classProperties', 'classStaticBlock', 'importAttributes'],
}).program;
const names = cesiumBindings(program);
const found = [];
// A re-export from `cesium` becomes a read of the global's property when
// the module loads.
for (const node of program.body)
if (
(node.type === 'ExportNamedDeclaration' ||
node.type === 'ExportAllDeclaration') &&
node.source?.value === 'cesium'
)
found.push({
path,
line: node.loc.start.line,
name: 'export from cesium',
});
const hasGlobal = /\bCesium\b/.test(code);
if (!names.size && !hasGlobal) return found;
// A `Cesium` global reference counts when the module does not bind it.
const watched = (name) =>
names.has(name) || (name === 'Cesium' && !names.has('Cesium'));
const visit = (node, parent, key, deferred) => {
if (!node || typeof node.type !== 'string') return;
if (node.type === 'ImportDeclaration') return;
if (!deferred && isReference(node, parent, key) && watched(node.name))
found.push({ path, line: node.loc.start.line, name: node.name });
let childDeferred = deferred;
if (FUNCTION_TYPES.has(node.type)) {
const invoked =
parent &&
(parent.type === 'CallExpression' || parent.type === 'NewExpression') &&
key === 'callee';
childDeferred = deferred || !invoked;
}
if (
(node.type === 'ClassProperty' || node.type === 'ClassPrivateProperty') &&
!node.static
)
childDeferred = true;
for (const [childKey, value] of Object.entries(node)) {
if (
childKey === 'loc' ||
childKey === 'leadingComments' ||
childKey === 'trailingComments' ||
childKey === 'innerComments'
)
continue;
// Computed keys and parameter defaults of a deferred function: its key
// is evaluated with the enclosing scope, its body later.
const keyDeferred =
FUNCTION_TYPES.has(node.type) && childKey === 'key'
? deferred
: childDeferred;
if (Array.isArray(value))
for (const child of value) visit(child, node, childKey, keyDeferred);
else if (value && typeof value === 'object')
visit(value, node, childKey, keyDeferred);
}
};
visit(program, null, null, false);
return found;
}
if (import.meta.url === `file://${process.argv[1]}`) {
const found = [];
for (const path of sourceFiles()) {
const code = readFileSync(new URL(path, root), 'utf8');
try {
found.push(...loadTimeReferences(code, path));
} catch (error) {
console.error(`${path}: ${error.message}`);
process.exitCode = 1;
}
}
if (process.argv.includes('--json'))
console.log(JSON.stringify(found, null, 2));
else
for (const { path, line, name } of found)
console.log(`${path}:${line} reads ${name} while the module loads`);
if (found.length) {
console.error(
`${found.length} load-time Cesium read(s); a page that starts on Navara has no Cesium global yet.`,
);
process.exitCode = 1;
} else console.log('No module reads Cesium while it loads.');
}
+66
View File
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { loadTimeReferences, sourceFiles } from './check-cesium-load-time.mjs';
const reads = (code) =>
loadTimeReferences(code).map(({ line, name }) => `${line}:${name}`);
test('reads that run while a module loads are found; reads inside later code are not', () => {
assert.deepEqual(
reads(`import * as Cesium from 'cesium';
const color = Cesium.Color.RED;
export function later() { return Cesium.Color.BLUE; }
const lazy = () => Cesium.Math.PI;
class Shape { static axis = Cesium.Axis.Y; field = Cesium.Axis.Z; draw() { return Cesium; } }
(() => Cesium.VERSION)();
export const fallback = (sdk = Cesium) => sdk;`),
['2:Cesium', '5:Cesium', '6:Cesium'],
);
assert.deepEqual(
reads(`import { Cartesian3 as C3, Color } from 'cesium';
export const ORIGIN = C3.ZERO;
export function white() { return Color.WHITE; }`),
['2:C3'],
);
assert.deepEqual(reads(`export { Axis } from 'cesium';`), [
'1:export from cesium',
]);
assert.deepEqual(
reads(`window.__CESIUM__ = Cesium;`),
['1:Cesium'],
'the global counts where the module does not import it',
);
assert.deepEqual(reads(`const geo = { Cesium: 1 }; geo.Cesium;`), []);
});
test('no shipped module reads Cesium while it loads', () => {
const found = sourceFiles().flatMap((path) =>
loadTimeReferences(readFileSync(path, 'utf8'), path),
);
assert.deepEqual(found, []);
});
test('re-exporting imported Cesium bindings reads them while loading', () => {
assert.deepEqual(
reads("import { Color } from 'cesium'; export { Color as Tint };"),
['1:Color'],
);
assert.deepEqual(reads("import * as C from 'cesium'; export { C };"), [
'1:C',
]);
});
test('Vue scripts participate in the load-time check with original line numbers', () => {
assert.deepEqual(
loadTimeReferences(
`<template><span>Cesium</span></template>
<script>
import { Color } from 'cesium';
export const tint = Color.RED;
</script>`,
'src/View.vue',
).map(({ line, name }) => `${line}:${name}`),
['4:Color'],
);
});
+123 -4
View File
@@ -8,7 +8,7 @@ import { analyzeModule } from './module-analysis.mjs';
const builtins = new Set( const builtins = new Set(
builtinModules.flatMap((name) => [name, `node:${name}`]), builtinModules.flatMap((name) => [name, `node:${name}`]),
); );
const code = /\.[mc]?js$/; const code = /\.(?:[mc]?js|vue)$/;
const entry = (file) => const entry = (file) =>
file === 'src/main.js' || file.startsWith('src/standalone/'); file === 'src/main.js' || file.startsWith('src/standalone/');
const tests = (file) => const tests = (file) =>
@@ -19,6 +19,17 @@ const tests = (file) =>
const source = (file) => const source = (file) =>
file.startsWith('src/sources/') || file.startsWith('src/sources/') ||
/^src\/layers\/[^/]+\/(?:source|bundledSource|flowSource)\.js$/.test(file); /^src\/layers\/[^/]+\/(?:source|bundledSource|flowSource)\.js$/.test(file);
// Engine-neutral geometry ported from Cesium; every runtime file is a portable root.
const geo = (file) => file.startsWith('src/geo/');
// The app clock (src/time): plain numbers, no engine, no DOM.
const time = (file) => file.startsWith('src/time/');
// The engine-neutral globe contract and its test support; engines live below it.
const globeContract = (file) =>
/^src\/globe\/[^/]+$/.test(file) || file.startsWith('src/globe/testing/');
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\/)/;
const renderer = (file) => const renderer = (file) =>
/^src\/(?:ui|app|standalone|overlays)\//.test(file) || /^src\/(?:ui|app|standalone|overlays)\//.test(file) ||
/^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test( /^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test(
@@ -70,6 +81,25 @@ export function checkImportDirections(root) {
(file) => (file) =>
code.test(file) && !tests(file) && existsSync(path.join(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 errors = new Set();
const modules = new Map(); const modules = new Map();
const relative = (file) => const relative = (file) =>
@@ -87,7 +117,10 @@ export function checkImportDirections(root) {
} }
let analysis; let analysis;
try { try {
analysis = analyzeModule(readFileSync(resolved, 'utf8')); analysis = analyzeModule(readFileSync(resolved, 'utf8'), {
dynamic: true,
filename: file,
});
} catch (error) { } catch (error) {
report(file, error.message); report(file, error.message);
return null; return null;
@@ -109,6 +142,19 @@ export function checkImportDirections(root) {
target = path.resolve(root, exported); target = path.resolve(root, exported);
} else { } else {
record.external.push(specifier); record.external.push(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 ( if (
file.startsWith('src/') && file.startsWith('src/') &&
(builtins.has(specifier) || specifier.startsWith('node:')) (builtins.has(specifier) || specifier.startsWith('node:'))
@@ -131,6 +177,18 @@ export function checkImportDirections(root) {
continue; continue;
} }
record.edges.push(to); record.edges.push(to);
// Navara stays out of the Cesium-mode bundle: outside its own directory
// it is reached only through import() (src/app/scene.js), which the
// bundler splits into a chunk loaded on ?engine=navara.
if (
to.startsWith('src/globe/navara/') &&
!file.startsWith('src/globe/navara/') &&
analysis.staticImports.includes(specifier)
)
report(
file,
`The Navara engine loads only through a dynamic import(): ${to}`,
);
if (file.startsWith('src/') && to.startsWith('server/')) if (file.startsWith('src/') && to.startsWith('server/'))
report(file, `Browser imports server: ${to}`); report(file, `Browser imports server: ${to}`);
if ( if (
@@ -177,6 +235,8 @@ export function checkImportDirections(root) {
for (const file of files.filter((file) => file.startsWith('src/'))) for (const file of files.filter((file) => file.startsWith('src/')))
browser(file); browser(file);
const roots = new Map(files.filter(source).map((file) => [file, 'source'])); const roots = new Map(files.filter(source).map((file) => [file, 'source']));
for (const file of files.filter(geo)) roots.set(file, 'geo');
for (const file of files.filter(time)) roots.set(file, 'time');
for (const [key, value] of Object.entries(pkg.exports || {})) { for (const [key, value] of Object.entries(pkg.exports || {})) {
if (portableExport(key) && typeof value === 'string') if (portableExport(key) && typeof value === 'string')
roots.set(value.replace(/^\.\//, ''), key); roots.set(value.replace(/^\.\//, ''), key);
@@ -202,7 +262,12 @@ export function checkImportDirections(root) {
if ( if (
builtins.has(specifier) || builtins.has(specifier) ||
specifier.startsWith('node:') || specifier.startsWith('node:') ||
/^(?:cesium|vite)(?:\/|$)/.test(specifier) /^(?:cesium|vite)(?:\/|$)/.test(specifier) ||
// Geo must not reach Cesium through its workspace packages either.
(label === 'geo' && /^@cesium\//.test(specifier)) ||
// Neither engine reaches the app clock.
(label === 'time' &&
(cesiumPackage.test(specifier) || navaraPackage.test(specifier)))
) )
report( report(
file, file,
@@ -217,6 +282,51 @@ export function checkImportDirections(root) {
} }
visit(start); visit(start);
} }
// The globe contract reaches no engine, directly or through helpers; each
// engine implementation imports only its own engine's packages.
const globeSeen = new Set();
function globe(file) {
if (globeSeen.has(file) || !code.test(file)) return;
globeSeen.add(file);
const info = load(file);
if (!info) return;
for (const specifier of info.external) {
if (cesiumPackage.test(specifier) || navaraPackage.test(specifier))
report(file, `globe contract reaches engine dependency: ${specifier}`);
}
for (const to of info.edges) {
if (globeEngine(to))
report(file, `globe contract imports engine implementation: ${to}`);
else globe(to);
}
}
for (const file of files.filter(globeContract)) globe(file);
// Each engine graph stays on its own engine, through helpers too.
for (const owner of ['cesium', 'navara']) {
const navara = owner === 'navara';
const label = navara ? 'Navara globe engine' : 'Cesium globe engine';
const foreignPackage = navara ? cesiumPackage : navaraPackage;
const foreignEngine = navara ? 'src/globe/cesium/' : 'src/globe/navara/';
const seen = new Set();
const visit = (file) => {
if (seen.has(file) || !code.test(file)) return;
seen.add(file);
const info = load(file);
if (!info) return;
for (const specifier of info.external)
if (foreignPackage.test(specifier))
report(file, `${label} reaches ${specifier}`);
for (const to of info.edges) {
if (to.startsWith(foreignEngine))
report(file, `${label} reaches ${to}`);
else visit(to);
}
};
for (const start of files.filter((file) =>
file.startsWith(`src/globe/${owner}/`),
))
visit(start);
}
// Common voice controls may use their DOM view, but never a protocol implementation. // Common voice controls may use their DOM view, but never a protocol implementation.
const voiceSeen = new Set(); const voiceSeen = new Set();
function voice(file) { function voice(file) {
@@ -231,8 +341,17 @@ export function checkImportDirections(root) {
} }
if (existsSync(path.join(root, 'src/voice/sessionCommands.js'))) if (existsSync(path.join(root, 'src/voice/sessionCommands.js')))
voice('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')); 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 ( if (
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env node
/**
* The size budget of the Cesium-mode main chunk (run after `npm run build`):
* gzip-9 of the entry chunk the built page loads (from the Vite manifest, or
* the module script dist/index.html loads) may exceed the baseline in
* scripts/main-chunk-baseline.json by at most its allowance. What the initial
* chunks may hold is the build's own check (build/initialChunkGuard.js).
*
* Prints the size. `--json` prints the whole result; `--dist=DIR` reads
* another build.
*
* npm run build && npm run check:main-chunk
*/
import fs from 'node:fs';
import path from 'node:path';
import { gzipSync } from 'node:zlib';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { entryFromHtml, entryFromManifest } from '../src/tooling/mainChunk.js';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
/** The verdict for a main-chunk size against the baseline. */
export function checkMainChunk(
gzipBytes,
{ gzipBytes: baseline, allowanceBytes },
) {
const limit = baseline + allowanceBytes;
const growth = gzipBytes - baseline;
return {
ok: gzipBytes <= limit,
message: `main chunk ${gzipBytes} bytes gzip-9 (${growth >= 0 ? '+' : ''}${growth} over the ${baseline} baseline; limit ${limit})`,
};
}
/**
* gzip-9 size of the main chunk, with its name: the entry the built page
* loads (`entry`, else found from the Vite manifest or dist/index.html).
*/
export function measureMainChunk(distDir, { entry = null } = {}) {
const file = entry ?? findEntry(distDir).entry;
const bytes = fs.readFileSync(path.join(distDir, file));
return {
name: path.basename(file),
gzipBytes: gzipSync(bytes, { level: 9 }).length,
};
}
/** The entry chunk (relative to dist) and how it was found. */
function findEntry(dist) {
const manifestPath = path.join(dist, '.vite', 'manifest.json');
if (fs.existsSync(manifestPath)) {
const entry = entryFromManifest(
JSON.parse(fs.readFileSync(manifestPath, 'utf8')),
);
if (entry) return { entry, via: 'manifest' };
}
const htmlPath = path.join(dist, 'index.html');
if (!fs.existsSync(htmlPath))
throw new Error(`No build at ${dist}: run npm run build first`);
const entry = entryFromHtml(fs.readFileSync(htmlPath, 'utf8'));
if (!entry) throw new Error(`${htmlPath} loads no module script`);
return { entry, via: 'index.html' };
}
function main() {
const option = (name) =>
process.argv.find((arg) => arg.startsWith(`--${name}=`))?.split('=')[1];
const dist = path.resolve(ROOT, option('dist') ?? 'dist');
const { entry, via } = findEntry(dist);
const baselinePath = path.join(ROOT, 'scripts/main-chunk-baseline.json');
let budget = null;
if (fs.existsSync(baselinePath)) {
const measured = measureMainChunk(dist, { entry });
budget = {
...measured,
...checkMainChunk(
measured.gzipBytes,
JSON.parse(fs.readFileSync(baselinePath, 'utf8')),
),
};
}
const ok = budget?.ok ?? true;
if (process.argv.includes('--json')) {
console.log(JSON.stringify({ entry, via, budget, ok }, null, 2));
} else {
console.log(`[check:main-chunk] entry ${entry} (via ${via})`);
console.log(
budget
? `[check:main-chunk] ${budget.name}: ${budget.message}`
: '[check:main-chunk] no scripts/main-chunk-baseline.json: size budget not checked',
);
}
if (!ok) process.exitCode = 1;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
main();
+111 -25
View File
@@ -1,17 +1,28 @@
import { readFile, realpath } from 'node:fs/promises'; import { readFile, realpath, writeFile } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url'; import { fileURLToPath, pathToFileURL } from 'node:url';
import vue from '@vitejs/plugin-vue'; import vue from '@vitejs/plugin-vue';
import { VUE_PLUGIN_OPTIONS } from '../build/vueOptions.js'; import { VUE_PLUGIN_OPTIONS } from '../build/vueOptions.js';
import { build, normalizePath } from 'vite'; import { build, normalizePath } from 'vite';
import * as prettier from 'prettier';
/** Build each browser or Node export group and reject imports outside its declared ownership. */ /** Build each browser or Node export group and check its module snapshot. */
export async function checkPackageBoundaries(root) { export async function checkPackageBoundaries(root) {
return buildPackageBoundaries(root, false);
}
/** Regenerate module ownership from each export group's complete build graph. */
export async function writePackageBoundaries(root) {
return buildPackageBoundaries(root, true);
}
async function buildPackageBoundaries(root, write) {
root = await realpath(root); root = await realpath(root);
const readJson = async (name) => const readJson = async (name) =>
JSON.parse(await readFile(path.join(root, name), 'utf8')); JSON.parse(await readFile(path.join(root, name), 'utf8'));
const pkg = await readJson('package.json'); const pkg = await readJson('package.json');
const groups = await readJson('scripts/package-boundaries.json'); const manifest = await readJson('scripts/package-boundaries.json');
const { engineImportExceptions, ...groups } = manifest;
const declaredExports = Object.keys(pkg.exports || {}).sort(); const declaredExports = Object.keys(pkg.exports || {}).sort();
const classifiedExports = Object.values(groups) const classifiedExports = Object.values(groups)
.flatMap((group) => group.exports) .flatMap((group) => group.exports)
@@ -25,6 +36,7 @@ export async function checkPackageBoundaries(root) {
); );
} }
const reports = []; const reports = [];
const differences = [];
for (const [name, group] of Object.entries(groups)) { for (const [name, group] of Object.entries(groups)) {
const node = group.runtime === 'node'; const node = group.runtime === 'node';
if (group.runtime && !['browser', 'node'].includes(group.runtime)) { if (group.runtime && !['browser', 'node'].includes(group.runtime)) {
@@ -32,12 +44,23 @@ export async function checkPackageBoundaries(root) {
} }
if ( if (
!Array.isArray(group.modules) || !Array.isArray(group.modules) ||
!group.modules.length || (!write && !group.modules.length) ||
!Array.isArray(group.external) !Array.isArray(group.external)
) { ) {
throw new Error(`Invalid package boundary: ${name}`); throw new Error(`Invalid package boundary: ${name}`);
} }
// The shared globe directory excludes engines and their test support.
const allowed = new Set(); const allowed = new Set();
const allowedDirectories = [];
const modulePaths = new Map();
const globeDirectory = `${normalizePath(path.join(root, 'src/globe'))}/`;
const ownsDirectory = (id, directory) =>
id.startsWith(directory) &&
(directory !== globeDirectory ||
!/^(?:cesium|navara|testing)\//.test(id.slice(directory.length)));
const owns = (id) =>
allowed.has(id) ||
allowedDirectories.some((directory) => ownsDirectory(id, directory));
for (const module of group.modules) { for (const module of group.modules) {
if ( if (
typeof module !== 'string' || typeof module !== 'string' ||
@@ -47,7 +70,13 @@ export async function checkPackageBoundaries(root) {
) { ) {
throw new Error(`Boundary modules must be repository paths: ${name}`); throw new Error(`Boundary modules must be repository paths: ${name}`);
} }
const resolved = await realpath(path.join(root, module)); let resolved;
try {
resolved = await realpath(path.join(root, module));
} catch (error) {
if (error.code === 'ENOENT') continue;
throw error;
}
const relative = path.relative(root, resolved); const relative = path.relative(root, resolved);
if ( if (
relative === '..' || relative === '..' ||
@@ -56,7 +85,12 @@ export async function checkPackageBoundaries(root) {
) { ) {
throw new Error(`Boundary module escapes repository: ${name}`); throw new Error(`Boundary module escapes repository: ${name}`);
} }
allowed.add(normalizePath(resolved)); if (module.endsWith('/')) {
allowedDirectories.push(`${normalizePath(resolved)}/`);
} else {
allowed.add(normalizePath(resolved));
}
modulePaths.set(module, normalizePath(resolved));
} }
for (const external of group.external) { for (const external of group.external) {
if ( if (
@@ -79,11 +113,7 @@ export async function checkPackageBoundaries(root) {
) { ) {
throw new Error(`Node export must have only a node condition: ${key}`); throw new Error(`Node export must have only a node condition: ${key}`);
} }
if ( if (typeof target !== 'string' || !target.startsWith('./')) {
typeof target !== 'string' ||
!target.startsWith('./') ||
!allowed.has(normalizePath(path.resolve(root, target)))
) {
throw new Error(`Export must point to an owned module: ${key}`); throw new Error(`Export must point to an owned module: ${key}`);
} }
return path.resolve(root, target); return path.resolve(root, target);
@@ -109,11 +139,6 @@ export async function checkPackageBoundaries(root) {
// template blocks belong to the component file itself. // template blocks belong to the component file itself.
if (info.id.startsWith('\0')) return; if (info.id.startsWith('\0')) return;
const id = info.id.split('?')[0]; const id = info.id.split('?')[0];
if (!allowed.has(id)) {
throw new Error(
`Package boundary ${name} imports an unowned module: ${path.relative(root, id)}`,
);
}
seen.add(id); seen.add(id);
}, },
}, },
@@ -138,9 +163,58 @@ export async function checkPackageBoundaries(root) {
}, },
}, },
}); });
reports.push({ name, exports: input.length, modules: seen.size }); const reached = [...seen];
const kept = group.modules.filter((module) => {
const resolved = modulePaths.get(module);
return module.endsWith('/')
? reached.some((id) => ownsDirectory(id, `${resolved}/`))
: seen.has(resolved);
});
const removed = group.modules.filter((module) => !kept.includes(module));
const added = reached
.filter((id) => !owns(id))
.map((id) => {
const relative = path.relative(root, id);
if (
!path.isAbsolute(id) ||
relative === '..' ||
relative.startsWith(`..${path.sep}`) ||
path.isAbsolute(relative)
) {
throw new Error(`Boundary module escapes repository: ${name}`);
}
return normalizePath(relative);
})
.sort();
if (added.length || removed.length)
differences.push({ name, added, removed });
if (write) {
group.modules = [...kept, ...added];
} else {
reports.push({ name, exports: input.length, modules: seen.size });
}
} }
return reports; if (!write && differences.length) {
throw new Error(
[
'Package boundary snapshots differ. Run node scripts/check-package-boundaries.mjs --write and review the diff.',
...differences.flatMap(({ name, added, removed }) => [
`${name}: ${added.length} added, ${removed.length} removed.`,
...added.map((module) => ` + ${module}`),
...removed.map((module) => ` - ${module}`),
]),
].join('\n'),
);
}
if (write && differences.length) {
const file = path.join(root, 'scripts/package-boundaries.json');
const formatted = await prettier.format(JSON.stringify(manifest), {
...(await prettier.resolveConfig(file)),
filepath: file,
});
await writeFile(file, formatted);
}
return write ? differences : reports;
} }
const invoked = process.argv[1] const invoked = process.argv[1]
@@ -148,13 +222,25 @@ const invoked = process.argv[1]
: ''; : '';
if (import.meta.url === invoked) { if (import.meta.url === invoked) {
try { try {
const reports = await checkPackageBoundaries( const root = fileURLToPath(new URL('../', import.meta.url));
fileURLToPath(new URL('../', import.meta.url)), if (process.argv.includes('--write')) {
); const reports = await writePackageBoundaries(root);
for (const report of reports) for (const { name, added, removed } of reports) {
console.log( console.log(
`Checked ${report.name}: ${report.exports} exports, ${report.modules} owned modules.`, `Updated ${name}: ${added.length} added, ${removed.length} removed.`,
); );
if (added.length + removed.length <= 20) {
for (const module of added) console.log(` + ${module}`);
for (const module of removed) console.log(` - ${module}`);
}
}
} else {
const reports = await checkPackageBoundaries(root);
for (const report of reports)
console.log(
`Checked ${report.name}: ${report.exports} exports, ${report.modules} owned modules.`,
);
}
} catch (error) { } catch (error) {
console.error(error.message); console.error(error.message);
process.exitCode = 1; process.exitCode = 1;
+469
View File
@@ -0,0 +1,469 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createFakeEngine } from '../src/globe/testing/fakeEngine.js';
import { fakeContractOptions } from '../src/globe/testing/contractFixtures.js';
import {
createContractSuite,
runContractSuite,
} from '../src/globe/testing/contractSuite/index.mjs';
const replace = (object, member, wrap) => {
object[member] = wrap(object[member].bind(object));
};
const handle = (engine, area, factory, mutate) =>
replace(engine[area], factory, (create) => (...args) => {
const value = create(...args);
mutate(value);
return value;
});
const mutants = [
[
'field of view changes are ignored',
'camera.setFovy: changes projection',
(e) => { e.camera.setFovy = () => {}; },
],
[
'optical fields below one degree are ignored',
'camera.setFovy: changes projection',
(e) => {
replace(e.camera, 'setFovy', (set) => (radians) => {
if (radians >= Math.PI / 180) set(radians);
});
},
],
[
'retarget omits followChanged',
'camera.following: set, retarget',
(e) => {
let previous;
replace(e.camera.followChanged, 'raiseEvent', (raise) => (value) => {
if (value !== previous) raise(value);
previous = value;
});
},
],
[
'pointer cancels an active flight',
'camera.flyTo: a pointer gesture',
(e) => {
replace(e.fake, 'input', (input) => (type, event) => {
if (type === 'down') e.camera.cancelFlight();
input(type, event);
});
},
],
[
'terrain errors are labelled as tiles',
'maps.errors: terrain failures',
(e) => {
replace(
e.maps.errors,
'raiseEvent',
(raise) => (error) => raise({ ...error, kind: 'tile' }),
);
},
],
[
'interrupted flight omits cancel',
'camera.flyTo: a newer flight',
(e) => {
replace(
e.camera,
'flyTo',
(fly) => (options) => fly({ ...options, cancel: undefined }),
);
},
],
[
'pure rotation omits changed',
'camera.changed: a pure heading',
(e) => {
let previous = e.camera.pose().position;
replace(e.camera.changed, 'raiseEvent', (raise) => (...args) => {
const position = e.camera.pose().position;
const moved =
Math.hypot(
position.x - previous.x,
position.y - previous.y,
position.z - previous.z,
) > 0.01;
previous = position;
if (moved) raise(...args);
});
},
],
[
'request during render is lost',
'render.request: a request inside',
(e) => {
let inFrame = false;
e.frames.preRender.addEventListener(() => {
inFrame = true;
});
e.frames.postRender.addEventListener(() => {
inFrame = false;
});
replace(e.render, 'request', (request) => (...args) => {
if (!inFrame) request(...args);
});
},
],
[
'terrain revision stays fixed',
'maps.terrainRevision: changes when setTerrain',
(e) => {
e.maps.terrainRevision = () => 0;
},
],
[
'removed mover remains listed',
'movers.list: shown follows',
(e) => {
const removed = [];
const list = e.movers.list;
handle(e, 'movers', 'createSet', (set) => {
replace(set, 'destroy', (destroy) => () => {
const row = list().find(({ id }) => id === set.id);
if (row) removed.push(row);
destroy();
});
});
e.movers.list = () => [...list(), ...removed];
},
],
[
'stop omits followChanged',
'camera.following: set, retarget',
(e) => {
replace(e.camera.followChanged, 'raiseEvent', (raise) => (value) => {
if (value !== null) raise(value);
});
},
],
[
'following forgets the handle',
'camera.following: set, retarget',
(e) => {
e.camera.following = () => null;
},
],
[
'retarget keeps the old target',
'camera.following: set, retarget',
(e) => {
handle(e, 'camera', 'follow', (follow) => {
follow.retarget = () => {};
});
},
],
[
'texture limit is not a power of two',
'render.maxTextureSize:',
(e) => {
e.render.maxTextureSize = () => 3;
},
],
[
'continuous mode never changes',
'render.setContinuous: continuous',
(e) => {
e.render.setContinuous = () => {};
},
],
[
'clock is never latched',
'clock.frameTime: advances before',
(e) => {
e.clock.frameTime = () => 0;
},
],
[
'terrain samples are reversed',
'surface.sampleTerrainMostDetailed: preserves',
(e) => {
replace(
e.surface,
'sampleTerrainMostDetailed',
(sample) =>
async (...args) =>
(await sample(...args)).reverse(),
);
},
],
[
'unknown terrain becomes zero',
'surface.sampleTerrainMostDetailed: preserves',
(e) => {
replace(
e.surface,
'sampleTerrainMostDetailed',
(sample) =>
async (...args) =>
(await sample(...args)).map((height) => height ?? 0),
);
},
],
[
'terrain rejection is swallowed',
'surface.sampleTerrainMostDetailed: passes',
(e) => {
replace(
e.surface,
'sampleTerrainMostDetailed',
(sample) =>
async (...args) => {
try {
return await sample(...args);
} catch {
return [];
}
},
);
},
],
[
'failed model reports no failure',
'movers.createSet: failed model retries',
(e) => {
handle(e, 'movers', 'createSet', (set) => {
set.failed = () => false;
});
},
],
[
'failed model reports ready',
'movers.createSet: failed model retries',
(e) => {
handle(e, 'movers', 'createSet', (set) => {
set.ready = () => true;
});
},
],
[
'failed model never retries',
'movers.createSet: failed model retries',
(e) => {
handle(e, 'movers', 'createSet', (set) => {
replace(set, 'commit', (commit) => () => {
const schedule = globalThis.setTimeout;
globalThis.setTimeout = () => null;
try {
commit();
} finally {
globalThis.setTimeout = schedule;
}
});
});
},
],
[
'hidden mover reports shown',
'movers.list: shown follows',
(e) => {
replace(
e.movers,
'list',
(list) => () => list().map((row) => ({ ...row, shown: true })),
);
},
],
[
'map error loses HTTP status',
'maps.errors: forwards source',
(e) => {
replace(
e.maps.errors,
'raiseEvent',
(raise) => (error) => raise({ ...error, status: undefined }),
);
},
],
[
'map errors are swallowed',
'maps.errors: forwards source',
(e) => {
e.maps.errors.raiseEvent = () => {};
},
],
[
'old map providers keep reporting',
'maps.errors: forwards source',
(e) => {
replace(e.maps, 'setBaseImagery', (set) => async (source) => {
await set(source);
source.provider?.errorEvent?.addEventListener((error) => {
e.maps.errors.raiseEvent({
source,
kind: 'tile',
status: error?.statusCode,
});
});
});
},
],
[
'flight remains active after completion',
'camera.flyTo: duration 0',
(e) => {
e.camera.flightActive = () => true;
},
],
[
'setView leaves the flight running',
'camera.setView: cancels an active',
(e) => {
e.camera.setView = ({ destination, orientation }) =>
e.fake.setPose({ position: destination, ...orientation });
},
],
[
'instant flight omits complete',
'camera.flyTo: duration 0',
(e) => {
replace(
e.camera,
'flyTo',
(fly) => (options) => fly({ ...options, complete: undefined }),
);
},
],
[
'hidden meshes remain shown',
'meshes.createMesh: picks use',
(e) => {
handle(e, 'meshes', 'createMesh', (mesh) => {
replace(
mesh,
'show',
(show) => (value) => show(value === false ? true : value),
);
});
},
],
[
'imagery ignores alpha changes',
'imagery.createLayer: layers are',
(e) => {
handle(e, 'imagery', 'createLayer', (layer) => {
layer.setAlpha = () => {};
});
},
],
[
'clearing tiles forgets base imagery',
'maps.setBaseImagery: setPhotorealTiles',
(e) => {
replace(e.maps, 'setPhotorealTiles', (set) => async (source) => {
await set(source);
if (source === null) e.fake.maps.imagery = null;
});
},
],
[
'tiles-only ground also draws on terrain',
'ground.createSet: tiles drape',
(e) => {
replace(
e.ground,
'createSet',
(create) => (options) =>
create({
...options,
drape: options.drape === 'tiles' ? 'both' : options.drape,
}),
);
},
],
[
'released photoreal tiles still answer surface queries',
'surface.sampleHeightDetailed: leaving photoreal',
(e) => {
replace(e.maps, 'setPhotorealTiles', (set) => async (source) => {
await set(source);
if (source === null)
e.fake.script('sampleHeightDetailed', () => ({
height: 50,
source: 'tiles',
}));
});
},
],
];
for (const [name, killedBy, mutate] of mutants) {
test(`${name} — killed by ${killedBy}`, async () => {
const engine = createFakeEngine();
try {
mutate(engine);
const results = await runContractSuite(engine, {
...fakeContractOptions(engine),
maxWaitFrames: 160,
});
const check = results.find(({ name }) => name.startsWith(killedBy));
assert.ok(check, `No check named ${killedBy}`);
assert.equal(check.ok, false, `Surviving mutant: ${name}`);
assert.ok(
check.error.name === 'ContractError' ||
check.error.message === 'No frame requested',
`Unexpected failure: ${check.error.stack}`,
);
} finally {
engine.destroy();
}
});
}
test('released tile surface check runs without a tileset fixture', async () => {
const engine = createFakeEngine();
try {
const check = createContractSuite(engine, {
...fakeContractOptions(engine),
fixtures: null,
mapSources: null,
}).find(({ name }) =>
name.startsWith('surface.sampleHeightDetailed: leaving photoreal'),
);
assert.ok(check);
await check.run();
engine.fake.script('sampleHeightDetailed', () => ({
height: 50,
source: 'tiles',
}));
await assert.rejects(check.run(), /released photoreal tiles/);
} finally {
engine.destroy();
}
});
test('tiles-only ground pixels on terrain are detected without a tileset fixture or a picking defect', async () => {
for (const drawsOnTerrain of [false, true]) {
const engine = createFakeEngine();
try {
const check = createContractSuite(engine, {
...fakeContractOptions(engine),
fixtures: null,
mapSources: null,
readRegion(x, y, width, height) {
const shown = engine.ground
.list()
.some((set) => set.id === 'contract-tiles-drape' && set.shown);
const color =
drawsOnTerrain && shown
? [255, 0, 255, 255]
: [50, 100, 150, 255];
return Uint8Array.from(
{ length: width * height * 4 },
(_, i) => color[i % 4],
);
},
}).find(({ name }) => name.startsWith('ground.createSet: tiles drape'));
assert.ok(check, 'tiles drape check runs without a tileset fixture');
if (drawsOnTerrain)
await assert.rejects(check.run(), /tiles drape changed terrain pixels/);
else await check.run();
} finally {
engine.destroy();
}
}
});
+1 -1
View File
@@ -250,7 +250,7 @@ if [[ ! -f "$SOURCE_ROOT/src/data/cctv.js" ]]; then
fi fi
if ! grep -q "return createApplicationCatalog(" "$SOURCE_ROOT/src/standalone/catalog.js" || \ if ! grep -q "return createApplicationCatalog(" "$SOURCE_ROOT/src/standalone/catalog.js" || \
! grep -q "^[[:space:]]*createApplicationCctv({" "$SOURCE_ROOT/src/app/constructCatalog.js"; then ! grep -q "createApplicationCctv(" "$SOURCE_ROOT/src/app/constructCatalog.js"; then
echo "error: CCTV layer not wired in src/standalone/catalog.js" echo "error: CCTV layer not wired in src/standalone/catalog.js"
exit 1 exit 1
fi fi
+19
View File
@@ -0,0 +1,19 @@
{
"metrics": {
"attachRendererWithViewer": 0,
"restoreFlagCopies": 0,
"surfaceCallsOutsideGlobe": 10,
"engineSwitchLayerIds": 0,
"cesiumLayerRendererLines": 0,
"navaraLayerRendererLines": 0,
"cesiumImportFiles": 2,
"viewerLines": 632,
"navaraKindChecks": 1,
"engineSniffCalls": 0,
"engineExtensionReads": 4,
"groundEffectPasses": 0,
"cesiumSdkLoads": 3,
"screenSpaceHandlerFiles": 2,
"engineNameLines": 20
}
}
+123
View File
@@ -0,0 +1,123 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
ENGINE_COUPLING_METRICS,
couplingGrowth,
measureEngineCoupling,
readBaseline,
} from './report-engine-coupling.mjs';
test('engine coupling does not grow past the baseline', () => {
const growth = couplingGrowth(
measureEngineCoupling(),
readBaseline().metrics,
);
assert.deepEqual(
growth,
[],
'Code outside the globe engines grew more engine-specific. Draw through ' +
'the globe contract instead; only a deliberate exception may raise ' +
'scripts/engine-coupling-baseline.json (--write-baseline).',
);
});
test('the baseline names every metric', () => {
const { metrics } = readBaseline();
assert.deepEqual(
Object.keys(metrics).sort(),
ENGINE_COUPLING_METRICS.map(({ id }) => id).sort(),
);
});
test('a metric that grows is reported, one that falls is not', () => {
const baseline = Object.fromEntries(
ENGINE_COUPLING_METRICS.map(({ id }) => [id, 10]),
);
const measured = { ...baseline, viewerLines: 11, navaraKindChecks: 3 };
assert.deepEqual(
couplingGrowth(measured, baseline).map(({ id }) => id),
['viewerLines'],
);
});
test('renderer attachment counts definitions once per module, not calls', () => {
const { measure } = ENGINE_COUPLING_METRICS.find(
({ id }) => id === 'attachRendererWithViewer',
);
assert.equal(
measure([
{
text: 'attachRenderer(viewer, options = {}) {}\nattachRenderer(scene) {}',
},
{ text: 'function attachRenderer(\n _viewer, options\n) {}' },
{ text: 'const attachRenderer = (nextViewer) => {};' },
{
text: 'attachRenderer(_scene); layer.attachRenderer(viewer, options);',
},
{ text: 'attachRenderer(engine) {}' },
{ text: 'attachRenderer(viewerState) {}' },
]),
3,
);
});
test('restore flags count layer directories and surface calls exclude globe', () => {
const measure = (id, files) =>
ENGINE_COUPLING_METRICS.find((metric) => metric.id === id).measure(files);
assert.equal(
measure('restoreFlagCopies', [
{
file: 'src/layers/traffic/lifecycle.js',
text: 'state._restoringViewport = true;',
},
{
file: 'src/layers/traffic/viewport.js',
text: 'state._restoringViewport',
},
{ file: 'src/layers/alpr/index.js', text: 'let restoringView = false;' },
{
file: 'src/layers/streetLevel/index.js',
text: 'let waitingForView = false;',
},
{ file: 'src/app/view.js', text: 'let restoringView = false;' },
]),
3,
);
const text = [
'terrainHeight',
'sampleHeight',
'sampleHeightDetailed',
'sampleTerrainMostDetailed',
'clampToHeight',
'raycast',
]
.map((name) => `engine.surface.${name}(point);`)
.join('\n');
assert.equal(
measure('surfaceCallsOutsideGlobe', [
{ file: 'src/globe/navara/surface.js', text },
{ file: 'src/layers/traffic/viewport.js', text },
{
file: 'src/app/view.js',
text: 'engine.surface?.raycast?.(ray); surface.tilesLoaded();',
},
]),
7,
);
});
test('engine switch layer ids count lines and follow the catalog', () => {
const { measure } = ENGINE_COUPLING_METRICS.find(
({ id }) => id === 'engineSwitchLayerIds',
);
assert.equal(
measure([
{
file: 'src/standalone/engineSwitch.js',
text: "get('directions'); get('satellites');\nget('traffic');\nget('not-a-layer');",
},
{ file: 'src/app/view.js', text: "get('directions');" },
]),
2,
);
});
+45
View File
@@ -0,0 +1,45 @@
# Import reference corpus
`reference.json` records Cesium 1.138.0 DataSources in Node with a DOM shim,
canvas pins stubbed and KMZ images resolved locally. It is independent of
`planImport` and checks entity geometry and styles, without rendering pixels.
Record and compare offline from the repository root; no server or browser is
needed. Recording uses the installed Cesium version, saved in the reference:
```sh
node scripts/qa-geo-import-reference.mjs --record
node scripts/qa-geo-import-reference.mjs
```
Recording uses Cesium's `KmlDataSource` and `GeoJsonDataSource` after the import's
sanitization and GeoJSON grounding policy. Files are read from this directory;
packed images use local blob URLs. Comparison uses 0.01 metres for heights,
1e-7 degrees for coordinates and one 8-bit channel (1/255) for colours.
Repeated ring endpoints and ignored short holes are normalized; input winding
is retained. Image overlay order is compared separately, including ties in
document order. Pixel composition and ordering between primitive families
still need browser acceptance.
| File | Cases |
| -------------------- | ----------------------------------------------------------------------------------------------------- |
| `absolute.kml` | Absolute pins, lines and polygons; positive and negative image heights; raised rotated colour overlay |
| `relative.kml` | Relative pins, lines, polygons and image overlay |
| `clamped.kml` | Clamped heights and tessellation |
| `rotations.kml` | Zero, positive, negative and full turns; a tinted 360-degree span |
| `pole.kml` | Rotation near and past a pole; polar polygon |
| `antimeridian.kml` | Dateline polygon and rotated image |
| `holes.kml` | Ring closure and opposite windings with holes |
| `short-holes.kml` | Short and duplicate-corner holes |
| `draw-order.kml` | Negative, positive and equal image orders; plain colour |
| `styles.kml` | KML colours, line width, icon scale, heading and hot spot |
| `geometries.geojson` | Grounded point, line and polygon |
| `large-polygon.kml` | A raised polygon with 20,000 vertices |
| `packed.kmz` | An icon and a tinted, rotated overlay using a packed PNG |
The corpus checks negative absolute image heights and raised plain-colour
overlays against Cesium. Short holes are skipped on direct record input without
rejecting the whole ground polygon. Raised fills have a 32,768-vertex ceiling
and raised polygons a 128-ring ceiling in `src/data/geoImportLimits.js`;
a repeated closing vertex does not consume triangulation budget. The large
polygon fixture exercises a 20,000-vertex fill within that budget.
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2"><Document>
<Placemark><name>absolute-pin</name><Point><altitudeMode>absolute</altitudeMode><coordinates>10,20,350</coordinates></Point></Placemark>
<Placemark><name>absolute-line-0</name><LineString><altitudeMode>absolute</altitudeMode><tessellate>0</tessellate><coordinates>10,20,150 11,20,300</coordinates></LineString></Placemark>
<Placemark><name>absolute-line-1</name><LineString><altitudeMode>absolute</altitudeMode><tessellate>1</tessellate><coordinates>10,20,150 11,20,300</coordinates></LineString></Placemark>
<Placemark><name>absolute-polygon</name><Polygon><altitudeMode>absolute</altitudeMode><outerBoundaryIs><LinearRing><coordinates>0,0,100 2,0,100 2,2,100 0,2,100 0,0,100</coordinates></LinearRing></outerBoundaryIs><innerBoundaryIs><LinearRing><coordinates>0.5,0.5,100 0.5,1.5,100 1.5,1.5,100 1.5,0.5,100 0.5,0.5,100</coordinates></LinearRing></innerBoundaryIs></Polygon></Placemark>
<GroundOverlay><name>absolute-image</name><drawOrder>0</drawOrder><color>ffffffff</color><altitudeMode>absolute</altitudeMode><altitude>200</altitude><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>10</west><south>20</south><east>12</east><north>22</north><rotation>0</rotation></LatLonBox></GroundOverlay>
<GroundOverlay><name>negative-image</name><drawOrder>0</drawOrder><color>ffffffff</color><altitudeMode>absolute</altitudeMode><altitude>-100</altitude><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>10</west><south>20</south><east>12</east><north>22</north><rotation>0</rotation></LatLonBox></GroundOverlay>
<GroundOverlay><name>raised-color</name><drawOrder>0</drawOrder><color>ffffffff</color><altitudeMode>absolute</altitudeMode><altitude>200</altitude><LatLonBox><west>10</west><south>20</south><east>12</east><north>22</north><rotation>30</rotation></LatLonBox></GroundOverlay>
</Document></kml>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2"><Document>
<GroundOverlay><name>crossing-image</name><drawOrder>0</drawOrder><color>ffffffff</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>179</west><south>-2</south><east>-179</east><north>2</north><rotation>30</rotation></LatLonBox></GroundOverlay>
<Placemark><name>crossing-ring</name><Polygon><altitudeMode>absolute</altitudeMode><outerBoundaryIs><LinearRing><coordinates>179,-1,100 -179,-1,100 -179,1,100 179,1,100</coordinates></LinearRing></outerBoundaryIs></Polygon></Placemark>
</Document></kml>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2"><Document>
<Placemark><name>clamped-pin</name><Point><altitudeMode>clampToGround</altitudeMode><coordinates>10,20,350</coordinates></Point></Placemark>
<Placemark><name>clamped-line-0</name><LineString><altitudeMode>clampToGround</altitudeMode><tessellate>0</tessellate><coordinates>10,20,150 11,20,300</coordinates></LineString></Placemark>
<Placemark><name>clamped-line-1</name><LineString><altitudeMode>clampToGround</altitudeMode><tessellate>1</tessellate><coordinates>10,20,150 11,20,300</coordinates></LineString></Placemark>
<Placemark><name>clamped-polygon</name><Polygon><altitudeMode>clampToGround</altitudeMode><outerBoundaryIs><LinearRing><coordinates>0,0,100 2,0,100 2,2,100 0,2,100 0,0,100</coordinates></LinearRing></outerBoundaryIs><innerBoundaryIs><LinearRing><coordinates>0.5,0.5,100 0.5,1.5,100 1.5,1.5,100 1.5,0.5,100 0.5,0.5,100</coordinates></LinearRing></innerBoundaryIs></Polygon></Placemark>
<GroundOverlay><name>clamped-image</name><drawOrder>0</drawOrder><color>ffffffff</color><altitudeMode>clampToGround</altitudeMode><altitude>200</altitude><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>10</west><south>20</south><east>12</east><north>22</north><rotation>0</rotation></LatLonBox></GroundOverlay>
</Document></kml>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2"><Document>
<GroundOverlay><name>top</name><drawOrder>9</drawOrder><color>804020ff</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-1</west><south>-1</south><east>1</east><north>1</north><rotation>0</rotation></LatLonBox></GroundOverlay>
<GroundOverlay><name>first</name><drawOrder>-2</drawOrder><color>ff00ff00</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-1</west><south>-1</south><east>1</east><north>1</north><rotation>0</rotation></LatLonBox></GroundOverlay>
<GroundOverlay><name>equal-a</name><drawOrder>3</drawOrder><color>ff0000ff</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-1</west><south>-1</south><east>1</east><north>1</north><rotation>0</rotation></LatLonBox></GroundOverlay>
<GroundOverlay><name>equal-b</name><drawOrder>3</drawOrder><color>ffff0000</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-1</west><south>-1</south><east>1</east><north>1</north><rotation>0</rotation></LatLonBox></GroundOverlay>
<GroundOverlay><name>plain-color</name><drawOrder>0</drawOrder><color>804020ff</color><LatLonBox><west>-1</west><south>-1</south><east>1</east><north>1</north><rotation>25</rotation></LatLonBox></GroundOverlay>
</Document></kml>
@@ -0,0 +1 @@
{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"name":"json-point"},"geometry":{"type":"Point","coordinates":[10,20,99]}},{"type":"Feature","properties":{"name":"json-line"},"geometry":{"type":"LineString","coordinates":[[10,20,4],[11,21,8]]}},{"type":"Feature","properties":{"name":"json-polygon"},"geometry":{"type":"Polygon","coordinates":[[[0,0,100],[2,0,100],[2,2,100],[0,2,100],[0,0,100]],[[0.5,0.5,100],[0.5,1.5,100],[1.5,1.5,100],[1.5,0.5,100],[0.5,0.5,100]]]}}]}
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2"><Document>
<Placemark><name>open</name><Polygon><altitudeMode>absolute</altitudeMode><outerBoundaryIs><LinearRing><coordinates>0,0,100 2,0,100 2,2,100 0,2,100</coordinates></LinearRing></outerBoundaryIs><innerBoundaryIs><LinearRing><coordinates>0.5,0.5,100 0.5,1.5,100 1.5,1.5,100 1.5,0.5,100</coordinates></LinearRing></innerBoundaryIs></Polygon></Placemark>
<Placemark><name>closed</name><Polygon><altitudeMode>absolute</altitudeMode><outerBoundaryIs><LinearRing><coordinates>0,0,100 2,0,100 2,2,100 0,2,100 0,0,100</coordinates></LinearRing></outerBoundaryIs><innerBoundaryIs><LinearRing><coordinates>0.5,0.5,100 0.5,1.5,100 1.5,1.5,100 1.5,0.5,100 0.5,0.5,100</coordinates></LinearRing></innerBoundaryIs></Polygon></Placemark>
<Placemark><name>reversed</name><Polygon><altitudeMode>absolute</altitudeMode><outerBoundaryIs><LinearRing><coordinates>0,0,100 0,2,100 2,2,100 2,0,100 0,0,100</coordinates></LinearRing></outerBoundaryIs><innerBoundaryIs><LinearRing><coordinates>0.5,0.5,100 1.5,0.5,100 1.5,1.5,100 0.5,1.5,100 0.5,0.5,100</coordinates></LinearRing></innerBoundaryIs></Polygon></Placemark>
<Placemark><name>ground-holes</name><Polygon><altitudeMode>clampToGround</altitudeMode><outerBoundaryIs><LinearRing><coordinates>0,0,100 2,0,100 2,2,100 0,2,100 0,0,100</coordinates></LinearRing></outerBoundaryIs><innerBoundaryIs><LinearRing><coordinates>0.5,0.5,100 0.5,1.5,100 1.5,1.5,100 1.5,0.5,100 0.5,0.5,100</coordinates></LinearRing></innerBoundaryIs></Polygon></Placemark>
</Document></kml>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2"><Document>
<Placemark><name>large</name><Polygon><altitudeMode>absolute</altitudeMode><outerBoundaryIs><LinearRing><coordinates>10.1,20.0,500 10.099999995,20.000031416,500 10.09999998,20.000062832,500 10.099999956,20.000094248,500 10.099999921,20.000125664,500 10.099999877,20.00015708,500 10.099999822,20.000188495,500 10.099999758,20.000219911,500 10.099999684,20.000251327,500 10.0999996,20.000282743,500 10.099999507,20.000314159,500 10.099999403,20.000345575,500 10.099999289,20.00037699,500 10.099999166,20.000408406,500 10.099999033,20.000439822,500 10.09999889,20.000471237,500 10.099998737,20.000502653,500 10.099998574,20.000534068,500 10.099998401,20.000565484,500 10.099998219,20.000596899,500 10.099998026,20.000628314,500 10.099997824,20.00065973,500 10.099997612,20.000691145,500 10.09999739,20.00072256,500 10.099997158,20.000753975,500 10.099996916,20.00078539,500 10.099996664,20.000816805,500 10.099996403,20.00084822,500 10.099996131,20.000879635,500 10.09999585,20.000911049,500 10.099995559,20.000942464,500 10.099995258,20.000973878,500 10.099994947,20.001005293,500 10.099994626,20.001036707,500 10.099994295,20.001068121,500 10.099993955,20.001099535,500 10.099993605,20.001130949,500 10.099993244,20.001162363,500 10.099992874,20.001193777,500 10.099992494,20.00122519,500 10.099992104,20.001256604,500 10.099991705,20.001288017,500 10.099991295,20.001319431,500 10.099990876,20.001350844,500 10.099990446,20.001382257,500 10.099990007,20.00141367,500 10.099989558,20.001445082,500 10.099989099,20.001476495,500 10.09998863,20.001507907,500 10.099988152,20.00153932,500 10.099987663,20.001570732,500 10.099987165,20.001602144,500 10.099986657,20.001633556,500 10.099986138,20.001664967,500 10.09998561,20.001696379,500 10.099985073,20.00172779,500 10.099984525,20.001759201,500 10.099983967,20.001790612,500 10.0999834,20.001822023,500 10.099982822,20.001853434,500 10.099982235,20.001884844,500 10.099981638,20.001916254,500 10.099981031,20.001947664,500 10.099980414,20.001979074,500 10.099979788,20.002010484,500 10.099979151,20.002041893,500 10.099978505,20.002073303,500 10.099977848,20.002104712,500 10.099977182,20.002136121,500 10.099976506,20.002167529,500 10.09997582,20.002198938,500 10.099975125,20.002230346,500 10.099974419,20.002261754,500 10.099973704,20.002293162,500 10.099972978,20.002324569,500 10.099972243,20.002355976,500 10.099971498,20.002387384,500 10.099970743,20.00241879,500 10.099969978,20.002450197,500 10.099969203,20.002481603,500 10.099968419,20.00251301,500 10.099967625,20.002544415,500 10.09996682,20.002575821,500 10.099966006,20.002607226,500 10.099965182,20.002638632,500 10.099964348,20.002670036,500 10.099963504,20.002701441,500 10.099962651,20.002732845,500 10.099961787,20.002764249,500 10.099960914,20.002795653,500 10.099960031,20.002827057,500 10.099959138,20.00285846,500 10.099958235,20.002889863,500 10.099957322,20.002921266,500 10.099956399,20.002952668,500 10.099955467,20.00298407,500 10.099954524,20.003015472,500 10.099953572,20.003046873,500 10.09995261,20.003078274,500 10.099951638,20.003109675,500 10.099950656,20.003141076,500 10.099949664,20.003172476,500 10.099948663,20.003203876,500 10.099947651,20.003235276,500 10.09994663,20.003266675,500 10.099945599,20.003298074,500 10.099944558,20.003329473,500 10.099943507,20.003360871,500 10.099942446,20.003392269,500 10.099941375,20.003423667,500 10.099940295,20.003455064,500 10.099939204,20.003486461,500 10.099938104,20.003517858,500 10.099936994,20.003549254,500 10.099935874,20.00358065,500 10.099934744,20.003612046,500 10.099933605,20.003643441,500 10.099932455,20.003674836,500 10.099931296,20.00370623,500 10.099930126,20.003737624,500 10.099928947,20.003769018,500 10.099927758,20.003800412,500 10.099926559,20.003831805,500 10.099925351,20.003863197,500 10.099924132,20.00389459,500 10.099922904,20.003925982,500 10.099921665,20.003957373,500 10.099920417,20.003988764,500 10.099919159,20.004020155,500 10.099917891,20.004051545,500 10.099916613,20.004082935,500 10.099915326,20.004114325,500 10.099914028,20.004145714,500 10.099912721,20.004177103,500 10.099911404,20.004208491,500 10.099910077,20.004239879,500 10.09990874,20.004271266,500 10.099907393,20.004302653,500 10.099906036,20.00433404,500 10.09990467,20.004365426,500 10.099903293,20.004396812,500 10.099901907,20.004428197,500 10.099900511,20.004459582,500 10.099899105,20.004490967,500 10.099897689,20.004522351,500 10.099896264,20.004553734,500 10.099894828,20.004585117,500 10.099893383,20.0046165,500 10.099891928,20.004647882,500 10.099890462,20.004679264,500 10.099888987,20.004710645,500 10.099887503,20.004742026,500 10.099886008,20.004773406,500 10.099884503,20.004804786,500 10.099882989,20.004836166,500 10.099881465,20.004867544,500 10.099879931,20.004898923,500 10.099878387,20.004930301,500 10.099876833,20.004961678,500 10.099875269,20.004993055,500 10.099873696,20.005024432,500 10.099872112,20.005055808,500 10.099870519,20.005087183,500 10.099868916,20.005118558,500 10.099867303,2Line truncated
</Document></kml>
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2"><Document>
<GroundOverlay><name>near-pole</name><drawOrder>0</drawOrder><color>ffffffff</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-2</west><south>84</south><east>2</east><north>86</north><rotation>30</rotation></LatLonBox></GroundOverlay>
<GroundOverlay><name>past-pole</name><drawOrder>0</drawOrder><color>ffffffff</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-10</west><south>86</south><east>10</east><north>89</north><rotation>45</rotation></LatLonBox></GroundOverlay>
<Placemark><name>polar-ring</name><Polygon><altitudeMode>absolute</altitudeMode><outerBoundaryIs><LinearRing><coordinates>10,88,200 14,88,200 14,88.2,200 10,88.2,200</coordinates></LinearRing></outerBoundaryIs></Polygon></Placemark>
</Document></kml>
File diff suppressed because it is too large. Load diff
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2"><Document>
<Placemark><name>relative-pin</name><Point><altitudeMode>relativeToGround</altitudeMode><coordinates>10,20,350</coordinates></Point></Placemark>
<Placemark><name>relative-line-0</name><LineString><altitudeMode>relativeToGround</altitudeMode><tessellate>0</tessellate><coordinates>10,20,150 11,20,300</coordinates></LineString></Placemark>
<Placemark><name>relative-line-1</name><LineString><altitudeMode>relativeToGround</altitudeMode><tessellate>1</tessellate><coordinates>10,20,150 11,20,300</coordinates></LineString></Placemark>
<Placemark><name>relative-polygon</name><Polygon><altitudeMode>relativeToGround</altitudeMode><outerBoundaryIs><LinearRing><coordinates>0,0,100 2,0,100 2,2,100 0,2,100 0,0,100</coordinates></LinearRing></outerBoundaryIs><innerBoundaryIs><LinearRing><coordinates>0.5,0.5,100 0.5,1.5,100 1.5,1.5,100 1.5,0.5,100 0.5,0.5,100</coordinates></LinearRing></innerBoundaryIs></Polygon></Placemark>
<GroundOverlay><name>relative-image</name><drawOrder>0</drawOrder><color>ffffffff</color><altitudeMode>relativeToGround</altitudeMode><altitude>200</altitude><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>10</west><south>20</south><east>12</east><north>22</north><rotation>0</rotation></LatLonBox></GroundOverlay>
</Document></kml>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2"><Document>
<GroundOverlay><name>rotation-0</name><drawOrder>0</drawOrder><color>ffffffff</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-1</west><south>-1</south><east>1</east><north>1</north><rotation>0</rotation></LatLonBox></GroundOverlay>
<GroundOverlay><name>rotation-45</name><drawOrder>0</drawOrder><color>ffffffff</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-1</west><south>-1</south><east>1</east><north>1</north><rotation>45</rotation></LatLonBox></GroundOverlay>
<GroundOverlay><name>rotation--30</name><drawOrder>0</drawOrder><color>ffffffff</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-1</west><south>-1</south><east>1</east><north>1</north><rotation>-30</rotation></LatLonBox></GroundOverlay>
<GroundOverlay><name>rotation-360</name><drawOrder>0</drawOrder><color>ffffffff</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-1</west><south>-1</south><east>1</east><north>1</north><rotation>360</rotation></LatLonBox></GroundOverlay>
<GroundOverlay><name>rotation--360</name><drawOrder>0</drawOrder><color>ffffffff</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-1</west><south>-1</south><east>1</east><north>1</north><rotation>-360</rotation></LatLonBox></GroundOverlay>
<GroundOverlay><name>all-longitudes</name><drawOrder>0</drawOrder><color>ff80ffff</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-180</west><south>-1</south><east>180</east><north>1</north><rotation>0</rotation></LatLonBox></GroundOverlay>
</Document></kml>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2"><Document>
<Placemark><name>two-point-hole</name><Polygon><altitudeMode>absolute</altitudeMode><outerBoundaryIs><LinearRing><coordinates>0,0,100 2,0,100 2,2,100 0,2,100 0,0,100</coordinates></LinearRing></outerBoundaryIs><innerBoundaryIs><LinearRing><coordinates>0.4,0.4,100 0.8,0.4,100</coordinates></LinearRing></innerBoundaryIs></Polygon></Placemark>
<Placemark><name>repeated-hole</name><Polygon><altitudeMode>absolute</altitudeMode><outerBoundaryIs><LinearRing><coordinates>0,0,100 2,0,100 2,2,100 0,2,100 0,0,100</coordinates></LinearRing></outerBoundaryIs><innerBoundaryIs><LinearRing><coordinates>0.4,0.4,100 0.8,0.4,100 0.4,0.4,100</coordinates></LinearRing></innerBoundaryIs></Polygon></Placemark>
</Document></kml>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2"><Document>
<Style id="shared"><LineStyle><color>804020ff</color><width>5</width></LineStyle><PolyStyle><color>7f00ffff</color><outline>1</outline></PolyStyle><IconStyle><color>cc804020</color><scale>1.5</scale><heading>45</heading><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><hotSpot x="0.25" y="0.75" xunits="fraction" yunits="fraction"/></IconStyle></Style><StyleMap id="mapped"><Pair><key>normal</key><styleUrl>#shared</styleUrl></Pair></StyleMap><Placemark><name>styled-polygon</name><styleUrl>#mapped</styleUrl><Polygon><altitudeMode>absolute</altitudeMode><outerBoundaryIs><LinearRing><coordinates>0,0,100 2,0,100 2,2,100 0,2,100 0,0,100</coordinates></LinearRing></outerBoundaryIs></Polygon></Placemark>
<Placemark><name>styled-pin</name><styleUrl>#mapped</styleUrl><Point><coordinates>10,20</coordinates></Point></Placemark><Placemark><name>styled-line</name><styleUrl>#shared</styleUrl><LineString><coordinates>10,20 11,21</coordinates></LineString></Placemark>
</Document></kml>
Binary file not shown.
+132
View File
@@ -0,0 +1,132 @@
import { PbfWriter } from 'pbf';
// Recorded journeys cover both a dense neighbourhood and the wider Austin view.
export const cameras = [
...Array.from({ length: 225 }, (_, i) => ({
latitude: 30.262 + (Math.floor(i / 15) - 7) * 0.003,
longitude: -97.7431 + ((i % 15) - 7) * 0.003,
})),
...Array.from({ length: 100 }, (_, i) => ({
latitude: 30.308 + (Math.floor(i / 10) - 4.5) * 0.001,
longitude: -97.77 + ((i % 10) - 4.5) * 0.001,
})),
].map((position, i) => ({
...position,
osmId: 9000000 + i,
operator: i % 3 ? 'Deterministic fixture' : 'Cámara pública',
brand: 'Fixture camera',
cameraType: 'fixed',
direction: i % 5 ? String((i * 37) % 360) : '',
}));
export function cameraTile(z, x, y, records = cameras) {
const extent = 4096;
const scale = 2 ** z;
const rows = records.flatMap((record) => {
const latitude = (record.latitude * Math.PI) / 180;
const tx = ((record.longitude + 180) / 360) * scale;
const ty = ((1 - Math.asinh(Math.tan(latitude)) / Math.PI) / 2) * scale;
if (Math.floor(tx) !== x || Math.floor(ty) !== y) return [];
return [
{
record,
x: Math.round((tx - x) * extent),
y: Math.round((ty - y) * extent),
},
];
});
const keys = [
'osmType',
'osmId',
'operator',
'brand',
'cameraType',
'direction',
];
const writer = new PbfWriter();
writer.writeMessage(
3,
(_, layer) => {
layer.writeVarintField(15, 2);
layer.writeStringField(1, 'cameras');
layer.writeVarintField(5, extent);
for (const key of keys) layer.writeStringField(3, key);
rows.forEach(({ record, x, y }, index) => {
for (const key of keys)
layer.writeMessage(
4,
(_, value) =>
value.writeStringField(
1,
key === 'osmType' ? 'node' : String(record[key]),
),
null,
);
layer.writeMessage(
2,
(_, feature) => {
feature.writeVarintField(1, record.osmId);
feature.writePackedVarint(
2,
keys.flatMap((_, i) => [i, index * keys.length + i]),
);
feature.writeVarintField(3, 1);
feature.writePackedVarint(4, [9, x * 2, y * 2]);
},
null,
);
});
},
null,
);
return Buffer.from(writer.finish());
}
/** Real TileJSON/MVT transport; no replacement of the layer or source parser. */
export async function installAlprFixture(page) {
await page.setRequestInterception(true);
page.on('request', (request) => {
if (request.isInterceptResolutionHandled()) return;
const url = new URL(request.url());
// The journey also asks for nearby-place labels while moving. Pin that
// auxiliary service; these runs do not test Google Places availability.
if (
['localhost', '127.0.0.1'].includes(url.hostname) &&
url.pathname === '/api/google/nearby-places'
) {
void request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ places: [] }),
});
return;
}
if (url.hostname !== 'tiles.dontgetflocked.com') {
void request.continue();
return;
}
const headers = { 'access-control-allow-origin': '*' };
if (url.pathname.endsWith('.json')) {
const country = url.pathname.includes('-ca-') ? 'ca' : 'us';
void request.respond({
status: 200,
headers,
contentType: 'application/json',
body: JSON.stringify({
tiles: [
`https://tiles.dontgetflocked.com/cameras-${country}-hourly/{z}/{x}/{y}.mvt`,
],
bounds: country === 'ca' ? [-124, 42, -63, 54] : [-160, 17, -64, 59],
}),
});
return;
}
const match = url.pathname.match(/\/(\d+)\/(\d+)\/(\d+)\.mvt$/);
void request.respond({
status: match ? 200 : 404,
headers,
contentType: 'application/x-protobuf',
body: match ? cameraTile(...match.slice(1).map(Number)) : '',
});
});
}
+197
View File
@@ -0,0 +1,197 @@
import sharp from 'sharp';
// Transport fixtures are parsed by the real CCTV catalog and media owners.
export const cameras = Array.from({ length: 25 }, (_, i) => ({
id: `fixture-cctv-${String(i).padStart(3, '0')}`,
name: i === 12 ? 'Cámara pública — Congress' : `Fixture camera ${i}`,
cityId: 'austin',
city: 'Austin',
lat: 30.2672 + (Math.floor(i / 5) - 2) * 0.0018,
lon: -97.7431 + ((i % 5) - 2) * 0.0018,
headingDeg: (i * 37) % 360,
fovDeg: i % 2 ? 44 : 56,
rangeM: i % 2 ? 145 : 210,
mountHeightM: i % 2 ? 8 : 10,
pitchDeg: i % 2 ? -18 : -24,
groundElevationM: 150,
headingConfidence: i % 3 ? 'medium' : 'low',
sourceKind: 'configured',
feedType: 'image',
provider: 'Deterministic fixture',
url: 'https://cctv-fixture.invalid/frame.png',
}));
export async function cameraFrame() {
const width = 320,
height = 180;
const pixels = Buffer.alloc(width * height * 3);
const colors = [
[240, 45, 70],
[35, 220, 90],
[40, 90, 235],
[245, 210, 30],
];
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const color =
colors[(x >= width / 2 ? 1 : 0) + (y >= height / 2 ? 2 : 0)];
const grid = x % 40 < 2 || y % 30 < 2;
const at = (y * width + x) * 3;
for (let c = 0; c < 3; c++) pixels[at + c] = grid ? 15 : color[c];
}
}
return sharp(pixels, { raw: { width, height, channels: 3 } })
.png()
.toBuffer();
}
export async function installCctvFixture(page, { video } = {}) {
const frame = await cameraFrame();
const catalog = cameras.map((camera) => ({ ...camera }));
if (video)
catalog.push({
...cameras[0],
id: 'fixture-cctv-video',
name: 'Fixture moving video',
lat: 30.2718,
lon: -97.744,
feedType: 'video',
url: 'https://cctv-fixture.invalid/camera.mp4',
});
const ids = new Set(catalog.map((camera) => camera.id));
// Global Puppeteer interception can stall Cesium's module workers in
// Chromium 152. Intercept only fixture API routes on the page's CDP session.
const session = await page.createCDPSession();
let disposed = false;
const routes = [
'/api/cctv/*',
'/api/flights*',
'/api/military*',
'/api/vessels*',
'/api/google/nearby-places*',
];
await session.send('Fetch.enable', {
patterns: ['localhost', '127.0.0.1'].flatMap((host) =>
routes.map((route) => ({ urlPattern: `http://${host}:*${route}` })),
),
});
const respond = (
requestId,
{ status, contentType, headers = {}, body = '' },
) =>
session.send('Fetch.fulfillRequest', {
requestId,
responseCode: status,
responseHeaders: Object.entries({
...(contentType ? { 'content-type': contentType } : {}),
...headers,
}).map(([name, value]) => ({ name, value: String(value) })),
body: Buffer.from(body).toString('base64'),
});
const handle = (request) => {
const url = new URL(request.url());
if (!['localhost', '127.0.0.1'].includes(url.hostname)) {
return request.continue();
}
const json = (body) =>
request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body),
});
const auxiliary = {
'/api/flights': { states: [] },
'/api/military': { ac: [] },
'/api/vessels': { connected: false, vessels: [] },
};
if (auxiliary[url.pathname]) {
return json(auxiliary[url.pathname]);
}
if (url.pathname === '/api/cctv/sources') {
return json({ sources: catalog });
}
if (url.pathname === '/api/cctv/health') {
return json({
cameras: catalog.map(({ id, feedType }) => ({
id,
status: 'online',
sourceKind: feedType,
label: 'Deterministic fixture',
updatedAt: Date.now(),
})),
});
}
const media = url.pathname.match(/^\/api\/cctv\/(frame|media)\/([^/]+)$/);
if (media) {
const id = decodeURIComponent(media[2]);
if (
!ids.has(id) ||
(media[1] === 'media' && (id !== 'fixture-cctv-video' || !video))
) {
return request.respond({ status: 404, body: 'Unknown fixture camera' });
}
if (media[1] === 'frame') {
return request.respond({
status: 200,
contentType: 'image/png',
body: frame,
});
}
const range = request.headers().range?.match(/^bytes=(\d+)-(\d*)$/);
const start = range ? Number(range[1]) : 0;
const end =
range && range[2]
? Math.min(Number(range[2]), video.length - 1)
: video.length - 1;
if (start > end || start >= video.length) {
return request.respond({
status: 416,
headers: { 'content-range': `bytes */${video.length}` },
});
}
return request.respond({
status: range ? 206 : 200,
contentType: 'video/mp4',
headers: {
'accept-ranges': 'bytes',
...(range
? { 'content-range': `bytes ${start}-${end}/${video.length}` }
: {}),
},
body: video.subarray(start, end + 1),
});
}
if (url.pathname === '/api/google/nearby-places') {
return json({ places: [] });
}
return request.continue();
};
session.on('Fetch.requestPaused', (event) => {
const request = {
url: () => event.request.url,
headers: () =>
Object.fromEntries(
Object.entries(event.request.headers).map(([name, value]) => [
name.toLowerCase(),
value,
]),
),
respond: (response) => respond(event.requestId, response),
continue: () =>
session.send('Fetch.continueRequest', { requestId: event.requestId }),
};
Promise.resolve()
.then(() => handle(request))
.catch((error) => {
if (!disposed && !page.isClosed()) page.emit('pageerror', error);
});
});
return {
cameras: catalog,
frame,
async dispose() {
disposed = true;
await session.detach();
},
};
}
+93
View File
@@ -0,0 +1,93 @@
const ORIGIN = { lat: 42.3554, lon: -71.0605 };
const grid = (i, cols, spacing) => ({
lat: ORIGIN.lat + (Math.floor(i / cols) - cols / 2) * spacing,
lon: ORIGIN.lon + ((i % cols) - cols / 2) * spacing,
});
const bikes = Array.from({ length: 200 }, (_, i) => ({
station_id: `fixture-bike-${i}`,
name: `Fixture station ${i}`,
capacity: 20,
...grid(i, 15, 0.0015),
}));
export const combinedPayloads = { bikes };
/** Bikeshare and startup feed fixtures shared by both engines. */
export async function installCombinedFixture(page) {
const requests = [];
const session = await page.createCDPSession();
await session.send('Fetch.enable', {
patterns: ['127.0.0.1', 'localhost'].map((host) => ({
urlPattern: `http://${host}:*/api/*`,
})),
});
let disposed = false;
session.on('Fetch.requestPaused', async (event) => {
try {
const u = new URL(event.request.url);
let body;
const p = u.pathname;
if (p.startsWith('/api/gbfs/'))
body = {
version: '3.0',
data: {
stations: decodeURIComponent(u.href).includes('station_information')
? bikes
: bikes.map(({ station_id }) => ({
station_id,
num_bikes_available: 7,
num_docks_available: 13,
is_renting: true,
is_returning: true,
})),
},
};
else if (p === '/api/flights') body = { states: [] };
else if (p === '/api/military') body = { ac: [] };
else if (p === '/api/vessels') body = { connected: false, vessels: [] };
else if (p === '/api/google/nearby-places') body = { places: [] };
else if (p === '/api/setup/status') body = { ready: true };
else if (p === '/api/terrain/heights')
body = {
results: (u.searchParams.get('points') || '')
.split(';')
.filter(Boolean)
.map((point) => {
const [lon, lat] = point.split(',').map(Number);
return { lon, lat, elevation: 20, geoid: 0, ellipsoid: 20 };
}),
};
if (body === undefined) {
await session.send('Fetch.continueRequest', {
requestId: event.requestId,
});
return;
}
requests.push(p);
await session.send('Fetch.fulfillRequest', {
requestId: event.requestId,
responseCode: 200,
responseHeaders: [
{ name: 'Content-Type', value: 'application/json' },
{ name: 'Access-Control-Allow-Origin', value: '*' },
{ name: 'Access-Control-Expose-Headers', value: 'Data-Present' },
{ name: 'Data-Present', value: 'true' },
],
body: Buffer.from(JSON.stringify(body)).toString('base64'),
});
} catch (error) {
if (
!disposed &&
!page.isClosed() &&
!String(error.message).includes('Invalid InterceptionId')
)
page.emit('pageerror', error);
}
});
return {
requests,
async dispose() {
disposed = true;
await session.detach();
},
};
}
+57
View File
@@ -0,0 +1,57 @@
// Real /api/firms envelope; fixed source receipt and acquisition clocks.
export const FIRMS_EPOCH_MS = Date.UTC(2026, 9, 16, 12);
export const FIRMS_VALID_COUNT = 371;
export function firmsFixture() {
const fires = [];
const regions = [
[37.77, -122.42],
[30.27, -97.74],
[61.2, -149.9],
];
for (const [region, [latitude, longitude]] of regions.entries()) {
for (let row = -5; row <= 5; row++) {
for (let column = -5; column <= 5; column++) {
const index = fires.length;
fires.push({
lat: latitude + row * 0.12,
lon: longitude + column * 0.12,
frp:
region === 0 && row === 0 && column === 0 ? 999 : 5 + (index % 120),
confidence: ['low', 'nominal', 'high', 85][index % 4],
brightness: 280 + (index % 90),
daynight: index % 2 ? 'N' : 'D',
acqDate: '2026-10-16',
acqTime: String(800 + (index % 60)),
instrument: index % 4 === 3 ? 'MODIS' : 'VIIRS',
satellite: index % 4 === 3 ? 'Terra' : 'NOAA-20',
});
}
}
}
for (const [lat, lon] of [
[10, 179.7],
[10, -179.7],
[89.5, 45],
[-89.5, -135],
[0.2, 179.8],
[-0.2, -179.8],
[70, 179.5],
[-70, -179.5],
]) {
fires.push({
lat,
lon,
frp: 30,
confidence: 'high',
brightness: 320,
daynight: 'D',
acqDate: '2026-10-16',
acqTime: '930',
instrument: 'VIIRS',
satellite: 'NOAA-20',
});
}
fires.push({ lat: 'invalid', lon: 0 }, { lat: 0, lon: 'invalid' });
return { fires, fetchedAt: FIRMS_EPOCH_MS - 120000, stale: false };
}
+109
View File
@@ -0,0 +1,109 @@
// Shared 750-station directory for legacy and both-engine radio QA.
const tags = [
'news',
'talk',
'weather,emergency',
'public safety,scanner',
'aviation,atc',
'marine,maritime',
'traffic,transit',
'music,jazz',
'music,rock',
'community',
];
export const stations = Array.from({ length: 750 }, (_, index) => {
const row = Math.floor(index / 30);
const column = index % 30;
return {
id: `00000000-0000-4000-8000-${index.toString(16).padStart(12, '0')}`,
name:
index === 360
? '100.3 The River - WQRV - Meridianville/Huntsville, AL'
: index === 390
? 'La Zeta (Hermosillo) - 93.9 FM - XHHY - Uniradio - Hermosillo, Sonora'
: `QA Radio ${String(index + 1).padStart(3, '0')}`,
lat: -72 + row * 6,
lon: -174 + column * 12,
streamUrl: `https://audio.example.test/${index}.mp3`,
homepage: `https://station.example.test/${index}`,
tags: tags[index % tags.length].split(','),
languages: index === 360 ? ['Spanish'] : ['English'],
state: `Region ${row + 1}`,
country: 'United States',
countryCode: 'US',
metadataTrust: 'untrusted-community',
codec: index % 2 ? 'AAC' : 'MP3',
bitrate: 128,
};
});
export async function installRadioFixture(page) {
await page.evaluateOnNewDocument((stations) => {
localStorage.clear();
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
window.__qaRadio = {
stations,
generation: 1,
plays: [],
delay: 0,
response: 'normal',
pending: null,
};
HTMLMediaElement.prototype.play = function () {
window.qaPlayer = this;
window.__qaRadio.plays.push(this.src);
if (window.__qaRadio.delayPlay) {
window.__qaRadio.delayPlay = false;
return new Promise((resolve) => {
window.__qaRadio.releasePlay = () => {
this.dispatchEvent(new Event('playing'));
resolve();
};
});
}
queueMicrotask(() => this.dispatchEvent(new Event('playing')));
return Promise.resolve();
};
const fetch = window.fetch.bind(window);
window.fetch = async (input, init) => {
const address = String(
typeof input === 'string' || input instanceof URL ? input : input.url,
);
if (address.includes('/api/radio/stations')) {
const qa = window.__qaRadio;
qa.reads = (qa.reads || 0) + 1;
const response = {
stations: qa.response === 'empty' ? [] : qa.stations,
updatedAt: new Date().toISOString(),
stale: false,
degraded: false,
acceptedGeneration: qa.generation,
catalogInstance: 'fixture-radio-fixture',
};
const mode = qa.response;
const delay = qa.delay;
qa.delay = 0;
if (delay)
await new Promise((resolve) => {
qa.pending = resolve;
});
init?.signal?.throwIfAborted();
return new Response(
mode === 'failure' ? '{}' : JSON.stringify(response),
{
status: mode === 'failure' ? 503 : 200,
headers: { 'Content-Type': 'application/json' },
},
);
}
if (address.includes('/api/radio/click/'))
return new Response(null, { status: 204 });
if (address.includes('/api/flights'))
return new Response('{"states":[]}');
if (address.includes('/api/military')) return new Response('{"ac":[]}');
if (address.includes('/api/vessels'))
return new Response('{"connected":false,"vessels":[]}');
return fetch(input, init);
};
}, stations);
}
+100
View File
@@ -0,0 +1,100 @@
export const TRANSIT_EPOCH = 1_793_232_000_000;
const routes = ['1', 'Green-B', 'Red', 'CR-Fitchburg', 'Boat-F1', ''];
export function transitSnapshot(step = 8) {
const fetchedAt = TRANSIT_EPOCH - 135000 + step * 15000;
return {
fetchedAt,
vehicles: [
...Array.from({ length: 12 }, (_, i) => ({
id: `fixture-${i}`,
routeId: routes[i % 6],
tripId: `trip-${i}`,
lat:
42.355 +
(Math.floor(i / 4) - 1) * 0.0017 +
(i < 6 ? step * 0.00012 : 0),
lon: -71.065 + ((i % 4) - 1.5) * 0.0017,
timestamp: fetchedAt / 1000,
timestampSource: 'vehicle',
bearing: 0,
})),
{
id: 'stale',
routeId: '1',
tripId: 'old',
lat: 42.355,
lon: -71.065,
timestamp: (TRANSIT_EPOCH - 900000) / 1000,
timestampSource: 'vehicle',
},
],
};
}
export async function installTransitFixture(page) {
let step = 0,
disposed = false;
const requests = [];
const session = await page.createCDPSession();
await page.evaluateOnNewDocument((epoch) => {
Date.now = () => epoch + performance.now();
}, TRANSIT_EPOCH);
await session.send('Fetch.enable', {
patterns: ['localhost', '127.0.0.1'].flatMap((host) =>
[
'/api/transit/*',
'/api/flights*',
'/api/military*',
'/api/vessels*',
].map((route) => ({ urlPattern: `http://${host}:*${route}` })),
),
});
session.on('Fetch.requestPaused', async (event) => {
try {
const url = new URL(event.request.url);
requests.push(url.pathname);
let body;
if (url.pathname.startsWith('/api/transit/vehicles/'))
body = url.pathname.endsWith('/mbta')
? transitSnapshot(step)
: { fetchedAt: TRANSIT_EPOCH, vehicles: [] };
else if (url.pathname.startsWith('/api/transit/trail/')) {
const vehicleId = decodeURIComponent(url.pathname.split('/').at(-1));
const record = transitSnapshot().vehicles.find(
(row) => row.id === vehicleId,
);
body = {
version: 1,
feedId: 'mbta',
vehicleId,
truncated: false,
epochs: [
{ id: 1, trip: record.tripId, route: record.routeId, mode: 'bus' },
],
fixes: Array.from({ length: 9 }, (_, i) => {
const snapshot = transitSnapshot(i),
row = snapshot.vehicles.find((v) => v.id === vehicleId);
return [snapshot.fetchedAt, row.lat, row.lon, 0, 1];
}),
};
} else body = { states: [], ac: [], vessels: [], feeds: [] };
await session.send('Fetch.fulfillRequest', {
requestId: event.requestId,
responseCode: 200,
responseHeaders: [{ name: 'Content-Type', value: 'application/json' }],
body: Buffer.from(JSON.stringify(body)).toString('base64'),
});
} catch (error) {
if (!disposed && !page.isClosed()) page.emit('pageerror', error);
}
});
return {
requests,
setStep(value) {
step = value;
},
async dispose() {
disposed = true;
await session.detach();
},
};
}
@@ -181,18 +181,6 @@ export function answerMapillaryRequest({ method, url, images }) {
: []; : [];
return { ...json(200, { data }), known: true }; return { ...json(200, { data }), known: true };
} }
if (q.has('lat') && q.has('lng')) {
const at = { lat: Number(q.get('lat')), lon: Number(q.get('lng')) };
const radius = Math.min(50, Number(q.get('radius')) || 50);
const limit = Number(q.get('limit')) || 10;
// In no particular order, as the real API answers.
const data = photos
.filter((image) => metresApart(at, image) <= radius)
.reverse()
.slice(0, limit)
.map((image) => withFields(imageRecord(image), fields));
return { ...json(200, { data }), known: true };
}
if (q.has('s2')) return { ...json(200, { data: [] }), known: true }; if (q.has('s2')) return { ...json(200, { data: [] }), known: true };
return { return {
...json(400, { error: 'unsupported images query' }), ...json(400, { error: 'unsupported images query' }),
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+805
View File
@@ -0,0 +1,805 @@
{
"asset": {
"version": "1.1"
},
"geometricError": 1000,
"root": {
"transform": [
0.9908821299915158,
-0.13473160157690048,
0,
0,
0.06790921036869463,
0.4994375649707324,
0.8636842350322285,
0,
-0.11636556024261228,
-0.8558092744488276,
0.5040332748507723,
0,
-742827.4189515795,
-5463116.347553584,
3195991.10594888,
1
],
"boundingVolume": {
"box": [
0,
0,
99,
300,
0,
0,
0,
300,
0,
0,
0,
101
]
},
"geometricError": 1000,
"refine": "ADD",
"children": [
{
"boundingVolume": {
"box": [
0,
0,
-1,
300,
0,
0,
0,
300,
0,
0,
0,
1
]
},
"geometricError": 0,
"content": {
"uri": "plate.glb"
}
},
{
"boundingVolume": {
"box": [
-160,
0,
25,
50,
0,
0,
0,
60,
0,
0,
0,
25
]
},
"geometricError": 0,
"content": {
"uri": "block50.glb"
}
},
{
"boundingVolume": {
"box": [
0,
170,
100,
50,
0,
0,
0,
50,
0,
0,
0,
100
]
},
"geometricError": 0,
"content": {
"uri": "block200.glb"
}
},
{
"boundingVolume": {
"box": [
160,
0,
50,
50,
0,
0,
0,
60,
0,
0,
0,
50
]
},
"geometricError": 0,
"content": {
"uri": "roof.glb"
}
}
]
},
"extras": {
"origin": {
"lon": -97.7431,
"lat": 30.2672
},
"datum": "WGS84 ellipsoid; local ENU heights in metres",
"probes": {
"plate": {
"position": {
"x": -742838.9635173421,
"y": -5463201.251939629,
"z": 3195844.2796289246
},
"cartographic": {
"longitude": -1.7059389161060694,
"latitude": 0.5282355305977933,
"height": 0.002275014548483431
},
"lon": -97.7431,
"lat": 30.265666492107215,
"height": 0.002275014548483431
},
"block50": {
"position": {
"x": -742991.7783703903,
"y": -5463137.580961054,
"z": 3196016.307612622
},
"cartographic": {
"longitude": -1.7059679361541311,
"latitude": 0.5282622951753985,
"height": 50.00200513183283
},
"lon": -97.7447627262752,
"lat": 30.267199989444443,
"height": 50.00200513183283
},
"block200": {
"position": {
"x": -742839.1474978654,
"y": -5463202.605022429,
"z": 3196238.7389238053
},
"cartographic": {
"longitude": -1.7059389161060696,
"latitude": 0.5282890592724478,
"height": 200.0022749319483
},
"lon": -97.74310000000001,
"lat": 30.26873345924785,
"height": 200.0022749319483
},
"roof": {
"position": {
"x": -742658.8348752414,
"y": -5463212.487220922,
"z": 3196033.444743967
},
"cartographic": {
"longitude": -1.7059062687258868,
"latitude": 0.528262295126465,
"height": 84.00253773142123
},
"lon": -97.74122944290338,
"lat": 30.267199986640765,
"height": 84.00253773142123
}
},
"points": [
{
"position": {
"x": -742701.1865288558,
"y": -5463227.075459457,
"z": 3195998.897374566
},
"cartographic": {
"longitude": -1.7059135237921106,
"latitude": 0.5282559977114081,
"height": 84.00166112171651
},
"lon": -97.74164512757808,
"lat": 30.26683917133616,
"height": 84.00166112171651
},
{
"position": {
"x": -742696.6975804468,
"y": -5463231.172354563,
"z": 3196000.9135076655
},
"cartographic": {
"longitude": -1.7059126169402343,
"latitude": 0.5282559977051193,
"height": 88.00177273310982
},
"lon": -97.74159316879293,
"lat": 30.26683917097584,
"height": 88.00177273310982
},
{
"position": {
"x": -742692.2086320379,
"y": -5463235.269249668,
"z": 3196002.929640765
},
"cartographic": {
"longitude": -1.7059117100894945,
"latitude": 0.528255997698471,
"height": 92.00188826158782
},
"lon": -97.7415412100729,
"lat": 30.266839170594917,
"height": 92.00188826158782
},
{
"position": {
"x": -742687.7196836289,
"y": -5463239.3661447745,
"z": 3196004.945773864
},
"cartographic": {
"longitude": -1.705910803239891,
"latitude": 0.5282559976914627,
"height": 96.00200770555026
},
"lon": -97.74148925141796,
"lat": 30.26683917019337,
"height": 96.00200770555026
},
{
"position": {
"x": -742683.2307352199,
"y": -5463243.46303988,
"z": 3196006.9619069635
},
"cartographic": {
"longitude": -1.7059098963914245,
"latitude": 0.5282559976840947,
"height": 100.00213106686036
},
"lon": -97.74143729282817,
"lat": 30.266839169771217,
"height": 100.00213106686036
},
{
"position": {
"x": -742677.810862329,
"y": -5463240.71346079,
"z": 3196004.945773864
},
"cartographic": {
"longitude": -1.7059089895065904,
"latitude": 0.5282559976684348,
"height": 96.00225834565195
},
"lon": -97.74138533215466,
"lat": 30.266839168873968,
"height": 96.00225834565195
},
{
"position": {
"x": -742672.390989438,
"y": -5463237.9638817,
"z": 3196002.929640765
},
"cartographic": {
"longitude": -1.7059080826206199,
"latitude": 0.5282559976524149,
"height": 92.002389541416
},
"lon": -97.74133337141606,
"lat": 30.2668391679561,
"height": 92.002389541416
},
{
"position": {
"x": -742666.9711165471,
"y": -5463235.21430261,
"z": 3196000.9135076655
},
"cartographic": {
"longitude": -1.705907175733513,
"latitude": 0.5282559976360354,
"height": 88.0025246520816
},
"lon": -97.74128141061233,
"lat": 30.26683916701762,
"height": 88.0025246520816
},
{
"position": {
"x": -742699.8283446485,
"y": -5463217.086708157,
"z": 3196016.1710592667
},
"cartographic": {
"longitude": -1.705913523745684,
"latitude": 0.5282591464650368,
"height": 84.0015666583481
},
"lon": -97.74164512491804,
"lat": 30.26701958162981,
"height": 84.0015666583481
},
{
"position": {
"x": -742695.3393962395,
"y": -5463221.183603263,
"z": 3196018.187192366
},
"cartographic": {
"longitude": -1.7059126168921497,
"latitude": 0.5282591464567652,
"height": 88.00167827023611
},
"lon": -97.74159316603789,
"lat": 30.267019581155882,
"height": 88.00167827023611
},
{
"position": {
"x": -742690.8504478304,
"y": -5463225.280498369,
"z": 3196020.2033254653
},
"cartographic": {
"longitude": -1.7059117100397518,
"latitude": 0.5282591464481338,
"height": 92.0017937984389
},
"lon": -97.74154120722285,
"lat": 30.267019580661337,
"height": 92.0017937984389
},
{
"position": {
"x": -742686.3614994214,
"y": -5463229.377393475,
"z": 3196022.2194585647
},
"cartographic": {
"longitude": -1.7059108031884906,
"latitude": 0.5282591464391424,
"height": 96.00191324267489
},
"lon": -97.74148924847293,
"lat": 30.26701958014617,
"height": 96.00191324267489
},
{
"position": {
"x": -742681.8725510124,
"y": -5463233.47428858,
"z": 3196024.2355916644
},
"cartographic": {
"longitude": -1.7059098963383656,
"latitude": 0.5282591464297914,
"height": 100.00203660347505
},
"lon": -97.74143728978812,
"lat": 30.2670195796104,
"height": 100.00203660347505
},
{
"position": {
"x": -742676.4526781215,
"y": -5463230.72470949,
"z": 3196022.2194585647
},
"cartographic": {
"longitude": -1.7059089894518735,
"latitude": 0.5282591464161143,
"height": 96.00216388302483
},
"lon": -97.74138532901962,
"lat": 30.267019578826755,
"height": 96.00216388302483
},
{
"position": {
"x": -742671.0328052306,
"y": -5463227.975130401,
"z": 3196020.2033254653
},
"cartographic": {
"longitude": -1.705908082564245,
"latitude": 0.5282591464020774,
"height": 92.00229507980886
},
"lon": -97.74133336818602,
"lat": 30.267019578022502,
"height": 92.00229507980886
},
{
"position": {
"x": -742665.6129323397,
"y": -5463225.225551311,
"z": 3196018.187192366
},
"cartographic": {
"longitude": -1.7059071756754798,
"latitude": 0.5282591463876805,
"height": 88.00243019067032
},
"lon": -97.74128140728727,
"lat": 30.26701957719762,
"height": 88.00243019067032
},
{
"position": {
"x": -742698.470160441,
"y": -5463207.097956859,
"z": 3196033.444743967
},
"cartographic": {
"longitude": -1.705913523699257,
"latitude": 0.5282622952185787,
"height": 84.00153517112447
},
"lon": -97.74164512225796,
"lat": 30.26719999191849,
"height": 84.00153517112447
},
{
"position": {
"x": -742693.981212032,
"y": -5463211.194851964,
"z": 3196035.460877067
},
"cartographic": {
"longitude": -1.7059126168440648,
"latitude": 0.528262295208324,
"height": 88.00164678309102
},
"lon": -97.74159316328283,
"lat": 30.267199991330937,
"height": 88.00164678309102
},
{
"position": {
"x": -742689.492263623,
"y": -5463215.29174707,
"z": 3196037.4770101663
},
"cartographic": {
"longitude": -1.705911709990009,
"latitude": 0.5282622951977096,
"height": 92.0017623126523
},
"lon": -97.7415412043728,
"lat": 30.267199990722776,
"height": 92.0017623126523
},
{
"position": {
"x": -742685.0033152141,
"y": -5463219.388642175,
"z": 3196039.4931432656
},
"cartographic": {
"longitude": -1.7059108031370898,
"latitude": 0.5282622951867355,
"height": 96.00188175721505
},
"lon": -97.74148924552787,
"lat": 30.267199990094007,
"height": 96.00188175721505
},
{
"position": {
"x": -742680.5143668051,
"y": -5463223.485537281,
"z": 3196041.509276365
},
"cartographic": {
"longitude": -1.705909896285307,
"latitude": 0.5282622951754012,
"height": 100.00200511623785
},
"lon": -97.74143728674808,
"lat": 30.267199989444602,
"height": 100.00200511623785
},
{
"position": {
"x": -742675.0944939142,
"y": -5463220.735958191,
"z": 3196039.4931432656
},
"cartographic": {
"longitude": -1.7059089893971566,
"latitude": 0.5282622951637073,
"height": 96.0021323949121
},
"lon": -97.74138532588458,
"lat": 30.267199988774586,
"height": 96.0021323949121
},
{
"position": {
"x": -742669.6746210232,
"y": -5463217.986379101,
"z": 3196037.4770101663
},
"cartographic": {
"longitude": -1.7059080825078698,
"latitude": 0.5282622951516531,
"height": 92.0022635916176
},
"lon": -97.74133336495596,
"lat": 30.267199988083934,
"height": 92.0022635916176
},
{
"position": {
"x": -742664.2547481323,
"y": -5463215.236800011,
"z": 3196035.460877067
},
"cartographic": {
"longitude": -1.7059071756174464,
"latitude": 0.5282622951392388,
"height": 88.00239870366701
},
"lon": -97.74128140396222,
"lat": 30.26719998737265,
"height": 88.00239870366701
},
{
"position": {
"x": -742697.1119762337,
"y": -5463197.109205559,
"z": 3196050.718428668
},
"cartographic": {
"longitude": -1.7059135236528302,
"latitude": 0.5282654439720338,
"height": 84.00156665806156
},
"lon": -97.7416451195979,
"lat": 30.267380402202193,
"height": 84.00156665806156
},
{
"position": {
"x": -742692.6230278247,
"y": -5463201.206100664,
"z": 3196052.7345617674
},
"cartographic": {
"longitude": -1.70591261679598,
"latitude": 0.5282654439597962,
"height": 88.00167827087749
},
"lon": -97.74159316052776,
"lat": 30.267380401501033,
"height": 88.00167827087749
},
{
"position": {
"x": -742688.1340794157,
"y": -5463205.30299577,
"z": 3196054.7506948668
},
"cartographic": {
"longitude": -1.705911709940266,
"latitude": 0.5282654439471988,
"height": 92.00179379845281
},
"lon": -97.74154120152274,
"lat": 30.26738040077925,
"height": 92.00179379845281
},
{
"position": {
"x": -742683.6451310067,
"y": -5463209.399890875,
"z": 3196056.766827966
},
"cartographic": {
"longitude": -1.7059108030856887,
"latitude": 0.5282654439342416,
"height": 96.00191324280611
},
"lon": -97.74148924258282,
"lat": 30.267380400036856,
"height": 96.00191324280611
},
{
"position": {
"x": -742679.1561825977,
"y": -5463213.496785982,
"z": 3196058.7829610654
},
"cartographic": {
"longitude": -1.7059098962322479,
"latitude": 0.5282654439209244,
"height": 100.00203660378942
},
"lon": -97.74143728370802,
"lat": 30.267380399273843,
"height": 100.00203660378942
},
{
"position": {
"x": -742673.7363097068,
"y": -5463210.747206892,
"z": 3196056.766827966
},
"cartographic": {
"longitude": -1.7059089893424393,
"latitude": 0.5282654439112129,
"height": 96.00216388304962
},
"lon": -97.74138532274951,
"lat": 30.26738039871741,
"height": 96.00216388304962
},
{
"position": {
"x": -742668.3164368159,
"y": -5463207.997627802,
"z": 3196054.7506948668
},
"cartographic": {
"longitude": -1.7059080824514945,
"latitude": 0.5282654439011418,
"height": 92.00229507962341
},
"lon": -97.74133336172589,
"lat": 30.267380398140382,
"height": 92.00229507962341
},
{
"position": {
"x": -742662.896563925,
"y": -5463205.248048712,
"z": 3196052.7345617674
},
"cartographic": {
"longitude": -1.7059071755594128,
"latitude": 0.5282654438907107,
"height": 88.00243019027465
},
"lon": -97.74128140063713,
"lat": 30.267380397542723,
"height": 88.00243019027465
},
{
"position": {
"x": -742695.7537920263,
"y": -5463187.120454259,
"z": 3196067.9921133686
},
"cartographic": {
"longitude": -1.7059135236064031,
"latitude": 0.528268592725402,
"height": 84.00166112141882
},
"lon": -97.74164511693783,
"lat": 30.26756081248092,
"height": 84.00166112141882
},
{
"position": {
"x": -742691.2648436173,
"y": -5463191.217349365,
"z": 3196070.008246468
},
"cartographic": {
"longitude": -1.7059126167478948,
"latitude": 0.5282685927111814,
"height": 88.00177273414431
},
"lon": -97.74159315777268,
"lat": 30.26756081166614,
"height": 88.00177273414431
},
{
"position": {
"x": -742686.7758952084,
"y": -5463195.314244471,
"z": 3196072.0243795672
},
"cartographic": {
"longitude": -1.705911709890523,
"latitude": 0.5282685926966009,
"height": 92.0018882615363
},
"lon": -97.74154119867266,
"lat": 30.267560810830734,
"height": 92.0018882615363
},
{
"position": {
"x": -742682.2869467994,
"y": -5463199.411139577,
"z": 3196074.040512667
},
"cartographic": {
"longitude": -1.7059108030342875,
"latitude": 0.5282685926816608,
"height": 96.0020077065962
},
"lon": -97.74148923963774,
"lat": 30.267560809974732,
"height": 96.0020077065962
},
{
"position": {
"x": -742677.7979983904,
"y": -5463203.508034682,
"z": 3196076.0566457664
},
"cartographic": {
"longitude": -1.7059098961791888,
"latitude": 0.5282685926663608,
"height": 100.00213106635087
},
"lon": -97.74143728066795,
"lat": 30.267560809098107,
"height": 100.00213106635087
},
{
"position": {
"x": -742672.3781254995,
"y": -5463200.758455592,
"z": 3196074.040512667
},
"cartographic": {
"longitude": -1.705908989287722,
"latitude": 0.5282685926586324,
"height": 96.00225834543971
},
"lon": -97.74138531961444,
"lat": 30.267560808655297,
"height": 96.00225834543971
},
{
"position": {
"x": -742666.9582526085,
"y": -5463198.0088765025,
"z": 3196072.0243795672
},
"cartographic": {
"longitude": -1.705908082395119,
"latitude": 0.5282685926505436,
"height": 92.00238954127981
},
"lon": -97.74133335849581,
"lat": 30.267560808191845,
"height": 92.00238954127981
},
{
"position": {
"x": -742661.5383797176,
"y": -5463195.259297413,
"z": 3196070.008246468
},
"cartographic": {
"longitude": -1.7059071755013793,
"latitude": 0.5282685926420952,
"height": 88.0025246543197
},
"lon": -97.74128139731205,
"lat": 30.267560807707792,
"height": 88.0025246543197
}
]
}
}
+261
View File
@@ -0,0 +1,261 @@
// Wildlife Sightings without the network: the app's own iNaturalist and
// Acartia proxies (server/providers) answer the preview's /api routes in
// this process, over upstream feeds faked here. Four sightings over the
// Salish Sea: three iNaturalist observations (one with an obscured
// location, drawn as its privacy cell) and one Acartia orca report.
import { installInatProxy } from '../../server/providers/inaturalist/proxy.fixture.mjs';
import { syntheticGrid } from '../../server/providers/inaturalist/grid.fixture.mjs';
import { acartiaProxy } from '../../server/providers/acartia.js';
/** The view the scenario frames (straight down). */
export const WILDLIFE_VIEW = Object.freeze({
lat: 48.4,
lon: -123.1,
alt: 200000,
});
const HOUR_MS = 3_600_000;
// iNaturalist iconic taxon ids, as the UTFGrid carries them.
const ICONIC_IDS = { Aves: 3, Mammalia: 40151, Plantae: 47126 };
/**
* The sightings as the browser draws them (`lat`/`lon` after the proxy's
* projection: an obscured record sits at the centre of its 0.2° cell, an
* Acartia report is rounded to 0.01°), with what the upstream feeds send.
*/
export const WILDLIFE_SIGHTINGS = Object.freeze([
{
id: 'inat:910001',
name: 'Bald Eagle',
scientific: 'Haliaeetus leucocephalus',
iconic: 'Aves',
raw: [48.4, -123.1],
lat: 48.4,
lon: -123.1,
},
{
id: 'inat:910002',
name: "Douglas's Squirrel",
scientific: 'Tamiasciurus douglasii',
iconic: 'Mammalia',
raw: [48.46, -122.92],
lat: 48.46,
lon: -122.92,
},
{
id: 'inat:910003',
name: 'Western Redcedar',
scientific: 'Thuja plicata',
iconic: 'Plantae',
obscured: true,
raw: [48.31, -123.29],
lat: 48.3,
lon: -123.3,
cell: { south: 48.2, west: -123.4, north: 48.4, east: -123.2 },
},
{
id: 'acartia:qa-orca-1',
name: 'Orca',
origin: 'acartia',
raw: [48.5234, -123.2167],
lat: 48.52,
lon: -123.22,
},
]);
const inat = WILDLIFE_SIGHTINGS.filter((s) => s.id.startsWith('inat:'));
const acartia = WILDLIFE_SIGHTINGS.filter((s) => s.origin === 'acartia');
/** An API v2 observation, with the fields the proxy asks for. */
function observation(sighting, nowMs) {
const id = Number(sighting.id.slice(5));
const [lat, lon] = sighting.raw;
const time = new Date(nowMs - 3 * HOUR_MS);
return {
id,
uri: `https://www.inaturalist.org/observations/${id}`,
location: `${lat},${lon}`,
geojson: { type: 'Point', coordinates: [lon, lat] },
geoprivacy: sighting.obscured ? 'obscured' : null,
taxon_geoprivacy: null,
obscured: sighting.obscured === true,
quality_grade: 'research',
observed_on: time.toISOString().slice(0, 10),
time_observed_at: time.toISOString(),
faves_count: 1,
num_identification_agreements: 2,
// The feed asks for observations with photos; these carry no open
// licence, so no thumbnail is fetched.
photos: [
{
url: `https://inaturalist-open-data.s3.amazonaws.com/photos/${id}/square.jpeg`,
attribution: '(c) QA observer, all rights reserved',
license_code: null,
},
],
taxon: {
name: sighting.scientific,
preferred_common_name: sighting.name,
iconic_taxon_name: sighting.iconic,
threatened: false,
},
};
}
/** Web Mercator tile coordinates of a point at zoom z (fractional). */
function tileOf(lat, lon, z) {
const count = 2 ** z;
const sin = Math.sin((lat * Math.PI) / 180);
return {
x: ((lon + 180) / 360) * count,
y: (0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI)) * count,
};
}
/**
* The faked upstream feeds: iNaturalist observations (filtered by the
* request's box), its UTFGrid density tiles, and the Acartia snapshot.
* `calls` counts what each upstream was asked.
*/
export function wildlifeUpstream({ now = () => Date.now() } = {}) {
const calls = { observations: 0, grid: 0, acartia: 0 };
async function fetchImpl(href) {
const url = new URL(href);
if (url.host === 'acartia.io') {
calls.acartia++;
const created = new Date(now() - 2 * HOUR_MS)
.toISOString()
.slice(0, 19)
.replace('T', ' ');
return Response.json(
acartia.map((s) => ({
entry_id: s.id.slice(8),
latitude: s.raw[0],
longitude: s.raw[1],
created,
type: s.name,
no_sighted: '3',
data_source_name: 'QA Whale Network',
data_source_entity: 'Community reports',
trusted: true,
})),
);
}
if (url.host !== 'api.inaturalist.org')
throw Error('unexpected upstream ' + url.host);
const grid = /^\/v1\/grid\/(\d+)\/(\d+)\/(\d+)\.grid\.json$/.exec(
url.pathname,
);
if (grid) {
calls.grid++;
const [z, x, y] = grid.slice(1).map(Number);
const cells = [];
for (const s of inat) {
const at = tileOf(s.raw[0], s.raw[1], z);
if (Math.floor(at.x) !== x || Math.floor(at.y) !== y) continue;
cells.push({
row: Math.min(63, Math.floor((at.y - y) * 64)),
column: Math.min(63, Math.floor((at.x - x) * 64)),
count: 4,
iconic: ICONIC_IDS[s.iconic],
});
}
return Response.json(syntheticGrid({ z, x, y, cells }));
}
if (url.pathname === '/v2/observations') {
calls.observations++;
const box = ['swlat', 'swlng', 'nelat', 'nelng'].map((key) =>
url.searchParams.has(key) ? Number(url.searchParams.get(key)) : null,
);
const inside = ({ raw: [lat, lon] }) =>
box[0] === null ||
(lat >= box[0] && lat <= box[2] && lon >= box[1] && lon <= box[3]);
const results = inat.filter(inside).map((s) => observation(s, now()));
return Response.json({
total_results: results.length,
page: 1,
per_page: 200,
results,
});
}
throw Error('unexpected upstream path ' + url.pathname);
}
return { fetchImpl, calls };
}
/** Mount an /api/acartia proxy over synthetic HTTP objects. */
function installAcartiaProxy(options) {
let handler;
acartiaProxy({ warn() {}, ...options }).configurePreviewServer({
middlewares: {
use(mount, callback) {
if (mount !== '/api/acartia') throw Error('acartia mount ' + mount);
handler = callback;
},
},
});
return async (url, { headers = {} } = {}) => {
const res = {
headers: {},
writeHead(status, values) {
this.status = status;
Object.assign(this.headers, values);
},
setHeader(key, value) {
this.headers[key] = value;
},
end(value) {
this.status ??= this.statusCode;
this.body = JSON.parse(value);
},
};
await handler(
{ url, method: 'GET', headers, socket: { remoteAddress: '127.0.0.1' } },
res,
);
return res;
};
}
/**
* Strict-harness routes for the preview's wildlife API: each request is
* answered by the app's own proxy in this process (it never reaches the
* preview, so the preview never calls the real providers). `asked` counts
* the browser's requests per route.
*/
export function wildlifeRoutes(origin, options = {}) {
const upstream = wildlifeUpstream(options);
const proxies = {
'/api/inaturalist': installInatProxy('/observations', {
fetchImpl: upstream.fetchImpl,
now: options.now ?? (() => Date.now()),
}),
'/api/acartia': installAcartiaProxy({
fetchImpl: upstream.fetchImpl,
now: options.now ?? (() => Date.now()),
}),
};
const asked = { observations: 0, grid: 0, acartia: 0 };
const route = (mount) => ({
pattern: `${origin}${mount}/*`,
async respond(request) {
const url = new URL(request.url);
const path = url.pathname.slice(mount.length);
asked[mount === '/api/acartia' ? 'acartia' : path.slice(1)]++;
const headers = { host: url.host };
for (const [name, value] of Object.entries(request.headers ?? {}))
headers[name.toLowerCase()] = value;
const res = await proxies[mount](path + url.search, { headers });
return {
status: res.status,
contentType: 'application/json',
body: JSON.stringify(res.body),
};
},
});
return {
routes: [route('/api/inaturalist'), route('/api/acartia')],
asked,
upstream: upstream.calls,
};
}
+369 -34
View File
@@ -1,4 +1,7 @@
[ [
"src/data/sceneServices.js",
"src/data/sceneServices.test.mjs",
"src/data/trackingGesture.js",
".prettierrc.json", ".prettierrc.json",
"package.json", "package.json",
"scripts/format-scope.json", "scripts/format-scope.json",
@@ -56,11 +59,11 @@
"src/app/application.test.mjs", "src/app/application.test.mjs",
"src/app/mapLifecycle.test.mjs", "src/app/mapLifecycle.test.mjs",
"src/app/startupCamera.test.mjs", "src/app/startupCamera.test.mjs",
"src/app/viewer.js", "src/globe/cesium/viewer.js",
"src/app/zoomOutGuard.test.mjs", "src/globe/cesium/zoomOutGuard.test.mjs",
"src/app/controls.test.mjs", "src/app/controls.test.mjs",
"scripts/qa-zoom-out-guard.mjs", "scripts/qa-zoom-out-guard.mjs",
"src/app/atmosphereCompat.test.mjs", "src/globe/cesium/atmosphereCompat.test.mjs",
"src/standalone/application.js", "src/standalone/application.js",
"src/standalone/controls.js", "src/standalone/controls.js",
"src/standalone/data.js", "src/standalone/data.js",
@@ -377,8 +380,10 @@
"src/layers/military/index.js", "src/layers/military/index.js",
"src/layers/aircraft/index.js", "src/layers/aircraft/index.js",
"src/layers/aircraft/classification.js", "src/layers/aircraft/classification.js",
"src/layers/aircraft/tracking.js",
"src/layers/military/ownership.test.mjs", "src/layers/military/ownership.test.mjs",
"src/layers/aircraft/classification.test.mjs", "src/layers/aircraft/classification.test.mjs",
"src/layers/aircraft/tracking.test.mjs",
"src/data/aisLiveVessels.js", "src/data/aisLiveVessels.js",
"src/layers/vessels/cards.js", "src/layers/vessels/cards.js",
"src/layers/vessels/evidence.js", "src/layers/vessels/evidence.js",
@@ -484,17 +489,14 @@
"src/layers/launches/source.test.mjs", "src/layers/launches/source.test.mjs",
"src/layers/launches/ingestion.test.mjs", "src/layers/launches/ingestion.test.mjs",
"src/layers/launches/failedEntry.test.mjs", "src/layers/launches/failedEntry.test.mjs",
"src/layers/installations/lifecycle.js",
"src/layers/installations/viewport.js", "src/layers/installations/viewport.js",
"src/layers/installations/controls.js", "src/layers/installations/controls.js",
"src/layers/installations/source.js", "src/layers/installations/source.js",
"src/layers/installations/state.js", "src/layers/installations/state.js",
"src/layers/installations/policy.js", "src/layers/installations/policy.js",
"src/layers/installations/rendering.js",
"src/layers/installations/ingestion.js", "src/layers/installations/ingestion.js",
"src/layers/installations/index.js", "src/layers/installations/index.js",
"src/layers/installations/model.js", "src/layers/installations/model.js",
"src/layers/installations/selection.js",
"src/layers/awareness/focus.js", "src/layers/awareness/focus.js",
"src/layers/awareness/lifecycle.js", "src/layers/awareness/lifecycle.js",
"src/layers/awareness/dependencies.js", "src/layers/awareness/dependencies.js",
@@ -530,20 +532,17 @@
"src/layers/traffic/model.js", "src/layers/traffic/model.js",
"src/layers/traffic/timing.js", "src/layers/traffic/timing.js",
"src/layers/traffic/style.js", "src/layers/traffic/style.js",
"src/layers/bikeshare/lifecycle.js",
"src/layers/bikeshare/viewport.js", "src/layers/bikeshare/viewport.js",
"src/layers/bikeshare/registry.js", "src/layers/bikeshare/registry.js",
"src/layers/bikeshare/controls.js", "src/layers/bikeshare/controls.js",
"src/layers/bikeshare/source.js", "src/layers/bikeshare/source.js",
"src/layers/bikeshare/state.js", "src/layers/bikeshare/state.js",
"src/layers/bikeshare/policy.js", "src/layers/bikeshare/policy.js",
"src/layers/bikeshare/rendering.js",
"src/layers/bikeshare/testing.js", "src/layers/bikeshare/testing.js",
"src/layers/bikeshare/ingestion.js", "src/layers/bikeshare/ingestion.js",
"src/layers/bikeshare/queries.js", "src/layers/bikeshare/queries.js",
"src/layers/bikeshare/index.js", "src/layers/bikeshare/index.js",
"src/layers/bikeshare/model.js", "src/layers/bikeshare/model.js",
"src/layers/bikeshare/selection.js",
"src/data/traffic.js", "src/data/traffic.js",
"src/data/bikeshare.js", "src/data/bikeshare.js",
"src/layers/traffic/source.test.mjs", "src/layers/traffic/source.test.mjs",
@@ -555,12 +554,6 @@
"src/ui/frameRateMonitor.js", "src/ui/frameRateMonitor.js",
"src/ui/frameRateMonitor.test.mjs", "src/ui/frameRateMonitor.test.mjs",
"src/ui/frameRateView.test.mjs", "src/ui/frameRateView.test.mjs",
"src/ui/renderFailure.test.mjs",
"src/ui/renderFailureView.test.mjs",
"src/ui/renderFailureLoopErrors.test.mjs",
"src/ui/renderFailureEventRetry.test.mjs",
"src/app/renderFailureInlineRetry.test.mjs",
"src/ui/renderFailureSecondLoop.test.mjs",
"src/layers/cctv/lifecycle.js", "src/layers/cctv/lifecycle.js",
"src/layers/cctv/controls.js", "src/layers/cctv/controls.js",
"src/layers/cctv/geometry.js", "src/layers/cctv/geometry.js",
@@ -611,9 +604,9 @@
"src/layers/alpr/index.js", "src/layers/alpr/index.js",
"src/layers/alpr/model.js", "src/layers/alpr/model.js",
"src/layers/alpr/policy.js", "src/layers/alpr/policy.js",
"src/layers/alpr/presentation.js",
"src/layers/alpr/visuals.js", "src/layers/alpr/visuals.js",
"src/layers/alpr/overlay.js", "src/layers/alpr/overlay.js",
"src/layers/alpr/presentation.js",
"src/layers/alpr/overlay.test.mjs", "src/layers/alpr/overlay.test.mjs",
"src/layers/alpr/source.js", "src/layers/alpr/source.js",
"src/layers/alpr/source.test.mjs", "src/layers/alpr/source.test.mjs",
@@ -644,13 +637,8 @@
"src/layers/submarineCables/bundledSource.js", "src/layers/submarineCables/bundledSource.js",
"src/layers/submarineCables/geometry.js", "src/layers/submarineCables/geometry.js",
"src/layers/submarineCables/index.js", "src/layers/submarineCables/index.js",
"src/layers/submarineCables/ingestion.js",
"src/layers/submarineCables/interaction.js",
"src/layers/submarineCables/lifecycle.js",
"src/layers/submarineCables/overlay.js", "src/layers/submarineCables/overlay.js",
"src/layers/submarineCables/policy.js", "src/layers/submarineCables/policy.js",
"src/layers/submarineCables/rendering.js",
"src/layers/submarineCables/state.js",
"src/layers/submarineCables/surface.js", "src/layers/submarineCables/surface.js",
"src/data/telegeographySubmarineCables.js", "src/data/telegeographySubmarineCables.js",
"src/data/telegeographySubmarineCables.test.mjs", "src/data/telegeographySubmarineCables.test.mjs",
@@ -666,9 +654,9 @@
"src/maps/credits.js", "src/maps/credits.js",
"src/maps/defaultSources.js", "src/maps/defaultSources.js",
"src/maps/google3d.js", "src/maps/google3d.js",
"src/maps/imagery.js", "src/globe/cesium/mapImagery.js",
"src/maps/registry.js", "src/maps/registry.js",
"src/maps/terrain.js", "src/globe/cesium/terrain.js",
"src/maps/terrainRetry.js", "src/maps/terrainRetry.js",
"src/maps/terrainRetry.test.mjs", "src/maps/terrainRetry.test.mjs",
"src/mapStackController.js", "src/mapStackController.js",
@@ -833,8 +821,6 @@
"src/data/transitIcons.js", "src/data/transitIcons.js",
"src/data/cameraSensitivity.js", "src/data/cameraSensitivity.js",
"src/data/contactPlayback.js", "src/data/contactPlayback.js",
"src/data/contactTrailRenderer.js",
"src/data/contactTrailRenderer.test.mjs",
"src/layers/transit/trails.js", "src/layers/transit/trails.js",
"src/data/contactPlayback.test.mjs", "src/data/contactPlayback.test.mjs",
"src/layers/transit/movement.js", "src/layers/transit/movement.js",
@@ -861,7 +847,6 @@
"src/layers/weather/index.js", "src/layers/weather/index.js",
"src/layers/weather/clock.js", "src/layers/weather/clock.js",
"src/layers/weather/clock.test.mjs", "src/layers/weather/clock.test.mjs",
"src/layers/weather/rendering.js",
"src/layers/weather/shellRendering.js", "src/layers/weather/shellRendering.js",
"src/layers/weather/shellRendering.test.mjs", "src/layers/weather/shellRendering.test.mjs",
"src/layers/weather/infraredAlpha.js", "src/layers/weather/infraredAlpha.js",
@@ -870,7 +855,6 @@
"src/layers/weather/infraredImage.test.mjs", "src/layers/weather/infraredImage.test.mjs",
"src/layers/weather/source.js", "src/layers/weather/source.js",
"src/layers/weather/weather.test.mjs", "src/layers/weather/weather.test.mjs",
"src/layers/weather/rasterTiles.test.mjs",
"src/layers/wind/rendering.test.mjs", "src/layers/wind/rendering.test.mjs",
"src/layers/weather/source.test.mjs", "src/layers/weather/source.test.mjs",
"src/data/weatherProxy.test.mjs", "src/data/weatherProxy.test.mjs",
@@ -896,8 +880,6 @@
"src/ui/railCards.test.mjs", "src/ui/railCards.test.mjs",
"src/ui/railTimeline.js", "src/ui/railTimeline.js",
"src/ui/railTimeline.test.mjs", "src/ui/railTimeline.test.mjs",
"src/layers/weather/imageryOrder.js",
"src/layers/weather/imageryOrder.test.mjs",
"server/providers/cyclones.js", "server/providers/cyclones.js",
"server/providers/firePerimeters.js", "server/providers/firePerimeters.js",
"server/providers/droneAirspace.js", "server/providers/droneAirspace.js",
@@ -930,7 +912,6 @@
"src/layers/cyclones/rendering.js", "src/layers/cyclones/rendering.js",
"src/layers/cyclones/labels.js", "src/layers/cyclones/labels.js",
"src/layers/cyclones/source.js", "src/layers/cyclones/source.js",
"src/layers/cyclones/rendering.test.mjs",
"src/layers/cyclones/labels.test.mjs", "src/layers/cyclones/labels.test.mjs",
"src/layers/cyclones/index.test.mjs", "src/layers/cyclones/index.test.mjs",
"src/layers/cyclones/source.test.mjs", "src/layers/cyclones/source.test.mjs",
@@ -939,7 +920,6 @@
"scripts/qa-weather-journey.mjs", "scripts/qa-weather-journey.mjs",
"scripts/qa-weather-swap.mjs", "scripts/qa-weather-swap.mjs",
"scripts/qa-weather-teardown.mjs", "scripts/qa-weather-teardown.mjs",
"src/layers/weather/imageryHost.test.mjs",
"src/layers/weather/display.test.mjs", "src/layers/weather/display.test.mjs",
"src/sdr/adsbDecoder.test.mjs", "src/sdr/adsbDecoder.test.mjs",
"src/sdr/controller.test.mjs", "src/sdr/controller.test.mjs",
@@ -964,8 +944,6 @@
"src/data/modelEligibility.test.mjs", "src/data/modelEligibility.test.mjs",
"src/layers/recentImagery/model.test.mjs", "src/layers/recentImagery/model.test.mjs",
"src/layers/recentImagery/catalog.test.mjs", "src/layers/recentImagery/catalog.test.mjs",
"src/maps/imageryHost.test.mjs",
"src/layers/recentImagery/rendering.test.mjs",
"src/layers/recentImagery/thumbnails.test.mjs", "src/layers/recentImagery/thumbnails.test.mjs",
"src/layers/recentImagery/index.test.mjs", "src/layers/recentImagery/index.test.mjs",
"src/ui/imageryBoxTool.test.mjs", "src/ui/imageryBoxTool.test.mjs",
@@ -1084,6 +1062,7 @@
"src/layers/traffic/roadModes.test.mjs", "src/layers/traffic/roadModes.test.mjs",
"scripts/qa-traffic-motion.mjs", "scripts/qa-traffic-motion.mjs",
"src/layers/traffic/motion.test.mjs", "src/layers/traffic/motion.test.mjs",
"src/layers/traffic/handoff.test.mjs",
"src/layers/traffic/lineRebuild.test.mjs", "src/layers/traffic/lineRebuild.test.mjs",
"scripts/qa-journey-recorder.mjs", "scripts/qa-journey-recorder.mjs",
"scripts/qa-alpr-journey.mjs", "scripts/qa-alpr-journey.mjs",
@@ -1133,7 +1112,7 @@
"scripts/build-bright-stars.mjs", "scripts/build-bright-stars.mjs",
"scripts/build-milky-way.mjs", "scripts/build-milky-way.mjs",
"src/sky/stars.test.mjs", "src/sky/stars.test.mjs",
"src/sky/starrySky.test.mjs", "src/globe/cesium/starrySky.test.mjs",
"src/tooling/skyAssets.test.mjs", "src/tooling/skyAssets.test.mjs",
"src/tooling/skyAssets.paintOut.test.mjs", "src/tooling/skyAssets.paintOut.test.mjs",
"src/tooling/responseCompression.test.mjs", "src/tooling/responseCompression.test.mjs",
@@ -1234,6 +1213,198 @@
"src/data/headingDegrees.test.mjs", "src/data/headingDegrees.test.mjs",
"src/data/distanceFormat.test.mjs", "src/data/distanceFormat.test.mjs",
"src/sources/adsbFields.test.mjs", "src/sources/adsbFields.test.mjs",
"src/layers/flights/render/display.js",
"src/layers/aircraft/render/display.test.mjs",
"src/layers/aircraft/render/display.js",
"src/layers/flights/renderer.test.mjs",
"src/layers/military/renderer.test.mjs",
"src/layers/military/policy.test.mjs",
"src/testSupport/aircraftRecordingRenderer.mjs",
"src/testSupport/cesiumMoversEngine.mjs",
"src/testSupport/cockpitEngine.mjs",
"src/layers/aircraft/render/contract.js",
"src/layers/shared/color.test.mjs",
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js",
"src/layers/shared/viewRectangle.js",
"src/layers/military/render/display.js",
"src/layers/aircraft/qaDrawn.js",
"src/app/navaraLayers.test.mjs",
"src/layers/vessels/icons.js",
"src/layers/vessels/render/contract.js",
"src/layers/vessels/renderer.test.mjs",
"src/layers/vessels/testFixtures.mjs",
"src/layers/shared/render/engineView.js",
"src/layers/shared/render/moverBank.js",
"src/layers/shared/render/imageAtlas.js",
"src/layers/bikeshare/presentation.js",
"src/layers/bikeshare/stationPoints.js",
"src/layers/bikeshare/presentation.test.mjs",
"src/layers/bikeshare/selectionModel.js",
"src/layers/cctv/projectionScheduling.test.mjs",
"src/layers/cctv/lifecycle.test.mjs",
"src/layers/cctv/projectionMedia.test.mjs",
"src/layers/cctv/ingestion.test.mjs",
"scripts/fixtures/layers/alpr.mjs",
"scripts/fixtures/layers/cctv.mjs",
"src/layers/radio/screenClusters.js",
"src/layers/radio/screenClusters.test.mjs",
"src/globe/cesium/mapScene.js",
"src/globe/cesium/mapScene.test.mjs",
"src/globe/contract.test.mjs",
"src/globe/testing/fakeEngine.test.mjs",
"src/globe/cesium/events.test.mjs",
"src/globe/cesium/camera.test.mjs",
"src/globe/cesium/projector.test.mjs",
"src/globe/cesium/picker.test.mjs",
"src/globe/cesium/frames.test.mjs",
"src/globe/cesium/render.test.mjs",
"src/globe/cesium/surface.test.mjs",
"src/globe/cesium/input.test.mjs",
"src/globe/cesium/credits.test.mjs",
"src/globe/cesium/maps.test.mjs",
"src/globe/cesium/createCesiumEngine.test.mjs",
"src/globe/cesium/movers.test.mjs",
"src/globe/cesium/lines.test.mjs",
"src/globe/cesium/testing/fakeViewer.mjs",
"src/globe/cesium/testing/cameraViewer.mjs",
"src/app/globeEngine.js",
"src/app/globeEngine.test.mjs",
"scripts/fixtures/layers/transit.mjs",
"src/layers/localAdsb/render/display.js",
"src/layers/localAdsb/renderer.test.mjs",
"build/navaraAssets.js",
"scripts/qa-engine-switch.mjs",
"scripts/qa/tileCache.mjs",
"src/annotations/resolverEngine.test.mjs",
"src/voice/gevActionsEngine.test.mjs",
"src/app/engineChoice.js",
"src/app/engineChoice.test.mjs",
"src/app/navaraMode.js",
"src/app/navaraScene.test.mjs",
"src/app/viewerlessTools.test.mjs",
"src/cameraAngles.js",
"src/cameraAngles.test.mjs",
"src/data/cameraArrival.test.mjs",
"src/data/cameraSensitivity.test.mjs",
"src/globe/cesium/camera.js",
"src/globe/cesium/convert.js",
"src/globe/cesium/createCesiumEngine.js",
"src/globe/cesium/credits.js",
"src/globe/cesium/events.js",
"src/globe/cesium/frames.js",
"src/globe/cesium/input.js",
"src/globe/cesium/maps.js",
"src/globe/cesium/picker.js",
"src/globe/cesium/projector.js",
"src/globe/cesium/render.js",
"src/globe/cesium/surface.js",
"src/globe/cesium/movers.js",
"src/globe/cesium/lines.js",
"src/globe/contract.js",
"src/globe/cameraMath.js",
"src/globe/cameraMath.test.mjs",
"src/globe/navara/cameraModel.js",
"src/globe/navara/cameraModel.test.mjs",
"src/globe/navara/createNavaraEngine.js",
"src/globe/navara/createNavaraEngine.test.mjs",
"src/globe/navara/credits.js",
"src/globe/navara/credits.test.mjs",
"src/globe/navara/groundFloor.js",
"src/globe/navara/groundFloor.test.mjs",
"src/globe/navara/index.js",
"src/globe/navara/input.js",
"src/globe/navara/input.test.mjs",
"src/globe/navara/internals.js",
"src/globe/navara/internals.test.mjs",
"src/globe/navara/maps.js",
"src/globe/navara/maps.test.mjs",
"src/globe/navara/sharpen.js",
"src/globe/navara/surface.js",
"src/globe/navara/surface.test.mjs",
"src/globe/navara/surfaceRay.test.mjs",
"src/globe/navara/viewOptions.js",
"src/globe/navara/viewOptions.test.mjs",
"src/globe/testing/contractSuite.mjs",
"src/globe/testing/fakeEngine.js",
"src/mapStackController.test.mjs",
"src/orbit.test.mjs",
"src/services/groundSnap.test.mjs",
"src/sharelinkPose.test.mjs",
"src/tooling/navaraAssets.test.mjs",
"src/tooling/tileCache.test.mjs",
"scripts/check-main-chunk.mjs",
"src/tooling/mainChunkComposition.test.mjs",
"src/tooling/initialChunkGuard.test.mjs",
"src/globe/movers.test.mjs",
"src/globe/navara/movers/pack.test.mjs",
"src/globe/navara/movers/pickIds.test.mjs",
"src/globe/navara/movers/moverMesh.test.mjs",
"src/globe/navara/movers/modelMover.test.mjs",
"src/bench/syntheticFlights.test.mjs",
"src/globe/lines.test.mjs",
"src/globe/navara/lines/lineMesh.test.mjs",
"src/globe/pickDelivery.test.mjs",
"src/globe/navara/picking.test.mjs",
"src/data/selectionDispatcher.test.mjs",
"src/globe/navara/cascadedLightCopy.test.mjs",
"src/tooling/navaraDependencies.test.mjs",
"build/navaraDependencies.js",
"build/initialChunkGuard.js",
"src/globe/follow.js",
"src/globe/follow.test.mjs",
"src/globe/cesium/follow.js",
"src/globe/cesium/follow.test.mjs",
"src/globe/surfaceDetail.js",
"src/globe/surfaceDetail.test.mjs",
"src/globe/navara/followGestures.js",
"src/globe/navara/followGestures.test.mjs",
"src/globe/cpuPicks.test.mjs",
"src/app/layers/directory.js",
"src/app/layers/renderers.js",
"src/app/layers/renderers.test.mjs",
"src/data/lifecycleEngine.test.mjs",
"src/ui/shareRestorationEngine.test.mjs",
"src/app/qaProbe.js",
"src/app/qaProbe.test.mjs",
"src/app/layers/qaSources.js",
"src/app/layers/qaSources.test.mjs",
"src/bench/w1Feeds.js",
"src/bench/satelliteFixture.js",
"src/bench/satelliteFixture.test.mjs",
"src/globe/cesium/celestial.js",
"src/celestialRing/view.js",
"src/geo/ellipsoidGeodesic.test.mjs",
"src/bench/launchFixture.js",
"src/layers/launches/replayClock.test.mjs",
"src/geo/boundingSphere.test.mjs",
"src/layers/launches/launchPadFrame.test.mjs",
"src/layers/satellites/interaction.test.mjs",
"src/layers/launches/lifecycleSnapshot.test.mjs",
"src/globe/cesium/sceneProjection.js",
"src/layers/flights/trackingRefresh.test.mjs",
"scripts/qa-heading-navara.mjs",
"src/layers/aircraft/qaDrawn.test.mjs",
"src/globe/cesium/screenEvents.js",
"src/globe/navara/movers/frameCamera.js",
"src/globe/navara/overlayPass.js",
"src/globe/navara/overlayPass.test.mjs",
"src/globe/navara/movers/modelTint.js",
"src/globe/navara/movers/frameCamera.test.mjs",
"src/layers/localAdsb/qaDrawn.js",
"src/layers/localAdsb/qaDrawn.test.mjs",
"src/tooling/browserRuntimeEvidence.test.mjs",
"src/layers/firms/rectangle.js",
"src/layers/firms/rectangle.test.mjs",
"src/layers/firms/viewport.test.mjs",
"scripts/fixtures/layers/firms.mjs",
"src/layers/firms/lifecycle.test.mjs",
"scripts/fixtures/layers/radio.mjs",
"scripts/qa-map-policy.mjs",
"scripts/qa-map-policy.test.mjs",
"scripts/qa-imagery-contract.mjs",
"src/globe/navara/postSamplingPass.test.mjs",
"src/globe/navara/emptySceneRender.test.mjs",
"src/data/firmsIntensity.js", "src/data/firmsIntensity.js",
"src/data/firmsIntensity.test.mjs", "src/data/firmsIntensity.test.mjs",
"src/ui/layerPanel/RowSwitch.vue", "src/ui/layerPanel/RowSwitch.vue",
@@ -1385,6 +1556,170 @@
"src/layers/wildlifeSightings/density.test.mjs", "src/layers/wildlifeSightings/density.test.mjs",
"src/layers/wildlifeSightings/selection.test.mjs", "src/layers/wildlifeSightings/selection.test.mjs",
"server/providers/inaturalist/proxy.fixture.mjs", "server/providers/inaturalist/proxy.fixture.mjs",
"scripts/report-engine-coupling.mjs",
"scripts/engineCoupling.test.mjs",
"scripts/qa-contract-suite.mjs",
"src/globe/cesium/doubleClick.js",
"scripts/engine-coupling-baseline.json",
"src/layers/wildlifeSightings/display.js",
"src/layers/alpr/render/display.js",
"src/layers/alpr/render/display.test.mjs",
"src/layers/bikeshare/render/display.js",
"src/layers/bikeshare/render/display.test.mjs",
"src/layers/bikeshare/render/style.js",
"src/layers/launches/render/display.js",
"src/layers/launches/render/display.test.mjs",
"src/layers/osmPlaces/render/display.js",
"src/layers/osmPlaces/render/display.test.mjs",
"src/layers/satellites/render/display.js",
"src/layers/satellites/render/display.test.mjs",
"src/layers/vessels/render/display.js",
"src/layers/vessels/render/display.test.mjs",
"scripts/qa-wildlife-switch.mjs",
"scripts/fixtures/wildlife.mjs",
"src/layers/cctv/render/display.js",
"src/layers/cctv/render/display.test.mjs",
"src/layers/cctv/render/displayGizmo.test.mjs",
"src/layers/cctv/render/meshes.js",
"src/layers/cctv/render/meshes.test.mjs",
"src/layers/cctv/render/style.js",
"src/layers/radio/render/display.js",
"src/layers/radio/render/display.test.mjs",
"src/layers/radio/render/style.js",
"src/layers/transit/render/display.js",
"src/layers/transit/render/display.test.mjs",
"src/layers/transit/render/style.js",
"src/layers/transit/render/testAtlas.mjs",
"src/testSupport/cctvEngines.mjs",
"src/testSupport/radioTestEngine.mjs",
"src/globe/ground.js",
"src/globe/ground.test.mjs",
"src/globe/cesium/ground.js",
"src/globe/cesium/ground.test.mjs",
"src/globe/navara/ground/index.js",
"src/globe/navara/ground/index.test.mjs",
"src/globe/navara/ground/pack.js",
"src/globe/navara/ground/pack.test.mjs",
"src/globe/navara/ground/pass.js",
"docs/GLOBE-PRIMITIVES.md",
"src/layers/airQuality/render/display.js",
"src/layers/airQuality/render/display.test.mjs",
"src/layers/cyclones/render/display.js",
"src/layers/cyclones/render/display.test.mjs",
"src/layers/droneAirspace/render/display.js",
"src/layers/droneAirspace/render/display.test.mjs",
"src/layers/earthquakes/render/display.js",
"src/layers/earthquakes/render/display.test.mjs",
"src/layers/firms/render/display.js",
"src/layers/firms/render/display.test.mjs",
"src/layers/firms/render/sprites.js",
"src/layers/ghostMaps/render/display.js",
"src/layers/ghostMaps/render/display.test.mjs",
"src/layers/gnss/render/display.js",
"src/layers/gnss/render/display.test.mjs",
"src/layers/perimeters/render/display.js",
"src/layers/perimeters/render/display.test.mjs",
"src/layers/submarineCables/render/display.js",
"src/layers/submarineCables/render/display.test.mjs",
"src/testSupport/groundDisplayEngines.mjs",
"src/globe/cesium/groundPulse.js",
"src/layers/awareness/render/display.js",
"src/layers/awareness/render/display.test.mjs",
"src/layers/infrastructure/render/display.js",
"src/layers/infrastructure/render/display.test.mjs",
"src/layers/installations/render/display.js",
"src/layers/installations/render/display.test.mjs",
"src/layers/installations/render/displayHarness.mjs",
"src/layers/installations/render/namedMarkers.js",
"src/layers/installations/render/style.js",
"src/layers/installations/selection.test.mjs",
"src/layers/installations/selectionFill.test.mjs",
"src/layers/installations/lifecycle.test.mjs",
"src/layers/installations/navigation.test.mjs",
"src/layers/infrastructure/index.js",
"src/layers/infrastructure/session.test.mjs",
"src/data/localGeojson.test.mjs",
"src/layers/directions/render/display.js",
"src/layers/directions/render/display.test.mjs",
"src/layers/directions/render/style.js",
"src/layers/streetLevel/render/display.js",
"src/layers/streetLevel/render/display.test.mjs",
"src/layers/traffic/render/display.js",
"src/layers/traffic/render/display.test.mjs",
"src/layers/directions/edgeCases.test.mjs",
"src/layers/streetLevel/edgeCases.test.mjs",
"src/globe/cesium/imagery.js",
"src/globe/cesium/imagery.test.mjs",
"src/globe/cesium/imageryShell.test.mjs",
"src/globe/imagery.js",
"src/globe/imagery.test.mjs",
"src/globe/navara/imagery/index.js",
"src/globe/navara/imagery/index.test.mjs",
"src/globe/navara/imagery/pass.js",
"src/layers/recentImagery/render/display.js",
"src/layers/recentImagery/render/display.test.mjs",
"src/layers/weather/render/display.test.mjs",
"src/layers/wind/render/display.test.mjs",
"src/layers/bhoteKoshi/render/display.test.mjs",
"src/globe/navara/navaraGroundOrder.test.mjs",
"src/globe/navara/ground/corridors.js",
"src/globe/cesium/windFlow.js",
"src/testSupport/imageryDisplayEngines.mjs",
"src/data/geoImportDisplay.js",
"src/data/geoImportDisplay.test.mjs",
"src/data/geoImportKml.js",
"src/data/geoImportKml.test.mjs",
"src/data/geoImportRecords.js",
"src/globe/cesium/atmosphereCompat.js",
"src/globe/cesium/postStages.js",
"src/globe/cesium/postStages.test.mjs",
"src/globe/cesium/sonar.js",
"src/globe/cesium/sonar.test.mjs",
"src/globe/cesium/sonarCommands.js",
"src/globe/cesium/sonarCommands.test.mjs",
"src/globe/cesium/starrySky.js",
"src/globe/cesium/zoomOutGuard.js",
"scripts/qa-geo-import-probe.mjs",
"src/globe/cesium/meshes.js",
"src/globe/meshes.js",
"src/globe/navara/meshes.js",
"src/globe/navara/meshes.test.mjs",
"src/scenes/dataPacks/display.js",
"src/scenes/dataPacks/shapes.js",
"src/scenes/dataPacks/shapes.test.mjs",
"src/globe/cesium/meshes.test.mjs",
"src/globe/meshes.test.mjs",
"src/scenes/dataPacks/display.test.mjs",
"src/globe/exclusivePointer.js",
"src/globe/cesium/followOffset.js",
"src/annotations/drawSurface.js",
"src/annotations/drawSurface.test.mjs",
"src/annotations/drawToolBehaviour.test.mjs",
"src/globe/navara/ground/pulse.js",
"src/globe/navara/ground/surface.js",
"src/layers/bhoteKoshi/render/story.js",
"src/globe/groundPulse.js",
"src/globe/groundPulse.test.mjs",
"src/globe/navara/ground/pulse.test.mjs",
"src/layers/bhoteKoshi/render/story.test.mjs",
"src/globe/navara/windFlowMesh.js",
"src/data/layerSceneBinding.js",
"src/ui/shellScene.js",
"src/app/layerSession.js",
"src/standalone/sceneSwitcher.js",
"src/app/layerSession.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",
"src/overlays/bindOverlayHost.js",
"src/data/trackedReadoutScenes.test.mjs",
"src/data/trackedAircraftScenes.test.mjs",
"src/ui/releaseTrackers.js",
"src/testSupport/domClasses.mjs",
"src/layers/shared/imageryHost.js",
"src/layers/shared/imageryHost.test.mjs",
"scripts/build-owner-flags.mjs", "scripts/build-owner-flags.mjs",
"src/ui/ownerFlagSheet.js", "src/ui/ownerFlagSheet.js",
"src/data/placePacksLoading.test.mjs", "src/data/placePacksLoading.test.mjs",
+6
View File
@@ -0,0 +1,6 @@
{
"gzipBytes": 2424291,
"allowanceBytes": 24243,
"measuredAt": "Keyless production build at the merge of main 14db73f20, whose own main chunk measures 2 351 884 bytes (main grew 232 KB since the last baseline: the News layer and the Swedish catalog); this branch adds 72 KB, as before",
"allowance": "1% of the baseline; growth past it fails npm run check:main-chunk until the baseline is raised on purpose"
}
+19 -4
View File
@@ -1,4 +1,5 @@
import { parsers } from 'prettier/plugins/babel.mjs'; import { parsers } from 'prettier/plugins/babel.mjs';
import { vueScripts } from './vue-scripts.mjs';
const browserGlobals = new Set([ const browserGlobals = new Set([
'window', 'window',
@@ -15,9 +16,14 @@ const browserGlobals = new Set([
'getComputedStyle', 'getComputedStyle',
]); ]);
/** Inspect literal module dependencies and browser-platform references without executing code. */ /**
export function analyzeModule(source) { * Inspect literal module dependencies and browser-platform references without
* executing code. With `dynamic`, also list the static (not import())
* specifiers apart.
*/
export function analyzeModule(source, { dynamic = false, filename = '' } = {}) {
const imports = []; const imports = [];
const staticImports = [];
const browser = new Set(); const browser = new Set();
function walk(node, parent, field) { function walk(node, parent, field) {
if (!node || typeof node !== 'object') return; if (!node || typeof node !== 'object') return;
@@ -35,6 +41,8 @@ export function analyzeModule(source) {
'Computed module imports are not allowed in runtime code', 'Computed module imports are not allowed in runtime code',
); );
imports.push(node.source.value); imports.push(node.source.value);
if (node.type !== 'ImportExpression')
staticImports.push(node.source.value);
} }
if (node.type === 'CallExpression' && node.callee?.name === 'require') if (node.type === 'CallExpression' && node.callee?.name === 'require')
throw new Error('Runtime modules must use ES imports'); throw new Error('Runtime modules must use ES imports');
@@ -70,8 +78,15 @@ export function analyzeModule(source) {
else if (value && typeof value === 'object') walk(value, node, key); else if (value && typeof value === 'object') walk(value, node, key);
} }
} }
walk(parsers.babel.parse(source)); for (const script of filename.endsWith('.vue')
return { imports, browser: [...browser] }; ? vueScripts(source, filename)
: [source])
walk(parsers.babel.parse(script));
return {
imports,
browser: [...browser],
...(dynamic && { staticImports }),
};
} }
/** Return static imports, literal dynamic imports and re-exports. */ /** Return static imports, literal dynamic imports and re-exports. */
File diff suppressed because it is too large. Load diff
+9 -10
View File
@@ -6,12 +6,13 @@
* native dispatch/disabled/teardown without providers or world calibration writes. * native dispatch/disabled/teardown without providers or world calibration writes.
* Real Scene authoring/playback remains covered by qa-scene-controls.mjs. * Real Scene authoring/playback remains covered by qa-scene-controls.mjs.
*/ */
import { whenSettled } from './qa/ready.mjs';
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
import fs from 'node:fs'; import fs from 'node:fs';
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({ const browser = await launchBrowser({
headless: true, headless: true,
args: ['--use-gl=angle', '--use-angle=swiftshader'], args: [],
}); });
const page = await browser.newPage(); const page = await browser.newPage();
const errors = []; const errors = [];
@@ -34,11 +35,10 @@ try {
{ waitUntil: 'domcontentloaded' }, { waitUntil: 'domcontentloaded' },
); );
await page.waitForFunction( await page.waitForFunction(
() => () => window.__godsEyeView?.styleManager?._cctvControls,
window.__godsEyeView?.styleManager?._cctvControls &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 }, { timeout: 60000 },
); );
await whenSettled(page);
const inventory = await page.evaluate(() => { const inventory = await page.evaluate(() => {
const ids = [ const ids = [
'scene-new-btn', 'scene-new-btn',
@@ -304,11 +304,10 @@ try {
localized.searchParams.set('lang', 'ru'); localized.searchParams.set('lang', 'ru');
await page.goto(localized.href, { waitUntil: 'domcontentloaded' }); await page.goto(localized.href, { waitUntil: 'domcontentloaded' });
await page.waitForFunction( await page.waitForFunction(
() => () => window.__godsEyeView?.styleManager?._cctvControls,
window.__godsEyeView?.styleManager?._cctvControls &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 }, { timeout: 60000 },
); );
await whenSettled(page);
for (const panel of ['scene-panel', 'cctv-panel']) { for (const panel of ['scene-panel', 'cctv-panel']) {
await page.evaluate((panel) => { await page.evaluate((panel) => {
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, { window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
@@ -329,7 +328,7 @@ try {
} }
check('no uncaught browser errors', errors.length === 0, errors); check('no uncaught browser errors', errors.length === 0, errors);
} finally { } finally {
await browser.close(); await closeBrowser(browser);
} }
console.log(`RESULT: ${failures} failures`); console.log(`RESULT: ${failures} failures`);
process.exitCode = failures ? 1 : 0; process.exitCode = failures ? 1 : 0;
+20 -33
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
// Cesium-only probes: DataSource and ground-primitive readiness validate the native outline adapter.
/** /**
* qa-admin-outlines.mjs — bundled state/province/county outlines, driven like a * qa-admin-outlines.mjs — bundled state/province/county outlines, driven like a
* user would drive them. * user would drive them.
@@ -23,11 +24,12 @@
* timings, frame-time p50/p95 and still-camera frame-difference statistics. * timings, frame-time p50/p95 and still-camera frame-difference statistics.
* Exits nonzero on any failed assertion. * Exits nonzero on any failed assertion.
*/ */
import { whenSettled, whenTilesLoaded } from './qa/ready.mjs';
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import puppeteer from 'puppeteer';
import sharp from 'sharp'; import sharp from 'sharp';
const args = process.argv.slice(2); const args = process.argv.slice(2);
@@ -63,12 +65,11 @@ const result = {
video: null, video: null,
}; };
const browser = await puppeteer.launch({ const browser = await launchBrowser({
headless: !args.includes('--headful'), headless: true,
protocolTimeout: 300_000, protocolTimeout: 300_000,
args: [ args: [
'--no-sandbox', '--no-sandbox',
'--use-gl=angle',
'--disable-dev-shm-usage', '--disable-dev-shm-usage',
'--disable-background-timer-throttling', '--disable-background-timer-throttling',
'--disable-renderer-backgrounding', '--disable-renderer-backgrounding',
@@ -145,14 +146,10 @@ try {
timeout: 60_000, timeout: 60_000,
}); });
await page.waitForFunction( await page.waitForFunction(
() => window.__godsEyeView?.viewer && window.__godsEyeView?.annotations, () => window.__godsEyeView?.engine && window.__godsEyeView?.annotations,
{ timeout: 90_000, polling: 500 }, { timeout: 90_000, polling: 500 },
); );
await page.waitForFunction( await whenSettled(page);
() =>
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 90_000, polling: 250 },
);
await page.evaluate( await page.evaluate(
() => window.__godsEyeView.styleManager.initialRestorePromise, () => window.__godsEyeView.styleManager.initialRestorePromise,
); );
@@ -162,7 +159,7 @@ try {
{ timeout: 10_000 }, { timeout: 10_000 },
); );
result.renderer = await page.evaluate(() => { result.renderer = await page.evaluate(() => {
const gl = window.__godsEyeView.viewer.scene.context._gl; const gl = window.__godsEyeView.engine.host.canvas.getContext('webgl2');
const debug = gl.getExtension('WEBGL_debug_renderer_info'); const debug = gl.getExtension('WEBGL_debug_renderer_info');
return debug return debug
? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL)
@@ -177,7 +174,7 @@ try {
window.__qaOutlines = { window.__qaOutlines = {
fly(view, duration) { fly(view, duration) {
return new Promise((resolve) => { return new Promise((resolve) => {
viewer.camera.cancelFlight(); window.__godsEyeView.engine.camera.cancelFlight();
viewer.camera.flyTo({ viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees( destination: Cesium.Cartesian3.fromDegrees(
view.lon, view.lon,
@@ -264,20 +261,7 @@ try {
}); });
const settle = () => const settle = () =>
page whenTilesLoaded(page, { timeoutMs: 60_000 }).catch(() => {});
.waitForFunction(
() => {
const { scene } = window.__godsEyeView.viewer;
for (let i = 0; i < scene.primitives.length; i++) {
const p = scene.primitives.get(i);
if (p?.show && typeof p.tilesLoaded === 'boolean' && !p.tilesLoaded)
return false;
}
return true;
},
{ timeout: 60_000, polling: 500 },
)
.catch(() => {});
async function shot(name) { async function shot(name) {
await page.evaluate( await page.evaluate(
@@ -317,11 +301,14 @@ try {
// built the mark's primitives (each tick updates them before render). // built the mark's primitives (each tick updates them before render).
const { viewer } = window.__godsEyeView; const { viewer } = window.__godsEyeView;
await new Promise((resolve) => { await new Promise((resolve) => {
const off = viewer.scene.postRender.addEventListener(() => { const off =
if (!viewer.dataSourceDisplay.ready) return; window.__godsEyeView.engine.frames.postRender.addEventListener(
off(); () => {
resolve(); if (!viewer.dataSourceDisplay.ready) return;
}); off();
resolve();
},
);
}); });
const drawnMs = performance.now() - started; const drawnMs = performance.now() - started;
const mark = window.__godsEyeView.annotations const mark = window.__godsEyeView.annotations
@@ -343,7 +330,7 @@ try {
target, target,
flyTo, flyTo,
); );
if (flyTo) await new Promise((r) => setTimeout(r, 2200)); if (flyTo) await whenSettled(page);
await settle(); await settle();
// Hold still: the drawn mark must not rebuild or grow. // Hold still: the drawn mark must not rebuild or grow.
marker = `still:${name}`; marker = `still:${name}`;
@@ -551,7 +538,7 @@ try {
.screenshot({ path: path.join(shots, 'admin-outlines-failure.png') }) .screenshot({ path: path.join(shots, 'admin-outlines-failure.png') })
.catch(() => {}); .catch(() => {});
} finally { } finally {
await browser.close(); await closeBrowser(browser);
} }
if (recordVideo && frames.length > 1) { if (recordVideo && frames.length > 1) {
+15 -21
View File
@@ -1,3 +1,6 @@
import { evaluateWithGeo } from './qa/geometry.mjs';
import { whenSettled } from './qa/ready.mjs';
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
/** /**
* qa-air-quality.mjs — headless proof for the Air quality layer. * qa-air-quality.mjs — headless proof for the Air quality layer.
* *
@@ -12,7 +15,6 @@
* Run: node scripts/qa-air-quality.mjs --url http://localhost:4173 * Run: node scripts/qa-air-quality.mjs --url http://localhost:4173
*/ */
import puppeteer from 'puppeteer';
const argv = process.argv.slice(2); const argv = process.argv.slice(2);
const getOpt = (name, dflt) => { const getOpt = (name, dflt) => {
@@ -27,19 +29,13 @@ function record(name, ok, detail) {
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'; const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`); console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
} }
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function main() { async function main() {
let exitCode = 0; let exitCode = 0;
const consoleErrors = []; const consoleErrors = [];
const browser = await puppeteer.launch({ const browser = await launchBrowser({
headless: 'new', headless: true,
args: [ args: ['--enable-unsafe-swiftshader', '--no-sandbox', '--disable-dev-shm-usage'],
'--use-gl=swiftshader',
'--enable-unsafe-swiftshader',
'--no-sandbox',
'--disable-dev-shm-usage',
],
defaultViewport: { width: 1400, height: 900 }, defaultViewport: { width: 1400, height: 900 },
}); });
@@ -52,10 +48,10 @@ async function main() {
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 90000 }); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 90000 });
await page.waitForFunction( await page.waitForFunction(
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, () => window.__godsEyeView?.engine && window.__godsEyeView?.dataManager,
{ timeout: 90000 }, { timeout: 90000 },
); );
await sleep(1500); await whenSettled(page);
const stats = await page.evaluate(async () => { const stats = await page.evaluate(async () => {
const dm = window.__godsEyeView.dataManager; const dm = window.__godsEyeView.dataManager;
@@ -106,26 +102,24 @@ async function main() {
// The card's whole point is "highest readings in view", so put the camera // The card's whole point is "highest readings in view", so put the camera
// over Canada before asserting on it — the default view is over Austin, // over Canada before asserting on it — the default view is over Austin,
// where an empty in-view list is correct and proves nothing. // where an empty in-view list is correct and proves nothing.
await page.evaluate(() => { await evaluateWithGeo(page, async (qaGeo) => {
const gev = window.__godsEyeView;
const ellipsoid = gev.viewer.scene.globe.ellipsoid;
const d2r = Math.PI / 180; const d2r = Math.PI / 180;
try { try {
gev.viewer.camera.cancelFlight(); window.__godsEyeView.engine.camera.cancelFlight();
} catch { } catch {
/* no flight in progress */ /* no flight in progress */
} }
gev.viewer.camera.setView({ window.__godsEyeView.engine.camera.setView({
destination: ellipsoid.cartographicToCartesian({ destination: qaGeo.cartographic.toCartesian({
longitude: -100 * d2r, longitude: -100 * d2r,
latitude: 55 * d2r, latitude: 55 * d2r,
height: 4_000_000, height: 4_000_000,
}), }),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
}); });
gev.viewer.scene.requestRender?.(); window.__godsEyeView.engine.render.request?.();
}); });
await sleep(2500); await whenSettled(page);
const controls = await page.evaluate( const controls = await page.evaluate(
() => () =>
@@ -154,7 +148,7 @@ async function main() {
console.error('\x1b[31mHarness error:\x1b[0m', e); console.error('\x1b[31mHarness error:\x1b[0m', e);
exitCode = 3; exitCode = 3;
} finally { } finally {
await browser.close(); await closeBrowser(browser);
} }
const pass = results.filter((r) => r.ok === true).length; const pass = results.filter((r) => r.ok === true).length;
@@ -1,6 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
// Cesium-only probes: tracked entities and native model axes validate Cesium aircraft heading.
/** /**
* qa-heading-b3.mjs — throwaway QA harness for skylight Task 4 * qa-aircraft-heading.mjs — aircraft display-heading checks
* (path-derived, rate-limited display course; Batch 3 overnight run). * (path-derived, rate-limited display course; Batch 3 overnight run).
* *
* The permanent track-regression harness flies planes STRAIGHT, so it cannot * The permanent track-regression harness flies planes STRAIGHT, so it cannot
@@ -19,8 +20,9 @@
* add order and the app registered first), i.e. at the exact wall-clock the * add order and the app registered first), i.e. at the exact wall-clock the
* rate limiter advanced — postRender sampling skews apparent rates by the * rate limiter advanced — postRender sampling skews apparent rates by the
* variable render-pass duration (~40% at SwiftShader's ~3 fps). The course * variable render-pass duration (~40% at SwiftShader's ~3 fps). The course
* is derived from the tracked standalone Cesium.Model's modelMatrix (the * is derived from the tracked model's heading-pitch-roll frame (the layer's
* actually-rendered transform), inverting _modelMatrix: with pitch=roll=0 * model handle, read through the QA probe's layers.drawn on both engines;
* the drawn Cesium Model adds the glTF axis turn), inverting it: with pitch=roll=0
* the matrix is ENU·Rz(−h), so local x in ENU = (cos h, −sin h, 0) → * the matrix is ENU·Rz(−h), so local x in ENU = (cos h, −sin h, 0) →
* h = atan2(−x·north, x·east), course = norm360(deg(h) − MODEL_HEADING_OFFSET). * h = atan2(−x·north, x·east), course = norm360(deg(h) − MODEL_HEADING_OFFSET).
* The 15 s fix cadence × 3°/s turn = 45° course step per interpolation * The 15 s fix cadence × 3°/s turn = 45° course step per interpolation
@@ -44,7 +46,7 @@
* normalized nose -X, offset 180°). * normalized nose -X, offset 180°).
* *
* VISUAL — with the turning flights plane tracked in 3D, capture screenshots * VISUAL — with the turning flights plane tracked in 3D, capture screenshots
* (two zoom levels × two orbit angles) into qa-shots/b3/ for human review: * (two zoom levels × two orbit angles) into qa-shots/aircraft-heading/ for human review:
* the nose must lead the curved trail, never sideways/backwards. * the nose must lead the curved trail, never sideways/backwards.
* *
* LOW-SPEED (heading-v2, 2026-07-03 field-test regression) — two extra * LOW-SPEED (heading-v2, 2026-07-03 field-test regression) — two extra
@@ -92,13 +94,15 @@
* exceeds 15°/s (truth is 2°/s; the pre-fix snap replayed ~120° of frozen * exceeds 15°/s (truth is 2°/s; the pre-fix snap replayed ~120° of frozen
* divergence at the 60°/s slew cap). * divergence at the 60°/s slew cap).
* *
* Run: node scripts/qa-heading-b3.mjs --url http://localhost:4300 * Run: node scripts/qa-aircraft-heading.mjs --url http://localhost:4300
* Exits non-zero if any assertion fails. Never commits anything. * Exits non-zero if any assertion fails. Never commits anything.
*/ */
import { whenSettled, whenFramesDrawn } from './qa/ready.mjs';
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import puppeteer from 'puppeteer'; import { installNavaraHeadingProbe } from './qa-heading-navara.mjs';
import { stubPlanespotters } from './qa-photo-stub.mjs'; import { stubPlanespotters } from './qa-photo-stub.mjs';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -111,31 +115,18 @@ const getOpt = (name, dflt) => {
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt; return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
}; };
const APP_URL = getOpt('--url', 'http://localhost:4173'); const appUrl = new URL(getOpt('--url', 'http://localhost:4173'));
const requestedEngine = argv.find((value) => value.startsWith('--engine='))?.slice(9) || getOpt('--engine');
if (requestedEngine) {
if (!['cesium', 'navara'].includes(requestedEngine)) throw new Error('Unknown engine');
appUrl.searchParams.set('engine', requestedEngine);
}
const APP_URL = appUrl.href;
const HEADFUL = getFlag('--headful'); const HEADFUL = getFlag('--headful');
const SHOT_DIR = path.resolve('qa-shots/b3'); const SHOT_DIR = path.resolve('qa-shots/aircraft-heading');
const SHOT_DIR_V2 = path.resolve('qa-shots/heading-v2'); const SHOT_DIR_V2 = path.resolve('qa-shots/heading-v2');
const CHROME_EXECUTABLE_CANDIDATES = [
process.env.PUPPETEER_EXECUTABLE_PATH,
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system
// Chrome: /Applications auto-updates underneath the harnesses, and its
// software-GL behavior shifts across majors (system Chrome 150 blew the
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
// pinned browser beats the newest one for regression harnesses.
await puppeteer.executablePath().catch(() => null),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
].filter(Boolean);
function findChromeExecutable() {
for (const candidate of CHROME_EXECUTABLE_CANDIDATES) {
try { if (fs.existsSync(candidate)) return candidate; } catch { /* skip */ }
}
return null;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// PASS/FAIL reporting // PASS/FAIL reporting
@@ -149,7 +140,7 @@ function record(name, ok, detail) {
async function observeTrackedHostPaint(page, timeoutMs = 5000) { async function observeTrackedHostPaint(page, timeoutMs = 5000) {
return page.evaluate((boundedTimeoutMs) => new Promise((resolve) => { return page.evaluate((boundedTimeoutMs) => new Promise((resolve) => {
const viewer = window.__godsEyeView?.viewer; const viewer = (window.__headingView ?? window.__godsEyeView?.engine);
const scene = viewer?.scene; const scene = viewer?.scene;
const history = []; const history = [];
const startedAt = performance.now(); const startedAt = performance.now();
@@ -447,20 +438,9 @@ async function main() {
fs.mkdirSync(SHOT_DIR, { recursive: true }); fs.mkdirSync(SHOT_DIR, { recursive: true });
fs.mkdirSync(SHOT_DIR_V2, { recursive: true }); fs.mkdirSync(SHOT_DIR_V2, { recursive: true });
const chromeExecutable = findChromeExecutable(); const browser = await launchBrowser({
const browser = await puppeteer.launch({ headless: true,
headless: HEADFUL ? false : 'new', args: ['--no-sandbox', '--disable-setuid-sandbox', ...(HEADFUL ? [] : []), '--disable-dev-shm-usage', '--disable-web-security', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1280,800'],
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
...(HEADFUL ? [] : ['--use-gl=angle', '--use-angle=swiftshader']),
'--disable-dev-shm-usage',
'--disable-web-security',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=1280,800',
],
}); });
const consoleErrors = []; const consoleErrors = [];
@@ -482,6 +462,7 @@ async function main() {
// ---- Turning-plane fetch shim (installed before any app code runs) ------ // ---- Turning-plane fetch shim (installed before any app code runs) ------
await page.evaluateOnNewDocument((turn) => { await page.evaluateOnNewDocument((turn) => {
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
window.__TURN = turn; window.__TURN = turn;
window.__TURN.epochMs = Date.now(); // arc phase zero = shim install time window.__TURN.epochMs = Date.now(); // arc phase zero = shim install time
window.__TURN_HITS = { opensky: 0, mil: 0 }; window.__TURN_HITS = { opensky: 0, mil: 0 };
@@ -576,58 +557,43 @@ async function main() {
console.log('Loading app...'); console.log('Loading app...');
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForFunction( await page.waitForFunction(
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.dataManager, () => window.__godsEyeView?.layers && window.__godsEyeView.dataManager,
{ timeout: 60000, polling: 200 } { timeout: 60000, polling: 200 }
); );
await whenSettled(page);
await page.evaluate(() => window.__godsEyeView.styleManager.initialRestorePromise);
await whenSettled(page);
await page.evaluate(() => window.__godsEyeView.styleManager.orbitController.stop());
console.log(' App globals ready.'); console.log(' App globals ready.');
// ---- Model finder + course derivation, installed once ------------------ // ---- Model finder + course derivation, installed once ------------------
await page.evaluate(() => { await page.evaluate(() => {
// Every sampler in this harness targets a KNOWN aircraft, and both the // Every sampler in this harness targets a KNOWN aircraft, and both the
// fleet models and the tracked standalone model carry id=icao — so the // fleet models and the tracked model carry id=icao — so the finder
// finder selects by id when given one. (The old nearest-to-tracked // selects by id. The models draw through the globe contract (engine
// fallback could mis-pick ANOTHER plane's model for a frame whenever // model movers), on Cesium as on Navara; the layer's drawn rows
// the tracked display position read null, which injected a one-frame // (window.__godsEyeView.layers.drawn, src/layers/aircraft/qaDrawn.js)
// ~90° course spike into the sampled series — a measurement flake, not // give each model's heading-pitch-roll frame, the matrix the course
// a product regression.) // derivation below inverts. The Cesium Model a mover set draws carries
// that frame times the layer's glTF axis turn (GLTF_AXIS in
// src/layers/aircraft/render/display.js), so its own modelMatrix reads
// 90° off; the handle's ready/show say whether the engine draws it.
window.__findTrackedModel = function (icao) { window.__findTrackedModel = function (icao) {
const v = window.__godsEyeView.viewer; const rows = ['flights', 'military']
const out = []; .flatMap((layer) => window.__godsEyeView.layers.drawn(layer) ?? [])
const walk = (coll) => { .filter((row) => row.id === icao);
const n = coll.length; const row =
for (let i = 0; i < n; i++) { rows.find((r) => r.trackedModel && r.model?.ready && r.model.show) ??
const p = coll.get(i); rows.find((r) => r.model?.ready && r.model.show);
if (!p) continue; return row
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; } ? { id: icao, ready: row.model.ready, show: row.model.show, modelMatrix: row.model.matrix }
if (p.modelMatrix && typeof p.ready !== 'undefined') { : null;
if (icao && p.id !== icao) continue;
out.push(p);
}
}
};
walk(v.scene.primitives);
const shown = out.filter((m) => m.show && m.ready);
if (icao) return shown[0] || null; // deterministic: the plane's own model or nothing
const pool = shown.length ? shown : out;
if (!pool.length) return null;
const ent = v.trackedEntity;
let tracked = null;
if (ent && typeof ent.gevDisplayPosition === 'function') tracked = ent.gevDisplayPosition();
if (!tracked) return pool[0];
let best = null, bestD = Infinity;
for (const m of pool) {
const mm = m.modelMatrix;
const dx = mm[12] - tracked.x, dy = mm[13] - tracked.y, dz = mm[14] - tracked.z;
const d = dx * dx + dy * dy + dz * dz;
if (d < bestD) { bestD = d; best = m; }
}
return best;
}; };
// Invert _modelMatrix (pitch=roll=0): local x in ENU = (cos h, −sin h, 0) // Invert _modelMatrix (pitch=roll=0): local x in ENU = (cos h, −sin h, 0)
// → h = atan2(−x·north, x·east); world course = h − headingOffsetDeg. // → h = atan2(−x·north, x·east); world course = h − headingOffsetDeg.
window.__courseFromModelMatrix = function (mm, headingOffsetDeg) { window.__courseFromModelMatrix = function (mm, headingOffsetDeg) {
const v = window.__godsEyeView.viewer; const v = (window.__headingView ?? window.__godsEyeView.viewer);
const C3 = v.camera.position.constructor; const C3 = v.camera.position.constructor;
const Carto = v.camera.positionCartographic.constructor; const Carto = v.camera.positionCartographic.constructor;
const carto = Carto.fromCartesian(new C3(mm[12], mm[13], mm[14])); const carto = Carto.fromCartesian(new C3(mm[12], mm[13], mm[14]));
@@ -648,7 +614,7 @@ async function main() {
// preUpdate/preRender listeners and reads the rendered model matrix, but // preUpdate/preRender listeners and reads the rendered model matrix, but
// does not depend on ambient render-loop scheduling for sample count. // does not depend on ambient render-loop scheduling for sample count.
window.__sampleModelCourse = async function (icao, headingOffsetDeg, windowMs, stepMs = 100) { window.__sampleModelCourse = async function (icao, headingOffsetDeg, windowMs, stepMs = 100) {
const v = window.__godsEyeView.viewer; const v = (window.__headingView ?? window.__godsEyeView.viewer);
const tileset = window.__godsEyeView.tileset; const tileset = window.__godsEyeView.tileset;
const out = []; const out = [];
const start = performance.now(); const start = performance.now();
@@ -687,13 +653,15 @@ async function main() {
}; };
}); });
await installNavaraHeadingProbe(page);
// ---- Enable layers with fixes back-dated 32 s, then prime forward ------ // ---- Enable layers with fixes back-dated 32 s, then prime forward ------
// First update() fires inside setEnabled, so the FIRST fix must already be // First update() fires inside setEnabled, so the FIRST fix must already be
// the oldest (history appends only monotonically-newer fix times). // the oldest (history appends only monotonically-newer fix times).
console.log('Priming turning history through the render delay (30 s / 15 s)...'); console.log('Priming turning history through the render delay (30 s / 15 s)...');
const primed = await page.evaluate(async () => { const primed = await page.evaluate(async () => {
const dm = window.__godsEyeView.dataManager; const dm = window.__godsEyeView.dataManager;
const v = window.__godsEyeView.viewer; const v = (window.__headingView ?? window.__godsEyeView.viewer);
window.__TURN.timeOffsetSec = -32; window.__TURN.timeOffsetSec = -32;
await dm.setEnabled('flights', true); await dm.setEnabled('flights', true);
await dm.setEnabled('military', true); await dm.setEnabled('military', true);
@@ -728,11 +696,14 @@ async function main() {
const flModelUp = await page.waitForFunction((icao) => { const flModelUp = await page.waitForFunction((icao) => {
const m = window.__findTrackedModel(icao); const m = window.__findTrackedModel(icao);
return !!(m && m.ready && m.show); const app = window.__godsEyeView;
const selected = app.dataManager.layers.get('flights').module.getTrackedInfo();
const following = app.engine.kind === 'navara' ? app.engine.camera.following() : app.viewer.trackedEntity;
return !!(m && m.ready && m.show && selected?.icao24 === icao && following);
}, { timeout: 40000, polling: 250 }, TURN.flights[0].icao).then(() => true).catch(() => false); }, { timeout: 40000, polling: 250 }, TURN.flights[0].icao).then(() => true).catch(() => false);
record('flights: tracked 3D model rendered (ready+shown)', flModelUp, flModelUp ? 'model up' : 'model never became ready'); record('flights: tracked 3D model rendered (ready+shown)', flModelUp, flModelUp ? 'model up' : 'model never became ready');
if (!flModelUp) { finish(); return; } if (!flModelUp) { finish(); return; }
await sleep(1500); // let the follow camera + reconciliation settle await whenFramesDrawn(page); // let the follow camera + reconciliation settle
const readoutObservation = await observeTrackedHostPaint(page); const readoutObservation = await observeTrackedHostPaint(page);
const readoutHost = await page.evaluate(() => ({ const readoutHost = await page.evaluate(() => ({
@@ -778,25 +749,25 @@ async function main() {
} }
// ---- Screenshots: two zoom levels × two orbit angles -------------------- // ---- Screenshots: two zoom levels × two orbit angles --------------------
console.log('\n Screenshots (turning plane tracked in 3D) → qa-shots/b3/'); console.log('\n Screenshots (turning plane tracked in 3D) → qa-shots/aircraft-heading/');
await page.evaluate(() => { await page.evaluate(() => {
window.__godsEyeView.styleManager._setDetectionMode('DENSE'); window.__godsEyeView.styleManager._setDetectionMode('DENSE');
}); });
await sleep(800); await whenFramesDrawn(page);
const shots = [ const shots = [
['flights-far-orbitA.png', async () => { /* default follow framing */ }], ['flights-far-orbitA.png', async () => { /* default follow framing */ }],
['flights-far-orbitB.png', () => page.evaluate(() => { window.__godsEyeView.viewer.camera.rotateRight(1.9); })], ['flights-far-orbitB.png', () => page.evaluate(() => { (window.__headingView ?? window.__godsEyeView.viewer).camera.rotateRight(1.9); })],
['flights-near-orbitA.png', () => page.evaluate(() => { ['flights-near-orbitA.png', () => page.evaluate(() => {
const cam = window.__godsEyeView.viewer.camera; const cam = (window.__headingView ?? window.__godsEyeView.viewer).camera;
// Synthetic cruise frame settles ~5.6 km out; leave ~430 m so model, // Synthetic cruise frame settles ~5.6 km out; leave ~430 m so model,
// readout anchor, tracking line, and trail can be judged close-up. // readout anchor, tracking line, and trail can be judged close-up.
cam.rotateRight(-1.9); cam.zoomIn(5200); cam.rotateRight(-1.9); cam.zoomIn(5200);
})], })],
['flights-near-orbitB.png', () => page.evaluate(() => { window.__godsEyeView.viewer.camera.rotateLeft(1.2); })], ['flights-near-orbitB.png', () => page.evaluate(() => { (window.__headingView ?? window.__godsEyeView.viewer).camera.rotateLeft(1.2); })],
]; ];
for (const [name, move] of shots) { for (const [name, move] of shots) {
try { await move(); } catch (e) { console.log(` (camera move for ${name} failed: ${e.message})`); } try { await move(); } catch (e) { console.log(` (camera move for ${name} failed: ${e.message})`); }
await sleep(1200); // let a few frames render at the new pose await whenFramesDrawn(page); // let a few frames render at the new pose
await page.screenshot({ path: path.join(SHOT_DIR, name) }); await page.screenshot({ path: path.join(SHOT_DIR, name) });
console.log(` saved ${name}`); console.log(` saved ${name}`);
} }
@@ -814,11 +785,14 @@ async function main() {
const milModelUp = await page.waitForFunction((hex) => { const milModelUp = await page.waitForFunction((hex) => {
const m = window.__findTrackedModel(hex); const m = window.__findTrackedModel(hex);
return !!(m && m.ready && m.show); const app = window.__godsEyeView;
const selected = app.dataManager.layers.get('military').module.getTrackedInfo();
const following = app.engine.kind === 'navara' ? app.engine.camera.following() : app.viewer.trackedEntity;
return !!(m && m.ready && m.show && selected?.icao24 === hex && following);
}, { timeout: 40000, polling: 250 }, TURN.military[0].hex).then(() => true).catch(() => false); }, { timeout: 40000, polling: 250 }, TURN.military[0].hex).then(() => true).catch(() => false);
record('military: tracked 3D model rendered (ready+shown)', milModelUp, milModelUp ? 'model up' : 'model never became ready'); record('military: tracked 3D model rendered (ready+shown)', milModelUp, milModelUp ? 'model up' : 'model never became ready');
if (milModelUp) { if (milModelUp) {
await sleep(1500); await whenFramesDrawn(page);
const milSamples = await page.evaluate( const milSamples = await page.evaluate(
(icao) => window.__sampleModelCourse(icao, 180, 40000), (icao) => window.__sampleModelCourse(icao, 180, 40000),
TURN.military[0].hex, TURN.military[0].hex,
@@ -840,21 +814,21 @@ async function main() {
await page.evaluate(() => { await page.evaluate(() => {
window.__godsEyeView.styleManager._setDetectionMode('DENSE'); window.__godsEyeView.styleManager._setDetectionMode('DENSE');
}); });
await sleep(800); await whenFramesDrawn(page);
for (const [name, move] of [ for (const [name, move] of [
['military-orbitA.png', async () => { }], ['military-orbitA.png', async () => { }],
['military-orbitB.png', () => page.evaluate(() => { window.__godsEyeView.viewer.camera.rotateRight(1.6); })], ['military-orbitB.png', () => page.evaluate(() => { (window.__headingView ?? window.__godsEyeView.viewer).camera.rotateRight(1.6); })],
['military-near-orbitA.png', () => page.evaluate(() => { ['military-near-orbitA.png', () => page.evaluate(() => {
const cam = window.__godsEyeView.viewer.camera; const cam = (window.__headingView ?? window.__godsEyeView.viewer).camera;
// 12,000 ft synthetic frame settles ~6.3 km out; leave ~630 m. // 12,000 ft synthetic frame settles ~6.3 km out; leave ~630 m.
cam.rotateLeft(1.6); cam.zoomIn(5700); cam.rotateLeft(1.6); cam.zoomIn(5700);
})], })],
['military-near-orbitB.png', () => page.evaluate(() => { ['military-near-orbitB.png', () => page.evaluate(() => {
window.__godsEyeView.viewer.camera.rotateRight(1.2); (window.__headingView ?? window.__godsEyeView.viewer).camera.rotateRight(1.2);
})], })],
]) { ]) {
try { await move(); } catch (e) { console.log(` (camera move for ${name} failed: ${e.message})`); } try { await move(); } catch (e) { console.log(` (camera move for ${name} failed: ${e.message})`); }
await sleep(1200); await whenFramesDrawn(page);
await page.screenshot({ path: path.join(SHOT_DIR, name) }); await page.screenshot({ path: path.join(SHOT_DIR, name) });
console.log(` saved ${name}`); console.log(` saved ${name}`);
} }
@@ -879,7 +853,10 @@ async function main() {
}, icao); }, icao);
const up = await page.waitForFunction((id) => { const up = await page.waitForFunction((id) => {
const m = window.__findTrackedModel(id); const m = window.__findTrackedModel(id);
return !!(m && m.ready && m.show); const app = window.__godsEyeView;
const selected = app.dataManager.layers.get('flights').module.getTrackedInfo();
const following = app.engine.kind === 'navara' ? app.engine.camera.following() : app.viewer.trackedEntity;
return !!(m && m.ready && m.show && selected?.icao24 === id && following);
}, { timeout: 40000, polling: 250 }, icao).then(() => true).catch(() => false); }, { timeout: 40000, polling: 250 }, icao).then(() => true).catch(() => false);
record(`${label}: tracked 3D model rendered (ready+shown)`, up, up ? 'model up' : 'model never became ready'); record(`${label}: tracked 3D model rendered (ready+shown)`, up, up ? 'model up' : 'model never became ready');
return up; return up;
@@ -888,7 +865,7 @@ async function main() {
// ---- Phase 3 — hovering helicopter -------------------------------------- // ---- Phase 3 — hovering helicopter --------------------------------------
console.log('\nPhase 3 — hovering helicopter (klass=helicopter, drift ~4 m, track flips ±45°)'); console.log('\nPhase 3 — hovering helicopter (klass=helicopter, drift ~4 m, track flips ±45°)');
if (await trackFlightsAndWaitModel(TURN.hover.icao, 'hover-heli')) { if (await trackFlightsAndWaitModel(TURN.hover.icao, 'hover-heli')) {
await sleep(1500); await whenFramesDrawn(page);
const heliSamples = await sampleCourse(180, 35000, TURN.hover.icao); const heliSamples = await sampleCourse(180, 35000, TURN.hover.icao);
analyzeHover('hover-heli', heliSamples, {}); analyzeHover('hover-heli', heliSamples, {});
for (const [name, waitMs] of [['hover-heli-T0.png', 0], ['hover-heli-T8s.png', 8000]]) { for (const [name, waitMs] of [['hover-heli-T0.png', 0], ['hover-heli-T8s.png', 8000]]) {
@@ -901,7 +878,7 @@ async function main() {
// ---- Phase 4 — 25 kt plane in a continuous 4°/s right turn -------------- // ---- Phase 4 — 25 kt plane in a continuous 4°/s right turn --------------
console.log('\nPhase 4 — slow plane in a tight turn (25 kt, 4°/s, R=184 m)'); console.log('\nPhase 4 — slow plane in a tight turn (25 kt, 4°/s, R=184 m)');
if (await trackFlightsAndWaitModel(TURN.slow.icao, 'slow-turn')) { if (await trackFlightsAndWaitModel(TURN.slow.icao, 'slow-turn')) {
await sleep(1500); await whenFramesDrawn(page);
const slowSamples = await sampleCourse(180, 40000, TURN.slow.icao); const slowSamples = await sampleCourse(180, 40000, TURN.slow.icao);
analyzeSlowTurn('slow-turn', slowSamples, { scoreProgress: false }); analyzeSlowTurn('slow-turn', slowSamples, { scoreProgress: false });
// Exercise the production dt clamp with render gaps above // Exercise the production dt clamp with render gaps above
@@ -916,10 +893,10 @@ async function main() {
}); });
for (const [name, move] of [ for (const [name, move] of [
['slowturn-far.png', async () => { /* default follow framing */ }], ['slowturn-far.png', async () => { /* default follow framing */ }],
['slowturn-near.png', () => page.evaluate(() => { window.__godsEyeView.viewer.camera.zoomIn(2500); })], ['slowturn-near.png', () => page.evaluate(() => { (window.__headingView ?? window.__godsEyeView.viewer).camera.zoomIn(2500); })],
]) { ]) {
try { await move(); } catch (e) { console.log(` (camera move for ${name} failed: ${e.message})`); } try { await move(); } catch (e) { console.log(` (camera move for ${name} failed: ${e.message})`); }
await sleep(1200); await whenFramesDrawn(page);
await page.screenshot({ path: path.join(SHOT_DIR_V2, name) }); await page.screenshot({ path: path.join(SHOT_DIR_V2, name) });
console.log(` saved ${name}`); console.log(` saved ${name}`);
} }
@@ -932,22 +909,7 @@ async function main() {
// ======================================================================== // ========================================================================
console.log('\nPhase 5 — 65 kt helicopter: tracked↔fleet course handoff (click / click-away)'); console.log('\nPhase 5 — 65 kt helicopter: tracked↔fleet course handoff (click / click-away)');
await page.evaluate(() => { await page.evaluate(() => {
window.__findModelByIcao = function (icao) { window.__findModelByIcao = window.__findTrackedModel;
const v = window.__godsEyeView.viewer;
const out = [];
const walk = (coll) => {
const n = coll.length;
for (let i = 0; i < n; i++) {
let p;
try { p = coll.get(i); } catch { continue; }
if (!p) continue;
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
if (p.modelMatrix && typeof p.ready !== 'undefined' && p.id === icao && p.show && p.ready) out.push(p);
}
};
walk(v.scene.primitives);
return out[0] || null;
};
}); });
// Activate the heli in the feed now (kept out of phases 1–4), ingest it, // Activate the heli in the feed now (kept out of phases 1–4), ingest it,
@@ -959,7 +921,7 @@ async function main() {
window.__TURN.heli65.active = true; window.__TURN.heli65.active = true;
const fl = window.__godsEyeView.dataManager.layers.get('flights').module; const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
fl.setParams({ models3d: false }); fl.setParams({ models3d: false });
await fl.update(window.__godsEyeView.viewer); // ingest the first heli fix await fl.update((window.__headingView ?? window.__godsEyeView.viewer)); // ingest the first heli fix
}); });
await sleep(4000); // several fleet ticks establish the heli's fleet course await sleep(4000); // several fleet ticks establish the heli's fleet course
await page.evaluate((icao) => { await page.evaluate((icao) => {
@@ -968,7 +930,7 @@ async function main() {
const heliTracked = await page.waitForFunction((icao) => { const heliTracked = await page.waitForFunction((icao) => {
const fl = window.__godsEyeView.dataManager.layers.get('flights').module; const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
const ti = fl.getTrackedInfo(); const ti = fl.getTrackedInfo();
return !!(ti && ti.icao24 === icao && window.__godsEyeView.viewer.trackedEntity); return !!(ti && ti.icao24 === icao && (window.__headingView ?? window.__godsEyeView.viewer).trackedEntity);
}, { timeout: 15000, polling: 250 }, TURN.heli65.icao).then(() => true).catch(() => false); }, { timeout: 15000, polling: 250 }, TURN.heli65.icao).then(() => true).catch(() => false);
record('heli65: tracking engaged (2D billboard mode)', heliTracked, record('heli65: tracking engaged (2D billboard mode)', heliTracked,
heliTracked ? 'tracked' : 'tracking never engaged'); heliTracked ? 'tracked' : 'tracking never engaged');
@@ -985,9 +947,10 @@ async function main() {
// rotation after. The release-in-place keeps the camera static, so // rotation after. The release-in-place keeps the camera static, so
// rotation deltas across the boundary are course deltas. // rotation deltas across the boundary are course deltas.
const c1 = await page.evaluate(async ({ icao, actionAtMs, windowMs }) => { const c1 = await page.evaluate(async ({ icao, actionAtMs, windowMs }) => {
const v = window.__godsEyeView.viewer; const v = (window.__headingView ?? window.__godsEyeView.viewer);
const fl = window.__godsEyeView.dataManager.layers.get('flights').module; const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
const findFleetBillboard = (id) => { const findFleetBillboard = (id) => {
if (window.__headingView) return window.__godsEyeView.layers.drawn('flights').find((row) => row.id === id && row.show);
const out = []; const out = [];
const walk = (coll) => { const walk = (coll) => {
const n = coll.length; const n = coll.length;
@@ -1039,7 +1002,16 @@ async function main() {
window.__godsEyeView.dataManager.layers.get('flights').module.setParams({ models3d: true }); window.__godsEyeView.dataManager.layers.get('flights').module.setParams({ models3d: true });
}); });
const fleetModelUp = await page.evaluate(async (icao) => { const fleetModelUp = await page.evaluate(async (icao) => {
const v = window.__godsEyeView.viewer; if (window.__headingView) {
const deadline = performance.now() + 40000;
while (performance.now() < deadline) {
if (window.__findModelByIcao(icao)) return true;
window.__godsEyeView.engine.render.request('heading-probe');
await new Promise((resolve) => setTimeout(resolve, 100));
}
return false;
}
const v = (window.__headingView ?? window.__godsEyeView.viewer);
const tileset = window.__godsEyeView.tileset; const tileset = window.__godsEyeView.tileset;
const priorDefaultLoop = v.useDefaultRenderLoop; const priorDefaultLoop = v.useDefaultRenderLoop;
const priorTilesetShow = tileset?.show; const priorTilesetShow = tileset?.show;
@@ -1063,7 +1035,7 @@ async function main() {
record('heli65 C2 (click): fleet model rendered before re-track', false, 'fleet model never became ready'); record('heli65 C2 (click): fleet model rendered before re-track', false, 'fleet model never became ready');
} else { } else {
const c2 = await page.evaluate(async ({ icao, offsetDeg, actionAtMs, windowMs }) => { const c2 = await page.evaluate(async ({ icao, offsetDeg, actionAtMs, windowMs }) => {
const v = window.__godsEyeView.viewer; const v = (window.__headingView ?? window.__godsEyeView.viewer);
const fl = window.__godsEyeView.dataManager.layers.get('flights').module; const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
const out = []; const out = [];
const start = performance.now(); const start = performance.now();
@@ -1098,7 +1070,7 @@ async function main() {
finish(); finish();
} finally { } finally {
await browser.close(); await closeBrowser(browser);
} }
function finish() { function finish() {
@@ -1,12 +1,12 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* qa-sprites-b5.mjs — throwaway QA harness for skylight Tasks 8+9 * qa-aircraft-sprites.mjs — aircraft sprite checks
* (type-aware billboard silhouettes + per-class 2D/3D scale; Batch 5). * (type-aware billboard silhouettes + per-class 2D/3D scale; Batch 5).
* *
* Injects synthetic STRAIGHT-FLYING aircraft covering ALL 8 classifyAircraft * Injects synthetic STRAIGHT-FLYING aircraft covering ALL 8 classifyAircraft
* classes — the flights layer via OpenSky extended categories (state[17]), * classes — the flights layer via OpenSky extended categories (state[17]),
* the military layer via adsb.lol type codes — using the same fetch-shim + * the military layer via adsb.lol type codes — using the same fetch-shim +
* history-priming pattern as scripts/qa-heading-b3.mjs (positions analytic at * history-priming pattern as scripts/qa-aircraft-heading.mjs (positions analytic at
* serve time so every fix is self-consistent through the render delay). * serve time so every fix is self-consistent through the render delay).
* *
* Machine assertions: * Machine assertions:
@@ -15,17 +15,17 @@
* per-class glyph (image data-URI === aircraftIcon(klass), * per-class glyph (image data-URI === aircraftIcon(klass),
* imported from src) and per-class scale (flights: * imported from src) and per-class scale (flights:
* CLASS_SCALE_2D; military: 0.7 × CLASS_SCALE_2D) * CLASS_SCALE_2D; military: 0.7 × CLASS_SCALE_2D)
* B3 models : with 3D on, fleet Cesium.Model.scale === the layer's * Model scales : with 3D on, fleet Cesium.Model.scale === the layer's
* MODEL_SCALE (both 1; legacy scale is baked into the GLB) * MODEL_SCALE (both 1; legacy scale is baked into the GLB)
* × CLASS_SCALE_3D[klass] per icao; tracked too * × CLASS_SCALE_3D[klass] per icao; tracked too
* B4 clean : no console errors * B4 clean : no console errors
* *
* Visual output (qa-shots/b5/, untracked): top-down 2D spreads of both rows, * Visual output (qa-shots/aircraft-sprites/, untracked): top-down 2D spreads of both rows,
* per-row close-ups, a tracked-cyan tint shot, and 3D cluster shots at two * per-row close-ups, a tracked-cyan tint shot, and 3D cluster shots at two
* zooms per layer showing per-class model scale differences. The PNGs are the * zooms per layer showing per-class model scale differences. The PNGs are the
* acceptance test — read them. * acceptance test — read them.
* *
* Run: node scripts/qa-sprites-b5.mjs --url http://localhost:4300 * Run: node scripts/qa-aircraft-sprites.mjs --url http://localhost:4300
* Exits non-zero if any assertion fails. Never commits anything. * Exits non-zero if any assertion fails. Never commits anything.
*/ */
@@ -45,7 +45,7 @@ const militaryWantScale = (klass) => (CLASS_MODEL_REAL[klass] ? 1
import { aircraftIcon } from '../src/data/aircraftIcons.js'; import { aircraftIcon } from '../src/data/aircraftIcons.js';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Args (same shape as qa-heading-b3.mjs) // Args (same shape as qa-aircraft-heading.mjs)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const argv = process.argv.slice(2); const argv = process.argv.slice(2);
const getFlag = (name) => argv.includes(name); const getFlag = (name) => argv.includes(name);
@@ -54,9 +54,15 @@ const getOpt = (name, dflt) => {
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt; return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
}; };
const APP_URL = getOpt('--url', 'http://localhost:4173'); const BASE_URL = getOpt('--url', 'http://localhost:4173');
// --engine=cesium|navara: on both engines the layers are read through the QA
// probe (window.__godsEyeView.layers.drawn, the dev server's); the military
// checks are skipped where the engine has no military layer.
const ENGINE = (argv.find((arg) => arg.startsWith('--engine=')) ?? '--engine=cesium').slice(9);
const APP_URL =
ENGINE === 'cesium' ? BASE_URL : `${BASE_URL.replace(/\/$/, '')}/?engine=${ENGINE}`;
const HEADFUL = getFlag('--headful'); const HEADFUL = getFlag('--headful');
const SHOT_DIR = path.resolve('qa-shots/b5'); const SHOT_DIR = path.resolve(ENGINE === 'cesium' ? 'qa-shots/aircraft-sprites' : `qa-shots/aircraft-sprites-${ENGINE}`);
const FL_MODEL_SCALE = 1; // flights.js (airplane.glb is transform-applied and meter-scale) const FL_MODEL_SCALE = 1; // flights.js (airplane.glb is transform-applied and meter-scale)
const MIL_PLANE_MODEL_SCALE = 1; // military heavy classes share the same baked airplane.glb const MIL_PLANE_MODEL_SCALE = 1; // military heavy classes share the same baked airplane.glb
@@ -132,7 +138,7 @@ const SPRITES = {
{ hex: 'dda006', flight: 'ML-QUAD', t: 'B744', lon0: ROW_LON0 + 5 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 80, altFt: 13000 }, { hex: 'dda006', flight: 'ML-QUAD', t: 'B744', lon0: ROW_LON0 + 5 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 80, altFt: 13000 },
{ hex: 'dda007', flight: 'ML-HEL', t: 'H60', lon0: ROW_LON0 + 6 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 40, altFt: 3000 }, { hex: 'dda007', flight: 'ML-HEL', t: 'H60', lon0: ROW_LON0 + 6 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 40, altFt: 3000 },
// course 0 like the rest — keeps the row a clean size-comparison line (2D // course 0 like the rest — keeps the row a clean size-comparison line (2D
// rotation is demoed by caa004 and gated by qa-heading-b3 anyway). // rotation is demoed by caa004 and gated by qa-aircraft-heading anyway).
{ hex: 'dda008', flight: 'ML-FJ', t: 'F16', lon0: ROW_LON0 + 7 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 80, altFt: 15000 }, { hex: 'dda008', flight: 'ML-FJ', t: 'F16', lon0: ROW_LON0 + 7 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 80, altFt: 15000 },
// 3D cluster: C17 vs F16 (tracked) vs B744 vs C130. jet.glb is a // 3D cluster: C17 vs F16 (tracked) vs B744 vs C130. jet.glb is a
// real-world-scale asset (native bounding radius ~29.8) rendered at // real-world-scale asset (native bounding radius ~29.8) rendered at
@@ -174,7 +180,7 @@ async function main() {
'--no-sandbox', '--no-sandbox',
'--disable-setuid-sandbox', '--disable-setuid-sandbox',
'--use-gl=angle', '--use-gl=angle',
'--use-angle=swiftshader', `--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`,
'--disable-dev-shm-usage', '--disable-dev-shm-usage',
'--disable-web-security', '--disable-web-security',
'--disable-background-timer-throttling', '--disable-background-timer-throttling',
@@ -202,6 +208,7 @@ async function main() {
// ---- Straight-flight fetch shim (installed before any app code runs) ---- // ---- Straight-flight fetch shim (installed before any app code runs) ----
await page.evaluateOnNewDocument((spec) => { await page.evaluateOnNewDocument((spec) => {
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
window.__SPR = spec; window.__SPR = spec;
window.__SPR.epochMs = Date.now(); window.__SPR.epochMs = Date.now();
window.__SPR_HITS = { opensky: 0, mil: 0 }; window.__SPR_HITS = { opensky: 0, mil: 0 };
@@ -277,52 +284,33 @@ async function main() {
console.log('Loading app...'); console.log('Loading app...');
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForFunction( await page.waitForFunction(
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.dataManager, () => window.__godsEyeView && window.__godsEyeView.engine && window.__godsEyeView.dataManager && window.__godsEyeView.layers,
{ timeout: 60000, polling: 200 } // A cold dev server compiles the Navara chunks on first load.
{ timeout: 180000, polling: 200 }
); );
await page.evaluate(() => window.__godsEyeView.styleManager.initialRestorePromise);
await page.waitForFunction(() => document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 180000 });
await page.evaluate(() => window.__godsEyeView.styleManager.orbitController.stop());
console.log(' App globals ready.'); console.log(' App globals ready.');
// ---- In-page probes: billboard walk, model walk, tracked-model finder --- // ---- In-page probes: billboard walk, model walk, tracked-model finder ---
await page.evaluate(() => { await page.evaluate(() => {
// Every billboard (image + alignedAxis + id) in every collection. // Every fleet glyph the flights and military layers draw, through the
// engine-neutral probe (layers.drawn: the layers' own sprite handles),
// on either engine. The layers no longer own Cesium billboards: on
// Cesium the contract's batched billboards carry atlas keys, not the
// glyph data-URIs.
window.__collectBillboards = function () { window.__collectBillboards = function () {
const v = window.__godsEyeView.viewer; return ['flights', 'military'].flatMap((id) => window.__godsEyeView.layers.drawn(id) ?? [])
const out = []; .filter((row) => !row.trackedModel)
const walk = (coll) => { .map(({ id, image, scale, show }) => ({ id, image, scale, show }));
const n = coll.length;
for (let i = 0; i < n; i++) {
let p;
try { p = coll.get(i); } catch { continue; }
if (!p) continue;
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
if (p.image !== undefined && p.alignedAxis !== undefined) {
out.push({ id: p.id, image: p.image, scale: p.scale, show: p.show });
}
}
};
walk(v.scene.primitives);
return out;
}; };
// Every glTF model primitive (modelMatrix + ready flag), with id + scale. // Every fleet and tracked 3D model, with id + scale (the probe's model
// handles). The world bounding radius is not on the contract: null.
window.__collectModels = function () { window.__collectModels = function () {
const v = window.__godsEyeView.viewer; return ['flights', 'military'].flatMap((id) => window.__godsEyeView.layers.drawn(id) ?? [])
const out = []; .filter((row) => row.model && (row.trackedModel || !row.tracked))
const walk = (coll) => { .map(({ id, model }) => ({ id, ...model, radius: null }));
const n = coll.length;
for (let i = 0; i < n; i++) {
let p;
try { p = coll.get(i); } catch { continue; }
if (!p) continue;
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
if (p.modelMatrix && typeof p.ready !== 'undefined' && p.image === undefined) {
let radius = null;
try { radius = p.ready && p.boundingSphere ? p.boundingSphere.radius : null; } catch { /* not ready */ }
out.push({ id: p.id, scale: p.scale, ready: p.ready, show: p.show, radius });
}
}
};
walk(v.scene.primitives);
return out;
}; };
// Displayed (render-delayed) latitude of a synthetic plane right now. // Displayed (render-delayed) latitude of a synthetic plane right now.
window.__displayedState = function (layer, idx, renderDelaySec) { window.__displayedState = function (layer, idx, renderDelaySec) {
@@ -333,43 +321,46 @@ async function main() {
}; };
// Canvas coordinates of every synthetic plane at its displayed position — // Canvas coordinates of every synthetic plane at its displayed position —
// ground truth for identifying which glyph is which in a screenshot. // ground truth for identifying which glyph is which in a screenshot.
window.__planeCanvasCoords = function (layer, renderDelaySec, altOf) { window.__planeCanvasCoords = async function (layer, renderDelaySec, altOf) {
const v = window.__godsEyeView.viewer; const geo = await import('/src/geo/index.js');
const { engine } = window.__godsEyeView;
const S = window.__SPR; const S = window.__SPR;
const C3 = v.camera.position.constructor;
return S[layer].map((p, i) => { return S[layer].map((p, i) => {
const s = window.__displayedState(layer, i, renderDelaySec); const s = window.__displayedState(layer, i, renderDelaySec);
const pos = C3.fromDegrees(s.lon, s.lat, altOf === 'ft' ? p.altFt * 0.3048 : p.altM); const pos = geo.cartesian3.fromDegrees(s.lon, s.lat, altOf === 'ft' ? p.altFt * 0.3048 : p.altM);
const c = v.scene.cartesianToCanvasCoordinates(pos); const c = engine.projector.worldToWindow(pos);
return `${p.icao || p.hex}=(${c ? Math.round(c.x) : '-'},${c ? Math.round(c.y) : '-'})`; return `${p.icao || p.hex}=(${c ? Math.round(c.x) : '-'},${c ? Math.round(c.y) : '-'})`;
}).join(' '); }).join(' ');
}; };
}); });
// ---- Prime history through the render delay (same pattern as b3) ------- // ---- Prime history through the render delay (same pattern as the heading checks) -------
console.log('Priming straight-flight history through the render delay (30 s / 15 s)...'); console.log('Priming straight-flight history through the render delay (30 s / 15 s)...');
const primed = await page.evaluate(async () => { const primed = await page.evaluate(async () => {
const dm = window.__godsEyeView.dataManager; const dm = window.__godsEyeView.dataManager;
const v = window.__godsEyeView.viewer; const v = window.__godsEyeView.viewer ?? null;
const hasMil = dm.layers.has('military');
window.__SPR.timeOffsetSec = -32; window.__SPR.timeOffsetSec = -32;
await dm.setEnabled('flights', true); await dm.setEnabled('flights', true);
await dm.setEnabled('military', true); if (hasMil) await dm.setEnabled('military', true);
const fl = dm.layers.get('flights').module; const fl = dm.layers.get('flights').module;
const mil = dm.layers.get('military').module; const mil = hasMil ? dm.layers.get('military').module : null;
for (const off of [-24, -16, -8, 0]) { for (const off of [-24, -16, -8, 0]) {
window.__SPR.timeOffsetSec = off; window.__SPR.timeOffsetSec = off;
await fl.update(v); await fl.update(v);
await mil.update(v); await mil?.update(v);
} }
window.__SPR.timeOffsetSec = 0; window.__SPR.timeOffsetSec = 0;
window.__SPR_DRIVER = setInterval(() => { fl.update(v); mil.update(v); }, 15000); window.__SPR_DRIVER = setInterval(() => { fl.update(v); mil?.update(v); }, 15000);
return { fl: fl.getStats().count, mil: mil.getStats().count, hits: window.__SPR_HITS }; return { fl: fl.getStats().count, mil: mil ? mil.getStats().count : null, hits: window.__SPR_HITS };
}); });
console.log(` flights count=${primed.fl} military count=${primed.mil} | shim hits opensky=${primed.hits.opensky} mil=${primed.hits.mil}`); console.log(` flights count=${primed.fl} military count=${primed.mil} | shim hits opensky=${primed.hits.opensky} mil=${primed.hits.mil}`);
const hasMilitary = primed.mil !== null;
if (!hasMilitary) console.log(` [SKIP] the military rows — no military layer on ${ENGINE}`);
record('B1 ingest: synthetic planes in both layers', record('B1 ingest: synthetic planes in both layers',
primed.fl === SPRITES.flights.length && primed.mil === SPRITES.military.length, primed.fl === SPRITES.flights.length && (!hasMilitary || primed.mil === SPRITES.military.length),
`flights=${primed.fl}/${SPRITES.flights.length} military=${primed.mil}/${SPRITES.military.length}`); `flights=${primed.fl}/${SPRITES.flights.length} military=${hasMilitary ? `${primed.mil}/${SPRITES.military.length}` : 'n/a'}`);
if (!(primed.fl > 0 && primed.mil > 0)) { finish(); return; } if (!(primed.fl > 0 && (!hasMilitary || primed.mil > 0))) { finish(); return; }
// ======================================================================== // ========================================================================
// B2 — billboard glyph + scale per class (machine check) // B2 — billboard glyph + scale per class (machine check)
@@ -381,37 +372,38 @@ async function main() {
let scaleBad = []; let scaleBad = [];
for (const [id, klass] of expectedFlights) { for (const [id, klass] of expectedFlights) {
const bb = bbById.get(id); const bb = bbById.get(id);
const wantImg = aircraftIcon(klass); const wantImg = [aircraftIcon(klass), aircraftIcon(klass, 192)];
const wantScale = CLASS_SCALE_2D[klass] || 1; const wantScale = CLASS_SCALE_2D[klass] || 1;
if (!bb || bb.image !== wantImg) glyphBad.push(`${id}(${klass})${bb ? ':wrong-image' : ':missing'}`); if (!bb || !wantImg.includes(bb.image)) glyphBad.push(`${id}(${klass})${bb ? ':wrong-image' : ':missing'}`);
if (bb && Math.abs(bb.scale - wantScale) > 1e-9) scaleBad.push(`${id}: ${bb.scale} != ${wantScale}`); if (bb && Math.abs(bb.scale - wantScale) > 1e-9) scaleBad.push(`${id}: ${bb.scale} != ${wantScale}`);
} }
for (const [id, klass] of expectedMilitary) { for (const [id, klass] of hasMilitary ? expectedMilitary : []) {
const bb = bbById.get(id); const bb = bbById.get(id);
const wantImg = aircraftIcon(klass); const wantImg = [aircraftIcon(klass), aircraftIcon(klass, 192)];
const wantScale = MIL_BILLBOARD_SCALE * (CLASS_SCALE_2D[klass] || 1); const wantScale = MIL_BILLBOARD_SCALE * (CLASS_SCALE_2D[klass] || 1);
if (!bb || bb.image !== wantImg) glyphBad.push(`${id}(${klass})${bb ? ':wrong-image' : ':missing'}`); if (!bb || !wantImg.includes(bb.image)) glyphBad.push(`${id}(${klass})${bb ? ':wrong-image' : ':missing'}`);
if (bb && Math.abs(bb.scale - wantScale) > 1e-9) scaleBad.push(`${id}: ${bb.scale} != ${wantScale}`); if (bb && Math.abs(bb.scale - wantScale) > 1e-9) scaleBad.push(`${id}: ${bb.scale} != ${wantScale}`);
} }
record('B2a: every synthetic billboard has its class glyph (data-URI match)', record('B2a: every synthetic billboard has its class glyph (data-URI match)',
glyphBad.length === 0, glyphBad.length ? glyphBad.join(' ') : `${expectedFlights.size + expectedMilitary.size} billboards matched`); glyphBad.length === 0, glyphBad.length ? glyphBad.join(' ') : `${expectedFlights.size + (hasMilitary ? expectedMilitary.size : 0)} billboards matched`);
record('B2b: every synthetic billboard has its class scale', record('B2b: every synthetic billboard has its class scale',
scaleBad.length === 0, scaleBad.length ? scaleBad.join(' ') : 'all scales exact'); scaleBad.length === 0, scaleBad.length ? scaleBad.join(' ') : 'all scales exact');
const distinctGlyphs = new Set([...expectedFlights.values(), ...expectedMilitary.values()].map((k) => aircraftIcon(k))); const distinctGlyphs = new Set([...expectedFlights.values(), ...(hasMilitary ? expectedMilitary.values() : [])].map((k) => aircraftIcon(k)));
record('B2c: all 8 classes exercised with 8 distinct glyphs', const wantGlyphs = hasMilitary ? 8 : new Set(expectedFlights.values()).size;
distinctGlyphs.size === 8, `${distinctGlyphs.size} distinct data URIs`); record(`B2c: all ${wantGlyphs} classes exercised with ${wantGlyphs} distinct glyphs`,
distinctGlyphs.size === wantGlyphs, `${distinctGlyphs.size} distinct data URIs`);
// ======================================================================== // ========================================================================
// 2D screenshots // 2D screenshots
// ======================================================================== // ========================================================================
console.log('\n2D screenshots → qa-shots/b5/'); console.log('\n2D screenshots → qa-shots/aircraft-sprites/');
const topDown = async (lat, lon, height) => { const topDown = async (lat, lon, height) => {
await page.evaluate(({ lat, lon, height }) => { await page.evaluate(async ({ lat, lon, height }) => {
const v = window.__godsEyeView.viewer; const camera = window.__godsEyeView.engine.camera;
v.camera.cancelFlight(); // the boot fly-to-Austin otherwise stomps setView camera.cancelFlight(); // the boot fly-to-Austin otherwise stomps setView
const C3 = v.camera.position.constructor; const geo = await import('/src/geo/index.js');
v.camera.setView({ camera.setView({
destination: C3.fromDegrees(lon, lat, height), destination: geo.cartesian3.fromDegrees(lon, lat, height),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
}); });
}, { lat, lon, height }); }, { lat, lon, height });
@@ -424,6 +416,7 @@ async function main() {
const zoomToRange = async (want) => { const zoomToRange = async (want) => {
await page.evaluate((want) => { await page.evaluate((want) => {
const v = window.__godsEyeView.viewer; const v = window.__godsEyeView.viewer;
if (!v) return; // the engine follow keeps its own range (screenshots only)
const p = v.camera.position; const p = v.camera.position;
const d = Math.hypot(p.x, p.y, p.z); const d = Math.hypot(p.x, p.y, p.z);
if (d > 1e6) return; // not in a tracked reference frame — leave it alone if (d > 1e6) return; // not in a tracked reference frame — leave it alone
@@ -438,19 +431,21 @@ async function main() {
const flRowLat = await rowLat('flights', 2, 30); const flRowLat = await rowLat('flights', 2, 30);
const milRowLat = await rowLat('military', 4, 15); const milRowLat = await rowLat('military', 4, 15);
await topDown((flRowLat + milRowLat) / 2, ROW_LON0 + 3 * ROW_STEP_DEG, 16000); await topDown((flRowLat + milRowLat) / 2, ROW_LON0 + 3 * ROW_STEP_DEG, 16000);
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-2d-spread-bothrows.png') }); await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-2d-spread-bothrows.png') });
console.log(' saved b5-2d-spread-bothrows.png (flights row top, military row bottom)'); console.log(' saved aircraft-sprites-2d-spread-bothrows.png (flights row top, military row bottom)');
// Row widths: military spans 7 steps (~8.1 km) → frame ~13.9 km at 12 km up; // Row widths: military spans 7 steps (~8.1 km) → frame ~13.9 km at 12 km up;
// flights spans 5 steps (~5.8 km, +caa004's eastward drift) → ~10.4 km at 9 km up. // flights spans 5 steps (~5.8 km, +caa004's eastward drift) → ~10.4 km at 9 km up.
await topDown(await rowLat('military', 4, 15), ROW_LON0 + 3.5 * ROW_STEP_DEG, 17000); if (hasMilitary) {
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-2d-military-row.png') }); await topDown(await rowLat('military', 4, 15), ROW_LON0 + 3.5 * ROW_STEP_DEG, 17000);
console.log(' saved b5-2d-military-row.png (8 classes, amber)'); await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-2d-military-row.png') });
console.log(` glyph positions: ${await page.evaluate(() => window.__planeCanvasCoords('military', 15, 'ft'))}`); console.log(' saved aircraft-sprites-2d-military-row.png (8 classes, amber)');
console.log(` glyph positions: ${await page.evaluate(() => window.__planeCanvasCoords('military', 15, 'ft'))}`);
}
await topDown(await rowLat('flights', 2, 30), ROW_LON0 + 2.5 * ROW_STEP_DEG, 12000); await topDown(await rowLat('flights', 2, 30), ROW_LON0 + 2.5 * ROW_STEP_DEG, 12000);
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-2d-flights-row.png') }); await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-2d-flights-row.png') });
console.log(' saved b5-2d-flights-row.png (6 category classes, white)'); console.log(' saved aircraft-sprites-2d-flights-row.png (6 category classes, white)');
console.log(` glyph positions: ${await page.evaluate(() => window.__planeCanvasCoords('flights', 30, 'm'))}`); console.log(` glyph positions: ${await page.evaluate(() => window.__planeCanvasCoords('flights', 30, 'm'))}`);
// Tracked tint: commercial tracked plane goes cyan; neighbors stay white. // Tracked tint: commercial tracked plane goes cyan; neighbors stay white.
@@ -458,11 +453,11 @@ async function main() {
window.__godsEyeView.dataManager.layers.get('flights').module.trackById(icao); window.__godsEyeView.dataManager.layers.get('flights').module.trackById(icao);
}, 'caa002'); }, 'caa002');
await sleep(3000); await sleep(3000);
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-2d-tracked-cyan.png') }); await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-2d-tracked-cyan.png') });
console.log(' saved b5-2d-tracked-cyan.png (tracked airliner cyan, follow camera)'); console.log(' saved aircraft-sprites-2d-tracked-cyan.png (tracked airliner cyan, follow camera)');
// ======================================================================== // ========================================================================
// B3 + 3D screenshots — flights cluster // Model scale and 3D screenshots — flights cluster
// ======================================================================== // ========================================================================
console.log('\n3D — flights cluster (airplane.glb, per-class scale)'); console.log('\n3D — flights cluster (airplane.glb, per-class scale)');
await page.evaluate(() => { await page.evaluate(() => {
@@ -499,16 +494,19 @@ async function main() {
await sleep(1500); await sleep(1500);
await zoomToRange(900); await zoomToRange(900);
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-flights-mid.png') }); await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-3d-flights-mid.png') });
console.log(' saved b5-3d-flights-mid.png (900 m range — near the min-pixel clamp)'); console.log(' saved aircraft-sprites-3d-flights-mid.png (900 m range — near the min-pixel clamp)');
await zoomToRange(450); await zoomToRange(450);
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-flights-close.png') }); await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-3d-flights-close.png') });
console.log(' saved b5-3d-flights-close.png (450 m range: widebody left, airliner center, fastjet right)'); console.log(' saved aircraft-sprites-3d-flights-close.png (450 m range: widebody left, airliner center, fastjet right)');
} }
// ======================================================================== // ========================================================================
// B3 + 3D screenshots — military cluster // Model scale and 3D screenshots — military cluster
// ======================================================================== // ========================================================================
if (!hasMilitary) {
console.log(` [SKIP] 3D military — no military layer on ${ENGINE}`);
} else {
console.log('\n3D — military cluster (jet.glb, per-class scale)'); console.log('\n3D — military cluster (jet.glb, per-class scale)');
await page.evaluate(() => { await page.evaluate(() => {
const dm = window.__godsEyeView.dataManager; const dm = window.__godsEyeView.dataManager;
@@ -547,11 +545,12 @@ async function main() {
// jet.glb at MODEL_SCALE 1 is real-world size — same shot ranges as the // jet.glb at MODEL_SCALE 1 is real-world size — same shot ranges as the
// flights cluster (close zoom no longer puts the camera inside a model). // flights cluster (close zoom no longer puts the camera inside a model).
await zoomToRange(900); await zoomToRange(900);
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-military-far.png') }); await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-3d-military-far.png') });
console.log(' saved b5-3d-military-far.png (900 m range: C130/C17 left, F16 center, B744 right)'); console.log(' saved aircraft-sprites-3d-military-far.png (900 m range: C130/C17 left, F16 center, B744 right)');
await zoomToRange(450); await zoomToRange(450);
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-military-close.png') }); await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-3d-military-close.png') });
console.log(' saved b5-3d-military-close.png (450 m range: C17 left, F16 center, B744 right)'); console.log(' saved aircraft-sprites-3d-military-close.png (450 m range: C17 left, F16 center, B744 right)');
}
} }
record('B4: no console errors during QA run', consoleErrors.length === 0, record('B4: no console errors during QA run', consoleErrors.length === 0,
+814
View File
@@ -0,0 +1,814 @@
import { execFileSync, spawn, spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import {
appendFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import assert from 'node:assert/strict';
import puppeteer from 'puppeteer';
import { APPLICATION_LAYER_DIRECTORY } from '../src/app/layers/directory.js';
import {
assertPreviewPortAvailable,
createStrict,
ENGINES,
localPreview,
waitForEngine,
} from './qa-strict-harness.mjs';
import { closeBrowser, exitAfterFlush } from './qa/browser.mjs';
import { runScenario } from './qa/process.mjs';
import {
classifyAttempt,
historyLine,
retryStatus,
statusCounts,
} from './qa/acceptance.mjs';
// The strict acceptance: every browser scenario against one keyless local
// preview, under the strict network policy (scripts/qa-strict-harness.mjs),
// writing <output>/acceptance.json, one log and one assertion manifest per
// scenario. A timed-out pass is hung; a retry that passes is flaky and does
// not fail the run. QA_EXIT_DIAGNOSTICS=1 preserves Chrome resources and
// reports them five seconds after strict completion instead of forcing exit.
// Pages start at the 18:30 UTC nearest to the real time unless createStrict's
// time overrides it.
//
// node scripts/qa-all-layer-switches.mjs http://127.0.0.1:4208 <output>
// [--serve[=<commit>]] build <commit> (default HEAD) keyless in a clean
// worktree and serve it with every .env key blank
// [--scratch=DIR] where --serve puts its checkout and build
// [--only=a,b] run only these labels
// [--no-retry] disable the one retry of a non-passing scenario
// [--history=FILE] append a JSONL run summary; inspect it with
// node scripts/report-acceptance-flake.mjs FILE
// [--quick] the soak with 2 runs of 10 switches and 100
// switches for the long run
//
// The run fails when a scenario fails, when any catalog layer (read from
// the live page and cross-checked with src/app/layers/directory.js) has no
// assertion on either engine, or when any network policy violation shows.
const startedAt = new Date().toISOString();
const base = localPreview(process.argv[2]);
const output = process.argv[3]?.startsWith('--')
? mkdtempSync(join(tmpdir(), 'gev-acceptance-'))
: resolve(process.argv[3] ?? mkdtempSync(join(tmpdir(), 'gev-acceptance-')));
const flag = (name) =>
process.argv
.find((arg) => arg === `--${name}` || arg.startsWith(`--${name}=`))
?.split('=')[1] ?? (process.argv.includes(`--${name}`) ? true : null);
const only = flag('only') ? String(flag('only')).split(',') : null;
const quick = Boolean(flag('quick'));
mkdirSync(join(output, 'evidence'), { recursive: true });
const root = fileURLToPath(new URL('..', import.meta.url));
const git = (...args) =>
execFileSync('git', args, { cwd: root, encoding: 'utf8' });
const dirty = () =>
git('status', '--porcelain')
.split('\n')
.filter(Boolean)
.map((line) => line.slice(3));
// Fail once, with the fix, instead of once per scenario.
let chrome;
try {
const browser = await puppeteer.launch({ headless: true });
chrome = await browser.version();
await closeBrowser(browser);
} catch (error) {
console.error(
'Puppeteer cannot start Chrome: ' +
String(error?.message)
.split('\n')[0]
.replace(/ This can occur.*/, '') +
'\nPoint PUPPETEER_EXECUTABLE_PATH at an installed Chrome, for example\n' +
' PUPPETEER_EXECUTABLE_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"\n' +
'or download the pinned build with `npx puppeteer browsers install chrome`.',
);
process.exit(2);
}
// Every name the checkout's .env files define, plus key-like names in this
// environment: all blank for the build and the preview.
const KEY_LIKE = /(_KEY|_TOKEN|_SECRET|_CLIENT_ID|PASSWORD)$/;
/** The [name, value] pairs of the checkout's .env and .env.local, in order. */
const dotenv = () =>
['.env', '.env.local']
.filter((file) => existsSync(join(root, file)))
.flatMap((file) => readFileSync(join(root, file), 'utf8').split('\n'))
.map((line) => line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)/))
.filter(Boolean)
.map(([, name, raw]) => [name, raw.trim().replace(/^["']|["']$/g, '')]);
const dotenvNames = () => [...new Set(dotenv().map(([name]) => name))].sort();
const envNames = () =>
[
...new Set([
...dotenvNames(),
...Object.keys(process.env).filter((name) => KEY_LIKE.test(name)),
]),
].sort();
// Credentials only (model names and URLs are settings the code may also
// carry as defaults).
const envValues = () =>
dotenv()
.filter(([name, value]) => KEY_LIKE.test(name) && value.length >= 8)
.map(([, value]) => value);
const scrubbed = () => ({
...process.env,
...Object.fromEntries(envNames().map((name) => [name, ''])),
});
// The report names no local directory: the output, the served checkout and
// this repository become placeholders (logs carry stack traces).
const machinePaths = (text) => {
let out = String(text);
for (const [path, name] of [
[served?.scratch, '<scratch>'],
[output, '<output>'],
[root.replace(/\/$/, ''), '<repo>'],
])
if (path) out = out.split(path).join(name);
return out;
};
let served = null;
let preview = null;
const cleanup = () => {
if (preview && !preview.killed) preview.kill('SIGTERM');
if (served?.worktree)
try {
git('worktree', 'remove', '--force', served.worktree);
served.worktree = null;
} catch (error) {
console.error('Could not remove', served.worktree, error.message);
}
};
process.on('exit', cleanup);
process.on('SIGINT', () => process.exit(130));
if (flag('serve')) {
const commit = git(
'rev-parse',
flag('serve') === true ? 'HEAD' : String(flag('serve')),
).trim();
// --scratch=DIR keeps the checkout and build out of the system temp.
const scratchRoot = flag('scratch')
? resolve(String(flag('scratch')))
: tmpdir();
mkdirSync(scratchRoot, { recursive: true });
await assertPreviewPortAvailable(base.href);
const scratch = mkdtempSync(join(scratchRoot, 'gev-acceptance-build-'));
const worktree = join(scratch, 'checkout');
const dist = join(scratch, 'dist');
git('worktree', 'add', '--detach', worktree, commit);
symlinkSync(join(root, 'node_modules'), join(worktree, 'node_modules'));
served = {
scratch,
commit,
worktree,
dist,
// Names from .env files only; other key-like variables of this shell
// are blanked too but only counted.
blankedEnv: dotenvNames(),
otherKeyLikeVariablesBlanked: envNames().filter(
(name) => !dotenvNames().includes(name),
).length,
envFilesInCheckout: ['.env', '.env.local'].filter((f) =>
existsSync(join(worktree, f)),
),
};
console.log('Building', commit, 'keyless into', dist);
const build = spawnSync(
process.execPath,
[join(root, 'node_modules/vite/bin/vite.js'), 'build', '--outDir', dist],
{ cwd: worktree, env: scrubbed(), encoding: 'utf8', maxBuffer: 1 << 26 },
);
if (build.status !== 0) {
console.error(build.stdout + build.stderr);
process.exit(2);
}
// No .env credential value may reach the built files.
const values = envValues();
let leaks = 0;
const walk = (dir) => {
for (const name of readdirSync(dir)) {
const file = join(dir, name);
if (statSync(file).isDirectory()) walk(file);
else if (/\.(js|html|json|css|webmanifest)$/.test(name)) {
const text = readFileSync(file, 'utf8');
for (const value of values) if (text.includes(value)) leaks++;
}
}
};
walk(dist);
served.distCredentialValuesFound = leaks;
served.mainChunk = JSON.parse(
spawnSync(
process.execPath,
[join(root, 'scripts/check-main-chunk.mjs'), `--dist=${dist}`, '--json'],
{ cwd: root, encoding: 'utf8' },
).stdout || 'null',
);
preview = spawn(
process.execPath,
[
join(root, 'node_modules/vite/bin/vite.js'),
'preview',
'--outDir',
dist,
'--host',
base.hostname,
'--port',
base.port,
'--strictPort',
],
{ cwd: worktree, env: scrubbed(), stdio: 'ignore' },
);
for (let i = 0; i < 60; i++) {
try {
if ((await fetch(base.origin + '/')).ok) break;
} catch {
// Not up yet.
}
await new Promise((r) => setTimeout(r, 500));
}
}
try {
const response = await fetch(base.origin + '/', {
signal: AbortSignal.timeout(10000),
});
if (!response.ok) throw Error('HTTP ' + response.status);
} catch (error) {
console.error(
'No preview answers at ' + base.origin + ' (' + error.message + ').',
);
process.exit(2);
}
// The preview reports no paid key: its own keyless status routes.
const probe = async (path) => {
try {
const r = await fetch(base.origin + path, {
headers: { 'Sec-Fetch-Site': 'same-origin' },
});
return { status: r.status, body: await r.json().catch(() => null) };
} catch (error) {
return { error: error.message };
}
};
const keys = {
openaiHudSummary: await probe('/api/openai/hud-summary/status'),
firms: await probe('/api/firms/status'),
tomtom: await probe('/api/tomtom/status'),
mapillary: await probe('/api/mapillary/status'),
};
const paidKeysConfigured = [
keys.openaiHudSummary.body?.configured !== false && 'OpenAI',
keys.firms.body?.hasKey !== false && 'FIRMS',
keys.tomtom.body?.hasKey !== false && 'TomTom',
keys.mapillary.body?.configured !== false && 'Mapillary',
].filter(Boolean);
const dirtyFiles = dirty();
const soakRuns = quick
? ['--runs=2', '--swaps=10']
: ['--runs=6', '--swaps=50'];
const longRun = quick ? '--swaps=100' : '--swaps=500';
// label, script, layers, extra arguments, timeout in seconds.
const runs = [
[
'switch-equals-cold-start--markers',
'switch-equals-cold-start',
['news', 'shipwrecks'],
['--group=markers'],
],
[
'switch-equals-cold-start--aircraft',
'switch-equals-cold-start',
['flights', 'military'],
['--group=aircraft'],
],
[
'switch-equals-cold-start--local',
'switch-equals-cold-start',
['local-adsb', 'local-dams', 'local-datacenters'],
['--group=local'],
],
[
'switch-equals-cold-start--ground',
'switch-equals-cold-start',
['ais-live-vessels', 'air-quality', 'gnss-integrity', 'fire-perimeters'],
['--group=ground'],
],
[
'switch-equals-cold-start--contacts',
'switch-equals-cold-start',
['military-awareness', 'military-installations'],
['--group=contacts'],
],
[
'switch-equals-cold-start--open',
'switch-equals-cold-start',
['drone-airspace', 'osm-places', 'sea-marks', 'ski-pistes'],
['--group=open'],
],
[
'switch-equals-cold-start--weather',
'switch-equals-cold-start',
[
'weather-radar',
'weather-satellite',
'weather-lightning',
'weather-cyclones',
],
['--group=weather'],
],
[
'switch-equals-cold-start--wind',
'switch-equals-cold-start',
['wind'],
['--group=wind'],
],
[
'switch-equals-cold-start--ghostmaps',
'switch-equals-cold-start',
['s2-ghostmaps'],
['--group=ghostmaps'],
],
[
'switch-equals-cold-start--wildlife',
'switch-equals-cold-start',
['wildlife-sightings'],
['--group=wildlife'],
],
[
'switch-equals-cold-start--urban',
'switch-equals-cold-start',
['bikeshare', 'transit', 'earthquakes'],
['--group=urban'],
],
[
'switch-equals-cold-start--cameras',
'switch-equals-cold-start',
['cctv', 'alpr-cameras'],
['--group=cameras'],
],
[
'switch-equals-cold-start--radio',
'switch-equals-cold-start',
['radio'],
['--group=radio'],
],
[
'switch-equals-cold-start--space',
'switch-equals-cold-start',
['satellites', 'rocket-launches'],
['--group=space'],
],
[
'switch-equals-cold-start--traffic',
'switch-equals-cold-start',
['traffic'],
['--group=traffic'],
],
[
'switch-equals-cold-start--street',
'switch-equals-cold-start',
['street-level'],
['--group=street'],
],
[
'switch-equals-cold-start--imagery',
'switch-equals-cold-start',
['recent-imagery'],
['--group=imagery'],
],
[
'switch-equals-cold-start--nepal',
'switch-equals-cold-start',
['bhote-koshi-locator', 'bhote-koshi-2026'],
['--group=nepal'],
],
[
'switch-equals-cold-start--cables',
'switch-equals-cold-start',
['telegeography-submarine-cables', 'directions'],
['--group=cables'],
],
[
'switch-equals-cold-start--fires',
'switch-equals-cold-start',
['local-firms'],
['--group=fires'],
],
['engine-switch', 'engine-switch', ['earthquakes']],
['director-engine-switch', 'director-engine-switch', []],
['layer-handoff', 'layer-handoff', ['flights']],
['layer-handoff--military', 'layer-handoff', ['military'], ['--military']],
[
'ground-layer-switch',
'ground-layer-switch',
['ais-live-vessels', 'air-quality', 'gnss-integrity', 'fire-perimeters'],
],
[
'contact-switch',
'contact-switch',
['military-awareness', 'military-installations'],
],
['local-adsb-engine-switch', 'local-adsb-engine-switch', ['local-adsb']],
['ghostmaps-switch', 'ghostmaps-switch', ['s2-ghostmaps']],
['wildlife-switch', 'wildlife-switch', ['wildlife-sightings']],
[
'open-layers-switch',
'open-layers-switch',
['drone-airspace', 'osm-places', 'sea-marks', 'ski-pistes'],
],
[
'weather-engine-switch',
'weather-engine-switch',
[
'weather-radar',
'weather-satellite',
'weather-lightning',
'weather-cyclones',
],
],
['wind-engine-switch', 'wind-engine-switch', ['wind']],
[
'infrastructure-engine-switch',
'infrastructure-engine-switch',
['local-dams', 'local-datacenters'],
],
[
'submarine-cables-engine-switch',
'submarine-cables-engine-switch',
['telegeography-submarine-cables'],
],
['directions-engine-switch', 'directions-engine-switch', ['directions']],
['nepal-locator-switch', 'nepal-locator-switch', ['bhote-koshi-locator']],
['nepal-event-switch', 'nepal-event-switch', ['bhote-koshi-2026']],
['traffic-engine-switch', 'traffic-engine-switch', ['traffic']],
[
'traffic-engine-switch--simulation',
'traffic-engine-switch',
['traffic'],
['--simulation'],
],
[
'street-level-engine-switch',
'street-level-engine-switch',
['street-level'],
],
[
'street-level-engine-switch--panoramax',
'street-level-engine-switch',
['street-level'],
['--panoramax'],
],
['radio-engine-switch', 'radio-engine-switch', ['radio']],
['imagery-contract', 'imagery-contract', ['recent-imagery']],
['tiles-fixture', 'tiles-fixture', ['osm-places']],
['fire-camera-switch', 'fire-camera-switch', ['alpr-cameras']],
[
'fire-camera-switch--fires',
'fire-camera-switch',
['local-firms'],
['--fires'],
],
['urban-engine-switch', 'urban-engine-switch', ['bikeshare']],
[
'urban-engine-switch--transit',
'urban-engine-switch',
['transit'],
['--transit'],
],
['cctv-engine-switch', 'cctv-engine-switch', ['cctv']],
['space-engine-switch', 'space-engine-switch', ['satellites']],
[
'space-engine-switch--missions',
'space-engine-switch',
['rocket-launches'],
['--missions'],
],
// One application per page, and voice across a switch: these block paid
// routes themselves but do not run under the strict harness, so they add
// no assertion manifest and no network ledger.
['app-once', 'app-once', [], [], 600, true],
['voice-engine-switch', 'voice-engine-switch', [], [], 300, true],
['strict-rapid-switch', 'strict-rapid-switch', []],
['strict-hidden-switch', 'strict-hidden-switch', []],
['strict-context-loss', 'strict-context-loss', []],
['strict-parked-release', 'strict-parked-release', []],
['strict-navara-hd', 'strict-navara-hd', []],
['strict-failure-matrix', 'strict-failure-matrix', [], [], 600],
['strict-cold-chunks', 'strict-cold-chunks', []],
['strict-prod-smoke', 'strict-prod-smoke', []],
// The app's visual styles, bloom, contact sonar, cockpit clouds, and the
// annotations, Draw, Import and the scope mask with the celestial ring on
// Navara (those are tools and display overlays, not catalog layers:
// TOOL_IDS).
['navara-visual-styles', 'navara-visual-styles', []],
['navara-bloom', 'navara-bloom', []],
['navara-sonar', 'navara-sonar', []],
['navara-cockpit-clouds', 'navara-cockpit-clouds', []],
['navara-annotations', 'navara-annotations', ['annotations']],
['navara-draw', 'navara-draw', ['draw']],
['navara-geo-import', 'navara-geo-import', ['geo-import']],
['navara-scope', 'navara-scope', ['scope-mask', 'celestial-ring']],
['strict-soak', 'strict-soak', [], soakRuns, 1200],
[
'strict-soak--long',
'strict-soak',
[],
['--runs=1', longRun, '--assert'],
1800,
],
];
// Ids a scenario covers that are tools, not catalog layers: their
// assertions are recorded, but the catalog comparison leaves them out.
const TOOL_IDS = new Set([
'annotations',
'draw',
'geo-import',
'scope-mask',
'celestial-ring',
]);
const scenarioIds = [
...new Set(
runs.flatMap(([, , ids]) => ids).filter((id) => !TOOL_IDS.has(id)),
),
].sort();
const directoryIds = APPLICATION_LAYER_DIRECTORY.map((l) => l.id).sort();
// The catalog as the live page has it, on both engines.
const liveCatalog = {};
{
const strict = createStrict({
origin: base.origin,
layers: [],
name: 'catalog',
});
const browser = await strict.launch();
try {
for (const engine of ENGINES) {
const page = await browser.newPage();
await page.goto(
`${base.origin}/?engine=${engine}&welcome=0#v=2&lat=30.27&lon=-97.74&alt=12000&pitch=-90&map=osm&l=`,
);
await waitForEngine(page, engine);
liveCatalog[engine] = await page.evaluate(() =>
[...window.__godsEyeView.dataManager.layers.keys()].sort(),
);
await page.close();
}
} finally {
await closeBrowser(browser);
}
liveCatalog.violations = strict.ledger.violations;
}
const scenarios = [];
const report = {
commit: git('rev-parse', 'HEAD').trim(),
dirty: dirtyFiles.length > 0,
dirtyFiles,
dirtyFilesAtEnd: null,
chrome,
puppeteerExecutable: process.env.PUPPETEER_EXECUTABLE_PATH || null,
node: process.version,
platform: process.platform + ' ' + process.arch,
baseUrl: base.origin,
preview: served
? {
servedByRunner: true,
appCommit: served.commit,
appCheckoutClean: true,
scenariosFrom: 'the same clean checkout',
envFilesInCheckout: served.envFilesInCheckout,
blankedEnv: served.blankedEnv,
otherKeyLikeVariablesBlanked: served.otherKeyLikeVariablesBlanked,
distCredentialValuesFound: served.distCredentialValuesFound,
}
: { servedByRunner: false },
keyProbes: keys,
paidKeysConfigured,
mainChunk: served?.mainChunk ?? null,
networkPolicy: {
blockedFirst:
'Google (*.googleapis.com, *.google.com, maps.google.*, *.gstatic.com), Cesium ion (*.cesium.com), preview /api/openai/*, /api/realtime/*, /api/google/*',
allowed:
'the preview origin, each scenario’s declared fixture URLs, keyless probes answered by the harness, and the free tile hosts answered from the disk cache',
keylessProbes: (await import('./qa-map-policy.mjs')).KEYLESS_PROBES.map(
(p) => p.pattern,
),
tileHosts: (await import('./qa-map-policy.mjs')).STRICT_TILE_HOSTS,
},
catalog: {
directory: directoryIds,
live: liveCatalog,
scenarios: scenarioIds,
},
startedAt,
finishedAt: null,
passed: 0,
failed: 0,
counts: statusCounts([]),
networkViolations: 0,
coverage: null,
uncovered: null,
scenarios,
};
const save = () =>
writeFileSync(
join(output, 'acceptance.json'),
machinePaths(JSON.stringify(report, null, 2)) + '\n',
);
save();
for (const [label, name, layers, extra = [], seconds = 300, external] of runs) {
if (only && !only.includes(label)) continue;
const script = 'scripts/qa-' + name + '.mjs';
const evidence = join(output, 'evidence', label + '.json');
const args = [base.origin, ...extra];
if (external) args.splice(0, 1, base.origin + '/');
else args.push(`--layers=${layers.join(',')}`, `--evidence=${evidence}`);
const start = Date.now();
// With --serve the scenarios run from the same clean checkout the app was
// built from, so other work in this tree cannot change them.
const scripts = served?.worktree ?? root;
const attempts = [];
for (let attempt = 1; attempt <= (flag('no-retry') ? 1 : 2); attempt++) {
rmSync(evidence, { force: true });
const attemptStart = Date.now();
const run = await runScenario(
process.execPath,
[join(scripts, script), ...args],
{
timeout: seconds * 1000,
env: scrubbed(),
},
);
const log = machinePaths(
(run.stdout || '') + (run.stderr || '') + (run.error?.message || ''),
);
let manifest = null;
let evidenceError = null;
if (existsSync(evidence)) {
try {
manifest = JSON.parse(machinePaths(readFileSync(evidence, 'utf8')));
} catch (error) {
evidenceError = 'Invalid evidence: ' + error.message;
}
}
const status =
evidenceError && !run.error
? 'fail'
: classifyAttempt(run, manifest, { external });
const logFile = `${label}.attempt-${attempt}.log`;
const evidenceFile = manifest
? `evidence/${label}.attempt-${attempt}.json`
: null;
writeFileSync(join(output, logFile), log);
if (manifest)
writeFileSync(
join(output, evidenceFile),
JSON.stringify(manifest, null, 2) + '\n',
);
attempts.push({
status,
exitCode: run.status,
seconds: Math.round((Date.now() - attemptStart) / 100) / 10,
log: logFile,
logSha256: createHash('sha256').update(log).digest('hex'),
evidence: evidenceFile,
error: evidenceError ?? manifest?.error ?? run.error?.message ?? null,
});
writeFileSync(join(output, label + '.log'), log);
if (manifest)
writeFileSync(evidence, JSON.stringify(manifest, null, 2) + '\n');
console.log(
status.toUpperCase(),
label,
attempts.at(-1).seconds,
`attempt ${attempt}`,
);
if (status === 'pass' || attempt === 2 || flag('no-retry')) break;
}
const last = attempts.at(-1);
const status = retryStatus(attempts[0].status, attempts[1]?.status);
const manifest = last.evidence
? JSON.parse(readFileSync(join(output, last.evidence), 'utf8'))
: null;
scenarios.push({
label,
script,
args: args.map((arg) =>
arg.includes(output) ? arg.replace(output, '<output>') : arg,
),
layers,
external: Boolean(external),
status,
exitCode: last.exitCode,
seconds: Math.round((Date.now() - start) / 100) / 10,
log: label + '.log',
logSha256: last.logSha256,
attempts,
evidence: manifest ? 'evidence/' + label + '.json' : null,
assertions: manifest?.assertions.length ?? 0,
coverage: manifest?.coverage ?? null,
picks: manifest?.picks ?? [],
network: manifest
? {
requests: manifest.network.requests,
violations: manifest.network.violations,
external: manifest.network.external,
tileCache: manifest.network.tileCache,
probes: manifest.network.probes,
routes: manifest.network.routes,
previewApi: manifest.network.previewApi,
fixtures: manifest.network.fixtures,
}
: null,
contracts: manifest?.contracts ?? null,
error: last.error,
});
if (manifest?.soak) scenarios.at(-1).soak = manifest.soak;
report.counts = statusCounts(scenarios);
report.passed = report.counts.pass + report.counts.flaky;
report.failed = scenarios.length - report.passed;
report.networkViolations =
scenarios.reduce(
(sum, row) => sum + (row.network?.violations.length ?? 0),
0,
) + liveCatalog.violations.length;
save();
console.log(status.toUpperCase(), label, scenarios.at(-1).seconds);
}
// Coverage that cannot be faked: every catalog id needs an assertion on each
// engine from some scenario's manifest.
const catalogIds = [
...new Set([...directoryIds, ...(liveCatalog.navara ?? [])]),
].sort();
report.coverage = Object.fromEntries(
catalogIds.map((id) => [
id,
Object.fromEntries(
ENGINES.map((engine) => [
engine,
scenarios.reduce(
(sum, row) => sum + (row.coverage?.[id]?.[engine] ?? 0),
0,
),
]),
),
]),
);
report.uncovered = Object.entries(report.coverage).flatMap(([id, counts]) =>
ENGINES.filter((engine) => !counts[engine]).map(
(engine) => `${id}@${engine}`,
),
);
report.catalogMatches =
JSON.stringify(liveCatalog.navara) === JSON.stringify(directoryIds) &&
JSON.stringify(liveCatalog.cesium) === JSON.stringify(directoryIds) &&
JSON.stringify(scenarioIds) === JSON.stringify(directoryIds);
report.finishedAt = new Date().toISOString();
report.dirtyFilesAtEnd = dirty();
save();
if (flag('history'))
appendFileSync(
resolve(String(flag('history'))),
JSON.stringify(historyLine(report)) + '\n',
);
console.log(
`${catalogIds.length} catalog layers, ${report.uncovered.length} engine slots without an assertion, ` +
`${report.networkViolations} network violations, ${report.passed}/${scenarios.length} scenarios, ` +
Object.entries(report.counts)
.map(([status, count]) => `${count} ${status}`)
.join(', ') +
', ' +
`paid keys configured: ${paidKeysConfigured.join(', ') || 'none'}. ${join(output, 'acceptance.json')}`,
);
if (
report.failed ||
(!only && report.uncovered.length) ||
report.networkViolations ||
paidKeysConfigured.length ||
!report.catalogMatches
)
process.exitCode = 1;
// The preview child would keep this process alive: stop it and drop the
// checkout now.
cleanup();
await exitAfterFlush();
+103 -47
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env node #!/usr/bin/env node
// Cesium-only probes: requestRender instrumentation measures Cesium request-mode cost.
/** /**
* qa-alpr-journey.mjs — ALPR cameras on a realistic Austin camera journey. * qa-alpr-journey.mjs — ALPR cameras on a realistic Austin camera journey.
* *
* Run: node scripts/qa-alpr-journey.mjs http://localhost:4173 [--headful] * Run: node scripts/qa-alpr-journey.mjs <dev-server-url> [--fixture]
* [--passes off,on,on] [--out qa-shots/alpr-journey] [--no-assert] * [--passes off,on,on] [--out qa-shots/alpr-journey] [--no-assert]
* *
* Drives the same eased camera path several times: a neighbourhood by Camp * Drives the same eased camera path several times: a neighbourhood by Camp
@@ -14,7 +15,7 @@
* *
* Per stop it records: time from the camera stopping to cameras shown (or an * Per stop it records: time from the camera stopping to cameras shown (or an
* explicit "zoom in" state), the camera count and row status, rAF interval and * explicit "zoom in" state), the camera count and row status, rAF interval and
* Cesium render CPU time (mean / p50 / p95) while moving and while holding, and * engine render CPU time (mean / p50 / p95) while moving and while holding, and
* marker stability while the camera is still (camera-set changes after the stop * marker stability while the camera is still (camera-set changes after the stop
* has settled). Asserts, unless --no-assert: neighbourhood and downtown show * has settled). Asserts, unless --no-assert: neighbourhood and downtown show
* cameras within 2 s on the warm pass; the whole-city stop shows cameras or a * cameras within 2 s on the warm pass; the whole-city stop shows cameras or a
@@ -36,10 +37,14 @@
* Writes <out>/result.json, <out>/clip.mp4 (CDP screencast with real frame * Writes <out>/result.json, <out>/clip.mp4 (CDP screencast with real frame
* timing) and per-stop screenshots. Needs ffmpeg on PATH. * timing) and per-stop screenshots. Needs ffmpeg on PATH.
*/ */
import { whenSettled, whenTilesLoaded } from './qa/ready.mjs';
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import puppeteer from 'puppeteer'; import { blockPaidQaMaps } from './qa-map-policy.mjs';
import sharp from 'sharp'; import sharp from 'sharp';
import { installAlprFixture } from './fixtures/layers/alpr.mjs';
import { redact, isExpectedPreviewSetupError } from './qa-browserEvidence.mjs';
import { import {
createScreencast, createScreencast,
frameStats, frameStats,
@@ -60,12 +65,20 @@ const option = (name, fallback) => {
const i = argv.indexOf(name); const i = argv.indexOf(name);
return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback; return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback;
}; };
const appUrl = const appUrl = argv.find((arg) => /^https?:/.test(arg));
argv.find((arg) => /^https?:/.test(arg)) || 'http://localhost:4173'; if (!appUrl) throw new Error('An explicit dev-server URL is required');
if (
['4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes(
new URL(appUrl).port,
)
)
throw new Error('ALPR QA may not use a reserved port');
const outDir = path.resolve(option('--out', 'qa-shots/alpr-journey')); const outDir = path.resolve(option('--out', 'qa-shots/alpr-journey'));
const passes = option('--passes', 'off,on,on').split(','); const passes = option('--passes', 'off,on,on').split(',');
const assertOn = !argv.includes('--no-assert'); const assertOn = !argv.includes('--no-assert');
const dropRenders = argv.includes('--self-test-drop-renders'); const dropRenders = argv.includes('--self-test-drop-renders');
if (dropRenders && new URL(appUrl).searchParams.get('engine') === 'navara')
throw new Error('The legacy render-drop self-test requires Cesium');
fs.mkdirSync(outDir, { recursive: true }); fs.mkdirSync(outDir, { recursive: true });
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const cyanCount = async (file) => { const cyanCount = async (file) => {
@@ -152,14 +165,12 @@ const STOPS = [
}, },
]; ];
const browser = await puppeteer.launch({ const browser = await launchBrowser({
headless: !argv.includes('--headful'), headless: true,
protocolTimeout: 300_000, protocolTimeout: 300_000,
args: [ args: [
'--no-sandbox', '--no-sandbox',
...(process.platform === 'darwin' ...(process.platform === 'darwin' ? ['--enable-gpu'] : []),
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
'--disable-background-timer-throttling', '--disable-background-timer-throttling',
'--disable-renderer-backgrounding', '--disable-renderer-backgrounding',
'--disable-backgrounding-occluded-windows', '--disable-backgrounding-occluded-windows',
@@ -170,6 +181,8 @@ const result = {
startedAt: new Date().toISOString(), startedAt: new Date().toISOString(),
passes: [], passes: [],
consoleErrors: [], consoleErrors: [],
expectedPreviewErrors: [],
failedRequests: [],
failures: [], failures: [],
}; };
const fail = (message) => { const fail = (message) => {
@@ -179,23 +192,51 @@ const fail = (message) => {
let page; let page;
try { try {
page = await browser.newPage(); page = await browser.newPage();
await blockPaidQaMaps(page);
if (argv.includes('--fixture')) await installAlprFixture(page);
await page.setViewport({ width: 1440, height: 900 }); await page.setViewport({ width: 1440, height: 900 });
await page.evaluateOnNewDocument(() => await page.evaluateOnNewDocument(() =>
sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed'), sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed'),
); );
page.on('console', (message) => { page.on('console', (message) => {
if (message.type() === 'error') result.consoleErrors.push(message.text()); if (
isExpectedPreviewSetupError(message, {
preview: argv.includes('--preview'),
baseUrl: appUrl,
})
) {
result.expectedPreviewErrors.push('/api/setup/status: 404');
return;
}
if (message.type() === 'error') {
let resource = '(location unavailable)';
try {
const url = new URL(message.location()?.url);
resource = url.origin + url.pathname;
} catch {
/* no resource location */
}
result.consoleErrors.push(redact(`${message.text()} — ${resource}`));
}
});
page.on('pageerror', (error) =>
result.consoleErrors.push(redact(error.message)),
);
page.on('requestfailed', (request) => {
if (result.failedRequests.length >= 200) return;
const url = new URL(request.url());
result.failedRequests.push({
resource: redact(url.origin + url.pathname),
error: request.failure()?.errorText,
});
}); });
page.on('pageerror', (error) => result.consoleErrors.push(error.message));
const url = new URL(appUrl); const url = new URL(appUrl);
url.searchParams.set('welcome', '0'); url.searchParams.set('welcome', '0');
await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 90_000 }); await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 90_000 });
await page.waitForFunction( await page.waitForFunction(() => window.__godsEyeView?.dataManager, {
() => timeout: 120_000,
window.__godsEyeView?.dataManager && });
document.getElementById('loading-screen')?.classList.contains('hidden'), await whenSettled(page);
{ timeout: 120_000 },
);
await page.evaluate( await page.evaluate(
() => window.__godsEyeView.styleManager.initialRestorePromise, () => window.__godsEyeView.styleManager.initialRestorePromise,
); );
@@ -210,23 +251,17 @@ try {
const entry = window.__godsEyeView.dataManager.layers.get('alpr-cameras'); const entry = window.__godsEyeView.dataManager.layers.get('alpr-cameras');
const stats = entry?.module?.getStats?.() || {}; const stats = entry?.module?.getStats?.() || {};
// Layers that publish a render revision are measured by it; otherwise // Layers that publish a render revision are measured by it; otherwise
// hash the rendered entity ids and their visibility. // hash the layer's mover sets on the globe contract (ids and counts).
let hash = 0; let hash = 0;
let shown = 0; let shown = 0;
if (Number.isFinite(stats.renderRevision)) { if (Number.isFinite(stats.renderRevision)) {
hash = stats.renderRevision; hash = stats.renderRevision;
shown = stats.shown ?? stats.count ?? 0; shown = stats.shown ?? stats.count ?? 0;
} else { } else if (entry?.module && entry.enabled) {
const entities = for (const set of window.__godsEyeView.engine.movers.list()) {
entry?.module && entry.enabled if (!String(set.id).startsWith('alpr-')) continue;
? window.__godsEyeView.viewer.dataSources.getByName( shown += set.count;
'alpr-cameras', const id = `${set.id}:${set.count}`;
)[0]?.entities.values || []
: [];
for (const entity of entities) {
if (entity.show === false) continue;
shown += 1;
const id = String(entity.id);
for (let i = 0; i < id.length; i++) for (let i = 0; i < id.length; i++)
hash = (hash * 31 + id.charCodeAt(i)) | 0; hash = (hash * 31 + id.charCodeAt(i)) | 0;
} }
@@ -249,20 +284,7 @@ try {
await recorder.start(); await recorder.start();
async function waitTiles(maxMs = 20_000) { async function waitTiles(maxMs = 20_000) {
await page await whenTilesLoaded(page, { timeoutMs: maxMs }).catch(() => {});
.waitForFunction(
() => {
const { scene } = window.__godsEyeView.viewer;
if (scene.globe.show) return scene.globe.tilesLoaded;
for (let i = 0; i < scene.primitives.length; i++) {
const p = scene.primitives.get(i);
if (p.show && p.tilesLoaded === false) return false;
}
return true;
},
{ timeout: maxMs, polling: 250 },
)
.catch(() => {});
} }
for (const [passIndex, mode] of passes.entries()) { for (const [passIndex, mode] of passes.entries()) {
@@ -308,8 +330,31 @@ try {
// changed state without asking for a render. // changed state without asking for a render.
let staleFrame = null; let staleFrame = null;
if (alpr) { if (alpr) {
const shown = await cyanCount(await page.screenshot({ type: 'png' })); const shownImage = await page.screenshot({ type: 'png' });
const shown = await cyanCount(shownImage);
await page.evaluate(async () => { await page.evaluate(async () => {
const { engine } = window.__godsEyeView;
if (engine.kind === 'navara') {
await new Promise((resolve, reject) => {
let count = 4;
const timeout = setTimeout(() => {
remove();
reject(new Error('ALPR forced-frame deadline'));
}, 10000);
const remove = engine.frames.postRender.addEventListener(() => {
if (--count === 0) {
remove();
clearTimeout(timeout);
resolve();
} else
requestAnimationFrame(() =>
engine.render.request('alpr-qa-frame'),
);
});
engine.render.request('alpr-qa-frame');
});
return;
}
const { scene } = window.__godsEyeView.viewer; const { scene } = window.__godsEyeView.viewer;
const mode = scene.requestRenderMode; const mode = scene.requestRenderMode;
scene.requestRenderMode = false; scene.requestRenderMode = false;
@@ -317,12 +362,23 @@ try {
await new Promise((resolve) => requestAnimationFrame(resolve)); await new Promise((resolve) => requestAnimationFrame(resolve));
scene.requestRenderMode = mode; scene.requestRenderMode = mode;
}); });
const forced = await cyanCount(await page.screenshot({ type: 'png' })); const forcedImage = await page.screenshot({ type: 'png' });
const forced = await cyanCount(forcedImage);
staleFrame = { staleFrame = {
shown, shown,
forced, forced,
stale: Math.abs(forced - shown) > Math.max(8, forced * 0.15), stale: Math.abs(forced - shown) > Math.max(8, forced * 0.15),
}; };
if (staleFrame.stale) {
fs.writeFileSync(
path.join(outDir, `${passName}-${stop.name}-shown.png`),
shownImage,
);
fs.writeFileSync(
path.join(outDir, `${passName}-${stop.name}-forced.png`),
forcedImage,
);
}
} }
const moving = frameStats(await readFrames(page, moveStart, holdStart)); const moving = frameStats(await readFrames(page, moveStart, holdStart));
const holding = frameStats(await readFrames(page, holdStart, holdEnd)); const holding = frameStats(await readFrames(page, holdStart, holdEnd));
@@ -580,7 +636,7 @@ try {
path.join(outDir, 'result.json'), path.join(outDir, 'result.json'),
JSON.stringify(result, null, 1), JSON.stringify(result, null, 1),
); );
await browser.close(); await closeBrowser(browser);
} }
console.log( console.log(
`${result.failures.length ? 'FAIL' : 'PASS'} — ${path.join(outDir, 'result.json')}`, `${result.failures.length ? 'FAIL' : 'PASS'} — ${path.join(outDir, 'result.json')}`,
+13 -13
View File
@@ -1,4 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
// Cesium-only probes: sky commands, the Julian clock and post-process stages validate the Cesium sky.
import { whenSettled } from './qa/ready.mjs';
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
/** /**
* qa-anime-sky — the anime style must leave the sky without bands. * qa-anime-sky — the anime style must leave the sky without bands.
* *
@@ -13,14 +16,13 @@
* Usage: node scripts/qa-anime-sky.mjs [--url http://localhost:4333/] * Usage: node scripts/qa-anime-sky.mjs [--url http://localhost:4333/]
* Exit: 0 pass, 1 fail, 2 the anime style was not reached. * Exit: 0 pass, 1 fail, 2 the anime style was not reached.
*/ */
import puppeteer from 'puppeteer';
const i = process.argv.indexOf('--url'); const i = process.argv.indexOf('--url');
const URL_ = const URL_ =
i > 0 && process.argv[i + 1] ? process.argv[i + 1] : 'http://localhost:4333/'; i > 0 && process.argv[i + 1] ? process.argv[i + 1] : 'http://localhost:4333/';
const browser = await puppeteer.launch({ const browser = await launchBrowser({
headless: 'new', headless: true,
args: ['--no-sandbox', '--ignore-gpu-blocklist'], args: ['--no-sandbox', '--ignore-gpu-blocklist'],
}); });
try { try {
@@ -41,17 +43,15 @@ try {
await page.waitForFunction(() => window.__godsEyeView?.viewer?.scene, { await page.waitForFunction(() => window.__godsEyeView?.viewer?.scene, {
timeout: 120000, timeout: 120000,
}); });
await whenSettled(page);
await page.waitForFunction( await page.waitForFunction(
() => { () => {
const sky = window.__godsEyeView.viewer.scene.skyBox; const sky = window.__godsEyeView.viewer.scene.skyBox;
return ( return sky?._milkyWayCubeMap && sky?._starCommand;
sky?._milkyWayCubeMap &&
sky?._starCommand &&
document.getElementById('loading-screen')?.classList.contains('hidden')
);
}, },
{ timeout: 60000 }, { timeout: 60000 },
); );
await whenSettled(page);
const r = await page.evaluate(async () => { const r = await page.evaluate(async () => {
const entry = performance const entry = performance
.getEntriesByType('resource') .getEntriesByType('resource')
@@ -94,22 +94,22 @@ try {
C.Cartesian3.cross(look, north, new C.Cartesian3()), C.Cartesian3.cross(look, north, new C.Cartesian3()),
new C.Cartesian3(), new C.Cartesian3(),
); );
s.camera.cancelFlight(); window.__godsEyeView.engine.camera.cancelFlight();
s.tweens.removeAll(); s.tweens.removeAll();
s.camera.setView({ window.__godsEyeView.engine.camera.setView({
destination: C.Cartesian3.multiplyByScalar(look, 3e7, new C.Cartesian3()), destination: C.Cartesian3.multiplyByScalar(look, 3e7, new C.Cartesian3()),
orientation: { orientation: {
direction: look, direction: look,
up: C.Cartesian3.cross(right, look, new C.Cartesian3()), up: C.Cartesian3.cross(right, look, new C.Cartesian3()),
}, },
}); });
const gl = s.context._gl, const gl = window.__godsEyeView.engine.host.canvas.getContext('webgl2'),
w = gl.drawingBufferWidth, w = gl.drawingBufferWidth,
h = gl.drawingBufferHeight, h = gl.drawingBufferHeight,
px = new Uint8Array(w * h * 4); px = new Uint8Array(w * h * 4);
const measure = async () => { const measure = async () => {
await new Promise((res) => setTimeout(res, 2500)); await new Promise((res) => setTimeout(res, 2500));
s.requestRender(); window.__godsEyeView.engine.render.request();
s.render(); s.render();
gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px); gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px);
let banded = 0; let banded = 0;
@@ -156,5 +156,5 @@ try {
console.error(`UNREACHED: ${error.message}`); console.error(`UNREACHED: ${error.message}`);
process.exitCode = 2; process.exitCode = 2;
} finally { } finally {
await browser.close(); await closeBrowser(browser);
} }
+424
View File
@@ -0,0 +1,424 @@
import { whenSettled } from './qa/ready.mjs';
import { closeBrowser, exitAfterFlush } from './qa/browser.mjs';
import { localPreview, waitForEngine } from './qa-strict-harness.mjs';
// The application is created once per page: switching engines detaches and
// attaches only the engine-bound parts. This gate runs three Cesium→Navara→
// Cesium round trips and checks that every session object keeps its identity,
// an open panel, typed text and the focused field survive, Escape still does
// what it did before the switches, window/document listeners do not grow,
// and a failed candidate rolls back onto the same session objects.
//
// node scripts/qa-app-once.mjs http://127.0.0.1:<port>/ [--report]
// `--report` prints the measurements without asserting them.
import assert from 'node:assert/strict';
import {
idDifference,
launch,
listenerCounts,
listenerDifference,
openSessionPage,
readSession,
requestEngine,
sessionAddress,
switchEngine,
} from './qa/appSessionHarness.mjs';
const base = localPreview(process.argv[2]);
const reportOnly = process.argv.includes('--report');
const report = { switches: [], failures: [] };
const check = (condition, message, detail) => {
if (condition) return;
report.failures.push({ message, detail });
if (!reportOnly)
assert.fail(`${message}${detail ? ` ${JSON.stringify(detail)}` : ''}`);
};
const FIELD = '#location-search';
const PANEL = 'data-panel';
// The page's visible UI state the switches must leave alone.
const readUi = (page) =>
page.evaluate(
(field, panel) => ({
value: document.querySelector(field)?.value ?? null,
focused: document.activeElement?.id || null,
panelOpen: !document
.getElementById(panel)
?.classList.contains('collapsed'),
}),
FIELD,
PANEL,
);
// Focus the field, press Escape and read what changed; then put the state
// back. The same press before and after the switches must have the same
// effect.
async function escapeEffect(page) {
await page.focus(FIELD);
const before = await readUi(page);
await page.keyboard.press('Escape');
await whenSettled(page);
const after = await readUi(page);
return { before, after };
}
async function prepareUi(page) {
await page.evaluate((panel) => {
const style = window.__godsEyeView.styleManager;
style.setPanelCollapsed(panel, false, { explicit: true });
style.setPanelCollapsed('location-bar', false, { explicit: true });
}, PANEL);
await page.waitForFunction(
(field) => document.querySelector(field)?.offsetParent !== null,
{},
FIELD,
);
await page.evaluate((field) => {
const input = document.querySelector(field);
input.focus();
input.value = 'Reykjav';
input.dispatchEvent(new Event('input', { bubbles: true }));
}, FIELD);
}
// The session's tools: a mark on the annotation board, an imported file and
// the Draw shape, set on Cesium before the switches.
async function prepareTools(page) {
return page.evaluate(async () => {
const annotated = await window.__gevAnnotations.annotate([
{ type: 'pin', latitude: 42.3554, longitude: -71.0605, label: 'QA mark' },
]);
const file = new File(
[
JSON.stringify({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: { name: 'QA area' },
geometry: {
type: 'Polygon',
coordinates: [
[
[-71.07, 42.35],
[-71.05, 42.35],
[-71.05, 42.36],
[-71.07, 42.35],
],
],
},
},
],
}),
],
'qa-area.geojson',
{ type: 'application/geo+json' },
);
const imported = await window.__gevGeoImport.importFiles([file]);
window.__gevDrawTool.setShape('line');
return { annotated: annotated.drawn, imported: imported[0]?.ok };
});
}
const readTools = (page) =>
page.evaluate(() => ({
marks: window.__gevAnnotations?.count() ?? null,
marksDrawn: window.__gevAnnotations?.attached ?? null,
files: window.__gevGeoImport?.list().length ?? null,
filesDrawn: window.__gevGeoImport?.diagnostics().drawn ?? null,
drawShape: window.__gevDrawTool?.shape ?? null,
}));
// Listeners whose difference is the parked renderer's: the Navara engine
// keeps its own input, resize and attribution listeners while it is parked
// for a switch back (scripts that are not the session's).
const PARKED_RENDERER = /@navara-engine\.js$/;
const browser = await launch();
try {
const { page, errors } = await openSessionPage(
browser,
sessionAddress(base, { engine: 'cesium' }),
);
await waitForEngine(page, 'cesium', { timeout: 90_000 });
// Let the first boot's deferred listeners land before counting.
await whenSettled(page);
const boot = await readSession(page);
const bootListeners = await listenerCounts(page, { bySource: true });
report.boot = boot;
report.toolsPrepared = await prepareTools(page);
check(
report.toolsPrepared.annotated === 1 && report.toolsPrepared.imported,
'the tools could not be prepared',
report.toolsPrepared,
);
// Let the imported file finish drawing.
await page.waitForFunction(
() => window.__gevGeoImport.diagnostics().drawn === 1,
);
const toolsBefore = await readTools(page);
report.toolsBefore = toolsBefore;
await prepareUi(page);
const escapeBefore = await escapeEffect(page);
report.escapeBefore = escapeBefore;
await prepareUi(page);
const uiBefore = await readUi(page);
report.uiBefore = uiBefore;
const sequence = ['navara', 'cesium', 'navara', 'cesium', 'navara', 'cesium'];
// Dev guard: once an engine is parked, nothing may move its camera or
// ask it to draw until it is attached again (its own resume aside).
await page.evaluate(() => {
window.__qaParkedCalls = [];
window.__qaWatchParked = (engine) => {
if (!engine || engine.__qaWatched) return;
engine.__qaWatched = true;
const watch = (owner, names, label) => {
for (const name of names) {
const original = owner[name];
if (typeof original !== 'function') continue;
owner[name] = function (...args) {
const parked = window.__godsEyeView?.engine !== engine;
const resume = name === 'request' && args[0] === 'engine-resume';
if (parked && !resume && !window.__qaSwitching)
window.__qaParkedCalls.push({
call: `${engine.kind}.${label}.${name}`,
reason: typeof args[0] === 'string' ? args[0] : null,
stack: new Error().stack.split('\n').slice(2, 6).join(' | '),
});
return original.apply(this, args);
};
}
};
watch(
engine.camera,
['setView', 'flyTo', 'lookAt', 'flyToBoundingSphere', 'release'],
'camera',
);
watch(engine.render, ['request', 'setContinuous'], 'render');
};
window.__qaWatchParked(window.__godsEyeView.engine);
});
for (const kind of sequence) {
const started = Date.now();
// The switch itself parks and resumes engines; only the time an engine
// sits parked is watched.
await page.evaluate(() => {
window.__qaSwitching = true;
});
await switchEngine(page, kind);
await page.evaluate(() => {
window.__qaSwitching = false;
window.__qaWatchParked(window.__godsEyeView.engine);
});
const state = await readSession(page);
const ui = await readUi(page);
if (kind === 'navara')
await page.evaluate(() => {
window.__qaParkedNavara = window.__godsEyeView.engine;
});
if (report.switches.length === 1) {
await whenSettled(page);
report.firstRoundTrip = await listenerCounts(page, { bySource: true });
}
// Both engines draw the session's marks and files (Navara included).
await page.waitForFunction(
() => window.__gevGeoImport.diagnostics().drawn === 1,
{ timeout: 30_000 },
);
const tools = await readTools(page);
check(
tools.marks === toolsBefore.marks &&
tools.files === toolsBefore.files &&
tools.drawShape === toolsBefore.drawShape &&
tools.marksDrawn === true &&
tools.filesDrawn === 1,
`the session tools changed on the switch to ${kind}`,
{ toolsBefore, tools },
);
report.switches.push({
kind,
ms: Date.now() - started,
ids: idDifference(boot.ids, state.ids),
counters: state.counters,
ui,
tools,
});
check(
Object.keys(idDifference(boot.ids, state.ids)).length === 0,
`session ids changed on the switch to ${kind}`,
idDifference(boot.ids, state.ids),
);
check(
ui.value === uiBefore.value,
`typed text lost on the switch to ${kind}`,
ui,
);
check(ui.panelOpen, `open panel closed on the switch to ${kind}`, ui);
check(
ui.focused === uiBefore.focused,
`focus moved on the switch to ${kind}`,
ui,
);
}
await whenSettled(page);
const after = await readSession(page);
const afterListeners = await listenerCounts(page, { bySource: true });
report.after = after;
// After three round trips the page holds exactly the listeners it held
// after the first boot, apart from the parked Navara renderer's own (it
// stays alive for the switch back), which the first round trip added and
// the later ones did not grow.
report.listeners = listenerDifference(bootListeners, afterListeners);
report.listenersSinceFirstRoundTrip = listenerDifference(
report.firstRoundTrip,
afterListeners,
);
const sessionListeners = Object.keys(report.listeners).filter(
(key) => !PARKED_RENDERER.test(key),
);
check(
sessionListeners.length === 0,
'session listeners on window/document changed after three round trips',
report.listeners,
);
check(
Object.keys(report.listenersSinceFirstRoundTrip).length === 0,
'listeners on window/document grew after the first round trip',
report.listenersSinceFirstRoundTrip,
);
check(
after.counters?.attachments === 1 + sequence.length &&
after.counters?.detaches === sequence.length,
'each switch detaches once and attaches once',
after.counters,
);
report.parkedEngineCalls = await page.evaluate(() => window.__qaParkedCalls);
check(
report.parkedEngineCalls.length === 0,
'a parked engine had its camera moved or a frame requested',
report.parkedEngineCalls,
);
check(
after.counters?.applications === 1,
'the application must be created once',
after.counters,
);
const escapeAfter = await escapeEffect(page);
report.escapeAfter = escapeAfter;
check(
JSON.stringify(escapeAfter) === JSON.stringify(escapeBefore),
'Escape behaves differently after the switches',
{ escapeBefore, escapeAfter },
);
// A failed candidate: the parked Navara renderer refuses to resume (as in
// qa-layer-handoff.mjs). The working Cesium attaches again on the same
// session objects.
await prepareUi(page);
const beforeRollback = await readSession(page);
const historyLength = await page.evaluate(() => history.length);
await page.evaluate(() => {
window.__qaRollback = false;
const parked = window.__qaParkedNavara;
const request = parked.render.request;
parked.render.request = function (...args) {
parked.render.request = request;
window.__qaRollback = true;
throw new Error('QA parked renderer failure');
};
});
const sabotaged = true;
if (sabotaged) {
await requestEngine(page, 'navara');
await page.waitForFunction(() => window.__qaRollback === true, {
timeout: 60_000,
});
await waitForEngine(page, 'cesium', { timeout: 90_000 });
const rolledBack = await readSession(page);
const ui = await readUi(page);
report.rollback = {
ids: idDifference(beforeRollback.ids, rolledBack.ids),
counters: rolledBack.counters,
ui,
history: (await page.evaluate(() => history.length)) - historyLength,
};
check(
Object.keys(report.rollback.ids).length === 0,
'a rollback changed session ids',
report.rollback,
);
check(
report.rollback.history === 0,
'a rollback added a history entry',
report.rollback,
);
check(
ui.value === uiBefore.value && ui.panelOpen,
'a rollback lost the UI state',
ui,
);
// The page still switches after the rollback.
await switchEngine(page, 'navara');
await switchEngine(page, 'cesium');
const final = await readSession(page);
check(
Object.keys(idDifference(boot.ids, final.ids)).length === 0,
'ids changed after the rollback',
idDifference(boot.ids, final.ids),
);
// A candidate failing after the shell attached to it: a layer's
// drawing refuses to attach on Navara. The shell lets go of the failed
// globe and Cesium attaches again; the next switch works.
await page.evaluate(() => {
window.__qaDataRefused = false;
const module =
window.__godsEyeView.dataManager.layers.get('earthquakes').module;
const attach = module.attachRenderer;
module.attachRenderer = function () {
module.attachRenderer = attach;
window.__qaDataRefused = true;
return Promise.reject(new Error('QA data attach failure'));
};
});
const beforeDataFailure = await page.evaluate(() => history.length);
await requestEngine(page, 'navara');
await page.waitForFunction(() => window.__qaDataRefused === true, {
timeout: 60_000,
});
await waitForEngine(page, 'cesium', { timeout: 90_000 });
const afterDataFailure = await readSession(page);
report.dataFailure = {
ids: idDifference(boot.ids, afterDataFailure.ids),
counters: afterDataFailure.counters,
history: (await page.evaluate(() => history.length)) - beforeDataFailure,
};
check(
Object.keys(report.dataFailure.ids).length === 0 &&
report.dataFailure.history === 0,
'a candidate failing in the data part changed the session',
report.dataFailure,
);
await switchEngine(page, 'navara');
await switchEngine(page, 'cesium');
check(
Object.keys(idDifference(boot.ids, (await readSession(page)).ids))
.length === 0,
'ids changed after the data-part failure',
);
}
report.errors = errors;
check(errors.length === 0, 'page errors', errors);
console.log(JSON.stringify(report, null, 2));
if (!reportOnly) console.log('PASS application created once; session kept');
} catch (error) {
console.error(error);
process.exitCode = 1;
} finally {
await closeBrowser(browser);
}
await exitAfterFlush();
+8 -8
View File
@@ -1,16 +1,15 @@
#!/usr/bin/env node #!/usr/bin/env node
import { whenSettled } from './qa/ready.mjs';
/** Prove standalone startup and terminal resource ownership in a real browser. */ /** Prove standalone startup and terminal resource ownership in a real browser. */
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import puppeteer from 'puppeteer';
const args = process.argv.slice(2); const args = process.argv.slice(2);
const urlIndex = args.indexOf('--url'); const urlIndex = args.indexOf('--url');
const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173'; const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173';
const browser = await puppeteer.launch({ const browser = await launchBrowser({
headless: true, headless: true,
executablePath: args: ['--no-sandbox'],
process.env.PUPPETEER_EXECUTABLE_PATH || (await puppeteer.executablePath()),
args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'],
}); });
try { try {
const page = await browser.newPage(); const page = await browser.newPage();
@@ -28,6 +27,7 @@ try {
await page.waitForFunction(() => window.__godsEyeView?.voiceCommands, { await page.waitForFunction(() => window.__godsEyeView?.voiceCommands, {
timeout: 60_000, timeout: 60_000,
}); });
await whenSettled(page);
const before = await page.evaluate(async () => { const before = await page.evaluate(async () => {
const entry = [...document.scripts].find((script) => const entry = [...document.scripts].find((script) =>
/\/src\/main\.js(?:\?|$)/.test(script.src), /\/src\/main\.js(?:\?|$)/.test(script.src),
@@ -52,7 +52,7 @@ try {
enabled, enabled,
layers: app.dataManager.layers.size, layers: app.dataManager.layers.size,
annotationCount: annotation.drawn, annotationCount: annotation.drawn,
viewerAlive: !app.viewer.isDestroyed(), viewerAlive: !window.__godsEyeView.engine.isDestroyed(),
credits: Boolean(document.querySelector('#cesium-credits')), credits: Boolean(document.querySelector('#cesium-credits')),
}; };
}); });
@@ -83,7 +83,7 @@ try {
return { return {
status: application.getState().status, status: application.getState().status,
samePromise, samePromise,
viewerDestroyed: app.viewer.isDestroyed(), viewerDestroyed: app.engine.isDestroyed(),
layers: app.dataManager.layers.size, layers: app.dataManager.layers.size,
annotations: app.annotations.count(), annotations: app.annotations.count(),
governor: app.getRenderGovernorDiagnostics(), governor: app.getRenderGovernorDiagnostics(),
@@ -112,5 +112,5 @@ try {
'PASS: terminal teardown releases runtime owners without late browser errors', 'PASS: terminal teardown releases runtime owners without late browser errors',
); );
} finally { } finally {
await browser.close(); await closeBrowser(browser);
} }
+15 -26
View File
@@ -1,3 +1,5 @@
// Cesium-only probes: the native credit registry and lightbox are the attribution oracle.
import { whenSettled, whenUiSettled } from './qa/ready.mjs';
/** /**
* qa-attribution-b12.mjs — visual + state proof for Batch 12 (data attribution). * qa-attribution-b12.mjs — visual + state proof for Batch 12 (data attribution).
* *
@@ -16,7 +18,7 @@
* *
* Run: node scripts/qa-attribution-b12.mjs --url http://localhost:4300 * Run: node scripts/qa-attribution-b12.mjs --url http://localhost:4300
*/ */
import puppeteer from 'puppeteer'; import { launchBrowser, closeBrowser } from './qa/browser.mjs';
import { existsSync, mkdirSync } from 'node:fs'; import { existsSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from 'node:path';
@@ -32,19 +34,7 @@ const APP_ORIGIN = new URL(APP_URL).origin;
const SHOT_DIR = resolve(__dirname, '..', 'qa-shots', 'b12'); const SHOT_DIR = resolve(__dirname, '..', 'qa-shots', 'b12');
mkdirSync(SHOT_DIR, { recursive: true }); mkdirSync(SHOT_DIR, { recursive: true });
const CHROME_EXECUTABLE_CANDIDATES = [
process.env.PUPPETEER_EXECUTABLE_PATH,
await puppeteer.executablePath().catch(() => null),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
].filter(Boolean);
function findChromeExecutable() {
return CHROME_EXECUTABLE_CANDIDATES.find((candidate) => {
try { return existsSync(candidate); } catch { return false; }
}) || null;
}
let passed = 0; let passed = 0;
let failed = 0; let failed = 0;
@@ -70,10 +60,8 @@ const REQUIRED_CREDIT_SUBSTRINGS = [
]; ];
async function main() { async function main() {
const chromeExecutable = findChromeExecutable(); const browser = await launchBrowser({
const browser = await puppeteer.launch({ headless: true,
headless: 'new',
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
args: ['--no-sandbox', '--disable-setuid-sandbox'], args: ['--no-sandbox', '--disable-setuid-sandbox'],
}); });
const page = await browser.newPage(); const page = await browser.newPage();
@@ -122,8 +110,9 @@ async function main() {
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.viewer.creditDisplay, () => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.viewer.creditDisplay,
{ timeout: 60000 }, { timeout: 60000 },
); );
await whenSettled(page);
// Give Cesium a few frames to render the on-screen credit line (logo + link). // Give Cesium a few frames to render the on-screen credit line (logo + link).
await new Promise((r) => setTimeout(r, 2500)); await whenUiSettled(page);
// ── (i) H11: static credits registered ──────────────────────────── // ── (i) H11: static credits registered ────────────────────────────
console.log('H11 — per-layer credits registered in viewer.creditDisplay'); console.log('H11 — per-layer credits registered in viewer.creditDisplay');
@@ -147,11 +136,11 @@ async function main() {
// Force a render frame so the credit display flushes, then open the lightbox. // Force a render frame so the credit display flushes, then open the lightbox.
const lightboxState = await page.evaluate(async () => { const lightboxState = await page.evaluate(async () => {
const viewer = window.__godsEyeView.viewer; const viewer = window.__godsEyeView.viewer;
viewer.scene.requestRender(); window.__godsEyeView.engine.render.request();
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
const cd = viewer.creditDisplay; const cd = viewer.creditDisplay;
cd.showLightbox(); cd.showLightbox();
viewer.scene.requestRender(); window.__godsEyeView.engine.render.request();
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
const box = document.querySelector('.cesium-credit-lightbox'); const box = document.querySelector('.cesium-credit-lightbox');
const list = box?.querySelector(':scope > ul'); const list = box?.querySelector(':scope > ul');
@@ -263,7 +252,7 @@ async function main() {
await page.evaluate(async () => { await page.evaluate(async () => {
const viewer = window.__godsEyeView.viewer; const viewer = window.__godsEyeView.viewer;
viewer.resize(); viewer.resize();
viewer.scene.requestRender(); window.__godsEyeView.engine.render.request();
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
}); });
const mobileLightbox = await page.evaluate(() => { const mobileLightbox = await page.evaluate(() => {
@@ -311,7 +300,7 @@ async function main() {
check('found datacenter + cable layer ids', !!dcId && !!cableId, `dc=${dcId} cable=${cableId}`); check('found datacenter + cable layer ids', !!dcId && !!cableId, `dc=${dcId} cable=${cableId}`);
if (dcId) await page.evaluate((id) => window.__godsEyeView.dataManager.setEnabled(id, true), dcId); if (dcId) await page.evaluate((id) => window.__godsEyeView.dataManager.setEnabled(id, true), dcId);
if (cableId) await page.evaluate((id) => window.__godsEyeView.dataManager.setEnabled(id, true), cableId); if (cableId) await page.evaluate((id) => window.__godsEyeView.dataManager.setEnabled(id, true), cableId);
await new Promise((r) => setTimeout(r, 800)); await whenUiSettled(page);
const afterEnableHtml = await page.evaluate(() => const afterEnableHtml = await page.evaluate(() =>
(window.__godsEyeView.viewer.creditDisplay._staticCredits || []).map((c) => c.html), (window.__godsEyeView.viewer.creditDisplay._staticCredits || []).map((c) => c.html),
); );
@@ -348,7 +337,7 @@ async function main() {
// ── (iii) clean-view: credit line STILL visible ─────────────────── // ── (iii) clean-view: credit line STILL visible ───────────────────
console.log('\nH10 — clean-view keeps the credit line visible'); console.log('\nH10 — clean-view keeps the credit line visible');
await page.evaluate(() => document.body.classList.add('ui-clean-view')); await page.evaluate(() => document.body.classList.add('ui-clean-view'));
await new Promise((r) => setTimeout(r, 400)); await whenUiSettled(page);
const cleanVis = await creditVisibility(); const cleanVis = await creditVisibility();
await page.screenshot({ path: resolve(SHOT_DIR, 'clean-view.png') }); await page.screenshot({ path: resolve(SHOT_DIR, 'clean-view.png') });
check( check(
@@ -366,7 +355,7 @@ async function main() {
// ── (iv) recording-mode: credit line STILL visible ──────────────── // ── (iv) recording-mode: credit line STILL visible ────────────────
console.log('\nH10 — recording-mode keeps the credit line visible'); console.log('\nH10 — recording-mode keeps the credit line visible');
await page.evaluate(() => document.body.classList.add('recording-mode')); await page.evaluate(() => document.body.classList.add('recording-mode'));
await new Promise((r) => setTimeout(r, 400)); await whenUiSettled(page);
const recVis = await creditVisibility(); const recVis = await creditVisibility();
await page.screenshot({ path: resolve(SHOT_DIR, 'recording-mode.png') }); await page.screenshot({ path: resolve(SHOT_DIR, 'recording-mode.png') });
check( check(
@@ -377,7 +366,7 @@ async function main() {
await page.evaluate(() => document.body.classList.remove('recording-mode')); await page.evaluate(() => document.body.classList.remove('recording-mode'));
// baseline (normal) screenshot for comparison // baseline (normal) screenshot for comparison
await new Promise((r) => setTimeout(r, 300)); await whenUiSettled(page);
await page.screenshot({ path: resolve(SHOT_DIR, 'normal.png') }); await page.screenshot({ path: resolve(SHOT_DIR, 'normal.png') });
check( check(
@@ -391,7 +380,7 @@ async function main() {
failedResponses.slice(0, 3).join(' | ') || 'clean', failedResponses.slice(0, 3).join(' | ') || 'clean',
); );
await browser.close(); await closeBrowser(browser);
console.log('\n────────────────────────────────────────────────────────────'); console.log('\n────────────────────────────────────────────────────────────');
console.log(` RESULT: ${passed} passed, ${failed} failed`); console.log(` RESULT: ${passed} passed, ${failed} failed`);
+70
View File
@@ -23,6 +23,74 @@ export function redact(text) {
.replace(/AIza[\w-]{30,}/g, 'AIza…REDACTED'); .replace(/AIza[\w-]{30,}/g, 'AIza…REDACTED');
} }
/** The standalone Vite preview has no setup API; opt in explicitly. */
export function isExpectedPreviewSetupError(
message,
{ preview = false, baseUrl } = {},
) {
if (
!preview ||
message.type() !== 'error' ||
!/^Failed to load resource: .*\b404\b/.test(message.text())
)
return false;
try {
const url = new URL(message.location()?.url);
return (
url.origin === new URL(baseUrl).origin &&
url.pathname === '/api/setup/status'
);
} catch {
return false;
}
}
// The runtime evidence gate shares one strict runtime verdict across its main page and
// every share-link page. Response diagnostics never retain query credentials.
export function watchBrowserPage(
page,
report,
{ name, preview = false, baseUrl },
) {
const errors = (report.browserErrors ??= []);
report.expectedPreviewErrors ??= [];
report.failedResponses ??= [];
(report.runtimePages ??= []).push(name);
const resourcePath = (value) => {
try {
const url = new URL(value);
return redact(url.origin + url.pathname);
} catch {
return '(location unavailable)';
}
};
page.on('console', (message) => {
if (isExpectedPreviewSetupError(message, { preview, baseUrl })) {
report.expectedPreviewErrors.push('/api/setup/status: 404');
return;
}
if (message.type() === 'error')
errors.push(
`[${name}] ${redact(message.text())} — ${resourcePath(message.location()?.url)}`,
);
});
for (const event of ['pageerror', 'error'])
page.on(event, (error) =>
errors.push(`[${name}] ${redact(error.message)}`),
);
page.on('response', (response) => {
if (
response.status() >= 400 &&
report.failedResponses.length < EVIDENCE_LIMIT
)
report.failedResponses.push({
page: name,
status: response.status(),
resource: resourcePath(response.url()),
});
});
}
function push(list, entry) { function push(list, entry) {
list.push(entry); list.push(entry);
if (list.length > EVIDENCE_LIMIT) if (list.length > EVIDENCE_LIMIT)
@@ -37,6 +105,8 @@ export function hookRenderErrors(page) {
return page.evaluateOnNewDocument(() => { return page.evaluateOnNewDocument(() => {
window.__qaRenderErrors = []; window.__qaRenderErrors = [];
const hook = () => { const hook = () => {
// Cesium-only: its render loop catches errors before pageerror sees them.
if (window.__godsEyeView?.engine?.kind === 'navara') return true;
const scene = window.__godsEyeView?.viewer?.scene; const scene = window.__godsEyeView?.viewer?.scene;
if (!scene?.renderError) return false; if (!scene?.renderError) return false;
scene.renderError.addEventListener((_, error) => scene.renderError.addEventListener((_, error) =>
+58 -52
View File
@@ -1,4 +1,9 @@
#!/usr/bin/env node #!/usr/bin/env node
// Cesium-only probes: native DataSource counts and scene-render timing measure the Cesium cable adapter.
import { evaluateWithGeo } from './qa/geometry.mjs';
import { whenSettled } from './qa/ready.mjs';
import { measureIdleRender } from './qa/idle.mjs';
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
/** /**
* qa-cables-overlay — submarine-cable label path measurement + contract gate. * qa-cables-overlay — submarine-cable label path measurement + contract gate.
* *
@@ -8,10 +13,11 @@
* a. per-frame `scene.render` cost over a ~10 s driven-orbit window * a. per-frame `scene.render` cost over a ~10 s driven-orbit window
* (mean / p50 / p95 / max — relative numbers, SwiftShader surface), * (mean / p50 / p95 / max — relative numbers, SwiftShader surface),
* b. toggle-ON → first-labels-visible latency, * b. toggle-ON → first-labels-visible latency,
* c. entity / native-label / host-entry counts, * c. contract set (ground paths, mover tips, stems) / host-entry counts,
* d. parked-idle postRender fires over 5 s (render-governor honesty). * d. parked-idle postRender fires over 5 s (render-governor honesty).
* 2. GATE (unified build only): assert the migration contract — * 2. GATE (unified build only): assert the migration contract —
* zero native `LabelGraphics` on cable reference entities, host entries * no layer-owned Cesium data source (paths, pins and tips are globe
* contract sets), host entries
* present for the source, idle stays near-zero, and a clean OFF→ON cycle * present for the source, idle stays near-zero, and a clean OFF→ON cycle
* (no orphan host entries after disable). * (no orphan host entries after disable).
* *
@@ -19,7 +25,6 @@
* Requires a running dev server. Headless; rAF throttling disabled so the * Requires a running dev server. Headless; rAF throttling disabled so the
* frame clock is honest (hidden-pane gotcha). * frame clock is honest (hidden-pane gotcha).
*/ */
import puppeteer from 'puppeteer';
const argv = process.argv; const argv = process.argv;
const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4214'; const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4214';
@@ -39,35 +44,26 @@ function report(name, detail) {
console.log(` [MEAS] ${name} — ${JSON.stringify(detail)}`); console.log(` [MEAS] ${name} — ${JSON.stringify(detail)}`);
} }
const browser = await puppeteer.launch({ const browser = await launchBrowser({
headless: 'new', headless: true,
protocolTimeout: 300_000, protocolTimeout: 300_000,
args: [ args: ['--no-sandbox', '--disable-setuid-sandbox', '--window-size=1440,900', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-background-timer-throttling'],
'--no-sandbox',
'--disable-setuid-sandbox',
'--window-size=1440,900',
'--disable-backgrounding-occluded-windows',
'--disable-renderer-backgrounding',
'--disable-background-timer-throttling',
],
}); });
try { try {
const page = await browser.newPage(); const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 860 }); await page.setViewport({ width: 1440, height: 860 });
await page.goto(url, { waitUntil: 'domcontentloaded' }); await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 90_000 }); await page.waitForFunction(() => !!window.__godsEyeView?.engine, { timeout: 90_000 });
await new Promise((r) => setTimeout(r, 12_000)); // boot flyTo + deferred init await whenSettled(page); // boot flyTo + deferred init
// Park mid-Atlantic (many cables + both coasts' landings in range) and // Park mid-Atlantic (many cables + both coasts' landings in range) and
// disable every layer so the cables layer is measured in isolation. // disable every layer so the cables layer is measured in isolation.
await page.evaluate(async () => { await evaluateWithGeo(page, async (qaGeo) => {
const gev = window.__godsEyeView; const gev = window.__godsEyeView;
const v = gev.viewer; window.__godsEyeView.engine.camera.cancelFlight();
v.camera.cancelFlight(); window.__godsEyeView.engine.camera.setView({
const ell = v.scene.globe.ellipsoid; destination: qaGeo.cartographic.toCartesian({
v.camera.setView({
destination: ell.cartographicToCartesian({
longitude: -40 * Math.PI / 180, latitude: 35 * Math.PI / 180, height: 4_500_000, longitude: -40 * Math.PI / 180, latitude: 35 * Math.PI / 180, height: 4_500_000,
}), }),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
@@ -76,7 +72,7 @@ try {
if (entry.enabled) { try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* measured via counts */ } } if (entry.enabled) { try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* measured via counts */ } }
} }
}); });
await new Promise((r) => setTimeout(r, 6_000)); // tiles + fades settle await whenSettled(page); // tiles + fades settle
// ── b. toggle-ON → labels visible ───────────────────────────────────── // ── b. toggle-ON → labels visible ─────────────────────────────────────
if (!control) { if (!control) {
@@ -95,45 +91,58 @@ try {
}; };
while (performance.now() < deadline) { while (performance.now() < deadline) {
if (labelsUp()) return { ms: Math.round(performance.now() - t0), timedOut: false }; if (labelsUp()) return { ms: Math.round(performance.now() - t0), timedOut: false };
gev.viewer.scene.requestRender?.(); window.__godsEyeView.engine.render.request?.();
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
} }
return { ms: 60_000, timedOut: true }; return { ms: 60_000, timedOut: true };
}, LAYER_ID, legacy); }, LAYER_ID, legacy);
report('toggle-ON -> labels visible (ms)', labelLatency); report('toggle-ON -> labels visible (ms)', labelLatency);
check('labels became visible after enable', labelLatency.timedOut === false, labelLatency); check('labels became visible after enable', labelLatency.timedOut === false, labelLatency);
await new Promise((r) => setTimeout(r, 3_000)); // let load/labels settle await whenSettled(page); // let load/labels settle
} }
// ── c. entity / label / host counts ─────────────────────────────────── // ── c. drawn-set / label / host counts ────────────────────────────────
const counts = await page.evaluate((layerId) => { // The layer draws through the globe contract (render/display.js): cable
// paths in one engine.ground corridor set, landing points and reference
// tips in mover sets, stems in engine.lines, labels on the world-overlay
// host. It owns no Cesium DataSource or Entity any more.
const counts = await page.evaluate((layerId, legacy) => {
const gev = window.__godsEyeView; const gev = window.__godsEyeView;
const v = gev.viewer; const v = gev.viewer;
const engine = gev.engine;
const out = { const out = {
dataSources: {}, nativeLabels: 0, dynamicPositionEntities: 0, layerDataSources: 0, layerEntities: 0,
referenceEntities: 0, hostEntries: 0, hostPainted: 0, pathSets: 0, pathItems: 0, markers: 0, tipSets: 0, tips: 0, stems: 0,
hostEntries: 0, hostPainted: 0,
}; };
for (let i = 0; i < v.dataSources.length; i++) { for (let i = 0; i < (v?.dataSources?.length ?? 0); i++) {
const ds = v.dataSources.get(i); const ds = v.dataSources.get(i);
if (!/TeleGeography/.test(ds.name || '')) continue; if (!/TeleGeography/.test(ds.name || '')) continue;
const list = ds.entities.values; out.layerDataSources++;
out.dataSources[ds.name] = list.length; out.layerEntities += ds.entities.values.length;
for (const e of list) {
if (e.label !== undefined) out.nativeLabels++;
if (e.position && e.position.isConstant === false) out.dynamicPositionEntities++;
if (e.polyline?.positions && e.polyline.positions.isConstant === false) {
out.dynamicPositionEntities++;
}
}
if (/References/.test(ds.name)) out.referenceEntities = list.length;
} }
if (legacy) return out;
for (const set of engine.ground.list()) {
if (set.id !== `${layerId}-paths`) continue;
out.pathSets++;
out.pathItems += set.count;
}
for (const set of engine.movers.list()) {
if (set.id === `${layerId}-markers`) out.markers += set.count;
else if (set.id.startsWith(`${layerId}-`) && set.id.endsWith('-tips')) {
out.tipSets++;
out.tips += set.count;
}
}
out.stems = (engine.lines.list().find((line) =>
line.id === `${layerId}-stems` && line.shown)?.points ?? 0) / 2;
const diag = window.__gevWorldOverlay?.getDiagnostics?.() || {}; const diag = window.__gevWorldOverlay?.getDiagnostics?.() || {};
out.hostEntries = diag.entriesBySource?.[layerId] || 0; out.hostEntries = diag.entriesBySource?.[layerId] || 0;
out.hostPainted = diag.paintedBySource?.[layerId] || 0; out.hostPainted = diag.paintedBySource?.[layerId] || 0;
const stats = gev.dataManager.layers.get(layerId)?.module?.getStats?.() || {}; const stats = gev.dataManager.layers.get(layerId)?.module?.getStats?.() || {};
out.referenceLabelCount = stats.referenceLabelCount ?? null; out.referenceLabelCount = stats.referenceLabelCount ?? null;
return out; return out;
}, LAYER_ID); }, LAYER_ID, legacy);
report(control ? 'counts (control, cables OFF)' : 'counts with cables ON', counts); report(control ? 'counts (control, cables OFF)' : 'counts with cables ON', counts);
// ── a. per-frame scene.render cost over a ~10 s driven orbit ───────── // ── a. per-frame scene.render cost over a ~10 s driven orbit ─────────
@@ -176,21 +185,18 @@ try {
: 'scene.render cost, cables ON, 10s orbit', frameCost); : 'scene.render cost, cables ON, 10s orbit', frameCost);
// ── d. parked idle honesty (labels must not force continuous render) ── // ── d. parked idle honesty (labels must not force continuous render) ──
await new Promise((r) => setTimeout(r, 4_000)); // orbit stop + fades settle await whenSettled(page);
const idle = await page.evaluate(() => new Promise((resolve) => { const idle = await measureIdleRender(page);
const scene = window.__godsEyeView.viewer.scene;
let renders = 0;
const remove = scene.postRender.addEventListener(() => { renders += 1; });
setTimeout(() => { remove(); resolve({ renders }); }, 5_000);
}));
report(control report(control
? 'parked idle, control (postRender fires / 5s)' ? 'parked idle, control (postRender fires / 5s)'
: 'parked idle with cables ON (postRender fires / 5s)', idle); : 'parked idle with cables ON (postRender fires / 5s)', idle);
if (!legacy && !control) { if (!legacy && !control) {
// ── unified-build contract gates ──────────────────────────────────── // ── unified-build contract gates ────────────────────────────────────
check('zero native LabelGraphics on cable entities', counts.nativeLabels === 0, counts.nativeLabels); // Paths, pins and tips belong to contract sets; labels belong to the host.
check('zero per-frame CallbackProperty cable entities', counts.dynamicPositionEntities === 0, counts.dynamicPositionEntities); check('no layer-owned Cesium data source or entities', counts.layerDataSources === 0 && counts.layerEntities === 0, counts);
check('cable paths drawn as an engine.ground corridor set', counts.pathSets === 1 && counts.pathItems > 0, counts);
check('reference tips drawn as mover sets', counts.tipSets > 0 && counts.tips > 0, counts);
check('host has cable entries', counts.hostEntries > 0, counts.hostEntries); check('host has cable entries', counts.hostEntries > 0, counts.hostEntries);
check('host painted cable labels', counts.hostPainted > 0, counts.hostPainted); check('host painted cable labels', counts.hostPainted > 0, counts.hostPainted);
// The cables source never republishes an unchanged cohort (unit pin), and // The cables source never republishes an unchanged cohort (unit pin), and
@@ -208,7 +214,7 @@ try {
const cycle = await page.evaluate(async (layerId) => { const cycle = await page.evaluate(async (layerId) => {
const gev = window.__godsEyeView; const gev = window.__godsEyeView;
await gev.dataManager.setEnabled(layerId, false, { origin: 'user' }); await gev.dataManager.setEnabled(layerId, false, { origin: 'user' });
gev.viewer.scene.requestRender?.(); window.__godsEyeView.engine.render.request?.();
await new Promise((r) => setTimeout(r, 1_200)); await new Promise((r) => setTimeout(r, 1_200));
const offDiag = window.__gevWorldOverlay?.getDiagnostics?.() || {}; const offDiag = window.__gevWorldOverlay?.getDiagnostics?.() || {};
const offEntries = offDiag.entriesBySource?.[layerId] || 0; const offEntries = offDiag.entriesBySource?.[layerId] || 0;
@@ -220,7 +226,7 @@ try {
const diag = window.__gevWorldOverlay?.getDiagnostics?.() || {}; const diag = window.__gevWorldOverlay?.getDiagnostics?.() || {};
onPainted = diag.paintedBySource?.[layerId] || 0; onPainted = diag.paintedBySource?.[layerId] || 0;
if (onPainted > 0) break; if (onPainted > 0) break;
gev.viewer.scene.requestRender?.(); window.__godsEyeView.engine.render.request?.();
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
} }
return { offEntries, offPainted, onPainted }; return { offEntries, offPainted, onPainted };
@@ -229,7 +235,7 @@ try {
check('re-enable restores painted labels', cycle.onPainted > 0, cycle); check('re-enable restores painted labels', cycle.onPainted > 0, cycle);
} }
} finally { } finally {
await browser.close(); await closeBrowser(browser);
} }
const passed = results.filter((r) => r.pass).length; const passed = results.filter((r) => r.pass).length;
+20 -23
View File
@@ -1,4 +1,8 @@
#!/usr/bin/env node #!/usr/bin/env node
// Cesium-only probes: manual native renders isolate Cesium cable render cost.
import { evaluateWithGeo } from './qa/geometry.mjs';
import { whenSettled } from './qa/ready.mjs';
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
/** /**
* qa-cables-render-probe — replicates the owner-side perf probe methodology * qa-cables-render-probe — replicates the owner-side perf probe methodology
* for the cables layer (relative numbers; SwiftShader when run headless): * for the cables layer (relative numbers; SwiftShader when run headless):
@@ -11,35 +15,29 @@
* *
* Usage: node scripts/qa-cables-render-probe.mjs [--url http://localhost:4216] * Usage: node scripts/qa-cables-render-probe.mjs [--url http://localhost:4216]
*/ */
import puppeteer from 'puppeteer';
const argv = process.argv; const argv = process.argv;
const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4216'; const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4216';
const LAYER_ID = 'telegeography-submarine-cables'; const LAYER_ID = 'telegeography-submarine-cables';
const browser = await puppeteer.launch({ const browser = await launchBrowser({
headless: 'new', headless: true,
protocolTimeout: 300_000, protocolTimeout: 300_000,
args: [ args: ['--no-sandbox', '--disable-setuid-sandbox', '--window-size=1440,900', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-background-timer-throttling'],
'--no-sandbox', '--disable-setuid-sandbox', '--window-size=1440,900',
'--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding',
'--disable-background-timer-throttling',
],
}); });
try { try {
const page = await browser.newPage(); const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 860 }); await page.setViewport({ width: 1440, height: 860 });
await page.goto(url, { waitUntil: 'domcontentloaded' }); await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 90_000 }); await page.waitForFunction(() => !!window.__godsEyeView?.engine, { timeout: 90_000 });
await new Promise((r) => setTimeout(r, 12_000)); await whenSettled(page);
await page.evaluate(async () => { await evaluateWithGeo(page, async (qaGeo) => {
const gev = window.__godsEyeView; const gev = window.__godsEyeView;
gev.viewer.camera.cancelFlight(); window.__godsEyeView.engine.camera.cancelFlight();
const ell = gev.viewer.scene.globe.ellipsoid; window.__godsEyeView.engine.camera.setView({
gev.viewer.camera.setView({ destination: qaGeo.cartographic.toCartesian({
destination: ell.cartographicToCartesian({
longitude: -40 * Math.PI / 180, latitude: 35 * Math.PI / 180, height: 4_500_000, longitude: -40 * Math.PI / 180, latitude: 35 * Math.PI / 180, height: 4_500_000,
}), }),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
@@ -48,7 +46,7 @@ try {
if (entry.enabled) { try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* probe */ } } if (entry.enabled) { try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* probe */ } }
} }
}); });
await new Promise((r) => setTimeout(r, 5_000)); await whenSettled(page);
const stats = (values) => ({ const stats = (values) => ({
mean: +(values.reduce((s, v) => s + v, 0) / Math.max(1, values.length)).toFixed(2), mean: +(values.reduce((s, v) => s + v, 0) / Math.max(1, values.length)).toFixed(2),
@@ -60,7 +58,7 @@ try {
const gev = window.__godsEyeView; const gev = window.__godsEyeView;
await gev.dataManager.setEnabled(id, on, { origin: 'user' }); await gev.dataManager.setEnabled(id, on, { origin: 'user' });
}, LAYER_ID, enabled); }, LAYER_ID, enabled);
await new Promise((r) => setTimeout(r, enabled ? 6_000 : 3_000)); // load/teardown + settle await whenSettled(page); // load/teardown + settle
// 1. PARKED timed renders. // 1. PARKED timed renders.
const parked = await page.evaluate(() => { const parked = await page.evaluate(() => {
@@ -77,18 +75,17 @@ try {
// 2. PAN-STOP cycles: move, raise moveEnd, wait 650 ms, 20 timed renders. // 2. PAN-STOP cycles: move, raise moveEnd, wait 650 ms, 20 timed renders.
const cycles = []; const cycles = [];
for (let cycle = 0; cycle < 3; cycle++) { for (let cycle = 0; cycle < 3; cycle++) {
const frames = await page.evaluate(async (step) => { const frames = await evaluateWithGeo(page, async (qaGeo, step) => {
const v = window.__godsEyeView.viewer; const v = window.__godsEyeView.viewer;
const ell = v.scene.globe.ellipsoid; window.__godsEyeView.engine.camera.setView({
v.camera.setView({ destination: qaGeo.cartographic.toCartesian({
destination: ell.cartographicToCartesian({
longitude: (-40 + step * 3) * Math.PI / 180, longitude: (-40 + step * 3) * Math.PI / 180,
latitude: 35 * Math.PI / 180, latitude: 35 * Math.PI / 180,
height: 4_500_000, height: 4_500_000,
}), }),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
}); });
v.camera.moveEnd.raiseEvent(); window.__godsEyeView.engine.camera.moveEnd.raiseEvent();
await new Promise((resolve) => setTimeout(resolve, 650)); await new Promise((resolve) => setTimeout(resolve, 650));
const durations = []; const durations = [];
for (let i = 0; i < 20; i++) { for (let i = 0; i < 20; i++) {
@@ -112,5 +109,5 @@ try {
console.log(`\nparked delta (ON-OFF mean): ${(on.parked.mean - off.parked.mean).toFixed(2)}ms`); console.log(`\nparked delta (ON-OFF mean): ${(on.parked.mean - off.parked.mean).toFixed(2)}ms`);
console.log(`pan-stop worst: ON ${on.worst}ms vs OFF ${off.worst}ms`); console.log(`pan-stop worst: ON ${on.worst}ms vs OFF ${off.worst}ms`);
} finally { } finally {
await browser.close(); await closeBrowser(browser);
} }
+15 -20
View File
@@ -1,10 +1,12 @@
#!/usr/bin/env node #!/usr/bin/env node
import { evaluateWithGeo } from './qa/geometry.mjs';
import { whenSettled } from './qa/ready.mjs';
/** /**
* qa-cables-shot — capture the cables layer at two fixed cameras for * qa-cables-shot — capture the cables layer at two fixed cameras for
* before/after visual-identity comparison. Writes to gitignored qa-shots/. * before/after visual-identity comparison. Writes to gitignored qa-shots/.
* Usage: node scripts/qa-cables-shot.mjs [--url http://localhost:4214] [--tag before] * Usage: node scripts/qa-cables-shot.mjs [--url http://localhost:4214] [--tag before]
*/ */
import puppeteer from 'puppeteer'; import { launchBrowser, closeBrowser } from './qa/browser.mjs';
import { mkdirSync } from 'node:fs'; import { mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -14,24 +16,20 @@ const tag = argv.includes('--tag') ? argv[argv.indexOf('--tag') + 1] : 'shot';
const LAYER_ID = 'telegeography-submarine-cables'; const LAYER_ID = 'telegeography-submarine-cables';
mkdirSync(new URL('../qa-shots', import.meta.url), { recursive: true }); mkdirSync(new URL('../qa-shots', import.meta.url), { recursive: true });
const browser = await puppeteer.launch({ const browser = await launchBrowser({
headless: 'new', headless: true,
protocolTimeout: 300_000, protocolTimeout: 300_000,
args: [ args: ['--no-sandbox', '--disable-setuid-sandbox', '--window-size=1440,900', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-background-timer-throttling'],
'--no-sandbox', '--disable-setuid-sandbox', '--window-size=1440,900',
'--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding',
'--disable-background-timer-throttling',
],
}); });
try { try {
const page = await browser.newPage(); const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 860 }); await page.setViewport({ width: 1440, height: 860 });
await page.goto(url, { waitUntil: 'domcontentloaded' }); await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 90_000 }); await page.waitForFunction(() => !!window.__godsEyeView?.engine, { timeout: 90_000 });
await new Promise((r) => setTimeout(r, 12_000)); await whenSettled(page);
await page.evaluate(async (layerId) => { await page.evaluate(async (layerId) => {
const gev = window.__godsEyeView; const gev = window.__godsEyeView;
gev.viewer.camera.cancelFlight(); window.__godsEyeView.engine.camera.cancelFlight();
for (const [id, entry] of gev.dataManager.layers) { for (const [id, entry] of gev.dataManager.layers) {
if (entry.enabled && id !== layerId) { if (entry.enabled && id !== layerId) {
try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* shot only */ } try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* shot only */ }
@@ -45,11 +43,9 @@ try {
{ name: 'ny-coast', lon: -73.5, lat: 40.4, height: 500_000 }, { name: 'ny-coast', lon: -73.5, lat: 40.4, height: 500_000 },
]; ];
for (const view of views) { for (const view of views) {
await page.evaluate((v) => { await evaluateWithGeo(page, async (qaGeo, v) => {
const viewer = window.__godsEyeView.viewer; window.__godsEyeView.engine.camera.setView({
const ell = viewer.scene.globe.ellipsoid; destination: qaGeo.cartographic.toCartesian({
viewer.camera.setView({
destination: ell.cartographicToCartesian({
longitude: v.lon * Math.PI / 180, latitude: v.lat * Math.PI / 180, height: v.height, longitude: v.lon * Math.PI / 180, latitude: v.lat * Math.PI / 180, height: v.height,
}), }),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
@@ -57,15 +53,14 @@ try {
}, view); }, view);
// Let the sweep + labels settle with frames flowing. // Let the sweep + labels settle with frames flowing.
await page.evaluate(() => new Promise((resolve) => { await page.evaluate(() => new Promise((resolve) => {
const v = window.__godsEyeView.viewer;
let ticks = 0; let ticks = 0;
const tick = () => { const tick = () => {
v.scene.requestRender?.(); window.__godsEyeView.engine.render.request?.();
if (++ticks < 240) requestAnimationFrame(tick); else resolve(); if (++ticks < 240) requestAnimationFrame(tick); else resolve();
}; };
requestAnimationFrame(tick); requestAnimationFrame(tick);
})); }));
await new Promise((r) => setTimeout(r, 1_000)); await whenSettled(page);
const path = fileURLToPath( const path = fileURLToPath(
new URL(`../qa-shots/cables-${tag}-${view.name}.png`, import.meta.url), new URL(`../qa-shots/cables-${tag}-${view.name}.png`, import.meta.url),
); );
@@ -73,5 +68,5 @@ try {
console.log(`saved ${path}`); console.log(`saved ${path}`);
} }
} finally { } finally {
await browser.close(); await closeBrowser(browser);
} }
+16 -14
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env node #!/usr/bin/env node
/** Camera-panel browser acceptance with controlled sources, images and terrain. */ /** Camera-panel browser acceptance with controlled sources, images and terrain. */
import { whenSettled, whenUiSettled } from './qa/ready.mjs';
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
import fs from 'node:fs'; import fs from 'node:fs';
import puppeteer from 'puppeteer';
const origin = process.env.QA_BASE_URL || 'http://localhost:4173'; const origin = process.env.QA_BASE_URL || 'http://localhost:4173';
const selectAllModifier = process.platform === 'darwin' ? 'Meta' : 'Control'; const selectAllModifier = process.platform === 'darwin' ? 'Meta' : 'Control';
const sources = [ const sources = [
@@ -31,13 +32,11 @@ const sources = [
rangeM: 300, rangeM: 300,
mountHeightM: 8, mountHeightM: 8,
})); }));
const browser = await puppeteer.launch({ const browser = await launchBrowser({
headless: true, headless: true,
args: [ args: [
'--no-sandbox', '--no-sandbox',
...(process.platform === 'darwin' ...(process.platform === 'darwin' ? ['--enable-gpu'] : []),
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
], ],
}); });
const page = await browser.newPage(); const page = await browser.newPage();
@@ -108,10 +107,10 @@ try {
await page.waitForFunction( await page.waitForFunction(
() => () =>
window.__godsEyeView?.styleManager?._cctvControls && window.__godsEyeView?.styleManager?._cctvControls &&
window.__godsEyeView?.dataManager?.layers?.has('cctv') && window.__godsEyeView?.dataManager?.layers?.has('cctv'),
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 }, { timeout: 60000 },
); );
await whenSettled(page);
await page.$eval('[data-collapse-target="cctv-panel"]', (button) => await page.$eval('[data-collapse-target="cctv-panel"]', (button) =>
button.click(), button.click(),
); );
@@ -280,6 +279,8 @@ try {
.classList.contains('has-frame'), .classList.contains('has-frame'),
); );
await page.setCacheEnabled(false); await page.setCacheEnabled(false);
// Keep both options while selecting A flies the view away from B.
await page.focus('#cctv-camera-select');
delayCameraA = true; delayCameraA = true;
await page.select('#cctv-camera-select', 'qa-camera-a'); await page.select('#cctv-camera-select', 'qa-camera-a');
await page.waitForFunction( await page.waitForFunction(
@@ -357,22 +358,23 @@ try {
fs.mkdirSync('qa-shots/camera-controls', { recursive: true }); fs.mkdirSync('qa-shots/camera-controls', { recursive: true });
await page.screenshot({ path: 'qa-shots/camera-controls/desktop.png' }); await page.screenshot({ path: 'qa-shots/camera-controls/desktop.png' });
await page.evaluate(() => { await page.evaluate(() => {
const camera = window.__godsEyeView.viewer.camera; window.__godsEyeView.engine.camera.setView({
camera.setView({ destination: window.__godsEyeView.engine.camera.pose().position,
orientation: { orientation: {
heading: camera.heading + Math.PI / 3, heading:
window.__godsEyeView.engine.camera.pose().heading + Math.PI / 3,
pitch: -0.6, pitch: -0.6,
roll: 0, roll: 0,
}, },
}); });
}); });
await new Promise((resolve) => setTimeout(resolve, 700)); await whenUiSettled(page);
await page.screenshot({ path: 'qa-shots/camera-controls/angle.png' }); await page.screenshot({ path: 'qa-shots/camera-controls/angle.png' });
await page.setViewport({ width: 390, height: 844 }); await page.setViewport({ width: 390, height: 844 });
await new Promise((resolve) => setTimeout(resolve, 700)); await whenUiSettled(page);
// Phones present CCTV in the Cameras tab's sheet. // Phones present CCTV in the Cameras tab's sheet.
await page.click('#mobile-tab-bar [data-mobile-sheet="cameras"]'); await page.click('#mobile-tab-bar [data-mobile-sheet="cameras"]');
await new Promise((resolve) => setTimeout(resolve, 400)); await whenUiSettled(page);
await page.screenshot({ path: 'qa-shots/camera-controls/narrow.png' }); await page.screenshot({ path: 'qa-shots/camera-controls/narrow.png' });
check( check(
'narrow camera panel remains within the viewport', 'narrow camera panel remains within the viewport',
@@ -411,6 +413,6 @@ try {
); );
} finally { } finally {
await Promise.all([...delayedFrames].map((respond) => respond())); await Promise.all([...delayedFrames].map((respond) => respond()));
await browser.close(); await closeBrowser(browser);
} }
if (failures) process.exitCode = 1; if (failures) process.exitCode = 1;
+107
View File
@@ -0,0 +1,107 @@
import '../src/testSupport/registerDom.mjs';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { createPinia } from 'pinia';
import { createApp, nextTick } from 'vue';
import { CctvControls } from '../src/ui/cctvControls.js';
import { useCctvStore } from '../src/ui/stores/cctv.js';
import CctvPanel from '../src/ui/views/panels/CctvPanel.vue';
const source = readFileSync(
new URL('./qa-camera-controls.mjs', import.meta.url),
'utf8',
);
test('the delayed-frame scenario keeps camera B selectable after the flight to A', async (t) => {
document.body.replaceChildren();
const host = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const app = createApp(CctvPanel).use(pinia);
app.mount(host);
const select = document.getElementById('cctv-camera-select');
const controls = new CctvControls({
elements: {
_cctvPanel: document.getElementById('cctv-panel'),
_cctvSelect: select,
},
cctv: {},
actions: {
isEnabled: () => true,
setPanelCollapsed() {},
syncViewport() {},
},
store: useCctvStore(pinia),
});
t.after(() => {
controls.destroy();
app.unmount();
});
const [a, b] = ['a', 'b'].map((id) => ({
id: `qa-camera-${id}`,
name: id,
city: 'Austin',
}));
const render = async (activeCamera, cameraIndex) => {
controls._renderCctvState({
enabled: true,
count: 2,
activeCameraId: activeCamera.id,
activeCamera,
cameraIndex,
});
await nextTick();
};
await render(b, [a, b]);
const prepare = source.slice(
source.indexOf(' await page.setCacheEnabled(false);'),
source.indexOf(' delayCameraA = true;'),
);
await new Function('page', `return (async () => { ${prepare} })();`)({
setCacheEnabled: async () => {},
focus: async (selector) => document.querySelector(selector).focus(),
});
await render(a, [a]);
assert.ok([...select.options].some((option) => option.value === b.id));
select.value = b.id;
assert.equal(select.value, b.id);
select.blur();
await nextTick();
assert.ok(
![...select.options].some((option) => option.value === b.id),
'an unfocused picker drops B once the flight takes it out of view',
);
});
test('camera B fixtures respond while camera A frames remain held', async () => {
let route;
const delayedFrames = new Set();
const routing = source.slice(
source.indexOf(" page.on('request',"),
source.indexOf(' await page.goto('),
);
new Function('page', 'origin', 'delayCameraA', 'delayedFrames', routing)(
{ on: (event, handler) => (route = handler) },
'http://localhost:4321',
true,
delayedFrames,
);
const replies = [];
for (const id of ['a', 'b']) {
route({
url: () => `http://localhost:4321/api/cctv/frame/qa-camera-${id}?ts=1`,
respond: async (response) => replies.push({ id, response }),
});
}
assert.equal(delayedFrames.size, 1);
assert.deepEqual(
replies.map(({ id }) => id),
['b'],
);
assert.match(replies[0].response.body, /QA CAMERA B/);
await Promise.all([...delayedFrames].map((respond) => respond()));
assert.deepEqual(
replies.map(({ id }) => id),
['b', 'a'],
);
});
+151
View File
@@ -0,0 +1,151 @@
import { waitForEngine } from './qa-strict-harness.mjs';
import { assertKeylessPreview } from './qa-strict-harness.mjs';
import assert from 'node:assert/strict';
import { promisify } from 'node:util';
import { execFile } from 'node:child_process';
import { installCctvFixture } from './fixtures/layers/cctv.mjs';
import { createStrict, localPreview } from './qa-strict-harness.mjs';
const url = localPreview(process.argv[2]);
url.search = '?engine=navara&welcome=0';
url.hash =
'v=2&lat=30.2718&lon=-97.744&alt=900&pitch=-70&heading=0&map=sentinel2&l=';
const { stdout: video } = await promisify(execFile)(
'ffmpeg',
[
'-hide_banner',
'-loglevel',
'error',
'-f',
'lavfi',
'-i',
'testsrc2=size=320x180:rate=12',
'-t',
'60',
'-an',
'-c:v',
'libx264',
'-pix_fmt',
'yuv420p',
'-preset',
'ultrafast',
'-threads',
'1',
'-movflags',
'frag_keyframe+empty_moov',
'-f',
'mp4',
'pipe:1',
],
{ encoding: 'buffer', maxBuffer: 16000000 },
);
const strict = createStrict({ origin: url.origin, layers: ['cctv'] });
await strict.run({}, async (b) => {
const p = await b.newPage();
const errors = [];
p.on('pageerror', (e) => {
errors.push(e.message);
console.log('error', e.message);
});
await installCctvFixture(p, { video });
let catalogs = 0;
p.on('request', (req) => {
if (new URL(req.url()).pathname === '/api/cctv/sources') catalogs++;
});
await p.goto(url.href);
await waitForEngine(p, 'navara');
await assertKeylessPreview(p);
await p.evaluate(async () => {
const { dataManager, styleManager } = window.__godsEyeView;
await styleManager.initialRestorePromise;
await dataManager.setEnabled('cctv', true, { origin: 'user' });
window.qaCctv = dataManager.layers.get('cctv').module;
window.qaCctv.setParams({
selectedCameraId: 'fixture-cctv-video',
showProjection: true,
coverageMode: 'viewshed',
calibration: { patch: { heightM: 31, pitchDeg: 20 } },
});
});
await p.waitForFunction(
() =>
window.qaCctv.getActiveVideoElement()?.videoWidth === 320 &&
window.qaCctv.getActiveVideoElement().currentTime > 0.1,
{ timeout: 40000 },
);
const first = await p.evaluate(() => {
window.qaVideo = window.qaCctv.getActiveVideoElement();
return window.qaVideo.getVideoPlaybackQuality().totalVideoFrames;
});
let previous = first;
const count = catalogs;
for (const kind of ['cesium', 'navara']) {
await strict.switchTo(p, kind);
await p.waitForFunction(
(n) =>
window.qaCctv.getActiveVideoElement()?.getVideoPlaybackQuality()
.totalVideoFrames > n,
{},
previous,
);
const v = await p.evaluate(() => ({
same:
window.qaCctv ===
window.__godsEyeView.dataManager.layers.get('cctv').module,
media: window.qaCctv.getActiveVideoElement() === window.qaVideo,
frames: window.qaVideo.getVideoPlaybackQuality().totalVideoFrames,
paused: window.qaVideo.paused,
params: window.qaCctv.getParams(),
plane: !!window.qaCctv
._cctvRecordsForTest()
.find((r) => r.camera.id === 'fixture-cctv-video').projection
?.planeEntity,
projection: (() => {
const r = window.qaCctv
._cctvRecordsForTest()
.find((r) => r.camera.id === 'fixture-cctv-video');
return {
show: r.projection?.planeEntity?.show === true,
visible: r.projection?.planeVisible === true,
calibration: r.camera.calibration,
};
})(),
}));
console.log(
kind,
v.same,
v.media,
v.frames,
v.plane,
v.params.selectedCameraId,
v.params.coverageMode,
);
assert.ok(v.same && v.media && v.plane && !v.paused);
assert.equal(v.params.selectedCameraId, 'fixture-cctv-video');
assert.equal(v.params.coverageMode, 'viewshed');
assert.equal(catalogs, count);
await strict.passed(
p,
'cctv',
'same model and video element decoding new frames; textured projection plane; no catalog read',
);
// The calibration patch (31 m mount, 20° pitch) and the plane's
// visibility are kept on the new engine.
const { calibration } = v.projection;
await strict.check(
p,
'cctv',
'calibration patch kept (31 m, 20° pitch); monitor plane visible',
v.projection.show &&
v.projection.visible &&
calibration?.heightM === 31 &&
calibration?.pitchDeg === 20 &&
v.params.calibration?.values?.heightM === 31 &&
v.params.calibration?.values?.pitchDeg === 20,
v.projection,
);
previous = v.frames;
}
assert.deepEqual(errors, []);
console.log('PASS retained CCTV catalog, decoder and textured projection');
});
+444 -280
View File
File diff suppressed because it is too large. Load diff
+19 -46
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
import { evaluateWithGeo } from './qa/geometry.mjs';
/** /**
* Rendered proof for backdrop-selective callout plates at GROUND LEVEL. * Rendered proof for backdrop-selective callout plates at GROUND LEVEL.
* *
@@ -46,13 +47,14 @@
* node scripts/qa-cockpit-plates.mjs --url http://localhost:4268 --tag before * node scripts/qa-cockpit-plates.mjs --url http://localhost:4268 --tag before
* node scripts/qa-cockpit-plates.mjs --url http://localhost:4268 --teeth * node scripts/qa-cockpit-plates.mjs --url http://localhost:4268 --teeth
* *
* Defaults to ANGLE/Metal on macOS and software GL elsewhere. `--swiftshader` selects software GL, * QA_ANGLE selects the renderer (Metal by default); use swiftshader for software GL,
* which is deterministic but is NOT real-GPU evidence; the banner says which * which is deterministic but is NOT real-GPU evidence; the banner says which
* backend actually ran and the run records it alongside the results. * backend actually ran and the run records it alongside the results.
*/ */
import { whenSettled, whenTilesLoaded } from './qa/ready.mjs';
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import puppeteer from 'puppeteer';
const argv = process.argv.slice(2); const argv = process.argv.slice(2);
const getOpt = (name, fallback) => { const getOpt = (name, fallback) => {
@@ -64,7 +66,7 @@ const APP_ORIGIN = new URL(APP_URL).origin;
const TAG = getOpt('--tag', 'after'); const TAG = getOpt('--tag', 'after');
const HEADFUL = argv.includes('--headful'); const HEADFUL = argv.includes('--headful');
const TEETH = argv.includes('--teeth'); const TEETH = argv.includes('--teeth');
const SWIFTSHADER = argv.includes('--swiftshader') || process.platform !== 'darwin'; const SWIFTSHADER = /swiftshader|software/i.test(process.env.QA_ANGLE || 'metal');
const SHOT_DIR = path.resolve('qa-shots/cockpitplates'); const SHOT_DIR = path.resolve('qa-shots/cockpitplates');
/** Mirrors `SKY_PLATE_SCALE` in src/overlays/worldOverlayTokens.js. */ /** Mirrors `SKY_PLATE_SCALE` in src/overlays/worldOverlayTokens.js. */
@@ -134,14 +136,6 @@ const POSES = [
}, },
]; ];
const CHROME_CANDIDATES = [
process.env.PUPPETEER_EXECUTABLE_PATH,
// Version-pinned Chrome-for-Testing over the auto-updating system Chrome:
// its software-GL behavior shifts across majors and has produced false
// negatives in this repo's harnesses before.
await puppeteer.executablePath().catch(() => null),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
].filter(Boolean);
const results = []; const results = [];
function record(name, ok, detail) { function record(name, ok, detail) {
@@ -213,25 +207,9 @@ async function main() {
} }
fs.mkdirSync(SHOT_DIR, { recursive: true }); fs.mkdirSync(SHOT_DIR, { recursive: true });
const executablePath = CHROME_CANDIDATES.find((candidate) => { const browser = await launchBrowser({
try { return fs.existsSync(candidate); } catch { return false; } headless: true,
}); args: ['--no-sandbox', '--disable-setuid-sandbox', ...(SWIFTSHADER ? [] : ['--enable-gpu', '--ignore-gpu-blocklist']), '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1280,800'],
const browser = await puppeteer.launch({
headless: HEADFUL ? false : 'new',
...(executablePath ? { executablePath } : {}),
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
// Metal is a macOS-only ANGLE backend; off-Mac the GPU branch has to fall
// back to the software rasterizer or WebGL init fails outright.
...(SWIFTSHADER
? ['--use-gl=angle', '--use-angle=swiftshader']
: ['--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist']),
'--disable-dev-shm-usage',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=1280,800',
],
}); });
const consoleErrors = []; const consoleErrors = [];
@@ -276,10 +254,7 @@ async function main() {
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 90_000 }); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 90_000 });
await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 90_000 }); await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 90_000 });
await page.waitForFunction( await whenSettled(page);
() => document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 90_000 },
);
renderer = await page.evaluate(() => { renderer = await page.evaluate(() => {
const gl = document.createElement('canvas').getContext('webgl2') const gl = document.createElement('canvas').getContext('webgl2')
@@ -302,10 +277,10 @@ async function main() {
// Inject the field. The layers keep their real pipelines; only the source // Inject the field. The layers keep their real pipelines; only the source
// of observations is replaced, exactly as scripts/qa-labels.mjs does. // of observations is replaced, exactly as scripts/qa-labels.mjs does.
await page.evaluate((payload) => { await evaluateWithGeo(page, async (qaGeo, payload) => {
const { viewer, dataManager, styleManager } = window.__godsEyeView; const { dataManager, styleManager } = window.__godsEyeView;
viewer.camera.cancelFlight(); window.__godsEyeView.engine.camera.cancelFlight();
const Cartesian3 = viewer.camera.position.constructor; const Cartesian3 = qaGeo.cartesian3;
window.__PLATE_QA_FIELD = payload.contacts.map((contact) => ({ window.__PLATE_QA_FIELD = payload.contacts.map((contact) => ({
sourceId: contact.id.toLowerCase(), sourceId: contact.id.toLowerCase(),
id: contact.id, id: contact.id,
@@ -330,17 +305,16 @@ async function main() {
for (const pose of POSES) { for (const pose of POSES) {
console.log(`\n — pose "${pose.name}" (${pose.note})`); console.log(`\n — pose "${pose.name}" (${pose.note})`);
await page.evaluate((payload) => { await evaluateWithGeo(page, async (qaGeo, payload) => {
const { viewer } = window.__godsEyeView; const Cartesian3 = qaGeo.cartesian3;
const Cartesian3 = viewer.camera.position.constructor; window.__godsEyeView.engine.camera.setView({
viewer.camera.setView({
destination: Cartesian3.fromDegrees(payload.lon, payload.lat, payload.height), destination: Cartesian3.fromDegrees(payload.lon, payload.lat, payload.height),
orientation: { heading: 0, pitch: payload.pitchDeg * Math.PI / 180, roll: 0 }, orientation: { heading: 0, pitch: payload.pitchDeg * Math.PI / 180, roll: 0 },
}); });
}, { lon: JFK_LON, lat: JFK_LAT, height: pose.height, pitchDeg: pose.pitchDeg }); }, { lon: JFK_LON, lat: JFK_LAT, height: pose.height, pitchDeg: pose.pitchDeg });
// Let the tiles settle and the label solve run before the tap window. // Let the tiles settle and the label solve run before the tap window.
await new Promise((resolve) => setTimeout(resolve, 6_000)); await whenTilesLoaded(page);
await page.evaluate(() => { await page.evaluate(() => {
window.__PLATE_TAP.events = []; window.__PLATE_TAP.events = [];
window.__PLATE_TAP.on = true; window.__PLATE_TAP.on = true;
@@ -356,10 +330,9 @@ async function main() {
// OUTPUT has to ask for its own renders. Production is unaffected: real // OUTPUT has to ask for its own renders. Production is unaffected: real
// Cockpit holds, and a parked map keeps showing the last correct frame // Cockpit holds, and a parked map keeps showing the last correct frame
// precisely because nothing changed. // precisely because nothing changed.
const scene = window.__godsEyeView.viewer.scene;
const pump = () => { const pump = () => {
if (!window.__PLATE_TAP.on) return; if (!window.__PLATE_TAP.on) return;
scene.requestRender(); window.__godsEyeView.engine.render.request();
requestAnimationFrame(pump); requestAnimationFrame(pump);
}; };
pump(); pump();
@@ -522,7 +495,7 @@ async function main() {
consoleErrors.slice(0, 3).join(' | ') || 'clean', consoleErrors.slice(0, 3).join(' | ') || 'clean',
); );
} finally { } finally {
await browser.close(); await closeBrowser(browser);
} }
fs.writeFileSync( fs.writeFileSync(
+50 -39
View File
@@ -1,9 +1,12 @@
#!/usr/bin/env node #!/usr/bin/env node
// Cesium-only probes: tracked-entity and local-frame coordinates verify the native cockpit handoff.
/** Focused rendered proof for adaptive Cockpit Display/Radio layout. */ /** Focused rendered proof for adaptive Cockpit Display/Radio layout. */
import { whenSettled, whenUiSettled } from './qa/ready.mjs';
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import puppeteer from 'puppeteer'; import { blockPaidQaMaps } from './qa-map-policy.mjs';
import { stubPlanespotters } from './qa-photo-stub.mjs'; import { stubPlanespotters } from './qa-photo-stub.mjs';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -12,24 +15,12 @@ const appUrl = process.env.QA_BASE_URL || 'http://localhost:4173';
const headful = process.argv.includes('--headful'); const headful = process.argv.includes('--headful');
fs.mkdirSync(shotsDir, { recursive: true }); fs.mkdirSync(shotsDir, { recursive: true });
const chromeCandidates = [ const browser = await launchBrowser({
process.env.PUPPETEER_EXECUTABLE_PATH, headless: true,
await puppeteer.executablePath().catch(() => null), args: [...(process.platform === 'darwin' ? ['--enable-gpu'] : []), '--no-sandbox'],
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
].filter(Boolean);
const executablePath = chromeCandidates.find((candidate) => fs.existsSync(candidate));
const browser = await puppeteer.launch({
headless: headful ? false : 'new',
...(executablePath ? { executablePath } : {}),
// Metal is a macOS-only ANGLE backend; anywhere else it fails WebGL init.
args: [
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
'--no-sandbox',
],
}); });
const page = await browser.newPage(); const page = await browser.newPage();
await blockPaidQaMaps(page);
await stubPlanespotters(page); await stubPlanespotters(page);
const failures = []; const failures = [];
const consoleErrors = []; const consoleErrors = [];
@@ -169,10 +160,7 @@ try {
page.on('request', routeQaRequest); page.on('request', routeQaRequest);
await page.goto(appUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 }); await page.goto(appUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 60_000 }); await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 60_000 });
await page.waitForFunction( await whenSettled(page);
() => document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60_000 },
);
await page.waitForFunction(() => ( await page.waitForFunction(() => (
typeof window.__gevQaRegisterLayer === 'function' typeof window.__gevQaRegisterLayer === 'function'
&& typeof window.__gevQaUnregisterLayer === 'function' && typeof window.__gevQaUnregisterLayer === 'function'
@@ -673,6 +661,7 @@ try {
() => Boolean(window.__godsEyeView.viewer.trackedEntity?.position), () => Boolean(window.__godsEyeView.viewer.trackedEntity?.position),
{ timeout: 10_000 }, { timeout: 10_000 },
); );
await whenSettled(page);
const preselectedContactAdoption = await page.evaluate(async () => { const preselectedContactAdoption = await page.evaluate(async () => {
const { styleManager, dataManager, viewer } = window.__godsEyeView; const { styleManager, dataManager, viewer } = window.__godsEyeView;
const flights = dataManager.layers.get('flights')?.module; const flights = dataManager.layers.get('flights')?.module;
@@ -765,7 +754,7 @@ try {
} : null, } : null,
navigation: snapshot?.navigation || null, navigation: snapshot?.navigation || null,
afterId: trackedInfo?.icao24 || null, afterId: trackedInfo?.icao24 || null,
viewerTrackedId: viewer.trackedEntity?.gevTrackedId || null, viewerTrackedId: window.__godsEyeView.engine.camera.following()?.label?.id || null,
contextVisible: !document.getElementById('military-awareness-panel')?.hidden, contextVisible: !document.getElementById('military-awareness-panel')?.hidden,
entryAvailableAfterSettlement: Boolean(entry && !entry.hidden), entryAvailableAfterSettlement: Boolean(entry && !entry.hidden),
blockerStillRegistered: dataManager.layers.has(blockerId), blockerStillRegistered: dataManager.layers.has(blockerId),
@@ -830,6 +819,7 @@ try {
await stubPlanespotters(readinessPage); await stubPlanespotters(readinessPage);
let deferredInstallationReadiness; let deferredInstallationReadiness;
try { try {
await blockPaidQaMaps(readinessPage);
await readinessPage.setViewport({ width: 1440, height: 900 }); await readinessPage.setViewport({ width: 1440, height: 900 });
readinessPage.on('pageerror', (error) => consoleErrors.push(error.message)); readinessPage.on('pageerror', (error) => consoleErrors.push(error.message));
await readinessPage.setRequestInterception(true); await readinessPage.setRequestInterception(true);
@@ -851,8 +841,8 @@ try {
const readinessUrl = new URL(appUrl); const readinessUrl = new URL(appUrl);
readinessUrl.searchParams.set('welcome', '0'); readinessUrl.searchParams.set('welcome', '0');
await readinessPage.goto(readinessUrl.href, { waitUntil: 'domcontentloaded' }); await readinessPage.goto(readinessUrl.href, { waitUntil: 'domcontentloaded' });
await readinessPage.waitForFunction(() => window.__godsEyeView?.dataManager await readinessPage.waitForFunction(() => window.__godsEyeView?.dataManager, { timeout: 60000 });
&& document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 60000 }); await whenSettled(readinessPage);
await readinessPage.evaluate(async () => { await readinessPage.evaluate(async () => {
const manager = window.__godsEyeView.dataManager; const manager = window.__godsEyeView.dataManager;
await manager.setEnabled('flights', true, { origin: 'user' }); await manager.setEnabled('flights', true, { origin: 'user' });
@@ -1097,7 +1087,7 @@ try {
const awareness = dataManager.layers.get('military-awareness')?.module; const awareness = dataManager.layers.get('military-awareness')?.module;
const before = awareness?.getContextSnapshot?.()?.subject || null; const before = awareness?.getContextSnapshot?.()?.subject || null;
const entityBefore = viewer.trackedEntity; const entityBefore = viewer.trackedEntity;
viewer.camera.zoomOut(1_500_000); window.__godsEyeView.engine.camera.zoomOut(1_500_000);
const cameraRange = () => Math.hypot( const cameraRange = () => Math.hypot(
viewer.camera.position.x, viewer.camera.position.x,
viewer.camera.position.y, viewer.camera.position.y,
@@ -1153,6 +1143,14 @@ try {
}, },
{ timeout: 10_000 }, { timeout: 10_000 },
); );
const startsClear = await page.evaluate(() => {
const hidden = ['left-panel-stack', 'intel-hud', 'corner-actions',
'cockpit-context', 'cockpit-signal-stream', 'cockpit-utility-controls'];
return hidden.every((id) => document.getElementById(id)?.getClientRects().length === 0)
&& document.getElementById('cockpit-speed-value').getClientRects().length > 0;
});
check('Cockpit starts with flight instruments and hides the map panels', startsClear);
await page.click('#cockpit-panels-toggle');
const firstCockpitContact = await page.evaluate(() => { const firstCockpitContact = await page.evaluate(() => {
const awareness = window.__godsEyeView.dataManager.layers const awareness = window.__godsEyeView.dataManager.layers
.get('military-awareness')?.module; .get('military-awareness')?.module;
@@ -1454,7 +1452,7 @@ try {
remove(); remove();
reject(new Error('Cockpit camera ownership check did not render a frame')); reject(new Error('Cockpit camera ownership check did not render a frame'));
}, 5000); }, 5000);
const remove = viewer.scene.postRender.addEventListener(() => { const remove = window.__godsEyeView.engine.frames.postRender.addEventListener(() => {
remove(); remove();
clearTimeout(timer); clearTimeout(timer);
resolve(); resolve();
@@ -1462,10 +1460,10 @@ try {
}); });
// Let revoked listeners retire before comparing ownership across exit. // Let revoked listeners retire before comparing ownership across exit.
await nextFrame(); await nextFrame();
const listenersBeforeExit = viewer.scene.preUpdate.numberOfListeners; const listenersBeforeExit = window.__godsEyeView.engine.frames.preUpdate.numberOfListeners;
const exited = styleManager.cockpitView.exit() === true; const exited = styleManager.cockpitView.exit() === true;
await nextFrame(); await nextFrame();
const listenersAfterExit = viewer.scene.preUpdate.numberOfListeners; const listenersAfterExit = window.__godsEyeView.engine.frames.preUpdate.numberOfListeners;
const refocused = awareness?.focusCurrent?.() === true; const refocused = awareness?.focusCurrent?.() === true;
await nextFrame(); await nextFrame();
return { return {
@@ -1474,7 +1472,7 @@ try {
sameEntity: viewer.trackedEntity === entity, sameEntity: viewer.trackedEntity === entity,
listenersBeforeExit, listenersBeforeExit,
listenersAfterExit, listenersAfterExit,
listenersAfterFocus: viewer.scene.preUpdate.numberOfListeners, listenersAfterFocus: window.__godsEyeView.engine.frames.preUpdate.numberOfListeners,
}; };
}); });
check( check(
@@ -1514,6 +1512,9 @@ try {
const before = read(); const before = read();
document.getElementById('cockpit-entry')?.click(); document.getElementById('cockpit-entry')?.click();
await new Promise((resolve) => setTimeout(resolve, 120)); await new Promise((resolve) => setTimeout(resolve, 120));
const startsClear = document.getElementById('cockpit-panels-toggle').getAttribute('aria-pressed') === 'false';
document.getElementById('cockpit-panels-toggle').click();
await new Promise((resolve) => requestAnimationFrame(resolve));
const during = read(); const during = read();
manager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false }); manager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false });
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
@@ -1534,6 +1535,7 @@ try {
before, before,
during, during,
after: read(), after: read(),
startsClear,
exited, exited,
clearancePx, clearancePx,
contactAutoCollapsed, contactAutoCollapsed,
@@ -1543,7 +1545,7 @@ try {
}); });
check( check(
'Cockpit temporarily collapses standard panels and restores the exact map layout on exit', 'Cockpit temporarily collapses standard panels and restores the exact map layout on exit',
cockpitPanelRoundTrip.exited cockpitPanelRoundTrip.exited && cockpitPanelRoundTrip.startsClear
&& Object.values(cockpitPanelRoundTrip.during).every(Boolean) && Object.values(cockpitPanelRoundTrip.during).every(Boolean)
&& JSON.stringify(cockpitPanelRoundTrip.after) && JSON.stringify(cockpitPanelRoundTrip.after)
=== JSON.stringify(cockpitPanelRoundTrip.before) === JSON.stringify(cockpitPanelRoundTrip.before)
@@ -1608,6 +1610,7 @@ try {
const states = [read()]; const states = [read()];
for (let index = 0; index < 6; index += 1) { for (let index = 0; index < 6; index += 1) {
current.click(); current.click();
await Promise.resolve();
states.push(read()); states.push(read());
} }
const originalStyle = manager.activeStyle; const originalStyle = manager.activeStyle;
@@ -1619,8 +1622,10 @@ try {
const enteredOnNoir = await reenter(); const enteredOnNoir = await reenter();
const entryNoir = read(); const entryNoir = read();
cockpit.setVisionMode('optical'); cockpit.setVisionMode('optical');
await Promise.resolve();
const normalInsideNoir = read(); const normalInsideNoir = read();
cockpit.setVisionMode('crt'); cockpit.setVisionMode('crt');
await Promise.resolve();
const cockpitOnlyCrt = read(); const cockpitOnlyCrt = read();
const exitedNoir = cockpit.exit({ restoreTracking: true }) === true; const exitedNoir = cockpit.exit({ restoreTracking: true }) === true;
await settle(); await settle();
@@ -1695,6 +1700,9 @@ try {
await page.screenshot({ path: path.join(shotsDir, 'vision-noir.png') }); await page.screenshot({ path: path.join(shotsDir, 'vision-noir.png') });
await page.evaluate(() => window.__godsEyeView.styleManager.cockpitView.setVisionMode('optical')); await page.evaluate(() => window.__godsEyeView.styleManager.cockpitView.setVisionMode('optical'));
await page.$eval('#cockpit-panels-toggle', button => {
if (button.getAttribute('aria-pressed') === 'false') button.click();
});
const desktopState = async (variant, openKind) => page.evaluate(async ({ variantName, kind }) => { const desktopState = async (variant, openKind) => page.evaluate(async ({ variantName, kind }) => {
const manager = window.__godsEyeView.styleManager; const manager = window.__godsEyeView.styleManager;
const hud = document.getElementById('cockpit-hud'); const hud = document.getElementById('cockpit-hud');
@@ -1880,7 +1888,7 @@ try {
// the wall Cockpit actually solves against — not by injecting an anchor. // the wall Cockpit actually solves against — not by injecting an anchor.
// The card is bottom-anchored, so an explicit height moves its top edge. // The card is bottom-anchored, so an explicit height moves its top edge.
const minTop = Math.max(96, window.innerHeight * 0.12); const minTop = Math.max(96, window.innerHeight * 0.12);
const moveSignalTopTo = (wantTop) => { const moveSignalTopTo = async (wantTop) => {
for (let pass = 0; pass < 8; pass += 1) { for (let pass = 0; pass < 8; pass += 1) {
const rect = signal.getBoundingClientRect(); const rect = signal.getBoundingClientRect();
const delta = wantTop - rect.top; const delta = wantTop - rect.top;
@@ -1893,9 +1901,11 @@ try {
// the now-shorter strip, so idempotence is asserted where the strip's // the now-shorter strip, so idempotence is asserted where the strip's
// composition is unchanged.) // composition is unchanged.)
manager.cockpitView.syncSignalLayout(); manager.cockpitView.syncSignalLayout();
await Promise.resolve();
const firstPassTop = hud.style.getPropertyValue('--cockpit-utility-top'); const firstPassTop = hud.style.getPropertyValue('--cockpit-utility-top');
const firstPassPrimaryOnly = utility.classList.contains('layout-primary-only'); const firstPassPrimaryOnly = utility.classList.contains('layout-primary-only');
manager.cockpitView.syncSignalLayout(); manager.cockpitView.syncSignalLayout();
await Promise.resolve();
return { return {
firstPassTop, firstPassTop,
firstPassPrimaryOnly, firstPassPrimaryOnly,
@@ -1906,6 +1916,7 @@ try {
signal.style.maxHeight = 'none'; signal.style.maxHeight = 'none';
signal.style.height = '150px'; signal.style.height = '150px';
manager.cockpitView.syncSignalLayout(); manager.cockpitView.syncSignalLayout();
await Promise.resolve();
const expandedHeight = Math.max(displayControl.scrollHeight, displayControl.getBoundingClientRect().height); const expandedHeight = Math.max(displayControl.scrollHeight, displayControl.getBoundingClientRect().height);
const collapsedHeight = Math.max(50, radioControl.scrollHeight); const collapsedHeight = Math.max(50, radioControl.scrollHeight);
const stripHeight = expandedHeight + collapsedHeight + 7; const stripHeight = expandedHeight + collapsedHeight + 7;
@@ -1930,11 +1941,11 @@ try {
// because 150 px of card leaves a corridor sized for the OLD strip. Seed from // because 150 px of card leaves a corridor sized for the OLD strip. Seed from
// the measured requirement instead, which holds for any future panel height, // the measured requirement instead, which holds for any future panel height,
// and assert the precondition rather than assuming it. // and assert the precondition rather than assuming it.
const roomySeed = moveSignalTopTo(exactSignalTop + 80); const roomySeed = await moveSignalTopTo(exactSignalTop + 80);
const roomySeedExpanded = !utility.classList.contains('layout-primary-only') const roomySeedExpanded = !utility.classList.contains('layout-primary-only')
&& roomySeed.firstPassPrimaryOnly === false; && roomySeed.firstPassPrimaryOnly === false;
const exact = { const exact = {
...moveSignalTopTo(exactSignalTop), ...await moveSignalTopTo(exactSignalTop),
utilityTop: hud.style.getPropertyValue('--cockpit-utility-top'), utilityTop: hud.style.getPropertyValue('--cockpit-utility-top'),
primaryOnly: utility.classList.contains('layout-primary-only'), primaryOnly: utility.classList.contains('layout-primary-only'),
siblingVisible: getComputedStyle(radioControl).display !== 'none', siblingVisible: getComputedStyle(radioControl).display !== 'none',
@@ -1944,7 +1955,7 @@ try {
const constrained = { const constrained = {
// One pixel on the far side of the same fit (`exactSignalTop` already sits // One pixel on the far side of the same fit (`exactSignalTop` already sits
// one pixel inside it), so this is still the one-pixel constraint it claims. // one pixel inside it), so this is still the one-pixel constraint it claims.
...moveSignalTopTo(exactSignalTop - 2 * BOUNDARY_EPSILON_PX), ...await moveSignalTopTo(exactSignalTop - 2 * BOUNDARY_EPSILON_PX),
utilityTop: hud.style.getPropertyValue('--cockpit-utility-top'), utilityTop: hud.style.getPropertyValue('--cockpit-utility-top'),
primaryOnly: utility.classList.contains('layout-primary-only'), primaryOnly: utility.classList.contains('layout-primary-only'),
siblingDisplay: getComputedStyle(radioControl).display, siblingDisplay: getComputedStyle(radioControl).display,
@@ -1955,7 +1966,7 @@ try {
<= signal.getBoundingClientRect().top - 7, <= signal.getBoundingClientRect().top - 7,
}; };
const restored = { const restored = {
...moveSignalTopTo(exactSignalTop + 80), ...await moveSignalTopTo(exactSignalTop + 80),
utilityTop: hud.style.getPropertyValue('--cockpit-utility-top'), utilityTop: hud.style.getPropertyValue('--cockpit-utility-top'),
primaryOnly: utility.classList.contains('layout-primary-only'), primaryOnly: utility.classList.contains('layout-primary-only'),
siblingVisible: getComputedStyle(radioControl).display !== 'none', siblingVisible: getComputedStyle(radioControl).display !== 'none',
@@ -2064,7 +2075,7 @@ try {
JSON.stringify(cyberStyleStates)); JSON.stringify(cyberStyleStates));
for (const height of [987, 720, 640, 900]) { for (const height of [987, 720, 640, 900]) {
await page.setViewport({ width: 1440, height, deviceScaleFactor: 1 }); await page.setViewport({ width: 1440, height, deviceScaleFactor: 1 });
await new Promise((resolve) => setTimeout(resolve, 400)); await whenUiSettled(page);
const geometry = await page.evaluate(() => { const geometry = await page.evaluate(() => {
const signal = document.getElementById('cockpit-signal-stream'); const signal = document.getElementById('cockpit-signal-stream');
const utility = document.getElementById('cockpit-utility-controls'); const utility = document.getElementById('cockpit-utility-controls');
@@ -2355,7 +2366,7 @@ try {
await page.waitForFunction(() => { await page.waitForFunction(() => {
const viewer = window.__godsEyeView.viewer; const viewer = window.__godsEyeView.viewer;
if (!viewer) return false; if (!viewer) return false;
const height = viewer.camera.positionCartographic?.height; const height = window.__godsEyeView.engine.camera.pose().cartographic?.height;
return Math.abs(height - 18_000_000) < 150_000; return Math.abs(height - 18_000_000) < 150_000;
}, { timeout: 6_000 }); }, { timeout: 6_000 });
const resetState = await page.evaluate(() => { const resetState = await page.evaluate(() => {
@@ -2363,7 +2374,7 @@ try {
const qa = window.__qaCockpitReset; const qa = window.__qaCockpitReset;
const awareness = gev.dataManager.layers.get('military-awareness')?.module; const awareness = gev.dataManager.layers.get('military-awareness')?.module;
const subjectId = awareness?.getContextSnapshot?.()?.subject?.id || null; const subjectId = awareness?.getContextSnapshot?.()?.subject?.id || null;
const height = gev.viewer.camera.positionCartographic?.height; const height = window.__godsEyeView.engine.camera.pose().cartographic?.height;
gev.styleManager._locationNavigation.resetToGlobeView = qa.original; gev.styleManager._locationNavigation.resetToGlobeView = qa.original;
delete window.__qaCockpitReset; delete window.__qaCockpitReset;
return { return {
@@ -2416,7 +2427,7 @@ try {
else hud.style.removeProperty('--cockpit-utility-top'); else hud.style.removeProperty('--cockpit-utility-top');
if (!prior.cockpit) document.body.classList.remove('cockpit-mode'); if (!prior.cockpit) document.body.classList.remove('cockpit-mode');
}).catch(() => {}); }).catch(() => {});
await browser.close(); await closeBrowser(browser);
} }
console.log(`RESULT: ${failures.length ? 'NOT_READY' : 'READY'} (${failures.length} failures)`); console.log(`RESULT: ${failures.length ? 'NOT_READY' : 'READY'} (${failures.length} failures)`);
+266
View File
@@ -0,0 +1,266 @@
import assert from 'node:assert/strict';
import { fromDegrees } from '../src/geo/cartesian3.js';
import {
createStrict,
localPreview,
openPage,
waitForEngine,
} from './qa-strict-harness.mjs';
const base = localPreview(process.argv[2]);
base.search = '?engine=navara&welcome=0&look=cinematic';
base.hash =
'v=2&lat=43.6500&lon=-79.3800&alt=46000&heading=0&pitch=-90&roll=0&map=sentinel2&l=4';
const strict = createStrict({
origin: base.origin,
layers: ['military-awareness', 'military-installations'],
});
await strict.run({}, async (b) => {
const { page: p, errors } = await openPage(b);
await p.evaluateOnNewDocument(() => {
const fetch = window.fetch.bind(window);
window.fetch = (input, options) => {
const { pathname } = new URL(input.url ?? input, document.baseURI);
if (pathname === '/api/vessels')
return Promise.resolve(
Response.json({
status: 'live',
rows: ['123456789', '223456789'].map((mmsi, i) => ({
mmsi,
name: 'Toronto ' + i,
lat: 43.65,
lon: -79.38 + i * 0.05,
last_position_epoch: Date.now() / 1000,
})),
}),
);
// Contacts reads air quality near the subject; no station here.
if (
new URL(input.url ?? input, document.baseURI).hostname ===
'api.weather.gc.ca'
)
return Promise.resolve(
Response.json({ type: 'FeatureCollection', features: [] }),
);
if (pathname === '/api/vessels/track')
return Promise.resolve(Response.json({ samples: [] }));
if (pathname === '/api/flights')
return Promise.resolve(
Response.json({ time: Date.now() / 1000, states: [] }),
);
if (pathname === '/api/military')
return Promise.resolve(Response.json({ now: Date.now(), ac: [] }));
// One mapped station next to the Contacts subject, with a footprint.
if (pathname === '/api/military-installations') {
window.qaInstallationReads = (window.qaInstallationReads ?? 0) + 1;
const ring = [
[43.665, -79.345],
[43.665, -79.315],
[43.68, -79.315],
[43.68, -79.345],
[43.665, -79.345],
];
return Promise.resolve(
Response.json({
status: 'ok',
elements: [
{
type: 'way',
id: 424242,
tags: { military: 'naval_base', name: 'QA Harbour Station' },
center: { lat: 43.6725, lon: -79.33 },
geometry: ring.map(([lat, lon]) => ({ lat, lon })),
},
],
}),
);
}
return fetch(input, options);
};
window.qaSelected = [];
for (const type of ['gev:entity-selected', 'gev:entity-selection-cleared'])
window.addEventListener(type, ({ detail }) =>
window.qaSelected.push({
type,
id: detail?.id,
layerId: detail?.layerId,
}),
);
});
await p.goto(base.href);
await waitForEngine(p, 'navara');
await p.evaluate(async () => {
const { dataManager, styleManager } = window.__godsEyeView;
await dataManager.setEnabled('ais-live-vessels', true, { origin: 'user' });
window.qaBefore = [...dataManager.getEnabledLayerIds()].sort();
const outcome = await styleManager.setContextMode('contacts');
if (!outcome.ok) throw Error(JSON.stringify(outcome));
window.qaContext = dataManager.layers.get('military-awareness').module;
window.qaSites = dataManager.layers.get('military-installations').module;
const vessels = dataManager.layers.get('ais-live-vessels').module;
if (!vessels.selectById('123456789')) throw Error('Missing first vessel');
if (!window.qaContext.navigateNext({ targetLayer: 'ais-live-vessels' }))
throw Error('Cannot navigate to the second vessel');
});
const sample = () =>
p.evaluate(() => {
const { dataManager, styleManager, engine } = window.__godsEyeView;
return {
context:
window.qaContext ===
dataManager.layers.get('military-awareness').module,
sites:
window.qaSites ===
dataManager.layers.get('military-installations').module,
mode: styleManager.getContextModeState().mode,
subject: window.qaContext.getContextSnapshot()?.subject?.id,
history:
window.qaContext._getAwarenessNavigationStateForTest().historyKeys,
enabled: [...dataManager.getEnabledLayerIds()].sort(),
ring:
engine.kind === 'navara'
? engine.lines
.list()
.filter((line) => line.id === 'gev-awareness-rim').length
: null,
};
});
// The station zone: the fixture's installation is the one mapped site
// within the Contacts radius of the subject, and the engine draws it where
// a pointer can reach it (the engine's own picker returns it at an
// uncovered canvas point at its marker or inside its footprint).
const inside = [fromDegrees(-79.33, 43.6725)];
for (let i = 1; i < 6; i++)
for (let k = 1; k < 6; k++)
inside.push(fromDegrees(-79.345 + 0.005 * k, 43.665 + 0.0025 * i));
const zone = async () => {
await p.waitForFunction(
() =>
!window.qaSites.getStats().loading &&
window.qaSites
.getAnalystRecords()
.some((r) => r.name === 'QA Harbour Station'),
{ timeout: 30000 },
);
const drawn = await p
.waitForFunction(
(points) => {
const { engine } = window.__godsEyeView;
const canvas = engine.host.canvas;
const box = canvas.getBoundingClientRect();
for (const world of points) {
const centre = engine.projector.worldToWindow(world, {});
if (!centre) continue;
for (const [dx, dy] of [
[0, 0],
[0, -8],
[8, 0],
[-8, 0],
[0, 8],
]) {
const at = { x: centre.x + dx, y: centre.y + dy };
if (
!canvas.contains(
document.elementFromPoint(box.left + at.x, box.top + at.y),
)
)
continue;
const id = engine.picker.pick(at)?.id;
if (String(id?.id ?? id) === 'osm:way:424242')
return [Math.round(at.x), Math.round(at.y)];
}
}
return false;
},
{ timeout: 20000 },
inside,
)
.then((handle) => handle.jsonValue())
.catch(() => null);
return p.evaluate(
(drawn) => ({
drawn,
records: window.qaSites.getAnalystRecords().map((r) => r.name),
coverage: window.qaSites.getStats().coverage,
count: window.qaSites.getStats().count,
reads: window.qaInstallationReads,
subject: window.qaContext.getContextSnapshot()?.subject?.id,
}),
drawn,
);
};
const checkZone = async () => {
const station = await zone();
await strict.check(
p,
'military-installations',
'the station zone holds the fixture station, drawn and pickable',
station.count === 1 &&
station.records.length === 1 &&
station.records[0] === 'QA Harbour Station' &&
station.coverage?.kind === 'subject' &&
Array.isArray(station.drawn),
station,
);
return station;
};
const firstZone = await checkZone();
const baseline = await sample();
console.log('baseline', JSON.stringify(baseline));
assert.equal(baseline.mode, 'flights');
assert.equal(baseline.subject, '223456789');
assert.ok(baseline.history.includes('ais-live-vessels:123456789'));
assert.ok(baseline.ring >= 1);
await strict.passed(
p,
'military-awareness',
'Contacts subject, navigation history and awareness rim',
baseline,
);
for (const kind of ['cesium', 'navara']) {
await strict.switchTo(p, kind);
const current = await sample();
console.log('handoff', kind, JSON.stringify(current));
assert.deepEqual(current, {
...baseline,
ring: kind === 'navara' ? baseline.ring : null,
});
await strict.passed(
p,
'military-awareness',
'same awareness model, Contacts mode, subject, history and enabled layers',
);
await strict.passed(
p,
'military-installations',
'same installations model and enabled through the switch',
);
const station = await checkZone();
await strict.check(
p,
'military-installations',
'the switch reads no installation snapshot',
station.reads === firstZone.reads,
{ before: firstZone.reads, after: station.reads },
);
}
await checkZone();
const restored = await p.evaluate(async () => {
const { dataManager, styleManager } = window.__godsEyeView;
const result = await styleManager.setContextMode('off');
return (
result.ok &&
JSON.stringify([...dataManager.getEnabledLayerIds()].sort()) ===
JSON.stringify(window.qaBefore)
);
});
assert.ok(
restored,
'Restore layers enabled by the user before entering Contacts',
);
assert.deepEqual(errors, []);
console.log(
'PASS Contacts history, dependency ownership and exit restore across both globes',
);
});
+57 -81
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
import { evaluateWithGeo } from './qa/geometry.mjs';
/** /**
* qa-context-arrivals.mjs — search arrivals above the surface, and Contacts * qa-context-arrivals.mjs — search arrivals above the surface, and Contacts
* listing mapped installations around a moving tracked aircraft. * listing mapped installations around a moving tracked aircraft.
@@ -25,9 +26,10 @@
* Writes <out>/result.json, screenshots and <out>/clip.mp4 (CDP screencast * Writes <out>/result.json, screenshots and <out>/clip.mp4 (CDP screencast
* with real frame timing; needs ffmpeg). * with real frame timing; needs ffmpeg).
*/ */
import { whenSettled, whenFramesDrawn, whenTilesLoaded } from './qa/ready.mjs';
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import puppeteer from 'puppeteer';
import { stubPlanespotters } from './qa-photo-stub.mjs'; import { stubPlanespotters } from './qa-photo-stub.mjs';
import { createScreencast, moveCamera } from './qa-journey-recorder.mjs'; import { createScreencast, moveCamera } from './qa-journey-recorder.mjs';
@@ -62,14 +64,12 @@ const check = (name, ok, detail = '') => {
if (!ok) result.failures.push(`${name}${detail ? ` — ${detail}` : ''}`); if (!ok) result.failures.push(`${name}${detail ? ` — ${detail}` : ''}`);
}; };
const browser = await puppeteer.launch({ const browser = await launchBrowser({
headless: !argv.includes('--headful'), headless: true,
protocolTimeout: 300_000, protocolTimeout: 300_000,
args: [ args: [
'--no-sandbox', '--no-sandbox',
...(process.platform === 'darwin' ...(process.platform === 'darwin' ? ['--enable-gpu'] : []),
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
'--disable-background-timer-throttling', '--disable-background-timer-throttling',
'--disable-renderer-backgrounding', '--disable-renderer-backgrounding',
'--disable-backgrounding-occluded-windows', '--disable-backgrounding-occluded-windows',
@@ -171,12 +171,10 @@ async function openPage({ aircraft = false } = {}) {
const url = new URL(appUrl); const url = new URL(appUrl);
url.searchParams.set('welcome', '0'); url.searchParams.set('welcome', '0');
await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 90_000 }); await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 90_000 });
await page.waitForFunction( await page.waitForFunction(() => window.__godsEyeView?.dataManager, {
() => timeout: 120_000,
window.__godsEyeView?.dataManager && });
document.getElementById('loading-screen')?.classList.contains('hidden'), await whenSettled(page);
{ timeout: 120_000 },
);
await page.evaluate( await page.evaluate(
() => window.__godsEyeView.styleManager.initialRestorePromise, () => window.__godsEyeView.styleManager.initialRestorePromise,
); );
@@ -185,31 +183,16 @@ async function openPage({ aircraft = false } = {}) {
} }
async function waitTiles(page, maxMs = 30_000) { async function waitTiles(page, maxMs = 30_000) {
await page await whenTilesLoaded(page, { timeoutMs: maxMs }).catch(() => {});
.waitForFunction(
() => {
const { scene } = window.__godsEyeView.viewer;
if (scene.globe.show) return scene.globe.tilesLoaded;
for (let i = 0; i < scene.primitives.length; i++) {
const p = scene.primitives.get(i);
if (p.show && p.tilesLoaded === false) return false;
}
return true;
},
{ timeout: maxMs, polling: 250 },
)
.catch(() => {});
} }
/** Camera height above the rendered surface under it and under the view centre. */ /** Camera height above the rendered surface under it and under the view centre. */
function clearance(page) { function clearance(page) {
return page.evaluate(() => { return evaluateWithGeo(page, async (qaGeo) => {
const { viewer } = window.__godsEyeView; const carto = window.__godsEyeView.engine.camera.pose().cartographic;
const { scene, camera } = viewer;
const carto = camera.positionCartographic.clone();
const sample = (c) => { const sample = (c) => {
try { try {
const h = scene.sampleHeight(c); const h = window.__godsEyeView.engine.surface.sampleHeight(c);
return Number.isFinite(h) ? h : null; return Number.isFinite(h) ? h : null;
} catch { } catch {
return null; return null;
@@ -217,12 +200,12 @@ function clearance(page) {
}; };
const under = sample(carto); const under = sample(carto);
let centre = null; let centre = null;
const canvas = scene.canvas; const canvas = window.__godsEyeView.engine.host.canvas;
const hit = scene.pickPosition?.({ const hit = window.__godsEyeView.engine.picker.pickPosition?.({
x: canvas.clientWidth / 2, x: canvas.clientWidth / 2,
y: canvas.clientHeight / 2, y: canvas.clientHeight / 2,
}); });
if (hit) centre = scene.globe.ellipsoid.cartesianToCartographic(hit); if (hit) centre = qaGeo.cartographic.fromCartesian(hit);
const centreSurface = centre ? sample(centre) : null; const centreSurface = centre ? sample(centre) : null;
const surface = Math.max(under ?? -Infinity, centreSurface ?? -Infinity); const surface = Math.max(under ?? -Infinity, centreSurface ?? -Infinity);
return { return {
@@ -234,7 +217,9 @@ function clearance(page) {
clearanceM: Number.isFinite(surface) clearanceM: Number.isFinite(surface)
? Math.round(carto.height - surface) ? Math.round(carto.height - surface)
: null, : null,
pitchDeg: Math.round((camera.pitch * 180) / Math.PI), pitchDeg: Math.round(
(window.__godsEyeView.engine.camera.pose().pitch * 180) / Math.PI,
),
}; };
}); });
} }
@@ -255,47 +240,32 @@ async function arrivals() {
}); });
await page.type('#location-search', query, { delay: 40 }); await page.type('#location-search', query, { delay: 40 });
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
// Wait for the flight to land: the camera still for 1.5 s. await whenSettled(page);
const landed = Date.now();
let last = null;
let stillSince = null;
while (Date.now() - landed < 30_000) {
const now = await page.evaluate(() => {
const c = window.__godsEyeView.viewer.camera.positionWC;
return [c.x, c.y, c.z];
});
const moved =
!last ||
Math.hypot(now[0] - last[0], now[1] - last[1], now[2] - last[2]) > 0.5;
last = now;
if (moved) stillSince = null;
else stillSince ??= Date.now();
if (
Date.now() - landed > 4000 &&
stillSince &&
Date.now() - stillSince > 1500
)
break;
await sleep(250);
}
await waitTiles(page); await waitTiles(page);
await sleep(2500); // the ground guard measures after tiles stream in await whenSettled(page); // the ground guard measures after tiles stream in
const arrived = await clearance(page); const arrived = await clearance(page);
const slug = query.replace(/\W+/g, '-').replace(/^-|-$/g, '').toLowerCase(); const slug = query.replace(/\W+/g, '-').replace(/^-|-$/g, '').toLowerCase();
await page.screenshot({ path: path.join(dir, `arrival-${slug}.png`) }); await page.screenshot({ path: path.join(dir, `arrival-${slug}.png`) });
// Look around: a gentle orbit, then check the eye still clears the ground. // Look around: a gentle orbit, then check the eye still clears the ground.
const view = await page.evaluate(() => { const view = await page.evaluate(() => {
const c = window.__godsEyeView.viewer.camera;
return { return {
lat: (c.positionCartographic.latitude * 180) / Math.PI, lat:
lon: (c.positionCartographic.longitude * 180) / Math.PI, (window.__godsEyeView.engine.camera.pose().cartographic.latitude *
height: c.positionCartographic.height, 180) /
heading: (c.heading * 180) / Math.PI, Math.PI,
pitch: (c.pitch * 180) / Math.PI, lon:
(window.__godsEyeView.engine.camera.pose().cartographic.longitude *
180) /
Math.PI,
height: window.__godsEyeView.engine.camera.pose().cartographic.height,
heading:
(window.__godsEyeView.engine.camera.pose().heading * 180) / Math.PI,
pitch:
(window.__godsEyeView.engine.camera.pose().pitch * 180) / Math.PI,
}; };
}); });
await moveCamera(page, { ...view, heading: view.heading + 25 }, 2.5); await moveCamera(page, { ...view, heading: view.heading + 25 }, 2.5);
await sleep(1500); await whenFramesDrawn(page);
const lookedAround = await clearance(page); const lookedAround = await clearance(page);
result.arrivals.push({ query, arrived, lookedAround }); result.arrivals.push({ query, arrived, lookedAround });
check( check(
@@ -323,7 +293,7 @@ async function contacts() {
{ lat: 30.35, lon: -97.76, height: 900_000, pitch: -90 }, { lat: 30.35, lon: -97.76, height: 900_000, pitch: -90 },
0, 0,
); );
await sleep(3000); await whenFramesDrawn(page);
// In-page probe: row text and installation entity churn every 250 ms. // In-page probe: row text and installation entity churn every 250 ms.
await page.evaluate(() => { await page.evaluate(() => {
const samples = []; const samples = [];
@@ -340,10 +310,16 @@ async function contacts() {
candidate.querySelector('strong')?.textContent?.trim() === candidate.querySelector('strong')?.textContent?.trim() ===
'Mapped installations', 'Mapped installations',
); );
const ds = window.__godsEyeView.viewer.dataSources.getByName( // The drawn sites are context handles in the shared context store
'military-installations', // (src/layers/installations/render/display.js keeps a site's handle
)[0]; // while its geometry is unchanged), not Entities: a new handle is a
for (const entity of ds?.entities.values || []) // rebuilt site.
const sites = [
...(window.__gevContextStore?.entities.values() || []),
].filter((record) =>
String(record.entity?.gevTrackedId ?? '').startsWith('installations:'),
);
for (const { entity } of sites)
if (!seen.has(entity)) { if (!seen.has(entity)) {
seen.add(entity); seen.add(entity);
created += 1; created += 1;
@@ -359,10 +335,10 @@ async function contacts() {
name: button.childNodes[0]?.textContent?.trim(), name: button.childNodes[0]?.textContent?.trim(),
distance: button.querySelector('span')?.textContent?.trim(), distance: button.querySelector('span')?.textContent?.trim(),
})), })),
entities: ds?.entities.values.length ?? 0, entities: sites.length,
created, created,
tracked: tracked:
window.__godsEyeView.viewer.trackedEntity?.gevTrackedId ?? null, window.__godsEyeView.engine.camera.following()?.label?.id ?? null,
cockpit: document.body.classList.contains('cockpit-mode'), cockpit: document.body.classList.contains('cockpit-mode'),
}); });
if (samples.length > 5000) samples.splice(0, 1000); if (samples.length > 5000) samples.splice(0, 1000);
@@ -377,7 +353,7 @@ async function contacts() {
await page await page
.waitForFunction( .waitForFunction(
() => () =>
window.__godsEyeView.viewer.trackedEntity?.gevTrackedId === window.__godsEyeView.engine.camera.following()?.label?.id ===
'flights:aaa051', 'flights:aaa051',
{ timeout: 60_000 }, { timeout: 60_000 },
) )
@@ -431,7 +407,7 @@ async function contacts() {
`${reverted.length}/${flight.length}`, `${reverted.length}/${flight.length}`,
); );
check( check(
'site entities are not rebuilt while tracking', 'drawn sites are not rebuilt while tracking',
createdDuring === 0, createdDuring === 0,
`${createdDuring} created`, `${createdDuring} created`,
); );
@@ -472,7 +448,7 @@ async function contacts() {
await page.evaluate(() => await page.evaluate(() =>
window.__godsEyeView.styleManager.cockpitView.exit?.(), window.__godsEyeView.styleManager.cockpitView.exit?.(),
); );
await sleep(2000); await whenFramesDrawn(page);
// SEARCH NEARBY SITES, as the user presses it. // SEARCH NEARBY SITES, as the user presses it.
await page.evaluate(() => { await page.evaluate(() => {
@@ -498,7 +474,7 @@ async function contacts() {
const toast = await page.evaluate(() => const toast = await page.evaluate(() =>
document.getElementById('toast').textContent.trim(), document.getElementById('toast').textContent.trim(),
); );
await sleep(1500); await whenFramesDrawn(page);
const afterSearch = ( const afterSearch = (
await readSamples((await page.evaluate(() => performance.now())) - 1000) await readSamples((await page.evaluate(() => performance.now())) - 1000)
).at(-1); ).at(-1);
@@ -513,13 +489,13 @@ async function contacts() {
await page.evaluate(() => await page.evaluate(() =>
document.getElementById('global-context-flights-btn').click(), document.getElementById('global-context-flights-btn').click(),
); );
await sleep(2000); await whenFramesDrawn(page);
await page.evaluate(() => await page.evaluate(() =>
window.__godsEyeView.dataManager.layers window.__godsEyeView.dataManager.layers
.get('flights') .get('flights')
.module.trackById('aaa052'), .module.trackById('aaa052'),
); );
await sleep(3000); await whenFramesDrawn(page);
const t1 = await page.evaluate(() => performance.now()); const t1 = await page.evaluate(() => performance.now());
await page.evaluate(() => await page.evaluate(() =>
document.getElementById('global-context-flights-btn').click(), document.getElementById('global-context-flights-btn').click(),
@@ -548,7 +524,7 @@ async function contacts() {
} }
await sleep(500); await sleep(500);
} }
await sleep(2000); await whenFramesDrawn(page);
await page.screenshot({ path: path.join(dir, 'contacts-fort-cavazos.png') }); await page.screenshot({ path: path.join(dir, 'contacts-fort-cavazos.png') });
result.contacts.cavazos = cavazos; result.contacts.cavazos = cavazos;
check( check(
@@ -594,7 +570,7 @@ try {
path.join(outDir, 'result.json'), path.join(outDir, 'result.json'),
JSON.stringify(result, null, 1), JSON.stringify(result, null, 1),
); );
await browser.close(); await closeBrowser(browser);
} }
console.log( console.log(
`${result.failures.length ? 'FAIL' : 'PASS'} — ${path.join(outDir, 'result.json')}`, `${result.failures.length ? 'FAIL' : 'PASS'} — ${path.join(outDir, 'result.json')}`,
+304
View File
@@ -0,0 +1,304 @@
#!/usr/bin/env node
/**
* Run the globe contract suite (src/globe/testing/contractSuite.mjs) in
* headless Chrome against the real engines of a running app, the way
* contract.test.mjs runs it against the fake engine.
*
* GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4241
* node scripts/qa-contract-suite.mjs http://localhost:4241 [--engine=cesium,navara] [--json=FILE]
*
* Serve the app keyless from the dev server: Google and Cesium ion requests
* are refused here anyway, so neither engine draws photoreal tiles. The page
* imports the suite from the dev server, so it shares the app's modules (an
* engine event is recognised by the module that made it). It gets a real
* pointer (the pick checks), real 2D canvases for sprite atlases, and no map
* sources (the maps checks would ask for photoreal tiles). Exits 1 when any check fails on any engine.
*/
import fs from 'node:fs';
import puppeteer from 'puppeteer';
const BLOCKED_HOSTS =
/(^|\.)(googleapis\.com|google\.com|gstatic\.com|cesium\.com)$/;
const args = process.argv.slice(2);
const origin = args.find((arg) => !arg.startsWith('--'));
const option = (name) =>
args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
if (!origin) {
console.error(
'Usage: node scripts/qa-contract-suite.mjs <origin> [--engine=cesium,navara] [--only=prefix,...] [--dpr=1|2] [--json=FILE]',
);
process.exit(2);
}
const engines = (option('engine') ?? 'cesium,navara').split(',');
// --only=prefix,prefix runs just the checks whose names start with one.
const only =
option('only')
?.split(',')
.map((prefix) =>
/^(ground|imagery|movers|lines|meshes|maps):$/.test(prefix)
? prefix.replace(':', '.')
: prefix,
) ?? null;
const dpr = Number(option('dpr') ?? 1);
if (![1, 2].includes(dpr)) throw new Error('--dpr must be 1 or 2');
// Checks of state the running app drives itself, by name prefix, with the
// owner. The app sets contact sonar on every frame (src/cyberSonarEngine.js),
// so a value the suite sets does not survive to its next frame.
const APP_OWNED = Object.freeze([
['render.setContactSonar', 'src/cyberSonarEngine.js sets it every frame'],
]);
async function runOn(browser, kind) {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: dpr });
const pageErrors = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
await page.setRequestInterception(true);
page.on('request', (request) => {
const { hostname } = new URL(request.url());
if (new URL(request.url()).pathname === '/__contract_missing_model.glb')
request.respond({ status: 404, body: 'Model unavailable' });
else if (BLOCKED_HOSTS.test(hostname)) request.abort();
else request.continue();
});
// A view in the link: the app starts there instead of flying to a city.
await page.goto(
`${origin}/?welcome=0&engine=${kind}#v=2&lat=10&lon=20&alt=1000000&heading=0&pitch=-90&map=osm&l=`,
);
await page.waitForFunction(
(expected) =>
window.__godsEyeView?.engine?.kind === expected &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
// A cold dev server compiles Navara's modules on the first visit.
{ timeout: 300_000, polling: 250 },
kind,
);
// The suite owns the globe: no camera flight running, and the app's panels
// let the pointer through to the canvas.
// The app restores the view in the link with a flight that can start
// after the loading screen goes: wait until the camera is there and still,
// or that flight lands in the middle of a check.
await page.waitForFunction(
() => {
const { camera } = window.__godsEyeView.engine;
return (
!camera.flightActive() && Math.abs(camera.height() - 1_000_000) < 1000
);
},
{ timeout: 120_000, polling: 250 },
);
// Only the engine's canvas takes the pointer: the world overlay draws on a
// canvas of its own above it.
await page.evaluate(() =>
window.__godsEyeView.engine.host.canvas.setAttribute(
'data-contract-target',
'',
),
);
await page.addStyleTag({
content:
'* { pointer-events: none !important; } [data-contract-target] { pointer-events: auto !important; }',
});
// The globe drawn and its tiles loaded: a restored view hides the globe
// for a moment after the loading screen goes.
await page.evaluate(async () => {
const { engine } = window.__godsEyeView;
const canvas = engine.host.canvas;
const probe = document.createElement('canvas');
probe.width = 1;
probe.height = 1;
const context = probe.getContext('2d', { willReadFrequently: true });
const started = performance.now();
let settled = 0;
while (performance.now() - started < 120_000 && settled < 20) {
await new Promise((resolve) => {
const remove = engine.frames.postRender.addEventListener(() => {
remove();
resolve();
});
engine.render.request('contract-suite-settle');
});
context.drawImage(
canvas,
canvas.width / 2,
canvas.height / 2,
1,
1,
0,
0,
1,
1,
);
const [r, g, b] = context.getImageData(0, 0, 1, 1).data;
settled =
r + g + b > 60 && engine.surface.tilesLoaded() ? settled + 1 : 0;
}
});
// A real left click and pointer move at a canvas position.
await page.exposeFunction('__contractPointer', async (type, x, y) => {
const box = await page.evaluate(() => {
const rect =
window.__godsEyeView.engine.host.canvas.getBoundingClientRect();
return { left: rect.left, top: rect.top };
});
const at = [box.left + x, box.top + y];
if (type === 'click') await page.mouse.click(...at);
else {
await page.mouse.move(at[0] - 1, at[1]);
await page.mouse.move(...at);
}
});
await page.exposeFunction('__contractDrag', async (x, y, endX, endY) => {
const box = await page.evaluate(() => {
const rect =
window.__godsEyeView.engine.host.canvas.getBoundingClientRect();
return { left: rect.left, top: rect.top };
});
await page.mouse.move(box.left + x, box.top + y);
await page.mouse.down();
await page.mouse.move(box.left + endX, box.top + endY, { steps: 4 });
await page.mouse.up();
});
await page.exposeFunction('__contractResize', async (width, height) => {
await page.setViewport({
width: Math.round(width),
height: Math.round(height),
deviceScaleFactor: dpr,
});
});
const results = await page.evaluate(
async (appOwned, only) => {
const { engine } = window.__godsEyeView;
const waitFrames = (count) =>
new Promise((resolve, reject) => {
const timer = setTimeout(() => {
remove();
reject(new Error('Timed out waiting for a rendered frame'));
}, 10_000);
let left = count;
const remove = engine.frames.postRender.addEventListener(() => {
if (--left > 0) return;
remove();
clearTimeout(timer);
resolve();
});
});
const pointer = {
click: (x, y) => window.__contractPointer('click', x, y),
move: (x, y) => window.__contractPointer('move', x, y),
drag: (x, y, endX, endY) => window.__contractDrag(x, y, endX, endY),
};
const { createContractSuite } =
await import('/src/globe/testing/contractSuite.mjs');
const fixtureModule =
engine.kind === 'cesium'
? await import('/src/globe/cesium/testing/contractFixtures.js')
: await import('/src/globe/navara/testing/contractFixtures.js');
const fixtures = await fixtureModule.createContractFixtures();
const outcome = [];
// A real 2D canvas: a sprite atlas the engines can draw and pick.
const makeCanvas = (width, height) =>
Object.assign(document.createElement('canvas'), { width, height });
// RGBA of the last drawn frame (both engines keep their drawing buffer).
const scratch = document.createElement('canvas');
const context = scratch.getContext('2d', { willReadFrequently: true });
const readRegion = (x, y, width, height) => {
const canvas = engine.host.canvas;
const scale = canvas.width / canvas.clientWidth;
scratch.width = width;
scratch.height = height;
context.clearRect(0, 0, width, height);
context.drawImage(
canvas,
x * scale,
y * scale,
width * scale,
height * scale,
0,
0,
width,
height,
);
return context.getImageData(0, 0, width, height).data;
};
for (const { name, run } of createContractSuite(engine, {
waitFrames,
pointer,
readRegion,
makeCanvas,
fixtures,
resize: (width, height) => window.__contractResize(width, height),
})) {
if (only && !only.some((prefix) => name.startsWith(prefix))) continue;
const owner = appOwned.find(([prefix]) => name.startsWith(prefix));
if (owner) {
outcome.push({ name, skipped: owner[1] });
continue;
}
try {
await run();
outcome.push({ name, ok: true });
} catch (error) {
outcome.push({
name,
ok: false,
error: String(error?.message ?? error),
});
}
}
fixtures.destroy?.();
return outcome;
},
APP_OWNED,
only,
);
await page.close();
return { engine: kind, dpr, results, pageErrors };
}
const browser = await puppeteer.launch({
headless: true,
// A Navara page can hold its main thread for minutes while it starts.
protocolTimeout: 600_000,
args: [
`--use-angle=${process.env.QA_ANGLE ?? (process.platform === 'darwin' ? 'metal' : 'swiftshader')}`,
'--enable-gpu',
'--no-sandbox',
],
});
const report = [];
try {
for (const kind of engines) {
const run = await runOn(browser, kind);
report.push(run);
const ran = run.results.filter(({ skipped }) => !skipped);
const failed = ran.filter(({ ok }) => !ok);
const known = failed.filter(({ knownFailure }) => knownFailure);
console.log(
`${kind} DPR ${dpr}: ${ran.length - failed.length}/${ran.length} checks pass; ${known.length} known failures; ${run.results.length - ran.length} skipped`,
);
for (const { name, skipped } of run.results)
if (skipped) console.log(` skipped ${name}: ${skipped}`);
for (const { name, error, knownFailure } of failed)
console.log(
` ${knownFailure ? 'KNOWN FAIL' : 'FAIL'} ${name}: ${error}`,
);
for (const message of run.pageErrors)
console.log(` page error: ${message}`);
}
} finally {
await browser.close();
}
const json = option('json');
if (json) fs.writeFileSync(json, `${JSON.stringify(report, null, 2)}\n`);
process.exit(
report.every(({ results }) =>
results.every(
({ ok, skipped, knownFailure }) => ok || skipped || knownFailure,
),
)
? 0
: 1,
);
+213
View File
@@ -0,0 +1,213 @@
import { whenSettled } from './qa/ready.mjs';
import assert from 'node:assert/strict';
import {
createStrict,
localPreview,
openPage,
waitForEngine,
} from './qa-strict-harness.mjs';
const base = localPreview(process.argv[2]);
base.search = '?engine=navara&welcome=0&look=cinematic';
base.hash =
'v=2&lat=55.3241&lon=10.7995&alt=25000&heading=0&pitch=-90&roll=0&map=sentinel2&l=';
const strict = createStrict({ origin: base.origin, layers: ['directions'] });
await strict.run({}, async (b) => {
const { page: p, errors } = await openPage(b);
let routeReads = 0,
release;
await p.setRequestInterception(true);
p.on('request', async (req) => {
const u = new URL(req.url());
const json = (v) =>
req.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify(v),
});
if (u.pathname === '/api/terrain/heights') {
const cells = (u.searchParams.get('points') ?? '').split(';');
return json({ results: cells.map(() => ({ ellipsoid: 34 })) });
}
if (u.pathname !== '/api/route') return req.continue();
routeReads++;
const [a, b] = u.searchParams
.get('coords')
.split(';')
.map((p) => p.split(',').map(Number));
const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
if (routeReads === 1)
await new Promise((resolve) => {
release = resolve;
});
return json({
ok: true,
geometry: [a, mid, b],
distanceM: 9000,
durationS: 800,
steps: [a, mid, b].map(([lon, lat], i) => ({
lon,
lat,
instruction: 'Route step ' + i,
distanceM: 3000,
durationS: 120,
})),
});
});
await p.goto(base.href);
await waitForEngine(p, 'navara');
await p.evaluate(async () => {
const { dataManager } = window.__godsEyeView;
window.qaRoute = dataManager.layers.get('directions').module;
await dataManager.setEnabled('directions', true, { origin: 'user' });
});
for (const [letter, x] of [
['a', 480],
['b', 645],
]) {
await p.evaluate((l) => window.qaRoute.setParams({ arm: l }), letter);
await p.mouse.click(x, 440);
await whenSettled(p);
}
assert.equal(routeReads, 1);
await strict.passed(
p,
'directions',
'A and B set by clicks on the globe; one route request',
);
await strict.switchTo(p, 'cesium');
release();
await p.waitForFunction(() => window.qaRoute.getStats().count === 3);
await p.waitForFunction(() => {
window.qaRoute.setParams({ step: 1 });
return window.qaRoute.getRowControls().list.items[1]?.active;
});
await strict.passed(
p,
'directions',
'the route asked for on Navara arrives on Cesium; a step is selectable',
);
const transfer = async (kind) => {
const flying = await p.evaluate(() =>
Boolean(window.qaRoute.getFlightProgress()),
);
if (flying) {
// A running dolly owns the camera; verify its handoff before asking for rest.
await strict.pick(p, kind, { settle: false });
await p.waitForFunction(
(kind) =>
window.__godsEyeView?.engine?.kind === kind &&
!document.querySelector('#globe-engine[aria-busy="true"]') &&
window.qaRoute.getFlightProgress()?.traveledM > 0,
{},
kind,
);
} else await strict.switchTo(p, kind);
assert.equal(
await p.evaluate(
() =>
window.__godsEyeView.dataManager.layers.get('directions').module ===
window.qaRoute,
),
true,
);
assert.equal(routeReads, 1);
await strict.passed(
p,
'directions',
'same model; the switch requests no route',
);
};
await transfer('navara');
assert.equal(
await p.evaluate(
() => window.qaRoute.getRowControls().list.items[1].active,
),
true,
);
await p.evaluate(() => window.qaRoute.setParams({ fly: true }));
await p.waitForFunction(
() =>
window.qaRoute.getRowControls().chips.find((c) => c.id === 'fly')?.active,
);
const pos = () =>
p.evaluate(() => window.__godsEyeView.engine.camera.pose().position);
const d = (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
await p.waitForFunction(() => window.qaRoute.getFlightProgress()?.traveledM > 0);
let progress = await p.evaluate(() => window.qaRoute.getFlightProgress());
for (const kind of ['cesium', 'navara']) {
const before = await pos();
await transfer(kind);
await p.waitForFunction(
() =>
window.qaRoute.getRowControls().chips.find((c) => c.id === 'fly')
?.active,
);
const at = await pos();
await p.waitForFunction(
({ at, traveledM }) => {
const next = window.__godsEyeView.engine.camera.pose().position;
return (
window.qaRoute.getFlightProgress()?.traveledM > traveledM &&
Math.hypot(next.x - at.x, next.y - at.y, next.z - at.z) > 1
);
},
{},
{ at, traveledM: progress.traveledM },
);
const next = await pos();
const resumed = await p.evaluate(() => window.qaRoute.getFlightProgress());
assert.equal(resumed.motionId, progress.motionId);
assert.ok(resumed.traveledM > progress.traveledM);
progress = resumed;
console.log(kind, 'moved', d(at, next), 'handoff', d(at, before));
assert.ok(d(at, next) > 1, 'dolly advances after switch to ' + kind);
await strict.passed(
p,
'directions',
'FLY continues with the same motion and advances',
{ moved: d(at, next) },
);
}
await p.evaluate(() => window.qaRoute.setParams({ clear: true }));
assert.equal(await p.evaluate(() => window.qaRoute.getStats().count), 0);
// CLEAR releases the camera: the route's motion ends, the camera stays
// where it is, and a wheel zoom by the person moves it and is kept.
await waitForEngine(p, 'navara');
const released = await p.evaluate(() => ({
motion: window.qaRoute.getFlightProgress(),
fly: window.qaRoute.getRowControls().chips.find((c) => c.id === 'fly')
?.active,
}));
const still = await pos();
await whenSettled(p);
const later = await pos();
const height = () =>
p.evaluate(() => window.__godsEyeView.engine.camera.height());
const beforeZoom = await height();
await p.mouse.move(512, 300);
for (let i = 0; i < 4; i++) {
await p.mouse.wheel({ deltaY: -240 });
await whenSettled(p);
}
await whenSettled(p);
const zoomed = await height();
await whenSettled(p);
const kept = await height();
await strict.check(
p,
'directions',
'CLEAR removes the route and releases the camera: no route motion, the camera stays put, a wheel zoom moves it and is kept',
released.motion === null &&
!released.fly &&
d(still, later) < 1 &&
zoomed < beforeZoom * 0.95 &&
Math.abs(kept - zoomed) < 0.02 * zoomed,
{ released, drift: d(still, later), beforeZoom, zoomed, kept },
);
assert.deepEqual(errors, []);
console.log(
'PASS route delivery during handoff, A/B clicks, step selection, ongoing FLY, clear and single route request',
);
});
+188 -114
View File
@@ -1,4 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
// Cesium-only probes: native primitive inspection independently verifies the Cesium route geometry.
import { evaluateWithGeo } from './qa/geometry.mjs';
import { whenSettled, whenUiSettled, pollPage } from './qa/ready.mjs';
/** /**
* Browser proof of the Directions layer: real chip clicks, real globe clicks, * Browser proof of the Directions layer: real chip clicks, real globe clicks,
* a real route from the real proxy, a real camera flight. * a real route from the real proxy, a real camera flight.
@@ -6,10 +9,10 @@
* Usage: node scripts/qa-directions.mjs [--url http://localhost:4173] * Usage: node scripts/qa-directions.mjs [--url http://localhost:4173]
* [--shots qa-shots/directions] [--label keyed] [--skip denver] * [--shots qa-shots/directions] [--label keyed] [--skip denver]
*/ */
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs'; import { chromeExecutablePath } from './qa-chrome.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -34,9 +37,8 @@ const check = (name, passed, detail = '') => {
if (!passed) failures += 1; if (!passed) failures += 1;
}; };
const browser = await puppeteer.launch({ const browser = await launchBrowser({
headless: false, headless: true,
executablePath: chromeExecutablePath(),
args: [ args: [
'--no-sandbox', '--no-sandbox',
'--disable-background-timer-throttling', '--disable-background-timer-throttling',
@@ -62,12 +64,12 @@ const shot = async (name) => {
/** Put the camera over a place, instantly, and let the scene settle. */ /** Put the camera over a place, instantly, and let the scene settle. */
async function lookAt(lat, lon, height) { async function lookAt(lat, lon, height) {
await page.evaluate( await evaluateWithGeo(
(la, lo, h) => { page,
const viewer = window.__godsEyeView.viewer; async (qaGeo, la, lo, h) => {
const Cartesian3 = viewer.camera.positionWC.constructor; const Cartesian3 = qaGeo.cartesian3;
viewer.camera.cancelFlight(); window.__godsEyeView.engine.camera.cancelFlight();
viewer.camera.setView({ window.__godsEyeView.engine.camera.setView({
destination: Cartesian3.fromDegrees(lo, la, h), destination: Cartesian3.fromDegrees(lo, la, h),
orientation: { heading: 0, pitch: -Math.PI / 2.2, roll: 0 }, orientation: { heading: 0, pitch: -Math.PI / 2.2, roll: 0 },
}); });
@@ -80,17 +82,16 @@ async function lookAt(lat, lon, height) {
/** Canvas position of a lon/lat on the rendered surface, or null. */ /** Canvas position of a lon/lat on the rendered surface, or null. */
const screenAt = (lat, lon) => const screenAt = (lat, lon) =>
page.evaluate( evaluateWithGeo(
(la, lo) => { page,
const viewer = window.__godsEyeView.viewer; async (qaGeo, la, lo) => {
const scene = viewer.scene; const Cartesian3 = qaGeo.cartesian3;
const Cartesian3 = viewer.camera.positionWC.constructor; const Cartographic = qaGeo.cartographic;
const Cartographic = viewer.camera.positionCartographic.constructor;
const carto = Cartographic.fromDegrees(lo, la); const carto = Cartographic.fromDegrees(lo, la);
let ground = scene.globe?.getHeight?.(carto); let ground = window.__godsEyeView.engine.surface.terrainHeight?.(carto);
if (!Number.isFinite(ground)) ground = 0; if (!Number.isFinite(ground)) ground = 0;
const world = Cartesian3.fromDegrees(lo, la, ground); const world = Cartesian3.fromDegrees(lo, la, ground);
const win = scene.cartesianToCanvasCoordinates(world); const win = window.__godsEyeView.engine.projector.worldToWindow(world);
if (!win || !Number.isFinite(win.x)) return null; if (!win || !Number.isFinite(win.x)) return null;
const x = Math.round(win.x); const x = Math.round(win.x);
const y = Math.round(win.y); const y = Math.round(win.y);
@@ -110,11 +111,7 @@ const layerState = () =>
'#utilities-toggles [data-layer-id="directions"]', '#utilities-toggles [data-layer-id="directions"]',
); );
const controls = module.getRowControls(); const controls = module.getRowControls();
const viewer = gev.viewer; const drawn = window.__gevQaDirections.drawn();
const entities = [...viewer.entities.values];
const routeEntities = entities.filter(
(entity) => String(entity.id) === 'directions:route',
);
const listNodes = row const listNodes = row
? [...row.querySelectorAll('.data-row-list-item')] ? [...row.querySelectorAll('.data-row-list-item')]
: []; : [];
@@ -136,14 +133,9 @@ const layerState = () =>
domCurrent: listNodes.findIndex((node) => domCurrent: listNodes.findIndex((node) =>
node.classList.contains('current'), node.classList.contains('current'),
), ),
routeEntities: routeEntities.length, routeEntities: drawn.routeSets,
routePositions: routePositions: drawn.routeVertices,
routeEntities[0]?.polyline?.positions?.getValue?.( markerCount: drawn.markersShown,
viewer.clock.currentTime,
)?.length || 0,
markerCount: entities.filter((entity) =>
String(entity.id).startsWith('directions:marker:'),
).length,
pointerOwner: window.__gevQa.pointerOwner(), pointerOwner: window.__gevQa.pointerOwner(),
cameraMotion: window.__gevQa.getActiveCameraMotion(), cameraMotion: window.__gevQa.getActiveCameraMotion(),
}; };
@@ -159,7 +151,7 @@ const clickChip = async (chipId) => {
button.click(); button.click();
return true; return true;
}, chipId); }, chipId);
await sleep(150); await whenUiSettled(page);
return clicked; return clicked;
}; };
@@ -167,6 +159,28 @@ const setMode = async (mode) => {
await clickChip(`mode-${mode}`); await clickChip(`mode-${mode}`);
}; };
const waitForFlight = (before) =>
pollPage(
page,
(origin) => {
const { engine, dataManager } = window.__godsEyeView;
const motion = dataManager.layers
.get('directions')
.module.getFlightProgress();
const position = engine.camera.pose().position;
return (
motion?.kind === 'route' &&
motion.traveledM > 0 &&
Math.hypot(
position.x - origin.x,
position.y - origin.y,
position.z - origin.z,
) > 50
);
},
{ timeoutMs: 15000, intervalMs: 50, args: [before] },
);
const waitForRoute = () => const waitForRoute = () =>
page.waitForFunction( page.waitForFunction(
() => () =>
@@ -189,7 +203,7 @@ const waitForMode = (word) =>
const cameraPose = () => const cameraPose = () =>
page.evaluate(() => { page.evaluate(() => {
const c = window.__godsEyeView.viewer.camera.positionWC; const c = window.__godsEyeView.engine.camera.pose().position;
return { x: c.x, y: c.y, z: c.z }; return { x: c.x, y: c.y, z: c.z };
}); });
const poseDelta = (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z); const poseDelta = (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
@@ -200,7 +214,7 @@ async function placeEndpoints(a, b) {
const atA = await screenAt(a.lat, a.lon); const atA = await screenAt(a.lat, a.lon);
if (!atA) return false; if (!atA) return false;
await page.mouse.click(atA.x, atA.y); await page.mouse.click(atA.x, atA.y);
await sleep(700); await whenUiSettled(page);
await clickChip('set-b'); await clickChip('set-b');
const atB = await screenAt(b.lat, b.lon); const atB = await screenAt(b.lat, b.lon);
if (!atB) return false; if (!atB) return false;
@@ -216,6 +230,7 @@ try {
await page.waitForFunction(() => window.__godsEyeView?.dataManager, { await page.waitForFunction(() => window.__godsEyeView?.dataManager, {
timeout: 90000, timeout: 90000,
}); });
await whenSettled(page);
// Two module reads the assertions need. Both are the app's OWN module // Two module reads the assertions need. Both are the app's OWN module
// instances (Vite serves one copy), so this observes rather than simulates. // instances (Vite serves one copy), so this observes rather than simulates.
await page.evaluate(async () => { await page.evaluate(async () => {
@@ -229,8 +244,98 @@ try {
releasePointer: own.releasePointer, releasePointer: own.releasePointer,
getActiveCameraMotion: verbs.getActiveCameraMotion, getActiveCameraMotion: verbs.getActiveCameraMotion,
}; };
// What Directions draws, read through the globe contract
// (src/layers/directions/render/display.js): the route is the
// `directions-route` ground corridor set, cut into dash pieces; the A/B
// discs and the maneuver dots are the `directions-markers` and
// `directions-steps` point mover sets. The route's pieces are seen as the
// display hands them to engine.ground; which instances are shown is read
// from the Cesium engine's own point collection for the set (this gate
// drives the Cesium camera).
const engine = window.__godsEyeView.engine;
let routePieces = null;
const createGroundSet = engine.ground.createSet.bind(engine.ground);
engine.ground.createSet = (options) => {
const set = createGroundSet(options);
if (options?.id === 'directions-route') {
const replace = set.replace.bind(set);
set.replace = (items) => {
routePieces = items.map((item) => item.positions.length);
return replace(items);
};
}
return set;
};
const moverPrimitive = (id) => {
const walk = (collection) => {
for (let i = 0; i < collection.length; i += 1) {
const primitive = collection.get(i);
if (!primitive) continue;
if (primitive.gevMoverSet === id) return primitive;
if (
primitive.gevMoverSet === undefined &&
typeof primitive.get === 'function' &&
typeof primitive.length === 'number'
) {
const found = walk(primitive);
if (found) return found;
}
}
return null;
};
return walk(window.__godsEyeView.viewer.scene.primitives);
};
const moverCount = (id) =>
engine.movers.list().find((set) => set.id === id)?.count ?? 0;
const shownInstances = (id) => {
const primitive = moverPrimitive(id);
if (!primitive || primitive.show === false) return [];
const shown = [];
const count = Math.min(moverCount(id), primitive.length);
for (let i = 0; i < count; i += 1) {
const instance = primitive.get(i);
if (instance.show) shown.push(instance);
}
return shown;
};
window.__gevQaDirections = {
drawn() {
const route = engine.ground
.list()
.filter((set) => set.id === 'directions-route');
// Every original vertex is kept, and each piece adds its two ends,
// which are the next piece's start: vertices = total - 2·pieces + 2.
const routeVertices =
route.length && routePieces?.length
? routePieces.reduce((sum, n) => sum + n, 0) -
2 * routePieces.length +
2
: 0;
return {
routeSets: route.filter((set) => set.shown && set.count > 0).length,
routeVertices,
markersShown: shownInstances('directions-markers').length,
stepCount: moverCount('directions-steps'),
stepsShown: shownInstances('directions-steps').map((point) =>
point.position.clone(),
),
};
},
// Every other layer's contract sets, to prove a disable touches only
// the Directions ones.
others() {
const mine = (id) => String(id).startsWith('directions');
return [
...engine.movers.list().map((set) => `movers:${set.id}`),
...engine.lines.list().map((line) => `lines:${line.id}`),
...engine.ground.list().map((set) => `ground:${set.id}`),
]
.filter((key) => !mine(key.split(':').slice(1).join(':')))
.sort();
},
};
}); });
await sleep(3000); await whenSettled(page);
// ── 1. Open the UTILITIES panel, then toggle the row on. ──────────────── // ── 1. Open the UTILITIES panel, then toggle the row on. ────────────────
const opened = await page.evaluate(() => { const opened = await page.evaluate(() => {
@@ -247,7 +352,7 @@ try {
.getElementById('utilities-panel') .getElementById('utilities-panel')
.classList.contains('collapsed'); .classList.contains('collapsed');
}); });
await sleep(700); await whenUiSettled(page);
check('the UTILITIES panel opens from its own header control', opened); check('the UTILITIES panel opens from its own header control', opened);
const toggled = await page.evaluate(() => { const toggled = await page.evaluate(() => {
const row = document.querySelector( const row = document.querySelector(
@@ -259,7 +364,7 @@ try {
button.click(); button.click();
return true; return true;
}); });
await sleep(1200); await whenUiSettled(page);
check( check(
'the Directions row is in the Data Layers panel and toggles on', 'the Directions row is in the Data Layers panel and toggles on',
toggled, toggled,
@@ -307,13 +412,13 @@ try {
); );
close?.click(); close?.click();
}); });
await sleep(500); await whenUiSettled(page);
// ── 2. Place A on the Ferry Building, B in Golden Gate Park. ───────────── // ── 2. Place A on the Ferry Building, B in Golden Gate Park. ─────────────
const FERRY = { lat: 37.7955, lon: -122.3937 }; const FERRY = { lat: 37.7955, lon: -122.3937 };
const GGPARK = { lat: 37.7694, lon: -122.4862 }; const GGPARK = { lat: 37.7694, lon: -122.4862 };
await lookAt(37.782, -122.44, 16000); await lookAt(37.782, -122.44, 16000);
await sleep(5000); await whenSettled(page);
await clickChip('set-a'); await clickChip('set-a');
state = await layerState(); state = await layerState();
@@ -330,7 +435,7 @@ try {
let at = await screenAt(FERRY.lat, FERRY.lon); let at = await screenAt(FERRY.lat, FERRY.lon);
check('the Ferry Building is on screen', !!at, JSON.stringify(at)); check('the Ferry Building is on screen', !!at, JSON.stringify(at));
await page.mouse.click(at.x, at.y); await page.mouse.click(at.x, at.y);
await sleep(800); await whenUiSettled(page);
state = await layerState(); state = await layerState();
check( check(
'placing A releases the pointer claim', 'placing A releases the pointer claim',
@@ -344,7 +449,7 @@ try {
// used to place B and deselect whatever was under it in the same gesture. // used to place B and deselect whatever was under it in the same gesture.
await page.evaluate(() => { await page.evaluate(() => {
window.__gevQaLateHandler = []; window.__gevQaLateHandler = [];
const canvas = window.__godsEyeView.viewer.scene.canvas; const canvas = window.__godsEyeView.engine.host.canvas;
canvas.addEventListener('pointerup', () => { canvas.addEventListener('pointerup', () => {
window.__gevQaLateHandler.push(window.__gevQa.pointerOwner()); window.__gevQaLateHandler.push(window.__gevQa.pointerOwner());
}); });
@@ -362,7 +467,7 @@ try {
`saw ${JSON.stringify(lateOwners)}`, `saw ${JSON.stringify(lateOwners)}`,
); );
await waitForRoute(); await waitForRoute();
await sleep(2000); await whenSettled(page);
state = await layerState(); state = await layerState();
check( check(
'once that click is over, the pointer claim is returned', 'once that click is over, the pointer claim is returned',
@@ -407,7 +512,7 @@ try {
items[2]?.click(); items[2]?.click();
return { active, tabIndex: items[2]?.tabIndex }; return { active, tabIndex: items[2]?.tabIndex };
}); });
await sleep(600); await whenUiSettled(page);
const afterListClick = await layerState(); const afterListClick = await layerState();
check( check(
'a list row takes keyboard focus and selects its maneuver', 'a list row takes keyboard focus and selects its maneuver',
@@ -444,7 +549,7 @@ try {
// ── 4. Swap endpoints. ────────────────────────────────────────────────── // ── 4. Swap endpoints. ──────────────────────────────────────────────────
const beforeSwap = await layerState(); const beforeSwap = await layerState();
await clickChip('swap'); await clickChip('swap');
await sleep(3500); await waitForRoute();
const afterSwap = await layerState(); const afterSwap = await layerState();
check( check(
'SWAP reverses the route', 'SWAP reverses the route',
@@ -453,7 +558,7 @@ try {
`"${beforeSwap.listText[0]}" -> "${afterSwap.listText[0]}"`, `"${beforeSwap.listText[0]}" -> "${afterSwap.listText[0]}"`,
); );
await clickChip('swap'); await clickChip('swap');
await sleep(3500); await waitForRoute();
// ── 5. Three rapid reroutes leave exactly one route. ───────────────────── // ── 5. Three rapid reroutes leave exactly one route. ─────────────────────
await page.evaluate(() => { await page.evaluate(() => {
@@ -462,7 +567,7 @@ try {
manager.setLayerParams('directions', { mode: 'bike' }, { origin: 'user' }); manager.setLayerParams('directions', { mode: 'bike' }, { origin: 'user' });
manager.setLayerParams('directions', { mode: 'car' }, { origin: 'user' }); manager.setLayerParams('directions', { mode: 'car' }, { origin: 'user' });
}); });
await sleep(6000); await waitForRoute();
const rapid = await layerState(); const rapid = await layerState();
check( check(
'three rapid reroutes leave exactly one route', 'three rapid reroutes leave exactly one route',
@@ -575,19 +680,19 @@ try {
), ),
); );
await waitForRoute(); await waitForRoute();
await sleep(1200); await whenUiSettled(page);
} }
await cdp.send('Fetch.disable').catch(() => {}); await cdp.send('Fetch.disable').catch(() => {});
// ── 7. FLY, a mid-flight frame, and a cancel that actually stops it. ───── // ── 7. FLY, a mid-flight frame, and a cancel that actually stops it. ─────
const beforeFly = await cameraPose(); const beforeFly = await cameraPose();
check('the FLY chip is offered once a route exists', await clickChip('fly')); check('the FLY chip is offered once a route exists', await clickChip('fly'));
await sleep(3000); const flightStarted = await waitForFlight(beforeFly);
const flying = await layerState(); const flying = await layerState();
const midFly = await cameraPose(); const midFly = await cameraPose();
check( check(
'FLY starts a route flight', 'FLY starts a route flight',
flying.cameraMotion?.kind === 'route', flightStarted && flying.cameraMotion?.kind === 'route',
JSON.stringify(flying.cameraMotion), JSON.stringify(flying.cameraMotion),
); );
check( check(
@@ -605,7 +710,7 @@ try {
// A reroute replaces the route the dolly is flying; the dolly has to land // A reroute replaces the route the dolly is flying; the dolly has to land
// first rather than keep flying a route that no longer exists. // first rather than keep flying a route that no longer exists.
await setMode('foot'); await setMode('foot');
await sleep(1200); await whenUiSettled(page);
const afterReroute = await layerState(); const afterReroute = await layerState();
check( check(
'changing profile mid-flight lands the flight it replaced', 'changing profile mid-flight lands the flight it replaced',
@@ -615,9 +720,10 @@ try {
await waitForMode('Walk'); await waitForMode('Walk');
await setMode('car'); await setMode('car');
await waitForMode('Drive'); await waitForMode('Drive');
await sleep(800); await whenUiSettled(page);
const beforeRefly = await cameraPose();
check('FLY is offered again after the reroute', await clickChip('fly')); check('FLY is offered again after the reroute', await clickChip('fly'));
await sleep(2000); check('the second FLY starts before CLEAR', await waitForFlight(beforeRefly));
await clickChip('clear'); await clickChip('clear');
await sleep(500); await sleep(500);
@@ -687,7 +793,7 @@ try {
// ── 9. Denver: maneuver dots sit on the ground, not at sea level. ──────── // ── 9. Denver: maneuver dots sit on the ground, not at sea level. ────────
if (!SKIP.has('denver')) { if (!SKIP.has('denver')) {
await lookAt(39.744, -104.999, 7000); await lookAt(39.744, -104.999, 7000);
await sleep(6000); await whenSettled(page);
const placed = await placeEndpoints( const placed = await placeEndpoints(
{ lat: 39.7392, lon: -104.9903 }, { lat: 39.7392, lon: -104.9903 },
{ lat: 39.7487, lon: -105.0077 }, { lat: 39.7487, lon: -105.0077 },
@@ -696,32 +802,21 @@ try {
if (placed) { if (placed) {
await waitForRoute(); await waitForRoute();
// Let the shared ground-floor cells warm and the dots re-anchor. // Let the shared ground-floor cells warm and the dots re-anchor.
await sleep(9000); await pollPage(
const denver = await page.evaluate(() => { page,
const viewer = window.__godsEyeView.viewer; () => {
const Cartographic = viewer.camera.positionCartographic.constructor; const { stepCount, stepsShown } = window.__gevQaDirections.drawn();
const scene = viewer.scene; return stepCount > 0 && stepsShown.length === stepCount;
let collection = null; },
for (let i = 0; i < scene.primitives.length; i += 1) { { timeoutMs: 60_000 },
const primitive = scene.primitives.get(i); );
if ( const denver = await evaluateWithGeo(page, async (qaGeo) => {
primitive && const Cartographic = qaGeo.cartographic;
typeof primitive.get === 'function' && const { stepCount, stepsShown } = window.__gevQaDirections.drawn();
primitive.length > 0 && const heights = stepsShown.map(
String(primitive.get(0)?.id || '').startsWith('directions:step:') (position) => Cartographic.fromCartesian(position).height,
) { );
collection = primitive; return { dots: stepCount, shown: heights.length, heights };
break;
}
}
if (!collection) return { dots: 0, shown: 0, heights: [] };
const heights = [];
for (let i = 0; i < collection.length; i += 1) {
const point = collection.get(i);
if (!point.show) continue;
heights.push(Cartographic.fromCartesian(point.position).height);
}
return { dots: collection.length, shown: heights.length, heights };
}); });
check('Denver has maneuver dots', denver.dots > 0, `${denver.dots} dots`); check('Denver has maneuver dots', denver.dots > 0, `${denver.dots} dots`);
check( check(
@@ -744,55 +839,34 @@ try {
} }
// ── 10. Disable: nothing is left behind. ──────────────────────────────── // ── 10. Disable: nothing is left behind. ────────────────────────────────
const { beforeDisable, directionsBeforeDisable } = await page.evaluate(() => { const { othersBefore, drawnBefore } = await page.evaluate(() => ({
const entities = [...window.__godsEyeView.viewer.entities.values]; othersBefore: window.__gevQaDirections.others(),
return { drawnBefore: window.__gevQaDirections.drawn(),
beforeDisable: entities.length, }));
directionsBeforeDisable: entities.filter((entity) =>
String(entity.id).startsWith('directions:'),
).length,
};
});
await page.evaluate(() => await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('directions', false, { window.__godsEyeView.dataManager.setEnabled('directions', false, {
origin: 'user', origin: 'user',
}), }),
); );
await sleep(1500); await whenSettled(page);
const off = await page.evaluate(() => { const off = await page.evaluate(() => {
const gev = window.__godsEyeView; const drawn = window.__gevQaDirections.drawn();
const viewer = gev.viewer;
const left = [...viewer.entities.values].filter((entity) =>
String(entity.id).startsWith('directions:'),
);
let shownDots = 0;
for (let i = 0; i < viewer.scene.primitives.length; i += 1) {
const primitive = viewer.scene.primitives.get(i);
if (
primitive &&
typeof primitive.get === 'function' &&
primitive.length > 0 &&
String(primitive.get(0)?.id || '').startsWith('directions:step:')
) {
shownDots = primitive.show ? primitive.length : 0;
}
}
const row = document.querySelector( const row = document.querySelector(
'#utilities-toggles [data-layer-id="directions"]', '#utilities-toggles [data-layer-id="directions"]',
); );
return { return {
entities: left.length, entities: drawn.routeSets + drawn.markersShown,
shownDots, shownDots: drawn.stepsShown.length,
controlsHidden: controlsHidden:
row?.querySelector('.data-toggle-controls')?.hidden === true, row?.querySelector('.data-toggle-controls')?.hidden === true,
listHidden: row?.querySelector('.data-row-list')?.hidden === true, listHidden: row?.querySelector('.data-row-list')?.hidden === true,
pointerOwner: window.__gevQa.pointerOwner(), pointerOwner: window.__gevQa.pointerOwner(),
motion: window.__gevQa.getActiveCameraMotion(), motion: window.__gevQa.getActiveCameraMotion(),
totalEntities: viewer.entities.values.length, others: window.__gevQaDirections.others(),
}; };
}); });
check( check(
'disabling removes every Directions entity', 'disabling removes the route and hides both markers',
off.entities === 0, off.entities === 0,
`${off.entities} left`, `${off.entities} left`,
); );
@@ -809,9 +883,9 @@ try {
); );
check('disabling leaves no camera motion running', off.motion === null); check('disabling leaves no camera motion running', off.motion === null);
check( check(
'exactly the Directions entities went away, and nothing else did', "only the Directions drawing went away: every other layer's sets stay",
off.totalEntities === beforeDisable - directionsBeforeDisable, JSON.stringify(off.others) === JSON.stringify(othersBefore),
`${beforeDisable} total (${directionsBeforeDisable} ours) -> ${off.totalEntities}`, `ours before: route ${drawnBefore.routeSets}, markers ${drawnBefore.markersShown}, dots ${drawnBefore.stepsShown.length}; others ${othersBefore.length} -> ${off.others.length}`,
); );
check( check(
@@ -834,7 +908,7 @@ try {
check('harness completed', false, error?.stack || String(error)); check('harness completed', false, error?.stack || String(error));
await shot(`99-${LABEL}-failure.jpg`).catch(() => {}); await shot(`99-${LABEL}-failure.jpg`).catch(() => {});
} finally { } finally {
await browser.close(); await closeBrowser(browser);
await fs.writeFile( await fs.writeFile(
path.join(SHOTS, `qa-directions-${LABEL}.log`), path.join(SHOTS, `qa-directions-${LABEL}.log`),
`${lines.join('\n')}\n${failures} failure(s)\n`, `${lines.join('\n')}\n${failures} failure(s)\n`,
+75
View File
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import vm from 'node:vm';
import { pollPage } from './qa/ready.mjs';
import { createFakeEngine } from '../src/globe/testing/fakeEngine.js';
// Evaluate the browser wait without launching the executable scenario.
const source = readFileSync(
new URL('./qa-directions.mjs', import.meta.url),
'utf8',
);
const waitSource = source.slice(
source.indexOf('const waitForFlight ='),
source.indexOf('const waitForRoute ='),
);
for (const startsAt of [50, 12000, null]) {
test(`directions FLY waits for owned route progress (${startsAt ?? 'never'} ms)`, async (t) => {
const engine = createFakeEngine();
t.after(() => engine.destroy());
let motion = null;
let disposed = 0;
let observedAt;
const before = engine.camera.pose().position;
const waitForFlight = vm.runInNewContext(`${waitSource}\nwaitForFlight`, {
pollPage,
window: {
__godsEyeView: {
engine,
dataManager: {
layers: new Map([
['directions', { module: { getFlightProgress: () => motion } }],
]),
},
},
},
page: {
async waitForFunction(check, options, origin) {
const predicate = () => check(origin);
assert.equal(options.timeout, 15000);
assert.equal(options.polling, 50);
assert.equal(predicate(), false, 'no owned flight yet');
motion = { kind: 'route', traveledM: 0 };
assert.equal(predicate(), false, 'arming is not a mid-flight frame');
motion = { kind: 'orbit', traveledM: 20 };
assert.equal(predicate(), false, 'another camera motion cannot pass');
motion = { kind: 'route', traveledM: 20 };
assert.equal(predicate(), false, 'the camera must have moved too');
engine.fake.setPose({ position: { ...before, y: before.y + 60 } });
for (let time = 0; time < options.timeout; time += options.polling) {
motion =
startsAt !== null && time >= startsAt
? { kind: 'route', traveledM: 20 }
: null;
if (predicate()) {
observedAt = time;
return {
dispose: async () => {
disposed++;
},
};
}
}
const error = new Error('flight did not start');
error.name = 'TimeoutError';
throw error;
},
},
});
assert.equal(await waitForFlight(before), startsAt !== null);
assert.equal(observedAt, startsAt ?? undefined);
assert.equal(disposed, startsAt === null ? 0 : 1);
});
}
+10 -11
View File
@@ -1,11 +1,12 @@
#!/usr/bin/env node #!/usr/bin/env node
/** Verify imported camera directions through real rendering and camera authority handoffs. */ /** Verify imported camera directions through real rendering and camera authority handoffs. */
import { whenSettled } from './qa/ready.mjs';
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import puppeteer from 'puppeteer';
const output = path.resolve('qa-shots/director-camera'); const output = path.resolve('qa-shots/director-camera');
fs.mkdirSync(output, { recursive: true }); fs.mkdirSync(output, { recursive: true });
const browser = await puppeteer.launch({ const browser = await launchBrowser({
headless: true, headless: true,
args: ['--no-sandbox', '--disable-dev-shm-usage'], args: ['--no-sandbox', '--disable-dev-shm-usage'],
}); });
@@ -23,12 +24,10 @@ try {
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
{ waitUntil: 'domcontentloaded' }, { waitUntil: 'domcontentloaded' },
); );
await page.waitForFunction( await page.waitForFunction(() => window.__godsEyeView?.sceneDirector, {
() => timeout: 60000,
window.__godsEyeView?.sceneDirector && });
document.getElementById('loading-screen')?.classList.contains('hidden'), await whenSettled(page);
{ timeout: 60000 },
);
const result = await page.evaluate(async () => { const result = await page.evaluate(async () => {
const { sceneDirector: d, styleManager: style } = window.__godsEyeView; const { sceneDirector: d, styleManager: style } = window.__godsEyeView;
const file = { const file = {
@@ -137,7 +136,7 @@ try {
}; };
}); });
for (const [name, passed] of Object.entries(result)) check(name, passed); for (const [name, passed] of Object.entries(result)) check(name, passed);
await new Promise((r) => setTimeout(r, 6000)); await whenSettled(page);
await page.screenshot({ path: path.join(output, 'austin.png') }); await page.screenshot({ path: path.join(output, 'austin.png') });
await page.evaluate(() => { await page.evaluate(() => {
const d = window.__godsEyeView.sceneDirector; const d = window.__godsEyeView.sceneDirector;
@@ -145,7 +144,7 @@ try {
d._setCameraView({ ...pose, heading: pose.heading + 25, pitch: -60 }); d._setCameraView({ ...pose, heading: pose.heading + 25, pitch: -60 });
d.viewer.scene.requestRender(); d.viewer.scene.requestRender();
}); });
await new Promise((r) => setTimeout(r, 3000)); await whenSettled(page);
await page.screenshot({ path: path.join(output, 'angle.png') }); await page.screenshot({ path: path.join(output, 'angle.png') });
const inputStarted = await page.evaluate(async () => { const inputStarted = await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector; const d = window.__godsEyeView.sceneDirector;
@@ -205,6 +204,6 @@ try {
); );
check('no uncaught browser errors', errors.length === 0); check('no uncaught browser errors', errors.length === 0);
} finally { } finally {
await browser.close(); await closeBrowser(browser);
} }
if (failed) process.exitCode = 1; if (failed) process.exitCode = 1;
+304
View File
@@ -0,0 +1,304 @@
import { whenPickable, whenSettled } from './qa/ready.mjs';
import { assertKeylessPreview } from './qa-strict-harness.mjs';
import assert from 'node:assert/strict';
import sharp from 'sharp';
import { createStrict, localPreview } from './qa-strict-harness.mjs';
import { fromDegrees } from '../src/geo/cartesian3.js';
const url = localPreview(process.argv[2]);
url.search = '?engine=navara&welcome=0';
url.hash =
'v=2&lat=30.2672&lon=-97.742&alt=4000&pitch=-90&heading=0&map=sentinel2&l=';
const png = await sharp({
create: { width: 64, height: 64, channels: 4, background: '#fd2233' },
})
.png()
.toBuffer();
const features = [
{
id: 'point',
geometry: { type: 'Point', coordinates: [-97.742, 30.27, 450] },
},
{
id: 'line',
geometry: {
type: 'LineString',
coordinates: [
[-97.745, 30.268, 400],
[-97.735, 30.268, 400],
],
},
},
{
id: 'polygon',
geometry: {
type: 'Polygon',
coordinates: [
[
[-97.747, 30.264, 450],
[-97.74, 30.264, 450],
[-97.74, 30.267, 450],
[-97.747, 30.264, 450],
],
],
},
},
].map((f) => ({ ...f, type: 'Feature' }));
const wav = Buffer.alloc(44 + 16000);
wav.write('RIFF');
wav.writeUInt32LE(wav.length - 8, 4);
wav.write('WAVEfmt ', 8);
wav.writeUInt32LE(16, 16);
wav.writeUInt16LE(1, 20);
wav.writeUInt16LE(1, 22);
wav.writeUInt32LE(8000, 24);
wav.writeUInt32LE(16000, 28);
wav.writeUInt16LE(2, 32);
wav.writeUInt16LE(16, 34);
wav.write('data', 36);
wav.writeUInt32LE(16000, 40);
const pack = (id, format, path, placement) => ({
id,
version: 1,
format,
source: { adapter: 'assets', path },
attribution: { text: 'Local fixture', license: 'CC0-1.0' },
placement,
});
const camera = {
lat: 30.2672,
lon: -97.742,
alt: 4000,
heading: 0,
pitch: -90,
roll: 0,
altitudeReference: 'ellipsoid',
};
const shot = {
id: 'one',
title: 'Pack view',
camera,
durationSec: 5,
holdSec: 3,
visual: { style: 'normal', mapStack: 'sentinel2' },
layers: { earthquakes: true },
dataPackIds: ['data', 'image', 'audio'],
interactions: features.map((f) => ({
id: f.id,
label: 'Show ' + f.id,
target: { packId: 'data', featureId: f.id },
action: { type: 'card', text: f.id },
})),
};
const project = {
version: 6,
scenes: [
{
id: 'parity',
title: 'Test project',
anchors: [
{
id: 'station',
lat: 30.271,
lon: -97.735,
alt: 500,
altitudeReference: 'ellipsoid',
},
],
dataPacks: [
pack('data', 'geojson', 'data.json', {
altitudeReference: 'ellipsoid',
}),
pack('image', 'image', 'image.png', {
altitudeReference: 'ellipsoid',
bounds: [-97.738, 30.264, -97.732, 30.267],
height: 500,
}),
pack('audio', 'media', 'audio.wav', { anchorId: 'station' }),
],
shots: [
shot,
{
...shot,
id: 'two',
title: 'Next shot',
camera: { ...camera, heading: 15 },
move: { from: camera, easing: 'linear' },
dataPackIds: [],
interactions: [],
},
],
},
],
};
// Scene Director is not a catalog layer; the scenario answers the scene's
// earthquake feed itself.
const strict = createStrict({
origin: url.origin,
layers: [],
fixtures: ['https://earthquake.usgs.gov/*'],
});
await strict.run({}, async (b) => {
const p = await b.newPage();
await p.setViewport({ width: 1280, height: 900 });
const errors = [];
p.on('pageerror', (e) => {
errors.push(e.message);
console.log('error', e.message);
});
const reads = [];
await p.setRequestInterception(true);
p.on('request', (req) => {
const path = new URL(req.url()).pathname;
if (path.startsWith('/scene-assets/')) {
reads.push(path);
const file = path.split('/').pop();
return req.respond({
status: 200,
contentType:
file === 'data.json'
? 'application/geo+json'
: file === 'audio.wav'
? 'audio/wav'
: 'image/png',
body:
file === 'data.json'
? JSON.stringify({ type: 'FeatureCollection', features })
: file === 'audio.wav'
? wav
: png,
});
}
if (new URL(req.url()).hostname === 'earthquake.usgs.gov')
return req.respond({
status: 200,
contentType: 'application/json',
headers: { 'Access-Control-Allow-Origin': '*' },
body: '{"type":"FeatureCollection","features":[]}',
});
return req.continue();
});
await p.goto(url.href);
await whenSettled(p);
await assertKeylessPreview(p);
await p.evaluate(async (project) => {
const a = window.__godsEyeView;
await a.styleManager.initialRestorePromise;
window.director = a.sceneDirector;
await window.director.importProjectFile(
new File([JSON.stringify(project)], 'qa.json'),
);
const out = await window.director.loadShot('parity', 'one', {
flyDuration: 0.2,
});
if (!out.started)
throw Error(
'Pack load failed: ' +
JSON.stringify(out) +
' ' +
window.director._presentation.status,
);
}, project);
await p.waitForSelector('[data-director-pack="audio"] audio');
await p.evaluate(() => {
window.qaAudio = document.querySelector(
'[data-director-pack="audio"] audio',
);
});
const at = (world) =>
p.evaluate((world) => {
const a = window.__godsEyeView;
const xy = a.engine.projector.worldToWindow(world),
rect = a.engine.host.canvas.getBoundingClientRect();
return { x: xy.x + rect.x, y: xy.y + rect.y };
}, world);
for (const kind of ['navara', 'cesium', 'navara']) {
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind)
await strict.switchTo(p, kind);
await whenSettled(p);
const st = await p.evaluate(() => ({
same: window.director === window.__godsEyeView.sceneDirector,
media:
window.qaAudio ===
document.querySelector('[data-director-pack="audio"] audio'),
packs: window.director.getDataPackState(),
actions: window.director.getInteractionState(),
}));
assert.ok(
st.same && st.media && st.packs.count === 3 && st.actions.count === 3,
);
const xy = await at(fromDegrees(-97.742, 30.27, 450));
await whenPickable(p, xy, (picked) => picked?.id === '["data","point"]');
await p.mouse.click(xy.x, xy.y);
await p.waitForFunction(
() => document.activeElement?.dataset.directorAction === 'point',
);
await p.keyboard.press('Enter');
await p.waitForFunction(
() => window.director.getInteractionState().selected === 'point',
);
for (const [id, coords] of [
['line', [-97.74, 30.268, 400]],
['polygon', [-97.742, 30.265, 450]],
]) {
const hit = await at(fromDegrees(...coords));
await p.mouse.click(hit.x, hit.y);
await p.waitForFunction(
(id) => document.activeElement?.dataset.directorAction === id,
{ timeout: 10000 },
id,
);
}
const imageAt = await at(fromDegrees(-97.735, 30.2655, 500));
const sample = await sharp(await p.screenshot())
.extract({
left: Math.round(imageAt.x),
top: Math.round(imageAt.y),
width: 1,
height: 1,
})
.removeAlpha()
.raw()
.toBuffer();
console.log(kind, st.packs.count, st.actions.count, Array.from(sample));
assert.ok(
sample[0] > 100 && sample[0] > sample[1] * 2,
'authored image really draws',
);
assert.equal(
reads.length,
3,
'packs are not acquired again on a globe change',
);
}
await p.evaluate(() => {
window.qaRun = window.director.startScene('parity', {
single: true,
preview: false,
});
});
await p.waitForFunction(
() =>
window.director.running &&
window.director.getPlaybackTimingState().snapshot?.shotId === 'one',
);
await strict.switchTo(p, 'cesium');
assert.ok(
await p.evaluate(
() =>
window.director.running &&
window.director === window.__godsEyeView.sceneDirector,
),
);
await p.waitForFunction(
() => window.director.getPlaybackTimingState().snapshot?.shotId === 'two',
{ timeout: 20000 },
);
await strict.switchTo(p, 'navara');
const complete = await p.evaluate(() => window.qaRun);
assert.ok(complete.started && complete.shots === 2);
assert.deepEqual(errors, []);
console.log(
'PASS authored packs, geographic picks, same player, original multi-shot timeline',
);
});
Loaded 100 of 1637 files, more files were not shown because too many files have changed in this diff. Show more