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:
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user