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
|
# 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)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in new issue
Block a user