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.
This commit is contained in:
German Khokhlov committed 2026-11-10 14:03:35 +04:00
1 parent 2a761aa5eb
commit a913e5466e
4 files changed
+243 -14

No files matched your search

+24 -1
View File
@@ -1,6 +1,6 @@
# KNOWN ISSUES # KNOWN ISSUES
Updated: September 22, 2026 Updated: November 10, 2026
This file tracks active runtime issues only. This file tracks active runtime issues only.
@@ -10,6 +10,29 @@ For the roadmap and open backlog, see the repository issue tracker.
## Open ## 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 ### Orbital cockpit is unavailable on Navara
Status: Open (Cesium only) Status: Open (Cesium only)
+34 -9
View File
@@ -41,11 +41,13 @@ const ICON_PIXEL_BUDGET = 16 * 1024 * 1024;
const OVERLAY_PIXEL_BUDGET = 16 * 1024 * 1024; const OVERLAY_PIXEL_BUDGET = 16 * 1024 * 1024;
// The longest side of a tinted or turned ground overlay's canvas. // The longest side of a tinted or turned ground overlay's canvas.
const MAX_OVERLAY_SIDE = 4096; const MAX_OVERLAY_SIDE = 4096;
// earcut runs on the main thread and grows quadratically with holes and // Bound main-thread triangulation stalls and the number of raised outlines.
// vertices: a raised polygon past these draws no fill (past the rings, not // Median polygonShape times (three runs on a fast laptop): 32,768 vertices
// even its outlines, one line each). // 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_RINGS = 128;
const MAX_RAISED_VERTICES = 4096; const MAX_RAISED_VERTICES = 32_768;
let serial = 0; let serial = 0;
// Each engine's imported point sets, left out of each other's ground. // Each engine's imported point sets, left out of each other's ground.
const pointSets = new WeakMap(); const pointSets = new WeakMap();
@@ -192,9 +194,11 @@ const lookKey = (icon, depth) =>
* @param {import('../globe/contract.js').GlobeEngine} engine * @param {import('../globe/contract.js').GlobeEngine} engine
* @param {import('./geoImportRecords.js').ImportedRecords} records * @param {import('./geoImportRecords.js').ImportedRecords} records
* @param {{ visible?: boolean, loadImage?: (url: string) => Promise<object>, * @param {{ visible?: boolean, loadImage?: (url: string) => Promise<object>,
* 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 * `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, * @returns {Promise<{ report: { drawn: number, undrawn: number,
* kinds: Object<string, number> }, setShow: (visible: boolean) => void, * kinds: Object<string, number> }, setShow: (visible: boolean) => void,
* refreshGround: (signal?: AbortSignal) => Promise<void>, * refreshGround: (signal?: AbortSignal) => Promise<void>,
@@ -209,6 +213,9 @@ export async function drawImportedFile(
loadImage = imageFromUrl, loadImage = imageFromUrl,
createCanvas = () => document.createElement('canvas'), createCanvas = () => document.createElement('canvas'),
pixelRatio = Math.max(1, Math.min(2, globalThis.devicePixelRatio || 1)), pixelRatio = Math.max(1, Math.min(2, globalThis.devicePixelRatio || 1)),
now = () => performance.now(),
signal,
isCurrent = () => true,
} = {}, } = {},
) { ) {
const prefix = `geo-import-${++serial}`; const prefix = `geo-import-${++serial}`;
@@ -225,6 +232,12 @@ export async function drawImportedFile(
const looks = new Map(); const looks = new Map();
let drawn = 0; let drawn = 0;
function checkActive() {
signal?.throwIfAborted();
if (destroyed || !isCurrent())
throw new Error('The imported file was removed');
}
function release() { function release() {
destroyed = true; destroyed = true;
removeCull?.(); removeCull?.();
@@ -242,9 +255,16 @@ export async function drawImportedFile(
} }
try { try {
checkActive();
// Fills under ground lines; a plain-colour overlay is a fill too. // Fills under ground lines; a plain-colour overlay is a fill too.
const fills = []; const fills = [];
let lastYield = now();
for (const polygon of records.polygons) { for (const polygon of records.polygons) {
if (now() - lastYield > 8) {
await new Promise((resolve) => setTimeout(resolve, 0));
checkActive();
lastYield = now();
}
let any = false; let any = false;
if (polygon.raised && polygon.rings.length > MAX_RAISED_RINGS) { if (polygon.raised && polygon.rings.length > MAX_RAISED_RINGS) {
kinds.complexPolygon = (kinds.complexPolygon ?? 0) + 1; kinds.complexPolygon = (kinds.complexPolygon ?? 0) + 1;
@@ -321,6 +341,7 @@ export async function drawImportedFile(
overlaySide, overlaySide,
overlayCache, overlayCache,
); );
checkActive();
if (!prepared) { if (!prepared) {
kinds.rotatedOverlay = (kinds.rotatedOverlay ?? 0) + 1; kinds.rotatedOverlay = (kinds.rotatedOverlay ?? 0) + 1;
continue; continue;
@@ -332,9 +353,11 @@ export async function drawImportedFile(
image = await loadImage(overlay.url); image = await loadImage(overlay.url);
overlayImages.set(overlay.url, image); overlayImages.set(overlay.url, image);
} catch { } catch {
checkActive();
kinds.raisedOverlay = (kinds.raisedOverlay ?? 0) + 1; kinds.raisedOverlay = (kinds.raisedOverlay ?? 0) + 1;
continue; continue;
} }
checkActive();
} }
const { west, south, east, north } = prepared.rectangle; const { west, south, east, north } = prepared.rectangle;
const shape = rectangleShape([west, south, east, north], raise); const shape = rectangleShape([west, south, east, north], raise);
@@ -477,7 +500,7 @@ export async function drawImportedFile(
return; return;
} }
} }
if (destroyed) return; checkActive();
const { canvas, side } = composeIcon(look.icon, { const { canvas, side } = composeIcon(look.icon, {
image, image,
createCanvas, createCanvas,
@@ -502,11 +525,13 @@ export async function drawImportedFile(
drawn += look.pins.length; drawn += look.pins.length;
}), }),
); );
if (destroyed) throw new Error('The imported file was removed'); checkActive();
for (let from = 0; from < pins.length; from += BATCH) { for (let from = 0; from < pins.length; from += BATCH) {
settle(heightContext(), from, from + BATCH); settle(heightContext(), from, from + BATCH);
if (from + BATCH < pins.length) if (from + BATCH < pins.length) {
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
checkActive();
}
} }
for (const look of looks.values()) writePins(look); for (const look of looks.values()) writePins(look);
syncCull(); syncCull();
+179 -3
View File
@@ -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( displayTest(
'a raised polygon with 10 000 holes does not block the import', 'a raised polygon with 10 000 holes does not block the import',
async ({ engine }) => { async ({ engine }) => {
@@ -452,11 +519,11 @@ for (const make of GROUND_HARNESSES) {
); );
displayTest( 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 }) => { async ({ engine }) => {
const records = emptyRecords(); const records = emptyRecords();
const outer = Array.from({ length: 5000 }, (_, i) => { const outer = Array.from({ length: 40_000 }, (_, i) => {
const angle = (i * 2 * Math.PI) / 5000; const angle = (i * 2 * Math.PI) / 40_000;
return [10 * Math.cos(angle), 10 * Math.sin(angle), 1000]; return [10 * Math.cos(angle), 10 * Math.sin(angle), 1000];
}); });
records.polygons.push({ 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( displayTest(
'a polygon with a degenerate hole draws its outer ring', 'a polygon with a degenerate hole draws its outer ring',
async ({ engine }) => { async ({ engine }) => {
+6 -1
View File
@@ -26,7 +26,8 @@ function localizedValues(values) {
* @param {{load?: Function, drawFile?: Function, document?: Document, * @param {{load?: Function, drawFile?: Function, document?: Document,
* publish?: Function, connect?: Function}} deps * publish?: Function, connect?: Function}} deps
* `load(file, {color, signal})` defaults to `loadGeoFile` (the file's * `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, * `drawImportedFile` (their drawing: { report, setShow, refreshGround,
* footprint, destroy }); tests pass doubles. `publish({rows, hint})` hands the file * footprint, destroy }); tests pass doubles. `publish({rows, hint})` hands the file
* list and hint to the view that renders them. `connect(actions)` answers * list and hint to the view that renders them. `connect(actions)` answers
@@ -252,9 +253,13 @@ export function createGeoFileImport({
try { try {
item.drawing = await drawFile(attached.engine, loaded.records, { item.drawing = await drawFile(attached.engine, loaded.records, {
visible: item.visible, visible: item.visible,
signal: attached.controller.signal,
isCurrent: () => !destroyed && scene === attached,
}); });
} catch (error) { } catch (error) {
undraw(item); undraw(item);
// A draw cut short by a scene change is not a file error.
if (destroyed || scene !== attached) return false;
throw error; throw error;
} }
if (destroyed || scene !== attached) { if (destroyed || scene !== attached) {