From a913e5466e080ad894b81dab2a965ea9252e65fa Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 14:03:35 +0400 Subject: [PATCH] Fill raised KML polygons up to 32,768 vertices A raised polygon past 4,096 vertices lost its fill, which main drew. Triangulating 32,768 vertices takes about 58 ms on a fast laptop, so the fill limit moves there; 128 rings stay the hole limit, since holes make earcut quadratic. The polygon loop yields to the event loop after 8 ms of work, and the import cancels a draw whose globe went away while it yielded, without reporting the file as unreadable. KNOWN-ISSUES names the fill limit and two Navara limits: an expired terrain token is not refreshed while the page is open, and a failing map source does not fall back to another map. --- docs/KNOWN-ISSUES.md | 25 +++- src/data/geoImportDisplay.js | 43 +++++-- src/data/geoImportDisplay.test.mjs | 182 ++++++++++++++++++++++++++++- src/ui/geoFileImport.js | 7 +- 4 files changed, 243 insertions(+), 14 deletions(-) diff --git a/docs/KNOWN-ISSUES.md b/docs/KNOWN-ISSUES.md index 617342f6b..1ed1f74d9 100644 --- a/docs/KNOWN-ISSUES.md +++ b/docs/KNOWN-ISSUES.md @@ -1,6 +1,6 @@ # KNOWN ISSUES -Updated: September 22, 2026 +Updated: November 10, 2026 This file tracks active runtime issues only. @@ -10,6 +10,29 @@ For the roadmap and open backlog, see the repository issue tracker. ## Open +### Large raised KML polygons omit their fill + +Status: Open (bounded triangulation) + +On both engines, raised polygons with more than 32,768 vertices draw only +their outlines. Those with more than 128 rings draw neither fill nor +outlines. These limits bound main-thread triangulation and outline work; +the omitted geometry is counted as undrawn in the import result. + +### Navara terrain tokens are not refreshed while the page stays open + +Status: Open (reload required) + +An expired terrain token is not refreshed during an open Navara session. +Reload the page to request a new terrain token. + +### Navara does not fall back when a map source fails + +Status: Open (manual map selection) + +Navara does not report tile errors, so a failing map source does not trigger +fallback to another map. Select another map manually when tiles fail to load. + ### Orbital cockpit is unavailable on Navara Status: Open (Cesium only) diff --git a/src/data/geoImportDisplay.js b/src/data/geoImportDisplay.js index 1a7b22235..2d912a529 100644 --- a/src/data/geoImportDisplay.js +++ b/src/data/geoImportDisplay.js @@ -41,11 +41,13 @@ const ICON_PIXEL_BUDGET = 16 * 1024 * 1024; const OVERLAY_PIXEL_BUDGET = 16 * 1024 * 1024; // The longest side of a tinted or turned ground overlay's canvas. const MAX_OVERLAY_SIDE = 4096; -// earcut runs on the main thread and grows quadratically with holes and -// vertices: a raised polygon past these draws no fill (past the rings, not -// even its outlines, one line each). +// Bound main-thread triangulation stalls and the number of raised outlines. +// Median polygonShape times (three runs on a fast laptop): 32,768 vertices +// took 58 ms (37 ms in 128 rings), versus 198 ms for 65,536 vertices. +// Holes make earcut quadratic: 1,000 took 110 ms and 10,000 took 4.8 s. +// Past the vertex limit only outlines draw; past the ring limit nothing draws. const MAX_RAISED_RINGS = 128; -const MAX_RAISED_VERTICES = 4096; +const MAX_RAISED_VERTICES = 32_768; let serial = 0; // Each engine's imported point sets, left out of each other's ground. const pointSets = new WeakMap(); @@ -192,9 +194,11 @@ const lookKey = (icon, depth) => * @param {import('../globe/contract.js').GlobeEngine} engine * @param {import('./geoImportRecords.js').ImportedRecords} records * @param {{ visible?: boolean, loadImage?: (url: string) => Promise, - * createCanvas?: () => object, pixelRatio?: number }} [options] + * createCanvas?: () => object, pixelRatio?: number, now?: () => number, + * signal?: AbortSignal, isCurrent?: () => boolean }} [options] * `loadImage` and `createCanvas` are test seams (icon decoding and the - * sprite canvases). + * sprite canvases). `now` measures polygon work in milliseconds; + * `signal` and `isCurrent` cancel work when the draw loses its owner. * @returns {Promise<{ report: { drawn: number, undrawn: number, * kinds: Object }, setShow: (visible: boolean) => void, * refreshGround: (signal?: AbortSignal) => Promise, @@ -209,6 +213,9 @@ export async function drawImportedFile( loadImage = imageFromUrl, createCanvas = () => document.createElement('canvas'), pixelRatio = Math.max(1, Math.min(2, globalThis.devicePixelRatio || 1)), + now = () => performance.now(), + signal, + isCurrent = () => true, } = {}, ) { const prefix = `geo-import-${++serial}`; @@ -225,6 +232,12 @@ export async function drawImportedFile( const looks = new Map(); let drawn = 0; + function checkActive() { + signal?.throwIfAborted(); + if (destroyed || !isCurrent()) + throw new Error('The imported file was removed'); + } + function release() { destroyed = true; removeCull?.(); @@ -242,9 +255,16 @@ export async function drawImportedFile( } try { + checkActive(); // Fills under ground lines; a plain-colour overlay is a fill too. const fills = []; + let lastYield = now(); for (const polygon of records.polygons) { + if (now() - lastYield > 8) { + await new Promise((resolve) => setTimeout(resolve, 0)); + checkActive(); + lastYield = now(); + } let any = false; if (polygon.raised && polygon.rings.length > MAX_RAISED_RINGS) { kinds.complexPolygon = (kinds.complexPolygon ?? 0) + 1; @@ -321,6 +341,7 @@ export async function drawImportedFile( overlaySide, overlayCache, ); + checkActive(); if (!prepared) { kinds.rotatedOverlay = (kinds.rotatedOverlay ?? 0) + 1; continue; @@ -332,9 +353,11 @@ export async function drawImportedFile( image = await loadImage(overlay.url); overlayImages.set(overlay.url, image); } catch { + checkActive(); kinds.raisedOverlay = (kinds.raisedOverlay ?? 0) + 1; continue; } + checkActive(); } const { west, south, east, north } = prepared.rectangle; const shape = rectangleShape([west, south, east, north], raise); @@ -477,7 +500,7 @@ export async function drawImportedFile( return; } } - if (destroyed) return; + checkActive(); const { canvas, side } = composeIcon(look.icon, { image, createCanvas, @@ -502,11 +525,13 @@ export async function drawImportedFile( drawn += look.pins.length; }), ); - if (destroyed) throw new Error('The imported file was removed'); + checkActive(); for (let from = 0; from < pins.length; from += BATCH) { settle(heightContext(), from, from + BATCH); - if (from + BATCH < pins.length) + if (from + BATCH < pins.length) { await new Promise((resolve) => setTimeout(resolve, 0)); + checkActive(); + } } for (const look of looks.values()) writePins(look); syncCull(); diff --git a/src/data/geoImportDisplay.test.mjs b/src/data/geoImportDisplay.test.mjs index 2a3cd7cea..6cc3806be 100644 --- a/src/data/geoImportDisplay.test.mjs +++ b/src/data/geoImportDisplay.test.mjs @@ -412,6 +412,73 @@ for (const make of GROUND_HARNESSES) { }, ); + displayTest( + 'a raised polygon with 20 000 vertices keeps its fill', + async ({ engine }) => { + const records = emptyRecords(); + const outer = Array.from({ length: 20_000 }, (_, i) => { + const angle = (i * 2 * Math.PI) / 20_000; + return [10 * Math.cos(angle), 10 * Math.sin(angle), 1000]; + }); + records.polygons.push({ + rings: [outer], + raised: true, + fill: [1, 0, 0, 1], + outline: { color: [1, 1, 1, 1], widthPx: 1 }, + }); + const drawing = await drawImportedFile(engine, records, { createCanvas }); + assert.deepEqual(drawing.report, { drawn: 1, undrawn: 0, kinds: {} }); + assert.equal(engine.meshes.list().length, 1); + assert.equal(engine.lines.list().length, 1); + drawing.destroy(); + }, + ); + + for (const rings of [128, 200]) + displayTest( + `a raised polygon with ${rings} rings respects the ring bound`, + async ({ engine }) => { + const records = emptyRecords(); + const outer = [ + [0, 0, 1000], + [10, 0, 1000], + [10, 10, 1000], + [0, 10, 1000], + ]; + const holes = Array.from({ length: rings - 1 }, (_, i) => { + const [x, y, d] = [ + 0.1 + (i % 20) * 0.4, + 0.1 + Math.floor(i / 20) * 0.4, + 0.1, + ]; + return [ + [x, y, 1000], + [x + d, y, 1000], + [x + d, y + d, 1000], + [x, y + d, 1000], + ]; + }); + records.polygons.push({ + rings: [outer, ...holes], + raised: true, + fill: [1, 0, 0, 1], + outline: { color: [1, 1, 1, 1], widthPx: 1 }, + }); + const drawing = await drawImportedFile(engine, records, { + createCanvas, + }); + assert.deepEqual( + drawing.report, + rings === 128 + ? { drawn: 1, undrawn: 0, kinds: {} } + : { drawn: 0, undrawn: 1, kinds: { complexPolygon: 1 } }, + ); + assert.equal(engine.meshes.list().length, rings === 128 ? 1 : 0); + assert.equal(engine.lines.list().length, rings === 128 ? rings : 0); + drawing.destroy(); + }, + ); + displayTest( 'a raised polygon with 10 000 holes does not block the import', async ({ engine }) => { @@ -452,11 +519,11 @@ for (const make of GROUND_HARNESSES) { ); displayTest( - 'a raised fill past the vertex bound is counted, its outline drawn', + 'a raised fill with 40 000 vertices is counted, its outline drawn', async ({ engine }) => { const records = emptyRecords(); - const outer = Array.from({ length: 5000 }, (_, i) => { - const angle = (i * 2 * Math.PI) / 5000; + const outer = Array.from({ length: 40_000 }, (_, i) => { + const angle = (i * 2 * Math.PI) / 40_000; return [10 * Math.cos(angle), 10 * Math.sin(angle), 1000]; }); records.polygons.push({ @@ -477,6 +544,115 @@ for (const make of GROUND_HARNESSES) { }, ); + displayTest( + 'polygon work yields after more than 8 ms and resets the clock after yielding', + async ({ engine }) => { + const records = emptyRecords(); + records.polygons = Array.from({ length: 7 }, () => ({ + rings: [ + [ + [0, 0, 1000], + [1, 0, 1000], + [1, 1, 1000], + ], + ], + raised: true, + fill: [1, 0, 0, 1], + outline: null, + })); + let elapsed = 0; + let created = 0; + const create = engine.meshes.createMesh; + engine.meshes.createMesh = (options) => { + elapsed += 4; + created++; + return create(options); + }; + const snapshots = []; + const observe = (async () => { + for (let i = 0; i < 2; i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + snapshots.push(created); + elapsed += 100; + } + })(); + const drawing = await drawImportedFile(engine, records, { + createCanvas, + now: () => elapsed, + }); + await observe; + assert.deepEqual(snapshots, [3, 6]); + assert.equal(created, 7); + assert.equal(drawing.report.drawn, 7); + drawing.destroy(); + }, + ); + + for (const cancellation of ['aborted', 'superseded', 'removed']) + displayTest( + `a draw ${cancellation} during a polygon yield stops and releases its geometry`, + async ({ engine }) => { + const records = geoJsonRecords( + { type: 'Point', coordinates: [1, 2] }, + '#39d0ff', + ); + records.polygons = Array.from({ length: 3 }, () => ({ + rings: [ + [ + [0, 0, 1000], + [1, 0, 1000], + [1, 1, 1000], + ], + ], + raised: true, + fill: [1, 0, 0, 1], + outline: { color: [1, 1, 1, 1], widthPx: 1 }, + })); + const controller = new AbortController(); + let current = records; + const files = new Set([records]); + let elapsed = 0; + let created = 0; + const create = engine.meshes.createMesh; + engine.meshes.createMesh = (options) => { + elapsed += 9; + created++; + return create(options); + }; + let beforeCancel; + const cancel = new Promise((resolve) => + setTimeout(() => { + beforeCancel = [ + engine.meshes.list().length, + engine.lines.list().length, + ]; + if (cancellation === 'aborted') controller.abort(); + else if (cancellation === 'superseded') current = emptyRecords(); + else files.delete(records); + resolve(); + }, 0), + ); + await assert.rejects( + drawImportedFile(engine, records, { + createCanvas, + now: () => elapsed, + signal: controller.signal, + isCurrent: () => current === records && files.has(records), + }), + cancellation === 'aborted' + ? { name: 'AbortError' } + : /The imported file was removed/, + ); + await cancel; + assert.deepEqual(beforeCancel, [1, 1]); + assert.equal(created, 1); + assert.equal(engine.meshes.list().length, 0); + assert.equal(engine.lines.list().length, 0); + assert.equal(engine.ground.list().length, 0); + assert.equal(engine.movers.list().length, 0); + }, + ); + displayTest( 'a polygon with a degenerate hole draws its outer ring', async ({ engine }) => { diff --git a/src/ui/geoFileImport.js b/src/ui/geoFileImport.js index 20cf6f83e..b69da6600 100644 --- a/src/ui/geoFileImport.js +++ b/src/ui/geoFileImport.js @@ -26,7 +26,8 @@ function localizedValues(values) { * @param {{load?: Function, drawFile?: Function, document?: Document, * publish?: Function, connect?: Function}} deps * `load(file, {color, signal})` defaults to `loadGeoFile` (the file's - * plain records) and `drawFile(engine, records, {visible})` to + * plain records) and `drawFile(engine, records, {visible, signal, + * isCurrent})` to * `drawImportedFile` (their drawing: { report, setShow, refreshGround, * footprint, destroy }); tests pass doubles. `publish({rows, hint})` hands the file * list and hint to the view that renders them. `connect(actions)` answers @@ -252,9 +253,13 @@ export function createGeoFileImport({ try { item.drawing = await drawFile(attached.engine, loaded.records, { visible: item.visible, + signal: attached.controller.signal, + isCurrent: () => !destroyed && scene === attached, }); } catch (error) { undraw(item); + // A draw cut short by a scene change is not a file error. + if (destroyed || scene !== attached) return false; throw error; } if (destroyed || scene !== attached) {