Compare commits

...
Author SHA1 Message Date
Sameh Khamis 0600d86584 docs(changelog): Draw hands one shape back (#1641) 2026-11-09 07:16:52 -08:00
Sameh Khamis 4da477a654 feat(draw): hand one drawn area or line back to the application
createApplicationTools returns drawShape({ shape, signal }): it opens Draw
for one area or line and resolves with its Polygon or LineString without
placing a mark, or null when draw mode is left, the signal aborts, a newer
request replaces it, the pointer is held elsewhere or the tool goes. The
shape controls cannot change the asked-for shape, and a shape across the
antimeridian is refused as a row's actions refuse it and drawn again. It
also returns pointerFree, so an application's own click handlers yield to a
tool holding the pointer, as the layers' do.
2026-11-09 07:16:14 -08:00
5 changed files with 237 additions and 2 deletions

No files matched your search

+5
View File
@@ -2,6 +2,11 @@
## [Unreleased]
- Applications can ask Draw for one area or line and get its geometry back
without placing a mark (`drawShape` from `createApplicationTools`), and their
own click handlers can yield while a tool holds the pointer, as the layers'
do (`pointerFree`) (samehkhamis, #1641).
## [0.3.0] — 2026-10-27 — Street Level, On Your Phone, in Your Language
- Show a Street Level view facing 359.5° or more as 0°. The viewer caption
+1
View File
@@ -6311,6 +6311,7 @@ request viewbox and continues searching without that geographic preference.
- UTILITIES ▸ **Draw** turns the globe into a whiteboard drawn by hand. Pick a shape (icon buttons for Area, Line and Pin) and a colour swatch for the next shape, click the vertices on the real world, and finish. **Keyboard contract, live only while Draw is on and never while you are typing in another field:** Enter finishes the shape (as does a double-click, and the two cannot both submit it — the session is replaced before the annotate call is awaited); Backspace removes the last vertex; Esc cancels the shape in progress, and a second Esc — Esc on an empty session — leaves draw mode. **Clear** wipes the board (the shape in progress and every placed mark), so removing marks does not require the console or the microphone.
- **Drawn shapes are listed under Draw like Import's files** (`src/ui/views/panels/DrawMarkList.vue`, rows worded by `presentDrawMarks` in `src/annotations/drawMarks.js`): one row per hand-drawn mark (`manual: true`; voice marks are not listed) with its colour swatch, its name, and show/hide, fly-to and remove. The draw tool publishes the rows with the rest of its controls and answers the row actions (`renameDrawMark`, `recolorDrawMark`, `toggleDrawMark`, `flyToDrawMark`, `removeDrawMark`) through the display store; it republishes on the engine's `onChange`, so a voice clear or an expiry updates the list. Rows are keyed and updated in place: a board change never takes a name being typed (the field keeps its draft while focused) or the button under a click. The name is saved on Enter or on leaving the field, once; Esc restores it, before anything else and with the row's colours open too. Both keep focus in the field, so neither drops it to the page or leaves Draw. The swatch opens the five colours right after it (shown at the end of the row), with the current colour focused; the arrows, Home and End try another, and a click or Esc closes them and returns focus to the swatch. Both colour groups (the next shape's and a row's) are one `DrawSwatches` radio group with a single tab stop, coloured from `DRAW_COLOR_CSS`; on touch screens each dot answers a 24 px target. An unnamed shape shows its kind, and an unnamed line its length; a renamed line keeps its length (`Ridge walk — 950 m`). **Hidden** keeps a mark on the board at alpha 0, and the world renderer takes every one of its entities out (`show = false`), so no material that keeps its own colour (the line's flowing stroke) still paints it; the screen renderer does not project a hidden callout, so it cannot push a visible label aside; `fly_route` skips a hidden route; and with every mark hidden the annotations render hold is released. **Fly to** is an immediate navigation (`runImmediateNavigation('drawing', …)`), like Import's: Cockpit refuses it and a running orbit, follow or track lets go first. A line is framed by its whole path, across the antimeridian too.
- Runtime entry: `createApplicationTools` (`src/app/tools.js`, behind the `src/standalone/tools.js` shim) calls `initDrawTool({ viewer, annotations, navigation, publish, connect, markActions })` after `initAnnotations` and defers its `destroy()`, so disposing the shell returns every DOM listener, the Cesium handler, the preview data source, the borrowed click actions and the `window.__gevDrawTool` handle. Its controls are Vue views in the UTILITIES panel (`DrawControls.vue`, `DrawMarkList.vue`) that render what the tool publishes to the display store and call its actions. That handle (`setActive`, `setShape`, `addVertex(lon, lat)`, `finish`, `cancel`, `clearAll`, `marks`, `setColor`, `diagnostics`) is the no-mouse test seam.
- **One shape, handed back.** `createApplicationTools` also returns `drawShape({ shape, signal })` (`drawTool.drawShape`): it opens draw mode for one `area` or `line` and resolves with its geometry, a closed `[lon, lat]` `Polygon` or a `LineString`, without placing a mark; the shape chosen before comes back and draw mode ends. While it is open the controls cannot change the shape, and one across the antimeridian is refused as a row's actions refuse it ("That shape could not be placed.") and drawn again. It resolves `null` when the drawing is cancelled (the second Escape), draw mode is left, `signal` aborts, another request replaces it, the pointer is held elsewhere, the tool is destroyed, or the page has no Draw controls. It returns `pointerFree` (`data/inputOwnership.js` `isPointerFree`) too, so an application's own click handlers yield to the tool holding the pointer, as the layers' do.
- **Row actions are an application's own.** `createApplicationTools({ drawMarkActions })` hands `initDrawTool` buttons for drawn areas' and lines' rows; the standalone shell passes none, so the open-source build shows no new button and makes no new request. Each action (`drawMarkActions` in `src/annotations/drawMarks.js`) is `{ id, icon, label(name), shapes: ['area', 'line'], accepts?(geometry, shape), run({ id, shape, name, title, geometry }) }`: the icon a Material Symbols name, the label worded by the application, `name` the person's own and `title` the row's (the name, else `Area` or `Line · 950 m`), the geometry `drawMarkGeometry`'s `[lon, lat]` `Polygon` (closed; a `MultiPolygon` for an area handle with several parts) or `LineString`, longitudes wrapped into [-180, 180]. A pin, and a shape that crosses the antimeridian or has a part that cannot be given whole, has no geometry and is offered nothing. Malformed actions and repeated ids are dropped once, at construction. What `run` returns or resolves to is a note under the row (`role=status`; past 200 characters it is cut at a word with an ellipsis); the row is busy (`aria-disabled`, no second press) until it settles, a rejection or a throw clears the note and is logged (so an action words its own refusals and resolves with them), and a shape's note goes with the shape, so a late answer never lands on a removed one.
- Contract: a finished shape is submitted through the SAME `annotationEngine.annotate()` the voice agent uses, with geometry supplied and `manual: true` (`type: area` + `ring`, `type: route` + `path`, `type: pin` + coordinates). `resolveSpec` short-circuits manual specs (`resolveManualSpec`) so no geocoder, Places or Overpass call is made. A drawn area is a real (not synthesized) outline and drapes solid; a drawn line is a `route` with `mode: 'manual'`, labelled with its length and never a travel time; a pin is a point. Drawn marks therefore render with the whiteboard look, persist, appear in `.list()`, and clear with `clear_annotations` / `.clear()`. A drawn shape is never merged with another as a duplicate: every one is drawn on purpose, and every one is unnamed until its row names it, so two pins dropped close together are two pins.
- **A vertex is a lon/lat, and the mark is DRAPED.** Clicks are picked through `pickWorldFromScreen` — one of two scene helpers the annotation resolver factory (`src/annotations/resolver.js`) exposes alongside `sampleGroundHeight`, so the app has ONE depth-aware pick cascade rather than a second written beside it — which returns the height the pick landed on as well as the coordinate, so the live preview hangs on the roof or hillside under the pointer instead of sinking to sea level. The world renderer draws areas and routes with `clampToGround`, so the height is dropped at finish in one place (`finishSpec`) rather than being carried downstream and discarded silently: what you get is the outline you drew, lying on the surface beneath it.
+65 -1
View File
@@ -54,6 +54,7 @@ import {
DRAW_COLORS,
DRAW_COLOR_CSS,
drawMarkActions,
drawMarkGeometry,
drawMarkActionsFor,
drawMarkFallbackName,
presentDrawMarks,
@@ -120,6 +121,9 @@ export function initDrawTool({
let savedDoubleClick = null;
let savedSingleClick = null;
let cursor = null; // last mouse position on the canvas, for the rubber band
// One shape asked for by `drawShape`: `{ resolve, restoreShape, off }`. Its
// finish hands the geometry back instead of placing a mark.
let request = 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.
@@ -280,6 +284,20 @@ export function initDrawTool({
// Named afterwards, in the shape's row.
const spec = finishSpec(session, { label: '', color });
if (!spec) return null;
if (request) {
// As a row's actions get it: a shape across the antimeridian has none,
// and is drawn again rather than handed back wrapped round the world.
const geometry = drawMarkGeometry(spec);
if (!geometry) {
session = createDrawSession(shape);
cursor = null;
syncPreview();
setHint(() => t('That shape could not be placed.'));
return null;
}
endRequest(geometry);
return geometry;
}
// Start a fresh session BEFORE awaiting, so a second Enter or the second
// half of a double-click cannot submit the same shape twice.
session = createDrawSession(shape);
@@ -407,6 +425,7 @@ export function initDrawTool({
generation += 1;
session = null;
cursor = null;
endRequest(null);
releaseSceneHandler();
releasePointer(lease);
lease = null;
@@ -532,6 +551,46 @@ export function initDrawTool({
return true;
}
/**
* Close the asked-for shape with `geometry` (null when there is none): the
* shape chosen before it comes back, and draw mode ends.
*/
function endRequest(geometry) {
const open = request;
if (!open) return;
request = null;
open.off();
chooseShape(open.restoreShape);
open.resolve(geometry);
if (active) setActive(false);
}
/**
* Draw one area or line and hand back its geometry (a Polygon or a
* LineString) without placing a mark. Escape works as in Draw: the first
* clears a shape in progress, and one on an empty shape leaves. Resolves null
* when draw mode is left, `signal` aborts, or the pointer is taken.
* A second request ends the first with null.
* @param {{shape?: 'area'|'line', signal?: AbortSignal}} [options]
* @returns {Promise<{type: 'Polygon', coordinates: number[][][]}|{type: 'LineString', coordinates: number[][]}|null>}
*/
function drawShape({ shape: wanted = 'area', signal } = {}) {
if (destroyed || signal?.aborted) return Promise.resolve(null);
endRequest(null);
const restoreShape = shape;
return new Promise((resolve) => {
const abort = () => endRequest(null);
signal?.addEventListener('abort', abort, { once: true });
request = {
resolve,
restoreShape,
off: () => signal?.removeEventListener('abort', abort),
};
chooseShape(normalizeShape(wanted) === 'line' ? 'line' : 'area');
if (!active && !setActive(true)) endRequest(null);
});
}
/** Draw `next` from now on; an open shape starts over in it. */
function chooseShape(next) {
shape = normalizeShape(next);
@@ -545,7 +604,10 @@ export function initDrawTool({
}
const disconnect = connect({
toggleDraw: () => setActive(!active),
setDrawShape: chooseShape,
// An asked-for shape is the one asked for.
setDrawShape: (next) => {
if (!request) chooseShape(next);
},
setDrawColor: (value) => {
color = DRAW_COLORS.includes(value) ? value : 'primary';
present();
@@ -602,6 +664,7 @@ export function initDrawTool({
finish,
cancel,
clearAll,
drawShape,
shapes: DRAW_SHAPES,
/** What this tool currently holds — for teardown and leak assertions. */
diagnostics() {
@@ -638,6 +701,7 @@ export function initDrawTool({
if (destroyed) return attaching;
// Supersede any finish still in flight before anything is torn down.
generation += 1;
endRequest(null);
if (active) setActive(false);
destroyed = true;
offLocale();
+152
View File
@@ -704,6 +704,158 @@ test('what a row action answers is its row\'s note; the row is busy until then,
}
});
test('drawShape hands back the drawn area as a polygon and places no mark', async () => {
const h = harness();
try {
h.act('setDrawShape', 'line');
const drawn = h.tool.drawShape({ shape: 'area' });
assert.equal(h.tool.active, true, 'it opens draw mode');
assert.equal(h.tool.shape, 'area');
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
h.tool.addVertex(-97.74, 30.27);
h.documentDouble.emit('keydown', {
key: 'Enter',
target: h.documentDouble.body,
});
const geometry = await drawn;
assert.deepEqual(geometry, {
type: 'Polygon',
coordinates: [
[
[-97.75, 30.26],
[-97.74, 30.26],
[-97.74, 30.27],
[-97.75, 30.26],
],
],
});
assert.equal(h.annotations.calls.annotate.length, 0, 'no mark is placed');
assert.equal(h.tool.active, false, 'draw mode ends');
assert.equal(isPointerFree(), true);
assert.equal(h.tool.shape, 'line', 'the shape chosen before comes back');
} finally {
h.restore();
}
});
test('drawShape hands back a line as a LineString', async () => {
const h = harness();
try {
const drawn = h.tool.drawShape({ shape: 'line' });
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
h.documentDouble.emit('keydown', {
key: 'Enter',
target: h.documentDouble.body,
});
assert.deepEqual(await drawn, {
type: 'LineString',
coordinates: [
[-97.75, 30.26],
[-97.74, 30.26],
],
});
} finally {
h.restore();
}
});
test('drawShape resolves null on Escape, on abort, on a newer request and when the pointer is taken', async () => {
const h = harness();
try {
// Escape with a shape started clears it; a second Escape leaves.
const escaped = h.tool.drawShape({ shape: 'area' });
h.tool.addVertex(-97.75, 30.26);
h.documentDouble.emit('keydown', {
key: 'Escape',
target: h.documentDouble.body,
});
assert.equal(h.tool.active, true, 'the first Escape only clears the shape');
h.documentDouble.emit('keydown', {
key: 'Escape',
target: h.documentDouble.body,
});
assert.equal(await escaped, null);
assert.equal(h.tool.active, false);
const controller = new AbortController();
const aborted = h.tool.drawShape({
shape: 'area',
signal: controller.signal,
});
controller.abort();
assert.equal(await aborted, null);
assert.equal(isPointerFree(), true, 'an abort gives the pointer back');
const first = h.tool.drawShape({ shape: 'area' });
const second = h.tool.drawShape({ shape: 'line' });
assert.equal(await first, null, 'a newer request ends the older one');
h.act('toggleDraw');
assert.equal(await second, null, 'leaving draw mode ends it');
const done = new AbortController();
done.abort();
assert.equal(await h.tool.drawShape({ signal: done.signal }), null);
const other = claimPointer('directions');
assert.equal(
await h.tool.drawShape({ shape: 'area' }),
null,
'a held pointer refuses it',
);
releasePointer(other);
// Drawing by hand afterwards places marks as before.
h.act('toggleDraw');
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
h.tool.addVertex(-97.74, 30.27);
h.documentDouble.emit('keydown', {
key: 'Enter',
target: h.documentDouble.body,
});
await Promise.resolve();
assert.equal(h.annotations.calls.annotate.length, 1);
} finally {
h.restore();
}
});
test('drawShape keeps its shape, and a shape across the antimeridian is drawn again', async () => {
const h = harness();
try {
const drawn = h.tool.drawShape({ shape: 'line' });
h.act('setDrawShape', 'pin');
assert.equal(h.tool.shape, 'line', 'the controls cannot change the asked-for shape');
h.tool.addVertex(179.5, 10);
h.tool.addVertex(-179.5, 10);
h.documentDouble.emit('keydown', { key: 'Enter', target: h.documentDouble.body });
assert.equal(h.tool.active, true, 'still drawing');
assert.equal(h.tool.session.vertices.length, 0, 'drawn again from the start');
assert.equal(h.shown.view.hint, 'That shape could not be placed.');
h.tool.addVertex(29, 41);
h.tool.addVertex(29.1, 41);
h.documentDouble.emit('keydown', { key: 'Enter', target: h.documentDouble.body });
assert.equal((await drawn).type, 'LineString');
h.act('setDrawShape', 'pin');
assert.equal(h.tool.shape, 'pin', 'after it, the controls choose again');
} finally {
h.restore();
}
});
test('destroy ends a drawShape request with null', async () => {
const h = harness();
try {
const drawn = h.tool.drawShape({ shape: 'area' });
h.tool.destroy();
assert.equal(await drawn, null);
} finally {
h.restore();
}
});
test('Backspace on the page removes the last vertex', () => {
const h = harness();
try {
+14 -1
View File
@@ -1,6 +1,7 @@
import { SceneDirector } from '../scenes/director.js';
import { initAnnotations } from '../annotations/index.js';
import { initDrawTool } from '../annotations/drawTool.js';
import { isPointerFree } from '../data/inputOwnership.js';
import { initGeoFileImport } from '../ui/geoFileImport.js';
import { useDisplayStore } from '../ui/stores/display.js';
import { applicationPinia } from '../ui/vueRuntime.js';
@@ -245,5 +246,17 @@ export function createApplicationTools({
signal,
}),
);
return { sceneDirector, annotations, voiceCommands };
// Draw one area or line and get its geometry back, without placing a mark
// (annotations/drawTool.js `drawShape`); null without the Draw controls.
const drawShape = (options) =>
drawTool ? drawTool.drawShape(options) : Promise.resolve(null);
// An application's own click handlers yield while a tool holds the pointer,
// as the layers' do (data/inputOwnership.js).
return {
sceneDirector,
annotations,
voiceCommands,
drawShape,
pointerFree: isPointerFree,
};
}