From aa6959c97b33fa2ec5a69aea1a26a3bc02744f0d Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Sun, 8 Nov 2026 00:30:36 +0400 Subject: [PATCH] Restore ground, picking and horizon parity through the globe contract Draw launch-pad zones as radial ground discs with the original fill, rim, transitions, emission and depth bias. Drape Transit history and its backing on ground corridors. Keep the current age bands and once-per-second updates; advance the active head with a reveal fraction and rebuild only at segment crossings. Its wider dark edge and colour core blend once, as on Cesium. Navara composes these looks after tone mapping. Build reveal corridors and dynamic Transit history synchronously once shared terrain bounds are loaded. Keep requesting frames until the visible globe tiles and committed primitives are ready: classification needs current surface depth after a camera move. Retain the reference radial material, its alpha envelope and the head's material-only reveal updates. Remove the empty Navara ground effect from the render chain when the last shown set is hidden or destroyed. It otherwise remains as the insertion anchor for later pulse effects after its packed textures have been disposed. Cover reveal, destruction and replacement with fresh-state comparisons for corridors, polygons, discs and cells. Cesium drawing is unchanged by this fix. Keep the radial pixel check at a 500 metre radius and 4 kilometre view, sample the brightest of six inner points around the native fill gap, and sample across the rim width. Restore its view during arrival flights and run it last among ground checks. Both suite launchers wait for the linked view to arrive before checking. Keep these checks with the radial look. Pass rectangular pick windows and drill limits through input subscriptions. Street Level uses its 9 pixel window and CCTV uses its 14 pixel window and six-result drill. Down picks stay synchronous, moves coalesce per frame and Navara clicks keep the GPU answer from their gesture. Add a projector ellipsoid option and use WGS84 radii reduced by 1,000 metres for Street Level. These changes restore the original Cesium pad fill and ground trail rather than the temporary ring and raised lines. Navara follows the same appearance and horizon policy, and now honours explicit CPU pick windows. Existing callers without these options retain their defaults. The four debts share the contract, suite and engine implementations, so keep them together. No coupling count grew or fell: renderer lines remain 0 and 189, external Cesium imports 3, external viewer lines 891, engine-kind checks 1, engine resolution calls 0, extension reads 3, ground passes 1, SDK loads 3, screen handler files 4 and engine-name lines 32. The contract and ground suites now wait until the camera has reached the view in their link before checking, because the app's restore flight could land in the middle of a check; that was also the cause of the intermittent camera-events failure. The radial check uses a launch pad's size and samples the fill at several inner points: Cesium's own old pad leaves the same gap in a small ground disc's fill. The pulse-band check takes its base frame once the surface has settled. Checked in the browser, keyless: the contract suite passes on both engines (84 and 85 checks), the ground suite passes on both, CCTV v2 passes 58 checks, and the space, transit, Street Level and CCTV engine switches pass. The transit and Street Level scripts fail as they do before this change. --- scripts/package-boundaries.json | 3 +- scripts/qa-contract-suite.mjs | 12 +- scripts/qa-ground-suite.mjs | 12 +- scripts/qa-transit-recovery.mjs | 11 +- scripts/qa-transit-scenes.mjs | 14 +- src/app/layers/renderers.js | 8 +- src/data/cctvGizmo.js | 6 +- src/data/inputOwnership.test.mjs | 2 +- src/globe/cameraMath.js | 16 +- src/globe/cesium/ground.js | 104 ++++++++-- src/globe/cesium/ground.test.mjs | 135 ++++++++++++- src/globe/cesium/input.js | 19 +- src/globe/cesium/input.test.mjs | 37 ++++ src/globe/cesium/projector.js | 21 +- src/globe/cesium/projector.test.mjs | 62 ++++++ src/globe/contract.js | 82 +++++++- src/globe/cpuPicks.js | 93 ++++++++- src/globe/cpuPicks.test.mjs | 30 +++ src/globe/ground.js | 82 +++++++- src/globe/ground.test.mjs | 66 ++++++ src/globe/navara/cameraModel.js | 3 +- src/globe/navara/createNavaraEngine.js | 11 +- src/globe/navara/ground/corridors.js | 11 +- src/globe/navara/ground/index.js | 26 +++ src/globe/navara/ground/index.test.mjs | 149 ++++++++++++++ src/globe/navara/ground/pack.js | 9 +- src/globe/navara/ground/pack.test.mjs | 22 ++ src/globe/navara/ground/pass.js | 31 ++- src/globe/navara/input.js | 29 ++- src/globe/navara/input.test.mjs | 60 ++++++ src/globe/navara/picking.js | 5 +- src/globe/pickDelivery.js | 50 +++-- src/globe/pickDelivery.test.mjs | 45 +++++ src/globe/testing/contractSuite.mjs | 190 ++++++++++++++++++ src/globe/testing/fakeEngine.js | 33 ++- src/globe/testing/fakeEngine.test.mjs | 35 +++- src/layers/cctv/render/display.js | 62 ++---- src/layers/cctv/render/displayGizmo.test.mjs | 22 ++ src/layers/launches/policy.js | 5 +- src/layers/launches/render/display.js | 90 +++------ src/layers/launches/render/display.test.mjs | 157 ++++++--------- src/layers/streetLevel/render/display.js | 38 +--- .../streetLevel/render/display.test.mjs | 26 --- src/layers/streetLevel/selection.js | 14 +- src/layers/streetLevel/selection.test.mjs | 4 +- src/layers/transit/lifecycle.test.mjs | 20 +- src/layers/transit/render/display.js | 97 +++++---- src/layers/transit/render/display.test.mjs | 116 ++++++++--- src/testSupport/cctvEngines.mjs | 7 +- 49 files changed, 1727 insertions(+), 455 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b7758d2a1..5211a41c6 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2143,7 +2143,8 @@ "src/geo/", "src/layers/shared/color.js", "src/globe/cameraMath.js", - "src/layers/streetLevel/render/display.js" + "src/layers/streetLevel/render/display.js", + "src/globe/contract.js" ], "external": ["pbf", "@mapbox/vector-tile", "mapillary-js"] }, diff --git a/scripts/qa-contract-suite.mjs b/scripts/qa-contract-suite.mjs index 6677dea0d..12021b7f4 100644 --- a/scripts/qa-contract-suite.mjs +++ b/scripts/qa-contract-suite.mjs @@ -66,9 +66,17 @@ async function runOn(browser, kind) { ); // The suite owns the globe: no camera flight running, and the app's panels // let the pointer through to the canvas. + // The app restores the view in the link with a flight that can start + // after the loading screen goes: wait until the camera is there and still, + // or that flight lands in the middle of a check. await page.waitForFunction( - () => !window.__godsEyeView.engine.camera.flightActive(), - { timeout: 60_000, polling: 250 }, + () => { + const { camera } = window.__godsEyeView.engine; + return ( + !camera.flightActive() && Math.abs(camera.height() - 1_000_000) < 1000 + ); + }, + { timeout: 120_000, polling: 250 }, ); // Only the engine's canvas takes the pointer: the world overlay draws on a // canvas of its own above it. diff --git a/scripts/qa-ground-suite.mjs b/scripts/qa-ground-suite.mjs index ded9d646e..3d6397abc 100644 --- a/scripts/qa-ground-suite.mjs +++ b/scripts/qa-ground-suite.mjs @@ -47,9 +47,17 @@ async function runOn(browser, kind) { { timeout: 300_000, polling: 250 }, kind, ); + // The app restores the view in the link with a flight that can start + // after the loading screen goes: wait until the camera is there and still, + // or that flight lands in the middle of a check. await page.waitForFunction( - () => !window.__godsEyeView.engine.camera.flightActive(), - { timeout: 60_000, polling: 250 }, + () => { + const { camera } = window.__godsEyeView.engine; + return ( + !camera.flightActive() && Math.abs(camera.height() - 1_000_000) < 1000 + ); + }, + { timeout: 120_000, polling: 250 }, ); // The globe drawn and its tiles loaded: a restored view hides the globe // for a moment after the loading screen goes. diff --git a/scripts/qa-transit-recovery.mjs b/scripts/qa-transit-recovery.mjs index d1456c5d0..3622dbb36 100644 --- a/scripts/qa-transit-recovery.mjs +++ b/scripts/qa-transit-recovery.mjs @@ -100,7 +100,7 @@ try { ), orientation: { heading: 0, pitch: -Math.PI / 3, roll: 0 }, }); - // The trail draws through engine.lines (render/display.js); its renderer + // The trail draws through engine.ground (render/display.js); its renderer // is the handle that shows and hides the whole trail. Keep the one the // selection below creates. let trailRenderer = null; @@ -320,8 +320,8 @@ try { scene.globe.tilesLoaded && parts.trails.diagnostics().body === trail.body, elapsedMs: performance.now() - started, - // The trail's engine lines (age bands and head, each with a backing). - lines: app.engine.lines + // The trail's ground corridors (age bands and head, each with a backing). + lines: app.engine.ground .list() .filter(({ id }) => id.startsWith('transit-trail-')), }; @@ -337,9 +337,8 @@ try { const pass = result.before?.shown === false && result.before?.phase === 'playing' && - // preparedAtSelection is reported, not required: the trail is engine - // lines in the air now (render/display.js), not a draped ground - // polyline, so it has no geometry until the floors it waits on arrive. + // Ground history is prepared even while marker floors are unresolved. + result.preparedAtSelection && result.markerShow && result.body && result.bodyShow && diff --git a/scripts/qa-transit-scenes.mjs b/scripts/qa-transit-scenes.mjs index 6de24858d..fb2918409 100644 --- a/scripts/qa-transit-scenes.mjs +++ b/scripts/qa-transit-scenes.mjs @@ -655,12 +655,12 @@ export async function runTrailVisibility({ ), orientation: { heading: 0, pitch: (-pitch * Math.PI) / 180, roll: 0 }, }); - // The trail draws through engine.lines (render/display.js); keep the + // The trail draws through engine.ground (render/display.js); keep the // renderer the selection below creates, the handle that shows and // hides the whole trail. - const createLine = app.engine.lines.createLine.bind(app.engine.lines); + const createLine = app.engine.ground.createSet.bind(app.engine.ground); window.__qaTransitLineKinds = new Map(); - app.engine.lines.createLine = (options) => { + app.engine.ground.createSet = (options) => { const line = createLine(options); if (options.id.startsWith('transit-trail-')) window.__qaTransitLineKinds.set( @@ -670,7 +670,7 @@ export async function runTrailVisibility({ return line; }; window.__restoreTransitLines = () => { - app.engine.lines.createLine = createLine; + app.engine.ground.createSet = createLine; }; const createTrailRenderer = parts.renderer.createTrailRenderer; parts.renderer.createTrailRenderer = (...args) => { @@ -796,12 +796,13 @@ export async function runTrailVisibility({ const walk = (collection) => { for (let i = 0; i < collection.length; i++) { const primitive = collection.get(i); - const kind = window.__qaTransitLineKinds.get(primitive?.gevLine); + const kind = window.__qaTransitLineKinds.get(primitive?.gevGroundSet); if (kind) wrap(primitive, kind); else if (typeof primitive?.get === 'function') walk(primitive); } }; walk(scene.primitives); + walk(scene.groundPrimitives); }); const post = scene.postRender.addEventListener(() => { frames++; @@ -907,7 +908,7 @@ export async function runTrailVisibility({ const item = collection.get(i); if ( String(item?.gevMoverSet ?? '').startsWith('transit') || - String(item?.gevLine ?? '').startsWith('transit-trail-') + String(item?.gevGroundSet ?? '').startsWith('transit-trail-') ) drawnByTransit.push(item); else if (typeof item?.get === 'function' && 'length' in item) @@ -915,6 +916,7 @@ export async function runTrailVisibility({ } }; walk(scene.primitives); + walk(scene.groundPrimitives); const lengths = [0]; for (let i = 1; i < points.length; i++) lengths.push(lengths.at(-1) + V.distance(points[i - 1], points[i])); diff --git a/src/app/layers/renderers.js b/src/app/layers/renderers.js index 73ebee2d8..0690c00a8 100644 --- a/src/app/layers/renderers.js +++ b/src/app/layers/renderers.js @@ -165,11 +165,15 @@ export const LAYER_MANIFEST = Object.freeze({ 'alpr-cameras': display('createAlprDisplay', ['movers']), 'street-level': display('createStreetLevelDisplay', ['movers', 'ground']), satellites: display('createSatellitesDisplay', ['movers', 'lines']), - 'rocket-launches': display('createLaunchesDisplay', ['movers', 'lines']), + 'rocket-launches': display('createLaunchesDisplay', [ + 'movers', + 'lines', + 'ground', + ]), traffic: display('createTrafficDisplay', ['movers', 'ground']), cctv: display('createCctvDisplay', ['movers', 'lines', 'meshes']), radio: display('createRadioDisplay', ['movers']), - transit: display('createTransitDisplay', ['movers', 'lines']), + transit: display('createTransitDisplay', ['movers', 'ground']), bikeshare: display('createBikeshareDisplay', ['movers']), directions: display('createDirectionsDisplay', ['movers', 'ground']), 'recent-imagery': display('createRecentImageryDisplay', [ diff --git a/src/data/cctvGizmo.js b/src/data/cctvGizmo.js index 0283f0f29..819426ee2 100644 --- a/src/data/cctvGizmo.js +++ b/src/data/cctvGizmo.js @@ -240,7 +240,7 @@ export function createCalibrationGizmo({ const entities = new Map(); // part -> Entity const { pickRay, setCursor } = renderer; - const pickGizmoPart = (position) => renderer.pickPart(position); + const pickGizmoPart = (event) => renderer.pickPart(event); const polylinePart = (part, ...args) => entities.set(part, renderer.createLine(part, ...args)); const pointPart = (part, ...args) => @@ -530,7 +530,7 @@ export function createCalibrationGizmo({ // A tool owns the pointer (src/data/inputOwnership.js): yield the press. if (!isPointerFree()) return; if (!enabled || drag) return; - const part = pickGizmoPart(event.position); + const part = pickGizmoPart(event); debugLog('LEFT_DOWN', event.position, 'part:', part); if (!part) return; const started = beginDrag(part, event.position); @@ -563,7 +563,7 @@ export function createCalibrationGizmo({ } if (now - lastHoverAt < HOVER_THROTTLE_MS) return; lastHoverAt = now; - setHovered(pickGizmoPart(event.endPosition)); + setHovered(pickGizmoPart(event)); }); renderer.on('up', () => { diff --git a/src/data/inputOwnership.test.mjs b/src/data/inputOwnership.test.mjs index acd045cf8..57313c133 100644 --- a/src/data/inputOwnership.test.mjs +++ b/src/data/inputOwnership.test.mjs @@ -119,7 +119,7 @@ test('every scene click handler consults ownership before it picks', () => { 'src/layers/infrastructure/render/display.js', 'const picked = event.picked;', ], - ['src/data/cctvGizmo.js', 'pickGizmoPart(event.position)'], + ['src/data/cctvGizmo.js', 'pickGizmoPart(event)'], // ALPR and bikeshare take the engine's click with its pick. ['src/layers/alpr/render/display.js', 'overlay.pick(position) ||'], ['src/layers/bikeshare/render/display.js', 'const picked = event.picked;'], diff --git a/src/globe/cameraMath.js b/src/globe/cameraMath.js index 035b8cf05..36d9ccaee 100644 --- a/src/globe/cameraMath.js +++ b/src/globe/cameraMath.js @@ -7,6 +7,7 @@ // keeps Navara's camera on the same pose and fov. import * as geo from '../geo/index.js'; +import { horizonEllipsoidRadii } from './contract.js'; const { cartesian2, cartesian3, cartographic, math, matrix4 } = geo; const WGS84 = geo.ellipsoid.WGS84; @@ -238,18 +239,19 @@ const horizonTarget = cartesian3.create(); const horizonVt = cartesian3.create(); /** Cesium's EllipsoidalOccluder.isPointVisible from `eye`. */ -export function horizonVisible(eye, position) { +export function horizonVisible(eye, position, options) { + const radii = horizonEllipsoidRadii(options) ?? RADII; const camera = cartesian3.fromElements( - eye.x / RADII.x, - eye.y / RADII.y, - eye.z / RADII.z, + eye.x / radii.x, + eye.y / radii.y, + eye.z / radii.z, horizonCamera, ); const limbSquared = cartesian3.magnitudeSquared(camera) - 1; const target = cartesian3.fromElements( - position.x / RADII.x, - position.y / RADII.y, - position.z / RADII.z, + position.x / radii.x, + position.y / radii.y, + position.z / radii.z, horizonTarget, ); const vt = cartesian3.subtract(target, camera, horizonVt); diff --git a/src/globe/cesium/ground.js b/src/globe/cesium/ground.js index bd1aeabbf..058f7da1a 100644 --- a/src/globe/cesium/ground.js +++ b/src/globe/cesium/ground.js @@ -78,6 +78,7 @@ const CORRIDOR_SOURCE = /* glsl */ ` czm_material czm_getMaterial(czm_materialInput materialInput) { czm_material material = czm_getDefaultMaterial(materialInput); vec2 st = materialInput.st; + if (st.s > reveal) discard; vec3 rgb = color.rgb; float alpha = color.a; if (pulsePeriod > 0.0) @@ -95,7 +96,11 @@ czm_material czm_getMaterial(czm_materialInput materialInput) { rgb *= 1.0 + dashEmission * on; alpha *= max(on, dashGap); } - material.emission = rgb; + if (abs(st.t - 0.5) > coreFraction * 0.5) { + rgb = backingColor.rgb; + alpha = backingColor.a; + } + material.emission = coreFraction < 1.0 ? czm_gammaCorrect(rgb) : rgb; material.alpha = alpha; return material; }`; @@ -106,6 +111,10 @@ export function corridorUniforms(item) { return { color: colorOf(item.fill), time: 0, + reveal: 1, + coreFraction: + item.widthPx / (item.animation.backing?.widthPx ?? item.widthPx), + backingColor: colorOf(item.animation.backing?.color ?? [0, 0, 0, 0]), glowPower: glow?.power ?? 0, taperPower: glow?.taper ?? 1, pulsePeriod: pulse ? pulse.periodMs / 1000 : 0, @@ -136,6 +145,28 @@ const corridorMaterial = (item) => translucent: true, }); +/** The radial disc material, including its soft outside edge. */ +export function radialMaterial(item) { + return new Cesium.Material({ + fabric: { + type: 'GevGroundRadial', + uniforms: { color: colorOf(item.fill), ...item.radial }, + source: ` +czm_material czm_getMaterial(czm_materialInput materialInput) { + czm_material material = czm_getDefaultMaterial(materialInput); + float radius = length((materialInput.st - vec2(0.5)) * 2.0); + float inside = 1.0 - smoothstep(rimEnd, 1.0, radius); + float rim = smoothstep(rimStart, rimEnd, radius) * inside; + material.diffuse = color.rgb; + material.emission = color.rgb * rim * emission; + material.alpha = color.a * inside * mix(fillAlpha, rimAlpha, rim); + return material; +}`, + }, + translucent: true, + }); +} + // Items of one look and colour share a primitive. const lookKey = (item) => JSON.stringify([Array.from(item.fill), item.animation]); @@ -151,12 +182,15 @@ const longitude = (degrees) => degrees - 360 * Math.round(degrees / 360); /** The fill geometry of one item of a set of `kind` (pure, for tests). */ export function fillGeometry(kind, item) { - const vertexFormat = Cesium.PerInstanceColorAppearance.FLAT_VERTEX_FORMAT; + const vertexFormat = item.radial + ? Cesium.MaterialAppearance.MaterialSupport.TEXTURED.vertexFormat + : Cesium.PerInstanceColorAppearance.FLAT_VERTEX_FORMAT; if (kind === 'disc') return new Cesium.EllipseGeometry({ center: Cesium.Cartesian3.fromDegrees(item.center[0], item.center[1]), semiMajorAxis: item.radiusM, semiMinorAxis: item.radiusM, + ...(item.radial ? { granularity: Cesium.Math.toRadians(0.08) } : {}), vertexFormat, }); if (kind === 'cell') { @@ -198,6 +232,7 @@ export function groundInstances(options, items) { const fills = []; const lines = []; const looks = new Map(); + const radial = []; items.forEach((item, instance) => { const id = { gevGroundSet: options.id, instance }; const attributes = (rgba) => ({ @@ -211,7 +246,7 @@ export function groundInstances(options, items) { new Cesium.GeometryInstance({ geometry: new Cesium.GroundPolylineGeometry({ positions: positionsOf(item.path), - width: item.widthPx, + width: item.animation.backing?.widthPx ?? item.widthPx, }), id, }), @@ -237,7 +272,15 @@ export function groundInstances(options, items) { ); return; } - if (item.fill) + if (item.radial) + radial.push({ + item, + instance: new Cesium.GeometryInstance({ + geometry: fillGeometry(options.kind, item), + id, + }), + }); + else if (item.fill) fills.push( new Cesium.GeometryInstance({ geometry: fillGeometry(options.kind, item), @@ -261,7 +304,7 @@ export function groundInstances(options, items) { }), ); }); - return { fills, lines, animated: [...looks.values()] }; + return { fills, lines, animated: [...looks.values()], radial }; } /** @@ -286,12 +329,15 @@ export function createCesiumGround( const ordered = []; const building = new Set(); let removeWatch = null; + // Classification reads the current globe depth, including after a camera move. + const surfaceReady = () => + scene.globe?.show === false || scene.globe?.tilesLoaded !== false; // Frames until every committed primitive has been built and drawn. const watch = () => { if (removeWatch || !scene.postRender) return; removeWatch = scene.postRender.addEventListener(() => { for (const primitive of building) - if (primitive.ready || primitive.isDestroyed?.()) + if ((primitive.ready && surfaceReady()) || primitive.isDestroyed?.()) building.delete(primitive); if (building.size) requestRender('ground-build'); else { @@ -341,8 +387,10 @@ export function createCesiumGround( rootCollection().add(slot, at); let drawn = []; let looks = []; + let fraction = 1; const classificationType = classificationOf(options.drape); const allowPicking = options.pickable; + const synchronous = options.dynamic || options.reveal; let pending = null; let waiting = false; let destroyed = false; @@ -355,7 +403,7 @@ export function createCesiumGround( const part = { commit(items) { pending = items; - if (options.dynamic && !heightsReady) { + if (synchronous && !heightsReady) { if (!waiting) { waiting = true; heightsPromise ??= @@ -376,7 +424,10 @@ export function createCesiumGround( } forget(); slot.removeAll(); - const { fills, lines, animated } = groundInstances(options, items); + const { fills, lines, animated, radial } = groundInstances( + options, + items, + ); if (fills.length) drawn.push( make.fills({ @@ -387,7 +438,29 @@ export function createCesiumGround( }), classificationType, allowPicking, - asynchronous: !options.dynamic, + asynchronous: !synchronous, + }), + ); + for (const { item, instance } of radial) + drawn.push( + make.fills({ + geometryInstances: instance, + appearance: new Cesium.MaterialAppearance({ + material: radialMaterial(item), + translucent: true, + closed: false, + faceForward: true, + flat: true, + renderState: { + depthTest: { enabled: true }, + depthMask: false, + polygonOffset: { enabled: true, factor: -1, units: -4 }, + blending: Cesium.BlendingState.ALPHA_BLEND, + }, + }), + classificationType, + allowPicking, + asynchronous: !synchronous, }), ); if (lines.length) @@ -397,12 +470,13 @@ export function createCesiumGround( appearance: new Cesium.PolylineColorAppearance(), classificationType, allowPicking, - asynchronous: !options.dynamic, + asynchronous: !synchronous, }), ); for (const { item, instances } of animated) { const material = corridorMaterial(item); material.uniforms.time = groundSeconds(); + material.uniforms.reveal = fraction; looks.push(material); materials.add(material); drawn.push( @@ -411,7 +485,7 @@ export function createCesiumGround( appearance: new Cesium.PolylineMaterialAppearance({ material }), classificationType, allowPicking, - asynchronous: !options.dynamic, + asynchronous: !synchronous, }), ); } @@ -424,14 +498,20 @@ export function createCesiumGround( animate(); requestRender('ground'); }, + reveal(value) { + fraction = value; + for (const material of looks) material.uniforms.reveal = value; + requestRender('ground-reveal'); + }, show(visible) { slot.show = visible; animate(); requestRender('ground'); }, ready: () => - (!options.dynamic || heightsReady) && + (!synchronous || heightsReady) && !waiting && + (drawn.length === 0 || surfaceReady()) && drawn.every((primitive) => primitive.ready === true), destroy() { destroyed = true; diff --git a/src/globe/cesium/ground.test.mjs b/src/globe/cesium/ground.test.mjs index 278f244b6..f91e81638 100644 --- a/src/globe/cesium/ground.test.mjs +++ b/src/globe/cesium/ground.test.mjs @@ -19,7 +19,7 @@ for (const name of [ const items = (kind, list) => list.map((item, index) => normalizeGroundItem(kind, item, index)); -test('dynamic reveals wait for height bounds, draw the latest path and cancel on release', async (t) => { +test('reveal and dynamic sets wait for height bounds, draw the latest path and cancel on release', async (t) => { const viewer = createFakeViewer(); viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection(); let resolve; @@ -44,7 +44,7 @@ test('dynamic reveals wait for height bounds, draw the latest path and cancel on const reveal = part.ground.createSet({ id: 'reveal', kind: 'corridor', - dynamic: true, + reveal: true, }); const cancelled = part.ground.createSet({ id: 'cancelled', @@ -69,6 +69,7 @@ test('dynamic reveals wait for height bounds, draw the latest path and cancel on assert.equal(reveal.ready(), false); reveal.replace(path(2)); reveal.commit(); + reveal.setReveal(0.25); cancelled.destroy(); resolve(); await Promise.resolve(); @@ -80,6 +81,7 @@ test('dynamic reveals wait for height bounds, draw the latest path and cancel on ); const primitive = root.get(0).get(0); assert.equal(primitive.asynchronous, false); + assert.equal(primitive.appearance.material.uniforms.reveal, 0.25); const end = primitive.geometryInstances[0].geometry._positions.at(-1); assert.ok( Math.abs( @@ -89,6 +91,89 @@ test('dynamic reveals wait for height bounds, draw the latest path and cancel on ); }); +for (const kind of ['disc', 'corridor']) { + test(`${kind} material readiness includes the globe depth after a camera move`, () => { + const viewer = createFakeViewer(); + viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection(); + viewer.scene.globe.tilesLoaded = false; + const made = []; + const make = (options) => { + const primitive = { + ...options, + ready: true, + destroy() {}, + isDestroyed: () => false, + }; + made.push(primitive); + return primitive; + }; + const renders = []; + const port = createCesiumGround(viewer, { + requestRender: (reason) => renders.push(reason), + factories: { fills: make, lines: make }, + }); + const set = port.ground.createSet({ + id: kind, + kind, + ...(kind === 'corridor' ? { reveal: true } : {}), + }); + assert.equal(set.ready(), true, 'an empty set needs no surface'); + set.replace([ + kind === 'disc' + ? { + center: [20, 10], + radiusM: 40000, + fill: [0, 1, 1, 1], + radial: { + fillAlpha: 0.105, + rimAlpha: 0.72, + rimStart: 0.952, + rimEnd: 0.985, + emission: 0.35, + }, + } + : { + positions: [ + [19.7, 10], + [20.3, 10], + ], + color: [1, 0, 1, 1], + widthPx: 12, + }, + ]); + set.commit(); + if (kind === 'corridor') set.setReveal(0.25); + const material = made[0].appearance.material; + assert.equal(set.ready(), false, 'built geometry needs a loaded surface'); + viewer.scene.postRender.raiseEvent(); + assert.ok(renders.includes('ground-build')); + viewer.scene.globe.tilesLoaded = true; + viewer.scene.postRender.raiseEvent(); + assert.equal(set.ready(), true); + assert.ok(renders.includes('ground-ready')); + renders.length = 0; + viewer.scene.postRender.raiseEvent(); + assert.deepEqual( + renders, + [], + 'a ready static material stops requesting frames', + ); + viewer.scene.globe.tilesLoaded = false; + assert.equal(set.ready(), false, 'camera changes can load another surface'); + viewer.scene.globe.tilesLoaded = true; + assert.equal(set.ready(), true); + assert.equal(made.length, 1, 'surface loading never rebuilds geometry'); + assert.equal(made[0].appearance.material, material); + if (kind === 'corridor') assert.equal(material.uniforms.reveal, 0.25); + else { + assert.equal(material.uniforms.rimAlpha, 0.72); + assert.equal(material.uniforms.fillAlpha, 0.105); + } + port.destroy(); + assert.equal(viewer.scene.postRender.numberOfListeners, 0); + }); +} + test('fills batch into GroundPrimitive instances, outlines into ground polylines', () => { const options = { id: 'zones', kind: 'polygon' }; const { fills, lines } = groundInstances( @@ -316,3 +401,49 @@ test('a glow, a pulse or a dash draws through its own material, timed every fram assert.ok(!renders.includes('ground-animate'), 'none once destroyed'); part.destroy(); }); + +test('corridor reveal mutates the live material and retains it through a geometry commit', () => { + const viewer = createFakeViewer(); + viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection(); + const built = []; + const make = (options) => { + const primitive = { + ...options, + ready: true, + destroy() {}, + isDestroyed: () => false, + }; + built.push(primitive); + return primitive; + }; + const port = createCesiumGround(viewer, { + factories: { lines: make, fills: make }, + }); + const set = port.ground.createSet({ + id: 'head', + kind: 'corridor', + reveal: true, + }); + const items = [ + { + positions: [ + [20, 10], + [20.001, 10], + ], + widthPx: 5, + color: [0, 1, 0, 0.8], + }, + ]; + set.replace(items); + set.commit(); + const geometry = built[0].geometryInstances; + set.setReveal(0.25); + set.setReveal(0.5); + assert.equal(built.length, 1); + assert.equal(built[0].geometryInstances, geometry); + assert.equal(built[0].appearance.material.uniforms.reveal, 0.5); + set.replace(items); + set.commit(); + assert.equal(built[1].appearance.material.uniforms.reveal, 0.5); + port.destroy(); +}); diff --git a/src/globe/cesium/input.js b/src/globe/cesium/input.js index d7f37607f..ba262c17a 100644 --- a/src/globe/cesium/input.js +++ b/src/globe/cesium/input.js @@ -68,15 +68,20 @@ export function createCesiumInput( }; }); const delivery = createPickDelivery({ - pick: (position) => picker?.pick(position), - drill: (position, limit) => picker?.drillPick(position, limit) ?? [], + pick: (position, type, window) => + picker?.pick(position, window?.width, window?.height), + drill: (position, limit, window) => + picker?.drillPick(position, limit, window?.width, window?.height) ?? [], schedule: (run) => scheduleFrame(() => !destroyed && run()), }); const input = { acquirePointer: () => pointer.acquire(), on(type, listener, options = {}) { - const { modifier, pick, drill } = normalizeInputOptions(type, options); + const { modifier, pick, drill, pickWindow } = normalizeInputOptions( + type, + options, + ); if (destroyed) return () => {}; handler ??= createEventHandler(scene.canvas); const key = `${type}:${modifier}`; @@ -95,7 +100,13 @@ export function createCesiumInput( KEYS[modifier], ); } - const entry = { listener, pick, drill, pointer: options.pointer }; + const entry = { + listener, + pick, + drill, + pickWindow, + pointer: options.pointer, + }; listeners.add(entry); return () => { if (!listeners.delete(entry) || listeners.size) return; diff --git a/src/globe/cesium/input.test.mjs b/src/globe/cesium/input.test.mjs index 1ebc5b42d..2a78ccfe2 100644 --- a/src/globe/cesium/input.test.mjs +++ b/src/globe/cesium/input.test.mjs @@ -264,3 +264,40 @@ test('exclusive pointer suppresses ambient and stock clicks, restores exact acti assert.equal(actions.get(Type.LEFT_DOUBLE_CLICK), double); assert.equal(input.acquirePointer(), null); }); + +test('input forwards each rectangular pick window and drill limit synchronously on down', () => { + const handlers = createFakeEventHandlers(); + const calls = [], + seen = []; + const { input, destroy } = createCesiumInput(createFakeViewer(), { + createEventHandler: handlers.create, + picker: { + pick: (...args) => { + calls.push(['pick', ...args]); + return { id: 'handle' }; + }, + drillPick: (...args) => { + calls.push(['drill', ...args]); + return [{ id: 'handle' }]; + }, + }, + }); + const pickWindow = { width: 11, height: 7 }; + input.on('down', (event) => seen.push(event), { + pick: true, + drill: 8, + pickWindow, + }); + pickWindow.width = 99; + handlers.handlers[0].fire(Type.LEFT_DOWN, { position: { x: 1, y: 2 } }); + assert.equal(seen.length, 1); + assert.deepEqual( + calls.map((call) => call.slice(2)), + [ + [11, 7], + [8, 11, 7], + ], + ); + assert.equal(seen[0].picked.id, 'handle'); + destroy(); +}); diff --git a/src/globe/cesium/projector.js b/src/globe/cesium/projector.js index 3aed5e06c..11ecec7d2 100644 --- a/src/globe/cesium/projector.js +++ b/src/globe/cesium/projector.js @@ -2,6 +2,7 @@ import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js'; +import { horizonEllipsoidRadii } from '../contract.js'; /** * @param {Cesium.Viewer} viewer @@ -25,6 +26,7 @@ export function createCesiumProjector( let occluderY = Number.NaN; let occluderZ = Number.NaN; + let custom = null; const projector = { worldToWindow(position, result) { // Cesium returns undefined in front of the near plane or behind the camera. @@ -47,8 +49,25 @@ export function createCesiumProjector( result[index] = scratchMatrix[index]; return result; }, - horizonVisible(position) { + horizonVisible(position, options) { const { positionWC } = camera; + const radii = horizonEllipsoidRadii(options); + if (radii) { + if ( + !custom || + custom.radii.x !== radii.x || + custom.radii.y !== radii.y || + custom.radii.z !== radii.z + ) + custom = { + radii: { ...radii }, + occluder: new Cesium.EllipsoidalOccluder( + new Cesium.Ellipsoid(radii.x, radii.y, radii.z), + ), + }; + custom.occluder.cameraPosition = positionWC; + return custom.occluder.isPointVisible(position); + } if ( positionWC.x !== occluderX || positionWC.y !== occluderY || diff --git a/src/globe/cesium/projector.test.mjs b/src/globe/cesium/projector.test.mjs index e3b7fa6e9..c1c0bf8f3 100644 --- a/src/globe/cesium/projector.test.mjs +++ b/src/globe/cesium/projector.test.mjs @@ -2,6 +2,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js'; +import { createFakeEngine } from '../testing/fakeEngine.js'; +import { horizonVisible } from '../cameraMath.js'; import { createCesiumProjector } from './projector.js'; import { createFakeViewer } from './testing/fakeViewer.mjs'; @@ -86,3 +88,63 @@ test('horizonVisible uses the WGS84 occluder from the camera position', () => { 'follows the camera', ); }); + +test('custom horizon radii match the ellipsoidal occluder at ground and orbit heights', () => { + const { viewer, projector } = setup(); + const fake = createFakeEngine(); + const radii = geo.ellipsoid.WGS84.radii; + const ellipsoid = geo.ellipsoid.create( + radii.x - 1000, + radii.y - 1000, + radii.z - 1000, + ); + const options = { ellipsoid }; + const reference = new Cesium.EllipsoidalOccluder( + new Cesium.Ellipsoid( + ellipsoid.radii.x, + ellipsoid.radii.y, + ellipsoid.radii.z, + ), + ); + const at = geo.cartesian3.fromDegrees; + const cases = [ + [at(-74.006, 40.7128, -19.6), at(-74.006, 40.71289, -22), true], + [at(79.8612, 6.9271, -21), at(79.8612, 6.92782, -95), true], + [at(79.8612, 6.9271, -21), at(79.86192, 6.9271, -95), true], + [at(-97.7431, 30.2672, 132), at(-97.7431, 30.2681, 130), true], + [at(0, 0, 20000000), at(10, 10, 0), true], + [at(0, 0, 20000000), at(180, 0, 0), false], + ]; + for (const height of [-1100, -20, 10, 10000, 20000000]) + for (const latitude of [-80, 0, 65]) + for (const separation of [0, 0.01, 0.1, 1, 5, 30, 90, 180]) + cases.push([at(0, latitude, height), at(separation, latitude, -22)]); + for (const [eye, point, known] of cases) { + viewer.camera.positionWC = new Cesium.Cartesian3(eye.x, eye.y, eye.z); + fake.camera.setView({ destination: eye }); + reference.cameraPosition = eye; + const expected = reference.isPointVisible(point); + if (known !== undefined) assert.equal(expected, known); + assert.equal(projector.horizonVisible(point, options), expected); + assert.equal(fake.projector.horizonVisible(point, options), expected); + assert.equal(horizonVisible(eye, point, options), expected); + const normal = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, eye); + assert.equal(projector.horizonVisible(point), normal.isPointVisible(point)); + } + for (const bad of [ + null, + {}, + { radii: { x: 0, y: 1, z: 1 } }, + { radii: { x: 1, y: NaN, z: 1 } }, + ]) { + assert.throws( + () => projector.horizonVisible(at(0, 0), { ellipsoid: bad }), + TypeError, + ); + assert.throws( + () => fake.projector.horizonVisible(at(0, 0), { ellipsoid: bad }), + TypeError, + ); + } + fake.destroy(); +}); diff --git a/src/globe/contract.js b/src/globe/contract.js index 04adaa133..6727e7c14 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -182,9 +182,12 @@ * @typedef {object} InputOptions * @property {{ release: () => void }} [pointer] The live exclusive pointer claim. * @property {InputModifier} [modifier] - * @property {boolean} [pick] click, doubleClick and move only. A move + * @property {boolean} [pick] click, doubleClick, down and move only. A move * listener with pick gets at most one payload per frame (the latest). - * @property {number} [drill] A positive integer (click and doubleClick). + * @property {number} [drill] A positive integer; moves coalesce with their picks. + * @property {{ width: number, height: number }} [pickWindow] Positive integer + * pick dimensions in pixels, passed to the picker. Omitted: engine default. + * Down picks are synchronous; click delivery keeps the engine's gesture pick. * * @typedef {object} GlobeProjector * @property {(position: Cartesian3, result?: Cartesian2) => (Cartesian2 | undefined)} worldToWindow @@ -193,9 +196,10 @@ * 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 + * @property {(position: Cartesian3, options?: { ellipsoid?: { radii: Cartesian3 } }) => boolean} horizonVisible * False when the WGS84 ellipsoid hides the point from the camera - * (Cesium's EllipsoidalOccluder.isPointVisible). + * (Cesium's EllipsoidalOccluder.isPointVisible). An optional ellipsoid + * supplies positive finite radii in metres; omitted means WGS84. * * @typedef {object} GlobeSurface * @property {(cartographic: Cartographic, exclude?: any[]) => (number | undefined)} sampleHeight @@ -513,6 +517,7 @@ * following their relief, under every mover, line and model (Cesium: * GroundPrimitive and GroundPolylinePrimitive; src/globe/ground.js checks * and defaults these). Nothing is extruded. + * @property {boolean} [reveal] Unpickable pixel corridors support a mutable reveal fraction. * @property {string} id Unique among the engine's live ground sets. * @property {'polygon'|'disc'|'cell'|'corridor'} kind One kind of item per * set (GroundItem). @@ -528,6 +533,14 @@ * (Cesium's classificationType). Navara drapes on whatever surface it * draws. * + * @typedef {object} GroundRadial + * @property {number} fillAlpha Interior alpha multiplier, 0..1. + * @property {number} rimAlpha Rim alpha multiplier, 0..1. + * @property {number} rimStart Start of the smooth rim, fraction of radius. + * @property {number} rimEnd End of the smooth rim; 0 <= rimStart < rimEnd < 1. + * The outer edge fades smoothly from rimEnd to 1. + * @property {number} emission Nonnegative rim colour gain. + * * @typedef {object} GroundItem * One fill, by its set's kind. Positions are [longitude, latitude] in * degrees (GeoJSON order); edges follow great circles; colours are rgba in @@ -551,6 +564,9 @@ * alpha times `gapAlpha`; a lit dash brightened by `emission` times its * colour). A shown set with a pulse or a dash keeps the engine drawing * a frame after every frame, as Cesium's continuous render did. + * A pixel corridor may take `backing: { widthPx, color }`, wider than its + * core. The edge uses the backing colour and alpha; the core is blended + * once, without a second layer underneath it. * A corridor may instead take `dashPattern` ({ lengthPx, pattern = 255 }): * a static 16-bit mask repeated every lengthPx CSS pixels, with clear * gaps, as PolylineDash. It cannot combine with the other looks. @@ -559,8 +575,12 @@ * omitted: none). Every kind takes `pickId`: what a pick of the item * returns (an item without one is not picked). * + * @property {GroundRadial} [radial] Disc with fill: radial alpha and emission. + * * @typedef {object} GroundSet * @property {string} id + * @property {(fraction: number) => void} setReveal Reveal each corridor from 0..1 + * of its length without rebuilding geometry; requires reveal: true. * @property {(items: GroundItem[]) => void} replace Checks and stores every * item at once (the previous items go); drawn after commit(). * @property {() => void} commit Draws the stored items in place of the last @@ -768,14 +788,19 @@ export const INPUT_TYPES = Object.freeze([ ]); export const MODIFIERS = Object.freeze(['ctrl', 'shift', 'alt']); /** Input types whose listeners may ask for picks. */ -const PICK_INPUT_TYPES = Object.freeze(['click', 'doubleClick', 'move']); +const PICK_INPUT_TYPES = Object.freeze([ + 'click', + 'doubleClick', + 'move', + 'down', +]); /** * input.on options checked and defaulted: { modifier, pick, drill } (drill * 0 when not asked), or a TypeError. */ export function normalizeInputOptions(type, options = {}) { - const { modifier, pick = false, drill = 0 } = options ?? {}; + const { modifier, pick = false, drill = 0, pickWindow } = options ?? {}; if (!INPUT_TYPES.includes(type)) throw new TypeError(`Unknown input type: ${type}`); if (modifier !== undefined && !MODIFIERS.includes(modifier)) @@ -786,8 +811,49 @@ export function normalizeInputOptions(type, options = {}) { throw new TypeError(`Input drill must be a whole number: ${drill}`); if ((pick || drill) && !PICK_INPUT_TYPES.includes(type)) throw new TypeError(`Input ${type} cannot pick`); - if (drill && type === 'move') throw new TypeError('A move cannot drill'); - return { modifier, pick: pick || drill > 0, drill }; + if ( + pickWindow !== undefined && + (!pickWindow || + !Number.isInteger(pickWindow.width) || + pickWindow.width <= 0 || + !Number.isInteger(pickWindow.height) || + pickWindow.height <= 0) + ) + throw new TypeError( + 'Input pickWindow needs positive integer width and height', + ); + if (pickWindow && !(pick || drill)) + throw new TypeError('Input pickWindow needs pick or drill'); + return { + modifier, + pick: pick || drill > 0, + drill, + ...(pickWindow + ? { + pickWindow: Object.freeze({ + width: pickWindow.width, + height: pickWindow.height, + }), + } + : {}), + }; +} + +/** Validated optional horizon ellipsoid radii; undefined selects WGS84. */ +export function horizonEllipsoidRadii(options) { + if (options?.ellipsoid === undefined) return undefined; + const radii = options.ellipsoid?.radii; + if ( + !radii || + !Number.isFinite(radii.x) || + radii.x <= 0 || + !Number.isFinite(radii.y) || + radii.y <= 0 || + !Number.isFinite(radii.z) || + radii.z <= 0 + ) + throw new TypeError('Horizon ellipsoid needs positive finite radii'); + return radii; } const ENGINE_EVENT = Symbol('gev.globe.engineEvent'); diff --git a/src/globe/cpuPicks.js b/src/globe/cpuPicks.js index f4fde1a41..94b4965de 100644 --- a/src/globe/cpuPicks.js +++ b/src/globe/cpuPicks.js @@ -67,6 +67,46 @@ export function distanceScale(scaleByDistance, distance) { return nearScale + (farScale - nearScale) * t; } +// Distance from a projected segment to a rectangular pick window. +function distanceToWindow(at, a, b, halfWidth, halfHeight) { + if (!halfWidth && !halfHeight) return distanceToSegment(at, a, b); + let enter = 0, + leave = 1; + for (const [axis, half] of [ + ['x', halfWidth], + ['y', halfHeight], + ]) { + const start = a[axis] - at[axis], + delta = b[axis] - a[axis]; + if (delta === 0) { + if (Math.abs(start) > half) { + enter = 1; + leave = 0; + break; + } + } else { + const first = (-half - start) / delta, + last = (half - start) / delta; + enter = Math.max(enter, Math.min(first, last)); + leave = Math.min(leave, Math.max(first, last)); + } + } + if (enter <= leave) return 0; + const distance = (point) => + Math.hypot( + Math.max(0, Math.abs(point.x - at.x) - halfWidth), + Math.max(0, Math.abs(point.y - at.y) - halfHeight), + ); + let nearest = Math.min(distance(a), distance(b)); + for (const dx of [-halfWidth, halfWidth]) + for (const dy of [-halfHeight, halfHeight]) + nearest = Math.min( + nearest, + distanceToSegment({ x: at.x + dx, y: at.y + dy }, a, b), + ); + return nearest; +} + function moverRadius(options, scale, distance, modelRadiusPx = null) { if (options.kind === 'model' && modelRadiusPx != null) return Math.max(modelRadiusPx, PICK_RADIUS_PX) + PICK_RADIUS_PX; @@ -117,7 +157,7 @@ export function createCpuPicker({ const depthOf = (position) => geo.cartesian3.distance(position, camera.livePosition); - function moverHits(at, hits) { + function moverHits(at, hits, halfWidth, halfHeight) { for (const { options, store, pickRadiusPx } of movers()) { if (!options.pickable) continue; const onTop = options.depthTest === 'always' ? 1 : 0; @@ -133,7 +173,10 @@ export function createCpuPicker({ ); const screen = projector.worldToWindow(scratch, scratchScreen); if (!screen || !projector.horizonVisible(scratch)) continue; - const distance = Math.hypot(screen.x - at.x, screen.y - at.y); + const distance = Math.hypot( + Math.max(0, Math.abs(screen.x - at.x) - halfWidth), + Math.max(0, Math.abs(screen.y - at.y) - halfHeight), + ); const depth = depthOf(scratch); if ( distance > @@ -150,7 +193,7 @@ export function createCpuPicker({ } } - function lineHits(at, hits) { + function lineHits(at, hits, halfWidth, halfHeight) { for (const { options, flat, modelMatrix } of lines()) { if (options.pickId === undefined) continue; const limit = Math.max( @@ -166,7 +209,8 @@ export function createCpuPicker({ const sa = projector.worldToWindow(a, scratchScreen); const sb = sa && projector.worldToWindow(b, scratchScreenB); if (!sa || !sb) continue; - if (distanceToSegment(at, sa, sb) > limit) continue; + if (distanceToWindow(at, sa, sb, halfWidth, halfHeight) > limit) + continue; const t = segmentT(at, sa, sb); const depth = depthOf( geo.cartesian3.lerp(a, b, t, geo.cartesian3.create()), @@ -187,16 +231,47 @@ export function createCpuPicker({ const drill = ( at, limit = Infinity, - { includeMovers = true, includeLines = true, includeMeshes = true } = {}, + { + includeMovers = true, + includeLines = true, + includeMeshes = true, + width = 1, + height = 1, + } = {}, ) => { const hits = []; - if (includeMovers) moverHits(at, hits); - if (includeLines) lineHits(at, hits); - if (includeMeshes) hits.push(...meshes(at)); + const halfWidth = (width - 1) / 2, + halfHeight = (height - 1) / 2; + if (includeMovers) moverHits(at, hits, halfWidth, halfHeight); + if (includeLines) lineHits(at, hits, halfWidth, halfHeight); + // Mesh and ground pickers answer points, so sample each pixel of the + // requested window; the projected movers and lines above are tested once. + if (includeMeshes) + for (let y = 0; y < height; y++) + for (let x = 0; x < width; x++) { + const sample = + width === 1 && height === 1 + ? at + : { x: at.x + x - halfWidth, y: at.y + y - halfHeight }; + hits.push(...meshes(sample)); + } hits.sort( (a, b) => b.onTop - a.onTop || b.order - a.order || a.depth - b.depth, ); - return hits.slice(0, limit).map(({ result }) => result); + const results = []; + for (const { result } of hits) { + if (results.length >= limit) break; + if ( + !results.some( + (hit) => + hit.owner === result.owner && + hit.instance === result.instance && + hit.id === result.id, + ) + ) + results.push(result); + } + return results; }; /** * Whether a mover instance is drawn near a window position: within its diff --git a/src/globe/cpuPicks.test.mjs b/src/globe/cpuPicks.test.mjs index b1ef95b22..7f88a0dae 100644 --- a/src/globe/cpuPicks.test.mjs +++ b/src/globe/cpuPicks.test.mjs @@ -71,3 +71,33 @@ test('distanceScale is Cesium.NearFarScalar interpolation', async () => { ); } }); + +test('rectangular windows include nearby hits once and keep draw order and drill limit', () => { + const picker = createCpuPicker({ + projector: {}, + camera: {}, + movers: () => [], + lines: () => [], + meshes: (at) => + Math.abs(at.x - 4) < 0.1 && Math.abs(at.y) < 0.1 + ? [ + { + onTop: 0, + order: 0, + depth: 1, + result: { id: 'behind', owner: 'b' }, + }, + { onTop: 1, order: 0, depth: 2, result: { id: 'top', owner: 'a' } }, + ] + : [], + }); + assert.deepEqual(picker({ x: 0, y: 0 }), []); + assert.deepEqual(picker({ x: 0, y: 0 }, 1, { width: 9, height: 3 }), [ + { id: 'top', owner: 'a' }, + ]); + assert.deepEqual( + picker({ x: 0, y: 0 }, 8, { width: 9, height: 3 }).map((hit) => hit.id), + ['top', 'behind'], + ); + assert.deepEqual(picker({ x: 0, y: 0 }, 8, { width: 3, height: 9 }), []); +}); diff --git a/src/globe/ground.js b/src/globe/ground.js index 59f548618..61cfcf7a3 100644 --- a/src/globe/ground.js +++ b/src/globe/ground.js @@ -47,6 +47,13 @@ export function normalizeGroundSetOptions(options) { fail('pickable must be a boolean'); if (options.dynamic !== undefined && typeof options.dynamic !== 'boolean') fail('dynamic must be a boolean'); + if ( + options.reveal !== undefined && + (typeof options.reveal !== 'boolean' || + kind !== 'corridor' || + options.pickable) + ) + fail('reveal needs an unpickable corridor set'); const drape = options.drape ?? 'both'; if (!GROUND_DRAPES.includes(drape)) fail(`drape must be one of ${GROUND_DRAPES.join(', ')}: ${drape}`); @@ -57,6 +64,7 @@ export function normalizeGroundSetOptions(options) { pickable: options.pickable === true, drape, ...(options.dynamic ? { dynamic: true } : {}), + ...(options.reveal ? { reveal: true } : {}), }; } @@ -117,6 +125,22 @@ function outlineOf(item) { return { color: Float32Array.from(outline.color), widthPx: outline.widthPx }; } +// A disc's radius fractions and alpha envelope, independent of its colour. +function radialOf(item) { + if (item.radial == null) return null; + const { fillAlpha, rimAlpha, rimStart, rimEnd, emission } = item.radial; + if ( + !item.fill || + ![fillAlpha, rimAlpha, rimStart, rimEnd].every(unit) || + !(rimStart < rimEnd && rimEnd < 1) || + !(isFiniteNumber(emission) && emission >= 0) + ) + fail( + 'radial needs fill, alpha in 0..1, 0 <= rimStart < rimEnd < 1 and emission >= 0', + ); + return Object.freeze({ fillAlpha, rimAlpha, rimStart, rimEnd, emission }); +} + function boundsOf(paths) { let west = Infinity; let east = -Infinity; @@ -144,6 +168,7 @@ const DEGREE_M = (Math.PI / 180) * EARTH_RADIUS; export function normalizeGroundItem(kind, item, index = 0) { if (!isObject(item)) fail(`item ${index} must be an object`); const base = { pickId: item.pickId }; + if (item.radial != null && kind !== 'disc') fail('radial needs a disc'); if (kind === 'polygon') { if (!Array.isArray(item.rings) || !item.rings.length) fail(`item ${index} needs rings: [outer, ...holes]`); @@ -173,6 +198,7 @@ export function normalizeGroundItem(kind, item, index = 0) { ...base, center: [center[0], center[1]], radiusM: item.radiusM, + radial: radialOf(item), fill: fillOf(item), outline: outlineOf(item), bounds: { @@ -244,8 +270,14 @@ const unit = (value) => isFiniteNumber(value) && value >= 0 && value <= 1; // alpha going up and down, `dash` the annotation route's flowing dashes. // Null without any. function animationOf(item, index) { - const { glow, pulse, dash, dashPattern } = item; - if (glow == null && pulse == null && dash == null && dashPattern == null) + const { glow, pulse, dash, dashPattern, backing } = item; + if ( + glow == null && + pulse == null && + dash == null && + dashPattern == null && + backing == null + ) return null; if (dashPattern != null) { if ( @@ -257,7 +289,7 @@ function animationOf(item, index) { const pattern = dashPattern.pattern ?? 255; if (!Number.isInteger(pattern) || pattern < 0 || pattern > 65535) fail(`item ${index} dashPattern.pattern must be a 16-bit integer`); - if (glow != null || pulse != null || dash != null) + if (glow != null || pulse != null || dash != null || backing != null) fail( `item ${index} dashPattern cannot combine with other corridor looks`, ); @@ -306,7 +338,28 @@ function animationOf(item, index) { fail(`${label} dash.emission must be a number >= 0: ${emission}`); dashOut = Object.freeze({ cells, duty, speed, gapAlpha, emission }); } - return Object.freeze({ glow: glowOut, pulse: pulseOut, dash: dashOut }); + let backingOut = null; + if (backing != null) { + if ( + !isObject(backing) || + !isColor(backing.color) || + !isFiniteNumber(backing.widthPx) || + !(backing.widthPx > item.widthPx) + ) + fail( + `${label} backing needs a colour and a widthPx larger than the core`, + ); + backingOut = Object.freeze({ + color: Object.freeze([...backing.color]), + widthPx: backing.widthPx, + }); + } + return Object.freeze({ + glow: glowOut, + pulse: pulseOut, + dash: dashOut, + ...(backingOut ? { backing: backingOut } : {}), + }); } /** Whether a checked item changes over time (a pulse or a dash). */ @@ -423,6 +476,7 @@ export function createGroundRegistry(build, { pulseField } = {}) { // What the last commit drew: picks read it. items: [], shown: true, + reveal: 1, // Whether the last commit has a pulse or a dash. animated: false, destroyed: false, @@ -435,9 +489,15 @@ export function createGroundRegistry(build, { pulseField } = {}) { replace(items) { alive(); if (!Array.isArray(items)) fail('items must be an array'); - state.pending = items.map((item, index) => - normalizeGroundItem(options.kind, item, index), - ); + state.pending = items.map((item, index) => { + const checked = normalizeGroundItem(options.kind, item, index); + if (options.reveal) { + if (!checked.widthPx || checked.animation?.pattern) + fail('reveal needs pixel corridors without dashPattern'); + checked.animation ??= {}; + } + return checked; + }); }, commit() { alive(); @@ -450,6 +510,14 @@ export function createGroundRegistry(build, { pulseField } = {}) { state.shown = Boolean(visible); part.show(state.shown); }, + setReveal(fraction) { + alive(); + if (!options.reveal || !unit(fraction)) + fail('setReveal needs a reveal set and fraction in 0..1'); + if (state.reveal === fraction) return; + state.reveal = fraction; + part.reveal?.(fraction); + }, shown: () => state.shown, count: () => state.items.length, ready: () => diff --git a/src/globe/ground.test.mjs b/src/globe/ground.test.mjs index 2b5c33eea..2b6862a3e 100644 --- a/src/globe/ground.test.mjs +++ b/src/globe/ground.test.mjs @@ -401,3 +401,69 @@ test('CPU picks find the item under the picked ground, on top first', () => { low.show(false); assert.deepEqual(ids(), []); }); + +test('radial discs copy and validate their alpha envelope', () => { + const radial = { + fillAlpha: 0.105, + rimAlpha: 0.72, + rimStart: 0.952, + rimEnd: 0.985, + emission: 0.35, + }; + const item = { center: [20, 10], radiusM: 100, fill: [0, 1, 1, 1], radial }; + const checked = normalizeGroundItem('disc', item); + assert.deepEqual(checked.radial, radial); + assert.notEqual(checked.radial, radial); + for (const patch of [ + { fillAlpha: -1 }, + { rimAlpha: 2 }, + { rimStart: 0.99 }, + { rimEnd: 1 }, + { emission: NaN }, + ]) + assert.throws( + () => + normalizeGroundItem('disc', { + ...item, + radial: { ...radial, ...patch }, + }), + TypeError, + ); + assert.throws( + () => normalizeGroundItem('disc', { ...item, fill: null }), + TypeError, + ); + assert.throws(() => normalizeGroundItem('cell', item), TypeError); +}); + +test('pixel corridor backing keeps a wider edge and rejects invalid combinations', () => { + const item = { + positions: [ + [0, 0], + [1, 1], + ], + widthPx: 3, + color: [0, 1, 0, 0.8], + backing: { widthPx: 5, color: [0.02, 0.03, 0.05, 0.8] }, + }; + assert.deepEqual( + normalizeGroundItem('corridor', item).animation.backing, + item.backing, + ); + for (const backing of [ + { widthPx: 3, color: [0, 0, 0, 1] }, + { widthPx: 5, color: [0, 0, 0, 2] }, + ]) + assert.throws( + () => normalizeGroundItem('corridor', { ...item, backing }), + TypeError, + ); + assert.throws( + () => + normalizeGroundItem('corridor', { + ...item, + dashPattern: { lengthPx: 16 }, + }), + TypeError, + ); +}); diff --git a/src/globe/navara/cameraModel.js b/src/globe/navara/cameraModel.js index 15ae20296..1645c87fd 100644 --- a/src/globe/navara/cameraModel.js +++ b/src/globe/navara/cameraModel.js @@ -420,7 +420,8 @@ export function createCameraModel({ worldToWindow: (position, result) => projectToWindow(view(), position, result), viewProjection: (result) => viewProjection(view(), result), - horizonVisible: (position) => horizonVisible(state.position, position), + horizonVisible: (position, options) => + horizonVisible(state.position, position, options), destroy() { if (destroyed) return; cancelCurrentFlight(); diff --git a/src/globe/navara/createNavaraEngine.js b/src/globe/navara/createNavaraEngine.js index 4c5cbda17..d555c9d3b 100644 --- a/src/globe/navara/createNavaraEngine.js +++ b/src/globe/navara/createNavaraEngine.js @@ -235,7 +235,8 @@ export function createNavaraEngine( projector: { worldToWindow: (position, result) => model.worldToWindow(position, result), - horizonVisible: (position) => model.horizonVisible(position), + horizonVisible: (position, options) => + model.horizonVisible(position, options), }, camera: model.camera, movers: () => moversPort?.pickables?.() ?? [], @@ -285,8 +286,9 @@ export function createNavaraEngine( registerMeshPicker: (pick) => picking.registerMeshPicker(pick), // The CPU pick of movers and lines (picking.js); 3D tiles carry no ids. // Clicks with { pick } get Navara's GPU pick of the gesture instead. - pick: (screen) => picking.pick(screen), - drillPick: (screen, limit) => picking.drillPick(screen, limit), + pick: (screen, width, height) => picking.pick(screen, width, height), + drillPick: (screen, limit, width, height) => + picking.drillPick(screen, limit, width, height), pickPosition(screen) { const rect = canvas.getBoundingClientRect(); const hit = view.pickDepthPosition( @@ -416,7 +418,8 @@ export function createNavaraEngine( worldToWindow: (position, result) => model.worldToWindow(position, result), viewProjection: (result) => model.viewProjection(result), - horizonVisible: (position) => model.horizonVisible(position), + horizonVisible: (position, options) => + model.horizonVisible(position, options), }, frames, render: { diff --git a/src/globe/navara/ground/corridors.js b/src/globe/navara/ground/corridors.js index 2c38965d2..2c47d2757 100644 --- a/src/globe/navara/ground/corridors.js +++ b/src/globe/navara/ground/corridors.js @@ -46,7 +46,7 @@ const E2 = /** Item looks as the shader reads them: glow, pulse and dash bits. */ export const LOOK = Object.freeze({ glow: 1, pulse: 2, dash: 4, pattern: 8 }); /** RGBA32F texels per corridor item. */ -export const ITEM_TEXELS = 5; +export const ITEM_TEXELS = 7; const wrap180 = (degrees) => degrees - 360 * Math.round(degrees / 360); const geodetic = (latcDeg) => @@ -54,7 +54,7 @@ const geodetic = (latcDeg) => ? latcDeg : Math.atan(Math.tan(latcDeg * DEG) / (1 - E2)) / DEG; -/** One item's look: five texels (colour, widths, glow/pulse, dash). */ +/** One item's look: colour, widths, glow/pulse, dash and reveal. */ function itemTexels(item, lengthM) { const { glow, pulse, dash, pattern } = item.animation ?? {}; const flags = @@ -64,7 +64,7 @@ function itemTexels(item, lengthM) { (pattern ? LOOK.pattern : 0); return [ ...item.fill, - item.widthPx ?? 0, + item.animation?.backing?.widthPx ?? item.widthPx ?? 0, item.widthM ?? 0, lengthM, flags, @@ -80,6 +80,11 @@ function itemTexels(item, lengthM) { dash?.emission ?? 0, pattern?.lengthPx ?? 0, pattern?.pattern ?? 0, + 1, + item.widthPx / (item.animation?.backing?.widthPx ?? item.widthPx) || 1, + 0, + 0, + ...(item.animation?.backing?.color ?? [0, 0, 0, 0]), ]; } diff --git a/src/globe/navara/ground/index.js b/src/globe/navara/ground/index.js index 2331d30fb..58d4c5194 100644 --- a/src/globe/navara/ground/index.js +++ b/src/globe/navara/ground/index.js @@ -12,6 +12,7 @@ import { createGroundRegistry } from '../../ground.js'; import { composeGround, packHeights, packSet } from './pack.js'; +import { ITEM_TEXELS } from './corridors.js'; // Frames before the first new try at a set's missing terrain heights; each // later try waits twice as long, up to RETRIES tries. @@ -60,6 +61,8 @@ export function createNavaraGround( }; const pack = (entry) => { entry.pack = packSet(entry.options.kind, entry.items, { terrainHeight }); + for (let at = 20; at < entry.pack.items.length; at += ITEM_TEXELS * 4) + entry.pack.items[at] = entry.fraction; entry.wait = entry.pack.heights.some(Number.isNaN) ? RETRY_FRAMES : 0; }; const sampleAgain = (entry) => { @@ -70,6 +73,12 @@ export function createNavaraGround( const drawn = [...entries] .filter((entry) => entry.shown && entry.pack?.count) .sort((a, b) => a.options.order - b.options.order); + for (const entry of entries) entry.offset = null; + let offset = 0; + for (const entry of drawn) { + entry.offset = offset; + offset += entry.pack.items.length; + } pass.set( drawn.length ? composeGround(drawn.map(({ pack }) => pack)) : null, ); @@ -94,6 +103,8 @@ export function createNavaraGround( const entry = { options, shown: true, + fraction: 1, + offset: null, items: [], pack: null, tries: 0, @@ -107,6 +118,21 @@ export function createNavaraGround( pack(entry); compose(); }, + reveal(fraction) { + if (entry.fraction === fraction) return; + entry.fraction = fraction; + if (entry.pack) { + for (let at = 20; at < entry.pack.items.length; at += ITEM_TEXELS * 4) + entry.pack.items[at] = fraction; + if (entry.offset !== null) + pass.reveal( + entry.offset, + entry.pack.items.length / (ITEM_TEXELS * 4), + fraction, + ); + } + view.forceUpdate(); + }, show(visible) { if (entry.shown === visible) return; entry.shown = visible; diff --git a/src/globe/navara/ground/index.test.mjs b/src/globe/navara/ground/index.test.mjs index bdfb24207..c45c7cfbb 100644 --- a/src/globe/navara/ground/index.test.mjs +++ b/src/globe/navara/ground/index.test.mjs @@ -1,7 +1,43 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; import { createNavaraGround } from './index.js'; +// The pass owns real data textures; the view below does not create GPU effects. +const hooks = registerHooks({ + resolve(specifier, context, nextResolve) { + return specifier === '@navaramap/three' + ? { + url: 'data:text/javascript,export class EffectDesc {} export class Effect {}', + shortCircuit: true, + } + : nextResolve(specifier, context); + }, +}); +const { createGroundPass } = await import('./pass.js'); +hooks.deregister(); + +function passView() { + const effects = new Set(); + return Object.assign(fakeView(), { + effects, + registerEffect() {}, + addEffect(config) { + const handle = { + config, + update(next) { + Object.assign(config, next); + }, + delete() { + effects.delete(handle); + }, + }; + effects.add(handle); + return handle; + }, + }); +} + function fakeView({ height = () => 12 } = {}) { const listeners = new Map(); return { @@ -125,3 +161,116 @@ test('while a shown set pulses or dashes, every frame asks for the next', () => queued.shift()(); assert.equal(view.frames, destroyed, 'none after destroy'); }); + +test('corridor reveal updates item data without repacking geometry, including hide and commit', () => { + const pass = fakePass(), + updates = []; + pass.reveal = (...args) => updates.push(args); + const port = createNavaraGround(fakeView(), { createPass: () => pass }); + const set = port.ground.createSet({ + id: 'head', + kind: 'corridor', + reveal: true, + }); + const items = [ + { + positions: [ + [20, 10], + [20.001, 10], + ], + widthPx: 5, + color: [0, 1, 0, 0.8], + }, + ]; + set.replace(items); + set.commit(); + const state = pass.owner.state; + const count = pass.states.length; + set.setReveal(0.25); + set.setReveal(0.5); + assert.equal(pass.states.length, count); + assert.equal(pass.owner.state.segments, state.segments); + assert.deepEqual(updates, [ + [0, 1, 0.25], + [0, 1, 0.5], + ]); + set.show(false); + set.setReveal(0.75); + set.show(true); + assert.equal(pass.owner.state.items.data[20], 0.75); + set.replace(items); + set.commit(); + assert.equal(pass.owner.state.items.data[20], 0.75); + set.destroy(); + port.destroy(); +}); + +test('destroying a revealed corridor leaves the next ground set with fresh pass data', () => { + const corridor = { + positions: [ + [20, 10], + [20.1, 10], + ], + widthPx: 12, + color: [1, 0, 1, 1], + }; + const view = passView(); + const pass = createGroundPass(view); + const port = createNavaraGround(view, { createPass: () => pass }); + const set = port.ground.createSet({ + id: 'reveal', + kind: 'corridor', + reveal: true, + }); + set.replace([corridor]); + set.commit(); + set.setReveal(0.25); + assert.equal(pass.owner.state.items.data[20], 0.25); + set.destroy(); + assert.equal(pass.owner.state, null); + assert.equal(pass.owner.textures, null); + assert.equal( + view.effects.size, + 0, + 'no retired ground effect remains in the pulse insertion path', + ); + + for (const [kind, item] of [ + ['corridor', corridor], + ['polygon', square(20)], + ['disc', { center: [20, 10], radiusM: 500, fill: [0, 1, 0, 1] }], + ['cell', { west: 20, east: 21, south: 10, north: 11, fill: [0, 0, 1, 1] }], + ]) { + const freshView = passView(); + const freshPass = createGroundPass(freshView); + const fresh = createNavaraGround(freshView, { + createPass: () => freshPass, + }); + const next = port.ground.createSet({ id: 'next', kind }); + const expected = fresh.ground.createSet({ id: 'next', kind }); + for (const target of [next, expected]) { + target.replace([item]); + target.commit(); + } + for (const key of [ + 'styles', + 'segments', + 'bins', + 'runs', + 'items', + 'frames', + 'grid', + 'candidates', + ]) + assert.deepEqual( + pass.owner.state[key], + freshPass.owner.state[key], + `${kind}: ${key}`, + ); + assert.throws(() => set.setReveal(0.9), /destroyed/); + assert.deepEqual(pass.owner.state.items, freshPass.owner.state.items); + next.destroy(); + fresh.destroy(); + } + port.destroy(); +}); diff --git a/src/globe/navara/ground/pack.js b/src/globe/navara/ground/pack.js index 5e7b5a4d4..d60a2e99e 100644 --- a/src/globe/navara/ground/pack.js +++ b/src/globe/navara/ground/pack.js @@ -195,7 +195,7 @@ const OPEN = 1e30; // anti-aliasing in pixels; a metre one its half width in metres and the // half pixel. const reachOf = (item) => [ - (item.widthPx ?? 0) / 2 + 0.5, + (item.animation?.backing?.widthPx ?? item.widthPx ?? 0) / 2 + 0.5, (item.widthM ?? 0) / 2, ]; @@ -351,6 +351,13 @@ export function packSet( const frame = frameAt(item.center[0], item.center[1]); const radius = frame.radius * Math.tan(item.radiusM / frame.radius); style.shape = [KIND.disc, item.outline?.widthPx ?? 0, 0, radius]; + if (item.radial) { + const { fillAlpha, rimAlpha, rimStart, rimEnd, emission } = + item.radial; + style.shape[2] = 1; + style.bins = [fillAlpha, rimAlpha, rimStart, rimEnd]; + style.origin = [emission, 0, 0, 0]; + } emit( frame, style, diff --git a/src/globe/navara/ground/pack.test.mjs b/src/globe/navara/ground/pack.test.mjs index 09e63a89d..253e07c88 100644 --- a/src/globe/navara/ground/pack.test.mjs +++ b/src/globe/navara/ground/pack.test.mjs @@ -480,3 +480,25 @@ test('screen-space dash masks keep their CSS length and all sixteen bits in the assert.deepEqual(Array.from(pack.items.slice(18, 20)), [18, 0xa55a]); assert.equal(pack.items.length, ITEM_TEXELS * 4); }); + +test('radial discs pack both transitions, alpha and emission without segment data', () => { + const radial = { + fillAlpha: 0.105, + rimAlpha: 0.72, + rimStart: 0.952, + rimEnd: 0.985, + emission: 0.35, + }; + const pack = packSet( + 'disc', + items('disc', [ + { center: [20, 10], radiusM: 100, fill: [0, 1, 1, 1], radial }, + ]), + ); + assert.equal(pack.styles[10], 1); + assert.deepEqual( + Array.from(pack.styles.slice(16, 21)), + Array.from(Float32Array.of(0.105, 0.72, 0.952, 0.985, 0.35)), + ); + assert.equal(pack.segments.length, 0); +}); diff --git a/src/globe/navara/ground/pass.js b/src/globe/navara/ground/pass.js index 2625c98f0..7e1c8812c 100644 --- a/src/globe/navara/ground/pass.js +++ b/src/globe/navara/ground/pass.js @@ -11,7 +11,7 @@ // blended in display space, in draw order, as Cesium blends translucent // ground primitives; a corridor's glow, pulse and dash follow Cesium's // materials, from the time uniform (src/globe/ground.js groundSeconds). -// Costs nothing while no item is drawn (the effect is hidden). +// No effect remains in the chain while no item is drawn. import { EffectDesc, Effect as NavaraEffect } from '@navaramap/three'; import { Effect, EffectAttribute } from 'postprocessing'; @@ -72,6 +72,7 @@ vec3 toLinear(vec3 c) { vec3 blendCorridor(vec3 color, int item, float nearest, vec2 normal, float along, vec2 localDx, vec2 localDy) { int at = item * ${ITEM_TEXELS}; vec4 shape = atSlot(tItems, at + 1); + if (along / max(shape.z, 1e-6) > atSlot(tItems, at + 5).x) return color; // Metres a pixel spans across the line (the screen gradient of the // distance to it): its width in pixels the same at any angle, as Cesium's. float pixel = max(length(vec2(dot(normal, localDx), dot(normal, localDy))), 1e-4); @@ -110,6 +111,12 @@ vec3 blendCorridor(vec3 color, int item, float nearest, vec2 normal, float along alpha *= max(on, dash.x); } } + float core = atSlot(tItems, at + 5).y; + if (core < 1.0 && nearest / max(halfWidth, 1e-6) > core) { + vec4 backing = atSlot(tItems, at + 6); + rgb = backing.rgb; + alpha = backing.a * cover; + } return min(color * (1.0 - alpha) + min(rgb * alpha, vec3(1.0)), vec3(1.0)); } void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) { @@ -301,6 +308,15 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) // Metres one pixel spans across the boundary. float pixel = max(abs(dot(normal, localDx)) + abs(dot(normal, localDy)), 1e-4); float inward = inside ? nearest : -nearest; + if (kind == ${KIND.disc} && shape.z > 0.5) { + vec4 radial = atSlot(tStyles, id * ${STYLE_TEXELS} + 4); + float emission = atSlot(tStyles, id * ${STYLE_TEXELS} + 5).x; + float radius = length(local) / shape.w; + float inside = 1.0 - smoothstep(radial.w, 1.0, radius); + float rim = smoothstep(radial.z, radial.w, radius) * inside; + float alpha = fillColor.a * inside * mix(radial.x, radial.y, rim); + color = min(color * (1.0 - alpha) + fillColor.rgb * (1.0 + rim * emission) * alpha, vec3(1.0)); + } else if (fillColor.a > 0.0) color = mix(color, fillColor.rgb, fillColor.a * clamp(inward / pixel + 0.5, 0.0, 1.0)); if (lineColor.a > 0.0 && shape.y > 0.0) @@ -465,7 +481,7 @@ const registered = new WeakSet(); /** * The pass of one view: set(state) shows a composed state (null: nothing, - * the effect hidden) and disposes the one before. + * the effect removed) and disposes the one before. */ export function createGroundPass(view) { if (!registered.has(view)) { @@ -481,7 +497,10 @@ export function createGroundPass(view) { owner.state = state && state.count ? state : null; owner.textures = owner.state ? texturesFor(owner.state) : null; const visible = owner.state !== null; - if (visible && !handle) + if (!visible) { + handle?.delete(); + handle = null; + } else if (!handle) handle = view.addEffect({ [GROUND_EFFECT_KEY]: { owner }, visible: true, @@ -489,6 +508,12 @@ export function createGroundPass(view) { else handle?.update({ visible }); previous?.dispose(); }, + reveal(offset, count, fraction) { + if (!owner.state) return; + for (let i = 0; i < count; i++) + owner.state.items.data[offset + i * ITEM_TEXELS * 4 + 20] = fraction; + owner.textures.items.needsUpdate = true; + }, textureBytes: () => textureBytes(owner.state), destroy() { handle?.delete(); diff --git a/src/globe/navara/input.js b/src/globe/navara/input.js index a076e9492..285828699 100644 --- a/src/globe/navara/input.js +++ b/src/globe/navara/input.js @@ -50,10 +50,27 @@ export function createNavaraInput( let last = null; const pointer = createExclusivePointer(); + const gestures = new WeakMap(); const delivery = createPickDelivery({ - pick: (position, type) => - type === 'move' ? picker?.pick(position) : picker?.clickPick(position), - drill: (position, limit) => picker?.drillPick(position, limit) ?? [], + pick: (position, type, window) => { + if (type === 'move' || type === 'down') + return picker?.pick(position, window?.width, window?.height); + let gesture = gestures.get(position); + if (!gesture) { + gesture = Promise.resolve(picker?.clickPick(position)); + gestures.set(position, gesture); + } + if (!window) return gesture; + return gesture.then( + (hit) => + hit ?? + (window + ? picker?.pick(position, window.width, window.height) + : undefined), + ); + }, + drill: (position, limit, window) => + picker?.drillPick(position, limit, window?.width, window?.height) ?? [], schedule: (run) => scheduleFrame(() => !destroyed && run()), }); const positionOf = (event) => { @@ -126,7 +143,10 @@ export function createNavaraInput( const input = { acquirePointer: () => pointer.acquire(), on(type, listener, options = {}) { - const { modifier, pick, drill } = normalizeInputOptions(type, options); + const { modifier, pick, drill, pickWindow } = normalizeInputOptions( + type, + options, + ); if (destroyed) return () => {}; if (!attached) { attached = true; @@ -138,6 +158,7 @@ export function createNavaraInput( modifier, pick, drill, + pickWindow, pointer: options.pointer, }; listeners.get(type).add(entry); diff --git a/src/globe/navara/input.test.mjs b/src/globe/navara/input.test.mjs index ff178c1ce..71851d624 100644 --- a/src/globe/navara/input.test.mjs +++ b/src/globe/navara/input.test.mjs @@ -265,3 +265,63 @@ test('exclusive pointer drops deferred picks across ownership changes and releas next.release(); assert.equal(input.acquirePointer(), null); }); + +test('custom windows keep down synchronous, moves coalesced and clicks on one GPU gesture', async () => { + const canvas = fakeCanvas(); + const calls = [], + seen = []; + let resolve, + frame, + clicks = 0; + const { input, destroy } = createNavaraInput( + { canvas, camera: {} }, + { + picker: { + pick: (at, w, h) => { + calls.push(['pick', w, h]); + return { id: 'cpu' }; + }, + drillPick: (at, limit, w, h) => { + calls.push(['drill', limit, w, h]); + return [{ id: 'cpu' }]; + }, + clickPick: () => { + clicks++; + return new Promise((done) => { + resolve = done; + }); + }, + }, + scheduleFrame: (run) => { + frame = run; + }, + }, + ); + const options = { drill: 6, pickWindow: { width: 11, height: 7 } }; + input.on('down', (event) => seen.push(event), options); + input.on('move', (event) => seen.push(event), options); + input.on('click', (event) => seen.push(event), options); + input.on('click', (event) => seen.push(event), { pick: true }); + canvas.dispatchEvent(pointer('pointerdown', 110, 60)); + assert.equal(seen[0].picked.id, 'cpu'); + assert.deepEqual(calls, [ + ['pick', 11, 7], + ['drill', 6, 11, 7], + ]); + canvas.dispatchEvent(pointer('pointermove', 111, 60)); + canvas.dispatchEvent(pointer('pointermove', 112, 60)); + assert.equal(seen.length, 1); + frame(); + assert.equal(seen.length, 2); + assert.equal(seen[1].endPosition.x, 12); + canvas.dispatchEvent(pointer('pointerup', 112, 60)); + assert.equal(clicks, 1); + assert.equal(seen.length, 2); + resolve({ id: 'gpu' }); + await new Promise((done) => setTimeout(done, 0)); + assert.deepEqual( + seen.slice(2).map((event) => event.picked.id), + ['gpu', 'gpu'], + ); + destroy(); +}); diff --git a/src/globe/navara/picking.js b/src/globe/navara/picking.js index c8c77f6d2..6eec08eba 100644 --- a/src/globe/navara/picking.js +++ b/src/globe/navara/picking.js @@ -102,8 +102,9 @@ export function createNavaraPicking({ meshPickers.add(pick); return () => meshPickers.delete(pick); }, - pick: (at) => drillPick(at, 1)[0], - drillPick: (at, limit) => drillPick(at, limit ?? Infinity), + pick: (at, width, height) => drillPick(at, 1, { width, height })[0], + drillPick: (at, limit, width, height) => + drillPick(at, limit ?? Infinity, { width, height }), /** The answer for a click at `at`: the GPU pick of its gesture. */ clickPick(at) { const gesture = early; diff --git a/src/globe/pickDelivery.js b/src/globe/pickDelivery.js index 7282e0028..c1b6adc87 100644 --- a/src/globe/pickDelivery.js +++ b/src/globe/pickDelivery.js @@ -1,5 +1,5 @@ // Delivers input events to listeners that asked for picks (input.on with -// { pick, drill }), for every engine: one pick (and one drill, as deep as +// { pick, drill }), for every engine: one pick per window (and one drill, as deep as // the deepest listener asked) per event, shared by its listeners; moves are // coalesced to one delivery per frame with the latest position. Picks may // be asynchronous (Navara answers clicks from its GPU pick of the gesture): @@ -16,25 +16,45 @@ const settle = (value, then) => export function createPickDelivery({ pick, drill, schedule }) { let pendingMove = null; - function run(type, event, entries, isLive) { + function runWindow(type, event, entries, isLive) { const at = event.position ?? event.endPosition; const depth = entries.reduce( (most, entry) => Math.max(most, entry.drill), 0, ); - return settle(pick(at, type), (picked) => - settle(depth ? drill(at, depth) : undefined, (drilled) => { - for (const entry of entries) { - if (!isLive(entry)) continue; - entry.listener({ - ...event, - picked, - ...(entry.drill - ? { drilled: (drilled ?? []).slice(0, entry.drill) } - : {}), - }); - } - }), + return settle(pick(at, type, entries[0].pickWindow), (picked) => + settle( + depth ? drill(at, depth, entries[0].pickWindow) : undefined, + (drilled) => { + for (const entry of entries) { + if (!isLive(entry)) continue; + entry.listener({ + ...event, + picked, + ...(entry.drill + ? { drilled: (drilled ?? []).slice(0, entry.drill) } + : {}), + }); + } + }, + ), + ); + } + + function run(type, event, entries, isLive) { + const windows = new Map(); + for (const entry of entries) { + if (!isLive(entry)) continue; + const key = entry.pickWindow + ? `${entry.pickWindow.width}:${entry.pickWindow.height}` + : 'default'; + if (!windows.has(key)) windows.set(key, []); + windows.get(key).push(entry); + } + return Promise.all( + [...windows.values()].map((group) => + runWindow(type, event, group, isLive), + ), ); } diff --git a/src/globe/pickDelivery.test.mjs b/src/globe/pickDelivery.test.mjs index 88f2b70e1..44b96ede6 100644 --- a/src/globe/pickDelivery.test.mjs +++ b/src/globe/pickDelivery.test.mjs @@ -84,3 +84,48 @@ test('asynchronous picks resolve before delivery; removed listeners get nothing' ); assert.deepEqual(seen[0][1].drilled, [{ id: 'later' }]); }); + +test('pick windows partition sharing, preserve drill limits and copy the latest move', async () => { + const picks = [], + drills = [], + seen = []; + let frame; + const delivery = createPickDelivery({ + pick: (at, type, window) => { + picks.push([at.x, window]); + return { id: window?.width }; + }, + drill: (at, limit, window) => { + drills.push([limit, window]); + return Array.from({ length: limit }, (_, id) => ({ id })); + }, + schedule: (run) => { + frame = run; + }, + }); + const small = { width: 9, height: 3 }, + large = { width: 17, height: 11 }; + const listeners = [ + entry(seen, 'a', { pick: true, drill: 2, pickWindow: small }), + entry(seen, 'b', { pick: true, drill: 4, pickWindow: { ...small } }), + entry(seen, 'c', { pick: true, drill: 1, pickWindow: large }), + ]; + await delivery.deliver('down', { position: { x: 1, y: 2 } }, listeners); + assert.equal(picks.length, 2); + assert.deepEqual(drills, [ + [4, small], + [1, large], + ]); + assert.deepEqual( + seen.map(([, event]) => event.drilled.length), + [2, 4, 1], + ); + picks.length = 0; + delivery.deliver('move', { endPosition: { x: 3, y: 0 } }, listeners); + delivery.deliver('move', { endPosition: { x: 4, y: 0 } }, listeners); + frame(); + assert.deepEqual( + picks.map(([x]) => x), + [4, 4], + ); +}); diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index b85ee08ff..8d3da3bc7 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -28,6 +28,11 @@ class ContractError extends Error { } } +// The radial ground look's disc in the contract checks, a launch pad's radius. +const RADIAL_RADIUS_M = 500; +// The camera height that shows that disc and its rim a few pixels wide. +const RADIAL_VIEW_M = 4000; + function ensure(condition, message) { if (!condition) throw new ContractError(message); } @@ -926,6 +931,7 @@ export function createContractSuite( engine.input.on('click', (event) => clicks.push(event), { pick: true, drill: 8, + pickWindow: { width: 9, height: 9 }, }), engine.input.on('move', (event) => moves.push(event), { pick: true, @@ -3028,6 +3034,81 @@ function groundChecks( ensure(error instanceof TypeError, `${label} did not throw a TypeError`); }; return [ + [ + 'projector: a lowered horizon ellipsoid keeps below-sea-level ground visible', + async () => { + const eye = cartesian3.fromDegrees(-74.006, 40.7128, -19.6); + engine.camera.setView({ destination: eye }); + await settle(); + const point = cartesian3.fromDegrees(-74.006, 40.71289, -22); + const options = { + ellipsoid: { + radii: { x: 6377137, y: 6377137, z: 6355752.314245179 }, + }, + }; + ensure( + engine.projector.horizonVisible(point, options), + 'nearby ground below WGS84 stays visible', + ); + ensure( + !engine.projector.horizonVisible( + cartesian3.fromDegrees(105.994, -40.7128), + options, + ), + 'far side stays hidden', + ); + throwsType( + () => + engine.projector.horizonVisible(point, { + ellipsoid: { radii: { x: 0, y: 1, z: 1 } }, + }), + 'zero horizon radius', + ); + await nadirView(1e6); + }, + ], + [ + 'ground: corridor reveals advance without replacing their path', + async () => { + const set = ground.createSet({ + id: 'contract-reveal', + kind: 'corridor', + reveal: true, + }); + try { + set.replace([ + { + positions: [ + [19.7, 10], + [20.3, 10], + ], + widthPx: 12, + color: [1, 0, 1, 1], + }, + ]); + set.commit(); + set.setReveal(0.25); + throwsType(() => set.setReveal(-1), 'negative reveal'); + throwsType(() => set.setReveal(2), 'large reveal'); + if (!readRegion) return; + await nadirView(350000); + await until(() => set.ready(), 'reveal ground ready'); + await settle(); + const magenta = (lon) => { + const { x, y } = screenOf(lon, 10); + const p = readRegion(Math.round(x), Math.round(y), 1, 1); + return Math.min(p[0], p[2]) - p[1]; + }; + ensure(magenta(19.8) > 60, 'revealed start draws'); + ensure(magenta(20.2) < 60, 'unrevealed end is hidden'); + set.setReveal(0.9); + await settle(); + ensure(magenta(20.2) > 60, 'reveal advances over the existing path'); + } finally { + set.destroy(); + } + }, + ], [ 'ground: pulse fields update fronts, move, hide and release without a clock', async () => { @@ -3078,6 +3159,13 @@ function groundChecks( async () => { if (!readRegion) return; await nadirView(1e6); + // The base frame must be the settled surface: a check before this one + // can leave tiles of another view still loading. + await until( + () => engine.surface.tilesLoaded?.() !== false, + 'surface settles', + ); + await settle(5); const at = engine.projector.worldToWindow( cartesian3.fromDegrees(20.9, 10, 2500), ); @@ -3775,6 +3863,108 @@ function groundChecks( } }, ], + [ + 'ground: radial discs retain their look and draw a brighter rim than fill', + async () => { + const background = ground.createSet({ + id: 'contract-radial-background', + kind: 'cell', + }); + const disc = ground.createSet({ + id: 'contract-radial', + kind: 'disc', + order: 1, + }); + try { + background.replace([ + { west: 19, south: 9, east: 21, north: 11, fill: [0, 0, 0, 1] }, + ]); + background.commit(); + disc.replace([ + { + center: [20, 10], + // A launch pad's size: Cesium maps a large ground disc's + // texture coordinates differently, and only pads use this look. + radiusM: RADIAL_RADIUS_M, + fill: [0, 1, 1, 1], + radial: { + fillAlpha: 0.105, + rimAlpha: 0.72, + rimStart: 0.952, + rimEnd: 0.985, + emission: 0.35, + }, + }, + ]); + disc.commit(); + ensure( + ground.list().find((set) => set.id === 'contract-radial').count === + 1, + 'radial disc committed', + ); + if (!readRegion) return; + await nadirView(RADIAL_VIEW_M); + await until( + () => disc.ready() && background.ready(), + 'radial ground ready', + ); + await settle(); + const metresPerDegree = 111319.49 * Math.cos((10 * Math.PI) / 180); + const at = (radius, angle) => { + const { x, y } = screenOf( + 20 + (radius * Math.cos(angle)) / metresPerDegree, + 10 + (radius * Math.sin(angle)) / 110574, + ); + const bytes = readRegion(Math.round(x), Math.round(y), 1, 1); + return bytes[1] + bytes[2]; + }; + // Cesium leaves part of a small ground disc's fill undrawn (its + // old launch pad shows the same gap): the brightest inner sample. + const fillAt = () => + Math.max( + ...[0, 1, 2, 3, 4, 5].map((k) => + at(RADIAL_RADIUS_M * 0.5, (k * Math.PI) / 3), + ), + ); + // Cesium classifies onto the terrain loaded at this height, which + // can take longer than the usual frame budget after the descent. + // The app's own arrival flight can land after the view was set: + // the view is set again until the disc draws. + // Pixels come from the last drawn frame, screen positions from the + // camera now: both must be at the view, with no flight under way. + const away = () => + engine.camera.flightActive() || + engine.camera.height() > RADIAL_VIEW_M * 1.5; + for ( + let frame = 0; + frame < 1200 && (away() || fillAt() <= 5); + frame++ + ) { + if (away()) { + engine.camera.cancelFlight(); + await nadirView(RADIAL_VIEW_M); + } + await settle(1); + } + const fill = fillAt(); + // The rim is a few pixels wide: its brightest sample across it. + let rim = 0; + for (let step = 0; step <= 25; step++) + rim = Math.max(rim, at(RADIAL_RADIUS_M * (0.85 + step * 0.01), 0)); + ensure(fill > 5, `radial fill is visible: ${fill}`); + ensure( + rim > fill + 50, + `radial rim ${rim} is brighter than fill ${fill}`, + ); + disc.show(false); + await settle(); + ensure(fillAt() < fill, 'hidden radial fill clears'); + } finally { + disc.destroy(); + background.destroy(); + } + }, + ], ]; } diff --git a/src/globe/testing/fakeEngine.js b/src/globe/testing/fakeEngine.js index 5bb7539a8..17719a7d9 100644 --- a/src/globe/testing/fakeEngine.js +++ b/src/globe/testing/fakeEngine.js @@ -272,6 +272,9 @@ export function createFakeEngine(options = {}) { destroy() {}, })); const groundRegistry = createGroundRegistry(() => ({ + reveal() { + fake.renderRequests++; + }, commit() { fake.renderRequests++; }, @@ -716,12 +719,16 @@ export function createFakeEngine(options = {}) { picker: { pick(screen, w, h) { record('picker.pick', [screen, w, h]); - return scripted('pick', [screen, w, h], () => cpuPick(screen, 1)[0]); + return scripted( + 'pick', + [screen, w, h], + () => cpuPick(screen, 1, { width: w, height: h })[0], + ); }, drillPick(screen, limit, w, h) { record('picker.drillPick', [screen, limit, w, h]); return scripted('drillPick', [screen, limit, w, h], () => - cpuPick(screen, limit ?? Infinity), + cpuPick(screen, limit ?? Infinity, { width: w, height: h }), ); }, pickPosition(screen) { @@ -748,7 +755,8 @@ export function createFakeEngine(options = {}) { return value; }, viewProjection: (result) => viewProjection(cameraView(), result), - horizonVisible: (position) => horizonVisible(state.position, position), + horizonVisible: (position, options) => + horizonVisible(state.position, position, options), }, movers: moverRegistry.movers, lines: lineRegistry.lines, @@ -824,7 +832,7 @@ export function createFakeEngine(options = {}) { acquirePointer: () => pointer.acquire(), on(type, listener, inputOptions = {}) { record('input.on', [type, inputOptions]); - const { modifier, pick, drill } = normalizeInputOptions( + const { modifier, pick, drill, pickWindow } = normalizeInputOptions( type, inputOptions, ); @@ -834,6 +842,7 @@ export function createFakeEngine(options = {}) { modifier, pick, drill, + pickWindow, pointer: inputOptions.pointer, }; listeners.add(entry); @@ -978,9 +987,20 @@ export function createFakeEngine(options = {}) { const at = event.position ?? event.endPosition; entry.listener({ ...event, - picked: engine.picker.pick(at), + picked: engine.picker.pick( + at, + entry.pickWindow?.width, + entry.pickWindow?.height, + ), ...(entry.drill - ? { drilled: engine.picker.drillPick(at, entry.drill) } + ? { + drilled: engine.picker.drillPick( + at, + entry.drill, + entry.pickWindow?.width, + entry.pickWindow?.height, + ), + } : {}), }); } @@ -998,6 +1018,7 @@ export function createFakeEngine(options = {}) { /** A live line's options, positions, colour, matrix and visibility, by id. */ line: (id) => fakeLines.get(id), /** A live mover set's options, store and commit count, by id. */ + groundSet: (id) => groundRegistry.live.get(id), groundPulse(id) { return groundRegistry.pulses.get(id); }, diff --git a/src/globe/testing/fakeEngine.test.mjs b/src/globe/testing/fakeEngine.test.mjs index 48098136c..d1316e71e 100644 --- a/src/globe/testing/fakeEngine.test.mjs +++ b/src/globe/testing/fakeEngine.test.mjs @@ -431,7 +431,40 @@ test('input listeners asking for picks get the pick and the drill in the payload TypeError, ); assert.throws( - () => engine.input.on('down', () => {}, { pick: true }), + () => engine.input.on('up', () => {}, { pick: true }), TypeError, ); }); + +test('input rejects invalid pick windows and forwards copied windows on down and move', () => { + const engine = createFakeEngine(); + for (const pickWindow of [ + null, + {}, + { width: 0, height: 9 }, + { width: 9, height: 0.5 }, + { width: Infinity, height: 1 }, + ]) + assert.throws( + () => engine.input.on('click', () => {}, { pick: true, pickWindow }), + TypeError, + ); + assert.throws( + () => + engine.input.on('click', () => {}, { + pickWindow: { width: 9, height: 9 }, + }), + TypeError, + ); + const picked = []; + engine.fake.script('pick', (at, w, h) => ({ id: `${w}:${h}` })); + const pickWindow = { width: 11, height: 7 }; + engine.input.on('down', (event) => picked.push(event.picked.id), { + pick: true, + pickWindow, + }); + pickWindow.width = 1; + engine.fake.input('down', { position: { x: 2, y: 3 } }); + assert.deepEqual(picked, ['11:7']); + engine.destroy(); +}); diff --git a/src/layers/cctv/render/display.js b/src/layers/cctv/render/display.js index b5bbbb753..fb4604750 100644 --- a/src/layers/cctv/render/display.js +++ b/src/layers/cctv/render/display.js @@ -56,8 +56,6 @@ const sameColor = (a, b) => a.green === b.green && a.blue === b.blue && a.alpha === b.alpha; -const gizmoDebug = () => - typeof window !== 'undefined' && window.__gevGizmoDebug; const scratchRotation = geo.matrix3.create(); const scratchCorner = geo.cartesian3.create(); @@ -504,46 +502,12 @@ export function createCctvDisplay(engine, { state, services = {} } = {}) { }, // Handles draw over the scene: the single pick usually finds one, // and a drill covers what overlaps them. - pickPart(position) { - try { - const part = partFrom( - engine.picker.pick(position, GIZMO_PICK_PX, GIZMO_PICK_PX), - ); - if (part) return part; - } catch (error) { - if (gizmoDebug()) - console.debug('[CCTV:gizmo] pick threw:', error?.message || error); - } - let results = []; - try { - results = - engine.picker.drillPick( - position, - GIZMO_DRILL_LIMIT, - GIZMO_PICK_PX, - GIZMO_PICK_PX, - ) || []; - } catch (error) { - if (gizmoDebug()) - console.debug( - '[CCTV:gizmo] drillPick threw:', - error?.message || error, - ); - return null; - } - if (gizmoDebug()) - console.debug( - '[CCTV:gizmo] drillPick @', - position?.x, - position?.y, - '→', - JSON.stringify(results.map((result) => String(result?.id))), - ); - for (const picked of results) { - const part = partFrom(picked); - if (part) return part; - } - return null; + pickPart(event) { + return ( + partFrom(event.picked) || + (event.drilled ?? []).map(partFrom).find(Boolean) || + null + ); }, pickRay(position) { try { @@ -556,7 +520,19 @@ export function createCctvDisplay(engine, { state, services = {} } = {}) { on(type, listener) { if (destroyed) return; if (type !== 'cancel') { - gizmoRemovers.push(engine.input.on(type, listener)); + gizmoRemovers.push( + engine.input.on( + type, + listener, + type === 'down' || type === 'move' + ? { + pick: true, + drill: GIZMO_DRILL_LIMIT, + pickWindow: { width: GIZMO_PICK_PX, height: GIZMO_PICK_PX }, + } + : {}, + ), + ); return; } const target = canvas(); diff --git a/src/layers/cctv/render/displayGizmo.test.mjs b/src/layers/cctv/render/displayGizmo.test.mjs index ffe5125dd..8ca7a2300 100644 --- a/src/layers/cctv/render/displayGizmo.test.mjs +++ b/src/layers/cctv/render/displayGizmo.test.mjs @@ -120,6 +120,28 @@ for (const makeHarness of CCTV_HARNESSES) { }); } + test(`gizmo input uses its window and drill to reach an overlapping handle (${name})`, (t) => { + const h = setup(makeHarness, t); + h.choose(); + const calls = []; + const drill = h.engine.picker.drillPick; + h.engine.picker.pick = (at, width, height) => { + calls.push(['pick', width, height]); + return { id: 'foreign', owner: 'foreign' }; + }; + h.engine.picker.drillPick = (at, limit, width, height) => { + calls.push(['drill', limit, width, height]); + return drill(at, limit, width, height); + }; + h.event('down'); + assert.equal(h.gizmo.isDragging(), true); + assert.deepEqual(calls, [ + ['pick', 14, 14], + ['drill', 6, 14, 14], + ]); + h.event('up'); + }); + test(`the gizmo needs its own handle, not a sibling with its id (${name})`, (t) => { const h = setup(makeHarness, t); const sibling = h.engine.lines.createLine({ diff --git a/src/layers/launches/policy.js b/src/layers/launches/policy.js index c6f675015..f0fd96238 100644 --- a/src/layers/launches/policy.js +++ b/src/layers/launches/policy.js @@ -91,9 +91,8 @@ export const LAUNCH_PAD_ZONE_RADIUS_M = 500; /** * The pad zone's look, as a fraction of the radius: a faint fill, a bright * rim between rimStart and rimEnd whose colour gains the emission, and a soft - * edge from rimEnd to the radius. The display draws the rim (render/display.js - * padRimColor, PAD_RIM_RADIUS_M) as a ring on the terrain under the pad, over - * the scene; the engine contract has no surface fill for the rest. + * edge from rimEnd to the radius. The ground disc drapes the whole zone + * on terrain and tiles. */ export const LAUNCH_PAD_ZONE_STYLE = Object.freeze({ color: '#22e6e6', diff --git a/src/layers/launches/render/display.js b/src/layers/launches/render/display.js index 25b5ed38c..3bb236d8f 100644 --- a/src/layers/launches/render/display.js +++ b/src/layers/launches/render/display.js @@ -2,7 +2,7 @@ // engine, through the engine contract only: engine.movers (the launch-site, // recovery and payload dots, the hover reticle), engine.lines (trajectories, // recovery and re-entry paths, the transfer, the orbits in the tactical -// pattern, the pad zone's rim), the selection dispatcher on engine.input +// pattern), engine.ground (the pad zones), the selection dispatcher on engine.input // (clicks), engine.picker (the hover cursor), engine.projector and the frame // events. The layer keeps its own records and moves the camera itself // (engine.camera); the engine draws. @@ -25,50 +25,11 @@ const HOVER_PICK_INTERVAL_MS = 120; const PICK_DEPTH = 12; const RETICLE_SIZE_PX = 24; const RETICLE_CELL = 'reticle'; -// The pad zone's rim: a closed ring through the middle of the style's rim -// band, in the rim's colour with its emission and alpha. -const PAD_RING_SEGMENTS = 128; -const PAD_RING_WIDTH_PX = 2; - const rgbaOf = (color) => Array.isArray(color) ? [...color] : [color?.red ?? 1, color?.green ?? 1, color?.blue ?? 1, color?.alpha ?? 1]; -/** The pad zone's rim colour: Cesium's rim emission over the zone colour. */ -export function padRimColor() { - const [red, green, blue] = rgbaFromCss(LAUNCH_PAD_ZONE_STYLE.color).rgba(); - const gain = 1 + LAUNCH_PAD_ZONE_STYLE.emission; - return [ - Math.min(1, red * gain), - Math.min(1, green * gain), - Math.min(1, blue * gain), - LAUNCH_PAD_ZONE_STYLE.rimAlpha, - ]; -} - -/** The pad zone's rim radius: the middle of the style's rim band, metres. */ -export const PAD_RIM_RADIUS_M = - (LAUNCH_PAD_ZONE_RADIUS_M * - (LAUNCH_PAD_ZONE_STYLE.rimStart + LAUNCH_PAD_ZONE_STYLE.rimEnd)) / - 2; - -/** The pad zone's rim positions around `launch` at `heightM`. */ -export function padRingPositions(launch, heightM = 0) { - const frame = geo.transforms.eastNorthUpToFixedFrame( - geo.cartesian3.fromDegrees(launch.lon, launch.lat, heightM), - geo.ellipsoid.WGS84, - ); - const local = geo.cartesian3.create(); - return Array.from({ length: PAD_RING_SEGMENTS + 1 }, (_, index) => { - const angle = (index / PAD_RING_SEGMENTS) * geo.math.TWO_PI; - local.x = PAD_RIM_RADIUS_M * Math.cos(angle); - local.y = PAD_RIM_RADIUS_M * Math.sin(angle); - local.z = 0; - return geo.matrix4.multiplyByPoint(frame, local, geo.cartesian3.create()); - }); -} - /** * Bound hover picking independently of precise click selection. A move inside * the throttle window is not dropped: its position is kept and one trailing @@ -194,6 +155,7 @@ export function createLaunchesDisplay(engine, { layerId = LAYER_ID } = {}) { let lineSeq = 0; let frameNumber = 0; const removers = []; + const pads = new Set(); // --- commits: once per frame listener, or in a microtask ----------------- const pending = new Set(); @@ -425,6 +387,7 @@ export function createLaunchesDisplay(engine, { layerId = LAYER_ID } = {}) { next = Boolean(next) && !destroyed; if (next === visible) return; visible = next; + for (const pad of pads) pad.refresh(); frame(() => { for (const bank of banks.values()) bank.setVisible(visible); if (visible) for (const marker of markers) refresh(marker); @@ -455,35 +418,39 @@ export function createLaunchesDisplay(engine, { layerId = LAYER_ID } = {}) { * modelMatrix?, depthFailAlpha? } (engine.lines options). */ addLine: (options) => new LineHandle(options), - /** - * The pad zone of a launch: its rim on the terrain under the pad, drawn - * over the scene. Picks pass through it, as Cesium's clicks and hover - * passed over the zone. - */ + /** The launch pad's radial fill, draped on terrain and tiles. */ createPad(launch) { - const terrain = engine.surface.terrainHeightSupported - ? engine.surface.terrainHeight( - geo.cartographic.fromDegrees(launch.lon, launch.lat, 0), - ) - : undefined; - const heightM = Number.isFinite(terrain) ? terrain : 0; - const ring = new LineHandle({ + const set = engine.ground.createSet({ id: `rocket-pad-zone:${launch.id}`, - positions: padRingPositions(launch, heightM), - widthPx: PAD_RING_WIDTH_PX, - color: padRimColor(), - depthFailAlpha: 1, + kind: 'disc', }); - return { - heightM, + const { color, ...radial } = LAUNCH_PAD_ZONE_STYLE; + set.replace([ + { + center: [launch.lon, launch.lat], + radiusM: LAUNCH_PAD_ZONE_RADIUS_M, + fill: rgbaFromCss(color).rgba(), + radial, + }, + ]); + set.commit(); + let shown = true; + const pad = { get show() { - return ring.show; + return shown; }, set show(value) { - ring.show = value; + shown = value !== false; + pad.refresh(); + }, + refresh: () => set.show(visible && shown), + destroy() { + if (pads.delete(pad)) set.destroy(); }, - destroy: () => ring.remove(), }; + pads.add(pad); + pad.refresh(); + return pad; }, project: (position, result) => engine.projector.worldToWindow(position, result), @@ -531,6 +498,7 @@ export function createLaunchesDisplay(engine, { layerId = LAYER_ID } = {}) { banks.clear(); pending.clear(); for (const handle of [...lines]) handle.remove(); + for (const pad of [...pads]) pad.destroy(); pickIds.clear(); }, }; diff --git a/src/layers/launches/render/display.test.mjs b/src/layers/launches/render/display.test.mjs index 56e225fa1..130339f4b 100644 --- a/src/layers/launches/render/display.test.mjs +++ b/src/layers/launches/render/display.test.mjs @@ -15,11 +15,8 @@ import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngin import { dashPattern } from '../../../globe/cesium/lines.js'; import { rgbaFromCss } from '../../shared/color.js'; import { LAUNCH_PAD_ZONE_RADIUS_M, LAUNCH_PAD_ZONE_STYLE } from '../policy.js'; -import { - PAD_RIM_RADIUS_M, - createLaunchesDisplay, - padRimColor, -} from './display.js'; +import { createLaunchesDisplay } from './display.js'; +import { GROUND_HARNESSES } from '../../../testSupport/groundDisplayEngines.mjs'; const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); const reticle = { width: 48, height: 48, getContext: () => null }; @@ -35,7 +32,19 @@ function fakeHarness() { } function cesiumHarness() { - const { engine, viewer, ...rest } = createCesiumMoversEngine(); + const { engine, viewer, ...rest } = createCesiumMoversEngine({ + engineOptions: { + groundFactories: { + fills: (options) => ({ + ...options, + ready: true, + destroy() {}, + isDestroyed: () => false, + }), + }, + }, + }); + viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection(); viewer.scene.globe.getHeight = () => 7; return { name: 'the Cesium engine', @@ -74,23 +83,6 @@ test('the display needs a globe engine', () => { assert.throws(() => createLaunchesDisplay(null), TypeError); }); -test("the pad zone's rim is the middle of the style's rim band in the rim's colour", () => { - near( - PAD_RIM_RADIUS_M, - LAUNCH_PAD_ZONE_RADIUS_M * - ((LAUNCH_PAD_ZONE_STYLE.rimStart + LAUNCH_PAD_ZONE_STYLE.rimEnd) / 2), - 'radius', - ); - const [red, green, blue] = rgbaFromCss(LAUNCH_PAD_ZONE_STYLE.color).rgba(); - const gain = 1 + LAUNCH_PAD_ZONE_STYLE.emission; - assert.deepEqual(padRimColor(), [ - Math.min(1, red * gain), - Math.min(1, green * gain), - Math.min(1, blue * gain), - LAUNCH_PAD_ZONE_STYLE.rimAlpha, - ]); -}); - for (const makeHarness of HARNESSES) { const { name } = makeHarness(); @@ -329,73 +321,6 @@ for (const makeHarness of HARNESSES) { h.destroy(); }); - test(`the pad zone is its rim on the terrain, drawn over the scene, not picked (${name})`, () => { - const h = setup(makeHarness); - const { engine, display } = h; - if (!h.cesium) engine.fake.script('terrainHeight', 7); - display.setVisible(true); - const zone = display.createPad({ id: 'a', lon: -80.6, lat: 28.6 }); - assert.equal(zone.heightM, 7); - assert.equal( - engine.fake?.callsOf('surface.sampleHeight').length ?? 0, - 0, - 'no tile height is sampled', - ); - const [listed] = engine.lines.list(); - assert.equal(listed.points, 129, 'a closed ring'); - const center = geo.cartesian3.fromDegrees(-80.6, 28.6, 7); - const ringPoint = (flat, index) => - geo.cartesian3.create( - flat[index * 3], - flat[index * 3 + 1], - flat[index * 3 + 2], - ); - if (h.cesium) { - const primitive = h.primitive(listed.id); - assert.equal(primitive.allowPicking, false); - const positions = primitive.geometryInstances.geometry._positions; - near( - Cesium.Cartesian3.distance(positions[0], center), - PAD_RIM_RADIUS_M, - 'rim radius', - 1e-3, - ); - const { color } = primitive.appearance.material.uniforms; - near(color.alpha, LAUNCH_PAD_ZONE_STYLE.rimAlpha, 'rim alpha'); - near( - primitive.depthFailAppearance.material.uniforms.color.alpha, - LAUNCH_PAD_ZONE_STYLE.rimAlpha, - 'drawn where the scene hides it', - ); - } else { - const line = engine.fake.line(listed.id); - assert.equal(line.options.pickId, undefined); - assert.equal(line.options.depthFailAlpha, 1); - assert.deepEqual(line.options.color, padRimColor()); - near( - geo.cartesian3.distance(ringPoint(line.positions, 0), center), - PAD_RIM_RADIUS_M, - 'rim radius', - 1e-3, - ); - near( - geo.cartesian3.distance(ringPoint(line.positions, 64), center), - PAD_RIM_RADIUS_M, - 'rim radius', - 1e-3, - ); - } - zone.show = false; - assert.equal(engine.lines.list()[0].shown, false); - zone.show = true; - assert.equal(engine.lines.list()[0].shown, true); - zone.destroy(); - zone.destroy(); - assert.deepEqual(engine.lines.list(), []); - display.destroy(); - h.destroy(); - }); - test(`teardown releases every set, line and frame listener (${name})`, async () => { const h = setup(makeHarness); const { engine, display } = h; @@ -417,6 +342,7 @@ for (const makeHarness of HARNESSES) { display.destroy(); display.destroy(); assert.deepEqual(engine.movers.list(), []); + assert.deepEqual(engine.ground.list(), []); assert.deepEqual(engine.lines.list(), []); h.frame(); assert.equal(frames, 1); @@ -478,3 +404,54 @@ test("the hover cursor is the engine canvas's pointer over a mission", () => { display.destroy(); engine.destroy(); }); + +for (const makeHarness of GROUND_HARNESSES) { + test(`launch pads retain radial fills, draping and lifecycle (${makeHarness.name})`, () => { + const h = makeHarness(); + const display = createLaunchesDisplay(h.engine); + const pad = display.createPad({ id: 'a', lon: -80.6, lat: 28.6 }); + const id = 'rocket-pad-zone:a'; + const record = h.set(id); + const { color, ...radial } = LAUNCH_PAD_ZONE_STYLE; + assert.deepEqual(record.items, [ + { + center: [-80.6, 28.6], + radiusM: LAUNCH_PAD_ZONE_RADIUS_M, + fill: rgbaFromCss(color).rgba(), + radial, + }, + ]); + assert.equal(record.options.drape, 'both'); + assert.equal(record.options.pickable, false); + assert.equal(record.shown, false); + display.setVisible(true); + assert.equal(h.set(id).shown, true); + if (h.cesium) { + const [primitive] = h.primitives(id).fills; + assert.equal( + primitive.options.classificationType, + Cesium.ClassificationType.BOTH, + ); + assert.equal(primitive.options.allowPicking, false); + const appearance = primitive.options.appearance; + assert.equal(appearance.flat, true); + assert.equal(appearance.renderState.polygonOffset.factor, -1); + for (const [key, value] of Object.entries(radial)) + assert.equal(appearance.material.uniforms[key], value); + } + pad.show = false; + display.setVisible(false); + display.setVisible(true); + assert.equal(h.set(id).shown, false); + pad.show = true; + assert.equal(h.set(id).shown, true); + assert.equal(h.set(id).commits, 1); + pad.destroy(); + pad.destroy(); + assert.equal(h.set(id), undefined); + display.createPad({ id: 'b', lon: 1, lat: 2 }); + display.destroy(); + assert.equal(h.sets(), 0); + h.destroy(); + }); +} diff --git a/src/layers/streetLevel/render/display.js b/src/layers/streetLevel/render/display.js index 6ff846be2..16a6b7d4b 100644 --- a/src/layers/streetLevel/render/display.js +++ b/src/layers/streetLevel/render/display.js @@ -30,10 +30,12 @@ const WHITE = Object.freeze([1, 1, 1, 1]); * (NYC −22 m), and an occluder at 0 m would hide the cones around it. */ const HORIZON_CULL_DEPTH_M = 1000; -const CULL_RADII = Object.freeze({ - x: geo.ellipsoid.WGS84.radii.x - HORIZON_CULL_DEPTH_M, - y: geo.ellipsoid.WGS84.radii.y - HORIZON_CULL_DEPTH_M, - z: geo.ellipsoid.WGS84.radii.z - HORIZON_CULL_DEPTH_M, +const HORIZON_OPTIONS = Object.freeze({ + ellipsoid: geo.ellipsoid.create( + geo.ellipsoid.WGS84.radii.x - HORIZON_CULL_DEPTH_M, + geo.ellipsoid.WGS84.radii.y - HORIZON_CULL_DEPTH_M, + geo.ellipsoid.WGS84.radii.z - HORIZON_CULL_DEPTH_M, + ), }); const finitePoint = (point) => @@ -48,28 +50,6 @@ export function lineColor(css, alpha) { return [rgba.red, rgba.green, rgba.blue, alpha ?? rgba.alpha]; } -/** - * Whether `position` is in front of the horizon seen from `eye`, for an - * ellipsoid HORIZON_CULL_DEPTH_M under WGS84 (Cesium's - * EllipsoidalOccluder.isPointVisible on that ellipsoid). - */ -export function aboveHorizon(eye, position) { - const cx = eye.x / CULL_RADII.x; - const cy = eye.y / CULL_RADII.y; - const cz = eye.z / CULL_RADII.z; - const limbSquared = cx * cx + cy * cy + cz * cz - 1; - const vx = position.x / CULL_RADII.x - cx; - const vy = position.y / CULL_RADII.y - cy; - const vz = position.z / CULL_RADII.z - cz; - const vtDotVc = -(vx * cx + vy * cy + vz * cz); - const occluded = - limbSquared < 0 - ? vtDotVc > 0 - : vtDotVc > limbSquared && - (vtDotVc * vtDotVc) / (vx * vx + vy * vy + vz * vz) > limbSquared; - return !occluded; -} - const scratchEnu = geo.matrix4.create(); const scratchLocal = geo.cartesian3.create(); const scratchWorld = geo.cartesian3.create(); @@ -215,7 +195,6 @@ export function createStreetLevelDisplay(engine) { tiles = nowTiles; if (!rows.size && !dirty) return; const pose = engine.camera.pose(); - const eye = pose.position; const signature = [ pose.position.x, pose.position.y, @@ -260,7 +239,10 @@ export function createStreetLevelDisplay(engine) { color: WHITE, cell: 'glyph', pickId: row.pickId, - show: aboveHorizon(eye, row.position), + show: engine.projector.horizonVisible( + row.position, + HORIZON_OPTIONS, + ), }); }); bank.setCount(group.length); diff --git a/src/layers/streetLevel/render/display.test.mjs b/src/layers/streetLevel/render/display.test.mjs index d62cfff0f..f45993383 100644 --- a/src/layers/streetLevel/render/display.test.mjs +++ b/src/layers/streetLevel/render/display.test.mjs @@ -2,7 +2,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import * as geo from '../../../geo/index.js'; import { - aboveHorizon, createStreetLevelDisplay, lineColor, screenRotation, @@ -314,31 +313,6 @@ test('a bearing straight into the screen keeps the last rotation', () => { ); }); -const at = (lon, lat, height) => geo.cartesian3.fromDegrees(lon, lat, height); - -test('cones and the marker stay visible on ground below the WGS84 ellipsoid', () => { - // NYC at street level: eye 2.4 m above ground at -22 m, a cone 10 m away. - assert.equal( - aboveHorizon(at(-74.006, 40.7128, -19.6), at(-74.006, 40.71289, -22)), - true, - ); - // Colombo framing view: camera at -21 m, cones at -95 m about 80 m away. - const colombo = at(79.8612, 6.9271, -21); - assert.equal(aboveHorizon(colombo, at(79.8612, 6.92782, -95)), true); - assert.equal(aboveHorizon(colombo, at(79.86192, 6.9271, -95)), true); - // Austin, ground well above the ellipsoid. - assert.equal( - aboveHorizon(at(-97.7431, 30.2672, 132), at(-97.7431, 30.2681, 130)), - true, - ); -}); - -test('the horizon cull still hides the far side of the Earth from orbit', () => { - const orbit = at(0, 0, 20_000_000); - assert.equal(aboveHorizon(orbit, at(10, 10, 0)), true); - assert.equal(aboveHorizon(orbit, at(180, 0, 0)), false); -}); - test('destroying the display takes everything it still draws', () => { const harness = streetLevelMap({ view: true }); harness.map.sequenceLines([{ id: 'a', parts: [line(0)] }], STYLE); diff --git a/src/layers/streetLevel/selection.js b/src/layers/streetLevel/selection.js index ecb2a523f..b3db1dcb9 100644 --- a/src/layers/streetLevel/selection.js +++ b/src/layers/streetLevel/selection.js @@ -3,8 +3,7 @@ import { CLICK_PICK_WINDOW_PX, STREET_LEVEL_LAYER_ID } from './policy.js'; /** * One click handler for every provider's lines and cones, and the marker, - * on the display's engine (engine.input for the click, engine.picker for - * the pick: the input's own pick has no pick window). + * on the display's engine, with a wider input pick window for thin lines. */ export function createSelection({ state, parts }) { const { picking, input } = state.services; @@ -14,11 +13,7 @@ export function createSelection({ state, parts }) { const engine = state.map?.engine; if (!engine || !state.enabled || !click?.position) return; if (input?.isPointerFree && !input.isPointerFree()) return; - const picked = engine.picker.pick( - click.position, - CLICK_PICK_WINDOW_PX, - CLICK_PICK_WINDOW_PX, - ); + const picked = click.picked; const id = picking?.resolvePickId ? picking.resolvePickId(picked) : picked?.id; @@ -72,7 +67,10 @@ export function createSelection({ state, parts }) { function install(map) { if (state.clickHandler || !map?.engine) return; - state.clickHandler = map.engine.input.on('click', onClick); + state.clickHandler = map.engine.input.on('click', onClick, { + pick: true, + pickWindow: { width: CLICK_PICK_WINDOW_PX, height: CLICK_PICK_WINDOW_PX }, + }); document.addEventListener('keydown', onKeyDown); picking?.registerPickOwner?.( STREET_LEVEL_LAYER_ID, diff --git a/src/layers/streetLevel/selection.test.mjs b/src/layers/streetLevel/selection.test.mjs index 06ffcc7b6..e6b7537f5 100644 --- a/src/layers/streetLevel/selection.test.mjs +++ b/src/layers/streetLevel/selection.test.mjs @@ -140,8 +140,8 @@ test('a click picks in a window a few pixels wide, through engine.input and engi const { fake } = h.map.engine; assert.deepEqual( fake.callsOf('input.on').map(({ args }) => args), - [['click', {}]], - 'one click listener, without its own pick', + [['click', { pick: true, pickWindow: { width: 9, height: 9 } }]], + 'one click listener with its pick window', ); h.scene.under = 'mly:seq:abc'; h.click(30); diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index 23e1dceb1..c89c89d25 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -754,6 +754,7 @@ test('disable aborts in-flight work and a response that lands late is ignored', 'a poll from the disabled generation never lands', ); assert.equal(app.state()._inFlight.size, 0); + assert.deepEqual(app.engine.ground.list(), []); assert.equal(app.holds().includes('transit'), false); // Re-enabling starts a clean generation and does take data. @@ -3184,7 +3185,7 @@ for (const count of [800, 3000]) { }); } -test('click handler selects a migrated scripted billboard and prepares a valid depth-fail trail', async (t) => { +test('click handler selects a migrated scripted billboard and prepares a draped trail', async (t) => { const app = harness(t, { floorAt: () => undefined }); app.layer.enable(app.viewer); app.layer._loadTransitFleetForTest(4, BOSTON, 70); @@ -3199,11 +3200,12 @@ test('click handler selects a migrated scripted billboard and prepares a valid d const { body } = app.layer._transitPartsForTest().trails.diagnostics(); assert.ok(body.lines > 0); app.frame(); - // Where the scene hides the trail, its colour lane still draws, dimmed. - const lanes = app.engine.lines + const lanes = app.engine.ground .list() - .map(({ id }) => app.engine.fake.line(id)); - assert.ok(lanes.some((line) => line.options.depthFailAlpha > 0)); + .map(({ id }) => app.engine.fake.groundSet(id)); + assert.ok(lanes.length > 0); + assert.ok(lanes.every((lane) => lane.options.drape === 'both')); + assert.deepEqual(app.engine.lines.list(), []); assert.ok(entry.trailSegments.length > 0); app.run(1000, 16); assert.equal(entry.marker.show, true); @@ -3839,7 +3841,7 @@ test('selected history is prepared while its head is hidden and remains shown ou app.state()._vehicles.set(entry.key, entry); }); -test('selected history waits for its surface heights: lines are not draped', async (t) => { +test('selected history drapes before floors resolve while marker corridors still wait', async (t) => { const app = harness(t, { floorAt: () => undefined }); app.layer.enable(app.viewer); app.layer._loadTransitFleetForTest(1, BOSTON); @@ -3850,9 +3852,9 @@ test('selected history waits for its surface heights: lines are not draped', asy entry.heightPending = true; parts.selection.selectVehicle(entry.key); const trail = parts.trails.diagnostics(); - assert.equal(trail.ground, false); - assert.equal(trail.segments, 0, 'no history before its floors resolve'); - assert.equal(trail.body, null); + assert.equal(trail.ground, true); + assert.ok(trail.segments > 0, 'ground history does not wait for floors'); + assert.ok(trail.body); assert.deepEqual(app.engine.lines.list(), []); assert.equal( entry.displayPaths.size, diff --git a/src/layers/transit/render/display.js b/src/layers/transit/render/display.js index 40dcf0460..d70ba2430 100644 --- a/src/layers/transit/render/display.js +++ b/src/layers/transit/render/display.js @@ -6,7 +6,7 @@ // (position, image, width, color, rotation, show); the writes of a frame // are committed once, at the end of the frame listener that made them (in // a microtask for writes outside a frame); -// - the selected vehicle's trail: engine.lines, its completed history in +// - the selected vehicle's trail: engine.ground, its completed history in // alpha bands by age and its live head up to the marker; // - the camera, surface and credits through engine.camera, engine.surface // and engine.credits; clicks through engine.input and engine.picker. @@ -37,7 +37,6 @@ import { TRAIL_BACKING_WIDTH_PX, TRAIL_DEFAULT_CSS, TRAIL_HEAD_ALPHA, - TRAIL_OCCLUDED_ALPHA, TRAIL_WIDTH_PX, markerOrder, trailAlpha, @@ -316,7 +315,7 @@ const samePosition = (a, b) => (a === b || geo.cartesian3.distanceSquared(a, b) < JOIN_DISTANCE_SQ); /** - * The selected vehicle's trail on engine.lines. The history is the + * The selected vehicle's trail on engine.ground. The history is the * layer's prepared segments ({ fromT, toT, fromSeq, toSeq, positions: [a, * b] }, in time order); the segments the display clock has passed draw in * two lanes (a dark edge under the colour), one line per lane for each run @@ -346,37 +345,53 @@ function createTrail(engine, { id }) { positions: [], width: TRAIL_BACKING_WIDTH_PX, }; - const lineOf = (widthPx, color, depthFailAlpha, order) => { - const line = engine.lines.createLine({ + const lineOf = (widthPx, color, order, reveal = false, backing) => { + const set = engine.ground.createSet({ id: `${id}-${++seq}`, - widthPx, - color, - depthFailAlpha, + kind: 'corridor', + dynamic: true, order, + reveal, }); - line.show(visible); - return line; + let positions = []; + const commit = () => { + set.replace( + positions.length + ? [{ positions, widthPx, color, ...(backing ? { backing } : {}) }] + : [], + ); + set.commit(); + }; + set.show(visible); + return { + show: set.show, + destroy: set.destroy, + setReveal: set.setReveal, + setPositions(points) { + positions = points.map((point) => { + const carto = geo.cartographic.fromCartesian(point); + return [ + geo.math.toDegrees(carto.longitude), + geo.math.toDegrees(carto.latitude), + ]; + }); + commit(); + }, + setColor(next) { + color = next; + commit(); + }, + }; }; const laneColor = (alpha) => [...rgb, alpha]; const backingColor = (alpha) => [...TRAIL_BACKING_RGB, alpha]; - // Where the scene hides the trail, its colour at TRAIL_OCCLUDED_ALPHA. - const occluded = (alpha) => Math.min(1, TRAIL_OCCLUDED_ALPHA / alpha); let headLines = null; const ensureHead = () => (headLines ??= { - backing: lineOf( - TRAIL_BACKING_WIDTH_PX, - backingColor(TRAIL_HEAD_ALPHA), - 0, - 2, - ), - line: lineOf( - TRAIL_WIDTH_PX, - laneColor(TRAIL_HEAD_ALPHA), - occluded(TRAIL_HEAD_ALPHA), - 3, - ), - drawn: [], + line: lineOf(TRAIL_WIDTH_PX, laneColor(TRAIL_HEAD_ALPHA), 3, true, { + widthPx: TRAIL_BACKING_WIDTH_PX, + color: backingColor(TRAIL_HEAD_ALPHA), + }), }); const dropGroup = (group) => { group.backing.destroy(); @@ -417,8 +432,8 @@ function createTrail(engine, { id }) { const alpha = trailBandAlpha(level); group = { level, - backing: lineOf(TRAIL_BACKING_WIDTH_PX, backingColor(alpha), 0, 0), - line: lineOf(TRAIL_WIDTH_PX, laneColor(alpha), occluded(alpha), 1), + backing: lineOf(TRAIL_BACKING_WIDTH_PX, backingColor(alpha), 0), + line: lineOf(TRAIL_WIDTH_PX, laneColor(alpha), 1), from: -1, to: -1, }; @@ -439,7 +454,6 @@ function createTrail(engine, { id }) { function hideHead() { head.show = backing.show = false; if (headLines) { - headLines.backing.show(false); headLines.line.show(false); } } @@ -501,22 +515,23 @@ function createTrail(engine, { id }) { hideHead(); return; } - active = segment; const lines = ensureHead(); - lines.backing.show(true); lines.line.show(true); const positions = [segment.positions[0], renderedPosition]; head.positions = backing.positions = positions; - if ( - lines.drawn.length !== 2 || - !geo.cartesian3.equals(lines.drawn[0], positions[0]) || - !geo.cartesian3.equals(lines.drawn[1], positions[1]) - ) { - lines.drawn = positions.map((p) => geo.cartesian3.clone(p)); - lines.backing.setPositions(lines.drawn); - lines.line.setPositions(lines.drawn); + if (active !== segment) { + active = segment; + lines.line.setPositions(segment.positions); headRebuilds++; } + const fraction = Math.max( + 0, + Math.min( + 1, + (sample.displayT - segment.fromT) / (segment.toT - segment.fromT), + ), + ); + lines.line.setReveal(fraction); }, setStyle(next) { if (destroyed || next === css) return; @@ -536,7 +551,6 @@ function createTrail(engine, { id }) { group.line.show(next); } const headShown = next && head.show; - headLines?.backing.show(headShown); headLines?.line.show(headShown); engine.render.request('transit-trail-visible'); }, @@ -545,7 +559,6 @@ function createTrail(engine, { id }) { destroyed = true; clearBody(); if (headLines) { - headLines.backing.destroy(); headLines.line.destroy(); headLines = null; } @@ -561,8 +574,8 @@ function createTrail(engine, { id }) { backing, headPrimitive: null, active, - ground: false, - headStrategy: 'engine lines: age bands and a head to the marker', + ground: true, + headStrategy: 'ground corridors: age bands and a segment reveal head', headRebuilds, entitiesAdded: 0, }), diff --git a/src/layers/transit/render/display.test.mjs b/src/layers/transit/render/display.test.mjs index 379c0dbc6..c68b67990 100644 --- a/src/layers/transit/render/display.test.mjs +++ b/src/layers/transit/render/display.test.mjs @@ -67,12 +67,28 @@ function fakeHarness(options = {}) { function cesiumHarness() { const handlers = createFakeEventHandlers(); + const groundPrimitives = []; + const groundPrimitive = (options) => { + const primitive = { + ...options, + ready: true, + destroy() {}, + isDestroyed: () => false, + }; + groundPrimitives.push(primitive); + return primitive; + }; const { engine, viewer, frame, primitive } = createCesiumMoversEngine({ engineOptions: { createEventHandler: handlers.create, scheduleFrame: (run) => run(), + groundFactories: { + lines: groundPrimitive, + fills: groundPrimitive, + }, }, }); + viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection(); let native; let picked = []; viewer.scene.pick = (position) => { @@ -86,6 +102,7 @@ function cesiumHarness() { cesium: true, engine, viewer, + groundPrimitives, frame, primitive, picked: () => picked, @@ -139,6 +156,40 @@ function setup( { vehicles = new Map(), credits = [], mesh = [] } = {}, ) { const h = makeHarness(); + const groundRecords = new Map(); + const createSet = h.engine.ground.createSet; + h.engine.ground.createSet = (options) => { + const set = createSet(options); + const record = { options, commits: 0, fraction: 1 }; + groundRecords.set(options.id, record); + const { replace, commit, setReveal } = set; + set.replace = (items) => { + record.items = items; + replace(items); + }; + set.commit = () => { + record.commits++; + commit(); + }; + set.setReveal = (fraction) => { + record.fraction = fraction; + setReveal(fraction); + }; + return set; + }; + h.trailLines = () => + h.engine.ground.list().map((set) => ({ + ...set, + points: groundRecords.get(set.id).items?.[0]?.positions.length ?? 0, + })); + h.trailLine = (id) => { + const record = groundRecords.get(id); + return { + ...record.items[0], + ...record, + points: record.items[0].positions.length, + }; + }; h.engine.camera.setView({ destination: geo.cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 2000), }); @@ -559,7 +610,7 @@ for (const makeHarness of HARNESSES) { ], }); assert.deepEqual( - f.engine.lines.list(), + f.trailLines(), [], 'nothing before the clock passes a segment', ); @@ -571,11 +622,19 @@ for (const makeHarness of HARNESSES) { ); const sample = { displayT: 15000, fromSeq: 0, toSeq: 1, fraction: 0.75 }; for (let i = 0; i < 120; i++) trail.setDisplaySample(sample, marker); - const lines = f.engine.lines.list(); - // The first segment (two lanes) and the head (two lanes). - assert.equal(lines.length, 4); - const lanes = lines.map(({ id }) => ({ id, ...f.line(id) })); - const [bodyBacking, bodyLane, headBacking, headLane] = lanes; + const lines = f.trailLines(); + // The first segment (two lanes) and one backed reveal head. + assert.equal(lines.length, 3); + const lanes = lines.map(({ id }) => ({ id, ...f.trailLine(id) })); + assert.ok(lanes.every((lane) => lane.options.dynamic)); + if (f.cesium) { + assert.equal(f.groundPrimitives.length, 3); + assert.ok( + f.groundPrimitives.every((primitive) => !primitive.asynchronous), + 'history and head build immediately without worker jobs', + ); + } + const [bodyBacking, bodyLane, headLane] = lanes; assert.equal(bodyBacking.points, 2); assert.equal(bodyLane.points, 2); near(bodyBacking.color[0], 0.02, 'dark edge'); @@ -587,18 +646,13 @@ for (const makeHarness of HARNESSES) { 'age alpha', ); near(headLane.color[3], 0.8, 'head alpha'); - near(headLane.depthFailAlpha, 0.55 / 0.8, 'hidden head at 0.55'); - near( - bodyLane.depthFailAlpha * bodyLane.color[3], - 0.55, - 'hidden body at 0.55', - 1e-3, - ); - assert.equal( - headBacking.depthFailAlpha, - 0, - 'the dark edge does not draw hidden', - ); + assert.deepEqual(headLane.backing, { + widthPx: 5, + color: [0.02, 0.03, 0.05, 0.8], + }); + assert.equal(headLane.fraction, 0.5); + assert.equal(headLane.commits, 1, 'head geometry built once'); + assert.equal(headLane.options.pickable, undefined, 'picks pass through'); if (!f.cesium) { assert.equal(bodyBacking.widthPx, 5); assert.equal(bodyLane.widthPx, 3); @@ -614,20 +668,23 @@ for (const makeHarness of HARNESSES) { assert.equal(result.backing.width, 5); assert.equal(result.head.positions.length, 2); assert.ok(geo.cartesian3.equals(result.head.positions.at(-1), marker)); - assert.equal(result.ground, false); + assert.equal(result.ground, true); + trail.setDisplaySample({ ...sample, displayT: 16000 }, marker); + assert.equal(f.trailLine(headLane.id).commits, 1); + assert.equal(f.trailLine(headLane.id).fraction, 0.6); // Rewind: the passed segment is future again. trail.setDisplaySample( { ...sample, displayT: 5000, fraction: 0.25 }, positions[0], ); - assert.equal(f.engine.lines.list().length, 2, 'only the head'); + assert.equal(f.trailLines().length, 1, 'only the head'); // Past the end the head goes too; long after, both segments share the // oldest band. trail.setDisplaySample( { ...sample, displayT: 2_000_000, fraction: 1, fromSeq: 1, toSeq: 1 }, positions[2], ); - const done = f.engine.lines.list(); + const done = f.trailLines(); assert.equal( done.filter((line) => line.shown).length, 2, @@ -639,11 +696,11 @@ for (const makeHarness of HARNESSES) { 'joined segments are one line', ); trail.setVisible(false); - assert.ok(f.engine.lines.list().every((line) => !line.shown)); + assert.ok(f.trailLines().every((line) => !line.shown)); trail.setVisible(true); trail.destroy(); trail.destroy(); - assert.deepEqual(f.engine.lines.list(), []); + assert.deepEqual(f.trailLines(), []); f.display.destroy(); f.engine.destroy(); }); @@ -666,7 +723,7 @@ for (const makeHarness of HARNESSES) { }); } trail.replaceHistory({ revision: 1, segments }); - const shownLines = () => f.engine.lines.list().filter((line) => line.shown); + const shownLines = () => f.trailLines().filter((line) => line.shown); // Ten minutes on, the passed history spans several bands; across the gap // it is two runs. const display = (displayT) => @@ -678,7 +735,7 @@ for (const makeHarness of HARNESSES) { const bands = new Set(); let points = 0; for (const { id, points: count } of shownLines()) { - const { color } = f.line(id); + const { color } = f.trailLine(id); if (color[0] > 0.1) { bands.add(color[3]); points += count; @@ -686,18 +743,18 @@ for (const makeHarness of HARNESSES) { } assert.ok(bands.size >= 1); assert.equal(points, 101 + 11 + (bands.size - 1) * 1, 'each band one span'); - const listed = f.engine.lines.list().map(({ id }) => id); + const listed = f.trailLines().map(({ id }) => id); // Within the same second nothing is rewritten. display(55000 + 120400); assert.deepEqual( - f.engine.lines.list().map(({ id }) => id), + f.trailLines().map(({ id }) => id), listed, ); // A new style recolours the colour lanes, not the dark edges. trail.setStyle('#FF4538'); const red = rgbaFromCss('#FF4538').rgba(); for (const { id } of shownLines()) { - const { color } = f.line(id); + const { color } = f.trailLine(id); if (color[0] > 0.1) near(color[0], red[0], 'restyled'); else near(color[0], 0.02, 'dark edge kept'); } @@ -705,7 +762,8 @@ for (const makeHarness of HARNESSES) { display(55000 + 2_000_000); const old = shownLines(); assert.equal(old.length, 4, 'two runs, two lanes each'); - for (const { id } of old) near(f.line(id).color[3], 0.08, 'oldest alpha'); + for (const { id } of old) + near(f.trailLine(id).color[3], 0.08, 'oldest alpha'); trail.destroy(); f.display.destroy(); f.engine.destroy(); diff --git a/src/testSupport/cctvEngines.mjs b/src/testSupport/cctvEngines.mjs index 92c32bcaf..f243ddbdb 100644 --- a/src/testSupport/cctvEngines.mjs +++ b/src/testSupport/cctvEngines.mjs @@ -104,8 +104,13 @@ const CESIUM_TYPES = { export function cesiumCctvHarness() { const handler = testEventHandler(); const { engine, viewer, frame, primitive } = createCesiumMoversEngine({ - engineOptions: { createEventHandler: () => handler }, + engineOptions: { + createEventHandler: () => handler, + scheduleFrame: (run) => run(), + }, }); + viewer.scene.pick = () => undefined; + viewer.scene.drillPick = () => []; const canvas = viewer.scene.canvas; canvas.style = {}; canvas.ownerDocument = { defaultView: new EventTarget() };