Files
gods-eye-view/docs/GLOBE-PRIMITIVES.md
T
German Khokhlov 14b2adb7af Never hide a mover behind the terrain it sits on
Cesium does not test points, billboards or labels against terrain depth;
only the horizon, 3D tiles and models hide them. Navara compared every
fragment of a marker's quad with the terrain, so markers on slopes or a
little below the rendered terrain disappeared (the flood markers in the
Nepal story). Navara now follows Cesium's rule for movers with the normal
depth test, the contract describes it, and the contract suite checks it.
2026-11-12 00:42:17 +04:00

37 KiB
Raw Blame History

Globe primitives

A layer draws through the globe-engine contract (src/globe/contract.js), not through Cesium or Navara. The contract has five drawing primitives:

  • engine.movers: things at points;
  • engine.lines: lines in the air;
  • engine.ground: fills on the ground;
  • engine.imagery: rasters on the ground;
  • engine.meshes: textured or coloured world triangles.

Each engine implements all five. Cesium's are in src/globe/cesium/, Navara's in src/globe/navara/, and the fake engine's in src/globe/testing/fakeEngine.js. The checks every engine must pass are in src/globe/testing/contractSuite/index.mjs.

A new contract member lands in one change with its implementation on Cesium, Navara and the fake engine, a behavioral check in the contract suite, and a mutant that the check kills. Keep check names prefixed with the member they exercise; optional behavior belongs in engineCapabilities.

Movers: things at a point

engine.movers.createSet({ id, kind, capacity, ... }) makes a set of up to capacity instances drawn as one batch. Write them with set(index, fields) and setCount(n), then upload the changes with one commit().

There are three kinds:

  • sprite: an image from an atlas, a square of sizePx, turned by rotation;
  • point: a filled disc with an outline;
  • model: a glTF in metres, at least minimumPixelSize on screen.

Use movers for aircraft, vessels, satellites, cameras, stations: anything that has a position and is drawn the same size on screen (or as a model).

Point instances may also set outlineColor independently of the fill, so an expanding callout can fade its outline without changing its width.

Instances move every frame without a rebuild. With depthTest: 'normal' (the default), terrain surface depth never hides a mover, even just below the surface. The Earth's horizon, 3D tiles and models still occlude it. Sprites and points can use 'always' to draw over the scene, or { disableWithinM } to skip depth testing within that camera distance; models always use normal depth. A pick returns the instance's pickId when the set is pickable. ready(index?) says whether a model's glTF or a sprite atlas has loaded and failed(index?) whether it failed to: a failed set is not loaded again, so a layer that retries makes a new set. Cesium loads a model per instance: an index written with another pickId loads its own model, so an instance moved into a failed index does not inherit the failure.

Lines: polylines in the air

engine.lines.createLine({ id, widthPx, color, ... }) makes one polyline. You can change its positions, colour and model matrix at any time.

The width is in CSS pixels. The line follows the 3D positions it is given; it does not follow the ground. Options:

  • arc: 'geodesic' bends it along great circles;
  • dash draws dashes;
  • depthFailAlpha draws a dimmer copy where the scene hides it.

Use lines for trails, orbits, launch paths, a ring at a fixed height, and leader lines.

Ground: fills on the ground

engine.ground.createSet({ id, kind, order, pickable, drape }) makes a set of flat fills that drape over the terrain and the 3D tiles. Nothing is extruded.

Work with a set like this:

  • replace(items) checks and stores all its items at once;
  • commit() draws them in place of the last ones;
  • show(visible) shows or hides the whole set;
  • list() names the sets.

Positions are [longitude, latitude] in degrees, as GeoJSON stores them. Edges follow great circles.

There are four kinds:

kind item for
polygon rings: [outer, ...holes], fill, outline perimeters, airspace, footprints, cones, KML polygons
disc center, radiusM (metres along the ground) earthquakes, air-quality stations, a pad zone
cell west, south, east, north (meridians, parallels) GNSS and FIRMS cells, privacy cells
corridor positions, color, widthPx or widthM routes, cables, roads, coverage lines, outlines in metres

