Compare commits

...
Author SHA1 Message Date
Sameh Khamis 70123fe4a9 Drape the open-data map overlays through the renderer's globe port
The sea-mark and ski-piste overlays add an XYZ imagery layer through the
globe port instead of building Cesium layers into a host collection. The
port drapes it on the globe on globe stacks and on the Google 3D tiles,
which the map stack now marks as its base tilesets. The row's hidden
reason reads the map stack's new getDrapeSurface, or the viewer's globe
when there is no map stack, and an overlay enabled before its viewer has
a globe port adds its layer once there is one. The overlays no longer
reach Cesium, so they leave the allow-list.
2026-11-06 18:29:40 -08:00
Sameh Khamis fbcac37f13 Give the Cesium renderer the contract's globe port
The globe port sets the base (photoreal tiles, terrain, imagery), shows
or hides the globe, and adds imagery layers: XYZ, WMTS, ArcGIS, world,
single-image and canvas sources, with admission, fetch and transform
hooks, a request budget and tile errors. Each layer drapes where its
drapeOn says: on the globe, or on the drawn base tileset, which is the
port's own photoreal tileset or one the map stack marks as its base,
followed every frame so a base switched outside the port moves the
layers. Port layers keep their order above other layers in each
collection. The sky reads the scene and writes through to it, and the
port reports settling and load progress.

The application renderer and the conformance renderer both have it, so
the application renderer now has every member of the contract. A viewer
with no renderer bound gets a globe port through findGlobe. The Cesium
conformance run checks imagery order, the split and the image-hook gate.
2026-11-06 18:29:40 -08:00
Sameh Khamis 9049357916 Add data credits through the renderer's credits port
Data attribution no longer builds Cesium credits itself: each credit is
added through the renderer's credits port, in the attribution list as
before. The port's credit carries one link, so a credit whose markup
holds several links becomes one credit per link, shown together in
order, its wording kept. dataCredits.js leaves the Cesium allow-list.
2026-11-06 17:59:00 -08:00
Sameh Khamis 8f612ba858 Read pixels and release unused caches through the Cesium renderer
The shared Cesium ports gain readPixels and releaseUnused, on both the
conformance renderer and the application's.

readPixels reads the drawn frame off the canvas at once, cropped in CSS
pixels and scaled from the canvas's CSS size, through one reused 2D canvas.
A canvas with no size reads blank.

releaseUnused trims every 3D tileset's cache, and after the next drawn frame
frees the globe tiles that frame did not draw. Cesium trims the globe's
cache only while tiles load, so a settled view would otherwise free nothing;
the trim goes through internals.js. The reader canvas is dropped too.

Two new checks, renderer.readPixels and renderer.releaseUnused, run on
Cesium and the stub.
2026-11-06 17:42:13 -08:00
Sameh Khamis 6ffdf30795 Settle the application's renderer through the shared Cesium ports
whenSettled moves from the conformance renderer into the shared Cesium
ports, unchanged but for what it waits for, and the application's renderer
gains it. A hidden globe loads nothing, so it no longer holds settling up:
the application hides its globe under photoreal tiles. Shown 3D tilesets in
the scene must have loaded what is in view, named 'tiles' while they have
not. Disposing the ports ends the watches under way, which fail as
destroyed, as the conformance renderer's destroy did.

It waits for the globe, the scene's tiles and what the draw sets hold, not
for what layers still draw through the viewer themselves.
2026-11-06 17:38:35 -08:00
Sameh Khamis 2bb0b1e367 Set scene time through the Cesium frame port, in the application too
frame.setTime moves from the conformance renderer into the shared Cesium
ports, so the application's renderer has it. It moves the clock and asks for
a frame, which on-demand drawing needs. It no longer stops the clock: the
conformance renderer stops its own at creation, and the application's clock
keeps whatever state its owner gave it.
2026-11-06 17:38:29 -08:00
Sameh Khamis 8b6bbf0f21 Give the Cesium draw port atlases and the clamp state
The shared Cesium ports gain draw.atlas, draw.clampState and
draw.on('clampState'), on both the conformance renderer and the
application's. An icon names an image by atlas slot; it draws once its atlas
holds the slot, and registering an atlas again rebuilds the icon sets that
read from it. A generated image with a key is one texture however many icons
draw it.

clampState is draped unless a shown 3D tileset is in the scene: the port
draws clamped items where it is given them and drapes nothing onto tiles yet,
so over tiles it says overlay. A change is told after the drawn frame that
shows it, never on subscribing.

A new draw.atlas check registers an atlas after its icons and picks them;
it and draw.clampState now run on Cesium.
2026-11-06 17:38:21 -08:00
Sameh Khamis 96bc73181b Give the Cesium camera port its user camera controls
camera.controls now maps the contract's CameraControls onto the scene's
ScreenSpaceCameraController: the enable flags, zoom distances, zoom
factor, coast as Cesium inertia (decay seconds, 0 for no coast), the
collision switch, the tilt limit as the upper end of pitchRange, and the
zoom bindings by name. suspend and enabled use the scene's shared input
holds, so they count with the input port's and the dispatcher's.

A follow's zoom hold now composes with the settings: minDistance and
coast.zoom read and set what the follow gives back, a higher floor takes
effect at once, and a follow never lengthens a shorter zoom coast.

The ctrl+wheel binding check reuses the controls' binding helpers, and
the controls checks run in the Cesium conformance set, the settings one
in Node as well.
2026-11-06 17:26:03 -08:00
Sameh Khamis c6bab5dcaa Give the Cesium renderer a quality port
The Cesium quality port supports pixel ratio, multisampling, shadows,
3D tile and terrain detail and the target frame rate, each applied when
first set and nothing changed before. Detail scales each tileset's and
the globe's own screen-space error and gives it back at full detail;
while tile detail is lowered, tilesets added later follow it. Frame
signals carry each drawn tick's CPU time, size, loop mode and holds.
The application and conformance renderers have the port, and the
conformance page runs the quality checks against Cesium.
2026-11-06 17:17:44 -08:00
Sameh Khamis 30c56a8ac8 Own the credits modules where the camera factory loads them
The Cesium camera module now loads the credits factory, so the flight,
military and satellite packages own the credits modules too, and the
infrastructure consumer build lists them.
2026-11-06 16:44:24 -08:00
Sameh Khamis 910a691494 Give a viewer with no renderer an effects port, as cameras get
The renderer bridge builds and keeps an effects port over a viewer no
renderer is bound to, with a factory the Cesium effects module provides,
so visual settings on a consumer's own viewer draw their styles, bloom
and sharpen as before and give the scene's bloom back when destroyed.
The application shell loads the factory; a test checks every export
that reaches the effects lookup loads it.
2026-11-06 16:33:40 -08:00
Sameh Khamis d0ef3a902b Drive the visual styles, bloom and sharpen through the effects port
VisualEffects defines each preset on the renderer and keeps its own
records of intensities and parameters, written through to the port, so
crossfades, the anime sky fade, bloom and sharpen behave as before. The
renderer animates the animated styles. Sensor picks read the style
records instead of the renderer's stages. The application renderer now
has the effects port and its capabilities.
2026-11-06 16:32:49 -08:00
Sameh Khamis d6b553646e Run styles, bloom and sharpen through a Cesium effects port
The effects port puts each style Core defines on its own post-process
stage, takes the scene's bloom stage and adds a sharpen stage, with the
mappings the application uses today. An animated style holds frames and
advances its time uniform while it draws. A replaced style keeps drawing
until its intensity is 0, so Core can crossfade; the contract, the stub
and a new conformance check say so. The conformance renderer has the
port; the application renderer does not yet.
2026-11-06 16:32:39 -08:00
Sameh Khamis cf915e8c16 Add layer and map credits through the renderer's credits port
The street-level provider credits, the map source credit, the shared
inline OpenStreetMap and OpenMapTiles credits and the ALPR provider credit
go through the renderer's credits port instead of Cesium's credit display.
Their markup has one link each and is read as the port's plain data; the
ALPR credit is built as plain data, which the port escapes. src/maps/credits.js
and src/layers/streetLevel/credits.js no longer reach Cesium and leave the
allow-list.

findCredits finds the port as findCamera finds the camera: a Cesium viewer
of a consumer's own, with no renderer bound, gets a credits port over its
own credit display, so the public entries keep their attribution. A port
built that way is released, not disposed, when a renderer binds later, and
a credit let go after its viewer is destroyed leaves the display alone.

The per-layer data credits and the transit and event credits stay on
Cesium's display: their markup carries several links each, which the
contract's credit cannot hold.
2026-11-06 16:32:23 -08:00
Sameh Khamis 9c9ba376e4 Give the Cesium renderer the contract's credits port
The shared Cesium ports gain credits, on both the conformance renderer and
the application's. list() reports what Cesium owes as plain data: its logo,
its static credits and the last drawn frame's imagery, terrain and tile
credits, read from the credit display through internals.js, with the
credits Core adds listed as given where the display shows them. addStatic
shows a credit through Cesium's display, as markup built from the plain
data with every value escaped and only web links kept, so attribution stays
on screen. change fires when Core adds or removes a credit, and after a
drawn frame whose Cesium credits differ from the last one's.

src/render/creditMarkup.js reads markup credits as plain data. The credits
listener check moves out of renderer.listenerRules into its own check, so a
renderer without every port can run it; it and the credits check now run on
Cesium.
2026-11-06 16:32:17 -08:00
Sameh Khamis 764b40950f Set the field of view and read the matrices through the camera port
setFov holds the vertical angle it is given across resizes (Cesium
keeps the wider one), waits for a frustum with a field of view, and
rejects angles outside (0, pi). matrices returns Cesium's view and
projection matrices, ECEF to eye in 3D, the only mode Core runs. The
conformance run checks both on Cesium.
2026-11-06 16:32:07 -08:00
Sameh Khamis c6f043d3fd Follow aircraft and satellites through the camera port
Flights and military follow their tracked aircraft with an
entityFollowTarget: framed in ENU from viewFrom, never closer than
150 m, presentation read from the drawn entity's record. Satellites
follow in Cesium's autodetect frame. The layers' own framing
(applyTrackedCameraFrame) goes; the port's follow has the same
close-range rules, and trackedCamera.js no longer imports Cesium.

The port's helper entity draws nothing: its point's outline is
transparent too, and click and voice picks of it count as picks of
the target's drawn entity. The fake camera binder follows when the
fake has Cesium's entity tracking.
2026-11-06 16:10:48 -08:00
Sameh Khamis e5f8e5753a Read the followed target, not trackedEntity, in Core's follow readers
The tracked card, screen annotations, north-up, share links and Cockpit
ask data/followed.js what the camera follows, so they work the same when
a layer follows through the camera port. followed.js gains
entityFollowTarget (a FollowTarget whose presentation reads the drawn
entity's record), resumeFollowing and followedPosition.
2026-11-06 15:31:36 -08:00
Sameh Khamis 2adec097de Read what the camera follows through one Core module
src/data/followed.js answers what the camera follows: the camera port's
target, or the entity a layer still tracks itself, seen through the same
fields. Ownership checks, render holds, guards, voice actions, the CCTV
pacing and the navigation release paths read it instead of Cesium's
trackedEntity, so they keep working when layers follow through the port.
The release paths stop following, cancel the flight and release the
camera through the port. The rotate refusal names the followed target by
its name or label title.
2026-11-06 12:27:23 -08:00
Sameh Khamis e40a4c78d5 Follow on the Cesium camera port
The Cesium camera port follows a target with Cesium's own entity
tracking, on a hidden helper entity per target, and gains followed,
followFramed, followOffset and followChanged. A target with a minimum
range gets GEV's close-range rules, moved here from trackedCamera.js:
an exact first framing, no zoom coasting, a zoom floor, and an eye that
never crosses through the target. followChanged now fires only when
what is followed changes, on the stub too. No layer follows through the
port yet.
2026-11-06 11:49:09 -08:00
Sameh Khamis 1d9c731c10 Orbit, move, look and zoom through the camera port
The Cesium camera port gains move, look, rotateAround and zoom, and the
browser conformance run now checks them, the lookAt-frame commands, the
changed thresholds and moveEnd (timed by the clock, not a frame count).
Orbit, the Bhote Koshi focus and cinematic, the launch zoom anchor and
replay chase, the camera verbs' motion tick and roll levelling, and
voice zoom write through the port.
2026-11-06 10:21:04 -08:00
Sameh Khamis 68855b2089 Undo the reformatting of four camera-slice tests
The camera-port commits ran prettier over four test files that were
never prettier-formatted. Restore their earlier text and keep only the
camera-port edits.
2026-11-06 09:55:05 -08:00
Sameh Khamis bc69e8a4ee Fly the director, annotations, voice framing and local GeoJSON through the camera port
The scene director's shot flights, start pose and stops, the annotation
framings and fly-to, the voice overhead framing and the local GeoJSON
click flight now drive the renderer's camera port. The director's stops
look the camera up with findCamera: teardown can run after the viewer is
gone, and then there is no flight to stop. The local GeoJSON flight,
part of the public infrastructure export, works on a plain Cesium viewer
through the unbound camera, and looks it up before taking its input
hold. Voice zoom stays for the move slice.
2026-11-06 09:49:30 -08:00
Sameh Khamis 0b1c5d0f23 Fly the remaining flights through the camera port, on any viewer
The embed view, the celestial-ring focus, the ground-guard lift, the
Bhote Koshi approach and orbit, launch selection and replay flights, and
the cockpit's view and takeover now drive the renderer's camera port.
The contract gains a sineInOut easing, pinned by a conformance trace.

A viewer with no renderer bound, such as one a consumer of a public
export brings, now gets a camera port too, as it already got an input
port: cameraOf builds one over the viewer's own Cesium camera and keeps
it for the viewer's life, until a renderer is bound. The Cesium adapter
provides the factory as it loads; code that reaches Cesium takes cameraOf
from the adapter so the factory comes with it, and findCamera answers
null where a missing camera is not an error (HUD, share link, world
focus, Recent Imagery's ZOOM IN). A test walks every package export and
fails one that can reach the bridge without loading the factory.

A destroyed viewer gets no camera and lets go of a built one, and the
application renderer stays bound until its viewer is destroyed.
2026-11-06 08:21:57 -08:00
Sameh Khamis e3baeec223 Fly layer focus flights through the camera port
flyToBounds takes an easing, as flyTo does: in the contract, the Cesium
port and the reference recorder, pinned by a new conformance trace.

The flights of radio, awareness, CCTV, submarine cables, cyclones, the
launch preview, installations, ALPR, street-level framing, recent
imagery, weather and world focus now go through the renderer's camera
port. Street-level framing cancels its own flight handle rather than
whatever flight is current. worldFocus.js no longer reaches Cesium and
leaves the allow-list.

Layer pose reads stay for each layer's own slice. The local GeoJSON
flight stays on Cesium: gods-eye-view/infrastructure is used with plain
Cesium viewers that have no renderer bound.
2026-11-05 20:46:58 -08:00
Sameh Khamis f95324acb1 Fly the camera through the renderer's camera port
The Cesium camera port moves into render/cesium/camera.js, with the
listener list and position conversion shared from common.js. It now
covers flights (flyTo, flyToBounds, cancelFlight, flying), lookAt and
release, command hooks, and a flightEnd event for the flights it starts.

A flight's end that Cesium raises inside one of the port's own calls is
delivered once Cesium's call returns, so its callback sees the flight
let go of. A flight Cesium declines to start, mid scene-mode morph,
ends as cancelled instead of never settling.

Flight traces run in Node on the recorder's headless camera with a
virtual clock; the browser harness keeps the instant traces.

The startup fly-in, landmark and viewport flights, share-link restore
and the shell's camera state now go through the port. camera.js and
sharelink.js leave the Cesium allow-list.
2026-11-05 20:15:52 -08:00
Sameh Khamis de0ff6cd2d Read the HUD and share-link camera through the renderer's camera port
The first camera-family slice. The camera port's events move into a
shared module (render/camera/events.js) that the stub and the Cesium
port both use: listener lists, delivery with errors reported, and the
contract's per-listener `changed` measure.

The Cesium port gains:
- camera.on: `changed` measured per listener on every tick, where Cesium
  measures its single threshold (the counts match Cesium's own at each
  threshold); moveStart and moveEnd relayed from Cesium's camera;
- camera.viewRectangle, in degrees (now in CESIUM_CHECKS);
- lon and lat in pose, and NaN where Cesium has no cartographic position.

Code that still holds the viewer reaches the renderer through
rendererOf(viewer); the application renderer binds it on create and
unbinds on destroy.

The HUD reads its telemetry from the camera port and no longer imports
Cesium (off the allow-list). The share link writes the hash from the
pose. Its restore path stays on the viewer's camera for a later slice.

Behaviour changes:
- The share link listens to `changed` at its own 0.5 threshold, not at
  whatever global threshold layers had set, and also to moveEnd, so the
  hash always catches up with where the camera comes to rest.
- The HUD's and share link's camera callbacks run from the port's single
  relay, ahead of other moveStart/moveEnd listeners, and a throw in one
  is reported to the renderer's error listeners instead of escaping from
  Cesium's render.
2026-11-05 19:48:58 -08:00
Sameh Khamis 24bc8fce7e Deliver the application's pointer input through the renderer's input port
The Cesium renderer now creates the input port and exposes it as
renderer.input. The input dispatcher becomes an adapter over the port:
one port listener per event type, fanned out to its subscribers with the
ownership check read once per event, keeping the legacy event shapes.

- Ctrl+wheel zoom is switched on through renderer.input.ctrlWheelZoom.
- A dispatcher made for a viewer with no port creates its own port with
  takeStock: false, which borrows the stock select and double-click
  follow only while they are suppressed.
- The stock actions see the press as it began: a click whose press
  started while select or follow was held off never runs them.
- A scene accepts one input port; cesiumInputOf(scene) returns it.
- Releasing one mouse button while another is held no longer gives the
  held button a click. Cesium still ends that press.
2026-11-05 19:22:43 -08:00
Sameh Khamis 1f2479d3ab Give the Cesium renderer the contract's input port
src/render/cesium/input.js feeds the shared router from one
ScreenSpaceEventHandler: every button's press, release and click, double
clicks, moves and pinches, under any one modifier. The DOM event being
dispatched gives the full modifiers and pointer type; an action Cesium
raises on a timer takes its own modifier. Wheel comes from the canvas in
lines and is never cancelled. Clicks are picked once, lazily, through the
query port. Cesium ends every held button on any release; those presses
end there, but only the button released clicks.

The viewer's stock select and follow move off its handler and run first,
as they did, but only for a press no lease took and while not suppressed.
Camera holds go through the scene's shared holds, ctrlWheelZoom through
ctrlWheelZoom.js. The router gains a before-hook and dispose, which stops
delivery mid-event.

The conformance renderer exposes the port and the harness feeds it;
input.lease, input.lease.grab, input.lease.press, input.clickPick and
input.behaviours join the Cesium checks.
2026-11-05 18:50:18 -08:00
Sameh Khamis 21f284f79c Move Ctrl+wheel and trackpad pinch zoom into the Cesium renderer
installTrackpadPinchZoom and its pinch scaling move unchanged from
src/app/viewer.js to src/render/cesium/ctrlWheelZoom.js, where the input
port's ctrlWheelZoom will turn it on and off. The application scene
imports it from there; the application/viewer export no longer carries
this undocumented helper.
2026-11-05 18:34:10 -08:00
Sameh Khamis 60a3a2808e Share a scene's camera-input holds beyond the dispatcher
The take-over of the camera controller's enableInputs moves, unchanged,
to src/render/cesium/cameraInput.js. acquireCameraInput(scene) gives every
user one set of counted holds per scene, so the renderer's input and
camera ports can hold camera input alongside the dispatcher; the
controller gets its own property back when the last user releases. The
dispatcher acquires its share once its handler is bound. The input guard
names the one module allowed to write the flag, and the new render files
join the format scope.
2026-11-05 18:29:38 -08:00
Sameh Khamis 468590485b Give the Cesium renderer the contract's listener rules and error channel
Frame-phase and draw-set ready listeners now run in subscription order,
skip one removed during delivery, defer one added during it, and hand a
throw to the renderer's error listeners (or reportError) instead of into
Cesium's render loop. The renderer gains on('error' | 'contextLost' |
'contextRestored'); Cesium cannot restore a lost context, so it never sends
contextRestored. Dispose clears every list. renderer.listeners joins the
Cesium checks.
2026-11-05 18:18:28 -08:00
Sameh Khamis 3e48e453d0 Share the stub's pointer routing as an engine-neutral input router
The stub's lease and press routing moves to src/render/input/router.js,
unchanged, so a real renderer can feed it its own engine's events. The
router also keeps the behaviour holds (telling the renderer when one turns
off or on, unless it lacks it) and ctrlWheelZoom, and dispatch says who an
event went to.
2026-11-05 18:09:38 -08:00
Sameh Khamis 3837e453ca Keep the render governor's holds on the frame port
The governor no longer writes the Cesium scene's render mode itself: each
owner holds the renderer's frame port once, requests go to it, and the
port is the one place the loop's mode is set. The port now also keeps
the loop from redrawing for scene time alone, which the governor did.

The application installs the governor as soon as its renderer exists and
holds the loop continuous ('boot') until its tools are up, where the
governor used to be installed, so boot draws as before. The hidden-tab
suspension goes through the port too, and the application's renderer now
has the whole frame port.
2026-11-05 17:24:34 -08:00
Sameh Khamis 48f06c68c8 Complete the Cesium frame port
The frame port now does everything the contract asks of it on Cesium:
preUpdate says whether the tick draws (Cesium's own render test, read
live until the tick is over), holds are counted per reason, and suspend,
target fps, renderNow and diagnostics are real. Releasing the last hold
asks for one more frame.

whenSettled also waits until Cesium stops redrawing on its own. Each
request or worker task Cesium completes queues a redraw (a terrain
picker build after the camera moved, say), which no loading flag shows,
so it now waits for quiet frames and then a short stretch in which the
loop draws nothing by itself. It is bounded by its timeout, names a
stopped loop, and ends on destroy.

The app renderer still leaves out the members that steer the loop:
the render governor and the page's visibility handling own those
settings until they move onto the port.

The new frame.prediction check compares the preUpdate answer with what
each tick drew.
2026-11-05 17:11:25 -08:00
Sameh Khamis bdc7692292 Complete the Cesium query port
The Cesium renderer now has every QueryPort member: picks and drill
picks, depth and globe picks, rays, projection, occlusion, heights now
and streamed, clamping and ray casts. Capabilities come from what the
scene supports (depth textures for pickPosition, sampleHeight,
finestHeights and rayCast), in both the conformance and app renderers.

- Picks skip unpickable and hidden items, and report each item once. A
  pick under an unpickable item stops at anything that hides the rest.
  drillPick's limit counts after that filtering, so 0 gives none.
- Height queries and ray casts see through sets not drawn with
  includeInHeightQueries, and through the sets a caller excludes, by
  their items' pick ids. The list is built once per change to the sets.
- `tiles` samples and casts run with the globe hidden for the call;
  `terrain` casts use the globe alone. A sample reports whether it came
  from tiles, terrain, the ellipsoid or a set.
- A position clampToSurface cannot read is a miss, not a failed batch.
- Terrain on an ellipsoid is height 0. Before, sampling it threw, for
  want of tile availability.

whenSettled also waits for icon images, and fails when one cannot load.
The conformance checks that pick or query settle first, because a
renderer answers from what it has loaded; the stub settles at once.
The Cesium renderer now passes 14 checks in the browser, and its
visual references are unchanged.
2026-11-05 16:22:48 -08:00
Sameh Khamis 92fd2b0c2e Run the conformance logic suite on the Cesium renderer
The conformance page can now run the logic checks against the Cesium
scene renderer as well as the stub. CESIUM_CHECKS lists the checks of
the ports Cesium already has; each phase 2 migration adds its own, so
the suite shows how far the renderer is through the contract. All eight
listed checks pass in the browser, and the stub still passes its 69.

A harness frame is one tick of the render loop, observed through
frame.on('preUpdate'). It asks for no frame itself, so a check's own
requestRender is what makes a tick draw, and a loop that stops fails
the check after five seconds instead of hanging the run. The harness
frees each WebGL context on destroy.

FrameTime.dt is now measured rather than fixed at 1/60, and renderOnce
honours timeoutMs. A scene renderer made without the models capability
has no draw.models.
2026-11-05 16:04:02 -08:00
Sameh Khamis a5143f4467 Share the Cesium ports between the app and conformance renderers
The frame, draw, camera, query and capture ports move out of the
conformance scene renderer into src/render/cesium/ports.js, unchanged.
The app's transitional renderer, now src/render/cesium/appRenderer.js,
builds the same ports over the full viewer; it still leaves out globe,
whenSettled and setTime, which would misreport photoreal tiles and stop
the app clock.

Disposing the ports now settles a pending renderOnce and detaches the
draw sets, so a model that loads after destroy is destroyed rather than
added to a destroyed collection. A viewer whose ports fail to build is
destroyed with its credits.

Checked in a browser: the app boots with one credits element, and all
eleven conformance scenes match their references exactly.
2026-11-05 15:50:48 -08:00
Sameh Khamis 03f2e2a9b9 Add the ratchet that keeps Cesium inside the renderer
Outside src/render/cesium/, only the 236 files on cesiumAllowList.json
may reach Cesium, and each must still do so, so porting a file removes
its entry. A file reaches Cesium by importing it or the Cesium adapter,
by name or by path, or by being handed the namespace as a parameter,
binding or property. Files added to the list since origin/main fail the
check, once the list is on main. The renderer contract, stub and
conformance suite must stay Cesium-free, and runtime code in a language
the check cannot read fails it.
2026-11-05 15:10:04 -08:00
Sameh Khamis 8d7f82f110 Create the application scene through a renderer plugin
createApplicationScene takes a renderer plugin, defaulting to Cesium,
which builds today's viewer and credits exactly as before. Its renderer
is transitional: it has only the members it really has, with read-only
capabilities, and a test pins the contract members still to come. Code
not yet ported reaches the viewer through cesiumViewerOf, never through
a renderer member. A plugin whose renderer has no viewer is refused and
destroyed, since the application still runs on the viewer. The result
now includes the renderer.

Not yet checked in a browser.
2026-11-05 14:51:42 -08:00
Sameh Khamis bcd24c458b Check the src/geo tests' formatting
The geo, celestial and quaternion tests join the format check, as the
runtime modules beside them already do.
2026-11-05 14:41:35 -08:00
Sameh Khamis 6060c2cee8 Convert voice action positions and distances through src/geo
Voice actions read degrees from ECEF positions with toLonLatH and
measure distances with src/geo, at the sites that only did maths. A
position that converts to NaN now reads as unknown, never as NaN
coordinates. The analyst test gives its subject a real ECEF position
instead of patching Cesium. Sites that keep a Cesium Cartographic, or
use the camera or picks, wait for the renderer ports.
2026-11-05 14:33:32 -08:00
Sameh Khamis bf758a0abb Convert share link angles and positions through src/geo
Share links convert degrees, radians and the flight destination with
src/geo. The hash and the destination are bit for bit the same as
before; the flight itself still goes through the Cesium camera.
2026-11-05 14:28:10 -08:00
Sameh Khamis e32988ae5e Match Cesium's fromDegrees exactly and keep toEcef in the stub
fromRadians divides by gamma as Cesium does, so geodetic to ECEF is now
bit for bit Cesium's, and the test checks it exactly. Its tests also
cover three height bands, points inside the Earth and NaN input. toEcef
reads the renderer contract's Position, so it moves to the stub's maths.
The ellipsoid docs pair each conversion with Cesium's.
2026-11-05 14:28:10 -08:00
Sameh Khamis 52d82b453c Add quaternions to src/geo
Ports Cesium's axis-angle, product, rotation-matrix and heading-pitch-roll
quaternions, with rotations given as frame axes. They match Cesium bit for
bit, including heading-pitch-roll frames on the polar axis. The model
convention is documented: at heading 0 the forward axis points east.
2026-11-05 14:15:13 -08:00
Sameh Khamis fd1c09dbbc Add the sun and moon to src/geo
A port of Cesium's Simon 1994 sun and moon positions, its TEME to
pseudo-fixed rotation and the JulianDate time handling, with epoch
milliseconds as the only input. Results are identical to Cesium's,
tested with exact equality from year -500 to 3000 and across every
leap second. Times outside the model throw a RangeError, where
Cesium's debug build throws.
2026-11-05 14:10:48 -08:00
Sameh Khamis 5652236a27 Read the agent world packet's camera through src/geo
The world packet converts the eye position with toLonLatH instead of
Cesium's Cartographic, so the agent-world-packet group no longer needs
cesium. Degree conversions use precomputed factors, as Cesium.Math does,
so results stay bit for bit the same.
2026-11-05 14:01:57 -08:00
Sameh Khamis 9f165e68f3 Move the stub's geodesy into a renderer-neutral src/geo
WGS84, ECEF and geodetic conversions, surface normals and east-north-up
frames, as plain {x, y, z} objects, exported as ./geo. A test checks
them against Cesium over random points and the poles. The stub's maths
re-exports them and keeps only the camera angle conventions.
2026-11-05 13:49:53 -08:00
Sameh Khamis fefdf242ee Settle imagery, world data and sky capabilities in the renderer contract
- setFrames takes a timed source (URL frames, or video tiles in
  segments) on the scene clock by default or its own; frameReady fires
  once each time the shown frame is drawn.
- drapeOn is 'surface' | 'objects' | 'all' plus lit; imagery.host() and
  onHostChange go, and the drape rule Core derives is written out.
- ImagerySpec.fetchTile answers bytes, missing or defer on every
  renderer; tileTransform, admit and canvas imagery need imageHooks, and
  add() throws for them without it.
- Generic names for the default renderer's world data: worldDataToken,
  the photoreal fallback route, and world terrain and imagery.
- clouds and water capabilities, with globe.setClouds and setWater.
2026-11-05 13:26:14 -08:00
Sameh Khamis e9c0dc5405 Settle leases, styles, credits and controls in the renderer contract
- claimPointer takes a grab filter, asked on each down: a lease takes
  only the presses it grabs, and wheel, pinch and every other press stay
  with the camera and everyone else. Each event of a press goes where its
  down went, per button, so a claim never takes a press under way.
- Core owns the styles: effects.defineStyle passes a GLSL (and optionally
  WGSL) full-screen pass, with the shader interface written out.
- Credits are plain data ({ text, href, logoUrl, inline }) that Core
  renders; CreateOptions.creditContainer goes.
- controls.coast { drag, zoom } in seconds replaces the inertia trio; the
  stub's defaults are the default renderer's, converted.
- The frame order is the default renderer's own: a move made by follow or
  in preUpdate reaches changed on the next frame.
2026-11-05 13:11:59 -08:00
Sameh Khamis 14f06df735 Settle the draw decisions in the renderer contract
- lodGroup links items across sets as one thing at different detail;
  picks name the member shown and report the group. displayRange is
  half-open.
- An orbits capability: the renderer propagates two-line elements with
  SGP4 on the set's motion clock. The stub propagates with satellite.js.
- Height queries see draw sets only with includeInHeightQueries (was
  excludeFromHeightQueries); HeightSample.source gains 'set'.
- courseGlide is a capability; courseBlendMs eases the course on every
  renderer.
- Arc segments climb at climbMps; a negative minDtS stays for the
  aircraft warm-up.
- The star catalogue is optional, a hidden column row keeps its motion,
  and draw.clampState() reports the drape fallback.

Checks cover the climb and warm-up, lodGroup ranges, orbits against SGP4
(with and without motionSegments), hidden rows resuming, and sets not
answering height queries by default.
2026-11-05 12:50:58 -08:00
Sameh Khamis 2fc99e7c19 Define the renderer contract, with a stub renderer and a conformance suite
The renderer contract is what the application asks of a 3D renderer, so a
renderer other than Cesium can draw the same application. It is nine ports
(frame, globe, draw, camera, query, input, effects, credits, quality) plus
the renderer itself, one strict .d.ts per port in src/render/contract/.
PORTS lists every member at run time, and a TypeScript-compiler test fails
when the list and the types disagree on one member. An optional member
exists only with its capability; a required one answers null or empty
without it. Nothing in the application uses the contract yet.

The stub renderer (src/render/stub/) implements every port in memory and
draws nothing. Its camera is a port of the default camera's maths: views,
lookAt, flights with their height arc and easing, rectangle framing on the
geodesic midpoint, the view rectangle with its horizon and pole cases,
changed thresholds, follows, and flights that finish inside the call.

The conformance suite (src/render/conformance/) runs 59 checks against
any renderer through the contract alone, in Node or in a browser page. The
camera checks compare traces recorded from Cesium's own Camera, headless on
a virtual clock (scripts/record-camera-goldens.mjs), to 1 mm, 1e-9 and
1e-3 px. The other checks cover listener rules on every subscription, the
frame loop, draw sets, picks, motion clocks, heights, leases, imagery,
styles, credits and quality knobs. The stub passes all of them but the
model check, which needs a glTF asset.

Reference scenes draw one scene per primitive family, a city and a
calibration path from orbit to the street, from contract calls only and
with no network. /dev/conformance/ draws them on the Cesium scene renderer
(src/render/cesium/sceneRenderer.js), records references through a
dev-server-only, loopback-only endpoint, and compares captures with
pixelmatch's antialias detection plus a solid-pixel count. Measured on
Cesium: every MSAA setting passes, and a dropped 4 px point, a dropped 2 px
line and a 1 px shift of the view fail. visual.js states the limits.

The package exports ./render/contract, ./render/stub and
./render/conformance. Documented in docs/RENDERER-CONTRACT.md,
docs/CODE-BOUNDARIES.md and docs/CURRENT-STATE.md.
2026-11-04 14:21:48 -08:00
Sameh Khamis fb30287898 Draw the last native world labels through the label overlay
The Directions A and B letters and a data pack's media label were the two
Cesium text labels left, kept by an allow-list in noCesiumLabels.test.mjs.
Both now publish entries to the shared world-overlay host, and the
allow-list is gone, so any Cesium label in src is a test failure.

Directions publishes the letters to the directions-markers source; the
markers keep only their points. A clicked endpoint's letter hangs over the
surface that was clicked. One placed without a click stays hidden until its
ground-floor cell resolves, then requests a render, so a still camera shows
it; a cell that settles with no height falls back to the ellipsoid. Each
position is computed once.

A pack resource handle gains label(entry). The application passes its
shared overlay host to SceneDirector through dataPacks.overlayHost; without
one a media pack fails to load rather than drawing a native label. Each
labelled resource takes a source from a pool and returns it on dispose, as
the host never removes a source, so two presentations of one pack never
clear each other's labels and repeat loads add no sources. A label written
after dispose is ignored. The media label is protected, so it always draws
as before.

Live (Denver, keyless globe): a clicked A and a programmatic B both anchor
on their dots over 1.58 km ground. Documented in docs/CURRENT-STATE.md and
docs/DIRECTOR-DATA-PACKS.md.
2026-11-04 12:30:31 -08:00
Sameh Khamis ad8adc85f4 Replace Cesium monkey-patches with hooks and keep internals in one module
The arrival ground guard replaced camera.flyTo, flyToBoundingSphere,
setView, lookAt and cancelFlight with wrappers so a direct caller would stop
it. It now remembers the pose it started on and, before each retry, yields
if a flight is under way or the camera has moved across the ground, down or
around. A straight rise is not a move: Cesium's terrain collision lifts a
buried eye that way as tiles load, which is the case the guard exists for.
This also catches owners the wrappers missed (a pose written directly,
lookAtTransform), and no camera method is replaced.

Recent imagery and weather overwrote provider.requestImage (weather also
provider.credit) to ration tiles. They now state that policy as a hook with
withTileRequestHook(provider, { requestImage, credit }) from
src/render/cesium/tileRequests.js, which hands Cesium a provider delegating
to the original and leaves the original untouched.

Every private Cesium field Core reaches goes through a named helper in
src/render/cesium/internals.js: the main and offscreen pick views and their
command extents, a billboard's collection, a cluster's point collection,
the widget's render-loop latch, the WebGL context and the current camera
flight. internals.test.mjs fails when the installed Cesium stops declaring
one of them, and a source scan fails on private access anywhere else. The
cyber sonar's primitives.update wrap stays until the effects work.

Live (Austin, keyless globe): a buried eye is lifted to 253 m over a 133 m
surface; a setView or flyTo by another caller makes the guard yield; radar
loads its tiles through the hook. Documented in docs/APPLICATION.md and
docs/CURRENT-STATE.md.
2026-11-04 11:15:35 -08:00
Sameh Khamis d51ea4007b Ask for heights through one service per scene
About 20 sites called scene.sampleHeight, globe.getHeight,
scene.clampToHeight or the MostDetailed variants directly, each with its own
idea of a miss (undefined, NaN, a throw). They now ask heightsFor(scene) in
src/queries/heights.js:

- now(point, { against, exclude }) answers from what is loaded, or null;
  against is 'surface' (what is drawn) or 'terrain' (loaded terrain only).
- clampNow(position) moves a position onto the drawn surface, or null.
- batch() and clampBatch() wait for the most detailed tiles: null per
  missed point, a rejection when the whole query fails.
- supported() reports { surface, clamp } without allocating.

Cesium still answers now() synchronously, so nothing changes on screen and
nothing is slower: in the live app 1,000 calls cost the same through the
service as direct (terrain 0.5-0.7 ms, surface 2-6 ms a call, run-to-run
noise larger than the difference). Every site already treated a miss as
"hold the last value or fall back"; a renderer that can only answer from a
cache or asynchronously can now plug in at one place.

- A Bhote Koshi evidence point whose terrain sample misses stays at its
  fallback elevation instead of reading the miss as sea level.
- The mesh floor sampler skips a scene without depth-texture support
  instead of spending its budget on probes that throw.
- The world annotation probe's trace reads hit/missed.
- A source scan forbids direct height calls outside the service.

The service is exported as ./queries/heights and documented in
docs/APPLICATION.md.
2026-11-04 09:24:30 -08:00
Sameh Khamis b51f1bf7c5 Take pointer input through one dispatcher per scene
Every layer and tool bound its own ScreenSpaceEventHandler and picked the
same click again: with the usual layers on, one click ran about 26
scene.pick and 2 drillPick passes (120-215 ms in NYC with 18 layers). Pointer
input now comes in through src/input/dispatcher.js, one handler per scene.
A click is picked lazily and at most once per kind of query, and every
subscriber reads the same ClickEvent (pick, hit, id, ownerId, gesture,
drill, ground). The same click now costs 3 picks and 2 drills, 17-70 ms.

- Ambient onClick subscribers run only while no tool holds the pointer,
  checked once per event; tools subscribe with their lease. Moves and
  releases always reach ambient subscribers, so hover and drags end cleanly.
- Camera input and the viewer's stock select/track clicks are ref-counted
  holds (suspendCamera, suppressStockClicks). The dispatcher owns the
  controller's enableInputs, so a Cesium flight that ends no longer turns
  input back on under a drag or the cockpit.
- The draw tool, imagery box, CCTV gizmo, cockpit, directions and every
  selecting layer use it; bindTrackingClickGesture is gone (the gesture is
  on the event). A source scan forbids new handlers, enableInputs writes and
  stock-click removal outside the dispatcher.
- The sensor-style pick correction transit had now applies to every layer.
- Radio's 8-pick near-miss ring is one 17x17 windowed drill.
- A missed pick passed to pickContact is no longer picked again, and a
  touch tap's contact search shares the click's drill.
- A gizmo drag whose release Cesium never reported ends on the next press
  instead of leaving the camera held.

The dispatcher is exported as ./input with the pointer-lease functions, and
documented in docs/APPLICATION.md.
2026-11-04 08:48:51 -08:00
Sameh Khamis 6035de88e2 Delete the billboard and model mirrors; models become layer-held records
Aircraft draw from GPU icon sets, so the billboard mirror, the billboard
treatment and the aircraft visual-state module had no callers left. 3D models
are now records the layer holds under the contact's id (modelRecord.js):
show and placement are written through showModel/placeModel and read back
from the record; renderer-measured values stay on record.model.

The tracked-model click check only matches when a tracked model exists, so
an id-only pick is never mistaken for a click on the tracked plane.
2026-11-03 22:36:27 -08:00
Sameh Khamis f008120b4c Draw FIRMS detections from a GPU icon set 2026-11-03 22:09:57 -08:00
Sameh Khamis 00d1bee127 Draw CCTV cameras from a GPU icon set 2026-11-03 21:47:16 -08:00
Sameh Khamis 89b07fddfd Draw live AIS vessels from a GPU icon set 2026-11-03 21:16:19 -08:00
Sameh Khamis fe32e47bdf Share the aircraft 3D-model regime between flights and military 2026-11-03 20:55:09 -08:00
Sameh Khamis 9fde0a2a59 Draw military flights from the shared GPU icon set
The flights icon planning and presentation move into
`createAircraftIcons` (src/data/aircraftIconLayer.js), and the motion
regime both layers dead-reckon from into src/data/aircraftMotionRegime.js.
Flights keeps only its grounded-floor pass on top.

Military now draws every contact as a row of that icon set, like
flights: the billboards, the cull positions and the rotation-pass
signature go. Tracking hides the row and untracking re-plans it from
the tracked course. Queries read the drawn row position and judge the
horizon as the shader does. Snapshots that arrive before init or after
destroy are ignored, and per-contact lookups find no contact then
instead of throwing.

With about 120 live military contacts, preRender p95 drops from
0.7-0.8 ms to 0.2 ms on the same views. Render time and frame gaps are
unchanged or slightly lower.
2026-11-03 20:30:43 -08:00
Sameh Khamis 2c4c66c710 Draw civil flights from an icon set moved on the GPU
Every flight is now a row in the layer's icon set. Its motion segment
follows the same regime `_deadReckon` picks (interpolate between fixes,
warm up backward from the oldest, coast forward from the newest), and
one shared `_motionRegime` makes that choice for both. The shader moves
every untracked contact between polls, so the 12 Hz fleet tick no
longer touches each billboard. It now only re-plans expired rows,
floors grounded contacts, advances focus while needed, and runs the 3D
model regime.

Grounded contacts the floor lifts are pinned to a hold at the floored
position. Their nose glides on the GPU. Contacts within 50 km re-floor
every tick, and farther ones take turns about once a second. Low
contacts are judged for the horizon at no lower than 12 m above the
ellipsoid, as before. Below hover speed a contact's nose holds. A row
whose fleet model is drawn keeps its focus updated, because the model
reads it. Untracking re-plans and re-floors the icon from the tracked
course.

On the same views with about 6,700 OpenSky flights, frame gaps at p95
drop from 28-40 ms to 17 ms, and the fleet tick from 16-37 ms to about
2 ms at most.
2026-11-03 19:40:39 -08:00
Sameh Khamis 5f36eb0e0d Add columnar icon sets drawn by an instanced Cesium primitive
Icon rows live in typed-array columns: one packed motion segment (hold,
lerp or arc, with a course glide), style, colour and pick ids per row.
The primitive draws every row in one instanced call, dead-reckons in the
vertex shader, matches the billboard treatment the fleet layers use, and
picks and drill-picks like a billboard. Cyber sonar covers icon sets.
2026-11-03 18:35:12 -08:00
Sameh Khamis 7204d66f0b Keep installation and local GeoJSON pick state off Cesium entities
Installation selection fills and outlines record the installation they
belong to in the layer's installationMarkers store instead of an
installationId expando, and the click handler resolves them from there.
Named-marker points keep their { installationId } pick payload, which is
Core's own object, so sibling layers treat a point click as before; the
marker remembers its record id for visit() instead of reading the point.

The local GeoJSON package tracks the entities each layer loaded in a
per-instance set instead of a __localLayerId tag, and passes the stem
base to registerEntityContext as targetPosition instead of hanging it on
the entity; FIRMS does the same, and voice targeting reads the context
record. QA scripts find a layer's features by their Core context id.
2026-11-03 14:57:31 -08:00
Sameh Khamis c5c0803901 Answer earthquake, CCTV pick and tracked sprite reads from Core
Earthquake analyst records come from the events the layer drew, gated on
its own enabled flag, instead of reading each entity's position and
property bag back; the discs no longer carry the bag. CCTV coverage rays
and projection planes record their camera id in a layer pick store
instead of a cctvCameraId entity property, with the same ownership checks.

The tracked flight, military and local ADS-B billboards record their size
and distance scaling as the entity record's sprite, which the close-range
camera guard and the screen annotations' tracked footprint now read in
place of the billboard's property getters.
2026-11-03 13:32:53 -08:00
Sameh Khamis 66869b09ef Read aircraft model placement from Core's mirror
Flights, military and local ADS-B now write their models' show and world
matrix through setModel in src/data/modelMirror.js, and read them back
through modelState: the fleet and tracked handoffs, the tracked visual and
trail anchors, detection anchors and modelAnchorWorld. A matrix is copied
into Core's record and into the model's own matrix, so the shared scratch
can be passed directly and models still never share one matrix object.

What Cesium measures (ready, computedScale) is still read from the model,
and colour stays live as on billboards, because cyber sonar writes it too.
Test seams mirror the models they install, as they do billboards.
2026-11-03 12:58:50 -08:00
Sameh Khamis 47f7eeb00e Read vessel, CCTV and FIRMS billboard state from Core's mirror
The three layers now add their billboards through addBillboard and write
the mirrored fields through setBillboard, so selection, cards, queries,
focus, view thinning and the FIRMS horizon cull read what was pushed from
Core instead of from Cesium. Vessels and FIRMS state their sprites' own
sizes (32 px ship artwork, the glow canvas at twice the core size), which
the mirror needs. A vessel helper, iconPosition, replaces the repeated
mirror-or-visual position lookups, and the vessel visibility pass writes
show only when it changes, as CCTV and FIRMS already did.

A billboard never given a position now has none in the mirror rather than
the origin.
2026-11-03 11:14:33 -08:00
Sameh Khamis 1cfff5803a Read flight and military billboard state from Core's mirror
Flights and military now add their billboards through addBillboard and
write show, position, scale, rotation, size and distance scaling through
setBillboard, which keeps Core's copy in src/data/billboardMirror.js.
Their reads go to billboardState instead of the Cesium billboard, so the
layers no longer read back what they pushed to the renderer.

A mirrored billboard needs an explicit width and height, because unset
Cesium reports the loaded image's size. Colour is not mirrored yet: the
cyber-sonar theme writes it too and tells the writers apart by the live
value, so it moves with the effects port.
2026-11-03 10:56:08 -08:00
Sameh Khamis a40e5c334c Keep cable pick info and the prewarm flag off Cesium objects
Submarine cables wrote each pick's info both to a per-load WeakMap and to a
__gevTeleGeography expando, always together; one cablePicks store replaces
both and resolves the same objects. The voice view-target prewarm marks its
viewer in a WeakSet instead of a __gevViewTargetPrewarmInstalled property.
2026-11-03 10:01:12 -08:00
Sameh Khamis f86dcc9179 Keep aircraft, installation and viewshed state off Cesium objects
The aircraft layers' model spec key, IR boost and belly offset and the
billboards' large-icon flag move into aircraftVisuals, one store shared by
flights, military and local ADS-B. Installation markers' last drawn height
and selection move into installationMarkers. The CCTV viewshed QA tag is now
the viewshedCameraId record field, which qa-cctv-v2 reads through
entityRecords. Test fixtures drop a placement flag nothing reads.
2026-11-03 09:54:41 -08:00
Sameh Khamis 42b17ffab6 Keep the context id in Core's entity records instead of on picked objects
registerEntityContext stamped __gevContextId on the entities, primitives and
carriers it registered, and selection and pick resolution read it back. It
is now the contextId field of the entity's Core record.
2026-11-03 09:40:15 -08:00
Sameh Khamis 07c8d7a17c Keep ALPR marker state in a layer store instead of on Cesium entities
The ALPR layer's private per-entity fields (record, clamp, canvas and display
anchors, wedge, native appearance, pick position, shown, surface check) move
from gevAlpr* expandos into alprMarkers, a store made by the new
createEntityStore(). The shared entity records are built on the same factory.
2026-11-03 08:24:33 -08:00
Sameh Khamis 95c0070412 Keep tracked-entity state in Core records instead of on Cesium entities
Flights, military, satellites, installations, local ADS-B and ALPR hung
gevTrackedId, gevDisplayPosition, gevVisualPosition, gevSelectionOrigin and
gevLabelModel on the Cesium entities they draw, and the tracked readout,
follow camera, cockpit, share links and awareness read them back. They now
live in src/data/entityRecords.js, a Core-owned record per entity: layers
write entityRecord(entity), readers use readEntityRecord(entity). Nothing
changes on screen. This is the first slice of the renderer plan's state
mirror (P1).

The module is exported as ./data/entity-records so hosted code can read the
same records, and window.__godsEyeView.entityRecords gives the QA scripts
the same access from the page.
2026-11-03 08:09:00 -08:00
478 changed files with 42231 additions and 8532 deletions

No files matched your search

+2
View File
@@ -9,6 +9,8 @@ coverage
pinokio/ENVIRONMENT pinokio/ENVIRONMENT
pinokio/.installed pinokio/.installed
src/data/local_data/ src/data/local_data/
# Written by scripts/record-camera-goldens.mjs and compared byte for byte.
src/render/conformance/goldens/
*.geojson *.geojson
*.geojsonl *.geojsonl
+114
View File
@@ -0,0 +1,114 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Renderer conformance</title>
<style>
:root {
color-scheme: dark;
font:
13px/1.4 system-ui,
sans-serif;
}
body {
margin: 0;
background: #0d1117;
color: #e6edf3;
}
header {
display: flex;
gap: 12px;
align-items: center;
padding: 10px 16px;
border-bottom: 1px solid #30363d;
flex-wrap: wrap;
}
h1 {
font-size: 15px;
margin: 0 12px 0 0;
}
button,
select {
font: inherit;
padding: 4px 10px;
}
#stage {
width: 640px;
height: 360px;
position: fixed;
left: -10000px;
top: 0;
}
main {
padding: 12px 16px;
}
table {
border-collapse: collapse;
width: 100%;
}
td,
th {
border-bottom: 1px solid #21262d;
padding: 4px 8px;
text-align: left;
vertical-align: top;
}
.pass {
color: #3fb950;
}
.fail {
color: #f85149;
}
.skip {
color: #8b949e;
}
.views {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(330px, 1fr));
gap: 12px;
margin-top: 12px;
}
figure {
margin: 0;
background: #161b22;
border: 1px solid #30363d;
padding: 6px;
}
figure img {
width: 100%;
display: block;
}
figure .row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 4px;
}
figcaption {
margin-top: 4px;
}
</style>
</head>
<body>
<header>
<h1>Renderer conformance</h1>
<label
>Renderer
<select id="renderer">
<option value="cesium">cesium (scene renderer)</option>
</select></label
>
<button id="logic">Run logic suite (stub)</button>
<button id="logicCesium">Run logic suite (cesium)</button>
<button id="check">Check scenes</button>
<button id="record">Record references</button>
<span id="status"></span>
</header>
<div id="stage"></div>
<main>
<section id="logicResults"></section>
<section class="views" id="views"></section>
</main>
<script type="module" src="./main.js"></script>
</body>
</html>
+230
View File
@@ -0,0 +1,230 @@
/**
* The conformance page: runs the logic suite in the browser, and draws the
* reference scenes on a renderer to check them against the stored
* references (or record new ones). Driven by hand or through the
* chrome-devtools MCP; `window.conformance` exposes the same actions.
*/
import {
DEFAULT_TOLERANCE,
runConformance,
} from '../../src/render/conformance/index.js';
import {
REFERENCE_SCENES,
sceneSupport,
} from '../../src/render/conformance/scenes.js';
import {
compareImages,
drawScene,
referenceName,
} from '../../src/render/conformance/visual.js';
import { createStubHarness } from '../../src/render/stub/harness.js';
import { toEcef } from '../../src/render/stub/math.js';
import { createCesiumSceneRenderer } from '../../src/render/cesium/sceneRenderer.js';
import {
CESIUM_CHECKS,
createCesiumHarness,
} from '../../src/render/cesium/harness.js';
const REFERENCES = '/src/render/conformance/goldens/visual/';
const SAVE = '/__conformance/references/';
const ctx = { toEcef, assets: { model: '/models/c172.glb' } };
const $ = (id) => document.getElementById(id);
const status = (text) => ($('status').textContent = text);
const RENDERERS = {
cesium: () => createCesiumSceneRenderer({ container: $('stage') }),
};
const HARNESSES = {
stub: () => [createStubHarness('stub (browser)'), {}],
cesium: () => [
createCesiumHarness({ model: ctx.assets.model }),
{ only: (id) => CESIUM_CHECKS.has(id) },
],
};
/** Runs the logic suite on `which`; `all` runs every check, listed or not. */
async function runLogic(which = 'stub', { all = false } = {}) {
status(`running the logic suite on ${which}…`);
const [harness, options] = HARNESSES[which]();
const report = await runConformance(harness, all ? {} : options);
$('logicResults').innerHTML =
`<p>${report.harness}: <b class="pass">${report.passed} passed</b>, <b class="fail">${report.failed} failed</b>, ${report.skipped} skipped</p>
<table>${report.results
.filter((r) => r.status !== 'pass')
.map(
(r) =>
`<tr><td class="${r.status}">${r.status}</td><td>${r.id}</td><td>${escape(r.detail ?? '')}</td></tr>`,
)
.join('')}</table>`;
status('');
return {
passed: report.passed,
failed: report.failed,
skipped: report.skipped,
failures: report.results.filter((r) => r.status === 'fail'),
};
}
const escape = (s) =>
s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c]);
async function pixels(blob) {
const bitmap = await createImageBitmap(blob);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const g = canvas.getContext('2d');
g.drawImage(bitmap, 0, 0);
return g.getImageData(0, 0, bitmap.width, bitmap.height);
}
// Object URLs shown for the current run, revoked when the next one starts.
let objectUrls = [];
function objectUrl(blob) {
const url = URL.createObjectURL(blob);
objectUrls.push(url);
return url;
}
async function toUrl(imageData) {
const canvas = new OffscreenCanvas(imageData.width, imageData.height);
canvas.getContext('2d').putImageData(imageData, 0, 0);
return objectUrl(await canvas.convertToBlob());
}
/** The views the stored manifest lists, or none. */
async function storedViews() {
try {
const res = await fetch(REFERENCES + 'manifest.json', {
cache: 'no-store',
});
return res.ok ? ((await res.json()).views ?? []) : [];
} catch {
return [];
}
}
async function reference(name) {
const res = await fetch(REFERENCES + name, { cache: 'no-store' });
return res.ok && res.headers.get('content-type')?.startsWith('image/')
? res.blob()
: null;
}
/** Draws every scene; records references or compares against them. */
async function runScenes({ record = false, only } = {}) {
const which = $('renderer').value;
const renderer = await RENDERERS[which]();
const results = [];
$('views').innerHTML = '';
for (const url of objectUrls) URL.revokeObjectURL(url);
objectUrls = [];
try {
for (const scene of REFERENCE_SCENES) {
if (only && !only.includes(scene.id)) continue;
const unsupported = sceneSupport(scene, renderer, ctx);
if (unsupported) {
results.push({ scene: scene.id, status: 'skip', detail: unsupported });
continue;
}
status(`${scene.id}…`);
await drawScene(renderer, scene, ctx, async (view, capture, settle) => {
const name = referenceName(scene.id, view.name);
const figure = document.createElement('figure');
$('views').append(figure);
if (settle.state !== 'settled') {
const detail = `${settle.state}: ${settle.blockers.join(', ')}`;
results.push({
scene: scene.id,
view: view.name,
status: 'fail',
detail,
});
figure.innerHTML = `<img src="${objectUrl(capture)}"><figcaption class="fail">${name}: ${escape(detail)}</figcaption>`;
return;
}
if (record) {
const res = await fetch(SAVE + name, {
method: 'PUT',
body: capture,
});
results.push({
scene: scene.id,
view: view.name,
status: res.ok ? 'recorded' : 'fail',
detail: res.ok ? undefined : `save ${res.status}`,
});
figure.innerHTML = `<img src="${objectUrl(capture)}"><figcaption>${name}: recorded</figcaption>`;
return;
}
const refBlob = await reference(name);
if (!refBlob) {
results.push({
scene: scene.id,
view: view.name,
status: 'fail',
detail: 'no reference',
});
figure.innerHTML = `<img src="${objectUrl(capture)}"><figcaption class="fail">${name}: no reference</figcaption>`;
return;
}
const [actual, expected] = await Promise.all([
pixels(capture),
pixels(refBlob),
]);
const diff = new Uint8ClampedArray(actual.data.length);
const outcome = compareImages(actual, expected, { diff });
results.push({
scene: scene.id,
view: view.name,
status: outcome.match ? 'pass' : 'fail',
diffFraction: outcome.diffFraction,
solidPixels: outcome.solidPixels,
mismatchedPixels: outcome.mismatchedPixels,
...(outcome.reason ? { detail: outcome.reason } : {}),
});
const diffUrl = outcome.reason
? ''
: await toUrl(new ImageData(diff, actual.width, actual.height));
figure.innerHTML = `<div class="row"><img src="${objectUrl(capture)}" title="${which}"><img src="${objectUrl(refBlob)}" title="reference"><img src="${diffUrl}" title="difference"></div>
<figcaption class="${outcome.match ? 'pass' : 'fail'}">${name}: ${outcome.solidPixels} solid, ${outcome.mismatchedPixels} unexcused, ${(outcome.diffFraction * 100).toFixed(3)}% differ ${outcome.reason ?? ''}</figcaption>`;
});
}
if (record) {
// A partial recording keeps the other scenes' entries.
const kept = only
? (await storedViews()).filter(
(view) => !only.some((id) => view.startsWith(`${id}-`)),
)
: [];
const recorded = results
.filter((r) => r.status === 'recorded')
.map((r) => referenceName(r.scene, r.view));
await fetch(SAVE + 'manifest.json', {
method: 'PUT',
body:
JSON.stringify(
{
renderer: which,
size: {
width: renderer.canvas.width,
height: renderer.canvas.height,
},
views: [...kept, ...recorded],
},
null,
2,
) + '\n',
});
}
} finally {
renderer.destroy();
status('');
}
return results;
}
$('logic').onclick = () => runLogic('stub');
$('logicCesium').onclick = () => runLogic('cesium');
$('check').onclick = () => runScenes();
$('record').onclick = () => runScenes({ record: true });
window.conformance = { runLogic, runScenes };
+94
View File
@@ -81,6 +81,100 @@ constructed component objects; the component instances themselves remain mutable
`ready` means all constructors have returned. Background feed activity and the `ready` means all constructors have returned. Background feed activity and the
standalone share-restoration result keep their existing separate contracts. standalone share-restoration result keep their existing separate contracts.
## Pointer input
`gods-eye-view/input` exports `inputFor(viewer)`, the scene's one input
dispatcher. Every layer and tool takes its pointer input from it, and so can
the caller: one `ScreenSpaceEventHandler` per scene, and a click picked once
and handed to every subscriber.
```js
import { inputFor } from 'gods-eye-view/input';
const input = inputFor(viewer);
const off = input.onClick((event) => {
if (event.ownerId === 'flights') showFlight(event.id);
});
// Later:
off();
```
`onClick` runs while no tool holds the pointer (the draw tool, the imagery
box), so a vertex placed on an aircraft is not also a click on it. Its event
reads lazily; a subscriber that reads nothing costs no pick:
| Member | What it is |
| ------------------ | -------------------------------------------------------------------- |
| `screenPosition` | Where the user clicked, in CSS pixels; for DOM and overlay hit-tests |
| `position` | Where that lands in the pick buffer (corrected under sensor styles) |
| `gesture` | `{ travelPx, durationMs }` of the press, to tell a click from a drag |
| `pick` | `scene.pick` at `position` |
| `hit` | `pick`, or on a coarse pointer the contact under a surface pick |
| `id`, `ownerId` | The hit's pick id and the layer that registered it, or `null` |
| `meta` | The hit's own metadata, when its pick carries any |
| `surface` | Whether the hit is the map itself |
| `drill(limit)` | `scene.drillPick`, shared by every subscriber of the click |
| `pickWindow(w, h)` | `scene.pick` over a `w`×`h` window around `position` |
| `ground()` | The world position under the click, or `null` over the sky |
`onPointer(type, fn, { lease })` subscribes to `down`, `move`, `up`, `click`
or `dblclick`; with a lease it runs only while that lease is current. A tool
takes its lease with `claimPointer(ownerId)` from the same export (and gives
it back with `releasePointer(lease)`), which also keeps every ambient
subscriber off its clicks; `isPointerFree()` and `pointerOwner()` report who
holds it. `suspendCamera(reason)` and `suppressStockClicks()` return their
own release: camera input and the viewer's stock select and double-click-track
come back when the last hold is released, so two holders cannot undo each
other. The dispatcher owns the camera controller's `enableInputs`: a direct
write (Cesium's camera flights make one when they start and end) is kept, but
reads true only while no hold is out. A caller-supplied `createScene` that creates its own viewer registers
`defer(() => inputFor(viewer).destroy())` right after it; the standalone scene
does this and returns the dispatcher as `scene.input`.
## Height queries
`gods-eye-view/queries/heights` exports `heightsFor(scene)`, the one place
height queries go. Layers, tools and the camera ask it instead of calling
`scene.sampleHeight`, `globe.getHeight`, `scene.clampToHeight` or the
`*MostDetailed` variants themselves, so a renderer that answers heights another
way plugs in without touching them.
```js
import { heightsFor } from 'gods-eye-view/queries/heights';
const heights = heightsFor(viewer.scene);
const ground = heights.now({ lon, lat }, { exclude: [ownMarkers] });
if (ground !== null) place(ground);
```
| Member | What it answers |
| ----------------------------- | ------------------------------------------------------------------------- |
| `now(point, opts)` | Height in metres from what is loaded, or `null` |
| `clampNow(position, opts)` | The `Cartesian3` moved onto the drawn surface, or `null` |
| `batch(points, opts)` | A promise of heights once the most detailed tiles stream; `null` per miss |
| `clampBatch(positions, opts)` | A promise of clamped copies of the positions; `null` per miss |
| `supported()` | `{ surface, clamp }`: whether the drawn surface can be queried at all |
A point is `{ lon, lat }` in degrees or a `Cesium.Cartographic` in radians.
`against: 'surface'` (the default) reads what is drawn, 3D tiles and globe;
`against: 'terrain'` reads the loaded terrain tiles alone. `exclude` lists the
primitives and entities a query sees through. Every miss is `null`, including
a Cesium throw for missing depth-texture support, and every caller treats it
as one. Cesium answers `now()` synchronously from the scene, so it costs what
the direct call did.
## Cesium internals
Core reaches private Cesium fields only through
`src/render/cesium/internals.js`, one named helper per field (the main and
offscreen pick views, a billboard's collection, a cluster's point collection,
the widget's render-loop latch, the WebGL context and whether a camera flight
is under way). `internals.test.mjs` fails when the installed Cesium stops
declaring one of them, and when a private field is reached anywhere else.
Imagery that rations its tile requests states the policy as a hook with
`withTileRequestHook(provider, { requestImage, credit })` from
`src/render/cesium/tileRequests.js`, which leaves the provider untouched.
## Standalone wiring ## Standalone wiring
`src/main.js` reads the existing browser configuration and starts `src/main.js` reads the existing browser configuration and starts
+2 -1
View File
@@ -33,12 +33,13 @@ export inventory. Use declared exports rather than reaching into internal files.
| `src/layers/<family>/` | Source acquisition, records and Cesium resources with explicit controller/renderer owners | | `src/layers/<family>/` | Source acquisition, records and Cesium resources with explicit controller/renderer 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/services/` | Supplied application operations; scene construction owns caches and cancellation | | `src/services/` | Supplied application operations; scene construction owns caches and cancellation |
| `src/render/` | The renderer contract, the stub renderer, the conformance suite and Cesium renderer pieces |
| `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 |
| `server/standalone/` | Environment, local settings writes and server composition | | `server/standalone/` | Environment, local settings writes and server composition |
See [application construction](APPLICATION.md) and the See [application construction](APPLICATION.md) and the
[infrastructure contract](INFRASTRUCTURE-LAYERS.md) for construction interfaces. [infrastructure contract](INFRASTRUCTURE-LAYERS.md) for construction interfaces, and the [renderer contract](RENDERER-CONTRACT.md) for what a renderer provides.
## Import direction gates ## Import direction gates
+54 -22
View File
@@ -1414,7 +1414,7 @@ and a Denver coordinate underground. Search now resolves the application's
ground-floor elevation (bounded wait, same ellipsoidal datum) before framing and ground-floor elevation (bounded wait, same ellipsoidal datum) before framing and
warms the cell; after landing, `src/cameraGroundGuard.js` samples the rendered warms the cell; after landing, `src/cameraGroundGuard.js` samples the rendered
surface under the target and the camera once tiles stream in and lifts the eye surface under the target and the camera once tiles stream in and lifts the eye
to 120 m clearance, holding heading and pitch. Navigation ownership changes (Director, tracking, keyboard/UI controls and direct camera flights), layer/app teardown, and pointer/wheel gestures cancel the check and remove its listeners. `scripts/qa-context-arrivals.mjs` types Camp Mabry to 120 m clearance, holding heading and pitch. Navigation ownership changes (Director, tracking, keyboard/UI controls), any other caller moving the camera or starting a flight before a retry, layer/app teardown, and pointer/wheel gestures cancel the check and remove its listeners; the guard compares the pose it started on rather than wrapping camera methods. `scripts/qa-context-arrivals.mjs` types Camp Mabry
and a Denver coordinate and requires at least 60 m clearance. and a Denver coordinate and requires at least 60 m clearance.
## Layer panel ownership ## Layer panel ownership
@@ -1473,11 +1473,14 @@ remain unchanged.
## Visual effects ownership ## Visual effects ownership
VisualEffects owns style-stage creation, crossfades, animation scheduling, bloom VisualEffects keeps the records of the styles, their crossfades, bloom and
and sharpen. Display supplies actions and renders settings; effect execution has sharpen, and drives them through the renderer's effects port: it defines each
no DOM dependency. Existing presets and the 500 ms transition remain unchanged. preset there as a style, and the renderer runs the passes and animates the
Stopping the controller revokes animation before asynchronous UI teardown; final animated ones. Display supplies actions and renders settings; effect execution
destruction removes its stages and restores the borrowed bloom configuration. has no DOM dependency. Existing presets and the 500 ms transition remain
unchanged. Stopping the controller revokes animation before asynchronous UI
teardown; final destruction removes its styles and turns bloom and sharpen off,
and the renderer restores the scene's bloom configuration when it goes.
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,
@@ -2396,9 +2399,9 @@ which embeds the MAP_KEY.
> so a turned-off layer's entities stayed reachable (Data Centers kept 116 > so a turned-off layer's entities stayed reachable (Data Centers kept 116
> MiB). `src/sceneCommandRelease.js`, installed in `src/app/tools.js`, > MiB). `src/sceneCommandRelease.js`, installed in `src/app/tools.js`,
> requests one frame through `governorRequestRender` after every layer > requests one frame through `governorRequestRender` after every layer
> disable and then trims those arrays. It reads private Cesium fields; > disable and then trims those arrays. It reads private Cesium fields through
> `src/sceneCommandRelease.test.mjs` pins them and fails if Cesium renames > `src/render/cesium/internals.js`; `src/sceneCommandRelease.test.mjs` pins
> them or changes which passes it truncates. > them and fails if Cesium renames them or changes which passes it truncates.
> **2026-08-08 — performance waves 1+2:** the app idles via an explicit render > **2026-08-08 — performance waves 1+2:** the app idles via an explicit render
> governor (`src/renderGovernor.js` — hold/release from every per-frame > governor (`src/renderGovernor.js` — hold/release from every per-frame
@@ -3463,8 +3466,14 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> celestial ring, annotations SVG, missions frame tick, and traffic). > celestial ring, annotations SVG, missions frame tick, and traffic).
> - **World-label accounting:** All intended label/card migrations use the > - **World-label accounting:** All intended label/card migrations use the
> shared host. The former native `LabelGraphics` exception for cable reference > shared host. The former native `LabelGraphics` exception for cable reference
> labels was retired on 2026-08-18; cable text may render over tile geometry, > labels was retired on 2026-08-18; cable text may render over tile geometry.
> so **zero native world-label creation sites remain**. The mission replay DOM vehicle, awareness > The Directions A/B letters (`directions-markers` source) and data-pack media
> labels (a pooled `data-pack-labels:<n>` source per pack resource, through
> the pack handle's `label`) moved to the host on 2026-11-04, and
> `src/noCesiumLabels.test.mjs` no longer keeps an allow-list, so **zero
> native world-label creation sites remain**. A media pack shown without an
> `overlayHost` fails to load rather than drawing a native label. The mission
> replay DOM vehicle, awareness
> compass, annotation SVG callouts, and host-owned detection isolation target > compass, annotation SVG callouts, and host-owned detection isolation target
> are explicit exceptions; the awareness ring and cockpit contact pips are > are explicit exceptions; the awareness ring and cockpit contact pips are
> non-text out-of-scope surfaces. Annotation callout text, geometry, leaders, > non-text out-of-scope surfaces. Annotation callout text, geometry, leaders,
@@ -3513,7 +3522,7 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> ambient-label host source. Its getter reads the already-propagated point > ambient-label host source. Its getter reads the already-propagated point
> cache, preserving the 1 Hz fleet epoch and eliminating the former second ISS > cache, preserving the 1 Hz fleet epoch and eliminating the former second ISS
> propagation. Satellite tracking continues to publish exactly one protected > propagation. Satellite tracking continues to publish exactly one protected
> tracked-lane card through `gevLabelModel` and `_trackedDisplayCached`; the > tracked-lane card through its entity record's `labelModel` and `_trackedDisplayCached`; the
> tracked entity is point-only. Tracking ISS suppresses the ambient entry and > tracked entity is point-only. Tracking ISS suppresses the ambient entry and
> untracking restores it, preventing duplicate ISS text. Disable, orbit-text > untracking restores it, preventing duplicate ISS text. Disable, orbit-text
> preference changes, catalog rebuild, and destroy clear/hide the source. > preference changes, catalog rebuild, and destroy clear/hide the source.
@@ -3528,7 +3537,7 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> - **Phase 5 tracked civil aircraft label:** A tracked flight's Cesium entity > - **Phase 5 tracked civil aircraft label:** A tracked flight's Cesium entity
> is a billboard-only camera target with no native label. The flight source > is a billboard-only camera target with no native label. The flight source
> retains callsign/registration fallback, flight-level/altitude, speed, stale > retains callsign/registration fallback, flight-level/altitude, speed, stale
> state, airline/type, and plausible-route formatting in `gevLabelModel`; the > state, airline/type, and plausible-route formatting in the entity record's `labelModel`; the
> one protected tracked-lane host card renders that complete model. Its > one protected tracked-lane host card renders that complete model. Its
> position getter reads only `_trackedDisplayCached`, the same authoritative > position getter reads only `_trackedDisplayCached`, the same authoritative
> Cartesian already consumed by the tracked visual and camera, and never > Cartesian already consumed by the tracked visual and camera, and never
@@ -3635,8 +3644,11 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> - **Tracked-readout ownership:** `trackedReadout.js` is now a presentation-model > - **Tracked-readout ownership:** `trackedReadout.js` is now a presentation-model
> bridge only; it owns no canvas, projection, post-render listener, layout, > bridge only; it owns no canvas, projection, post-render listener, layout,
> keyhole fade, or paint path. Civilian flights, military flights, satellites, > keyhole fade, or paint path. Civilian flights, military flights, satellites,
> and mapped installations write explicit `gevLabelModel` objects and expose > and mapped installations write explicit `labelModel` objects and
> `gevDisplayPosition` getters backed by their layer-owned frame/display cache. > `displayPosition` getters, backed by their layer-owned frame/display cache,
> into Core's entity records (`src/data/entityRecords.js`), never onto the
> Cesium entity. A layer's private per-entity state goes in its own store from
> `createEntityStore()` (ALPR markers: `src/layers/alpr/markerState.js`).
> Selected AIS vessels continue using the vessel source's protected selected > Selected AIS vessels continue using the vessel source's protected selected
> card, so they do not create a duplicate tracked readout. The host registers > card, so they do not create a duplicate tracked readout. The host registers
> the active readout in the protected tracked lane with a zero ambient quota; > the active readout in the protected tracked lane with a zero ambient quota;
@@ -4394,11 +4406,11 @@ the same navigation authority as every other camera destination.
given; re-arming for the other endpoint reuses that lease rather than asking given; re-arming for the other endpoint reuses that lease rather than asking
for a second claim, and every release names the lease, so a superseded instance for a second claim, and every release names the lease, so a superseded instance
can never free the claim its replacement holds. The claim is held until can never free the claim its replacement holds. The claim is held until
the click that uses it has finished being dispatched — every layer binds its the click that uses it has finished being dispatched: the input dispatcher
own handler to the same canvas and they all run inside one browser event, so a checks the claim once per click, before any subscriber runs, but DOM canvas
claim dropped the instant the endpoint is placed would hand the rest of that listeners (the evidence cards) still see the same browser event afterwards, so
same click to the ambient handlers bound after this one. It is returned on the a claim dropped the instant the endpoint is placed would hand them that click.
next tick after placement, and immediately on cancel, Escape, CLEAR, disable It is returned on the next tick after placement, and immediately on cancel, Escape, CLEAR, disable
and destroy; if another tool already holds it nothing is armed and the row says and destroy; if another tool already holds it nothing is armed and the row says
so. Maneuver-dot selection is an ordinary ambient handler and yields while any so. Maneuver-dot selection is an ordinary ambient handler and yields while any
tool holds the pointer. FLY runs through the UI shell's immediate-navigation tool holds the pointer. FLY runs through the UI shell's immediate-navigation
@@ -5628,9 +5640,9 @@ request viewbox and continues searching without that geographic preference.
- **Measurements and centres are computed on unwrapped longitudes** (`unwrapLongitudes` / `wrapLongitude` in `drawMode.js`, used by `ringAreaM2`, `ringCentroid` and the engine's manual-area anchor). A ~50,000 m² rectangle straddling the antimeridian reads its 0.002° width as 359.998° under raw arithmetic: it measured thousands of square kilometres and anchored on the Greenwich meridian, half a world from the shape. Path length was already safe — haversine is periodic in the longitude delta — and a test pins that so a "fix" cannot break it. - **Measurements and centres are computed on unwrapped longitudes** (`unwrapLongitudes` / `wrapLongitude` in `drawMode.js`, used by `ringAreaM2`, `ringCentroid` and the engine's manual-area anchor). A ~50,000 m² rectangle straddling the antimeridian reads its 0.002° width as 359.998° under raw arithmetic: it measured thousands of square kilometres and anchored on the Greenwich meridian, half a world from the shape. Path length was already safe — haversine is periodic in the longitude delta — and a test pins that so a "fix" cannot break it.
- **Bounds.** One shape holds at most `MAX_VERTICES` = 512 points; two clicks within 0.5 m are one vertex (that is what makes a double-click finish without leaving a stray point); a vertex that is not a finite on-globe coordinate is refused. A shape that encloses nothing is refused rather than placed: three collinear points are not an area and a line under a metre long is not a line, and the hint says which. - **Bounds.** One shape holds at most `MAX_VERTICES` = 512 points; two clicks within 0.5 m are one vertex (that is what makes a double-click finish without leaving a stray point); a vertex that is not a finite on-globe coordinate is refused. A shape that encloses nothing is refused rather than placed: three collinear points are not an area and a line under a metre long is not a line, and the hint says which.
- Drawn-area measures round before choosing m², ha or km², so 9,999.6 m² displays as `1.0 ha`, not `10000 m²`, and 999,960 m² as `1.00 km²`, not `100.0 ha`. - Drawn-area measures round before choosing m², ha or km², so 9,999.6 m² displays as `1.0 ha`, not `10000 m²`, and 999,960 m² as `1.00 km²`, not `100.0 ha`.
- Pointer ownership: while a session is open the tool holds the single pointer claim (`claimPointer('draw')`, `src/data/inputOwnership.js`) and **every** ambient scene selection handler — the shared tracking click gesture behind flights, military and CCTV, plus vessels, satellites, launches, fires, installations, bikeshare, radio, submarine cables, the bundled GeoJSON layers and the CCTV calibration gizmo — returns before picking. A vertex over an aircraft is a vertex, not a selection. - Pointer ownership: while a session is open the tool holds the single pointer claim (`claimPointer('draw')`, `src/data/inputOwnership.js`), and the scene's input dispatcher (`src/input/dispatcher.js`) hands its clicks, moves and double clicks to the draw tool's leased subscriptions only: **every** ambient subscriber — flights, military, CCTV and its calibration gizmo, vessels, satellites, launches, fires, installations, bikeshare, radio, submarine cables, the bundled GeoJSON layers — is skipped for that event before anything is picked. A vertex over an aircraft is a vertex, not a selection.
- **The claim is a LEASE.** `claimPointer(owner)` returns an opaque token or `null`; `releasePointer(lease)` takes that token. A second claim fails even under the same owner name, because two live instances of one tool — an old one mid-teardown and its replacement — are two owners; releasing by name let the old instance free the claim its successor was holding, and every layer started selecting through the live session's vertices. A superseded lease now releases nothing. - **The claim is a LEASE.** `claimPointer(owner)` returns an opaque token or `null`; `releasePointer(lease)` takes that token. A second claim fails even under the same owner name, because two live instances of one tool — an old one mid-teardown and its replacement — are two owners; releasing by name let the old instance free the claim its successor was holding, and every layer started selecting through the live session's vertices. A superseded lease now releases nothing.
- **Cesium's own click actions are borrowed too.** The Viewer binds a selecting LEFT_CLICK and a tracking LEFT_DOUBLE_CLICK on its own handler, outside every layer and therefore outside the shared claim — guarding the layers alone still left a drawn vertex selecting the entity under it. Both are removed for the session and restored, as the same function objects, when it ends or the tool is destroyed. - **Cesium's own click actions are suppressed too.** The Viewer binds a selecting LEFT_CLICK and a tracking LEFT_DOUBLE_CLICK on its own handler, outside every layer and therefore outside the shared claim — guarding the layers alone still left a drawn vertex selecting the entity under it. The tool takes the dispatcher's `suppressStockClicks()` hold for the session; both actions come back, as the same function objects, when the last hold is released (the imagery box takes the same hold).
- **Enter belongs to whatever has the keyboard.** The finish key is taken only from the canvas and the page body. With a control focused — Clear, a shape button — Enter activates that control, as a keyboard user expects; it does not quietly finish the shape. - **Enter belongs to whatever has the keyboard.** The finish key is taken only from the canvas and the page body. With a control focused — Clear, a shape button — Enter activates that control, as a keyboard user expects; it does not quietly finish the shape.
- Pure half: `src/annotations/drawMode.js` (session, minimum vertices, double-click de-dup at 0.5 m, vertex ceiling, degenerate-geometry refusal, ring closing, dateline-safe length/area/centroid, hint text). Regression surface: `src/annotations/drawMode.test.mjs` and `src/data/inputOwnership.test.mjs` (pure), `src/annotations/drawToolBehaviour.test.mjs` (the tool driven against DOM and viewer doubles — leases, borrowed input actions, teardown, the Enter key, preview heights), `src/annotations/drawTool.test.mjs` (structural: where files live, which ids the markup carries, what the docs may claim), and the rendered harness `scripts/qa-draw-tool.mjs`. - Pure half: `src/annotations/drawMode.js` (session, minimum vertices, double-click de-dup at 0.5 m, vertex ceiling, degenerate-geometry refusal, ring closing, dateline-safe length/area/centroid, hint text). Regression surface: `src/annotations/drawMode.test.mjs` and `src/data/inputOwnership.test.mjs` (pure), `src/annotations/drawToolBehaviour.test.mjs` (the tool driven against DOM and viewer doubles — leases, borrowed input actions, teardown, the Enter key, preview heights), `src/annotations/drawTool.test.mjs` (structural: where files live, which ids the markup carries, what the docs may claim), and the rendered harness `scripts/qa-draw-tool.mjs`.
- The annotation tree is a declared ownership group: `./annotations` → `src/annotations/index.js` in `package.json`, with its module graph listed in `scripts/package-boundaries.json`, so an annotation module reaching outside that graph is a gate failure rather than a surprise in a layer's bundle. `drawMode.js`, `drawTool.js` and their tests are in `scripts/format-scope.json`. - The annotation tree is a declared ownership group: `./annotations` → `src/annotations/index.js` in `package.json`, with its module graph listed in `scripts/package-boundaries.json`, so an annotation module reaching outside that graph is a gate failure rather than a surprise in a layer's bundle. `drawMode.js`, `drawTool.js` and their tests are in `scripts/format-scope.json`.
@@ -5985,6 +5997,26 @@ easier to meet (detection is now on more often), but does not create it.
above and the DISPLAY-rail entry below (was `OFF`). above and the DISPLAY-rail entry below (was `OFF`).
- Style shader starting params: CRT/NVG/FLIR pixelation `1.2` (just above the native `1.0` floor); thermal/FLIR ships an optional Ironbow "Predator" palette (`palette` uniform, default `0` = accurate grayscale). - Style shader starting params: CRT/NVG/FLIR pixelation `1.2` (just above the native `1.0` floor); thermal/FLIR ships an optional Ironbow "Predator" palette (`palette` uniform, default `0` = accurate grayscale).
## Renderer contract
`src/render/contract/` types the renderer contract, version `0`: nine ports
(frame, globe, draw, camera, query, input, effects, credits and quality),
plus the renderer itself. `PORTS` lists every member at run time, and
`contract.test.mjs` keeps that list and the types in step. The application
does not yet draw through the contract: it still drives Cesium directly.
`src/render/stub/` is a renderer that draws nothing, with the default
camera's exact maths. `src/render/conformance/` is the suite: logic checks
in Node, plus camera traces recorded from Cesium's own `Camera` into
`goldens/camera.json`. It also holds the reference scenes, whose views are
checked against `goldens/visual/` on the `/dev/conformance/` page. See
[RENDERER-CONTRACT.md](RENDERER-CONTRACT.md).
`src/geo/` (export `./geo`) is the renderer-neutral maths: WGS84, ECEF and
geodetic conversions, surface normals and east-north-up frames, as plain
`{x, y, z}` objects. It follows Cesium's formulas step for step, and
`geo.test.mjs` checks it against Cesium. The stub renderer's maths is built
on it.
## Operational Notes ## Operational Notes
- **Earthquake discs are STATIC geometry.** Every quake is a `CLAMP_TO_GROUND` - **Earthquake discs are STATIC geometry.** Every quake is a `CLAMP_TO_GROUND`
+5
View File
@@ -95,6 +95,11 @@ 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,
not scene document fields. Without registrations, referenced sources fail closed. not scene document fields. Without registrations, referenced sources fail closed.
A standalone `SceneDirector` also passes `dataPacks.overlayHost`, the
world-overlay host that draws pack world text such as a media pack's label (an
object with `setEntries(sourceId, entries)` and `clearSource(sourceId)`);
without it a media pack fails to load. `createApplicationTools` passes the
application's shared host.
The portable session receives format adapters separately. Each adapter receives The portable session receives format adapters separately. Each adapter receives
`{ pack, asset, anchors, signal }` and returns a resource with an idempotent `{ pack, asset, anchors, signal }` and returns a resource with an idempotent
+171
View File
@@ -0,0 +1,171 @@
# Renderer contract
The renderer contract is what the application asks of a 3D renderer. A
renderer other than the default Cesium one can draw the same application if
it meets the contract. The contract is version `0`. Today the application
still drives Cesium directly; later work moves the layers onto the contract
one family at a time.
The package exports three modules:
| Export | What it is |
| ---------------------------------- | ------------------------------------------------------------------------------ |
| `gods-eye-view/render/contract` | The types, one `.d.ts` per port, plus the run-time member list and shape check |
| `gods-eye-view/render/stub` | A renderer that draws nothing, for tests |
| `gods-eye-view/render/conformance` | The conformance suite, reference scenes and visual comparison |
## Ports
A renderer is created by a `RendererPlugin` (`{ id, create(options) }`). It
exposes nine ports, each typed in its own file in `src/render/contract/`:
| Port | File | Covers |
| --------- | -------------- | ------------------------------------------------------------------------------ |
| `frame` | `frame.d.ts` | The render-on-demand loop, frame phases, continuous holds, scene time |
| `globe` | `globe.d.ts` | Base (photoreal, terrain, vector, imagery), imagery layers, split, sky |
| `draw` | `draw.d.ts` | Draw sets: icons, points, lines, shapes, models, quads, meshes, fields, agents |
| `camera` | `camera.d.ts` | Pose, views, flights, `lookAt`, follow, user controls, events, matrices |
| `query` | `query.d.ts` | Picks, projection, rays, heights |
| `input` | `input.d.ts` | Pointer events with their pick made, leases, stock behaviours |
| `effects` | `effects.d.ts` | Screen styles, bloom, sharpen, screen modulation |
| `credits` | `credits.d.ts` | Data credits owed |
| `quality` | `quality.d.ts` | Quality knobs, device facts, frame signals, benchmark |
The renderer itself (`renderer.d.ts`) adds capture, pixel reads, settling,
resize, context loss and `destroy`. `common.d.ts` holds the shared shapes
(ECEF positions, colours, listener semantics) and the capability list.
The rules that hold everywhere:
- **Core keeps its own records.** It never reads a renderer to learn its own
state, only what the renderer measures: picks, depth, heights and
readiness.
- **Capabilities.** An optional member (`?`) exists only with the capability
its documentation names first. A required member that names a capability
always exists, and without the capability it answers null or empty.
Quality support is listed by `quality.knobs()`, not by capabilities.
- **Heights are physical.** All heights are metres above the ellipsoid,
unexaggerated. Only drawing follows exaggerated terrain.
- **Listeners** run in subscription order. A listener removed during
delivery does not run; one added during delivery runs from the next event.
A listener that throws is reported to the renderer's `error` event, or to
the platform's `reportError` when nothing listens for errors. The other
listeners, and the frame, still run.
- **The frame order is fixed:** camera flight → input → camera `changed` →
follow → `preUpdate` → move detection → `preRender` → render →
`postRender`, the default renderer's own. A camera move made by follow or
in `preUpdate` is reported by `changed` on the next frame.
- **Leases:** a tool takes the pointer with `claimPointer`. With `grab`, it
takes only the presses it grabs on `down`; the rest, and wheel and pinch,
stay with the camera and everyone else.
`src/render/contract/index.js` lists every member of every port in `PORTS`,
along with `CAPABILITIES`, `FRAME_STEPS` and `QUALITY_KEYS`.
`checkRendererShape(renderer)` names each required member that is missing,
and each capability member present without its capability.
`contract.test.mjs` type-checks the contract under strict TypeScript, and
fails when `PORTS` and the types disagree on a single member.
## The stub renderer
`createStubRenderer({ capabilities, viewport, time })` implements every port
in memory and draws nothing. It has:
- the default camera's exact maths: views, `lookAt`, flights with their
height arc and easing, `changed` thresholds and follows;
- picks against what was drawn, or a scripted table;
- scripted heights;
- a virtual clock that nothing advances on its own.
`renderer.testing` drives it with `tick()`, `advance(ms)`, `pointer(type, x, y)`,
`scriptPicks`, `setHeights` and `cacheHeight`, and records its calls.
A listener that throws with nothing subscribed to `error` goes to
`reportError`; Node has none, so the stub rethrows it on a microtask and
`node:test` fails the running test. A test that expects a throw subscribes
to `error`.
The stub passes the whole conformance suite (`src/render/stub/stub.test.mjs`).
## Conformance suite
`runConformance(harness)` runs every check against a renderer through the
contract alone and reports each one as passed, failed or skipped. It runs in
Node and in a browser page. A harness tells the suite how to drive the
renderer under test:
- `create({ viewport, capabilities })`;
- `frame(r)`: one frame, 1/60 s on a virtual clock;
- `now(r)` and `toEcef(p)`;
- `tolerance` and `flightPaths`.
Optional members widen what is checked: `pointer` for synthetic input,
`scriptHeights`, and `assets.model`.
There are two layers of checks.
**Logic checks** run in CI. They cover:
- the shape check;
- listener semantics and the frame loop;
- draw-set bookkeeping, picks and the `meta` round-trip;
- projection, rays and heights;
- leases and click picks;
- imagery order, sky, styles, credits and quality knobs;
- the camera traces.
**Camera traces.** `cameraScripts.js` lists camera scripts in the contract's
terms. `scripts/record-camera-goldens.mjs` runs them on Cesium's own
`Camera`, headless, on a virtual clock, and writes
`src/render/conformance/goldens/camera.json`. A renderer under test must
match each recorded pose:
- within 1 mm in position, 1e-9 in unit vectors and angles, and 1e-3 px on
screen;
- with every flight ending within one frame of the recording.
`src/render/cesium/cameraGoldens.test.mjs` fails when the installed Cesium
no longer records the committed traces. After a Cesium upgrade or a script
change, run `node scripts/record-camera-goldens.mjs` and review the diff.
**Visual checks** run in the browser. The reference scenes
(`src/render/conformance/scenes.js`) are built from contract calls only:
- one scene per primitive family;
- a city scene;
- a calibration path from orbit down to the street.
The scenes load nothing from the network.
`npm run dev`, then open `/dev/conformance/`. The page can:
- **Run logic suite (stub)** runs the logic checks in the browser.
- **Record references** draws every scene the chosen renderer supports.
It saves each view to `src/render/conformance/goldens/visual/`, through a
dev-server-only endpoint.
- **Check scenes** draws the scenes again and compares each capture with its
reference. Pixels are compared as pixelmatch compares them: by YIQ
difference (threshold 0.1), with antialiasing in either image excused. A
capture matches when at most 250 pixels are unexcused and at most 2 are
solid (a differing pixel whose four neighbours differ too). Between them
the two catch a dropped 4 px point, a dropped 2 px line and a 1 px shift
of the view, and pass any MSAA setting. A change nowhere 3 px thick (a
thin line gone, an item moved a pixel or two) that covers at most 250 px
passes. `visual.js` has the measurements.
The page shows the capture, the reference and the difference side by side.
`window.conformance.runScenes()` and `runLogic()` do the same from a script.
Browser checks use the chrome-devtools MCP against a page in its own window.
The Cesium scene renderer (`src/render/cesium/sceneRenderer.js`) draws the
icon, point, line, shape, model and city scenes, and the references were
recorded with it. It has only the members the scenes need. The quad, mesh,
field, agent and orbit scenes have no reference yet. Each family's reference
is recorded when the Cesium renderer gains that family.
Some behaviour is only drawn, so the logic checks cannot see it: course
easing (`courseBlendMs`, `courseGlide`), and a hidden row keeping its eased
course. The stub keeps no drawn course. These wait for scenes that capture
an icon mid-turn. Height queries that count a set
(`includeInHeightQueries`) are not checked either: the stub's heights are
scripted, so it does not model them. Nor are video frames (a check needs a
video), `fetchTile` (the stub fetches nothing) or what clouds and water
draw.
+15
View File
@@ -32,6 +32,7 @@
"prettier": "3.9.6", "prettier": "3.9.6",
"puppeteer": "^25.16.0", "puppeteer": "^25.16.0",
"sharp": "^0.35.4", "sharp": "^0.35.4",
"typescript": "5.9.3",
"vite": "^6.4.4", "vite": "^6.4.4",
"vite-plugin-cesium": "^1.2.23", "vite-plugin-cesium": "^1.2.23",
"ws": "^8.21.0" "ws": "^8.21.0"
@@ -3545,6 +3546,20 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "8.9.0", "version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
+9 -1
View File
@@ -79,6 +79,7 @@
"prettier": "3.9.6", "prettier": "3.9.6",
"puppeteer": "^25.16.0", "puppeteer": "^25.16.0",
"sharp": "^0.35.4", "sharp": "^0.35.4",
"typescript": "5.9.3",
"vite": "^6.4.4", "vite": "^6.4.4",
"vite-plugin-cesium": "^1.2.23", "vite-plugin-cesium": "^1.2.23",
"ws": "^8.21.0" "ws": "^8.21.0"
@@ -227,6 +228,9 @@
"./standalone/catalog": "./src/standalone/catalog.js", "./standalone/catalog": "./src/standalone/catalog.js",
"./application/layers": "./src/app/constructCatalog.js", "./application/layers": "./src/app/constructCatalog.js",
"./application/operations": "./src/app/operations.js", "./application/operations": "./src/app/operations.js",
"./data/entity-records": "./src/data/entityRecords.js",
"./input": "./src/input/dispatcher.js",
"./queries/heights": "./src/queries/heights.js",
"./data/lifecycle": "./src/data/lifecycle.js", "./data/lifecycle": "./src/data/lifecycle.js",
"./ui/navigation": "./src/ui/navigationController.js", "./ui/navigation": "./src/ui/navigationController.js",
"./ui/share-restoration": "./src/ui/shareRestoration.js", "./ui/share-restoration": "./src/ui/shareRestoration.js",
@@ -290,6 +294,10 @@
"./tools/panel": "./src/tools/panel.js", "./tools/panel": "./src/tools/panel.js",
"./tools/services": "./src/tools/services.js", "./tools/services": "./src/tools/services.js",
"./view": "./src/view/index.js", "./view": "./src/view/index.js",
"./agent/world-packet": "./src/agent/worldPacket.js" "./agent/world-packet": "./src/agent/worldPacket.js",
"./geo": "./src/geo/index.js",
"./render/contract": "./src/render/contract/index.js",
"./render/stub": "./src/render/stub/index.js",
"./render/conformance": "./src/render/conformance/index.js"
} }
} }
+26 -38
View File
@@ -1,11 +1,8 @@
#!/usr/bin/env node #!/usr/bin/env node
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import { advanceSpriteFocus } from '../src/data/focusDeemphasis.js';
advanceSpriteFocus, import { aircraftTreatmentAlpha } from '../src/data/aircraftRecession.js';
} from '../src/data/focusDeemphasis.js'; import { createIconSet } from '../src/data/iconSet.js';
import {
applyAircraftBillboardTreatment,
} from '../src/data/aircraftRecession.js';
if (typeof global.gc !== 'function') { if (typeof global.gc !== 'function') {
throw new Error('focus-allocation-check.mjs requires node --expose-gc'); throw new Error('focus-allocation-check.mjs requires node --expose-gc');
@@ -66,17 +63,14 @@ const focusInput = {
target, target,
}; };
const baseColor = { withAlpha: (alpha) => ({ alpha }) };
const billboard = { scale: 1, color: { alpha: 1 } };
const aircraftInput = { const aircraftInput = {
billboard,
baseScale: 1,
baseAlpha: 1, baseAlpha: 1,
baseColor,
focusFactor: 1, focusFactor: 1,
cameraDistanceM: 100, cameraDistanceM: 100,
cameraHeightM: 1000, cameraHeightM: 1000,
}; };
const icons = createIconSet({ capacity: 4 });
const row = icons.add('sprite', {});
const baseline = bytesPerCall(() => focusInput.nowMs); const baseline = bytesPerCall(() => focusInput.nowMs);
const focus = netOfLoopBaseline( const focus = netOfLoopBaseline(
@@ -84,30 +78,22 @@ const focus = netOfLoopBaseline(
baseline, baseline,
); );
const aircraft = netOfLoopBaseline( const aircraft = netOfLoopBaseline(
bytesPerCall(() => applyAircraftBillboardTreatment(aircraftInput).alpha), bytesPerCall(() => aircraftTreatmentAlpha(aircraftInput)),
baseline, baseline,
); );
const productionSpriteTick = netOfLoopBaseline( const productionSpriteTick = netOfLoopBaseline(
bytesPerCall(() => { bytesPerCall(() => {
// Match the hot civilian/military sprite path: the caller constructs fresh // Match the hot icon-set focus pass: the caller constructs a fresh option
// option literals, consumes the shared focus result immediately, and then // literal, consumes the shared focus result immediately and writes it to
// constructs the composed aircraft-treatment options for the same sprite. // the row's focus column (the shader composes it with limb recession).
const focusResult = advanceSpriteFocus(sprite, { const focusResult = advanceSpriteFocus(sprite, {
screenPosition: focusInput.screenPosition, screenPosition: focusInput.screenPosition,
cameraDistance: focusInput.cameraDistance, cameraDistance: focusInput.cameraDistance,
nowMs: focusInput.nowMs, nowMs: focusInput.nowMs,
target, target,
}); });
const treatment = applyAircraftBillboardTreatment({ icons.setFocus(row, focusResult.factor);
billboard, return focusResult.factor;
baseScale: aircraftInput.baseScale,
baseAlpha: aircraftInput.baseAlpha,
baseColor,
focusFactor: focusResult.factor,
cameraDistanceM: aircraftInput.cameraDistanceM,
cameraHeightM: aircraftInput.cameraHeightM,
});
return focusResult.factor + treatment.alpha;
}), }),
baseline, baseline,
); );
@@ -118,22 +104,24 @@ assert.ok(
); );
assert.ok( assert.ok(
aircraft.roundedMedian <= PURE_CALL_BUDGET, aircraft.roundedMedian <= PURE_CALL_BUDGET,
`applyAircraftBillboardTreatment allocated ${aircraft.rawMedian.toFixed(3)} B/call median`, `aircraftTreatmentAlpha allocated ${aircraft.rawMedian.toFixed(3)} B/call median`,
); );
assert.ok( assert.ok(
productionSpriteTick.roundedMedian <= PRODUCTION_SPRITE_TICK_BUDGET, productionSpriteTick.roundedMedian <= PRODUCTION_SPRITE_TICK_BUDGET,
`production sprite tick allocated ${productionSpriteTick.rawMedian.toFixed(3)} B/call median`, `production sprite tick allocated ${productionSpriteTick.rawMedian.toFixed(3)} B/call median`,
); );
console.log(JSON.stringify({ console.log(
callsPerTrial: CALLS, JSON.stringify({
trials: TRIALS, callsPerTrial: CALLS,
budgets: { trials: TRIALS,
pureCall: PURE_CALL_BUDGET, budgets: {
productionSpriteTick: PRODUCTION_SPRITE_TICK_BUDGET, pureCall: PURE_CALL_BUDGET,
}, productionSpriteTick: PRODUCTION_SPRITE_TICK_BUDGET,
loopBaseline: baseline, },
advanceSpriteFocus: focus, loopBaseline: baseline,
applyAircraftBillboardTreatment: aircraft, advanceSpriteFocus: focus,
productionSpriteTick, aircraftTreatmentAlpha: aircraft,
})); productionSpriteTick,
}),
);
+47 -1
View File
@@ -43,6 +43,25 @@
"scripts/build-admin-packs.mjs", "scripts/build-admin-packs.mjs",
"scripts/qa-admin-outlines.mjs", "scripts/qa-admin-outlines.mjs",
"src/data/inputOwnership.test.mjs", "src/data/inputOwnership.test.mjs",
"src/input/dispatcher.test.mjs",
"src/input/testDoubles.mjs",
"src/input/sensorPick.js",
"src/input/dispatcher.js",
"src/queries/heights.js",
"src/render/cesium/internals.js",
"src/render/cesium/cameraInput.js",
"src/render/cesium/ctrlWheelZoom.js",
"src/render/cesium/input.js",
"src/render/cesium/input.test.mjs",
"src/render/cesium/cameraInput.test.mjs",
"src/render/cesium/cameraControls.js",
"src/render/cesium/cameraControls.test.mjs",
"src/render/input/router.js",
"src/render/input/router.test.mjs",
"src/render/cesium/internals.test.mjs",
"src/render/cesium/tileRequests.js",
"src/render/cesium/tileRequests.test.mjs",
"src/queries/heights.test.mjs",
"docs/CODE-BOUNDARIES.md", "docs/CODE-BOUNDARIES.md",
"src/main.js", "src/main.js",
"docs/APPLICATION.md", "docs/APPLICATION.md",
@@ -661,6 +680,12 @@
"src/services/application.js", "src/services/application.js",
"src/services/requests.js", "src/services/requests.js",
"src/app/scene.js", "src/app/scene.js",
"src/render/cesium/appRenderer.test.mjs",
"src/render/noCesiumOutsideRender.test.mjs",
"src/render/cesiumAllowList.json",
"src/geo/geo.test.mjs",
"src/geo/celestial.test.mjs",
"src/geo/quaternion.test.mjs",
"src/app/controls.js", "src/app/controls.js",
"src/app/data.js", "src/app/data.js",
"src/app/tools.js", "src/app/tools.js",
@@ -1111,5 +1136,26 @@
"src/cameraGlobeRotation.test.mjs", "src/cameraGlobeRotation.test.mjs",
"src/voice/globeResetConfirmation.test.mjs", "src/voice/globeResetConfirmation.test.mjs",
"src/hudDms.test.mjs", "src/hudDms.test.mjs",
"server/providers/common/rateLimitKeys.test.mjs" "server/providers/common/rateLimitKeys.test.mjs",
"src/render/contract/contract.test.mjs",
"src/render/contract/common.d.ts",
"src/render/contract/renderer.d.ts",
"src/render/contract/frame.d.ts",
"src/render/contract/globe.d.ts",
"src/render/contract/draw.d.ts",
"src/render/contract/camera.d.ts",
"src/render/contract/query.d.ts",
"src/render/contract/input.d.ts",
"src/render/contract/effects.d.ts",
"src/render/contract/credits.d.ts",
"src/render/contract/quality.d.ts",
"src/render/contract/index.d.ts",
"src/render/stub/stub.test.mjs",
"src/render/conformance/scenes.test.mjs",
"src/render/cesium/cameraGoldens.test.mjs",
"scripts/record-camera-goldens.mjs",
"server/standalone/conformance-references.js",
"dev/conformance/index.html",
"dev/conformance/main.js",
"docs/RENDERER-CONTRACT.md"
] ]
+2 -1
View File
@@ -479,7 +479,8 @@ function readMarkup(file) {
// Text that never reaches the page as UI: tool results and descriptions for // Text that never reaches the page as UI: tool results and descriptions for
// AI clients (MCP and the voice model). // AI clients (MCP and the voice model).
const NOT_UI = [/^src\/tools\//]; // The conformance suite's check titles are developer output, not UI.
const NOT_UI = [/^src\/tools\//, /^src\/render\/conformance\//];
function sourceFiles() { function sourceFiles() {
return trackedFiles( return trackedFiles(
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -127,7 +127,7 @@ try {
let animeOn = false; let animeOn = false;
for (let i = 0; i < s.postProcessStages.length; i++) { for (let i = 0; i < s.postProcessStages.length; i++) {
const stage = s.postProcessStages.get(i); const stage = s.postProcessStages.get(i);
if (stage.name === 'godsEyeView_anime') if (stage.name === 'effects_style_anime')
animeOn = stage.enabled && stage.uniforms.intensity > 0.99; animeOn = stage.enabled && stage.uniforms.intensity > 0.99;
} }
return { return {
+3 -3
View File
@@ -1669,7 +1669,7 @@ async function main() {
const prims = window.__godsEyeView.viewer.scene.primitives; const prims = window.__godsEyeView.viewer.scene.primitives;
let n = 0; let n = 0;
for (let i = 0; i < prims.length; i++) { for (let i = 0; i < prims.length; i++) {
if (prims.get(i) && prims.get(i)._gevViewshed) n += 1; if (window.__godsEyeView.entityRecords.read(prims.get(i))?.viewshedCameraId) n += 1;
} }
return n; return n;
}); });
@@ -1729,7 +1729,7 @@ async function main() {
const prims = window.__godsEyeView.viewer.scene.primitives; const prims = window.__godsEyeView.viewer.scene.primitives;
window.__qaViewshedRefs = []; window.__qaViewshedRefs = [];
for (let i = 0; i < prims.length; i++) { for (let i = 0; i < prims.length; i++) {
if (prims.get(i) && prims.get(i)._gevViewshed) if (window.__godsEyeView.entityRecords.read(prims.get(i))?.viewshedCameraId)
window.__qaViewshedRefs.push(prims.get(i)); window.__qaViewshedRefs.push(prims.get(i));
} }
}); });
@@ -1738,7 +1738,7 @@ async function main() {
const prims = window.__godsEyeView.viewer.scene.primitives; const prims = window.__godsEyeView.viewer.scene.primitives;
const now = []; const now = [];
for (let i = 0; i < prims.length; i++) { for (let i = 0; i < prims.length; i++) {
if (prims.get(i) && prims.get(i)._gevViewshed) now.push(prims.get(i)); if (window.__godsEyeView.entityRecords.read(prims.get(i))?.viewshedCameraId) now.push(prims.get(i));
} }
const before = window.__qaViewshedRefs || []; const before = window.__qaViewshedRefs || [];
const sameCount = now.length === before.length; const sameCount = now.length === before.length;
+2 -2
View File
@@ -763,7 +763,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.entityRecords.read(viewer.trackedEntity)?.trackedId || 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),
@@ -1362,7 +1362,7 @@ try {
contextMode: styleManager._contextControls._contextMode, contextMode: styleManager._contextControls._contextMode,
contextChanging: styleManager._contextControls._contextModeChanging, contextChanging: styleManager._contextControls._contextModeChanging,
subject: context?.subject ? `${context.subject.layerId}:${context.subject.id}` : null, subject: context?.subject ? `${context.subject.layerId}:${context.subject.id}` : null,
tracked: tracker?.gevTrackedId || null, tracked: window.__godsEyeView.entityRecords.read(tracker)?.trackedId || null,
density: styleManager.getDetectionState().densityPct, density: styleManager.getDetectionState().densityPct,
detectionMode: styleManager.getDetectionState().detectionMode, detectionMode: styleManager.getDetectionState().detectionMode,
installations: dataManager.getLayerLifecycleState('military-installations'), installations: dataManager.getLayerLifecycleState('military-installations'),
+6 -3
View File
@@ -360,7 +360,9 @@ async function contacts() {
entities: ds?.entities.values.length ?? 0, entities: ds?.entities.values.length ?? 0,
created, created,
tracked: tracked:
window.__godsEyeView.viewer.trackedEntity?.gevTrackedId ?? null, window.__godsEyeView.entityRecords.read(
window.__godsEyeView.viewer.trackedEntity,
)?.trackedId ?? 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);
@@ -375,8 +377,9 @@ async function contacts() {
await page await page
.waitForFunction( .waitForFunction(
() => () =>
window.__godsEyeView.viewer.trackedEntity?.gevTrackedId === window.__godsEyeView.entityRecords.read(
'flights:aaa051', window.__godsEyeView.viewer.trackedEntity,
)?.trackedId === 'flights:aaa051',
{ timeout: 60_000 }, { timeout: 60_000 },
) )
.catch(() => {}); .catch(() => {});
+32 -68
View File
@@ -263,39 +263,16 @@ try {
}), }),
); );
// The viewer's OWN click actions, captured before the tool ever runs. Both // The viewer's own select and follow, which the input port runs: on before
// must be absent while drawing and back — as the SAME functions — after. // the tool runs, off while drawing, and on again after.
await page.evaluate(() => { const stockActions = async () => {
const Cesium = window.__CESIUM__; const state = await drawState();
const stock = window.__godsEyeView.viewer.screenSpaceEventHandler; return {
window.__qaStock = { clickOn: state.stockSingleClick,
click: doubleOn: state.stockDoubleClick,
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null,
double:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
null,
}; };
}); };
const stockActions = () => const beforeDraw = await stockActions();
page.evaluate(() => {
const Cesium = window.__CESIUM__;
const stock = window.__godsEyeView.viewer.screenSpaceEventHandler;
const live = {
click:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null,
double:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
null,
};
return {
clickPresent: Boolean(live.click),
doublePresent: Boolean(live.double),
clickIsOriginal: live.click === window.__qaStock.click,
doubleIsOriginal: live.double === window.__qaStock.double,
hadClick: Boolean(window.__qaStock.click),
hadDouble: Boolean(window.__qaStock.double),
};
});
// ── enable / disable cycles leave nothing behind ──────────────────────── // ── enable / disable cycles leave nothing behind ────────────────────────
const before = await drawState(); const before = await drawState();
@@ -318,24 +295,21 @@ try {
`handler ${after.sceneHandler}, owner ${after.pointerOwner}, stock dblclick ${after.stockDoubleClick}`, `handler ${after.sceneHandler}, owner ${after.pointerOwner}, stock dblclick ${after.stockDoubleClick}`,
); );
// ── the viewer's own click actions are borrowed, then given back ──────── // ── the viewer's own click actions are held off, then given back ───────
await page.click('#draw-toggle'); await page.click('#draw-toggle');
const duringDraw = await stockActions(); const duringDraw = await stockActions();
check( check(
"Cesium's own selecting click and tracking double-click are both borrowed while drawing", "Cesium's own selecting click and tracking double-click are both off while drawing",
duringDraw.clickPresent === false && duringDraw.doublePresent === false, duringDraw.clickOn === false && duringDraw.doubleOn === false,
`click present ${duringDraw.clickPresent}, double present ${duringDraw.doublePresent} ` + JSON.stringify({ beforeDraw, duringDraw }),
`(viewer had click ${duringDraw.hadClick}, double ${duringDraw.hadDouble})`,
); );
await page.click('#draw-toggle'); await page.click('#draw-toggle');
const afterDraw = await stockActions(); const afterDraw = await stockActions();
check( check(
'and both are restored as the SAME functions, not replacements', 'and both are back after',
afterDraw.clickPresent === duringDraw.hadClick && afterDraw.clickOn === beforeDraw.clickOn &&
afterDraw.doublePresent === duringDraw.hadDouble && afterDraw.doubleOn === beforeDraw.doubleOn,
afterDraw.clickIsOriginal && JSON.stringify({ beforeDraw, afterDraw }),
afterDraw.doubleIsOriginal,
JSON.stringify(afterDraw),
); );
// ── a pin, finished with Enter ────────────────────────────────────────── // ── a pin, finished with Enter ──────────────────────────────────────────
@@ -664,19 +638,6 @@ try {
document.getElementById('draw-toggle')?.getBoundingClientRect().width > 0, document.getElementById('draw-toggle')?.getBoundingClientRect().width > 0,
{ timeout: 10_000 }, { timeout: 10_000 },
); );
// The reload built a new page context: re-capture the viewer's own click
// actions so the identity comparisons below still mean something.
await page.evaluate(() => {
const Cesium = window.__CESIUM__;
const stock = window.__godsEyeView.viewer.screenSpaceEventHandler;
window.__qaStock = {
click:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null,
double:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
null,
};
});
await page.evaluate((view) => { await page.evaluate((view) => {
const viewer = window.__godsEyeView.viewer; const viewer = window.__godsEyeView.viewer;
viewer.camera.cancelFlight?.(); viewer.camera.cancelFlight?.();
@@ -827,24 +788,20 @@ try {
const wasActive = tool.active; const wasActive = tool.active;
await tool.destroy(); await tool.destroy();
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
const Cesium = window.__CESIUM__;
const viewer = window.__godsEyeView.viewer; const viewer = window.__godsEyeView.viewer;
const stock = viewer.screenSpaceEventHandler;
let previews = 0; let previews = 0;
for (let i = 0; i < viewer.dataSources.length; i += 1) for (let i = 0; i < viewer.dataSources.length; i += 1)
if (viewer.dataSources.get(i)?.name === 'gev-draw-preview') previews += 1; if (viewer.dataSources.get(i)?.name === 'gev-draw-preview') previews += 1;
const diagnostics = tool.diagnostics();
return { return {
wasActive, wasActive,
diagnostics: tool.diagnostics(), diagnostics,
previews, previews,
dataSources: viewer.dataSources.length, dataSources: viewer.dataSources.length,
handleGone: window.__gevDrawTool === undefined, handleGone: window.__gevDrawTool === undefined,
clickRestored: // The viewer's own select and follow run again.
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) === clickRestored: diagnostics.stockSingleClick,
window.__qaStock.click, doubleRestored: diagnostics.stockDoubleClick,
doubleRestored:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ===
window.__qaStock.double,
drawingClass: document.body.classList.contains('gev-drawing'), drawingClass: document.body.classList.contains('gev-drawing'),
}; };
}); });
@@ -941,6 +898,11 @@ async function findClickableTarget() {
const site = await page const site = await page
.evaluate(async () => { .evaluate(async () => {
const viewer = window.__godsEyeView.viewer; const viewer = window.__godsEyeView.viewer;
// Core registers each local feature's context as `<layer id>:<feature id>`.
const isDataCentre = (entity) =>
window.__godsEyeView.entityRecords
.read(entity)
?.contextId?.startsWith('local-datacenters:');
await window.__godsEyeView.dataManager.setEnabled( await window.__godsEyeView.dataManager.setEnabled(
'local-datacenters', 'local-datacenters',
true, true,
@@ -960,7 +922,7 @@ async function findClickableTarget() {
for (let attempt = 0; attempt < 40; attempt += 1) { for (let attempt = 0; attempt < 40; attempt += 1) {
for (let i = 0; i < viewer.dataSources.length; i += 1) { for (let i = 0; i < viewer.dataSources.length; i += 1) {
for (const entity of viewer.dataSources.get(i).entities.values) { for (const entity of viewer.dataSources.get(i).entities.values) {
if (entity.__localLayerId !== 'local-datacenters') continue; if (!isDataCentre(entity)) continue;
const point = centreOf(entity); const point = centreOf(entity);
if (!point) continue; if (!point) continue;
const carto = window.Cesium const carto = window.Cesium
@@ -993,8 +955,10 @@ async function findClickableTarget() {
viewer.scene.requestRender(); viewer.scene.requestRender();
}, site); }, site);
await wait(4000); await wait(4000);
const spot = await onScreen( const spot = await onScreen((entity) =>
(entity) => entity.__localLayerId === 'local-datacenters', window.__godsEyeView.entityRecords
.read(entity)
?.contextId?.startsWith('local-datacenters:'),
); );
return spot ? { ...spot, layer: 'data centre' } : null; return spot ? { ...spot, layer: 'data centre' } : null;
} }
+3 -3
View File
@@ -169,8 +169,8 @@ async function observeTrackedHostPaint(page, timeoutMs = 5000) {
const tracked = viewer?.trackedEntity; const tracked = viewer?.trackedEntity;
const current = { const current = {
elapsedMs: Math.round(performance.now() - startedAt), elapsedMs: Math.round(performance.now() - startedAt),
entryId: tracked?.gevTrackedId || null, entryId: window.__godsEyeView?.entityRecords?.read(tracked)?.trackedId || null,
hasPresentationModel: Boolean(tracked?.gevLabelModel?.title), hasPresentationModel: Boolean(window.__godsEyeView?.entityRecords?.read(tracked)?.labelModel?.title),
candidateCount: diagnostics.candidateCount || 0, candidateCount: diagnostics.candidateCount || 0,
projectedCount: diagnostics.projectedCount || 0, projectedCount: diagnostics.projectedCount || 0,
selectedCount: diagnostics.selectedCount || 0, selectedCount: diagnostics.selectedCount || 0,
@@ -610,7 +610,7 @@ async function main() {
if (!pool.length) return null; if (!pool.length) return null;
const ent = v.trackedEntity; const ent = v.trackedEntity;
let tracked = null; let tracked = null;
if (ent && typeof ent.gevDisplayPosition === 'function') tracked = ent.gevDisplayPosition(); if (ent && typeof window.__godsEyeView.entityRecords.read(ent)?.displayPosition === 'function') tracked = window.__godsEyeView.entityRecords.read(ent)?.displayPosition();
if (!tracked) return pool[0]; if (!tracked) return pool[0];
let best = null, bestD = Infinity; let best = null, bestD = Infinity;
for (const m of pool) { for (const m of pool) {
+3 -1
View File
@@ -218,9 +218,11 @@ try {
const gev = window.__godsEyeView; const gev = window.__godsEyeView;
const snap = () => infraIds.map((id) => { const snap = () => infraIds.map((id) => {
const visibleIds = []; const visibleIds = [];
// Core registers each local feature's context as `<layer id>:<feature id>`.
for (let i = 0; i < gev.viewer.dataSources.length; i++) { for (let i = 0; i < gev.viewer.dataSources.length; i++) {
for (const entity of gev.viewer.dataSources.get(i).entities.values) { for (const entity of gev.viewer.dataSources.get(i).entities.values) {
if (entity.__localLayerId === id && entity.show) visibleIds.push(String(entity.id)); const contextId = gev.entityRecords.read(entity)?.contextId;
if (contextId?.startsWith(`${id}:`) && entity.show) visibleIds.push(String(entity.id));
} }
} }
return visibleIds.sort(); return visibleIds.sort();
+5 -2
View File
@@ -17,9 +17,11 @@ export async function checkInstallationPan(page, dir) {
'military-installations', 'military-installations',
).module; ).module;
const centerIds = []; const centerIds = [];
// Core registers each local feature's context as `<layer id>:<feature id>`.
for (let i = 0; i < g.viewer.dataSources.length; i++) for (let i = 0; i < g.viewer.dataSources.length; i++)
for (const e of g.viewer.dataSources.get(i).entities.values) for (const e of g.viewer.dataSources.get(i).entities.values)
if (e.__localLayerId === 'local-datacenters') centerIds.push(e.id); if (g.entityRecords.read(e)?.contextId?.startsWith('local-datacenters:'))
centerIds.push(e.id);
window.__polishPanFrames = []; window.__polishPanFrames = [];
window.__stopPolishPan = g.viewer.scene.postRender.addEventListener(() => { window.__stopPolishPan = g.viewer.scene.postRender.addEventListener(() => {
const sites = [], const sites = [],
@@ -333,7 +335,8 @@ export async function checkInstallationSelection(
for (let y = 250; y < 650; y += 75) for (let y = 250; y < 650; y += 75)
for (let x = 400; x < 850; x += 75) { for (let x = 400; x < 850; x += 75) {
const pick = g.viewer.scene.pick({ x, y }); const pick = g.viewer.scene.pick({ x, y });
if (!pick?.id?.installationId && !pick?.id?.gevTrackedId) // Nothing picked: no installation marker, outline or tracked contact.
if (!pick?.id)
return { x, y }; return { x, y };
} }
throw new Error('No empty map point'); throw new Error('No empty map point');
+3 -2
View File
@@ -1277,8 +1277,9 @@ try {
outlines = 0; outlines = 0;
for (let i = 0; i < viewer.dataSources.length; i++) for (let i = 0; i < viewer.dataSources.length; i++)
for (const entity of viewer.dataSources.get(i).entities.values) { for (const entity of viewer.dataSources.get(i).entities.values) {
if (!ids.has(entity.installationId || entity.id) || !entity.show) // Outline entities are `<record id>:outline:<ring>`.
continue; const recordId = String(entity.id).replace(/:outline:\d+$/, '');
if (!ids.has(recordId) || !entity.show) continue;
if (entity.billboard || entity.point) markers++; if (entity.billboard || entity.point) markers++;
if (entity.polyline) outlines++; if (entity.polyline) outlines++;
} }
+6 -6
View File
@@ -3817,7 +3817,7 @@ async function main() {
id: `qa:${ownerPrefix}:radio-camera-owner`, id: `qa:${ownerPrefix}:radio-camera-owner`,
position: viewer.camera.positionWC.clone(), position: viewer.camera.positionWC.clone(),
}); });
owner.gevTrackedId = `${ownerPrefix}:qa-radio-camera-owner`; window.__godsEyeView.entityRecords.write(owner).trackedId = `${ownerPrefix}:qa-radio-camera-owner`;
viewer.trackedEntity = owner; viewer.trackedEntity = owner;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const beforeStationId = radio.getUIState().selected?.id || null; const beforeStationId = radio.getUIState().selected?.id || null;
@@ -3831,7 +3831,7 @@ async function main() {
afterStationId: radio.getUIState().selected?.id || null, afterStationId: radio.getUIState().selected?.id || null,
flyToDelta: window.__qaRadioFlyToCalls.length - beforeFlyToCalls, flyToDelta: window.__qaRadioFlyToCalls.length - beforeFlyToCalls,
trackedIdentityPreserved: viewer.trackedEntity === owner, trackedIdentityPreserved: viewer.trackedEntity === owner,
trackedId: viewer.trackedEntity?.gevTrackedId || null, trackedId: window.__godsEyeView.entityRecords.read(viewer.trackedEntity)?.trackedId || null,
}; };
viewer.trackedEntity = undefined; viewer.trackedEntity = undefined;
viewer.entities.remove(owner); viewer.entities.remove(owner);
@@ -3867,11 +3867,11 @@ async function main() {
id: 'qa:flights:delayed-radio-fallback-owner', id: 'qa:flights:delayed-radio-fallback-owner',
position: viewer.camera.positionWC.clone(), position: viewer.camera.positionWC.clone(),
}); });
owner.gevTrackedId = 'flights:qa-delayed-radio-fallback-owner'; window.__godsEyeView.entityRecords.write(owner).trackedId = 'flights:qa-delayed-radio-fallback-owner';
viewer.trackedEntity = owner; viewer.trackedEntity = owner;
window.__qaDelayedRadioTrackingOwner = owner; window.__qaDelayedRadioTrackingOwner = owner;
window.__qaRejectRadioPlay(); window.__qaRejectRadioPlay();
return owner.gevTrackedId; return window.__godsEyeView.entityRecords.read(owner)?.trackedId;
}); });
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing'); await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
const delayedFallbackOwnership = await page.evaluate((beforeFlyToCalls) => { const delayedFallbackOwnership = await page.evaluate((beforeFlyToCalls) => {
@@ -3880,7 +3880,7 @@ async function main() {
const result = { const result = {
flyToDelta: window.__qaRadioFlyToCalls.length - beforeFlyToCalls, flyToDelta: window.__qaRadioFlyToCalls.length - beforeFlyToCalls,
trackedIdentityPreserved: viewer.trackedEntity === owner, trackedIdentityPreserved: viewer.trackedEntity === owner,
trackedId: viewer.trackedEntity?.gevTrackedId || null, trackedId: window.__godsEyeView.entityRecords.read(viewer.trackedEntity)?.trackedId || null,
selectedId: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().selected?.id || null, selectedId: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().selected?.id || null,
}; };
viewer.trackedEntity = undefined; viewer.trackedEntity = undefined;
@@ -4222,7 +4222,7 @@ async function main() {
id: 'qa:flights:radio-recenter-stage-owner', id: 'qa:flights:radio-recenter-stage-owner',
position: viewer.camera.positionWC.clone(), position: viewer.camera.positionWC.clone(),
}); });
owner.gevTrackedId = 'flights:qa-radio-recenter-stage-owner'; window.__godsEyeView.entityRecords.write(owner).trackedId = 'flights:qa-radio-recenter-stage-owner';
viewer.trackedEntity = owner; viewer.trackedEntity = owner;
completeFlight(0); completeFlight(0);
const trackingBetweenStagesCalls = calls.length; const trackingBetweenStagesCalls = calls.length;
+18 -10
View File
@@ -169,7 +169,7 @@ try {
result.bloom.intensityPct === 98 && result.bloom.intensityPct === 98 &&
snapshot.bloom.intensity === 98 && snapshot.bloom.intensity === 98 &&
manager._visualEffects.bloomIntensity === 98 && manager._visualEffects.bloomIntensity === 98 &&
manager._bloomStage.enabled manager.viewer.scene.postProcessStages.bloom.enabled
); );
}), }),
); );
@@ -183,7 +183,11 @@ try {
result.sharpen.intensityPct === 61 && result.sharpen.intensityPct === 61 &&
manager.getVisualState().sharpen.intensity === 61 && manager.getVisualState().sharpen.intensity === 61 &&
manager._visualEffects.sharpenIntensity === 0.61 && manager._visualEffects.sharpenIntensity === 0.61 &&
Math.abs(manager._sharpenStage.uniforms.amount - 1.32) < 1e-9 Math.abs(
manager.viewer.scene.postProcessStages.getStageByName(
'effects_sharpen',
).uniforms.amount - 1.32,
) < 1e-9
); );
}), }),
); );
@@ -199,9 +203,10 @@ try {
const manager = window.__godsEyeView.styleManager; const manager = window.__godsEyeView.styleManager;
const effects = manager._visualEffects; const effects = manager._visualEffects;
manager.setStyle('retro'); manager.setStyle('retro');
const owned = [...Object.values(effects.stages), effects.sharpenStage];
const collection = manager.viewer.scene.postProcessStages; const collection = manager.viewer.scene.postProcessStages;
const previousBloom = effects.previousBloom; const owned = Object.keys(effects.stages).map((name) =>
collection.getStageByName(`effects_style_${name}`),
);
const pending = manager.dispose(); const pending = manager.dispose();
const stoppedBeforeAwait = const stoppedBeforeAwait =
effects.stopped && effects.stopped &&
@@ -210,12 +215,15 @@ try {
await pending; await pending;
return { return {
stoppedBeforeAwait, stoppedBeforeAwait,
removed: owned.every((stage) => !collection.contains(stage)), removed:
restored: owned.every(Boolean) &&
effects.bloomStage.enabled === previousBloom.enabled && Object.keys(effects.stages).every(
Object.entries(previousBloom.uniforms).every( (name) => !collection.getStageByName(`effects_style_${name}`),
([name, value]) => effects.bloomStage.uniforms[name] === value,
), ),
// The renderer gives the scene's bloom back when it goes.
restored:
!collection.bloom.enabled &&
!collection.getStageByName('effects_sharpen')?.enabled,
}; };
}); });
check( check(
@@ -223,7 +231,7 @@ try {
teardown.stoppedBeforeAwait, teardown.stoppedBeforeAwait,
); );
check( check(
'UI teardown removes owned stages and restores borrowed bloom', 'UI teardown removes its styles and turns bloom and sharpen off',
teardown.removed && teardown.restored, teardown.removed && teardown.restored,
); );
check( check(
+7 -4
View File
@@ -150,7 +150,7 @@ try {
manager._bloomSlider.focus(); manager._bloomSlider.focus();
return { return {
value: Number(manager._bloomSlider.value), value: Number(manager._bloomSlider.value),
contrast: manager._bloomStage.uniforms.contrast, contrast: manager.viewer.scene.postProcessStages.bloom.uniforms.contrast,
}; };
}); });
await page.keyboard.press('ArrowRight'); await page.keyboard.press('ArrowRight');
@@ -160,7 +160,8 @@ try {
const manager = window.__godsEyeView.styleManager; const manager = window.__godsEyeView.styleManager;
return ( return (
Number(manager._bloomSlider.value) > before.value && Number(manager._bloomSlider.value) > before.value &&
manager._bloomStage.uniforms.contrast < before.contrast manager.viewer.scene.postProcessStages.bloom.uniforms.contrast <
before.contrast
); );
}, displayBefore), }, displayBefore),
); );
@@ -267,13 +268,15 @@ try {
const manager = window.__godsEyeView.styleManager; const manager = window.__godsEyeView.styleManager;
manager._displayBindings._detachDisplayControls(); manager._displayBindings._detachDisplayControls();
const before = manager.bloomEnabled; const before = manager.bloomEnabled;
const contrast = manager._bloomStage.uniforms.contrast; const contrast =
manager.viewer.scene.postProcessStages.bloom.uniforms.contrast;
manager._bloomBtn.click(); manager._bloomBtn.click();
manager._bloomSlider.value = '15'; manager._bloomSlider.value = '15';
manager._bloomSlider.dispatchEvent(new Event('input', { bubbles: true })); manager._bloomSlider.dispatchEvent(new Event('input', { bubbles: true }));
return ( return (
manager.bloomEnabled === before && manager.bloomEnabled === before &&
manager._bloomStage.uniforms.contrast === contrast manager.viewer.scene.postProcessStages.bloom.uniforms.contrast ===
contrast
); );
}), }),
); );
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env node
// Writes the reference camera traces the conformance suite compares a
// renderer against: src/render/conformance/goldens/camera.json. Run it after
// a Cesium upgrade or a change to the camera scripts, and review the diff.
//
// node scripts/record-camera-goldens.mjs [--check]
//
// With --check it writes nothing and exits 1 when the file is out of date.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { recordCameraGoldens } from '../src/render/cesium/cameraGoldens.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const OUT = path.join(ROOT, 'src/render/conformance/goldens/camera.json');
const text = `${JSON.stringify(await recordCameraGoldens(), null, 2)}\n`;
if (process.argv.includes('--check')) {
const current = fs.existsSync(OUT) ? fs.readFileSync(OUT, 'utf8') : '';
if (current !== text) {
console.error(`${path.relative(ROOT, OUT)} is out of date`);
process.exit(1);
}
} else {
fs.mkdirSync(path.dirname(OUT), { recursive: true });
fs.writeFileSync(OUT, text);
console.log(`wrote ${path.relative(ROOT, OUT)}`);
}
+14 -14
View File
@@ -701,7 +701,7 @@ async function main() {
const tracked = manager?.layers?.get('flights')?.module?.getTrackedInfo?.(); const tracked = manager?.layers?.get('flights')?.module?.getTrackedInfo?.();
return manager?.isEnabled?.('flights') return manager?.isEnabled?.('flights')
&& tracked?.icao24 === 'aaa001' && tracked?.icao24 === 'aaa001'
&& window.__godsEyeView.viewer.trackedEntity?.gevTrackedId === 'flights:aaa001'; && window.__godsEyeView.entityRecords.read(window.__godsEyeView.viewer.trackedEntity)?.trackedId === 'flights:aaa001';
}, },
{ timeout: 30000, polling: 100 }, { timeout: 30000, polling: 100 },
); );
@@ -712,7 +712,7 @@ async function main() {
flightsEnabled: manager.isEnabled('flights'), flightsEnabled: manager.isEnabled('flights'),
trackedId: flights.getTrackedInfo()?.icao24 || null, trackedId: flights.getTrackedInfo()?.icao24 || null,
durableId: flights.getParams()?.selectedFlightsTrackingId || null, durableId: flights.getParams()?.selectedFlightsTrackingId || null,
viewerTrackedId: window.__godsEyeView.viewer.trackedEntity?.gevTrackedId || null, viewerTrackedId: window.__godsEyeView.entityRecords.read(window.__godsEyeView.viewer.trackedEntity)?.trackedId || null,
}; };
}); });
record( record(
@@ -742,7 +742,7 @@ async function main() {
const tracked = manager?.layers?.get('flights')?.module?.getTrackedInfo?.(); const tracked = manager?.layers?.get('flights')?.module?.getTrackedInfo?.();
return manager?.isEnabled?.('flights') return manager?.isEnabled?.('flights')
&& tracked?.icao24 === 'aaa001' && tracked?.icao24 === 'aaa001'
&& window.__godsEyeView.viewer.trackedEntity?.gevTrackedId === 'flights:aaa001'; && window.__godsEyeView.entityRecords.read(window.__godsEyeView.viewer.trackedEntity)?.trackedId === 'flights:aaa001';
}, },
{ timeout: 30000, polling: 100 }, { timeout: 30000, polling: 100 },
); );
@@ -753,7 +753,7 @@ async function main() {
flightsEnabled: manager.isEnabled('flights'), flightsEnabled: manager.isEnabled('flights'),
trackedId: flights.getTrackedInfo()?.icao24 || null, trackedId: flights.getTrackedInfo()?.icao24 || null,
durableId: flights.getParams()?.selectedFlightsTrackingId || null, durableId: flights.getParams()?.selectedFlightsTrackingId || null,
viewerTrackedId: window.__godsEyeView.viewer.trackedEntity?.gevTrackedId || null, viewerTrackedId: window.__godsEyeView.entityRecords.read(window.__godsEyeView.viewer.trackedEntity)?.trackedId || null,
}; };
}); });
record( record(
@@ -815,7 +815,7 @@ async function main() {
const latchedShare = await evalPage(() => ({ const latchedShare = await evalPage(() => ({
tracked: window.__godsEyeView.dataManager.layers tracked: window.__godsEyeView.dataManager.layers
.get('flights').module.getTrackedInfo()?.icao24 || null, .get('flights').module.getTrackedInfo()?.icao24 || null,
viewerTrackedId: window.__godsEyeView.viewer.trackedEntity?.gevTrackedId || null, viewerTrackedId: window.__godsEyeView.entityRecords.read(window.__godsEyeView.viewer.trackedEntity)?.trackedId || null,
})); }));
record( record(
'share-v2: the pending subject latches on when it arrives on a later poll', 'share-v2: the pending subject latches on when it arrives on a later poll',
@@ -1380,7 +1380,7 @@ async function main() {
if (exactPickModel) return exactPickModel; if (exactPickModel) return exactPickModel;
const ent = window.__godsEyeView.viewer.trackedEntity; const ent = window.__godsEyeView.viewer.trackedEntity;
let tracked = null; let tracked = null;
if (ent && typeof ent.gevDisplayPosition === 'function') tracked = ent.gevDisplayPosition(); if (ent && typeof window.__godsEyeView.entityRecords.read(ent)?.displayPosition === 'function') tracked = window.__godsEyeView.entityRecords.read(ent)?.displayPosition();
if (!tracked) return pool[0]; if (!tracked) return pool[0];
let best = null, bestD = Infinity; let best = null, bestD = Infinity;
for (const m of pool) { for (const m of pool) {
@@ -1427,7 +1427,7 @@ async function main() {
const dm = window.__godsEyeView.dataManager; const dm = window.__godsEyeView.dataManager;
const fl = dm.layers.get('flights').module; const fl = dm.layers.get('flights').module;
const ent = window.__godsEyeView.viewer.trackedEntity; const ent = window.__godsEyeView.viewer.trackedEntity;
const disp = ent && typeof ent.gevDisplayPosition === 'function' ? ent.gevDisplayPosition() : null; const disp = ent && typeof window.__godsEyeView.entityRecords.read(ent)?.displayPosition === 'function' ? window.__godsEyeView.entityRecords.read(ent)?.displayPosition() : null;
const dets = fl.getDetectableObjects({ maxCount: 1000 }); const dets = fl.getDetectableObjects({ maxCount: 1000 });
const me = dets.find((d) => d.id === icao || d.id === (fl.getTrackedInfo() && fl.getTrackedInfo().callsign)); const me = dets.find((d) => d.id === icao || d.id === (fl.getTrackedInfo() && fl.getTrackedInfo().callsign));
if (!disp || !me || !me.position) return null; if (!disp || !me || !me.position) return null;
@@ -1656,7 +1656,7 @@ async function main() {
const ti = fl.getTrackedInfo(); const ti = fl.getTrackedInfo();
if (!ti) return null; if (!ti) return null;
const ent = v.trackedEntity; const ent = v.trackedEntity;
const disp = ent && typeof ent.gevDisplayPosition === 'function' ? ent.gevDisplayPosition() : null; const disp = ent && typeof window.__godsEyeView.entityRecords.read(ent)?.displayPosition === 'function' ? window.__godsEyeView.entityRecords.read(ent)?.displayPosition() : null;
const pos = disp || ti.position; const pos = disp || ti.position;
if (!pos) return null; if (!pos) return null;
const center = v.scene.cartesianToCanvasCoordinates(pos); const center = v.scene.cartesianToCanvasCoordinates(pos);
@@ -1908,7 +1908,7 @@ async function main() {
// presentation model (entities are asserted native-label-free); // presentation model (entities are asserted native-label-free);
// the native read stays as a fallback so this probe still fails // the native read stays as a fallback so this probe still fails
// loudly if BOTH surfaces are ever absent. // loudly if BOTH surfaces are ever absent.
const model = ent.gevLabelModel; const model = window.__godsEyeView.entityRecords.read(ent)?.labelModel;
if (model) return [model.title, ...(model.details || [])].filter(Boolean).join('\n'); if (model) return [model.title, ...(model.details || [])].filter(Boolean).join('\n');
if (!ent.label || !ent.label.text) return null; if (!ent.label || !ent.label.text) return null;
const t = ent.label.text; const t = ent.label.text;
@@ -2553,7 +2553,7 @@ async function main() {
} }
// WELD (tracked): the tracked CARD anchors to the model you can see. // WELD (tracked): the tracked CARD anchors to the model you can see.
// `gevVisualPosition` is a SEPARATE accessor from `gevDisplayPosition` on // the record's `visualPosition` is a SEPARATE accessor from its `displayPosition` on
// purpose — the latter carries the follow-camera anti-jitter contract and // purpose — the latter carries the follow-camera anti-jitter contract and
// must keep returning the cached dead-reckoned value the camera settled // must keep returning the cached dead-reckoned value the camera settled
// on. So this asserts both halves: the visual accessor is welded to the // on. So this asserts both halves: the visual accessor is welded to the
@@ -2580,10 +2580,10 @@ async function main() {
const at = (p) => (p ? Math.hypot( const at = (p) => (p ? Math.hypot(
p.x - anchor.x, p.y - anchor.y, p.z - anchor.z, p.x - anchor.x, p.y - anchor.y, p.z - anchor.z,
) : null); ) : null);
const visual = typeof ent?.gevVisualPosition === 'function' ? ent.gevVisualPosition() : null; const visual = typeof window.__godsEyeView.entityRecords.read(ent)?.visualPosition === 'function' ? window.__godsEyeView.entityRecords.read(ent)?.visualPosition() : null;
const display = typeof ent?.gevDisplayPosition === 'function' ? ent.gevDisplayPosition() : null; const display = typeof window.__godsEyeView.entityRecords.read(ent)?.displayPosition === 'function' ? window.__godsEyeView.entityRecords.read(ent)?.displayPosition() : null;
return { return {
hasVisualAccessor: typeof ent?.gevVisualPosition === 'function', hasVisualAccessor: typeof window.__godsEyeView.entityRecords.read(ent)?.visualPosition === 'function',
visualDelta: at(visual), visualDelta: at(visual),
displayDelta: at(display), displayDelta: at(display),
}; };
@@ -2599,7 +2599,7 @@ async function main() {
// anti-jitter accessor has been repurposed and the follow camera is at // anti-jitter accessor has been repurposed and the follow camera is at
// risk. Skipped (not failed) when the DR cache is momentarily invalid. // risk. Skipped (not failed) when the DR cache is momentarily invalid.
const displayDelta = trackedWeld.displayDelta; const displayDelta = trackedWeld.displayDelta;
record('weld: gevDisplayPosition still reports the dead-reckoned position (anti-jitter intact)', record('weld: the record displayPosition still reports the dead-reckoned position (anti-jitter intact)',
!Number.isFinite(displayDelta) || displayDelta > ACCESSOR_SEPARATION_EPSILON_M, !Number.isFinite(displayDelta) || displayDelta > ACCESSOR_SEPARATION_EPSILON_M,
Number.isFinite(displayDelta) Number.isFinite(displayDelta)
? `displayΔ=${displayDelta.toFixed(3)} m (must stay > ${ACCESSOR_SEPARATION_EPSILON_M} m — the ground-snap split)` ? `displayΔ=${displayDelta.toFixed(3)} m (must stay > ${ACCESSOR_SEPARATION_EPSILON_M} m — the ground-snap split)`
@@ -0,0 +1,59 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
const DIR = fileURLToPath(
new URL('../../src/render/conformance/goldens/visual/', import.meta.url),
);
const NAME = /^[a-z0-9-]+\.(png|json)$/;
const MAX_BYTES = 8 * 1024 * 1024;
/**
* Dev server only: lets `/dev/conformance/` save the visual references it
* records into `src/render/conformance/goldens/visual/`. Takes a PUT of one
* PNG or the manifest, named in the path; nothing else is written.
*/
export function conformanceReferencesPlugin() {
return {
name: 'conformance-references',
apply: 'serve',
configureServer(server) {
server.middlewares.use('/__conformance/references/', (req, res) => {
const name = decodeURIComponent((req.url ?? '').replace(/^\//, ''));
// Only the page on this machine: a loopback peer, sent from the
// dev server's own origin.
const peer = req.socket.remoteAddress ?? '';
const loopback = /^(127\.|::1$|::ffff:127\.)/.test(peer);
let sameOrigin = false;
try {
sameOrigin = new URL(req.headers.origin).host === req.headers.host;
} catch {
// No Origin, or not a URL.
}
if (!loopback || !sameOrigin) {
res.writeHead(403).end();
return;
}
if (req.method !== 'PUT' || !NAME.test(name)) {
res.writeHead(400).end();
return;
}
const chunks = [];
let size = 0;
req.on('data', (chunk) => {
size += chunk.length;
if (size > MAX_BYTES) req.destroy();
else chunks.push(chunk);
});
req.on('end', async () => {
try {
await mkdir(DIR, { recursive: true });
await writeFile(DIR + name, Buffer.concat(chunks));
res.writeHead(204).end();
} catch (error) {
res.writeHead(500).end(String(error?.message ?? error));
}
});
});
},
};
}
+2
View File
@@ -5,6 +5,7 @@ import { createBrowserViteConfig } from '../../build/vite.js';
import { localProviderPlugins } from '../providers/local.js'; import { localProviderPlugins } from '../providers/local.js';
import { localMcpPlugin } from '../mcp/plugin.js'; import { localMcpPlugin } from '../mcp/plugin.js';
import { apiNotFoundPlugin } from './api-not-found.js'; import { apiNotFoundPlugin } from './api-not-found.js';
import { conformanceReferencesPlugin } from './conformance-references.js';
import { googleTokenUnavailablePlugin } from './google-token-unavailable.js'; import { googleTokenUnavailablePlugin } from './google-token-unavailable.js';
import { standaloneVoiceTools } from './voiceTools.js'; import { standaloneVoiceTools } from './voiceTools.js';
import { unwatchedRuntimeDirsPlugin } from './unwatched-cache.js'; import { unwatchedRuntimeDirsPlugin } from './unwatched-cache.js';
@@ -30,6 +31,7 @@ export function standaloneConfig(
}), }),
localMcpPlugin(), localMcpPlugin(),
googleTokenUnavailablePlugin(), googleTokenUnavailablePlugin(),
conformanceReferencesPlugin(),
apiNotFoundPlugin(), apiNotFoundPlugin(),
unwatchedRuntimeDirsPlugin(), unwatchedRuntimeDirsPlugin(),
], ],
+5 -7
View File
@@ -1,4 +1,4 @@
import * as Cesium from 'cesium'; import { toLonLatH } from '../geo/index.js';
import { import {
feedProvenanceEnvelope, feedProvenanceEnvelope,
layerSnapshot, layerSnapshot,
@@ -109,9 +109,7 @@ export function getWorldPacket(options = {}) {
if (!dataManager || typeof dataManager.getAll !== 'function') if (!dataManager || typeof dataManager.getAll !== 'function')
throw new TypeError('World Packet requires a data manager'); throw new TypeError('World Packet requires a data manager');
const cartographic = Cesium.Cartographic.fromCartesian( const eye = toLonLatH(viewer.camera.positionWC);
viewer.camera.positionWC,
);
// Read the roster once. Voice must not rewalk disabled layers: preserving the // Read the roster once. Voice must not rewalk disabled layers: preserving the
// current enabled-only view keeps the model's context budget bounded. // current enabled-only view keeps the model's context budget bounded.
const rows = dataManager.getAll(); const rows = dataManager.getAll();
@@ -126,9 +124,9 @@ export function getWorldPacket(options = {}) {
ok: true, ok: true,
action: 'get_current_view_state', action: 'get_current_view_state',
camera: { camera: {
latitude: Cesium.Math.toDegrees(cartographic.latitude), latitude: eye.lat,
longitude: Cesium.Math.toDegrees(cartographic.longitude), longitude: eye.lon,
heightM: cartographic.height, heightM: eye.height,
}, },
style: styleManager?.activeStyle || 'normal', style: styleManager?.activeStyle || 'normal',
context: contextState context: contextState
+39 -31
View File
@@ -1,6 +1,7 @@
import { isUnavailableCapability } from '../sources/capability.js'; import { isUnavailableCapability } from '../sources/capability.js';
import { defaultGeospatial } from '../search/defaults.js'; import { defaultGeospatial } from '../search/defaults.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { cameraOf } from '../render/cesium/camera.js';
import { greatCircleMeters } from '../geoDistance.js'; import { greatCircleMeters } from '../geoDistance.js';
import { import {
holdContinuousRender, holdContinuousRender,
@@ -1700,14 +1701,18 @@ export function createAnnotationEngine({
Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0), Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0),
); );
const sphere = Cesium.BoundingSphere.fromPoints(cart); const sphere = Cesium.BoundingSphere.fromPoints(cart);
viewer.camera.flyToBoundingSphere(sphere, { const camera = cameraOf(viewer);
offset: new Cesium.HeadingPitchRange( camera.flyToBounds(
viewer.camera.heading, { center: sphere.center, radius: sphere.radius },
Cesium.Math.toRadians(-35), {
Math.max(900, sphere.radius * 2.6), offset: {
), heading: camera.pose().heading,
duration: 1.6, pitch: Cesium.Math.toRadians(-35),
}); range: Math.max(900, sphere.radius * 2.6),
},
durationS: 1.6,
},
);
} catch { } catch {
/* camera assist is best-effort */ /* camera assist is best-effort */
} }
@@ -1757,22 +1762,23 @@ export function createAnnotationEngine({
: anno.ring, : anno.ring,
) )
: viewportRange(anno.viewport) || 600; : viewportRange(anno.viewport) || 600;
viewer.camera.flyToBoundingSphere( const camera = cameraOf(viewer);
new Cesium.BoundingSphere( camera.flyToBounds(
Cesium.Cartesian3.fromDegrees(
target.lon,
target.lat,
anno.anchor.height || 0,
),
range,
),
{ {
offset: new Cesium.HeadingPitchRange( center: {
viewer.camera.heading, lon: target.lon,
Cesium.Math.toRadians(-35), lat: target.lat,
range * 2.4, height: anno.anchor.height || 0,
), },
duration: 1.8, radius: range,
},
{
offset: {
heading: camera.pose().heading,
pitch: Cesium.Math.toRadians(-35),
range: range * 2.4,
},
durationS: 1.8,
}, },
); );
} catch { } catch {
@@ -1978,15 +1984,17 @@ export function createAnnotationEngine({
}) { }) {
if (destroyed) return; if (destroyed) return;
try { try {
viewer.camera.flyTo({ cameraOf(viewer).flyTo(
destination: Cesium.Cartesian3.fromDegrees(lon, lat, height), { lon, lat, height },
orientation: { {
heading: Cesium.Math.toRadians(heading), orientation: {
pitch: Cesium.Math.toRadians(pitch), heading: Cesium.Math.toRadians(heading),
roll: 0, pitch: Cesium.Math.toRadians(pitch),
roll: 0,
},
durationS: duration,
}, },
duration, );
});
} catch { } catch {
/* camera flight best-effort */ /* camera flight best-effort */
} }
+6 -4
View File
@@ -1,3 +1,4 @@
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
import { createStandalonePlaceSearch } from '../standalone/placeSearch.js'; import { createStandalonePlaceSearch } from '../standalone/placeSearch.js';
// Voice-annotation resilience contract tests — pure logic, no network, no browser. // Voice-annotation resilience contract tests — pure logic, no network, no browser.
// //
@@ -25,6 +26,7 @@ import {
installRenderGovernor, installRenderGovernor,
_resetRenderGovernorForTest, _resetRenderGovernorForTest,
} from '../renderGovernor.js'; } from '../renderGovernor.js';
import { framePortFor } from '../testSupport/fakeFramePort.mjs';
// A resolver that replays a scripted sequence of outcomes (undefined = transient, // A resolver that replays a scripted sequence of outcomes (undefined = transient,
// null = definitive miss, object = footprint) and counts its invocations. // null = definitive miss, object = footprint) and counts its invocations.
@@ -1261,7 +1263,7 @@ test('hand-drawn marks: one mark can be hidden, renamed, recoloured, framed and
const { renderer, calls } = fakeRenderer(); const { renderer, calls } = fakeRenderer();
const flights = []; const flights = [];
const engine = createAnnotationEngine({ const engine = createAnnotationEngine({
viewer: { camera: { heading: 0, flyToBoundingSphere: (sphere) => flights.push(sphere) } }, viewer: bindFakeCesiumCamera({ camera: { heading: 0, flyToBoundingSphere: (sphere) => flights.push(sphere) } }),
renderer, renderer,
}); });
let changes = 0; let changes = 0;
@@ -1330,12 +1332,12 @@ test('hand-drawn marks: remove, hide, clear, expiry and framing have their effec
const { renderer, calls } = fakeRenderer(); const { renderer, calls } = fakeRenderer();
const flights = []; const flights = [];
const engine = createAnnotationEngine({ const engine = createAnnotationEngine({
viewer: { viewer: bindFakeCesiumCamera({
camera: { camera: {
heading: 0, heading: 0,
flyToBoundingSphere: (sphere) => flights.push(sphere), flyToBoundingSphere: (sphere) => flights.push(sphere),
}, },
}, }),
renderer, renderer,
}); });
let changes = 0; let changes = 0;
@@ -1547,7 +1549,7 @@ test('hand-drawn marks: with every mark hidden the scene idles, and showing one
_resetRenderGovernorForTest(); _resetRenderGovernorForTest();
t.after(() => _resetRenderGovernorForTest()); t.after(() => _resetRenderGovernorForTest());
let renders = 0; let renders = 0;
installRenderGovernor({ scene: { requestRenderMode: true, requestRender: () => (renders += 1) } }); installRenderGovernor(framePortFor({ requestRenderMode: true, requestRender: () => (renders += 1) }));
const { renderer } = fakeRenderer(); const { renderer } = fakeRenderer();
const engine = createAnnotationEngine({ viewer: {}, renderer }); const engine = createAnnotationEngine({ viewer: {}, renderer });
await engine.annotate([ await engine.annotate([
+32 -62
View File
@@ -18,9 +18,9 @@
* exceptions inside each. * exceptions inside each.
* - Cesium's Viewer binds its OWN left click (select the entity under the * - Cesium's Viewer binds its OWN left click (select the entity under the
* pointer) and double click (track it) on the viewer's handler, outside every * pointer) and double click (track it) on the viewer's handler, outside every
* layer and therefore outside the shared claim. Both are borrowed for the * layer and therefore outside the shared claim. Both are suppressed for the
* session and given back on the way out, and `destroy()` gives back every * session and given back on the way out, and `destroy()` gives back every
* listener, the Cesium handler, the preview data source and the window * listener, the pointer subscriptions, the preview data source and the window
* handle, so the tool can be turned off or the shell disposed without leaving * handle, so the tool can be turned off or the shell disposed without leaving
* anything behind. * anything behind.
* *
@@ -37,6 +37,8 @@ import {
pointerOwner, pointerOwner,
releasePointer, releasePointer,
} from '../data/inputOwnership.js'; } from '../data/inputOwnership.js';
import { inputFor } from '../input/dispatcher.js';
import { sensorPickPosition } from '../input/sensorPick.js';
import { pointerOwnerName } from '../data/pointerOwnerNames.js'; import { pointerOwnerName } from '../data/pointerOwnerNames.js';
import { import {
DRAW_SHAPES, DRAW_SHAPES,
@@ -88,10 +90,8 @@ export function initDrawTool({
let shape = 'area'; let shape = 'area';
let color = 'primary'; let color = 'primary';
let hint = ''; let hint = '';
let handler = null; let unbindScene = null;
let lease = null; let lease = null;
let savedDoubleClick = null;
let savedSingleClick = null;
let cursor = null; // last mouse position on the canvas, for the rubber band let cursor = null; // last mouse position on the canvas, for the rubber band
// Bumped by anything that supersedes an in-flight finish: cancelling, clearing // Bumped by anything that supersedes an in-flight finish: cancelling, clearing
// the board, leaving draw mode, teardown. An `annotate()` that resolves after // the board, leaving draw mode, teardown. An `annotate()` that resolves after
@@ -228,7 +228,9 @@ export function initDrawTool({
}; };
const onMove = (event) => { const onMove = (event) => {
if (!session || session.shape === 'pin' || destroyed) return; if (!session || session.shape === 'pin' || destroyed) return;
const p = worldAt(event.endPosition); // The click's own position is sensor-corrected; the rubber band must
// follow the same point the click would place.
const p = worldAt(sensorPickPosition(viewer.scene, event.endPosition));
cursor = p cursor = p
? Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0) ? Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0)
: null; : null;
@@ -384,50 +386,29 @@ export function initDrawTool({
} }
function bindSceneHandler() { function bindSceneHandler() {
if (handler) return; if (unbindScene) return;
handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); const input = inputFor(viewer);
handler.setInputAction(onClick, Cesium.ScreenSpaceEventType.LEFT_CLICK); const held = { lease: () => lease };
handler.setInputAction(onMove, Cesium.ScreenSpaceEventType.MOUSE_MOVE); const offs = [
handler.setInputAction(() => { input.onPointer('click', onClick, held),
void finish(); input.onPointer('move', onMove, held),
}, Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK); input.onPointer('dblclick', () => void finish(), held),
// Cesium's Viewer binds BOTH stock click actions on its own handler: the // Cesium's Viewer binds BOTH stock click actions on its own handler: the
// single click picks an entity into `viewer.selectedEntity`, the double // single click picks an entity into `viewer.selectedEntity`, the double
// click tracks it. Neither goes through a layer, so neither can be fixed by // click tracks it. Neither goes through a layer, so neither yields to the
// the shared pointer claim — they have to be borrowed outright for the // shared pointer claim — they are suppressed for the session and given
// session and given back on the way out. Borrowing the single click is what // back on the way out. Suppressing the single click is what stops a
// stops a vertex placed on a contact from also selecting it. // vertex placed on a contact from also selecting it.
const stock = viewer.screenSpaceEventHandler; input.suppressStockClicks(),
savedSingleClick = ];
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null; unbindScene = () => offs.forEach((off) => off());
savedDoubleClick =
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
null;
stock.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK);
stock.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
listen(document, 'keydown', onKey, true); listen(document, 'keydown', onKey, true);
} }
function releaseSceneHandler() { function releaseSceneHandler() {
if (handler) { unbindScene?.();
handler.destroy(); unbindScene = null;
handler = null; // Drop the keydown listener bound alongside the pointer subscriptions.
}
if (savedSingleClick) {
viewer.screenSpaceEventHandler.setInputAction(
savedSingleClick,
Cesium.ScreenSpaceEventType.LEFT_CLICK,
);
savedSingleClick = null;
}
if (savedDoubleClick) {
viewer.screenSpaceEventHandler.setInputAction(
savedDoubleClick,
Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK,
);
savedDoubleClick = null;
}
// Drop the keydown listener bound alongside this handler.
for (let i = domListeners.length - 1; i >= 0; i -= 1) { for (let i = domListeners.length - 1; i >= 0; i -= 1) {
const [target, type, listener, options] = domListeners[i]; const [target, type, listener, options] = domListeners[i];
if (listener !== onKey) continue; if (listener !== onKey) continue;
@@ -523,26 +504,15 @@ export function initDrawTool({
return { return {
active, active,
destroyed, destroyed,
sceneHandler: Boolean(handler), sceneHandler: Boolean(unbindScene),
domListeners: domListeners.length, domListeners: domListeners.length,
previewDataSources: countPreviewDataSources(viewer), previewDataSources: countPreviewDataSources(viewer),
previewEntities: destroyed ? 0 : dataSource.entities.values.length, previewEntities: destroyed ? 0 : dataSource.entities.values.length,
pointerOwner: pointerOwner(), pointerOwner: pointerOwner(),
// The viewer's OWN click actions. While a session is open both are // The viewer's own select and follow, which the input port runs: off
// borrowed (absent here); after it closes both are back. A harness can // while a session is open, back on after it closes.
// compare the restored functions by identity with what it captured stockSingleClick: inputFor(viewer).stockEnabled('select'),
// before, which is the only way to prove they were given back rather stockDoubleClick: inputFor(viewer).stockEnabled('dblclickFollow'),
// than replaced.
stockSingleClick: Boolean(
viewer.screenSpaceEventHandler?.getInputAction?.(
Cesium.ScreenSpaceEventType.LEFT_CLICK,
),
),
stockDoubleClick: Boolean(
viewer.screenSpaceEventHandler?.getInputAction?.(
Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK,
),
),
}; };
}, },
/** Resolves once every deferred teardown step has run. */ /** Resolves once every deferred teardown step has run. */
+25 -16
View File
@@ -13,6 +13,7 @@ import {
releasePointer, releasePointer,
resetPointerOwnership, resetPointerOwnership,
} from '../data/inputOwnership.js'; } from '../data/inputOwnership.js';
import { fakeInput } from '../input/testDoubles.mjs';
/* ── DOM doubles ───────────────────────────────────────────────────────── */ /* ── DOM doubles ───────────────────────────────────────────────────────── */
@@ -223,10 +224,13 @@ function fakeAnnotations() {
} }
/** Stand up a tool against fresh doubles, with globals restored afterwards. */ /** Stand up a tool against fresh doubles, with globals restored afterwards. */
function harness({ navigation = null } = {}) { function harness({ navigation = null, beforeInput = () => {} } = {}) {
resetPointerOwnership(); resetPointerOwnership();
const { byId, documentDouble } = fakeDom(); const { byId, documentDouble } = fakeDom();
const viewer = fakeViewer(); const viewer = fakeViewer();
// The viewer's own actions are bound before its input port takes them.
beforeInput(viewer);
const input = fakeInput(viewer);
const annotations = fakeAnnotations(); const annotations = fakeAnnotations();
const previousDocument = globalThis.document; const previousDocument = globalThis.document;
const previousWindow = globalThis.window; const previousWindow = globalThis.window;
@@ -256,7 +260,9 @@ function harness({ navigation = null } = {}) {
controls, controls,
/** A control's change, as the view sends it. */ /** A control's change, as the view sends it. */
act: (name, ...args) => controls.actions?.[name]?.(...args), act: (name, ...args) => controls.actions?.[name]?.(...args),
input,
restore() { restore() {
input.destroy();
resetPointerOwnership(); resetPointerOwnership();
if (previousDocument === undefined) delete globalThis.document; if (previousDocument === undefined) delete globalThis.document;
else globalThis.document = previousDocument; else globalThis.document = previousDocument;
@@ -335,30 +341,33 @@ test('a destroyed instance cannot free the pointer its replacement holds', async
} }
}); });
test('both stock viewer click actions are borrowed for the session and given back', async () => { test('both stock viewer click actions are held off for the session and given back', async () => {
const h = harness(); const stockClick = () => 'viewer picks and selects';
const stockDouble = () => 'viewer tracks';
const h = harness({
beforeInput(viewer) {
viewer.screenSpaceEventHandler.setInputAction(stockClick, LEFT_CLICK);
viewer.screenSpaceEventHandler.setInputAction(stockDouble, LEFT_DOUBLE_CLICK);
},
});
try { try {
const stockClick = () => 'viewer picks and selects'; assert.equal(h.input.stockEnabled('select'), true);
const stockDouble = () => 'viewer tracks';
h.viewer.screenSpaceEventHandler.setInputAction(stockClick, LEFT_CLICK);
h.viewer.screenSpaceEventHandler.setInputAction(stockDouble, LEFT_DOUBLE_CLICK);
h.act('toggleDraw'); h.act('toggleDraw');
assert.equal( assert.equal(
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK), h.input.stockEnabled('select'),
undefined, false,
"the viewer's own selecting click must not run while drawing", "the viewer's own selecting click must not run while drawing",
); );
assert.equal( assert.equal(h.input.stockEnabled('dblclickFollow'), false);
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_DOUBLE_CLICK),
undefined,
);
h.act('toggleDraw'); h.act('toggleDraw');
assert.equal(h.input.stockEnabled('select'), true);
assert.equal(h.input.stockEnabled('dblclickFollow'), true);
h.input.destroy();
assert.equal( assert.equal(
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK), h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK),
stockClick, stockClick,
'the exact function is restored, not a replacement', 'the exact function goes back to the viewer, not a replacement',
); );
assert.equal( assert.equal(
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_DOUBLE_CLICK), h.viewer.screenSpaceEventHandler.getInputAction(LEFT_DOUBLE_CLICK),
@@ -468,7 +477,7 @@ test('destroy releases the pointer, the handler, the listeners and the stock act
assert.equal(isPointerFree(), true, 'the pointer is free'); assert.equal(isPointerFree(), true, 'the pointer is free');
const after = h.tool.diagnostics(); const after = h.tool.diagnostics();
assert.equal(after.destroyed, true); assert.equal(after.destroyed, true);
assert.equal(after.sceneHandler, false, 'the Cesium handler is gone'); assert.equal(after.sceneHandler, false, 'the pointer subscriptions are gone');
assert.equal(after.domListeners, 0, 'every DOM listener came off'); assert.equal(after.domListeners, 0, 'every DOM listener came off');
assert.equal(h.documentDouble.listeners.length, 0, 'including the keydown'); assert.equal(h.documentDouble.listeners.length, 0, 'including the keydown');
assert.equal(h.controls.actions, null, 'and the controls are disconnected'); assert.equal(h.controls.actions, null, 'and the controls are disconnected');
@@ -5,6 +5,7 @@ import { createPinia } from 'pinia';
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js'; import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js'; import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js'; import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
import { bindFakeCesiumCredits } from '../testSupport/fakeCreditsPort.mjs';
/** A hybrid renderer whose screen board is presented to its own store. */ /** A hybrid renderer whose screen board is presented to its own store. */
function hybridWithBoard(viewer) { function hybridWithBoard(viewer) {
@@ -110,7 +111,7 @@ function fakeViewer(lon, lat) {
return { return {
dataSources, dataSources,
frames, frames,
viewer: { viewer: bindFakeCesiumCredits({
credits, credits,
creditDisplay: { creditDisplay: {
addStaticCredit: (credit) => credits.add(credit), addStaticCredit: (credit) => credits.add(credit),
@@ -130,7 +131,7 @@ function fakeViewer(lon, lat) {
return index >= 0; return index >= 0;
}, },
}, },
}, }),
}; };
} }
@@ -540,8 +541,9 @@ test('buildings share one probe per frame and preserve probe geometry, filtering
const detail = parent.detail; const detail = parent.detail;
assert.equal(detail.index, index); assert.equal(detail.index, index);
assert.equal(detail.status, trace === firstTrace ? 'recorded' : 'missing'); assert.equal(detail.status, trace === firstTrace ? 'recorded' : 'missing');
// The fourth probe finds nothing and the fifth throws: both are misses.
assert.equal(detail.clampResult, assert.equal(detail.clampResult,
trace === firstTrace && index === 4 ? 'threw' : 'returned'); trace === firstTrace && index !== 3 && index !== 4 ? 'hit' : 'missed');
assert.ok(Number.isFinite(detail.clampStart) && Number.isFinite(detail.clampEnd)); assert.ok(Number.isFinite(detail.clampStart) && Number.isFinite(detail.clampEnd));
assert.ok(parent.start <= detail.clampStart && detail.clampStart <= detail.clampEnd && detail.clampEnd <= parent.end); assert.ok(parent.start <= detail.clampStart && detail.clampStart <= detail.clampEnd && detail.clampEnd <= parent.end);
if (index === 7) { if (index === 7) {
+3 -2
View File
@@ -7,6 +7,7 @@ import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
import { createAnnotationResolver } from './resolver.js'; import { createAnnotationResolver } from './resolver.js';
import { polygonsContain } from '../data/adminBoundaries.js'; import { polygonsContain } from '../data/adminBoundaries.js';
import { streetFootprint } from './outlineRungs.js'; import { streetFootprint } from './outlineRungs.js';
import { bindFakeCesiumCredits } from '../testSupport/fakeCreditsPort.mjs';
function viewerAt(lat, lon, height = 20_000) { function viewerAt(lat, lon, height = 20_000) {
return { return {
@@ -180,10 +181,10 @@ test('a geocoded city or neighborhood upgrades to the bundled outline', async ()
const credits = []; const credits = [];
const cancelled = await city.resolve( const cancelled = await city.resolve(
'Austin', 'Austin',
{ bindFakeCesiumCredits({
...viewerAt(39, -100, 5_000_000), ...viewerAt(39, -100, 5_000_000),
creditDisplay: { addStaticCredit: (credit) => credits.push(credit) }, creditDisplay: { addStaticCredit: (credit) => credits.push(credit) },
}, }),
{ signal: controller.signal }, { signal: controller.signal },
); );
const pending = cancelled.resolveOutline(); const pending = cancelled.resolveOutline();
+11 -18
View File
@@ -45,6 +45,7 @@ import {
import { createOutlineRungs } from './outlineRungs.js'; import { createOutlineRungs } from './outlineRungs.js';
import { unavailablePlaceSearch } from '../search/placeSearch.js'; import { unavailablePlaceSearch } from '../search/placeSearch.js';
import { isPickedWorldPosition } from '../data/scenePick.js'; import { isPickedWorldPosition } from '../data/scenePick.js';
import { heightsFor } from '../queries/heights.js';
/** Prepare only local dateline outlines for planar admission and centering. */ /** Prepare only local dateline outlines for planar admission and centering. */
function localOutlineFrame(fp) { function localOutlineFrame(fp) {
@@ -2351,26 +2352,18 @@ export function createAnnotationResolver({
function sampleGroundHeight(viewer, lon, lat) { function sampleGroundHeight(viewer, lon, lat) {
const scene = viewer?.scene; const scene = viewer?.scene;
if (!scene) return 0; if (!scene) return 0;
try { const heights = heightsFor(scene);
if ( // A miss is tiles not ready: fall through to the terrain, then 0.
scene.clampToHeightSupported && if (heights.supported().clamp) {
typeof scene.clampToHeight === 'function' const surface = Cesium.Cartesian3.fromDegrees(lon, lat, 0);
) { const clamped = heights.clampNow(surface);
const carto = Cesium.Cartographic.fromDegrees(lon, lat); if (clamped) {
const surface = Cesium.Cartographic.toCartesian(carto); const h = Cesium.Cartographic.fromCartesian(clamped)?.height;
const clamped = scene.clampToHeight(surface); if (Number.isFinite(h)) return h;
if (clamped) {
const h = Cesium.Cartographic.fromCartesian(clamped).height;
if (Number.isFinite(h)) return h;
}
} }
} catch {
/* tiles not ready — fall through */
} }
const globeHeight = scene.globe?.getHeight?.( const globeHeight = heights.now({ lon, lat }, { against: 'terrain' });
Cesium.Cartographic.fromDegrees(lon, lat), return globeHeight !== null && globeHeight > 0 ? globeHeight : 0;
);
return Number.isFinite(globeHeight) && globeHeight > 0 ? globeHeight : 0;
} }
function shortLabel(formattedAddress) { function shortLabel(formattedAddress) {
+16 -18
View File
@@ -1,3 +1,4 @@
import { followedOf } from '../data/followed.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { getOverlayPaintRect } from '../overlays/worldOverlay.js'; import { getOverlayPaintRect } from '../overlays/worldOverlay.js';
import { import {
@@ -8,6 +9,7 @@ import { t } from '../i18n/index.js';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js'; import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
import { applicationPinia } from '../ui/vueRuntime.js'; import { applicationPinia } from '../ui/vueRuntime.js';
import { heightsFor } from '../queries/heights.js';
/** A label whose detailed outline could not be resolved. */ /** A label whose detailed outline could not be resolved. */
function outlineUnavailableLabel(label) { function outlineUnavailableLabel(label) {
@@ -141,13 +143,10 @@ export function createScreenAnnotationRenderer(
return cached.h; return cached.h;
} }
try { try {
if ( const heights = heightsFor(scene);
scene.clampToHeightSupported && if (heights.supported().clamp) {
typeof scene.clampToHeight === 'function' // A miss is tiles not ready.
) { const c = heights.clampNow(Cesium.Cartesian3.fromDegrees(lon, lat, 0));
const c = scene.clampToHeight(
Cesium.Cartesian3.fromDegrees(lon, lat, 0),
);
if (c) { if (c) {
const h = Cesium.Cartographic.fromCartesian(c).height; const h = Cesium.Cartographic.fromCartesian(c).height;
if (Number.isFinite(h) && h > -430 && h < 9000) { if (Number.isFinite(h) && h > -430 && h < 9000) {
@@ -422,12 +421,10 @@ export function createScreenAnnotationRenderer(
let top = painted?.y; let top = painted?.y;
let bottom = painted ? painted.y + painted.h : undefined; let bottom = painted ? painted.y + painted.h : undefined;
const ent = viewer.trackedEntity; const followed = followedOf(viewer);
const now = Cesium.JulianDate.now(); const displayPosition = followed?.displayPosition;
const world = const world =
typeof ent?.gevDisplayPosition === 'function' typeof displayPosition === 'function' ? displayPosition() : null;
? ent.gevDisplayPosition()
: null;
const win = world const win = world
? Cesium.SceneTransforms.worldToWindowCoordinates( ? Cesium.SceneTransforms.worldToWindowCoordinates(
scene, scene,
@@ -436,13 +433,14 @@ export function createScreenAnnotationRenderer(
) )
: null; : null;
// Billboard box — centered on the anchor, magnified by scaleByDistance. // Billboard box — centered on the anchor, magnified by scaleByDistance, from
const bb = ent?.billboard; // the size the tracking layer recorded for its sprite.
if (bb && win && Number.isFinite(win.x) && Number.isFinite(win.y)) { const sprite = followed?.sprite;
const baseW = bb.width?.getValue?.(now) ?? 28; if (sprite && win && Number.isFinite(win.x) && Number.isFinite(win.y)) {
const baseH = bb.height?.getValue?.(now) ?? 28; const baseW = sprite.width ?? 28;
const baseH = sprite.height ?? 28;
let scale = 1; let scale = 1;
const sbd = bb.scaleByDistance?.getValue?.(now); const sbd = sprite.scaleByDistance;
if (sbd) if (sbd)
scale = nearFarValue( scale = nearFarValue(
sbd, sbd,
+4 -7
View File
@@ -2,6 +2,7 @@ import { recordWorkSlice } from '../sources/phaseTiming.js';
import { ringCentroid } from '../sources/featureGeometry.js'; import { ringCentroid } from '../sources/featureGeometry.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js'; import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
import { heightsFor } from '../queries/heights.js';
/** /**
* World-space annotation renderer (Direction A). * World-space annotation renderer (Direction A).
@@ -174,20 +175,16 @@ export function createWorldAnnotationRenderer(viewer) {
const sliceStart = performance.now(); const sliceStart = performance.now();
let error; let error;
try { try {
if ( if (heightsFor(viewer.scene).supported().clamp) {
viewer.scene.clampToHeightSupported &&
typeof viewer.scene.clampToHeight === 'function'
) {
const [lon, lat] = task.points[task.index++]; const [lon, lat] = task.points[task.index++];
timing.probes += 1; timing.probes += 1;
try { try {
const position = Cesium.Cartesian3.fromDegrees(lon, lat, 0); const position = Cesium.Cartesian3.fromDegrees(lon, lat, 0);
let c; let c;
note?.('clampStart'); note?.('clampStart');
if (detail) detail.clampResult = 'threw';
try { try {
c = viewer.scene.clampToHeight(position); c = heightsFor(viewer.scene).clampNow(position);
if (detail) detail.clampResult = 'returned'; if (detail) detail.clampResult = c ? 'hit' : 'missed';
} finally { } finally {
note?.('clampEnd'); note?.('clampEnd');
} }
+1 -1
View File
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { createApplication } from './application.js'; import { createApplication } from './application.js';
import { installTrackpadPinchZoom } from './viewer.js'; import { installTrackpadPinchZoom } from '../render/cesium/ctrlWheelZoom.js';
const phases = ['Scene', 'Controls', 'Data', 'Tools']; const phases = ['Scene', 'Controls', 'Data', 'Tools'];
function fixture(overrides = {}) { function fixture(overrides = {}) {
+13 -14
View File
@@ -13,6 +13,7 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { announceNavigationAuthority } from '../navigationPolicy.js'; import { announceNavigationAuthority } from '../navigationPolicy.js';
import { cameraOf } from '../render/cesium/camera.js';
import { annotationsFromParams, createView } from '../view/index.js'; import { annotationsFromParams, createView } from '../view/index.js';
const EMBED_VIEW_MESSAGE = 'gev:view'; const EMBED_VIEW_MESSAGE = 'gev:view';
@@ -147,21 +148,19 @@ const delay = (ms, signal) =>
function flyTo(viewer, camera) { function flyTo(viewer, camera) {
announceNavigationAuthority('embed-view'); announceNavigationAuthority('embed-view');
return new Promise((resolve) => { return new Promise((resolve) => {
viewer.camera.flyTo({ cameraOf(viewer).flyTo(
destination: Cesium.Cartesian3.fromDegrees( { lon: camera.lon, lat: camera.lat, height: camera.altitude_m },
camera.lon, {
camera.lat, orientation: {
camera.altitude_m, heading: Cesium.Math.toRadians(camera.heading_deg),
), pitch: Cesium.Math.toRadians(camera.pitch_deg),
orientation: { roll: 0,
heading: Cesium.Math.toRadians(camera.heading_deg), },
pitch: Cesium.Math.toRadians(camera.pitch_deg), durationS: FLIGHT_SECONDS,
roll: 0, onComplete: () => resolve(true),
onCancel: () => resolve(false),
}, },
duration: FLIGHT_SECONDS, );
complete: () => resolve(true),
cancel: () => resolve(false),
});
}); });
} }
+3 -2
View File
@@ -1,3 +1,4 @@
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { import {
@@ -12,7 +13,7 @@ import { createView } from '../view/index.js';
const fakeViewer = () => { const fakeViewer = () => {
const flights = []; const flights = [];
return { return bindFakeCesiumCamera({
flights, flights,
camera: { camera: {
flyTo(options) { flyTo(options) {
@@ -20,7 +21,7 @@ const fakeViewer = () => {
options.complete(); options.complete();
}, },
}, },
}; });
}; };
const fakeLayers = (enabled) => ({ const fakeLayers = (enabled) => ({
getAll: () => getAll: () =>
-4
View File
@@ -1,4 +1,3 @@
import * as Cesium from 'cesium';
import { createFirmsHeatmapLayer as createLayer } from '../../layers/firms/index.js'; import { createFirmsHeatmapLayer as createLayer } from '../../layers/firms/index.js';
import * as render from '../../renderGovernor.js'; import * as render from '../../renderGovernor.js';
import * as sprites from '../../data/spriteOrder.js'; import * as sprites from '../../data/spriteOrder.js';
@@ -29,8 +28,5 @@ export function createApplicationFirms({ surface, ...options }) {
clearSource: overlays.clearOverlaySource, clearSource: overlays.clearOverlaySource,
hitTest: overlays.hitTestWorldOverlay, hitTest: overlays.hitTestWorldOverlay,
}, },
screenSpaceEventHandlerFactory:
options.screenSpaceEventHandlerFactory ??
((viewer) => new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)),
}); });
} }
-5
View File
@@ -1,8 +1,6 @@
import * as Cesium from 'cesium';
import { createGhostMapsLayer } from '../../layers/ghostMaps/index.js'; import { createGhostMapsLayer } from '../../layers/ghostMaps/index.js';
import * as picking from '../../data/pickRegistry.js'; import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js'; import * as overlays from '../../overlays/worldOverlay.js';
import { isPointerFree } from '../../data/inputOwnership.js';
/** Wire the S2 Underground GhostMaps layer into the application catalog. */ /** Wire the S2 Underground GhostMaps layer into the application catalog. */
export function createApplicationGhostMaps(options) { export function createApplicationGhostMaps(options) {
@@ -14,10 +12,7 @@ export function createApplicationGhostMaps(options) {
hitTest: overlays.hitTestWorldOverlay, hitTest: overlays.hitTestWorldOverlay,
}, },
openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'), openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
screenSpaceEventHandlerFactory: (viewer) =>
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
picking, picking,
pointer: { isPointerFree },
...options, ...options,
}); });
} }
-5
View File
@@ -1,4 +1,3 @@
import * as Cesium from 'cesium';
import { import {
createFirePerimetersLayer, createFirePerimetersLayer,
createInciwebIndexSource, createInciwebIndexSource,
@@ -6,7 +5,6 @@ import {
} from '../../layers/perimeters/index.js'; } from '../../layers/perimeters/index.js';
import * as picking from '../../data/pickRegistry.js'; import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js'; import * as overlays from '../../overlays/worldOverlay.js';
import { isPointerFree } from '../../data/inputOwnership.js';
/** Wire WFIGS fire perimeters into the application catalog. */ /** Wire WFIGS fire perimeters into the application catalog. */
export function createApplicationFirePerimeters(options) { export function createApplicationFirePerimeters(options) {
@@ -20,10 +18,7 @@ export function createApplicationFirePerimeters(options) {
inciwebSource: createInciwebIndexSource(), inciwebSource: createInciwebIndexSource(),
inciwebPublications: createInciwebPublicationSource(), inciwebPublications: createInciwebPublicationSource(),
openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'), openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
screenSpaceEventHandlerFactory: (viewer) =>
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
picking, picking,
pointer: { isPointerFree },
...options, ...options,
}); });
} }
+1 -2
View File
@@ -5,7 +5,6 @@ import { createKartaViewProvider } from '../../layers/streetLevel/providers/kart
import { createPanoramaxProvider } from '../../layers/streetLevel/providers/panoramax/index.js'; import { createPanoramaxProvider } from '../../layers/streetLevel/providers/panoramax/index.js';
import * as sprites from '../../data/spriteOrder.js'; import * as sprites from '../../data/spriteOrder.js';
import * as picking from '../../data/pickRegistry.js'; import * as picking from '../../data/pickRegistry.js';
import * as input from '../../data/inputOwnership.js';
import * as scenePick from '../../data/scenePick.js'; import * as scenePick from '../../data/scenePick.js';
import * as render from '../../renderGovernor.js'; import * as render from '../../renderGovernor.js';
@@ -37,6 +36,6 @@ export function createApplicationStreetLevel({
}) { }) {
return createStreetLevelLayer({ return createStreetLevelLayer({
providers, providers,
services: { sprites, picking, input, scenePick, render }, services: { sprites, picking, scenePick, render },
}); });
} }
-3
View File
@@ -1,12 +1,9 @@
import * as Cesium from 'cesium';
import { createSubmarineCableLayer } from '../../layers/submarineCables/index.js'; import { createSubmarineCableLayer } from '../../layers/submarineCables/index.js';
import { overlayHost } from './overlayHost.js'; import { overlayHost } from './overlayHost.js';
/** Wire cable source geometry to the application overlay host. */ /** Wire cable source geometry to the application overlay host. */
export function createApplicationCables(options) { export function createApplicationCables(options) {
return createSubmarineCableLayer({ return createSubmarineCableLayer({
overlayHost, overlayHost,
screenSpaceEventHandlerFactory: (canvas) =>
new Cesium.ScreenSpaceEventHandler(canvas),
mapStackEventTarget: typeof window !== 'undefined' ? window : null, mapStackEventTarget: typeof window !== 'undefined' ? window : null,
...options, ...options,
}); });
+4 -3
View File
@@ -8,6 +8,7 @@ import {
governorRequestRender, governorRequestRender,
getRenderGovernorDiagnostics, getRenderGovernorDiagnostics,
} from '../renderGovernor.js'; } from '../renderGovernor.js';
import { framePortFor } from '../testSupport/fakeFramePort.mjs';
test('destroy invalidates a pending imagery provider before it can touch the viewer', async () => { test('destroy invalidates a pending imagery provider before it can touch the viewer', async () => {
let resolveProvider; let resolveProvider;
@@ -44,11 +45,11 @@ test('uninstall only releases the current viewer and makes later render requests
}, },
}, },
}; };
installRenderGovernor(viewer); installRenderGovernor(framePortFor(viewer.scene));
holdContinuousRender('fixture'); holdContinuousRender('fixture');
uninstallRenderGovernor({}); uninstallRenderGovernor(framePortFor({}));
assert.equal(getRenderGovernorDiagnostics().installed, true); assert.equal(getRenderGovernorDiagnostics().installed, true);
uninstallRenderGovernor(viewer); uninstallRenderGovernor(framePortFor(viewer.scene));
const before = renders; const before = renders;
governorRequestRender('late'); governorRequestRender('late');
assert.equal(renders, before); assert.equal(renders, before);
+53 -16
View File
@@ -1,23 +1,32 @@
import { createApplicationOperations } from './operations.js'; import { createApplicationOperations } from './operations.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { import {
createApplicationViewer, cesiumRenderer,
installTrackpadPinchZoom, cesiumViewerOf,
} from '../app/viewer.js'; } from '../render/cesium/appRenderer.js';
import { registerDataCredits } from '../data/dataCredits.js'; import { registerDataCredits } from '../data/dataCredits.js';
import { configureCreditKeyboardAccess } from '../creditKeyboard.js'; import { configureCreditKeyboardAccess } from '../creditKeyboard.js';
import { MapStackController } from '../mapStackController.js'; import { MapStackController } from '../mapStackController.js';
import { loadPhotorealisticTileset } from '../mapStartup.js'; import { loadPhotorealisticTileset } from '../mapStartup.js';
import { initLogoGaze } from '../logoGaze.js'; import { initLogoGaze } from '../logoGaze.js';
import { inputFor } from '../input/dispatcher.js';
import { import {
holdContinuousRender,
installRenderGovernor,
uninstallRenderGovernor, uninstallRenderGovernor,
governorRequestRender, governorRequestRender,
} from '../renderGovernor.js'; } from '../renderGovernor.js';
import { describeError } from './errors.js'; import { describeError } from './errors.js';
import { onLocaleChange, t } from '../i18n/index.js'; import { onLocaleChange, t } from '../i18n/index.js';
/** Construct the application globe using the caller's local configuration. */ /**
* Construct the application globe using the caller's local configuration.
* `renderer` is the `RendererPlugin` that draws it (default: Cesium). The
* application still runs on the Cesium viewer until its layers are ported,
* so a plugin whose renderer has none is refused.
*/
export async function createApplicationScene({ export async function createApplicationScene({
renderer: rendererPlugin = cesiumRenderer,
requestServices, requestServices,
googleApiKey, googleApiKey,
googleTokens = null, googleTokens = null,
@@ -44,20 +53,41 @@ export async function createApplicationScene({
}); });
} }
loaderStatus.textContent = t('Configuring viewer...'); loaderStatus.textContent = t('Configuring viewer...');
// Provider attribution stays visible, including clean-view and recording. // The scene still loads the tiles, so their credentials stay here.
const creditContainer = document.createElement('div'); const renderer = await rendererPlugin.create({
creditContainer.id = 'cesium-credits'; container: document.getElementById('cesiumContainer'),
document.body.appendChild(creditContainer); signal,
defer(() => creditContainer.remove());
const viewer = createApplicationViewer({
container: 'cesiumContainer',
creditContainer,
}); });
const viewer = cesiumViewerOf(renderer);
if (!viewer) {
const refusal = new Error(
`The ${rendererPlugin.id} renderer cannot run the application yet`,
);
try {
renderer.destroy();
} catch (error) {
console.warn('[Init] Refused renderer failed to destroy:', error);
}
throw refusal;
}
// The governor keeps its holds on the renderer's frame port. The loop
// draws every frame until the application's tools are up (they release
// 'boot'), as it did before anything could hold it.
installRenderGovernor(renderer.frame);
holdContinuousRender('boot');
defer(() => { defer(() => {
uninstallRenderGovernor(viewer); try {
if (!viewer.isDestroyed()) viewer.destroy(); uninstallRenderGovernor(renderer.frame);
} finally {
renderer.destroy();
}
}); });
defer(installTrackpadPinchZoom(viewer)); // Before any layer binds a click: every layer and tool shares this one.
// Deferred after the viewer, so it is released before the viewer goes.
const input = inputFor(viewer);
defer(() => input.destroy());
// Ctrl+wheel and trackpad pinches zoom the globe, not the page.
renderer.input.ctrlWheelZoom = true;
registerDataCredits(viewer, credits); registerDataCredits(viewer, credits);
configureCreditKeyboardAccess(document); configureCreditKeyboardAccess(document);
defer(onLocaleChange(() => configureCreditKeyboardAccess(document))); defer(onLocaleChange(() => configureCreditKeyboardAccess(document)));
@@ -128,5 +158,12 @@ export async function createApplicationScene({
}); });
signal.throwIfAborted(); signal.throwIfAborted();
return { viewer, tileset, mapStackController, operations }; return {
renderer,
viewer,
tileset,
mapStackController,
operations,
input,
};
} }
+55 -12
View File
@@ -1,25 +1,68 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { flyToAustin } from '../camera.js'; import { flyToAustin } from '../camera.js';
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
test('teardown before the initial camera delay prevents a late flight', (t) => { test('teardown before the initial camera delay prevents a late flight', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] }); t.mock.timers.enable({ apis: ['setTimeout'] });
let flights = 0; let flights = 0;
let cancelled = 0; let cancelled = 0;
const stop = flyToAustin({ const stop = flyToAustin(
isDestroyed: () => false, bindFakeCesiumCamera({
camera: { isDestroyed: () => false,
setView() {}, camera: {
flyTo() { setView() {},
flights++; flyTo() {
flights++;
},
cancelFlight() {
cancelled++;
},
}, },
cancelFlight() { }),
cancelled++; );
},
},
});
stop(); stop();
t.mock.timers.tick(1000); t.mock.timers.tick(1000);
assert.equal(flights, 0); assert.equal(flights, 0);
assert.equal(cancelled, 1); assert.equal(cancelled, 0, 'no fly-in started, so none to cancel');
});
test('teardown cancels the fly-in, not a flight the user started since', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const cancelled = [];
let current = null;
const viewer = bindFakeCesiumCamera({
isDestroyed: () => false,
camera: {
get _currentFlight() {
return current;
},
setView() {},
flyTo(options) {
current?.cancel();
current = {
name: options.duration === 4 ? 'fly-in' : 'user',
...options,
};
},
cancelFlight() {
cancelled.push(current?.name);
const flight = current;
current = null;
flight?.cancel();
},
},
});
const stop = flyToAustin(viewer);
t.mock.timers.tick(500);
stop();
assert.deepEqual(cancelled, ['fly-in']);
// A user flight replaces the fly-in; teardown leaves it alone.
const second = flyToAustin(viewer);
t.mock.timers.tick(500);
viewer.camera.flyTo({ duration: 2 });
second();
assert.deepEqual(cancelled, ['fly-in']);
assert.equal(current.name, 'user');
}); });
+15 -14
View File
@@ -1,3 +1,4 @@
import { onFollowChanged } from '../data/followed.js';
import { SceneDirector } from '../scenes/director.js'; import { SceneDirector } from '../scenes/director.js';
import { initAnnotations } from '../annotations/index.js'; import { initAnnotations } from '../annotations/index.js';
import { initDrawTool } from '../annotations/drawTool.js'; import { initDrawTool } from '../annotations/drawTool.js';
@@ -11,12 +12,13 @@ import { installViews, isEmbeddedInline } from './embed.js';
import { installScopeMask, destroyScopeMask } from '../scopeMask.js'; import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
import { installLayerReleaseTrim } from '../sceneCommandRelease.js'; import { installLayerReleaseTrim } from '../sceneCommandRelease.js';
import { import {
installRenderGovernor,
getRenderGovernorDiagnostics, getRenderGovernorDiagnostics,
governorRequestRender, governorRequestRender,
holdContinuousRender, holdContinuousRender,
releaseContinuousRender, releaseContinuousRender,
} from '../renderGovernor.js'; } from '../renderGovernor.js';
import { entityRecord, readEntityRecord } from '../data/entityRecords.js';
import { overlayHost } from './layers/overlayHost.js';
/** Attach scene tools, rendering listeners and the application debug handle. */ /** Attach scene tools, rendering listeners and the application debug handle. */
export function createApplicationTools({ export function createApplicationTools({
@@ -32,11 +34,11 @@ export function createApplicationTools({
signal, signal,
defer, defer,
}) { }) {
const { viewer, tileset, mapStackController, operations } = scene; const { viewer, renderer, tileset, mapStackController, operations } = scene;
const { styleManager, weatherEffects, cockpitCloudEffects } = controls; const { styleManager, weatherEffects, cockpitCloudEffects } = controls;
const { dataManager } = data; const { dataManager } = data;
const sceneDirector = new SceneDirector(viewer, styleManager, dataManager, { const sceneDirector = new SceneDirector(viewer, styleManager, dataManager, {
dataPacks: sceneDataPacks, dataPacks: { overlayHost, ...sceneDataPacks },
isMapStackAvailable: (id) => isMapStackAvailable: (id) =>
mapStackController?.isStackAvailable(id) === true, mapStackController?.isStackAvailable(id) === true,
}); });
@@ -125,10 +127,10 @@ export function createApplicationTools({
} }
if (startChrome) if (startChrome)
defer(startChrome({ loadingScreen, styleManager, dataManager, signal })); defer(startChrome({ loadingScreen, styleManager, dataManager, signal }));
// Idle render governor: flips the scene into requestRenderMode whenever // Idle render governor: the loop draws on demand whenever nothing animates
// nothing animates per frame. Installed AFTER every module above has had // per frame. It goes idle only now, after every module above has had its
// its chance to register pre-install holds. (perf wave 2) // chance to hold. (perf wave 2)
installRenderGovernor(viewer); releaseContinuousRender('boot');
// Cesium keeps a turned-off layer's draw commands (and so all its entities) // Cesium keeps a turned-off layer's draw commands (and so all its entities)
// alive in reused per-pass arrays; trim them after each disable. // alive in reused per-pass arrays; trim them after each disable.
defer( defer(
@@ -145,12 +147,10 @@ export function createApplicationTools({
// The follow camera recomputes the tracked target's dead-reckon position // The follow camera recomputes the tracked target's dead-reckon position
// every frame — tracking anything is a per-frame animation. (perf wave 2) // every frame — tracking anything is a per-frame animation. (perf wave 2)
const removeTrackingListener = viewer.trackedEntityChanged.addEventListener( const removeTrackingListener = onFollowChanged(viewer, (followed) => {
() => { if (followed) holdContinuousRender('tracked-entity');
if (viewer.trackedEntity) holdContinuousRender('tracked-entity'); else releaseContinuousRender('tracked-entity');
else releaseContinuousRender('tracked-entity'); });
},
);
// Hidden-state suspension (perf wave 2): when the window/tab is hidden, // Hidden-state suspension (perf wave 2): when the window/tab is hidden,
// stop the default render loop outright — a hidden canvas repaints for // stop the default render loop outright — a hidden canvas repaints for
@@ -161,7 +161,7 @@ export function createApplicationTools({
// A panel's host may report it hidden while it is on screen; the panel // A panel's host may report it hidden while it is on screen; the panel
// keeps drawing itself (see keepPanelRendering in embed.js). // keeps drawing itself (see keepPanelRendering in embed.js).
const hidden = document.hidden && !isEmbeddedInline(); const hidden = document.hidden && !isEmbeddedInline();
viewer.useDefaultRenderLoop = !hidden; renderer.frame.suspend(hidden);
cockpitCloudEffects?.setSuspended?.(hidden); cockpitCloudEffects?.setSuspended?.(hidden);
if (!hidden) { if (!hidden) {
data.presentation.flushVisible(); data.presentation.flushVisible();
@@ -194,6 +194,7 @@ export function createApplicationTools({
getRenderGovernorDiagnostics, getRenderGovernorDiagnostics,
surfaceServices: operations.surface, surfaceServices: operations.surface,
requestRender: governorRequestRender, requestRender: governorRequestRender,
entityRecords: { read: readEntityRecord, write: entityRecord },
}; };
const debug = window.__godsEyeView; const debug = window.__godsEyeView;
defer(() => { defer(() => {
-101
View File
@@ -2,107 +2,6 @@ import * as Cesium from 'cesium';
import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js'; import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js';
import { StarrySky } from '../sky/starrySky.js'; import { StarrySky } from '../sky/starrySky.js';
const PINCH_ZOOM_MULTIPLIER = 8;
const MAX_PINCH_PIXEL_DELTA = 120;
function boundedPinchDelta(delta) {
if (!Number.isFinite(delta) || delta === 0) return delta;
return (
Math.sign(delta) *
Math.min(Math.abs(delta) * PINCH_ZOOM_MULTIPLIER, MAX_PINCH_PIXEL_DELTA)
);
}
/**
* Add browser trackpad pinch to Cesium's zoom inputs and return its disposer.
* Browsers expose this gesture as a small pixel-mode Ctrl+wheel event.
*/
export function installTrackpadPinchZoom(
viewer,
{ createWheelEvent = (type, init) => new WheelEvent(type, init) } = {},
) {
const controller = viewer?.scene?.screenSpaceCameraController;
const container = viewer?.container;
const canvas = viewer?.canvas;
if (!controller || !container || !canvas)
throw new TypeError('A complete Cesium viewer is required');
const originalZoomEventTypes = controller.zoomEventTypes;
const zoomEventTypes = Array.isArray(originalZoomEventTypes)
? originalZoomEventTypes
: originalZoomEventTypes === undefined
? []
: [originalZoomEventTypes];
const alreadyHandlesControlWheel = zoomEventTypes.some(
(binding) =>
binding?.eventType === Cesium.CameraEventType.WHEEL &&
binding?.modifier === Cesium.KeyboardEventModifier.CTRL,
);
const configuredZoomEventTypes = alreadyHandlesControlWheel
? originalZoomEventTypes
: [
...zoomEventTypes,
{
eventType: Cesium.CameraEventType.WHEEL,
modifier: Cesium.KeyboardEventModifier.CTRL,
},
];
if (!alreadyHandlesControlWheel)
controller.zoomEventTypes = configuredZoomEventTypes;
const relayedEvents = new WeakSet();
const relayPinch = (event) => {
if (
!event.ctrlKey ||
relayedEvents.has(event) ||
event.deltaMode !== 0 ||
!Number.isFinite(event.deltaY) ||
event.deltaY === 0
)
return;
let relayed;
try {
relayed = createWheelEvent('wheel', {
deltaX: event.deltaX,
deltaY: boundedPinchDelta(event.deltaY),
deltaZ: event.deltaZ,
deltaMode: event.deltaMode,
screenX: event.screenX,
screenY: event.screenY,
clientX: event.clientX,
clientY: event.clientY,
ctrlKey: true,
bubbles: true,
cancelable: true,
view: globalThis.window,
});
} catch {
// The registered Ctrl+wheel binding can still consume the original.
return;
}
relayedEvents.add(relayed);
event.preventDefault();
event.stopPropagation();
canvas.dispatchEvent(relayed);
};
container.addEventListener('wheel', relayPinch, {
capture: true,
passive: false,
});
let active = true;
return () => {
if (!active) return;
active = false;
container.removeEventListener('wheel', relayPinch, true);
if (
!alreadyHandlesControlWheel &&
controller.zoomEventTypes === configuredZoomEventTypes
)
controller.zoomEventTypes = originalZoomEventTypes;
};
}
/** Cesium.Viewer options of the standard globe viewer. */ /** Cesium.Viewer options of the standard globe viewer. */
export function applicationViewerOptions(creditContainer) { export function applicationViewerOptions(creditContainer) {
return { return {
+16 -18
View File
@@ -1,36 +1,34 @@
import * as Cesium from 'cesium'; import { toRadians } from './geo/index.js';
import { cameraOf } from './render/rendererOf.js';
/** /**
* Set camera to Austin on load with a cinematic fly-in. * Set camera to Austin on load with a cinematic fly-in.
* @returns {Function} Cancels the pending or active startup flight. * @returns {Function} Cancels the pending or active startup flight.
*/ */
export function flyToAustin(viewer) { export function flyToAustin(viewer) {
const camera = cameraOf(viewer);
// Start from a high altitude, then fly down // Start from a high altitude, then fly down
viewer.camera.setView({ camera.setView({
destination: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 25000), destination: { lon: -97.7431, lat: 30.2672, height: 25000 },
orientation: { orientation: { heading: 0, pitch: toRadians(-90), roll: 0 },
heading: Cesium.Math.toRadians(0),
pitch: Cesium.Math.toRadians(-90),
roll: 0.0,
},
}); });
// Cinematic fly-in after a brief pause // Cinematic fly-in after a brief pause
let flight = null;
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (viewer.isDestroyed()) return; if (viewer.isDestroyed()) return;
viewer.camera.flyTo({ flight = camera.flyTo(
destination: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 600), { lon: -97.7431, lat: 30.2672, height: 600 },
orientation: { {
heading: Cesium.Math.toRadians(15), orientation: { heading: toRadians(15), pitch: toRadians(-30), roll: 0 },
pitch: Cesium.Math.toRadians(-30), durationS: 4.0,
roll: 0.0, easing: 'cubicInOut',
}, },
duration: 4.0, );
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
});
}, 500); }, 500);
return () => { return () => {
clearTimeout(timer); clearTimeout(timer);
if (!viewer.isDestroyed()) viewer.camera.cancelFlight(); // Only the fly-in: a flight the user started since is theirs.
if (!viewer.isDestroyed()) flight?.cancel();
}; };
} }
+9 -5
View File
@@ -1,3 +1,4 @@
import { bindFakeCesiumCamera } from './testSupport/fakeCameraPort.mjs';
import { GLOBE_VIEW } from './locations.js'; import { GLOBE_VIEW } from './locations.js';
import { projectEarthDiscToViewport } from './celestialRing.js'; import { projectEarthDiscToViewport } from './celestialRing.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -146,13 +147,13 @@ function harness(
roll: radians(roll), roll: radians(roll),
}, },
}); });
const viewer = { const viewer = bindFakeCesiumCamera({
camera, camera,
scene, scene,
trackedEntity: undefined, trackedEntity: undefined,
trackedEntityChanged: new Cesium.Event(), trackedEntityChanged: new Cesium.Event(),
clock: { onTick: new Cesium.Event() }, clock: { onTick: new Cesium.Event() },
}; });
const target = Cesium.Cartesian3.fromDegrees( const target = Cesium.Cartesian3.fromDegrees(
longitudeDeg, longitudeDeg,
latitude, latitude,
@@ -288,9 +289,12 @@ test('replaced viewer releases the old tracking listener without duplicate subsc
true, true,
); );
const id = activeCameraMotionId(); const id = activeCameraMotionId();
first.viewer.trackedEntityChanged.raiseEvent({ id: 'stale-aircraft' }); // As Cesium's setter does: the value changes, then the event fires.
first.viewer.trackedEntity = { id: 'stale-aircraft' };
first.viewer.trackedEntityChanged.raiseEvent(first.viewer.trackedEntity);
assert.equal(activeCameraMotionId(), id); assert.equal(activeCameraMotionId(), id);
next.viewer.trackedEntityChanged.raiseEvent({ id: 'new-aircraft' }); next.viewer.trackedEntity = { id: 'new-aircraft' };
next.viewer.trackedEntityChanged.raiseEvent(next.viewer.trackedEntity);
assert.equal(getActiveCameraMotion(), null); assert.equal(getActiveCameraMotion(), null);
assert.equal(hasRenderHold(), false); assert.equal(hasRenderHold(), false);
}); });
@@ -597,7 +601,7 @@ for (const mode of ['once', 'continuous']) {
test('direct globe rotation refuses a tracked camera without disturbing its reference frame', (t) => { test('direct globe rotation refuses a tracked camera without disturbing its reference frame', (t) => {
const { viewer, camera, target } = harness(t); const { viewer, camera, target } = harness(t);
viewer.trackedEntity = { id: 'tracked-aircraft' }; viewer.trackedEntity = { id: 'tracked-aircraft', name: 'tracked-aircraft' };
camera.lookAt(target, new Cesium.HeadingPitchRange(0, radians(-40), 1_000)); camera.lookAt(target, new Cesium.HeadingPitchRange(0, radians(-40), 1_000));
const before = pose(camera); const before = pose(camera);
const transform = Cesium.Matrix4.clone(camera.transform); const transform = Cesium.Matrix4.clone(camera.transform);
+81 -49
View File
@@ -14,8 +14,12 @@
* lifts the eye if it sits below a usable clearance. Heading and pitch are * lifts the eye if it sits below a usable clearance. Heading and pitch are
* held, so the correction reads as the shot settling. * held, so the correction reads as the shot settling.
*/ */
import { onFollowChanged } from './data/followed.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { ownCameraArrival } from './data/cameraArrival.js'; import { ownCameraArrival } from './data/cameraArrival.js';
import { heightsFor } from './queries/heights.js';
import { cameraOf } from './render/cesium/camera.js';
import { cameraFlightActive } from './render/cesium/internals.js';
/** Minimum eye height above the rendered surface for a usable view. */ /** Minimum eye height above the rendered surface for a usable view. */
export const MIN_EYE_CLEARANCE_M = 120; export const MIN_EYE_CLEARANCE_M = 120;
@@ -24,6 +28,11 @@ const GUARD_ATTEMPTS = 8;
const GUARD_INTERVAL_MS = 600; const GUARD_INTERVAL_MS = 600;
/** Ignore sub-metre noise rather than nudging the camera forever. */ /** Ignore sub-metre noise rather than nudging the camera forever. */
const GUARD_EPSILON_M = 2; const GUARD_EPSILON_M = 2;
/** Eye travel (m) and turn (cosine) that mean another owner moved the camera. */
const MOVED_M = 0.01;
const TURNED_COS = 1 - 1e-9;
const groundScratch = new Cesium.Cartesian3();
const probeScratch = new Cesium.Cartographic(); const probeScratch = new Cesium.Cartographic();
@@ -37,22 +46,60 @@ const probeScratch = new Cesium.Cartographic();
function sampleSurfaceM(scene, cells) { function sampleSurfaceM(scene, cells) {
let heightM = Number.NaN; let heightM = Number.NaN;
let sampled = 0; let sampled = 0;
if (typeof scene?.sampleHeight !== 'function') return { heightM, sampled }; if (!scene) return { heightM, sampled };
const heights = heightsFor(scene);
for (const cell of cells) { for (const cell of cells) {
try { // A miss is a cell whose tiles are not ready.
const height = scene.sampleHeight( const height = heights.now(
Cesium.Cartographic.fromDegrees(cell.lon, cell.lat, 0, probeScratch), Cesium.Cartographic.fromDegrees(cell.lon, cell.lat, 0, probeScratch),
); );
if (!Number.isFinite(height)) continue; if (height === null) continue;
sampled += 1; sampled += 1;
heightM = Number.isFinite(heightM) ? Math.max(heightM, height) : height; heightM = Number.isFinite(heightM) ? Math.max(heightM, height) : height;
} catch {
/* tiles not ready for this cell */
}
} }
return { heightM, sampled }; return { heightM, sampled };
} }
/** The eye's ground point (height 0), its height, and which way it looks. */
function poseOf(camera) {
const carto =
camera?.positionWC && Cesium.Cartographic.fromCartesian(camera.positionWC);
if (!carto || !camera.directionWC || !camera.upWC) return null;
return {
ground: Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude),
height: carto.height,
direction: Cesium.Cartesian3.clone(camera.directionWC),
up: Cesium.Cartesian3.clone(camera.upWC),
};
}
/**
* Whether another owner has moved the camera off `pose`: across the ground,
* down, or around. A pure rise is not one: Cesium's terrain collision lifts a
* buried eye straight up as tiles load, which is the case the guard exists
* for, so `pose` follows the highest eye seen and the guard keeps measuring.
*/
function moved(camera, pose) {
const carto = Cesium.Cartographic.fromCartesian(camera.positionWC);
if (!carto) return true;
const ground = Cesium.Cartesian3.fromRadians(
carto.longitude,
carto.latitude,
0,
undefined,
groundScratch,
);
if (
Cesium.Cartesian3.distance(ground, pose.ground) > MOVED_M ||
carto.height < pose.height - MOVED_M ||
Cesium.Cartesian3.dot(camera.directionWC, pose.direction) < TURNED_COS ||
Cesium.Cartesian3.dot(camera.upWC, pose.up) < TURNED_COS
)
return true;
pose.height = Math.max(pose.height, carto.height);
return false;
}
/** /**
* How far the camera must rise to clear the ground, given a measured surface. * How far the camera must rise to clear the ground, given a measured surface.
* Pure so the policy is testable without a scene. * Pure so the policy is testable without a scene.
@@ -76,8 +123,10 @@ export function groundClearanceDeficitM(
* Watch an arrival and lift the camera once the real surface can be measured. * Watch an arrival and lift the camera once the real surface can be measured.
* *
* Stops quietly on a clear view, exhausted attempts, the user taking the * Stops quietly on a clear view, exhausted attempts, the user taking the
* controls (pointer or wheel on the canvas), or `isStale()` reporting that a * controls (pointer or wheel on the canvas), `isStale()` reporting that a
* newer arrival owns the camera. * newer arrival owns the camera, or any other caller moving the camera or
* starting a flight in the meantime: the guard only ever corrects the pose it
* was started on.
* @param {Cesium.Viewer} viewer Live viewer. * @param {Cesium.Viewer} viewer Live viewer.
* @param {{lat: number, lon: number}} at Destination being framed. * @param {{lat: number, lon: number}} at Destination being framed.
* @param {{clearanceM?: number, attempts?: number, intervalMs?: number, * @param {{clearanceM?: number, attempts?: number, intervalMs?: number,
@@ -95,6 +144,10 @@ export function guardCameraAboveGround(viewer, at, options = {}) {
if (!viewer?.scene || !Number.isFinite(at?.lat) || !Number.isFinite(at?.lon)) if (!viewer?.scene || !Number.isFinite(at?.lat) || !Number.isFinite(at?.lon))
return () => {}; return () => {};
// Without a readable pose the guard could not tell a later owner's move.
const arrival = poseOf(viewer.camera);
if (!arrival) return () => {};
let timer = null; let timer = null;
let done = false; let done = false;
const canvas = viewer.scene.canvas; const canvas = viewer.scene.canvas;
@@ -112,32 +165,7 @@ export function guardCameraAboveGround(viewer, at, options = {}) {
release(); release();
}; };
release = ownCameraArrival(viewer, stop); release = ownCameraArrival(viewer, stop);
// Direct camera API callers (including duration-zero flights) must yield removers.push(onFollowChanged(viewer, stop));
// before moving the eye. Restore the exact method ownership on completion.
for (const name of [
'flyTo',
'flyToBoundingSphere',
'setView',
'lookAt',
'cancelFlight',
]) {
const camera = viewer.camera;
const original = camera[name];
if (typeof original !== 'function') continue;
const descriptor = Object.getOwnPropertyDescriptor(camera, name);
const wrapped = function (...args) {
stop();
return original.apply(this, args);
};
camera[name] = wrapped;
removers.push(() => {
if (camera[name] !== wrapped) return;
if (descriptor) Object.defineProperty(camera, name, descriptor);
else delete camera[name];
});
}
const removeTracking = viewer.trackedEntityChanged?.addEventListener(stop);
if (removeTracking) removers.push(removeTracking);
documentRef?.addEventListener?.('keydown', stop, true); documentRef?.addEventListener?.('keydown', stop, true);
// A manual gesture ends the guard, as it interrupts camera motion elsewhere. // A manual gesture ends the guard, as it interrupts camera motion elsewhere.
for (const type of ['pointerdown', 'wheel']) for (const type of ['pointerdown', 'wheel'])
@@ -147,8 +175,10 @@ export function guardCameraAboveGround(viewer, at, options = {}) {
const attempt = () => { const attempt = () => {
if (done) return; if (done) return;
if (isStale() || viewer.isDestroyed?.()) return stop(); if (isStale() || viewer.isDestroyed?.()) return stop();
remaining -= 1;
const camera = viewer.camera; const camera = viewer.camera;
// Another caller owns the camera now; lifting would undo its move.
if (cameraFlightActive(camera) || moved(camera, arrival)) return stop();
remaining -= 1;
const carto = camera.positionCartographic; const carto = camera.positionCartographic;
// The eye must clear both the framed target and the ground beneath it. // The eye must clear both the framed target and the ground beneath it.
const below = { const below = {
@@ -167,20 +197,22 @@ export function guardCameraAboveGround(viewer, at, options = {}) {
); );
if (deficit > 0) { if (deficit > 0) {
stop(); stop();
camera.flyTo({ cameraOf(viewer).flyTo(
destination: Cesium.Cartesian3.fromRadians( Cesium.Cartesian3.fromRadians(
carto.longitude, carto.longitude,
carto.latitude, carto.latitude,
carto.height + deficit, carto.height + deficit,
), ),
orientation: { {
heading: camera.heading, orientation: {
pitch: camera.pitch, heading: camera.heading,
roll: camera.roll, pitch: camera.pitch,
roll: camera.roll,
},
durationS: 0.9,
easing: 'cubicInOut',
}, },
duration: 0.9, );
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
});
try { try {
onLift?.(deficit); onLift?.(deficit);
} catch { } catch {
+146 -22
View File
@@ -3,6 +3,7 @@
// Arrival framing is computed before the destination's tiles exist, so it // Arrival framing is computed before the destination's tiles exist, so it
// works from a predicted ground height. The guard measures the real surface // works from a predicted ground height. The guard measures the real surface
// after arrival and lifts the eye; these tests pin when it acts. // after arrival and lifts the eye; these tests pin when it acts.
import { bindFakeCesiumCamera } from './testSupport/fakeCameraPort.mjs';
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
@@ -49,8 +50,12 @@ test('the clearance frames a subject without turning into an overflight', () =>
function fakeViewer({ cameraHeightM, surfaceM }) { function fakeViewer({ cameraHeightM, surfaceM }) {
const flights = []; const flights = [];
const position = Cesium.Cartographic.fromDegrees(-97.7603, 30.3141, cameraHeightM); const position = Cesium.Cartographic.fromDegrees(
return { -97.7603,
30.3141,
cameraHeightM,
);
return bindFakeCesiumCamera({
flights, flights,
scene: { scene: {
canvas: null, canvas: null,
@@ -59,12 +64,19 @@ function fakeViewer({ cameraHeightM, surfaceM }) {
}, },
camera: { camera: {
positionCartographic: position, positionCartographic: position,
positionWC: Cesium.Cartographic.toCartesian(position),
directionWC: new Cesium.Cartesian3(0, 0, -1),
upWC: new Cesium.Cartesian3(0, 1, 0),
heading: 0.5, heading: 0.5,
pitch: -0.55, pitch: -0.55,
roll: 0, roll: 0,
flyTo: (options) => flights.push(options), // As Cesium does: a flight is under way until it completes.
flyTo(options) {
flights.push(options);
this._currentFlight = {};
},
}, },
}; });
} }
test('the guard lifts a buried arrival once the surface answers', async () => { test('the guard lifts a buried arrival once the surface answers', async () => {
@@ -104,7 +116,11 @@ test('the guard waits for streaming tiles and yields to a newer arrival', async
test('the guard leaves a clear view untouched', async () => { test('the guard leaves a clear view untouched', async () => {
const viewer = fakeViewer({ cameraHeightM: 400, surfaceM: 171 }); const viewer = fakeViewer({ cameraHeightM: 400, surfaceM: 171 });
guardCameraAboveGround(viewer, { lat: 30.3125, lon: -97.765 }, { intervalMs: 1 }); guardCameraAboveGround(
viewer,
{ lat: 30.3125, lon: -97.765 },
{ intervalMs: 1 },
);
await new Promise((resolve) => setTimeout(resolve, 20)); await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(viewer.flights.length, 0); assert.equal(viewer.flights.length, 0);
}); });
@@ -122,33 +138,76 @@ function handoffFixture(t) {
viewer.trackedEntityChanged = new Cesium.Event(); viewer.trackedEntityChanged = new Cesium.Event();
viewer.camera.cancelFlight = () => {}; viewer.camera.cancelFlight = () => {};
viewer.camera.lookAtTransform = () => {}; viewer.camera.lookAtTransform = () => {};
viewer.camera.flyToBoundingSphere = () => {}; viewer.camera.flyToBoundingSphere = function () {
viewer.camera.setView = () => {}; this._currentFlight = {};
};
viewer.camera.setView = function () {
this.positionWC = Cesium.Cartesian3.add(
this.positionWC,
new Cesium.Cartesian3(0, 0, 50),
new Cesium.Cartesian3(),
);
};
const originalFly = viewer.camera.flyTo; const originalFly = viewer.camera.flyTo;
const navigation = new NavigationController({ const navigation = new NavigationController({
viewer, viewer,
tracking: Object.fromEntries(['flightsLayer', 'militaryFlightsLayer', tracking: Object.fromEntries(
'satellitesLayer', 'aisLiveVesselsLayer', 'militaryAwarenessLayer', [
'rocketLaunchesLayer'].map((key) => [key, {}])), 'flightsLayer',
cancelOrientation() {}, clearLocation() {}, cancelShareSelection() {}, 'militaryFlightsLayer',
getDataManager() {}, interruptCameraMotion() {}, stopOrbit() {}, 'satellitesLayer',
isCockpitActive: () => false, showToast() {}, 'aisLiveVesselsLayer',
'militaryAwarenessLayer',
'rocketLaunchesLayer',
].map((key) => [key, {}]),
),
cancelOrientation() {},
clearLocation() {},
cancelShareSelection() {},
getDataManager() {},
interruptCameraMotion() {},
stopOrbit() {},
isCockpitActive: () => false,
showToast() {},
}); });
guardCameraAboveGround(viewer, { lat: 30.3, lon: -97.7 }); guardCameraAboveGround(viewer, { lat: 30.3, lon: -97.7 });
return { viewer, navigation, originalFly }; return { viewer, navigation, originalFly };
} }
for (const handoff of ['Director', 'tracking', 'follow', 'keyboard', 'UI', for (const handoff of [
'flyTo', 'flyToBoundingSphere', 'setView', 'layer-disable', 'layer-destroy', 'app-stop']) { 'Director',
'tracking',
'follow',
'keyboard',
'UI',
'flyTo',
'flyToBoundingSphere',
'setView',
'layer-disable',
'layer-destroy',
'app-stop',
]) {
test(`arrival guard relinquishes and removes hooks on ${handoff}`, async (t) => { test(`arrival guard relinquishes and removes hooks on ${handoff}`, async (t) => {
const { viewer, navigation, originalFly } = handoffFixture(t); const { viewer, navigation, originalFly } = handoffFixture(t);
const methods = [
'flyTo',
'flyToBoundingSphere',
'setView',
'lookAt',
'cancelFlight',
].map((name) => viewer.camera[name]);
if (handoff === 'Director') { if (handoff === 'Director') {
SceneDirector.prototype._claimCameraOwnership.call({ SceneDirector.prototype._claimCameraOwnership.call({
viewer, styleManager: { runImmediateNavigation: (noun, fn) => viewer,
navigation._runExplicitNavigation(noun, fn) }, styleManager: {
runImmediateNavigation: (noun, fn) =>
navigation._runExplicitNavigation(noun, fn),
},
}); });
} else if (handoff === 'tracking') { } else if (handoff === 'tracking') {
viewer.trackedEntityChanged.raiseEvent({ id: 'contact' }); // As Cesium's setter does: the value changes, then the event fires.
viewer.trackedEntity = { id: 'contact' };
viewer.trackedEntityChanged.raiseEvent(viewer.trackedEntity);
} else if (handoff === 'follow' || handoff === 'UI') { } else if (handoff === 'follow' || handoff === 'UI') {
navigation._stampNavigation({ cancelPendingSelection: false }); navigation._stampNavigation({ cancelPendingSelection: false });
} else if (handoff === 'keyboard') { } else if (handoff === 'keyboard') {
@@ -156,16 +215,81 @@ for (const handoff of ['Director', 'tracking', 'follow', 'keyboard', 'UI',
} else if (handoff === 'app-stop') navigation.stop(); } else if (handoff === 'app-stop') navigation.stop();
else if (handoff.startsWith('layer-')) { else if (handoff.startsWith('layer-')) {
const lifecycle = new LayerLifecycle(viewer); const lifecycle = new LayerLifecycle(viewer);
lifecycle.register({ id: 'test', init() {}, enable() {}, disable() {}, destroy() {} }); lifecycle.register({
id: 'test',
init() {},
enable() {},
disable() {},
destroy() {},
});
await lifecycle.setEnabled('test', true); await lifecycle.setEnabled('test', true);
if (handoff === 'layer-disable') await lifecycle.setEnabled('test', false); if (handoff === 'layer-disable')
await lifecycle.setEnabled('test', false);
else await lifecycle.destroyLayer('test'); else await lifecycle.destroyLayer('test');
} else viewer.camera[handoff]({}); } else viewer.camera[handoff]({});
// A direct flyTo is itself one recorded flight; the guard adds none.
const priorFlights = viewer.flights.length; const priorFlights = viewer.flights.length;
t.mock.timers.tick(10000); t.mock.timers.tick(10000);
assert.equal(viewer.flights.length, priorFlights, 'the old probe cannot fly'); assert.equal(
assert.equal(viewer.camera.flyTo, originalFly, 'direct-call hook is removed'); viewer.flights.length,
priorFlights,
'the old probe cannot fly',
);
assert.equal(viewer.camera.flyTo, originalFly);
assert.deepEqual(
['flyTo', 'flyToBoundingSphere', 'setView', 'lookAt', 'cancelFlight'].map(
(name) => viewer.camera[name],
),
methods,
'the guard never replaces camera methods',
);
assert.equal(viewer.trackedEntityChanged.numberOfListeners, 0); assert.equal(viewer.trackedEntityChanged.numberOfListeners, 0);
navigation.destroy(); navigation.destroy();
}); });
} }
test('the guard yields to a camera moved without any camera method', async () => {
const viewer = fakeViewer({ cameraHeightM: 172, surfaceM: 171 });
guardCameraAboveGround(
viewer,
{ lat: 30.3125, lon: -97.765 },
{ intervalMs: 5 },
);
// A cockpit or follow frame writes the pose directly.
viewer.camera.directionWC = new Cesium.Cartesian3(1, 0, 0);
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(viewer.flights.length, 0, 'the moved view is not corrected');
});
test('Cesium lifting a buried eye straight up is not another owner', async () => {
const viewer = fakeViewer({ cameraHeightM: 104, surfaceM: 171 });
guardCameraAboveGround(
viewer,
{ lat: 30.3125, lon: -97.765 },
{ intervalMs: 5 },
);
// Terrain collision raises the eye to just above the loaded ground.
const raised = Cesium.Cartographic.fromDegrees(-97.7603, 30.3141, 172);
viewer.camera.positionCartographic = raised;
viewer.camera.positionWC = Cesium.Cartographic.toCartesian(raised);
await new Promise((resolve) => setTimeout(resolve, 30));
assert.equal(viewer.flights.length, 1, 'the guard still lifts the eye');
const lifted = Cesium.Cartographic.fromCartesian(
viewer.flights[0].destination,
);
assert.ok(Math.abs(lifted.height - (171 + MIN_EYE_CLEARANCE_M)) < 0.5);
});
test('another owner lowering the eye is not corrected', async () => {
const viewer = fakeViewer({ cameraHeightM: 172, surfaceM: 171 });
guardCameraAboveGround(
viewer,
{ lat: 30.3125, lon: -97.765 },
{ intervalMs: 5 },
);
const lowered = Cesium.Cartographic.fromDegrees(-97.7603, 30.3141, 150);
viewer.camera.positionCartographic = lowered;
viewer.camera.positionWC = Cesium.Cartographic.toCartesian(lowered);
await new Promise((resolve) => setTimeout(resolve, 30));
assert.equal(viewer.flights.length, 0);
});
+9 -9
View File
@@ -44,8 +44,8 @@ test('Cockpit takeover invalidates deferred work before camera cancellation', ()
ordered(enter, [ ordered(enter, [
'if (!info || !entity?.position) return false;', 'if (!info || !entity?.position) return false;',
'this.onCameraTakeover?.();', 'this.onCameraTakeover?.();',
'this.viewer.camera.cancelFlight();', 'cameraOf(this.viewer).cancelFlight();',
'this.viewer.trackedEntity = undefined;', 'stopFollowing(this.viewer);',
], 'Cockpit takeover'); ], 'Cockpit takeover');
assert.match( assert.match(
ui, ui,
@@ -97,7 +97,7 @@ test('voice Cockpit entry reaches the camera only through stamping seams', () =>
// The transaction has exactly two camera-owner mutations, and each one // The transaction has exactly two camera-owner mutations, and each one
// stamps: // stamps:
// 1. selectedLayer.trackById(id) -> viewer.trackedEntity // 1. selectedLayer.trackById(id) -> viewer.trackedEntity
// -> viewer.trackedEntityChanged -> _stampNavigation() // -> onFollowChanged(viewer) -> _stampNavigation()
// 2. cockpitView.enter() -> onCameraTakeover() -> _stampNavigation() // 2. cockpitView.enter() -> onCameraTakeover() -> _stampNavigation()
const transaction = body( const transaction = body(
cockpitTracking, cockpitTracking,
@@ -117,7 +117,7 @@ test('voice Cockpit entry reaches the camera only through stamping seams', () =>
// Seam 1: any tracker handoff stamps, so the adoption step is covered. // Seam 1: any tracker handoff stamps, so the adoption step is covered.
assert.match( assert.match(
ui, ui,
/viewer\.trackedEntityChanged\.addEventListener\(\s*\(entity\) => \{\s*if \(entity && !this\._disposed\)\s*this\._stampNavigation\(\{\s*cancelPendingSelection: false,?\s*\}\);/, /onFollowChanged\(\s*this\.viewer,\s*\(followed\) => \{\s*if \(followed && !this\._disposed\)\s*this\._stampNavigation\(\{\s*cancelPendingSelection: false,?\s*\}\);/,
'tracker handoff must stamp', 'tracker handoff must stamp',
); );
// Seam 2 is pinned by "Cockpit takeover invalidates deferred work" above. // Seam 2 is pinned by "Cockpit takeover invalidates deferred work" above.
@@ -190,10 +190,10 @@ test('accepted navigation releases through PR15-aware ownership before flight',
'origin: trackingOrigin', 'origin: trackingOrigin',
'satellitesLayer.stopTracking?.({ origin: trackingOrigin })', 'satellitesLayer.stopTracking?.({ origin: trackingOrigin })',
'rocketLaunchesLayer.releaseCameraOwnership?.()', 'rocketLaunchesLayer.releaseCameraOwnership?.()',
'this.viewer.trackedEntity = undefined;', 'stopFollowing(this.viewer);',
"interruptCameraMotion('explicit-navigation')", "interruptCameraMotion('explicit-navigation')",
'this.viewer.camera.cancelFlight();', 'camera?.cancelFlight();',
'this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);', 'camera?.release();',
], 'follow release'); ], 'follow release');
assert.match(release, /flightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/); assert.match(release, /flightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/);
assert.match(release, /militaryFlightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/); assert.match(release, /militaryFlightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/);
@@ -389,7 +389,7 @@ test('vessel and fire layers announce valid clicks and never fly cameras', () =>
} }
const vesselClick = body( const vesselClick = body(
vessels, vessels,
/handler\.setInputAction\(\s*\(click\) => \{([\s\S]*?)\n\s*\},\s*Cesium\.ScreenSpaceEventType\.LEFT_CLICK,?\s*\);/, /inputFor\(viewer\)\.onClick\(\(event\) => \{([\s\S]*?)\n \}\);/,
'vessel click', 'vessel click',
); );
ordered(vesselClick, [ ordered(vesselClick, [
@@ -405,7 +405,7 @@ test('vessel and fire layers announce valid clicks and never fly cameras', () =>
assert.match(vesselFocus, /requestWorldFocus\(\{/); assert.match(vesselFocus, /requestWorldFocus\(\{/);
const fireClick = body( const fireClick = body(
firms, firms,
/_clickHandler\.setInputAction\(\(click\) => \{([\s\S]*?)\n \}, Cesium\.ScreenSpaceEventType\.LEFT_CLICK\);/, /input\.onClick\(\(event\) => \{([\s\S]*?)\n \}\);/,
'fire click', 'fire click',
); );
ordered(fireClick, [ ordered(fireClick, [
+42 -46
View File
@@ -18,6 +18,7 @@
* globe motion, opposite to the camera's travel around Earth. * globe motion, opposite to the camera's travel around Earth.
*/ */
import { followedOf, isFollowing, onFollowChanged } from './data/followed.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { GLOBE_VIEW } from './locations.js'; import { GLOBE_VIEW } from './locations.js';
import { import {
@@ -29,6 +30,9 @@ import {
holdContinuousRender, holdContinuousRender,
releaseContinuousRender, releaseContinuousRender,
} from './renderGovernor.js'; } from './renderGovernor.js';
import { heightsFor } from './queries/heights.js';
import { cameraOf } from './render/cesium/camera.js';
import { findCamera } from './render/rendererOf.js';
let _isCockpitActive = null; let _isCockpitActive = null;
/** °/s by speed word — orbit; pan uses fractions of view height/s. */ /** °/s by speed word — orbit; pan uses fractions of view height/s. */
@@ -559,25 +563,18 @@ function acquireCorridorFloor(state, takeWhole) {
export function probeMeshFloorM(scene, cells) { export function probeMeshFloorM(scene, cells) {
const requested = Array.isArray(cells) ? cells.length : 0; const requested = Array.isArray(cells) ? cells.length : 0;
const empty = { heightM: Number.NaN, sampled: 0, requested }; const empty = { heightM: Number.NaN, sampled: 0, requested };
if (!scene || typeof scene.sampleHeight !== 'function' || !requested) if (!scene || !requested) return empty;
return empty; const heights = heightsFor(scene);
let heightM = Number.NaN; let heightM = Number.NaN;
let sampled = 0; let sampled = 0;
for (const cell of cells) { for (const cell of cells) {
try { // A miss is a cell whose tiles are not ready.
const carto = Cesium.Cartographic.fromDegrees( const height = heights.now(
cell.lon, Cesium.Cartographic.fromDegrees(cell.lon, cell.lat, 0, _probeCarto),
cell.lat, );
0, if (height === null) continue;
_probeCarto, sampled += 1;
); heightM = Number.isFinite(heightM) ? Math.max(heightM, height) : height;
const height = scene.sampleHeight(carto);
if (!Number.isFinite(height)) continue;
sampled += 1;
heightM = Number.isFinite(heightM) ? Math.max(heightM, height) : height;
} catch {
/* tiles not ready for this cell */
}
} }
return { heightM, sampled, requested }; return { heightM, sampled, requested };
} }
@@ -1010,7 +1007,7 @@ export function interruptCameraMotionIfActive(
function clearLookAt() { function clearLookAt() {
try { try {
_viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); findCamera(_viewer)?.release();
} catch { } catch {
/* teardown race */ /* teardown race */
} }
@@ -1028,13 +1025,11 @@ function clearLookAt() {
*/ */
function levelCameraRoll() { function levelCameraRoll() {
try { try {
const cam = _viewer?.camera; const camera = findCamera(_viewer);
if (!cam || typeof cam.setView !== 'function') return false; if (!camera) return false;
const roll = Cesium.Math.negativePiToPi(cam.roll || 0); const { heading, pitch, roll } = camera.pose();
if (Math.abs(roll) < 1e-5) return false; if (Math.abs(Cesium.Math.negativePiToPi(roll || 0)) < 1e-5) return false;
cam.setView({ camera.setView({ orientation: { heading, pitch, roll: 0 } });
orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 },
});
return true; return true;
} catch { } catch {
return false; // teardown race return false; // teardown race
@@ -1047,7 +1042,7 @@ export function interruptCameraMotion(reason = 'interrupt') {
// While an entity is TRACKED, the follow camera owns the reference frame — // While an entity is TRACKED, the follow camera owns the reference frame —
// resetting the lookAt transform here flings the camera into space (field // resetting the lookAt transform here flings the camera into space (field
// finding). Leave the transform to the tracker; it re-asserts every frame. // finding). Leave the transform to the tracker; it re-asserts every frame.
const tracking = Boolean(_viewer?.trackedEntity); const tracking = isFollowing(_viewer);
const wasRoute = wasActive && _active.kind === 'route'; const wasRoute = wasActive && _active.kind === 'route';
if ( if (
wasActive && wasActive &&
@@ -1087,6 +1082,9 @@ export function getActiveCameraMotion() {
return info; return info;
} }
/** A pan's direction as the camera move it makes; any other is down. */
const PAN_MOVES = Object.freeze({ left: 'left', right: 'right', up: 'up' });
function onTick() { function onTick() {
if (!_active || !_viewer) return; if (!_active || !_viewer) return;
// Wall-clock dt: clock.currentTime FREEZES when the app clock isn't // Wall-clock dt: clock.currentTime FREEZES when the app clock isn't
@@ -1097,6 +1095,8 @@ function onTick() {
Math.max(0.001, (nowMs - (_active.lastMs ?? nowMs)) / 1000), Math.max(0.001, (nowMs - (_active.lastMs ?? nowMs)) / 1000),
); );
_active.lastMs = nowMs; _active.lastMs = nowMs;
// Reads stay on Cesium's camera until the query port has them; every
// write goes through the camera port.
const cam = _viewer.camera; const cam = _viewer.camera;
const m = _active; const m = _active;
@@ -1112,6 +1112,7 @@ function onTick() {
} }
try { try {
const camera = cameraOf(_viewer);
if (m.kind === 'orbit' && m.pending) { if (m.kind === 'orbit' && m.pending) {
// Armed but target-less (started mid-flight, e.g. "fly to X and orbit // Armed but target-less (started mid-flight, e.g. "fly to X and orbit
// it"): keep trying for a ground target until the camera settles. // it"): keep trying for a ground target until the camera settles.
@@ -1161,7 +1162,7 @@ function onTick() {
} else { } else {
m.hpr.heading += rate * dt; m.hpr.heading += rate * dt;
} }
cam.lookAt(m.target, m.hpr); camera.lookAt(m.target, m.hpr);
} else if (m.kind === 'pan') { } else if (m.kind === 'pan') {
const heightM = Math.max(50, cam.positionCartographic.height); const heightM = Math.max(50, cam.positionCartographic.height);
const step = const step =
@@ -1169,10 +1170,7 @@ function onTick() {
? heightM * ONCE.panViewFraction * (scale - (m.lastScale || 0)) ? heightM * ONCE.panViewFraction * (scale - (m.lastScale || 0))
: heightM * PAN_VIEW_FRACTION_S[m.speed] * dt; : heightM * PAN_VIEW_FRACTION_S[m.speed] * dt;
m.lastScale = scale; m.lastScale = scale;
if (m.direction === 'left') cam.moveLeft(step); camera.move(PAN_MOVES[m.direction] ?? 'down', step);
else if (m.direction === 'right') cam.moveRight(step);
else if (m.direction === 'up') cam.moveUp(step);
else cam.moveDown(step);
} else if ( } else if (
m.kind === 'tilt' || m.kind === 'tilt' ||
m.kind === 'rotate' || m.kind === 'rotate' ||
@@ -1193,7 +1191,7 @@ function onTick() {
// only describes Cesium's rotation while the frame is identity. // only describes Cesium's rotation while the frame is identity.
if ( if (
_viewer.scene.mode !== Cesium.SceneMode.SCENE3D || _viewer.scene.mode !== Cesium.SceneMode.SCENE3D ||
_viewer.trackedEntity || isFollowing(_viewer) ||
_isCockpitActive?.() || _isCockpitActive?.() ||
!Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY) !Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY)
) { ) {
@@ -1217,7 +1215,7 @@ function onTick() {
interruptCameraMotion('globe-surface-limit'); interruptCameraMotion('globe-surface-limit');
return; return;
} }
cam.rotate(m.rotationAxis, angle); camera.rotateAround(m.rotationAxis, angle);
} else if (m.kind === 'tilt') { } else if (m.kind === 'tilt') {
const up = m.direction === 'up'; const up = m.direction === 'up';
// Clamp pitch to [-89°, -5°]: never through the ground, never at the sky. // Clamp pitch to [-89°, -5°]: never through the ground, never at the sky.
@@ -1226,10 +1224,8 @@ function onTick() {
interruptCameraMotion('tilt-clamp'); interruptCameraMotion('tilt-clamp');
return; return;
} }
if (up) cam.lookUp(stepRad); camera.look(up ? 'up' : 'down', stepRad);
else cam.lookDown(stepRad); } else camera.look(m.direction === 'left' ? 'left' : 'right', stepRad);
} else if (m.direction === 'left') cam.lookLeft(stepRad);
else cam.lookRight(stepRad);
} else if (m.kind === 'route') { } else if (m.kind === 'route') {
// The dolly's shaping lives in advanceRouteFlight(); the tick only // The dolly's shaping lives in advanceRouteFlight(); the tick only
// applies the pose and honours the same completion path as before. // applies the pose and honours the same completion path as before.
@@ -1238,7 +1234,7 @@ function onTick() {
// user left it rather than teleporting to an altitude derived from // user left it rather than teleporting to an altitude derived from
// nothing. An interrupt during the arm is still instant. // nothing. An interrupt during the arm is still instant.
if (frame.arming) return; if (frame.arming) return;
cam.setView({ camera.setView({
destination: frame.eye, destination: frame.eye,
orientation: { direction: frame.direction, up: frame.up }, orientation: { direction: frame.direction, up: frame.up },
}); });
@@ -1418,8 +1414,8 @@ export function initCameraVerbs(
if (_trackingRemover) _trackingRemover(); if (_trackingRemover) _trackingRemover();
// Cockpit can acquire then clear tracking before the next clock tick. // Cockpit can acquire then clear tracking before the next clock tick.
// Yield on acquisition itself without changing legacy camera motions. // Yield on acquisition itself without changing legacy camera motions.
_trackingRemover = viewer.trackedEntityChanged?.addEventListener((entity) => { _trackingRemover = onFollowChanged(viewer, (followed) => {
if (entity && _active?.kind === 'rotate_globe') if (followed && _active?.kind === 'rotate_globe')
interruptCameraMotion('globe-camera-owner-changed'); interruptCameraMotion('globe-camera-owner-changed');
}); });
for (const rm of _inputRemovers) rm(); for (const rm of _inputRemovers) rm();
@@ -1527,15 +1523,14 @@ export function moveCamera(args = {}, runNavigation = null) {
if ( if (
(motion === 'rotate_globe' || (motion === 'rotate_globe' ||
(motion === 'orbit' && typeof runNavigation !== 'function')) && (motion === 'orbit' && typeof runNavigation !== 'function')) &&
_viewer?.trackedEntity isFollowing(_viewer)
) { ) {
// The follow camera owns a tracked view; a lookAt orbit fights it frame // The follow camera owns a tracked view; a lookAt orbit fights it frame
// by frame and stop rips the camera out (field finding). Honest refusal // by frame and stop rips the camera out (field finding). Honest refusal
// until tracked-orbit rides the follow camera natively (roadmap). // until tracked-orbit rides the follow camera natively (roadmap).
const followed = followedOf(_viewer);
const label = const label =
_viewer.trackedEntity?.name || followed?.name || followed?.labelModel?.title || 'the tracked target';
_viewer.trackedEntity?.id ||
'the tracked target';
return { return {
ok: false, ok: false,
action: 'move_camera', action: 'move_camera',
@@ -1618,9 +1613,10 @@ export function moveCamera(args = {}, runNavigation = null) {
if (motion === 'rotate_globe') { if (motion === 'rotate_globe') {
// Convert an inherited ground-target frame without changing the world // Convert an inherited ground-target frame without changing the world
// pose. Rotation must pivot about Earth, never the previous local target. // pose. Rotation must pivot about Earth, never the previous local target.
const camera = cameraOf(_viewer);
camera.cancelFlight();
camera.release();
const cam = _viewer.camera; const cam = _viewer.camera;
cam.cancelFlight();
cam.lookAtTransform(Cesium.Matrix4.IDENTITY);
// Handoff may level a route's bank. Keep the caller's exact world pose. // Handoff may level a route's bank. Keep the caller's exact world pose.
// Copy the basis directly: setView's heading/pitch conversion is singular // Copy the basis directly: setView's heading/pitch conversion is singular
// at nadir and can change the captured orientation near the poles. // at nadir and can change the captured orientation near the poles.
@@ -1705,7 +1701,7 @@ export function moveCamera(args = {}, runNavigation = null) {
const preserveCameraFlight = const preserveCameraFlight =
motion === 'orbit' && motion === 'orbit' &&
_viewer.scene.tweens.length > 0 && _viewer.scene.tweens.length > 0 &&
!_viewer.trackedEntity; !isFollowing(_viewer);
return typeof runNavigation === 'function' return typeof runNavigation === 'function'
? runNavigation(start, { preserveCameraFlight }) ? runNavigation(start, { preserveCameraFlight })
: start(); : start();
+9 -8
View File
@@ -1,22 +1,23 @@
/** /**
* Activates the nearest CCTV record on layer enable and flies only when no * Activates the nearest CCTV record on layer enable and flies only when no
* aircraft tracker or cockpit camera owns the view. * followed target or cockpit camera owns the view.
* @param {Object} options Layer-enable focus inputs. * @param {Object} options Layer-enable focus inputs.
* @param {*} options.trackedEntity Current Cesium tracked entity, if any. * @param {*} options.followed What the camera follows, if anything
* (`followedOf`).
* @param {boolean} options.cockpitActive Whether cockpit mode owns the camera. * @param {boolean} options.cockpitActive Whether cockpit mode owns the camera.
* @param {() => (string|null)} options.activate Nearest-camera activation. * @param {() => (string|null)} options.activate Nearest-camera activation.
* @param {(cameraId: string) => *} options.fly Optional camera flight. * @param {(cameraId: string) => *} options.fly Optional camera flight.
* @returns {*} Activated camera ID for a held view, or the flight result. * @returns {*} Activated camera ID for a held view, or the flight result.
*/ */
export function runCctvLayerEnableFocus({ export function runCctvLayerEnableFocus({
trackedEntity = null, followed = null,
cockpitActive = false, cockpitActive = false,
activate, activate,
fly, fly,
} = {}) { } = {}) {
const cameraId = activate?.(); const cameraId = activate?.();
if (!cameraId) return false; if (!cameraId) return false;
if (trackedEntity || cockpitActive) return cameraId; if (followed || cockpitActive) return cameraId;
return fly?.(cameraId); return fly?.(cameraId);
} }
@@ -25,11 +26,11 @@ export function runCctvLayerEnableFocus({
* chain. Either observation suppresses the optional CCTV focus flight. * chain. Either observation suppresses the optional CCTV focus flight.
* @param {Object} [before={}] Ownership immediately before the await. * @param {Object} [before={}] Ownership immediately before the await.
* @param {Object} [after={}] Ownership immediately after the await. * @param {Object} [after={}] Ownership immediately after the await.
* @returns {{ trackedEntity: *, cockpitActive: boolean }} Conservative ownership. * @returns {{ followed: *, cockpitActive: boolean }} Conservative ownership.
*/ */
function mergeCctvEnableOwnership(before = {}, after = {}) { function mergeCctvEnableOwnership(before = {}, after = {}) {
return { return {
trackedEntity: before.trackedEntity || after.trackedEntity || null, followed: before.followed || after.followed || null,
cockpitActive: !!(before.cockpitActive || after.cockpitActive), cockpitActive: !!(before.cockpitActive || after.cockpitActive),
}; };
} }
@@ -60,7 +61,7 @@ export async function runCctvLayerEnableTransition({
const ownershipBefore = readOwnership?.() || {}; const ownershipBefore = readOwnership?.() || {};
if (target === true) { if (target === true) {
debug?.('[UI:CCTV] ownership before setEnabled await', { debug?.('[UI:CCTV] ownership before setEnabled await', {
trackedId: ownershipBefore.trackedEntity?.id ?? null, trackedId: ownershipBefore.followed?.key ?? null,
cockpitActive: !!ownershipBefore.cockpitActive, cockpitActive: !!ownershipBefore.cockpitActive,
}); });
} }
@@ -68,7 +69,7 @@ export async function runCctvLayerEnableTransition({
const ownershipAfter = readOwnership?.() || {}; const ownershipAfter = readOwnership?.() || {};
if (target === true) { if (target === true) {
debug?.('[UI:CCTV] ownership after setEnabled await', { debug?.('[UI:CCTV] ownership after setEnabled await', {
trackedId: ownershipAfter.trackedEntity?.id ?? null, trackedId: ownershipAfter.followed?.key ?? null,
cockpitActive: !!ownershipAfter.cockpitActive, cockpitActive: !!ownershipAfter.cockpitActive,
}); });
} }
+6 -6
View File
@@ -8,8 +8,8 @@ import {
test('CCTV layer enable activates nearest without stealing a tracked or cockpit view', () => { test('CCTV layer enable activates nearest without stealing a tracked or cockpit view', () => {
for (const ownership of [ for (const ownership of [
{ trackedEntity: { id: 'tracked-plane' }, cockpitActive: false }, { followed: { key: 'tracked-plane' }, cockpitActive: false },
{ trackedEntity: null, cockpitActive: true }, { followed: null, cockpitActive: true },
]) { ]) {
const calls = []; const calls = [];
const result = runCctvLayerEnableFocus({ const result = runCctvLayerEnableFocus({
@@ -48,15 +48,15 @@ test('CCTV layer enable activates nearest without stealing a tracked or cockpit
}); });
test('CCTV enable retains a pre-await tracking snapshot when tracking clears during enable', async () => { test('CCTV enable retains a pre-await tracking snapshot when tracking clears during enable', async () => {
let trackedEntity = { id: 'tracked-plane' }; let followed = { key: 'tracked-plane' };
let flyCalls = 0; let flyCalls = 0;
const diagnostics = []; const diagnostics = [];
const result = await runCctvLayerEnableTransition({ const result = await runCctvLayerEnableTransition({
target: true, target: true,
readOwnership: () => ({ trackedEntity, cockpitActive: false }), readOwnership: () => ({ followed, cockpitActive: false }),
setEnabled: async () => { setEnabled: async () => {
await Promise.resolve(); await Promise.resolve();
trackedEntity = null; followed = null;
}, },
shouldFocus: () => true, shouldFocus: () => true,
activate: () => 'cam-near', activate: () => 'cam-near',
@@ -83,7 +83,7 @@ test('CCTV disable transition does not emit enable-ownership diagnostics', async
const result = await runCctvLayerEnableTransition({ const result = await runCctvLayerEnableTransition({
target: false, target: false,
readOwnership: () => ({ trackedEntity: { id: 'tracked-plane' }, cockpitActive: false }), readOwnership: () => ({ followed: { key: 'tracked-plane' }, cockpitActive: false }),
setEnabled: async (target) => transitions.push(target), setEnabled: async (target) => transitions.push(target),
shouldFocus: () => true, shouldFocus: () => true,
activate: () => 'cam-near', activate: () => 'cam-near',
+18 -14
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { governorRequestRender } from './renderGovernor.js'; import { governorRequestRender } from './renderGovernor.js';
import { cameraOf } from './render/cesium/camera.js';
/** Outer edge of the existing NVG/FLIR keyhole in normalized shader space. */ /** Outer edge of the existing NVG/FLIR keyhole in normalized shader space. */
export const KEYHOLE_OUTER_RADIUS = 1.05; export const KEYHOLE_OUTER_RADIUS = 1.05;
@@ -576,30 +577,33 @@ export class CelestialRing {
const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4); const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4);
const altitude = Math.max(earthRadius * 1.55, distance - earthRadius); const altitude = Math.max(earthRadius * 1.55, distance - earthRadius);
const camera = cameraOf(this.viewer);
this._focusInProgress = true; this._focusInProgress = true;
const finishFocus = () => { const finishFocus = () => {
this._focusInProgress = false; this._focusInProgress = false;
this.viewer.scene.requestRender?.(); this.viewer.scene.requestRender?.();
}; };
this.viewer.camera.flyTo({ camera.flyTo(
destination: Cesium.Cartesian3.fromRadians( Cesium.Cartesian3.fromRadians(
cartographic.longitude, cartographic.longitude,
cartographic.latitude, cartographic.latitude,
altitude, altitude,
), ),
orientation: { {
heading: this.viewer.camera.heading, orientation: {
pitch: -Cesium.Math.PI_OVER_TWO, heading: camera.pose().heading,
roll: 0, pitch: -Cesium.Math.PI_OVER_TWO,
roll: 0,
},
durationS: duration,
easing: 'cubicInOut',
onComplete: finishFocus,
onCancel: () => {
finishFocus();
if (!this.isGlobeFullyVisible()) this._autoDisable();
},
}, },
duration, );
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
complete: finishFocus,
cancel: () => {
finishFocus();
if (!this.isGlobeFullyVisible()) this._autoDisable();
},
});
return true; return true;
} }
+8 -8
View File
@@ -227,7 +227,7 @@ test('Cockpit Escape handling precedes form-control shortcut suppression and foc
assert.doesNotMatch(exit, /applyTrackedCameraFrame/); assert.doesNotMatch(exit, /applyTrackedCameraFrame/);
assert.match( assert.match(
ui, ui,
/restoreTrackingFrame: \(entity\) => \{[\s\S]*?gevTrackedId[\s\S]*?flightsLayer\.refocusTrackedById[\s\S]*?militaryFlightsLayer\.refocusTrackedById/, /restoreTrackingFrame: \(entity\) => \{[\s\S]*?readEntityRecord\(entity\)\?\.trackedId[\s\S]*?flightsLayer\.refocusTrackedById[\s\S]*?militaryFlightsLayer\.refocusTrackedById/,
'Cockpit exit must return camera-frame ownership through the source layer so Contact Focus cannot accumulate a second owner', 'Cockpit exit must return camera-frame ownership through the source layer so Contact Focus cannot accumulate a second owner',
); );
}); });
@@ -238,7 +238,7 @@ test('Cockpit shortcut failures do not leak and open utilities own the first Esc
assert.match(keydown, /if \(this\.isUtilityDisclosureOpen\?\.\(\)\) return;/); assert.match(keydown, /if \(this\.isUtilityDisclosureOpen\?\.\(\)\) return;/);
assert.match( assert.match(
keydown, keydown,
/const cockpitAttempt = !!\(\s*this\.readAircraftInfo\(\) && this\.viewer\.trackedEntity\?\.position\s*\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/, /const cockpitAttempt = !!\(\s*this\.readAircraftInfo\(\) && followedOf\(this\.viewer\)\?\.entity\?\.position\s*\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/,
); );
}); });
@@ -295,14 +295,14 @@ test('the Contact panel never hides itself out from under its own NEXT button',
); );
}); });
test('the cockpit reads its aircraft from the layer that owns Cesium tracking', () => { test('the cockpit reads its aircraft from the layer the camera follows', () => {
const read = readAircraftInfo.toString(); const read = readAircraftInfo.toString();
assert.ok(read, 'readAircraftInfo is missing'); assert.ok(read, 'readAircraftInfo is missing');
assert.match(read, /resolveTrackedAircraftInfo\(\{/); assert.match(read, /resolveTrackedAircraftInfo\(\{/);
assert.match(read, /gevTrackedId/); assert.match(read, /trackedId: followed\?\.key/);
// In cockpit mode the controller moves the entity off viewer.trackedEntity, // In cockpit mode the controller stops the camera following, so its own
// so its own handle is the tracked identity there. // handle is the tracked identity there.
assert.match(read, /this\.viewer\?\.trackedEntity \|\| this\.trackedEntity/); assert.match(read, /followedOf\(this\.viewer\) \|\| this\.trackedFollow/);
}); });
test('programmatic Context layer changes cannot bypass explicit expansion policy', () => { test('programmatic Context layer changes cannot bypass explicit expansion policy', () => {
@@ -1171,7 +1171,7 @@ test('cockpit route direction uses self-contained vector artwork, not a font lig
test('cockpit aircraft handoff invalidates the prior world-position anchor', () => { test('cockpit aircraft handoff invalidates the prior world-position anchor', () => {
const match = _adoptTrackedEntity.toString(); const match = _adoptTrackedEntity.toString();
assert.ok(match, 'cockpit tracked-entity handoff block is missing'); assert.ok(match, 'cockpit tracked-entity handoff block is missing');
assert.match(match, /this\.viewer\.trackedEntity = undefined;/); assert.match(match, /stopFollowing\(this\.viewer\);/);
assert.match(match, /this\.cockpitAnchorValid = false;/); assert.match(match, /this\.cockpitAnchorValid = false;/);
assert.match( assert.match(
match, match,
+1 -1
View File
@@ -306,7 +306,7 @@ export function relativeBearing(bearing, heading) {
* Resolve which flight layer owns the cockpit's tracked aircraft. * Resolve which flight layer owns the cockpit's tracked aircraft.
* *
* Both layers can briefly describe a tracked aircraft during a cross-layer * Both layers can briefly describe a tracked aircraft during a cross-layer
* handoff, so the viewer's normalized tracked identity (`gevTrackedId`) is the * handoff, so the viewer's normalized tracked identity (the entity record's `trackedId`) is the
* tie-breaker — the same rule `militaryAwareness.currentTrackedFlightSubject` * tie-breaker — the same rule `militaryAwareness.currentTrackedFlightSubject`
* uses. Layer precedence remains the fallback when no normalized identity is * uses. Layer precedence remains the fallback when no normalized identity is
* available, so tracking paths that never stamp one keep working. * available, so tracking paths that never stamp one keep working.
+1 -1
View File
@@ -365,7 +365,7 @@ test('tracked-identity resolution falls back to layer precedence', () => {
const civilian = { icao24: 'aaa077' }; const civilian = { icao24: 'aaa077' };
const military = { icao24: 'ae01ce' }; const military = { icao24: 'ae01ce' };
// No stamped identity (or one that matches neither layer) keeps the historic // No stamped identity (or one that matches neither layer) keeps the historic
// civilian-first order so tracking paths without gevTrackedId still work. // civilian-first order so tracking paths without an entity record trackedId still work.
assert.equal(resolveTrackedAircraftInfo({ civilian, military }).layerId, 'flights'); assert.equal(resolveTrackedAircraftInfo({ civilian, military }).layerId, 'flights');
assert.equal( assert.equal(
resolveTrackedAircraftInfo({ civilian, military, trackedId: 'satellites:25544' }).layerId, resolveTrackedAircraftInfo({ civilian, military, trackedId: 'satellites:25544' }).layerId,
+1 -1
View File
@@ -1,7 +1,7 @@
/* /*
* Cockpit aircraft metadata itself is resolved by `resolveTrackedAircraftInfo` * Cockpit aircraft metadata itself is resolved by `resolveTrackedAircraftInfo`
* in `cockpitMath.js` — the landed owner of that rule. It keeps layer * in `cockpitMath.js` — the landed owner of that rule. It keeps layer
* precedence as the fallback when no normalized `gevTrackedId` is available, * precedence as the fallback when the entity record has no normalized `trackedId`,
* so tracking paths that never stamp one keep working; `readAircraftInfo` is * so tracking paths that never stamp one keep working; `readAircraftInfo` is
* pinned to it by `cockpitMarkup.test.mjs`. This module owns only the * pinned to it by `cockpitMarkup.test.mjs`. This module owns only the
* multi-step ENTRY TRANSACTION (adopt → enter → roll back). * multi-step ENTRY TRANSACTION (adopt → enter → roll back).
+3 -2
View File
@@ -5,6 +5,7 @@ import {
enterCockpitWithTracking, enterCockpitWithTracking,
restoreAircraftTrackingOwner, restoreAircraftTrackingOwner,
} from './cockpitTracking.js'; } from './cockpitTracking.js';
import { readEntityRecord, withEntityRecord } from './data/entityRecords.js';
/** /**
* Model the two production stamping seams that sit under this transaction: * Model the two production stamping seams that sit under this transaction:
@@ -22,7 +23,7 @@ function stampingWorld({ entryAllowed = true } = {}) {
layerId, layerId,
trackById(id) { trackById(id) {
if (!trackable) return false; if (!trackable) return false;
world.trackedEntity = { gevTrackedId: `${layerId}:${id}` }; world.trackedEntity = withEntityRecord({}, { trackedId: `${layerId}:${id}` });
stamp('trackedEntityChanged'); // the ui.js listener stamp('trackedEntityChanged'); // the ui.js listener
return true; return true;
}, },
@@ -80,7 +81,7 @@ test('a refused Cockpit entry still stamps for the tracker it already moved', ()
// Adoption stamped, and the rollback re-track stamped again. // Adoption stamped, and the rollback re-track stamped again.
assert.deepEqual(world.stamps, ['trackedEntityChanged', 'trackedEntityChanged']); assert.deepEqual(world.stamps, ['trackedEntityChanged', 'trackedEntityChanged']);
assert.equal(world.generation, 2); assert.equal(world.generation, 2);
assert.equal(world.trackedEntity.gevTrackedId, 'military:ae01ce'); assert.equal(readEntityRecord(world.trackedEntity)?.trackedId, 'military:ae01ce');
}); });
test('a Cockpit entry that mutates nothing takes no camera authority', () => { test('a Cockpit entry that mutates nothing takes no camera authority', () => {
+14 -5
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { IconSetPrimitive } from './data/iconSetPrimitive.js';
// Narrow, version-tested engine adapter. Cesium has no public custom shader // Narrow, version-tested engine adapter. Cesium has no public custom shader
// option for native point/billboard collections. Do not patch its prototypes or // option for native point/billboard collections. Do not patch its prototypes or
@@ -64,9 +65,15 @@ export function sonarVertexSource(source, kind) {
kind === 'point' ? 'positionHighAndSize' : 'positionHighAndScale'; kind === 'point' ? 'positionHighAndSize' : 'positionHighAndScale';
const lowAttribute = const lowAttribute =
kind === 'point' ? 'positionLowAndOutline' : 'positionLowAndRotation'; kind === 'point' ? 'positionLowAndOutline' : 'positionLowAndRotation';
// Icon sets move rows on the GPU, so their shader publishes the anchor itself.
const anchorSource =
kind === 'icon'
? 'iconAnchorClip'
: `czm_modelViewProjectionRelativeToEye * czm_translateRelativeToEye(${attribute}.xyz, ${lowAttribute}.xyz)`;
if ( if (
!text.includes(attribute) || (kind === 'icon'
!text.includes(lowAttribute) || ? !text.includes('iconAnchorClip')
: !text.includes(attribute) || !text.includes(lowAttribute)) ||
!text.includes('v_color') || !text.includes('v_color') ||
!/void\s+main\s*\(\s*\)/.test(text) !/void\s+main\s*\(\s*\)/.test(text)
) )
@@ -83,7 +90,7 @@ void main() {
gev_sonar_main(); gev_sonar_main();
// All corners/glyphs share the contact anchor, before native screen offsets. // All corners/glyphs share the contact anchor, before native screen offsets.
// Leave gl_Position untouched so appearance never changes geometry or picks. // Leave gl_Position untouched so appearance never changes geometry or picks.
vec4 anchor = czm_modelViewProjectionRelativeToEye * czm_translateRelativeToEye(${attribute}.xyz, ${lowAttribute}.xyz); vec4 anchor = ${anchorSource};
if (anchor.w <= 0.0) return; if (anchor.w <= 0.0) return;
vec2 pixel = anchor.xy / anchor.w * u_gevSonarViewport * 0.5; vec2 pixel = anchor.xy / anchor.w * u_gevSonarViewport * 0.5;
float floorValue = u_gevSonar.z; float floorValue = u_gevSonar.z;
@@ -108,7 +115,7 @@ void main() {
v_color.rgb = v_color.rgb * 0.28 + (vec3(0.62, 0.68, 0.72) + vec3(0.38, 0.16, 0.12) * coverage) * 0.72; v_color.rgb = v_color.rgb * 0.28 + (vec3(0.62, 0.68, 0.72) + vec3(0.38, 0.16, 0.12) * coverage) * 0.72;
// Apply before native fragment pass classification, never after main(). // Apply before native fragment pass classification, never after main().
v_color.a *= factor; v_color.a *= factor;
${kind === 'point' ? 'v_outlineColor.a *= factor;' : '#ifdef SDF\n v_outlineColor.a *= factor;\n #endif'} ${kind === 'point' ? 'v_outlineColor.a *= factor;' : kind === 'icon' ? '' : '#ifdef SDF\n v_outlineColor.a *= factor;\n #endif'}
} }
`, `,
]; ];
@@ -208,7 +215,9 @@ export function createCyberSonarGpu(scene, readFrame) {
? 'point' ? 'point'
: native.owner instanceof Cesium.BillboardCollection : native.owner instanceof Cesium.BillboardCollection
? 'billboard' ? 'billboard'
: null; : native.owner instanceof IconSetPrimitive
? 'icon'
: null;
if (!kind || !native.shaderProgram || !native.uniformMap) continue; if (!kind || !native.shaderProgram || !native.uniformMap) continue;
const source = native.shaderProgram; const source = native.shaderProgram;
// Label backgrounds are non-SDF billboard batches. The version-tested // Label backgrounds are non-SDF billboard batches. The version-tested
+42 -14
View File
@@ -3,6 +3,12 @@ import { TRACKED_ICON_COLOR } from '../layers/military/policy.js';
import { createRendering as civilRendering } from '../layers/flights/rendering.js'; import { createRendering as civilRendering } from '../layers/flights/rendering.js';
import { createRendering as militaryRendering } from '../layers/military/rendering.js'; import { createRendering as militaryRendering } from '../layers/military/rendering.js';
import { createMilitarySnapshotRenderer } from '../layers/military/snapshotRenderer.js'; import { createMilitarySnapshotRenderer } from '../layers/military/snapshotRenderer.js';
import { createIcons as createMilitaryIcons } from '../layers/military/icons.js';
import { createIconSet } from './iconSet.js';
import {
aircraftMotionRegime,
createMotionRegime,
} from './aircraftMotionRegime.js';
import { aircraftEmergency } from './aircraftEmergency.js'; import { aircraftEmergency } from './aircraftEmergency.js';
// Source-to-voice provenance, status retention/clearing, contact-time contracts, // Source-to-voice provenance, status retention/clearing, contact-time contracts,
// and civil/military billboard, Cockpit and model tint regressions. // and civil/military billboard, Cockpit and model tint regressions.
@@ -462,18 +468,16 @@ test('tint priorities cover normal, priority, emergency, tracking and Cockpit do
} }
}); });
test('GERMAN: an existing untracked military billboard repaints each poll without alpha loss', () => { test('GERMAN: an existing untracked military icon repaints each poll without losing its fade', () => {
const { flightState, rendering } = renderHarness(militaryRendering); const { flightState, rendering } = renderHarness(militaryRendering);
const bb = {
color: rendering._fleetBillboardColor('abc123').withAlpha(0.65),
};
Object.assign(flightState, { Object.assign(flightState, {
_billboards: new Map([['abc123', bb]]), _icons: createIconSet(),
_models: new Map(), _models: new Map(),
_positionHistory: new Map(), _positionHistory: new Map(),
_cullPositions: new Map(), _displayCourse: new Map(),
_groundSnap: { forget() {} }, _groundSnap: { forget() {} },
_cockpitContactMode: false, _cockpitContactMode: false,
_cockpitNearContacts: new Set(),
}); });
const records = new MilitaryFlightRecords({ const records = new MilitaryFlightRecords({
geoidHeight: () => 0, geoidHeight: () => 0,
@@ -481,18 +485,42 @@ test('GERMAN: an existing untracked military billboard repaints each poll withou
floorAltitudeM: (value) => value, floorAltitudeM: (value) => value,
}); });
flightState.records = records; flightState.records = records;
const parts = {
rendering: {
...rendering,
_iconKind: () => 'jet',
_syncModelToClass() {},
},
motion: {
_motionRegime: (history, info, renderTime) =>
aircraftMotionRegime(
history,
info,
renderTime,
{ speed: 'speedMps', track: 'track' },
createMotionRegime(),
),
},
};
const icons = createMilitaryIcons({
flightState,
services: { focus: { focusPassIsNeeded: () => false } },
parts,
});
const row = flightState._icons.add('abc123', {
layer: 'military',
id: 'abc123',
});
// The focus fade the fleet tick composes lives in its own column.
flightState._icons.setFocus(row, 0.65);
const apply = createMilitarySnapshotRenderer({ const apply = createMilitarySnapshotRenderer({
flightState, flightState,
records, records,
groundFloor: { warmGroundFloor() {}, cachedGroundFloor: () => null }, groundFloor: { warmGroundFloor() {}, cachedGroundFloor: () => null },
meshFloor: { sampleMeshFloorCells() {} }, meshFloor: { sampleMeshFloorCells() {} },
militaryRegistry: { registerMilitaryIcaos() {} }, militaryRegistry: { registerMilitaryIcaos() {} },
rendering: { rendering: parts.rendering,
...rendering, icons,
_syncModelToClass() {},
_applyFleetBillboardPresentation() {},
},
motion: {},
tracking: {}, tracking: {},
queries: {}, queries: {},
}); });
@@ -518,7 +546,7 @@ test('GERMAN: an existing untracked military billboard repaints each poll withou
{ observedAtMs: NOW_MS }, { observedAtMs: NOW_MS },
); );
apply(snapshot, {}); apply(snapshot, {});
assert.equal(bb.color.green < 0.5, red); assert.equal(flightState._icons.color(row).green < 0.5, red);
assert.equal(bb.color.alpha, 0.65); assert.ok(Math.abs(flightState._icons.focus(row) - 0.65) < 1e-6);
} }
}); });
+451
View File
@@ -0,0 +1,451 @@
import * as Cesium from 'cesium';
import {
COURSE_HOLD_SPEED_MPS,
courseSlewCapDps,
norm180,
} from './motionModel.js';
import {
createIconSet,
ICON_CULL_LIFT,
ICON_RECEDES,
ICON_ROTATES,
} from './iconSet.js';
import {
segmentCourse,
writeArcSegment,
writeCourseGlide,
writeHoldSegment,
writeLerpSegment,
} from './iconSegment.js';
import { IconSetPrimitive } from './iconSetPrimitive.js';
import { iconAtlas } from './iconAtlas.js';
import {
aircraftIcon,
FLEET_ICON_PX,
TRACKED_ICON_PX,
} from './aircraftIcons.js';
import { cockpitContactDotImage } from './cockpitContactDot.js';
import { horizonOccluder } from './iconOrientation.js';
/** Raster size of the cockpit contact pip (cockpitContactDot.js draws 16 px). */
const COCKPIT_DOT_PX = 16;
/** Fleet glyphs keep the established close-range 3× scale. */
const NORMAL_SCALE_BY_DISTANCE = new Cesium.NearFarScalar(
1000,
3.0,
8000000,
0.5,
);
const COCKPIT_SCALE_BY_DISTANCE = new Cesium.NearFarScalar(
1000,
1.15,
8000000,
0.65,
);
/** Contacts drawn below this height (m) are judged for the horizon a little
* above the ellipsoid (ICON_CULL_LIFT). */
const CULL_LIFT_BELOW_M = 10;
/**
* An aircraft layer's contacts as one icon set: each row's motion segment
* mirrors the layer's dead reckoning for the delayed display time, so the GPU
* moves every untracked contact between polls with no per-frame CPU pass.
*
* The layer's state supplies `_icons`, `_iconPrimitive`, `_scratchIconRenderTime`,
* `_iconFocusActive`, `_positionHistory`, `_displayCourse`, `_groundSnap`,
* `_models`, `_trackedIcao`, `_viewer`, `_cockpitContactMode`,
* `_cockpitNearContacts` and `records` (`data`, `missingPolls`, `forget`).
* `parts.motion._motionRegime` picks the motion; `parts.rendering` supplies
* the icon kind, scale, colours and distance scalars, and `_releaseModel`.
*
* @param {object} options
* @param {object} options.state The layer's mutable state.
* @param {object} options.services The layer's services (`focus`).
* @param {object} options.parts The layer's parts (`motion`, `rendering`).
* @param {string} options.layerId Pick and focus owner id.
* @param {number} options.renderDelaySec How far behind real time contacts draw.
* @param {number} options.courseMaxDps The fleet's course slew cap.
* @param {number} options.cockpitContactSizePx Cockpit far-contact pip size.
* @param {(icao24: string) => boolean} [options.holdsPlan] True while something
* else writes this row's segment (it is not planned).
* @param {(icao24: string) => void} [options.beforeSettle] Runs before a row is
* planned afresh.
* @param {(icao24: string) => void} [options.afterSettle] Runs after it.
* @param {(icao24: string) => void} [options.onForget] Drops the layer's own
* per-contact state when a contact goes.
*/
export function createAircraftIcons({
state,
services,
parts,
layerId,
renderDelaySec,
courseMaxDps,
cockpitContactSizePx,
holdsPlan = () => false,
beforeSettle = () => {},
afterSettle = () => {},
onForget = () => {},
}) {
const { focusPassIsNeeded } = services.focus;
const scratchTime = new Cesium.JulianDate();
const scratchPosition = new Cesium.Cartesian3();
const scratchCull = new Cesium.Cartesian3();
const scratchScaleByDistance = new Cesium.NearFarScalar();
/** The delayed instant every untracked contact is drawn at. */
function _displayTime(result = scratchTime) {
return Cesium.JulianDate.addSeconds(
Cesium.JulianDate.now(result),
-renderDelaySec,
result,
);
}
function _displayTau() {
return state._icons.seconds(_displayTime());
}
/** Create the set and its primitive for one layer lifetime. */
function _createIcons(viewer) {
state._icons = createIconSet({ epoch: _displayTime() });
state._iconPrimitive = new IconSetPrimitive({
set: state._icons,
renderTime: () => _displayTime(state._scratchIconRenderTime),
});
viewer.scene.primitives.add(state._iconPrimitive);
return state._iconPrimitive;
}
/** Where a contact is drawn right now, or null when it has no row. */
function _iconPosition(icao24, result = new Cesium.Cartesian3()) {
const set = state._icons;
const row = set?.row(icao24) ?? -1;
return row < 0 ? null : set.position(row, _displayTau(), result);
}
/** The nose direction a contact is drawn with right now. */
function _iconCourse(icao24) {
const set = state._icons;
const row = set?.row(icao24) ?? -1;
return row < 0 ? null : set.course(row, _displayTau());
}
/** Whether the layer draws this contact's icon (not tracked, not model-owned). */
function _iconShown(icao24) {
const set = state._icons;
const row = set?.row(icao24) ?? -1;
return row >= 0 && set.shown(row);
}
/** The camera's horizon test, or null when there is no placed camera. */
function _occluder() {
const camera = state._viewer?.camera;
return camera?.positionWC ? horizonOccluder(camera) : null;
}
/** Whether a drawn row at `position` clears the camera's horizon, judged
* exactly as the icon shader judges it. No camera, no horizon. */
function _aboveHorizon(row, position, occluder) {
return (
!occluder ||
occluder.isPointVisible(
state._icons.cullPoint(row, position, scratchCull),
)
);
}
/**
* Write the segment the layer's dead reckoning would follow from now on:
* interpolation between the bracketing fixes, the warm-up arc back from the
* oldest fix, or the coast arc past the newest. The displayed nose glides
* from where it is onto the new course at the fleet's slew cap; a hovering
* contact keeps its nose. The row expires when the regime would change.
* @param {string} icao24
* @param {{fromCourseDeg?: number|null}} [options] Nose to glide from when
* the row's own segment is not what was on screen (tracking handoff).
*/
function _planIcon(icao24, { fromCourseDeg } = {}) {
const set = state._icons;
const row = set?.row(icao24) ?? -1;
if (row < 0 || holdsPlan(icao24)) return;
const history = state._positionHistory.get(icao24);
if (!history?.length) return;
const info = state.records.data.get(icao24);
const renderTime = _displayTime();
const tau = set.seconds(renderTime);
const seg = set.segments;
const shownCourse =
fromCourseDeg ??
(set.expires(row) === -Infinity ? null : set.course(row, tau));
const regime = parts.motion._motionRegime(history, info, renderTime);
let speed;
let expiresAt = Infinity;
if (regime.kind === 'between') {
const { a, b, span, trackFrom, trackTo, chordCourse } = regime;
speed = regime.speed;
const blend = chordCourse == null ? 0 : regime.blend;
if (span > 0) {
writeLerpSegment(seg, row, {
from: a.position,
to: b.position,
t0: set.seconds(a.time),
t1: set.seconds(b.time),
course0Deg:
trackFrom + blend * norm180((chordCourse ?? 0) - trackFrom),
courseSlopeDeg: norm180(trackTo - trackFrom) * (1 - blend),
});
} else {
writeHoldSegment(
seg,
row,
b.position,
trackTo + blend * norm180((chordCourse ?? 0) - trackTo),
);
}
expiresAt = set.seconds(b.time);
} else {
const { fix, heading } = regime;
speed = regime.speed;
const t0 = set.seconds(fix.time);
if (speed === 0) {
writeHoldSegment(seg, row, fix.position, heading);
} else {
writeArcSegment(seg, row, {
fix: fix.position,
t0,
minDt: regime.minDt,
maxDt: regime.maxDt,
speedMps: speed,
trackDeg: heading,
turnRateDps: regime.turnRateDps,
});
}
// A warm-up's arc ends at its fix; a coast's runs to its limit.
if (regime.maxDt === 0) expiresAt = t0;
}
// Below hover speed the nose holds what is on screen (or, for a new row,
// where this segment starts); above it, it glides on at the slew cap.
const hold = speed < COURSE_HOLD_SPEED_MPS;
if (hold || shownCourse != null) {
writeCourseGlide(
seg,
row,
shownCourse ?? segmentCourse(seg, row, tau),
tau,
hold ? 0 : courseSlewCapDps(speed, courseMaxDps),
);
}
set.segmentWritten(row, expiresAt);
}
/** Re-plan every row whose regime ended before `tau`. */
function _replanExpired(tau) {
const set = state._icons;
const due = [];
for (let row = 0; row < set.count; row++)
if (set.expires(row) <= tau) due.push(set.idAt(row));
for (const icao24 of due) _planIcon(icao24);
}
/**
* Advance focus de-emphasis for every drawn row (and every row a fleet model
* draws for, whose alpha reads it), only while a focus target exists or some
* row is still relaxing back to full emphasis.
*/
function _focusIcons(tau, scene, camera, nowMs, target) {
if (!focusPassIsNeeded(target, state._iconFocusActive)) return;
const { advanceProjectedSpriteFocus, nearFarScalarValueAtDistance } =
services.focus;
const set = state._icons;
let active = 0;
for (let row = 0; row < set.count; row++) {
if (!set.shown(row) && !state._models.has(set.idAt(row))) continue;
const position = set.position(row, tau, scratchPosition);
const distanceScale = nearFarScalarValueAtDistance(
set.scaleByDistance(row, scratchScaleByDistance),
Cesium.Cartesian3.distance(camera.positionWC, position),
);
const half = set.scale(row) * distanceScale * 0.5;
const focus = advanceProjectedSpriteFocus(
set.metaAt(row),
position,
scene,
camera,
nowMs,
target,
undefined,
set.width(row) * half,
set.height(row) * half,
);
set.setFocus(row, focus.factor);
if (focus.active || focus.transitioning) active++;
}
state._iconFocusActive = active;
}
/** Image, size, scale, tint and freshness for one row under the current
* normal/cockpit contract. Motion and rotation stay with the segment. */
function _applyIconPresentation(icao24) {
const set = state._icons;
const row = set?.row(icao24) ?? -1;
if (row < 0) return;
const rendering = parts.rendering;
const info = state.records.data.get(icao24);
const tracked = icao24 === state._trackedIcao;
const cockpitDot =
state._cockpitContactMode &&
!tracked &&
!state._cockpitNearContacts.has(icao24);
if (cockpitDot) {
const dot = iconAtlas.slot(cockpitContactDotImage(), COCKPIT_DOT_PX);
set.setImage(row, dot, dot);
set.setSize(row, cockpitContactSizePx, cockpitContactSizePx);
set.setScale(row, 1);
set.setScaleByDistance(row, COCKPIT_SCALE_BY_DISTANCE);
set.setColor(row, rendering._cockpitContactColor(icao24, info));
set.setFlag(row, ICON_ROTATES, false);
} else {
set.setImage(
row,
iconAtlas.slot(
aircraftIcon(rendering._iconKind(icao24, info?.klass)),
FLEET_ICON_PX,
),
iconAtlas.slot(
aircraftIcon(
rendering._iconKind(icao24, info?.klass),
TRACKED_ICON_PX,
),
TRACKED_ICON_PX,
),
);
const size = tracked ? 24 : 20;
set.setSize(row, size, size);
set.setScale(row, rendering._fleetBillboardScale(icao24, info?.klass));
set.setScaleByDistance(row, NORMAL_SCALE_BY_DISTANCE);
set.setColor(row, rendering._fleetBillboardColor(icao24, info));
set.setFlag(row, ICON_ROTATES, true);
}
set.setFlag(row, ICON_RECEDES, true);
set.setFlag(
row,
ICON_CULL_LIFT,
Number.isFinite(info?.renderAltitudeM) &&
info.renderAltitudeM < CULL_LIFT_BELOW_M,
);
set.setAlpha(row, state.records.missingPolls.get(icao24) ? 0.45 : 1);
}
/** Add a row for a new contact, hidden while it is the tracked one. */
function _addIcon(icao24) {
const set = state._icons;
const row = set.add(icao24, { layer: layerId, id: icao24 });
_applyIconPresentation(icao24);
_settleIcon(icao24);
set.setShown(row, icao24 !== state._trackedIcao);
return row;
}
/**
* Plan a row afresh, with the layer's own settle work on either side, and
* let a dimmed row relax: one that sat out the focus pass (tracked) may come
* back dimmed after the pass went idle, so it is counted to run the pass.
* @param {string} icao24
* @param {{fromCourseDeg?: number}} [options] The course on screen to glide from.
*/
function _settleIcon(icao24, options) {
const set = state._icons;
const row = set?.row(icao24) ?? -1;
if (row < 0) return;
beforeSettle(icao24);
if (set.focus(row) < 1) state._iconFocusActive += 1;
_planIcon(icao24, options);
afterSettle(icao24);
}
/** Drop a contact and everything the layer keeps for it. */
function _forgetContact(icao24) {
state._icons?.remove(icao24);
parts.rendering._releaseModel(icao24);
state.records.forget(icao24);
state._positionHistory.delete(icao24);
state._displayCourse.delete(icao24);
state._groundSnap.forget(icao24);
onForget(icao24);
}
/** Icon ids, so ray samples never land on an aircraft instead of the ground. */
function _iconIds() {
const set = state._icons;
const ids = new Array(set?.count ?? 0);
for (let row = 0; row < ids.length; row++) ids[row] = set.idAt(row);
return ids;
}
return {
_displayTime,
_displayTau,
_createIcons,
_iconPosition,
_iconCourse,
_iconShown,
_occluder,
_aboveHorizon,
_planIcon,
_settleIcon,
_replanExpired,
_focusIcons,
_applyIconPresentation,
_addIcon,
_forgetContact,
_iconIds,
};
}
/**
* Test seams over an aircraft layer's icon rows, shared by the flights and
* military `testing` parts.
* @param {object} state The layer state holding `_icons`.
* @param {{icons: ReturnType<typeof createAircraftIcons>}} parts Read at call time.
* @param {string} layerId
*/
export function aircraftIconTestSeams(state, parts, layerId) {
/** Give a test contact an icon row held at `icon.position`, shown as
* `icon.show` says (hidden by default while tracked). */
function _seedIconForTest(icao24, icon, tracked) {
const set = state._icons;
const row = set.add(icao24, { layer: layerId, id: icao24 });
if (icon?.position) writeHoldSegment(set.segments, row, icon.position, 0);
set.segmentWritten(row);
set.setShown(row, icon?.show ?? !tracked);
return row;
}
/** Where a contact's icon is drawn now and whether the layer shows it. */
function _iconForTest(icao24) {
const position = parts.icons._iconPosition(icao24);
return position
? {
position,
shown: parts.icons._iconShown(icao24),
course: parts.icons._iconCourse(icao24),
}
: null;
}
/** Show or hide a contact's icon as a handoff would, or hold it somewhere. */
function _setIconForTest(icao24, { shown, position } = {}) {
const set = state._icons;
const row = set?.row(icao24) ?? -1;
if (row < 0) return;
if (shown !== undefined) set.setShown(row, shown);
if (position) {
writeHoldSegment(set.segments, row, position, 0);
set.segmentWritten(row);
}
}
return { _seedIconForTest, _iconForTest, _setIconForTest };
}
+2
View File
@@ -252,6 +252,8 @@ const _b64 = (s) =>
* NEAR the display size lets the browser's SVG AA do the work: crisp AND * NEAR the display size lets the browser's SVG AA do the work: crisp AND
* smooth. 64 covers the 40–58 px fleet band with ≤1.6× minification. */ * smooth. 64 covers the 40–58 px fleet band with ≤1.6× minification. */
const FLEET_RASTER_PX = 64; const FLEET_RASTER_PX = 64;
/** Raster size of the default fleet glyph. */
export const FLEET_ICON_PX = FLEET_RASTER_PX;
/** Tracked raster: the tracked billboard is the one SUSTAINED large 2D glyph /** Tracked raster: the tracked billboard is the one SUSTAINED large 2D glyph
* (close-zoom fleet flybys hand off to 3D models). Keep the 192 px texture so * (close-zoom fleet flybys hand off to 3D models). Keep the 192 px texture so
* it stays crisp at its biggest on-screen sizes. */ * it stays crisp at its biggest on-screen sizes. */
+888
View File
@@ -0,0 +1,888 @@
import * as Cesium from 'cesium';
import { selectModelEligible } from './modelEligibility.js';
import { horizonOccluder } from './iconOrientation.js';
import { aircraftTreatmentAlpha } from './aircraftRecession.js';
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from './aircraftModelLighting.js';
import {
createModelRecord,
modelIsRendering,
placeModel,
showModel,
} from './modelRecord.js';
/**
* An aircraft layer's 3D models: the fleet regime and its gap-proof handoff
* from the icon set (`createAircraftIcons`), the standalone tracked model, and
* the ~12 Hz fleet tick that drives both. Shared by the flights and military
* layers, which differ only in the options below.
* @param {object} options
* @param {object} options.state The layer state.
* @param {object} options.services
* @param {object} options.parts The layer's parts; `icons`, `motion` and
* `tracking` are read at call time.
* @param {(url: string) => string} options.resolveAsset
* @param {string} options.logLabel Console prefix, e.g. `Data:Flights`.
* @param {object} options.policy The layer's model budgets, radii, pixel
* bounds, heading offset, IR reload batch and fleet tick interval.
* @param {(klass: string) => object} options.modelSpec The class's GLB, scale,
* geometry and tint blend; `headingOffsetDeg` overrides the policy's.
* @param {(icao24: string) => Cesium.Color} options.modelColor Model tint,
* including the tracked contact's.
* @param {(icao24: string) => void} [options.onModelEligible] Runs when a
* contact is about to render in 3D.
* @param {(renderTime: Cesium.JulianDate, tau: number, nowMs: number,
* camera: Cesium.Camera) => void} [options.afterReplan] The layer's own pass
* in the fleet tick, after expired rows are re-planned.
*/
export function createAircraftModels({
state,
services,
parts,
resolveAsset,
logLabel,
policy,
modelSpec: _modelSpec,
modelColor: _modelColor,
onModelEligible = () => {},
afterReplan = () => {},
}) {
const { isTr3b } = services.aircraftPresentation;
const { trackedModelScaleForPixelCap } = services.camera;
const { focusNowMs, getFocusTarget } = services.focus;
const { applyAircraftModelTreatment } = services.recession;
/** The model heading offset a class's GLB needs. */
const _headingOffsetDeg = (klass) =>
_modelSpec(klass).headingOffsetDeg ?? policy.MODEL_HEADING_OFFSET_DEG;
/** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude
* ceiling. Since 2026-08-22 the toggle DEFAULTS ON in `proximity`, which is itself the
* budget: models only appear below MODEL_ALT_CEIL_M and only for the nearest MODEL_MAX in
* view. The toggle still OWNS the fleet — an operator who wants every in-view plane arms
* `all`, and one who wants none turns 3D off — this predicate is unchanged. The TRACKED
* contact does not route through here: it is one model, it is what the camera is aimed at,
* and it takes its own default-on, hysteretic zoom regime
* (`_trackedModelRegimeActive`). */
function _modelRegimeActive() {
if (!state._models3dEnabled) return false;
const h = state._viewer?.camera?.positionCartographic?.height ?? Infinity;
return h < policy.MODEL_ALT_CEIL_M;
}
/** Active model cap — the eligibility pre-pass AND _ensureModel's admission checks must use the
* SAME value, else 'all' (MODEL_MAX_ALL) would mark planes eligible that _ensureModel then refuses
* at the lower MODEL_MAX, silently degrading 'all' to 'proximity'. */
function _modelCap() {
const mapCap =
state._models3dMode === 'all' ? policy.MODEL_MAX_ALL : policy.MODEL_MAX;
// `Math.min` on purpose: cockpit may only ever LOWER the GLB budget. Cockpit is
// already the heaviest mode (20 Hz camera setView ahead of scene update, photoreal
// retraversal, the cloud pass) and every model is its own draw call.
return state._cockpitContactMode
? Math.min(policy.COCKPIT_MODEL_MAX, mapCap)
: mapCap;
}
/** Active ADD radius (m) — new planes inside this range get a model. Mode-aware: 'all' reaches far. */
function _modelAddDistM() {
return state._models3dMode === 'all'
? policy.MODEL_ALL_ADD_M
: policy.MODEL_PROX_ADD_M;
}
/** Active KEEP radius (m) — a modeled plane keeps its model out to here (hysteresis vs ADD). */
function _modelKeepDistM() {
return state._models3dMode === 'all'
? policy.MODEL_ALL_KEEP_M
: policy.MODEL_PROX_KEEP_M;
}
/** World model matrix from a position + course heading (pitch/roll 0; ENU frame). Writes into
* `result` (a shared scratch by default) and returns it. Push it with `placeModel`, which copies
* it into the model's record and its own matrix, so models never share one mutable matrix
* object. `Model.modelMatrix` is a plain field (not a cloning setter); Cesium
* clones it per frame in updateModelMatrix(). Sharing a single scratch made every model render at
* the LAST-written transform — all stacked on one plane — and, once the tracked model wrote the
* scratch every frame, the stack point oscillated frame-to-frame: the "flickering like mad" bug. */
function _modelMatrix(
pos,
headingDeg,
result = state._scratchModelMtx,
offsetDeg = policy.MODEL_HEADING_OFFSET_DEG,
) {
state._scratchModelHpr.heading = Cesium.Math.toRadians(
(headingDeg || 0) + offsetDeg,
);
state._scratchModelHpr.pitch = 0;
state._scratchModelHpr.roll = 0;
return Cesium.Transforms.headingPitchRollToFixedFrame(
pos,
state._scratchModelHpr,
Cesium.Ellipsoid.WGS84,
undefined,
result,
);
}
/** Everything scene.sampleHeight must NOT hit when snapping a grounded model: the
* vertical pick ray at a plane's own lat/lon otherwise lands on its (or a parked
* neighbor's) billboard/model instead of the tile skin. Cesium's ray-pick exclusion
* matches picked-object IDs, and every icon AND model in this layer carries its
* icao as `id`, so the icao strings cover both; the tracked entity is excluded as the
* object itself. Built lazily — only when a sample actually fires (one-shot). */
function _groundSampleExclusions() {
const out = parts.icons._iconIds();
if (state._trackedEntity) out.push(state._trackedEntity);
return out;
}
/** Position a 3D MODEL renders at. Airborne planes use their dead-reckoned position
* verbatim. GROUNDED planes' meta altitude is last-known baro or 0 m — nowhere near
* the photoreal tile skin in ellipsoid heights (buried ~100+ m at inland airports,
* hovering ~30 m at sea-level ones), and unlike the ground billboards a depth-tested
* model can't hide behind disableDepthTestDistance. So a modeled grounded plane rides
* a ONE-SHOT cached scene.sampleHeight of the skin at its lat/lon (groundSnap.js,
* CCTV-B9b discipline: never per-frame; taxiing >50 m retires the cached value to a
* bounded last-known and resamples), plus
* the belly offset so it sits on its gear rather than sinking to the model-origin
* fuselage centerline. Until the FIRST sample lands (tiles streaming / sample miss)
* there is no safe depth-tested placement at all: this returns null, the caller
* keeps the 2D billboard visible and the model hidden, and it retries later. Once
* a contact has resolved once, a later outage holds that measurement inside
* groundSnap's drift bound instead — a taxiing aircraft does not pop back to 2D
* because a resample is mid-backoff. */
function _modelDisplayPosition(icao24, pos, result) {
const meta = state.records.data.get(icao24);
if (!meta || !meta.onGround) return pos;
const h = state._groundSnap.heightFor(
state._viewer,
icao24,
pos,
_groundSampleExclusions,
);
if (h == null) return null;
const carto = Cesium.Cartographic.fromCartesian(
pos,
Cesium.Ellipsoid.WGS84,
state._scratchGroundCarto,
);
carto.height = h + _modelSpec(meta.klass).bellyM;
return Cesium.Cartesian3.fromRadians(
carto.longitude,
carto.latitude,
carto.height,
Cesium.Ellipsoid.WGS84,
result,
);
}
/** Atomically hand one fleet contact from its icon to a safely placed,
* ready 3D model. Missing/loading models and unresolved terrain always leave
* the icon owning the visual, so no render frame can hide both.
* `beforeShow` runs only on the committing path — the per-tick model treatment
* belongs to a model that is about to draw, not to one still waiting. */
function _driveFleetModelHandoff(
icao24,
record,
row,
pos,
course,
beforeShow,
) {
const icons = state._icons;
if (!record) {
icons.setShown(row, true);
return false;
}
const displayPos = _modelDisplayPosition(
icao24,
pos,
state._scratchGroundPos,
);
if (!displayPos) {
showModel(record, false); // no ground evidence → nothing safe to place a depth-tested model at
icons.setShown(row, true);
return false;
}
// The matrix is written BEFORE the readiness test on purpose: a model can flip
// `ready` during scene update after this tick, and a first rendered frame on a
// stale load-start matrix is the one-frame jump this ordering prevents.
placeModel(
record,
_modelMatrix(
displayPos,
course,
undefined,
_headingOffsetDeg(state.records.data.get(icao24)?.klass),
),
);
if (!record.model.ready) {
showModel(record, false); // not loaded yet → keep the 2D icon, no half-model flash
icons.setShown(row, true);
return false;
}
beforeShow?.();
if (!record.show) showModel(record, true);
icons.setShown(row, false); // hand off ONLY once the model renders
return true;
}
/**
* Whether a 3D model — not the billboard — is what the user actually SEES for
* this contact, and therefore whether the display floor should stand aside.
*
* Model EXISTENCE is not ownership. A fleet model that is still loading, or has
* no resolved ground to stand on, stays hidden while `bb.show` stays true (the
* gap-proof handoff: "hand off ONLY once the model renders"), and a tracked model
* is retained but hidden whenever the model regime is off — 3D disabled, camera
* zoomed past the ceiling, or cockpit mode. Gating on `has()`/existence therefore
* suppressed the clamp in exactly the states where the BILLBOARD is the visual,
* putting the burial straight back.
*
* Cesium's own default `show === true` is the reason admission clears it
* explicitly: a fleet model registered in `_models` the instant its glTF resolves
* would otherwise claim ownership — at the identity matrix — for the frames
* between admission and the next fleet tick, while the BILLBOARD was still the
* visual. Ownership means actually rendering, and every site that sets `show`
* true does so only after committing a matrix.
*
* The two halves:
* - Fleet: the rendering test alone. Safe for the ground snap either way,
* because a fleet model is positioned from the RAW dead-reckon, not from
* the billboard.
* - Tracked: the rendering test AND `_trackedModelRegimeActive()`. The tracked
* model is fed from `_trackedDisplayPosition`, so once it is live the clamp
* must stand aside: two different chains would otherwise be deciding one
* contact's ground, and the billboard's is the one the operator is not
* looking at (T7). The regime check is what makes a regime
* flip take effect on the same frame rather than waiting for
* `_updateTrackedModel` to clear `show`; the rendering test is what keeps a
* null or still-loading tracked model from claiming a visual it is not
* drawing yet.
* @param {string} icao24
* @param {import('./modelRecord.js').ModelRecord|null} [record] Resolved
* fleet model record; null means absent.
* @returns {boolean}
*/
function _modelOwnsVisual(icao24, record) {
if (icao24 === state._trackedIcao) {
return (
parts.tracking._trackedModelRegimeActive() &&
modelIsRendering(state._trackedModel)
);
}
return modelIsRendering(
record === undefined ? state._models.get(icao24) : record,
);
}
// A model is the visual only once it actually draws, loaded AND shown
// (`modelIsRendering`). `show` is sufficient evidence of a safe placement
// because only ONE site ever sets it true for a fleet model
// (`_driveFleetModelHandoff`, after the matrix is committed) and one for the
// tracked model (`_updateTrackedModel`, likewise); everything else —
// admission, an unresolved ground, a not-yet-ready glTF, the limb cull, a
// regime exit — only ever clears it.
/** Spec identity for a LOADED model: URL and scale together (same-URL classes
* differ by scale — airliner vs quadjet both ship airplane.glb). */
const _specKeyFor = (klass) => {
const spec = _modelSpec(klass);
return `${spec.url}@${spec.scale}`;
};
/** Class-change model sync (enrichment AND poll-path klass updates): when the
* aircraft's live model or in-flight load no longer matches its class's spec,
* drop it so the eligibility pass reloads the right asset at the right scale.
* Gap-proof: the fleet billboard is re-shown BEFORE the release so the
* contact never goes invisible for the tick gap; _releaseModel's generation
* bump also invalidates any pending load. The tracked standalone model gets
* the same rule (its billboard entity is always the fallback visual). */
function _syncModelToClass(icao24) {
const key = _specKeyFor(state.records.data.get(icao24)?.klass);
const current = state._models.get(icao24);
if (
(current && current.specKey !== key) ||
(!current && state._modelPending.has(icao24))
) {
_releaseModel(icao24);
}
if (
icao24 === state._trackedIcao &&
state._trackedModel &&
state._trackedModel.specKey !== key
) {
_releaseTrackedModel();
}
}
function _reloadModelsForIrBoost() {
state._irReloadQueue = [...state._models.keys()];
for (const icao of state._modelPending) {
if (!state._models.has(icao))
state._modelGen.set(icao, (state._modelGen.get(icao) || 0) + 1);
}
_releaseTrackedModel();
}
function _drainIrReloadQueue() {
if (!state._irReloadQueue) return;
const batch = state._irReloadQueue.splice(0, policy.IR_RELOAD_BATCH);
for (const icao of batch) {
const record = state._models.get(icao);
if (!record || record.irBoost === state._irBoost) continue; // already right state
_releaseModel(icao);
}
if (state._irReloadQueue.length === 0) state._irReloadQueue = null;
}
/** Lazily create the glTF model for an aircraft (fire-and-forget; billboard shows until ready). */
async function _ensureModel(icao24) {
// Never model the TRACKED aircraft — it owns a separate entity billboard, and the fleet
// tick skips it, so a model here would be orphaned + double-rendered.
if (icao24 === state._trackedIcao) return;
onModelEligible(icao24);
if (state._models.has(icao24) || state._modelPending.has(icao24)) return;
// Count PENDING loads in the cap so a zoomed-in tick can't fire 100s of concurrent loads
// (the cap is rechecked post-await too, before the add). Mode-aware so 'all' can reach MAX_ALL.
if (state._models.size + state._modelPending.size >= _modelCap()) return;
const epoch = state._modelEpoch; // lifecycle token: if destroy() bumps it, this load is dead
const gen = state._modelGen.get(icao24) || 0; // capture; if it changes during the load, we're stale
state._modelPending.add(icao24);
let model = null;
// Spec identity captured at load START — if enrichment reclassifies the
// aircraft mid-load, the post-await admission below rejects the stale asset.
// Boost state likewise: the creation options bake it in, so a mid-load
// toggle must reject too (the reload queue only covers ADMITTED models).
const specKey = _specKeyFor(state.records.data.get(icao24)?.klass);
const loadIrBoost = state._irBoost;
try {
const spec = _modelSpec(state.records.data.get(icao24)?.klass);
model = await Cesium.Model.fromGltfAsync({
url: resolveAsset(spec.url),
asynchronous: false,
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
minimumPixelSize: policy.MODEL_MIN_PX,
scale: spec.scale,
color: state._irBoost ? Cesium.Color.WHITE : _modelColor(icao24),
colorBlendMode: Cesium.ColorBlendMode.MIX,
// Launch presentation keeps the code-side tint dominant for every approved
// model; IR boost removes the remaining diffuse hint with flat UNLIT white.
colorBlendAmount: state._irBoost ? 1.0 : spec.blendAmount,
customShader: state._irBoost ? state._IR_UNLIT_SHADER : undefined,
id: icao24, // so scene.pick returns the icao for click-to-track
});
} catch {
// asset/decode fail — stay an icon. Only touch this lifecycle's state if still current
// (a destroy/re-init may have swapped the globals while this load was in flight).
if (epoch === state._modelEpoch) {
state._modelPending.delete(icao24);
_cleanupModelGen(icao24);
}
return;
}
// A load from a PREVIOUS lifecycle (destroy→init happened mid-load) must NOT mutate the new
// epoch's _modelPending/_modelGen or add to the new collection — just drop its model.
if (epoch !== state._modelEpoch) {
try {
model.destroy();
} catch {
/* gone */
}
return;
}
state._modelPending.delete(icao24);
// Post-await admission: reject (and DESTROY the loaded model) if anything changed during the
// load — a release bumped the generation (track/untrack/remove), the layer toggled off / was
// torn down, the aircraft is gone or now tracked, a model already exists, or the cap filled.
// Recheck the shared Display 3D toggle and altitude ceiling after the async
// load. Cockpit uses the same OFF / Proximity / All contract as map Display.
const stale =
(state._modelGen.get(icao24) || 0) !== gen ||
!_modelRegimeActive() ||
!state._modelCollection ||
state._modelCollection.isDestroyed() ||
!state.records.data.has(icao24) ||
icao24 === state._trackedIcao ||
state._models.has(icao24) ||
state._models.size >= _modelCap() ||
// Class reclassified mid-load → this GLB/scale is for the OLD class.
_specKeyFor(state.records.data.get(icao24)?.klass) !== specKey ||
// IR boost flipped mid-load → this model baked the wrong shader/tint.
state._irBoost !== loadIrBoost;
if (stale) {
try {
model.destroy();
} catch {
/* already gone */
}
_cleanupModelGen(icao24); // bound the map
return;
}
// Keep the pick identity explicit on the resolved primitive. This also
// protects injected/custom loaders that do not copy the creation option.
model.id = icao24;
const record = createModelRecord(model, {
specKey, // class-change sync compares against this
irBoost: loadIrBoost, // boost-flip reload queue compares against this
});
// Admitted, not yet the visual. Cesium's default is show=true, which would let
// an unplaced primitive claim ownership from the icon for the frames
// between admission and the next fleet tick (and draw at the identity matrix,
// i.e. the Earth's centre). The handoff turns it on once it has a matrix.
showModel(record, false);
state._modelCollection.add(model);
state._models.set(icao24, record);
state._planeModelLoaded = true; // GLB is cached now — the tracked entity's model can fade in its billboard
}
/** Remove the 3D model for ONE aircraft (removal / military-suppression / track handoff).
* Bumps the load generation so any in-flight load for this icao is rejected on completion. */
function _releaseModel(icao24) {
const record = state._models.get(icao24);
const pending = state._modelPending.has(icao24);
// Only bump the generation when there's something to invalidate (an in-flight load or a
// live model) — so removing never-modeled aircraft doesn't grow _modelGen.
if (record || pending) {
state._modelGen.set(icao24, (state._modelGen.get(icao24) || 0) + 1);
}
if (record) {
if (state._modelCollection && !state._modelCollection.isDestroyed()) {
try {
state._modelCollection.remove(record.model);
} catch {
/* gone */
}
}
state._models.delete(icao24);
_showReleasedIcon(icao24);
}
// Do NOT clear _modelPending here — the in-flight load's OWN post-await removes it. Keeping
// it (a) prevents a duplicate load from starting and (b) keeps the bumped gen entry alive so
// the resolving load's gen-check still rejects it.
_cleanupModelGen(icao24);
}
/** Drop an icao's generation entry once nothing references it (no live model, no in-flight
* load) — keeps _modelGen bounded. Shared by _releaseModel + both _ensureModel exit paths. */
function _cleanupModelGen(icao24) {
if (!state._modelPending.has(icao24) && !state._models.has(icao24))
state._modelGen.delete(icao24);
}
/** Remove all live models (toggle-off / zoom-out); billboards take back over next tick. */
function _releaseModels() {
state._irReloadQueue = null; // a full release supersedes any pending boost-flip drain
// Invalidate in-flight loads so a completion after this bulk release can't add a model.
for (const icao of state._modelPending)
state._modelGen.set(icao, (state._modelGen.get(icao) || 0) + 1);
if (state._modelCollection && !state._modelCollection.isDestroyed()) {
for (const record of state._models.values()) {
try {
state._modelCollection.remove(record.model);
} catch {
/* gone */
}
}
}
for (const icao24 of state._models.keys()) _showReleasedIcon(icao24);
state._models.clear();
}
/** A released fleet model hands the visual straight back to its icon (the
* tracked contact's entity keeps its own). */
function _showReleasedIcon(icao24) {
const icons = state._icons;
const row = icons?.row(icao24) ?? -1;
if (row >= 0 && icao24 !== state._trackedIcao) icons.setShown(row, true);
}
/** Destroy the standalone tracked-aircraft model and invalidate any in-flight load. */
function _releaseTrackedModel() {
state._trackedModelGen++;
state._trackedModelLoading = false;
if (state._trackedModel) {
if (state._modelCollection && !state._modelCollection.isDestroyed()) {
try {
state._modelCollection.remove(state._trackedModel.model);
} catch {
/* gone */
}
}
state._trackedModel = null;
}
}
/** Per-frame driver for the standalone tracked model. Runs every preUpdate (not the 80 ms fleet
* cadence) so the centered plane moves smoothly. The tracked entity stays a pure billboard, so the
* follow-camera's bounding sphere is ALWAYS ready — toggling 3D, or tracking while already zoomed
* in, can never stall or freeze the centering (the old model-graphic-on-entity failure mode). */
function _updateTrackedModel() {
const active =
state._trackedIcao &&
parts.tracking._trackedModelRegimeActive() &&
state._modelCollection &&
!state._modelCollection.isDestroyed();
if (!active) {
if (state._trackedModel) showModel(state._trackedModel, false);
return;
}
// Ask the frame-cached source directly. If the entity callback already ran,
// this is a no-op; if model loading completed between phases, it establishes
// this frame's single sample before the model renders. Camera, detection, and
// readout consumers then reuse that exact cached position.
const pos =
parts.motion._trackedDisplayPosition(state._trackedIcao) ||
parts.icons._iconPosition(state._trackedIcao);
if (!pos) {
if (state._trackedModel) showModel(state._trackedModel, false);
return;
}
if (
!state._trackedModel &&
!state._trackedModelLoading &&
parts.tracking._trackedModelLoadAllowed()
) {
state._trackedModelLoading = true;
const gen = state._trackedModelGen;
const trackedSpec = _modelSpec(
state.records.data.get(state._trackedIcao)?.klass,
);
const trackedKey = _specKeyFor(
state.records.data.get(state._trackedIcao)?.klass,
);
const trackedIrBoost = state._irBoost;
Cesium.Model.fromGltfAsync({
url: resolveAsset(trackedSpec.url),
asynchronous: false,
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
minimumPixelSize: policy.TRACKED_MODEL_MIN_PX,
scale: trackedSpec.scale,
color: state._irBoost
? Cesium.Color.WHITE
: _modelColor(state._trackedIcao),
colorBlendMode: Cesium.ColorBlendMode.MIX,
// The tracked aircraft uses the same dominant light tint as the fleet;
// IR boost removes the remaining diffuse hint with flat UNLIT white.
colorBlendAmount: state._irBoost ? 1.0 : trackedSpec.blendAmount,
customShader: state._irBoost ? state._IR_UNLIT_SHADER : undefined,
// Pick id (H1): without it, clicking the very plane being tracked read as
// EMPTY SPACE (scene.pick → primitive with no id) → an unintended
// deselect. With the icao, the click handler recognizes it as ours.
id: state._trackedIcao,
})
.then((m) => {
// Untracked / re-tracked / torn down during the load → drop it.
if (
gen !== state._trackedModelGen ||
!state._modelCollection ||
state._modelCollection.isDestroyed()
) {
try {
m.destroy();
} catch {
/* gone */
}
return;
}
// Class reclassified OR boost flipped mid-load (no release ran —
// _trackedModel was still null): drop the stale asset; driver reloads.
if (
_specKeyFor(state.records.data.get(state._trackedIcao)?.klass) !==
trackedKey ||
state._irBoost !== trackedIrBoost
) {
try {
m.destroy();
} catch {
/* gone */
}
state._trackedModelLoading = false;
return;
}
// Assign after resolution as well as in the creation options so the
// standalone primitive always exposes the tracked aircraft pick id.
m.id = state._trackedIcao;
const record = createModelRecord(m, {
specKey: trackedKey, // class-change sync compares against this
irBoost: trackedIrBoost,
});
showModel(record, false); // admitted, not yet the visual — the driver shows it once placed
// Seed the world transform before the primitive enters the scene. A model
// can become ready+shown between render phases; leaving Cesium's identity
// default here produces a one-frame jump to the Earth's center.
const currentPos =
parts.motion._trackedDisplayCached() ||
parts.icons._iconPosition(state._trackedIcao);
if (currentPos) {
const displayPos = _modelDisplayPosition(
state._trackedIcao,
currentPos,
state._scratchGroundPos,
);
if (displayPos)
placeModel(
record,
_modelMatrix(
displayPos,
parts.motion._trackedDisplayCourse(),
undefined,
trackedSpec.headingOffsetDeg ??
policy.MODEL_HEADING_OFFSET_DEG,
),
);
}
state._trackedModel = record;
state._trackedModelLoading = false;
// A good load retires this selection's failure budget — a contact that
// recovers after a transient blip is not one attempt from giving up.
state._trackedModelFailIcao = null;
state._trackedModelFailCount = 0;
state._trackedModelRetryAtMs = 0;
state._modelCollection.add(m);
state._planeModelLoaded = true; // GLB cached — the tracked billboard can fade out once the model is up
})
.catch((err) => {
if (gen !== state._trackedModelGen) return; // superseded load — not this selection's failure
state._trackedModelLoading = false;
parts.tracking._noteTrackedModelLoadFailure(trackedSpec.url, err);
});
return; // billboard carries the visual until the model is ready
}
if (state._trackedModel) {
// Keep the transform current while GPU resources are still loading. Cesium
// can flip ready during scene update after this callback; waiting for ready
// here would let that first rendered frame use a stale load-start matrix.
const displayPos = _modelDisplayPosition(
state._trackedIcao,
pos,
state._scratchGroundPos,
);
if (!displayPos) {
showModel(state._trackedModel, false); // no ground evidence → the icon carries it
return;
}
const spec = _modelSpec(
state.records.data.get(state._trackedIcao)?.klass,
);
placeModel(
state._trackedModel,
_modelMatrix(
displayPos,
parts.motion._trackedDisplayCourse(),
undefined,
spec.headingOffsetDeg ?? policy.MODEL_HEADING_OFFSET_DEG,
),
);
if (!state._trackedModel.model.ready) return;
state._trackedModel.model.scale = trackedModelScaleForPixelCap({
baseScale: spec.scale,
nativeRadiusM: spec.nativeRadiusM,
rangeM: Cesium.Cartesian3.distance(
state._viewer.camera.positionWC,
displayPos,
),
viewportHeightPx: state._viewer.scene.canvas.clientHeight,
fovyRad: state._viewer.camera.frustum.fovy,
maximumPixelSize: policy.TRACKED_MODEL_MAX_PX,
});
showModel(state._trackedModel, true);
}
}
/**
* ~12 Hz fleet pass. Motion, rotation, the horizon cull, limb recession and
* the two-tier glyph swap all run on the GPU from each row's segment, so this
* only re-plans rows whose regime ended, runs the layer's own pass after
* that (flights floors grounded contacts), advances focus de-emphasis while
* a target exists, and drives the 3D-model handoff inside the model regime.
* Driven by scene.preRender.
* @returns {void}
*/
function _fleetTick() {
const icons = state._icons;
if (!state._viewer || !icons || !icons.show) return;
const scene = state._viewer.scene;
const camera = state._viewer.camera;
const nowMs = focusNowMs(Date.now());
if (nowMs - state._lastFleetTickMs < policy.FLEET_DR_INTERVAL_MS) return;
state._lastFleetTickMs = nowMs;
// Every contact in this pass shares the same delayed display instant.
const renderTime = parts.icons._displayTime(state._scratchFleetRenderTime);
const tau = icons.seconds(renderTime);
_drainIrReloadQueue(); // bounded per-tick slice of any pending boost-flip reload
if (state._cockpitContactMode) parts.tracking._refreshCockpitNearContacts();
parts.icons._replanExpired(tau);
afterReplan(renderTime, tau, nowMs, camera);
const focusTarget = getFocusTarget();
parts.icons._focusIcons(tau, scene, camera, nowMs, focusTarget);
// 3D model regime: only when enabled AND the camera is zoomed in past the altitude ceiling.
// Drop all models the moment we leave it (toggled off / zoomed out) so icons resume.
const useModels = _modelRegimeActive();
// Drop live models AND invalidate in-flight loads on leaving the regime (else a load that
// resolves after zoom-out could briefly add a model outside the 3D-model regime).
if (!useModels && (state._models.size || state._modelPending.size))
_releaseModels();
if (!useModels) return;
// 3D-model eligibility: by DISTANCE (the mode's add/keep band), with ON-SCREEN PRIORITY under the
// cap. FOUR passes, visible-first, so the slots are spent on what you can see: (1) KEEP on-screen
// already-modeled (hysteresis for visible planes); (2) ADD on-screen new inside the add radius,
// nearest first; (3) KEEP off-screen already-modeled; (4) ADD off-screen new with leftover slots
// (so a plane just off the cone still models — the "planes right next to me aren't 3D" complaint).
// Crucially KEEP is SPLIT by frustum: an off-screen retained model (pass 3) can never starve an
// on-screen plane that wants one (passes 1–2) — a single KEEP-everything pass could fill the cap
// with off-screen retained models. Pure-distance let off-screen planes eat the cap; pure-frustum
// filtering dropped near off-screen planes entirely. This does both right.
const cap = _modelCap();
const camPos = camera.positionWC;
const addM = _modelAddDistM();
const addDistSq = addM * addM;
const keepM = _modelKeepDistM();
const keepDistSq = keepM * keepM;
const cull = camera.frustum.computeCullingVolume(
camPos,
camera.directionWC,
camera.upWC,
);
// Candidates = planes within the KEEP radius, nearest first, each tagged with on-screen-ness.
const cand = [];
for (let row = 0; row < icons.count; row++) {
const icao = icons.idAt(row);
if (icao === state._trackedIcao) continue;
// A converted TR-3B renders as an icon and can never take a model, so
// it must not occupy a CAP SLOT either — excluded here at selection time,
// not just at the handoff below, or accumulated conversions would starve
// ordinary contacts of 3D models. (The handoff guard stays as defence.)
if (isTr3b(icao)) continue;
// Ground planes compete for model slots like everyone else (owner decision
// 2026-07-03: "3D mode is respected regardless of whether a plane is on the
// ground or in the air — no distinction"). The cap + nearest-first ordering
// below already bound airport clusters; grounded placement is handled by the
// one-shot ground snap in _modelDisplayPosition.
const position = icons.position(row, tau, state._scratchModelBS.center);
const d2 = Cesium.Cartesian3.distanceSquared(camPos, position);
if (d2 > keepDistSq) continue; // beyond the keep radius → never eligible
cand.push([
icao,
d2,
cull.computeVisibility(state._scratchModelBS) !==
Cesium.Intersect.OUTSIDE,
]);
}
cand.sort((a, b) => a[1] - b[1]); // nearest first
if (cand.length > cap && nowMs - state._lastModelCapWarnMs > 5000) {
console.warn(
`[${logLabel}] ${cand.length} planes in 3D range; capped at ${cap} (${state._models3dMode}). On-screen planes are prioritized.`,
);
state._lastModelCapWarnMs = nowMs;
}
const modelEligible = selectModelEligible(cand, {
cap,
addDistSq,
isModeled: (icao) => state._models.has(icao),
});
const toRelease = [];
for (const icao of state._models.keys()) {
if (icao !== state._trackedIcao && !modelEligible.has(icao))
toRelease.push(icao);
}
for (const icao of toRelease) _releaseModel(icao);
// 3D model takes over from the icon for eligible planes. GAP-PROOF: the icon stays shown
// until the model is actually READY to render, so a plane is never both iconless AND
// modelless (the "planes vanish when 3D turns on" bug). Position the model every tick
// regardless so it's framed the instant it becomes ready. Converted TR-3Bs stay 2D on
// purpose: the Easter egg IS the triangle, and there is no GLB for it.
const occluder = horizonOccluder(camera);
for (const icao24 of modelEligible) {
if (isTr3b(icao24)) continue;
const row = icons.row(icao24);
if (row < 0) continue;
const position = icons.position(row, tau, state._scratchFleetPos);
if (!parts.icons._aboveHorizon(row, position, occluder)) {
// A model that crossed the limb would keep rendering through the
// hidden globe at its last matrix; the icon's own cull is on the GPU.
const hidden = state._models.get(icao24);
if (hidden?.show) showModel(hidden, false);
continue;
}
_ensureModel(icao24);
const record = state._models.get(icao24);
const cameraDistanceM = Cesium.Cartesian3.distance(camPos, position);
_driveFleetModelHandoff(
icao24,
record,
row,
position,
icons.course(row, tau),
() => {
applyAircraftModelTreatment({
model: record.model,
// IR boost must survive the per-tick treatment write — otherwise
// any alpha change would repaint the ordinary tint over the hot
// white. Boosted models also skip the recession fade: hot targets
// stay full-strength at any range (icons keep their normal
// fade — full-opacity glyph walls read as overwhelming).
baseColor: state._irBoost
? Cesium.Color.WHITE
: _modelColor(icao24),
alpha: state._irBoost
? 1
: aircraftTreatmentAlpha({
baseAlpha: icons.alpha(row),
focusFactor: icons.focus(row),
cameraDistanceM,
cameraHeightM: camera.positionCartographic?.height,
}),
});
},
);
}
}
return {
_modelRegimeActive,
_modelCap,
_modelAddDistM,
_modelKeepDistM,
_modelMatrix,
_groundSampleExclusions,
_modelDisplayPosition,
_driveFleetModelHandoff,
_modelOwnsVisual,
_specKeyFor,
_syncModelToClass,
_reloadModelsForIrBoost,
_drainIrReloadQueue,
_ensureModel,
_releaseModel,
_cleanupModelGen,
_releaseModels,
_showReleasedIcon,
_releaseTrackedModel,
_updateTrackedModel,
_fleetTick,
};
}
+138
View File
@@ -0,0 +1,138 @@
import * as Cesium from 'cesium';
import {
courseBetweenCartesians,
speedRamp,
staleCoastLimitSeconds,
} from './motionModel.js';
/**
* Which record fields carry an aircraft layer's reported ground speed (m/s)
* and track (degrees clockwise from north).
* @typedef {{speed: string, track: string}} AircraftRecordFields
*/
/** A fix's own ground speed, else the record's. */
export function fixSpeedMps(fix, info, fields) {
return Number.isFinite(fix.velocity)
? fix.velocity
: (info && info[fields.speed]) || 0;
}
/** A fix's own track, else the record's. */
export function fixTrackDeg(fix, info, fields) {
return Number.isFinite(fix.track)
? fix.track
: (info && info[fields.track]) || 0;
}
/** A reusable result for `aircraftMotionRegime`. */
export function createMotionRegime() {
return {
kind: 'fix',
a: null,
b: null,
span: 0,
speed: 0,
trackFrom: 0,
trackTo: 0,
chordCourse: null,
blend: 0,
fix: null,
heading: 0,
turnRateDps: 0,
minDt: 0,
maxDt: 0,
};
}
/**
* Which motion an aircraft's history gives at `renderTime` — the one choice
* the CPU dead reckoning and the icon planner (GPU segment) both draw from.
*
* - `between`: interpolate the bracketing pair `a`→`b`, no extrapolation
* error and no snap-back. The course of the DISPLAYED motion blends the
* time-interpolated reported track toward the chord by `blend`: the chord
* is only trustworthy when the segment covers real ground (at hover its
* direction is GPS jitter; on a slow tight turn it STEPS the whole
* per-segment turn at each boundary), and helicopters never use it
* (rotorcraft chords are noise-dominated at their typical speeds).
* - `fix`: extrapolate `fix` by the elapsed time clamped to [minDt, maxDt].
* Warm-up (renderTime predates ALL history: freshly seen or just tracked,
* before a poll interval of history exists) runs the OLDEST fix BACKWARD,
* preserving the behind-real-time invariant, so as history fills the icon
* glides FORWARD into interpolation with no freeze and no backward snap
* (holding the oldest fix froze the icon; extrapolating the newest to
* wall-clock made it jump back a poll interval when interpolation took
* over). Otherwise the newest fix coasts. A feed can still be receiving
* fresh contact messages for that aircraft; freezing 60 s after the
* position produced a stop → catch-up → stop cadence, so it coasts through
* the latest real contact plus a bounded grace, with an absolute cap so a
* stale cached feed cannot drift forever.
*
* @param {Array<object>} history Non-empty, oldest first.
* @param {object|undefined} info The contact's record.
* @param {Cesium.JulianDate} renderTime
* @param {AircraftRecordFields} fields
* @param {object} result From `createMotionRegime`; returned filled in.
*/
export function aircraftMotionRegime(
history,
info,
renderTime,
fields,
result,
) {
for (let i = history.length - 1; i >= 1; i--) {
const a = history[i - 1];
const b = history[i];
if (
!Cesium.JulianDate.lessThanOrEquals(a.time, renderTime) ||
!Cesium.JulianDate.lessThanOrEquals(renderTime, b.time)
)
continue;
const span = Cesium.JulianDate.secondsDifference(b.time, a.time);
const chordLenM = Cesium.Cartesian3.distance(a.position, b.position);
const speed =
span > 0 ? chordLenM / span : (info && info[fields.speed]) || 0;
const fallbackTrack = (info && info[fields.track]) || 0;
const trackFrom = Number.isFinite(a.track) ? a.track : fallbackTrack;
const blend = info && info.klass === 'helicopter' ? 0 : speedRamp(speed);
result.kind = 'between';
result.a = a;
result.b = b;
result.span = span;
result.speed = speed;
result.trackFrom = trackFrom;
result.trackTo = Number.isFinite(b.track) ? b.track : trackFrom;
result.chordCourse =
blend > 0 ? courseBetweenCartesians(a.position, b.position) : null;
result.blend = blend;
return result;
}
const newest = history[history.length - 1];
const warming = Cesium.JulianDate.lessThan(renderTime, history[0].time);
result.kind = 'fix';
result.fix = warming ? history[0] : newest;
result.speed = fixSpeedMps(result.fix, info, fields);
result.heading = fixTrackDeg(result.fix, info, fields);
result.turnRateDps = (info && info.turnRateDps) || 0;
result.minDt = -60;
// A warm-up ends when real time reaches the oldest fix; a single fix keeps
// coasting forward on the same arc.
result.maxDt =
warming && history.length > 1
? 0
: staleCoastLimitSeconds({
// `epochMs` is captured once when the poll is normalized. Avoid
// allocating a Date per aircraft on every 12 Hz fleet tick.
fixEpochMs: Number.isFinite(newest.epochMs)
? newest.epochMs
: Cesium.JulianDate.toDate(newest.time).getTime(),
lastContactEpochMs: info?.lastContactEpochMs,
// Permit one minute of contact grace but cap any cached-feed drift
// at five minutes. Source backoff is exposed separately as a STALE cue.
minimumSec: 60,
maximumSec: 300,
});
return result;
}
+15 -51
View File
@@ -1,9 +1,10 @@
/** /**
* Limb-relative recession for distant aircraft billboards. * Limb-relative recession for distant aircraft: the factors icon sets apply in
* their shader (ICON_RECEDES) and models apply through their colour alpha.
* *
* This multiplies the billboard's ordinary scale, which Cesium then composes * It multiplies an icon's ordinary scale, which composes with its locked
* with the locked NearFarScalar. It never edits that scalar, culls a contact, * NearFarScalar. It never edits that scalar, culls a contact, or allows alpha
* or allows alpha to reach zero. * to reach zero.
*/ */
export const DEFAULT_AIRCRAFT_RECESSION_PARAMS = Object.freeze({ export const DEFAULT_AIRCRAFT_RECESSION_PARAMS = Object.freeze({
@@ -19,13 +20,6 @@ export const DEFAULT_AIRCRAFT_RECESSION_PARAMS = Object.freeze({
let _params = { ...DEFAULT_AIRCRAFT_RECESSION_PARAMS }; let _params = { ...DEFAULT_AIRCRAFT_RECESSION_PARAMS };
const _scratchFactors = { scale: 1, alpha: 1, limbRatio: null }; const _scratchFactors = { scale: 1, alpha: 1, limbRatio: null };
const _treatmentResult = {
scale: 1,
alpha: 1,
scaleWrites: 0,
alphaWrites: 0,
factors: _scratchFactors,
};
function clamp(value, min, max) { function clamp(value, min, max) {
return Math.min(max, Math.max(min, value)); return Math.min(max, Math.max(min, value));
@@ -178,28 +172,18 @@ export function aircraftRecessionFactors(
} }
/** /**
* Production wire helper: compose base scale/alpha, focus emphasis, and limb * The alpha an aircraft draws with: base × max(combined floor, focus × limb
* recession into one deadband-gated billboard write site. * haze). Icon sets compose the same thing in their shader; models need it here.
* @param {object} input * @param {object} input
* @param {object} input.billboard
* @param {number} input.baseScale
* @param {number} input.baseAlpha * @param {number} input.baseAlpha
* @param {{withAlpha:(alpha:number)=>object}} input.baseColor
* @param {number} input.focusFactor * @param {number} input.focusFactor
* @param {number} input.cameraDistanceM * @param {number} input.cameraDistanceM
* @param {number} input.cameraHeightM * @param {number} input.cameraHeightM
* @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [input.params] * @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [input.params]
* * @returns {number}
* IMPORTANT: the returned object (including `factors`) is a module-owned
* mutable singleton. Consume every needed property before the next call;
* never retain it as a snapshot.
* @returns {{scale:number,alpha:number,scaleWrites:number,alphaWrites:number,factors:object}}
*/ */
export function applyAircraftBillboardTreatment({ export function aircraftTreatmentAlpha({
billboard,
baseScale,
baseAlpha, baseAlpha,
baseColor,
focusFactor, focusFactor,
cameraDistanceM, cameraDistanceM,
cameraHeightM, cameraHeightM,
@@ -212,33 +196,13 @@ export function applyAircraftBillboardTreatment({
tuning, tuning,
_scratchFactors, _scratchFactors,
); );
const scale = baseScale * factors.scale; return (
const composedTreatment = Math.max( baseAlpha *
tuning.combinedAlphaFloor, Math.max(
clamp(focusFactor, 0, 1) * factors.alpha, tuning.combinedAlphaFloor,
clamp(focusFactor, 0, 1) * factors.alpha,
)
); );
const alpha = baseAlpha * composedTreatment;
let scaleWrites = 0;
let alphaWrites = 0;
if (
!Number.isFinite(billboard.scale) ||
Math.abs(billboard.scale - scale) > tuning.writeEpsilon
) {
billboard.scale = scale;
scaleWrites = 1;
}
if (
!Number.isFinite(billboard.color?.alpha) ||
Math.abs(billboard.color.alpha - alpha) > tuning.writeEpsilon
) {
billboard.color = baseColor.withAlpha(alpha);
alphaWrites = 1;
}
_treatmentResult.scale = scale;
_treatmentResult.alpha = alpha;
_treatmentResult.scaleWrites = scaleWrites;
_treatmentResult.alphaWrites = alphaWrites;
return _treatmentResult;
} }
/** /**
+3 -60
View File
@@ -3,9 +3,9 @@ import assert from 'node:assert/strict';
import { import {
DEFAULT_AIRCRAFT_RECESSION_PARAMS, DEFAULT_AIRCRAFT_RECESSION_PARAMS,
aircraftRecessionFactors, aircraftRecessionFactors,
applyAircraftBillboardTreatment,
applyAircraftModelTreatment, applyAircraftModelTreatment,
cameraLimbDistanceM, cameraLimbDistanceM,
aircraftTreatmentAlpha,
} from './aircraftRecession.js'; } from './aircraftRecession.js';
const params = { ...DEFAULT_AIRCRAFT_RECESSION_PARAMS }; const params = { ...DEFAULT_AIRCRAFT_RECESSION_PARAMS };
@@ -66,72 +66,15 @@ test('globe-view transition eases to identity without a threshold pop', () => {
assert.deepEqual(end, { scale: 1, alpha: 1, limbRatio: null }); assert.deepEqual(end, { scale: 1, alpha: 1, limbRatio: null });
}); });
test('aircraft billboard wire writes scale and alpha only for the far treatment', () => {
const color = { withAlpha: (alpha) => ({ alpha }) };
const near = { scale: 1.2, color: { alpha: 0.8 } };
const nearResult = applyAircraftBillboardTreatment({
billboard: near,
baseScale: 1.2,
baseAlpha: 0.8,
baseColor: color,
focusFactor: 1,
cameraDistanceM: limb * 0.4,
cameraHeightM,
params,
});
assert.equal(nearResult.scaleWrites, 0);
assert.equal(nearResult.alphaWrites, 0);
const far = { scale: 1.2, color: { alpha: 0.8 } };
const farResult = applyAircraftBillboardTreatment({
billboard: far,
baseScale: 1.2,
baseAlpha: 0.8,
baseColor: color,
focusFactor: 0.5,
cameraDistanceM: limb,
cameraHeightM,
params,
});
assert.equal(farResult.scaleWrites, 1);
assert.equal(farResult.alphaWrites, 1);
assert.ok(Math.abs(far.scale - (1.2 * 0.45)) < 1e-12);
assert.ok(Math.abs(far.color.alpha - (0.8 * 0.20)) < 1e-12);
});
test('combined focus and haze product is clamped at the composed alpha floor', () => { test('combined focus and haze product is clamped at the composed alpha floor', () => {
const color = { withAlpha: (alpha) => ({ alpha }) }; const alpha = aircraftTreatmentAlpha({
const billboard = { scale: 0.45, color: { alpha: 1 } };
const result = applyAircraftBillboardTreatment({
billboard,
baseScale: 1,
baseAlpha: 1, baseAlpha: 1,
baseColor: color,
focusFactor: 0.25, focusFactor: 0.25,
cameraDistanceM: limb, cameraDistanceM: limb,
cameraHeightM, cameraHeightM,
params, params,
}); });
assert.equal(result.alpha, 0.20); assert.equal(alpha, 0.2);
assert.equal(billboard.color.alpha, 0.20);
});
test('applyAircraftBillboardTreatment returns its documented module singleton', () => {
const color = { withAlpha: (alpha) => ({ alpha }) };
const billboard = { scale: 1, color: { alpha: 1 } };
const input = {
billboard,
baseAlpha: 1,
baseColor: color,
focusFactor: 1,
cameraDistanceM: 0,
cameraHeightM,
params,
};
const first = applyAircraftBillboardTreatment({ ...input, baseScale: 1 });
const second = applyAircraftBillboardTreatment({ ...input, baseScale: 0.5 });
assert.strictEqual(first, second);
assert.equal(first.scale, 0.5, 'the prior reference reflects the next call');
}); });
test('ambient model presentation receives composed alpha without changing blend semantics', () => { test('ambient model presentation receives composed alpha without changing blend semantics', () => {
File diff suppressed because it is too large. Load diff
+30 -15
View File
@@ -3,6 +3,7 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
import alprCamerasLayer, { import alprCamerasLayer, {
createAlprCamerasLayer, createAlprCamerasLayer,
configureAlprSource, configureAlprSource,
@@ -31,6 +32,8 @@ import {
isAlprSurveillanceType, isAlprSurveillanceType,
normalizeAlprNode, normalizeAlprNode,
} from './alprCameras.js'; } from './alprCameras.js';
import { readEntityRecord } from './entityRecords.js';
import { alprMarkers } from '../layers/alpr/markerState.js';
test('normalizeAlprNode: maps a full OSM node to a plain record', () => { test('normalizeAlprNode: maps a full OSM node to a plain record', () => {
const record = normalizeAlprNode({ const record = normalizeAlprNode({
@@ -340,13 +343,21 @@ function cameraHarness(layer = alprCamerasLayer) {
remove() {}, remove() {},
}, },
}; };
bindFakeCesiumCamera(viewer);
// The renderer's camera port relays moveEnd; the layer's own come on top.
const portMoveEndListeners = viewer.camera.moveEnd.numberOfListeners;
const setInput = Cesium.ScreenSpaceEventHandler.prototype.setInputAction; const setInput = Cesium.ScreenSpaceEventHandler.prototype.setInputAction;
Cesium.ScreenSpaceEventHandler.prototype.setInputAction = function ( Cesium.ScreenSpaceEventHandler.prototype.setInputAction = function (
fn, fn,
type, type,
modifier, modifier,
) { ) {
if (type === Cesium.ScreenSpaceEventType.LEFT_CLICK) click = fn; // The unmodified click: the input port binds one per modifier.
if (
type === Cesium.ScreenSpaceEventType.LEFT_CLICK &&
modifier === undefined
)
click = fn;
return setInput.call(this, fn, type, modifier); return setInput.call(this, fn, type, modifier);
}; };
try { try {
@@ -357,6 +368,7 @@ function cameraHarness(layer = alprCamerasLayer) {
layer.enable(); layer.enable();
return { return {
viewer, viewer,
portMoveEndListeners,
requests, requests,
credits, credits,
get source() { get source() {
@@ -443,25 +455,25 @@ test('all manufacturers share one ALPR title and color; only supplied metadata a
const entities = h.source.entities.values; const entities = h.source.entities.values;
assert.equal(alprCamerasLayer.name, 'ALPR Cameras'); assert.equal(alprCamerasLayer.name, 'ALPR Cameras');
assert.deepEqual( assert.deepEqual(
entities.map((entity) => entity.gevLabelModel.title), entities.map((entity) => readEntityRecord(entity)?.labelModel.title),
['ALPR-0042', 'ALPR-0043', 'ALPR-0044'], ['ALPR-0042', 'ALPR-0043', 'ALPR-0044'],
); );
assert.equal( assert.equal(
new Set(entities.map((entity) => entity.billboard.image.getValue())).size, new Set(entities.map((entity) => entity.billboard.image.getValue())).size,
1, 1,
); );
assert.deepEqual(entities[0].gevLabelModel.details, [ assert.deepEqual(readEntityRecord(entities[0])?.labelModel.details, [
'OSM MAPPED', 'OSM MAPPED',
'FLOCK SAFETY · CITY POLICE · FIXED', 'FLOCK SAFETY · CITY POLICE · FIXED',
'PUBLIC MAP DATA', 'PUBLIC MAP DATA',
]); ]);
assert.deepEqual(entities[1].gevLabelModel.details, [ assert.deepEqual(readEntityRecord(entities[1])?.labelModel.details, [
'OSM MAPPED', 'OSM MAPPED',
'MOTOROLA SOLUTIONS', 'MOTOROLA SOLUTIONS',
'PUBLIC MAP DATA', 'PUBLIC MAP DATA',
]); ]);
assert.deepEqual( assert.deepEqual(
entities[2].gevLabelModel.details, readEntityRecord(entities[2])?.labelModel.details,
['OSM MAPPED', 'PUBLIC MAP DATA'], ['OSM MAPPED', 'PUBLIC MAP DATA'],
'unknown metadata is not guessed, but the source is always named', 'unknown metadata is not guessed, but the source is always named',
); );
@@ -813,7 +825,7 @@ test('selected readout samples the rendered ground, not zero elevation or the di
h.click('alpr:42'); h.click('alpr:42');
const entity = h.source.entities.getById('alpr:42'); const entity = h.source.entities.getById('alpr:42');
const anchor = Cesium.Cartographic.fromCartesian( const anchor = Cesium.Cartographic.fromCartesian(
entity.gevDisplayPosition(), readEntityRecord(entity)?.displayPosition(),
); );
assert.ok(Math.abs(anchor.height - 187) < 0.001); assert.ok(Math.abs(anchor.height - 187) < 0.001);
assert.ok( assert.ok(
@@ -821,7 +833,7 @@ test('selected readout samples the rendered ground, not zero elevation or the di
); );
for (let i = 0; i < 60; i++) { for (let i = 0; i < 60; i++) {
h.viewer.scene.postRender.raiseEvent(); h.viewer.scene.postRender.raiseEvent();
entity.gevDisplayPosition(); readEntityRecord(entity)?.displayPosition();
} }
assert.equal(samples, 1, 'no per-frame raycasts'); assert.equal(samples, 1, 'no per-frame raycasts');
h.advance(1000); h.advance(1000);
@@ -921,11 +933,14 @@ test('two factories keep their requests, records and destruction independent', a
assert.equal(b.source.entities.values[0].id, 'directory:99'); assert.equal(b.source.entities.values[0].id, 'directory:99');
assert.equal(second.source, 'Test camera directory'); assert.equal(second.source, 'Test camera directory');
assert.ok( assert.ok(
b.source.entities.values[0].gevLabelModel.details.includes( readEntityRecord(
'Source: Test directory', b.source.entities.values[0],
), )?.labelModel.details.includes('Source: Test directory'),
);
assert.equal(
a.viewer.camera.moveEnd.numberOfListeners,
a.portMoveEndListeners,
); );
assert.equal(a.viewer.camera.moveEnd.numberOfListeners, 0);
assert.equal(a.viewer.scene.postRender.numberOfListeners, 0); assert.equal(a.viewer.scene.postRender.numberOfListeners, 0);
} finally { } finally {
b.restore(); b.restore();
@@ -1444,12 +1459,12 @@ test('map-source changes reposition all ALPR entities and saved native appearanc
await layer.update(); await layer.update();
const entities = [...h.source.entities.values]; const entities = [...h.source.entities.values];
const saved = entities[1]; const saved = entities[1];
saved.gevAlprNativeAppearance = { alprMarkers.write(saved).nativeAppearance = {
position: saved.position, position: saved.position,
heightReference: saved.billboard.heightReference, heightReference: saved.billboard.heightReference,
}; };
saved.position = Cesium.Cartesian3.fromDegrees(-97.7431, 30.26721, 190); saved.position = Cesium.Cartesian3.fromDegrees(-97.7431, 30.26721, 190);
saved.gevAlprPickPosition = saved.position; alprMarkers.write(saved).pickPosition = saved.position;
for (const next of [150, 120, null, 180]) { for (const next of [150, 120, null, 180]) {
floor = next; floor = next;
// Includes a provider switch with the same globe.show value. // Includes a provider switch with the same globe.show value.
@@ -1470,10 +1485,10 @@ test('map-source changes reposition all ALPR entities and saved native appearanc
? Cesium.HeightReference.CLAMP_TO_GROUND ? Cesium.HeightReference.CLAMP_TO_GROUND
: Cesium.HeightReference.NONE, : Cesium.HeightReference.NONE,
); );
assert.equal(entity.gevAlprCanvasPosition, null); assert.equal(alprMarkers.read(entity)?.canvasPosition, null);
} }
assert.equal( assert.equal(
saved.gevAlprNativeAppearance, alprMarkers.read(saved)?.nativeAppearance,
null, null,
'saved appearance restores at the new floor', 'saved appearance restores at the new floor',
); );
+36 -40
View File
@@ -28,6 +28,9 @@ import { createImagerySplit } from '../ui/imagerySplit.js';
import { formatDate, t, tn, msg, onLocaleChange } from '../i18n/index.js'; import { formatDate, t, tn, msg, onLocaleChange } from '../i18n/index.js';
import { useBhoteKoshiPanelStore } from '../ui/stores/bhoteKoshiPanel.js'; import { useBhoteKoshiPanelStore } from '../ui/stores/bhoteKoshiPanel.js';
import { applicationPinia } from '../ui/vueRuntime.js'; import { applicationPinia } from '../ui/vueRuntime.js';
import { heightsFor } from '../queries/heights.js';
import { cameraOf } from '../render/cesium/camera.js';
import { findCamera } from '../render/rendererOf.js';
export const BHOTE_KOSHI_LAYER_ID = 'bhote-koshi-2026'; export const BHOTE_KOSHI_LAYER_ID = 'bhote-koshi-2026';
export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses'; export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses';
@@ -122,20 +125,11 @@ const DEFAULT_EVENT_LOADER = async () => {
const DEFAULT_IMAGERY_PROVIDER_FACTORY = (url, options) => const DEFAULT_IMAGERY_PROVIDER_FACTORY = (url, options) =>
Cesium.SingleTileImageryProvider.fromUrl(url, options); Cesium.SingleTileImageryProvider.fromUrl(url, options);
const DEFAULT_TERRAIN_SAMPLER = async (viewer, observations) => { const DEFAULT_TERRAIN_SAMPLER = async (viewer, observations) => {
if ( if (!viewer?.scene || !viewer.terrainProvider?.availability) return null;
!viewer?.terrainProvider || const heights = await heightsFor(viewer.scene).batch(observations, {
!viewer.terrainProvider.availability || against: 'terrain',
typeof Cesium.sampleTerrainMostDetailed !== 'function' });
) { return heights.map((height) => ({ height }));
return null;
}
const cartographics = observations.map((observation) =>
Cesium.Cartographic.fromDegrees(observation.lon, observation.lat),
);
return Cesium.sampleTerrainMostDetailed(
viewer.terrainProvider,
cartographics,
);
}; };
const DEFAULT_MEDIA_LOADER = async (url, { signal } = {}) => { const DEFAULT_MEDIA_LOADER = async (url, { signal } = {}) => {
if (typeof createImageBitmap !== 'function') { if (typeof createImageBitmap !== 'function') {
@@ -896,20 +890,24 @@ function focusView(viewer, viewpoint) {
viewpoint.lat, viewpoint.lat,
viewpoint.targetElevationM, viewpoint.targetElevationM,
); );
const offset = new Cesium.HeadingPitchRange( const offset = {
Cesium.Math.toRadians(viewpoint.headingDeg), heading: Cesium.Math.toRadians(viewpoint.headingDeg),
Cesium.Math.toRadians(viewpoint.pitchDeg), pitch: Cesium.Math.toRadians(viewpoint.pitchDeg),
viewpoint.rangeM, range: viewpoint.rangeM,
); };
viewer.camera.cancelFlight?.(); const camera = cameraOf(viewer);
viewer.camera.flyToBoundingSphere(new Cesium.BoundingSphere(target, 80), { camera.cancelFlight();
offset, camera.flyToBounds(
duration: 2.2, { center: target, radius: 80 },
complete: () => { {
viewer.camera.lookAt(target, offset); offset,
viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); durationS: 2.2,
onComplete: () => {
camera.lookAt(target, offset);
camera.release();
},
}, },
}); );
} }
function setElementProperty(element, property, value) { function setElementProperty(element, property, value) {
@@ -1053,7 +1051,7 @@ export function createBhoteKoshiEventLayer({
let _cinematicActive = false; let _cinematicActive = false;
let _cinematicReplayArmed = false; let _cinematicReplayArmed = false;
let _cinematicPose = { target: new Cesium.Cartesian3() }; let _cinematicPose = { target: new Cesium.Cartesian3() };
let _cinematicOffset = new Cesium.HeadingPitchRange(); const _cinematicOffset = { heading: 0, pitch: 0, range: 0 };
let _lastCinematicProgress = null; let _lastCinematicProgress = null;
let _cameraInputTarget = null; let _cameraInputTarget = null;
let _evidenceClickTarget = null; let _evidenceClickTarget = null;
@@ -1197,7 +1195,8 @@ export function createBhoteKoshiEventLayer({
} }
throwIfEnableCancelled(signal, _enabled); throwIfEnableCancelled(signal, _enabled);
return observations.map((observation, index) => { return observations.map((observation, index) => {
const sampledHeight = Number(sampled?.[index]?.height); // A miss is null or undefined, never sea level.
const sampledHeight = Number(sampled?.[index]?.height ?? Number.NaN);
const fallbackHeight = Number(observation.fallbackElevationM) || 0; const fallbackHeight = Number(observation.fallbackElevationM) || 0;
return Cesium.Cartesian3.fromDegrees( return Cesium.Cartesian3.fromDegrees(
observation.lon, observation.lon,
@@ -1573,14 +1572,12 @@ export function createBhoteKoshiEventLayer({
// a single high sample can make the front dive backward in screen space. // a single high sample can make the front dive backward in screen space.
if (_sceneControls.evidencePathElevation !== 'source') { if (_sceneControls.evidencePathElevation !== 'source') {
// Sample once while preparing the shot, never from an animation frame. // Sample once while preparing the shot, never from an animation frame.
try { const heights = _viewer?.scene ? heightsFor(_viewer.scene) : null;
height = _viewer?.scene?.sampleHeight?.( height = heights?.now(position, {
position, exclude: [_floodCore, _floodHalo, _surgePoint].filter(Boolean),
[_floodCore, _floodHalo, _surgePoint].filter(Boolean), });
);
} catch {}
if (!Number.isFinite(height)) if (!Number.isFinite(height))
height = _viewer?.scene?.globe?.getHeight?.(position); height = heights?.now(position, { against: 'terrain' });
} }
if (!Number.isFinite(height)) height = point.fallbackElevationM; if (!Number.isFinite(height)) height = point.fallbackElevationM;
return Cesium.Cartesian3.fromDegrees(point.lon, point.lat, height + 8); return Cesium.Cartesian3.fromDegrees(point.lon, point.lat, height + 8);
@@ -2635,7 +2632,7 @@ export function createBhoteKoshiEventLayer({
_cinematicOffset.heading = Cesium.Math.toRadians(pose.headingDeg); _cinematicOffset.heading = Cesium.Math.toRadians(pose.headingDeg);
_cinematicOffset.pitch = Cesium.Math.toRadians(pose.pitchDeg); _cinematicOffset.pitch = Cesium.Math.toRadians(pose.pitchDeg);
_cinematicOffset.range = pose.rangeM; _cinematicOffset.range = pose.rangeM;
_viewer.camera.lookAt(pose.target, _cinematicOffset); findCamera(_viewer)?.lookAt(pose.target, _cinematicOffset);
_lastCinematicProgress = _progress; _lastCinematicProgress = _progress;
} }
@@ -2705,7 +2702,7 @@ export function createBhoteKoshiEventLayer({
_cinematicActive = true; _cinematicActive = true;
_lastCinematicProgress = null; _lastCinematicProgress = null;
announceNavigationAuthority('bhote-koshi-cinematic'); announceNavigationAuthority('bhote-koshi-cinematic');
_viewer?.camera?.cancelFlight?.(); findCamera(_viewer)?.cancelFlight();
} }
syncCinematicButton(); syncCinematicButton();
return true; return true;
@@ -2722,8 +2719,7 @@ export function createBhoteKoshiEventLayer({
} }
_cinematicActive = false; _cinematicActive = false;
_lastCinematicProgress = null; _lastCinematicProgress = null;
if (releaseCamera) if (releaseCamera) findCamera(_viewer)?.release();
_viewer?.camera?.lookAtTransform?.(Cesium.Matrix4.IDENTITY);
syncCinematicButton(); syncCinematicButton();
renderHost.request('bhote-koshi-cinematic-stop'); renderHost.request('bhote-koshi-cinematic-stop');
} }
+43 -2
View File
@@ -1,3 +1,4 @@
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import test from 'node:test'; import test from 'node:test';
@@ -239,7 +240,7 @@ function eventViewer(splitPosition = 0.37) {
const canvas = new FakeElement('canvas'); const canvas = new FakeElement('canvas');
const cameraLooks = []; const cameraLooks = [];
const cameraTransforms = []; const cameraTransforms = [];
return { return bindFakeCesiumCamera({
scene: { splitPosition, canvas }, scene: { splitPosition, canvas },
camera: { camera: {
cancelFlight() {}, cancelFlight() {},
@@ -272,7 +273,7 @@ function eventViewer(splitPosition = 0.37) {
cameraLooks, cameraLooks,
cameraTransforms, cameraTransforms,
}, },
}; });
} }
test('public lifecycle restores Nepal scene ownership without forwarding origin to enable', async () => { test('public lifecycle restores Nepal scene ownership without forwarding origin to enable', async () => {
@@ -2992,3 +2993,43 @@ test('Bhote Koshi corridor remains ordered downstream and inside the imagery rec
priorChainage = point.chainageM; priorChainage = point.chainageM;
} }
}); });
test('an evidence point whose terrain sample misses sits at its fallback elevation, not sea level', async () => {
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
const originalDocument = globalThis.document;
const originalWindow = globalThis.window;
installEventDom();
globalThis.window = { setTimeout: () => 71, clearTimeout() {} };
const viewer = eventViewer();
const publications = [];
const layer = createBhoteKoshiEventLayer({
eventLoader: async () => event,
imageryProviderFactory: async (url) => ({ url }),
terrainSampler: async (_viewer, observations) => observations.map(() => ({ height: null })),
mediaLoader: async () => {
const error = new Error('fixture media intentionally unavailable');
error.name = 'AbortError';
throw error;
},
overlayHost: { setEntries(_source, entries) { publications.push(entries); }, setVisible() {}, clearSource() {} },
renderHost: { request() {}, hold() {}, release() {} },
});
try {
layer.setParams({
presentation: 'scene-beat',
sceneSurface: 'evidence-beat',
beatId: 'gyirong-border-gate',
}, { origin: 'scene' });
await layer.enable(viewer, { origin: 'scene' });
const card = publications.at(-1).find((entry) => entry.id === 'evidence-card-gyirong-border-gate');
const fallback = event.evidenceSpine.find(({ id }) => id === 'gyirong-border-gate').fallbackElevationM;
assert.ok(fallback > 100, 'the fixture has a real fallback elevation');
const height = Cesium.Cartographic.fromCartesian(card.position()).height;
assert.ok(Math.abs(height - (fallback + 18)) < 1, `card at ${height} m`);
await layer.disable();
} finally {
await layer.destroy();
globalThis.document = originalDocument;
globalThis.window = originalWindow;
}
});
+48 -42
View File
@@ -4,6 +4,7 @@ import {
setOverlayEntries, setOverlayEntries,
} from '../overlays/worldOverlay.js'; } from '../overlays/worldOverlay.js';
import { governorRequestRender } from '../renderGovernor.js'; import { governorRequestRender } from '../renderGovernor.js';
import { cameraOf } from '../render/cesium/camera.js';
import { import {
BHOTE_KOSHI_LOCATOR_CREDIT, BHOTE_KOSHI_LOCATOR_CREDIT,
registerDynamicCredit, registerDynamicCredit,
@@ -620,6 +621,7 @@ export function createBhoteKoshiLocatorLayer({
requestRender('bhote-koshi-trigger-record-reveal'); requestRender('bhote-koshi-trigger-record-reveal');
const camera = _viewer?.camera; const camera = _viewer?.camera;
if (!camera?.flyTo) return; if (!camera?.flyTo) return;
const port = cameraOf(_viewer);
const generation = _generation; const generation = _generation;
_triggerApproachInFlight = true; _triggerApproachInFlight = true;
const finish = () => { const finish = () => {
@@ -627,50 +629,54 @@ export function createBhoteKoshiLocatorLayer({
attempt.phase = 'finished'; attempt.phase = 'finished';
_triggerApproachInFlight = false; _triggerApproachInFlight = false;
}; };
camera.flyTo({ port.flyTo(
destination: Cesium.Cartesian3.fromDegrees( {
TRIGGER_APPROACH_CAMERA.lon, lon: TRIGGER_APPROACH_CAMERA.lon,
TRIGGER_APPROACH_CAMERA.lat, lat: TRIGGER_APPROACH_CAMERA.lat,
TRIGGER_APPROACH_CAMERA.alt, height: TRIGGER_APPROACH_CAMERA.alt,
),
orientation: {
heading: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.heading),
pitch: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.pitch),
roll: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.roll),
}, },
duration: TRIGGER_APPROACH_DURATION_SEC, {
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, orientation: {
complete: () => { heading: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.heading),
if (_triggerApproachAttempt !== attempt || attempt.phase !== 'flying') pitch: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.pitch),
return; roll: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.roll),
if ( },
!_enabled || durationS: TRIGGER_APPROACH_DURATION_SEC,
generation !== _generation || easing: 'cubicInOut',
_presentation !== BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION onComplete: () => {
) { if (_triggerApproachAttempt !== attempt || attempt.phase !== 'flying')
finish(); return;
return; if (
} !_enabled ||
attempt.phase = 'orbiting'; generation !== _generation ||
camera.flyTo({ _presentation !== BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION
destination: Cesium.Cartesian3.fromDegrees( ) {
TRIGGER_ORBIT_CAMERA.lon, finish();
TRIGGER_ORBIT_CAMERA.lat, return;
TRIGGER_ORBIT_CAMERA.alt, }
), attempt.phase = 'orbiting';
orientation: { port.flyTo(
heading: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.heading), {
pitch: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.pitch), lon: TRIGGER_ORBIT_CAMERA.lon,
roll: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.roll), lat: TRIGGER_ORBIT_CAMERA.lat,
}, height: TRIGGER_ORBIT_CAMERA.alt,
duration: TRIGGER_ORBIT_DURATION_SEC, },
easingFunction: Cesium.EasingFunction.SINUSOIDAL_IN_OUT, {
complete: finish, orientation: {
cancel: finish, heading: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.heading),
}); pitch: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.pitch),
roll: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.roll),
},
durationS: TRIGGER_ORBIT_DURATION_SEC,
easing: 'sineInOut',
onComplete: finish,
onCancel: finish,
},
);
},
onCancel: finish,
}, },
cancel: finish, );
});
} }
function armTriggerApproach() { function armTriggerApproach() {
+9 -2
View File
@@ -1,3 +1,4 @@
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
@@ -647,6 +648,7 @@ test('Bhote Koshi trigger-record waits for the scene camera, zooms, then orbits'
}, },
cancelFlight() {}, cancelFlight() {},
}; };
bindFakeCesiumCamera(viewer);
const layer = createBhoteKoshiLocatorLayer({ const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => null, boundaryResolver: async () => null,
requestRender() {}, requestRender() {},
@@ -703,6 +705,9 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
}, },
cancelFlight() {}, cancelFlight() {},
}; };
bindFakeCesiumCamera(viewer);
// The renderer's camera port relays these; the layer's own come on top.
const portListeners = [moveStart, moveEnd].map((e) => e.numberOfListeners);
const layer = createBhoteKoshiLocatorLayer({ const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => null, boundaryResolver: async () => null,
overlayHost: overlayHostFixture(), overlayHost: overlayHostFixture(),
@@ -745,8 +750,10 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
const oldFlights = [...flights]; const oldFlights = [...flights];
SceneDirector.prototype.stopScene.call(director); SceneDirector.prototype.stopScene.call(director);
assert.equal(timers.size, 0, 'Stop cancels the actual delayed work'); assert.equal(timers.size, 0, 'Stop cancels the actual delayed work');
assert.equal(moveStart.numberOfListeners, 0); assert.deepEqual(
assert.equal(moveEnd.numberOfListeners, 0); [moveStart, moveEnd].map((e) => e.numberOfListeners),
portListeners,
);
callbacks[0](); callbacks[0]();
moveEnd.raiseEvent(); moveEnd.raiseEvent();
for (const flight of oldFlights) flight.complete(); for (const flight of oldFlights) flight.complete();
+1
View File
@@ -65,6 +65,7 @@ export const materializeCctvVisibleCoverageEntities =
export const _extractPickedCameraIdForTest = export const _extractPickedCameraIdForTest =
layer._extractPickedCameraIdForTest; layer._extractPickedCameraIdForTest;
export const _setCctvCoverageStateForTest = layer._setCctvCoverageStateForTest; export const _setCctvCoverageStateForTest = layer._setCctvCoverageStateForTest;
export const _cctvIconsForTest = layer._cctvIconsForTest;
export const focusCctvRecord = layer.focusCctvRecord; export const focusCctvRecord = layer.focusCctvRecord;
export const maybeAutoHop = layer.maybeAutoHop; export const maybeAutoHop = layer.maybeAutoHop;
export const cctvCycleIndex = layer.cctvCycleIndex; export const cctvCycleIndex = layer.cctvCycleIndex;
+48 -61
View File
@@ -13,6 +13,7 @@
// //
// computeFrustumGeometry is PURE (no viewer, no scene queries) so it runs under // computeFrustumGeometry is PURE (no viewer, no scene queries) so it runs under
// plain node:test. // plain node:test.
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
import { test } from 'node:test'; import { test } from 'node:test';
// Preserve structural pins across component qualification and formatter wrapping. // Preserve structural pins across component qualification and formatter wrapping.
function componentFunctionSource(fn) { function componentFunctionSource(fn) {
@@ -32,6 +33,7 @@ import cctvLayer, {
_extractPickedCameraIdForTest, _extractPickedCameraIdForTest,
_updateCctvProjectionPlaneForTest, _updateCctvProjectionPlaneForTest,
_setCctvCoverageStateForTest, _setCctvCoverageStateForTest,
_cctvIconsForTest,
_pushAmbientCardEntriesForTest, _pushAmbientCardEntriesForTest,
_setCctvOverlayHostForTest, _setCctvOverlayHostForTest,
activationProbeClampRange, activationProbeClampRange,
@@ -79,27 +81,12 @@ import {
IDLE_CAMERA_COLOR, IDLE_CAMERA_COLOR,
LIVE_CAMERA_COLOR, LIVE_CAMERA_COLOR,
} from '../layers/cctv/policy.js'; } from '../layers/cctv/policy.js';
import { cctvPicks } from '../layers/cctv/pickState.js';
import { FAKE_EVENT_TYPES, fakeInput } from '../input/testDoubles.mjs';
const ASPECT = 16 / 9; const ASPECT = 16 / 9;
const toRad = (deg) => (deg * Math.PI) / 180; const toRad = (deg) => (deg * Math.PI) / 180;
const GESTURE_TYPES = { const GESTURE_TYPES = FAKE_EVENT_TYPES;
LEFT_DOWN: 'left-down',
MOUSE_MOVE: 'mouse-move',
LEFT_UP: 'left-up',
LEFT_CLICK: 'left-click',
};
function makeGestureHandler() {
const actions = new Map();
return {
setInputAction(callback, type) {
actions.set(type, callback);
},
fire(type, event) {
actions.get(type)?.(event);
},
};
}
// Same spherical earth radius projectPoint uses (R = 6371 km) so the // Same spherical earth radius projectPoint uses (R = 6371 km) so the
// lat/lon→metres conversion in the assertions matches the module's math. // lat/lon→metres conversion in the assertions matches the module's math.
const M_PER_DEG = (Math.PI / 180) * 6371000; const M_PER_DEG = (Math.PI / 180) * 6371000;
@@ -355,7 +342,6 @@ test('default COVERAGE refresh materializes the active and visible camera frustu
test('camera icons are violet for video feeds, cyan for stills, amber when active', () => { test('camera icons are violet for video feeds, cyan for stills, amber when active', () => {
const record = (id, feedType) => ({ const record = (id, feedType) => ({
camera: { ...UNCLAMPED_CAMERA, id, feedType, groundElevationM: 0 }, camera: { ...UNCLAMPED_CAMERA, id, feedType, groundElevationM: 0 },
billboard: {},
coverageEntities: [], coverageEntities: [],
projection: null, projection: null,
}); });
@@ -375,9 +361,15 @@ test('camera icons are violet for video feeds, cyan for stills, amber when activ
coverageMode: 'off', coverageMode: 'off',
}); });
refreshCoverageStyles(); refreshCoverageStyles();
assert.equal(idleVideo.billboard.color, LIVE_CAMERA_COLOR); const icons = _cctvIconsForTest();
assert.equal(idleStill.billboard.color, IDLE_CAMERA_COLOR); const color = (record) => icons.color(icons.row(record.camera.id));
assert.equal(activeVideo.billboard.color, ACTIVE_CAMERA_COLOR); const bytes = (c) =>
[c.red, c.green, c.blue, c.alpha].map(Cesium.Color.floatToByte);
assert.deepEqual(bytes(color(idleVideo)), bytes(LIVE_CAMERA_COLOR));
assert.deepEqual(bytes(color(idleStill)), bytes(IDLE_CAMERA_COLOR));
assert.deepEqual(bytes(color(activeVideo)), bytes(ACTIVE_CAMERA_COLOR));
assert.equal(icons.scale(icons.row(activeVideo.camera.id)), 1.25);
assert.equal(icons.scale(icons.row(idleVideo.camera.id)), 1);
} finally { } finally {
_setCctvCoverageStateForTest({ enabled: false }); _setCctvCoverageStateForTest({ enabled: false });
} }
@@ -386,7 +378,7 @@ test('camera icons are violet for video feeds, cyan for stills, amber when activ
test('geometry drain pacing yields to tracked and cockpit camera ownership', () => { test('geometry drain pacing yields to tracked and cockpit camera ownership', () => {
assert.deepEqual(cctvGeometryDrainPacing(), { batchSize: 4, delayMs: 120 }); assert.deepEqual(cctvGeometryDrainPacing(), { batchSize: 4, delayMs: 120 });
assert.deepEqual( assert.deepEqual(
cctvGeometryDrainPacing({ trackedEntity: { id: 'flight-1' } }), cctvGeometryDrainPacing({ followed: { key: 'flights:1' } }),
{ batchSize: 2, delayMs: 250 }, { batchSize: 2, delayMs: 250 },
); );
assert.deepEqual(cctvGeometryDrainPacing({ cockpitActive: true }), { assert.deepEqual(cctvGeometryDrainPacing({ cockpitActive: true }), {
@@ -401,13 +393,13 @@ test('geometry drain pacing yields to tracked and cockpit camera ownership', ()
}); });
test('geometry drain rechecks pacing when tracking releases between batches', () => { test('geometry drain rechecks pacing when tracking releases between batches', () => {
let trackedEntity = { id: 'flight-1' }; let followed = { key: 'flights:1' };
const queue = Array.from({ length: 10 }, (_, index) => index + 1); const queue = Array.from({ length: 10 }, (_, index) => index + 1);
const visited = []; const visited = [];
const runBatch = () => const runBatch = () =>
processCctvGeometryDrainBatch({ processCctvGeometryDrainBatch({
queue, queue,
readOwnership: () => ({ trackedEntity, cockpitActive: false }), readOwnership: () => ({ followed, cockpitActive: false }),
visit: (record) => visited.push(record), visit: (record) => visited.push(record),
progress: () => {}, progress: () => {},
complete: () => {}, complete: () => {},
@@ -417,7 +409,7 @@ test('geometry drain rechecks pacing when tracking releases between batches', ()
assert.deepEqual(trackedBatch, { hasMore: true, batchSize: 2, delayMs: 250 }); assert.deepEqual(trackedBatch, { hasMore: true, batchSize: 2, delayMs: 250 });
assert.deepEqual(visited, [1, 2]); assert.deepEqual(visited, [1, 2]);
trackedEntity = null; followed = null;
const untrackedBatch = runBatch(); const untrackedBatch = runBatch();
assert.deepEqual(untrackedBatch, { assert.deepEqual(untrackedBatch, {
hasMore: true, hasMore: true,
@@ -682,7 +674,6 @@ test('CCTV disable hide sweep hides record visuals and destroys viewsheds withou
camera: { id: 'a' }, camera: { id: 'a' },
activationDone: true, activationDone: true,
probeClampRangeM: 105, probeClampRangeM: 105,
billboard: { color: 'keep-color', scale: 1.25 },
coverageEntities: [{ show: true }, { show: true }], coverageEntities: [{ show: true }, { show: true }],
projection: { planeEntity: { show: true } }, projection: { planeEntity: { show: true } },
viewshedPrimitive: viewshedA, viewshedPrimitive: viewshedA,
@@ -691,7 +682,6 @@ test('CCTV disable hide sweep hides record visuals and destroys viewsheds withou
camera: { id: 'b' }, camera: { id: 'b' },
activationDone: true, activationDone: true,
probeClampRangeM: 146, probeClampRangeM: 146,
billboard: { color: 'keep-color', scale: 1 },
projection: null, projection: null,
viewshedPrimitive: viewshedB, viewshedPrimitive: viewshedB,
}, },
@@ -725,13 +715,6 @@ test('CCTV disable hide sweep hides record visuals and destroys viewsheds withou
records.map((record) => record.activationDone), records.map((record) => record.activationDone),
[false, true], [false, true],
); );
assert.deepEqual(
records.map((record) => record.billboard),
[
{ color: 'keep-color', scale: 1.25 },
{ color: 'keep-color', scale: 1 },
],
);
}); });
test('real active monitor plane owns one protected host label and no native label entity', () => { test('real active monitor plane owns one protected host label and no native label entity', () => {
@@ -886,6 +869,7 @@ test('CCTV focus distinguishes tracking ownership from a missing active camera',
}, },
}, },
}; };
bindFakeCesiumCamera(viewer);
const record = { const record = {
camera: { rangeM: 210, headingDeg: 41 }, camera: { rangeM: 210, headingDeg: 41 },
position: { x: 1, y: 2, z: 3 }, position: { x: 1, y: 2, z: 3 },
@@ -917,6 +901,7 @@ test('CCTV focus reports when the camera flight starts', () => {
}, },
}, },
}; };
bindFakeCesiumCamera(viewer);
const record = { const record = {
camera: { rangeM: 210, headingDeg: 41 }, camera: { rangeM: 210, headingDeg: 41 },
position: { x: 1, y: 2, z: 3 }, position: { x: 1, y: 2, z: 3 },
@@ -937,6 +922,7 @@ test('CCTV focus refuses camera flights while cockpit owns the view', () => {
}, },
}, },
}; };
bindFakeCesiumCamera(viewer);
const record = { const record = {
camera: { rangeM: 210, headingDeg: 41 }, camera: { rangeM: 210, headingDeg: 41 },
position: { x: 1, y: 2, z: 3 }, position: { x: 1, y: 2, z: 3 },
@@ -989,7 +975,7 @@ test('CCTV repeated in-world clicks dispatch focus only for the one real activat
assert.deepEqual(requests, [{ cameraId: 'atx-cam-3' }]); assert.deepEqual(requests, [{ cameraId: 'atx-cam-3' }]);
assert.match( assert.match(
componentFunctionSource(cctvLayer.init), componentFunctionSource(cctvLayer.init),
/bindCctvWorldClickGesture\(_clickHandler/, /bindCctvWorldClickGesture\(\s*input,/,
); );
assert.match( assert.match(
componentFunctionSource(cctvLayer.init), componentFunctionSource(cctvLayer.init),
@@ -1128,14 +1114,12 @@ test('CCTV empty-click gate excludes every identified scene object, ADJUST, and
}); });
test('CCTV camera extraction requires layer ownership, not a colliding sibling ID', () => { test('CCTV camera extraction requires layer ownership, not a colliding sibling ID', () => {
const billboard = { id: 'shared-id' }; const ownedCoverageEntity = { id: 'cctv-shared-id-cap' };
const ownedCoverageEntity = { cctvPicks.write(ownedCoverageEntity).cameraId = 'shared-id';
id: 'cctv-shared-id-cap', const strayEntity = { id: 'cctv-shared-id-stray' };
properties: { cctvCameraId: 'shared-id' }, cctvPicks.write(strayEntity).cameraId = 'shared-id';
};
const record = { const record = {
...makeDeselectRecord('shared-id'), ...makeDeselectRecord('shared-id'),
billboard,
coverageEntities: [ownedCoverageEntity], coverageEntities: [ownedCoverageEntity],
}; };
_setCctvCoverageStateForTest({ _setCctvCoverageStateForTest({
@@ -1144,12 +1128,17 @@ test('CCTV camera extraction requires layer ownership, not a colliding sibling I
}); });
try { try {
assert.equal( assert.equal(
_extractPickedCameraIdForTest({ id: 'shared-id', primitive: billboard }), _extractPickedCameraIdForTest({
id: 'shared-id',
iconSet: _cctvIconsForTest(),
primitive: { id: 'shared-id' },
}),
'shared-id', 'shared-id',
); );
assert.equal( assert.equal(
_extractPickedCameraIdForTest({ _extractPickedCameraIdForTest({
id: 'shared-id', id: 'shared-id',
iconSet: {},
primitive: { id: 'shared-id' }, primitive: { id: 'shared-id' },
}), }),
null, null,
@@ -1175,6 +1164,11 @@ test('CCTV camera extraction requires layer ownership, not a colliding sibling I
null, null,
'a sibling cannot impersonate CCTV by copying its property shape', 'a sibling cannot impersonate CCTV by copying its property shape',
); );
assert.equal(
_extractPickedCameraIdForTest({ id: strayEntity }),
null,
'a camera id alone is not ownership: the record must hold the entity',
);
} finally { } finally {
_setCctvCoverageStateForTest({ enabled: false }); _setCctvCoverageStateForTest({ enabled: false });
} }
@@ -1401,29 +1395,22 @@ test('active CCTV camera is absent from host by default and protected only when
test('CCTV drag-then-release over a camera is inert, while a clean tap activates and dispatches', () => { test('CCTV drag-then-release over a camera is inert, while a clean tap activates and dispatches', () => {
let timeMs = 0; let timeMs = 0;
let activationCalls = 0; let activationCalls = 0;
const handler = makeGestureHandler(); const { input, handler } = fakeInput({ scene: {} }, { now: () => timeMs });
const target = new EventTarget(); const target = new EventTarget();
const requests = []; const requests = [];
target.addEventListener(CCTV_FOCUS_REQUEST_EVENT, (event) => target.addEventListener(CCTV_FOCUS_REQUEST_EVENT, (event) =>
requests.push(event.detail), requests.push(event.detail),
); );
bindCctvWorldClickGesture( bindCctvWorldClickGesture(input, () => {
handler, activateCctvCameraFromWorldClick(
() => { 'atx-cam-3',
activateCctvCameraFromWorldClick( () => {
'atx-cam-3', activationCalls += 1;
() => { return CCTV_ACTIVATION_RESULT.ACTIVATED;
activationCalls += 1; },
return CCTV_ACTIVATION_RESULT.ACTIVATED; target,
}, );
target, });
);
},
{
now: () => timeMs,
eventTypes: GESTURE_TYPES,
},
);
handler.fire(GESTURE_TYPES.LEFT_DOWN, { position: { x: 10, y: 10 } }); handler.fire(GESTURE_TYPES.LEFT_DOWN, { position: { x: 10, y: 10 } });
timeMs = 20; timeMs = 20;
+23 -20
View File
@@ -8,7 +8,7 @@
* - Pure drag math (this top section): ray↔axis closest-point, ray↔plane * - Pure drag math (this top section): ray↔axis closest-point, ray↔plane
* intersection with a grazing-angle guard, ring angles. All unit-tested * intersection with a grazing-angle guard, ring angles. All unit-tested
* under node:test with zero scene dependencies. * under node:test with zero scene dependencies.
* - createCalibrationGizmo (below): the entity + ScreenSpaceEventHandler * - createCalibrationGizmo (below): the entity + pointer-input
* controller that renders grab-able handles on the ACTIVE camera and turns * controller that renders grab-able handles on the ACTIVE camera and turns
* drags into calibration patches through a narrow callback interface. It * drags into calibration patches through a narrow callback interface. It
* never touches the layer's records/stores directly. * never touches the layer's records/stores directly.
@@ -24,7 +24,7 @@
* invariant is untouched. * invariant is untouched.
*/ */
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { isPointerFree } from './inputOwnership.js'; import { inputFor } from '../input/dispatcher.js';
// Grazing guard (spec §5): reject plane intersections when the view ray is // Grazing guard (spec §5): reject plane intersections when the view ray is
// nearly parallel to the constraint plane — the hit point races to infinity // nearly parallel to the constraint plane — the hit point races to infinity
@@ -118,8 +118,8 @@ export function signedAngleDelta(fromRad, toRad) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Entity id prefix for every gizmo part — the layer's pick-owner regex and /** Entity id prefix for every gizmo part — the layer's pick-owner regex and
* click-to-select guard key off this. Gizmo entities NEVER carry a * click-to-select guard key off this. Gizmo entities NEVER get a camera id in
* `cctvCameraId` property, so the layer's selection path ignores them. */ * the layer's pick store, so the layer's selection path ignores them. */
export const GIZMO_ID_PREFIX = 'cctv-gizmo-'; export const GIZMO_ID_PREFIX = 'cctv-gizmo-';
const RING_SEGMENTS = 96; const RING_SEGMENTS = 96;
@@ -237,7 +237,8 @@ export function createCalibrationGizmo({
const entities = new Map(); // part -> Entity const entities = new Map(); // part -> Entity
const scene = viewer.scene; const scene = viewer.scene;
const handler = new Cesium.ScreenSpaceEventHandler(scene.canvas); const input = inputFor(viewer);
let releaseCamera = null;
/** Adds (or re-adds) a gizmo entity, clearing any stale duplicate id. */ /** Adds (or re-adds) a gizmo entity, clearing any stale duplicate id. */
function addEntity(part, options) { function addEntity(part, options) {
@@ -533,9 +534,7 @@ export function createCalibrationGizmo({
drag = state; drag = state;
setCursor('grabbing'); setCursor('grabbing');
if (scene.screenSpaceCameraController) { releaseCamera = input.suspendCamera('cctv-gizmo-drag');
scene.screenSpaceCameraController.enableInputs = false;
}
return true; return true;
} }
@@ -615,9 +614,8 @@ export function createCalibrationGizmo({
if (!drag) return; if (!drag) return;
const record = drag.record; const record = drag.record;
drag = null; drag = null;
if (scene.screenSpaceCameraController) { releaseCamera?.();
scene.screenSpaceCameraController.enableInputs = true; releaseCamera = null;
}
setCursor(hoveredId ? 'grab' : ''); setCursor(hoveredId ? 'grab' : '');
endPatch(record); endPatch(record);
} }
@@ -629,18 +627,21 @@ export function createCalibrationGizmo({
} }
} }
handler.setInputAction((event) => { // An ambient press: the dispatcher withholds it while a tool owns the pointer
// A tool owns the pointer (src/data/inputOwnership.js): yield the press. // (src/data/inputOwnership.js).
if (!isPointerFree()) return; const offDown = input.onPointer('down', (event) => {
if (!enabled) return; if (!enabled) return;
// A release Cesium never reported (one with a modifier key held) leaves
// the last drag open; a new press ends it, hold and patch included.
if (drag) endDrag();
const part = pickGizmoPart(event.position); const part = pickGizmoPart(event.position);
debugLog('LEFT_DOWN', event.position, 'part:', part); debugLog('LEFT_DOWN', event.position, 'part:', part);
if (!part) return; if (!part) return;
const started = beginDrag(part, event.position); const started = beginDrag(part, event.position);
debugLog('beginDrag', part, '→', started); debugLog('beginDrag', part, '→', started);
}, Cesium.ScreenSpaceEventType.LEFT_DOWN); });
handler.setInputAction((event) => { const offMove = input.onPointer('move', (event) => {
if (!enabled) return; if (!enabled) return;
const now = Date.now(); const now = Date.now();
if (drag) { if (drag) {
@@ -667,12 +668,12 @@ export function createCalibrationGizmo({
if (now - lastHoverAt < HOVER_THROTTLE_MS) return; if (now - lastHoverAt < HOVER_THROTTLE_MS) return;
lastHoverAt = now; lastHoverAt = now;
setHovered(pickGizmoPart(event.endPosition)); setHovered(pickGizmoPart(event.endPosition));
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE); });
handler.setInputAction(() => { const offUp = input.onPointer('up', () => {
if (!enabled) return; if (!enabled) return;
endDrag(); endDrag();
}, Cesium.ScreenSpaceEventType.LEFT_UP); });
return { return {
setEnabled(value) { setEnabled(value) {
@@ -688,7 +689,9 @@ export function createCalibrationGizmo({
refresh, refresh,
destroy() { destroy() {
if (drag) endDrag(); if (drag) endDrag();
handler.destroy(); offDown();
offMove();
offUp();
for (const entity of entities.values()) { for (const entity of entities.values()) {
viewer.entities.remove(entity); viewer.entities.remove(entity);
} }
+140 -15
View File
@@ -8,53 +8,87 @@
// - rayPlaneIntersect refuses grazing rays (|dir·normal| < 0.08 — spec §5 // - rayPlaneIntersect refuses grazing rays (|dir·normal| < 0.08 — spec §5
// precision guard) and behind-origin hits; // precision guard) and behind-origin hits;
// - ringAngle/signedAngleDelta give quadrant-correct, wrap-safe angles for // - ringAngle/signedAngleDelta give quadrant-correct, wrap-safe angles for
// the heading/pitch ring drags. // the heading/pitch ring drags;
// - the controller ends an open drag, and its camera hold, on the next press.
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { import {
GIZMO_ID_PREFIX,
closestParamOnAxis, closestParamOnAxis,
createCalibrationGizmo,
rayPlaneIntersect, rayPlaneIntersect,
ringAngle, ringAngle,
signedAngleDelta, signedAngleDelta,
} from './cctvGizmo.js'; } from './cctvGizmo.js';
import { FAKE_EVENT_TYPES, fakeInput } from '../input/testDoubles.mjs';
const c3 = (x, y, z) => new Cesium.Cartesian3(x, y, z); const c3 = (x, y, z) => new Cesium.Cartesian3(x, y, z);
test('closestParamOnAxis: perpendicular ray hits the axis at its own offset', () => { test('closestParamOnAxis: perpendicular ray hits the axis at its own offset', () => {
// Axis along +X from origin. Ray shooting straight down (-Z) from (10, 0, 5): // Axis along +X from origin. Ray shooting straight down (-Z) from (10, 0, 5):
// nearest axis point is x=10 → t=10. // nearest axis point is x=10 → t=10.
const t = closestParamOnAxis(c3(10, 0, 5), c3(0, 0, -1), c3(0, 0, 0), c3(1, 0, 0)); const t = closestParamOnAxis(
c3(10, 0, 5),
c3(0, 0, -1),
c3(0, 0, 0),
c3(1, 0, 0),
);
assert.ok(Math.abs(t - 10) < 1e-9, `expected 10, got ${t}`); assert.ok(Math.abs(t - 10) < 1e-9, `expected 10, got ${t}`);
}); });
test('closestParamOnAxis: skew ray resolves to the geometric closest point', () => { test('closestParamOnAxis: skew ray resolves to the geometric closest point', () => {
// Axis +X. Ray from (0, 10, 0) toward (1, -1, 0)/√2 passes closest to the // Axis +X. Ray from (0, 10, 0) toward (1, -1, 0)/√2 passes closest to the
// axis around x=10 (it reaches y=0 at x=10). // axis around x=10 (it reaches y=0 at x=10).
const dir = Cesium.Cartesian3.normalize(c3(1, -1, 0), new Cesium.Cartesian3()); const dir = Cesium.Cartesian3.normalize(
c3(1, -1, 0),
new Cesium.Cartesian3(),
);
const t = closestParamOnAxis(c3(0, 10, 0), dir, c3(0, 0, 0), c3(1, 0, 0)); const t = closestParamOnAxis(c3(0, 10, 0), dir, c3(0, 0, 0), c3(1, 0, 0));
assert.ok(Math.abs(t - 10) < 1e-6, `expected ~10, got ${t}`); assert.ok(Math.abs(t - 10) < 1e-6, `expected ~10, got ${t}`);
}); });
test('closestParamOnAxis: axis origin offset shifts the parameter', () => { test('closestParamOnAxis: axis origin offset shifts the parameter', () => {
const t = closestParamOnAxis(c3(10, 0, 5), c3(0, 0, -1), c3(4, 0, 0), c3(1, 0, 0)); const t = closestParamOnAxis(
c3(10, 0, 5),
c3(0, 0, -1),
c3(4, 0, 0),
c3(1, 0, 0),
);
assert.ok(Math.abs(t - 6) < 1e-9, `expected 6, got ${t}`); assert.ok(Math.abs(t - 6) < 1e-9, `expected 6, got ${t}`);
}); });
test('closestParamOnAxis: near-parallel ray/axis returns null', () => { test('closestParamOnAxis: near-parallel ray/axis returns null', () => {
assert.equal(closestParamOnAxis(c3(0, 1, 0), c3(1, 0, 0), c3(0, 0, 0), c3(1, 0, 0)), null); assert.equal(
const nearly = Cesium.Cartesian3.normalize(c3(1, 1e-9, 0), new Cesium.Cartesian3()); closestParamOnAxis(c3(0, 1, 0), c3(1, 0, 0), c3(0, 0, 0), c3(1, 0, 0)),
assert.equal(closestParamOnAxis(c3(0, 1, 0), nearly, c3(0, 0, 0), c3(1, 0, 0)), null); null,
);
const nearly = Cesium.Cartesian3.normalize(
c3(1, 1e-9, 0),
new Cesium.Cartesian3(),
);
assert.equal(
closestParamOnAxis(c3(0, 1, 0), nearly, c3(0, 0, 0), c3(1, 0, 0)),
null,
);
}); });
test('rayPlaneIntersect: straight-on hit lands at the expected point', () => { test('rayPlaneIntersect: straight-on hit lands at the expected point', () => {
const hit = rayPlaneIntersect(c3(0, 0, 10), c3(0, 0, -1), c3(0, 0, 0), c3(0, 0, 1)); const hit = rayPlaneIntersect(
c3(0, 0, 10),
c3(0, 0, -1),
c3(0, 0, 0),
c3(0, 0, 1),
);
assert.ok(hit, 'expected a hit'); assert.ok(hit, 'expected a hit');
assert.ok(Cesium.Cartesian3.distance(hit, c3(0, 0, 0)) < 1e-9); assert.ok(Cesium.Cartesian3.distance(hit, c3(0, 0, 0)) < 1e-9);
}); });
test('rayPlaneIntersect: oblique hit resolves correctly', () => { test('rayPlaneIntersect: oblique hit resolves correctly', () => {
const dir = Cesium.Cartesian3.normalize(c3(1, 0, -1), new Cesium.Cartesian3()); const dir = Cesium.Cartesian3.normalize(
c3(1, 0, -1),
new Cesium.Cartesian3(),
);
const hit = rayPlaneIntersect(c3(0, 0, 5), dir, c3(0, 0, 0), c3(0, 0, 1)); const hit = rayPlaneIntersect(c3(0, 0, 5), dir, c3(0, 0, 0), c3(0, 0, 1));
assert.ok(hit); assert.ok(hit);
assert.ok(Cesium.Cartesian3.distance(hit, c3(5, 0, 0)) < 1e-9, `got ${hit}`); assert.ok(Cesium.Cartesian3.distance(hit, c3(5, 0, 0)) < 1e-9, `got ${hit}`);
@@ -62,12 +96,21 @@ test('rayPlaneIntersect: oblique hit resolves correctly', () => {
test('rayPlaneIntersect: grazing ray (|dir·n| < 0.08) returns null', () => { test('rayPlaneIntersect: grazing ray (|dir·n| < 0.08) returns null', () => {
// dir almost in-plane: z component 0.05 < 0.08 threshold. // dir almost in-plane: z component 0.05 < 0.08 threshold.
const dir = Cesium.Cartesian3.normalize(c3(1, 0, -0.05), new Cesium.Cartesian3()); const dir = Cesium.Cartesian3.normalize(
assert.equal(rayPlaneIntersect(c3(0, 0, 5), dir, c3(0, 0, 0), c3(0, 0, 1)), null); c3(1, 0, -0.05),
new Cesium.Cartesian3(),
);
assert.equal(
rayPlaneIntersect(c3(0, 0, 5), dir, c3(0, 0, 0), c3(0, 0, 1)),
null,
);
}); });
test('rayPlaneIntersect: plane behind the ray origin returns null', () => { test('rayPlaneIntersect: plane behind the ray origin returns null', () => {
assert.equal(rayPlaneIntersect(c3(0, 0, 10), c3(0, 0, 1), c3(0, 0, 0), c3(0, 0, 1)), null); assert.equal(
rayPlaneIntersect(c3(0, 0, 10), c3(0, 0, 1), c3(0, 0, 0), c3(0, 0, 1)),
null,
);
}); });
test('ringAngle: quadrant sweep in the (basisA, basisB) frame', () => { test('ringAngle: quadrant sweep in the (basisA, basisB) frame', () => {
@@ -75,9 +118,15 @@ test('ringAngle: quadrant sweep in the (basisA, basisB) frame', () => {
const a = c3(1, 0, 0); const a = c3(1, 0, 0);
const b = c3(0, 1, 0); const b = c3(0, 1, 0);
assert.ok(Math.abs(ringAngle(c3(5, 0, 0), center, a, b) - 0) < 1e-9); assert.ok(Math.abs(ringAngle(c3(5, 0, 0), center, a, b) - 0) < 1e-9);
assert.ok(Math.abs(ringAngle(c3(0, 5, 0), center, a, b) - Math.PI / 2) < 1e-9); assert.ok(
assert.ok(Math.abs(Math.abs(ringAngle(c3(-5, 0, 0), center, a, b)) - Math.PI) < 1e-9); Math.abs(ringAngle(c3(0, 5, 0), center, a, b) - Math.PI / 2) < 1e-9,
assert.ok(Math.abs(ringAngle(c3(0, -5, 0), center, a, b) + Math.PI / 2) < 1e-9); );
assert.ok(
Math.abs(Math.abs(ringAngle(c3(-5, 0, 0), center, a, b)) - Math.PI) < 1e-9,
);
assert.ok(
Math.abs(ringAngle(c3(0, -5, 0), center, a, b) + Math.PI / 2) < 1e-9,
);
}); });
test('signedAngleDelta: shortest-path wrap', () => { test('signedAngleDelta: shortest-path wrap', () => {
@@ -86,3 +135,79 @@ test('signedAngleDelta: shortest-path wrap', () => {
assert.ok(Math.abs(signedAngleDelta(d2r(-170), d2r(170)) - d2r(-20)) < 1e-9); assert.ok(Math.abs(signedAngleDelta(d2r(-170), d2r(170)) - d2r(-20)) < 1e-9);
assert.ok(Math.abs(signedAngleDelta(d2r(10), d2r(30)) - d2r(20)) < 1e-9); assert.ok(Math.abs(signedAngleDelta(d2r(10), d2r(30)) - d2r(20)) < 1e-9);
}); });
test('a press after a release Cesium never reported ends the open drag and its camera hold', (t) => {
const viewer = {
entities: {
byId: new Map(),
getById(id) {
return this.byId.get(id);
},
add(options) {
this.byId.set(options.id, options);
return options;
},
remove(entity) {
this.byId.delete(entity.id);
},
},
scene: {
canvas: { style: {} },
screenSpaceCameraController: { enableInputs: true },
pick: () => ({ id: { id: `${GIZMO_ID_PREFIX}move-up` } }),
drillPick: () => [],
},
};
const mount = Cesium.Cartesian3.fromDegrees(0, 0, 10);
const east = new Cesium.Cartesian3(0, 1, 0); // east at lon 0, lat 0
viewer.camera = {
getPickRay: () => ({
origin: Cesium.Cartesian3.add(
mount,
Cesium.Cartesian3.multiplyByScalar(east, 100, new Cesium.Cartesian3()),
new Cesium.Cartesian3(),
),
direction: Cesium.Cartesian3.negate(east, new Cesium.Cartesian3()),
}),
};
const { input, handler } = fakeInput(viewer);
t.after(() => input.destroy());
const record = {
frustumPositions: {
mount,
capCenter: mount,
tl: mount,
tr: mount,
bl: mount,
br: mount,
},
frustumGeometry: { rangeM: 50 },
camera: {
headingDeg: 0,
pitchDeg: 0,
rangeM: 50,
calibration: {},
basePose: {},
},
};
const ended = [];
const gizmo = createCalibrationGizmo({
viewer,
getActiveRecord: () => record,
applyPatch() {},
endPatch: (r) => ended.push(r),
});
t.after(() => gizmo.destroy());
gizmo.setEnabled(true);
const controller = viewer.scene.screenSpaceCameraController;
handler.fire(FAKE_EVENT_TYPES.LEFT_DOWN, { position: { x: 5, y: 5 } });
assert.equal(gizmo.isDragging(), true);
assert.equal(controller.enableInputs, false);
// The release came with a modifier key held: Cesium raised no LEFT_UP.
handler.fire(FAKE_EVENT_TYPES.LEFT_DOWN, { position: { x: 5, y: 5 } });
assert.deepEqual(ended, [record], 'the open drag was committed');
handler.fire(FAKE_EVENT_TYPES.LEFT_UP, { position: { x: 5, y: 5 } });
assert.equal(gizmo.isDragging(), false);
assert.equal(controller.enableInputs, true, 'no hold was stranded');
});
+15 -4
View File
@@ -6,6 +6,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import cctvLayer, { import cctvLayer, {
_cctvIconsForTest,
_setCctvCoverageStateForTest, _setCctvCoverageStateForTest,
_viewRecordIdsForTest, _viewRecordIdsForTest,
refreshHorizonCulling, refreshHorizonCulling,
@@ -40,7 +41,6 @@ function recordsAt(
mountHeightM: 8, mountHeightM: 8,
}, },
position, position,
billboard: { position, show: true },
coverageEntities: [], coverageEntities: [],
projection: null, projection: null,
}; };
@@ -59,7 +59,11 @@ function viewerAt(heightM, screenByPosition, shiftX = 0) {
scene: { scene: {
canvas: { clientWidth: WIDTH, clientHeight: HEIGHT }, canvas: { clientWidth: WIDTH, clientHeight: HEIGHT },
cartesianToCanvasCoordinates: (position) => { cartesianToCanvasCoordinates: (position) => {
const screen = screenByPosition.get(position); // Match by value: a record's position may be a copy of the fixture's.
const key = [...screenByPosition.keys()].find((candidate) =>
Cesium.Cartesian3.equals(candidate, position),
);
const screen = screenByPosition.get(key);
return screen && { x: screen.x + shiftX, y: screen.y }; return screen && { x: screen.x + shiftX, y: screen.y };
}, },
}, },
@@ -68,7 +72,10 @@ function viewerAt(heightM, screenByPosition, shiftX = 0) {
function shownIds(records) { function shownIds(records) {
return records return records
.filter((record) => record.billboard.show) .filter((record) => {
const icons = _cctvIconsForTest();
return icons.shown(icons.row(record.camera.id));
})
.map((record) => record.camera.id) .map((record) => record.camera.id)
.sort(); .sort();
} }
@@ -160,7 +167,11 @@ test('the active camera always shows, even in a crowded cell', () => {
activeCameraId: active.camera.id, activeCameraId: active.camera.id,
}); });
refreshHorizonCulling(); refreshHorizonCulling();
assert.ok(active.billboard.show, `${active.camera.id} shows while active`); const icons = _cctvIconsForTest();
assert.ok(
icons.shown(icons.row(active.camera.id)),
`${active.camera.id} shows while active`,
);
} }
}); });
+4 -4
View File
@@ -27,8 +27,8 @@ const LAYERS = [
_setTrackedFlightRefreshStateForTest({ _setTrackedFlightRefreshStateForTest({
icao24, icao24,
entity: null, entity: null,
billboard: candidateBillboard(icao24), icon: candidateBillboard(icao24),
billboardCollection: { show: true, remove() {} }, iconLayer: { show: true, remove() {} },
viewer: candidateViewer(), viewer: candidateViewer(),
tracked: false, tracked: false,
meta: { meta: {
@@ -48,8 +48,8 @@ const LAYERS = [
_setTrackedMilitaryRefreshStateForTest({ _setTrackedMilitaryRefreshStateForTest({
icao24, icao24,
entity: null, entity: null,
billboard: candidateBillboard(icao24), icon: candidateBillboard(icao24),
billboardCollection: { show: true, remove() {} }, iconLayer: { show: true, remove() {} },
viewer: candidateViewer(), viewer: candidateViewer(),
tracked: false, tracked: false,
meta: { meta: {
+37 -14
View File
@@ -1,4 +1,5 @@
import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import { readFileSync } from 'node:fs';
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -39,9 +40,9 @@ for (const layer of LAYERS) {
test(`${layer.name}: Cockpit 3D obeys the shared Display toggle`, () => { test(`${layer.name}: Cockpit 3D obeys the shared Display toggle`, () => {
const regime = /^([ \t]*)function _modelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0]; const regime = /^([ \t]*)function _modelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
assert.ok(regime, '_modelRegimeActive is defined'); assert.ok(regime, '_modelRegimeActive is defined');
assert.match(regime, /if\s*\(\s*!(?:flightState\.)?_models3dEnabled,?\s*\)\s*return\s*false;/, assert.match(regime, /if\s*\(\s*!(?:(?:flightState|state)\.)?_models3dEnabled,?\s*\)\s*return\s*false;/,
'OFF must keep Cockpit AIR contacts in 2D'); 'OFF must keep Cockpit AIR contacts in 2D');
assert.doesNotMatch(regime, /!(?:flightState\.)?_models3dEnabled\s*&&\s*!(?:flightState\.)?_cockpitContactMode/, assert.doesNotMatch(regime, /!(?:(?:flightState|state)\.)?_models3dEnabled\s*&&\s*!(?:(?:flightState|state)\.)?_cockpitContactMode/,
'Cockpit must not bypass the owner-visible Display toggle'); 'Cockpit must not bypass the owner-visible Display toggle');
}); });
@@ -53,7 +54,7 @@ for (const layer of LAYERS) {
// suppression is now an explicit early return. // suppression is now an explicit early return.
const regime = /^([ \t]*)function _trackedModelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0]; const regime = /^([ \t]*)function _trackedModelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
assert.ok(regime, '_trackedModelRegimeActive is defined'); assert.ok(regime, '_trackedModelRegimeActive is defined');
assert.match(regime, /if\s*\(\s*!(?:flightState\.)?_trackedIcao\s*\|\|\s*(?:flightState\.)?_cockpitContactMode\s*\|\|[\s\S]*?return\s*false;/, assert.match(regime, /if\s*\(\s*!(?:(?:flightState|state)\.)?_trackedIcao\s*\|\|\s*(?:(?:flightState|state)\.)?_cockpitContactMode\s*\|\|[\s\S]*?return\s*false;/,
'_trackedModelRegimeActive excludes cockpit'); '_trackedModelRegimeActive excludes cockpit');
const tracked = /^([ \t]*)function _updateTrackedModel\b[\s\S]*?\n\1\}/m.exec(source)?.[0]; const tracked = /^([ \t]*)function _updateTrackedModel\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
assert.ok(tracked, '_updateTrackedModel is defined'); assert.ok(tracked, '_updateTrackedModel is defined');
@@ -70,37 +71,59 @@ for (const layer of LAYERS) {
const add = /^([ \t]*)function _modelAddDistM\b[\s\S]*?\n\1\}/m.exec(source)?.[0]; const add = /^([ \t]*)function _modelAddDistM\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
const keep = /^([ \t]*)function _modelKeepDistM\b[\s\S]*?\n\1\}/m.exec(source)?.[0]; const keep = /^([ \t]*)function _modelKeepDistM\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
assert.match(add, /(?:flightState\.)?_models3dMode\s*===\s*'all'\s*\?\s*MODEL_ALL_ADD_M\s*:\s*MODEL_PROX_ADD_M/); assert.match(add, /(?:(?:flightState|state)\.)?_models3dMode\s*===\s*'all'\s*\?\s*(?:policy\.)?MODEL_ALL_ADD_M\s*:\s*(?:policy\.)?MODEL_PROX_ADD_M/);
assert.match(keep, /(?:flightState\.)?_models3dMode\s*===\s*'all'\s*\?\s*MODEL_ALL_KEEP_M\s*:\s*MODEL_PROX_KEEP_M/); assert.match(keep, /(?:(?:flightState|state)\.)?_models3dMode\s*===\s*'all'\s*\?\s*(?:policy\.)?MODEL_ALL_KEEP_M\s*:\s*(?:policy\.)?MODEL_PROX_KEEP_M/);
assert.doesNotMatch(add, /COCKPIT_MODEL_ADD_M/); assert.doesNotMatch(add, /COCKPIT_MODEL_ADD_M/);
assert.doesNotMatch(keep, /COCKPIT_MODEL_KEEP_M/); assert.doesNotMatch(keep, /COCKPIT_MODEL_KEEP_M/);
const cap = /^([ \t]*)function _modelCap\b[\s\S]*?\n\1\}/m.exec(source)?.[0]; const cap = /^([ \t]*)function _modelCap\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
assert.match(cap, /Math\.min\(\s*COCKPIT_MODEL_MAX/, assert.match(cap, /Math\.min\(\s*(?:policy\.)?COCKPIT_MODEL_MAX/,
'Cockpit keeps its 60-model performance ceiling'); 'Cockpit keeps its 60-model performance ceiling');
}); });
test(`${layer.name}: near AIR state is independent from model admission`, () => { test(`${layer.name}: near AIR state is independent from model admission`, () => {
assert.match(source, /nextCockpitNearContacts\(/, assert.match(source, /nextCockpitNearContacts\(/,
'Cockpit derives a separate near-contact hysteresis set'); 'Cockpit derives a separate near-contact hysteresis set');
assert.match(source, /isCockpitContact && !isCockpitNear[\s\S]*cockpitContactDotImage\(\)/, assert.match(source, /!state\._cockpitNearContacts\.has\(icao24\);\s*if \(cockpitDot\) \{\s*const dot = iconAtlas\.slot\(cockpitContactDotImage\(\)/,
'only out-of-range Cockpit contacts become dots'); 'only out-of-range Cockpit contacts become dots');
// `_iconKind` is identity for every unconverted contact (see // `_iconKind` is identity for every unconverted contact (see
// tr3bRegistry.test.mjs) — it only swaps the glyph for a contact the // tr3bRegistry.test.mjs) — it only swaps the glyph for a contact the
// operator explicitly converted into a TR-3B. // operator explicitly converted into a TR-3B.
assert.match(source, /bb\.image\s*=\s*aircraftIcon\(\s*_iconKind\(\s*icao24,\s*meta\?\.klass,?\s*\)(,\s*bb\._gevIconLarge\s*\?\s*TRACKED_ICON_PX\s*:\s*undefined)?,?\s*\)/, assert.match(source, /aircraftIcon\(\s*(?:parts\.)?rendering\._iconKind\(icao24, info\?\.klass\),?\s*\)/,
'near contacts and model fallbacks retain the class-derived aircraft silhouette'); 'near contacts and model fallbacks retain the class-derived aircraft silhouette');
assert.match(source, /bb\.rotation\s*=\s*0;/, assert.match(source, /set\.setFlag\(row, ICON_ROTATES, false\);/,
'far dots are reset to a rotation-free presentation'); 'far dots are reset to a rotation-free presentation');
assert.match(source, /\(\s*!(?:flightState\.)?_cockpitContactMode\s*\|\|\s*isCockpitNear,?\s*\)\s*&&\s*\(\s*doRotations\s*\|\|\s*revealed,?\s*\)/, assert.match(source, /set\.setFlag\(row, ICON_ROTATES, true\);/,
'near 2D silhouettes continue to receive projected course'); 'near 2D silhouettes continue to receive projected course');
assert.match(source, /if\s*\(\s*bb\.show,?\s*\)\s*bb\.show\s*=\s*false;\s*\/\/\s*hand\s*off\s*ONLY\s*once\s*the\s*model\s*renders/, assert.match(source, /icons\.setShown\(row, false\);\s*\/\/\s*hand\s*off\s*ONLY\s*once\s*the\s*model\s*renders/,
'the gap-proof billboard-to-model handoff remains intact'); 'the gap-proof icon-to-model handoff remains intact');
}); });
test(`${layer.name}: Cockpit exit clears near state before restoring map presentation`, () => { test(`${layer.name}: Cockpit exit clears near state before restoring map presentation`, () => {
const setMode = /^([ \t]*)function _setCockpitContactMode\b[\s\S]*?\n\1\}/m.exec(source)?.[0]; const setMode = /^([ \t]*)function _setCockpitContactMode\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
assert.match(setMode, /else\s*(?:flightState\.)?_cockpitNearContacts\s*=\s*new\s*Set\(\s*,?\s*\);/); assert.match(setMode, /else\s*(?:(?:flightState|state)\.)?_cockpitNearContacts\s*=\s*new\s*Set\(\s*,?\s*\);/);
assert.match(setMode, /for\s*\(\s*const\s*\[icao24,\s*bb\]\s*of\s*(?:flightState\.)?_billboards,?\s*\)\s*(?:parts\.\w+\.)?_applyFleetBillboardPresentation\(\s*icao24,\s*bb,?\s*\);/); assert.match(setMode, /for \(let row = 0; row < \(icons\?\.count \?\? 0\); row\+\+\)\s*parts\.icons\._applyIconPresentation\(icons\.idAt\(row\)\);/);
}); });
} }
// The source reads above include the shared model module, so they pass from it
// alone. These pin that each layer actually builds its models from it, and that
// flights keeps its own hooks.
const RENDERING = {
flights: new URL('../layers/flights/rendering.js', import.meta.url),
militaryFlights: new URL('../layers/military/rendering.js', import.meta.url),
};
for (const [name, url] of Object.entries(RENDERING)) {
test(`${name}: 3D models come from the shared aircraft model module`, () => {
const source = readFileSync(url, 'utf8');
assert.match(source, /createAircraftModels\(\{/);
assert.match(source, /\.\.\.models\b/);
});
}
test('flights: model eligibility nudges enrichment and the tick floors grounded icons', () => {
const source = readFileSync(RENDERING.flights, 'utf8');
assert.match(source, /onModelEligible:[\s\S]*?_requestTypeEnrichment\(icao24, true\)/);
assert.match(source, /afterReplan:[\s\S]*?_floorGroundedIcons\(/);
});
+5 -3
View File
@@ -1,3 +1,5 @@
import { entityRecord, readEntityRecord } from './entityRecords.js';
const STORE_KEY = '__gevContextStore'; const STORE_KEY = '__gevContextStore';
function createStore() { function createStore() {
@@ -36,14 +38,14 @@ export function registerEntityContext(entity, metadata) {
entity, entity,
updatedAt: Date.now(), updatedAt: Date.now(),
}; };
entity.__gevContextId = metadata.id; entityRecord(entity).contextId = metadata.id;
store.entities.set(metadata.id, record); store.entities.set(metadata.id, record);
return record; return record;
} }
export function selectEntityContext(entity) { export function selectEntityContext(entity) {
const store = getContextStore(); const store = getContextStore();
const contextId = entity?.__gevContextId; const contextId = readEntityRecord(entity)?.contextId;
if (!contextId || !store.entities.has(contextId)) return null; if (!contextId || !store.entities.has(contextId)) return null;
store.selectedEntityId = contextId; store.selectedEntityId = contextId;
store.selectedAt = Date.now(); store.selectedAt = Date.now();
@@ -84,7 +86,7 @@ export function selectTrackedSubjectContext(metadata) {
store.entities.delete(key); store.entities.delete(key);
} }
// Reuse the existing carrier so a per-poll refresh does not churn identity. // Reuse the existing carrier so a per-poll refresh does not churn identity.
const carrier = store.entities.get(id)?.entity || { __gevContextId: id }; const carrier = store.entities.get(id)?.entity || {};
const record = registerEntityContext(carrier, { ...metadata, id }); const record = registerEntityContext(carrier, { ...metadata, id });
if (!record) return null; if (!record) return null;
store.selectedEntityId = id; store.selectedEntityId = id;
+2 -2
View File
@@ -96,7 +96,7 @@ test('a sibling tracking layer keeps its own subject slot', () => {
test('the per-poll refresh updates values without stealing the selection', () => { test('the per-poll refresh updates values without stealing the selection', () => {
withWindow(() => { withWindow(() => {
selectTrackedSubjectContext(flightSubject('aaa001')); selectTrackedSubjectContext(flightSubject('aaa001'));
const other = registerEntityContext({ __gevContextId: 'dc-7' }, { const other = registerEntityContext({}, {
id: 'dc-7', layerId: 'local-datacenters', label: 'Datacenter 7', id: 'dc-7', layerId: 'local-datacenters', label: 'Datacenter 7',
}); });
selectEntityContext(other.entity); selectEntityContext(other.entity);
@@ -130,7 +130,7 @@ test('deselecting a contact releases the shared slot', () => {
test('clearing one tracking layer leaves another layer selection alone', () => { test('clearing one tracking layer leaves another layer selection alone', () => {
withWindow(() => { withWindow(() => {
const other = registerEntityContext({ __gevContextId: 'dam-3' }, { const other = registerEntityContext({}, {
id: 'dam-3', layerId: 'local-dams', label: 'Dam 3', id: 'dam-3', layerId: 'local-dams', label: 'Dam 3',
}); });
selectEntityContext(other.entity); selectEntityContext(other.entity);
+95 -49
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium'; import { creditFromMarkup } from '../render/creditMarkup.js';
import { findCredits } from '../render/rendererOf.js';
/** /**
* Per-layer data attribution registered into Cesium's credit display. * Per-layer data attribution registered into Cesium's credit display.
@@ -36,6 +37,20 @@ export const OPENMAPTILES_CREDIT = {
const osmDisplays = new WeakMap(); const osmDisplays = new WeakMap();
/** The short inline OSM credits, as the renderer's credits port takes them. */
const OSM_INLINE_CREDITS = Object.freeze({
osm: Object.freeze({
text: '© OpenStreetMap',
href: 'https://www.openstreetmap.org/copyright',
inline: true,
}),
tiles: Object.freeze({
text: '© OpenMapTiles',
href: 'https://openmaptiles.org',
inline: true,
}),
});
function updateOsmDisplay(viewer, state) { function updateOsmDisplay(viewer, state) {
const osm = state.owners.size > 0; const osm = state.owners.size > 0;
let tiles = false; let tiles = false;
@@ -44,37 +59,27 @@ function updateOsmDisplay(viewer, state) {
['osm', osm], ['osm', osm],
['tiles', tiles], ['tiles', tiles],
]) { ]) {
if (state[key].visible === visible) continue; if (Boolean(state.offs[key]) === visible) continue;
state[key].visible = visible; if (visible) {
viewer.creditDisplay[visible ? 'addStaticCredit' : 'removeStaticCredit']( // Looked up each time: a renderer bound later takes over.
state[key].credit, const credits = findCredits(viewer);
); if (credits) state.offs[key] = credits.addStatic(OSM_INLINE_CREDITS[key]);
viewer.scene?.requestRender?.(); } else {
state.offs[key]();
state.offs[key] = null;
}
} }
} }
/** Keep one short inline credit while an OSM display owner has visible data. */ /**
* Keep one short inline credit, through the renderer's credits port, while
* an OSM display owner has visible data.
*/
export function showOsmCredit(viewer, owner, { openMapTiles = false } = {}) { export function showOsmCredit(viewer, owner, { openMapTiles = false } = {}) {
if (!viewer?.creditDisplay?.addStaticCredit || !owner) return false; if (!owner || !findCredits(viewer)) return false;
let state = osmDisplays.get(viewer); let state = osmDisplays.get(viewer);
if (!state) { if (!state) {
state = { state = { owners: new Map(), offs: { osm: null, tiles: null } };
owners: new Map(),
osm: {
visible: false,
credit: new Cesium.Credit(
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap</a>',
true,
),
},
tiles: {
visible: false,
credit: new Cesium.Credit(
'<a href="https://openmaptiles.org" target="_blank" rel="noopener">© OpenMapTiles</a>',
true,
),
},
};
osmDisplays.set(viewer, state); osmDisplays.set(viewer, state);
} }
if (state.owners.get(owner) === openMapTiles) return false; if (state.owners.get(owner) === openMapTiles) return false;
@@ -612,43 +617,84 @@ export const BHOTE_KOSHI_LOCATOR_CREDIT = {
'(CC BY-NC 4.0)', '(CC BY-NC 4.0)',
}; };
/** A link, its whole element. */
const LINK = /<a\b(?:[^>"']|"[^"]*"|'[^']*')*>[\s\S]*?<\/a\s*>/gi;
/**
* The separators text between two links can break at; it breaks at its
* last. A semicolon ending a character reference is not one. A full stop
* after an abbreviation reads as one, so keep "Dr." and "e.g." out of the
* text between links.
*/
const SEPARATOR = /\s*(?:[,·—–]|(?<!&#?\w+);)\s*|\.\s+|\s+(?=\()/g;
/**
* A credit's markup as the renderer's credits, one link each: the
* contract's credit carries one link, so markup with several becomes one
* credit per link, shown together in order. The text between two links
* breaks at its last separator (a comma, semicolon, dot or dash, a full
* stop, or before an opening bracket); closing brackets right after a link
* stay with it, and a break's comma, semicolon, dot or dash is dropped.
* @param {string} html
* @returns {import('../render/contract/credits').Credit[]}
*/
export function creditsFromMarkup(html) {
const markup = String(html);
const pieces = [''];
let at = 0;
for (const link of markup.matchAll(LINK)) {
let gap = markup.slice(at, link.index);
if (at > 0) {
const close = /^[)\]]*/.exec(gap)[0];
gap = gap.slice(close.length);
const cut = [...gap.matchAll(SEPARATOR)].at(-1);
const before = cut ? gap.slice(0, cut.index) : '';
const stop = cut && cut[0].trimStart().startsWith('.') ? '.' : '';
pieces[pieces.length - 1] += close + before + stop;
pieces.push(cut ? gap.slice(cut.index + cut[0].length) : gap);
} else pieces[0] += gap;
pieces[pieces.length - 1] += link[0];
at = link.index + link[0].length;
}
pieces[pieces.length - 1] += markup.slice(at);
return pieces
.map((piece) => ({ ...creditFromMarkup(piece), inline: false }))
.filter((credit) => credit.text);
}
/** Add a data credit to the attribution list through the renderer. */
function addDataCredit(viewer, credit) {
const credits = findCredits(viewer);
if (!credits) return false;
for (const one of creditsFromMarkup(credit.html)) credits.addStatic(one);
return true;
}
/** @type {Set<string>} Keys of dynamic credits already registered this session. */ /** @type {Set<string>} Keys of dynamic credits already registered this session. */
const _dynamicCreditKeys = new Set(); const _dynamicCreditKeys = new Set();
/** /**
* Register a conditional credit at the moment its data source activates. * Register a conditional credit at the moment its data source activates.
* Idempotent per `credit.key`; lands in the same "Data attribution" popover * Idempotent per `credit.key`; lands in the same "Data attribution" list
* as the static credits (showOnScreen=false). * as the static credits, through the renderer's credits port.
* @param {Cesium.Viewer} viewer — the initialized Cesium viewer * @param {object} viewer — the scene object the renderer is bound to
* @param {{ key: string, html: string }} credit — e.g. `TOMTOM_CREDIT` * @param {{ key: string, html: string }} credit — e.g. `TOMTOM_CREDIT`
* @returns {boolean} True when the credit is (now) registered. * @returns {boolean} True when the credit is (now) registered.
*/ */
export function registerDynamicCredit(viewer, credit) { export function registerDynamicCredit(viewer, credit) {
const creditDisplay = viewer?.creditDisplay; if (!viewer || !credit?.key || !credit?.html) return false;
if (!creditDisplay || typeof creditDisplay.addStaticCredit !== 'function') { if (_dynamicCreditKeys.has(credit.key)) return Boolean(findCredits(viewer));
return false; if (!addDataCredit(viewer, credit)) return false;
}
if (!credit?.key || !credit?.html) return false;
if (_dynamicCreditKeys.has(credit.key)) return true;
creditDisplay.addStaticCredit(new Cesium.Credit(credit.html, false));
_dynamicCreditKeys.add(credit.key); _dynamicCreditKeys.add(credit.key);
return true; return true;
} }
/** /**
* Register every per-layer data credit into the viewer's credit display. * Register every per-layer data credit through the renderer's credits
* Idempotent: safe to call once at init. Credits are static and always * port. Safe to call once at init. Credits are static and always present
* present in the "Data attribution" popover. * in the "Data attribution" list, not on the map's credit line.
* @param {Cesium.Viewer} viewer — the initialized Cesium viewer * @param {object} viewer — the scene object the renderer is bound to
*/ */
export function registerDataCredits(viewer, credits = DATA_CREDITS) { export function registerDataCredits(viewer, credits = DATA_CREDITS) {
const creditDisplay = viewer?.creditDisplay; if (!viewer) return;
if (!creditDisplay || typeof creditDisplay.addStaticCredit !== 'function') { for (const credit of credits) addDataCredit(viewer, credit);
return;
}
for (const { html } of credits) {
// showOnScreen=false → lives in the expandable "Data attribution" popover,
// not the on-globe credit line.
creditDisplay.addStaticCredit(new Cesium.Credit(html, false));
}
} }
+107 -4
View File
@@ -1,6 +1,7 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { DATA_CREDITS, NYC_DOT_DISCLAIMER } from './dataCredits.js'; import { DATA_CREDITS, NYC_DOT_DISCLAIMER } from './dataCredits.js';
import { bindFakeCesiumCredits } from '../testSupport/fakeCreditsPort.mjs';
test('every credit carries a unique key and some markup to render', () => { test('every credit carries a unique key and some markup to render', () => {
const keys = DATA_CREDITS.map((entry) => entry.key); const keys = DATA_CREDITS.map((entry) => entry.key);
@@ -60,13 +61,13 @@ test('inline OSM attribution persists until its last display owner leaves', asyn
const { showOsmCredit, hideOsmCredit } = await import('./dataCredits.js'); const { showOsmCredit, hideOsmCredit } = await import('./dataCredits.js');
t.mock.timers.enable({ apis: ['setTimeout'] }); t.mock.timers.enable({ apis: ['setTimeout'] });
const visible = new Set(); const visible = new Set();
const viewer = { const viewer = bindFakeCesiumCredits({
creditDisplay: { creditDisplay: {
addStaticCredit: (c) => visible.add(c), addStaticCredit: (c) => visible.add(c),
removeStaticCredit: (c) => visible.delete(c), removeStaticCredit: (c) => visible.delete(c),
}, },
scene: { requestRender() {} }, scene: { requestRender() {} },
}; });
assert.equal(showOsmCredit(viewer, 'alpr'), true); assert.equal(showOsmCredit(viewer, 'alpr'), true);
assert.equal(showOsmCredit(viewer, 'alpr'), false); assert.equal(showOsmCredit(viewer, 'alpr'), false);
assert.equal(visible.size, 1); assert.equal(visible.size, 1);
@@ -100,13 +101,13 @@ test('tile attribution follows source changes and viewers have independent owner
const { showOsmCredit, hideOsmCredit } = await import('./dataCredits.js'); const { showOsmCredit, hideOsmCredit } = await import('./dataCredits.js');
const create = () => { const create = () => {
const credits = new Set(); const credits = new Set();
return { return bindFakeCesiumCredits({
credits, credits,
creditDisplay: { creditDisplay: {
addStaticCredit: (c) => credits.add(c), addStaticCredit: (c) => credits.add(c),
removeStaticCredit: (c) => credits.delete(c), removeStaticCredit: (c) => credits.delete(c),
}, },
}; });
}; };
const a = create(), const a = create(),
b = create(); b = create();
@@ -141,3 +142,105 @@ test('every Street Level imagery source is in the attribution lightbox', () => {
`${key} has no credit`, `${key} has no credit`,
); );
}); });
test('a credit with several links becomes one credit per link, in order', async () => {
const {
creditsFromMarkup,
OSM_CREDIT,
OPENMAPTILES_CREDIT,
WOF_CREDIT,
BHOTE_KOSHI_CREDIT,
} = await import('./dataCredits.js');
const html = (key) => DATA_CREDITS.find((entry) => entry.key === key).html;
const read = (markup) =>
creditsFromMarkup(markup).map(({ text, href }) => [text, href]);
assert.deepEqual(read(OSM_CREDIT.html), [
[
'Map and place data © OpenStreetMap contributors',
'https://www.openstreetmap.org/copyright',
],
['(ODbL)', 'https://opendatacommons.org/licenses/odbl/1-0/'],
]);
assert.deepEqual(read(OPENMAPTILES_CREDIT.html), [
['Vector tiles: OpenFreeMap', 'https://openfreemap.org'],
['© OpenMapTiles', 'https://openmaptiles.org'],
]);
assert.deepEqual(read(html('nasa-gibs'))[1], [
'HLS product page',
'https://lpdaac.usgs.gov/products/hlss30v002/',
]);
assert.deepEqual(read(html('wfigs')), [
[
'Wildfire perimeters: National Interagency Fire Center (WFIGS)',
'https://data-nifc.opendata.arcgis.com/',
],
['Incident information: InciWeb', 'https://inciweb.wildfire.gov/'],
]);
assert.deepEqual(read(html('eccc-aqhi')), [
[
'Air quality (AQHI): Environment and Climate Change Canada — MSC GeoMet (eccc-msc.github.io/open-data)',
'https://eccc-msc.github.io/open-data/',
],
[
'ECCC Data Servers End-use Licence',
'https://eccc-msc.github.io/open-data/licence/readme_en/',
],
]);
assert.deepEqual(
read(BHOTE_KOSHI_CREDIT.html)[1][0].slice(0, 15),
'and GeoPera (CC',
);
assert.deepEqual(
read(WOF_CREDIT.html).map(([text]) => text),
[
"Neighborhood boundaries: Who's On First and contributors, simplified",
'(sources and licences)',
],
);
const link = (text) => `<a href="https://example.org">${text}</a>`;
assert.deepEqual(
read(`${link('A')} by Hoffleit &amp; Warren ${link('B')}`).map(
([text]) => text,
),
['A', 'by Hoffleit & Warren B'],
'a character reference is not a separator',
);
assert.deepEqual(read(html('usgs')), [
['Earthquakes: Data courtesy of the U.S. Geological Survey', undefined],
]);
for (const entry of DATA_CREDITS) {
const credits = creditsFromMarkup(entry.html);
const links = entry.html.match(/<a\b/g)?.length ?? 0;
assert.equal(credits.length, Math.max(links, 1), entry.key);
assert.ok(
credits.every((credit) => credit.inline === false),
`${entry.key} belongs in the attribution list`,
);
}
});
test('data credits go through the renderer to the attribution list', async () => {
const { registerDataCredits, registerDynamicCredit, TOMTOM_CREDIT } =
await import('./dataCredits.js');
const shown = [];
const viewer = bindFakeCesiumCredits({
creditDisplay: {
addStaticCredit: (c) => shown.push(c),
removeStaticCredit() {},
},
});
const credits = DATA_CREDITS.filter((entry) =>
['openstreetmap', 'usgs'].includes(entry.key),
);
registerDataCredits(viewer, credits);
assert.equal(shown.length, 3);
assert.ok(shown.every((credit) => !credit.showOnScreen));
const odbl = shown.find((credit) => credit.html.includes('opendatacommons'));
assert.match(odbl.html, /href="https:\/\/opendatacommons.org/);
assert.match(odbl.html, />\(ODbL\)</);
assert.equal(registerDynamicCredit(viewer, TOMTOM_CREDIT), true);
assert.equal(registerDynamicCredit(viewer, TOMTOM_CREDIT), true);
assert.equal(shown.length, 4, 'a dynamic credit is added once');
assert.match(shown[3].html, /Traffic flow data © TomTom/);
assert.equal(registerDynamicCredit({}, { key: 'none', html: 'x' }), false);
});
+2
View File
@@ -54,6 +54,8 @@ export {
STEP_ANCHOR_FAST_ATTEMPTS, STEP_ANCHOR_FAST_ATTEMPTS,
STEP_ANCHOR_RETRY_MS, STEP_ANCHOR_RETRY_MS,
STEP_ANCHOR_SLOW_RETRY_MS, STEP_ANCHOR_SLOW_RETRY_MS,
DIRECTIONS_MARKER_OVERLAY_SOURCE_ID,
createDirectionsMarkerOverlayEntry,
createDirectionsStepOverlayEntry, createDirectionsStepOverlayEntry,
directionsRequestUrl, directionsRequestUrl,
directionsRowControls, directionsRowControls,
+82
View File
@@ -6,6 +6,8 @@ import {
DIRECTIONS_MODES, DIRECTIONS_MODES,
DIRECTIONS_POINTER_OWNER, DIRECTIONS_POINTER_OWNER,
DIRECTIONS_ROUTE_COLOR, DIRECTIONS_ROUTE_COLOR,
DIRECTIONS_MARKER_OVERLAY_SOURCE_ID,
createDirectionsMarkerOverlayEntry,
createDirectionsStepOverlayEntry, createDirectionsStepOverlayEntry,
directionsRequestUrl, directionsRequestUrl,
directionsRowControls, directionsRowControls,
@@ -950,3 +952,83 @@ test('the reroute tooltip keeps German mode nouns capitalized', (t) => {
'Fahrrad – Route im Modus „Fahrrad“ neu berechnen', 'Fahrrad – Route im Modus „Fahrrad“ neu berechnen',
); );
}); });
test('the A and B letters are protected overlay labels in the marker colour', () => {
const position = Cesium.Cartesian3.fromDegrees(-122.39, 37.79);
const entry = createDirectionsMarkerOverlayEntry('B', position, '#ff6b6b');
assert.equal(entry.id, 'directions-marker-b');
assert.equal(entry.title, 'B');
assert.equal(entry.variant, 'label');
assert.equal(entry.accent, '#ff6b6b');
assert.equal(entry.protected, true);
assert.equal(entry.position, position);
assert.equal(entry.placement, 'above');
});
test('a placed endpoint draws its letter through the overlay, not as entity text', async (t) => {
const published = [];
const cleared = [];
const visibility = [];
const entities = [];
const markerVisibility = () =>
visibility
.filter(([source]) => source === DIRECTIONS_MARKER_OVERLAY_SOURCE_ID)
.at(-1)?.[1];
directionsLayer = createApplicationDirectionsLayer();
directionsLayer._setOverlayHostForTest({
setEntries: (source, entries) => published.push([source, entries]),
setVisible: (source, visible) => visibility.push([source, visible]),
clearSource: (source) => cleared.push(source),
});
resetPointerOwnership();
const viewer = {
isDestroyed: () => false,
entities: {
add: (spec) => (entities.push(spec), spec),
remove() {},
},
scene: {
requestRender() {},
primitives: {
add: (primitive) => primitive,
remove() {},
raiseToTop() {},
lowerToBottom() {},
contains: () => true,
length: 0,
},
},
};
t.after(() => {
directionsLayer.destroy(viewer);
directionsLayer._setOverlayHostForTest(null);
resetPointerOwnership();
});
directionsLayer.init(viewer);
assert.equal(markerVisibility(), false, 'hidden until enabled');
directionsLayer.placeEndpoint('a', { lat: 37.7955, lon: -122.3937 });
assert.equal(entities.length, 1);
assert.equal(entities[0].label, undefined, 'no Cesium label on the marker');
assert.ok(entities[0].point, 'the marker keeps its point');
const [source, entries] = published.at(-1);
assert.equal(source, DIRECTIONS_MARKER_OVERLAY_SOURCE_ID);
assert.deepEqual(
entries.map((entry) => entry.title),
['A'],
);
// Hidden until the floor cell settles, then anchored (the ellipsoid here,
// where the test resolver finds no height).
assert.equal(entries[0].position(), null);
const started = Date.now();
while (!entries[0].position() && Date.now() - started < 5000)
await new Promise((resolve) => setTimeout(resolve, 10));
const anchor = Cesium.Cartographic.fromCartesian(entries[0].position());
assert.ok(Math.abs(Cesium.Math.toDegrees(anchor.latitude) - 37.7955) < 1e-9);
directionsLayer.setParams({ clear: true });
assert.ok(cleared.includes(DIRECTIONS_MARKER_OVERLAY_SOURCE_ID));
cleared.length = 0;
directionsLayer.destroy(viewer);
assert.ok(cleared.includes(DIRECTIONS_MARKER_OVERLAY_SOURCE_ID));
});
+2 -1
View File
@@ -19,6 +19,7 @@ import {
installRenderGovernor, installRenderGovernor,
_resetRenderGovernorForTest, _resetRenderGovernorForTest,
} from '../renderGovernor.js'; } from '../renderGovernor.js';
import { framePortFor } from '../testSupport/fakeFramePort.mjs';
const FULL_RAW = { const FULL_RAW = {
id: 'us7000abcd', id: 'us7000abcd',
@@ -246,7 +247,7 @@ test('a quake poll still reaches the screen with the render loop idle', async ()
}); });
_resetRenderGovernorForTest(); _resetRenderGovernorForTest();
installRenderGovernor(viewer); installRenderGovernor(framePortFor(viewer.scene));
// Installing the governor enters idle mode, which itself paints one settling // Installing the governor enters idle mode, which itself paints one settling
// frame. Drop that from the baseline or the enable assertion below can pass on // frame. Drop that from the baseline or the enable assertion below can pass on
// the install alone, without the manager ever requesting anything. // the install alone, without the manager ever requesting anything.
+73
View File
@@ -0,0 +1,73 @@
/**
* @module entityRecords
* @description Core-owned records for the entities layers draw, so layer state lives in Core
* rather than on the renderer's objects. A layer writes the record for an entity it draws;
* the tracked readout, follow camera, cockpit, share links and awareness read it back
* through the entity the renderer hands them (a pick or the tracked entity).
*
* Fields a record may hold:
* - `trackedId`: the normalized `layer:id` identity, e.g. `flights:a1b2c3`.
* - `displayPosition()`: the layer's cached display position (the follow camera's
* anti-jitter contract: the value the camera settled on, never recomputed late).
* - `visualPosition()`: where a 3D model is actually drawn, for the tracked card.
* - `labelModel`: the tracked card's `{ title, details, accent, … }`.
* - `selectionOrigin`: what started the selection (`'user'`, …).
* - `sprite`: a tracked billboard's pushed `{ width, height, scale, scaleByDistance }`, for
* the close-range camera guard and the screen annotations' tracked footprint.
*
* A layer's private per-entity state goes in its own store ({@link createEntityStore}).
*/
/**
* A store of one record per renderer object, held by Core and dropped with the object.
* @returns {{ write: (entity: Object) => Object, read: (entity: unknown) => Object|null }}
* `write` makes the record empty on first use; `read` gives null when there is none.
*/
export function createEntityStore() {
const records = new WeakMap();
return {
write(entity) {
let record = records.get(entity);
if (!record) {
record = {};
records.set(entity, record);
}
return record;
},
read(entity) {
if (!entity || typeof entity !== 'object') return null;
return records.get(entity) ?? null;
},
};
}
const shared = createEntityStore();
/**
* The record for `entity`, made empty on first use. For layers writing their own state.
* @param {Object} entity An entity the layer draws.
* @returns {Object}
*/
export function entityRecord(entity) {
return shared.write(entity);
}
/**
* The record for `entity`, or null when it has none (or there is no entity). For readers.
* @param {Object|null|undefined} entity
* @returns {Object|null}
*/
export function readEntityRecord(entity) {
return shared.read(entity);
}
/**
* Write `fields` into `entity`'s record and return the entity.
* @param {Object} entity
* @param {Object} fields Record fields to set.
* @returns {Object} `entity`
*/
export function withEntityRecord(entity, fields) {
Object.assign(entityRecord(entity), fields);
return entity;
}
+48
View File
@@ -0,0 +1,48 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createEntityStore,
entityRecord,
readEntityRecord,
withEntityRecord,
} from './entityRecords.js';
test('an entity record is made on first write and read back by the same entity', () => {
const entity = {};
assert.equal(readEntityRecord(entity), null);
entityRecord(entity).trackedId = 'flights:a1b2c3';
assert.equal(entityRecord(entity), entityRecord(entity), 'one record per entity');
assert.equal(readEntityRecord(entity)?.trackedId, 'flights:a1b2c3');
assert.equal(readEntityRecord({}), null, 'another entity has its own record');
});
test('the record lives in Core, not on the entity', () => {
const entity = {};
entityRecord(entity).labelModel = { title: 'DAL123', details: [] };
assert.deepEqual(Object.keys(entity), []);
});
test('reading without an entity gives null', () => {
assert.equal(readEntityRecord(null), null);
assert.equal(readEntityRecord(undefined), null);
assert.equal(readEntityRecord('flights:a1b2c3'), null);
});
test('withEntityRecord writes fields into the record and returns the entity', () => {
const entity = withEntityRecord({ id: 'alpr:42' }, { trackedId: 'alpr:42' });
assert.equal(entity.id, 'alpr:42');
assert.equal(readEntityRecord(entity)?.trackedId, 'alpr:42');
assert.equal(Object.hasOwn(entity, 'trackedId'), false);
});
test('a layer store is separate from the shared records and from other stores', () => {
const entity = {};
const a = createEntityStore();
const b = createEntityStore();
a.write(entity).canvasPosition = 1;
assert.equal(a.read(entity)?.canvasPosition, 1);
assert.equal(b.read(entity), null);
assert.equal(readEntityRecord(entity), null);
assert.equal(a.read(null), null);
assert.deepEqual(Object.keys(entity), []);
});
+2 -1
View File
@@ -16,6 +16,7 @@ import {
import { FIRE_ANCHOR_LIFT_M } from './fireAnchors.js'; import { FIRE_ANCHOR_LIFT_M } from './fireAnchors.js';
import { getLocale, setLocale } from '../i18n/index.js'; import { getLocale, setLocale } from '../i18n/index.js';
import { reportMeshFloorCell, setMeshFloorPreferred } from './groundFloor.js'; import { reportMeshFloorCell, setMeshFloorPreferred } from './groundFloor.js';
import { fakeInput } from '../input/testDoubles.mjs';
const NOW = Date.UTC(2026, 6, 17, 4, 0); const NOW = Date.UTC(2026, 6, 17, 4, 0);
const H = 3600000; const H = 3600000;
@@ -216,6 +217,7 @@ test('real FIRMS lifecycle clears host entries on disable and destroy', async ()
primitives: { contains() { return false; } }, primitives: { contains() { return false; } },
}, },
}; };
fakeInput(viewer);
const layer = createFirmsHeatmapLayer({ const layer = createFirmsHeatmapLayer({
id: 'firms', id: 'firms',
name: 'FIRMS', name: 'FIRMS',
@@ -224,7 +226,6 @@ test('real FIRMS lifecycle clears host entries on disable and destroy', async ()
setVisible: (...args) => calls.push(['visible', ...args]), setVisible: (...args) => calls.push(['visible', ...args]),
clearSource: (...args) => calls.push(['clear', ...args]), clearSource: (...args) => calls.push(['clear', ...args]),
}, },
screenSpaceEventHandlerFactory: () => ({ setInputAction() {}, destroy() {} }),
}); });
try { try {
layer.init(viewer); layer.init(viewer);
-1
View File
@@ -16,7 +16,6 @@ const helpers = createFirmsHelpers({
}); });
export const mapAnalystRecord = helpers.mapAnalystRecord; export const mapAnalystRecord = helpers.mapAnalystRecord;
export const fireCullPosition = helpers.fireCullPosition; export const fireCullPosition = helpers.fireCullPosition;
export const applyHorizonCull = helpers.applyHorizonCull;
export const buildSelectedFireCard = helpers.buildSelectedFireCard; export const buildSelectedFireCard = helpers.buildSelectedFireCard;
export const buildFireCard = helpers.buildFireCard; export const buildFireCard = helpers.buildFireCard;
export const buildCellCard = helpers.buildCellCard; export const buildCellCard = helpers.buildCellCard;
+168 -130
View File
@@ -1,11 +1,7 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { import { createFirmsHeatmapLayer, fireCullPosition } from './firmsHeatmap.js';
applyHorizonCull,
createFirmsHeatmapLayer,
fireCullPosition,
} from './firmsHeatmap.js';
import { FIRE_ANCHOR_LIFT_M } from './fireAnchors.js'; import { FIRE_ANCHOR_LIFT_M } from './fireAnchors.js';
import { FIRMS_AMBIENT_COHORT_LIMIT, FIRMS_OVERLAY_SOURCE_ID } from './firmsLabels.js'; import { FIRMS_AMBIENT_COHORT_LIMIT, FIRMS_OVERLAY_SOURCE_ID } from './firmsLabels.js';
import { import {
@@ -15,6 +11,9 @@ import {
} from './groundFloor.js'; } from './groundFloor.js';
import { unregisterSpriteCollection } from './spriteOrder.js'; import { unregisterSpriteCollection } from './spriteOrder.js';
import { isOverlayPointVisible, normalizeOverlayEntry } from '../overlays/worldOverlay.js'; import { isOverlayPointVisible, normalizeOverlayEntry } from '../overlays/worldOverlay.js';
import { createIconSet, ICON_CULL_LIFT } from './iconSet.js';
import { writeHoldSegment } from './iconSegment.js';
import { fakeInput } from '../input/testDoubles.mjs';
const AUSTIN = { lon: -97.7, lat: 30.2 }; const AUSTIN = { lon: -97.7, lat: 30.2 };
/** Antipode of Austin — as far behind the limb as a point on Earth can be. */ /** Antipode of Austin — as far behind the limb as a point on Earth can be. */
@@ -28,24 +27,26 @@ const REPRO_HEIGHT_M = 1_500_000;
* at −22 m but visible at +5/+12 m. Measured against Cesium's own occluder. * at −22 m but visible at +5/+12 m. Measured against Cesium's own occluder.
*/ */
const LIMB_DLON = 41.87; const LIMB_DLON = 41.87;
/** Farther east, where 11 m reads hidden and 12 m visible from the same camera. */
const LIFT_BAND_DLON = 41.9452;
/** /**
* Billboard stub that records every write to `show` so the tests can assert * Whether the icon set's GPU pass draws `row` from `occluder`'s camera: the
* the pass does not dirty the collection's vertex buffer needlessly. * shader's horizon test on the row's cull point (its CPU twin).
*/ */
function billboard(position, show = true) { function gpuShows(set, row, occluder) {
return { const position = set.position(row, 0, new Cesium.Cartesian3());
position, return set.shown(row) && occluder.isPointVisible(set.cullPoint(row, position));
_show: show,
writes: 0,
get show() { return this._show; },
set show(value) { this._show = value; this.writes += 1; },
};
} }
/** Cesium BillboardCollection-shaped stub. */ /** A fire-shaped row (shown, CULL_LIFT) held at `position`. */
function collection(items) { function fireRow(set, id, position) {
return { length: items.length, get: (i) => items[i] }; const row = set.add(id, { layer: 'firms', id });
set.setFlag(row, ICON_CULL_LIFT, true);
set.setShown(row, true);
writeHoldSegment(set.segments, row, position, 0);
set.segmentWritten(row);
return row;
} }
/** Horizon occluder for a camera directly above Austin at `heightM`. */ /** Horizon occluder for a camera directly above Austin at `heightM`. */
@@ -57,92 +58,71 @@ function occluderOverAustin(heightM) {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// The pure cull predicate // The GPU horizon cull a fire row gets
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
test('applyHorizonCull: far-side fires are hidden, near-side fires stay visible', () => { test('icon cull: far-side fires are hidden, near-side fires stay visible', () => {
const occluder = occluderOverAustin(REPRO_HEIGHT_M); const occluder = occluderOverAustin(REPRO_HEIGHT_M);
const near = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0)); const set = createIconSet();
const nearby = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon + 6, AUSTIN.lat + 4, 0)); const at = (p) => Cesium.Cartesian3.fromDegrees(p.lon, p.lat, 0);
const far = billboard(Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, 0)); const near = fireRow(set, 'near', at(AUSTIN));
const otherSide = billboard(Cesium.Cartesian3.fromDegrees(TOKYO.lon, TOKYO.lat, 0)); const nearby = fireRow(set, 'nearby', at({ lon: AUSTIN.lon + 6, lat: AUSTIN.lat + 4 }));
const far = fireRow(set, 'far', at(ANTIPODE));
const otherSide = fireRow(set, 'tokyo', at(TOKYO));
const visible = applyHorizonCull(collection([near, nearby, far, otherSide]), occluder); assert.equal(gpuShows(set, near, occluder), true, 'the fire under the camera renders');
assert.equal(gpuShows(set, nearby, occluder), true, 'a fire well inside the horizon renders');
assert.equal(near.show, true, 'the fire under the camera renders'); assert.equal(gpuShows(set, far, occluder), false, 'the antipodal fire must not shine through the planet');
assert.equal(nearby.show, true, 'a fire well inside the horizon renders'); assert.equal(gpuShows(set, otherSide, occluder), false, 'Tokyo is beyond the limb from Austin at 1.5 Mm');
assert.equal(far.show, false, 'the antipodal fire must not shine through the planet');
assert.equal(otherSide.show, false, 'Tokyo is beyond the limb from Austin at 1.5 Mm');
assert.equal(visible, 2, 'returns the surviving count');
}); });
test('applyHorizonCull: raising the camera reveals fires the low camera hid', () => { test('icon cull: raising the camera reveals fires the low camera hid', () => {
const paris = () => billboard(Cesium.Cartesian3.fromDegrees(2.35, 48.86, 0)); const set = createIconSet();
const paris = fireRow(set, 'paris', Cesium.Cartesian3.fromDegrees(2.35, 48.86, 0));
const low = paris(); assert.equal(gpuShows(set, paris, occluderOverAustin(REPRO_HEIGHT_M)), false);
applyHorizonCull(collection([low]), occluderOverAustin(REPRO_HEIGHT_M)); assert.equal(gpuShows(set, paris, occluderOverAustin(35_000_000)), true, 'in view from geostationary height');
assert.equal(low.show, false, 'Paris is over the horizon from a 1.5 Mm Austin camera');
const high = paris();
applyHorizonCull(collection([high]), occluderOverAustin(35_000_000));
assert.equal(high.show, true, 'from geostationary height the same fire is in view');
}); });
test('applyHorizonCull: a hidden fire is restored when it swings back into view', () => { test('icon cull: a sub-ellipsoid anchor is judged lifted, a far-side one still culls', () => {
const revealed = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0), false);
applyHorizonCull(collection([revealed]), occluderOverAustin(REPRO_HEIGHT_M));
assert.equal(revealed.show, true, 'the cull un-hides as well as hides');
assert.equal(revealed.writes, 1);
});
test('applyHorizonCull: only flipped billboards are written (vertex-buffer churn)', () => {
const occluder = occluderOverAustin(REPRO_HEIGHT_M); const occluder = occluderOverAustin(REPRO_HEIGHT_M);
const stays = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0), true); const set = createIconSet();
const flips = billboard(Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, 0), true); const sunken = fireRow(set, 'sunken', Cesium.Cartesian3.fromDegrees(AUSTIN.lon + LIMB_DLON, AUSTIN.lat, -22));
assert.equal(gpuShows(set, sunken, occluder), true, 'the lifted cull point keeps the near-limb fire on screen');
set.setFlag(sunken, ICON_CULL_LIFT, false);
assert.equal(gpuShows(set, sunken, occluder), false, 'baseline: the raw anchor false-hides at the limb');
applyHorizonCull(collection([stays, flips]), occluder); const far = fireRow(set, 'far', Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, -22));
applyHorizonCull(collection([stays, flips]), occluder); assert.equal(gpuShows(set, far, occluder), false, '12 m of lift must not defeat the actual horizon cull');
assert.equal(stays.writes, 0, 'an already-visible billboard is never re-assigned');
assert.equal(flips.writes, 1, 'a hidden billboard is written once, not once per pass');
}); });
test('applyHorizonCull: missing collection/occluder/holes are no-ops, not throws', () => { test('fireCullPosition: a card is judged where its sprite is', () => {
assert.equal(applyHorizonCull(null, occluderOverAustin(REPRO_HEIGHT_M)), 0);
assert.equal(applyHorizonCull({ length: 3 }, occluderOverAustin(REPRO_HEIGHT_M)), 0, 'non-collection shape');
const item = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0));
assert.equal(applyHorizonCull(collection([item]), null), 0, 'no occluder → leave the layer alone');
assert.equal(applyHorizonCull(collection([item]), {}), 0, 'occluder without isPointVisible');
assert.equal(item.writes, 0, 'a skipped pass must not touch show');
assert.equal(
applyHorizonCull(collection([undefined, item]), occluderOverAustin(REPRO_HEIGHT_M)),
1,
'sparse slots are skipped, surviving neighbours still counted',
);
});
// ---------------------------------------------------------------------------
// Sub-ellipsoid anchors: datum-correct render point, lifted CULL point
// ---------------------------------------------------------------------------
test('applyHorizonCull: an index-aligned cull position overrides the render anchor', () => {
const occluder = occluderOverAustin(REPRO_HEIGHT_M); const occluder = occluderOverAustin(REPRO_HEIGHT_M);
const sunken = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon + LIMB_DLON, AUSTIN.lat, -22)); const set = createIconSet();
const lifted = Cesium.Cartesian3.fromDegrees(AUSTIN.lon + LIMB_DLON, AUSTIN.lat, 12); setMeshFloorPreferred(true);
try {
applyHorizonCull(collection([sunken]), occluder); // Sunk, an 11 m anchor (floor 6 + lift), and well above: each card must
assert.equal(sunken.show, false, 'baseline: the raw sub-ellipsoid anchor false-hides at the limb'); // agree with the GPU test on its own row, at limb points where they can.
for (const [i, floor] of [-27, 6, 900].entries()) {
applyHorizonCull(collection([sunken]), occluder, [lifted]); _clearMeshFloorCellsForTest(); // a reported cell latches its first floor
assert.equal(sunken.show, true, 'the lifted cull point keeps the near-limb fire on screen'); for (const dLon of [LIMB_DLON, LIFT_BAND_DLON]) {
}); const lat = AUSTIN.lat;
const lon = AUSTIN.lon + dLon;
test('applyHorizonCull: a lifted cull point does NOT resurrect a genuinely far-side fire', () => { reportMeshFloorCell(lat, lon, floor);
const occluder = occluderOverAustin(REPRO_HEIGHT_M); const fire = { index: i, lat, lon, frp: 10, position: null };
const far = billboard(Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, -22)); const cull = fireCullPosition(fire);
const lifted = Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, 12); assert.equal(fire.positionHeight, floor + FIRE_ANCHOR_LIFT_M);
applyHorizonCull(collection([far]), occluder, [lifted]); const row = fireRow(set, `${floor}:${dLon}`, fire.position);
assert.equal(far.show, false, '12 m of lift must not defeat the actual horizon cull'); assert.equal(
occluder.isPointVisible(cull),
gpuShows(set, row, occluder),
`floor ${floor} m at +${dLon}°`,
);
}
}
} finally {
_clearMeshFloorCellsForTest();
setMeshFloorPreferred(false);
}
}); });
test('fireCullPosition: negative-geoid anchor renders at the datum but culls lifted', () => { test('fireCullPosition: negative-geoid anchor renders at the datum but culls lifted', () => {
@@ -304,17 +284,21 @@ class MockEvent {
emit() { for (const listener of [...this.listeners]) listener(); } emit() { for (const listener of [...this.listeners]) listener(); }
} }
/** Canvas stub good enough for the pre-baked glow sprites. */ /**
* Canvas stub good enough for the pre-baked glow sprites. Its data URL names
* the gradient's colours, so each colour stop gets its own atlas slot.
*/
function canvasStub() { function canvasStub() {
const stops = [];
return { return {
width: 0, width: 0,
height: 0, height: 0,
getContext: () => ({ getContext: () => ({
createRadialGradient: () => ({ addColorStop() {} }), createRadialGradient: () => ({ addColorStop: (offset, color) => stops.push(color) }),
fillStyle: null, fillStyle: null,
fillRect() {}, fillRect() {},
}), }),
toDataURL: () => 'data:image/png;base64,iVBORw0KGgo=', toDataURL: () => `data:image/png;base64,iVBORw0KGgo=#${stops.join('|')}`,
}; };
} }
@@ -335,9 +319,8 @@ function rawFire(lat, lon, frp) {
} }
/** /**
* Boots the REAL layer against a headless viewer stub with a REAL * Boots the REAL layer against a headless viewer stub; what the GPU would draw
* Cesium.BillboardCollection, so the production hooks (not just the pure * is read off the layer's real icon set (`gpuShows`) from the stub camera.
* predicate) are what the assertions exercise.
*/ */
function createHarness(rawFires) { function createHarness(rawFires) {
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
@@ -383,13 +366,20 @@ function createHarness(rawFires) {
preRender, preRender,
primitives: { primitives: {
add: (value) => { primitives.push(value); return value; }, add: (value) => { primitives.push(value); return value; },
remove: () => {}, remove: (value) => {
const at = primitives.indexOf(value);
if (at < 0) return false;
primitives.splice(at, 1);
value.destroy();
return true;
},
contains: () => false, contains: () => false,
}, },
frameState: { mode: Cesium.SceneMode.SCENE3D, mapProjection: new Cesium.GeographicProjection() }, frameState: { mode: Cesium.SceneMode.SCENE3D, mapProjection: new Cesium.GeographicProjection() },
mapProjection: new Cesium.GeographicProjection(), mapProjection: new Cesium.GeographicProjection(),
}, },
}; };
fakeInput(viewer);
const layer = createFirmsHeatmapLayer({ const layer = createFirmsHeatmapLayer({
id: 'firms', id: 'firms',
@@ -399,7 +389,6 @@ function createHarness(rawFires) {
setVisible: () => {}, setVisible: () => {},
clearSource: () => {}, clearSource: () => {},
}, },
screenSpaceEventHandlerFactory: () => ({ setInputAction() {}, destroy() {} }),
}); });
return { return {
@@ -407,15 +396,19 @@ function createHarness(rawFires) {
viewer, viewer,
preRender, preRender,
moveEnd, moveEnd,
/** The real BillboardCollection the layer created. */ /** The icon set the layer draws its detections from. */
billboards: () => primitives[0], icons: () => primitives[0].set,
/** Primitives currently in the stub scene. */
primitives,
/** Latest entry list published to the overlay host. */ /** Latest entry list published to the overlay host. */
latestEntries: () => entryCalls[entryCalls.length - 1] || [], latestEntries: () => entryCalls[entryCalls.length - 1] || [],
/** `show` flags keyed by billboard pick id. */ /** Whether the GPU draws each row from the current camera, by pick id. */
showById() { showById() {
const bb = primitives[0]; const set = primitives[0].set;
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, viewer.camera.positionWC);
const out = new Map(); const out = new Map();
for (let i = 0; i < bb.length; i += 1) out.set(bb.get(i).id, bb.get(i).show); for (let row = 0; row < set.count; row += 1)
out.set(set.idAt(row), set.show && gpuShows(set, row, occluder));
return out; return out;
}, },
moveCameraTo(lon, lat, height = REPRO_HEIGHT_M) { moveCameraTo(lon, lat, height = REPRO_HEIGHT_M) {
@@ -433,7 +426,7 @@ function createHarness(rawFires) {
}; };
} }
test('layer: a detection rebuild culls its own freshly-added sprites', async () => { test('layer: far-side detections are culled from the first frame', async () => {
const harness = createHarness([ const harness = createHarness([
rawFire(AUSTIN.lat, AUSTIN.lon, 120), rawFire(AUSTIN.lat, AUSTIN.lon, 120),
rawFire(ANTIPODE.lat, ANTIPODE.lon, 900), rawFire(ANTIPODE.lat, ANTIPODE.lon, 900),
@@ -453,7 +446,7 @@ test('layer: a detection rebuild culls its own freshly-added sprites', async ()
} }
}); });
test('layer: camera moveEnd re-culls without a rebuild', async () => { test('layer: the GPU cull follows the camera with no CPU show pass', async () => {
const harness = createHarness([ const harness = createHarness([
rawFire(AUSTIN.lat, AUSTIN.lon, 120), rawFire(AUSTIN.lat, AUSTIN.lon, 120),
rawFire(TOKYO.lat, TOKYO.lon, 800), rawFire(TOKYO.lat, TOKYO.lon, 800),
@@ -466,6 +459,9 @@ test('layer: camera moveEnd re-culls without a rebuild', async () => {
harness.moveCameraTo(TOKYO.lon, TOKYO.lat); harness.moveCameraTo(TOKYO.lon, TOKYO.lat);
harness.moveEnd.emit(); harness.moveEnd.emit();
const set = harness.icons();
for (let row = 0; row < set.count; row += 1)
assert.equal(set.shown(row), true, `${set.idAt(row)} keeps its row shown; the GPU decides`);
const show = harness.showById(); const show = harness.showById();
assert.equal(show.get('firms-1'), true, 'Tokyo becomes visible once the camera is over it'); assert.equal(show.get('firms-1'), true, 'Tokyo becomes visible once the camera is over it');
assert.equal(show.get('firms-0'), false, 'Austin drops behind the limb'); assert.equal(show.get('firms-0'), false, 'Austin drops behind the limb');
@@ -474,32 +470,33 @@ test('layer: camera moveEnd re-culls without a rebuild', async () => {
} }
}); });
test('layer: the throttled preRender watcher re-culls on a camera move', async () => { test('layer: a refresh keeps rows and restyles one whose index now holds another fire', async () => {
const harness = createHarness([ const fires = [rawFire(AUSTIN.lat, AUSTIN.lon, 400), rawFire(TOKYO.lat, TOKYO.lon, 800)];
rawFire(AUSTIN.lat, AUSTIN.lon, 120), const harness = createHarness(fires);
rawFire(TOKYO.lat, TOKYO.lon, 800),
]);
try { try {
harness.layer.init(harness.viewer); harness.layer.init(harness.viewer);
await harness.layer.enable(harness.viewer); await harness.layer.enable(harness.viewer);
harness.moveCameraTo(TOKYO.lon, TOKYO.lat); const set = harness.icons();
// The watcher is throttled to LOD_CHECK_MS (650 ms) against const austin = set.row('firms-0');
// performance.now(), whose value at this point depends on process const handle = set.handleAt(austin);
// start-up — so emit, wait out the full throttle window, and emit again. const before = set.width(austin);
// Whichever tick passes the gate, one of them must run the pass. const beforeImage = set.imageFar(austin);
harness.preRender.emit();
await new Promise((resolve) => { setTimeout(resolve, 700); });
harness.preRender.emit();
const show = harness.showById(); fires[0] = rawFire(AUSTIN.lat + 0.5, AUSTIN.lon, 1);
assert.equal(show.get('firms-1'), true, 'Tokyo revealed by the preRender pass'); await harness.layer.update();
assert.equal(show.get('firms-0'), false, 'Austin culled by the preRender pass');
const row = set.row('firms-0');
assert.equal(set.handleAt(row), handle, 'the row (and its pick id) survives the rebuild');
assert.ok(set.width(row) < before, `restyled for the weaker fire: ${set.width(row)} < ${before}`);
assert.notEqual(set.imageFar(row), beforeImage, 'a red glow becomes a yellow one');
const carto = Cesium.Cartographic.fromCartesian(set.position(row, 0, new Cesium.Cartesian3()));
assert.ok(Math.abs(Cesium.Math.toDegrees(carto.latitude) - (AUSTIN.lat + 0.5)) < 1e-6, 'moved to the new fire');
} finally { } finally {
harness.cleanup(); harness.cleanup();
} }
}); });
test('layer: re-enabling after the camera moved does not expose stale far-side sprites', async () => { test('layer: re-enabling after the camera moved shows the icons, culled from the new camera', async () => {
const harness = createHarness([ const harness = createHarness([
rawFire(AUSTIN.lat, AUSTIN.lon, 120), rawFire(AUSTIN.lat, AUSTIN.lon, 120),
rawFire(TOKYO.lat, TOKYO.lon, 800), rawFire(TOKYO.lat, TOKYO.lon, 800),
@@ -516,10 +513,10 @@ test('layer: re-enabling after the camera moved does not expose stale far-side s
await harness.layer.enable(harness.viewer); await harness.layer.enable(harness.viewer);
// Assert with NO camera event fired and NO refetch (fires are already // Assert with NO camera event fired and NO refetch (fires are already
// loaded, so enable() does not reload): the enable path itself must have // loaded, so enable() does not reload): the GPU culls from the current
// re-culled before the collection went visible. // camera on the first frame.
const show = harness.showById(); const show = harness.showById();
assert.equal(harness.billboards().show, true, 'the collection is visible again'); assert.equal(harness.icons().show, true, 'the icon set is visible again');
assert.equal(show.get('firms-1'), true, 'the Tokyo fire is revealed at enable time'); assert.equal(show.get('firms-1'), true, 'the Tokyo fire is revealed at enable time');
assert.equal(show.get('firms-0'), false, 'the Austin fire is NOT left shining through the planet'); assert.equal(show.get('firms-0'), false, 'the Austin fire is NOT left shining through the planet');
} finally { } finally {
@@ -527,6 +524,47 @@ test('layer: re-enabling after the camera moved does not expose stale far-side s
} }
}); });
test('layer: a refresh drops rows whose fires left, and an empty one drops them all', async () => {
const fires = [
rawFire(AUSTIN.lat, AUSTIN.lon, 120),
rawFire(AUSTIN.lat + 1, AUSTIN.lon, 300),
rawFire(AUSTIN.lat + 2, AUSTIN.lon, 200),
];
const harness = createHarness(fires);
try {
harness.layer.init(harness.viewer);
await harness.layer.enable(harness.viewer);
const set = harness.icons();
assert.equal(set.count, 3);
// Index 0 still holds a fire; 1 and 2 are gone, so their rows swap out.
fires.splice(1, 2);
await harness.layer.update();
assert.deepEqual(
Array.from({ length: set.count }, (_, row) => set.idAt(row)),
['firms-0'],
);
assert.equal(set.metaAt(set.row('firms-0')).fire.lat, AUSTIN.lat, 'the kept row holds its own fire');
fires.length = 0;
await harness.layer.update();
assert.equal(set.count, 0, 'an empty refresh leaves no sprite behind');
assert.equal(harness.latestEntries().length, 0, 'nor any card');
} finally {
harness.cleanup();
}
});
test('layer: destroy takes the icons out of the scene', async () => {
const harness = createHarness([rawFire(AUSTIN.lat, AUSTIN.lon, 120)]);
harness.layer.init(harness.viewer);
await harness.layer.enable(harness.viewer);
const primitive = harness.primitives[0];
harness.cleanup();
assert.equal(harness.primitives.includes(primitive), false);
assert.equal(primitive.isDestroyed(), true);
});
test('layer: a near-limb fire on a sub-ellipsoid anchor is not culled by its datum', async () => { test('layer: a near-limb fire on a sub-ellipsoid anchor is not culled by its datum', async () => {
const lat = AUSTIN.lat; const lat = AUSTIN.lat;
const lon = AUSTIN.lon + LIMB_DLON; const lon = AUSTIN.lon + LIMB_DLON;
@@ -542,14 +580,14 @@ test('layer: a near-limb fire on a sub-ellipsoid anchor is not culled by its dat
harness.layer.init(harness.viewer); harness.layer.init(harness.viewer);
await harness.layer.enable(harness.viewer); await harness.layer.enable(harness.viewer);
const bb = harness.billboards(); const set = harness.icons();
const sunken = bb.get(1); const sunken = set.row('firms-1');
const carto = Cesium.Cartographic.fromCartesian(sunken.position); const carto = Cesium.Cartographic.fromCartesian(set.position(sunken, 0, new Cesium.Cartesian3()));
assert.ok( assert.ok(
Math.abs(carto.height - (-27 + FIRE_ANCHOR_LIFT_M)) < 0.5, Math.abs(carto.height - (-27 + FIRE_ANCHOR_LIFT_M)) < 0.5,
`the sprite still RENDERS at the datum-correct anchor, got ${carto.height}`, `the sprite still RENDERS at the datum-correct anchor, got ${carto.height}`,
); );
assert.equal(sunken.show, true, 'the near-limb fire stays on screen (lifted cull anchor)'); assert.equal(harness.showById().get('firms-1'), true, 'the near-limb fire stays on screen (lifted cull anchor)');
assert.equal(harness.showById().get('firms-0'), true, 'the camera-nadir fire is unaffected'); assert.equal(harness.showById().get('firms-0'), true, 'the camera-nadir fire is unaffected');
} finally { } finally {
harness.cleanup(); harness.cleanup();
Loaded 100 of 478 files, more files were not shown because too many files have changed in this diff. Show more