// 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/Parkplatz/Bahn/Wald). 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" | "parking" | "railway" | "forest"; /** 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 = { building: "Gebäude", road: "Strassen", water: "Gewässer", green: "Grünflächen", parking: "Parkplätze", railway: "Bahn", forest: "Wald", }; /** Layer-Kennung (englischer Identifier) je Kategorie — für spätere Zuordnung. */ const CATEGORY_LAYER: Record = { building: "context-buildings", road: "context-roads", water: "context-water", green: "context-green", parking: "context-parking", railway: "context-railway", forest: "context-forest", }; /** 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(); 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 }).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; }