From 708c96b6f648fbf427004785f449e5677ffa911e Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 15 Oct 2026 17:15:35 +0400 Subject: [PATCH] fix(globe): tighten the engine contract after review Engine events get their own documented semantics (single registration, branded instances); the fake follows Cesium's roll sign, angle ranges, synchronous zero-duration flights, flyToSphere defaults and computeViewRectangle. The contract adds viewProjection, horizonVisible, flightActive, lookAtTransform/transform, zoomIn/zoomOut, input modifiers, terrainHeight and clampToHeight, with geometric suite checks. Engine import rules now follow each engine graph transitively. --- docs/CODE-BOUNDARIES.md | 6 +- scripts/check-import-directions.mjs | 31 +- src/globe/contract.js | 180 ++++++-- src/globe/contract.test.mjs | 86 +++- src/globe/testing/contractSuite.mjs | 521 +++++++++++++++++++++- src/globe/testing/fakeEngine.js | 619 +++++++++++++++++--------- src/globe/testing/fakeEngine.test.mjs | 100 ++++- src/tooling/importDirections.test.mjs | 37 +- 8 files changed, 1300 insertions(+), 280 deletions(-) diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 2fda132e5..5fd2e172f 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -67,8 +67,10 @@ platform names such as `document` and `window` in these modules; it is an architectural check, not a JavaScript sandbox. The globe contract (`src/globe/*.js` and `src/globe/testing/**`) cannot reach `cesium`, `@cesium/*`, `three`, `postprocessing` or `@navaramap/*`, directly or through -helpers, nor import an engine implementation; `src/globe/cesium/**` imports no -Navara/three package and `src/globe/navara/**` no Cesium package. Common voice controls cannot depend on a +helpers, nor import an engine implementation; the `src/globe/cesium/**` graph +reaches no Navara/three package or `src/globe/navara/**` module, and the +`src/globe/navara/**` graph no Cesium package or `src/globe/cesium/**` module, +through helpers too. Common voice controls cannot depend on a Realtime protocol implementation. Negative fixtures cover indirect helpers, self-package imports, symlinks and unreachable files. diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index cdf0cf5e7..8f19215a7 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -257,15 +257,28 @@ export function checkImportDirections(root) { } } for (const file of files.filter(globeContract)) globe(file); - for (const file of files.filter(globeEngine)) { - const navara = file.startsWith('src/globe/navara/'); - for (const specifier of load(file)?.external || []) { - if ((navara ? cesiumPackage : navaraPackage).test(specifier)) - report( - file, - `${navara ? 'Navara' : 'Cesium'} globe engine imports ${specifier}`, - ); - } + // Each engine graph stays on its own engine, through helpers too. + for (const start of files.filter(globeEngine)) { + const navara = start.startsWith('src/globe/navara/'); + const label = navara ? 'Navara globe engine' : 'Cesium globe engine'; + const foreignPackage = navara ? cesiumPackage : navaraPackage; + const foreignEngine = navara ? 'src/globe/cesium/' : 'src/globe/navara/'; + const seen = new Set(); + const visit = (file) => { + if (seen.has(file) || !code.test(file)) return; + seen.add(file); + const info = load(file); + if (!info) return; + for (const specifier of info.external) + if (foreignPackage.test(specifier)) + report(file, `${label} reaches ${specifier}`); + for (const to of info.edges) { + if (to.startsWith(foreignEngine)) + report(file, `${label} reaches ${to}`); + else visit(to); + } + }; + visit(start); } // Common voice controls may use their DOM view, but never a protocol implementation. const voiceSeen = new Set(); diff --git a/src/globe/contract.js b/src/globe/contract.js index 422ef3594..0a16cd619 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -1,30 +1,44 @@ // The engine-neutral globe contract. The application shell talks to a globe // engine (Cesium today, Navara next) only through these small capability -// objects. Positions are src/geo values, angles are radians and screen -// coordinates are CSS pixels from the canvas's top-left corner. This module -// and src/globe/testing/** never import cesium, three or @navaramap/*; see -// docs/CODE-BOUNDARIES.md. +// objects. Positions are src/geo values in Earth-fixed metres, angles are +// radians and screen coordinates are CSS pixels from the canvas's top-left +// corner. This module and src/globe/testing/** never import cesium, three or +// @navaramap/*; see docs/CODE-BOUNDARIES.md. /** * @typedef {{ x: number, y: number }} Cartesian2 * @typedef {{ x: number, y: number, z: number }} Cartesian3 * @typedef {{ longitude: number, latitude: number, height: number }} Cartographic + * @typedef {number[]} Matrix4 16 numbers, column-major (m[column * 4 + row]). * @typedef {(...args: any[]) => void} Listener * * @typedef {object} EngineEvent + * The contract's own event semantics (engines expose createEngineEvent() + * instances, never a raw Cesium.Event): + * - listeners run in registration order; + * - adding a function that is already registered is a no-op (it stays + * registered once and fires once), so re-initialisation cannot double-fire; + * - a listener removed during dispatch is not called in that dispatch; + * - a listener added during dispatch runs from the next dispatch, and one + * removed then re-added during dispatch stays registered; + * - every remover handle is idempotent and only removes the registration it + * was returned for. * @property {(listener: Listener) => () => void} addEventListener - * Returns a function that removes this registration (idempotent). * @property {(listener: Listener) => boolean} removeEventListener * * @typedef {object} CameraPose - * @property {Cartesian3} position + * Angles follow Cesium's camera: heading in [0, 2π) clockwise from north, + * pitch in [−π/2, π/2] (negative looks down), roll in [0, 2π) with positive + * roll tilting `up` toward the camera's right; roll is 0 and heading follows + * `up` when looking (nearly, within 1e-3) straight up or down. + * @property {Cartesian3} position World position (Cesium's positionWC). * @property {Cartographic} cartographic * @property {number} heading * @property {number} pitch * @property {number} roll * @property {Cartesian3} direction * @property {Cartesian3} up - * @property {Cartesian3} right + * @property {Cartesian3} right direction × up * @property {number} fovy Vertical field of view. * * @typedef {{ heading: number, pitch: number, roll: number } @@ -32,21 +46,49 @@ * * @typedef {object} FlightOptions * @property {Cartesian3} destination - * @property {Orientation} [orientation] - * @property {number} [duration] Seconds; 0 lands on the next frame. + * @property {Orientation} [orientation] Missing angles keep the current ones. + * @property {number} [duration] Seconds. 0 (or less) sets the view and calls + * `complete` synchronously, before flyTo returns, as Cesium does. * @property {'cubicInOut'|'sinusoidalInOut'|'linear'} [easing] * @property {() => void} [complete] * @property {() => void} [cancel] Called by cancelFlight or a newer flight. * + * @typedef {{ heading: number, pitch: number, range: number }} HeadingPitchRange + * Offset from a target in its local east-north-up frame; the camera looks at + * the target along heading/pitch from `range` metres away. + * + * @typedef {object} SphereFlightOptions + * Defaults are Cesium's: heading 0, pitch −π/4 and, when range is missing or + * 0, range = max(r / tan(fovx/2), r / tan(fovy/2)), never below 100 m. + * @property {number} [heading] + * @property {number} [pitch] + * @property {number} [range] + * @property {number} [duration] + * @property {() => void} [complete] + * @property {() => void} [cancel] + * * @typedef {object} GlobeCamera * @property {() => CameraPose} pose Fresh values on every call. * @property {(view: { destination: Cartesian3, orientation?: Orientation }) => void} setView - * @property {(options: FlightOptions) => void} flyTo - * @property {(sphere: { center: Cartesian3, radius: number }, options?: { heading?: number, pitch?: number, range?: number, duration?: number, complete?: () => void, cancel?: () => void }) => void} flyToSphere - * @property {(target: Cartesian3, offset: { heading: number, pitch: number, range: number }) => void} lookAt - * @property {() => void} release Leaves lookAt (Cesium's lookAtTransform(IDENTITY)). + * @property {(options: FlightOptions) => void} flyTo Cancels a running flight first. + * @property {(sphere: { center: Cartesian3, radius: number }, options?: SphereFlightOptions) => void} flyToSphere + * Ends unlocked (identity transform). + * @property {() => boolean} flightActive + * @property {(target: Cartesian3, offset: HeadingPitchRange) => void} lookAt + * lookAtTransform(eastNorthUp(target), offset). + * @property {(transform: Matrix4, offset?: HeadingPitchRange | Cartesian3) => void} lookAtTransform + * Locks the camera to `transform`; a Cartesian3 offset is the camera position + * in that frame, looking at its origin with the frame's +Z as "up". Without + * an offset the camera keeps its world pose. + * @property {() => Matrix4} transform A copy of the current camera transform. + * @property {() => void} release lookAtTransform(IDENTITY). * @property {() => void} cancelFlight + * @property {(amount?: number) => void} zoomIn Moves `amount` metres along direction. + * @property {(amount?: number) => void} zoomOut Moves `amount` metres against direction. * @property {() => ({ west: number, south: number, east: number, north: number } | undefined)} viewRectangle + * Cesium's computeViewRectangle: undefined when the globe is out of view; + * west > east across the antimeridian; ±π longitudes and a ±π/2 latitude + * over a pole; the whole globe when fewer than two canvas corners hit it. * @property {(screen: Cartesian2) => (Cartesian3 | undefined)} pickEllipsoid * @property {(screen: Cartesian2) => { origin: Cartesian3, direction: Cartesian3 }} pickRay * @property {EngineEvent} changed Listener receives the change fraction. @@ -55,18 +97,51 @@ * @property {number} percentageChanged * * @typedef {'click'|'doubleClick'|'move'|'down'|'up'} InputType + * @typedef {'ctrl'|'shift'|'alt'} InputModifier * @typedef {{ position?: Cartesian2, startPosition?: Cartesian2, endPosition?: Cartesian2 }} InputEvent * + * @typedef {object} GlobeProjector + * @property {(position: Cartesian3, result?: Cartesian2) => (Cartesian2 | undefined)} worldToWindow + * Undefined behind the camera; fills and returns `result` when given. + * @property {(result?: Matrix4) => Matrix4} viewProjection + * World → clip matrix consistent with worldToWindow: clip = M·[x, y, z, 1], + * in front when clip.w > 0, window = ((clip.x/clip.w + 1)/2 · width, + * (1 − clip.y/clip.w)/2 · height). + * @property {(position: Cartesian3) => boolean} horizonVisible + * False when the WGS84 ellipsoid hides the point from the camera + * (Cesium's EllipsoidalOccluder.isPointVisible). + * + * @typedef {object} GlobeSurface + * @property {(cartographic: Cartographic, exclude?: any[]) => (number | undefined)} sampleHeight + * Height of the rendered scene (tiles, models) under a point. + * @property {boolean} supported Whether sampleHeight works. + * @property {(cartographic: Cartographic) => (number | undefined)} terrainHeight + * Height of the terrain/globe surface only (Cesium's globe.getHeight). + * @property {boolean} terrainHeightSupported + * @property {(position: Cartesian3, exclude?: any[]) => (Cartesian3 | undefined)} clampToHeight + * The position moved onto the rendered scene along the geodetic normal. + * @property {boolean} clampToHeightSupported + * @property {() => boolean} tilesLoaded + * + * @typedef {object} GlobeRender + * @property {() => void} request Renders a frame soon in request mode. + * @property {(enabled: boolean) => void} setContinuous + * Owns whatever the engine needs to render every frame or only on request + * (Cesium: requestRenderMode and maximumRenderTimeChange = Infinity). + * @property {() => boolean} isContinuous + * * @typedef {object} GlobeEngine * @property {'cesium'|'navara'} kind * @property {{ container: object, canvas: object, size: () => { width: number, height: number } }} host * @property {GlobeCamera} camera * @property {{ pick: (screen: Cartesian2, width?: number, height?: number) => ({ id: any, primitive: any } | undefined), drillPick: (screen: Cartesian2, limit?: number) => any[], pickPosition: (screen: Cartesian2) => (Cartesian3 | undefined), pickPositionSupported: boolean }} picker - * @property {{ worldToWindow: (position: Cartesian3, result?: Cartesian2) => (Cartesian2 | undefined) }} projector + * @property {GlobeProjector} projector * @property {{ preUpdate: EngineEvent, preRender: EngineEvent, postRender: EngineEvent }} frames Listeners receive a timestamp in ms. - * @property {{ request: () => void, setContinuous: (enabled: boolean) => void, isContinuous: () => boolean }} render - * @property {{ sampleHeight: (cartographic: Cartographic, exclude?: any[]) => (number | undefined), supported: boolean, tilesLoaded: () => boolean }} surface - * @property {{ on: (type: InputType, listener: (event: InputEvent) => void) => () => void, setCameraInputs: (enabled: boolean) => void }} input + * @property {GlobeRender} render + * @property {GlobeSurface} surface + * @property {{ on: (type: InputType, listener: (event: InputEvent) => void, options?: { modifier?: InputModifier }) => () => void, setCameraInputs: (enabled: boolean) => void }} input + * As in Cesium, a listener with a modifier fires only while that key is + * held and one without fires only while no modifier is held. * @property {{ addStatic: (html: string, options?: { onScreen?: boolean }) => () => void }} credits * @property {{ setBaseImagery: (source: object) => void, setTerrain: (source: object) => void, setPhotorealTiles: (source: object | null) => void, photorealActive: () => boolean }} maps * Sources are maps/registry.js descriptions; the engine builds its own providers. @@ -81,26 +156,30 @@ export const INPUT_TYPES = Object.freeze([ 'down', 'up', ]); +export const MODIFIERS = Object.freeze(['ctrl', 'shift', 'alt']); export const FLIGHT_EASINGS = Object.freeze([ 'cubicInOut', 'sinusoidalInOut', 'linear', ]); +const ENGINE_EVENT = Symbol('gev.globe.engineEvent'); + +/** Whether a value is an event made by createEngineEvent. */ +export const isEngineEvent = (value) => + value != null && value[ENGINE_EVENT] === true; + /** - * An event with Cesium.Event semantics: listeners run in registration order, - * removal during dispatch is deferred safely, and listeners added during - * dispatch run from the next raise. `raiseEvent` is for engine implementations. + * An event with the contract's semantics (see EngineEvent). `raiseEvent` is + * for engine implementations; a listener that throws propagates, and the + * event stays usable. */ export function createEngineEvent() { let entries = []; let depth = 0; let removedDuringRaise = false; - const compact = () => { - if (depth || !removedDuringRaise) return; - entries = entries.filter((entry) => entry.listener); - removedDuringRaise = false; - }; + const active = (listener) => + entries.find((entry) => entry.listener === listener); const removeEntry = (entry) => { if (!entry.listener) return false; entry.listener = null; @@ -108,7 +187,7 @@ export function createEngineEvent() { else entries.splice(entries.indexOf(entry), 1); return true; }; - return { + const event = { get numberOfListeners() { return entries.reduce( (count, entry) => count + (entry.listener ? 1 : 0), @@ -118,16 +197,17 @@ export function createEngineEvent() { addEventListener(listener) { if (typeof listener !== 'function') throw new TypeError('Engine event listener must be a function'); - const entry = { listener }; - entries.push(entry); + let entry = active(listener); + if (!entry) { + entry = { listener }; + entries.push(entry); + } return () => { removeEntry(entry); }; }, removeEventListener(listener) { - const entry = entries.find( - (candidate) => candidate.listener === listener, - ); + const entry = active(listener); return entry ? removeEntry(entry) : false; }, raiseEvent(...args) { @@ -141,19 +221,20 @@ export function createEngineEvent() { } } finally { depth--; - compact(); + if (!depth && removedDuringRaise) { + entries = entries.filter((entry) => entry.listener); + removedDuringRaise = false; + } } }, }; + Object.defineProperty(event, ENGINE_EVENT, { value: true }); + return event; } const isFunction = (value) => typeof value === 'function'; const isObject = (value) => value != null && (typeof value === 'object' || typeof value === 'function'); -const isEvent = (value) => - isObject(value) && - isFunction(value.addEventListener) && - isFunction(value.removeEventListener); const isBoolean = (value) => typeof value === 'boolean'; const isNumber = (value) => typeof value === 'number' && Number.isFinite(value); @@ -165,15 +246,20 @@ const SHAPE = { setView: isFunction, flyTo: isFunction, flyToSphere: isFunction, + flightActive: isFunction, lookAt: isFunction, + lookAtTransform: isFunction, + transform: isFunction, release: isFunction, cancelFlight: isFunction, + zoomIn: isFunction, + zoomOut: isFunction, viewRectangle: isFunction, pickEllipsoid: isFunction, pickRay: isFunction, - changed: isEvent, - moveStart: isEvent, - moveEnd: isEvent, + changed: isEngineEvent, + moveStart: isEngineEvent, + moveEnd: isEngineEvent, percentageChanged: isNumber, }, picker: { @@ -182,8 +268,16 @@ const SHAPE = { pickPosition: isFunction, pickPositionSupported: isBoolean, }, - projector: { worldToWindow: isFunction }, - frames: { preUpdate: isEvent, preRender: isEvent, postRender: isEvent }, + projector: { + worldToWindow: isFunction, + viewProjection: isFunction, + horizonVisible: isFunction, + }, + frames: { + preUpdate: isEngineEvent, + preRender: isEngineEvent, + postRender: isEngineEvent, + }, render: { request: isFunction, setContinuous: isFunction, @@ -192,6 +286,10 @@ const SHAPE = { surface: { sampleHeight: isFunction, supported: isBoolean, + terrainHeight: isFunction, + terrainHeightSupported: isBoolean, + clampToHeight: isFunction, + clampToHeightSupported: isBoolean, tilesLoaded: isFunction, }, input: { on: isFunction, setCameraInputs: isFunction }, diff --git a/src/globe/contract.test.mjs b/src/globe/contract.test.mjs index aa94bb14b..dc9a8f31a 100644 --- a/src/globe/contract.test.mjs +++ b/src/globe/contract.test.mjs @@ -3,8 +3,10 @@ import test from 'node:test'; import { ENGINE_KINDS, INPUT_TYPES, + MODIFIERS, assertGlobeEngine, createEngineEvent, + isEngineEvent, } from './contract.js'; import { createFakeEngine } from './testing/fakeEngine.js'; import { @@ -44,7 +46,7 @@ test('engine event passes every argument and rejects non-functions', () => { assert.throws(() => event.addEventListener(null), TypeError); }); -test('removing listeners during dispatch is safe, as with Cesium.Event', () => { +test('a listener removed during dispatch is not called in that dispatch', () => { const event = createEngineEvent(); const seen = []; let removeLater; @@ -76,23 +78,60 @@ test('listeners added during dispatch run from the next raise', () => { assert.deepEqual(seen, ['first', 'first', 'added']); }); -test('a listener can be added again after removal, and each handle removes its own entry', () => { +test('adding the same listener twice registers it once; every remover handle is idempotent', () => { const event = createEngineEvent(); let count = 0; const listener = () => count++; const first = event.addEventListener(listener); - first(); - first(); const second = event.addEventListener(listener); - const third = event.addEventListener(listener); + assert.equal(event.numberOfListeners, 1); event.raiseEvent(); - assert.equal(count, 2); + assert.equal(count, 1); second(); + second(); + first(); event.raiseEvent(); - assert.equal(count, 3); - third(); + assert.equal(count, 1); + assert.equal(event.numberOfListeners, 0); + const again = event.addEventListener(listener); + first(); event.raiseEvent(); - assert.equal(count, 3); + assert.equal(count, 2, 'a stale handle removed a newer registration'); + again(); + assert.equal(event.numberOfListeners, 0); +}); + +test('removing and re-adding a listener during dispatch leaves it registered', () => { + const event = createEngineEvent(); + const seen = []; + const later = () => seen.push('later'); + event.addEventListener(() => { + seen.push('first'); + if (seen.length > 1) return; + event.removeEventListener(later); + event.addEventListener(later); + }); + event.addEventListener(later); + event.raiseEvent(); + assert.equal(event.numberOfListeners, 2); + event.raiseEvent(); + assert.deepEqual(seen, ['first', 'first', 'later']); +}); + +test('engine events are branded so a raw Cesium.Event fails the shape check', () => { + assert.equal(isEngineEvent(createEngineEvent()), true); + const raw = { addEventListener() {}, removeEventListener() {} }; + assert.equal(isEngineEvent(raw), false); + const engine = createFakeEngine(); + assert.throws( + () => + assertGlobeEngine({ + ...engine, + frames: { ...engine.frames, preRender: raw }, + }), + /frames\.preRender/, + ); + assert.deepEqual(MODIFIERS, ['ctrl', 'shift', 'alt']); }); test('a throwing listener does not leave the event stuck in dispatch', () => { @@ -168,3 +207,32 @@ test('the contract suite reports failures of a misbehaving engine', async () => assert.ok(failed.every((result) => result.error instanceof Error)); assert.ok(results.some((result) => result.ok)); }); + +test('the contract suite rejects a raw Cesium-style event that double-registers', async () => { + const engine = createFakeEngine(); + // Cesium.Event pushes a listener again when it is added twice. + const listeners = []; + const raw = { + addEventListener(listener) { + listeners.push(listener); + return () => raw.removeEventListener(listener); + }, + removeEventListener(listener) { + const index = listeners.indexOf(listener); + if (index >= 0) listeners.splice(index, 1); + return index >= 0; + }, + }; + engine.frames.postRender.addEventListener((time) => { + for (const listener of [...listeners]) listener(time); + }); + const wrapped = { ...engine, frames: { ...engine.frames, postRender: raw } }; + const results = await runContractSuite(wrapped, { + waitFrames: async (count) => engine.fake.frames(count), + }); + const failed = results.filter((result) => !result.ok).map((r) => r.name); + assert.ok( + failed.some((name) => /twice/.test(name)), + failed.join('\n'), + ); +}); diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index 537ed3ba1..3cc87948f 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -10,9 +10,9 @@ // Each check sets the view it needs; checks may leave the camera anywhere. import * as geo from '../../geo/index.js'; -import { INPUT_TYPES, assertGlobeEngine } from '../contract.js'; +import { INPUT_TYPES, MODIFIERS, assertGlobeEngine } from '../contract.js'; -const { cartesian2, cartesian3, cartographic, ellipsoid, math } = geo; +const { cartesian2, cartesian3, cartographic, ellipsoid, math, matrix4 } = geo; const WGS84 = ellipsoid.WGS84; class ContractError extends Error { @@ -42,6 +42,23 @@ const isCartesian3 = (value) => Number.isFinite(value.y) && Number.isFinite(value.z); +// The local east/north/up axes at a position. +function localAxes(position) { + const frame = geo.transforms.eastNorthUpToFixedFrame(position, WGS84); + return { + frame, + east: cartesian3.create(frame[0], frame[1], frame[2]), + north: cartesian3.create(frame[4], frame[5], frame[6]), + up: cartesian3.create(frame[8], frame[9], frame[10]), + }; +} + +const inRange = (value, low, high, label) => + ensure( + typeof value === 'number' && value >= low && value < high, + `${label} ${value} is outside [${low}, ${high})`, + ); + const unit = (vector) => cartesian3.normalize(vector, cartesian3.create()); const along = (origin, direction, distance) => cartesian3.add( @@ -313,7 +330,7 @@ export function createContractSuite( }, ], [ - 'camera.flyTo: duration 0 lands on the destination and completes', + 'camera.flyTo: duration 0 lands and completes before flyTo returns', async () => { await nadirView(); const destination = cartesian3.fromDegrees(-3.7, 40.4, 50000); @@ -325,8 +342,11 @@ export function createContractSuite( complete: () => outcome.push('complete'), cancel: () => outcome.push('cancel'), }); - await until(() => outcome.length > 0, 'flight completion'); - ensure(outcome.join() === 'complete', `flight ended with ${outcome}`); + ensure( + outcome.join() === 'complete', + `flyTo returned with [${outcome}] instead of [complete]`, + ); + ensure(!camera.flightActive(), 'a duration-0 flight is still active'); const pose = camera.pose(); near( cartesian3.distance(pose.position, destination), @@ -595,8 +615,30 @@ export function createContractSuite( }, ], [ - 'surface: supported, tilesLoaded and sampleHeight shapes', + 'surface: flags, tilesLoaded, sampleHeight, terrainHeight and clampToHeight shapes', async () => { + for (const flag of ['terrainHeightSupported', 'clampToHeightSupported']) + ensure( + typeof engine.surface[flag] === 'boolean', + `${flag} is not boolean`, + ); + const place = cartographic.fromDegrees(20, 10, 0); + if (engine.surface.terrainHeightSupported) { + const terrain = engine.surface.terrainHeight(place); + ensure( + terrain === undefined || Number.isFinite(terrain), + `terrainHeight returned ${terrain}`, + ); + } + if (engine.surface.clampToHeightSupported) { + const clamped = engine.surface.clampToHeight( + cartesian3.fromDegrees(20, 10, 5000), + ); + ensure( + clamped === undefined || isCartesian3(clamped), + 'clampToHeight is not a Cartesian3', + ); + } ensure( typeof engine.surface.supported === 'boolean', 'supported is not boolean', @@ -619,13 +661,15 @@ export function createContractSuite( 'input: every type subscribes and unsubscribes; camera inputs toggle', async () => { for (const type of INPUT_TYPES) { - const off = engine.input.on(type, () => {}); - ensure( - typeof off === 'function', - `input.on('${type}') returned no remover`, - ); - off(); - off(); + for (const modifier of [undefined, ...MODIFIERS]) { + const off = engine.input.on(type, () => {}, { modifier }); + ensure( + typeof off === 'function', + `input.on('${type}', ${modifier}) returned no remover`, + ); + off(); + off(); + } } engine.input.setCameraInputs(false); engine.input.setCameraInputs(true); @@ -652,6 +696,457 @@ export function createContractSuite( ); }, ], + [ + 'events: adding a listener twice registers it once', + async () => { + let once = 0; + let twice = 0; + const reference = () => once++; + const listener = () => twice++; + const removers = [ + engine.frames.postRender.addEventListener(reference), + engine.frames.postRender.addEventListener(listener), + engine.frames.postRender.addEventListener(listener), + ]; + try { + await until(() => once >= 2, 'postRender'); + } finally { + for (const remove of removers) remove(); + } + ensure( + twice === once, + `a listener added twice fired ${twice} times in ${once} frames`, + ); + removers[1](); + removers[2](); + await settle(2); + ensure(twice === once, 'a removed listener still fired'); + }, + ], + [ + 'events: removal during dispatch skips the listener; re-adding keeps it', + async () => { + const event = engine.frames.postRender; + const seen = []; + const victim = () => seen.push('victim'); + const survivor = () => seen.push('survivor'); + let dispatches = 0; + const remover = () => { + dispatches++; + if (dispatches !== 1) return; + event.removeEventListener(victim); + event.removeEventListener(survivor); + event.addEventListener(survivor); + }; + event.addEventListener(remover); + event.addEventListener(victim); + event.addEventListener(survivor); + try { + await until(() => dispatches >= 3, 'three postRender dispatches'); + } finally { + for (const listener of [remover, victim, survivor]) + event.removeEventListener(listener); + } + ensure( + !seen.includes('victim'), + 'a listener removed during dispatch ran', + ); + ensure( + seen.includes('survivor'), + 'a listener removed and re-added during dispatch was lost', + ); + }, + ], + [ + 'camera.pose: angle ranges follow Cesium', + async () => { + const destination = cartesian3.fromDegrees(30, 20, 20000); + camera.setView({ + destination, + orientation: { heading: -0.5, pitch: -0.4, roll: -0.2 }, + }); + await settle(); + let pose = camera.pose(); + inRange(pose.heading, 0, math.TWO_PI, 'heading'); + inRange(pose.roll, 0, math.TWO_PI, 'roll'); + near(pose.heading, math.TWO_PI - 0.5, 1e-6, 'heading of −0.5'); + near(pose.roll, math.TWO_PI - 0.2, 1e-6, 'roll of −0.2'); + ensure( + pose.pitch >= -math.PI_OVER_TWO && pose.pitch <= math.PI_OVER_TWO, + `pitch ${pose.pitch} outside [−π/2, π/2]`, + ); + camera.setView({ + destination, + orientation: { heading: 0.7, pitch: -math.PI_OVER_TWO, roll: 0.3 }, + }); + await settle(); + pose = camera.pose(); + near(pose.roll, 0, 0, 'roll looking straight down'); + inRange(pose.heading, 0, math.TWO_PI, 'heading looking straight down'); + }, + ], + [ + 'camera.setView: heading π/2 at pitch 0 looks east', + async () => { + const destination = cartesian3.fromDegrees(-120, 35, 3000); + camera.setView({ + destination, + orientation: { heading: math.PI_OVER_TWO, pitch: 0, roll: 0 }, + }); + await settle(); + const pose = camera.pose(); + const axes = localAxes(pose.position); + near( + cartesian3.dot(pose.direction, axes.east), + 1, + 1e-6, + 'direction·east', + ); + near(cartesian3.dot(pose.up, axes.up), 1, 1e-6, 'up·localUp'); + }, + ], + [ + 'camera.setView: pitch −π/2 looks down the local up', + async () => { + const destination = cartesian3.fromDegrees(-120, 35, 3000); + camera.setView({ + destination, + orientation: { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 }, + }); + await settle(); + const pose = camera.pose(); + const axes = localAxes(pose.position); + near(cartesian3.dot(pose.direction, axes.up), -1, 1e-6, 'direction·up'); + near( + cartesian3.dot(pose.up, axes.north), + 1, + 1e-6, + 'up·north at heading 0', + ); + }, + ], + [ + 'camera.setView: positive roll tilts up toward the camera right', + async () => { + const destination = cartesian3.fromDegrees(10, 45, 3000); + camera.setView({ + destination, + orientation: { heading: 0.4, pitch: -0.2, roll: 0 }, + }); + await settle(); + const level = camera.pose(); + camera.setView({ + destination, + orientation: { heading: 0.4, pitch: -0.2, roll: 0.3 }, + }); + await settle(); + const rolled = camera.pose(); + near( + cartesian3.dot(rolled.up, level.right), + Math.sin(0.3), + 1e-6, + 'up·levelRight', + ); + near( + cartesian3.dot(rolled.direction, level.direction), + 1, + 1e-9, + 'roll changed the direction', + ); + }, + ], + [ + 'camera.flightActive: true during a flight, false after it ends', + async () => { + await nadirView(); + ensure(!camera.flightActive(), 'a flight is active before flyTo'); + camera.flyTo({ + destination: cartesian3.fromDegrees(100, 0, 5e6), + duration: 5, + }); + ensure(camera.flightActive(), 'no flight is active after flyTo'); + camera.cancelFlight(); + ensure( + !camera.flightActive(), + 'the flight is active after cancelFlight', + ); + }, + ], + [ + 'camera.flyToSphere: Cesium default heading, pitch and range', + async () => { + await nadirView(); + const center = cartesian3.fromDegrees(2.35, 48.85, 100); + const radius = 1000; + let done = false; + camera.flyToSphere( + { center, radius }, + { duration: 0, complete: () => (done = true) }, + ); + ensure( + done, + 'a duration-0 sphere flight did not complete synchronously', + ); + const pose = camera.pose(); + const { width, height } = engine.host.size(); + const tanPhi = Math.tan(pose.fovy / 2); + const tanTheta = (width / height) * tanPhi; + const range = Math.max(100, radius / tanTheta, radius / tanPhi); + near( + cartesian3.distance(pose.position, center), + range, + range * 1e-6, + 'range (m)', + ); + const axes = localAxes(center); + const toCamera = unit( + cartesian3.subtract(pose.position, center, cartesian3.create()), + ); + near( + cartesian3.dot(toCamera, axes.up), + Math.sin(Math.PI / 4), + 1e-6, + 'pitch −π/4', + ); + near( + cartesian3.dot(toCamera, axes.east), + 0, + 1e-6, + 'heading 0 (east offset)', + ); + ensure( + cartesian3.dot(toCamera, axes.north) < 0, + 'camera is not south of the centre', + ); + camera.flyToSphere({ center, radius: 0 }, { duration: 0 }); + near( + cartesian3.distance(camera.pose().position, center), + 100, + 1e-3, + 'minimum range (m)', + ); + const identity = camera.transform(); + for (let index = 0; index < 16; index++) + near( + identity[index], + matrix4.IDENTITY[index], + 0, + `transform[${index}]`, + ); + }, + ], + [ + 'camera.lookAtTransform: heading/pitch/range and Cartesian offsets, transform and release', + async () => { + await nadirView(); + const target = cartesian3.fromDegrees(-0.12, 51.5, 0); + const { frame, up } = localAxes(target); + camera.lookAtTransform(frame, { + heading: 0.4, + pitch: -0.7, + range: 2000, + }); + await settle(); + let pose = camera.pose(); + near( + cartesian3.distance(pose.position, target), + 2000, + 1e-3, + 'range (m)', + ); + const aim = () => + cartesian3.dot( + unit( + cartesian3.subtract( + target, + camera.pose().position, + cartesian3.create(), + ), + ), + camera.pose().direction, + ); + near(aim(), 1, 1e-9, 'direction·toTarget'); + const held = camera.transform(); + for (let index = 0; index < 16; index++) + near(held[index], frame[index], 1e-6, `transform[${index}]`); + camera.lookAtTransform(frame, cartesian3.create(0, -1000, 1000)); + await settle(); + pose = camera.pose(); + near( + cartesian3.distance(pose.position, target), + Math.SQRT2 * 1000, + 1e-3, + 'Cartesian offset range (m)', + ); + near(aim(), 1, 1e-9, 'Cartesian offset direction·toTarget'); + ensure( + cartesian3.dot(pose.up, up) > 0, + 'up does not follow the frame +Z', + ); + camera.release(); + await settle(); + near( + cartesian3.distance(camera.pose().position, pose.position), + 0, + 0.01, + 'release moved the camera (m)', + ); + const released = camera.transform(); + for (let index = 0; index < 16; index++) + near( + released[index], + matrix4.IDENTITY[index], + 0, + `released transform[${index}]`, + ); + }, + ], + [ + 'camera.zoomIn and zoomOut move along the view direction', + async () => { + camera.setView({ + destination: cartesian3.fromDegrees(20, 10, 50000), + orientation: { heading: 0.3, pitch: -0.9, roll: 0 }, + }); + await settle(); + const start = camera.pose(); + camera.zoomIn(2000); + await settle(); + const zoomed = camera.pose().position; + const moved = cartesian3.subtract( + zoomed, + start.position, + cartesian3.create(), + ); + near( + cartesian3.dot(moved, start.direction), + 2000, + 1e-3, + 'zoomIn distance (m)', + ); + near( + cartesian3.magnitude(moved), + 2000, + 1e-3, + 'zoomIn stays on the view axis', + ); + camera.zoomOut(500); + await settle(); + near( + cartesian3.distance(camera.pose().position, start.position), + 1500, + 1e-3, + 'zoomOut distance (m)', + ); + }, + ], + [ + 'camera.viewRectangle: wraps west > east across the antimeridian', + async () => { + camera.setView({ + destination: cartesian3.fromDegrees(180, 0, 1e6), + orientation: { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 }, + }); + await settle(); + const rectangle = camera.viewRectangle(); + ensure(rectangle, 'no view rectangle over the antimeridian'); + ensure( + rectangle.west > rectangle.east, + `west ${rectangle.west} is not east of east ${rectangle.east}`, + ); + ensure( + rectangle.west > 0 && rectangle.east < 0, + 'the rectangle does not cross ±180°', + ); + ensure( + rectangle.south < 0 && rectangle.north > 0, + 'the rectangle misses the equator', + ); + }, + ], + [ + 'camera.viewRectangle: spans all longitudes over a pole', + async () => { + camera.setView({ + destination: cartesian3.fromDegrees(0, 89, 1e6), + orientation: { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 }, + }); + await settle(); + const rectangle = camera.viewRectangle(); + ensure(rectangle, 'no view rectangle over the pole'); + near(rectangle.west, -Math.PI, 1e-9, 'west'); + near(rectangle.east, Math.PI, 1e-9, 'east'); + near(rectangle.north, math.PI_OVER_TWO, 1e-9, 'north'); + ensure( + rectangle.south > 0, + `south ${rectangle.south} crosses the equator`, + ); + }, + ], + [ + 'projector.viewProjection: agrees with worldToWindow', + async () => { + camera.setView({ + destination: cartesian3.fromDegrees(20, 10, 30000), + orientation: { heading: 0.6, pitch: -0.5, roll: 0.1 }, + }); + await settle(); + const { width, height } = engine.host.size(); + const result = new Array(16).fill(0); + const matrix = engine.projector.viewProjection(result); + ensure( + matrix === result, + 'viewProjection did not fill its result argument', + ); + ensure( + matrix.length === 16 && matrix.every(Number.isFinite), + 'not 16 finite numbers', + ); + let compared = 0; + for (const [x, y] of [ + [width / 2, height / 2], + [width / 5, height / 4], + [width * 0.9, height * 0.8], + ]) { + const point = camera.pickEllipsoid(cartesian2.create(x, y)); + if (!point) continue; + const window = engine.projector.worldToWindow(point); + ensure(window, 'a picked point did not project'); + const clip = [0, 1, 2, 3].map( + (row) => + matrix[row] * point.x + + matrix[4 + row] * point.y + + matrix[8 + row] * point.z + + matrix[12 + row], + ); + ensure(clip[3] > 0, 'a visible point has clip.w <= 0'); + near(((clip[0] / clip[3] + 1) / 2) * width, window.x, 0.5, 'x (px)'); + near(((1 - clip[1] / clip[3]) / 2) * height, window.y, 0.5, 'y (px)'); + compared++; + } + ensure(compared >= 2, 'too few screen points hit the globe'); + }, + ], + [ + 'projector.horizonVisible: the nadir is visible and the far side hidden', + async () => { + await nadirView(); + ensure( + engine.projector.horizonVisible(cartesian3.fromDegrees(20, 10, 0)), + 'the nadir is hidden', + ); + ensure( + !engine.projector.horizonVisible( + cartesian3.fromDegrees(-160, -10, 0), + ), + 'the antipode is visible', + ); + ensure( + engine.projector.horizonVisible(cartesian3.fromDegrees(-70, 10, 2e7)), + 'a high point beside the limb is hidden', + ); + }, + ], ]; return checks.map(([name, run]) => ({ name, run })); } diff --git a/src/globe/testing/fakeEngine.js b/src/globe/testing/fakeEngine.js index 16a50a405..b1d8bb0d6 100644 --- a/src/globe/testing/fakeEngine.js +++ b/src/globe/testing/fakeEngine.js @@ -3,12 +3,29 @@ // against it, records every contract call and lets tests emit events, drive // input, advance frames and script pick/height/projection results through // `engine.fake`. It passes src/globe/testing/contractSuite.mjs. +// +// Simplifications against Cesium, which shell tests must not rely on: +// - a flight interpolates the position linearly (eased) through space and +// snaps the orientation at the end; Cesium arcs over the globe and turns +// gradually. A flight without a duration lasts 3 s of fake frame time. +// - moveStart/changed/moveEnd are detected once per `fake.frame()`, and +// moveEnd fires after one frame without movement; Cesium waits 500 ms +// (scene.cameraEventWaitTime) of wall time. +// - nothing occludes picks or projection except where the contract says so +// (horizonVisible); there is no terrain or scene geometry unless scripted. import * as geo from '../../geo/index.js'; -import { INPUT_TYPES, createEngineEvent } from '../contract.js'; +import { INPUT_TYPES, MODIFIERS, createEngineEvent } from '../contract.js'; const { cartesian2, cartesian3, cartographic, ellipsoid, math, matrix4 } = geo; const WGS84 = ellipsoid.WGS84; +const RADII = WGS84.radii; +const MAXIMUM_RADIUS = Math.max(RADII.x, RADII.y, RADII.z); +const DEFAULT_FLIGHT_SECONDS = 3; +const DEFAULT_ZOOM_AMOUNT = 100000; // Cesium's camera.defaultZoomAmount +const MINIMUM_SPHERE_RANGE = 100; +const NEAR = 1; +const FAR = 1e9; const SCRIPTABLE = new Set([ 'pick', 'drillPick', @@ -16,6 +33,8 @@ const SCRIPTABLE = new Set([ 'pickEllipsoid', 'viewRectangle', 'sampleHeight', + 'terrainHeight', + 'clampToHeight', 'tilesLoaded', 'worldToWindow', ]); @@ -27,11 +46,26 @@ const EASINGS = { }; const copy3 = (value) => cartesian3.create(value.x, value.y, value.z); +const scaled = (vector, scalar) => + cartesian3.multiplyByScalar(vector, scalar, cartesian3.create()); +const sum = (...vectors) => + vectors.reduce( + (total, vector) => cartesian3.add(total, vector, total), + cartesian3.create(), + ); +const unit = (vector) => cartesian3.normalize(vector, cartesian3.create()); +const cross = (left, right) => + cartesian3.cross(left, right, cartesian3.create()); + +// An angle in [0, 2π), as Cesium's camera reports it. +function wrapAngle(angle) { + const value = math.zeroToTwoPi(angle); + return value < 1e-12 || math.TWO_PI - value < 1e-12 ? 0 : value; +} // The local east/north/up axes at a position, in Earth-fixed coordinates. function localAxes(position) { - const frame = matrix4.create(); - geo.transforms.eastNorthUpToFixedFrame(position, WGS84, frame); + const frame = geo.transforms.eastNorthUpToFixedFrame(position, WGS84); return { east: cartesian3.create(frame[0], frame[1], frame[2]), north: cartesian3.create(frame[4], frame[5], frame[6]), @@ -40,14 +74,16 @@ function localAxes(position) { } const combine = (axes, e, n, u) => - cartesian3.create( - axes.east.x * e + axes.north.x * n + axes.up.x * u, - axes.east.y * e + axes.north.y * n + axes.up.y * u, - axes.east.z * e + axes.north.z * n + axes.up.z * u, - ); + sum(scaled(axes.east, e), scaled(axes.north, n), scaled(axes.up, u)); +const toLocal = (axes, vector) => ({ + e: cartesian3.dot(vector, axes.east), + n: cartesian3.dot(vector, axes.north), + u: cartesian3.dot(vector, axes.up), +}); -// Direction and up for a heading/pitch/roll in the local frame at position. -// Heading 0 looks north, positive pitch looks up, as in Cesium. +// Direction and up for a heading/pitch/roll in the local frame at position, +// as Cesium's setView builds them: heading 0 looks north, positive pitch looks +// up, positive roll tilts `up` toward the camera's right. function orientationVectors(position, heading, pitch, roll) { const axes = localAxes(position); const cosPitch = Math.cos(pitch); @@ -58,54 +94,50 @@ function orientationVectors(position, heading, pitch, roll) { Math.sin(pitch), ); const levelRight = combine(axes, Math.cos(heading), -Math.sin(heading), 0); - const levelUp = cartesian3.cross(levelRight, direction, cartesian3.create()); - const up = cartesian3.create(); - cartesian3.add( - cartesian3.multiplyByScalar(levelUp, Math.cos(roll), cartesian3.create()), - cartesian3.multiplyByScalar( - levelRight, - -Math.sin(roll), - cartesian3.create(), - ), - up, + const levelUp = cross(levelRight, direction); + const up = sum( + scaled(levelUp, Math.cos(roll)), + scaled(levelRight, Math.sin(roll)), ); - return { direction, up: cartesian3.normalize(up, up) }; + return { direction: unit(direction), up: unit(up) }; } +// Cesium's Camera heading/pitch/roll getters, in the local frame at position. function headingPitchRoll(position, direction, up) { const axes = localAxes(position); - const e = cartesian3.dot(direction, axes.east); - const n = cartesian3.dot(direction, axes.north); - const u = cartesian3.dot(direction, axes.up); - const pitch = Math.asin(math.clamp(u, -1, 1)); - // Looking straight up or down, heading follows the camera's up vector. - const heading = - Math.hypot(e, n) > 1e-9 - ? math.zeroToTwoPi(Math.atan2(e, n)) - : math.zeroToTwoPi( - Math.atan2( - cartesian3.dot(up, axes.east) * -Math.sign(u), - cartesian3.dot(up, axes.north) * -Math.sign(u), - ), - ); - const level = orientationVectors(position, heading, pitch, 0); - const levelRight = cartesian3.cross( - level.direction, - level.up, - cartesian3.create(), + 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 raw = vertical + ? Math.atan2(u.n, u.e) - math.PI_OVER_TWO + : Math.atan2(d.n, d.e) - math.PI_OVER_TWO; + return { + heading: wrapAngle(math.TWO_PI - math.zeroToTwoPi(raw)), + pitch: math.PI_OVER_TWO - Math.acos(math.clamp(d.u, -1, 1)), + roll: vertical ? 0 : wrapAngle(Math.atan2(-r.u, u.u)), + }; +} + +// Camera offset from a target for a HeadingPitchRange, in the target's frame. +function offsetFromHeadingPitchRange(heading, pitch, range) { + const p = math.clamp(pitch, -math.PI_OVER_TWO, math.PI_OVER_TWO); + const cosPitch = Math.cos(p); + return cartesian3.create( + -Math.sin(heading) * cosPitch * range, + -Math.cos(heading) * cosPitch * range, + -Math.sin(p) * range, ); - const roll = Math.atan2( - -cartesian3.dot(up, levelRight), - cartesian3.dot(up, level.up), - ); - return { heading, pitch, roll: Math.abs(roll) < 1e-12 ? 0 : roll }; } // Nearest intersection of a ray with the WGS84 ellipsoid, if any. function intersectEllipsoid(origin, direction) { - const r = WGS84.radii; - const o = [origin.x / r.x, origin.y / r.y, origin.z / r.z]; - const d = [direction.x / r.x, direction.y / r.y, direction.z / r.z]; + const o = [origin.x / RADII.x, origin.y / RADII.y, origin.z / RADII.z]; + const d = [ + direction.x / RADII.x, + direction.y / RADII.y, + direction.z / RADII.z, + ]; const a = d[0] * d[0] + d[1] * d[1] + d[2] * d[2]; const b = 2 * (o[0] * d[0] + o[1] * d[1] + o[2] * d[2]); const c = o[0] * o[0] + o[1] * o[1] + o[2] * o[2] - 1; @@ -115,11 +147,60 @@ function intersectEllipsoid(origin, direction) { const near = (-b - root) / (2 * a); const t = near > 0 ? near : (-b + root) / (2 * a); if (!(t > 0)) return undefined; - return cartesian3.add( - origin, - cartesian3.multiplyByScalar(direction, t, cartesian3.create()), - cartesian3.create(), + return sum(origin, scaled(direction, t)); +} + +// Cesium's computeHorizonQuad: four limb points around the camera's nadir. +function horizonQuad(position) { + const q = cartesian3.create( + position.x / RADII.x, + position.y / RADII.y, + position.z / RADII.z, ); + const qMagnitude = cartesian3.magnitude(q); + const qUnit = unit(q); + let east; + let north; + if (cartesian3.equalsEpsilon(qUnit, cartesian3.UNIT_Z, math.EPSILON10)) { + east = cartesian3.create(0, 1, 0); + north = cartesian3.create(0, 0, 1); + } else { + east = unit(cross(cartesian3.UNIT_Z, qUnit)); + north = unit(cross(qUnit, east)); + } + const center = scaled(qUnit, 1 / qMagnitude); + const offset = + Math.sqrt(Math.max(0, cartesian3.magnitudeSquared(q) - 1)) / qMagnitude; + const point = (n, e) => { + const p = sum(center, scaled(north, n * offset), scaled(east, e * offset)); + return cartesian3.create(p.x * RADII.x, p.y * RADII.y, p.z * RADII.z); + }; + // Same order as the corner picks: upper-left, lower-left, lower-right, upper-right. + return [point(1, -1), point(-1, -1), point(-1, 1), point(1, 1)]; +} + +// Cesium's Rectangle.fromCartographicArray, including the antimeridian case. +function rectangleFromCartographics(points) { + let west = Infinity; + let east = -Infinity; + let westOverIDL = Infinity; + let eastOverIDL = -Infinity; + let south = Infinity; + let north = -Infinity; + for (const { longitude, latitude } of points) { + west = Math.min(west, longitude); + east = Math.max(east, longitude); + south = Math.min(south, latitude); + north = Math.max(north, latitude); + const adjusted = longitude >= 0 ? longitude : longitude + math.TWO_PI; + westOverIDL = Math.min(westOverIDL, adjusted); + eastOverIDL = Math.max(eastOverIDL, adjusted); + } + if (east - west > eastOverIDL - westOverIDL) { + west = westOverIDL > Math.PI ? westOverIDL - math.TWO_PI : westOverIDL; + east = eastOverIDL > Math.PI ? eastOverIDL - math.TWO_PI : eastOverIDL; + } + return { west, south, east, north }; } /** @@ -153,6 +234,7 @@ export function createFakeEngine(options = {}) { }; const inputs = new Map(INPUT_TYPES.map((type) => [type, new Set()])); const scripts = new Map(); + let transform = [...matrix4.IDENTITY]; let percentageChanged = 0.5; let continuous = false; let flight = null; @@ -172,85 +254,63 @@ export function createFakeEngine(options = {}) { }; const record = (method, args) => fake.calls.push({ method, args }); - function setOrientation(position, orientation) { - if (orientation && 'direction' in orientation) { - state.position = copy3(position); - state.direction = cartesian3.normalize( - orientation.direction, - cartesian3.create(), - ); - state.up = cartesian3.normalize(orientation.up, cartesian3.create()); - return; - } - // Missing angles keep the current ones, measured where the camera is now. - const current = poseAngles(); - state.position = copy3(position); + const aspect = () => width / height; + const right = () => unit(cross(state.direction, state.up)); + const poseAngles = () => + headingPitchRoll(state.position, state.direction, state.up); + + // The pose for a destination and orientation; missing angles use `fallback`. + function viewFor(position, orientation, fallback) { + if (orientation && 'direction' in orientation) + return { + position: copy3(position), + direction: unit(orientation.direction), + up: unit(orientation.up), + }; const vectors = orientationVectors( - state.position, - orientation?.heading ?? current.heading, - orientation?.pitch ?? current.pitch, - orientation?.roll ?? current.roll, + position, + orientation?.heading ?? fallback.heading, + orientation?.pitch ?? fallback.pitch, + orientation?.roll ?? fallback.roll, ); - state.direction = vectors.direction; - state.up = vectors.up; + return { position: copy3(position), ...vectors }; + } + // Cesium's setView/flyTo defaults for missing angles. + const CESIUM_DEFAULTS = { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 }; + + function apply(view) { + state.position = copy3(view.position); + state.direction = copy3(view.direction); + state.up = copy3(view.up); } - function poseAngles() { - if (cartesian3.magnitudeSquared(state.direction) === 0) - return { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 }; - return headingPitchRoll(state.position, state.direction, state.up); + function setTransform(next, target) { + transform = [...next]; + lookAtTarget = target ? copy3(target) : null; } - function right() { - const result = cartesian3.cross( - state.direction, - state.up, - cartesian3.create(), - ); - return cartesian3.normalize(result, result); - } - - // Camera placed at `range` from target, looking at it, with heading/pitch - // measured in the target's local frame (Cesium's HeadingPitchRange). - function offsetView(target, heading, pitch, range) { - const toTarget = orientationVectors(target, heading, pitch, 0); - const position = cartesian3.subtract( - target, - cartesian3.multiplyByScalar( - toTarget.direction, - range, - cartesian3.create(), - ), - cartesian3.create(), - ); - return { position, direction: toTarget.direction, up: toTarget.up }; - } - - function scripted(name, args, fallback) { - if (!scripts.has(name)) return fallback(); - const value = scripts.get(name); - return typeof value === 'function' ? value(...args) : value; - } - - function startFlight(options, target) { - if (flight) cancelCurrentFlight(); - const duration = Math.max(0, options.duration ?? 3); - flight = { - start: copy3(state.position), - target, - duration: duration * 1000, - elapsed: 0, - ease: EASINGS[options.easing] ?? EASINGS.cubicInOut, - complete: options.complete, - cancel: options.cancel, - }; - if (duration === 0) applyFlightTarget(flight.target); - } - - function applyFlightTarget(target) { - state.position = copy3(target.position); - state.direction = copy3(target.direction); - state.up = copy3(target.up); + function lookAtFrame(frame, offset) { + const local = + offset && 'heading' in offset + ? offsetFromHeadingPitchRange( + offset.heading, + offset.pitch, + offset.range, + ) + : copy3(offset); + const localDirection = unit(cartesian3.negate(local, cartesian3.create())); + let localRight = cross(localDirection, cartesian3.UNIT_Z); + if (cartesian3.magnitudeSquared(localRight) < math.EPSILON10) + localRight = copy3(cartesian3.UNIT_X); + localRight = unit(localRight); + const localUp = unit(cross(localRight, localDirection)); + const vector = (value) => + unit(matrix4.multiplyByPointAsVector(frame, value, cartesian3.create())); + apply({ + position: matrix4.multiplyByPoint(frame, local, cartesian3.create()), + direction: vector(localDirection), + up: vector(localUp), + }); } function cancelCurrentFlight() { @@ -259,12 +319,29 @@ export function createFakeEngine(options = {}) { current?.cancel?.(); } + function startFlight(flightOptions, target) { + cancelCurrentFlight(); + const duration = flightOptions.duration ?? DEFAULT_FLIGHT_SECONDS; + if (duration <= 0) { + apply(target); + flightOptions.complete?.(); + return; + } + flight = { + start: copy3(state.position), + target, + duration: duration * 1000, + elapsed: 0, + ease: EASINGS[flightOptions.easing] ?? EASINGS.cubicInOut, + complete: flightOptions.complete, + cancel: flightOptions.cancel, + }; + } + function advanceFlight(dt) { if (!flight) return; flight.elapsed += dt; - const t = flight.duration - ? Math.min(1, flight.elapsed / flight.duration) - : 1; + const t = Math.min(1, flight.elapsed / flight.duration); if (t < 1) { cartesian3.lerp( flight.start, @@ -276,7 +353,7 @@ export function createFakeEngine(options = {}) { } const current = flight; flight = null; - applyFlightTarget(current.target); + apply(current.target); current.complete?.(); } @@ -333,8 +410,7 @@ export function createFakeEngine(options = {}) { const depth = cartesian3.dot(offset, state.direction); if (!(depth > 0)) return undefined; const tangent = Math.tan(fovy / 2); - const ndcX = - cartesian3.dot(offset, right()) / (depth * tangent * (width / height)); + const ndcX = cartesian3.dot(offset, right()) / (depth * tangent * aspect()); const ndcY = cartesian3.dot(offset, state.up) / (depth * tangent); const x = ((ndcX + 1) / 2) * width; const y = ((1 - ndcY) / 2) * height; @@ -344,48 +420,136 @@ export function createFakeEngine(options = {}) { return result; } + function viewProjection(result = matrix4.create()) { + const f = 1 / Math.tan(fovy / 2); + const rows = [ + [right(), f / aspect()], + [state.up, f], + ]; + const D = state.direction; + const P = state.position; + for (const [row, [axis, scale]] of rows.entries()) { + result[row] = axis.x * scale; + result[4 + row] = axis.y * scale; + result[8 + row] = axis.z * scale; + result[12 + row] = -cartesian3.dot(axis, P) * scale; + } + // Eye z is −direction·(p − position); clip.w = −z_eye. + const A = (FAR + NEAR) / (NEAR - FAR); + const B = (2 * FAR * NEAR) / (NEAR - FAR); + result[2] = -D.x * A; + result[6] = -D.y * A; + result[10] = -D.z * A; + result[14] = cartesian3.dot(D, P) * A + B; + result[3] = D.x; + result[7] = D.y; + result[11] = D.z; + result[15] = -cartesian3.dot(D, P); + return result; + } + + // Cesium's EllipsoidalOccluder.isPointVisible. + function horizonVisible(position) { + const camera = cartesian3.create( + state.position.x / RADII.x, + state.position.y / RADII.y, + state.position.z / RADII.z, + ); + const limbSquared = cartesian3.magnitudeSquared(camera) - 1; + const target = cartesian3.create( + position.x / RADII.x, + position.y / RADII.y, + position.z / RADII.z, + ); + const vt = cartesian3.subtract(target, camera, cartesian3.create()); + const vtDotVc = -cartesian3.dot(vt, camera); + const occluded = + limbSquared < 0 + ? vtDotVc > 0 + : vtDotVc > limbSquared && + (vtDotVc * vtDotVc) / cartesian3.magnitudeSquared(vt) > limbSquared; + return !occluded; + } + function ray(screen) { const tangent = Math.tan(fovy / 2); const ndcX = (2 * screen.x) / width - 1; const ndcY = 1 - (2 * screen.y) / height; - const direction = combine( - { east: right(), north: state.up, up: state.direction }, - ndcX * tangent * (width / height), - ndcY * tangent, - 1, + const direction = sum( + state.direction, + scaled(right(), ndcX * tangent * aspect()), + scaled(state.up, ndcY * tangent), ); - return { - origin: copy3(state.position), - direction: cartesian3.normalize(direction, direction), - }; + return { origin: copy3(state.position), direction: unit(direction) }; } + // Whether the sphere around the Earth is fully outside the view frustum. + function globeOutsideFrustum() { + const toCentre = cartesian3.negate(state.position, cartesian3.create()); + const D = state.direction; + const tanY = Math.tan(fovy / 2); + const tanX = tanY * aspect(); + const R = right(); + const U = state.up; + const planes = [ + [D, NEAR], + [unit(sum(scaled(R, -1), scaled(D, tanX))), 0], + [unit(sum(R, scaled(D, tanX))), 0], + [unit(sum(scaled(U, -1), scaled(D, tanY))), 0], + [unit(sum(U, scaled(D, tanY))), 0], + ]; + return planes.some( + ([normal, offset]) => + cartesian3.dot(normal, toCentre) - offset < -MAXIMUM_RADIUS, + ); + } + + // Cesium's Camera.computeViewRectangle. function computedViewRectangle() { - const corners = [ + if (globeOutsideFrustum()) return undefined; + const horizon = horizonQuad(state.position); + let hits = 0; + const points = [ [0, 0], - [width, 0], [0, height], [width, height], - [width / 2, 0], - [width / 2, height], - [0, height / 2], - [width, height / 2], - ].map(([x, y]) => - intersectEllipsoid( + [width, 0], + ].map(([x, y], index) => { + const hit = intersectEllipsoid( state.position, ray(cartesian2.create(x, y)).direction, - ), - ); - if (corners.some((corner) => !corner)) return undefined; - const points = corners.map((corner) => - cartographic.fromCartesian(corner, WGS84), - ); - return { - west: Math.min(...points.map((point) => point.longitude)), - south: Math.min(...points.map((point) => point.latitude)), - east: Math.max(...points.map((point) => point.longitude)), - north: Math.max(...points.map((point) => point.latitude)), - }; + ); + if (hit) hits++; + return cartographic.fromCartesian(hit ?? horizon[index], WGS84); + }); + if (hits < 2) + return { + west: -Math.PI, + south: -math.PI_OVER_TWO, + east: Math.PI, + north: math.PI_OVER_TWO, + }; + const rectangle = rectangleFromCartographics(points); + let travelled = 0; + let last = points[3].longitude; + for (const { longitude } of points) { + const step = Math.abs(longitude - last); + travelled += step > Math.PI ? math.TWO_PI - step : step; + last = longitude; + } + if (math.equalsEpsilon(Math.abs(travelled), math.TWO_PI, 1e-9)) { + rectangle.west = -Math.PI; + rectangle.east = Math.PI; + if (points[0].latitude >= 0) rectangle.north = math.PI_OVER_TWO; + else rectangle.south = -math.PI_OVER_TWO; + } + return rectangle; + } + + function scripted(name, args, fallback) { + if (!scripts.has(name)) return fallback(); + const value = scripts.get(name); + return typeof value === 'function' ? value(...args) : value; } const camera = { @@ -407,54 +571,84 @@ export function createFakeEngine(options = {}) { record('camera.setView', [view]); if (!view?.destination) throw new TypeError('setView needs a destination'); - setOrientation(view.destination, view.orientation); + apply(viewFor(view.destination, view.orientation, CESIUM_DEFAULTS)); }, flyTo(flightOptions) { record('camera.flyTo', [flightOptions]); if (!flightOptions?.destination) throw new TypeError('flyTo needs a destination'); + startFlight( + flightOptions, + viewFor( + flightOptions.destination, + flightOptions.orientation, + CESIUM_DEFAULTS, + ), + ); + }, + flyToSphere(sphere, sphereOptions = {}) { + record('camera.flyToSphere', [sphere, sphereOptions]); + setTransform(matrix4.IDENTITY); + let range = sphereOptions.range; + if (!range) { + const tanPhi = Math.tan(fovy / 2); + const tanTheta = aspect() * tanPhi; + range = Math.max( + MINIMUM_SPHERE_RANGE, + Math.max(sphere.radius / tanTheta, sphere.radius / tanPhi), + ); + } + const frame = geo.transforms.eastNorthUpToFixedFrame( + sphere.center, + WGS84, + ); const saved = { position: state.position, direction: state.direction, up: state.up, }; - setOrientation(flightOptions.destination, flightOptions.orientation); + lookAtFrame(frame, { + heading: sphereOptions.heading ?? 0, + pitch: sphereOptions.pitch ?? -Math.PI / 4, + range, + }); const target = { position: state.position, direction: state.direction, up: state.up, }; - Object.assign(state, saved); - startFlight(flightOptions, target); - }, - flyToSphere(sphere, sphereOptions = {}) { - record('camera.flyToSphere', [sphere, sphereOptions]); - const range = - sphereOptions.range ?? - Math.max(sphere.radius / Math.sin(fovy / 2), 100); - const target = offsetView( - sphere.center, - sphereOptions.heading ?? 0, - sphereOptions.pitch ?? -0.5, - range, - ); + apply(saved); startFlight(sphereOptions, target); }, + flightActive: () => flight != null, lookAt(target, offset) { record('camera.lookAt', [target, offset]); - lookAtTarget = copy3(target); - applyFlightTarget( - offsetView(target, offset.heading, offset.pitch, offset.range), - ); + const frame = geo.transforms.eastNorthUpToFixedFrame(target, WGS84); + setTransform(frame, target); + lookAtFrame(frame, offset); }, + lookAtTransform(frame, offset) { + record('camera.lookAtTransform', [frame, offset]); + setTransform(frame, cartesian3.create(frame[12], frame[13], frame[14])); + if (offset) lookAtFrame(frame, offset); + }, + transform: () => [...transform], release() { record('camera.release', []); - lookAtTarget = null; + setTransform(matrix4.IDENTITY); }, cancelFlight() { record('camera.cancelFlight', []); cancelCurrentFlight(); }, + zoomIn(amount = DEFAULT_ZOOM_AMOUNT) { + record('camera.zoomIn', [amount]); + state.position = sum(state.position, scaled(state.direction, amount)); + }, + zoomOut(amount = DEFAULT_ZOOM_AMOUNT) { + record('camera.zoomOut', [amount]); + state.position = sum(state.position, scaled(state.direction, -amount)); + }, viewRectangle() { record('camera.viewRectangle', []); return scripted('viewRectangle', [], computedViewRectangle); @@ -514,6 +708,8 @@ export function createFakeEngine(options = {}) { return cartesian2.clone(value, result); return value; }, + viewProjection, + horizonVisible, }, frames: { preUpdate: events['frames.preUpdate'], @@ -537,14 +733,27 @@ export function createFakeEngine(options = {}) { return scripted('sampleHeight', [position, exclude], () => undefined); }, supported: true, + terrainHeight(position) { + record('surface.terrainHeight', [position]); + return scripted('terrainHeight', [position], () => 0); + }, + terrainHeightSupported: true, + clampToHeight(position, exclude) { + record('surface.clampToHeight', [position, exclude]); + return scripted('clampToHeight', [position, exclude], () => undefined); + }, + clampToHeightSupported: true, tilesLoaded: () => scripted('tilesLoaded', [], () => true), }, input: { - on(type, listener) { - record('input.on', [type]); + on(type, listener, inputOptions = {}) { + record('input.on', [type, inputOptions]); const listeners = inputs.get(type); if (!listeners) throw new TypeError(`Unknown input type: ${type}`); - const entry = (event) => listener(event); + const modifier = inputOptions.modifier; + if (modifier !== undefined && !MODIFIERS.includes(modifier)) + throw new TypeError(`Unknown input modifier: ${modifier}`); + const entry = { listener, modifier }; listeners.add(entry); return () => listeners.delete(entry); }, @@ -598,11 +807,12 @@ export function createFakeEngine(options = {}) { pose.height ?? 1e7, WGS84, ); - setOrientation( - position, - pose.direction - ? { direction: pose.direction, up: pose.up } - : { heading: pose.heading, pitch: pose.pitch, roll: pose.roll }, + apply( + viewFor( + position, + pose.direction ? { direction: pose.direction, up: pose.up } : pose, + poseAngles(), + ), ); }, setSize(nextWidth, nextHeight) { @@ -620,11 +830,15 @@ export function createFakeEngine(options = {}) { if (!event) throw new TypeError(`Unknown engine event: ${name}`); event.raiseEvent(...args); }, - /** Delivers an input event to `engine.input.on(type)` subscribers. */ - input(type, event) { + /** + * Delivers input to `engine.input.on(type)` subscribers registered for + * `modifier` (none by default), as Cesium's ScreenSpaceEventHandler does. + */ + input(type, event, modifier) { const listeners = inputs.get(type); if (!listeners) throw new TypeError(`Unknown input type: ${type}`); - for (const listener of [...listeners]) listener(event); + for (const entry of [...listeners]) + if (entry.modifier === modifier) entry.listener(event); }, /** Replaces a result with a value or a function of the call's arguments. */ script(name, valueOrFunction) { @@ -635,7 +849,6 @@ export function createFakeEngine(options = {}) { unscript(name) { scripts.delete(name); }, - flying: () => flight != null, lookAtTarget: () => (lookAtTarget ? copy3(lookAtTarget) : undefined), /** Runs one frame: preUpdate, flight progress, preRender, camera events, postRender. */ frame(dt = 16) { @@ -651,14 +864,18 @@ export function createFakeEngine(options = {}) { }, }); - fake.setPose({ - longitude: options.longitude ?? 0, - latitude: options.latitude ?? 0, - height: options.altitude ?? 1e7, - heading: 0, - pitch: -math.PI_OVER_TWO, - roll: 0, - }); + apply( + viewFor( + cartesian3.fromRadians( + options.longitude ?? 0, + options.latitude ?? 0, + options.altitude ?? 1e7, + WGS84, + ), + CESIUM_DEFAULTS, + CESIUM_DEFAULTS, + ), + ); lastFramePose = { position: copy3(state.position), direction: copy3(state.direction), diff --git a/src/globe/testing/fakeEngine.test.mjs b/src/globe/testing/fakeEngine.test.mjs index 2b468fe03..133ef472a 100644 --- a/src/globe/testing/fakeEngine.test.mjs +++ b/src/globe/testing/fakeEngine.test.mjs @@ -67,7 +67,7 @@ test('fake flights advance with frames and finish or cancel exactly once', () => complete: () => outcomes.push('complete'), cancel: () => outcomes.push('cancel'), }); - assert.equal(engine.fake.flying(), true); + assert.equal(engine.camera.flightActive(), true); engine.fake.frames(30, 16); assert.deepEqual(outcomes, []); engine.fake.frames(40, 16); @@ -87,9 +87,9 @@ test('fake flights advance with frames and finish or cancel exactly once', () => duration: 0, complete: () => outcomes.push('second'), }); - assert.deepEqual(outcomes, ['complete', 'cancel']); - engine.fake.frame(); + // Duration 0 lands and completes before flyTo returns, as in Cesium. assert.deepEqual(outcomes, ['complete', 'cancel', 'second']); + assert.equal(engine.camera.flightActive(), false); }); test('fake events can be emitted by hand and frames fire in order', () => { @@ -184,3 +184,97 @@ test('fake credits, maps and destroy keep inspectable state', () => { engine.destroy(); assert.equal(engine.fake.destroyed, true); }); + +test('the fake camera follows Cesium conventions for roll, angle ranges and zoom', () => { + const engine = createFakeEngine(); + const destination = geo.cartesian3.fromDegrees(0, 0, 1000); + engine.camera.setView({ + destination, + orientation: { heading: 0, pitch: 0, roll: 0.3 }, + }); + const pose = engine.camera.pose(); + // Facing north at (0, 0), east is +Y: positive roll tilts up toward east. + close(pose.up.y, Math.sin(0.3), 1e-9, 'up east component'); + close(pose.roll, 0.3, 1e-9, 'roll'); + engine.camera.setView({ + destination, + orientation: { heading: -0.5, pitch: 0, roll: -0.2 }, + }); + const wrapped = engine.camera.pose(); + close(wrapped.heading, 2 * Math.PI - 0.5, 1e-9, 'heading in [0, 2π)'); + close(wrapped.roll, 2 * Math.PI - 0.2, 1e-9, 'roll in [0, 2π)'); + engine.camera.setView({ + destination, + orientation: { heading: 0.4, pitch: -Math.PI / 2, roll: 0.3 }, + }); + assert.equal(engine.camera.pose().roll, 0); + engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(0, 0, 5000), + }); + const before = engine.camera.pose(); + engine.camera.zoomIn(1000); + const after = engine.camera.pose(); + close( + geo.cartesian3.distance(before.position, after.position), + 1000, + 1e-6, + 'zoomIn', + ); + engine.camera.zoomOut(400); + close(engine.camera.pose().cartographic.height, 5000 - 600, 1e-3, 'zoomOut'); +}); + +test('fake lookAtTransform keeps the transform until release', () => { + const engine = createFakeEngine(); + const target = geo.cartesian3.fromDegrees(10, 10, 0); + const frame = geo.transforms.eastNorthUpToFixedFrame(target); + engine.camera.lookAtTransform(frame, { + heading: 0, + pitch: -0.5, + range: 1000, + }); + assert.deepEqual(engine.camera.transform(), [...frame]); + engine.camera.transform()[0] = 42; + assert.notEqual(engine.camera.transform()[0], 42); + engine.camera.release(); + assert.deepEqual(engine.camera.transform(), [...geo.matrix4.IDENTITY]); + engine.camera.lookAt(target, { heading: 0, pitch: -0.5, range: 1000 }); + assert.deepEqual(engine.camera.transform(), [...frame]); + assert.deepEqual(engine.fake.lookAtTarget(), target); +}); + +test('fake input honours modifiers like Cesium: unmodified listeners skip modified input', () => { + const engine = createFakeEngine(); + const seen = []; + engine.input.on('click', () => seen.push('plain')); + engine.input.on('click', () => seen.push('ctrl'), { modifier: 'ctrl' }); + engine.fake.input('click', { position: geo.cartesian2.create(1, 1) }); + engine.fake.input('click', { position: geo.cartesian2.create(1, 1) }, 'ctrl'); + engine.fake.input( + 'click', + { position: geo.cartesian2.create(1, 1) }, + 'shift', + ); + assert.deepEqual(seen, ['plain', 'ctrl']); + assert.throws( + () => engine.input.on('click', () => {}, { modifier: 'meta' }), + /Unknown input modifier/, + ); +}); + +test('fake terrain height and clamp results default to the bare ellipsoid and can be scripted', () => { + const engine = createFakeEngine(); + const place = geo.cartographic.fromDegrees(1, 2, 0); + assert.equal(engine.surface.terrainHeight(place), 0); + assert.equal( + engine.surface.clampToHeight(geo.cartesian3.fromDegrees(1, 2, 50)), + undefined, + ); + engine.fake.script('terrainHeight', 12); + engine.fake.script('clampToHeight', (position) => position); + assert.equal(engine.surface.terrainHeight(place), 12); + const position = geo.cartesian3.fromDegrees(1, 2, 50); + assert.equal(engine.surface.clampToHeight(position), position); + assert.equal(engine.surface.terrainHeightSupported, true); + assert.equal(engine.surface.clampToHeightSupported, true); +}); diff --git a/src/tooling/importDirections.test.mjs b/src/tooling/importDirections.test.mjs index 0b5f07d54..049aa9adf 100644 --- a/src/tooling/importDirections.test.mjs +++ b/src/tooling/importDirections.test.mjs @@ -134,12 +134,45 @@ for (const [name, files, pattern] of [ [ 'Cesium globe engine imports Navara', { 'src/globe/cesium/engine.js': "import '@navaramap/three';" }, - /Cesium globe engine imports @navaramap\/three/, + /Cesium globe engine reaches @navaramap\/three/, ], [ 'Navara globe engine imports Cesium', { 'src/globe/navara/engine.js': "import { Cartesian3 } from 'cesium';" }, - /Navara globe engine imports cesium/, + /Navara globe engine reaches cesium/, + ], + [ + 'Navara globe engine reaches Cesium through a helper', + { + 'src/globe/navara/engine.js': "import '../../maps/helper.js';", + 'src/maps/helper.js': "export * from 'cesium';", + }, + /src\/maps\/helper\.js: Navara globe engine reaches cesium/, + ], + [ + 'Navara globe engine imports the Cesium engine', + { + 'src/globe/navara/engine.js': "import '../cesium/camera.js';", + 'src/globe/cesium/camera.js': '', + }, + /src\/globe\/navara\/engine\.js: Navara globe engine reaches src\/globe\/cesium\/camera\.js/, + ], + [ + 'Cesium globe engine reaches three through a helper', + { + 'src/globe/cesium/engine.js': "import '../../ui/helper.js';", + 'src/ui/helper.js': "import 'three/addons/controls.js';", + }, + /src\/ui\/helper\.js: Cesium globe engine reaches three\/addons\/controls\.js/, + ], + [ + 'Cesium globe engine reaches the Navara engine through a helper', + { + 'src/globe/cesium/engine.js': "import '../shared.js';", + 'src/globe/shared.js': "import('./navara/engine.js');", + 'src/globe/navara/engine.js': '', + }, + /Cesium globe engine reaches src\/globe\/navara\/engine\.js/, ], [ 'browser imports Node without prefix',