diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 1348ee4..62c4959 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -1273,7 +1273,14 @@ export const de = { "ctxImport.radius": "Radius", "ctxImport.sources": "Quellen", "ctxImport.src.swissBuildings": "Swisstopo-Gebäude", + "ctxImport.buildings.off": "Aus", + "ctxImport.buildings.flach": "Vereinfacht (Umriss, schnell)", + "ctxImport.buildings.v2": "Echt 2.0 (stabil)", + "ctxImport.buildings.v3": "Echt 3.0 (Beta)", "ctxImport.src.swissTerrain": "Swisstopo-Gelände", + "ctxImport.terrain.resolution": "Auflösung", + "ctxImport.terrain.res2": "2 m (schnell)", + "ctxImport.terrain.res05": "0.5 m (fein, grössere Kacheln)", "ctxImport.src.osmBuildings": "OSM-Gebäude", "ctxImport.src.osmRoads": "OSM-Strassen", "ctxImport.src.osmWater": "OSM-Wasser", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 6cbf05c..673a225 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -1261,7 +1261,14 @@ export const en: Record = { "ctxImport.radius": "Radius", "ctxImport.sources": "Sources", "ctxImport.src.swissBuildings": "Swisstopo buildings", + "ctxImport.buildings.off": "Off", + "ctxImport.buildings.flach": "Simplified (footprint, fast)", + "ctxImport.buildings.v2": "Real 2.0 (stable)", + "ctxImport.buildings.v3": "Real 3.0 (Beta)", "ctxImport.src.swissTerrain": "Swisstopo terrain", + "ctxImport.terrain.resolution": "Resolution", + "ctxImport.terrain.res2": "2 m (fast)", + "ctxImport.terrain.res05": "0.5 m (fine, larger tiles)", "ctxImport.src.osmBuildings": "OSM buildings", "ctxImport.src.osmRoads": "OSM roads", "ctxImport.src.osmWater": "OSM water", diff --git a/src/io/stacApi.test.ts b/src/io/stacApi.test.ts new file mode 100644 index 0000000..e12dbf7 --- /dev/null +++ b/src/io/stacApi.test.ts @@ -0,0 +1,64 @@ +// Tests für die reinen (netzwerkfreien) Bausteine der gemeinsamen STAC-API- +// Anbindung: Asset-Auswahl (Prioritäts-/Tile-Filter). Die Netzwerk-Funktionen +// (`stacQuery`/`downloadAssetText`) bleiben ungetestet, analog zu +// `swissTopo.ts::fetchBuildings`/`geocode` (kein Fetch-Mocking im Repo). + +import { describe, it, expect } from "vitest"; +import { pickAsset } from "./stacApi"; +import type { StacItem } from "./stacApi"; + +const DXF_PRIORITY = [".dxf", ".dwg", ".obj", ".ifc", ".dxf.zip", ".dwg.zip", ".obj.zip", ".ifc.zip"]; + +describe("pickAsset", () => { + it("bevorzugt die erste Endung der Prioritätsliste (z. B. DXF vor OBJ/IFC/ZIP)", () => { + const item: StacItem = { + id: "t_1150-23", + assets: [ + { href: "https://x/t_1150-23_.ifc" }, + { href: "https://x/t_1150-23_.obj" }, + { href: "https://x/t_1150-23_.dxf" }, + { href: "https://x/t_1150-23_.dxf.zip" }, + ], + }; + expect(pickAsset(item, DXF_PRIORITY)?.href).toBe("https://x/t_1150-23_.dxf"); + }); + + it("fällt auf ZIP zurück, wenn keine rohe Datei vorhanden ist", () => { + const item: StacItem = { + id: "t_1150-23", + assets: [ + { href: "https://x/t_1150-23_.ifc.zip" }, + { href: "https://x/t_1150-23_.dxf.zip" }, + ], + }; + expect(pickAsset(item, DXF_PRIORITY)?.href).toBe("https://x/t_1150-23_.dxf.zip"); + }); + + it("filtert auf Per-Tile-Assets (Tile-Coord-Marker im Dateinamen)", () => { + const item: StacItem = { + id: "t", + assets: [ + { href: "https://x/gesamte_schweiz.dxf" }, // kein Tile-Marker -> Fallback-Kandidat + { href: "https://x/t_1150-23_.dxf" }, + ], + }; + expect(pickAsset(item, DXF_PRIORITY)?.href).toBe("https://x/t_1150-23_.dxf"); + }); + + it("liefert null bei leerer Asset-Liste", () => { + expect(pickAsset({ id: "t", assets: [] }, DXF_PRIORITY)).toBeNull(); + }); + + it("respektiert eine andere Prioritätsliste (z. B. XYZ vor TIF)", () => { + const item: StacItem = { + id: "t_1150-23", + assets: [ + { href: "https://x/t_1150-23_0.5_2056.tif" }, + { href: "https://x/t_1150-23_0.5_2056.xyz.zip" }, + ], + }; + expect(pickAsset(item, [".xyz", ".xyz.zip", ".tif"])?.href).toBe( + "https://x/t_1150-23_0.5_2056.xyz.zip", + ); + }); +}); diff --git a/src/io/stacApi.ts b/src/io/stacApi.ts new file mode 100644 index 0000000..9f10335 --- /dev/null +++ b/src/io/stacApi.ts @@ -0,0 +1,100 @@ +// Gemeinsame STAC-API-Bausteine für swisstopo-Datensätze (data.geo.admin.ch, +// offen, kein Auth/Key, CORS `*`) — genutzt von `swissBuildings3d.ts` +// (swissBUILDINGS3D 2.0/3.0) und `swissAlti3d.ts` (Höhenmodell). Referenz- +// Implementierung (Rhino-Vorgänger-Plugin, 1:1 als Blaupause portiert): +// git.kgva.ch/karim/DOSSIER, Datei rhino/swisstopo.py. +// +// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Einheiten: Meter. + +import JSZip from "jszip"; +import { viaProxy } from "./geoContext"; + +export const STAC_BASE = "https://data.geo.admin.ch/api/stac/v1"; + +/** Asset-Grössenlimit (Bytes) — schützt vor riesigen Kacheln (v3-Stadt-Tiles, + * 0.5-m-Terrain über einen grossen Radius). Grosszügig, aber endlich. */ +export const MAX_ASSET_BYTES = 150 * 1024 * 1024; + +export interface StacAsset { + href: string; + size?: number; +} +export interface StacItem { + id: string; + assets: StacAsset[]; +} + +/** STAC-Items einer Collection in einer WGS84-bbox, gefiltert nach Datei-Endung. */ +export async function stacQuery( + collectionId: string, + bbox: { south: number; west: number; north: number; east: number }, + assetExtensions: string[], + limit = 100, +): Promise { + const url = + `${STAC_BASE}/collections/${collectionId}/items` + + `?bbox=${bbox.west},${bbox.south},${bbox.east},${bbox.north}&limit=${limit}`; + const res = await fetch(viaProxy(url)); + if (!res.ok) throw new Error(`STAC-Abfrage fehlgeschlagen (${res.status})`); + const data = (await res.json()) as { + features?: { + id?: string; + assets?: Record; + }[]; + }; + const out: StacItem[] = []; + for (const f of data.features ?? []) { + if (!f.id) continue; + const assets: StacAsset[] = []; + for (const v of Object.values(f.assets ?? {})) { + if (!v.href) continue; + const low = v.href.toLowerCase(); + if (!assetExtensions.some((ext) => low.endsWith(ext))) continue; + assets.push({ href: v.href, size: v["file:size"] }); + } + if (assets.length === 0) continue; + out.push({ id: f.id, assets }); + } + return out; +} + +// Tile-Coord-Marker im Dateinamen (z. B. "_1150-23_") — filtert versehentliche +// gesamt-CH-Assets aus (Python-Pendant: `_TILE_FILE_PAT`). +const TILE_FILE_PAT = /_\d{3,4}-\d{2,4}[_.]/; + +/** Bevorzugtes Kachel-Asset EINES STAC-Items wählen: `priority`-Endungen zuerst + * (in Reihenfolge), nur Per-Tile-Assets (kein versehentlicher CH-Komplett- + * datensatz). Exportiert (pur, kein Netzwerk) für Unit-Tests der Auswahllogik. */ +export function pickAsset(item: StacItem, priority: string[]): StacAsset | null { + const perTile = item.assets.filter((a) => TILE_FILE_PAT.test(a.href)); + const pool = perTile.length > 0 ? perTile : item.assets; + for (const ext of priority) { + const hit = pool.find((a) => a.href.toLowerCase().endsWith(ext)); + if (hit) return hit; + } + return pool[0] ?? 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. */ +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"); + } + return res.text(); +} diff --git a/src/io/swissAlti3d.test.ts b/src/io/swissAlti3d.test.ts new file mode 100644 index 0000000..d287b56 --- /dev/null +++ b/src/io/swissAlti3d.test.ts @@ -0,0 +1,95 @@ +// Tests für die reinen (netzwerkfreien) Bausteine des swissALTI3D-XYZ-Imports: +// ASCII-Parsing + Grid-Aufbau (Merge/Clip/Dezimierung). Der eigentliche +// Netzwerk-Fetch (`fetchTerrainXyz`) bleibt ungetestet, analog zu +// `swissTopo.ts::fetchTerrain`/`geocode` (kein Fetch-Mocking im Repo). + +import { describe, it, expect } from "vitest"; +import { parseXyzPoints, buildTerrainGrid } from "./swissAlti3d"; + +describe("parseXyzPoints", () => { + it("parst 'E N Z'-Zeilen zu einer Punkt-Map + sortierten Achsen", () => { + const text = "2600000.00 1200000.00 500.5\n2600002.00 1200000.00 501.0\n2600000.00 1200002.00 502.3\n"; + const { points, es, ns } = parseXyzPoints(text); + expect(points.get("2600000,1200000")).toBe(500.5); + expect(points.get("2600002,1200000")).toBe(501); + expect(es).toEqual([2600000, 2600002]); + expect(ns).toEqual([1200000, 1200002]); + }); + + it("überspringt Kopfzeilen/kaputte Zeilen ohne 3 Zahlen", () => { + const text = "E N Z\nnot a number line\n2600000 1200000 500\n\n"; + const { points } = parseXyzPoints(text); + expect(points.size).toBe(1); + }); + + it("liefert eine leere Map bei leerem Text", () => { + const { points, es, ns } = parseXyzPoints(""); + expect(points.size).toBe(0); + expect(es).toEqual([]); + expect(ns).toEqual([]); + }); +}); + +describe("buildTerrainGrid", () => { + it("baut ein reguläres Grid aus einer einzelnen Kachel, x/y relativ zur Origin", () => { + const tile = parseXyzPoints( + "2600000 1200000 500\n2600002 1200000 501\n2600000 1200002 502\n2600002 1200002 503\n", + ); + const grid = buildTerrainGrid([tile], { e: 2600000, n: 1200000 }, [ + 2599990, 1199990, 2600010, 1200010, + ]); + expect(grid).not.toBeNull(); + expect(grid!.xs).toEqual([0, 2]); + expect(grid!.ys).toEqual([0, 2]); + expect(grid!.z).toEqual([ + [500, 501], + [502, 503], + ]); + }); + + it("clippt Punkte ausserhalb der bbox weg", () => { + const tile = parseXyzPoints("2600000 1200000 500\n2600100 1200100 999\n"); + const grid = buildTerrainGrid([tile], { e: 2600000, n: 1200000 }, [ + 2599990, 1199990, 2600010, 1200010, + ]); + expect(grid!.xs).toEqual([0]); + expect(grid!.ys).toEqual([0]); + expect(grid!.z).toEqual([[500]]); + }); + + it("merged mehrere Kacheln zu einem gemeinsamen Grid", () => { + const a = parseXyzPoints("2600000 1200000 500\n2600002 1200000 501\n"); + const b = parseXyzPoints("2600000 1200002 502\n2600002 1200002 503\n"); + const grid = buildTerrainGrid([a, b], { e: 2600000, n: 1200000 }, [ + 2599990, 1199990, 2600010, 1200010, + ]); + expect(grid!.xs).toEqual([0, 2]); + expect(grid!.ys).toEqual([0, 2]); + expect(grid!.z).toEqual([ + [500, 501], + [502, 503], + ]); + }); + + it("liefert null, wenn nach dem Clipping nichts übrig bleibt", () => { + const tile = parseXyzPoints("2600100 1200100 999\n"); + const grid = buildTerrainGrid([tile], { e: 2600000, n: 1200000 }, [ + 2599990, 1199990, 2600010, 1200010, + ]); + expect(grid).toBeNull(); + }); + + it("dezimiert das Raster gleichmässig, wenn es die Zellenzahl-Grenze überschreitet", () => { + // 700×700 = 490'000 Zellen > MAX_GRID_CELLS (400'000) -> muss dezimiert werden. + const lines: string[] = []; + for (let i = 0; i < 700; i++) { + for (let j = 0; j < 700; j++) lines.push(`${2600000 + i} ${1200000 + j} ${500 + i + j}`); + } + const tile = parseXyzPoints(lines.join("\n")); + const grid = buildTerrainGrid([tile], { e: 2600000, n: 1200000 }, [ + 2599000, 1199000, 2601000, 1201000, + ]); + expect(grid).not.toBeNull(); + expect(grid!.xs.length * grid!.ys.length).toBeLessThanOrEqual(490_000 / 4 + 1); + }); +}); diff --git a/src/io/swissAlti3d.ts b/src/io/swissAlti3d.ts new file mode 100644 index 0000000..2ae23f5 --- /dev/null +++ b/src/io/swissAlti3d.ts @@ -0,0 +1,145 @@ +// swissALTI3D-Import (STAC-API) — echtes Höhenraster (0.5 m oder 2 m native +// Punktdichte) statt der groben `profile.json`-Näherung (s. +// `swissTopo.ts::fetchTerrain`, ~20 m Rasterweite über wenige Profillinien). +// Referenz-Implementierung (Rhino-Vorgänger-Plugin, 1:1 als Blaupause genutzt): +// git.kgva.ch/karim/DOSSIER, Datei rhino/swisstopo.py +// (`fetch_terrain_xyz`/`xyz_to_grid`). +// +// swissALTI3D-Kacheln liegen als ASCII-XYZ vor ("E N Z" je Zeile, LV95), +// bereits in einem regulären, LV95-ausgerichteten Raster bei der gewählten +// Auflösung (Dateiname trägt `_0.5_` bzw. `_2_`) — kein Downsampling nötig, +// die Punkte werden direkt in ein `TerrainGrid` (model/terrain.ts) übernommen. +// Bei sehr grossem Radius (viele Zellen) wird das Achsenraster gleichmässig +// dezimiert (s. {@link MAX_GRID_CELLS}). +// +// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Einheiten: Meter. + +import { bboxAround, lv95BboxToWgs84, makeOrigin } from "./lv95"; +import type { GeoOrigin, LV95 } from "./lv95"; +import { stacQuery, downloadAssetText } from "./stacApi"; +import type { TerrainGrid } from "../model/terrain"; + +const COLLECTION = "ch.swisstopo.swissalti3d"; + +/** Native swissALTI3D-Auflösungen (Dateiname-Tag `_0.5_`/`_2_`). */ +export type TerrainResolution = "0.5" | "2"; + +/** Max. Rasterzellen im finalen Grid — schützt vor Speicherexplosion bei + * grossem Radius + 0.5-m-Auflösung (z. B. 2000 m Radius ⇒ sonst 8000×8000). */ +const MAX_GRID_CELLS = 400_000; + +interface XyzTile { + points: Map; + es: number[]; + ns: number[]; +} + +/** Parst rohe swissALTI3D-XYZ-ASCII-Zeilen ("E N Z", LV95) in eine Punkt-Map + + * sortierte Achsen. Exportiert (pur, kein Netzwerk) für Unit-Tests. */ +export function parseXyzPoints(text: string): XyzTile { + const points = new Map(); + const esSet = new Set(); + const nsSet = new Set(); + for (const line of text.split("\n")) { + const parts = line.trim().split(/\s+/); + if (parts.length < 3) continue; + const e = Number(parts[0]); + const n = Number(parts[1]); + const z = Number(parts[2]); + if (!Number.isFinite(e) || !Number.isFinite(n) || !Number.isFinite(z)) continue; + // Auf 2 Nachkommastellen runden (swissALTI3D-XYZ hat begrenzte Präzision) — + // vermeidet Float-Drift-Duplikate am Kachel-Rand. + const eR = Math.round(e * 100) / 100; + const nR = Math.round(n * 100) / 100; + points.set(`${eR},${nR}`, z); + esSet.add(eR); + nsSet.add(nR); + } + return { points, es: [...esSet].sort((a, b) => a - b), ns: [...nsSet].sort((a, b) => a - b) }; +} + +/** + * Baut ein {@link TerrainGrid} aus mehreren geparsten Kacheln (Punkte werden + * vereinigt, auf `clipBbox` beschnitten, Achsen aus der Vereinigung aller + * verbleibenden Punkte). Dezimiert die Achsen gleichmässig, falls die + * Zellenzahl {@link MAX_GRID_CELLS} überschritten würde. Exportiert (pur, + * kein Netzwerk) für Unit-Tests. `null` bei leerem Ergebnis. + */ +export function buildTerrainGrid( + tiles: XyzTile[], + origin: GeoOrigin, + clipBbox: [number, number, number, number], +): TerrainGrid | null { + const merged = new Map(); + const esSet = new Set(); + const nsSet = new Set(); + const [eMin, nMin, eMax, nMax] = clipBbox; + for (const tile of tiles) { + for (const [key, z] of tile.points) { + const sep = key.indexOf(","); + const e = Number(key.slice(0, sep)); + const n = Number(key.slice(sep + 1)); + if (e < eMin || e > eMax || n < nMin || n > nMax) continue; + merged.set(key, z); + esSet.add(e); + nsSet.add(n); + } + } + let es = [...esSet].sort((a, b) => a - b); + let ns = [...nsSet].sort((a, b) => a - b); + if (es.length === 0 || ns.length === 0) return null; + + const totalCells = es.length * ns.length; + if (totalCells > MAX_GRID_CELLS) { + const factor = Math.ceil(Math.sqrt(totalCells / MAX_GRID_CELLS)); + es = es.filter((_, i) => i % factor === 0); + ns = ns.filter((_, i) => i % factor === 0); + } + + const z: (number | null)[][] = ns.map((n) => + es.map((e) => merged.get(`${e},${n}`) ?? null), + ); + return { + xs: es.map((e) => e - origin.e), + ys: ns.map((n) => n - origin.n), + z, + name: "Gelände (swissALTI3D)", + }; +} + +export interface FetchTerrainXyzResult { + origin: GeoOrigin; + grid: TerrainGrid | null; +} + +/** + * Echtes swissALTI3D-Höhenraster (0.5 m oder 2 m native Punktdichte, je nach + * `resolution`) für eine LV95-bbox — ersetzt die grobe `profile.json`- + * Näherung (`swissTopo.ts::fetchTerrain`, ~20 m Rasterweite). Mehrere + * überdeckte 1-km-Kacheln werden zu einem gemeinsamen Grid vereinigt. + */ +export async function fetchTerrainXyz( + center: LV95, + radius: number, + originIn?: GeoOrigin, + resolution: TerrainResolution = "2", +): Promise { + const origin = originIn ?? makeOrigin(center); + const bboxLv95 = bboxAround(center, radius); + const bboxWgs = lv95BboxToWgs84(bboxLv95); + + const items = await stacQuery(COLLECTION, bboxWgs, [".xyz", ".xyz.zip"]); + const tag = `_${resolution}_`; + const tiles: XyzTile[] = []; + for (const item of items) { + // Asset mit passendem Auflösungs-Tag bevorzugen, sonst irgendeines nehmen + // (bessere Teilabdeckung als gar nichts). + const asset = item.assets.find((a) => a.href.includes(tag)) ?? item.assets[0]; + if (!asset) continue; + const text = await downloadAssetText(asset, [".xyz"]); + if (!text) continue; + tiles.push(parseXyzPoints(text)); + } + if (tiles.length === 0) return { origin, grid: null }; + return { origin, grid: buildTerrainGrid(tiles, origin, bboxLv95) }; +} diff --git a/src/io/swissBuildings3d.test.ts b/src/io/swissBuildings3d.test.ts new file mode 100644 index 0000000..28b15b2 --- /dev/null +++ b/src/io/swissBuildings3d.test.ts @@ -0,0 +1,38 @@ +// Tests für die reinen (netzwerkfreien) Bausteine des swissBUILDINGS3D-STAC- +// Imports: Koordinaten-Verschiebung der geparsten Meshes. Asset-Auswahl wird +// generisch in `stacApi.test.ts` getestet. Der eigentliche Netzwerk-Fetch +// (`fetchBuildings3d`) bleibt ungetestet, analog zu +// `swissTopo.ts::fetchBuildings`/`geocode` (kein Fetch-Mocking im Repo). + +import { describe, it, expect } from "vitest"; +import { shiftMeshToOrigin } from "./swissBuildings3d"; +import type { ImportedMesh } from "../model/types"; + +describe("shiftMeshToOrigin", () => { + it("verschiebt x/y um die Origin, lässt z unverändert", () => { + const mesh: ImportedMesh = { + id: "m1", + type: "importedMesh", + name: "roh", + positions: [2600100, 1200050, 3.5, 2600110, 1200060, 7.2], + indices: [0, 1, 0], + }; + const shifted = shiftMeshToOrigin(mesh, { e: 2600000, n: 1200000 }, "tile-42"); + expect(shifted.positions).toEqual([100, 50, 3.5, 110, 60, 7.2]); + expect(shifted.indices).toBe(mesh.indices); + expect(shifted.name).toContain("tile-42"); + }); + + it("mutiert das Eingabe-Mesh nicht", () => { + const mesh: ImportedMesh = { + id: "m1", + type: "importedMesh", + name: "roh", + positions: [2600100, 1200050, 0], + indices: [], + }; + const original = mesh.positions.slice(); + shiftMeshToOrigin(mesh, { e: 2600000, n: 1200000 }, "x"); + expect(mesh.positions).toEqual(original); + }); +}); diff --git a/src/io/swissBuildings3d.ts b/src/io/swissBuildings3d.ts new file mode 100644 index 0000000..2ed72e7 --- /dev/null +++ b/src/io/swissBuildings3d.ts @@ -0,0 +1,105 @@ +// swissBUILDINGS3D-Import (STAC-API) — echte Gebäude-3D-Geometrie (Wände + +// Dachformen) statt der pauschal auf DEFAULT_BUILDING_HEIGHT extrudierten +// VEC25-Footprints (s. `swissTopo.ts::fetchBuildings`). Zwei Datensatz- +// Generationen, beide über dieselbe offene STAC-API (`stacApi.ts`): +// • v2 ("ch.swisstopo.swissbuildings3d_2") — stabil, 1-km-Tiles, ~50 MB. +// • v3 ("ch.swisstopo.swissbuildings3d_3_0") — Beta, Solid/Separated- +// Varianten, in Städten teils >150 MB/Tile (nicht 1-km-strukturiert) → +// wir fallen bei leeren/zu grossen v3-Treffern automatisch auf v2 zurück. +// Referenz-Implementierung (Rhino-Vorgänger-Plugin, 1:1 als Blaupause genutzt): +// git.kgva.ch/karim/DOSSIER, Datei rhino/swisstopo.py. +// +// Assets liegen als DXF/DWG/OBJ/IFC vor (teils `.zip`) — wir wählen bevorzugt +// DXF: `parseDxf` (dxfParser.ts) liest 3DFACE/MESH/Polyface-Entities bereits +// verlustfrei zu `ImportedMesh` (positions/indices, echtes Z) — kein neuer +// Parser nötig. +// +// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Einheiten: Meter. + +import { bboxAround, lv95BboxToWgs84, makeOrigin } from "./lv95"; +import type { GeoOrigin, LV95 } from "./lv95"; +import { stacQuery, pickAsset, downloadAssetText } from "./stacApi"; +import { parseDxf } from "./dxfParser"; +import type { ImportedMesh } from "../model/types"; + +const COLLECTION_V2 = "ch.swisstopo.swissbuildings3d_2"; +const COLLECTION_V3 = "ch.swisstopo.swissbuildings3d_3_0"; + +/** Bevorzugte Asset-Endungen, in Prioritätsreihenfolge (DXF am stabilsten, + * da `parseDxf` ihn direkt zu `ImportedMesh` verarbeitet). */ +const ASSET_PRIORITY = [ + ".dxf", + ".dwg", + ".obj", + ".ifc", + ".dxf.zip", + ".dwg.zip", + ".obj.zip", + ".ifc.zip", +]; + +export type BuildingsVersion = "v2" | "v3"; + +/** Verschiebt ein DXF-geparstes Mesh (rohe LV95-Koordinaten) um die Origin in + * lokale Modell-Meter — dieselbe Konvention wie `swissTopo.ts::fetchBuildings`. + * Exportiert (pur, kein Netzwerk) für Unit-Tests der Koordinaten-Verschiebung. */ +export function shiftMeshToOrigin( + mesh: ImportedMesh, + origin: GeoOrigin, + sourceId: string, +): ImportedMesh { + const positions = mesh.positions.slice(); + for (let i = 0; i < positions.length; i += 3) { + positions[i] -= origin.e; + positions[i + 1] -= origin.n; + } + return { ...mesh, positions, name: `swissBUILDINGS3D ${sourceId}` }; +} + +export interface FetchBuildings3dResult { + origin: GeoOrigin; + meshes: ImportedMesh[]; + /** Tatsächlich gelieferte Version (nach evtl. v3→v2-Fallback). */ + versionUsed: BuildingsVersion; +} + +/** + * Echte swissBUILDINGS3D-Gebäude (Wände + Dachformen, keine Box-Extrusion) für + * eine LV95-bbox. `version="v3"` versucht zuerst die Beta-Generation und fällt + * bei leeren/unbrauchbaren Treffern automatisch auf v2 zurück (analog zum + * Rhino-Referenz-Plugin — v3-Tiles können in Städten riesig sein). + * + * `originIn` fehlt ⇒ neue Origin aus `center` (wie `swissTopo.ts::fetchBuildings`) — + * so teilen sich mehrere Importquellen eines Laufs dieselbe Origin, wenn der + * Aufrufer die erste zurückgelieferte `origin` an die nächste Quelle reicht. + */ +export async function fetchBuildings3d( + center: LV95, + radius: number, + originIn?: GeoOrigin, + version: BuildingsVersion = "v2", +): Promise { + const origin = originIn ?? makeOrigin(center); + const bboxWgs = lv95BboxToWgs84(bboxAround(center, radius)); + + const tryCollection = async (collectionId: string): Promise => { + const items = await stacQuery(collectionId, bboxWgs, ASSET_PRIORITY); + const meshes: ImportedMesh[] = []; + for (const item of items) { + const asset = pickAsset(item, ASSET_PRIORITY); + if (!asset) continue; + const text = await downloadAssetText(asset, [".dxf"]); + if (!text) continue; + const parsed = parseDxf(text); + for (const m of parsed.meshes) meshes.push(shiftMeshToOrigin(m, origin, item.id)); + } + return meshes; + }; + + if (version === "v3") { + const v3 = await tryCollection(COLLECTION_V3); + if (v3.length > 0) return { origin, meshes: v3, versionUsed: "v3" }; + } + const v2 = await tryCollection(COLLECTION_V2); + return { origin, meshes: v2, versionUsed: "v2" }; +} diff --git a/src/io/swissTopo.ts b/src/io/swissTopo.ts index 05b5d6b..66a20da 100644 --- a/src/io/swissTopo.ts +++ b/src/io/swissTopo.ts @@ -3,13 +3,17 @@ // • geocode(query) — Ortssuche/Adresse → Kandidaten mit LV95-Koordinaten. // Nutzt api3.geo.admin.ch SearchServer (sendet CORS `*`, // daher Direktabruf; Proxy nur als Fallback). -// • fetchBuildings() — Gebäude-GRUNDRISSE für eine LV95-Box → Ringe in +// • fetchBuildings() — Gebäude-GRUNDRISSE (vereinfachter 1:25'000-Kartografie- +// Layer, KEINE Höhe) für eine LV95-Box → Ringe in // lokalen Modell-Metern. Nutzt den geo.admin -// MapServer/identify auf `ch.swisstopo.vec25-gebaeude` -// (liefert Polygon-`rings` in LV95, aus dem Browser -// abrufbar). swissBUILDINGS3D-Kachel-Downloads (STAC) -// sind für einen Live-Box-Query im Browser zu schwer und -// werden bewusst NICHT verwendet. +// MapServer/identify auf `ch.swisstopo.vec25-gebaeude`. +// Schneller "Vorschau"-Pfad; für echte Gebäudegeometrie +// (Wände+Dach, reale Höhe) s. `swissBuildings3d.ts` +// (STAC-API, entgegen einer früheren Annahme hier DOCH +// gut browser-tauglich — s. Recherche in PENDENZEN.md). +// +// Echtes Höhenraster (0.5 m/2 m): s. `swissAlti3d.ts::fetchTerrainXyz` +// (ersetzt die frühere `fetchTerrain`-profile.json-Näherung in dieser Datei). // // Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Einheiten: Meter. @@ -17,7 +21,6 @@ import { bboxAround, makeOrigin, wgs84ToLv95 } from "./lv95"; import type { GeoOrigin, LV95 } from "./lv95"; import type { GeoFeature } from "./geoContext"; import { viaProxy } from "./geoContext"; -import type { TerrainGrid } from "../model/terrain"; const GEOADMIN = "https://api3.geo.admin.ch"; @@ -136,104 +139,7 @@ export async function fetchBuildings( return { origin, features }; } -/** Ein Punkt aus dem geo.admin-Höhenprofil (profile.json, sr=2056). */ -interface ProfilePoint { - dist: number; - easting: number; - northing: number; - alts?: { DTM2?: number; DTM25?: number; COMB?: number }; -} - -/** - * Gelände-Höhenraster (swissALTI3D-DTM) für Zentrum + Radius laden. - * - * Statt schwerer swissALTI3D-Kachel-Downloads (STAC) nutzt der Port den - * CORS-fähigen geo.admin `profile.json`-Dienst: pro Rasterzeile EIN Request - * liefert die Höhen entlang einer Ost-West-Linie (nb_points Stützstellen). So - * entsteht ein reguläres N×N-Höhenraster mit wenigen Requests, direkt aus dem - * Browser. - * - * Die Höhen werden auf den Wert am Zentrum bezogen (z=0 im Zentrum), damit das - * Gelände beim Modell-Ursprung liegt und lagerichtig zu den Gebäuden (Basis - * z=0) sitzt. Rückgabe: das Raster in lokalen Metern + die verwendete Origin. - */ -export async function fetchTerrain( - center: LV95, - radius: number, - originIn?: GeoOrigin, -): Promise<{ origin: GeoOrigin; grid: TerrainGrid | null }> { - const origin = originIn ?? makeOrigin(center); - const [eMin, nMin, eMax] = bboxAround(center, radius); - const span = 2 * radius; - // Ziel-Rasterweite ~20 m, aber N in [6, 24] begrenzen (Request-Zahl zähmen). - const n = Math.max(6, Math.min(24, Math.round(span / 20) + 1)); - - // Achsen (LV95) + lokale Achsen. - const eAxis: number[] = []; - const nAxis: number[] = []; - const xs: number[] = []; - const ys: number[] = []; - for (let i = 0; i < n; i++) { - const e = eMin + (span * i) / (n - 1); - const nn = nMin + (span * i) / (n - 1); - eAxis.push(e); - nAxis.push(nn); - xs.push(e - origin.e); - ys.push(nn - origin.n); - } - - // Eine Rasterzeile (konstantes N) via profile.json abrufen → Höhen je Spalte. - const fetchRow = async (north: number): Promise<(number | null)[]> => { - const geom = JSON.stringify({ - type: "LineString", - coordinates: [ - [eMin, north], - [eMax, north], - ], - }); - const url = - `${GEOADMIN}/rest/services/profile.json` + - `?geom=${encodeURIComponent(geom)}` + - `&sr=2056&nb_points=${n}&distinct_points=true&offset=0`; - try { - const res = await fetch(viaProxy(url)); - if (!res.ok) return new Array(n).fill(null); - const pts = (await res.json()) as ProfilePoint[]; - const row: (number | null)[] = new Array(n).fill(null); - // Die Punkte kommen nach Distanz sortiert (West→Ost) — direkt Spalte i. - pts.forEach((p, i) => { - if (i >= n) return; - const z = p.alts?.DTM2 ?? p.alts?.COMB ?? p.alts?.DTM25; - row[i] = Number.isFinite(z as number) ? (z as number) : null; - }); - return row; - } catch { - return new Array(n).fill(null); - } - }; - - // Alle Zeilen parallel (n ≤ 24 Requests). - const rows = await Promise.all(nAxis.map((north) => fetchRow(north))); - - // Höhen-Nullpunkt: Wert am Rasterzentrum (fällt der aus, Mittel aller Werte). - const mid = Math.floor(n / 2); - let zRef = rows[mid]?.[mid] ?? null; - if (zRef == null || !Number.isFinite(zRef)) { - let sum = 0; - let cnt = 0; - for (const r of rows) - for (const v of r) - if (v != null && Number.isFinite(v)) { - sum += v; - cnt++; - } - zRef = cnt > 0 ? sum / cnt : null; - } - if (zRef == null) return { origin, grid: null }; - - // Höhen relativ zum Zentrum (z=0 im Zentrum). - const z: (number | null)[][] = rows.map((r) => - r.map((v) => (v == null ? null : v - (zRef as number))), - ); - return { origin, grid: { xs, ys, z, name: "Gelände (swisstopo)" } }; -} +// Das frühere `fetchTerrain` (grobe `profile.json`-Näherung, ~20 m Raster- +// weite über wenige Profillinien) ist durch `swissAlti3d.ts::fetchTerrainXyz` +// ersetzt (echtes swissALTI3D-Raster bei 0.5 m/2 m nativer Punktdichte, STAC- +// API, s. Referenz git.kgva.ch/karim/DOSSIER rhino/swisstopo.py). diff --git a/src/styles.css b/src/styles.css index eeaf88f..d84b13f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -5474,6 +5474,20 @@ body { color: var(--ink); cursor: pointer; } +.cim-check-row { + justify-content: space-between; + cursor: default; +} +.cim-check-row select.cim-input { + width: auto; + min-width: 160px; +} +.cim-check-with-box { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; +} .cim-status { color: var(--muted); font-size: 12px; diff --git a/src/ui/ContextImportDialog.tsx b/src/ui/ContextImportDialog.tsx index e99a632..6b218ae 100644 --- a/src/ui/ContextImportDialog.tsx +++ b/src/ui/ContextImportDialog.tsx @@ -11,8 +11,12 @@ import { useEffect, useRef, useState } from "react"; import { t } from "../i18n"; -import { geocode, fetchBuildings, fetchTerrain } from "../io/swissTopo"; +import { geocode, fetchBuildings } from "../io/swissTopo"; import type { GeocodeCandidate } from "../io/swissTopo"; +import { fetchBuildings3d } from "../io/swissBuildings3d"; +import type { BuildingsVersion } from "../io/swissBuildings3d"; +import { fetchTerrainXyz } from "../io/swissAlti3d"; +import type { TerrainResolution } from "../io/swissAlti3d"; import { fetchOsm } from "../io/osm"; import type { OsmSelection } from "../io/osm"; import { featuresToContextObjects } from "../io/geoContext"; @@ -21,6 +25,10 @@ import { terrainMeshFromGrid } from "../model/terrain"; import type { GeoOrigin } from "../io/lv95"; import type { ContextObject } from "../model/types"; +/** Gebäude-Import-Modus: aus, vereinfachte Box (schnell, alt) oder echte + * swissBUILDINGS3D-Geometrie (Wände+Dach) in einer der zwei Generationen. */ +export type SwissBuildingsMode = "off" | "flach" | "v2" | "v3"; + export interface ContextImportDialogProps { /** Übernimmt die fertigen Kontext-Objekte (nach erfolgreichem Import). */ onImport: (objs: ContextObject[]) => void; @@ -28,7 +36,7 @@ export interface ContextImportDialogProps { } type Sources = { - swissBuildings: boolean; + swissBuildings: SwissBuildingsMode; swissTerrain: boolean; osmBuildings: boolean; osmRoads: boolean; @@ -40,7 +48,9 @@ type Sources = { }; const DEFAULT_SOURCES: Sources = { - swissBuildings: true, + // v2 = stabile Generation (1-km-Tiles, ~50 MB) als Default; v3 (Beta) kann in + // Städten sehr grosse Kacheln liefern (s. swissBuildings3d.ts-Kommentar). + swissBuildings: "v2", swissTerrain: true, osmBuildings: false, osmRoads: true, @@ -68,6 +78,8 @@ export function ContextImportDialog({ const [selected, setSelected] = useState(null); const [radius, setRadius] = useState(200); const [sources, setSources] = useState(DEFAULT_SOURCES); + // 2 m = Default (schneller/kleinere Kacheln); 0.5 m für Detailarbeit. + const [terrainResolution, setTerrainResolution] = useState("2"); const [searching, setSearching] = useState(false); const [importing, setImporting] = useState(false); @@ -95,11 +107,13 @@ export function ContextImportDialog({ } }; - const toggle = (key: keyof Sources) => + const toggle = (key: Exclude) => setSources((s) => ({ ...s, [key]: !s[key] })); + const setSwissBuildingsMode = (mode: SwissBuildingsMode) => + setSources((s) => ({ ...s, swissBuildings: mode })); const anySource = - sources.swissBuildings || + sources.swissBuildings !== "off" || sources.swissTerrain || sources.osmBuildings || sources.osmRoads || @@ -118,11 +132,23 @@ export function ContextImportDialog({ const center = selected.lv95; let origin: GeoOrigin | undefined; const all: GeoFeature[] = []; + // Echte swissBUILDINGS3D-Meshes (v2/v3) landen direkt als ContextObject + // (kein Footprint+Box-Umweg über GeoFeature/featuresToContextObjects). + const extraObjs: ContextObject[] = []; - if (sources.swissBuildings) { + if (sources.swissBuildings === "flach") { const r = await fetchBuildings(center, radius, origin); origin = r.origin; all.push(...r.features); + } else if (sources.swissBuildings === "v2" || sources.swissBuildings === "v3") { + const r = await fetchBuildings3d( + center, + radius, + origin, + sources.swissBuildings as BuildingsVersion, + ); + origin = r.origin; + extraObjs.push(...r.meshes); } const osmSel: OsmSelection = { @@ -148,10 +174,10 @@ export function ContextImportDialog({ all.push(...r.features); } - // Gelände-Höhenraster (swissALTI3D via geo.admin-Profil) → Terrain-Mesh. + // Gelände-Höhenraster (echtes swissALTI3D-XYZ, native Auflösung) → Terrain-Mesh. let terrain: ContextObject | null = null; if (sources.swissTerrain) { - const r = await fetchTerrain(center, radius, origin); + const r = await fetchTerrainXyz(center, radius, origin, terrainResolution); origin = r.origin; if (r.grid) { const mesh = terrainMeshFromGrid(r.grid); @@ -159,17 +185,21 @@ export function ContextImportDialog({ } } - if (all.length === 0 && !terrain) { + if (all.length === 0 && !terrain && extraObjs.length === 0) { setError(t("ctxImport.empty")); setStatus(null); return; } const objs = featuresToContextObjects(all, selected.label); + objs.push(...extraObjs); if (terrain) objs.push(terrain); onImport(objs); setStatus( - t("ctxImport.imported", { count: all.length, sets: objs.length }), + t("ctxImport.imported", { + count: all.length + extraObjs.length, + sets: objs.length, + }), ); onClose(); } catch { @@ -272,21 +302,43 @@ export function ContextImportDialog({
{t("ctxImport.sources")}
-