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

+34 -9
View File
@@ -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<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
* 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<string, number> }, setShow: (visible: boolean) => void,
* refreshGround: (signal?: AbortSignal) => Promise<void>,
@@ -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();
+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(
'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 }) => {
+6 -1
View File
@@ -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) {