Item options:

  • Every item has its own colours, rgba in 0..1; the alpha is the opacity.
  • fill and outline ({ color, widthPx }) are optional; an outline runs along every ring.
  • Within a set, a later item draws on top. Between sets, a higher order draws on top. All ground fills draw under movers, lines and models.
  • pickable: true makes picks return an item's pickId, with owner (the set) and instance (the item's index).
  • drape is 'both' (the default), 'terrain' or 'tiles'. Both engines honour it: Navara tells 3D tiles from terrain by its globe depth, and its CPU picks skip a set draped only on the surface not shown (terrain while photoreal tiles show, tiles otherwise).

Discs with a fill may use radial: { fillAlpha, rimAlpha, rimStart, rimEnd, emission }. Alpha multipliers are in 0..1; the radius fractions satisfy 0 <= rimStart < rimEnd < 1; emission is nonnegative. The rim grows with smoothstep between the two radii, then fades smoothly to zero at the outer edge. Space Missions uses a 500 m disc with alpha 0.105 inside and 0.72 at the rim, transitions at 0.952 and 0.985, and emission 0.35. Cesium retains its textured ground material and polygon depth bias. Navara composes the same radial envelope in its ground pass after tone mapping.

An unpickable pixel corridor set with reveal: true accepts setReveal(fraction) in 0..1. Each path is clipped at that fraction of its length. Geometry stays intact: Cesium updates material uniforms and Navara updates the corridor item texture. Hiding, showing and committing a new path retain the fraction. A corridor may have backing: { widthPx, color }: a wider edge with its own colour, with the core blended only once.

Transit drapes its history and active head through ground corridors. Its completed runs retain their age bands, 5 px dark backing and 3 px colour, with band updates once per displayed second. The active head is one backed corridor revealed along the whole current segment; only a segment crossing rebuilds its geometry. Transit uses dynamic sets so new history and head corridors build synchronously once Cesium's terrain bounds are available. The ground pass handles occlusion on both engines.

Cesium also builds reveal sets synchronously after loading those bounds. For nonempty sets, ready() includes the visible globe's tile readiness: classification needs the current surface depth as well as built geometry.

A widthPx corridor can also take the looks Cesium's ground polyline materials gave it. The GPU works them out every frame from the time, so a commit never rebuilds them:

look fields Cesium's
glow power, taper (1: none) PolylineGlow's glowPower and taperPower
pulse periodMs, min, max the alpha times a factor from min to max and back, a sine
dash cells, duty, speed, gapAlpha, emission the annotation route's GevRouteFlow: dashes flowing to the end
  • glow brightens the line toward white along its centre and fades it to nothing at its edges.
  • pulse follows the page's monotonic clock (performance.now()), as the old materials did, not the app clock.
  • dash lays cells dashes along the whole line. Each is lit for its first duty, with soft edges of 0.08 of a cell, and brightened by emission times its colour. Between them the alpha is times gapAlpha. The dashes move toward the line's end at speed cells a second.
  • A shown set with a pulse or a dash makes the engine draw a frame after every frame, as Cesium's continuous render did. list() reports it as animated. A glow alone does not animate.

Traffic's jam heat lines glow (power 0.25) and pulse (alpha 0.35 to 0.75, 1.63 s). The Directions route's 64 dashes flow at 0.55 cells a second.

A pixel corridor may instead use dashPattern: { lengthPx, pattern: 255 }, a static 16-bit screen-space dash mask with transparent gaps, as Cesium's PolylineDash material. It cannot combine with the other corridor looks. Bhote Koshi uses an 18 px dash behind its 5 px glowing flood core.

Use dynamic: true on a set whose path changes each frame. Cesium waits for its shared terrain-height bounds, then builds these small reveals synchronously so successive frames do not discard unfinished worker jobs. Other sets retain asynchronous building.

Radial pulse fields

