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:
@@ -1273,7 +1273,14 @@ export const de = {
|
|||||||
"ctxImport.radius": "Radius",
|
"ctxImport.radius": "Radius",
|
||||||
"ctxImport.sources": "Quellen",
|
"ctxImport.sources": "Quellen",
|
||||||
"ctxImport.src.swissBuildings": "Swisstopo-Gebäude",
|
"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.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.osmBuildings": "OSM-Gebäude",
|
||||||
"ctxImport.src.osmRoads": "OSM-Strassen",
|
"ctxImport.src.osmRoads": "OSM-Strassen",
|
||||||
"ctxImport.src.osmWater": "OSM-Wasser",
|
"ctxImport.src.osmWater": "OSM-Wasser",
|
||||||
|
|||||||
@@ -1261,7 +1261,14 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"ctxImport.radius": "Radius",
|
"ctxImport.radius": "Radius",
|
||||||
"ctxImport.sources": "Sources",
|
"ctxImport.sources": "Sources",
|
||||||
"ctxImport.src.swissBuildings": "Swisstopo buildings",
|
"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.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.osmBuildings": "OSM buildings",
|
||||||
"ctxImport.src.osmRoads": "OSM roads",
|
"ctxImport.src.osmRoads": "OSM roads",
|
||||||
"ctxImport.src.osmWater": "OSM water",
|
"ctxImport.src.osmWater": "OSM water",
|
||||||
|
|||||||
@@ -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.
|
// • geocode(query) — Ortssuche/Adresse → Kandidaten mit LV95-Koordinaten.
|
||||||
// Nutzt api3.geo.admin.ch SearchServer (sendet CORS `*`,
|
// Nutzt api3.geo.admin.ch SearchServer (sendet CORS `*`,
|
||||||
// daher Direktabruf; Proxy nur als Fallback).
|
// 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
|
// lokalen Modell-Metern. Nutzt den geo.admin
|
||||||
// MapServer/identify auf `ch.swisstopo.vec25-gebaeude`
|
// MapServer/identify auf `ch.swisstopo.vec25-gebaeude`.
|
||||||
// (liefert Polygon-`rings` in LV95, aus dem Browser
|
// Schneller "Vorschau"-Pfad; für echte Gebäudegeometrie
|
||||||
// abrufbar). swissBUILDINGS3D-Kachel-Downloads (STAC)
|
// (Wände+Dach, reale Höhe) s. `swissBuildings3d.ts`
|
||||||
// sind für einen Live-Box-Query im Browser zu schwer und
|
// (STAC-API, entgegen einer früheren Annahme hier DOCH
|
||||||
// werden bewusst NICHT verwendet.
|
// 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.
|
// 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 { GeoOrigin, LV95 } from "./lv95";
|
||||||
import type { GeoFeature } from "./geoContext";
|
import type { GeoFeature } from "./geoContext";
|
||||||
import { viaProxy } from "./geoContext";
|
import { viaProxy } from "./geoContext";
|
||||||
import type { TerrainGrid } from "../model/terrain";
|
|
||||||
|
|
||||||
const GEOADMIN = "https://api3.geo.admin.ch";
|
const GEOADMIN = "https://api3.geo.admin.ch";
|
||||||
|
|
||||||
@@ -136,104 +139,7 @@ export async function fetchBuildings(
|
|||||||
return { origin, features };
|
return { origin, features };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ein Punkt aus dem geo.admin-Höhenprofil (profile.json, sr=2056). */
|
// Das frühere `fetchTerrain` (grobe `profile.json`-Näherung, ~20 m Raster-
|
||||||
interface ProfilePoint {
|
// weite über wenige Profillinien) ist durch `swissAlti3d.ts::fetchTerrainXyz`
|
||||||
dist: number;
|
// ersetzt (echtes swissALTI3D-Raster bei 0.5 m/2 m nativer Punktdichte, STAC-
|
||||||
easting: number;
|
// API, s. Referenz git.kgva.ch/karim/DOSSIER rhino/swisstopo.py).
|
||||||
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)" } };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5474,6 +5474,20 @@ body {
|
|||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
cursor: pointer;
|
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 {
|
.cim-status {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -11,8 +11,12 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { geocode, fetchBuildings, fetchTerrain } from "../io/swissTopo";
|
import { geocode, fetchBuildings } from "../io/swissTopo";
|
||||||
import type { GeocodeCandidate } 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 { fetchOsm } from "../io/osm";
|
||||||
import type { OsmSelection } from "../io/osm";
|
import type { OsmSelection } from "../io/osm";
|
||||||
import { featuresToContextObjects } from "../io/geoContext";
|
import { featuresToContextObjects } from "../io/geoContext";
|
||||||
@@ -21,6 +25,10 @@ import { terrainMeshFromGrid } from "../model/terrain";
|
|||||||
import type { GeoOrigin } from "../io/lv95";
|
import type { GeoOrigin } from "../io/lv95";
|
||||||
import type { ContextObject } from "../model/types";
|
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 {
|
export interface ContextImportDialogProps {
|
||||||
/** Übernimmt die fertigen Kontext-Objekte (nach erfolgreichem Import). */
|
/** Übernimmt die fertigen Kontext-Objekte (nach erfolgreichem Import). */
|
||||||
onImport: (objs: ContextObject[]) => void;
|
onImport: (objs: ContextObject[]) => void;
|
||||||
@@ -28,7 +36,7 @@ export interface ContextImportDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Sources = {
|
type Sources = {
|
||||||
swissBuildings: boolean;
|
swissBuildings: SwissBuildingsMode;
|
||||||
swissTerrain: boolean;
|
swissTerrain: boolean;
|
||||||
osmBuildings: boolean;
|
osmBuildings: boolean;
|
||||||
osmRoads: boolean;
|
osmRoads: boolean;
|
||||||
@@ -40,7 +48,9 @@ type Sources = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_SOURCES: 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,
|
swissTerrain: true,
|
||||||
osmBuildings: false,
|
osmBuildings: false,
|
||||||
osmRoads: true,
|
osmRoads: true,
|
||||||
@@ -68,6 +78,8 @@ export function ContextImportDialog({
|
|||||||
const [selected, setSelected] = useState<GeocodeCandidate | null>(null);
|
const [selected, setSelected] = useState<GeocodeCandidate | null>(null);
|
||||||
const [radius, setRadius] = useState(200);
|
const [radius, setRadius] = useState(200);
|
||||||
const [sources, setSources] = useState<Sources>(DEFAULT_SOURCES);
|
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 [searching, setSearching] = useState(false);
|
||||||
const [importing, setImporting] = 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] }));
|
setSources((s) => ({ ...s, [key]: !s[key] }));
|
||||||
|
const setSwissBuildingsMode = (mode: SwissBuildingsMode) =>
|
||||||
|
setSources((s) => ({ ...s, swissBuildings: mode }));
|
||||||
|
|
||||||
const anySource =
|
const anySource =
|
||||||
sources.swissBuildings ||
|
sources.swissBuildings !== "off" ||
|
||||||
sources.swissTerrain ||
|
sources.swissTerrain ||
|
||||||
sources.osmBuildings ||
|
sources.osmBuildings ||
|
||||||
sources.osmRoads ||
|
sources.osmRoads ||
|
||||||
@@ -118,11 +132,23 @@ export function ContextImportDialog({
|
|||||||
const center = selected.lv95;
|
const center = selected.lv95;
|
||||||
let origin: GeoOrigin | undefined;
|
let origin: GeoOrigin | undefined;
|
||||||
const all: GeoFeature[] = [];
|
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);
|
const r = await fetchBuildings(center, radius, origin);
|
||||||
origin = r.origin;
|
origin = r.origin;
|
||||||
all.push(...r.features);
|
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 = {
|
const osmSel: OsmSelection = {
|
||||||
@@ -148,10 +174,10 @@ export function ContextImportDialog({
|
|||||||
all.push(...r.features);
|
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;
|
let terrain: ContextObject | null = null;
|
||||||
if (sources.swissTerrain) {
|
if (sources.swissTerrain) {
|
||||||
const r = await fetchTerrain(center, radius, origin);
|
const r = await fetchTerrainXyz(center, radius, origin, terrainResolution);
|
||||||
origin = r.origin;
|
origin = r.origin;
|
||||||
if (r.grid) {
|
if (r.grid) {
|
||||||
const mesh = terrainMeshFromGrid(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"));
|
setError(t("ctxImport.empty"));
|
||||||
setStatus(null);
|
setStatus(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const objs = featuresToContextObjects(all, selected.label);
|
const objs = featuresToContextObjects(all, selected.label);
|
||||||
|
objs.push(...extraObjs);
|
||||||
if (terrain) objs.push(terrain);
|
if (terrain) objs.push(terrain);
|
||||||
onImport(objs);
|
onImport(objs);
|
||||||
setStatus(
|
setStatus(
|
||||||
t("ctxImport.imported", { count: all.length, sets: objs.length }),
|
t("ctxImport.imported", {
|
||||||
|
count: all.length + extraObjs.length,
|
||||||
|
sets: objs.length,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
onClose();
|
onClose();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -272,21 +302,43 @@ export function ContextImportDialog({
|
|||||||
<section className="imp-section">
|
<section className="imp-section">
|
||||||
<div className="imp-section-title">{t("ctxImport.sources")}</div>
|
<div className="imp-section-title">{t("ctxImport.sources")}</div>
|
||||||
<div className="cim-checks">
|
<div className="cim-checks">
|
||||||
<label className="cim-check">
|
<label className="cim-check cim-check-row">
|
||||||
<input
|
<span>{t("ctxImport.src.swissBuildings")}</span>
|
||||||
type="checkbox"
|
<select
|
||||||
checked={sources.swissBuildings}
|
className="cim-input"
|
||||||
onChange={() => toggle("swissBuildings")}
|
value={sources.swissBuildings}
|
||||||
/>
|
onChange={(e) =>
|
||||||
{t("ctxImport.src.swissBuildings")}
|
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>
|
||||||
<label className="cim-check">
|
<label className="cim-check cim-check-row">
|
||||||
|
<span className="cim-check-with-box">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={sources.swissTerrain}
|
checked={sources.swissTerrain}
|
||||||
onChange={() => toggle("swissTerrain")}
|
onChange={() => toggle("swissTerrain")}
|
||||||
/>
|
/>
|
||||||
{t("ctxImport.src.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>
|
||||||
<label className="cim-check">
|
<label className="cim-check">
|
||||||
<input
|
<input
|
||||||
|
|||||||
Reference in New Issue
Block a user