mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 13:52:19 +00:00
Plan KML and KMZ drawing in one pure function, checked against Cesium
src/data/geoImportPlan.js decides what an imported file draws and with what: raised or clamped geometry, rings and holes, rotation near the poles and across 360 degrees, absolute and relative altitudes, draw order, canvas budgets and the vertex and ring limits, with no engine and no DOM. drawImportedFile draws that plan through the globe contract and keeps pin placement, yielding and cancellation. Every limit of the import lives in src/data/geoImportLimits.js with what it protects. scripts/fixtures/geo-import holds 13 files covering those cases, and reference.json what Cesium's own KML and GeoJSON data sources make of them; scripts/qa-geo-import-reference.mjs records it and compares the plan with it. Property tests on seeded random polygons check area, bounds, ring closure and winding. Writing the plan fixed three cases: overlays below the ellipsoid kept a negative height, raised plain-colour overlays now draw, and the vertex limit no longer depends on whether a ring repeats its first point.
This commit is contained in:
1 parent
9c2a2d6951
commit
9ae7dea918
32 files changed
+103263
-458
No files matched your search
+20
-19
@@ -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.'));
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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'],
|
||||
);
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user