Files
DOSSIER-STANDALONE/src/io/osm.ts
T
karim 35299307d6 Akkumulierten grünen Arbeitsstand landen (Basis für Weiterarbeit)
Bündelt den über mehrere Sessions gewachsenen, uncommitteten Stand in
einem Basis-Commit, damit Folge-Features isoliert darauf aufsetzen.
Verifikation: tsc --noEmit sauber, vitest 600/600 grün.

Enthalten (Details in PENDENZEN.md -Liste / HANDOVER.md):
- truck-Integration: Profil-Extrusion + Verjüngung + Boolean-CSG (csgrs),
  Crate src-tauri/trucksolid, Werkzeug `extrude`, ExtrudedSolid-Modell.
- kernel2d-Port nach Rust/WASM (Phasen 1–5, Diff-Harness).
- render3d 3D-Live-Schnitt = 2D-Schnitt: geschichteter Bodenaufbau,
  Prioritäts-Verschneidung (section_boolean.rs), einstellbare
  Schichttrennlinien, per-Hatch-Strichstärke, relativeToWall-Orientierung.
- Interop-Export IFC4/STL/OBJ (Loch-Ausschnitt wallMeshCut), Schnellexport.
- Projektdatei .obp + OS-Lock (lock.rs, LockConflictDialog).
- Layout-Blätter (Modell/Editor/Panel/PDF), Ausschnitte, Override-Engine,
  Tragwerk-Stützen (Column), BIM-Tree-Panel.
- Bauteil-Typsystem (Tür/Fenster/Treppe-Typen), Betontreppe mit schräger
  Laufplatte, Text-/Textbox-Werkzeug, Mess-Werkzeug, 2D/3D-Griffe für
  Öffnungen/Treppen, Snap-Symbol-Restyle.
2026-07-09 00:57:29 +02:00

184 lines
6.1 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.
// OpenStreetMap-Anbindung (Overpass API) für den Standort-Import.
//
// Holt für eine LV95-Box Gebäude / Strassen / Wasser / Grünflächen / Parkplätze /
// Bahnlinien / Wald 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;
parking: boolean;
railway: boolean;
forest: 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|meadow|recreation_ground"](${b});`);
}
if (sel.parking) parts.push(`way["amenity"="parking"](${b});`);
if (sel.railway) parts.push(`way["railway"~"rail|tram"](${b});`);
if (sel.forest) {
parts.push(`way["landuse"="forest"](${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.forest && (tags.natural === "wood" || tags.landuse === "forest"))
return "forest";
if (
sel.green &&
(tags.leisure === "park" ||
/grass|meadow|recreation_ground/.test(tags.landuse ?? ""))
)
return "green";
if (sel.parking && tags.amenity === "parking") return "parking";
if (sel.railway && /rail|tram/.test(tags.railway ?? "")) return "railway";
if (sel.roads && tags.highway) return "road";
return null;
}
/** Geschlossene Kategorien (Flächen) vs. offene (Strassen/Bahn). */
function isClosedCategory(cat: GeoCategory): boolean {
return cat !== "road" && cat !== "railway";
}
/**
* Gebäudehöhe (m) aus OSM-Tags: bevorzugt `height` (numerisch, evtl. mit
* Einheit), sonst `building:levels` × 3 m Geschosshöhe. Ohne Angabe `undefined`
* (die Extrusion nutzt dann die Default-Höhe).
*/
function osmBuildingHeight(
tags: Record<string, string> | undefined,
): number | undefined {
if (!tags) return undefined;
const raw = tags.height ?? tags["building:height"];
if (raw) {
const m = parseFloat(raw.replace(",", "."));
if (Number.isFinite(m) && m > 0) return m;
}
const lvl = tags["building:levels"] ?? tags["building:levels:aboveground"];
if (lvl) {
const l = parseFloat(lvl.replace(",", "."));
if (Number.isFinite(l) && l > 0) return l * 3;
}
return undefined;
}
/**
* 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 &&
!sel.parking &&
!sel.railway &&
!sel.forest
) {
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,
height?: number,
) => {
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, height });
};
for (const el of data.elements ?? []) {
const cat = categorize(el.tags, sel);
if (!cat) continue;
const h = cat === "building" ? osmBuildingHeight(el.tags) : undefined;
if (el.type === "way") {
push(el.geometry, cat, h);
} 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, h);
}
}
}
return { origin, features };
}