swissBUILDINGS3D-Import: Absturz bei riesigen Kacheln behoben, Übersprungene sichtbar gemacht

Nutzer-Report „funktioniert nicht": Der Import stürzte mit einem harten
RangeError ab, sobald eine STAC-Kachel entpackt die maximale JS-String-Länge
überschritt (DXF komprimiert stark — eine Kachel unter dem 150-MB-Limit kann
trotzdem >700 MB unkomprimierten Text ergeben, real reproduziert für ein
dicht bebautes Stadtzentrum). downloadAssetText prüft jetzt zusätzlich die
JSZip-interne unkomprimierte Grössenschätzung VOR dem Entpacken und fängt
verbleibende Fehler (Netzwerk/ZIP/String-Länge) sicher ab, statt zu werfen.

Zu grosse/fehlgeschlagene Kacheln wurden bisher stumm übersprungen (0 Gebäude,
keine Erklärung — sah wie ein Bug aus). fetchBuildings3d liefert jetzt
skippedTiles mit; der Dialog zeigt „X Kachel(n) übersprungen, zu gross" statt
eines wortlosen Leer-Ergebnisses.
This commit is contained in:
2026-07-12 19:15:45 +02:00
parent 7dc8f0d5c6
commit 35a6834072
5 changed files with 85 additions and 24 deletions
+2
View File
@@ -1306,6 +1306,8 @@ export const de = {
"ctxImport.empty": "Keine Geometrie im gewählten Bereich gefunden.",
"ctxImport.importError": "Import fehlgeschlagen.",
"ctxImport.imported": "{count} Objekte in {sets} Sätzen importiert.",
"ctxImport.importedWithSkipped": "{count} Objekte in {sets} Sätzen importiert ({skipped} Gebäude-Kachel(n) übersprungen, zu gross).",
"ctxImport.buildingsSkipped": "Keine Gebäude geladen — {count} Kachel(n) waren zu gross (dicht bebautes Gebiet). Kleineren Radius versuchen oder Generation wechseln.",
// ── Raum-Befehl ─────────────────────────────────────────────────────────────
"cmd.room.label": "Raum",
+2
View File
@@ -1294,6 +1294,8 @@ export const en: Record<TranslationKey, string> = {
"ctxImport.empty": "No geometry found in the selected area.",
"ctxImport.importError": "Import failed.",
"ctxImport.imported": "Imported {count} objects in {sets} sets.",
"ctxImport.importedWithSkipped": "Imported {count} objects in {sets} sets ({skipped} building tile(s) skipped, too large).",
"ctxImport.buildingsSkipped": "No buildings loaded — {count} tile(s) were too large (dense built-up area). Try a smaller radius or switch generation.",
// Room command.
"cmd.room.label": "Room",
+36 -13
View File
@@ -75,26 +75,49 @@ export function pickAsset(item: StacItem, priority: string[]): StacAsset | null
return pool[0] ?? null;
}
/** Grobe jszip-interne Schätzung der UNKOMPRIMIERTEN Grösse eines Zip-Eintrags
* (Bytes), falls verfügbar — `_data.uncompressedSize` ist kein offiziell
* typisiertes API, aber ein stabiles, verbreitet genutztes internes Feld
* (siehe `jszip/lib/compressedObject.js`). `null`, wenn nicht auslesbar. */
function estimateUncompressedSize(entry: JSZip.JSZipObject): number | null {
const data = (entry as unknown as { _data?: { uncompressedSize?: number } })._data;
return typeof data?.uncompressedSize === "number" ? data.uncompressedSize : null;
}
/** Lädt ein Asset als Text; entpackt automatisch, wenn es ein `.zip` ist
* (liefert den Inhalt der ERSTEN enthaltenen Datei mit einer der `innerExt`-
* Endungen). `null` bei Fehler, fehlendem Inhalt im ZIP, oder wenn das Asset
* {@link MAX_ASSET_BYTES} überschreitet. */
* {@link MAX_ASSET_BYTES} überschreitet (komprimiert ODER — DXF/Text
* komprimiert stark — unkomprimiert, s. u.). Wirft NIE (fängt Netzwerk-/
* ZIP-/String-Längen-Fehler ab und liefert `null`), damit eine einzelne
* kaputte/überdimensionierte Kachel nicht den ganzen Import abbricht. */
export async function downloadAssetText(
asset: StacAsset,
innerExt: string[],
): Promise<string | null> {
if (asset.size != null && asset.size > MAX_ASSET_BYTES) return null;
const res = await fetch(viaProxy(asset.href));
if (!res.ok) return null;
if (asset.href.toLowerCase().endsWith(".zip")) {
const buf = await res.arrayBuffer();
if (buf.byteLength > MAX_ASSET_BYTES) return null;
const zip = await JSZip.loadAsync(buf);
const entry = Object.values(zip.files).find(
(f) => !f.dir && innerExt.some((ext) => f.name.toLowerCase().endsWith(ext)),
);
if (!entry) return null;
return entry.async("text");
try {
const res = await fetch(viaProxy(asset.href));
if (!res.ok) return null;
if (asset.href.toLowerCase().endsWith(".zip")) {
const buf = await res.arrayBuffer();
if (buf.byteLength > MAX_ASSET_BYTES) return null;
const zip = await JSZip.loadAsync(buf);
const entry = Object.values(zip.files).find(
(f) => !f.dir && innerExt.some((ext) => f.name.toLowerCase().endsWith(ext)),
);
if (!entry) return null;
// Proaktiv prüfen: DXF/Text komprimiert stark, ein Zip unter dem Limit
// kann trotzdem eine Text-Datei enthalten, die die max. JS-String-Länge
// sprengt (führte zu einem harten Absturz statt einem übersprungenen
// Tile). Bekannte, aber unschätzbare Grösse (`null`) lässt es zu — der
// äussere try/catch fängt den Fall dann trotzdem sicher ab.
const uncompressed = estimateUncompressedSize(entry);
if (uncompressed != null && uncompressed > MAX_ASSET_BYTES) return null;
return await entry.async("text");
}
return await res.text();
} catch {
return null;
}
return res.text();
}
+25 -6
View File
@@ -61,6 +61,14 @@ export interface FetchBuildings3dResult {
meshes: ImportedMesh[];
/** Tatsächlich gelieferte Version (nach evtl. v3→v2-Fallback). */
versionUsed: BuildingsVersion;
/**
* Anzahl STAC-Kacheln, die NICHT geladen werden konnten (zu gross — DXF
* komprimiert stark, eine Kachel unter {@link MAX_ASSET_BYTES} kann
* entpackt trotzdem die max. Textlänge sprengen —, Netzwerkfehler oder
* fehlender Inhalt). Dient der UI, „0 Gebäude" von „X Kacheln übersprungen,
* zu gross" zu unterscheiden statt eines stummen Leer-Ergebnisses.
*/
skippedTiles: number;
}
/**
@@ -82,24 +90,35 @@ export async function fetchBuildings3d(
const origin = originIn ?? makeOrigin(center);
const bboxWgs = lv95BboxToWgs84(bboxAround(center, radius));
const tryCollection = async (collectionId: string): Promise<ImportedMesh[]> => {
const tryCollection = async (
collectionId: string,
): Promise<{ meshes: ImportedMesh[]; skipped: number }> => {
const items = await stacQuery(collectionId, bboxWgs, ASSET_PRIORITY);
const meshes: ImportedMesh[] = [];
let skipped = 0;
for (const item of items) {
const asset = pickAsset(item, ASSET_PRIORITY);
if (!asset) continue;
if (!asset) {
skipped++;
continue;
}
const text = await downloadAssetText(asset, [".dxf"]);
if (!text) continue;
if (!text) {
skipped++;
continue;
}
const parsed = parseDxf(text);
for (const m of parsed.meshes) meshes.push(shiftMeshToOrigin(m, origin, item.id));
}
return meshes;
return { meshes, skipped };
};
if (version === "v3") {
const v3 = await tryCollection(COLLECTION_V3);
if (v3.length > 0) return { origin, meshes: v3, versionUsed: "v3" };
if (v3.meshes.length > 0) {
return { origin, meshes: v3.meshes, versionUsed: "v3", skippedTiles: v3.skipped };
}
}
const v2 = await tryCollection(COLLECTION_V2);
return { origin, meshes: v2, versionUsed: "v2" };
return { origin, meshes: v2.meshes, versionUsed: "v2", skippedTiles: v2.skipped };
}
+20 -5
View File
@@ -135,6 +135,10 @@ export function ContextImportDialog({
// Echte swissBUILDINGS3D-Meshes (v2/v3) landen direkt als ContextObject
// (kein Footprint+Box-Umweg über GeoFeature/featuresToContextObjects).
const extraObjs: ContextObject[] = [];
// Übersprungene Kacheln (zu gross entpackt, Netzwerkfehler o. Ä.) —
// damit „0 Gebäude" von „X Kacheln übersprungen" unterscheidbar bleibt
// statt eines stummen Leer-Ergebnisses (Nutzer-Report).
let buildingsSkipped = 0;
if (sources.swissBuildings === "flach") {
const r = await fetchBuildings(center, radius, origin);
@@ -149,6 +153,7 @@ export function ContextImportDialog({
);
origin = r.origin;
extraObjs.push(...r.meshes);
buildingsSkipped = r.skippedTiles;
}
const osmSel: OsmSelection = {
@@ -186,7 +191,11 @@ export function ContextImportDialog({
}
if (all.length === 0 && !terrain && extraObjs.length === 0) {
setError(t("ctxImport.empty"));
setError(
buildingsSkipped > 0
? t("ctxImport.buildingsSkipped", { count: buildingsSkipped })
: t("ctxImport.empty"),
);
setStatus(null);
return;
}
@@ -196,10 +205,16 @@ export function ContextImportDialog({
if (terrain) objs.push(terrain);
onImport(objs);
setStatus(
t("ctxImport.imported", {
count: all.length + extraObjs.length,
sets: objs.length,
}),
buildingsSkipped > 0
? t("ctxImport.importedWithSkipped", {
count: all.length + extraObjs.length,
sets: objs.length,
skipped: buildingsSkipped,
})
: t("ctxImport.imported", {
count: all.length + extraObjs.length,
sets: objs.length,
}),
);
onClose();
} catch {