From b14bf7e01374f25ff7d003722c47634835014cb1 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Sat, 17 Oct 2026 02:15:04 +0400 Subject: [PATCH] feat(flights): render on Navara Task 10b. src/layers/aircraft/render/navara.js implements the aircraft renderer contract on the engine contract alone (no three, no Cesium): - Fleet glyphs: banks of engine.movers sprite sets (one per distance scale and depth mode the handles ask for; flights: one, 'always', Cesium's NearFarScalar), growing by doubling, from a glyph atlas of every aircraftIcon() kind at the fleet (64 px) and tracked (192 px) rasters (data/aircraftIcons: AIRCRAFT_ICON_KINDS, aircraftIconCells, aircraftIconAtlasLayout, drawAircraftIconAtlas). Sprite handles keep the Billboard field vocabulary as setters; a frame's writes commit once at the end of the frame listener that made them (a microtask outside one). - Models: banks of model mover sets per glTF and look; handles report ready from MoverSet.ready() and computedScale from minimumPixelSize kept on the CPU (radiusM in the spec, the class's nativeRadiusM); poses are Cesium's HPR frame (modelMatrix) turned by Navara's glTF axes (Z_UP_TO_X_UP: its loader is already Z-up; chosen side by side with Cesium among six candidates). - Tracked glyph: a sprite whose position/colour/rotation callbacks are read every frame in preRender; follow = engine.camera.follow with the tracked readout's card as its label (gevFollowHandle); current() maps the follow back to the tracked handle, also while follow() raises followChanged. - Trail and live head: engine.lines (alpha 0.85, depth-fail 0.4, geodesic) in the gev-trail pick namespace. - View: camera/scene adapters over engine.camera.pose() (registered with the engine so shared helpers project through it), frustum and horizon tests, frame number, engine frame events. flights/render/navara.js registers it for 'navara' from the Navara mode's import(); the Navara catalog now offers Live Flights. Also: tracking a contact that is selected but no longer followed follows it again (a camera command ends a Navara follow; on Cesium a search flight releases trackedEntity): trackById/select re-follows instead of only re-publishing. Behaviour change on Cesium only in that case. Tests: the renderer on the fake engine (contract, atlas, banks, per-frame commits, growth, tracked callbacks, follow label, model pose, trails) and the flights logic over it (fleet, click-select, follow, re-follow after a camera command, release, no set left behind). --- scripts/format-scope.json | 3 + scripts/package-boundaries.json | 4 +- src/app/navaraMode.js | 22 +- src/data/aircraftIcons.js | 66 ++ src/layers/aircraft/render/navara.js | 1027 ++++++++++++++++++++ src/layers/aircraft/render/navara.test.mjs | 291 ++++++ src/layers/flights/queries.js | 12 +- src/layers/flights/render/navara.js | 22 + src/layers/flights/renderer.test.mjs | 82 ++ src/layers/flights/rendering.js | 4 + 10 files changed, 1530 insertions(+), 3 deletions(-) create mode 100644 src/layers/aircraft/render/navara.js create mode 100644 src/layers/aircraft/render/navara.test.mjs create mode 100644 src/layers/flights/render/navara.js diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 2e5f14dcf..cb7dc3d90 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -322,6 +322,9 @@ "src/layers/flights/evidence.js", "src/layers/flights/ingestion.js", "src/layers/flights/render/cesium.js", + "src/layers/flights/render/navara.js", + "src/layers/aircraft/render/navara.test.mjs", + "src/layers/aircraft/render/navara.js", "src/layers/flights/renderer.test.mjs", "src/layers/aircraft/render/cesium.test.mjs", "src/layers/aircraft/render/cesium.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index d7c18c43f..7ae5e19ce 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2592,7 +2592,9 @@ "src/layers/aircraft/render/cesium.js", "src/layers/flights/render/cesium.js", "src/data/selectionDispatcher.js", - "src/layers/shared/color.js" + "src/layers/shared/color.js", + "src/layers/aircraft/render/navara.js", + "src/layers/flights/render/navara.js" ], "external": [ "hls.js", diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 527d6a355..8d185fac2 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -5,7 +5,11 @@ import { DATA_CREDITS, registerDataCredits } from '../data/dataCredits.js'; import { isEmbeddedInline } from './embed.js'; -import { createLayerAvailability } from './layers/renderers.js'; +import { + createLayerAvailability, + layerAvailableOn, + registerLayerRenderers, +} from './layers/renderers.js'; import { getRenderGovernorDiagnostics, governorRequestRender, @@ -45,6 +49,18 @@ const SHELL_CREDIT_KEYS = new Set([ 'reearth-terrain', ]); +/** + * Registers the Navara layer renderers (once per page). Each loads only + * here, through import(), so Cesium mode never fetches them. + */ +export async function registerNavaraLayerRenderers() { + if (layerAvailableOn('flights', 'navara')) return; + const { createFlightsNavaraRenderer } = + await import('../layers/flights/render/navara.js'); + if (!layerAvailableOn('flights', 'navara')) + registerLayerRenderers('navara', { flights: createFlightsNavaraRenderer }); +} + /** * The scene on the experimental Navara engine: no viewer and no Cesium * objects. Navara builds the map sources from the controller's descriptions @@ -72,6 +88,10 @@ export async function createNavaraScene({ }); defer(() => globe.destroy()); const { engine } = globe; + // The layers drawn on Navara (their render/navara.js), before the catalog + // asks the registry which layers this engine shows. + await registerNavaraLayerRenderers(); + signal.throwIfAborted(); // The tools install the governor on this engine; it goes before the engine. defer(() => uninstallRenderGovernor(engine)); signal.throwIfAborted(); diff --git a/src/data/aircraftIcons.js b/src/data/aircraftIcons.js index bca4ca655..879a47b78 100644 --- a/src/data/aircraftIcons.js +++ b/src/data/aircraftIcons.js @@ -261,6 +261,10 @@ const TRACKED_RASTER_PX = 192; * Default size serves the fleet; pass `aircraftIcon(kind, TRACKED_ICON_PX)` * (re-exported below) for the tracked billboard. */ export const TRACKED_ICON_PX = TRACKED_RASTER_PX; +/** The fleet raster size (aircraftIcon's default). */ +export const FLEET_ICON_PX = FLEET_RASTER_PX; +/** Every silhouette kind aircraftIcon draws (an engine's sprite atlas). */ +export const AIRCRAFT_ICON_KINDS = Object.freeze(Object.keys(BODIES)); export function aircraftIcon(kind, px = FLEET_RASTER_PX) { const k = BODIES[kind] ? kind : 'airliner'; const key = `${k}@${px}`; @@ -272,3 +276,65 @@ export function aircraftIcon(kind, px = FLEET_RASTER_PX) { } return uri; } + +// --------------------------------------------------------------------------- +// The glyph atlas (engines that draw sprites from one texture: Navara) +// --------------------------------------------------------------------------- + +const ATLAS_PAD = 2; +const atlasCellKey = (kind, px) => `${kind}@${px}`; + +/** Every aircraftIcon() data URI → its atlas cell key (`${kind}@${px}`). */ +export function aircraftIconCells() { + const cells = new Map(); + for (const kind of AIRCRAFT_ICON_KINDS) + for (const px of [FLEET_RASTER_PX, TRACKED_RASTER_PX]) + cells.set(aircraftIcon(kind, px), atlasCellKey(kind, px)); + return cells; +} + +/** + * The atlas layout (pure): the tracked raster in row 0, the fleet raster in + * row 1, one column per kind, ATLAS_PAD px around each cell; cells are + * [u0, v0, u1, v1] in 0..1. + */ +export function aircraftIconAtlasLayout(kinds = AIRCRAFT_ICON_KINDS) { + const big = TRACKED_RASTER_PX + 2 * ATLAS_PAD; + const small = FLEET_RASTER_PX + 2 * ATLAS_PAD; + const width = kinds.length * big; + const height = big + small; + const rects = new Map(); + const cells = {}; + kinds.forEach((kind, column) => { + for (const [px, row] of [ + [TRACKED_RASTER_PX, 0], + [FLEET_RASTER_PX, big], + ]) { + const x = column * big + ATLAS_PAD; + const y = row + ATLAS_PAD; + const key = atlasCellKey(kind, px); + rects.set(key, { x, y, size: px }); + cells[key] = [x / width, y / height, (x + px) / width, (y + px) / height]; + } + }); + return { width, height, rects, cells }; +} + +/** Draws the atlas (browser): a canvas and its cells, once the SVGs decode. */ +export async function drawAircraftIconAtlas() { + const layout = aircraftIconAtlasLayout(); + const canvas = document.createElement('canvas'); + canvas.width = layout.width; + canvas.height = layout.height; + const context = canvas.getContext('2d'); + await Promise.all( + [...aircraftIconCells()].map(async ([uri, key]) => { + const image = new Image(); + image.src = uri; + await image.decode(); + const { x, y, size } = layout.rects.get(key); + context.drawImage(image, x, y, size, size); + }), + ); + return { canvas, cells: layout.cells }; +} diff --git a/src/layers/aircraft/render/navara.js b/src/layers/aircraft/render/navara.js new file mode 100644 index 000000000..825e26725 --- /dev/null +++ b/src/layers/aircraft/render/navara.js @@ -0,0 +1,1027 @@ +// The aircraft renderer on the Navara engine (contract: ./contract.js), +// built only on the engine contract: engine.movers (the fleet glyphs, the +// tracked glyph, the 3D models), engine.lines (the trail and its live head), +// engine.camera.follow (the follow camera), engine.projector and +// engine.camera.pose() (the view the logic reads). No three, no Cesium: the +// engine draws. Loaded only through the Navara mode's import(). +// +// Handles keep the Cesium field vocabulary the logic writes (contract.js) as +// setters that write the engine's instance stores; the writes of a frame are +// committed once, at the end of the frame listener that made them (or in a +// microtask for writes outside a frame), so a fleet tick draws in its frame. +// +// Sprites go in banks: one mover set per distance scale and depth mode the +// handles ask for (flights: one), growing by doubling. Models go in banks per +// glTF and look (url, minimum pixels, blend, unlit). The glyph atlas holds +// every aircraftIcon() kind at the fleet and tracked raster sizes, drawn +// once from the same SVGs Cesium's billboards load. + +import * as geo from '../../../geo/index.js'; +import { + aircraftIconAtlasLayout, + aircraftIconCells, + drawAircraftIconAtlas, +} from '../../../data/aircraftIcons.js'; +import { registerViewerEngine } from '../../../globe/cesium/engineFromViewer.js'; + +const BASE_SIZE_PX = 20; + +/** + * The model frame → glTF scene axes on Navara. Cesium draws a glTF through + * Y_UP_TO_Z_UP × Z_UP_TO_X_UP (data/modelVisualAnchor.js); Navara's glTF + * loader already turns the scene Y-up to Z-up, so here it is Z_UP_TO_X_UP + * alone: nose, top and heading of airplane.glb where Cesium draws them + * (checked side by side with Cesium on a tracked aircraft at 300 m against + * five other axis candidates). + */ +// Cesium's Axis.Z_UP_TO_X_UP, column-major. +const NAVARA_GLTF_AXIS = [0, 0, -1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1]; +const FIRST_CAPACITY = 1024; +const ATLAS_PAD = 2; +const TRAIL_ALPHA = 0.85; +const TRAIL_OCCLUDED_ALPHA = 0.4; +const MIN_SEGMENT_DISTANCE_SQ = 0.01; + +// --------------------------------------------------------------------------- +// Banks of mover sets +// --------------------------------------------------------------------------- + +// Reused set() arguments (set() copies them; no object per write). +const fieldsNoPick = { + position: undefined, + orientation: undefined, + color: undefined, + rotation: undefined, + scale: undefined, + cell: undefined, + show: undefined, +}; +const scratchColor = new Float64Array(4); +function writeFields(set, index, fields) { + const scratch = fieldsNoPick; + for (const key in fields) scratch[key] = fields[key]; + set.set(index, scratch); + for (const key in fields) scratch[key] = undefined; +} + +/** + * A growing bank of one kind of mover: handles hold an index; removal moves + * the last handle into the hole. `options(capacity, generation)` gives the + * createSet options; `write(handle, set, index)` writes a handle whole. + */ +export function createMoverBank(engine, { options, write, onCommit }) { + const handles = []; + let set = null; + let generation = 0; + let capacity = 0; + let visible = true; + let dirty = false; + const build = (wanted) => { + const next = engine.movers.createSet(options(wanted, ++generation)); + set?.destroy(); + set = next; + capacity = wanted; + handles.forEach((handle, index) => write(handle, set, index)); + set.setCount(visible ? handles.length : 0); + dirty = true; + }; + const bank = { + handles, + get set() { + return set; + }, + ready: () => Boolean(set?.ready()), + add(handle) { + if (handle.gevBank === bank) return; + handle.gevBank?.remove(handle); + handle.gevBank = bank; + handle.gevIndex = handles.length; + handles.push(handle); + if (!set || handles.length > capacity) + build(Math.max(FIRST_CAPACITY, capacity * 2, handles.length)); + else { + write(handle, set, handle.gevIndex); + if (visible) set.setCount(handles.length); + } + bank.touch(); + }, + remove(handle) { + if (handle.gevBank !== bank) return; + const index = handle.gevIndex; + const last = handles.pop(); + if (last !== handle) { + handles[index] = last; + last.gevIndex = index; + write(last, set, index); + } + handle.gevBank = null; + handle.gevIndex = -1; + set?.setCount(visible ? handles.length : 0); + bank.touch(); + }, + /** Writes `fields` of one handle (when it is in this bank). */ + update(handle, fields) { + if (handle.gevBank !== bank || !set) return; + writeFields(set, handle.gevIndex, fields); + bank.touch(); + }, + setVisible(next) { + visible = next; + set?.setCount(visible ? handles.length : 0); + bank.touch(); + }, + visible: () => visible, + touch() { + if (!dirty) { + dirty = true; + onCommit(bank); + } + }, + commit() { + if (!dirty) return; + dirty = false; + set?.commit(); + }, + destroy() { + for (const handle of handles) { + handle.gevBank = null; + handle.gevIndex = -1; + } + handles.length = 0; + set?.destroy(); + set = null; + }, + }; + return bank; +} + +const rgbaOf = (color, result = scratchColor) => { + result[0] = color?.red ?? 1; + result[1] = color?.green ?? 1; + result[2] = color?.blue ?? 1; + result[3] = color?.alpha ?? 1; + return result; +}; + +/** Plain colour copy (the stored value a handle's colour getter returns). */ +class StoredColor { + constructor(color) { + this.red = color?.red ?? 1; + this.green = color?.green ?? 1; + this.blue = color?.blue ?? 1; + this.alpha = color?.alpha ?? 1; + } + + withAlpha(alpha) { + return new StoredColor({ ...this, alpha }); + } +} + +const depthKey = (distance) => + distance === Number.POSITIVE_INFINITY + ? 'always' + : Number.isFinite(distance) && distance > 0 + ? { disableWithinM: distance } + : 'normal'; + +const scalarKey = (scalar) => + scalar + ? `${scalar.near},${scalar.nearValue},${scalar.far},${scalar.farValue}` + : 'none'; + +// --------------------------------------------------------------------------- +// The renderer +// --------------------------------------------------------------------------- + +/** + * @param {object} options + * @param {import('../../../globe/contract.js').GlobeEngine} options.engine + * @param {string} options.layerId Names the layer's mover sets and lines. + * @param {number} [options.order=0] Draw order of the layer's sprites. + * @param {number} [options.headingOffsetDeg=0] Model nose vs heading 0. + * @param {() => Promise<{ canvas: object, cells: object }>} [options.drawAtlas] + * Test seam: the glyph atlas. + * @returns {import('./contract.js').AircraftRenderer} + */ +export function createAircraftNavaraRenderer({ + engine, + layerId, + order = 0, + headingOffsetDeg = 0, + drawAtlas = drawAircraftIconAtlas, +}) { + let destroyed = false; + const removers = []; + const iconCells = aircraftIconCells(); + const defaultIcon = [...iconCells.keys()][0]; + + // --- commits: once per frame listener, or in a microtask ----------------- + const pending = new Set(); + let inFrame = 0; + let microtask = false; + const flush = () => { + microtask = false; + for (const bank of pending) bank.commit(); + pending.clear(); + }; + const onCommit = (bank) => { + pending.add(bank); + if (inFrame === 0 && !microtask) { + microtask = true; + queueMicrotask(flush); + } + }; + const inFrameDo = (run) => { + inFrame++; + try { + return run(); + } finally { + inFrame--; + if (inFrame === 0) flush(); + } + }; + + // --- the view ------------------------------------------------------------ + let frameNumber = 0; + removers.push( + engine.frames.preUpdate.addEventListener(() => { + frameNumber++; + }), + ); + const camera = { + positionWC: geo.cartesian3.create(), + positionCartographic: geo.cartographic.create(), + directionWC: geo.cartesian3.create(), + upWC: geo.cartesian3.create(), + rightWC: geo.cartesian3.create(), + heading: 0, + pitch: 0, + roll: 0, + frustum: { fovy: 1, aspectRatio: 1 }, + }; + let poseFrame = -1; + const lastPosition = geo.cartesian3.create(Number.NaN, 0, 0); + // The camera as of now: refreshed once per frame, and whenever it moved. + const freshCamera = () => { + const live = engine.camera.livePosition; + if ( + poseFrame === frameNumber && + live.x === lastPosition.x && + live.y === lastPosition.y && + live.z === lastPosition.z + ) + return camera; + const pose = engine.camera.pose(); + geo.cartesian3.clone(pose.position, camera.positionWC); + geo.cartographic.clone(pose.cartographic, camera.positionCartographic); + geo.cartesian3.clone(pose.direction, camera.directionWC); + geo.cartesian3.clone(pose.up, camera.upWC); + geo.cartesian3.clone(pose.right, camera.rightWC); + camera.heading = pose.heading; + camera.pitch = pose.pitch; + camera.roll = pose.roll; + camera.frustum.fovy = pose.fovy; + camera.frustum.aspectRatio = pose.aspectRatio ?? 1; + geo.cartesian3.clone(live, lastPosition); + poseFrame = frameNumber; + return camera; + }; + const cameraView = new Proxy(camera, { + get: (target, key) => freshCamera()[key], + }); + const scene = { + get camera() { + return cameraView; + }, + canvas: { + get clientHeight() { + return engine.host.size().height; + }, + }, + frameState: { + get frameNumber() { + return frameNumber; + }, + }, + }; + // Shared helpers project through the engine registered for their scene. + removers.push(registerViewerEngine(scene, engine)); + const horizon = { + isPointVisible: (position) => engine.projector.horizonVisible(position), + }; + const scratchDelta = geo.cartesian3.create(); + let frustum = null; + const inFrustum = (position) => { + const { origin, direction, right, up, tanX, tanY } = frustum; + geo.cartesian3.subtract(position, origin, scratchDelta); + const z = geo.cartesian3.dot(scratchDelta, direction); + if (z <= -1) return false; + const x = Math.abs(geo.cartesian3.dot(scratchDelta, right)); + const y = Math.abs(geo.cartesian3.dot(scratchDelta, up)); + return x <= z * tanX + 1 && y <= z * tanY + 1; + }; + const view = { + get camera() { + return cameraView; + }, + scene, + surfaceOwner: engine, + surfaceScene: engine, + frameNumber: () => frameNumber, + viewportHeightPx: () => engine.host.size().height, + frustumTester() { + const cam = freshCamera(); + const tanY = Math.tan(cam.frustum.fovy / 2); + const { width, height } = engine.host.size(); + frustum = { + origin: cam.positionWC, + direction: cam.directionWC, + right: cam.rightWC, + up: cam.upWC, + tanY, + tanX: tanY * (height > 0 ? width / height : 1), + }; + return inFrustum; + }, + horizon: () => horizon, + worldToWindow: (position, result) => + engine.projector.worldToWindow(position, result), + requestRender: () => engine.render.request(layerId), + on(event, listener) { + const source = + event === 'moveEnd' + ? engine.camera.moveEnd + : event === 'preRender' + ? engine.frames.preRender + : event === 'preUpdate' + ? engine.frames.preUpdate + : null; + if (!source) return null; + return source.addEventListener((...args) => + inFrameDo(() => listener(...args)), + ); + }, + isDestroyed: () => destroyed || engine.isDestroyed?.() === true, + holdFrames() {}, + closeFrameLatch() {}, + releaseFrames() {}, + }; + + // --- fleet sprites --------------------------------------------------------- + let atlas = null; + let fleetVisible = true; + const spriteBanks = new Map(); + const unbanked = new Set(); // sprites added before the atlas + const spriteWrite = (sprite, set, index) => { + writeFields(set, index, { + position: sprite.position, + rotation: sprite.rotation, + scale: sprite.drawnScale(), + color: rgbaOf(sprite.color), + cell: sprite.cell, + show: sprite.show, + }); + set.set(index, { pickId: sprite.id }); + }; + const spriteBankFor = (sprite) => { + if (!atlas) return null; + const depth = depthKey(sprite.disableDepthTestDistance); + const key = `${scalarKey(sprite.scaleByDistance)}|${JSON.stringify(depth)}`; + let bank = spriteBanks.get(key); + if (!bank) { + const scalar = sprite.scaleByDistance; + const index = spriteBanks.size; + bank = createMoverBank(engine, { + options: (capacity, generation) => ({ + id: `${layerId}-sprites-${index}-${generation}`, + kind: 'sprite', + capacity, + order, + depthTest: depth, + pickable: true, + sprite: { + atlas, + sizePx: BASE_SIZE_PX, + scaleByDistance: scalar + ? [scalar.near, scalar.nearValue, scalar.far, scalar.farValue] + : undefined, + }, + }), + write: spriteWrite, + onCommit, + }); + bank.setVisible(fleetVisible); + spriteBanks.set(key, bank); + } + return bank; + }; + const placeSprite = (sprite) => { + const bank = spriteBankFor(sprite); + if (!bank) { + unbanked.add(sprite); + return; + } + bank.add(sprite); + }; + const atlasReady = Promise.resolve() + .then(() => drawAtlas()) + .then((drawn) => { + if (destroyed) return; + atlas = drawn; + const waiting = [...unbanked]; + unbanked.clear(); + for (const sprite of waiting) if (sprite.live) placeSprite(sprite); + for (const sprite of trackedSprites) + if (sprite.live) placeTracked(sprite); + engine.render.request(layerId); + }) + .catch((error) => + console.warn(`[${layerId}] aircraft atlas failed`, error), + ); + + class Sprite { + constructor(spec, bankFor) { + this.gevBank = null; + this.gevIndex = -1; + this.live = true; + this.bankFor = bankFor; + this._position = geo.cartesian3.clone( + spec.position ?? geo.cartesian3.ZERO, + ); + this._show = spec.show !== false; + this._rotation = spec.rotation ?? 0; + this._scale = spec.scale ?? 1; + this._width = spec.width ?? BASE_SIZE_PX; + this._height = spec.height ?? BASE_SIZE_PX; + this._color = new StoredColor(spec.color); + this._image = spec.image; + this.cell = iconCells.get(spec.image) ?? iconCells.get(defaultIcon); + this._scaleByDistance = spec.scaleByDistance + ? { ...spec.scaleByDistance } + : undefined; + this.disableDepthTestDistance = spec.disableDepthTestDistance ?? 0; + this.id = spec.id; + this.alignedAxis = spec.alignedAxis; + this.sizeInMeters = false; + } + + drawnScale() { + return Math.max(0, this._scale * (this._width / BASE_SIZE_PX)); + } + + write(fields) { + this.gevBank?.update(this, fields); + } + + get position() { + return this._position; + } + + set position(value) { + geo.cartesian3.clone(value, this._position); + this.write({ position: this._position }); + } + + get show() { + return this._show; + } + + set show(value) { + this._show = value !== false; + this.write({ show: this._show }); + } + + get rotation() { + return this._rotation; + } + + set rotation(value) { + this._rotation = value; + this.write({ rotation: value }); + } + + get scale() { + return this._scale; + } + + set scale(value) { + this._scale = value; + this.write({ scale: this.drawnScale() }); + } + + get width() { + return this._width; + } + + set width(value) { + this._width = value; + this.write({ scale: this.drawnScale() }); + } + + get height() { + return this._height; + } + + set height(value) { + this._height = value; + } + + get color() { + return this._color; + } + + set color(value) { + this._color = new StoredColor(value); + this.write({ color: rgbaOf(this._color) }); + } + + get image() { + return this._image; + } + + set image(value) { + this._image = value; + const cell = iconCells.get(value); + if (!cell || cell === this.cell) return; + this.cell = cell; + this.write({ cell }); + } + + get scaleByDistance() { + return this._scaleByDistance; + } + + set scaleByDistance(value) { + const next = value ? { ...value } : undefined; + if (scalarKey(next) === scalarKey(this._scaleByDistance)) return; + this._scaleByDistance = next; + // Another distance scale is another set. + if (this.gevBank) this.bankFor(this); + } + } + + const fleet = { + attach() {}, + detach() { + for (const bank of spriteBanks.values()) bank.destroy(); + spriteBanks.clear(); + unbanked.clear(); + }, + attached: () => !destroyed, + visible: () => !destroyed && fleetVisible, + setVisible(visible) { + fleetVisible = visible; + for (const bank of spriteBanks.values()) bank.setVisible(visible); + }, + add(spec) { + const sprite = new Sprite(spec, (s) => placeSprite(s)); + placeSprite(sprite); + return sprite; + }, + remove(sprite) { + if (!sprite) return; + sprite.live = false; + unbanked.delete(sprite); + sprite.gevBank?.remove(sprite); + }, + restoreOrder() {}, + restoreOrderOnEnable() {}, + }; + + // --- models ---------------------------------------------------------------- + let modelsVisible = true; + const modelBanks = new Map(); + const scratchMatrix3 = geo.matrix3.create(); + const scratchCorrected = geo.matrix4.create(); + const scratchQuaternion = geo.quaternion.create(); + const scratchHpr = { heading: 0, pitch: 0, roll: 0 }; + const modelWrite = (model, set, index) => { + writeFields(set, index, { + position: model.translation, + orientation: model.orientation, + scale: model.scale, + color: rgbaOf(model.color), + show: model.show && model.placed, + }); + set.set(index, { pickId: model.id }); + }; + const modelBankFor = (model) => { + const { url, minimumPixelSize, blendAmount, unlit } = model.spec; + const key = `${url}|${minimumPixelSize}|${blendAmount}|${unlit}`; + let bank = modelBanks.get(key); + if (!bank) { + const index = modelBanks.size; + bank = createMoverBank(engine, { + options: (capacity, generation) => ({ + id: `${layerId}-models-${index}-${generation}`, + kind: 'model', + capacity: Math.min(capacity, 512), + order, + pickable: true, + model: { + url, + minimumPixelSize, + colorBlend: { mode: 'mix', amount: blendAmount ?? 0.5 }, + unlit: unlit === true, + }, + }), + write: modelWrite, + onCommit, + }); + bank.setVisible(modelsVisible); + modelBanks.set(key, bank); + } + return bank; + }; + + class ModelHandle { + constructor(spec) { + this.spec = spec; + this.gevBank = null; + this.gevIndex = -1; + this.placed = false; + this._show = true; + this._scale = spec.scale ?? 1; + this._color = new StoredColor(spec.color); + this.id = spec.id; + this.modelMatrix = geo.matrix4.create(); + this.translation = geo.cartesian3.create(); + this.orientation = geo.quaternion.create(0, 0, 0, 1); + this.radiusM = spec.radiusM ?? 1; + } + + get ready() { + return this.placed && Boolean(this.gevBank?.ready()); + } + + /** The scale drawn: Cesium's minimumPixelSize kept on the CPU. */ + get computedScale() { + const minimum = this.spec.minimumPixelSize ?? 0; + if (!(minimum > 0)) return this._scale; + const cam = freshCamera(); + const distance = geo.cartesian3.distance( + cam.positionWC, + this.translation, + ); + const height = engine.host.size().height; + if (!(distance > 0) || !(height > 0)) return this._scale; + const pixels = + (2 * this.radiusM * this._scale * height) / + (2 * Math.tan(cam.frustum.fovy / 2) * distance); + return pixels < minimum ? this._scale * (minimum / pixels) : this._scale; + } + + get show() { + return this._show; + } + + set show(value) { + this._show = value !== false; + this.gevBank?.update(this, { show: this._show && this.placed }); + } + + get scale() { + return this._scale; + } + + set scale(value) { + this._scale = value; + this.gevBank?.update(this, { scale: value }); + } + + get color() { + return this._color; + } + + set color(value) { + this._color = new StoredColor(value); + this.gevBank?.update(this, { color: rgbaOf(this._color) }); + } + } + + const models = { + live: () => !destroyed, + setVisible(visible) { + modelsVisible = visible; + for (const bank of modelBanks.values()) bank.setVisible(visible); + }, + loadFleet: (spec) => Promise.resolve(new ModelHandle(spec)), + loadTracked: (spec) => Promise.resolve(new ModelHandle(spec)), + // The engine loads each glTF once per bank; nothing to warm. + preload: (url) => Promise.resolve({ url, destroy() {} }), + add(model) { + modelBankFor(model).add(model); + }, + remove(model) { + model?.gevBank?.remove(model); + }, + destroy(model) { + model?.gevBank?.remove(model); + }, + /** The same HPR frame as Cesium (modelMatrix); the instance turned by + * Navara's glTF axes (NAVARA_GLTF_AXIS). */ + setPose(model, position, headingDeg) { + scratchHpr.heading = geo.math.toRadians( + (headingDeg || 0) + headingOffsetDeg, + ); + geo.transforms.headingPitchRollToFixedFrame( + position, + scratchHpr, + geo.ellipsoid.WGS84, + undefined, + model.modelMatrix, + ); + geo.matrix4.multiplyTransformation( + model.modelMatrix, + NAVARA_GLTF_AXIS, + scratchCorrected, + ); + geo.quaternion.fromRotationMatrix( + geo.matrix4.getMatrix3(scratchCorrected, scratchMatrix3), + scratchQuaternion, + ); + model.orientation.x = scratchQuaternion.x; + model.orientation.y = scratchQuaternion.y; + model.orientation.z = scratchQuaternion.z; + model.orientation.w = scratchQuaternion.w; + geo.cartesian3.clone(position, model.translation); + model.placed = true; + model.gevBank?.update(model, { + position: model.translation, + orientation: model.orientation, + show: model._show, + }); + return model.modelMatrix; + }, + }; + + // --- the tracked glyph ----------------------------------------------------- + const trackedSprites = new Set(); + let trackedBank = null; + const trackedBankFor = () => { + if (!atlas) return null; + trackedBank ??= createMoverBank(engine, { + options: (capacity, generation) => ({ + id: `${layerId}-tracked-${generation}`, + kind: 'sprite', + capacity: Math.max(capacity, 4), + order: order + 0.5, + depthTest: 'always', + pickable: true, + sprite: { + atlas, + sizePx: BASE_SIZE_PX, + scaleByDistance: [1000, 3.0, 8000000, 0.5], + }, + }), + write: spriteWrite, + onCommit, + }); + return trackedBank; + }; + const placeTracked = (sprite) => trackedBankFor()?.add(sprite); + let trackedSeq = 0; + // Each frame (before drawing): the tracked glyphs read their callbacks. + removers.push( + engine.frames.preRender.addEventListener(() => + inFrameDo(() => { + for (const sprite of trackedSprites) { + const { spec } = sprite; + const position = spec.position(); + if (position) sprite.position = position; + sprite.rotation = spec.rotation(); + sprite.color = spec.color(); + } + }), + ), + ); + + const tracked = { + create(spec) { + const sprite = new Sprite( + { + position: spec.position() ?? geo.cartesian3.ZERO, + image: spec.image, + width: spec.width, + height: spec.height, + scale: spec.scale, + color: spec.color(), + rotation: spec.rotation(), + disableDepthTestDistance: spec.disableDepthTestDistance, + id: `${layerId}-tracked-${++trackedSeq}`, + }, + () => {}, + ); + // The tracked glyph's distance scale is its bank's (as Cesium's). + sprite._scaleByDistance = spec.scaleByDistance; + sprite.spec = spec; + sprite.viewFrom = null; + trackedSprites.add(sprite); + placeTracked(sprite); + return sprite; + }, + remove(handle) { + if (!handle) return; + handle.live = false; + trackedSprites.delete(handle); + handle.gevBank?.remove(handle); + if ( + handle.gevFollowHandle && + engine.camera.following() === handle.gevFollowHandle + ) + handle.gevFollowHandle.stop(); + }, + isLive: (handle) => trackedSprites.has(handle), + setImage(handle, image) { + if (handle) handle.image = image; + }, + setViewFrom(handle, offset) { + handle.viewFrom = geo.cartesian3.create(offset.x, offset.y, offset.z); + }, + billboardSize: (handle) => + handle + ? { + width: handle.width, + height: handle.height, + scale: handle.scale, + scaleByDistance: handle.scaleByDistance, + } + : null, + }; + + // --- follow --------------------------------------------------------------- + const ownFollows = new WeakMap(); // follow handle → tracked handle + // The follow carries the tracked readout's card as plain data (the + // handle's annotation fields, read when the card paints). + const followOf = (handle) => { + const card = { + id: handle.gevTrackedId, + model: () => handle.gevLabelModel, + position: () => handle.gevDisplayPosition?.() ?? handle.spec.position(), + }; + return engine.camera.follow({ + position: () => handle.spec.position(), + viewFrom: () => handle.viewFrom, + label: card, + }); + }; + // The tracked handle whose follow is being started: the engine raises + // followChanged inside follow(), before the handle is known here. + let starting = null; + const follow = { + start(handle) { + starting = handle; + let followHandle; + try { + followHandle = followOf(handle); + } finally { + starting = null; + } + handle.gevFollowHandle = followHandle; + ownFollows.set(followHandle, handle); + // Cesium returns the one-frame camera framing's disposer; the engine's + // follow frames itself. + return () => {}; + }, + refocus(handle) { + return follow.start(handle); + }, + release() { + engine.camera.following()?.stop(); + }, + isFollowing: (handle) => + Boolean(handle?.gevFollowHandle) && + engine.camera.following() === handle.gevFollowHandle, + current() { + if (starting) return starting; + const handle = engine.camera.following(); + return handle ? (ownFollows.get(handle) ?? handle) : undefined; + }, + onChanged: (listener) => + engine.camera.followChanged.addEventListener(listener), + }; + + // --- trails ---------------------------------------------------------------- + let lineSeq = 0; + const rgbaFromCss = (css) => { + const hex = String(css).replace('#', ''); + return [0, 2, 4].map((at) => parseInt(hex.slice(at, at + 2), 16) / 255); + }; + const trails = { + create({ color, width = 2.5 }) { + const [r, g, b] = rgbaFromCss(color); + const id = `gev-trail:${layerId}-${++lineSeq}`; + let line = null; + let visible = true; + let current = []; + const ensure = () => + (line ??= engine.lines.createLine({ + id, + widthPx: width, + color: [r, g, b, TRAIL_ALPHA], + depthFailAlpha: TRAIL_OCCLUDED_ALPHA / TRAIL_ALPHA, + arc: 'geodesic', + pickId: id, + })); + return { + setPositions(cartesians) { + if (destroyed) return; + const positions = []; + for (const position of Array.isArray(cartesians) ? cartesians : []) { + if (!position) continue; + const last = positions[positions.length - 1]; + if ( + last && + geo.cartesian3.distanceSquared(last, position) < + MIN_SEGMENT_DISTANCE_SQ + ) + continue; + positions.push(position); + } + current = positions.length >= 2 ? positions : []; + ensure().setPositions(current); + line.show(visible); + }, + setVisible(next) { + visible = next !== false; + line?.show(visible); + }, + clear() { + current = []; + line?.setPositions(current); + }, + destroy() { + line?.destroy(); + line = null; + }, + }; + }, + createHead({ id, show, positions, color, width = 2.5 }) { + const [r, g, b] = rgbaFromCss(color); + const line = engine.lines.createLine({ + id: `${id}`, + widthPx: width, + color: [r, g, b, 0.9], + depthFailAlpha: 0.5, + arc: 'geodesic', + pickId: id, + }); + let shown = show !== false; + line.show(shown); + const remove = engine.frames.preRender.addEventListener(() => { + if (!shown) return; + line.setPositions(positions()); + }); + return { + id, + get show() { + return shown; + }, + set show(value) { + shown = value !== false; + line.show(shown); + }, + remove() { + remove(); + line.destroy(); + }, + }; + }, + removeHead(head) { + head?.remove(); + }, + }; + + return { + kind: 'navara', + engine, + view, + fleet, + models, + tracked, + follow, + trails, + /** The atlas is drawn (tests, QA). */ + whenReady: () => atlasReady, + /** A vertical surface probe skips the layer's own mover sets (by id). */ + surfaceExclusions: () => + [...spriteBanks.values(), ...modelBanks.values(), trackedBank] + .map((bank) => bank?.set?.id) + .filter(Boolean), + destroy() { + if (destroyed) return; + flush(); + destroyed = true; + for (const remove of removers.splice(0)) remove?.(); + fleet.detach(); + for (const bank of modelBanks.values()) bank.destroy(); + modelBanks.clear(); + trackedBank?.destroy(); + trackedBank = null; + trackedSprites.clear(); + }, + }; +} + +// The glyph atlas helpers, re-exported for tests. +export { aircraftIconAtlasLayout as aircraftAtlasLayout, aircraftIconCells }; diff --git a/src/layers/aircraft/render/navara.test.mjs b/src/layers/aircraft/render/navara.test.mjs new file mode 100644 index 000000000..6c186caa6 --- /dev/null +++ b/src/layers/aircraft/render/navara.test.mjs @@ -0,0 +1,291 @@ +// The aircraft renderer on the engine contract (Navara's), over the fake +// engine: the contract's members, sprite banks with the glyph atlas, handle +// writes committed once per frame, the tracked glyph's per-frame callbacks, +// the follow and its readout label, model poses and the trail lines. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import * as geo from '../../../geo/index.js'; +import { createFakeEngine } from '../../../globe/testing/fakeEngine.js'; +import { aircraftIcon, TRACKED_ICON_PX } from '../../../data/aircraftIcons.js'; +import { CYAN, WHITE, nearFarScale, rgbaFromCss } from '../../shared/color.js'; +import { missingAircraftRendererMembers } from './contract.js'; +import { + aircraftAtlasLayout, + aircraftIconCells, + createAircraftNavaraRenderer, +} from './navara.js'; + +const layout = aircraftAtlasLayout(); +const drawAtlas = async () => ({ + canvas: { width: layout.width, height: layout.height }, + cells: layout.cells, +}); + +async function setup() { + const engine = createFakeEngine(); + const renderer = createAircraftNavaraRenderer({ + engine, + layerId: 'flights', + order: 8, + headingOffsetDeg: 180, + drawAtlas, + }); + await renderer.whenReady(); + return { engine, renderer }; +} + +const setsOf = (engine, prefix) => + engine.movers.list().filter(({ id }) => id.startsWith(prefix)); + +test('the Navara aircraft renderer has every member of the contract', async () => { + const { engine, renderer } = await setup(); + assert.deepEqual(missingAircraftRendererMembers(renderer), []); + assert.equal(renderer.kind, 'navara'); + renderer.destroy(); + engine.destroy(); +}); + +test('the atlas has every glyph at the fleet and tracked sizes', () => { + const cells = aircraftIconCells(); + for (const key of cells.values()) + assert.ok(layout.cells[key], `${key} in the atlas`); + for (const [, [u0, v0, u1, v1]] of Object.entries(layout.cells)) + assert.ok(u0 >= 0 && v0 >= 0 && u1 <= 1 && v1 <= 1 && u1 > u0 && v1 > v0); + assert.equal( + cells.get(aircraftIcon('quadjet', TRACKED_ICON_PX)), + `quadjet@${TRACKED_ICON_PX}`, + ); +}); + +test('fleet sprites write the mover store, once per frame', async () => { + const { engine, renderer } = await setup(); + const position = geo.cartesian3.fromDegrees(-97.7, 30.2, 3000); + const sprite = renderer.fleet.add({ + position, + image: aircraftIcon('helicopter'), + width: 20, + height: 20, + scale: 1.5, + rotation: 0.3, + color: rgbaFromCss('#FFB800'), + scaleByDistance: nearFarScale(1000, 3.0, 8000000, 0.5), + disableDepthTestDistance: Number.POSITIVE_INFINITY, + id: 'abc123', + show: true, + }); + await Promise.resolve(); + const [listed] = setsOf(engine, 'flights-sprites'); + const set = engine.fake.moverSet(listed.id); + assert.equal(set.options.depthTest, 'always'); + assert.deepEqual(set.options.sprite.scaleByDistance, [1000, 3, 8000000, 0.5]); + assert.equal(set.options.sprite.sizePx, 20); + assert.equal(set.options.order, 8); + const { store } = set; + assert.equal(store.count, 1); + assert.equal(store.positions[0], position.x); + assert.equal(store.pickIds[0], 'abc123'); + assert.equal(store.cellKeys[store.cells[0]], 'helicopter@64'); + assert.ok(Math.abs(store.scales[0] - 1.5) < 1e-6); + assert.ok(Math.abs(store.rotations[0] - 0.3) < 1e-6); + assert.ok(Math.abs(store.colors[1] - 184 / 255) < 1e-6); + // Writes inside a frame listener commit at its end, once. + const before = set.commits; + const remove = renderer.view.on('preRender', () => { + sprite.color = WHITE.withAlpha(0.45); + sprite.scale = 2; + sprite.width = 24; + sprite.image = aircraftIcon('helicopter', TRACKED_ICON_PX); + sprite.show = false; + }); + engine.fake.frame(); + remove(); + assert.equal(engine.fake.moverSet(listed.id).commits, before + 1); + assert.ok(Math.abs(store.colors[3] - 0.45) < 1e-6); + assert.ok(Math.abs(store.scales[0] - 2.4) < 1e-6, 'scale × width/20'); + assert.equal(store.cellKeys[store.cells[0]], `helicopter@${TRACKED_ICON_PX}`); + assert.equal(store.shows[0], 0); + assert.equal(sprite.color.alpha, 0.45); + // Removal moves the last sprite into the hole. + const other = renderer.fleet.add({ + position, + image: aircraftIcon('light'), + id: 'def456', + scaleByDistance: nearFarScale(1000, 3.0, 8000000, 0.5), + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }); + renderer.fleet.remove(sprite); + await Promise.resolve(); + assert.equal(store.count, 1); + assert.equal(store.pickIds[0], 'def456'); + assert.equal(other.gevIndex, 0); + renderer.fleet.setVisible(false); + await Promise.resolve(); + assert.equal(store.count, 0); + assert.equal(renderer.fleet.visible(), false); + renderer.destroy(); + assert.equal(setsOf(engine, 'flights').length, 0, 'no set left behind'); + engine.destroy(); +}); + +test('the bank grows past its capacity without losing sprites', async () => { + const { engine, renderer } = await setup(); + const sprites = []; + for (let index = 0; index < 1500; index++) + sprites.push( + renderer.fleet.add({ + position: geo.cartesian3.fromDegrees(index * 0.01, 0, 1000), + image: aircraftIcon('airliner'), + id: `a${index}`, + scaleByDistance: nearFarScale(1000, 3.0, 8000000, 0.5), + }), + ); + await Promise.resolve(); + const sets = setsOf(engine, 'flights-sprites'); + assert.equal(sets.length, 1); + assert.equal(sets[0].count, 1500); + assert.ok(sets[0].capacity >= 1500); + const { store } = engine.fake.moverSet(sets[0].id); + assert.equal(store.pickIds[1499], 'a1499'); + renderer.destroy(); + engine.destroy(); +}); + +test('the tracked glyph reads its callbacks every frame; follow carries its readout label', async () => { + const { engine, renderer } = await setup(); + let position = geo.cartesian3.fromDegrees(-97.7, 30.2, 3000); + let color = CYAN; + const handle = renderer.tracked.create({ + position: () => position, + image: aircraftIcon('airliner', TRACKED_ICON_PX), + width: 28, + height: 28, + scale: 1, + color: () => color, + scaleByDistance: nearFarScale(1000, 3.0, 8000000, 0.5), + disableDepthTestDistance: Number.POSITIVE_INFINITY, + rotation: () => 0.5, + }); + handle.gevTrackedId = 'flights:abc123'; + handle.gevLabelModel = { title: 'DAL123' }; + handle.gevDisplayPosition = () => position; + renderer.tracked.setViewFrom(handle, { x: 0, y: -2400, z: 1650 }); + engine.fake.frame(); + const [listed] = setsOf(engine, 'flights-tracked'); + const { store } = engine.fake.moverSet(listed.id); + assert.equal(store.pickIds[0], handle.id); + assert.ok(Math.abs(store.rotations[0] - 0.5) < 1e-6); + position = geo.cartesian3.fromDegrees(-97.6, 30.2, 3000); + color = CYAN.withAlpha(0); + engine.fake.frame(); + assert.equal( + store.positions[0], + position.x, + 'the position callback, per frame', + ); + assert.equal(store.colors[3], 0, 'the colour callback, per frame'); + + const stop = renderer.follow.start(handle); + assert.equal(typeof stop, 'function'); + const following = engine.camera.following(); + assert.equal(following, handle.gevFollowHandle); + assert.equal(following.label.id, 'flights:abc123'); + assert.equal(following.label.model(), handle.gevLabelModel); + assert.equal(renderer.follow.isFollowing(handle), true); + assert.equal(renderer.follow.current(), handle); + renderer.follow.release(); + assert.equal(engine.camera.following(), null); + assert.equal(renderer.follow.current(), undefined); + renderer.tracked.remove(handle); + assert.equal(renderer.tracked.isLive(handle), false); + renderer.destroy(); + engine.destroy(); +}); + +test("a model pose is the Cesium model frame, turned to Navara's glTF axes", async () => { + const { engine, renderer } = await setup(); + const model = await renderer.models.loadFleet({ + url: '/models/airplane.glb', + minimumPixelSize: 24, + scale: 1, + radiusM: 34.41, + color: WHITE, + blendAmount: 0.94, + unlit: false, + id: 'abc123', + }); + model.show = false; + renderer.models.add(model); + const position = geo.cartesian3.fromDegrees(-97.7, 30.2, 3000); + renderer.models.setPose(model, position, 75); + // modelMatrix is Cesium's (what the trail anchors read). + const expected = Cesium.Transforms.headingPitchRollToFixedFrame( + position, + new Cesium.HeadingPitchRoll(Cesium.Math.toRadians(75 + 180), 0, 0), + Cesium.Ellipsoid.WGS84, + ); + for (let index = 0; index < 16; index++) + assert.ok(Math.abs(model.modelMatrix[index] - expected[index]) < 1e-6); + // The instance orientation: modelMatrix × Cesium's Z_UP_TO_X_UP (Navara's + // loader already turned the glTF Z-up). + const corrected = Cesium.Matrix4.multiplyTransformation( + expected, + Cesium.Axis.Z_UP_TO_X_UP, + new Cesium.Matrix4(), + ); + const q = Cesium.Quaternion.fromRotationMatrix( + Cesium.Matrix4.getMatrix3(corrected, new Cesium.Matrix3()), + ); + await Promise.resolve(); + const [listed] = setsOf(engine, 'flights-models'); + const set = engine.fake.moverSet(listed.id); + assert.equal(set.options.model.minimumPixelSize, 24); + assert.deepEqual(set.options.model.colorBlend, { mode: 'mix', amount: 0.94 }); + const { store } = set; + for (const [k, value] of ['x', 'y', 'z', 'w'].entries()) + assert.ok( + Math.abs(store.orientations[k] - q[value]) < 1e-9, + `${value}: ${[...store.orientations.slice(0, 4)]} vs ${[q.x, q.y, q.z, q.w]}`, + ); + assert.equal(store.shows[0], 0, 'admitted hidden'); + model.show = true; + await Promise.resolve(); + assert.equal(store.shows[0], 1); + assert.equal(model.ready, true, 'the fake engine draws at once'); + assert.ok(model.computedScale >= model.scale); + renderer.destroy(); + engine.destroy(); +}); + +test('trails are engine lines in the trail pick namespace; the head follows each frame', async () => { + const { engine, renderer } = await setup(); + const trail = renderer.trails.create({ color: '#00d4ff', width: 2.5 }); + const a = geo.cartesian3.fromDegrees(0, 0, 1000); + const b = geo.cartesian3.fromDegrees(0.1, 0, 1000); + trail.setPositions([a, a, b]); + const lines = engine.lines.list(); + assert.equal(lines.length, 1); + const line = engine.fake.line(lines[0].id); + assert.match(lines[0].id, /^gev-trail:/); + assert.equal(line.options.pickId, lines[0].id); + assert.ok(Math.abs(line.options.color[3] - 0.85) < 1e-9); + assert.equal(line.options.arc, 'geodesic'); + let ends = [a, b]; + const head = renderer.trails.createHead({ + id: 'gev-trail:fl-head-1', + show: true, + positions: () => ends, + color: '#00d4ff', + width: 2.5, + }); + engine.fake.frame(); + assert.equal(engine.lines.list().length, 2); + head.show = false; + ends = []; + renderer.trails.removeHead(head); + trail.destroy(); + assert.equal(engine.lines.list().length, 0); + renderer.destroy(); + engine.destroy(); +}); diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js index c1ca4ec06..507fc2567 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -718,8 +718,18 @@ export function createQueries({ if (!flightState._billboards.has(id)) return false; if (parts.tracking._isExplicitTrackingOrigin(origin)) parts.tracking._cancelPendingTrackingRestore(); - if (flightState._trackedIcao === id) + if (flightState._trackedIcao === id) { + // Selected but no longer followed (a camera command took the camera: + // a search flight, the startup view; on Navara any command ends a + // follow): asking to track it again follows it again. + const entity = flightState._trackedEntity; + if (entity && !flightState._renderer?.follow.isFollowing(entity)) { + flightState._trackedCameraFrameStop?.(); + flightState._trackedCameraFrameStop = + flightState._renderer.follow.refocus(entity); + } return parts.tracking._publishTrackedSelection(id, origin); + } parts.tracking._trackFlight(id, { origin }); return true; }, diff --git a/src/layers/flights/render/navara.js b/src/layers/flights/render/navara.js new file mode 100644 index 000000000..5c9294814 --- /dev/null +++ b/src/layers/flights/render/navara.js @@ -0,0 +1,22 @@ +// The civil flights layer's renderer on the Navara engine: the shared +// aircraft renderer (src/layers/aircraft/render/navara.js) with the flights' +// sprite slot (drawn over the other contact layers, as data/spriteOrder +// orders them on Cesium) and airplane.glb's nose offset. Registered for +// 'navara' by the Navara mode after its import() (src/app/navaraMode.js). + +import { createAircraftNavaraRenderer } from '../../aircraft/render/navara.js'; +import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js'; +import { MODEL_HEADING_OFFSET_DEG } from '../policy.js'; + +/** + * Registry factory (src/app/layers/renderers.js): `(engine, options)`. + * @param {import('../../../globe/contract.js').GlobeEngine} engine + */ +export function createFlightsNavaraRenderer(engine) { + return createAircraftNavaraRenderer({ + engine, + layerId: 'flights', + order: SPRITE_LAYER_ORDER.indexOf('flights'), + headingOffsetDeg: MODEL_HEADING_OFFSET_DEG, + }); +} diff --git a/src/layers/flights/renderer.test.mjs b/src/layers/flights/renderer.test.mjs index 02051b1ab..c19115aac 100644 --- a/src/layers/flights/renderer.test.mjs +++ b/src/layers/flights/renderer.test.mjs @@ -247,3 +247,85 @@ test('the flights logic draws, selects and follows only through its renderer', a } assert.ok(renderer.calls.some(([name]) => name === 'destroy')); }); + +test('the flights logic draws and follows on the Navara renderer (fake engine)', async () => { + const { createAircraftNavaraRenderer, aircraftAtlasLayout } = + await import('../aircraft/render/navara.js'); + const layout = aircraftAtlasLayout(); + const engine = createFakeEngine(); + let renderer = null; + const layer = createApplicationFlights({ + surface: defaultSurface, + source: { + label: 'Fixture aircraft', + getSnapshot: async () => snapshotOf(['abc123', 'def456']), + }, + militaryRegistry, + renderer: { + create: ({ engine: given }) => + (renderer = createAircraftNavaraRenderer({ + engine: given, + layerId: 'flights', + order: 8, + headingOffsetDeg: 180, + drawAtlas: async () => ({ + canvas: { width: layout.width, height: layout.height }, + cells: layout.cells, + }), + })), + }, + }); + layer.init(null, { engine }); + layer.enable(); + try { + await renderer.whenReady(); + await layer.update(null); + await Promise.resolve(); + const sprites = engine.movers + .list() + .filter(({ id }) => id.startsWith('flights-sprites')); + assert.equal(sprites.length, 1); + assert.equal(sprites[0].count, 2); + const { store } = engine.fake.moverSet(sprites[0].id); + assert.deepEqual([...store.pickIds.slice(0, 2)].sort(), [ + 'abc123', + 'def456', + ]); + // A click on a contact selects it; the engine follows with its label. + engine.fake.script('pick', () => ({ id: 'def456' })); + engine.fake.script('drillPick', () => [{ id: 'def456' }]); + engine.fake.input('down', { position: { x: 10, y: 10 } }); + engine.fake.input('up', { position: { x: 10, y: 10 } }); + engine.fake.input('click', { position: { x: 10, y: 10 } }); + assert.equal(layer.getTrackedInfo()?.icao24, 'def456'); + const following = engine.camera.following(); + assert.equal(following?.label?.id, 'flights:def456'); + engine.fake.frame(); + const tracked = engine.movers + .list() + .find(({ id }) => id.startsWith('flights-tracked')); + assert.equal(tracked.count, 1); + assert.equal(layer.getAllPositions().length, 2); + // A camera command ends the engine's follow; the contact stays selected + // (as on Cesium after a search flight), and tracking it again follows. + engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(-97.7, 30.2, 50_000), + }); + assert.equal(engine.camera.following(), null); + assert.equal(layer.getTrackedInfo()?.icao24, 'def456'); + assert.equal(layer.trackById('def456', { origin: 'voice' }), true); + assert.equal(engine.camera.following()?.label?.id, 'flights:def456'); + assert.equal(layer.stopTracking(), true); + assert.equal(engine.camera.following(), null); + assert.equal( + engine.movers.list().find(({ id }) => id.startsWith('flights-tracked')) + .count, + 0, + ); + } finally { + layer.disable(); + layer.destroy(null); + } + assert.equal(engine.movers.list().length, 0, 'no mover set left behind'); + engine.destroy(); +}); diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js index 7de20f4cd..cda24e150 100644 --- a/src/layers/flights/rendering.js +++ b/src/layers/flights/rendering.js @@ -467,6 +467,9 @@ export function createRendering({ url: resolveAsset(spec.url), minimumPixelSize: MODEL_MIN_PX, scale: spec.scale, + // Bounding radius per scale unit (an engine that keeps + // minimumPixelSize on the CPU reports the drawn scale with it). + radiusM: spec.nativeRadiusM, color: flightState._irBoost ? WHITE : _modelColor(icao24), // Launch presentation keeps the code-side tint dominant for every approved // model; IR boost removes the remaining diffuse hint with flat UNLIT white. @@ -635,6 +638,7 @@ export function createRendering({ url: resolveAsset(trackedSpec.url), minimumPixelSize: TRACKED_MODEL_MIN_PX, scale: trackedSpec.scale, + radiusM: trackedSpec.nativeRadiusM, color: flightState._irBoost ? WHITE : CYAN, // The tracked aircraft uses the same dominant light tint as the fleet; // IR boost removes the remaining diffuse hint with flat UNLIT white.