From 35a6834072caaf3ffdcc79c3a3ca31a6457e8438 Mon Sep 17 00:00:00 2001 From: Karim Date: Sun, 12 Jul 2026 19:15:45 +0200 Subject: [PATCH] =?UTF-8?q?swissBUILDINGS3D-Import:=20Absturz=20bei=20ries?= =?UTF-8?q?igen=20Kacheln=20behoben,=20=C3=9Cbersprungene=20sichtbar=20gem?= =?UTF-8?q?acht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/i18n/de.ts | 2 ++ src/i18n/en.ts | 2 ++ src/io/stacApi.ts | 49 +++++++++++++++++++++++++--------- src/io/swissBuildings3d.ts | 31 ++++++++++++++++----- src/ui/ContextImportDialog.tsx | 25 +++++++++++++---- 5 files changed, 85 insertions(+), 24 deletions(-) diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 707d657..bd4893f 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -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", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 9e6cfa4..5b6b776 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -1294,6 +1294,8 @@ export const en: Record = { "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", diff --git a/src/io/stacApi.ts b/src/io/stacApi.ts index 9f10335..9934471 100644 --- a/src/io/stacApi.ts +++ b/src/io/stacApi.ts @@ -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 { 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(); } diff --git a/src/io/swissBuildings3d.ts b/src/io/swissBuildings3d.ts index 2ed72e7..c63553a 100644 --- a/src/io/swissBuildings3d.ts +++ b/src/io/swissBuildings3d.ts @@ -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 => { + 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 }; } diff --git a/src/ui/ContextImportDialog.tsx b/src/ui/ContextImportDialog.tsx index 6b218ae..8d3d736 100644 --- a/src/ui/ContextImportDialog.tsx +++ b/src/ui/ContextImportDialog.tsx @@ -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 {