mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 13:52:19 +00:00
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:
1 parent
2a761aa5eb
commit
a913e5466e
4 files changed
+243
-14
No files matched your search
+24
-1
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user