2D-Plan-Renderer auf WebGL2 (GPU) + akkumulierter Funktionsstand

Neuer GPU-Renderer fuer den Grundriss (src/plan/glPlan/): Earcut-Tessellierung
(konkav-faehig), gehrte Linienzuege (Miter), echte Papier-mm-Strichbreiten im
Massstab (repliziert den SVG-printStrokeVb-Pfad), Hybrid mit scharfem SVG-Text-
Overlay. GPU ist der Standardpfad; der SVG-Renderer bleibt automatischer Fallback,
falls WebGL2/Shader nicht verfuegbar sind. Imperativer Pan (rAF + CSS-transform)
fuer fluessige Interaktion ohne React-Re-Render je Frame.

Enthaelt zudem den bisher nicht committeten Arbeitsstand des Browser-BIM
(Oeffnungen, Treppen, Raeume, Decken, DXF-Export, Materialbibliothek, Kontext-
Import, Tauri-Compute-Boundary-PoC).
This commit is contained in:
2026-07-02 00:12:39 +02:00
parent cfe5249440
commit 3d2d4d6321
184 changed files with 29421 additions and 669 deletions
+139
View File
@@ -0,0 +1,139 @@
// OpenStreetMap-Anbindung (Overpass API) für den Standort-Import.
//
// Holt für eine LV95-Box Gebäude / Strassen / Wasser / Grünflächen und wandelt
// sie in Polylinien in LOKALEN Modell-Metern (über LV95 + Origin, siehe
// lv95.ts). Overpass sendet je nach Mirror unzuverlässige CORS-Header, daher
// läuft der Abruf über den openbureau-core Geo-Proxy (`viaProxy`), sofern
// `VITE_GEO_PROXY` gesetzt ist — sonst Direktabruf gegen den CORS-fähigen
// Mirror.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Einheiten: Meter.
import { lv95BboxToWgs84, makeOrigin, wgs84ToLocal } from "./lv95";
import type { GeoOrigin, LV95 } from "./lv95";
import { bboxAround } from "./lv95";
import type { GeoCategory, GeoFeature } from "./geoContext";
import { viaProxy } from "./geoContext";
// Direkter Mirror mit CORS `*` (Fallback, falls kein Proxy konfiguriert ist).
const OVERPASS = "https://maps.mail.ru/osm/tools/overpass/api/interpreter";
/** Welche OSM-Kategorien der Aufrufer möchte. */
export interface OsmSelection {
buildings: boolean;
roads: boolean;
water: boolean;
green: boolean;
}
/** Ein Overpass-Element mit inline-Geometrie (`out geom`). */
interface OverpassElement {
type: "node" | "way" | "relation";
id: number;
tags?: Record<string, string>;
geometry?: { lat: number; lon: number }[];
members?: {
type: string;
role: string;
geometry?: { lat: number; lon: number }[];
}[];
}
/** Baut die Overpass-QL-Abfrage aus der WGS84-Box + Auswahl. */
function buildQuery(
bbox: { south: number; west: number; north: number; east: number },
sel: OsmSelection,
): string {
const b = `${bbox.south},${bbox.west},${bbox.north},${bbox.east}`;
const parts: string[] = [];
if (sel.buildings) parts.push(`way["building"](${b});`);
if (sel.roads) parts.push(`way["highway"](${b});`);
if (sel.water) {
parts.push(`way["natural"="water"](${b});`);
parts.push(`way["waterway"](${b});`);
parts.push(`relation["natural"="water"](${b});`);
}
if (sel.green) {
parts.push(`way["leisure"="park"](${b});`);
parts.push(`way["landuse"~"grass|forest|meadow|recreation_ground"](${b});`);
parts.push(`way["natural"="wood"](${b});`);
}
return `[out:json][timeout:40];(${parts.join("")});out geom;`;
}
/** Ordnet ein Element anhand seiner Tags einer Kategorie zu (oder null). */
function categorize(
tags: Record<string, string> | undefined,
sel: OsmSelection,
): GeoCategory | null {
if (!tags) return null;
if (sel.buildings && tags.building) return "building";
if (sel.water && (tags.natural === "water" || tags.waterway)) return "water";
if (
sel.green &&
(tags.leisure === "park" ||
tags.natural === "wood" ||
/grass|forest|meadow|recreation_ground/.test(tags.landuse ?? ""))
)
return "green";
if (sel.roads && tags.highway) return "road";
return null;
}
/** Geschlossene Kategorien (Flächen) vs. offene (Strassen). */
function isClosedCategory(cat: GeoCategory): boolean {
return cat !== "road";
}
/**
* OSM-Features für Zentrum + Radius (Meter) laden. `originIn` erlaubt es, exakt
* dieselbe Origin wie ein vorheriger swisstopo-Import zu verwenden.
*/
export async function fetchOsm(
center: LV95,
radius: number,
sel: OsmSelection,
originIn?: GeoOrigin,
): Promise<{ origin: GeoOrigin; features: GeoFeature[] }> {
const origin = originIn ?? makeOrigin(center);
if (!sel.buildings && !sel.roads && !sel.water && !sel.green) {
return { origin, features: [] };
}
const bbox = lv95BboxToWgs84(bboxAround(center, radius));
const query = buildQuery(bbox, sel);
const res = await fetch(viaProxy(OVERPASS), {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: "data=" + encodeURIComponent(query),
});
if (!res.ok) throw new Error(`OSM-Abruf fehlgeschlagen (${res.status})`);
const data = (await res.json()) as { elements?: OverpassElement[] };
const features: GeoFeature[] = [];
const push = (
geometry: { lat: number; lon: number }[] | undefined,
cat: GeoCategory,
) => {
if (!geometry || geometry.length < 2) return;
const pts = geometry.map((g) => wgs84ToLocal(g.lat, g.lon, origin));
const closed = isClosedCategory(cat);
if (closed && pts.length < 3) return;
features.push({ category: cat, pts, closed });
};
for (const el of data.elements ?? []) {
const cat = categorize(el.tags, sel);
if (!cat) continue;
if (el.type === "way") {
push(el.geometry, cat);
} else if (el.type === "relation") {
// Multipolygon: outer-Ringe als einzelne geschlossene Linien übernehmen.
for (const m of el.members ?? []) {
if (m.role === "outer" || m.role === "") push(m.geometry, cat);
}
}
}
return { origin, features };
}