diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index 888d81f48..945381142 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -21,6 +21,8 @@ const source = (file) => /^src\/layers\/[^/]+\/(?:source|bundledSource|flowSource)\.js$/.test(file); // Engine-neutral geometry ported from Cesium; every runtime file is a portable root. const geo = (file) => file.startsWith('src/geo/'); +// The app clock (src/time): plain numbers, no engine, no DOM. +const time = (file) => file.startsWith('src/time/'); // The engine-neutral globe contract and its test support; engines live below it. const globeContract = (file) => /^src\/globe\/[^/]+$/.test(file) || file.startsWith('src/globe/testing/'); @@ -210,6 +212,7 @@ export function checkImportDirections(root) { browser(file); const roots = new Map(files.filter(source).map((file) => [file, 'source'])); for (const file of files.filter(geo)) roots.set(file, 'geo'); + for (const file of files.filter(time)) roots.set(file, 'time'); for (const [key, value] of Object.entries(pkg.exports || {})) { if (portableExport(key) && typeof value === 'string') roots.set(value.replace(/^\.\//, ''), key); @@ -237,7 +240,10 @@ export function checkImportDirections(root) { specifier.startsWith('node:') || /^(?:cesium|vite)(?:\/|$)/.test(specifier) || // Geo must not reach Cesium through its workspace packages either. - (label === 'geo' && /^@cesium\//.test(specifier)) + (label === 'geo' && /^@cesium\//.test(specifier)) || + // Neither engine reaches the app clock. + (label === 'time' && + (cesiumPackage.test(specifier) || navaraPackage.test(specifier))) ) report( file, diff --git a/src/globe/cesium/clock.js b/src/globe/cesium/clock.js new file mode 100644 index 000000000..483919a18 --- /dev/null +++ b/src/globe/cesium/clock.js @@ -0,0 +1,54 @@ +// The engine clock on a Cesium viewer: the app clock (src/time/clock.js), +// with one frame time latched per turn of CesiumWidget's loop. The latch +// listens to the viewer clock's tick, which the widget runs right before +// scene.render, so every preUpdate/preRender/postRender listener of the frame +// (including those the app added to the scene before the engine existed) +// reads the same frame time. The viewer clock itself is never changed: the +// app does not animate it (shouldAnimate stays false), and its time still +// drives Cesium's own lighting as before. The tick subscription appears on +// first use (a ticked listener or a frameTime read) and destroy removes it. + +import { createEngineEvent } from '../contract.js'; +import { appClock as pageClock } from '../../time/clock.js'; + +/** + * @param {Cesium.Viewer} viewer + * @param {{ appClock?: import('../../time/clock.js').AppClock }} [options] + * `appClock` defaults to the page's app clock. + * @returns {{ clock: import('../contract.js').GlobeClock, destroy: () => void }} + */ +export function createCesiumClock(viewer, { appClock = pageClock } = {}) { + // A viewer-shaped object without a clock (tests, a bare scene): the + // scene's preUpdate opens the frame instead. + const tickSource = viewer.clock?.onTick ?? viewer.scene.preUpdate; + const ticked = createEngineEvent(); + let removeTick = null; + let destroyed = false; + const onTick = () => ticked.raiseValue(appClock.beginFrame()); + const attach = () => { + if (!removeTick && !destroyed) + removeTick = tickSource.addEventListener(onTick); + }; + const add = ticked.addEventListener; + ticked.addEventListener = (listener) => { + const remove = add(listener); + attach(); + return remove; + }; + return { + clock: { + now: () => appClock.now(), + frameTime() { + attach(); + return appClock.frameTime(); + }, + ticked, + }, + destroy() { + destroyed = true; + const remove = removeTick; + removeTick = null; + remove?.(); + }, + }; +} diff --git a/src/globe/cesium/clock.test.mjs b/src/globe/cesium/clock.test.mjs new file mode 100644 index 000000000..a8f5a0724 --- /dev/null +++ b/src/globe/cesium/clock.test.mjs @@ -0,0 +1,116 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { isEngineEvent } from '../contract.js'; +import { createAppClock } from '../../time/clock.js'; +import { createCesiumClock } from './clock.js'; +import { createFakeViewer } from './testing/fakeViewer.mjs'; + +function manualAppClock(start = Date.UTC(2026, 9, 16)) { + let wall = start; + const appClock = createAppClock({ now: () => wall }); + return { + appClock, + advance(ms) { + wall += ms; + }, + }; +} + +// One turn of CesiumWidget's loop: the clock ticks, then scene.render +// raises preUpdate (and preRender/postRender when it draws). +function cesiumFrame(viewer, { draw = true } = {}) { + const time = Cesium.JulianDate.now(); + viewer.clock.onTick.raiseEvent(viewer.clock); + viewer.scene.preUpdate.raiseEvent(viewer.scene, time); + if (!draw) return; + viewer.scene.preRender.raiseEvent(viewer.scene, time); + viewer.scene.postRender.raiseEvent(viewer.scene, time); +} + +test('the clock reads the app clock and subscribes to nothing at creation', () => { + const viewer = createFakeViewer(); + const wall = manualAppClock(); + const { clock } = createCesiumClock(viewer, { appClock: wall.appClock }); + assert.ok(isEngineEvent(clock.ticked)); + assert.equal(viewer.clock.onTick.numberOfListeners, 0); + assert.equal(clock.now(), wall.appClock.now()); + wall.advance(5); + assert.equal(clock.now(), wall.appClock.now()); +}); + +test('frameTime is latched on the viewer clock tick, before preUpdate', () => { + const viewer = createFakeViewer(); + const wall = manualAppClock(); + const { clock } = createCesiumClock(viewer, { appClock: wall.appClock }); + const seen = []; + clock.ticked.addEventListener((time) => seen.push(['ticked', time])); + // A preUpdate listener the app added before the engine existed still + // reads this frame's time: the latch is on the tick, not on preUpdate. + viewer.scene.preUpdate.addEventListener(() => + seen.push(['preUpdate', clock.frameTime()]), + ); + viewer.scene.preRender.addEventListener(() => { + wall.advance(3); + seen.push(['preRender', clock.frameTime()]); + }); + const t0 = wall.appClock.now(); + cesiumFrame(viewer); + wall.advance(16); + cesiumFrame(viewer, { draw: false }); + assert.deepEqual(seen, [ + ['ticked', t0], + ['preUpdate', t0], + ['preRender', t0], + ['ticked', t0 + 19], + ['preUpdate', t0 + 19], + ]); +}); + +test('frameTime alone starts the latch; destroy removes it', () => { + const viewer = createFakeViewer(); + const wall = manualAppClock(); + const { clock, destroy } = createCesiumClock(viewer, { + appClock: wall.appClock, + }); + const before = clock.frameTime(); + assert.equal(before, wall.appClock.now()); + assert.equal(viewer.clock.onTick.numberOfListeners, 1); + wall.advance(40); + cesiumFrame(viewer); + assert.equal(clock.frameTime(), before + 40); + wall.advance(10); + assert.equal(clock.frameTime(), before + 40); + destroy(); + assert.equal(viewer.clock.onTick.numberOfListeners, 0); +}); + +test('a viewer without a clock latches on preUpdate', () => { + const viewer = createFakeViewer(); + delete viewer.clock; + const wall = manualAppClock(); + const { clock, destroy } = createCesiumClock(viewer, { + appClock: wall.appClock, + }); + const seen = []; + clock.ticked.addEventListener((time) => seen.push(time)); + wall.advance(7); + viewer.scene.preUpdate.raiseEvent(viewer.scene); + assert.deepEqual(seen, [wall.appClock.now()]); + destroy(); + assert.equal(viewer.scene.preUpdate.numberOfListeners, 0); +}); + +test('the viewer clock itself is left alone', () => { + const viewer = createFakeViewer(); + viewer.clock.shouldAnimate = false; + viewer.clock.multiplier = 1; + const { clock } = createCesiumClock(viewer, { + appClock: manualAppClock().appClock, + }); + clock.ticked.addEventListener(() => {}); + cesiumFrame(viewer); + assert.equal(viewer.clock.shouldAnimate, false); + assert.equal(viewer.clock.multiplier, 1); + assert.equal(viewer.clock.currentTime, undefined); +}); diff --git a/src/globe/cesium/createCesiumEngine.js b/src/globe/cesium/createCesiumEngine.js index 91a232032..1c05c4285 100644 --- a/src/globe/cesium/createCesiumEngine.js +++ b/src/globe/cesium/createCesiumEngine.js @@ -4,6 +4,7 @@ // (the viewer itself stays owned by the app). import { createCesiumCamera } from './camera.js'; +import { createCesiumClock } from './clock.js'; import { createCesiumCredits } from './credits.js'; import { createCesiumFrames } from './frames.js'; import { createCesiumInput } from './input.js'; @@ -21,8 +22,9 @@ import { createCesiumSurface } from './surface.js'; * createEventHandler?: Function, * createImageryLayer?: Function, * loadPhotorealTileset?: Function, - * }} [options] `requestRender` defaults to the render governor; the - * `create*`/`load*` options are test seams. + * appClock?: import('../../time/clock.js').AppClock, + * }} [options] `requestRender` defaults to the render governor; `appClock` + * to the page's app clock; the `create*`/`load*` options are test seams. * * `render.setContinuous` takes a hold on the app's singleton renderGovernor * (src/renderGovernor.js), which governs the one viewer it was installed on; @@ -40,6 +42,7 @@ export function createCesiumEngine(viewer, options = {}) { picker: createCesiumPicker(viewer), projector: createCesiumProjector(viewer), frames: createCesiumFrames(viewer), + clock: createCesiumClock(viewer, options), render, surface: createCesiumSurface(viewer), input: createCesiumInput(viewer, options), diff --git a/src/globe/cesium/createCesiumEngine.test.mjs b/src/globe/cesium/createCesiumEngine.test.mjs index c2becc91c..7869a9336 100644 --- a/src/globe/cesium/createCesiumEngine.test.mjs +++ b/src/globe/cesium/createCesiumEngine.test.mjs @@ -138,3 +138,21 @@ test('the engine reads the Viewer aliases and builds on a minimal viewer', () => assert.equal(renders, 1); engine.destroy(); }); + +test('the engine clock is the app clock given, latched on the viewer clock tick', async () => { + const { createAppClock } = await import('../../time/clock.js'); + let wall = 1_000_000; + const appClock = createAppClock({ now: () => wall }); + const viewer = createFakeViewer(); + const engine = createCesiumEngine(viewer, { appClock }); + assert.equal(engine.clock.now(), 1_000_000); + const seen = []; + engine.clock.ticked.addEventListener((time) => seen.push(time)); + assert.equal(viewer.clock.onTick.numberOfListeners, 1); + wall += 16; + viewer.clock.onTick.raiseEvent(viewer.clock); + assert.deepEqual(seen, [1_000_016]); + assert.equal(engine.clock.frameTime(), 1_000_016); + engine.destroy(); + assert.equal(viewer.clock.onTick.numberOfListeners, 0); +}); diff --git a/src/globe/cesium/testing/fakeViewer.mjs b/src/globe/cesium/testing/fakeViewer.mjs index 9459ef87c..d6ba09cc6 100644 --- a/src/globe/cesium/testing/fakeViewer.mjs +++ b/src/globe/cesium/testing/fakeViewer.mjs @@ -165,6 +165,9 @@ export function createFakeViewer({ width = 1024, height = 768 } = {}) { staticCredits, primitiveList, trackedEntityChanged: new Cesium.Event(), + // The viewer clock: only its tick event (CesiumWidget ticks it right + // before scene.render on every animation frame). + clock: { onTick: new Cesium.Event() }, imageryList, terrainProvider: undefined, imageryLayers: { diff --git a/src/globe/contract.js b/src/globe/contract.js index 8d07e22d9..746740a71 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -209,6 +209,25 @@ * The source objects on screen, as passed: the base imagery layer on the * globe and the photoreal tiles shown (null for none). * + * @typedef {object} GlobeClock + * The app's own time (src/time/clock.js), in milliseconds since the Unix + * epoch: the one time source for layers and the engine's own time-driven + * drawing (Navara's sun). By default it is the wall clock (Date.now); QA + * and tests may freeze, offset or speed it up on the app clock. Cesium's + * viewer.clock is not driven by it (the app never animates that clock). + * @property {() => number} now The current app time. + * @property {() => number} frameTime The app time latched at the start of the + * engine's current frame: every reader in one frame gets the same value; it + * does not decrease from frame to frame while the app clock runs forward. + * Before the first frame, the current time. + * @property {EngineEvent} ticked Raised once per engine frame, with the + * frame time, before that frame's preUpdate (and so before preRender). A + * frame is one turn of the engine's update loop, also when it draws + * nothing: Cesium raises it on the viewer clock's tick, right before + * scene.render (which raises preUpdate on every animation frame, drawn or + * not); Navara on the view's preUpdate, which it emits on every turn of + * its loop. + * * @typedef {object} GlobeEngine * @property {'cesium'|'navara'} kind * @property {{ container: object, canvas: object, size: () => { width: number, height: number } }} host @@ -228,6 +247,7 @@ * held and one without fires only while no modifier is held. * @property {{ addStatic: (html: string, options?: { onScreen?: boolean }) => () => void }} credits * @property {GlobeMaps} maps + * @property {GlobeClock} clock * @property {() => void} destroy * @property {() => boolean} isDestroyed True once destroy() has run. */ @@ -406,6 +426,7 @@ const SHAPE = { }, input: { on: isFunction, setCameraInputs: isFunction }, credits: { addStatic: isFunction }, + clock: { now: isFunction, frameTime: isFunction, ticked: isEngineEvent }, maps: { setBaseImagery: isFunction, setTerrain: isFunction, diff --git a/src/globe/contract.test.mjs b/src/globe/contract.test.mjs index 2ac6108c0..190412acc 100644 --- a/src/globe/contract.test.mjs +++ b/src/globe/contract.test.mjs @@ -314,3 +314,21 @@ test('the contract carries the sharpening and checks its values', () => { ]) assert.throws(() => mergeSharpen(on, bad), TypeError); }); + +test('the contract carries the app clock', () => { + for (const member of ['clock.now', 'clock.frameTime', 'clock.ticked']) + assert.ok(GLOBE_ENGINE_MEMBERS.includes(member), member); + const engine = createFakeEngine(); + assert.equal(assertGlobeEngine(engine), engine); + const broken = { + ...engine, + clock: { ...engine.clock, frameTime: 1, ticked: {} }, + }; + assert.throws( + () => assertGlobeEngine(broken), + /clock\.frameTime.*clock\.ticked/, + ); + const missing = { ...engine }; + delete missing.clock; + assert.throws(() => assertGlobeEngine(missing), /\bclock\b/); +}); diff --git a/src/globe/navara/clock.js b/src/globe/navara/clock.js new file mode 100644 index 000000000..889455aa7 --- /dev/null +++ b/src/globe/navara/clock.js @@ -0,0 +1,28 @@ +// The engine clock on a Navara view: the app clock (src/time/clock.js), with +// one frame time latched per turn of the view's loop. The engine calls +// beginFrame from its handler of the view's preUpdate, which Navara emits +// first on every turn of its loop (drawn or not), before raising the +// contract's preUpdate; every listener of the frame then reads that time. +// The look (look.js) reads the same clock for the sun. + +import { createEngineEvent } from '../contract.js'; +import { appClock as pageClock } from '../../time/clock.js'; + +/** + * @param {{ appClock?: import('../../time/clock.js').AppClock }} [options] + * `appClock` defaults to the page's app clock. + * @returns {{ clock: import('../contract.js').GlobeClock, beginFrame: () => void }} + */ +export function createNavaraClock({ appClock = pageClock } = {}) { + const ticked = createEngineEvent(); + return { + clock: { + now: () => appClock.now(), + frameTime: () => appClock.frameTime(), + ticked, + }, + beginFrame() { + ticked.raiseValue(appClock.beginFrame()); + }, + }; +} diff --git a/src/globe/navara/clock.test.mjs b/src/globe/navara/clock.test.mjs new file mode 100644 index 000000000..9d5158515 --- /dev/null +++ b/src/globe/navara/clock.test.mjs @@ -0,0 +1,21 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { isEngineEvent } from '../contract.js'; +import { createAppClock } from '../../time/clock.js'; +import { createNavaraClock } from './clock.js'; + +test('beginFrame latches the app clock and raises ticked with the frame time', () => { + let wall = 10_000; + const appClock = createAppClock({ now: () => wall }); + const { clock, beginFrame } = createNavaraClock({ appClock }); + assert.ok(isEngineEvent(clock.ticked)); + const seen = []; + clock.ticked.addEventListener((time) => seen.push(time)); + assert.equal(clock.frameTime(), 10_000); + wall = 10_016; + beginFrame(); + wall = 10_020; + assert.deepEqual(seen, [10_016]); + assert.equal(clock.frameTime(), 10_016); + assert.equal(clock.now(), 10_020); +}); diff --git a/src/globe/navara/createNavaraEngine.js b/src/globe/navara/createNavaraEngine.js index 8e4f3434f..e28a77aaf 100644 --- a/src/globe/navara/createNavaraEngine.js +++ b/src/globe/navara/createNavaraEngine.js @@ -14,6 +14,7 @@ import { } from '../contract.js'; import { cesiumFovy } from './cameraMath.js'; import { createCameraModel } from './cameraModel.js'; +import { createNavaraClock } from './clock.js'; import { createNavaraCredits } from './credits.js'; import { createNavaraInput } from './input.js'; import { createNavaraMaps } from './maps.js'; @@ -68,16 +69,25 @@ function rayRaisedEllipsoid({ origin, direction }, height) { * container?: HTMLElement, * fetch?: typeof fetch, * clock?: object, + * appClock?: import('../../time/clock.js').AppClock, * sharpen?: { set: (state: { enabled: boolean, amount: number }) => void, destroy: () => void }, * }} options `hooks` are internals.js; `container` defaults to the * canvas's parent; `sharpen` draws render.setSharpen (sharpen.js; without - * it the engine only keeps the state) and the engine destroys it; `fetch` - * and `clock` are test seams. + * it the engine only keeps the state) and the engine destroys it; + * `appClock` (the engine clock's time) defaults to the page's app clock; + * `fetch` and `clock` (the camera model's timers) are test seams. * @returns {import('../contract.js').GlobeEngine} */ export function createNavaraEngine( view, - { hooks, container, fetch, clock, sharpen: sharpenPort = null } = {}, + { + hooks, + container, + fetch, + clock, + appClock, + sharpen: sharpenPort = null, + } = {}, ) { if (!view?.camera?.raw) throw new TypeError('createNavaraEngine needs a view'); @@ -112,10 +122,14 @@ export function createNavaraEngine( preRender: createEngineEvent(), postRender: createEngineEvent(), }; + const engineClock = createNavaraClock(appClock ? { appClock } : {}); // Defined below, with the surface and input it needs. let groundFloor = null; const handlers = { - preUpdate: (time) => frames.preUpdate.raiseEvent(time), + preUpdate: (time) => { + engineClock.beginFrame(); + frames.preUpdate.raiseEvent(time); + }, preRender: (time) => { model.sync(); groundFloor?.update(); @@ -219,6 +233,7 @@ export function createNavaraEngine( input: input.input, credits: credits.credits, maps: maps.maps, + clock: engineClock.clock, destroy() { if (destroyed) return; destroyed = true; diff --git a/src/globe/navara/createNavaraEngine.test.mjs b/src/globe/navara/createNavaraEngine.test.mjs index 0f98dad22..c8ef37188 100644 --- a/src/globe/navara/createNavaraEngine.test.mjs +++ b/src/globe/navara/createNavaraEngine.test.mjs @@ -130,6 +130,37 @@ test("Navara's frame events drive the contract frames in order with timestamps", ]); }); +test('the engine clock is the app clock, latched on the view preUpdate before frames', async () => { + const { createAppClock } = await import('../../time/clock.js'); + let wall = 2_000_000; + const appClock = createAppClock({ now: () => wall }); + const view = fakeView(); + const engine = createNavaraEngine(view, { hooks, appClock }); + assert.equal(engine.clock.now(), 2_000_000); + const seen = []; + engine.clock.ticked.addEventListener((time) => seen.push(['ticked', time])); + for (const name of ['preUpdate', 'preRender', 'postRender']) + engine.frames[name].addEventListener(() => + seen.push([name, engine.clock.frameTime()]), + ); + wall += 16; + view.emit('preUpdate', 1); + wall += 4; + view.emit('preRender', 2); + view.emit('postRender', 3); + assert.deepEqual(seen, [ + ['ticked', 2_000_016], + ['preUpdate', 2_000_016], + ['preRender', 2_000_016], + ['postRender', 2_000_016], + ]); + assert.equal(engine.clock.now(), 2_000_020); + engine.destroy(); + wall += 16; + view.emit('preUpdate', 4); + assert.equal(seen.length, 4, 'no tick after destroy'); +}); + test('render requests are forceUpdate and continuous mode is Navara animation', () => { const { engine, view } = setup(); engine.render.request(); diff --git a/src/globe/navara/index.js b/src/globe/navara/index.js index 805a49c8d..8867db26f 100644 --- a/src/globe/navara/index.js +++ b/src/globe/navara/index.js @@ -79,9 +79,12 @@ function groundInView(engine) { * effects?: 'minimal' | 'photoreal' | 'cinematic', * search?: string, * pixelRatio?: number, + * appClock?: import('../../time/clock.js').AppClock, * }} options `effects` names the look (cinematic by default); `search`, the * page's query string, picks it from `?look=` instead (the app's way). * `pixelRatio` only for measurements; the product uses the device's. + * `appClock` (the page's by default) is the engine clock's time and so the + * sun's: the look reads the engine clock's frame time. * @returns {Promise<{ view: object, engine: import('../contract.js').GlobeEngine, destroy: () => void }>} */ export async function createNavaraGlobe({ @@ -89,6 +92,7 @@ export async function createNavaraGlobe({ effects, search, pixelRatio, + appClock, }) { if (!container) throw new TypeError('createNavaraGlobe needs a container'); const canvas = preservedCanvas(container); @@ -103,10 +107,15 @@ export async function createNavaraGlobe({ const plan = navaraScenePlan(effects ?? resolveNavaraLook(search), { mobile: Boolean(view.isMobileOptimized?.()), }); - const look = buildNavaraLook(view, plugin, plan); + // The sun follows the engine clock (one time source for layers and the + // look); the look only runs in preRender, after the engine exists. + const look = buildNavaraLook(view, plugin, plan, () => + engine.clock.frameTime(), + ); engine = createNavaraEngine(view, { hooks: navaraHooks(view), container, + ...(appClock ? { appClock } : {}), sharpen: createNavaraSharpen(view), }); const camera = engine.camera; diff --git a/src/globe/navara/look.js b/src/globe/navara/look.js index c319b4f5e..34e17ffdc 100644 --- a/src/globe/navara/look.js +++ b/src/globe/navara/look.js @@ -1,6 +1,8 @@ // Builds a Navara look (viewOptions.js navaraScenePlan) on an initialised -// view and keeps it current while the view renders: the sun follows the wall -// clock as the HUD's SUN elevation does; the cinematic look lights the globe +// view and keeps it current while the view renders: the sun follows `now` +// (the engine clock's frame time in the app: the app clock, which is the wall +// clock the HUD's SUN elevation reads unless QA moves it); the cinematic look +// lights the globe // from the atmosphere when seen from space, shows its clouds only for low // views over low ground, brightens the atmosphere (atmosphereLuminance.js) // and shows its stars only at night or from space. All of it runs only on frames the view draws diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index 398111004..3ed1f25a5 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -359,6 +359,77 @@ export function createContractSuite( ); }, ], + [ + 'clock: one frame time per frame, latched before preUpdate, never decreasing', + async () => { + const records = []; + const read = (name, value) => + records.push({ + name, + value, + frame: engine.clock.frameTime(), + now: engine.clock.now(), + }); + const removers = [ + engine.clock.ticked.addEventListener((time) => read('ticked', time)), + ...['preUpdate', 'preRender', 'postRender'].map((name) => + engine.frames[name].addEventListener(() => read(name)), + ), + ]; + try { + await until( + () => + records.filter(({ name }) => name === 'postRender').length >= 3, + 'three drawn frames', + ); + } finally { + for (const remove of removers) remove(); + } + ensure( + Number.isFinite(engine.clock.now()) && + Number.isFinite(engine.clock.frameTime()), + 'clock.now or clock.frameTime is not a number', + ); + // From the first tick on: every frame event belongs to the frame the + // last tick opened, and reads that tick's frame time. + const start = records.findIndex(({ name }) => name === 'ticked'); + ensure(start >= 0, 'clock.ticked never fired'); + let tick = null; + let ticksSincePreUpdate = 0; + let previousFrame = -Infinity; + for (const record of records.slice(start)) { + ensure( + record.frame <= record.now, + `${record.name}: frameTime ${record.frame} is after now ${record.now}`, + ); + if (record.name === 'ticked') { + ensure( + record.value === record.frame, + `ticked passed ${record.value}, frameTime read ${record.frame}`, + ); + ensure( + record.frame >= previousFrame, + `frame time went back from ${previousFrame} to ${record.frame}`, + ); + previousFrame = record.frame; + tick = record; + ticksSincePreUpdate++; + continue; + } + ensure( + record.frame === tick.frame, + `${record.name} read frame time ${record.frame}, its frame's is ${tick.frame}`, + ); + if (record.name === 'preUpdate') { + ensure( + ticksSincePreUpdate >= 1, + 'preUpdate ran without a tick before it', + ); + ticksSincePreUpdate = 0; + } + } + }, + ], [ 'render: continuous mode toggles', async () => { diff --git a/src/globe/testing/fakeEngine.js b/src/globe/testing/fakeEngine.js index 395247d67..7b75bf1c5 100644 --- a/src/globe/testing/fakeEngine.js +++ b/src/globe/testing/fakeEngine.js @@ -25,9 +25,12 @@ import { createEngineEvent, mergeSharpen, } from '../contract.js'; +import { createAppClock } from '../../time/clock.js'; const { cartesian2, cartesian3, cartographic, ellipsoid, math, matrix4 } = geo; const WGS84 = ellipsoid.WGS84; +// Where the fake wall clock starts: 2026-01-01T00:00:00Z. +const FAKE_EPOCH_MS = Date.UTC(2026, 0, 1); const RADII = WGS84.radii; const MAXIMUM_RADIUS = Math.max(RADII.x, RADII.y, RADII.z); // The terrain kinds the engines build themselves (src/maps/registry.js). @@ -220,10 +223,14 @@ function rectangleFromCartographics(points) { /** * @param {{ kind?: 'cesium'|'navara', width?: number, height?: number, fovy?: number, * longitude?: number, latitude?: number, altitude?: number, - * container?: object, canvas?: object }} [options] + * container?: object, canvas?: object, nowMs?: number, + * appClock?: import('../../time/clock.js').AppClock }} [options] * Canvas size in CSS px, angles in radians; `container`/`canvas` stand in * for the host elements (DOM mocks for shell tests). The initial pose looks straight - * down from `altitude` metres (10 000 km). + * down from `altitude` metres (10 000 km). The engine clock reads + * `appClock`, by default an app clock on a fake wall clock that starts at + * `nowMs` (2026-01-01T00:00Z) and moves only with `fake.frame(dt)` and + * `fake.advanceTime(ms)` (a given appClock keeps its own wall clock). * @returns {import('../contract.js').GlobeEngine & { fake: object }} */ export function createFakeEngine(options = {}) { @@ -248,7 +255,11 @@ export function createFakeEngine(options = {}) { 'frames.preUpdate': createEngineEvent(), 'frames.preRender': createEngineEvent(), 'frames.postRender': createEngineEvent(), + 'clock.ticked': createEngineEvent(), }; + let wallMs = options.nowMs ?? FAKE_EPOCH_MS; + const appClock = options.appClock ?? createAppClock({ now: () => wallMs }); + const ownsWall = !options.appClock; const inputs = new Map(INPUT_TYPES.map((type) => [type, new Set()])); const livePosition = cartesian3.create(); let lastBaseImagery = null; @@ -782,6 +793,11 @@ export function createFakeEngine(options = {}) { viewProjection, horizonVisible, }, + clock: { + now: () => appClock.now(), + frameTime: () => appClock.frameTime(), + ticked: events['clock.ticked'], + }, frames: { preUpdate: events['frames.preUpdate'], preRender: events['frames.preRender'], @@ -951,9 +967,17 @@ export function createFakeEngine(options = {}) { scripts.delete(name); }, lookAtTarget: () => (lookAtTarget ? copy3(lookAtTarget) : undefined), - /** Runs one frame: preUpdate, flight progress, preRender, camera events, postRender. */ + /** The app clock the engine clock reads (freeze/offset/rate for tests). */ + appClock, + /** Moves the fake wall clock without a frame (own app clock only). */ + advanceTime(ms) { + if (ownsWall) wallMs += ms; + }, + /** Runs one frame: clock.ticked, preUpdate, flight progress, preRender, camera events, postRender. */ frame(dt = 16) { clock += dt; + if (ownsWall) wallMs += dt; + events['clock.ticked'].raiseValue(appClock.beginFrame()); events['frames.preUpdate'].raiseEvent(clock); advanceFlight(dt); events['frames.preRender'].raiseEvent(clock); diff --git a/src/globe/testing/fakeEngine.test.mjs b/src/globe/testing/fakeEngine.test.mjs index 1f845c931..87faeb080 100644 --- a/src/globe/testing/fakeEngine.test.mjs +++ b/src/globe/testing/fakeEngine.test.mjs @@ -397,3 +397,46 @@ test('fake isDestroyed, commanded and pickGlobe are inspectable', () => { engine.destroy(); assert.equal(engine.isDestroyed(), true); }); + +test('the fake clock is the app clock the test controls, latched per frame', () => { + const start = Date.UTC(2026, 9, 16, 12); + const engine = createFakeEngine({ nowMs: start }); + const seen = []; + engine.clock.ticked.addEventListener((time) => + seen.push(['ticked', time, engine.clock.frameTime()]), + ); + for (const name of ['preUpdate', 'preRender', 'postRender']) + engine.frames[name].addEventListener(() => + seen.push([name, engine.clock.frameTime()]), + ); + assert.equal(engine.clock.now(), start); + engine.fake.frame(16); + assert.deepEqual(seen, [ + ['ticked', start + 16, start + 16], + ['preUpdate', start + 16], + ['preRender', start + 16], + ['postRender', start + 16], + ]); + // Time moves between frames; the frame time only at the next frame. + engine.fake.advanceTime(1000); + assert.equal(engine.clock.now(), start + 1016); + assert.equal(engine.clock.frameTime(), start + 16); + engine.fake.frame(16); + assert.equal(engine.clock.frameTime(), start + 1032); + // The app clock's controls work through engine.fake.appClock. + engine.fake.appClock.freeze(start); + engine.fake.frame(16); + assert.equal(engine.clock.frameTime(), start); +}); + +test('a fake engine can share an app clock', async () => { + const { createAppClock } = await import('../../time/clock.js'); + let wall = 5000; + const appClock = createAppClock({ now: () => wall }); + const engine = createFakeEngine({ appClock }); + assert.equal(engine.fake.appClock, appClock); + wall = 6000; + assert.equal(engine.clock.now(), 6000); + engine.fake.frame(); + assert.equal(appClock.frameTime(), 6000); +}); diff --git a/src/time/clock.js b/src/time/clock.js new file mode 100644 index 000000000..b75f7b3c5 --- /dev/null +++ b/src/time/clock.js @@ -0,0 +1,113 @@ +// The app's own time: milliseconds since the Unix epoch, read by layers and +// both globe engines instead of Cesium's JulianDate or viewer.clock. By +// default it is the wall clock (Date.now) exactly; QA and tests can freeze, +// offset or speed it up. The globe engine latches one frame time per engine +// frame (beginFrame), so every reader in a frame sees the same value. +// No engine, DOM or Node imports: this runs in the browser and in Node tests. + +const isFiniteNumber = (value) => + typeof value === 'number' && Number.isFinite(value); + +function finite(value, label) { + if (!isFiniteNumber(value)) + throw new TypeError(`${label} must be a finite number: ${value}`); + return value; +} + +/** + * @typedef {object} AppClock + * @property {() => number} now The current app time (ms since the epoch). + * @property {() => number} frameTime The time latched for the current engine + * frame; the current time before the first frame. + * @property {() => number} beginFrame Latches and returns the frame time; the + * globe engine calls it once at the start of each of its frames. + * @property {(ms?: number) => void} freeze Holds the time at `ms` (default: + * the current time). QA and tests only. + * @property {() => void} resume Runs on from the frozen time. + * @property {(ms: number) => void} offset Sets the time to the wall clock + * plus `ms`, running on from it at the current rate (it ends a freeze). + * QA and tests only. + * @property {(rate: number) => void} rate App milliseconds per wall + * millisecond (>= 0), without a jump. QA and tests only. + * @property {() => void} reset Back to the wall clock. + * @property {() => { frozen: boolean, offsetMs: number, rate: number }} state + * The controls in force (offsetMs: the current time minus the wall clock). + */ + +/** + * @param {{ now?: () => number }} [options] `now`: the wall clock (Date.now). + * @returns {AppClock} + */ +export function createAppClock({ now: source = () => Date.now() } = {}) { + if (typeof source !== 'function') + throw new TypeError('createAppClock needs a now() function'); + // Rate 1: wall + offsetMs, exact. Any other rate: anchorValue plus the wall + // time since anchorSource, scaled. + let offsetMs = 0; + let rateValue = 1; + let anchorValue = 0; + let anchorSource = 0; + let frozen = null; + let latched = null; + + const running = () => + rateValue === 1 + ? source() + offsetMs + : anchorValue + (source() - anchorSource) * rateValue; + const now = () => (frozen === null ? running() : frozen); + // Make the running clock read `value` from this moment on. + const runFrom = (value) => { + if (rateValue === 1) offsetMs = value - source(); + else { + anchorValue = value; + anchorSource = source(); + } + }; + + return { + now, + frameTime: () => (latched === null ? now() : latched), + beginFrame() { + latched = now(); + return latched; + }, + freeze(ms) { + frozen = ms === undefined ? now() : finite(ms, 'Frozen time'); + }, + resume() { + if (frozen === null) return; + const value = frozen; + frozen = null; + runFrom(value); + }, + offset(ms) { + const value = source() + finite(ms, 'Clock offset'); + frozen = null; + runFrom(value); + }, + rate(rate) { + if (!(isFiniteNumber(rate) && rate >= 0)) + throw new TypeError(`Clock rate must be a finite number >= 0: ${rate}`); + if (frozen !== null) { + rateValue = rate; + return; + } + const value = running(); + rateValue = rate; + runFrom(value); + }, + reset() { + offsetMs = 0; + rateValue = 1; + frozen = null; + }, + state: () => ({ + frozen: frozen !== null, + offsetMs: now() - source(), + rate: rateValue, + }), + }; +} + +/** The page's app clock: the one both globe engines and the layers read. */ +export const appClock = createAppClock(); diff --git a/src/time/clock.test.mjs b/src/time/clock.test.mjs new file mode 100644 index 000000000..195797cdb --- /dev/null +++ b/src/time/clock.test.mjs @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { appClock, createAppClock } from './clock.js'; + +// A wall clock the test moves by hand. +function manualSource(start = Date.UTC(2026, 9, 16, 12)) { + let value = start; + return { + now: () => value, + advance(ms) { + value += ms; + }, + }; +} + +test('by default the app clock is the wall clock, exactly', () => { + const wall = manualSource(); + const clock = createAppClock({ now: wall.now }); + assert.equal(clock.now(), wall.now()); + wall.advance(1234.5); + assert.equal(clock.now(), wall.now()); +}); + +test('the page clock reads Date.now', () => { + const before = Date.now(); + const value = appClock.now(); + assert.ok(value >= before && value <= Date.now()); +}); + +test('frameTime is the time latched by beginFrame, one value per frame', () => { + const wall = manualSource(); + const clock = createAppClock({ now: wall.now }); + // Before the first frame it is the current time. + assert.equal(clock.frameTime(), wall.now()); + const latched = clock.beginFrame(); + assert.equal(latched, wall.now()); + wall.advance(7); + assert.equal(clock.frameTime(), latched); + assert.equal(clock.now(), latched + 7); + assert.equal(clock.beginFrame(), latched + 7); + assert.equal(clock.frameTime(), latched + 7); +}); + +test('freeze holds the time, resume continues from it', () => { + const wall = manualSource(); + const clock = createAppClock({ now: wall.now }); + const frozenAt = Date.UTC(2020, 0, 1); + clock.freeze(frozenAt); + wall.advance(5000); + assert.equal(clock.now(), frozenAt); + assert.equal(clock.beginFrame(), frozenAt); + clock.resume(); + assert.equal(clock.now(), frozenAt); + wall.advance(250); + assert.equal(clock.now(), frozenAt + 250); +}); + +test('freeze without a time holds the current time', () => { + const wall = manualSource(); + const clock = createAppClock({ now: wall.now }); + const at = wall.now(); + clock.freeze(); + wall.advance(100); + assert.equal(clock.now(), at); +}); + +test('offset shifts the clock from the wall clock', () => { + const wall = manualSource(); + const clock = createAppClock({ now: wall.now }); + clock.offset(-30_000); + assert.equal(clock.now(), wall.now() - 30_000); + wall.advance(10); + assert.equal(clock.now(), wall.now() - 30_000); + clock.offset(0); + assert.equal(clock.now(), wall.now()); +}); + +test('rate runs the clock faster or slower without a jump', () => { + const wall = manualSource(); + const clock = createAppClock({ now: wall.now }); + const start = clock.now(); + clock.rate(4); + assert.equal(clock.now(), start); + wall.advance(1000); + assert.equal(clock.now(), start + 4000); + clock.rate(0); + wall.advance(1000); + assert.equal(clock.now(), start + 4000); + clock.rate(1); + wall.advance(500); + assert.equal(clock.now(), start + 4500); +}); + +test('a rate set while frozen applies from resume', () => { + const wall = manualSource(); + const clock = createAppClock({ now: wall.now }); + clock.freeze(1000); + clock.rate(2); + wall.advance(400); + assert.equal(clock.now(), 1000); + clock.resume(); + wall.advance(400); + assert.equal(clock.now(), 1800); +}); + +test('reset returns to the wall clock and reports the state', () => { + const wall = manualSource(); + const clock = createAppClock({ now: wall.now }); + assert.deepEqual(clock.state(), { frozen: false, offsetMs: 0, rate: 1 }); + clock.rate(3); + clock.freeze(); + assert.equal(clock.state().frozen, true); + assert.equal(clock.state().rate, 3); + clock.reset(); + assert.deepEqual(clock.state(), { frozen: false, offsetMs: 0, rate: 1 }); + assert.equal(clock.now(), wall.now()); +}); + +test('non-finite controls are refused', () => { + const clock = createAppClock({ now: () => 0 }); + for (const call of [ + () => clock.freeze(Number.NaN), + () => clock.offset(Number.POSITIVE_INFINITY), + () => clock.rate('2'), + () => clock.rate(-1), + ]) + assert.throws(call, TypeError); + assert.throws(() => createAppClock({ now: 5 }), TypeError); +}); + +test('offset ends a freeze and runs on from the wall clock plus the offset', () => { + const wall = manualSource(); + const clock = createAppClock({ now: wall.now }); + clock.freeze(5); + clock.offset(1000); + assert.equal(clock.now(), wall.now() + 1000); + wall.advance(20); + assert.equal(clock.now(), wall.now() + 1000); +}); + +test('the default wall clock is read through Date.now at each call', (t) => { + const clock = createAppClock(); + t.mock.method(Date, 'now', () => 1_800_000_000_000); + assert.equal(clock.now(), 1_800_000_000_000); +}); diff --git a/src/tooling/importDirections.test.mjs b/src/tooling/importDirections.test.mjs index 1b5fd1695..fefd07c0f 100644 --- a/src/tooling/importDirections.test.mjs +++ b/src/tooling/importDirections.test.mjs @@ -100,6 +100,23 @@ for (const [name, files, pattern] of [ }, /src\/data\/helper\.js: geo reaches platform\/rendering dependency: cesium/, ], + [ + 'app clock imports cesium', + { 'src/time/clock.js': "import 'cesium';" }, + /src\/time\/clock\.js: time reaches platform\/rendering dependency: cesium/, + ], + [ + 'app clock imports three', + { 'src/time/clock.js': "import * as THREE from 'three';" }, + /src\/time\/clock\.js: time reaches platform\/rendering dependency: three/, + ], + [ + 'app clock reaches the DOM', + { + 'src/time/clock.js': 'export const now = () => window.performance.now();', + }, + /src\/time\/clock\.js: time reaches browser globals/, + ], [ 'globe contract imports cesium', { 'src/globe/contract.js': "import 'cesium';" },