diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index e5a9999ca..5e7d13243 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -100,13 +100,15 @@ panel carries no Navara or three. There is no blanket exception for layer The temporary `engineImportExceptions` list in `scripts/package-boundaries.json` contains exact file/package pairs with reasons. The checker reports the used -entries and rejects duplicates, missing reasons and unused entries. Its three -exceptions are the Bhote Koshi event, locator and embedded-media models in -`src/data/`, which still construct Cesium property records. Their drawing uses -shared displays. Exceptions do not relax the portable, contract or cross-engine -graph checks. Cesium annotation renderers live in `src/globe/cesium/annotations/`; -wind backend adapters live in their engine folders. Wind's older injected-SDK -helpers and the remaining annotation migration are separate debt. +entries and rejects duplicates, missing reasons and unused entries. Its two +exceptions are the Bhote Koshi event and locator models in `src/data/`, which +still construct Cesium property records. Their drawing uses shared displays. +Exceptions do not relax the portable, contract or cross-engine graph checks. +Cesium annotation renderers live in `src/globe/cesium/annotations/`. Wind's +Cesium rendering (the particle canvas, its GPU path and the relief shading) +lives in `src/globe/cesium/`; `src/layers/wind/` keeps the data, model, +presentation and engine-neutral inspection. The remaining annotation migration +is separate debt. Source-only factories use dedicated `layers//source` exports where available, preserving the same factory implementation without loading rendering. diff --git a/src/app/tools.js b/src/app/tools.js index 8533195bc..94fcec1d7 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -101,7 +101,7 @@ export async function createApplicationTools({ // Cesium only for now: it reads Cesium's camera (positionWC, its // running flight). attachScene(next, { defer: deferScene }) { - const viewer = cesiumViewerOf(next.engine); + const viewer = next.viewer; if (!viewer) return; deferScene( installGlobeInViewRecovery(viewer, { @@ -255,12 +255,6 @@ export async function createApplicationTools({ }; } -/** - * The Cesium viewer under an engine, or null on an engine without one: the - * one place the tools read it. - */ -const cesiumViewerOf = (engine) => engine?.extensions?.cesium?.viewer ?? null; - /** * The tool backend of each engine kind (see attachEngineTools). Cesium's * holds the tools that still drive its viewer, so they stay absent on @@ -273,13 +267,8 @@ const TOOL_BACKENDS = Object.freeze({ }); /** The Cesium tool backend: the shared tools and its own on the viewer. */ -function cesiumToolBackend({ engine, tileset }) { - const viewer = cesiumViewerOf(engine); - const { - governorRequestRender, - holdContinuousRender, - releaseContinuousRender, - } = renderGovernorFor(engine); +function cesiumToolBackend({ engine, viewer, tileset }) { + const { governorRequestRender } = renderGovernorFor(engine); return { viewer, startupChrome: {}, @@ -300,18 +289,6 @@ function cesiumToolBackend({ engine, tileset }) { requestRender: governorRequestRender, }), ); - // The follow camera recomputes the tracked target's dead-reckon - // position every frame — tracking anything is a per-frame animation. - // (perf wave 2) - const removeTrackingListener = - viewer.trackedEntityChanged.addEventListener(() => { - if (viewer.trackedEntity) holdContinuousRender('tracked-entity'); - else releaseContinuousRender('tracked-entity'); - }); - defer(() => { - removeTrackingListener(); - releaseContinuousRender('tracked-entity'); - }); }, // Hidden-state suspension (perf wave 2): when the window/tab is hidden, // stop the default render loop outright — a hidden canvas repaints for @@ -418,6 +395,20 @@ async function attachEngineTools( governor.install(); backend.attach?.({ data, styleManager, dataManager }, { signal, defer }); + // A followed target moves every frame, even while the camera is still. + const updateFollow = () => { + if (engine.camera.following()) + governor.holdContinuousRender('tracked-entity'); + else governor.releaseContinuousRender('tracked-entity'); + }; + const removeFollow = + engine.camera.followChanged.addEventListener(updateFollow); + updateFollow(); + defer(() => { + removeFollow(); + governor.releaseContinuousRender('tracked-entity'); + }); + // Install the explicit scope mask used by the DISPLAY controls: a canvas // in the engine's container, its terminus following the camera's height. installScopeMask(engine); diff --git a/src/celestialRing.js b/src/celestialRing.js index 887bcc801..5bb9b8f63 100644 --- a/src/celestialRing.js +++ b/src/celestialRing.js @@ -1,12 +1,7 @@ import * as geo from './geo/index.js'; -import { createCelestialView } from './celestialRing/view.js'; -import { - cesiumIcrfToFixed, - projectCelestialPoint, - flyCelestialViewer, -} from './globe/cesium/celestial.js'; +import { cesiumIcrfToFixed } from './globe/cesium/celestial.js'; import { createCelestialEphemeris } from './celestialRing/ephemeris.js'; -import { renderGovernorFor } from './renderGovernor.js'; +import { celestialHost } from './globe/cesium/celestialHost.js'; import { worldClock, worldTimeFor } from './time/worldClock.js'; // The ring shows the sun and moon at the time the sky is drawn. @@ -154,8 +149,8 @@ export function isCelestialRingStyleSupported(styleName) { * is inverted. When the celestial vector points almost directly into/out of the * camera, retain the prior stable bearing and fade the marker. * - * @param {number} rightComponent - Dot(direction, camera.rightWC). - * @param {number} upComponent - Dot(direction, camera.upWC). + * @param {number} rightComponent - Dot(direction, camera.right). + * @param {number} upComponent - Dot(direction, camera.up). * @param {number} lastAngle - Previous stable angle in radians. * @returns {{angle:number, opacity:number, stable:boolean}} */ @@ -245,29 +240,29 @@ export function earthDiscScreenRadius(cameraDistance, viewportHeight, fovy) { * Project the visible Earth disc into viewport coordinates. * Shared by full-globe UI treatments that must agree on visual containment. * - * @param {object} viewer - Cesium Viewer-like object. + * @param {object} engine - Globe engine or legacy scene host. * @param {number} width - Viewport width in CSS pixels. * @param {number} height - Viewport height in CSS pixels. - * @param {object} [scratchCenter] - Reusable Cesium Cartesian2 result. - * @param {object} [scratchToCenter] - Reusable Cesium Cartesian3 result. + * @param {object} [scratchCenter] - Reusable Cartesian2 result. + * @param {object} [scratchToCenter] - Reusable Cartesian3 result. * @param {import('./globe/contract.js').GlobeProjector | null} [projector] - * The globe engine's projector; without one, Cesium's SceneTransforms * (the same projection on a Cesium scene). * @returns {object|null} */ export function projectEarthDiscToViewport( - viewer, + engine, width, height, scratchCenter = undefined, scratchToCenter = undefined, projector = null, ) { - const camera = viewer?.camera; - const scene = viewer?.scene; - if (!camera || !scene || !(width > 0) || !(height > 0)) return null; + if (!engine?.camera || !(width > 0) || !(height > 0)) return null; + const host = engine.camera.pose ? engine : celestialHost(engine); + const camera = host.camera.pose(); - const distance = geo.cartesian3.magnitude(camera.positionWC); + const distance = geo.cartesian3.magnitude(camera.position); if ( !Number.isFinite(distance) || distance <= geo.ellipsoid.WGS84.maximumRadius @@ -275,22 +270,19 @@ export function projectEarthDiscToViewport( return null; const toCenter = geo.cartesian3.negate( - camera.positionWC, + camera.position, scratchToCenter || geo.cartesian3.create(), ); - if (geo.cartesian3.dot(camera.directionWC, toCenter) <= 0) return null; + if (geo.cartesian3.dot(camera.direction, toCenter) <= 0) return null; - const center = projector - ? projector.worldToWindow(geo.cartesian3.ZERO, scratchCenter) - : projectCelestialPoint(scene, geo.cartesian3.ZERO, scratchCenter); + const center = (projector ?? host.projector).worldToWindow( + geo.cartesian3.ZERO, + scratchCenter, + ); if (!center || !Number.isFinite(center.x) || !Number.isFinite(center.y)) return null; - const earthRadius = earthDiscScreenRadius( - distance, - height, - camera.frustum?.fovy, - ); + const earthRadius = earthDiscScreenRadius(distance, height, camera.fovy); if (!earthRadius) return null; const keyhole = getKeyholeGeometry(width, height); @@ -401,7 +393,7 @@ let rings = 0; /** * Screen-space ring and celestial direction overlay for the full-globe view. * The overlay is presented to the celestial ring store, whose view renders it - * inside the viewer's container and hands back the two canvases this paints. + * inside the engine's container and hands back the two canvases this paints. */ export class CelestialRing { /** @@ -416,13 +408,7 @@ export class CelestialRing { viewer, { enabled = true, onAutoDisable = null, store, engine = null } = {}, ) { - this._engine = engine ?? (viewer?.kind ? viewer : null); - this._engineView = viewer?.kind ? createCelestialView(viewer) : null; - this.viewer = this._engineView?.view ?? viewer; - this._projector = engine?.projector ?? null; - this._renderGovernor = renderGovernorFor( - engine ?? (viewer?.kind ? viewer : null), - ); + this._engine = engine ?? (viewer?.kind ? viewer : celestialHost(viewer)); this.enabled = !!enabled; this.visible = false; this._onAutoDisable = @@ -455,8 +441,8 @@ export class CelestialRing { ); this._initView(store); - this._removePostRender = this.viewer.scene.postRender.addEventListener(() => - this._draw(), + this._removePostRender = this._engine.frames.postRender.addEventListener( + () => this._draw(), ); // Pre-existing staleness fix (perf wave 2 review): the ephemeris was // sampled once per visible-enable from the FROZEN app clock, so the @@ -470,7 +456,7 @@ export class CelestialRing { // dirty flag up immediately. (review finding) this._ephemerisDirty = true; if (typeof document !== 'undefined' && document.hidden) return; - this._renderGovernor?.governorRequestRender('celestial-ephemeris'); + this._engine.render.request('celestial-ephemeris'); }, 60_000); // A jump in the world time moves the sun and moon at once. // Played through, the sky moves on each minute of world time it passes. @@ -495,7 +481,7 @@ export class CelestialRing { this._view = { id: ++rings, owner, - container: this.viewer.container, + container: this._engine.host.container, enabled: this.enabled, visible: false, globeVisible: null, @@ -548,8 +534,7 @@ export class CelestialRing { this._moonCtx = ctx; this._moonRenderKey = ''; } else return; - if (canvas && this.enabled) - this._renderGovernor?.governorRequestRender('celestial-ring'); + if (canvas && this.enabled) this._engine.render.request('celestial-ring'); } /** Enable or disable the user preference for the effect. */ @@ -563,7 +548,7 @@ export class CelestialRing { // all. Camera motion covers every later repaint; the 60 s ephemeris // timer requests its own. (perf wave 2 fix — owner playtest finding) if (this.enabled !== wasEnabled) - this._renderGovernor?.governorRequestRender('celestial-ring'); + this._engine.render.request('celestial-ring'); if (!this.enabled) { this.visible = false; this._clear(); @@ -576,7 +561,7 @@ export class CelestialRing { /** Whether the current camera already frames the complete globe inside the keyhole. */ isGlobeFullyVisible() { - const canvas = this.viewer.scene.canvas; + const canvas = this._engine.host.canvas; const width = canvas.clientWidth || canvas.width; const height = canvas.clientHeight || canvas.height; if (!(width > 0 && height > 0)) return false; @@ -591,10 +576,10 @@ export class CelestialRing { * @returns {boolean} Whether a valid camera flight was started. */ focusFullGlobe({ duration = 2.4 } = {}) { - const canvas = this.viewer.scene.canvas; + const canvas = this._engine.host.canvas; const height = canvas.clientHeight || canvas.height; - const cartographic = this.viewer.camera.positionCartographic; - const fovy = this.viewer.camera.frustum?.fovy; + const pose = this._engine.camera.pose(); + const { cartographic, fovy } = pose; if ( !(height > 0) || !cartographic || @@ -620,7 +605,7 @@ export class CelestialRing { this._focusInProgress = true; const finishFocus = () => { this._focusInProgress = false; - this.viewer.scene.requestRender?.(); + this._engine.render.request('celestial-ring'); }; const flight = { destination: geo.cartesian3.fromRadians( @@ -629,7 +614,7 @@ export class CelestialRing { altitude, ), orientation: { - heading: this.viewer.camera.heading, + heading: pose.heading, pitch: -geo.math.PI_OVER_TWO, roll: 0, }, @@ -640,9 +625,7 @@ export class CelestialRing { if (!this.isGlobeFullyVisible()) this._autoDisable(); }, }; - if (this._engine) - this._engine.camera.flyTo({ ...flight, easing: 'cubicInOut' }); - else flyCelestialViewer(this.viewer, flight); + this._engine.camera.flyTo({ ...flight, easing: 'cubicInOut' }); return true; } @@ -713,12 +696,11 @@ export class CelestialRing { /** Return the projected Earth disc used for the full-globe gate. */ _projectedEarthDisc(width, height) { return projectEarthDiscToViewport( - this.viewer, + this._engine, width, height, this._screenCenter, this._toCenter, - this._projector, ); } @@ -795,7 +777,7 @@ export class CelestialRing { const now = performance.now(); if (now < this._nextDrawAt) return; this._nextDrawAt = now + 1000 / CELESTIAL_MAX_FRAME_RATE; - const canvas = this.viewer.scene.canvas; + const canvas = this._engine.host.canvas; const width = canvas.clientWidth || canvas.width; const height = canvas.clientHeight || canvas.height; if (!(width > 0 && height > 0)) return; @@ -828,15 +810,15 @@ export class CelestialRing { const time = skyTime(); if (!this._updateEphemeris(time)) return; this._present({ ephemerisUpdates: String(this._ephemerisUpdateCount) }); - const camera = this.viewer.camera; + const camera = this._engine.camera.pose(); const sunProjection = celestialScreenAngle( - geo.cartesian3.dot(this._sunFixed, camera.rightWC), - geo.cartesian3.dot(this._sunFixed, camera.upWC), + geo.cartesian3.dot(this._sunFixed, camera.right), + geo.cartesian3.dot(this._sunFixed, camera.up), this._sunAngle, ); const moonProjection = celestialScreenAngle( - geo.cartesian3.dot(this._moonFixed, camera.rightWC), - geo.cartesian3.dot(this._moonFixed, camera.upWC), + geo.cartesian3.dot(this._moonFixed, camera.right), + geo.cartesian3.dot(this._moonFixed, camera.up), this._moonAngle, ); if (sunProjection.stable) this._sunAngle = sunProjection.angle; @@ -923,6 +905,5 @@ export class CelestialRing { this._store.publish(null); } this._sunCanvas = this._sunCtx = this._moonCanvas = this._moonCtx = null; - this._engineView?.destroy(); } } diff --git a/src/celestialRingFocus.test.mjs b/src/celestialRingFocus.test.mjs index 382d5c8ef..3033bff2c 100644 --- a/src/celestialRingFocus.test.mjs +++ b/src/celestialRingFocus.test.mjs @@ -5,25 +5,19 @@ import { CelestialRing } from './celestialRing.js'; test('full globe focus uses the engine without a Cesium global', () => { let flight; const engine = { + host: { canvas: { clientWidth: 1200, clientHeight: 800 } }, + render: { request() {} }, camera: { - flyTo: (options) => { - flight = options; - }, + pose: () => ({ + cartographic: { longitude: 0, latitude: 0 }, + heading: 0, + fovy: Math.PI / 3, + }), + flyTo: (options) => { flight = options; }, }, }; const ring = Object.assign(Object.create(CelestialRing.prototype), { _engine: engine, - viewer: { - scene: { - canvas: { clientWidth: 1200, clientHeight: 800 }, - requestRender() {}, - }, - camera: { - positionCartographic: { longitude: 0, latitude: 0 }, - heading: 0, - frustum: { fovy: Math.PI / 3 }, - }, - }, }); assert.equal(globalThis.Cesium, undefined); assert.equal(ring.focusFullGlobe(), true); diff --git a/src/data/bhoteKoshiEmbeddedMedia.js b/src/data/bhoteKoshiEmbeddedMedia.js index d75bf8612..63ee90c10 100644 --- a/src/data/bhoteKoshiEmbeddedMedia.js +++ b/src/data/bhoteKoshiEmbeddedMedia.js @@ -1,4 +1,3 @@ -import * as Cesium from 'cesium'; import { t, msg, onLocaleChange } from '../i18n/index.js'; const YOUTUBE_HOSTS = new Set([ @@ -538,25 +537,12 @@ function ensureProviderConnections(documentRef, provider) { } } -function anchorOnScreen(viewer, engine, anchor, root) { - if ((!viewer?.scene && !engine) || !anchor || !root) return null; - const camera = viewer?.camera; - if (engine && !engine.projector.horizonVisible(anchor)) return null; - if (camera?.positionWC && camera?.directionWC) { - const delta = Cesium.Cartesian3.subtract( - anchor, - camera.positionWC, - new Cesium.Cartesian3(), - ); - if (Cesium.Cartesian3.dot(delta, camera.directionWC) <= 0) return null; - } - const pixel = engine - ? engine.projector.worldToWindow(anchor, {}) - : Cesium.SceneTransforms.worldToWindowCoordinates(viewer.scene, anchor); +function anchorOnScreen(engine, anchor, root) { + if (!engine || !anchor || !root) return null; + if (!engine.projector.horizonVisible(anchor)) return null; + const pixel = engine.projector.worldToWindow(anchor, {}); if (!pixel) return null; - const canvasBounds = ( - engine?.host.canvas ?? viewer?.canvas - )?.getBoundingClientRect?.(); + const canvasBounds = engine.host.canvas?.getBoundingClientRect?.(); const rootBounds = root.getBoundingClientRect?.(); if (!canvasBounds || !rootBounds) return null; const x = pixel.x + canvasBounds.left - rootBounds.left; @@ -855,7 +841,7 @@ export function createBhoteKoshiEmbeddedMedia({ const position = () => { if (!record) return; - const projected = anchorOnScreen(viewer, engine, record.anchor, root); + const projected = anchorOnScreen(engine, record.anchor, root); record.card.hidden = !projected; if (projected) { placeCard(record.card, record.leader, projected); diff --git a/src/data/bhoteKoshiEvent.js b/src/data/bhoteKoshiEvent.js index 5082bc48e..66971de2e 100644 --- a/src/data/bhoteKoshiEvent.js +++ b/src/data/bhoteKoshiEvent.js @@ -884,7 +884,7 @@ function openExternal(url) { if (target) target.opener = null; } -function focusView(viewer, viewpoint, engine) { +function focusView(engine, viewpoint) { announceNavigationAuthority('bhote-koshi-event-focus'); const target = Cesium.Cartesian3.fromDegrees( viewpoint.lon, @@ -896,32 +896,20 @@ function focusView(viewer, viewpoint, engine) { Cesium.Math.toRadians(viewpoint.pitchDeg), viewpoint.rangeM, ); - if (engine) { - engine.camera.cancelFlight(); - engine.camera.flyToSphere( - { center: target, radius: 80 }, - { - heading: offset.heading, - pitch: offset.pitch, - range: offset.range, - duration: 2.2, - complete: () => { - engine.camera.lookAt(target, offset); - engine.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); - }, + engine.camera.cancelFlight(); + engine.camera.flyToSphere( + { center: target, radius: 80 }, + { + heading: offset.heading, + pitch: offset.pitch, + range: offset.range, + duration: 2.2, + complete: () => { + engine.camera.lookAt(target, offset); + engine.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); }, - ); - return; - } - viewer.camera.cancelFlight?.(); - viewer.camera.flyToBoundingSphere(new Cesium.BoundingSphere(target, 80), { - offset, - duration: 2.2, - complete: () => { - viewer.camera.lookAt(target, offset); - viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); }, - }); + ); } function setElementProperty(element, property, value) { @@ -3521,8 +3509,7 @@ export function createBhoteKoshiEventLayer({ _embeddedEvidenceIndex = -1; _embeddedMedia?.hide?.(); setSplit(0.5); - if (cameraForScene()) - focusView(_viewer, _event.viewpoints.corridor, _engine); + if (_engine) focusView(_engine, _event.viewpoints.corridor); } // Each attachment and release advances this. An attachment that resumes diff --git a/src/globe/cesium/celestialHost.js b/src/globe/cesium/celestialHost.js new file mode 100644 index 000000000..079d09286 --- /dev/null +++ b/src/globe/cesium/celestialHost.js @@ -0,0 +1,26 @@ +// Compatibility for callers that still construct the ring on a bare viewer. +import { flyCelestialViewer, projectCelestialPoint } from './celestial.js'; + +export function celestialHost(viewer) { + return { + host: { container: viewer.container, canvas: viewer.scene.canvas }, + frames: { postRender: viewer.scene.postRender }, + camera: { + pose: () => ({ + position: viewer.camera.positionWC, + cartographic: viewer.camera.positionCartographic, + direction: viewer.camera.directionWC, + up: viewer.camera.upWC, + right: viewer.camera.rightWC, + heading: viewer.camera.heading, + fovy: viewer.camera.frustum?.fovy, + }), + flyTo: (options) => flyCelestialViewer(viewer, options), + }, + projector: { + worldToWindow: (position, result) => + projectCelestialPoint(viewer.scene, position, result), + }, + render: { request: () => viewer.scene.requestRender?.() }, + }; +}