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() };