engine.ground.createPulseField({ id, center, radiusM, heightM, color, lineColor, haloColor, depth, lineWidth, haloWidth }) draws up to four concentric pulse fronts. The centre is Earth-fixed metres; the radius is metres along the surface. The disc lies on the ellipsoid raised by heightM (default 0), with normal scene depth testing. Colours are rgba in 0..1, widths are CSS pixels, and depth is the inward band depth as a fraction of the radius. A band's opacity falls quadratically to zero.

Call draw({ radii, alphas, level }) with up to four radius fractions and opacities in 0..1. Missing fronts are zero. level controls visibility; the caller includes it in the alphas. The field starts hidden. move(center) follows a new centre; destroy() releases it and is safe to repeat. ground.list() includes it as kind: 'pulse-field'. The caller owns its clock and render requests; the field starts no animation loop.

Global Context keeps its 2,500 m pulse height, fixed-width front and halo, wake and rest windows, reduced-motion behaviour and 80 ms frame cadence. Cesium retains one material disc, updating its uniforms and rotating for small moves. Navara evaluates the same bands after tone mapping against a raised ellipsoid and scene depth; its shader lives in src/globe/navara/ground/pulse.js. The fake engine records the same state.

Bhote Koshi's event and locator share src/layers/bhoteKoshi/render/story.js: the authored reveal properties feed ground corridors and polygons, and point movers retain their independent fill and outline opacity. No story backend is selected by engine. The authored model still uses its existing property records; removing that model dependency is separate work.

Ground drawing guarantees

Both engines preserve item order, fill and outline colours, pixel and metre widths, visibility, and pick ownership. Non-pickable sets let picks through. ready() reports when committed geometry is ready to draw. Animated corridor looks use the shared ground time and request frames while shown.

Cesium translates ground items to classified fills and polylines. Navara classifies visible scene depth and blends the ground in display space. Navara picks ground items at the surface under the pointer. Packing, tiling and bin sizes are implementation details in src/globe/navara/ground/.

Imagery: rasters on the ground

engine.imagery.createLayer({ id, source, alpha, show, split, order, raise }) makes one raster layer draped over the terrain and the 3D tiles, under every ground fill, mover, line and model. Its colours are the image's own, blended by the texel's alpha times the layer's alpha.

There are two kinds of source:

kind source for
tiles url with {z}, {x}, {y} (and {s} of subdomains), minLevel, maxLevel, tileSize Recent Imagery's GIBS days, weather tiles, open overlays
image image: a canvas, an ImageBitmap or image element, or { width, height, data } RGBA bytes; or url the FIRMS heat raster, a field image, event imagery

Both take:

  • rectangle ({ west, south, east, north } in degrees; west > east crosses the antimeridian): where the layer draws, the globe when omitted;
  • credit: HTML shown while the layer is.

