diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 4c770e1fc..48d9af8a9 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3595,6 +3595,7 @@ "src/globe/navara/look.js", "src/globe/navara/maps.js", "src/globe/navara/sharpen.js", + "src/annotations/drawInputNavara.js", "src/globe/navara/surface.js", "src/globe/navara/viewOptions.js", "src/app/engineChoice.js", diff --git a/scripts/qa-navara-draw.mjs b/scripts/qa-navara-draw.mjs new file mode 100644 index 000000000..b1133cf9a --- /dev/null +++ b/scripts/qa-navara-draw.mjs @@ -0,0 +1,303 @@ +#!/usr/bin/env node +// UTILITIES ▸ Draw on Navara under the strict network policy, with real +// clicks and keys: Draw takes the pointer, clicks on the globe are vertices +// with a live rubber band, a double click finishes an area that paints on +// the canvas and joins the session board, Enter finishes a line, Escape +// cancels a sketch and then leaves Draw, and a switch to Cesium in the +// middle of a sketch ends it (the pointer is free, shape and colour kept); +// the shapes drawn on either engine stay on the board and draw on both. +// +// node scripts/qa-navara-draw.mjs http://127.0.0.1:/ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import sharp from 'sharp'; +import { createStrict, localPreview } from './qa-strict-harness.mjs'; + +const VIEW = { lat: 37.7749, lon: -122.4194, alt: 6000 }; +const base = localPreview(process.argv[2]); +base.search = '?engine=navara&welcome=0'; +base.hash = `v=2&lat=${VIEW.lat}&lon=${VIEW.lon}&alt=${VIEW.alt}&heading=0&pitch=-90&roll=0&map=osm&l=`; +const strict = createStrict({ origin: base.origin, layers: ['draw'] }); +const SHOTS = process.env.QA_SHOT_DIR || 'qa-shots/navara-draw'; +fs.mkdirSync(SHOTS, { recursive: true }); +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +// An area's corners, clear of the open UTILITIES panel and the rails. +const AREA = [ + [560, 330], + [760, 330], + [760, 500], + [560, 500], +]; + +await strict.run( + { + args: [ + '--use-angle=metal', + '--enable-gpu', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + ], + }, + async (browser) => { + const page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 860 }); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.goto(base.href); + const ready = async (kind) => { + await page.waitForFunction( + (k) => + window.__godsEyeView?.engine?.kind === k && + window.__gevDrawTool?.attached && + !document.querySelector('#globe-engine')?.disabled && + !document.querySelector('.engine-switch-frame') && + document.querySelector('#loading-screen.hidden'), + { timeout: 120000 }, + kind, + ); + await page.evaluate( + () => window.__godsEyeView.styleManager.initialRestorePromise, + ); + }; + await ready('navara'); + assert.equal( + await page.evaluate(() => + Boolean(window.__godsEyeView.tileset || window.__GOOGLE_MAPS_API_KEY__), + ), + false, + 'keyless preview required', + ); + const tool = () => + page.evaluate(() => { + const draw = window.__gevDrawTool; + return { + active: draw.active, + shape: draw.shape, + color: draw.color, + vertices: draw.session?.vertices.length ?? null, + board: window.__gevAnnotations.count(), + attached: window.__gevAnnotations.attached, + diagnostics: draw.diagnostics(), + band: + window.__godsEyeView.engine.lines + ?.list?.() + .find(({ id }) => id === 'gev-draw-preview') ?? null, + }; + }); + /** Amber pixels of the globe canvas alone (the SVG board hidden). */ + const amber = async () => { + await page.addStyleTag({ + content: '.gev-screen-whiteboard{visibility:hidden!important}', + }); + await wait(800); + const { data, info } = await sharp( + await page.screenshot({ + clip: { x: 520, y: 300, width: 280, height: 230 }, + }), + ) + .removeAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + await page.evaluate(() => + document.querySelectorAll('style').forEach((style) => { + if (style.textContent.includes('gev-screen-whiteboard{visibility')) + style.remove(); + }), + ); + let n = 0; + for (let i = 0; i < data.length; i += info.channels) { + const [r, g, b] = [data[i], data[i + 1], data[i + 2]]; + if (r > 150 && g > 90 && g < 225 && b < 150 && r - b > 60 && r - g > 15) + n++; + } + return n; + }; + const openUtilities = () => + page.evaluate(() => { + const panel = document.getElementById('utilities-panel'); + if (panel?.classList.contains('collapsed')) + document + .querySelector( + '.panel-collapse-btn[data-collapse-target="utilities-panel"]', + ) + ?.click(); + }); + const drawShape = async (shape, points, finish) => { + await page.evaluate((s) => { + window.__gevDrawTool.setShape(s); + window.__gevDrawTool.setColor('amber'); + }, shape); + for (const [x, y] of points) { + await page.mouse.click(x, y); + await wait(120); + } + await finish(); + await wait(800); + }; + + await openUtilities(); + await page.waitForSelector('#draw-toggle', { visible: true }); + const bare = await amber(); + await page.click('#draw-toggle'); + await page.waitForFunction(() => window.__gevDrawTool.active); + const on = await page.evaluate(() => ({ + owner: document.body.classList.contains('gev-drawing'), + })); + // Three corners, then the rubber band to the fourth under the pointer. + await page.evaluate(() => { + window.__gevDrawTool.setShape('area'); + window.__gevDrawTool.setColor('amber'); + }); + // Each click is a vertex (the rubber band under the pointer never + // swallows one). + for (const [index, [x, y]] of AREA.slice(0, 3).entries()) { + await page.mouse.click(x, y); + await page.waitForFunction( + (n) => window.__gevDrawTool.session?.vertices.length === n, + { timeout: 3000 }, + index + 1, + ); + await wait(150); + } + await page.mouse.move(...AREA[3]); + await wait(300); + const sketch = await tool(); + await page.screenshot({ path: `${SHOTS}/navara-sketch.png` }); + await strict.check( + page, + 'draw', + 'Draw takes the pointer; clicks on the globe are vertices with a live rubber band', + on.owner && + sketch.active && + sketch.vertices === 3 && + sketch.diagnostics.sceneHandler && + sketch.band?.shown === true, + sketch, + ); + await page.mouse.click(AREA[3][0], AREA[3][1], { count: 2 }); + await page.waitForFunction(() => window.__gevAnnotations.count() === 1, { + timeout: 10000, + }); + await wait(800); + const drawn = await amber(); + await page.screenshot({ path: `${SHOTS}/navara-area.png` }); + const area = await page.evaluate(() => + window.__gevAnnotations.list().map((m) => [m.type, m.color, m.manual]), + ); + await strict.check( + page, + 'draw', + 'a double click finishes the area: it joins the board and paints amber on Navara', + area.length === 1 && area[0][0] === 'area' && drawn - bare > 1500, + { area, bare, drawn }, + ); + // Enter finishes a line. + await drawShape( + 'line', + [ + [600, 560], + [760, 600], + ], + () => page.keyboard.press('Enter'), + ); + const lines = await page.evaluate(() => + window.__gevAnnotations.list().map((m) => m.type), + ); + await strict.check( + page, + 'draw', + 'Enter on the map finishes a line', + lines.length === 2 && lines.includes('route'), + { lines }, + ); + // Escape: the sketch, then Draw itself. + await page.evaluate(() => window.__gevDrawTool.setShape('line')); + await page.mouse.click(620, 360); + await page.mouse.click(700, 380); + const before = await tool(); + await page.keyboard.press('Escape'); + const cancelled = await tool(); + await page.keyboard.press('Escape'); + const left = await tool(); + await strict.check( + page, + 'draw', + 'Escape cancels the sketch, then leaves Draw and gives the pointer back', + before.vertices === 2 && + cancelled.active && + cancelled.vertices === 0 && + !left.active && + !left.diagnostics.sceneHandler && + left.diagnostics.pointerOwner == null && + left.band?.shown !== true, + { before, cancelled, left }, + ); + assert.equal(left.board, 2, 'Escape keeps the finished shapes'); + + // A switch to Cesium in the middle of a sketch. + await page.click('#draw-toggle'); + await page.waitForFunction(() => window.__gevDrawTool.active); + await page.evaluate(() => { + window.__gevDrawTool.setShape('line'); + window.__gevDrawTool.setColor('green'); + }); + await page.mouse.click(620, 360); + await page.mouse.click(700, 380); + assert.equal((await tool()).vertices, 2); + await strict.pick(page, 'cesium'); + await ready('cesium'); + await wait(1500); + const switched = await tool(); + await page.screenshot({ path: `${SHOTS}/cesium-after-switch.png` }); + await strict.check( + page, + 'draw', + 'a switch mid-sketch ends the sketch, frees the pointer and keeps shape, colour and the board', + !switched.active && + switched.vertices === null && + switched.diagnostics.pointerOwner == null && + switched.shape === 'line' && + switched.color === 'green' && + switched.board === 2 && + switched.attached, + switched, + ); + // Draw on Cesium too, with the same clicks. + await openUtilities(); + await page.click('#draw-toggle'); + await page.waitForFunction(() => window.__gevDrawTool.active); + await drawShape( + 'line', + [ + [600, 420], + [760, 450], + ], + () => page.keyboard.press('Enter'), + ); + await page.keyboard.press('Escape'); + const onCesium = await tool(); + await strict.check( + page, + 'draw', + 'a shape drawn on Cesium joins the same board', + onCesium.board === 3 && !onCesium.active, + onCesium, + ); + await strict.pick(page, 'navara'); + await ready('navara'); + await wait(2000); + const back = await tool(); + const backAmber = await amber(); + await page.screenshot({ path: `${SHOTS}/navara-back.png` }); + await strict.check( + page, + 'draw', + 'back on Navara the three shapes are on the board and the area paints again', + back.board === 3 && back.attached && backAmber - bare > 1500, + { board: back.board, bare, backAmber }, + ); + await page.evaluate(() => window.__gevAnnotations.clear()); + assert.deepEqual(errors, []); + console.log('PASS Draw on Navara with real clicks and keys'); + }, +); diff --git a/src/annotations/drawInputNavara.js b/src/annotations/drawInputNavara.js new file mode 100644 index 000000000..a3241c7f7 --- /dev/null +++ b/src/annotations/drawInputNavara.js @@ -0,0 +1,163 @@ +// UTILITIES ▸ Draw on a globe engine without a Cesium viewer (Navara): the +// draw surface src/annotations/drawTool.js runs on. Clicks, moves and the +// double click come from engine.input (the pointer claim the tool holds +// makes every layer's own click yield, as on Cesium); the open shape shows +// as a dashed engine line (the rubber band) and a dot per vertex, at the +// height each click landed on. No Cesium, no three. + +import * as geo from '../geo/index.js'; +import { pickWorldFromScreen } from './annotationResolver.js'; +import { DRAW_COLOR_CSS } from './drawMarks.js'; +import { MAX_VERTICES } from './drawMode.js'; + +const LINE_ID = 'gev-draw-preview'; +const DOTS_ID = 'gev-draw-vertices'; + +/** '#rrggbb' → rgba in 0..1. */ +export function cssRgba(css, alpha = 1) { + const hex = Number.parseInt(String(css).slice(1), 16); + return [ + ((hex >> 16) & 255) / 255, + ((hex >> 8) & 255) / 255, + (hex & 255) / 255, + alpha, + ]; +} + +const world = (v) => geo.cartesian3.fromDegrees(v.lon, v.lat, v.height || 0); + +/** + * The draw surface factory for `engine`: `surface({ session, cursor })` + * (the open shape and the rubber band's end, as live getters) returns the + * input and preview initDrawTool runs on. + * @param {import('../globe/contract.js').GlobeEngine} engine + */ +export function navaraDrawSurface(engine) { + return (live) => { + let line = null; + let dots = null; + let removers = null; + let color = 'primary'; + let destroyed = false; + + const ensurePreview = () => { + line ??= engine.lines.createLine({ + id: LINE_ID, + widthPx: 3, + color: cssRgba(DRAW_COLOR_CSS.primary, 0.9), + // Cesium's depth-fail dash at 0.35 of the colour, 0.9 in front. + depthFailAlpha: 0.35 / 0.9, + dash: { lengthPx: 8, gapPx: 8 }, + }); + dots ??= engine.movers.createSet({ + id: DOTS_ID, + kind: 'point', + capacity: MAX_VERTICES, + order: 10, + depthTest: 'always', + pickable: false, + point: { sizePx: 8, outlinePx: 2, outlineColor: [0, 0, 0, 0.6] }, + }); + }; + /** The rubber band through the vertices, the pointer and back (areas). */ + const drawLine = () => { + const session = live.session(); + if (!line) return; + if (!session || session.shape === 'pin') { + line.show(false); + return; + } + const points = session.vertices.map(world); + const cursor = live.cursor(); + if (cursor) points.push(world(cursor)); + if (session.shape === 'area' && points.length >= 3) + points.push(points[0]); + if (points.length < 2) { + line.show(false); + return; + } + line.setPositions(points); + line.show(true); + }; + + return { + canvas: engine.host.canvas, + pick(position) { + const { width, height } = engine.host.size(); + return pickWorldFromScreen( + engine, + position.x / (width || 1), + position.y / (height || 1), + ); + }, + sync(session, nextColor) { + if (destroyed) return; + color = nextColor; + if (!session) { + line?.show(false); + if (dots) { + dots.setCount(0); + dots.commit(); + } + engine.render.request('draw'); + return; + } + ensurePreview(); + const css = DRAW_COLOR_CSS[color] || DRAW_COLOR_CSS.primary; + line.setColor(cssRgba(css, 0.9)); + const stroke = cssRgba(css, 1); + session.vertices.forEach((v, index) => + dots.set(index, { position: world(v), color: stroke, show: true }), + ); + dots.setCount(session.vertices.length); + dots.commit(); + drawLine(); + engine.render.request('draw'); + }, + moved() { + if (destroyed) return; + drawLine(); + engine.render.request('draw'); + }, + bound: () => Boolean(removers), + bind({ click, move, doubleClick }) { + if (removers || destroyed) return; + removers = [ + engine.input.on('click', click), + engine.input.on('move', move), + engine.input.on('doubleClick', doubleClick), + ]; + }, + unbind() { + for (const remove of removers ?? []) remove(); + removers = null; + }, + diagnostics: ({ destroyed: toolDestroyed }) => ({ + sceneHandler: Boolean(removers), + // Navara draws no Cesium data source; its preview is one line and + // one dot per vertex. + previewDataSources: 0, + previewEntities: toolDestroyed + ? 0 + : (line ? 1 : 0) + (dots ? dots.count() : 0), + previewLines: engine.lines.list().filter(({ id }) => id === LINE_ID) + .length, + stockSingleClick: false, + stockDoubleClick: false, + }), + settled: () => Promise.resolve(), + destroy() { + if (!destroyed) { + destroyed = true; + for (const remove of removers ?? []) remove(); + removers = null; + line?.destroy(); + dots?.destroy(); + line = dots = null; + engine.render.request('draw'); + } + return Promise.resolve(); + }, + }; + }; +} diff --git a/src/annotations/drawInputNavara.test.mjs b/src/annotations/drawInputNavara.test.mjs new file mode 100644 index 000000000..dc8c9b7e5 --- /dev/null +++ b/src/annotations/drawInputNavara.test.mjs @@ -0,0 +1,150 @@ +import '../testSupport/registerDom.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; +import { + isPointerFree, + pointerOwner, + resetPointerOwnership, +} from '../data/inputOwnership.js'; +import { createSessionDrawTool } from './drawTool.js'; +import { navaraDrawSurface } from './drawInputNavara.js'; +import * as geo from '../geo/index.js'; + +/** The board double the tool draws on (its calls are what Draw sends). */ +function fakeAnnotations() { + const calls = { annotate: [], frame: [] }; + return { + calls, + async annotate(specs, options) { + calls.annotate.push({ specs, options }); + return { drawn: specs.length }; + }, + clear() {}, + list: () => [], + onChange: () => () => {}, + frame: (id) => calls.frame.push(id), + }; +} + +function harness(t) { + resetPointerOwnership(); + document.body.replaceChildren(); + const toggle = document.createElement('button'); + toggle.id = 'draw-toggle'; + document.body.append(toggle); + // Over (0, 0): the canvas centre is the ellipsoid under the camera. + const engine = createFakeEngine({ kind: 'navara', altitude: 20000 }); + const annotations = fakeAnnotations(); + let actions = null; + const tool = createSessionDrawTool({ + annotations, + connect: (next) => { + actions = next; + return () => (actions = null); + }, + }); + t.after(() => { + tool.destroy(); + resetPointerOwnership(); + }); + const attach = () => tool.attachScene({ surface: navaraDrawSurface(engine) }); + attach(); + const click = (x, y) => engine.fake.input('click', { position: { x, y } }); + const key = (name) => + document.body.dispatchEvent( + new KeyboardEvent('keydown', { key: name, bubbles: true }), + ); + return { + engine, + tool, + annotations, + attach, + click, + key, + act: (name, ...args) => actions?.[name]?.(...args), + line: () => engine.fake.line('gev-draw-preview'), + dots: () => engine.fake.moverSet('gev-draw-vertices'), + }; +} + +test('on Navara a click is a vertex, the rubber band follows the pointer and a double click finishes', async (t) => { + const h = harness(t); + h.act('toggleDraw'); + assert.equal(pointerOwner(), 'draw', 'the session holds the pointer'); + assert.equal(h.tool.diagnostics().sceneHandler, true); + h.tool.setShape('area'); + h.click(500, 380); + h.click(540, 380); + h.click(540, 420); + assert.equal(h.tool.session.vertices.length, 3); + assert.equal( + h.engine.movers.list().find(({ id }) => id === 'gev-draw-vertices').count, + 3, + ); + // The band: three vertices, the pointer and the closing vertex. + h.engine.fake.input('move', { + startPosition: { x: 540, y: 420 }, + endPosition: { x: 500, y: 420 }, + }); + const band = h.line(); + assert.equal(band.shown, true); + assert.equal(band.positions.length, 5 * 3); + assert.equal(band.options.dash.lengthPx, 8); + assert.equal(band.options.dash.gapPx, 8); + // The vertex hangs where the click landed (the ellipsoid here). + const first = geo.cartographic.fromCartesian({ + x: band.positions[0], + y: band.positions[1], + z: band.positions[2], + }); + assert.ok(Math.abs(first.height) < 1); + h.engine.fake.input('doubleClick', { position: { x: 500, y: 420 } }); + await Promise.resolve(); + assert.equal(h.annotations.calls.annotate.length, 1); + const [spec] = h.annotations.calls.annotate[0].specs; + assert.equal(spec.type, 'area'); + assert.equal(spec.manual, true); + assert.ok(spec.ring.length >= 3); + assert.equal(h.tool.session.vertices.length, 0, 'a fresh shape is open'); +}); + +test('Escape cancels the open shape, then leaves Draw and frees the pointer', (t) => { + const h = harness(t); + h.act('toggleDraw'); + h.tool.setShape('line'); + h.click(500, 380); + h.click(560, 380); + assert.equal(h.tool.session.vertices.length, 2); + h.key('Escape'); + assert.equal(h.tool.session.vertices.length, 0, 'the sketch is cancelled'); + assert.equal(h.tool.active, true); + h.key('Escape'); + assert.equal(h.tool.active, false); + assert.equal(isPointerFree(), true); + assert.equal(h.tool.diagnostics().sceneHandler, false); +}); + +test('a switch mid-sketch ends the sketch, takes the preview off the engine and keeps shape and colour', (t) => { + const h = harness(t); + h.act('toggleDraw'); + h.tool.setShape('line'); + h.tool.setColor('amber'); + h.click(500, 380); + h.click(560, 380); + assert.ok(h.line()); + h.tool.detachScene(); + assert.equal(h.tool.active, false); + assert.equal(isPointerFree(), true, 'the pointer is free after the switch'); + assert.equal(h.line(), undefined, 'the band left the engine'); + assert.equal(h.dots(), undefined); + // A click on the old engine does nothing now. + h.click(520, 400); + assert.equal(h.tool.session, null); + h.attach(); + assert.equal(h.tool.shape, 'line'); + assert.equal(h.tool.color, 'amber'); + h.act('toggleDraw'); + h.click(500, 380); + assert.equal(h.tool.session.vertices.length, 1); +}); diff --git a/src/annotations/drawTool.js b/src/annotations/drawTool.js index fee977d67..ee34fb66f 100644 --- a/src/annotations/drawTool.js +++ b/src/annotations/drawTool.js @@ -67,11 +67,16 @@ const PREVIEW = DRAW_COLOR_CSS; * through the actions it gives `connect`, which returns their disconnect. * A row's fly-to is an immediate navigation through `navigation`, like * Import's, so Cockpit can refuse it and a running orbit or follow lets go. - * @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, navigation?: object, publish?: Function, connect?: Function}} deps + * The globe it draws on is a Cesium `viewer` or a draw `surface` factory + * (`surface({ session, cursor })`: the input and preview of a globe without + * a viewer, src/annotations/drawInputNavara.js), which gets the open shape + * and the rubber band's end as live getters. + * @param {{viewer?: Cesium.Viewer, surface?: Function, annotations: {annotate: Function, clear: Function}, navigation?: object, publish?: Function, connect?: Function}} deps * @returns {{destroy: Function}|null} */ export function initDrawTool({ - viewer, + viewer = null, + surface: makeSurface = null, annotations, navigation = null, publish = () => {}, @@ -81,7 +86,11 @@ export function initDrawTool({ }) { // A page without the Draw controls (the Display controls view omitted) has // no draw tool. - if (!viewer || !annotations || !document.getElementById('draw-toggle')) + if ( + (!viewer && !makeSurface) || + !annotations || + !document.getElementById('draw-toggle') + ) return null; let active = false; @@ -90,16 +99,14 @@ export function initDrawTool({ let shape = normalizeShape(initialShape); let color = DRAW_COLORS.includes(initialColor) ? initialColor : 'primary'; let hint = ''; - let handler = null; let lease = null; - let savedDoubleClick = null; - let savedSingleClick = null; - let cursor = null; // last mouse position on the canvas, for the rubber band + // The last pointer position on the globe ({ lon, lat, height }), for the + // rubber band. + let cursor = null; // Bumped by anything that supersedes an in-flight finish: cancelling, clearing // the board, leaving draw mode, teardown. An `annotate()` that resolves after // one of those must not write its outcome over the newer state. let generation = 0; - const previewEntities = []; // Every DOM listener this tool adds (the session's keydown), so teardown is // one loop rather than a list that can fall out of step with the bindings. const domListeners = []; @@ -108,14 +115,11 @@ export function initDrawTool({ domListeners.push([target, type, listener, options]); }; - const dataSource = new Cesium.CustomDataSource(PREVIEW_DATA_SOURCE_NAME); - // `add()` resolves on the NEXT tick, so an init-then-immediate-destroy would - // remove a source that had not been attached yet and leave the attachment to - // land afterwards — a preview data source nobody owns. Chain the removal onto - // the attachment instead of racing it. - let attaching = Promise.resolve(viewer.dataSources.add(dataSource)).catch( - () => null, - ); + // The globe's input and preview: Cesium's here, or the surface's. + const live = { session: () => session, cursor: () => cursor }; + const surface = makeSurface + ? makeSurface(live) + : cesiumDrawSurface(viewer, live); const present = () => publish({ @@ -138,75 +142,14 @@ export function initDrawTool({ }); // ---- preview --------------------------------------------------------- - const vertexPositions = () => - session.vertices.map((v) => - Cesium.Cartesian3.fromDegrees(v.lon, v.lat, v.height || 0), - ); - const previewLine = dataSource.entities.add({ - show: false, - polyline: { - positions: new Cesium.CallbackProperty(() => { - if (!session) return []; - const pts = vertexPositions(); - if (cursor && session.shape !== 'pin') pts.push(cursor); - if (session.shape === 'area' && pts.length >= 3) pts.push(pts[0]); - return pts; - }, false), - width: 3, - material: new Cesium.PolylineDashMaterialProperty({ - color: Cesium.Color.fromCssColorString(PREVIEW.primary).withAlpha(0.9), - dashLength: 16, - }), - depthFailMaterial: new Cesium.PolylineDashMaterialProperty({ - color: Cesium.Color.fromCssColorString(PREVIEW.primary).withAlpha(0.35), - dashLength: 16, - }), - clampToGround: false, - }, - }); const syncPreview = () => { if (destroyed) return; - previewEntities.forEach((e) => dataSource.entities.remove(e)); - previewEntities.length = 0; - if (!session) { - previewLine.show = false; - setHint(() => drawHint(null)); - viewer.scene.requestRender(); - return; - } - const stroke = Cesium.Color.fromCssColorString( - PREVIEW[color] || PREVIEW.primary, - ); - previewLine.polyline.material = new Cesium.PolylineDashMaterialProperty({ - color: stroke.withAlpha(0.9), - dashLength: 16, - }); - previewLine.show = session.shape !== 'pin'; - for (const v of session.vertices) { - previewEntities.push( - dataSource.entities.add({ - position: Cesium.Cartesian3.fromDegrees(v.lon, v.lat, v.height || 0), - point: { - pixelSize: 8, - color: stroke, - outlineColor: Cesium.Color.BLACK.withAlpha(0.6), - outlineWidth: 2, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, - }), - ); - } + surface.sync(session, color); setHint(() => drawHint(session)); - viewer.scene.requestRender(); }; // ---- vertices from clicks -------------------------------------------- - const worldAt = (position) => { - const canvas = viewer.scene.canvas; - const w = canvas.clientWidth || canvas.width || 1; - const h = canvas.clientHeight || canvas.height || 1; - return pickWorldFromScreen(viewer, position.x / w, position.y / h); - }; + const worldAt = (position) => surface.pick(position); const onClick = (event) => { if (!session || destroyed) return; const p = worldAt(event.position); @@ -231,10 +174,8 @@ export function initDrawTool({ const onMove = (event) => { if (!session || session.shape === 'pin' || destroyed) return; const p = worldAt(event.endPosition); - cursor = p - ? Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0) - : null; - viewer.scene.requestRender(); + cursor = p ? { lon: p.lon, lat: p.lat, height: p.height || 0 } : null; + surface.moved(); }; // ---- finish / cancel ------------------------------------------------- @@ -315,7 +256,7 @@ export function initDrawTool({ ) return false; return ( - t === document.body || t === viewer.scene.canvas || t.tagName === 'CANVAS' + t === document.body || t === surface.canvas || t.tagName === 'CANVAS' ); }; const onKey = (event) => { @@ -386,49 +327,19 @@ export function initDrawTool({ } function bindSceneHandler() { - if (handler) return; - handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); - handler.setInputAction(onClick, Cesium.ScreenSpaceEventType.LEFT_CLICK); - handler.setInputAction(onMove, Cesium.ScreenSpaceEventType.MOUSE_MOVE); - handler.setInputAction(() => { - void finish(); - }, Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK); - // Cesium's Viewer binds BOTH stock click actions on its own handler: the - // single click picks an entity into `viewer.selectedEntity`, the double - // click tracks it. Neither goes through a layer, so neither can be fixed by - // the shared pointer claim — they have to be borrowed outright for the - // session and given back on the way out. Borrowing the single click is what - // stops a vertex placed on a contact from also selecting it. - const stock = viewer.screenSpaceEventHandler; - savedSingleClick = - stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null; - savedDoubleClick = - stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) || - null; - stock.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK); - stock.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK); + if (surface.bound()) return; + surface.bind({ + click: onClick, + move: onMove, + doubleClick: () => { + void finish(); + }, + }); listen(document, 'keydown', onKey, true); } function releaseSceneHandler() { - if (handler) { - handler.destroy(); - handler = null; - } - if (savedSingleClick) { - viewer.screenSpaceEventHandler.setInputAction( - savedSingleClick, - Cesium.ScreenSpaceEventType.LEFT_CLICK, - ); - savedSingleClick = null; - } - if (savedDoubleClick) { - viewer.screenSpaceEventHandler.setInputAction( - savedDoubleClick, - Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK, - ); - savedDoubleClick = null; - } + surface.unbind(); // Drop the keydown listener bound alongside this handler. for (let i = domListeners.length - 1; i >= 0; i -= 1) { const [target, type, listener, options] = domListeners[i]; @@ -528,34 +439,19 @@ export function initDrawTool({ return { active, destroyed, - sceneHandler: Boolean(handler), domListeners: domListeners.length, - previewDataSources: countPreviewDataSources(viewer), - previewEntities: destroyed ? 0 : dataSource.entities.values.length, pointerOwner: pointerOwner(), - // The viewer's OWN click actions. While a session is open both are - // borrowed (absent here); after it closes both are back. A harness can - // compare the restored functions by identity with what it captured - // before, which is the only way to prove they were given back rather - // than replaced. - stockSingleClick: Boolean( - viewer.screenSpaceEventHandler?.getInputAction?.( - Cesium.ScreenSpaceEventType.LEFT_CLICK, - ), - ), - stockDoubleClick: Boolean( - viewer.screenSpaceEventHandler?.getInputAction?.( - Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK, - ), - ), + // The globe's handler, preview and (Cesium) the viewer's OWN click + // actions: see the surface's diagnostics. + ...surface.diagnostics({ destroyed }), }; }, /** Resolves once every deferred teardown step has run. */ whenSettled() { - return attaching; + return surface.settled(); }, destroy() { - if (destroyed) return attaching; + if (destroyed) return surface.settled(); // Supersede any finish still in flight before anything is torn down. generation += 1; if (active) setActive(false); @@ -569,37 +465,203 @@ export function initDrawTool({ for (const [target, type, listener, options] of domListeners.splice(0)) { target.removeEventListener(type, listener, options); } - previewEntities.length = 0; - dataSource.entities.removeAll(); - // Wait for the pending add() before removing: a destroy() in the same - // tick as init would otherwise remove nothing and let the attachment land - // behind it. - attaching = attaching.then(() => { - try { - viewer.dataSources.remove(dataSource, true); - } catch { - /* viewer already disposed — nothing to detach from */ - } - }); + // The preview leaves once the globe's pending attachment has landed. + const settled = surface.destroy(); document.body.classList.remove('gev-drawing'); hint = ''; present(); if (window.__gevDrawTool === api) delete window.__gevDrawTool; - return attaching; + return settled; }, }; window.__gevDrawTool = api; return api; } +/** + * The Draw tool's input and preview on a Cesium viewer: a dashed rubber band + * and a dot per vertex in a preview data source, and a ScreenSpaceEventHandler + * for clicks, moves and the double click. Cesium's Viewer binds BOTH stock + * click actions on its own handler (select, track); they are borrowed while a + * sketch is open and given back on the way out. + */ +function cesiumDrawSurface(viewer, live) { + const previewEntities = []; + const dataSource = new Cesium.CustomDataSource(PREVIEW_DATA_SOURCE_NAME); + // `add()` resolves on the NEXT tick, so an init-then-immediate-destroy would + // remove a source that had not been attached yet and leave the attachment to + // land afterwards — a preview data source nobody owns. Chain the removal onto + // the attachment instead of racing it. + let attaching = Promise.resolve(viewer.dataSources.add(dataSource)).catch( + () => null, + ); + let handler = null; + let savedSingleClick = null; + let savedDoubleClick = null; + const toCartesian = (v) => + Cesium.Cartesian3.fromDegrees(v.lon, v.lat, v.height || 0); + const previewLine = dataSource.entities.add({ + show: false, + polyline: { + positions: new Cesium.CallbackProperty(() => { + const session = live.session(); + if (!session) return []; + const pts = session.vertices.map(toCartesian); + const cursor = live.cursor(); + if (cursor && session.shape !== 'pin') pts.push(toCartesian(cursor)); + if (session.shape === 'area' && pts.length >= 3) pts.push(pts[0]); + return pts; + }, false), + width: 3, + material: new Cesium.PolylineDashMaterialProperty({ + color: Cesium.Color.fromCssColorString(PREVIEW.primary).withAlpha(0.9), + dashLength: 16, + }), + depthFailMaterial: new Cesium.PolylineDashMaterialProperty({ + color: Cesium.Color.fromCssColorString(PREVIEW.primary).withAlpha(0.35), + dashLength: 16, + }), + clampToGround: false, + }, + }); + return { + canvas: viewer.scene.canvas, + /** The world point under a canvas position, or null. */ + pick(position) { + const canvas = viewer.scene.canvas; + const w = canvas.clientWidth || canvas.width || 1; + const h = canvas.clientHeight || canvas.height || 1; + return pickWorldFromScreen(viewer, position.x / w, position.y / h); + }, + /** Show the open shape (null: none) in `color`. */ + sync(session, color) { + previewEntities.forEach((e) => dataSource.entities.remove(e)); + previewEntities.length = 0; + if (!session) { + previewLine.show = false; + viewer.scene.requestRender(); + return; + } + const stroke = Cesium.Color.fromCssColorString( + PREVIEW[color] || PREVIEW.primary, + ); + previewLine.polyline.material = new Cesium.PolylineDashMaterialProperty({ + color: stroke.withAlpha(0.9), + dashLength: 16, + }); + previewLine.show = session.shape !== 'pin'; + for (const v of session.vertices) { + previewEntities.push( + dataSource.entities.add({ + position: toCartesian(v), + point: { + pixelSize: 8, + color: stroke, + outlineColor: Cesium.Color.BLACK.withAlpha(0.6), + outlineWidth: 2, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + }), + ); + } + viewer.scene.requestRender(); + }, + /** The rubber band's end moved (read live by the preview line). */ + moved() { + viewer.scene.requestRender(); + }, + bound: () => Boolean(handler), + bind({ click, move, doubleClick }) { + if (handler) return; + handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); + handler.setInputAction(click, Cesium.ScreenSpaceEventType.LEFT_CLICK); + handler.setInputAction(move, Cesium.ScreenSpaceEventType.MOUSE_MOVE); + handler.setInputAction( + doubleClick, + Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK, + ); + // Neither stock action goes through a layer, so neither can be fixed by + // the shared pointer claim — they are borrowed outright for the session. + // Borrowing the single click is what stops a vertex placed on a contact + // from also selecting it. + const stock = viewer.screenSpaceEventHandler; + savedSingleClick = + stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null; + savedDoubleClick = + stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) || + null; + stock.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK); + stock.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK); + }, + unbind() { + if (handler) { + handler.destroy(); + handler = null; + } + if (savedSingleClick) { + viewer.screenSpaceEventHandler.setInputAction( + savedSingleClick, + Cesium.ScreenSpaceEventType.LEFT_CLICK, + ); + savedSingleClick = null; + } + if (savedDoubleClick) { + viewer.screenSpaceEventHandler.setInputAction( + savedDoubleClick, + Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK, + ); + savedDoubleClick = null; + } + }, + diagnostics: ({ destroyed }) => ({ + sceneHandler: Boolean(handler), + previewDataSources: countPreviewDataSources(viewer), + previewEntities: destroyed ? 0 : dataSource.entities.values.length, + // The viewer's OWN click actions. While a session is open both are + // borrowed (absent here); after it closes both are back. A harness can + // compare the restored functions by identity with what it captured + // before, which is the only way to prove they were given back rather + // than replaced. + stockSingleClick: Boolean( + viewer.screenSpaceEventHandler?.getInputAction?.( + Cesium.ScreenSpaceEventType.LEFT_CLICK, + ), + ), + stockDoubleClick: Boolean( + viewer.screenSpaceEventHandler?.getInputAction?.( + Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK, + ), + ), + }), + /** Resolves once every deferred teardown step has run. */ + settled: () => attaching, + destroy() { + previewEntities.length = 0; + dataSource.entities.removeAll(); + // Wait for the pending add() before removing: a destroy() in the same + // tick as init would otherwise remove nothing and let the attachment + // land behind it. + attaching = attaching.then(() => { + try { + viewer.dataSources.remove(dataSource, true); + } catch { + /* viewer already disposed — nothing to detach from */ + } + }); + return attaching; + }, + }; +} + /** * The Draw control for the page session. The marks live on the annotation * board and the chosen shape and colour here; the drawing tool itself (its * preview, click handler and Escape/Enter keys) belongs to the attached - * Cesium viewer. `attachScene({ viewer, navigation })` starts the tool on a - * viewer with the shape and colour last chosen; `detachScene()` ends it, an - * open shape included. Without a viewer the controls answer nothing, as on - * an engine without Draw. Same handle as initDrawTool's + * globe. `attachScene({ viewer, navigation })` starts the tool on a Cesium + * viewer, `attachScene({ surface, navigation })` on a globe without one + * (Navara: src/annotations/drawInputNavara.js), with the shape and colour + * last chosen; `detachScene()` ends it, an open shape included. Without a + * globe the controls answer nothing. Same handle as initDrawTool's * (`window.__gevDrawTool`), kept across switches. */ export function createSessionDrawTool({ @@ -667,13 +729,18 @@ export function createSessionDrawTool({ pointerOwner: pointerOwner(), }, whenSettled: () => inner?.whenSettled() ?? settled, - attachScene({ viewer = null, navigation: next = navigation } = {}) { + attachScene({ + viewer = null, + surface = null, + navigation: next = navigation, + } = {}) { if (destroyed) return; detachScene(); navigation = next; - if (!viewer) return; + if (!viewer && !surface) return; inner = initDrawTool({ viewer, + surface, annotations, navigation, publish, diff --git a/src/annotations/resolver.js b/src/annotations/resolver.js index 0cf65345f..1292a0cf6 100644 --- a/src/annotations/resolver.js +++ b/src/annotations/resolver.js @@ -2294,6 +2294,19 @@ export function createAnnotationResolver({ }; } + /** + * Whether a depth pick landed on the camera itself: something drawn into + * the depth buffer at the near plane (Navara's screen-space preview lines + * and sprites) reads back as a point a metre or so from the camera. Such a + * pick is a miss; the ray's globe hit answers instead. + */ + function depthAtCamera(engine, screen, hit) { + const camera = engine.camera.livePosition; + const ground = engine.camera.pickEllipsoid(screen); + const reference = ground ? geo.cartesian3.distance(ground, camera) : 0; + return geo.cartesian3.distance(hit, camera) < Math.max(2, 0.01 * reference); + } + /** pickWorldFromScreen through a globe engine: the same cascade. */ function enginePickWorldFromScreen(engine, nx, ny) { const { width: w, height: h } = engine.host.size(); @@ -2303,10 +2316,11 @@ export function createAnnotationResolver({ y: Math.max(0, Math.min(1, ny)) * h, }; const stages = [ - () => - engine.picker.pickPositionSupported - ? engine.picker.pickPosition(screen) - : undefined, + () => { + if (!engine.picker.pickPositionSupported) return undefined; + const hit = engine.picker.pickPosition(screen); + return hit && !depthAtCamera(engine, screen, hit) ? hit : undefined; + }, () => engine.camera.pickEllipsoid(screen), () => engine.picker.pickGlobe(screen), ]; diff --git a/src/annotations/resolverEngine.test.mjs b/src/annotations/resolverEngine.test.mjs index 9396cd34a..67d7db8b0 100644 --- a/src/annotations/resolverEngine.test.mjs +++ b/src/annotations/resolverEngine.test.mjs @@ -68,6 +68,27 @@ test('pickWorldFromScreen tries the depth pick, then the ellipsoid, then the glo assert.equal(resolver.pickWorldFromScreen(engine, 0.5, 0.5), null); }); +test('a depth pick at the camera itself (an overlay at the near plane) is a miss', () => { + const engine = lookingDownAt(-97.7431, 30.2672); + // Navara reads a screen-space preview line at the near plane: a point a + // metre from the camera, 20 km above the ground. + engine.fake.script( + 'pickPosition', + geo.cartesian3.fromDegrees(-97.7431, 30.2672, 19999), + ); + const picked = resolver.pickWorldFromScreen(engine, 0.5, 0.5); + assert.ok(Math.abs(picked.height) < 1e-3, 'the ellipsoid answers instead'); + // A roof well below the camera is kept. + engine.fake.script( + 'pickPosition', + geo.cartesian3.fromDegrees(-97.7431, 30.2672, 400), + ); + assert.ok( + Math.abs(resolver.pickWorldFromScreen(engine, 0.5, 0.5).height - 400) < + 1e-6, + ); +}); + test('near-view recovery centres on the engine camera when nothing is picked', async () => { const engine = lookingDownAt(2.35, 48.85, 10000); for (const name of ['pickPosition', 'pickEllipsoid', 'pickGlobe']) diff --git a/src/app/navaraTools.js b/src/app/navaraTools.js index c3fc1f2ab..6542de244 100644 --- a/src/app/navaraTools.js +++ b/src/app/navaraTools.js @@ -3,7 +3,12 @@ // with src/app/navaraMode.js, so the Cesium-mode bundle carries none of it. import { createNavaraAnnotationRenderer } from '../annotations/navaraAnnotationRenderer.js'; -import { sessionAnnotations, sessionGeoImport } from './sessionTools.js'; +import { navaraDrawSurface } from '../annotations/drawInputNavara.js'; +import { + sessionAnnotations, + sessionDrawTool, + sessionGeoImport, +} from './sessionTools.js'; /** * The annotation board drawn on this Navara scene: draped areas and routes @@ -33,9 +38,30 @@ export function attachNavaraAnnotations( return annotations; } -/** UTILITIES ▸ Draw on this Navara scene; null while it has none. */ -export function attachNavaraDraw() { - return null; +/** + * UTILITIES ▸ Draw on this Navara scene: the session's tool on the engine's + * input and preview (src/annotations/drawInputNavara.js). A switch ends an + * open sketch and keeps the chosen shape and colour. Returns the tool (null + * on a page without it or without the board). + */ +export function attachNavaraDraw( + { engine }, + { layerSession, annotations, styleManager }, + { defer }, +) { + if (!annotations) return null; + const drawTool = sessionDrawTool({ + layerSession, + annotations, + styleManager, + defer, + }); + drawTool?.attachScene({ + surface: navaraDrawSurface(engine), + navigation: styleManager, + }); + defer(() => drawTool?.detachScene()); + return drawTool; } /**