fix(import): correct clamping and bound local-file rendering

Normalize GeoJSON ground footprints, preserve explicit KML track heights,
and tessellate ground paths before native loading. Validate coordinates,
geometry types and decoded image budgets before allocating native resources;
strip loader-sensitive metadata and settle deferred failures and cancellation.

Render static pins in owned billboard collections instead of walking tens
of thousands of point entities each frame. Refine nearby ground heights in
batches, isolate imported pins from height picks, and release owned resources
and point selections on removal. Use safe ray origins above a hidden coarse
globe and retain valid mesh heights outside the current footprint.

Frame complete geometry with viewport-aware nadir arrivals, including walls
and rotated overlays; respect camera gestures and accept document file drops.
Strengthen real-loader, native 3D Tiles, paint, clock and lifecycle coverage.
Addresses both P1 and all 25 P2 findings in the follow-up review of #1122.
This commit is contained in:
Mazeyar Moeini Feizabadi committed 2026-10-15 04:10:41 +02:00
1 parent 4092c2e9a2
commit 5ea6d81310
19 files changed
+2023 -332

No files matched your search

+2
View File
@@ -63,6 +63,8 @@ export function createApplicationTools({
const geoFileImport = initGeoFileImport({
viewer,
navigation: styleManager,
ground: operations.surface?.groundFloor,
subscribeMapSource: (listener) => mapStackController?.subscribe?.(listener),
});
defer(() => geoFileImport?.destroy());
// DATA ▸ Recent Imagery: the box tool claims the pointer like Draw and the
+15
View File
@@ -0,0 +1,15 @@
import * as Cesium from 'cesium';
/** Camera range with breathing room in both viewport dimensions. */
export function rangeForBoundingSphere(viewer, radius) {
const frustum = viewer.camera?.frustum;
const verticalFov = Number(frustum?.fov) || Cesium.Math.toRadians(60);
const aspectRatio = Math.max(0.5, Number(frustum?.aspectRatio) || 1);
const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * aspectRatio);
const limitingFov = Math.min(verticalFov, horizontalFov);
// Target 57% sphere occupancy so perspective still leaves at least 40%
// measured roof-to-base breathing room in ordinary oblique building views.
const occupiedViewportFraction = 0.57;
const desiredAngularRadius = (limitingFov * occupiedViewportFraction) / 2;
return (radius / Math.sin(desiredAngularRadius)) * 1.05;
}
+466 -123
View File
@@ -1,36 +1,7 @@
/**
* Your own geodata on the globe: GeoJSON, KML, KMZ and GPX files read in the
* browser and handed to Cesium's own data sources.
*
* Nothing here talks to the network. The file never leaves the page, and a
* KML/KMZ file cannot make the page fetch anything either: network links,
* remote icons, remote styles, models and photo/screen overlays are removed
* before Cesium sees the document (`sanitizeKmlDocument`). Images packed
* inside a KMZ are served to Cesium from blob URLs the caller revokes.
*
* What the file says about itself stays inert: descriptions, balloons,
* extended data and GeoJSON properties are dropped from every entity, names
* are kept only as short plain text, and no native Cesium label is drawn
* (`scrubImportedEntities`). GeoJSON and GPX draw in the row's color:
* simplestyle properties are dropped, including `marker-symbol`, so no icon
* asset is loaded on the file's behalf. KML and KMZ keep their own styles.
*
* An import never moves the app's shared clock: dated KML (TimeSpan,
* TimeStamp, gx:Track) is drawn for all time, and the data source exposes no
* clock for the viewer to adopt (`detachImportedTime`). GPX is read here
* rather than by Cesium (`gpxToGeoJson`), keeping each track segment as its
* own line and leaving times out.
*
* Size is checked before Cesium builds anything (`countGeoJsonGeometry`,
* `countKmlGeometry`): a file with too many features or vertices is refused
* up front instead of after the entities exist.
*
* The bounded pieces — format detection, the KMZ (zip) reader, the size and
* count limits — are plain functions so they run under `node --test`; the
* Cesium loaders need a browser DOM and are exercised by
* `scripts/qa-geo-import.mjs`.
*/
/** Session-local geodata loading with bounded geometry and local-only file references. */
import * as Cesium from 'cesium';
import { createImportImageBudget } from './geoImportImages.js';
import { createImportedPins } from './geoImportPins.js';
/** Largest file accepted, in bytes. */
export const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
@@ -84,6 +55,10 @@ const KML_REMOVED_ELEMENTS = new Set([
'TimeStamp',
'LookAt',
'Camera',
'visibility',
'viewRefreshMode',
'viewFormat',
'httpQuery',
]);
/** An error whose message is meant for the person who chose the file. */
@@ -186,7 +161,13 @@ export function countGeoJsonGeometry(
if (!node || typeof node !== 'object') continue;
if (node.type === 'FeatureCollection') {
const list = Array.isArray(node.features) ? node.features : [];
for (let i = 0; i < list.length; i++) stack.push(list[i]);
for (let i = 0; i < list.length; i++) {
if (list[i]?.type !== 'Feature')
throw new GeoImportError(
'Every GeoJSON feature must have type Feature.',
);
stack.push(list[i]);
}
continue;
}
if (node.type === 'Feature') {
@@ -200,11 +181,27 @@ export function countGeoJsonGeometry(
continue;
}
if (node.type === 'GeometryCollection') {
const list = Array.isArray(node.geometries) ? node.geometries : [];
for (let i = 0; i < list.length; i++) stack.push(list[i]);
const list = node.geometries;
if (!Array.isArray(list))
throw new GeoImportError('That GeoJSON has invalid geometries.');
for (let i = 0; i < list.length; i++) {
if (!list[i] || ['Feature', 'FeatureCollection'].includes(list[i].type))
throw new GeoImportError(
'That GeoJSON contains an unsupported geometry.',
);
stack.push(list[i]);
}
continue;
}
if (GEOJSON_TYPES.has(node.type)) {
const depth = {
Point: 0,
MultiPoint: 1,
LineString: 1,
MultiLineString: 2,
Polygon: 2,
MultiPolygon: 3,
}[node.type];
if (depth !== undefined) {
// Multipart geometries create one Cesium entity per component, even
// when the document wraps them in just one Feature.
entities += node.type.startsWith('Multi')
@@ -215,16 +212,30 @@ export function countGeoJsonGeometry(
if (entities > maxEntities) return result('entities');
// Walk the coordinate arrays iteratively: a position is an array whose
// first item is a number.
const coords = [node.coordinates];
const coords = [[node.coordinates, depth]];
while (coords.length) {
const c = coords.pop();
if (!Array.isArray(c)) continue;
if (typeof c[0] === 'number') {
const [c, remaining] = coords.pop();
if (!Array.isArray(c))
throw new GeoImportError('That GeoJSON has invalid coordinates.');
if (remaining === 0) {
if (
c.length < 2 ||
c.length > 3 ||
!c.every(Number.isFinite) ||
Math.abs(c[0]) > 180 ||
Math.abs(c[1]) > 90 ||
(c.length === 3 && Math.abs(c[2]) > 1_000_000)
)
throw new GeoImportError('That GeoJSON has invalid coordinates.');
positions += 1;
if (positions > maxPositions) return result('positions');
} else for (let i = 0; i < c.length; i++) coords.push(c[i]);
} else
for (let i = 0; i < c.length; i++) coords.push([c[i], remaining - 1]);
}
}
} else
throw new GeoImportError(
'That GeoJSON contains an unsupported geometry.',
);
}
return result();
}
@@ -248,42 +259,32 @@ function overLimitError({ over, features }) {
);
}
// simplestyle-spec keys Cesium honors. `marker-symbol` would load a maki icon
// asset on the file's behalf; the colors would override the row's color.
const GEOJSON_STYLE_KEYS = Object.freeze([
'marker-symbol',
'marker-color',
'marker-size',
'stroke',
'stroke-opacity',
'stroke-width',
'fill',
'fill-opacity',
]);
/**
* Drop GeoJSON simplestyle properties, so a GeoJSON file draws in its row's
* color like a GPX file and no icon is fetched for it. Mutates and returns
* the object.
* @param {object} geojson
* @returns {object}
*/
export function stripGeoJsonStyleProperties(geojson) {
// The import uses WGS84 ground footprints and a short name only. Remove
// loader-sensitive metadata before Cesium allocates properties or entity IDs.
function prepareGeoJson(geojson) {
const stack = [geojson];
while (stack.length) {
const node = stack.pop();
if (!node || typeof node !== 'object') continue;
if (node.type === 'FeatureCollection' && Array.isArray(node.features))
for (let i = 0; i < node.features.length; i++)
stack.push(node.features[i]);
else if (
node.type === 'Feature' &&
node.properties &&
typeof node.properties === 'object'
)
for (const key of GEOJSON_STYLE_KEYS) delete node.properties[key];
if (!node) continue;
delete node.crs;
delete node.id;
if (node.type === 'FeatureCollection') {
for (const feature of node.features) stack.push(feature);
} else if (node.type === 'Feature') {
const name = importFeatureName(node.properties?.name);
node.properties = name ? { name } : {};
stack.push(node.geometry);
} else if (node.type === 'GeometryCollection') {
for (const geometry of node.geometries) stack.push(geometry);
} else {
const coords = [node.coordinates];
while (coords.length) {
const value = coords.pop();
if (typeof value[0] === 'number') value.length = 2;
else for (const child of value) coords.push(child);
}
}
}
return geojson;
}
/** Inflate raw-deflate bytes with the platform's stream, stopping at `maxBytes`. */
@@ -498,14 +499,26 @@ export function sanitizeKmlDocument(doc, resolveLocal = () => null) {
// every discarded element type. Strip unused metadata before the loader
// constructs descriptions, availability intervals and camera objects.
for (const el of [...doc.getElementsByTagName('*')])
if (KML_REMOVED_ELEMENTS.has(el.localName)) drop(el);
if (
KML_REMOVED_ELEMENTS.has(el.localName) ||
el.localName in Object.prototype
)
drop(el);
else if (!['Style', 'StyleMap'].includes(el.localName))
el.removeAttribute('id');
for (const href of elementsNamed(doc, 'href')) {
const value = (href.textContent || '').trim();
if (!value) continue;
const local = resolveLocal(value);
if (local) href.textContent = local;
else if (/^data:image\/(png|jpeg|gif|webp|bmp);/i.test(value)) continue;
else {
const icon = href.parentNode;
const overlay = icon?.parentNode;
if (overlay?.localName === 'GroundOverlay') {
drop(overlay);
continue;
}
// An empty Icon explicitly hides the billboard in Cesium. Remove the
// blocked icon definition so the loader supplies its local default pin.
// Authored empty Icons (with no blocked href) still mean no marker.
@@ -516,12 +529,18 @@ export function sanitizeKmlDocument(doc, resolveLocal = () => null) {
);
}
}
for (const overlay of elementsNamed(doc, 'GroundOverlay')) {
if (!elementsNamed(overlay, 'href').length) drop(overlay);
}
for (const styleUrl of elementsNamed(doc, 'styleUrl')) {
if (!(styleUrl.textContent || '').trim().startsWith('#')) drop(styleUrl);
const value = (styleUrl.textContent || '').trim();
if (!value.startsWith('#')) drop(styleUrl);
else styleUrl.textContent = value;
}
for (const pair of elementsNamed(doc, 'Pair'))
if (
!childText(pair, 'key') ||
(!childrenNamed(pair, 'styleUrl').length &&
!childrenNamed(pair, 'Style').length)
)
drop(pair);
// Draw recorded KML paths as their original static vertices. Letting the
// loader build time-dynamic tracks would expand each into thousands of
// interpolated points later and exceed the checked geometry budget.
@@ -539,6 +558,8 @@ export function sanitizeKmlDocument(doc, resolveLocal = () => null) {
);
coordinates.textContent = recordedCoordinates.join(' ');
line.appendChild(coordinates);
for (const mode of childrenNamed(track, 'altitudeMode'))
line.appendChild(mode.cloneNode(true));
track.parentNode?.replaceChild(line, track);
}
for (const tracks of elementsNamed(doc, 'MultiTrack')) {
@@ -550,6 +571,28 @@ export function sanitizeKmlDocument(doc, resolveLocal = () => null) {
geometry.appendChild(line);
tracks.parentNode?.replaceChild(geometry, tracks);
}
for (const line of [
...elementsNamed(doc, 'LineString'),
...elementsNamed(doc, 'LinearRing'),
]) {
const mode = childText(line, 'altitudeMode');
if (
!mode ||
mode === 'clampToGround' ||
mode === 'clampToSeaFloor' ||
!childrenNamed(line, 'tessellate').length
) {
let tessellate = childrenNamed(line, 'tessellate')[0];
if (!tessellate) {
tessellate = doc.createElementNS(
'http://www.opengis.net/kml/2.2',
'tessellate',
);
line.appendChild(tessellate);
}
tessellate.textContent = '1';
}
}
return { removed };
}
@@ -708,6 +751,22 @@ export function countKmlGeometry(
]);
for (const el of doc.getElementsByTagName('*')) {
const name = el.localName;
if (
['north', 'south', 'east', 'west', 'rotation', 'altitude'].includes(name)
) {
const value = Number(el.textContent);
const limit = ['north', 'south'].includes(name)
? 90
: name === 'altitude'
? 1_000_000
: 360;
if (
!el.textContent.trim() ||
!Number.isFinite(value) ||
Math.abs(value) > limit
)
throw new GeoImportError('That KML has invalid geometry bounds.');
}
// Cesium creates entities for folders, a parent for every placemark, and
// a child for each geometry in a MultiGeometry, including nested containers.
if (
@@ -738,21 +797,13 @@ export function countKmlGeometry(
if (features > maxFeatures) return result('features');
} else if (name === 'coordinates') {
const text = el.textContent || '';
// Tuples are separated by whitespace; count runs without splitting
// a large string into a large array.
let inTuple = false;
for (let i = 0; i < text.length; i++) {
const code = text.charCodeAt(i);
// Match Cesium's whitespace splitting, keeping the common ASCII
// path cheap and avoiding a large token array during preflight.
const space = code <= 32 || (code > 127 && /\s/.test(text[i]));
if (!space && !inTuple) {
positions += 1;
if (positions > maxPositions) return result('positions');
}
inTuple = !space;
for (const match of text.matchAll(/\S+/g)) {
checkKmlPosition(match[0].split(','));
positions += 1;
if (positions > maxPositions) return result('positions');
}
} else if (name === 'coord') {
checkKmlPosition(el.textContent.trim().split(/\s+/));
positions += 1;
if (positions > maxPositions) return result('positions');
}
@@ -760,6 +811,20 @@ export function countKmlGeometry(
return result('');
}
function checkKmlPosition(tuple) {
const numbers = tuple.map(Number);
if (
tuple.length < 2 ||
tuple.length > 3 ||
tuple.some((v) => !v.trim()) ||
!numbers.every(Number.isFinite) ||
Math.abs(numbers[0]) > 180 ||
Math.abs(numbers[1]) > 90 ||
(numbers.length === 3 && Math.abs(numbers[2]) > 1_000_000)
)
throw new GeoImportError('That KML has invalid coordinates.');
}
/**
* Read a parsed GPX document into GeoJSON: waypoints become points, each
* route a line and each track one MultiLineString with a line per `<trkseg>`
@@ -891,6 +956,11 @@ export function scrubImportedEntities(dataSource) {
entity.label = undefined;
entity.properties = undefined;
if (entity.kml && typeof entity.kml === 'object') entity.kml = undefined;
if (
entity.billboard?.heightReference?.getValue?.() ===
Cesium.HeightReference.CLAMP_TO_GROUND
)
entity.billboard.disableDepthTestDistance = Number.POSITIVE_INFINITY;
const property = entity.billboard?.image;
if (property?.isConstant === false) continue;
const image = property?.getValue?.();
@@ -934,6 +1004,25 @@ export function detachImportedTime(dataSource) {
}
function parseXml(text, what) {
// Match the namespace repairs Cesium applies to ordinary exported KML.
if (what !== 'GPX')
text = String(text).replace(/<kml\b[^>]*>/i, (root) => {
const seen = new Set();
root = root.replace(
/\s+xmlns:([\w.-]+)\s*=\s*(["'])[^"']*\2/g,
(declaration, prefix) => {
if (seen.has(prefix)) return '';
seen.add(prefix);
return declaration;
},
);
if (!seen.has('xsi') && /[<\s]xsi:/.test(text))
root = root.replace(
/>$/,
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">',
);
return root;
});
const doc = new DOMParser().parseFromString(
String(text).replace(/^/, ''),
'application/xml',
@@ -943,21 +1032,224 @@ function parseXml(text, what) {
return doc;
}
function decodeXml(bytes) {
const encoding =
bytes[0] === 0xff && bytes[1] === 0xfe
? 'utf-16le'
: bytes[0] === 0xfe && bytes[1] === 0xff
? 'utf-16be'
: 'utf-8';
return new TextDecoder(encoding).decode(bytes);
}
function checkLocalImages(doc, entries = new Map(), main = '') {
const charge = createImportImageBudget();
const checked = new Set();
for (const href of elementsNamed(doc, 'href')) {
const value = href.textContent.trim();
if (checked.has(value)) continue;
checked.add(value);
const path = resolveKmzPath(value, main, entries);
try {
if (path && kmzImageType(path)) charge(path, entries.get(path));
else if (/^data:image\/(png|jpeg|gif|webp|bmp);/i.test(value)) {
const comma = value.indexOf(',');
const header = value.slice(0, comma);
const body = value.slice(comma + 1);
const raw = /;base64$/i.test(header)
? atob(body)
: decodeURIComponent(body);
charge(
value,
Uint8Array.from(raw, (c) => c.charCodeAt(0)),
);
}
} catch {
throw new GeoImportError(
'That file has an unreadable image or exceeds the 16-megapixel image limit.',
);
}
}
}
// Cesium also processes KML in deferred tasks; an exception there does not
// reject its load promise. Keep a failed file from blocking the serial queue.
async function loadKml(doc, viewer, signal) {
let removeError;
try {
return await boundedImport(
Promise.race([
Cesium.KmlDataSource.load(doc, {
camera: viewer.scene.camera,
canvas: viewer.scene.canvas,
clampToGround: true,
}),
new Promise((_, reject) => {
const failed = () => {
reject(new GeoImportError('That KML could not be read.'));
};
globalThis.addEventListener?.('error', failed);
removeError = () => globalThis.removeEventListener?.('error', failed);
}),
]),
signal,
);
} finally {
removeError?.();
}
}
// Resolve a static file's ground pins in batches before they are visualized.
// Per-billboard CLAMP_TO_GROUND subscriptions scale poorly on large imports.
const groundPins = new WeakMap();
async function settleGroundPins(dataSource, viewer, signal) {
let pins = groundPins.get(dataSource);
if (!pins) {
pins = [];
for (const entity of dataSource.entities.values) {
const reference = entity.billboard?.heightReference?.getValue?.();
if (
reference === Cesium.HeightReference.CLAMP_TO_GROUND ||
reference === Cesium.HeightReference.RELATIVE_TO_GROUND
) {
const position = entity.position?.getValue?.(Cesium.JulianDate.now());
if (!position) continue;
const cartographic = Cesium.Cartographic.fromCartesian(position);
if (!cartographic)
throw new GeoImportError('That file has invalid point coordinates.');
pins.push({
entity,
original: position,
cartographic,
offset:
reference === Cesium.HeightReference.RELATIVE_TO_GROUND
? cartographic.height
: 0,
});
entity.billboard.heightReference = Cesium.HeightReference.NONE;
entity.billboard.disableDepthTestDistance = Number.POSITIVE_INFINITY;
}
}
groundPins.set(dataSource, pins);
}
for (let at = 0; at < pins.length; at += 256) {
signal?.throwIfAborted();
const batch = pins.slice(at, at + 256);
const positions = batch.map(
({ cartographic: c }) =>
new Cesium.Cartographic(c.longitude, c.latitude, 0),
);
const mesh =
viewer.scene?.globe?.show === false &&
viewer.scene?.clampToHeightSupported;
// A coarse rendered ellipsoid mesh can report a height kilometres below
// zero. Its provider's exact height is zero even while a 3D map hides it;
// a buried starting point can make the downward mesh ray miss the surface.
const ellipsoid =
viewer.terrainProvider instanceof Cesium.EllipsoidTerrainProvider;
for (let i = 0; i < positions.length; i++) {
const sampled = ellipsoid
? 0
: viewer.scene?.globe?.getHeight?.(positions[i]);
positions[i].height = mesh
? (batch[i].groundHeight ??
Math.max(0, Number.isFinite(sampled) ? sampled : 0))
: Number.isFinite(sampled)
? sampled
: batch[i].groundHeight || 0;
}
const points = positions.map((c) =>
Cesium.Cartesian3.fromRadians(c.longitude, c.latitude, c.height),
);
const height = viewer.camera?.positionCartographic?.height;
if (mesh && Number.isFinite(height) && height < 250_000) {
const rectangle = viewer.camera.computeViewRectangle?.();
const indices = [];
for (let i = 0; i < positions.length; i++)
if (rectangle && Cesium.Rectangle.contains(rectangle, positions[i]))
indices.push(i);
if (indices.length) {
const resolved = await boundedImport(
viewer.scene.clampToHeightMostDetailed(
indices.map((i) => {
const c = positions[i];
return Cesium.Cartesian3.fromRadians(
c.longitude,
c.latitude,
Math.max(0, c.height),
);
}),
),
signal,
);
for (let i = 0; i < indices.length; i++)
if (resolved[i]) points[indices[i]] = resolved[i];
}
}
signal?.throwIfAborted();
for (let i = 0; i < batch.length; i++) {
const item = batch[i];
const c = points[i] && Cesium.Cartographic.fromCartesian(points[i]);
if (!c)
throw new GeoImportError(
'Ground heights are not available for that file. Try again when the map has loaded.',
);
item.groundHeight = c.height;
c.height += item.offset;
item.entity.position =
Math.abs(c.height - item.cartographic.height) < 1e-6
? item.original
: Cesium.Cartesian3.fromRadians(c.longitude, c.latitude, c.height);
}
if (at + batch.length < pins.length)
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
async function boundedImport(promise, signal) {
let timer, abort;
try {
signal?.throwIfAborted();
return await Promise.race([
promise,
new Promise((_, reject) => {
abort = () => reject(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
timer = setTimeout(
() => reject(new GeoImportError('That file took too long to read.')),
60_000,
);
}),
]);
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', abort);
}
}
/**
* Load one file into a Cesium data source, clamped to the ground.
* Load a ground footprint, retaining explicit KML altitude modes. Call mount after
* adding the source; revoke owns pin and blob cleanup.
* @param {File|Blob & {name?: string}} file
* @param {{viewer: Cesium.Viewer, color?: string}} options `color` is a CSS color.
* @returns {Promise<{dataSource: Cesium.DataSource, name: string,
* format: string, entityCount: number, removed: number, revoke: Function}>}
* format: string, entityCount: number, removed: number, mount: Function, revoke: Function}>}
*/
export async function loadGeoFile(file, { viewer, color = '#39d0ff' }) {
export async function loadGeoFile(
file,
{ viewer, color = '#39d0ff', signal } = {},
) {
signal = signal
? AbortSignal.any([signal, AbortSignal.timeout(60_000)])
: AbortSignal.timeout(60_000);
signal?.throwIfAborted();
if (!file || !Number.isFinite(file.size))
throw new GeoImportError('Nothing to import.');
if (file.size > MAX_IMPORT_BYTES)
throw new GeoImportError(
`That file is larger than ${MAX_IMPORT_BYTES / 1024 / 1024} MB.`,
`That file is larger than ${MAX_IMPORT_BYTES / 1024 / 1024} MiB.`,
);
const bytes = new Uint8Array(await file.arrayBuffer());
let bytes = new Uint8Array(await boundedImport(file.arrayBuffer(), signal));
const format = detectGeoFileFormat(file.name, bytes.subarray(0, 1024));
if (!format)
throw new GeoImportError(
@@ -965,8 +1257,11 @@ export async function loadGeoFile(file, { viewer, color = '#39d0ff' }) {
);
const stroke = Cesium.Color.fromCssColorString(color);
const urls = [];
const revoke = () =>
let pins;
const revoke = () => {
pins?.destroy();
urls.splice(0).forEach((url) => URL.revokeObjectURL(url));
};
const name = importDisplayName(file.name);
let dataSource;
let removed = 0;
@@ -974,56 +1269,62 @@ export async function loadGeoFile(file, { viewer, color = '#39d0ff' }) {
if (format === 'geojson' || format === 'gpx') {
let geojson;
if (format === 'gpx') {
geojson = gpxToGeoJson(
parseXml(new TextDecoder().decode(bytes), 'GPX'),
);
geojson = gpxToGeoJson(parseXml(decodeXml(bytes), 'GPX'));
} else {
geojson = parseGeoJsonText(new TextDecoder().decode(bytes));
stripGeoJsonStyleProperties(geojson);
}
const size = countGeoJsonGeometry(geojson);
if (size.over) throw overLimitError(size);
dataSource = await Cesium.GeoJsonDataSource.load(geojson, {
clampToGround: true,
stroke,
fill: stroke.withAlpha(0.25),
strokeWidth: 3,
markerColor: stroke,
// No HTML description table is built from the file's properties.
describe: () => undefined,
});
if (!size.positions)
throw new GeoImportError('That file has nothing to draw.');
prepareGeoJson(geojson);
bytes = null;
dataSource = await boundedImport(
Cesium.GeoJsonDataSource.load(geojson, {
clampToGround: true,
stroke,
fill: stroke.withAlpha(0.25),
strokeWidth: 3,
markerColor: stroke,
// No HTML description table is built from the file's properties.
describe: () => undefined,
}),
signal,
);
} else {
let text;
let entries;
let main;
let resolveLocal = () => null;
if (format === 'kmz') {
const entries = await readZipEntries(bytes);
const main = kmzMainDocument(entries);
entries = await readZipEntries(bytes);
main = kmzMainDocument(entries);
if (!main) throw new GeoImportError('That KMZ holds no KML document.');
text = new TextDecoder().decode(entries.get(main));
text = decodeXml(entries.get(main));
resolveLocal = kmzImageResolver(entries, main, (url) => urls.push(url));
} else {
text = new TextDecoder().decode(bytes);
text = decodeXml(bytes);
}
const doc = parseXml(text, format.toUpperCase());
text = null;
bytes = null;
// Bound geometry before transformations, then count the styles and
// image links that survive sanitizing and sharing repeated styles.
const size = countKmlGeometry(doc, { maxLinks: Infinity });
if (size.over) throw overLimitError(size);
checkLocalImages(doc, entries, main);
removed = sanitizeKmlDocument(doc, resolveLocal).removed;
entries?.clear();
deduplicateKmlStyles(doc);
const normalizedSize = countKmlGeometry(doc);
if (normalizedSize.over) throw overLimitError(normalizedSize);
dataSource = await Cesium.KmlDataSource.load(doc, {
camera: viewer.scene.camera,
canvas: viewer.scene.canvas,
clampToGround: true,
});
dataSource = await loadKml(doc, viewer, signal);
}
} catch (error) {
revoke();
if (error instanceof GeoImportError) throw error;
throw new GeoImportError(
`That ${format.toUpperCase()} file could not be read${error?.message ? `: ${error.message}` : '.'}`,
`That ${format.toUpperCase()} file could not be read.`,
);
}
const entityCount = dataSource.entities.values.length;
@@ -1033,15 +1334,57 @@ export async function loadGeoFile(file, { viewer, color = '#39d0ff' }) {
`That file has ${entityCount.toLocaleString()} map objects; the limit is ${MAX_IMPORT_ENTITIES.toLocaleString()}.`,
);
}
if (entityCount === 0) {
const now = Cesium.JulianDate.now();
const drawable = dataSource.entities.values.some(
(e) =>
e.billboard?.image?.getValue(now) ||
e.point ||
e.rectangle ||
e.polyline?.positions?.getValue(now)?.length >= 2 ||
e.wall?.positions?.getValue(now)?.length >= 2 ||
e.polygon?.hierarchy?.getValue(now)?.positions?.length >= 3,
);
if (!drawable) {
revoke();
throw new GeoImportError('That file has nothing to draw.');
}
scrubImportedEntities(dataSource);
try {
await settleGroundPins(dataSource, viewer, signal);
} catch (error) {
revoke();
throw error instanceof GeoImportError
? error
: new GeoImportError(
'Ground heights could not be read. Try that file again.',
);
}
// Keep the clock invariant explicit even though unused KML dates have
// already been stripped and all supported geometries are static.
detachImportedTime(dataSource);
try {
pins = createImportedPins(dataSource, viewer);
} catch {
revoke();
throw new GeoImportError('That file has invalid point graphics.');
}
// The display name travels beside the data source (the file name, not
// anything the document says about itself).
return { dataSource, name, format, entityCount, removed, revoke };
return {
dataSource,
name,
format,
entityCount,
removed,
revoke,
mount: () => pins.mount(),
refreshGround: (refreshSignal) =>
settleGroundPins(
dataSource,
viewer,
refreshSignal
? AbortSignal.any([refreshSignal, AbortSignal.timeout(15_000)])
: AbortSignal.timeout(15_000),
).then(() => pins.sync()),
};
}
+293 -25
View File
@@ -4,6 +4,7 @@ import { deflateRawSync } from 'node:zlib';
import * as Cesium from 'cesium';
import {
GeoImportError,
loadGeoFile,
MAX_IMPORT_POSITIONS,
MAX_IMPORT_ENTITIES,
countGeoJsonGeometry,
@@ -14,7 +15,6 @@ import {
importFeatureName,
scrubImportedEntities,
sanitizeKmlDocument,
stripGeoJsonStyleProperties,
detectGeoFileFormat,
importDisplayName,
kmzImageResolver,
@@ -24,6 +24,298 @@ import {
readZipEntries,
resolveKmzPath,
} from './geoFileImport.js';
import { createImportImageBudget, importImageSize } from './geoImportImages.js';
test('real loader grounds XYZ GeoJSON and strips loader-sensitive metadata before allocation', async () => {
const previous = Cesium.PinBuilder.prototype.fromColor;
Cesium.PinBuilder.prototype.fromColor = () => ({
toDataURL: () => 'data:image/png;base64,local',
tagName: 'CANVAS',
});
try {
const object = {
type: 'FeatureCollection',
crs: { type: 'name', properties: { name: 'unsupported' } },
features: [
{
type: 'Feature',
id: 'kartaview-owned',
properties: { name: 'Test', addProperty: 1, 'marker-symbol': 'rail' },
geometry: { type: 'Point', coordinates: [1, 2, 999] },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[1, 2, 0],
[2, 2, 0],
[2, 3, 0],
[1, 2, 0],
],
],
},
},
],
};
const loaded = await loadGeoFile(
new File([JSON.stringify(object)], 'xyz.geojson'),
{
viewer: {
scene: {
frameState: { mode: Cesium.SceneMode.SCENE3D },
primitives: new Cesium.PrimitiveCollection(),
globe: { getHeight: () => 1234 },
},
},
},
);
const [point, polygon] =
loaded.dataSource.importedEntities || loaded.dataSource.entities.values;
assert.notEqual(point.id, 'kartaview-owned');
assert.equal(point.name, 'Test');
assert.equal(point.properties, undefined);
assert.equal(
point.billboard.heightReference.getValue(),
Cesium.HeightReference.NONE,
);
assert.equal(point.billboard.disableDepthTestDistance.getValue(), Infinity);
assert.ok(
Math.abs(
Cesium.Cartographic.fromCartesian(
point.position.getValue(Cesium.JulianDate.now()),
).height - 1234,
) < 1e-6,
);
assert.notEqual(polygon.polygon.perPositionHeight?.getValue(), true);
assert.equal(loaded.dataSource.clock, undefined);
assert.equal(
loaded.dataSource.entities.values.length,
1,
'only polygon geometry remains in the per-frame entity collection',
);
assert.equal(loaded.dataSource.importedBillboards.length, 1);
const update = Cesium.BillboardCollection.prototype.update;
let passes = 0;
Cesium.BillboardCollection.prototype.update = () => passes++;
try {
const collection = loaded.dataSource.importedBillboards;
collection.update({ passes: { pick: true, offscreen: true } });
assert.equal(passes, 0, 'height picks ignore imported markers');
collection.update({ passes: { pick: true, offscreen: false } });
collection.update({ passes: { render: true, offscreen: true } });
assert.equal(passes, 2, 'ordinary picks and screenshots include markers');
} finally {
Cesium.BillboardCollection.prototype.update = update;
}
loaded.mount();
loaded.dataSource.show = false;
assert.equal(loaded.dataSource.importedBillboards.show, false);
loaded.dataSource.show = true;
await loaded.refreshGround();
assert.equal(
point.billboard.heightReference.getValue(),
Cesium.HeightReference.NONE,
);
loaded.revoke();
assert.ok(loaded.dataSource.importedBillboards.isDestroyed());
} finally {
Cesium.PinBuilder.prototype.fromColor = previous;
}
});
test('real loader refuses empty files and malformed nested geometry before allocating entities', async () => {
for (const object of [
{ type: 'Feature', geometry: null },
{
type: 'FeatureCollection',
features: [{ geometry: { type: 'Point', coordinates: [1, 2] } }],
},
{ type: 'Feature', geometry: { type: 'Topology', arcs: [] } },
...[
[1, '2'],
[Infinity, 0],
[0, 91],
[0, 0, 1e100],
].map((coordinates) => ({ type: 'Point', coordinates })),
])
await assert.rejects(
loadGeoFile(new File([JSON.stringify(object)], 'bad.geojson'), {
viewer: {},
}),
GeoImportError,
);
});
test('KML tracks retain their explicit altitude and ground lines are tessellated', () => {
const track = xmlEl('Track', {}, [
xmlEl('altitudeMode', {}, 'absolute'),
xmlEl('coord', {}, '1 2 10000'),
xmlEl('coord', {}, '2 3 10000'),
]);
const line = xmlEl('LineString', {}, [xmlEl('coordinates', {}, '1,2 2,3')]);
const ground = xmlEl('Track', {}, [
xmlEl('coord', {}, '1 2 0'),
xmlEl('coord', {}, '2 3 0'),
]);
const root = xmlEl('kml', {}, [track, line, ground]);
sanitizeKmlDocument(xmlDoc(root));
assert.equal(
root.childNodes[0].childNodes.find((n) => n.localName === 'altitudeMode')
.textContent,
'absolute',
);
assert.equal(
root.childNodes[1].childNodes.find((n) => n.localName === 'tessellate')
.textContent,
'1',
);
assert.equal(
root.childNodes[2].childNodes.find((n) => n.localName === 'tessellate')
.textContent,
'1',
);
});
test('sanitizer normalizes styles, removes incomplete pairs and keeps colored overlays', () => {
const local = xmlEl('styleUrl', {}, ' #s ');
const pair = xmlEl('Pair', {}, [
xmlEl('key', {}, 'normal'),
xmlEl('styleUrl', {}, 'https://example.invalid/s'),
]);
const overlay = xmlEl('GroundOverlay', {}, [xmlEl('color', {}, 'ff00ff00')]);
const hidden = xmlEl('visibility', {}, '0');
const id = xmlEl('Placemark', { id: 'kartaview-123' });
const root = xmlEl('kml', {}, [
local,
xmlEl('StyleMap', { id: 's' }, [pair]),
overlay,
hidden,
id,
xmlEl('toString'),
]);
sanitizeKmlDocument(xmlDoc(root));
assert.equal(local.textContent, '#s');
assert.equal(pair.parentNode, null);
assert.ok(root.childNodes.includes(overlay));
assert.equal(hidden.parentNode, null);
assert.equal(id.getAttribute('id'), null);
assert.ok(!root.childNodes.some((n) => n.localName === 'toString'));
});
test('image budget rejects large decoded rasters before decoding and shares repeated images', () => {
const png = (w, h) => {
const b = new Uint8Array(24);
const v = new DataView(b.buffer);
v.setUint32(0, 0x89504e47);
b.set([73, 72, 68, 82], 12);
v.setUint32(16, w);
v.setUint32(20, h);
return b;
};
assert.deepEqual(importImageSize(png(4096, 4096)), [4096, 4096]);
const charge = createImportImageBudget();
charge('shared', png(4096, 4096));
charge('shared', png(4096, 4096));
assert.throws(() => charge('other', png(1, 1)), /limit/);
assert.throws(
() => createImportImageBudget()('huge', png(16384, 16384)),
/limit/,
);
});
test('pending file reads can be cancelled without waiting for the loader timeout', async () => {
const controller = new AbortController();
const file = {
name: 'waiting.geojson',
size: 1,
arrayBuffer: () => new Promise(() => {}),
};
const promise = loadGeoFile(file, { viewer: {}, signal: controller.signal });
controller.abort(new Error('cancelled by teardown'));
await assert.rejects(promise, /cancelled by teardown/);
});
test('a hidden coarse globe never seeds a mesh-clamp ray below the surface', async () => {
const previous = Cesium.PinBuilder.prototype.fromColor;
Cesium.PinBuilder.prototype.fromColor = () => ({
tagName: 'CANVAS',
toDataURL: () => 'data:image/png;base64,local',
});
const starts = [];
try {
for (const terrainProvider of [new Cesium.EllipsoidTerrainProvider(), {}]) {
const viewer = {
terrainProvider,
camera: {
positionCartographic: { height: 100000 },
computeViewRectangle: () => Cesium.Rectangle.MAX_VALUE,
},
scene: {
frameState: { mode: Cesium.SceneMode.SCENE3D },
globe: { show: false, getHeight: () => -12311 },
clampToHeightSupported: true,
clampToHeightMostDetailed: async (points) =>
points.map((p) => {
const c = Cesium.Cartographic.fromCartesian(p);
starts.push(c.height);
return c.height < -9000
? undefined
: Cesium.Cartesian3.fromRadians(c.longitude, c.latitude, 2000);
}),
},
};
const loaded = await loadGeoFile(
new File(
[JSON.stringify({ type: 'Point', coordinates: [1, 2] })],
'surface.geojson',
),
{ viewer },
);
assert.ok(
Math.abs(starts.at(-1)) < 1e-6,
`ray started at ${starts.at(-1)}`,
);
assert.ok(
Math.abs(
Cesium.Cartographic.fromCartesian(
loaded.dataSource.importedBillboards.get(0).position,
).height - 2000,
) < 1e-6,
);
const sampled = starts.length;
viewer.camera.computeViewRectangle = () => undefined;
await loaded.refreshGround();
assert.equal(
starts.length,
sampled,
'distant points do not start a detailed mesh sweep',
);
assert.ok(
Math.abs(
Cesium.Cartographic.fromCartesian(
loaded.dataSource.importedBillboards.get(0).position,
).height - 2000,
) < 1e-6,
'the last valid mesh height survives outside the camera footprint',
);
const point = loaded.dataSource.importedEntities[0];
const other = new Cesium.Entity({ id: 'unrelated-aircraft' });
viewer.trackedEntity = starts.length === 1 ? other : point;
viewer.selectedEntity = point;
loaded.revoke();
assert.equal(viewer.selectedEntity, undefined);
assert.equal(
viewer.trackedEntity,
starts.length === 1 ? other : undefined,
);
}
} finally {
Cesium.PinBuilder.prototype.fromColor = previous;
}
});
const bytes = (text) => new TextEncoder().encode(text);
@@ -756,30 +1048,6 @@ test('a KMZ image gets one blob URL however many hrefs name it', () => {
assert.deepEqual(kept, ['blob:test/1', 'blob:test/2']);
});
test('simplestyle is dropped: no icon fetched, the row color is what draws', () => {
const fc = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: {
'marker-symbol': 'rail',
'marker-color': '#f00',
stroke: '#0f0',
'stroke-width': 9,
fill: '#00f',
'fill-opacity': 1,
name: 'x',
},
geometry: null,
},
{ type: 'Feature', properties: null, geometry: null },
],
};
stripGeoJsonStyleProperties(fc);
assert.deepEqual(fc.features[0].properties, { name: 'x' });
});
test('imported entities keep only a short plain-text name: no description, label or properties', () => {
const ds = new Cesium.CustomDataSource('t');
const e = ds.entities.add({
+74
View File
@@ -0,0 +1,74 @@
// Inspect encoded headers before allowing the browser to decode local images.
export const MAX_IMPORT_IMAGE_PIXELS = 16 * 1024 * 1024;
const MAX_SIDE = 8192;
/** Width and height from a supported raster header; no image is decoded. */
export function importImageSize(bytes) {
const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const ascii = (at, n) => String.fromCharCode(...bytes.subarray(at, at + n));
const u24 = (at) => bytes[at] | (bytes[at + 1] << 8) | (bytes[at + 2] << 16);
if (
bytes.length >= 24 &&
v.getUint32(0) === 0x89504e47 &&
ascii(12, 4) === 'IHDR'
)
return [v.getUint32(16), v.getUint32(20)];
if (bytes.length >= 10 && /^GIF8[79]a$/.test(ascii(0, 6)))
return [v.getUint16(6, true), v.getUint16(8, true)];
if (bytes.length >= 26 && ascii(0, 2) === 'BM') {
if (v.getUint32(14, true) === 12)
return [v.getUint16(18, true), v.getUint16(20, true)];
return [Math.abs(v.getInt32(18, true)), Math.abs(v.getInt32(22, true))];
}
if (bytes.length >= 30 && ascii(0, 4) === 'RIFF' && ascii(8, 4) === 'WEBP') {
const kind = ascii(12, 4);
if (kind === 'VP8X') return [1 + u24(24), 1 + u24(27)];
if (kind === 'VP8 ' && u24(23) === 0x2a019d)
return [v.getUint16(26, true) & 0x3fff, v.getUint16(28, true) & 0x3fff];
if (kind === 'VP8L' && bytes[20] === 0x2f) {
const bits = v.getUint32(21, true);
return [(bits & 0x3fff) + 1, ((bits >>> 14) & 0x3fff) + 1];
}
}
if (bytes.length >= 4 && v.getUint16(0) === 0xffd8) {
for (let at = 2; at + 4 <= bytes.length;) {
if (bytes[at++] !== 0xff) break;
while (bytes[at] === 0xff) at++;
const marker = bytes[at++];
if (marker === 0xd9 || marker === 0xda) break;
if (marker === 1 || (marker >= 0xd0 && marker <= 0xd7)) continue;
const length = v.getUint16(at);
if (length < 2 || at + length > bytes.length) break;
if (
[
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd,
0xce, 0xcf,
].includes(marker) &&
length >= 8
)
return [v.getUint16(at + 5), v.getUint16(at + 3)];
at += length;
}
}
return null;
}
/** Charge each distinct encoded image once against the file's decoded budget. */
export function createImportImageBudget() {
const seen = new Set();
let pixels = 0;
return (key, bytes) => {
if (seen.has(key)) return;
const size = importImageSize(bytes);
if (
!size ||
size.some((n) => !Number.isInteger(n) || n <= 0 || n > MAX_SIDE) ||
pixels + size[0] * size[1] > MAX_IMPORT_IMAGE_PIXELS
)
throw new Error(
'Local images exceed the decoded image limit or have an unreadable header.',
);
pixels += size[0] * size[1];
seen.add(key);
};
}
+128
View File
@@ -0,0 +1,128 @@
import * as Cesium from 'cesium';
const STYLE = [
'image',
'scale',
'rotation',
'alignedAxis',
'horizontalOrigin',
'verticalOrigin',
'color',
'eyeOffset',
'pixelOffset',
'width',
'height',
'scaleByDistance',
'translucencyByDistance',
'pixelOffsetScaleByDistance',
'distanceDisplayCondition',
'sizeInMeters',
'disableDepthTestDistance',
'imageSubRegion',
];
class ImportedBillboards extends Cesium.BillboardCollection {
update(frameState) {
// Height queries should find the map, never another imported marker.
// Ordinary click picks and rendered screenshots still include the pins.
if (frameState.passes.pick && frameState.passes.offscreen) return;
super.update(frameState);
}
}
/** Own static imported pins without a per-frame Entity visualizer walk. */
export function createImportedPins(dataSource, viewer) {
const all = dataSource.entities.values.slice();
const now = Cesium.JulianDate.now();
const pins = all.filter((entity) => entity.billboard && entity.position);
if (!pins.length) return { mount() {}, sync() {}, destroy() {} };
const collection = new ImportedBillboards({ scene: viewer.scene });
const billboards = [];
try {
for (const entity of pins) {
const position = entity.position.getValue(now);
if (
!position ||
![position.x, position.y, position.z].every(Number.isFinite)
)
throw new Error('Invalid imported point position.');
const options = {
id: entity,
position,
show:
entity.isShowing && entity.billboard.show?.getValue(now) !== false,
heightReference: Cesium.HeightReference.NONE,
};
for (const key of STYLE) {
const value = entity.billboard[key]?.getValue(now);
if (value !== undefined) options[key] = value;
}
billboards.push(collection.add(options));
}
} catch (error) {
collection.destroy();
throw error;
}
// Rebuild once before the source is added to DataSourceDisplay. Removing
// one entity at a time would itself make this transition quadratic.
dataSource.entities.suspendEvents();
try {
dataSource.entities.removeAll();
for (const entity of all)
if (!entity.billboard || !entity.position)
dataSource.entities.add(entity);
} finally {
dataSource.entities.resumeEvents();
}
Object.defineProperty(dataSource, 'importedEntities', {
value: all,
configurable: true,
});
Object.defineProperty(dataSource, 'importedBillboards', {
value: collection,
configurable: true,
});
let show = dataSource.show,
mounted = false,
destroyed = false;
Object.defineProperty(dataSource, 'show', {
configurable: true,
get: () => show,
set(value) {
show = Boolean(value);
dataSource.entities.show = show;
collection.show = show;
},
});
collection.show = show;
return {
mount() {
if (destroyed || mounted) return;
viewer.scene.primitives?.add(collection);
mounted = true;
},
sync() {
if (destroyed) return;
for (let i = 0; i < pins.length; i++) {
billboards[i].position = pins[i].position.getValue(now);
billboards[i].show =
pins[i].isShowing && pins[i].billboard.show?.getValue(now) !== false;
}
viewer.scene.requestRender?.();
},
destroy() {
if (destroyed) return;
destroyed = true;
if (viewer.trackedEntity && pins.includes(viewer.trackedEntity))
viewer.trackedEntity = undefined;
if (viewer.selectedEntity && pins.includes(viewer.selectedEntity))
viewer.selectedEntity = undefined;
if (mounted && !viewer.scene.primitives?.isDestroyed?.())
viewer.scene.primitives?.remove(collection);
if (!collection.isDestroyed()) collection.destroy();
all.length = 0;
pins.length = 0;
billboards.length = 0;
},
};
}
+1 -13
View File
@@ -1,3 +1,4 @@
import { rangeForBoundingSphere } from './data/cameraFraming.js';
import { isUnavailableCapability } from './sources/capability.js';
import { createOverpassFeatureSource } from './sources/overpassFeatures.js';
import { applicationServices } from './services/application.js';
@@ -1377,19 +1378,6 @@ function buildingBoundingRadius(bounds) {
return Math.hypot(halfHeight, halfWidth, halfDepth) * 1.18;
}
function rangeForBoundingSphere(viewer, radius) {
const frustum = viewer.camera.frustum;
const verticalFov = Number(frustum?.fov) || Cesium.Math.toRadians(60);
const aspectRatio = Math.max(0.5, Number(frustum?.aspectRatio) || 1);
const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * aspectRatio);
const limitingFov = Math.min(verticalFov, horizontalFov);
// Target 57% sphere occupancy so perspective still leaves at least 40%
// measured roof-to-base breathing room in ordinary oblique building views.
const occupiedViewportFraction = 0.57;
const desiredAngularRadius = (limitingFov * occupiedViewportFraction) / 2;
return (radius / Math.sin(desiredAngularRadius)) * 1.05;
}
function buildingPitch(bounds) {
if (!bounds) return -25;
const footprint = Math.max(bounds.width, bounds.depth);
+100 -70
View File
@@ -1,92 +1,122 @@
/**
* Fly to everything in an imported file without
* trusting Cesium's own framing of ground-clamped points: before the
* destination's terrain has streamed in, a clamped billboard reports a
* bounding sphere kilometers below the surface, and `viewer.flyTo` follows it
* underground. Instead the footprint is framed from the features' longitudes
* and latitudes, and the camera ground guard lifts the eye once the real
* surface can be measured.
*/
/** Frame the complete finite footprint of a session-local file. */
import * as Cesium from 'cesium';
import { guardCameraAboveGround } from '../cameraGroundGuard.js';
import { rangeForBoundingSphere } from '../data/cameraFraming.js';
import { cancelCameraArrival } from '../data/cameraArrival.js';
const SAMPLE_TARGET_POINTS = 5000;
/**
* Longitude/latitude pairs of every entity's position, line and polygon,
* sampling long paths toward `SAMPLE_TARGET_POINTS` while retaining each
* geometry's endpoints. Separate point features stay in the footprint so
* distant outliers are not lost. Long tracks are walked by index, never
* spread into a call, so a file with hundreds of thousands of vertices cannot
* overflow the stack; only the kept points are converted.
*/
/** All visible positions, path vertices, polygon rings, walls and overlay bounds. */
export function dataSourcePoints(dataSource, time = Cesium.JulianDate.now()) {
const parts = [];
let total = 0;
for (const entity of dataSource?.entities?.values ?? []) {
const at = entity.position?.getValue?.(time);
if (at) {
parts.push([at]);
total += 1;
}
const line = entity.polyline?.positions?.getValue?.(time);
if (Array.isArray(line) && line.length) {
parts.push(line);
total += line.length;
}
const ring = entity.polygon?.hierarchy?.getValue?.(time)?.positions;
if (Array.isArray(ring) && ring.length) {
parts.push(ring);
total += ring.length;
}
}
const stride = Math.max(1, Math.ceil(total / SAMPLE_TARGET_POINTS));
const points = [];
let index = 0;
for (const part of parts) {
// Keep every stride-th vertex across all parts, plus each part's ends so
// a short line is never skipped entirely.
for (let i = 0; i < part.length; i++, index++) {
if (index % stride !== 0 && i !== 0 && i !== part.length - 1) continue;
const carto = Cesium.Cartographic.fromCartesian(part[i]);
if (!carto) continue;
const add = (position, grounded = false) => {
if (
!position ||
![position.x, position.y, position.z].every(Number.isFinite)
)
return;
const c = Cesium.Cartographic.fromCartesian(position);
if (c && Number.isFinite(c.longitude) && Number.isFinite(c.latitude))
points.push([
Cesium.Math.toDegrees(carto.longitude),
Cesium.Math.toDegrees(carto.latitude),
Cesium.Math.toDegrees(c.longitude),
Cesium.Math.toDegrees(c.latitude),
grounded ? 0 : c.height,
]);
};
if (dataSource?.show === false) return points;
for (const entity of dataSource?.importedEntities ??
dataSource?.entities?.values ??
[]) {
if (entity.isShowing === false) continue;
add(
entity.position?.getValue?.(time),
entity.billboard?.heightReference?.getValue?.(time) ===
Cesium.HeightReference.CLAMP_TO_GROUND,
);
for (const graphic of [entity.polyline, entity.wall])
for (const position of graphic?.positions?.getValue?.(time) || [])
add(position, graphic.clampToGround?.getValue?.(time) === true);
const rings = [entity.polygon?.hierarchy?.getValue?.(time)];
while (rings.length) {
const ring = rings.pop();
for (const position of ring?.positions || [])
add(
position,
entity.polygon?.perPositionHeight?.getValue?.(time) !== true,
);
for (const hole of ring?.holes || []) rings.push(hole);
}
const rectangle = entity.rectangle?.coordinates?.getValue?.(time);
if (rectangle) {
const bounds = Cesium.RectangleGeometry.computeRectangle({
rectangle,
rotation: entity.rectangle.rotation?.getValue?.(time) || 0,
});
for (const corner of [
'southwest',
'southeast',
'northwest',
'northeast',
]) {
const c = Cesium.Rectangle[corner](bounds);
if (Number.isFinite(c.longitude) && Number.isFinite(c.latitude))
points.push([
Cesium.Math.toDegrees(c.longitude),
Cesium.Math.toDegrees(c.latitude),
entity.rectangle.height?.getValue?.(time) || 0,
]);
}
}
}
return points;
}
/**
* @param {Cesium.Viewer} viewer
* @param {Cesium.DataSource} dataSource
* @param {{duration?: number}} [options]
* @returns {boolean} Whether there was anything to fly to.
*/
export function flyToDataSource(viewer, dataSource, { duration = 1.5 } = {}) {
/** Fly synchronously to the footprint; later navigation always owns its camera. */
export function flyToDataSource(
viewer,
dataSource,
{ duration = 1.5, ground = null } = {},
) {
const points = dataSourcePoints(dataSource);
if (!points.length) {
viewer.flyTo?.(dataSource, { duration });
return false;
}
const sphere = Cesium.BoundingSphere.fromPoints(
points.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat, 0)),
if (!points.length) return false;
cancelCameraArrival(viewer);
const positions = points.map(([lon, lat, height]) =>
Cesium.Cartesian3.fromDegrees(lon, lat, height),
);
const center = Cesium.Cartographic.fromCartesian(sphere.center);
const sphere = Cesium.BoundingSphere.fromPoints(positions);
const center =
Cesium.Cartographic.fromCartesian(sphere.center) ||
Cesium.Cartographic.fromDegrees(points[0][0], points[0][1]);
const lat = Cesium.Math.toDegrees(center.latitude),
lon = Cesium.Math.toDegrees(center.longitude);
const terrain = viewer.scene?.globe?.getHeight?.(center);
const cached = ground?.cachedGroundFloor?.(lat, lon);
const height =
Number.isFinite(terrain) && terrain > 0
? terrain
: Number.isFinite(cached)
? cached
: Number.isFinite(terrain)
? terrain
: 0;
ground?.warmGroundFloor?.([{ lat, lon }]);
sphere.center = Cesium.Cartesian3.fromRadians(
center.longitude,
center.latitude,
Math.max(0, height, center.height),
);
sphere.radius = 0;
for (const position of positions)
sphere.radius = Math.max(
sphere.radius,
Cesium.Cartesian3.distance(sphere.center, position),
);
viewer.camera?.flyToBoundingSphere?.(sphere, {
offset: new Cesium.HeadingPitchRange(
viewer.camera.heading || 0,
Cesium.Math.toRadians(-45),
Math.max(800, sphere.radius * 2.8),
-Math.PI / 2,
Math.max(800, rangeForBoundingSphere(viewer, sphere.radius)),
),
duration,
complete: () =>
guardCameraAboveGround(viewer, {
lat: Cesium.Math.toDegrees(center.latitude),
lon: Cesium.Math.toDegrees(center.longitude),
}),
complete: () => guardCameraAboveGround(viewer, { lat, lon }),
});
return true;
}
+90 -3
View File
@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { cancelCameraArrival } from '../data/cameraArrival.js';
import { dataSourcePoints, flyToDataSource } from './flyToDataSource.js';
function source() {
@@ -55,7 +56,11 @@ test('the camera frames the footprint at the surface, never a buried center', ()
flyToDataSource(viewer, new Cesium.CustomDataSource('empty')),
false,
);
assert.equal(calls.at(-1), 'viewer.flyTo');
assert.equal(
calls.length,
1,
'empty sources never start deferred navigation',
);
});
test('a very long track is framed without spreading it into a call', () => {
@@ -69,8 +74,90 @@ test('a very long track is framed without spreading it into a call', () => {
ds.entities.add({ polyline: { positions: line } });
ds.entities.add({ position: Cesium.Cartesian3.fromDegrees(-110, 41) });
const points = dataSourcePoints(ds);
assert.ok(points.length <= 5002, `${points.length} points kept`);
assert.equal(points.length, n + 1, 'every vertex contributes to framing');
const lons = points.map(([lon]) => lon);
assert.ok(Math.min(...lons) < -119.99 && Math.max(...lons) > -110.01);
assert.ok(
lons.reduce((a, b) => Math.min(a, b), Infinity) < -119.99 &&
lons.reduce((a, b) => Math.max(a, b), -Infinity) > -110.01,
);
assert.ok(lons.some((lon) => Math.abs(lon - -118.000005) < 0.01));
});
test('framing includes a sparse interior outlier, walls and rotated overlays', () => {
const ds = new Cesium.CustomDataSource('bounds');
const line = Array.from({ length: 200031 }, () =>
Cesium.Cartesian3.fromDegrees(0, 0),
);
line[12345] = Cesium.Cartesian3.fromDegrees(20, 10);
ds.entities.add({ polyline: { positions: line } });
ds.entities.add({
wall: { positions: Cesium.Cartesian3.fromDegreesArray([30, 10, 31, 11]) },
});
ds.entities.add({
rectangle: {
coordinates: Cesium.Rectangle.fromDegrees(40, 0, 41, 1),
rotation: Math.PI / 4,
},
});
const points = dataSourcePoints(ds);
assert.ok(points.some(([lon]) => Math.abs(lon - 20) < 1e-6));
assert.ok(points.some(([lon]) => Math.abs(lon - 31) < 1e-6));
assert.ok(points.some(([lon]) => lon > 41));
});
test('portrait framing accounts for FOV, high ground and symmetric extents', () => {
const ds = new Cesium.CustomDataSource('view');
ds.entities.add({
polyline: {
positions: Cesium.Cartesian3.fromDegreesArray([-105, 39, -104.9, 39]),
},
});
const calls = [];
const viewer = {
scene: { globe: { getHeight: () => 2600 } },
camera: {
frustum: { fov: Math.PI / 3, aspectRatio: 390 / 844 },
flyToBoundingSphere: (sphere, options) => calls.push({ sphere, options }),
},
};
flyToDataSource(viewer, ds);
assert.ok(
Math.abs(
Cesium.Cartographic.fromCartesian(calls[0].sphere.center).height - 2600,
) < 1e-6,
);
assert.equal(calls[0].options.offset.pitch, -Math.PI / 2);
viewer.camera.frustum.aspectRatio = 16 / 9;
flyToDataSource(viewer, ds);
assert.ok(calls[0].options.offset.range > calls[1].options.offset.range);
const symmetric = {
entities: {
values: [
{ position: { getValue: () => new Cesium.Cartesian3(6378137, 0, 0) } },
{ position: { getValue: () => new Cesium.Cartesian3(-6378137, 0, 0) } },
],
},
};
assert.doesNotThrow(() => flyToDataSource(viewer, symmetric));
assert.ok(Number.isFinite(calls.at(-1).options.offset.range));
assert.doesNotThrow(() => calls.at(-1).options.complete());
cancelCameraArrival(viewer);
});
test('non-finite positions and hidden features never enter the camera footprint', () => {
const position = (p) => ({ position: { getValue: () => p } });
const points = dataSourcePoints({
entities: {
values: [
position(new Cesium.Cartesian3(NaN, 1, 2)),
{
...position(Cesium.Cartesian3.fromDegrees(10, 10)),
isShowing: false,
},
position(Cesium.Cartesian3.fromDegrees(20, 20)),
],
},
});
assert.equal(points.length, 1);
assert.ok(Math.abs(points[0][0] - 20) < 1e-6);
});
+79 -31
View File
@@ -1,24 +1,4 @@
/**
* DISPLAY ▸ Import: put your own GeoJSON, KML, KMZ or GPX files on the globe.
*
* Files come in through the Import button or by dropping them onto the globe.
* Each one becomes its own Cesium data source, clamped to the ground, with a
* row in the panel to show or hide it, fly to it, or remove it. Files are read
* in the browser and never uploaded; see `src/data/geoFileImport.js` for what
* is accepted and what is stripped from KML before Cesium reads it.
*
* Imported files last for the page session: a reload starts empty.
*
* Imports run one batch at a time (a second pick or drop waits for the
* first), so the file cap is checked against what is really on the globe.
* Fly-to goes through the app's navigation policy (`navigation`, the
* application shell): the button is an immediate navigation, and the fly
* after an import is a deferred one, stamped when the import starts and
* re-asserted just before flying, so a camera move, Cockpit or any newer
* navigation in between wins over a slow file.
* `destroy()` removes every imported data source, revokes every blob URL and
* gives back every listener and the `window.__gevGeoImport` handle.
*/
/** Session-local file controls, serial loading and application-owned navigation. */
import {
GEO_FILE_ACCEPT,
GeoImportError,
@@ -44,6 +24,8 @@ export function initGeoFileImport({
document: doc = globalThis.document,
flyToSource = flyToDataSource,
navigation = null,
ground = null,
subscribeMapSource = null,
}) {
const button = doc?.getElementById('geo-import-button');
const input = doc?.getElementById('geo-import-input');
@@ -58,6 +40,9 @@ export function initGeoFileImport({
let colorAt = 0;
let destroyed = false;
let dragDepth = 0;
let cameraIntent = 0;
const lifetime = new AbortController();
let removeMapSource;
const listen = (target, type, listener, options) => {
if (!target?.addEventListener) return;
@@ -68,6 +53,52 @@ export function initGeoFileImport({
if (hint) hint.textContent = text || '';
};
const render = () => viewer.scene?.requestRender?.();
let groundTimer,
groundQueued = false,
groundAgain = false;
const groundRemovers = [];
const scheduleGround = () => {
if (destroyed || !imports.length) return;
if (groundQueued) {
groundAgain = true;
return;
}
clearTimeout(groundTimer);
groundTimer = setTimeout(() => {
groundQueued = true;
queue = queue
.catch(() => {})
.then(async () => {
try {
for (const item of imports) {
if (destroyed) return;
if (item.visible) await item.refreshGround?.(lifetime.signal);
}
render();
} catch {
if (!destroyed)
setHint(
'Ground heights could not be refreshed. Try the file again when the map has loaded.',
);
} finally {
groundQueued = false;
if (groundAgain) {
groundAgain = false;
scheduleGround();
}
}
});
}, 200);
};
removeMapSource = subscribeMapSource?.(scheduleGround);
const ownEvent = (event, listener) => {
const remove = event?.addEventListener?.(listener);
if (remove) groundRemovers.push(remove);
};
ownEvent(viewer.camera?.moveEnd, scheduleGround);
ownEvent(viewer.scene?.globe?.tileLoadProgressEvent, (pending) => {
if (!pending) scheduleGround();
});
/** Which row button has focus, so a re-render can give it back. */
function focusedAction() {
@@ -108,7 +139,7 @@ export function initGeoFileImport({
name.className = 'geo-import-name';
// textContent, never markup: the name comes from the file system.
name.textContent = item.name;
name.title = `${item.name} — ${item.format.toUpperCase()}, ${item.entityCount.toLocaleString()} feature${item.entityCount === 1 ? '' : 's'}${item.ownStyles ? ', drawn with its own styles' : ''}`;
name.title = `${item.name} — ${item.format.toUpperCase()}, ${item.entityCount.toLocaleString()} map object${item.entityCount === 1 ? '' : 's'}${item.ownStyles ? ', drawn with its own styles' : ''}`;
const actions = [
[
item.visible ? 'visibility' : 'visibility_off',
@@ -149,6 +180,7 @@ export function initGeoFileImport({
if (!item) return false;
item.visible = Boolean(visible);
item.dataSource.show = item.visible;
if (item.visible) scheduleGround();
renderList();
render();
return true;
@@ -158,7 +190,7 @@ export function initGeoFileImport({
// destination's terrain has loaded (see ./flyToDataSource.js).
const fly = (item) => {
if (!item.visible) setVisible(item.id, true);
flyToSource(viewer, item.dataSource);
flyToSource(viewer, item.dataSource, { ground });
return true;
};
@@ -216,12 +248,13 @@ export function initGeoFileImport({
// Capture the user's intent when the files are chosen, including time
// spent waiting for an earlier batch. Later navigation must still win.
const generation = beginAutoFly();
const run = queue.then(() => importBatch(list, generation));
const intent = ++cameraIntent;
const run = queue.then(() => importBatch(list, generation, intent));
queue = run.catch(() => {});
return run;
}
async function importBatch(list, generation) {
async function importBatch(list, generation, intent) {
const outcomes = [];
if (destroyed || !list.length) return outcomes;
for (const file of list) {
@@ -239,7 +272,7 @@ export function initGeoFileImport({
const color = COLORS[colorAt % COLORS.length];
let loaded;
try {
loaded = await load(file, { viewer, color });
loaded = await load(file, { viewer, color, signal: lifetime.signal });
} catch (error) {
outcomes.push({
name: fileName,
@@ -247,7 +280,7 @@ export function initGeoFileImport({
error:
error instanceof GeoImportError || error?.name === 'GeoImportError'
? error.message
: `That file could not be read${error?.message ? `: ${error.message}` : '.'}`,
: 'That file could not be read.',
});
continue;
}
@@ -268,6 +301,7 @@ export function initGeoFileImport({
visible: true,
dataSource: loaded.dataSource,
revoke: loaded.revoke,
refreshGround: loaded.refreshGround,
};
await viewer.dataSources.add(item.dataSource);
if (destroyed) {
@@ -275,6 +309,7 @@ export function initGeoFileImport({
item.revoke?.();
break;
}
loaded.mount?.();
imports.push(item);
renderList();
render();
@@ -288,6 +323,7 @@ export function initGeoFileImport({
// result, unless something else took the camera meanwhile.
if (
outcomes.filter((o) => o.ok).length === 1 &&
intent === cameraIntent &&
autoFlyStillOwns(generation)
)
fly(item);
@@ -317,6 +353,15 @@ export function initGeoFileImport({
// ---- drop onto the globe ----------------------------------------------
const container = viewer.container;
const gesture = () => {
cameraIntent += 1;
};
listen(viewer.scene?.canvas || container, 'pointerdown', gesture, {
passive: true,
});
listen(viewer.scene?.canvas || container, 'wheel', gesture, {
passive: true,
});
const hasFiles = (event) =>
[...(event.dataTransfer?.types || [])].includes('Files');
const setDragging = (on) =>
@@ -327,7 +372,7 @@ export function initGeoFileImport({
dragDepth += 1;
setDragging(true);
});
listen(container, 'dragover', (event) => {
listen(doc, 'dragover', (event) => {
if (!hasFiles(event)) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
@@ -337,7 +382,7 @@ export function initGeoFileImport({
dragDepth = Math.max(0, dragDepth - 1);
if (!dragDepth) setDragging(false);
});
listen(container, 'drop', (event) => {
listen(doc, 'drop', (event) => {
if (!hasFiles(event)) return;
event.preventDefault();
dragDepth = 0;
@@ -371,6 +416,10 @@ export function initGeoFileImport({
destroy() {
if (destroyed) return;
destroyed = true;
lifetime.abort();
removeMapSource?.();
clearTimeout(groundTimer);
for (const remove of groundRemovers) remove();
for (const item of imports.splice(0)) {
viewer.dataSources?.remove?.(item.dataSource, true);
item.revoke?.();
@@ -408,7 +457,6 @@ export function summarize(outcomes) {
(n, o) => n + (o.ok ? o.removed || 0 : 0),
0,
);
if (added && stripped)
parts.push('KML descriptions and external references were left out.');
if (added && stripped) parts.push('Some KML content was left out.');
return parts.join(' ');
}
+81 -6
View File
@@ -134,7 +134,7 @@ class FakeElement {
}
}
function setup({ load, navigation, flyToSource } = {}) {
function setup({ load, navigation, flyToSource, subscribeMapSource } = {}) {
focus.active = null;
const els = {
'geo-import-button': new FakeElement('button', 'geo-import-button'),
@@ -150,6 +150,7 @@ function setup({ load, navigation, flyToSource } = {}) {
return focus.active;
},
};
Object.setPrototypeOf(document, new FakeElement('document'));
const added = [];
const removed = [];
const flights = [];
@@ -189,7 +190,8 @@ function setup({ load, navigation, flyToSource } = {}) {
load: load || defaultLoad,
document,
...(navigation ? { navigation } : {}),
...(flyToSource ? { flyToSource } : {}),
...(subscribeMapSource ? { subscribeMapSource } : {}),
flyToSource: flyToSource || ((_viewer, ds) => flights.push(ds)),
});
return {
tool,
@@ -301,7 +303,7 @@ test('a refused file says why and does not stop the rest of the batch', async ()
[false, false, true],
);
assert.equal(out[0].error, 'That KML file is not valid XML.');
assert.match(out[1].error, /could not be read: boom/);
assert.match(out[1].error, /could not be read\./);
assert.match(
t.els['geo-import-hint'].textContent,
/^Added 1 file\. 2 files could not be added \(bad\.kml: That KML file is not valid XML\.\)/,
@@ -329,13 +331,13 @@ test('dropping files on the globe imports them; other drags are left alone', asy
const c = t.viewer.container;
const files = { types: ['Files'], files: [file('drop.kmz')] };
let ev = { dataTransfer: { types: ['text/plain'] } };
c.emit('dragover', ev);
t.document.emit('dragover', ev);
c.emit('dragenter', { dataTransfer: files });
assert.ok(t.document.body.classList.contains('gev-geo-import-drag'));
c.emit('dragleave', { dataTransfer: files });
assert.ok(!t.document.body.classList.contains('gev-geo-import-drag'));
c.emit('dragenter', { dataTransfer: files });
c.emit('drop', { dataTransfer: files });
t.document.emit('drop', { dataTransfer: files });
assert.ok(!t.document.body.classList.contains('gev-geo-import-drag'));
await new Promise((r) => setImmediate(r));
assert.deepEqual(
@@ -347,7 +349,7 @@ test('dropping files on the globe imports them; other drags are left alone', asy
test('stripped KML content is mentioned once, for the batch that had it', () => {
assert.equal(
summarize([{ name: 'a.kml', ok: true, removed: 3 }]),
'Added 1 file. KML descriptions and external references were left out.',
'Added 1 file. Some KML content was left out.',
);
assert.equal(
summarize([{ name: 'b.geojson', ok: true, removed: 0 }]),
@@ -478,6 +480,79 @@ function navigationDouble({ immediate = true, reassert = true } = {}) {
};
}
test('a direct camera gesture cancels the fly-to of a file still being read', async () => {
for (const gesture of ['pointerdown', 'wheel']) {
let release;
const t = setup({
load: async () => {
await new Promise((r) => (release = r));
return {
name: 'slow.geojson',
format: 'geojson',
entityCount: 1,
dataSource: {},
};
},
});
const result = t.tool.importFiles([file('slow.geojson')]);
await new Promise((r) => setImmediate(r));
t.viewer.container.emit(gesture);
release();
await result;
assert.equal(t.added.length, 1);
assert.equal(t.flights.length, 0);
t.tool.destroy();
assert.equal(t.document.listeners.length, 0);
}
});
test('dropping on panel controls is accepted and file drags prevent browser navigation', async () => {
const t = setup();
let prevented = 0;
const event = {
target: t.els['geo-import-button'],
dataTransfer: { types: ['Files'], files: [file('panel.kml')] },
preventDefault: () => prevented++,
};
t.document.emit('dragover', event);
t.document.emit('drop', event);
await new Promise((r) => setImmediate(r));
assert.equal(prevented, 2);
assert.equal(t.tool.list()[0].name, 'panel.kml');
t.tool.destroy();
assert.equal(t.document.listeners.length, 0);
});
test('ground refresh coalesces map changes and releases its subscription on teardown', async () => {
let changed,
unsubscribed = 0,
refreshed = 0;
const t = setup({
subscribeMapSource: (listener) => {
changed = listener;
return () => unsubscribed++;
},
load: async () => ({
name: 'point.geojson',
format: 'geojson',
entityCount: 1,
dataSource: {},
refreshGround: async () => refreshed++,
}),
});
await t.tool.importFiles([file('point.geojson')]);
changed();
changed();
changed();
await new Promise((resolve) => setTimeout(resolve, 300));
assert.equal(refreshed, 1);
t.tool.destroy();
changed();
await new Promise((resolve) => setTimeout(resolve, 250));
assert.equal(refreshed, 1);
assert.equal(unsubscribed, 1);
});
test('Fly to is an immediate navigation that Cockpit can refuse', async () => {
const flown = [];
const nav = navigationDouble({ immediate: false });
+1 -1
View File
@@ -148,7 +148,7 @@
</div>
</div>
<div class="pp-toggle-group" id="geo-import-group">
<button class="pp-toggle-btn" id="geo-import-button" type="button" title="Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). Files stay in this browser.">
<button class="pp-toggle-btn" id="geo-import-button" type="button" title="Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice.">
<span class="pp-icon material-symbols-outlined" aria-hidden="true">upload_file</span>
<span class="pp-label">Import</span>
</button>