Standort-Import: echte swissBUILDINGS3D-Gebäude (2.0/3.0) + swissALTI3D-Terrain

Ersetzt die stark vereinfachte Box-Extrusion (vec25-Footprint + Pauschalhöhe
9m) durch echte Gebäudegeometrie aus den swissBUILDINGS3D-STAC-Kacheln (Wahl
zwischen Generation 2.0 stabil und 3.0 Beta, DXF-Kacheln über den bestehenden
dxfParser als Mesh eingelesen). Gelände kommt neu aus echten swissALTI3D-XYZ-
Rastern (0.5m/2m wählbare Punktdichte) statt der groben profile.json-Näherung.
Gemeinsames STAC-Client-Modul (stacApi.ts) für beide Quellen.
This commit is contained in:
2026-07-12 17:38:47 +02:00
parent 00bff27b02
commit ed724be481
11 changed files with 665 additions and 132 deletions
+7
View File
@@ -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",
+7
View File
@@ -1261,7 +1261,14 @@ export const en: Record<TranslationKey, string> = {
"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",
+64
View File
@@ -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",
);
});
});
+100
View File
@@ -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<StacItem[]> {
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<string, { href?: string; "file:size"?: number }>;
}[];
};
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<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");
}
return res.text();
}
+95
View File
@@ -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);
});
});
+145
View File
@@ -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<string, number>;
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<string, number>();
const esSet = new Set<number>();
const nsSet = new Set<number>();
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<string, number>();
const esSet = new Set<number>();
const nsSet = new Set<number>();
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<FetchTerrainXyzResult> {
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) };
}
+38
View File
@@ -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);
});
});
+105
View File
@@ -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<FetchBuildings3dResult> {
const origin = originIn ?? makeOrigin(center);
const bboxWgs = lv95BboxToWgs84(bboxAround(center, radius));
const tryCollection = async (collectionId: string): Promise<ImportedMesh[]> => {
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" };
}
+14 -108
View File
@@ -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).
+14
View File
@@ -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;
+76 -24
View File
@@ -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<GeocodeCandidate | null>(null);
const [radius, setRadius] = useState(200);
const [sources, setSources] = useState<Sources>(DEFAULT_SOURCES);
// 2 m = Default (schneller/kleinere Kacheln); 0.5 m für Detailarbeit.
const [terrainResolution, setTerrainResolution] = useState<TerrainResolution>("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<keyof Sources, "swissBuildings">) =>
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({
<section className="imp-section">
<div className="imp-section-title">{t("ctxImport.sources")}</div>
<div className="cim-checks">
<label className="cim-check">
<input
type="checkbox"
checked={sources.swissBuildings}
onChange={() => toggle("swissBuildings")}
/>
{t("ctxImport.src.swissBuildings")}
<label className="cim-check cim-check-row">
<span>{t("ctxImport.src.swissBuildings")}</span>
<select
className="cim-input"
value={sources.swissBuildings}
onChange={(e) =>
setSwissBuildingsMode(e.target.value as SwissBuildingsMode)
}
aria-label={t("ctxImport.src.swissBuildings")}
>
<option value="off">{t("ctxImport.buildings.off")}</option>
<option value="flach">{t("ctxImport.buildings.flach")}</option>
<option value="v2">{t("ctxImport.buildings.v2")}</option>
<option value="v3">{t("ctxImport.buildings.v3")}</option>
</select>
</label>
<label className="cim-check">
<input
type="checkbox"
checked={sources.swissTerrain}
onChange={() => toggle("swissTerrain")}
/>
{t("ctxImport.src.swissTerrain")}
<label className="cim-check cim-check-row">
<span className="cim-check-with-box">
<input
type="checkbox"
checked={sources.swissTerrain}
onChange={() => toggle("swissTerrain")}
/>
{t("ctxImport.src.swissTerrain")}
</span>
<select
className="cim-input"
value={terrainResolution}
disabled={!sources.swissTerrain}
onChange={(e) =>
setTerrainResolution(e.target.value as TerrainResolution)
}
aria-label={t("ctxImport.terrain.resolution")}
>
<option value="2">{t("ctxImport.terrain.res2")}</option>
<option value="0.5">{t("ctxImport.terrain.res05")}</option>
</select>
</label>
<label className="cim-check">
<input