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:
+171
-1
@@ -7,8 +7,9 @@
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import Delaunator from "delaunator";
|
||||
import type { GeoOrigin } from "./lv95";
|
||||
import type { ContextObject, Contour } from "../model/types";
|
||||
import type { ContextObject, Contour, ImportedMesh } from "../model/types";
|
||||
|
||||
/** Fachliche Kategorie einer importierten Kontext-Linie. */
|
||||
export type GeoCategory = "building" | "road" | "water" | "green";
|
||||
@@ -20,8 +21,17 @@ export interface GeoFeature {
|
||||
pts: { x: number; y: number }[];
|
||||
/** Geschlossener Ring (Gebäude/Wasser/Grün) vs. offene Linie (Strasse). */
|
||||
closed: boolean;
|
||||
/**
|
||||
* Gebäudehöhe in Metern (nur `building`). Quelle: OSM-Tags (height /
|
||||
* building:levels). Fehlt sie, greift {@link DEFAULT_BUILDING_HEIGHT} bei der
|
||||
* Extrusion.
|
||||
*/
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/** Default-Gebäudehöhe (m), wenn keine Höhe aus der Quelle vorliegt. */
|
||||
export const DEFAULT_BUILDING_HEIGHT = 9;
|
||||
|
||||
/** Ergebnis eines Import-Laufs: Features + verwendete Origin. */
|
||||
export interface GeoImportResult {
|
||||
origin: GeoOrigin;
|
||||
@@ -86,9 +96,169 @@ export function featuresToContextObjects(
|
||||
contours,
|
||||
});
|
||||
}
|
||||
|
||||
// Gebäude zusätzlich als extrudierte Volumen (Footprint × Höhe) für die
|
||||
// 3D-Ansicht. Der flache Footprint bleibt als contourSet erhalten (2D-Plan),
|
||||
// das Volumen kommt als importedMesh dazu — beide teilen dieselbe Lage.
|
||||
const buildingMesh = buildingsToMesh(
|
||||
features.filter((f) => f.category === "building"),
|
||||
sourceLabel,
|
||||
);
|
||||
if (buildingMesh) objs.push(buildingMesh);
|
||||
|
||||
return objs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut aus allen Gebäude-Features EIN extrudiertes Volumen-Mesh (roh
|
||||
* positions/indices in Modell-Metern, three-frei). Jeder geschlossene Footprint
|
||||
* wird von z=0 bis z=Höhe hochgezogen (Wände + Deckel + Boden). Ohne Höhe greift
|
||||
* {@link DEFAULT_BUILDING_HEIGHT}. Liefert `null`, wenn nichts Brauchbares dabei
|
||||
* ist.
|
||||
*/
|
||||
export function buildingsToMesh(
|
||||
buildings: GeoFeature[],
|
||||
sourceLabel: string,
|
||||
): ImportedMesh | null {
|
||||
const positions: number[] = [];
|
||||
const indices: number[] = [];
|
||||
for (const b of buildings) {
|
||||
if (!b.closed) continue;
|
||||
const h = Number.isFinite(b.height) && (b.height as number) > 0
|
||||
? (b.height as number)
|
||||
: DEFAULT_BUILDING_HEIGHT;
|
||||
extrudeRing(b.pts, h, positions, indices);
|
||||
}
|
||||
if (positions.length < 9 || indices.length < 3) return null;
|
||||
return {
|
||||
id: contextId("geo-building3d"),
|
||||
type: "importedMesh",
|
||||
name: `Gebäude 3D (${sourceLabel})`,
|
||||
layer: CATEGORY_LAYER.building,
|
||||
positions,
|
||||
indices,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrudiert einen geschlossenen (x,y)-Ring zu einem Prisma [0..height] und
|
||||
* hängt Vertices/Indizes an die gemeinsamen Arrays an. Erzeugt Seitenwände,
|
||||
* Deckel und Boden. Winding ist unkritisch (Kontext-Material rendert doppel-
|
||||
* seitig). Entartete Ringe (< 3 Punkte oder ~0 Fläche) werden übersprungen.
|
||||
*/
|
||||
function extrudeRing(
|
||||
ringIn: { x: number; y: number }[],
|
||||
height: number,
|
||||
positions: number[],
|
||||
indices: number[],
|
||||
): void {
|
||||
// Schließenden Doppelpunkt entfernen.
|
||||
const ring = ringIn.slice();
|
||||
if (
|
||||
ring.length > 1 &&
|
||||
Math.abs(ring[0].x - ring[ring.length - 1].x) < 1e-6 &&
|
||||
Math.abs(ring[0].y - ring[ring.length - 1].y) < 1e-6
|
||||
) {
|
||||
ring.pop();
|
||||
}
|
||||
const n = ring.length;
|
||||
if (n < 3) return;
|
||||
if (!ringArea(ring)) return;
|
||||
|
||||
const base = positions.length / 3;
|
||||
// n Boden-Vertices (z=0), dann n Deckel-Vertices (z=height).
|
||||
for (const p of ring) positions.push(p.x, p.y, 0);
|
||||
for (const p of ring) positions.push(p.x, p.y, height);
|
||||
|
||||
// Seitenwände: je Kante ein Quad → 2 Dreiecke.
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n;
|
||||
const b0 = base + i;
|
||||
const b1 = base + j;
|
||||
const t0 = base + n + i;
|
||||
const t1 = base + n + j;
|
||||
indices.push(b0, b1, t1, b0, t1, t0);
|
||||
}
|
||||
|
||||
// Deckel + Boden über eine Polygon-Triangulation des Rings.
|
||||
const tris = triangulateRing(ring);
|
||||
for (let k = 0; k < tris.length; k += 3) {
|
||||
const a = tris[k], b = tris[k + 1], c = tris[k + 2];
|
||||
// Deckel (obere Vertex-Reihe).
|
||||
indices.push(base + n + a, base + n + b, base + n + c);
|
||||
// Boden (untere Reihe, umgekehrte Reihenfolge).
|
||||
indices.push(base + a, base + c, base + b);
|
||||
}
|
||||
}
|
||||
|
||||
/** Doppelte Ringfläche > EPS? (Filtert entartete/kollineare Footprints.) */
|
||||
function ringArea(ring: { x: number; y: number }[]): boolean {
|
||||
let a2 = 0;
|
||||
for (let i = 0; i < ring.length; i++) {
|
||||
const p = ring[i];
|
||||
const q = ring[(i + 1) % ring.length];
|
||||
a2 += p.x * q.y - q.x * p.y;
|
||||
}
|
||||
return Math.abs(a2) > 1e-6;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trianguliert einen einfachen (evtl. konkaven) Ring. Strategie: Delaunay über
|
||||
* die (x,y)-Punkte, dann nur die Dreiecke behalten, deren Schwerpunkt IM Polygon
|
||||
* liegt (Point-in-Polygon) — so werden konkave Einbuchtungen respektiert, ohne
|
||||
* eine externe Earcut-Abhängigkeit. Fällt bei Bedarf auf eine Fächer-
|
||||
* Triangulation zurück. Rückgabe: Dreiecks-Indizes in `ring`.
|
||||
*/
|
||||
function triangulateRing(ring: { x: number; y: number }[]): number[] {
|
||||
const n = ring.length;
|
||||
if (n < 3) return [];
|
||||
if (n === 3) return [0, 1, 2];
|
||||
const coords = new Float64Array(n * 2);
|
||||
for (let i = 0; i < n; i++) {
|
||||
coords[i * 2] = ring[i].x;
|
||||
coords[i * 2 + 1] = ring[i].y;
|
||||
}
|
||||
let tri: Uint32Array;
|
||||
try {
|
||||
tri = new Delaunator(coords).triangles;
|
||||
} catch {
|
||||
return fanTriangulate(n);
|
||||
}
|
||||
const out: number[] = [];
|
||||
for (let t = 0; t < tri.length; t += 3) {
|
||||
const a = tri[t], b = tri[t + 1], c = tri[t + 2];
|
||||
const cx = (ring[a].x + ring[b].x + ring[c].x) / 3;
|
||||
const cy = (ring[a].y + ring[b].y + ring[c].y) / 3;
|
||||
if (pointInRing(cx, cy, ring)) out.push(a, b, c);
|
||||
}
|
||||
return out.length >= 3 ? out : fanTriangulate(n);
|
||||
}
|
||||
|
||||
/** Fächer-Triangulation ab Vertex 0 (Fallback für konvexe/sternförmige Ringe). */
|
||||
function fanTriangulate(n: number): number[] {
|
||||
const out: number[] = [];
|
||||
for (let i = 1; i < n - 1; i++) out.push(0, i, i + 1);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Point-in-Polygon (Ray-Casting) in der (x,y)-Ebene. */
|
||||
function pointInRing(
|
||||
x: number,
|
||||
y: number,
|
||||
ring: { x: number; y: number }[],
|
||||
): boolean {
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
const xi = ring[i].x, yi = ring[i].y;
|
||||
const xj = ring[j].x, yj = ring[j].y;
|
||||
const hit =
|
||||
yi > y !== yj > y &&
|
||||
x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
|
||||
if (hit) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/**
|
||||
* Basis-URL des optionalen Geo-Proxys (openbureau-core `/geoproxy`). Wird für
|
||||
* Overpass zwingend benutzt (CORS unzuverlässig); für geo.admin nur als
|
||||
|
||||
Reference in New Issue
Block a user