swisstopo: Gebaeude als Volumen + Terrain-Mesh in die 3D-Ansicht

Gebaeude-Footprints (swisstopo vec25) werden jetzt zu Volumen extrudiert
(z=0 bis Hoehe; Hoehe aus OSM height/building:levels, sonst 9 m; Deckel/
Boden via Delaunay, konkave Grundrisse) und als importedMesh gerendert — der
flache Footprint-contourSet bleibt fuer den 2D-Plan erhalten. Neuer
Terrain-Abruf (swissALTI3D ueber den CORS-faehigen profile.json-Dienst,
zeilenweise parallel) liefert ein N-Raster, das terrainMeshFromGrid
trianguliert; Hoehen aufs Zentrum bezogen, lagerichtig zu den Gebaeuden.
Neue Quelle 'Swisstopo-Gelaende' im Import-Dialog. Viewport3D unveraendert
(bestehender importedMesh/terrainMesh-Pfad). 8 neue Tests.
This commit is contained in:
2026-07-04 01:33:51 +02:00
parent b7f1cffbd6
commit 73ca87023d
9 changed files with 553 additions and 6 deletions
+103
View File
@@ -17,6 +17,7 @@ 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";
@@ -134,3 +135,105 @@ 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)" } };
}