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