Files
DOSSIER-STANDALONE/src/io/geoContext.ts
T
karim bd13495923 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.
2026-07-04 01:33:51 +02:00

281 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Gemeinsame Typen + Helfer für den Standort-Import (swisstopo & OSM).
//
// Beide Quellen liefern am Ende dasselbe: geschlossene/offene Polylinien in
// LOKALEN Modell-Metern (bereits um die Origin verschoben, siehe lv95.ts),
// kategorisiert nach Art (Gebäude/Strasse/Wasser/Grün). Die UI wandelt diese in
// `ContourSet`-Kontext-Objekte pro Kategorie um und legt sie in `project.context`.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import Delaunator from "delaunator";
import type { GeoOrigin } from "./lv95";
import type { ContextObject, Contour, ImportedMesh } from "../model/types";
/** Fachliche Kategorie einer importierten Kontext-Linie. */
export type GeoCategory = "building" | "road" | "water" | "green";
/** Ein importiertes Polygon/Polylinie in lokalen Modell-Metern. */
export interface GeoFeature {
category: GeoCategory;
/** Stützpunkte in lokalen Metern (x=Ost, y=Nord). */
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;
features: GeoFeature[];
}
/** Menschlicher Layer-/Objektname je Kategorie (deutsch, für die Kontext-Liste). */
const CATEGORY_LABEL: Record<GeoCategory, string> = {
building: "Gebäude",
road: "Strassen",
water: "Gewässer",
green: "Grünflächen",
};
/** Layer-Kennung (englischer Identifier) je Kategorie — für spätere Zuordnung. */
const CATEGORY_LAYER: Record<GeoCategory, string> = {
building: "context-buildings",
road: "context-roads",
water: "context-water",
green: "context-green",
};
/** Erzeugt eine (kollisionsarme) ID für ein neues Kontext-Objekt. */
function contextId(prefix: string): string {
return `${prefix}-${Date.now().toString(36)}-${Math.random()
.toString(36)
.slice(2, 8)}`;
}
/**
* Wandelt importierte Features in `ContextObject`s (je Kategorie EIN
* ContourSet). Die Konturen liegen auf z=0 (Grundriss-Kontext); geschlossene
* Ringe (Gebäude/Wasser/Grün) werden als geschlossene Konturen markiert,
* Strassen als offene Linienzüge. Der Aufrufer legt das Ergebnis über
* `onAddContextObjects` in `project.context` ab.
*/
export function featuresToContextObjects(
features: GeoFeature[],
sourceLabel: string,
): ContextObject[] {
const byCat = new Map<GeoCategory, Contour[]>();
for (const f of features) {
if (f.pts.length < 2) continue;
const list = byCat.get(f.category) ?? [];
list.push({
z: 0,
pts: f.pts.map((p) => ({ x: p.x, y: p.y })),
closed: f.closed,
layer: CATEGORY_LAYER[f.category],
});
byCat.set(f.category, list);
}
const objs: ContextObject[] = [];
for (const [cat, contours] of byCat) {
if (contours.length === 0) continue;
objs.push({
id: contextId(`geo-${cat}`),
type: "contourSet",
name: `${CATEGORY_LABEL[cat]} (${sourceLabel})`,
layer: CATEGORY_LAYER[cat],
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
* Fallback. Konfiguration über `VITE_GEO_PROXY` (z. B. "/geoproxy" bei
* gleichem Origin oder eine absolute URL auf den API-Server). Leer = kein Proxy.
*/
export function geoProxyBase(): string {
const env = (import.meta as unknown as { env?: Record<string, string> }).env;
return (env?.VITE_GEO_PROXY ?? "").replace(/\/$/, "");
}
/**
* Verpackt eine absolute Upstream-URL für den Geo-Proxy. Ohne konfigurierten
* Proxy wird die URL unverändert zurückgegeben (Direktabruf).
*/
export function viaProxy(url: string): string {
const base = geoProxyBase();
return base ? `${base}?u=${encodeURIComponent(url)}` : url;
}