diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 35d1a9a14..2d2aa61b4 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1155,5 +1155,10 @@ "scripts/qa-launches.mjs", "src/geo/boundingSphere.test.mjs", "src/layers/launches/render/cesium.js", - "src/layers/launches/launchPadFrame.test.mjs" + "src/layers/launches/launchPadFrame.test.mjs", + "src/layers/launches/render/navara.js", + "src/layers/launches/render/navara.test.mjs", + "scripts/qa-launches-compare.mjs", + "src/layers/satellites/interaction.test.mjs", + "src/layers/launches/lifecycleSnapshot.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 44906e625..d4a83f7d2 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2652,6 +2652,7 @@ "src/layers/vessels/render/cesium.js", "src/layers/localAdsb/render/navara.js", "src/layers/vessels/render/navara.js", + "src/layers/launches/render/navara.js", "src/layers/satellites/render/navara.js", "src/layers/shared/render/engineView.js", "src/celestialRing/render/cesium.js", diff --git a/src/app/navaraLayers.test.mjs b/src/app/navaraLayers.test.mjs index 634ec1849..ae4f397f6 100644 --- a/src/app/navaraLayers.test.mjs +++ b/src/app/navaraLayers.test.mjs @@ -21,6 +21,7 @@ test('the Navara mode registers the aircraft layers’ and vessels’ renderers, 'flights', 'military', 'local-adsb', + 'rocket-launches', 'satellites', 'ais-live-vessels', ]) @@ -29,7 +30,14 @@ test('the Navara mode registers the aircraft layers’ and vessels’ renderers, createLayerAvailability('navara') .available() .map(({ id }) => id), - ['flights', 'military', 'local-adsb', 'satellites', 'ais-live-vessels'], + [ + 'flights', + 'military', + 'local-adsb', + 'satellites', + 'rocket-launches', + 'ais-live-vessels', + ], ); assert.equal(layerAvailableOn('satellites', 'navara'), true); }); diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 2c296e598..e1ad842aa 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -18,6 +18,9 @@ import { } from '../renderGovernor.js'; import { initGevVoiceCommands } from '../voice/gevRealtime.js'; +// Native mesh capability stays at the composition boundary. +const navaraViews = new WeakMap(); + /** The Navara globe module, loaded on demand. */ const loadNavaraGlobeModule = async () => (await import('../globe/navara/index.js')).createNavaraGlobe; @@ -55,6 +58,15 @@ const SHELL_CREDIT_KEYS = new Set([ */ export async function registerNavaraLayerRenderers() { const loaders = { + 'rocket-launches': async () => { + const { createLaunchesNavaraRenderer } = + await import('../layers/launches/render/navara.js'); + return (engine, options) => + createLaunchesNavaraRenderer(engine, { + ...options, + ...navaraViews.get(engine), + }); + }, satellites: async () => (await import('../layers/satellites/render/navara.js')) .createSatellitesNavaraRenderer, @@ -112,6 +124,8 @@ export async function createNavaraScene({ }); defer(() => globe.destroy()); const { engine } = globe; + navaraViews.set(engine, { view: globe.view, MeshDesc: globe.MeshDesc }); + defer(() => navaraViews.delete(engine)); // The layers drawn on Navara (their render/navara.js), before the catalog // asks the registry which layers this engine shows. await registerNavaraLayerRenderers(); diff --git a/src/bench/launchFixture.js b/src/bench/launchFixture.js index 0f38460c0..38f91bc56 100644 --- a/src/bench/launchFixture.js +++ b/src/bench/launchFixture.js @@ -90,7 +90,35 @@ export function launchFixture() { ] : [], }, - trajectory: [], + trajectory: + i === 2 + ? [ + { + latitude: lat, + longitude: lon, + altitude: 0, + stage: 'Stage 1', + }, + { + latitude: lat + 0.2, + longitude: lon + 0.5, + altitude: 40000, + stage: 'Stage 1', + }, + { + latitude: lat + 0.4, + longitude: lon + 1, + altitude: 80000, + stage: 'Stage 2', + }, + { + latitude: lat + 0.8, + longitude: lon + 2, + altitude: 180000, + stage: 'Stage 2', + }, + ] + : [], }), ), }; diff --git a/src/globe/navara/cameraMath.js b/src/globe/navara/cameraMath.js index 07d68f61a..71d6bc367 100644 --- a/src/globe/navara/cameraMath.js +++ b/src/globe/navara/cameraMath.js @@ -85,12 +85,17 @@ export function orientationVectors(position, heading, pitch, roll) { * Cesium's camera heading/pitch/roll getters: heading and roll in [0, 2π); * within 1e-3 of straight up or down roll is 0 and heading follows `up`. */ -export function headingPitchRoll(position, direction, up) { +export function headingPitchRoll( + position, + direction, + up, + verticalEpsilon = 1e-3, +) { const axes = localAxes(position); const d = toLocal(axes, direction); const u = toLocal(axes, up); const r = toLocal(axes, cross(direction, up)); - const vertical = Math.abs(Math.abs(d.u) - 1) <= 1e-3; + const vertical = Math.abs(Math.abs(d.u) - 1) <= verticalEpsilon; const raw = vertical ? Math.atan2(u.n, u.e) - math.PI_OVER_TWO : Math.atan2(d.n, d.e) - math.PI_OVER_TWO; @@ -109,7 +114,12 @@ export function headingPitchRoll(position, direction, up) { */ export function navaraPose(position, direction, up) { const place = cartographic.fromCartesian(position, WGS84); - const angles = headingPitchRoll(position, direction, up); + // Navara orients its camera in the surface ENU frame at lng/lat. Cesium's + // Cartesian ENU normal at a high-altitude eye differs from that frame. + const surface = cartesian3.fromRadians(place.longitude, place.latitude, 0); + // Keep near-nadir tilt when serializing; the public pose keeps Cesium's + // wider singularity tolerance, which would discard it here. + const angles = headingPitchRoll(surface, direction, up, 1e-14); let roll = math.toDegrees(angles.roll); if (roll > 180) roll -= 360; return { diff --git a/src/globe/navara/cameraMath.test.mjs b/src/globe/navara/cameraMath.test.mjs index 8fddd9090..6f78874fe 100644 --- a/src/globe/navara/cameraMath.test.mjs +++ b/src/globe/navara/cameraMath.test.mjs @@ -116,12 +116,11 @@ test('navaraPose gives Navara setCamera degrees in the same convention', () => { near(pose.lng, -70, 1e-9, 'lng'); near(pose.lat, 45, 1e-9, 'lat'); near(pose.height, 25000, 1e-6, 'height'); - near(pose.heading, math.toDegrees(1.2), 1e-9, 'heading (deg)'); - near(pose.pitch, math.toDegrees(-0.6), 1e-9, 'pitch (deg)'); - near(pose.roll, math.toDegrees(-0.05), 1e-9, 'roll (deg) in (−180, 180]'); - // Back through the same convention, the basis is unchanged. + assert.ok(pose.heading >= 0 && pose.heading <= 360); + assert.ok(pose.roll > -180 && pose.roll <= 180); + // Navara uses the surface frame at lng/lat to reconstruct the basis. const back = orientationVectors( - camera.position, + cartesian3.fromDegrees(pose.lng, pose.lat, 0), math.toRadians(pose.heading), math.toRadians(pose.pitch), math.toRadians(pose.roll), @@ -358,3 +357,24 @@ test("horizonVisible agrees with Cesium's EllipsoidalOccluder, also at the limb" assert.equal(horizonVisible(eye, buried), false); assert.equal(horizonVisible(eye, lifted), true); }); + +test('Navara high-orbit angles reconstruct the exact requested world basis', () => { + for (const height of [0, 25000, 420000, 2e7, 4.5e7]) + for (const lat of [-70, -45, 15, 45, 70]) { + const camera = cameraAt(-70, lat, height, 1.2, -1.4, -0.05); + const pose = navaraPose(camera.position, camera.direction, camera.up); + const back = orientationVectors( + cartesian3.fromDegrees(pose.lng, pose.lat, 0), + math.toRadians(pose.heading), + math.toRadians(pose.pitch), + math.toRadians(pose.roll), + ); + near( + cartesian3.distance(back.direction, camera.direction), + 0, + 1e-12, + 'high orbit direction', + ); + near(cartesian3.distance(back.up, camera.up), 0, 1e-12, 'high orbit up'); + } +}); diff --git a/src/globe/navara/cameraModel.test.mjs b/src/globe/navara/cameraModel.test.mjs index b45604846..7386ab21d 100644 --- a/src/globe/navara/cameraModel.test.mjs +++ b/src/globe/navara/cameraModel.test.mjs @@ -51,7 +51,7 @@ function createFakePort() { port.drawn = { position, ...orientationVectors( - position, + cartesian3.fromDegrees(pose.lng, pose.lat, 0), math.toRadians(pose.heading), math.toRadians(pitch), math.toRadians(pose.roll), @@ -127,8 +127,14 @@ test('setView updates the pose at once and pushes the Navara pose in degrees', ( near(pose.roll, 0.05, 1e-12, 'roll'); const pushed = port.applied.at(-1); near(pushed.lng, -70, 1e-9, 'lng'); - near(pushed.heading, math.toDegrees(1.2), 1e-9, 'heading (deg)'); - near(pushed.roll, math.toDegrees(0.05), 1e-9, 'roll (deg)'); + port.update(); + near( + cartesian3.distance(port.drawn.direction, pose.direction), + 0, + 1e-12, + 'drawn direction', + ); + near(cartesian3.distance(port.drawn.up, pose.up), 0, 1e-12, 'drawn up'); assert.ok(port.frames > 0, 'no frame was requested'); }); diff --git a/src/globe/navara/createNavaraEngine.test.mjs b/src/globe/navara/createNavaraEngine.test.mjs index 219adc231..2dc649b60 100644 --- a/src/globe/navara/createNavaraEngine.test.mjs +++ b/src/globe/navara/createNavaraEngine.test.mjs @@ -214,7 +214,20 @@ test("the camera starts from Navara's drawn camera and pushes poses with setCame engine.camera.setView({ destination: cartesian3.fromDegrees(20, 10, 1e6) }); const [, pose] = view.calls.findLast(([name]) => name === 'setCamera'); near(pose.lng, 20, 1e-9, 'lng'); - near(pose.pitch, -90, 1e-9, 'pitch'); + const drawn = orientationVectors( + cartesian3.fromDegrees(pose.lng, pose.lat, 0), + math.toRadians(pose.heading), + math.toRadians(pose.pitch), + math.toRadians(pose.roll), + ); + const desired = engine.camera.pose(); + near( + cartesian3.distance(drawn.direction, desired.direction), + 0, + 1e-12, + 'world direction', + ); + near(cartesian3.distance(drawn.up, desired.up), 0, 1e-12, 'world up'); }); test("Navara's fov follows Cesium's vertical fov, also after a resize", () => { diff --git a/src/globe/navara/index.js b/src/globe/navara/index.js index c26e48001..f47f6ff2d 100644 --- a/src/globe/navara/index.js +++ b/src/globe/navara/index.js @@ -2,7 +2,7 @@ // the product way (viewOptions.js) and the GlobeEngine on it. Load it with a // dynamic import() so Navara stays out of the Cesium-mode bundle. -import ThreeView from '@navaramap/three'; +import ThreeView, { MeshDesc } from '@navaramap/three'; import * as geo from '../../geo/index.js'; import { DefaultPlugin } from '@navaramap/three-default-plugin'; import { createNavaraEngine } from './createNavaraEngine.js'; @@ -179,6 +179,7 @@ export async function createNavaraGlobe({ return { view, engine, + MeshDesc, destroy() { if (destroyed) return; destroyed = true; diff --git a/src/layers/launches/index.js b/src/layers/launches/index.js index 4286325d5..feb7dbb00 100644 --- a/src/layers/launches/index.js +++ b/src/layers/launches/index.js @@ -132,6 +132,7 @@ export function createRocketLaunchesLayer({ services, source }) { } : null, padVisible: state._launchPadZonePrimitive?.show === true, + padHeightM: state._launchPadZonePrimitive?.heightM ?? null, }; }, satelliteParamsForSpaceMissions: diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index f33560fac..77d66e102 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -80,6 +80,8 @@ export function createLifecycle({ layerState._updateDirty = false; if (layerState._dataSource) layerState._dataSource.show = true; parts.orbitRendering.syncMissionOrbitPrimitiveVisibility(); + // Snapshot the existing follow before the mission flight releases it. + await parts.ingestion.captureSatelliteDependency(); parts.selection.focusFullGlobe(layerState._viewer); document.getElementById('cockpit-context')?.setAttribute('hidden', ''); if (layerState._selectedLaunchId) @@ -88,7 +90,6 @@ export function createLifecycle({ layerState._explicitSelection, ); else parts.selection.setSelectedMission(null, false); - await parts.ingestion.captureSatelliteDependency(); }, async disable() { diff --git a/src/layers/launches/lifecycleSnapshot.test.mjs b/src/layers/launches/lifecycleSnapshot.test.mjs new file mode 100644 index 000000000..ec7fdbda8 --- /dev/null +++ b/src/layers/launches/lifecycleSnapshot.test.mjs @@ -0,0 +1,39 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createLifecycle } from './lifecycle.js'; +test('missions preserve the satellite target before their focus flight releases it', async () => { + const previous = globalThis.document; + globalThis.document = { getElementById: () => null }; + try { + let target = 25544, + snapshot = null; + const lifecycle = createLifecycle({ + state: { _dataSource: { show: false }, _viewer: {} }, + services: { render: {} }, + parts: { + orbitRendering: { syncMissionOrbitPrimitiveVisibility() {} }, + selection: { + focusFullGlobe() { + target = null; + }, + setSelectedMission() {}, + }, + ingestion: { + async captureSatelliteDependency() { + snapshot = target; + }, + }, + }, + }); + await lifecycle.methods._enableBody(); + assert.equal(target, null); + assert.equal( + snapshot, + 25544, + 'mission camera must not erase the restoration target', + ); + } finally { + if (previous === undefined) delete globalThis.document; + else globalThis.document = previous; + } +}); diff --git a/src/layers/launches/orbitRendering.js b/src/layers/launches/orbitRendering.js index 10d5191b1..6d1915ed0 100644 --- a/src/layers/launches/orbitRendering.js +++ b/src/layers/launches/orbitRendering.js @@ -69,6 +69,7 @@ export function createOrbitRendering({ new Date(appClock.now()), collection.modelMatrix, ); + getRenderer().commitOrbit(collection); layerState._missionOrbitPrimitives.set(launch.id, { primitive: collection, basePositions: orbitPath, diff --git a/src/layers/launches/render/navara.js b/src/layers/launches/render/navara.js new file mode 100644 index 000000000..48a1421fc --- /dev/null +++ b/src/layers/launches/render/navara.js @@ -0,0 +1,565 @@ +// Mission points, paths and the documented flat pad disc on Navara. +import * as geo from '../../../geo/index.js'; +import { + Group, + Mesh, + CircleGeometry, + RingGeometry, + MeshBasicMaterial, + DoubleSide, +} from 'three'; +import { createMoverBank } from '../../aircraft/render/navara.js'; +import { createEngineLayerView } from '../../shared/render/engineView.js'; +import { registerViewerEngine } from '../../../globe/cesium/engineFromViewer.js'; +import { selectionDispatcherFor } from '../../../data/selectionDispatcher.js'; +import { LAUNCH_PAD_ZONE_RADIUS_M } from '../policy.js'; + +const PAD_KIND = 'gevLaunchPad'; +const registered = new WeakSet(); +const read = (v, time) => + typeof v?.getValue === 'function' ? v.getValue(time) : v; +const property = (v) => + typeof v?.getValue === 'function' ? v : { getValue: () => v }; +const rgba = (c) => (c ? [c.red, c.green, c.blue, c.alpha] : [1, 1, 1, 1]); +export function createLaunchesNavaraRenderer( + engine, + { view: nativeView, MeshDesc } = {}, +) { + let destroyed = false, + sequence = 0, + inFrame = false, + queued = false, + bankVisibility = false; + const sources = new Set(), + banks = new Map(), + dirty = new Set(), + primitives = new Set(); + const flush = () => { + queued = false; + for (const bank of dirty) bank.commit(); + dirty.clear(); + }; + const touch = (bank) => { + dirty.add(bank); + if (!inFrame && !queued) { + queued = true; + queueMicrotask(flush); + } + }; + const fields = { position: null, color: null, show: true }; + const pointBank = (record, graphic, sprite) => { + const time = engine.clock.now(), + size = + read(sprite ? graphic.width : graphic.pixelSize, time) ?? + (sprite ? 24 : 5); + const image = sprite ? read(graphic.image, time) : null; + const outline = rgba( + read(graphic.outlineColor, time) ?? { + red: 0, + green: 0, + blue: 0, + alpha: 1, + }, + ); + const outlineWidth = read(graphic.outlineWidth, time) ?? 0; + const key = [ + sprite ? 'sprite' : 'point', + size, + outlineWidth, + ...outline, + image ?? '', + ].join(':'); + let bank = banks.get(key); + if (!bank) { + const id = ++sequence; + bank = createMoverBank(engine, { + options: (capacity, generation) => ({ + id: `launches-${id}-${generation}`, + kind: sprite ? 'sprite' : 'point', + capacity, + pickable: true, + depthTest: 'always', + ...(sprite + ? { + sprite: { + sizePx: size, + atlas: { + ...(typeof image === 'string' + ? { url: image } + : { canvas: image }), + cells: { reticle: [0, 0, 1, 1] }, + }, + }, + } + : { + point: { + sizePx: size, + outlinePx: outlineWidth, + outlineColor: outline, + }, + }), + }), + write: (point, set, index) => + set.set(index, { + position: point.position, + color: point.color, + show: point.show, + pickId: point.record.entity.id, + ...(sprite ? { cell: 'reticle' } : {}), + }), + onCommit: touch, + }); + banks.set(key, bank); + bank.setVisible(bankVisibility); + } + return bank; + }; + const syncPoint = (point, graphic, record) => { + const time = engine.clock.now(); + if ( + !record.source.show || + !record.entity.show || + read(graphic.show, time) === false + ) { + if (point.show) { + point.show = false; + fields.position = point.position; + fields.color = point.color; + fields.show = false; + point.gevBank.update(point, fields); + } + return; + } + const p = read(record.entity.position, time), + c = read(graphic.color, time); + const shown = Boolean(p); + const red = c?.red ?? 1, + green = c?.green ?? 1, + blue = c?.blue ?? 1, + alpha = c?.alpha ?? 1; + if ( + p && + (point.position.x !== p.x || + point.position.y !== p.y || + point.position.z !== p.z || + point.show !== shown || + red !== point.color[0] || + green !== point.color[1] || + blue !== point.color[2] || + alpha !== point.color[3]) + ) { + geo.cartesian3.clone(p, point.position); + point.color[0] = red; + point.color[1] = green; + point.color[2] = blue; + point.color[3] = alpha; + point.show = shown; + fields.position = point.position; + fields.color = point.color; + fields.show = shown; + point.gevBank.update(point, fields); + } else if (point.show !== shown) { + point.show = shown; + fields.position = point.position; + fields.color = point.color; + fields.show = shown; + point.gevBank.update(point, fields); + } + }; + const syncEntity = (record) => { + for (const point of record.points) syncPoint(point, point.graphic, record); + if (record.line) { + const show = Boolean( + record.source.show && + record.entity.show && + read(record.entity.polyline.show, engine.clock.now()) !== false, + ); + if (show !== record.lineVisible) { + record.lineVisible = show; + record.line.show(show); + } + } + }; + const sync = () => { + let visible = false; + for (const source of sources) + if (source.show) { + visible = true; + break; + } + if (visible !== bankVisibility) { + bankVisibility = visible; + for (const bank of banks.values()) bank.setVisible(visible); + } + for (const source of sources) + for (const record of source.records.values()) syncEntity(record); + }; + const frame = (run) => { + inFrame = true; + try { + return run(); + } finally { + sync(); + inFrame = false; + flush(); + } + }; + const adapter = createEngineLayerView(engine, { + layerId: 'rocket-launches', + wrap: frame, + isDestroyed: () => destroyed, + }); + const view = adapter.view; + view.container = engine.host.container; + view.scene.canvas = engine.host.canvas; + view.scene.preUpdate = engine.frames.preUpdate; + view.scene.preRender = engine.frames.preRender; + view.scene.postRender = engine.frames.postRender; + view.scene.requestRender = () => engine.render.request('rocket-launches'); + Object.defineProperty(view, 'trackedEntity', { + get: () => engine.camera.following(), + set: (value) => { + if (!value) engine.camera.following()?.stop(); + }, + }); + view.camera.cancelFlight = () => engine.camera.cancelFlight(); + view.camera.lookAt = (target, offset) => engine.camera.lookAt(target, offset); + view.camera.lookAtTransform = (matrix, offset) => + engine.camera.lookAtTransform(matrix, offset); + view.camera.setView = (options) => engine.camera.setView(options); + view.camera.flyTo = ({ easingFunction, ...options }) => + engine.camera.flyTo({ ...options, easing: 'cubicInOut' }); + view.camera.flyToBoundingSphere = ( + sphere, + { offset, easingFunction, ...options } = {}, + ) => + engine.camera.flyToSphere(sphere, { + ...options, + ...offset, + easing: 'cubicInOut', + }); + const unregister = registerViewerEngine(view, engine); + const clearSource = (source) => { + for (const record of source.records.values()) { + for (const point of record.points) point.gevBank?.remove(point); + record.line?.destroy(); + } + source.records.clear(); + source.entitiesArray.length = 0; + }; + const addEntity = (source, spec) => { + const record = { + source, + entity: null, + points: [], + line: null, + lineVisible: null, + }; + let shown = spec.show !== false; + const entity = { + ...spec, + position: spec.position ? property(spec.position) : undefined, + }; + record.entity = entity; + Object.defineProperty(entity, 'show', { + get: () => shown, + set: (value) => { + shown = Boolean(value); + syncEntity(record); + }, + }); + for (const name of ['point', 'billboard', 'polyline']) + if (spec[name]) { + const values = Object.fromEntries( + Object.entries(spec[name]).map(([k, v]) => [k, property(v)]), + ); + entity[name] = new Proxy(values, { + set: (target, key, value) => { + target[key] = property(value); + syncEntity(record); + return true; + }, + }); + } + source.records.set(entity.id, record); + source.entitiesArray.push(entity); + for (const name of ['point', 'billboard']) + if (entity[name]) { + const graphic = entity[name], + p = read(entity.position, engine.clock.now()); + const point = { + record, + graphic, + position: geo.cartesian3.clone(p ?? geo.cartesian3.ZERO), + color: new Float64Array( + rgba(read(graphic.color, engine.clock.now())), + ), + show: false, + gevBank: null, + gevIndex: -1, + }; + record.points.push(point); + pointBank(record, graphic, name === 'billboard').add(point); + } + if (spec.polyline) { + const material = read(entity.polyline.material, engine.clock.now()); + const color = material?.kind ? material.color : material; + const dash = + material?.kind === 'tactical' + ? 'tactical' + : material?.kind === 'dash' + ? { + lengthPx: + material.dashLength / + (material.dashPattern === 0xaaaa ? 16 : 4), + gapPx: + material.dashLength / + (material.dashPattern === 0xaaaa ? 16 : 4), + } + : null; + record.line = engine.lines.createLine({ + id: `launch-entity-${++sequence}`, + pickId: entity.id, + widthPx: read(entity.polyline.width, engine.clock.now()) ?? 2, + color: rgba(color), + dash, + arc: 'none', + }); + record.line.setPositions( + read(entity.polyline.positions, engine.clock.now()), + ); + } + syncEntity(record); + return entity; + }; + const createDataSource = () => { + let visible = false; + const source = { + records: new Map(), + entitiesArray: [], + get show() { + return visible; + }, + set show(value) { + visible = Boolean(value); + sync(); + }, + entities: {}, + }; + Object.defineProperty(source.entities, 'values', { + get: () => source.entitiesArray, + }); + source.entities.getById = (id) => source.records.get(id)?.entity; + source.entities.removeAll = () => clearSource(source); + sources.add(source); + return source; + }; + const createPad = (launch) => { + const group = new Group(); + group.matrixAutoUpdate = false; + group.frustumCulled = false; + const fill = new Mesh( + new CircleGeometry(LAUNCH_PAD_ZONE_RADIUS_M, 96), + new MeshBasicMaterial({ + color: 0x22e6e6, + transparent: true, + opacity: 0.105, + side: DoubleSide, + depthWrite: false, + polygonOffset: true, + polygonOffsetFactor: -1, + polygonOffsetUnits: -4, + }), + ); + const rim = new Mesh( + new RingGeometry( + LAUNCH_PAD_ZONE_RADIUS_M * 0.952, + LAUNCH_PAD_ZONE_RADIUS_M * 0.985, + 96, + ), + new MeshBasicMaterial({ + color: 0x22e6e6, + transparent: true, + opacity: 0.687, + side: DoubleSide, + depthWrite: false, + polygonOffset: true, + polygonOffsetFactor: -1, + polygonOffsetUnits: -4, + }), + ); + fill.frustumCulled = false; + rim.frustumCulled = false; + rim.renderOrder = 1; + group.add(fill, rim); + let padHeightM = 0; + let dead = false, + freed = false; + const dispose = () => { + if (freed) return; + freed = true; + for (const mesh of [fill, rim]) { + mesh.geometry.dispose(); + mesh.material.dispose(); + } + }; + const place = (height) => { + if (dead) return; + padHeightM = Number.isFinite(height) ? height : 0; + group.matrix.fromArray( + geo.transforms.eastNorthUpToFixedFrame( + geo.cartesian3.fromDegrees( + launch.lon, + launch.lat, + Number.isFinite(height) ? height : 0, + ), + geo.ellipsoid.WGS84, + ), + ); + group.matrixWorldNeedsUpdate = true; + engine.render.request('launch-pad'); + }; + // W1 parity decision: a flat ellipsoid disc, not terrain classification. + // A coarse tile height sampled during the focus flight can be kilometres off. + place(0); + let handle; + if (nativeView) { + if (!registered.has(nativeView)) { + registered.add(nativeView); + nativeView.registerMesh( + PAD_KIND, + class extends MeshDesc { + constructor(view, ctx, config) { + super(view, ctx, config); + this.pad = config[PAD_KIND]; + } + createMesh() { + return this.pad.group; + } + onDestroy() { + this.pad.dispose(); + super.onDestroy(); + } + }, + ); + } + handle = nativeView.addMesh({ [PAD_KIND]: { group, dispose } }); + } + const pad = { + get heightM() { + return padHeightM; + }, + get show() { + return group.visible; + }, + set show(value) { + if (group.visible === Boolean(value)) return; + group.visible = Boolean(value); + engine.render.request('launch-pad'); + }, + destroy() { + if (dead) return; + dead = true; + handle?.delete(); + dispose(); + primitives.delete(pad); + engine.render.request('launch-pad'); + }, + }; + primitives.add(pad); + return pad; + }; + const listenClick = (source, listener, { enabled = () => true } = {}) => { + const remove = selectionDispatcherFor(engine).register('rocket-launches', { + owns: (id) => source.records.has(id), + onClick: (event) => { + if (!enabled()) return; + const entity = [event.picked, ...event.drilled] + .map((p) => source.entities.getById(p?.id)) + .find(Boolean); + listener(entity); + }, + }); + return { destroy: remove }; + }; + return { + kind: 'navara', + engine, + view, + time: (epoch) => epoch, + occluder: () => ({ + cameraPosition: null, + isPointVisible: (p) => engine.projector.horizonVisible(p), + }), + callback: (fn) => ({ getValue: fn }), + range: (heading, pitch, range) => ({ heading, pitch, range }), + easing: undefined, + createDataSource, + addEntity, + removeDataSource(source) { + clearSource(source); + sources.delete(source); + sync(); + }, + dash: (options) => ({ kind: 'dash', ...options }), + patternMaterial: (color) => ({ kind: 'tactical', color }), + createOrbit({ positions, width, color }) { + const line = engine.lines.createLine({ + id: `launch-orbit-${++sequence}`, + widthPx: width, + color: rgba(color), + dash: 'tactical', + arc: 'none', + }); + line.setPositions(positions); + let visible = true; + const primitive = { + modelMatrix: geo.matrix4.clone(geo.matrix4.IDENTITY), + get show() { + return visible; + }, + set show(value) { + if (visible === Boolean(value)) return; + visible = Boolean(value); + line.show(visible); + }, + destroy() { + line.destroy(); + primitives.delete(primitive); + }, + line, + }; + primitives.add(primitive); + return primitive; + }, + commitOrbit: (primitive) => + primitive.line.setModelMatrix(primitive.modelMatrix), + createPad, + removePrimitive: (primitive) => primitive.destroy(), + project: (p, r) => engine.projector.worldToWindow(p, r), + on: (event, listener) => view.on(event, listener), + listenClick, + listenMove: (source, listener, { enabled = () => true } = {}) => ({ + destroy: engine.input.on('move', (event) => { + if (enabled()) + listener( + engine.picker + .drillPick(event.endPosition, 12) + .map((p) => source.entities.getById(p.id)) + .find(Boolean), + ); + }), + }), + destroy() { + destroyed = true; + for (const source of sources) clearSource(source); + sources.clear(); + for (const primitive of [...primitives]) primitive.destroy(); + for (const bank of banks.values()) bank.destroy(); + banks.clear(); + dirty.clear(); + unregister(); + adapter.destroy(); + }, + }; +} diff --git a/src/layers/launches/render/navara.test.mjs b/src/layers/launches/render/navara.test.mjs new file mode 100644 index 000000000..85385636e --- /dev/null +++ b/src/layers/launches/render/navara.test.mjs @@ -0,0 +1,170 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as geo from '../../../geo/index.js'; +import { createFakeEngine } from '../../../globe/testing/fakeEngine.js'; +import { WHITE } from '../../shared/color.js'; +import { createLaunchesNavaraRenderer } from './navara.js'; + +const position = geo.cartesian3.fromDegrees(0, 0, 1000); +test('mission bank growth, callbacks and source visibility write actual mover stores', async () => { + const engine = createFakeEngine(); + const renderer = createLaunchesNavaraRenderer(engine); + const source = renderer.createDataSource(); + let sample = position; + for (let i = 0; i < 1100; i++) + renderer.addEntity(source, { + id: `rocket-launch:${i}`, + position: renderer.callback(() => sample), + point: { pixelSize: 6, color: WHITE, show: true }, + }); + const values = source.entities.values; + source.show = true; + await Promise.resolve(); + const listed = engine.movers.list(); + assert.equal(listed.length, 1); + assert.equal(listed[0].count, 1100); + const set = engine.fake.moverSet(listed[0].id); + assert.equal(set.store.pickIds[1000], 'rocket-launch:1000'); + const remove = renderer.on('preRender', () => {}); + sample = { x: 1, y: 2, z: 3 }; + engine.fake.frame(); + assert.deepEqual([...set.store.positions.slice(0, 3)], [1, 2, 3]); + assert.equal(source.entities.values, values); + source.entities.getById('rocket-launch:0').point.show = false; + await Promise.resolve(); + assert.equal(set.store.shows[0], 0); + source.show = false; + await Promise.resolve(); + assert.equal(engine.movers.list()[0].count, 0); + const commits = engine.fake.moverSet(listed[0].id).commits; + sample = { x: 7, y: 8, z: 9 }; + engine.fake.resetCalls(); + engine.fake.frames(3); + assert.equal(engine.fake.moverSet(listed[0].id).commits, commits); + assert.equal( + engine.fake.renderRequests, + 0, + 'hidden motion must not sustain rendering', + ); + source.show = true; + await Promise.resolve(); + assert.equal(engine.movers.list()[0].count, 1100); + source.entities.removeAll(); + await Promise.resolve(); + assert.equal(values.length, 0); + assert.equal(engine.movers.list()[0].count, 0); + remove(); + renderer.destroy(); + assert.equal(engine.movers.list().length, 0); + engine.destroy(); +}); + +test('mission lines retain dash styles and orbit matrices and release on removal', () => { + const engine = createFakeEngine(); + const renderer = createLaunchesNavaraRenderer(engine); + const source = renderer.createDataSource(); + source.show = true; + const entity = renderer.addEntity(source, { + id: 'rocket-transfer:x', + polyline: { + positions: [position, { x: 0, y: 6778137, z: 0 }], + width: 2, + material: renderer.dash({ + color: WHITE, + dashLength: 24, + dashPattern: 0xf0f0, + }), + }, + }); + const first = engine.lines.list()[0]; + assert.deepEqual(engine.fake.line(first.id).options.dash, { + kind: 'pixels', + lengthPx: 6, + gapPx: 6, + }); + entity.show = false; + assert.equal(engine.lines.list()[0].shown, false); + const orbit = renderer.createOrbit({ + positions: [position, { x: 0, y: 6778137, z: 0 }], + width: 3, + color: WHITE, + }); + assert.deepEqual(orbit.modelMatrix, geo.matrix4.IDENTITY); + orbit.modelMatrix[12] = 42; + renderer.commitOrbit(orbit); + assert.equal(engine.fake.line(engine.lines.list()[1].id).modelMatrix[12], 42); + renderer.removePrimitive(orbit); + renderer.removeDataSource(source); + assert.equal(engine.lines.list().length, 0); + renderer.destroy(); + engine.destroy(); +}); + +test('mission hover uses engine drillPick and obeys disabled state', () => { + const engine = createFakeEngine(); + const renderer = createLaunchesNavaraRenderer(engine); + const source = renderer.createDataSource(); + const entity = renderer.addEntity(source, { + id: 'rocket-launch:x', + position, + point: { pixelSize: 6, color: WHITE }, + }); + engine.fake.script('drillPick', [{ id: entity.id }]); + let enabled = true, + hovered = null; + const handler = renderer.listenMove( + source, + (e) => { + hovered = e; + }, + { enabled: () => enabled }, + ); + engine.fake.input('move', { endPosition: { x: 1, y: 2 } }); + assert.equal(hovered, entity); + enabled = false; + hovered = null; + engine.fake.input('move', { endPosition: { x: 1, y: 2 } }); + assert.equal(hovered, null); + handler.destroy(); + renderer.destroy(); + engine.destroy(); +}); + +test('flat pad uses a native mesh description, ellipsoid ENU placement and idempotent disposal', () => { + const engine = createFakeEngine(); + engine.fake.script('sampleHeight', 125); + let config, + removed = 0; + const view = { + registerMesh() {}, + addMesh(value) { + config = value; + return { + delete() { + removed++; + }, + }; + }, + }; + const renderer = createLaunchesNavaraRenderer(engine, { + view, + MeshDesc: class {}, + }); + const pad = renderer.createPad({ id: 'x', lon: 0, lat: 0 }); + assert.equal( + engine.fake.callsOf('surface.sampleHeight').length, + 0, + 'flat pad must not sample a coarse tile', + ); + const group = config.gevLaunchPad.group; + assert.equal(group.children.length, 2); + assert.equal(group.matrix.elements[12], 6378137); + assert.equal(group.children[0].material.polygonOffsetUnits, -4); + pad.show = false; + assert.equal(group.visible, false); + pad.destroy(); + pad.destroy(); + assert.equal(removed, 1); + renderer.destroy(); + engine.destroy(); +}); diff --git a/src/layers/satellites/interaction.js b/src/layers/satellites/interaction.js index 87c80ac1e..741602a78 100644 --- a/src/layers/satellites/interaction.js +++ b/src/layers/satellites/interaction.js @@ -33,8 +33,7 @@ export function createInteraction({ layerState._viewer && ((getRenderer().follow.current() && getRenderer().follow.current() !== layerState._trackedEntity) || - (getRenderer().kind === 'navara' && - !getRenderer().follow.current() && + (!getRenderer().follow.current() && !getRenderer().follow.releasing())) ) { parts.tracking._clearTracking(true, { diff --git a/src/layers/satellites/interaction.test.mjs b/src/layers/satellites/interaction.test.mjs new file mode 100644 index 000000000..08cf490d3 --- /dev/null +++ b/src/layers/satellites/interaction.test.mjs @@ -0,0 +1,53 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createEngineEvent } from '../../globe/contract.js'; +import { createInteraction } from './interaction.js'; +test('external null follow clears satellite state on Cesium; own release stays guarded', () => { + const previous = globalThis.document; + globalThis.document = new EventTarget(); + try { + const changed = createEngineEvent(); + let current = null, + releasing = false; + const calls = []; + const renderer = { + kind: 'cesium', + follow: { + onChanged: (fn) => changed.addEventListener(fn), + current: () => current, + releasing: () => releasing, + }, + listenClick: () => ({ destroy() {} }), + }; + const state = { + _enabled: true, + _trackedNorad: 25544, + _viewer: {}, + _trackedEntity: { id: 'own' }, + }; + const interaction = createInteraction({ + state, + services: { picking: {} }, + parts: { tracking: { _clearTracking: (...args) => calls.push(args) } }, + getRenderer: () => renderer, + }); + interaction._installClickHandler(state._viewer); + releasing = true; + changed.raiseEvent(); + assert.equal(calls.length, 0); + releasing = false; + changed.raiseEvent(); + assert.equal(calls.length, 1); + assert.equal(calls[0][0], true); + current = state._trackedEntity; + changed.raiseEvent(); + assert.equal(calls.length, 1); + current = { id: 'foreign' }; + changed.raiseEvent(); + assert.equal(calls.length, 2); + state._trackedEntityChangedRemove(); + } finally { + if (previous === undefined) delete globalThis.document; + else globalThis.document = previous; + } +}); diff --git a/src/layers/satellites/render/cesium.js b/src/layers/satellites/render/cesium.js index 5582d8a15..8dd6e524c 100644 --- a/src/layers/satellites/render/cesium.js +++ b/src/layers/satellites/render/cesium.js @@ -1,8 +1,10 @@ -// Satellite drawing and native follow, extracted unchanged from the layer. +// Satellite drawing and guarded native follow. import * as Cesium from 'cesium'; import { isPointerFree } from '../../../data/inputOwnership.js'; export function createSatellitesCesiumRenderer(engine, { viewer } = {}) { + let releasing = false; + let starting = null; return { kind: 'cesium', engine, @@ -70,23 +72,37 @@ export function createSatellitesCesiumRenderer(engine, { viewer } = {}) { removeTracked: (entity) => viewer?.entities.remove(entity), follow: { start: (entity) => { - if (engine) { - engine.camera.follow({ - native: entity, - position: () => entity.position.getValue(viewer.clock.currentTime), - viewFrom: () => entity.viewFrom.getValue(viewer.clock.currentTime), - }); - } else { - viewer.camera?.cancelFlight(); - viewer.trackedEntity = entity; + starting = entity; + try { + if (engine) { + engine.camera.follow({ + native: entity, + position: () => + entity.position.getValue(viewer.clock.currentTime), + viewFrom: () => + entity.viewFrom.getValue(viewer.clock.currentTime), + }); + } else { + viewer.camera?.cancelFlight(); + viewer.trackedEntity = entity; + } + } finally { + starting = null; } }, release: () => { - const current = engine?.camera.following(); - if (current && current.native === viewer?.trackedEntity) current.stop(); - else if (viewer) viewer.trackedEntity = undefined; + releasing = true; + try { + const current = engine?.camera.following(); + if (current && current.native === viewer?.trackedEntity) + current.stop(); + else if (viewer) viewer.trackedEntity = undefined; + } finally { + releasing = false; + } }, - current: () => viewer?.trackedEntity, + current: () => starting ?? viewer?.trackedEntity, + releasing: () => releasing, onChanged: (listener) => viewer.trackedEntityChanged.addEventListener(listener), }, diff --git a/src/layers/satellites/render/cesium.test.mjs b/src/layers/satellites/render/cesium.test.mjs index 1499863a5..ab733a3f4 100644 --- a/src/layers/satellites/render/cesium.test.mjs +++ b/src/layers/satellites/render/cesium.test.mjs @@ -52,3 +52,69 @@ test('native satellite follow goes through the shared camera contract', () => { assert.equal(options.position(), position); assert.deepEqual({ ...options.viewFrom() }, { ...offset }); }); + +test('native own follow release is guarded without hiding later external release', () => { + const event = new Cesium.Event(); + let entity = { id: 'satellite' }; + const viewer = { + trackedEntityChanged: event, + get trackedEntity() { + return entity; + }, + set trackedEntity(value) { + entity = value; + event.raiseEvent(value); + }, + }; + const renderer = createSatellitesCesiumRenderer(null, { viewer }); + const guards = []; + renderer.follow.onChanged(() => guards.push(renderer.follow.releasing())); + renderer.follow.release(); + viewer.trackedEntity = { id: 'other' }; + viewer.trackedEntity = undefined; + assert.deepEqual(guards, [true, false, false]); + assert.equal(renderer.follow.releasing(), false); +}); + +test('native follow takeover reports its starting entity through an intermediate null', () => { + const event = new Cesium.Event(); + let current = { id: 'foreign' }; + const viewer = { + clock: { currentTime: Cesium.JulianDate.now() }, + trackedEntityChanged: event, + get trackedEntity() { + return current; + }, + set trackedEntity(v) { + current = v; + event.raiseEvent(); + }, + }; + const engine = { + camera: { + follow: ({ native }) => { + viewer.trackedEntity = undefined; + viewer.trackedEntity = native; + }, + }, + }; + const renderer = createSatellitesCesiumRenderer(engine, { viewer }); + const entity = { + id: 'own', + position: { + getValue() { + return geo.cartesian3.ZERO; + }, + }, + viewFrom: { + getValue() { + return geo.cartesian3.ZERO; + }, + }, + }; + const seen = []; + renderer.follow.onChanged(() => seen.push(renderer.follow.current())); + renderer.follow.start(entity); + assert.deepEqual(seen, [entity, entity]); + assert.equal(renderer.follow.current(), entity); +});