Tiles are Web Mercator XYZ tiles, which stop at ±85.05°, or with scheme: 'geographic' EPSG:4326 tiles (2×1 at level 0, y down from 90°N, Cesium's GeographicTilingScheme). opaque tiles have no alpha channel, and maxRequests caps the layer's tile requests in flight. transform(image) turns each decoded tile (row 0 on the north edge) into the canvas or image that draws: the weather's infrared mask. An image is stretched over its rectangle in longitude and latitude, row 0 on the north edge; one over the whole globe wraps across 180°.

raise: heightM puts an image layer on a shell that high above the ellipsoid instead of draping it: it draws over terrain, 3D tiles and the sky's edge, hidden only by what is nearer. The shell rises with the camera (4 m per kilometre of camera height, at most 60 km, in 500 m steps) so the coarse 3D tiles seen from far never hide it. Raised layers take image sources only.

Work with a layer like this:

  • setSource(source) draws another source in its place, at its place in the order; the same canvas or bytes again draw their new pixels, and a source that differs only in its credit keeps its pixels;
  • setSource(source, { staged: true, signal }) keeps the drawn source until the new one is complete (the image loaded; the tiles in view in), then swaps them. It resolves true then, and false when the new source failed, signal aborted it, or another setSource replaced it first. A hidden layer takes it at once. staging() says one is on its way;
  • setDetail({ image, rectangle } | null) (raised layers): inside the rectangle the layer draws the detail image in place of its own (a sharper view of the area in view). The last detail draws until the new one is uploaded; it resolves true once it draws. null clears it;
  • setAlpha(alpha) fades it: at 0 it draws nothing but stays on its surface, so it comes back without a gap (the FIRMS hand-off);
  • show(visible) shows or hides it (Cesium unmaps a hidden layer's tiles);
  • setSplit('left' | 'right' | 'none') puts it on one side of the split;
  • ready() says the source is built (an image URL loaded); tiles arrive after it;
  • destroy().

On the engine:

  • list() names the layers in draw order, bottom first: a higher order draws on top, then the later layer;
  • setSplitPosition(fraction) sets where the split runs, a fraction of the canvas width from the left (one per engine, Cesium's scene.splitPosition); splitPosition() reads it;
  • surface() says where imagery drapes now: 'globe', 'tiles' (3D tiles) or 'none'.

Imagery drawing guarantees

Both engines draw imagery below ground shapes, movers, lines and models, in layer order. Each texel blends by its alpha times the layer alpha. Visibility, split position, rectangle bounds and raised-shell height follow the imagery contract. Replacing or destroying a source releases its owned resources; stale loads cannot replace the current source.

Cesium translates sources to imagery providers and raised shells. Navara samples imagery in its surface pass, including raised ellipsoid intersections. The adapters own texture storage, tile selection and depth reconstruction.

Cesium moves draped layers between the visible globe and tileset surfaces; without either surface, layers wait. Its split applies to globe imagery, not raised shells. Navara draws the top eight visible imagery slots, with detail images consuming a slot. Staged sources keep the current image visible until their replacement is ready; a failed source leaves it intact.

Open map overlays use engine.imagery when maps.addOverlay is absent. Navara's optional native raster path retains terrain detail, but does not provide rectangle bounds, split, alpha, order, or drawing on 3D tiles.

Who draws through imagery

  • FIRMS heat (src/layers/firms/render/display.js): one image.

  • Recent Imagery (src/layers/recentImagery/render/display.js): two Web Mercator tile layers under a split.

  • Weather radar, satellite clouds and lightning (src/layers/weather/render/display.js): on the globe, the product's geographic tiles (the regional infrared through its mask, a tile transform), or the global infrared mosaic as one image; each new frame a staged source; clouds, radar and lightning at orders 21, 22, 23. On 3D tiles, a raised layer at the product's shell height (shellRendering.js WEATHER_SHELL_HEIGHTS) with the detail window around the view as its detail.

  • The wind field (src/layers/wind/render/display.js): one image over the globe at order 20, faded with the camera's height while streamlines draw, raised at 5 km on 3D tiles. The streamlines stay each engine's, behind the display's flow backend, loaded by the manifest: src/globe/cesium/windFlow.js supplies Cesium's streamlines (src/layers/wind/rendering.js), and src/globe/navara/windFlow.js supplies Navara's flow mesh.

  • The Bhote Koshi event imagery (src/layers/bhoteKoshi/render/display.js): two url images over the event's rectangle at alpha 0.96, split left and right at the engine's split position, order 15. The event's and the locator's story graphics use the shared ground and mover display described above.

  • Imported KML/KMZ ground images (src/data/geoImportDisplay.js): one image layer per rectangle at order 30, with the authored alpha capped at 0.99 as on Cesium; a tinted or rotated image is drawn on a canvas first, and an absolute one is raised to its altitude.

  • Sea Marks and Ski Pistes (src/data/osmOverlays.js): tile layers at order 10 on engines without maps.addOverlay.

Imported files

geoFileImport.js reads GeoJSON, GPX, KML and KMZ into the plain records in src/data/geoImportRecords.js. geoImportKml.js resolves KML styles with Cesium's precedence; its tests compare the records with KmlDataSource. No import loads the Cesium SDK. One geoImportDisplay.js draws the records on every engine: points on movers, ground paths and polygon fills on ground, raised paths and outlines on lines, raised polygon fills on meshes, and packed ground images on imagery. Every owned id starts with geo-import-; the file controller owns visibility, framing, refresh and destruction across engine switches.

Pins use composed square sprites to retain Cesium's pin tip, hot spot, subregion, colour, scale and rotation; a KML heading turns with the camera's heading, so it stays measured from north. Icon textures share a per-file budget of 16 million pixels: many distinct styles get lower-resolution textures at the same size. Ground heights come from the loaded surface, refreshed after the camera or map settles, in batches of 256; on 3D tiles only pins in the window are clamped to the tiles drawn. Ground pins ignore scene depth and are culled beyond the horizon; absolute pins retain their height.

Raised polygons (KML absolute or relative) draw their fills as meshes and their outlines as lines at the vertices' heights. A ground overlay's colour tints its image and its alpha fades it; a rotated overlay's image is turned on a canvas over the box around it, as Cesium turns its rectangle in degrees, and an absolute image or plain-colour overlay is a mesh at its altitude, including negative heights (Cesium raises only absolute ones). The shared display has known limits against Cesium: wall fills and extruded prisms are omitted (their top geometry remains), and gx:LatLonQuad and a rotated overlay whose box passes a pole are omitted. These are counted as undrawn. Ground edges use great circles rather than rhumb lines.

Input ownership

engine.input.on(type, listener, { pick, drill, pickWindow }) accepts pickWindow: { width, height } with positive integer dimensions passed to the picker. Omitted dimensions keep the engine default. Listeners share a pick only when their windows match, and each receives its requested drill limit. Pointer-down picks are synchronous. Moves, including drills, coalesce to the latest position per frame. Navara clicks still use one GPU answer from the gesture; a missing hit falls back to the requested CPU window. Street Level requests 9 × 9 px; CCTV's gizmo requests its own window and limit for presses and hover. Pointer ownership and removal still suppress delivery while a pick is pending.

engine.projector.horizonVisible(position, { ellipsoid }) accepts a plain ellipsoid with positive finite radii: { x, y, z } in metres. Without the option it uses WGS84. Street Level supplies WGS84 radii reduced by 1,000 m so nearby cones remain visible below sea level. The shared math is tested against the native ellipsoidal occluder, including points near the limb.

engine.input.suspendCameraInputs() disables camera gestures and returns an idempotent resume function. Suspensions are counted: only the last resume restores the state captured before the first suspension. This preserves an already-disabled camera. cameraInputsEnabled() reads the current state. Cockpit, imagery-box drawing, CCTV calibration and infrastructure flights use these methods on every engine. Pointer claims remain separate from camera input suspension.

Pick ownership belongs to the scene attachment, not to an engine implementation. scene.services.picking owns its predicates and scene.services.selection owns its shared click dispatcher. Layer entry points resolve these services using their attached engine (sceneServicesFor(engine)); the pick-registry helper functions take that engine as their final argument. Standalone entries acquire the same services on first use. The application destroys them on detach and creates fresh services when reattaching a parked engine. Identical layer IDs in simultaneous scenes cannot replace or unregister one another's picks.

Render demand

scene.services.render owns continuous-render holds and the last 16 idle request reasons for one attachment. holdContinuousRender(id) and releaseContinuousRender(id) are idempotent per owner; the last release requests one settling frame. governorRequestRender(reason) forwards a one-shot request even while another owner holds continuous rendering. The tools call install() after scene consumers have registered their holds.

The governor drives engine.render on every engine. Cesium implements setContinuous with its scene's requestRenderMode and disables automatic simulation-time renders with maximumRenderTimeChange = Infinity. Engine internals request their own scene directly and never import the governor. Layer service helpers take their attached engine as the final argument; attachment-bound callbacks can capture renderGovernorFor(engine) directly. Destroying the scene clears its holds and makes the captured service inert. A parked engine gets a fresh service when reattached.

World overlays

scene.services.overlay owns one attachment's world labels and cards. It keeps its sources, collision domains, paint lanes, canvases, frame listeners, observers, timers and accessible action subscribers. init() installs its DOM inside engine.host.container; the detection blend surface stays beside the card root so it can blend against the scene.

Publish with setEntries(sourceId, entries, options), setVisible(sourceId, visible) and clearSource(sourceId). Read painted rectangles with getPaintRect(sourceId, entryId) and register custom painters with registerPaintLane(laneId, painter, options). subscribeActions(listener) provides the accessible mirror; activateAction(owner, key) rejects an old host's actions. The shell reconnects these operations on every attachment.

worldOverlayFor(engine) resolves the same service for standalone consumers. The module helper functions take the engine first. Retained layer models use bindOverlayHost with their attached-engine getter; injected hosts without a forEngine resolver keep their own operations. Publications can buffer before init(). Destruction is terminal, including before initialization; a parked engine needs fresh scene services. An old paint-lane handle cannot mutate its replacement. Font measurements are shared until the last live host releases them.

scene.services.trackedReadout bridges the follow handle's label and selected ALPR or Local ADS-B card into this overlay. init() subscribes to that engine's follow changes and that attachment's context events; refresh(entity) republishes changed label text, and activeId() resolves the card for annotation placement. Positions stay live cached visual anchors. trackedReadoutFor(engine) resolves the service; destruction removes its listeners and clears only its own source.

scene.services.context owns selection records and notifications. Layer publishers use bindOverlayHost with their current engine, or a context service captured by their display. It mirrors records to the page context slot and preserves the gev:entity-selected, gev:entity-selection-cleared and aircraft awareness event payloads for page consumers. A clear reaches the page only when that attachment owns the page's selected record. Readouts listen only to their scene's event target. Identical IDs in different scenes cannot share a card or clear one another's selection. Retired context services cannot publish into a replacement attachment. An engine handoff carries the page-selected record through the layer session. The incoming context adopts it only while the page still selects that record; a clear between attachments wins. Rebuilt markers replace the retained carrier in the incoming readout without announcing another page selection. Readout initialization reads the retained record without testing the outgoing carrier's visibility; layers rebuild their carriers after camera restoration. The page chrome and detection controller still have page lifetimes.

Render effects

The optional visualStyles, bloom and contactSonar capabilities are available on Cesium and Navara through engine.render. Cesium implements styles, sharpening, borrowed bloom and the Anime Milky Way fade in src/globe/cesium/postStages.js; contact sonar is in sonar.js and sonarCommands.js. Cesium does not advertise cloudCover.

VisualEffects drives transitions through the render contract. UI teardown stops animation and disables effects; engine destruction removes its stages and restores the borrowed bloom state. Sharpen intensity zero disables sharpening on both engines, following Cesium.

Wildlife privacy cells

Wildlife privacy cells use one cell ground set for distinct bounds and taxon accents. The fill is 25% opaque and the 2 px outline is 80% opaque, matching Cesium's original ground geometry. Both drape on terrain and tiles and pass picks through to the badges. Wide views and detach release the set.

Batched line segments

engine.lines.createSegments({ id, widthPx, color, order }) groups independent straight segments in one batch. replace(items) copies pairs of Earth-fixed endpoints and optional per-item widthPx, color and pickId; commit() rebuilds the drawing once. Segments never connect to one another. show() and destroy() affect the whole batch; an empty replacement clears it. The batch shares createLine's id namespace and list(), which includes its endpoint count and committed pickIds in item order.

Cesium draws one synchronous primitive with per-instance colours and widths. Navara draws one ribbon mesh in the overlay after tone mapping, with scene depth and independent segment picks. Batches are solid and depth-tested; curved, dashed, transformed and depth-fail lines still use createLine. Infrastructure and cable references each use one batch for their visible stems, preserving Cesium's colours, widths, label winners and update cadence. Infrastructure excludes the whole stem batch while sampling its bases.

Choosing a primitive

  • A position and an on-screen size: a mover.
  • A line at given heights, or one that moves or is restyled every frame (trails, orbits, a ring at the terrain height): a line.
  • Anything that lies on the ground and follows its relief:
    • an area: polygon, or disc or cell when that is its shape (they are exact and cheaper);
    • a line on the ground: corridor, with widthM when its width is a real width (an outline in metres) and widthPx otherwise.
  • Group items that change together into one set, and replace them in bulk. A ground set rebuilds on commit. Small paths revealed each frame use dynamic: true; corridor looks animate without a rebuild, and radial fronts use ground.createPulseField.
  • Pixels on the ground (tiles, a raster, a photo): an imagery layer. Fade it with setAlpha, and give a new raster to setSource.

Meshes: world triangles

engine.meshes.createMesh({ id, positions, indices, uv, image, color, show, depthTest, pickId }) draws indexed triangles on both sides. Positions are flat Earth-fixed xyz metres and indices name their vertices. Optional UVs run from (0, 0) at the image's bottom left to (1, 1) at its top right. The engine copies geometry arrays; the caller owns the canvas, image or video.

update(patch) replaces only supplied fields. Repeating image uploads new canvas pixels; videos update on rendered frames. RGBA color multiplies the image, white by default. show() reads visibility and show(boolean) changes it. depthTest is normal or always; null pickId disables picking. Picks return the authored id and the mesh id as owner. destroy() is idempotent; engine.meshes.list() returns live ids in creation order.

Cesium draws a synchronous Primitive with a flat material, no back-face culling and no depth writes. Colour, image and visibility changes retain the primitive; geometry, pick-id and depth-test changes replace it. The engine requests frames while the primitive or its image is preparing. Navara draws local coordinates relative to a double-precision world origin in the shared overlay pass after tone mapping and anti-aliasing, the pass that draws sprites and lines, with scene depth and triangle picking. Their authored colours are blended in display space. Both engines own geometry, material and texture disposal. The fake engine keeps the same state and tests triangle intersections for picks.

CCTV's render/meshes.js describes its live monitor quad and viewshed pyramid through this primitive. Its markers and outlines remain movers and lines. Obstruction probes use engine.surface.raycast; exclusions accept owned mesh, line and mover handles on Cesium and restore their visibility after the query.

Data packs use one src/scenes/dataPacks/display.js: points on movers, straight raised paths on lines, raised polygons and image rectangles on meshes, and media markers with captions in a composed sprite. Polygon holes and authored vertex heights are retained. Shared triangulation uses earcut, with tests comparing its area to Cesium's polygon geometry. Images retain Cesium's one-degree rectangle grid. Scene detach releases drawn resources and pick targets; reattach redraws the retained decoded assets.

Annotation drapes still use their existing renderers. Their bounded building classification volumes, animated fills and wire cages are not ordinary world triangles; they need a classification contract before that migration can preserve Cesium's behaviour.

Shared follow offsets and manual drawing

engine.camera.follow({ position, viewFrom, swingTo }) can swing from the initial follow frame to swingTo, an east/north/up offset in metres. Each frame turns at most 60 degrees and interpolates range, preserving Cesium's tracked-camera crossing guard. handle.retarget({ swingTo }) changes only the pending swing; null cancels it. Stopping or replacing the follow, a camera command or engine destruction ends it. Share links read the current handle's target and label identity and the camera pose through the contract.

engine.input.acquirePointer() returns an exclusive claim, or null if one is held. Pass it to input.on(type, listener, { pointer: claim }) and call claim.release() when done. Other screen subscriptions yield, including picks that settle after ownership changes. Camera gestures stay enabled. Cesium suspends its stock select and track clicks and restores the original functions when the claim is released or the engine is destroyed.

The Draw tool uses one drawSurface.js on every engine: a dashed geodesic line and a point mover set at the clicked heights. The dim, occluded band uses the selected colour as well as the visible band. Finished geometry still goes through the annotation board. Detaching the scene releases the claim, listeners and preview while retaining the chosen shape and colour. The page's pointer lease still arbitrates Draw against other tools.

How to add an engine

Put the implementation in a new src/globe/<name>/ folder. Keep SDK classes, shaders, resource disposal and SDK workarounds there. Use src/globe/testing/fakeEngine.js as the contract model, and src/globe/contract.js as the authoritative member and data-shape reference. Coordinates, colours, picks and display options crossing the boundary are plain data; displays do not receive native viewers or SDK types.

Implement the required host, camera, picker, projector, frame, render, surface, input, credit, clock and map interfaces, plus all five drawing primitives: movers, lines, ground, imagery and meshes. assertGlobeEngine(engine) checks these required interfaces. engineCapabilities(engine) reports only the optional visualStyles, bloom, cloudCover and contactSonar groups. An optional group must be complete or absent. Do not advertise a capability with a no-op implementation. Match Cesium's behavior where the contract permits both engines to implement it.

Run createContractSuite(engine, { waitFrames, ... }) from src/globe/testing/contractSuite.mjs. Supply real pointer delivery, canvases and pixel reads for browser checks; keep the fake-engine tests as fast contract checks. Test ownership and repeated destruction as well as visible output. The browser harness is scripts/qa-contract-suite.mjs; use --only=ground.,imagery. for ground and imagery pixel checks.

Three reports keep migration debt from growing: scripts/report-engine-coupling.mjs counts application coupling, scripts/report-qa-harness.mjs counts browser QA duplication, and src/globe/testing/contractCoverage.test.mjs reports contract members the fake-engine suite leaves uncalled. After removing coupling or moving QA work to scripts/qa-strict-harness.mjs helpers, run the matching report with --write-baseline and keep only decreases in its baseline. After adding behavioral contract checks, remove the newly exercised names from src/globe/testing/contract-uncalled-baseline.json; keep it sorted, and remove members that no longer exist too. Run the matching tests with node --import=./build/registerVue.js --test followed by their paths; the numeric baselines reject growth, and the contract baseline also rejects stale entries.

The application currently registers two kinds: adding another still requires registration in ENGINE_KINDS, scene construction/choice and the corresponding QA harnesses. assertGlobeEngine currently validates that registration. The folder and passing contract suite are the implementation boundary, not a claim that arbitrary engine folders are auto-discovered today. Layers and their fixtures should need no new drawing implementation.

The layer manifest (src/app/layers/renderers.js) loads shared factories through display. Every valid engine implements all five primitives; the manifest has no per-kind primitive lists or needs declarations. backends supplies optional engine-specific display options, currently only wind's flow, loaded dynamically from its engine folder. A missing wind backend leaves the shared raster field available. Self-drawing layers and map overlays use the same contract and do not register supported engine kinds in the manifest.

Run src/app/layers/displayRehearsal.test.mjs: it loads every manifest display on the fake engine with an unknown kind and empty extensions. Its small fixtures exercise attachment, drawing, hiding or release, and destruction; primitive registries, frame/camera/input subscriptions, follow state and service records must be clean before the engine itself is destroyed. New display entries need a fixture. Wildlife Sightings and the two map overlays are explicit skips because they have no display factory; their layer tests remain the coverage for those controllers. Native wind streamlines, DOM rasterization and GPU pixels are not covered by this rehearsal.

node scripts/check-import-directions.mjs enforces SDK ownership for static imports, re-exports and literal dynamic imports, including package subpaths. Cesium packages belong in src/globe/cesium/; three, postprocessing and Navara packages belong in src/globe/navara/. Add the new SDK's ownership rule when registering it. Contract graphs cannot reach any engine, and the existing engines cannot reach one another. The temporary exact file/package exceptions in scripts/package-boundaries.json must have reasons and must still be used; the checker prints them. They are migration debt, not a pattern for new layers. Run package boundaries, formatting, the full unit suite and the coupling report as well; engine coupling counts may not grow.