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