Merge branch 'german/refactor-e9-import-plan' into german/navara-refactor

This commit is contained in:
German Khokhlov committed 2026-11-10 15:27:12 +04:00
commit 49b180f1fd
32 files changed
+103263 -458

No files matched your search

+20 -19
View File
@@ -8,20 +8,19 @@ import {
} from './geoImportRecords.js';
import { msg } from '../i18n/marker.js';
import {
MAX_IMPORT_BYTES,
MAX_KMZ_INFLATED_BYTES,
MAX_IMPORT_ENTITIES,
MAX_IMPORT_POSITIONS,
MAX_KML_LINKS,
MAX_IMPORT_HEIGHT_M,
IMPORT_TIMEOUT_MS,
MAX_IMPORT_DISPLAY_NAME_LENGTH,
} from './geoImportLimits.js';
export { importFeatureName };
/** Largest file accepted, in bytes. */
const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
/** Largest total a KMZ may inflate to — a zip bomb stops here. */
const MAX_KMZ_INFLATED_BYTES = 120 * 1024 * 1024;
/** Most files on the globe at once. */
export const MAX_IMPORTED_FILES = 12;
/** Most entities one file may create before it is refused. */
export const MAX_IMPORT_ENTITIES = 20_000;
/** Most vertices (points of every line, ring and marker) in one file. */
export const MAX_IMPORT_POSITIONS = 250_000;
/** Most normalized style definitions and local image links in one KML/KMZ. */
const MAX_KML_LINKS = 2_000;
/** What the file picker offers. */
export const GEO_FILE_ACCEPT = '.geojson,.json,.kml,.kmz,.gpx';
@@ -122,7 +121,9 @@ export function importDisplayName(name) {
.replace(/[\u0000-\u001f\u007f]/g, '')
.trim();
if (!clean) return 'Imported file';
return clean.length > 60 ? `${clean.slice(0, 57)}…` : clean;
return clean.length > MAX_IMPORT_DISPLAY_NAME_LENGTH
? `${clean.slice(0, MAX_IMPORT_DISPLAY_NAME_LENGTH - 3)}…`
: clean;
}
/**
@@ -240,7 +241,7 @@ export function countGeoJsonGeometry(
!c.every(Number.isFinite) ||
Math.abs(c[0]) > 180 ||
Math.abs(c[1]) > 90 ||
(c.length === 3 && Math.abs(c[2]) > 1_000_000)
(c.length === 3 && Math.abs(c[2]) > MAX_IMPORT_HEIGHT_M)
)
throw new GeoImportError(
msg('That GeoJSON has invalid coordinates.'),
@@ -800,7 +801,7 @@ export function countKmlGeometry(
const limit = ['north', 'south'].includes(name)
? 90
: name === 'altitude'
? 1_000_000
? MAX_IMPORT_HEIGHT_M
: 360;
if (
!el.textContent.trim() ||
@@ -863,7 +864,7 @@ function checkKmlPosition(tuple) {
!numbers.every(Number.isFinite) ||
Math.abs(numbers[0]) > 180 ||
Math.abs(numbers[1]) > 90 ||
(numbers.length === 3 && Math.abs(numbers[2]) > 1_000_000)
(numbers.length === 3 && Math.abs(numbers[2]) > MAX_IMPORT_HEIGHT_M)
)
throw new GeoImportError(msg('That KML has invalid coordinates.'));
}
@@ -1123,7 +1124,7 @@ async function boundedImport(promise, signal) {
timer = setTimeout(
() =>
reject(new GeoImportError(msg('That file took too long to read.'))),
60_000,
IMPORT_TIMEOUT_MS,
);
}),
]);
@@ -1147,8 +1148,8 @@ async function boundedImport(promise, signal) {
*/
export async function loadGeoFile(file, { color = '#39d0ff', signal } = {}) {
signal = signal
? AbortSignal.any([signal, AbortSignal.timeout(60_000)])
: AbortSignal.timeout(60_000);
? AbortSignal.any([signal, AbortSignal.timeout(IMPORT_TIMEOUT_MS)])
: AbortSignal.timeout(IMPORT_TIMEOUT_MS);
signal?.throwIfAborted();
if (!file || !Number.isFinite(file.size))
throw new GeoImportError(msg('Nothing to import.'));
+11 -6
View File
@@ -6,8 +6,6 @@ import {
GeoImportError,
repairKmlRootNamespaces,
loadGeoFile,
MAX_IMPORT_POSITIONS,
MAX_IMPORT_ENTITIES,
countGeoJsonGeometry,
countKmlGeometry,
deduplicateKmlStyles,
@@ -23,6 +21,12 @@ import {
readZipEntries,
resolveKmzPath,
} from './geoFileImport.js';
import {
MAX_IMPORT_POSITIONS,
MAX_IMPORT_ENTITIES,
MAX_IMPORT_IMAGE_PIXELS,
MAX_IMPORT_IMAGE_SIDE,
} from './geoImportLimits.js';
import { createImportImageBudget, importImageSize } from './geoImportImages.js';
test('real loader grounds XYZ GeoJSON and keeps only names and footprints in its records', async () => {
@@ -167,13 +171,14 @@ test('image budget rejects large decoded rasters before decoding and shares repe
v.setUint32(20, h);
return b;
};
assert.deepEqual(importImageSize(png(4096, 4096)), [4096, 4096]);
const side = Math.sqrt(MAX_IMPORT_IMAGE_PIXELS);
assert.deepEqual(importImageSize(png(side, side)), [side, side]);
const charge = createImportImageBudget();
charge('shared', png(4096, 4096));
charge('shared', png(4096, 4096));
charge('shared', png(side, side));
charge('shared', png(side, side));
assert.throws(() => charge('other', png(1, 1)), /limit/);
assert.throws(
() => createImportImageBudget()('huge', png(16384, 16384)),
() => createImportImageBudget()('huge', png(MAX_IMPORT_IMAGE_SIDE + 1, 1)),
/limit/,
);
});
+146 -396
View File
@@ -11,7 +11,7 @@
// ignored the depth test;
// - ground overlays: image layers, or textured meshes at absolute altitudes,
// tinted and turned on a canvas when the KML says so; a plain colour is
// a ground fill.
// a ground fill or a mesh at an absolute altitude.
// Nothing is pickable: an imported file has no card. Ground heights under
// the points come from engine.surface, sampled again by refreshGround():
// the terrain where the globe is drawn, else (3D tiles) the scene drawn
@@ -19,35 +19,17 @@
// pins out of height queries).
import * as geo from '../geo/index.js';
import { normalizeGroundItem } from '../globe/ground.js';
import { polygonShape, rectangleShape } from '../scenes/dataPacks/shapes.js';
import { planImport } from './geoImportPlan.js';
import {
BATCH,
HORIZON_LIFT_M,
CLAMP_TILES_BELOW_M,
MAX_ICON_PIXEL_RATIO,
POLYGON_YIELD_MS,
} from './geoImportLimits.js';
import { composeIcon } from './geoImportPins.js';
import { recordFootprint } from './geoImportRecords.js';
import { emptyRecords, recordFootprint } from './geoImportRecords.js';
const POLYGON_ORDER = 0;
const LINE_ORDER = 1;
// Over the layers' rasters, as Cesium's ground rectangle drew over imagery.
const OVERLAY_ORDER = 30;
// The horizon test lifts each point by this much (as GhostMaps does):
// ground can lie below the ellipsoid.
const HORIZON_LIFT_M = 2000;
// Under this camera height, points on 3D tiles are clamped to the tiles
// drawn now; higher up the terrain answers (Cesium's 250 km).
const CLAMP_TILES_BELOW_M = 250_000;
const BATCH = 256;
// Canvas pixels every imported icon look shares (64 MiB of RGBA).
const ICON_PIXEL_BUDGET = 16 * 1024 * 1024;
// Canvas pixels every tinted or turned ground overlay of a file shares.
const OVERLAY_PIXEL_BUDGET = 16 * 1024 * 1024;
// The longest side of a tinted or turned ground overlay's canvas.
const MAX_OVERLAY_SIDE = 4096;
// 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 = 32_768;
let serial = 0;
// Each engine's imported point sets, left out of each other's ground.
const pointSets = new WeakMap();
@@ -64,105 +46,27 @@ const imageFromUrl = async (url) => {
return image;
};
// A ground item the contract accepts, or null (a degenerate ring or line).
function checked(kind, item) {
try {
normalizeGroundItem(kind, item);
return item;
} catch {
return null;
}
}
// A ground overlay's box with east past west across 180°, and how a corner
// turns by its rotation: KML's degrees counter-clockwise about the centre,
// in longitude and latitude (Cesium's rectangle rotation).
function overlayBox({ rectangle, rotation = 0 }) {
const { west, south, north } = rectangle;
const east = rectangle.east < west ? rectangle.east + 360 : rectangle.east;
const box = { west, south, east, north };
const center = [(west + east) / 2, (south + north) / 2];
const angle = (rotation * Math.PI) / 180;
const [cos, sin] = [Math.cos(angle), Math.sin(angle)];
const turn = ([lon, lat]) => {
const [x, y] = [lon - center[0], lat - center[1]];
return [center[0] + x * cos - y * sin, center[1] + x * sin + y * cos];
};
return { box, center, angle, turn };
}
// What a ground overlay's image layer draws: its image tinted by the
// overlay's colour and turned by its rotation onto the box around the
// turned image (transparent outside it). Null when that box passes a pole.
async function overlayImage(overlay, loadImage, createCanvas, maxSide, cache) {
const tint = overlay.color.slice(0, 3);
const { box, center, angle } = overlayBox(overlay);
if (tint.every((value) => value === 1) && !angle)
return { url: overlay.url, rectangle: overlay.rectangle };
const [width, height] = [box.east - box.west, box.north - box.south];
const [cos, sin] = [Math.abs(Math.cos(angle)), Math.abs(Math.sin(angle))];
const [spanX, spanY] = [
width * cos + height * sin,
width * sin + height * cos,
];
const rectangle = {
west: center[0] - spanX / 2,
south: center[1] - spanY / 2,
east: center[0] + spanX / 2,
north: center[1] + spanY / 2,
};
if (angle && (rectangle.south < -90 || rectangle.north > 90 || spanX >= 360))
return null;
const wrap = (lon) => (lon > 180 ? lon - 360 : lon < -180 ? lon + 360 : lon);
const bounds = angle
? { ...rectangle, west: wrap(rectangle.west), east: wrap(rectangle.east) }
: overlay.rectangle;
const key = JSON.stringify([overlay.url, tint, angle, width / height]);
if (cache.has(key)) return { image: cache.get(key), rectangle: bounds };
let image;
try {
image = await loadImage(overlay.url);
} catch {
// An image that does not decode draws nothing, as on the plain path.
return { url: overlay.url, rectangle: overlay.rectangle };
}
const natural = [
image.naturalWidth || image.width,
image.naturalHeight || image.height,
];
// Pixels per degree: one scale on both axes when turned, so the image
// turns in degrees as Cesium's does (the canvas budget bounds its size).
let [scaleX, scaleY] = [natural[0] / width, natural[1] / height];
if (angle) scaleX = scaleY = Math.max(scaleX, scaleY);
const fit = Math.min(
1,
maxSide / (spanX * scaleX),
maxSide / (spanY * scaleY),
);
[scaleX, scaleY] = [scaleX * fit, scaleY * fit];
// Execute the canvas recipe; geometry and pixel budgets are decided by the plan.
function overlayImage(entry, images, createCanvas, cache) {
const recipe = entry.canvas;
if (!recipe) return images.get(entry.url);
if (cache.has(recipe.key)) return cache.get(recipe.key);
const canvas = createCanvas();
canvas.width = Math.max(1, Math.round(spanX * scaleX));
canvas.height = Math.max(1, Math.round(spanY * scaleY));
canvas.width = recipe.width;
canvas.height = recipe.height;
const context = canvas.getContext('2d');
context.translate(canvas.width / 2, canvas.height / 2);
// The canvas's y runs south: a counter-clockwise turn is a negative one.
context.rotate(-angle);
context.drawImage(
image,
(-width * scaleX) / 2,
(-height * scaleY) / 2,
width * scaleX,
height * scaleY,
);
if (tint.some((value) => value !== 1)) {
context.rotate(-recipe.angle);
context.drawImage(images.get(entry.url), ...recipe.draw);
if (recipe.tint.some((value) => value !== 1)) {
const pixels = context.getImageData(0, 0, canvas.width, canvas.height);
for (let i = 0; i < pixels.data.length; i += 4)
for (let channel = 0; channel < 3; channel++)
pixels.data[i + channel] *= tint[channel];
pixels.data[i + channel] *= recipe.tint[channel];
context.putImageData(pixels, 0, 0);
}
cache.set(key, canvas);
return { image: canvas, rectangle: bounds };
cache.set(recipe.key, canvas);
return canvas;
}
// KML headings are from north (Cesium's billboard on the Earth's axis):
@@ -170,24 +74,6 @@ async function overlayImage(overlay, loadImage, createCanvas, maxSide, cache) {
const turnsWithCamera = (look) => look.pins.some((pin) => pin.rotation);
const onScreen = (pin, heading) => (pin.rotation ? pin.rotation + heading : 0);
const flat = (positions) => positions.map(([lon, lat]) => [lon, lat]);
const cartesian = ([lon, lat, height = 0]) =>
geo.cartesian3.fromDegrees(lon, lat, height);
/** One key per drawn look of an icon, so equal pins share a sprite set. */
const lookKey = (icon, depth) =>
JSON.stringify([
icon.image,
icon.widthPx,
icon.heightPx,
icon.scale,
icon.origin,
icon.offsetPx,
icon.subRegion,
icon.scaleByDistance,
depth,
]);
/**
* Draw one imported file's records on `engine`. Resolves once its icons
* are decoded and every set is committed.
@@ -212,7 +98,10 @@ export async function drawImportedFile(
visible = true,
loadImage = imageFromUrl,
createCanvas = () => document.createElement('canvas'),
pixelRatio = Math.max(1, Math.min(2, globalThis.devicePixelRatio || 1)),
pixelRatio = Math.max(
1,
Math.min(MAX_ICON_PIXEL_RATIO, globalThis.devicePixelRatio || 1),
),
now = () => performance.now(),
signal,
isCurrent = () => true,
@@ -227,7 +116,7 @@ export async function drawImportedFile(
const lines = [];
const meshes = [];
const pins = [];
const kinds = { ...records.undrawn };
const kinds = {};
const layers = [];
const looks = new Map();
let drawn = 0;
@@ -256,276 +145,137 @@ export async function drawImportedFile(
try {
checkActive();
// Fills under ground lines; a plain-colour overlay is a fill too.
const fills = [];
const ground = [];
const images = new Map();
const metadata = new Map();
const overlayCache = new Map();
function drawPlan(plan) {
drawn += plan.report.drawn;
for (const [kind, count] of Object.entries(plan.report.kinds))
kinds[kind] = (kinds[kind] ?? 0) + count;
ground.push(...plan.ground);
for (const entry of plan.meshes) {
const { positions, indices, color, uv } = entry;
meshes.push(
engine.meshes.createMesh({
id: `${prefix}-mesh-${meshes.length}`,
positions,
indices,
color,
show: shown,
...(uv
? {
uv,
image: overlayImage(
entry,
images,
createCanvas,
overlayCache,
),
}
: {}),
}),
);
}
for (const entry of plan.lines) {
const line = engine.lines.createLine({
id: `${prefix}-line-${lines.length}`,
widthPx: entry.widthPx,
color: entry.color,
});
line.setPositions(entry.positions);
line.show(shown);
lines.push(line);
}
for (const entry of plan.overlays) {
const image =
entry.canvas &&
overlayImage(entry, images, createCanvas, overlayCache);
layers.push(
engine.imagery.createLayer({
id: `${prefix}-overlay-${layers.length}`,
source: {
kind: 'image',
rectangle: entry.rectangle,
...(image ? { image } : { url: entry.url }),
},
alpha: entry.alpha,
show: shown,
order: entry.order,
}),
);
}
}
let lastYield = now();
for (const polygon of records.polygons) {
if (now() - lastYield > 8) {
if (now() - lastYield > POLYGON_YIELD_MS) {
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;
continue;
}
if (polygon.fill && polygon.raised) {
const vertices = polygon.rings.reduce((n, ring) => n + ring.length, 0);
const shape =
vertices <= MAX_RAISED_VERTICES && polygonShape(polygon.rings);
if (vertices > MAX_RAISED_VERTICES)
kinds.complexPolygon = (kinds.complexPolygon ?? 0) + 1;
if (shape) {
meshes.push(
engine.meshes.createMesh({
id: `${prefix}-fill-${meshes.length}`,
positions: shape.attributes.position.values,
indices: shape.indices,
color: polygon.fill,
show: shown,
}),
);
any = true;
}
} else if (polygon.fill) {
const item = checked('polygon', {
rings: polygon.rings.map(flat),
fill: polygon.fill,
});
if (item) {
fills.push(item);
any = true;
} else kinds.polygon = (kinds.polygon ?? 0) + 1;
}
if (polygon.raised && polygon.outline)
for (const ring of polygon.rings) {
if (ring.length < 2) continue;
const closed = [...ring, ring[0]].map(cartesian);
const line = engine.lines.createLine({
id: `${prefix}-outline-${lines.length}`,
widthPx: 1,
color: polygon.outline.color,
});
line.setPositions(closed);
line.show(shown);
lines.push(line);
any = true;
}
if (any) drawn++;
drawPlan(planImport({ ...emptyRecords(), polygons: [polygon] }));
}
const overlayCache = new Map();
const overlayImages = new Map();
const overlaySide = Math.min(
MAX_OVERLAY_SIDE,
Math.floor(
Math.sqrt(OVERLAY_PIXEL_BUDGET / Math.max(1, records.overlays.length)),
),
);
// KML drawOrder, then the document's: equal layer orders draw in
// creation order, and a file imported later over this one.
const overlays = [...records.overlays].sort(
(a, b) => (a.drawOrder ?? 0) - (b.drawOrder ?? 0),
);
for (const overlay of overlays) {
// Cesium raises only an absolute overlay, to its altitude.
const raise =
overlay.altitudeMode === 'absolute' && overlay.altitude > 0
? overlay.altitude
: undefined;
if (overlay.url) {
const prepared = await overlayImage(
overlay,
loadImage,
createCanvas,
overlaySide,
overlayCache,
);
checkActive();
if (!prepared) {
kinds.rotatedOverlay = (kinds.rotatedOverlay ?? 0) + 1;
continue;
}
if (raise) {
let image = prepared.image ?? overlayImages.get(overlay.url);
if (!image) {
try {
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);
meshes.push(
engine.meshes.createMesh({
id: `${prefix}-overlay-${meshes.length}`,
positions: shape.attributes.position.values,
indices: shape.indices,
uv: shape.attributes.st.values,
image,
color: [1, 1, 1, Math.min(0.99, overlay.color[3])],
show: shown,
}),
);
drawn++;
continue;
}
layers.push(
engine.imagery.createLayer({
id: `${prefix}-overlay-${layers.length}`,
source: { kind: 'image', ...prepared },
// Cesium's transparent image material: at most 0.99.
alpha: Math.min(0.99, overlay.color[3]),
show: shown,
order: OVERLAY_ORDER,
}),
);
drawn++;
continue;
}
// A plain colour draws as a ground fill.
if (raise) {
kinds.raisedOverlay = (kinds.raisedOverlay ?? 0) + 1;
continue;
}
const { west, south, east, north } = overlay.rectangle;
const item = checked('cell', { west, south, east, north });
if (!item) continue;
const { box, turn } = overlayBox(overlay);
const rings = [
[
[box.west, box.south],
[box.east, box.south],
[box.east, box.north],
[box.west, box.north],
].map(turn),
];
const fill = checked('polygon', { rings, fill: overlay.color });
if (!fill) {
kinds.rotatedOverlay = (kinds.rotatedOverlay ?? 0) + 1;
continue;
}
fills.push(fill);
drawn++;
}
if (fills.length) {
const set = engine.ground.createSet({
id: `${prefix}-polygons`,
kind: 'polygon',
order: POLYGON_ORDER,
});
groundSets.push(set);
set.replace(fills);
set.commit();
set.show(shown);
}
const corridors = [];
for (const record of records.lines) {
if (record.ground) {
const item = checked('corridor', {
positions: flat(record.positions),
color: record.color,
widthPx: record.widthPx,
});
if (!item) continue;
corridors.push(item);
} else {
const line = engine.lines.createLine({
id: `${prefix}-line-${lines.length}`,
widthPx: record.widthPx,
color: record.color,
});
line.setPositions(record.positions.map(cartesian));
line.show(shown);
lines.push(line);
}
drawn++;
}
if (corridors.length) {
const set = engine.ground.createSet({
id: `${prefix}-lines`,
kind: 'corridor',
order: LINE_ORDER,
});
groundSets.push(set);
set.replace(corridors);
set.commit();
set.show(shown);
}
for (const point of records.points) {
const depth = point.altitude === 'absolute' ? 'normal' : 'always';
const key = lookKey(point.icon, depth);
let look = looks.get(key);
if (!look)
looks.set(
key,
(look = { icon: point.icon, depth, pins: [], set: null, count: 0 }),
);
const [lon, lat, height = 0] = point.position;
look.pins.push({
lon,
lat,
offset: point.altitude === 'relative' ? height : 0,
height: point.altitude === 'absolute' ? height : null,
ground: undefined,
position: null,
horizon: null,
show: true,
color: point.icon.color ?? [1, 1, 1, 1],
rotation: point.icon.rotation ?? 0,
});
}
pins.push(...[...looks.values()].flatMap((look) => look.pins));
const maxSide = Math.min(
512,
Math.floor(Math.sqrt(ICON_PIXEL_BUDGET / Math.max(1, looks.size))),
);
const remaining = { ...records, polygons: [] };
const pending = planImport(remaining);
await Promise.all(
[...looks.values()].map(async (look) => {
const url = look.icon.image?.url;
let image;
if (url) {
try {
image = await loadImage(url);
} catch {
// An icon that does not decode draws nothing, as a billboard
// without an image.
return;
}
pending.images.map(async (url) => {
try {
const image = await loadImage(url);
images.set(url, image);
metadata.set(url, {
width: image.naturalWidth || image.width,
height: image.naturalHeight || image.height,
});
} catch {
metadata.set(url, null);
}
checkActive();
const { canvas, side } = composeIcon(look.icon, {
image,
createCanvas,
ratio: pixelRatio,
maxSide,
});
look.set = engine.movers.createSet({
id: `${prefix}-pins-${look.depth}-${looks.size}-${++serial}`,
kind: 'sprite',
capacity: look.pins.length,
depthTest: look.depth,
pickable: false,
sprite: {
sizePx: side,
atlas: { canvas, cells: { icon: [0, 0, 1, 1] } },
...(look.icon.scaleByDistance
? { scaleByDistance: [...look.icon.scaleByDistance] }
: {}),
},
});
pointSetsOf(engine).add(look.set);
drawn += look.pins.length;
}),
);
checkActive();
const plan = planImport(remaining, undefined, { images: metadata });
drawPlan(plan);
for (const kind of ['polygon', 'corridor']) {
const entries = ground.filter((entry) => entry.kind === kind);
if (!entries.length) continue;
const set = engine.ground.createSet({
id: `${prefix}-${kind}`,
kind,
order: entries[0].order,
});
groundSets.push(set);
set.replace(entries.map((entry) => entry.item));
set.commit();
set.show(shown);
}
for (const planned of plan.pins) {
const look = { ...planned, set: null, count: 0 };
looks.set(looks.size, look);
pins.push(...look.pins);
if (!look.draw) continue;
const { canvas, side } = composeIcon(look.icon, {
image: images.get(look.icon.image?.url),
createCanvas,
ratio: pixelRatio,
maxSide: look.maxSide,
});
look.set = engine.movers.createSet({
id: `${prefix}-pins-${look.depth}-${looks.size}-${++serial}`,
kind: 'sprite',
capacity: look.pins.length,
depthTest: look.depth,
pickable: false,
sprite: {
sizePx: side,
atlas: { canvas, cells: { icon: [0, 0, 1, 1] } },
...(look.icon.scaleByDistance
? { scaleByDistance: [...look.icon.scaleByDistance] }
: {}),
},
});
pointSetsOf(engine).add(look.set);
}
checkActive();
for (let from = 0; from < pins.length; from += BATCH) {
settle(heightContext(), from, from + BATCH);
if (from + BATCH < pins.length) {
+26 -15
View File
@@ -3,6 +3,12 @@ import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../geo/index.js';
import { GROUND_HARNESSES } from '../testSupport/groundDisplayEngines.mjs';
import {
MAX_RAISED_RINGS,
MAX_RAISED_VERTICES,
OVERLAY_PIXEL_BUDGET,
BATCH,
} from './geoImportLimits.js';
import { drawImportedFile } from './geoImportDisplay.js';
import { emptyRecords, geoJsonRecords } from './geoImportRecords.js';
@@ -434,7 +440,7 @@ for (const make of GROUND_HARNESSES) {
},
);
for (const rings of [128, 200])
for (const rings of [MAX_RAISED_RINGS, MAX_RAISED_RINGS + 1])
displayTest(
`a raised polygon with ${rings} rings respects the ring bound`,
async ({ engine }) => {
@@ -469,12 +475,18 @@ for (const make of GROUND_HARNESSES) {
});
assert.deepEqual(
drawing.report,
rings === 128
rings === MAX_RAISED_RINGS
? { 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);
assert.equal(
engine.meshes.list().length,
rings === MAX_RAISED_RINGS ? 1 : 0,
);
assert.equal(
engine.lines.list().length,
rings === MAX_RAISED_RINGS ? rings : 0,
);
drawing.destroy();
},
);
@@ -519,11 +531,11 @@ for (const make of GROUND_HARNESSES) {
);
displayTest(
'a raised fill with 40 000 vertices is counted, its outline drawn',
'a raised fill past the vertex limit is counted, its outline drawn',
async ({ engine }) => {
const records = emptyRecords();
const outer = Array.from({ length: 40_000 }, (_, i) => {
const angle = (i * 2 * Math.PI) / 40_000;
const outer = Array.from({ length: MAX_RAISED_VERTICES + 1 }, (_, i) => {
const angle = (i * 2 * Math.PI) / (MAX_RAISED_VERTICES + 1);
return [10 * Math.cos(angle), 10 * Math.sin(angle), 1000];
});
records.polygons.push({
@@ -684,7 +696,7 @@ for (const make of GROUND_HARNESSES) {
);
displayTest(
'a polygon the ground contract rejects is counted undrawn',
'a ground polygon skips a short hole as Cesium does',
async ({ engine }) => {
const records = geoJsonRecords(
{
@@ -706,9 +718,9 @@ for (const make of GROUND_HARNESSES) {
]);
const drawing = await drawImportedFile(engine, records, { createCanvas });
assert.deepEqual(drawing.report, {
drawn: 0,
undrawn: 1,
kinds: { polygon: 1 },
drawn: 1,
undrawn: 0,
kinds: {},
});
drawing.destroy();
},
@@ -1054,7 +1066,6 @@ for (const make of GROUND_HARNESSES) {
},
};
};
const OVERLAY_PIXELS = 16 * 1024 * 1024;
displayTest(
'turned overlays sharing one small image share one bounded canvas',
@@ -1075,7 +1086,7 @@ for (const make of GROUND_HARNESSES) {
assert.equal(drawing.report.drawn, 20);
assert.equal(counted.canvases.length, 1, 'equal looks share a canvas');
// Each of 20 overlays may hold a twentieth of the budget.
const side = Math.floor(Math.sqrt(OVERLAY_PIXELS / 20));
const side = Math.floor(Math.sqrt(OVERLAY_PIXEL_BUDGET / 20));
const [canvas] = counted.canvases;
assert.ok(
canvas.width <= side && canvas.height <= side,
@@ -1104,7 +1115,7 @@ for (const make of GROUND_HARNESSES) {
assert.equal(drawing.report.drawn, 100);
assert.equal(counted.canvases.length, 100);
assert.ok(
counted.pixels() <= OVERLAY_PIXELS,
counted.pixels() <= OVERLAY_PIXEL_BUDGET,
`${Math.round((counted.pixels() * 4) / 2 ** 20)} MiB of canvas RGBA`,
);
drawing.destroy();
@@ -1275,7 +1286,7 @@ for (const make of GROUND_HARNESSES) {
}, 0);
await drawing.refreshGround();
clearTimeout(heartbeat);
assert.equal(beforeYield, 256);
assert.equal(beforeYield, BATCH);
assert.equal(clamps, 600);
drawing.destroy();
},
+7 -3
View File
@@ -1,6 +1,8 @@
// Inspect encoded headers before allowing the browser to decode local images.
const MAX_IMPORT_IMAGE_PIXELS = 16 * 1024 * 1024;
const MAX_SIDE = 8192;
import {
MAX_IMPORT_IMAGE_PIXELS,
MAX_IMPORT_IMAGE_SIDE,
} from './geoImportLimits.js';
/** Width and height from a supported raster header; no image is decoded. */
export function importImageSize(bytes) {
@@ -62,7 +64,9 @@ export function createImportImageBudget() {
const size = importImageSize(bytes);
if (
!size ||
size.some((n) => !Number.isInteger(n) || n <= 0 || n > MAX_SIDE) ||
size.some(
(n) => !Number.isInteger(n) || n <= 0 || n > MAX_IMPORT_IMAGE_SIDE,
) ||
pixels + size[0] * size[1] > MAX_IMPORT_IMAGE_PIXELS
)
throw new Error(
+54
View File
@@ -0,0 +1,54 @@
// Import resource ceilings and placement thresholds shared by readers and drawing.
// Values without a timing measurement are the existing import policy budgets.
// Bound the file read and XML/JSON parse; the picker policy allows 50 MiB.
export const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
// Stop zip bombs before allocation; the KMZ policy allows 120 MiB inflated.
export const MAX_KMZ_INFLATED_BYTES = 120 * 1024 * 1024;
// Bound retained files and drawing sets; the session UI policy allows twelve.
export const MAX_IMPORTED_FILES = 12;
// Bound drawing objects per file; the import policy allows 20,000 entities.
export const MAX_IMPORT_ENTITIES = 20_000;
// Bound total coordinate work; the import policy allows 250,000 positions.
export const MAX_IMPORT_POSITIONS = 250_000;
// Bound style/link normalization and image lookup; policy allows 2,000 links.
export const MAX_KML_LINKS = 2_000;
// Reject implausible coordinates; the reader policy accepts heights to 1,000 km.
export const MAX_IMPORT_HEIGHT_M = 1_000_000;
// Bound pending reads; the existing reader deadline is one minute.
export const IMPORT_TIMEOUT_MS = 60_000;
// Bound feature labels retained in records; the existing label policy is 120 chars.
export const MAX_IMPORT_NAME_LENGTH = 120;
// Bound file names in the import list; the existing row policy is 60 chars.
export const MAX_IMPORT_DISPLAY_NAME_LENGTH = 60;
// Bound decoded local images to 64 MiB RGBA per file, from the image memory budget.
export const MAX_IMPORT_IMAGE_PIXELS = 16 * 1024 * 1024;
// Reject oversized raster axes before decoding; the existing image ceiling is 8K.
export const MAX_IMPORT_IMAGE_SIDE = 8192;
// Bound sprite canvases to 64 MiB RGBA per file, from the icon memory budget.
export const ICON_PIXEL_BUDGET = 16 * 1024 * 1024;
// Bound transformed overlay canvases to 64 MiB RGBA, from the overlay memory budget.
export const OVERLAY_PIXEL_BUDGET = 16 * 1024 * 1024;
// Bound each transformed overlay texture; the existing canvas ceiling is 4K.
export const MAX_OVERLAY_SIDE = 4096;
// Bound each icon canvas; the existing sprite ceiling is 512 pixels.
export const MAX_ICON_SIDE = 512;
// Bound high-DPI sprite memory; the existing raster scale ceiling is 2x.
export const MAX_ICON_PIXEL_RATIO = 2;
// Bound triangulation: measured 32,768 vertices in 58 ms, versus 198 ms at 65,536.
export const MAX_RAISED_VERTICES = 32_768;
// Bound holes and outlines: 128 rings took 37 ms; 1,000 holes took 110 ms.
export const MAX_RAISED_RINGS = 128;
// Yield between polygons after half a 60 Hz frame, preserving the 8 ms work slice.
export const POLYGON_YIELD_MS = 8;
// Bound synchronous surface queries between yields; the existing batch is 256 pins.
export const BATCH = 256;
// Keep below-ellipsoid ground visible at the horizon, matching GhostMaps' 2 km lift.
export const HORIZON_LIFT_M = 2000;
// Limit tile clamping to a low camera, matching Cesium's 250 km threshold.
export const CLAMP_TILES_BELOW_M = 250_000;
// Cesium draws ground lines over fills and ground rectangles over layer rasters.
export const POLYGON_ORDER = 0;
export const LINE_ORDER = 1;
export const OVERLAY_ORDER = 30;
+3 -1
View File
@@ -4,6 +4,8 @@
// whose centre is the point (src/data/geoImportDisplay.js), so the pin's
// tip, a KML hot spot or an icon's sub-region lands where Cesium put it.
import { MAX_ICON_SIDE } from './geoImportLimits.js';
/** Cesium.Color's CSS form of rgba in 0..1 (its float-to-byte conversion). */
export function cssOf([red, green, blue, alpha = 1]) {
const byte = (v) => (v === 1 ? 255 : (v * 256) | 0);
@@ -89,7 +91,7 @@ export function iconBox(icon, natural) {
*/
export function composeIcon(
icon,
{ image, createCanvas, ratio = 1, maxSide = 512 } = {},
{ image, createCanvas, ratio = 1, maxSide = MAX_ICON_SIDE } = {},
) {
const natural = image
? {
+381
View File
@@ -0,0 +1,381 @@
// Pure drawing decisions for imported records. Image metadata is supplied by
// the caller; this module neither decodes images nor talks to a globe engine.
import * as geo from '../geo/index.js';
import { normalizeGroundItem } from '../globe/ground.js';
import { polygonShape, rectangleShape } from '../scenes/dataPacks/shapes.js';
import { hasRingVertices } from './geoImportRecords.js';
import * as defaults from './geoImportLimits.js';
const flat = (positions) => positions.map(([lon, lat]) => [lon, lat]);
const cartesian = ([lon, lat, height = 0]) =>
geo.cartesian3.fromDegrees(lon, lat, height);
// A ground item the contract accepts, or null (a degenerate ring or line).
function checked(kind, item) {
try {
normalizeGroundItem(kind, item);
return item;
} catch {
return null;
}
}
// A ground overlay's box with east past west across 180°, and how a corner
// turns by its rotation: KML's degrees counter-clockwise about the centre,
// in longitude and latitude (Cesium's rectangle rotation).
function overlayBox({ rectangle, rotation = 0 }) {
const { west, south, north } = rectangle;
const east = rectangle.east < west ? rectangle.east + 360 : rectangle.east;
const box = { west, south, east, north };
const center = [(west + east) / 2, (south + north) / 2];
const angle = (rotation * Math.PI) / 180;
const [cos, sin] = [Math.cos(angle), Math.sin(angle)];
const turn = ([lon, lat]) => {
const [x, y] = [lon - center[0], lat - center[1]];
return [center[0] + x * cos - y * sin, center[1] + x * sin + y * cos];
};
return { box, center, angle, turn };
}
/** One key per drawn look of an icon, so equal pins share a sprite set. */
const lookKey = (icon, depth) =>
JSON.stringify([
icon.image,
icon.widthPx,
icon.heightPx,
icon.scale,
icon.origin,
icon.offsetPx,
icon.subRegion,
icon.scaleByDistance,
depth,
]);
// Canvas coordinates use one scale on both axes for rotations in degrees.
function overlayCanvas(overlay, maxSide, natural) {
const tint = overlay.color.slice(0, 3);
const { box, center, angle } = overlayBox(overlay);
if (tint.every((value) => value === 1) && !angle)
return { rectangle: overlay.rectangle, canvas: null };
const [width, height] = [box.east - box.west, box.north - box.south];
const [cos, sin] = [Math.abs(Math.cos(angle)), Math.abs(Math.sin(angle))];
const [spanX, spanY] = [
width * cos + height * sin,
width * sin + height * cos,
];
const rectangle = {
west: center[0] - spanX / 2,
south: center[1] - spanY / 2,
east: center[0] + spanX / 2,
north: center[1] + spanY / 2,
};
if (angle && (rectangle.south < -90 || rectangle.north > 90 || spanX >= 360))
return null;
const wrap = (lon) => (lon > 180 ? lon - 360 : lon < -180 ? lon + 360 : lon);
const bounds = angle
? { ...rectangle, west: wrap(rectangle.west), east: wrap(rectangle.east) }
: overlay.rectangle;
// A failed decode keeps the original image-layer path, as a plain image does.
if (natural === null) return { rectangle: overlay.rectangle, canvas: null };
let [scaleX, scaleY] = [
(natural?.width ?? 1) / width,
(natural?.height ?? 1) / height,
];
if (angle) scaleX = scaleY = Math.max(scaleX, scaleY);
const fit = Math.min(
1,
maxSide / (spanX * scaleX),
maxSide / (spanY * scaleY),
);
[scaleX, scaleY] = [scaleX * fit, scaleY * fit];
return {
rectangle: bounds,
canvas: {
key: JSON.stringify([overlay.url, tint, angle, width / height]),
width: Math.max(1, Math.round(spanX * scaleX)),
height: Math.max(1, Math.round(spanY * scaleY)),
angle,
tint,
draw: [
(-width * scaleX) / 2,
(-height * scaleY) / 2,
width * scaleX,
height * scaleY,
],
},
};
}
/**
* Decide what one file draws, without a DOM or engine. `limits` overrides the
* shared ceilings for tests. `context.images` is a Map of URL to {width, height},
* or null for a failed decode. `images` in the result names missing metadata;
* a caller can load those images and plan again. Report counts retain the import
* UI's semantics: a partial polygon can be both drawn and reported unsupported.
* Each primitive retains its source record for diagnostics.
*/
export function planImport(records, limits = defaults, context = {}) {
limits = { ...defaults, ...limits };
const ground = [];
const lines = [];
const pins = [];
const overlays = [];
const meshes = [];
const undrawn = [];
const images = new Set();
const kinds = { ...records.undrawn };
let drawn = 0;
const skip = (record, reason, counted = true) => {
undrawn.push({ record, reason });
if (counted) kinds[reason] = (kinds[reason] ?? 0) + 1;
};
for (const [reason, count] of Object.entries(records.undrawn))
undrawn.push({ reason, count });
const needImage = (url) => {
if (!context.images?.has(url)) images.add(url);
return context.images?.get(url);
};
for (const polygon of records.polygons) {
let any = false;
if (polygon.raised && polygon.rings.length > limits.MAX_RAISED_RINGS) {
skip(polygon, 'complexPolygon');
continue;
}
// Cesium discards short holes; an invalid outer ring cannot become a hole's fill.
const rings = [
polygon.rings[0] ?? [],
...polygon.rings.slice(1).filter(hasRingVertices),
];
if (polygon.fill && polygon.raised) {
// The closing copy is not a vertex in the triangulation budget.
const vertices = polygon.rings.reduce(
(n, ring) =>
n +
ring.length -
Number(
ring.length > 1 &&
ring[0][0] === ring.at(-1)[0] &&
ring[0][1] === ring.at(-1)[1],
),
0,
);
const shape =
vertices <= limits.MAX_RAISED_VERTICES &&
hasRingVertices(rings[0]) &&
polygonShape(rings);
if (vertices > limits.MAX_RAISED_VERTICES)
skip(polygon, 'complexPolygon');
if (shape) {
meshes.push({
record: polygon,
positions: shape.attributes.position.values,
indices: shape.indices,
color: polygon.fill,
});
any = true;
} else if (vertices <= limits.MAX_RAISED_VERTICES)
skip(polygon, 'polygon', false);
} else if (polygon.fill) {
const item =
hasRingVertices(rings[0]) &&
checked('polygon', { rings: rings.map(flat), fill: polygon.fill });
if (item) {
ground.push({
record: polygon,
kind: 'polygon',
order: limits.POLYGON_ORDER,
item,
});
any = true;
} else skip(polygon, 'polygon');
}
if (polygon.raised && polygon.outline)
for (const ring of rings) {
if (ring.length < 2) continue;
lines.push({
record: polygon,
positions: [...ring, ring[0]].map(cartesian),
widthPx: 1,
color: polygon.outline.color,
});
any = true;
}
if (any) drawn++;
else if (!polygon.fill) skip(polygon, 'noFill', false);
}
const overlaySide = Math.min(
limits.MAX_OVERLAY_SIDE,
Math.floor(
Math.sqrt(
limits.OVERLAY_PIXEL_BUDGET / Math.max(1, records.overlays.length),
),
),
);
// Stable sort preserves the document's order for equal KML drawOrder values.
for (const overlay of [...records.overlays].sort(
(a, b) => (a.drawOrder ?? 0) - (b.drawOrder ?? 0),
)) {
const raise =
overlay.altitudeMode === 'absolute' && overlay.altitude !== 0
? overlay.altitude
: undefined;
if (overlay.url) {
const prepared = overlayCanvas(
overlay,
overlaySide,
context.images?.get(overlay.url),
);
if (!prepared) {
skip(overlay, 'rotatedOverlay');
continue;
}
if (prepared.canvas || raise) needImage(overlay.url);
if (raise && context.images?.get(overlay.url) === null) {
skip(overlay, 'raisedOverlay');
continue;
}
const alpha = Math.min(0.99, overlay.color[3]);
const entry = {
record: overlay,
url: overlay.url,
...prepared,
alpha,
order: limits.OVERLAY_ORDER,
};
if (raise) {
const { west, south, east, north } = prepared.rectangle;
const shape = rectangleShape([west, south, east, north], raise);
meshes.push({
...entry,
positions: shape.attributes.position.values,
indices: shape.indices,
uv: shape.attributes.st.values,
color: [1, 1, 1, alpha],
});
} else overlays.push(entry);
drawn++;
continue;
}
if (!checked('cell', overlay.rectangle)) {
skip(overlay, 'overlay', false);
continue;
}
const { box, center, turn } = overlayBox(overlay);
const rings = [
[
[box.west, box.south],
[box.east, box.south],
[box.east, box.north],
[box.west, box.north],
].map(turn),
];
const item = checked('polygon', { rings, fill: overlay.color });
if (!item) {
skip(overlay, 'rotatedOverlay');
continue;
}
if (raise) {
const shape = rectangleShape(
[box.west, box.south, box.east, box.north],
raise,
);
const positions = shape.attributes.position.values;
for (let i = 0; i < positions.length; i += 3) {
const at = geo.cartographic.fromCartesian({
x: positions[i],
y: positions[i + 1],
z: positions[i + 2],
});
const longitude = geo.math.toDegrees(at.longitude);
const [lon, lat] = turn([
longitude + 360 * Math.round((center[0] - longitude) / 360),
geo.math.toDegrees(at.latitude),
]);
const p = cartesian([lon, lat, raise]);
positions.set([p.x, p.y, p.z], i);
}
meshes.push({
record: overlay,
positions,
indices: shape.indices,
color: overlay.color,
});
} else
ground.push({
record: overlay,
kind: 'polygon',
order: limits.POLYGON_ORDER,
item,
});
drawn++;
}
for (const record of records.lines) {
if (record.ground) {
const item = checked('corridor', {
positions: flat(record.positions),
color: record.color,
widthPx: record.widthPx,
});
if (!item) {
skip(record, 'line', false);
continue;
}
ground.push({ record, kind: 'corridor', order: limits.LINE_ORDER, item });
} else
lines.push({
record,
positions: record.positions.map(cartesian),
widthPx: record.widthPx,
color: record.color,
});
drawn++;
}
const looks = new Map();
for (const point of records.points) {
const depth = point.altitude === 'absolute' ? 'normal' : 'always';
const key = lookKey(point.icon, depth);
let look = looks.get(key);
if (!look) looks.set(key, (look = { icon: point.icon, depth, pins: [] }));
const [lon, lat, height = 0] = point.position;
look.pins.push({
record: point,
lon,
lat,
offset: point.altitude === 'relative' ? height : 0,
height: point.altitude === 'absolute' ? height : null,
ground: undefined,
position: null,
horizon: null,
show: true,
color: point.icon.color ?? [1, 1, 1, 1],
rotation: point.icon.rotation ?? 0,
});
}
const maxSide = Math.min(
limits.MAX_ICON_SIDE,
Math.floor(Math.sqrt(limits.ICON_PIXEL_BUDGET / Math.max(1, looks.size))),
);
for (const look of looks.values()) {
const url = look.icon.image?.url;
if (url && needImage(url) === null) {
for (const pin of look.pins) skip(pin.record, 'icon', false);
}
const draw = !url || context.images?.get(url) !== null;
pins.push({ ...look, maxSide, draw });
if (draw) drawn += look.pins.length;
}
return {
ground,
lines,
pins,
overlays,
meshes,
undrawn,
images: [...images],
report: {
drawn,
undrawn: Object.values(kinds).reduce((n, c) => n + c, 0),
kinds,
},
};
}
+118
View File
@@ -0,0 +1,118 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as geo from '../geo/index.js';
import { planImport } from './geoImportPlan.js';
import { emptyRecords } from './geoImportRecords.js';
const random = (seed) => () => {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
return seed / 2 ** 32;
};
const ringArea = (ring) =>
Math.abs(
ring.reduce((sum, p, i) => {
const q = ring[(i + 1) % ring.length];
return sum + p[0] * q[1] - q[0] * p[1];
}, 0),
) / 2;
function triangulated(rings, center) {
const records = emptyRecords();
records.polygons.push({
rings,
raised: true,
fill: [1, 0, 0, 1],
outline: null,
});
const plan = planImport(records);
assert.equal(plan.report.drawn, 1);
assert.equal(plan.meshes.length, 1);
const { positions, indices } = plan.meshes[0];
const points = [];
for (let i = 0; i < positions.length; i += 3) {
const p = geo.cartographic.fromCartesian({
x: positions[i],
y: positions[i + 1],
z: positions[i + 2],
});
const lon = geo.math.toDegrees(p.longitude);
const unwrapped = lon - 360 * Math.round((lon - center[0]) / 360);
points.push([
unwrapped - center[0],
geo.math.toDegrees(p.latitude) - center[1],
]);
}
const local = rings.flat().map(([x, y]) => [x - center[0], y - center[1]]);
const min = [0, 1].map((axis) => Math.min(...local.map((p) => p[axis])));
const max = [0, 1].map((axis) => Math.max(...local.map((p) => p[axis])));
let area = 0;
for (let i = 0; i < indices.length; i += 3) {
const triangle = Array.from(
indices.slice(i, i + 3),
(index) => points[index],
);
for (const p of triangle)
for (let axis = 0; axis < 2; axis++)
assert.ok(
p[axis] >= min[axis] - 1e-9 && p[axis] <= max[axis] + 1e-9,
'triangle stays inside ring bounds',
);
area += ringArea(triangle);
}
return area;
}
for (let seed = 1; seed <= 128; seed++) {
test(`raised polygon area, bounds, closure and winding (seed ${seed})`, () => {
const next = random(seed);
const center = [
seed % 8 === 0 ? 179.99 : next() * 350 - 175,
next() * 170 - 85,
];
const radius = 0.01 + next() * 0.1;
const height = next() * 5000 - 200;
const count = 6 + Math.floor(next() * 40);
// Ordered radial vertices make simple concave outlines. Small, disjoint
// holes stay inside their minimum radius even near a pole or the dateline.
const outer = Array.from({ length: count }, (_, i) => {
const angle = (2 * Math.PI * i) / count;
const r = radius * (0.7 + next() * 0.3);
return [
center[0] + Math.cos(angle) * r,
center[1] + Math.sin(angle) * r,
height,
];
});
const holes = Array.from({ length: seed % 4 }, (_, h) => {
const x = center[0] + (h - 1) * radius * 0.3;
return Array.from({ length: 5 + h }, (_, i) => {
const angle = (2 * Math.PI * i) / (5 + h);
return [
x + Math.cos(angle) * radius * 0.08,
center[1] + Math.sin(angle) * radius * 0.08,
height,
];
});
});
const rings = [outer, ...holes];
const areas = rings.map((ring) =>
ringArea(ring.map(([x, y]) => [x - center[0], y - center[1]])),
);
const expected = areas[0] - areas.slice(1).reduce((sum, n) => sum + n, 0);
const actual = triangulated(rings, center);
assert.ok(
Math.abs(actual - expected) / expected < 0.01,
`area ${actual} matches ${expected}`,
);
for (const variant of [
rings.map((r) => [...r, r[0]]),
rings.map((r) => [...r].reverse()),
[outer, ...holes.map((r) => [...r].reverse())],
]) {
const changed = triangulated(variant, center);
assert.ok(
Math.abs(changed - actual) / actual < 1e-8,
'closure and winding preserve area',
);
}
});
}
+273
View File
@@ -0,0 +1,273 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { planImport } from './geoImportPlan.js';
import { emptyRecords } from './geoImportRecords.js';
import * as limits from './geoImportLimits.js';
const ring = [
[0, 0, 100],
[1, 0, 100],
[1, 1, 100],
[0, 1, 100],
];
const polygon = (options = {}) => ({
rings: [ring],
fill: [1, 0, 0, 1],
outline: { color: [0, 1, 0, 1], widthPx: 5 },
raised: true,
...options,
});
const overlay = (options = {}) => ({
url: 'local-image',
color: [1, 1, 1, 1],
rectangle: { west: 0, south: 0, east: 2, north: 1 },
rotation: 0,
drawOrder: 0,
...options,
});
for (const [name, record, ceilings, counts] of [
[
'ground fill without outline',
polygon({ raised: false }),
{},
[1, 0, 0, 1, 0],
],
['raised fill and outline', polygon(), {}, [0, 1, 1, 1, 0]],
[
'open ring at vertex limit',
polygon(),
{ MAX_RAISED_VERTICES: ring.length },
[0, 1, 1, 1, 0],
],
[
'closed ring at vertex limit',
polygon({ rings: [[...ring, ring[0]]] }),
{ MAX_RAISED_VERTICES: ring.length },
[0, 1, 1, 1, 0],
],
['outline only', polygon({ fill: null }), {}, [0, 1, 0, 1, 0]],
[
'vertex limit keeps outline',
polygon(),
{ MAX_RAISED_VERTICES: 3 },
[0, 1, 0, 1, 1],
],
[
'ring limit skips polygon',
polygon(),
{ MAX_RAISED_RINGS: 0 },
[0, 0, 0, 0, 1],
],
[
'short hole is skipped',
polygon({
rings: [
ring,
[
[0.2, 0.2],
[0.3, 0.2],
],
],
}),
{},
[0, 1, 1, 1, 0],
],
[
'short outer never promotes hole',
polygon({
rings: [
[
[0, 0],
[1, 0],
],
ring,
],
outline: null,
}),
{},
[0, 0, 0, 0, 0],
],
])
test(name, () => {
const records = { ...emptyRecords(), polygons: [record] };
const before = structuredClone(records);
const plan = planImport(records, ceilings);
assert.deepEqual(
[
plan.ground.length,
plan.lines.length,
plan.meshes.length,
plan.report.drawn,
plan.report.undrawn,
],
counts,
);
assert.deepEqual(records, before);
if (plan.lines.length) assert.equal(plan.lines[0].widthPx, 1);
});
for (const [name, record, drawable] of [
['unturned image', overlay(), true],
['full turn', overlay({ rotation: 360 }), true],
['turn past 360', overlay({ rotation: 405 }), true],
[
'across antimeridian',
overlay({
rectangle: { west: 179, south: -1, east: -179, north: 1 },
rotation: 30,
}),
true,
],
[
'near pole',
overlay({
rectangle: { west: 0, south: 85, east: 2, north: 86 },
rotation: 30,
}),
true,
],
[
'past pole',
overlay({
rectangle: { west: 0, south: 88, east: 20, north: 90 },
rotation: 45,
}),
false,
],
[
'all longitudes tinted',
overlay({
rectangle: { west: -180, south: -1, east: 180, north: 1 },
color: [0.5, 1, 1, 1],
}),
true,
],
])
test(name, () => {
const plan = planImport({ ...emptyRecords(), overlays: [record] });
assert.equal(plan.overlays.length, Number(drawable));
assert.equal(plan.report.undrawn, Number(!drawable));
if (drawable) assert.equal(plan.overlays[0].order, limits.OVERLAY_ORDER);
});
test('altitudes, look grouping and budgets are planned without decoding images', () => {
const icon = { image: { url: 'pin' }, color: [1, 1, 1, 1] };
const records = {
...emptyRecords(),
points: ['ground', 'relative', 'absolute'].map((altitude) => ({
position: [2, 3, 40],
altitude,
icon,
})),
overlays: Array.from({ length: 9 }, (_, i) =>
overlay({
drawOrder: 9 - i,
rotation: 30,
color: [i / 9, 1, 1, 0.8],
}),
),
};
const context = {
images: new Map([
['pin', { width: 1000, height: 1000 }],
['local-image', { width: 8192, height: 8192 }],
]),
};
const plan = planImport(records, undefined, context);
assert.deepEqual(
plan.pins.map((p) => p.depth),
['always', 'normal'],
);
assert.deepEqual(
plan.pins.flatMap((p) => p.pins.map((pin) => [pin.height, pin.offset])),
[
[null, 0],
[null, 40],
[40, 0],
],
);
assert.deepEqual(
plan.overlays.map((o) => o.record.drawOrder),
[1, 2, 3, 4, 5, 6, 7, 8, 9],
);
assert.ok(
plan.overlays.reduce(
(sum, o) => sum + o.canvas.width * o.canvas.height,
0,
) <= limits.OVERLAY_PIXEL_BUDGET,
);
assert.ok(plan.pins.every((look) => look.maxSide <= limits.MAX_ICON_SIDE));
assert.equal(plan.report.drawn, 12);
assert.deepEqual(plan.images, []);
});
test('decode failures retain the untransformed layer and report unsupported raised images', () => {
const records = {
...emptyRecords(),
points: [
{
position: [0, 0],
altitude: 'ground',
icon: { image: { url: 'missing' } },
},
],
overlays: [
overlay({ url: 'missing', rotation: 30 }),
overlay({ url: 'missing', altitudeMode: 'absolute', altitude: 100 }),
],
};
const plan = planImport(records, undefined, {
images: new Map([['missing', null]]),
});
assert.equal(plan.pins[0].draw, false);
assert.equal(plan.overlays[0].canvas, null);
assert.deepEqual(plan.overlays[0].rectangle, records.overlays[0].rectangle);
assert.deepEqual(plan.report, {
drawn: 1,
undrawn: 1,
kinds: { raisedOverlay: 1 },
});
assert.deepEqual(
plan.undrawn.map((item) => item.reason),
['raisedOverlay', 'icon'],
);
});
for (const [altitudeMode, altitude, url, expectedHeight] of [
['absolute', -100, 'local-image', -100],
['absolute', 200, 'local-image', 200],
['relative', 200, 'local-image', null],
['ground', 200, 'local-image', null],
['absolute', 200, null, 200],
])
test(`overlay altitude ${altitudeMode} ${altitude}, image ${Boolean(url)}`, async () => {
const { cartographic } = await import('../geo/index.js');
const record = overlay({ altitudeMode, altitude, url, rotation: 30 });
const plan = planImport({ ...emptyRecords(), overlays: [record] });
assert.equal(plan.report.drawn, 1);
assert.equal(plan.report.undrawn, 0);
assert.equal(plan.meshes.length, Number(expectedHeight !== null));
if (expectedHeight !== null) {
const positions = plan.meshes[0].positions;
for (let i = 0; i < positions.length; i += 3) {
const height = cartographic.fromCartesian({
x: positions[i],
y: positions[i + 1],
z: positions[i + 2],
}).height;
assert.ok(Math.abs(height - expectedHeight) < 0.001);
}
}
});
test('equal overlay orders retain document order', () => {
const a = overlay({ name: 'a', drawOrder: 3 });
const b = overlay({ name: 'b', drawOrder: -1 });
const c = overlay({ name: 'c', drawOrder: 3 });
const plan = planImport({ ...emptyRecords(), overlays: [a, b, c] });
assert.deepEqual(
plan.overlays.map((item) => item.record.name),
['b', 'a', 'c'],
);
});
+1 -2
View File
@@ -69,8 +69,7 @@
// absolute overlay to it.
// @property {'ground'|'relative'|'absolute'} altitudeMode
/** Longest plain-text name kept from a file's features. */
const MAX_IMPORT_NAME_LENGTH = 120;
import { MAX_IMPORT_NAME_LENGTH } from './geoImportLimits.js';
/** A feature name as short plain text (no control characters), or undefined. */
export function importFeatureName(value) {
+2 -5
View File
@@ -1,9 +1,6 @@
/** Session-local file controls, serial loading and application-owned navigation. */
import {
GeoImportError,
MAX_IMPORTED_FILES,
loadGeoFile,
} from '../data/geoFileImport.js';
import { GeoImportError, loadGeoFile } from '../data/geoFileImport.js';
import { MAX_IMPORTED_FILES } from '../data/geoImportLimits.js';
import { drawImportedFile } from '../data/geoImportDisplay.js';
import { formatNumber, t, tn, translateKnown } from '../i18n/index.js';
import { flyToFootprint } from './flyToDataSource.js';
+2 -1
View File
@@ -9,7 +9,8 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { createGeoFileImport, summarize } from './geoFileImport.js';
import { GeoImportError, MAX_IMPORTED_FILES } from '../data/geoFileImport.js';
import { GeoImportError } from '../data/geoFileImport.js';
import { MAX_IMPORTED_FILES } from '../data/geoImportLimits.js';
import { emptyRecords, recordFootprint } from '../data/geoImportRecords.js';
class FakeClassList {