perf(import): reuse generated pin textures and trim unused processing

This commit is contained in:
Mazeyar Moeini Feizabadi committed 2026-10-14 15:01:47 +02:00
1 parent c58ff5c215
commit 767a7d5ae8
6 files changed
+169 -96

No files matched your search

+61 -73
View File
@@ -68,14 +68,22 @@ const KMZ_IMAGE_TYPES = Object.freeze({
webp: 'image/webp',
bmp: 'image/bmp',
});
// KML elements that fetch, embed or execute something outside the file.
const KML_REMOVED_ELEMENTS = Object.freeze([
// External content and metadata the session-local import does not use.
const KML_REMOVED_ELEMENTS = new Set([
'NetworkLink',
'NetworkLinkControl',
'ScreenOverlay',
'PhotoOverlay',
'Model',
'Tour',
'description',
'BalloonStyle',
'Snippet',
'ExtendedData',
'TimeSpan',
'TimeStamp',
'LookAt',
'Camera',
]);
/** An error whose message is meant for the person who chose the file. */
@@ -222,7 +230,7 @@ export function countGeoJsonGeometry(
}
/** The refusal for a file over a size limit, in the person's terms. */
function overLimitError({ over, features, positions }) {
function overLimitError({ over, features }) {
if (over === 'entities')
return new GeoImportError(
`That file creates more than ${MAX_IMPORT_ENTITIES.toLocaleString()} map objects (the limit).`,
@@ -486,8 +494,11 @@ export function sanitizeKmlDocument(doc, resolveLocal = () => null) {
removed += 1;
}
};
for (const name of KML_REMOVED_ELEMENTS)
elementsNamed(doc, name).forEach(drop);
// One snapshot and pass, rather than rescanning the whole XML tree for
// 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);
for (const href of elementsNamed(doc, 'href')) {
const value = (href.textContent || '').trim();
const local = resolveLocal(value);
@@ -511,10 +522,6 @@ export function sanitizeKmlDocument(doc, resolveLocal = () => null) {
for (const styleUrl of elementsNamed(doc, 'styleUrl')) {
if (!(styleUrl.textContent || '').trim().startsWith('#')) drop(styleUrl);
}
// Placemark balloons are HTML; the app shows no info box, and keeping their
// <img>/<iframe> sources out means no future surface can load them either.
for (const name of ['description', 'BalloonStyle', 'Snippet', 'ExtendedData'])
elementsNamed(doc, name).forEach(drop);
// 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.
@@ -735,7 +742,10 @@ export function countKmlGeometry(
// a large string into a large array.
let inTuple = false;
for (let i = 0; i < text.length; i++) {
const space = text.charCodeAt(i) <= 32;
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');
@@ -854,47 +864,61 @@ export function importFeatureName(value) {
/**
* Make every entity of an imported data source inert: no description,
* balloon, extended data or properties, no native label, and a name that is
* short plain text. Returns how many entities were touched.
* short plain text. Generated pin canvases share a data URL so Cesium uploads
* each image once, rather than allocating a texture entry for every entity.
* Returns how many entities were touched.
* @param {{entities: {values: object[]}}} dataSource
* @returns {number}
*/
export function scrubImportedEntities(dataSource) {
const list = dataSource?.entities?.values || [];
for (let i = 0; i < list.length; i++) {
const entity = list[i];
let name;
try {
name = importFeatureName(
typeof entity.name === 'string' ? entity.name : undefined,
);
} catch {
name = undefined;
const collection = dataSource?.entities;
const list = collection?.values || [];
const images = new Map();
collection?.suspendEvents?.();
try {
for (let i = 0; i < list.length; i++) {
const entity = list[i];
let name;
try {
name = importFeatureName(
typeof entity.name === 'string' ? entity.name : undefined,
);
} catch {
name = undefined;
}
entity.name = name;
entity.description = undefined;
entity.label = undefined;
entity.properties = undefined;
if (entity.kml && typeof entity.kml === 'object') entity.kml = undefined;
const property = entity.billboard?.image;
if (property?.isConstant === false) continue;
const image = property?.getValue?.();
if (image?.tagName === 'CANVAS') {
let url = images.get(image);
if (!url) {
url = image.toDataURL();
images.set(image, url);
}
entity.billboard.image = url;
}
}
entity.name = name;
entity.description = undefined;
entity.label = undefined;
entity.properties = undefined;
if (entity.kml && typeof entity.kml === 'object') entity.kml = undefined;
} finally {
collection?.resumeEvents?.();
}
return list.length;
}
const TRACK_SAMPLES = 2000;
/**
* Take time out of an imported data source so adding it cannot move the app's
* shared clock: the data source reports no clock (the viewer adopts the clock
* of every added source that has one), every entity is available for all
* time, and a time-dynamic position (a KML gx:Track) is drawn as the line it
* travels instead of a moving marker.
* time. KML tracks have already become their original static vertices in
* `sanitizeKmlDocument`, so no sampled-position fallback is needed here.
* @param {object} dataSource
* @param {{Cesium?: object, color?: object}} [options]
* @returns {object} The same data source.
*/
export function detachImportedTime(
dataSource,
{ Cesium: C = Cesium, color } = {},
) {
export function detachImportedTime(dataSource) {
Object.defineProperty(dataSource, 'clock', {
configurable: true,
enumerable: false,
@@ -904,42 +928,6 @@ export function detachImportedTime(
const list = dataSource?.entities?.values || [];
for (let i = 0; i < list.length; i++) {
const entity = list[i];
const span = entity.availability;
const position = entity.position;
if (position && position.isConstant === false) {
const start = span?.start;
const stop = span?.stop;
const line = [];
if (start && stop && C.JulianDate.lessThan(start, stop)) {
const seconds = C.JulianDate.secondsDifference(stop, start);
for (let n = 0; n <= TRACK_SAMPLES; n++) {
const t = C.JulianDate.addSeconds(
start,
(seconds * n) / TRACK_SAMPLES,
new C.JulianDate(),
);
const at = position.getValue(t);
if (at) line.push(at);
}
}
entity.path = undefined;
entity.model = undefined;
if (line.length >= 2 && !entity.polyline) {
entity.billboard = undefined;
entity.point = undefined;
entity.position = undefined;
entity.polyline = new C.PolylineGraphics({
positions: line,
clampToGround: true,
width: 3,
material: color || C.Color.WHITE,
});
} else {
entity.position = line.length
? new C.ConstantPositionProperty(line[0])
: undefined;
}
}
entity.availability = undefined;
}
return dataSource;
@@ -1050,8 +1038,8 @@ export async function loadGeoFile(file, { viewer, color = '#39d0ff' }) {
throw new GeoImportError('That file has nothing to draw.');
}
scrubImportedEntities(dataSource);
// Only KML has time-dynamic positions (GPX is read without times); its
// track line is drawn in KML's default white, like its other unstyled lines.
// Keep the clock invariant explicit even though unused KML dates have
// already been stripped and all supported geometries are static.
detachImportedTime(dataSource);
// The display name travels beside the data source (the file name, not
// anything the document says about itself).
+88 -15
View File
@@ -657,6 +657,22 @@ test('KML placemarks and coordinate tuples are counted before loading', () => {
assert.equal(countKmlGeometry(doc, { maxFeatures: 1 }).over, 'features');
});
test('KML vertex counting recognizes the Unicode whitespace Cesium accepts', () => {
for (const separator of ['\u00a0', '\u2003', '\ufeff']) {
const doc = xmlDoc(
xmlEl('kml', {}, [
xmlEl('Placemark', {}, [
xmlEl('LineString', {}, [
xmlEl('coordinates', {}, ['1,2', '3,4', '5,6'].join(separator)),
]),
]),
]),
);
assert.equal(countKmlGeometry(doc).positions, 3);
assert.equal(countKmlGeometry(doc, { maxPositions: 2 }).over, 'positions');
}
});
test('KML multipart geometries count every Cesium entity before loading', () => {
const doc = xmlDoc(
xmlEl('kml', {}, [
@@ -786,6 +802,67 @@ test('imported entities keep only a short plain-text name: no description, label
assert.equal(importFeatureName(' '), undefined);
});
test('generated pin canvases are encoded once and shared without changing authored image URLs', () => {
const ds = new Cesium.CustomDataSource('pins');
let encoded = 0;
const canvas = {
tagName: 'CANVAS',
toDataURL: () => {
encoded++;
return 'data:image/png;base64,shared-pin';
},
};
for (let i = 0; i < 1000; i++)
ds.entities.add({
billboard: { image: canvas },
label: { text: 'discard' },
properties: { discard: true },
});
const authored = ds.entities.add({
billboard: { image: 'blob:packed-image' },
});
const dynamicImage = new Cesium.CallbackProperty(() => canvas, false);
const dynamic = ds.entities.add({ billboard: { image: dynamicImage } });
let notifications = 0;
ds.entities.collectionChanged.addEventListener(() => notifications++);
scrubImportedEntities(ds);
assert.equal(encoded, 1);
assert.equal(
notifications,
1,
'normalization publishes one complete collection change',
);
for (const e of ds.entities.values.slice(0, 1000))
assert.equal(
e.billboard.image.getValue(),
'data:image/png;base64,shared-pin',
);
assert.equal(authored.billboard.image.getValue(), 'blob:packed-image');
assert.equal(
dynamic.billboard.image,
dynamicImage,
'time-dependent image properties stay intact',
);
});
test('unused KML time and camera metadata is removed before loading without changing geometry', () => {
const coordinates = xmlEl('coordinates', {}, '1,2,0 3,4,0');
const placemark = xmlEl('Placemark', {}, [
xmlEl('TimeSpan'),
xmlEl('TimeStamp'),
xmlEl('LookAt'),
xmlEl('Camera'),
xmlEl('LineString', {}, [coordinates]),
]);
const doc = xmlDoc(xmlEl('kml', {}, [placemark]));
sanitizeKmlDocument(doc);
assert.deepEqual(
placemark.childNodes.map((el) => el.localName),
['LineString'],
);
assert.equal(coordinates.textContent, '1,2,0 3,4,0');
});
test('a dated import exposes no clock and draws for all time', () => {
// A data source with its own clock, as KmlDataSource has for a dated file.
class DatedSource extends Cesium.CustomDataSource {
@@ -800,15 +877,15 @@ test('a dated import exposes no clock and draws for all time', () => {
const span = new Cesium.TimeIntervalCollection([
new Cesium.TimeInterval({ start, stop }),
]);
const track = new Cesium.SampledPositionProperty();
track.addSample(start, Cesium.Cartesian3.fromDegrees(10, 10));
track.addSample(stop, Cesium.Cartesian3.fromDegrees(10.1, 10.1));
const mover = ds.entities.add({
// KML tracks become their original static vertices before Cesium loading.
const track = ds.entities.add({
availability: span,
position: track,
path: {},
billboard: {},
polyline: {
positions: Cesium.Cartesian3.fromDegreesArray([10, 10, 10.1, 10.1]),
clampToGround: true,
},
});
const recorded = track.polyline.positions;
const pin = ds.entities.add({
availability: span,
position: Cesium.Cartesian3.fromDegrees(1, 1),
@@ -821,12 +898,8 @@ test('a dated import exposes no clock and draws for all time', () => {
assert.equal(ds.clock, undefined);
assert.equal(pin.availability, undefined);
assert.ok(pin.isAvailable(Cesium.JulianDate.now()));
// The moving marker became the line it travels, visible now.
assert.equal(mover.availability, undefined);
assert.equal(mover.position, undefined);
assert.equal(mover.path, undefined);
assert.equal(mover.billboard, undefined);
const line = mover.polyline.positions.getValue(Cesium.JulianDate.now());
assert.ok(line.length > 100);
assert.ok(mover.polyline.clampToGround.getValue());
assert.equal(track.availability, undefined);
assert.equal(track.polyline.positions, recorded);
assert.equal(recorded.getValue(Cesium.JulianDate.now()).length, 2);
assert.ok(track.polyline.clampToGround.getValue());
});
+6 -4
View File
@@ -1,5 +1,5 @@
/**
* Fly to everything in a data source (an imported file, a live feed) without
* 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
@@ -10,11 +10,13 @@
import * as Cesium from 'cesium';
import { guardCameraAboveGround } from '../cameraGroundGuard.js';
const MAX_POINTS = 5000;
const SAMPLE_TARGET_POINTS = 5000;
/**
* Longitude/latitude pairs of every entity's position, line and polygon,
* thinned to at most `MAX_POINTS`. Long tracks are walked by index, never
* 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.
*/
@@ -38,7 +40,7 @@ export function dataSourcePoints(dataSource, time = Cesium.JulianDate.now()) {
total += ring.length;
}
}
const stride = Math.max(1, Math.ceil(total / MAX_POINTS));
const stride = Math.max(1, Math.ceil(total / SAMPLE_TARGET_POINTS));
const points = [];
let index = 0;
for (const part of parts) {