mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 13:52:19 +00:00
Merge branch 'german/refactor-e9-import-plan' into german/navara-refactor
This commit is contained in:
commit
49b180f1fd
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