mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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).
This commit is contained in:
1 parent
20e9d4a0fa
commit
b14bf7e013
10 files changed
+1530
-3
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+21
-1
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user