From e4a964375a0fa0f50a1cf58166f5a5a27fea3eaf Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:06:47 +0530 Subject: [PATCH] fix(annotations): label a 996 m line or route as 1.0 km, not 1000 m Placed route, arrow and drawn-line labels picked metres before rounding to ten metres, so anything from 995 m to 1 km read "1000 m", even where the draw hint for the same line read "1.0 km". The label now rounds first, then picks the unit, and does the same at the 10 km cut, where 9,960 m read "10.0 km". --- docs/CURRENT-STATE.md | 2 +- src/annotations/annotationEngine.js | 7 +++++-- src/annotations/annotationEngine.test.mjs | 15 +++++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c55031a6a..1b08eb7d9 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5347,7 +5347,7 @@ request viewbox and continues searching without that geographic preference. - **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. - **Draped marks classify onto `ClassificationType.BOTH`** — terrain AND 3D tiles — not onto 3D tiles alone. With only `CESIUM_3D_TILE`, every area fill, area outline, route and arrow rendered to nothing on a keyless boot, where Cesium's own globe carries Esri/OSM imagery and there are no tiles to classify against; the labels appeared and the geometry did not. That hit spoken annotations exactly as hard as hand-drawn ones. `scripts/qa-draw-tool.mjs` counts the mark's own colour in the rendered frame before and after a shape is finished, on a keyed AND a keyless server, so "the board says there is a mark" can no longer pass for "the mark is on screen". - **A finished area is a CLOSED ring.** The fill polygon closes itself but the outline beside it is a polyline, so an open ring drew every edge except the one back to the first vertex — a triangle with two sides. `finishSpec` repeats the first position at the end. A line is never closed: joining its ends would invent a segment nobody drew. -- Drawn-line distance labels select metres or kilometres after rounding to the nearest metre, so a 999.5-metre line displays as `1.0 km` rather than `1000 m`. +- Drawn-line distance labels select metres or kilometres after rounding to the nearest metre, so a 999.5-metre line displays as `1.0 km` rather than `1000 m`. The placed mark's label (shared with spoken routes and arrows) rounds before each unit cut, so a 996-metre line is labelled `1.0 km`, not `1000 m`, and a 9,960-metre one `10 km`, not `10.0 km`. - **Measurements and centres are computed on unwrapped longitudes** (`unwrapLongitudes` / `wrapLongitude` in `drawMode.js`, used by `ringAreaM2`, `ringCentroid` and the engine's manual-area anchor). A ~50,000 m² rectangle straddling the antimeridian reads its 0.002° width as 359.998° under raw arithmetic: it measured thousands of square kilometres and anchored on the Greenwich meridian, half a world from the shape. Path length was already safe — haversine is periodic in the longitude delta — and a test pins that so a "fix" cannot break it. - **Bounds.** One shape holds at most `MAX_VERTICES` = 512 points; two clicks within 0.5 m are one vertex (that is what makes a double-click finish without leaving a stray point); a vertex that is not a finite on-globe coordinate is refused. A shape that encloses nothing is refused rather than placed: three collinear points are not an area and a line under a metre long is not a line, and the hint says which. - Pointer ownership: while a session is open the tool holds the single pointer claim (`claimPointer('draw')`, `src/data/inputOwnership.js`) and **every** ambient scene selection handler — the shared tracking click gesture behind flights, military and CCTV, plus vessels, satellites, launches, fires, installations, bikeshare, radio, submarine cables, the bundled GeoJSON layers and the CCTV calibration gizmo — returns before picking. A vertex over an aircraft is a vertex, not a selection. diff --git a/src/annotations/annotationEngine.js b/src/annotations/annotationEngine.js index b0622579d..c867782c7 100644 --- a/src/annotations/annotationEngine.js +++ b/src/annotations/annotationEngine.js @@ -1573,8 +1573,11 @@ function greatCircleM(a, b) { function formatDistance(m) { if (!Number.isFinite(m)) return null; - if (m >= 1000) return `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`; - return `${Math.round(m / 10) * 10} m`; + // Round before each unit cut: 996 m was printed as "1000 m" and + // 9,960 m as "10.0 km". + const tens = Math.round(m / 10) * 10; + if (tens < 1000) return `${tens} m`; + return `${(m / 1000).toFixed(Math.round(m / 100) >= 100 ? 0 : 1)} km`; } function composeRouteLabel(baseLabel, distM, durS, mode, fallback) { diff --git a/src/annotations/annotationEngine.test.mjs b/src/annotations/annotationEngine.test.mjs index 5f39a077a..4f5804290 100644 --- a/src/annotations/annotationEngine.test.mjs +++ b/src/annotations/annotationEngine.test.mjs @@ -730,3 +730,18 @@ test('only an explicit navigation request permits resolving distant annotation t assert.deepEqual(received.map(options => options.allowDistant), [false, true]); engine.destroy(); }); + +test('a drawn line that rounds up to the next unit is labelled in it, not as 1000 m or 10.0 km', async (t) => { + installAnimationFrameStubs(t); + const labels = []; + const renderer = { add(anno) { labels.push(anno.label); }, update() {}, remove() {}, sync() {}, destroy() {} }; + const engine = createAnnotationEngine({ viewer: {}, renderer }); + const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180; + for (const metres of [994, 996, 9940, 9960]) { + await engine.annotate([ + { type: 'route', manual: true, path: [[0, 0], [0, metres / metresPerDegree]] }, + ]); + } + assert.deepEqual(labels, ['990 m', '1.0 km', '9.9 km', '10 km']); + engine.destroy(); +});