From 6727aaadcd68e7c88b70c2d4931f266ff1bd6fe4 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 16:14:14 +0400 Subject: [PATCH] Fly, follow and draw the celestial ring through the contract The Bhote Koshi event flies with engine.camera instead of the viewer's camera, tools listens to camera.followChanged instead of the viewer's tracked entity, and the celestial ring uses the engine's host, frames, pose and render requests. The embedded story media no longer imports Cesium, which removes one of the three import exceptions. --- docs/CODE-BOUNDARIES.md | 16 +++-- src/app/tools.js | 43 +++++------- src/celestialRing.js | 101 +++++++++++----------------- src/celestialRingFocus.test.mjs | 22 +++--- src/data/bhoteKoshiEmbeddedMedia.js | 26 ++----- src/data/bhoteKoshiEvent.js | 41 ++++------- src/globe/cesium/celestialHost.js | 26 +++++++ 7 files changed, 121 insertions(+), 154 deletions(-) create mode 100644 src/globe/cesium/celestialHost.js 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?.() }, + }; +}