Files
DOSSIER-STANDALONE/src/geometry/roomBoundary.ts
T
karim 3d2d4d6321 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).
2026-07-02 00:12:39 +02:00

458 lines
16 KiB
TypeScript

// Raum-Erkennung — geschlossene Raum-Umrisse aus Wandsegmenten ableiten.
//
// Zwei komplementäre Ansätze:
//
// 1. detectRooms(walls) — baut die planare Anordnung (Arrangement) der Wand-
// MITTELLINIEN, schneidet alle Kanten an ihren Kreuzungen, snappt kleine
// Lücken zu und extrahiert die minimalen geschlossenen Maschen (Faces) des
// Graphen über das „next edge counter-clockwise"-Verfahren (half-edge face
// tracing). Die unbeschränkte Aussenmasche wird verworfen. Für jede innere
// Masche wird die lichte Innenkontur berechnet, indem die Masche um die
// halbe Wanddicke nach innen versetzt wird (Miter-Offset).
//
// 2. roomFromPointInside(point, wallFaces) — klick-in-Raum-Fallback: umläuft
// ausgehend von einem Saatpunkt die den Punkt einschliessende Masche direkt
// (gap-tolerant, wie die meisten CAD-Werkzeuge für „Raum durch Klick"). Er
// liefert die lichte Innenkontur der angeklickten Zelle.
//
// Grenzen (ehrlich): Der Arrangement-Ansatz behandelt Wände als 1D-Mittellinien;
// überlappende/kollineare Doppelwände oder gekrümmte Wände werden nicht speziell
// aufgelöst. Lücken unterhalb `gapTol` werden per Endpunkt-Snapping geschlossen;
// grössere Öffnungen (Türen) müssen als geschlossen modelliert oder vom Aufrufer
// überbrückt werden. Der Offset nach innen nutzt Miter-Joins ohne Selbstschnitt-
// Heilung — für orthogonale/konvexe Grundrisse (der Normalfall) exakt, bei sehr
// spitzen einspringenden Ecken approximativ.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Einheit: METER.
import type { Vec2 } from "../model/types";
import { signedArea, polygonArea } from "./roomArea";
/** Ein Wandsegment: Mittellinie a→b mit Dicke (beidseitig je thickness/2). */
export interface WallSegment {
a: Vec2;
b: Vec2;
thickness: number;
}
/** Parameter der Raum-Erkennung. */
export interface DetectRoomsOptions {
/** Lücken/Endpunkt-Abstände ≤ gapTol (m) werden zusammengeschnappt. Default 0.05. */
gapTol?: number;
/** Kleinste als Raum akzeptierte Fläche (m²). Default 0.05. */
minArea?: number;
/**
* true (Default): Maschen um halbe Wanddicke nach innen versetzen, sodass die
* zurückgegebenen Loops die LICHTE Innenkontur sind. false: Wand-Mittellinien-
* Maschen (roh) zurückgeben.
*/
offsetToInner?: boolean;
}
// ── Vektorhelfer (lokal, ohne Fremdabhängigkeit) ─────────────────────────────
const sub = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x - b.x, y: a.y - b.y });
const add = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x + b.x, y: a.y + b.y });
const scale = (a: Vec2, s: number): Vec2 => ({ x: a.x * s, y: a.y * s });
const cross = (a: Vec2, b: Vec2): number => a.x * b.y - a.y * b.x;
const len = (a: Vec2): number => Math.hypot(a.x, a.y);
const normalize = (a: Vec2): Vec2 => {
const l = len(a);
return l < 1e-12 ? { x: 0, y: 0 } : { x: a.x / l, y: a.y / l };
};
/** Linke Normale (90° gegen den Uhrzeigersinn). */
const leftNormal = (a: Vec2): Vec2 => ({ x: -a.y, y: a.x });
const dist = (a: Vec2, b: Vec2): number => Math.hypot(a.x - b.x, a.y - b.y);
const EPS = 1e-9;
// ── Planarer Graph (Knoten + ungerichtete Kanten) ────────────────────────────
interface Node {
p: Vec2;
}
interface UEdge {
u: number; // Knotenindex
v: number; // Knotenindex
}
/**
* Baut aus den Wand-Mittellinien einen planaren Graphen: Endpunkte werden mit
* Toleranz `snap` verschmolzen, jedes Segment an allen echten Kreuzungen mit
* anderen Segmenten unterteilt, kollineare Duplikate entfernt.
*/
function buildPlanarGraph(
segments: { a: Vec2; b: Vec2 }[],
snap: number,
): { nodes: Node[]; edges: UEdge[] } {
const nodes: Node[] = [];
// Knoten mit Snapping-Toleranz einfügen (linearer Scan; für Grundriss-Grössen
// ausreichend).
const addNode = (p: Vec2): number => {
for (let i = 0; i < nodes.length; i++) {
if (dist(nodes[i].p, p) <= snap) return i;
}
nodes.push({ p: { x: p.x, y: p.y } });
return nodes.length - 1;
};
// Für jedes Segment: alle Split-Parameter t sammeln (Endpunkte + Kreuzungen).
const raw = segments
.map((s) => ({ a: s.a, b: s.b }))
.filter((s) => dist(s.a, s.b) > snap);
interface Split {
t: number;
p: Vec2;
}
const perSegment: Split[][] = raw.map((s) => [
{ t: 0, p: s.a },
{ t: 1, p: s.b },
]);
for (let i = 0; i < raw.length; i++) {
for (let j = i + 1; j < raw.length; j++) {
const A = raw[i];
const B = raw[j];
const da = sub(A.b, A.a);
const db = sub(B.b, B.a);
const denom = cross(da, db);
if (Math.abs(denom) < EPS) continue; // parallel/kollinear
const t = cross(sub(B.a, A.a), db) / denom;
const s = cross(sub(B.a, A.a), da) / denom;
// Echter innerer Schnitt (Endpunkt-Berührungen sind bereits Knoten).
const tolT = snap / Math.max(len(da), 1e-9);
const tolS = snap / Math.max(len(db), 1e-9);
if (t < -tolT || t > 1 + tolT || s < -tolS || s > 1 + tolS) continue;
const p = add(A.a, scale(da, t));
perSegment[i].push({ t, p });
perSegment[j].push({ t: s, p });
}
}
const edgeSet = new Set<string>();
const edges: UEdge[] = [];
const pushEdge = (u: number, v: number) => {
if (u === v) return;
const key = u < v ? `${u}_${v}` : `${v}_${u}`;
if (edgeSet.has(key)) return;
edgeSet.add(key);
edges.push({ u, v });
};
for (let i = 0; i < raw.length; i++) {
const splits = perSegment[i].sort((p, q) => p.t - q.t);
let prev = -1;
let prevT = -Infinity;
for (const sp of splits) {
if (sp.t - prevT < 1e-9 && prev >= 0) continue;
const idx = addNode(sp.p);
if (prev >= 0 && idx !== prev) pushEdge(prev, idx);
prev = idx;
prevT = sp.t;
}
}
return { nodes, edges };
}
// ── Face-Extraktion über gerichtete Halbkanten ───────────────────────────────
interface HalfEdge {
from: number;
to: number;
/** Ausgangswinkel from→to (für die CCW-Sortierung an jedem Knoten). */
angle: number;
used: boolean;
}
/**
* Extrahiert die minimalen geschlossenen Maschen (Faces) des planaren Graphen.
* Verfahren: jede ungerichtete Kante wird zu zwei Halbkanten. An jedem Knoten
* werden die abgehenden Halbkanten nach Winkel sortiert. Beim Umlaufen einer
* Masche wählt man an jedem Zielknoten stets die Halbkante, die im Uhrzeigersinn
* unmittelbar VOR der eingehenden (umgekehrten) liegt — das „next edge"-Kriterium
* für konsistente Face-Umläufe. Jede Halbkante gehört zu genau einer Masche.
* Die (einzige) Masche mit positiver Fläche und maximaler |Fläche|, deren Umlauf
* die anderen umschliesst, ist die Aussenmasche und wird verworfen.
*/
function extractFaces(nodes: Node[], edges: UEdge[]): number[][] {
// Adjazenz: je Knoten die Indizes seiner abgehenden Halbkanten.
const halfEdges: HalfEdge[] = [];
const outgoing: number[][] = nodes.map(() => []);
const angleOf = (from: number, to: number): number => {
const d = sub(nodes[to].p, nodes[from].p);
return Math.atan2(d.y, d.x);
};
for (const e of edges) {
const h1 = halfEdges.length;
halfEdges.push({ from: e.u, to: e.v, angle: angleOf(e.u, e.v), used: false });
outgoing[e.u].push(h1);
const h2 = halfEdges.length;
halfEdges.push({ from: e.v, to: e.u, angle: angleOf(e.v, e.u), used: false });
outgoing[e.v].push(h2);
}
// Abgehende Halbkanten je Knoten nach Winkel sortieren.
for (const list of outgoing) {
list.sort((h1, h2) => halfEdges[h1].angle - halfEdges[h2].angle);
}
// Positionsindex je Halbkante in der sortierten Liste seines from-Knotens.
const posInList = new Map<number, number>();
for (const list of outgoing) {
for (let k = 0; k < list.length; k++) posInList.set(list[k], k);
}
// Zwilling (umgekehrte Halbkante): Paare liegen benachbart (2i, 2i+1).
const twin = (h: number): number => (h % 2 === 0 ? h + 1 : h - 1);
const faces: number[][] = [];
for (let start = 0; start < halfEdges.length; start++) {
if (halfEdges[start].used) continue;
const faceNodes: number[] = [];
let h = start;
let guard = 0;
const maxSteps = halfEdges.length + 2;
do {
halfEdges[h].used = true;
faceNodes.push(halfEdges[h].from);
// An to = halfEdges[h].to die nächste Halbkante wählen: rund um den Knoten
// die im Uhrzeigersinn nächste NACH dem Zwilling der eingehenden Kante.
const tw = twin(h);
const list = outgoing[halfEdges[h].to];
const pos = posInList.get(tw)!;
const nextPos = (pos - 1 + list.length) % list.length; // im Uhrzeigersinn davor
h = list[nextPos];
guard++;
} while (h !== start && guard < maxSteps);
if (faceNodes.length >= 3) faces.push(faceNodes);
}
// Aussenmasche(n) verwerfen: eine Masche ist „innen", wenn sie CCW orientiert
// ist (positive Shoelace-Fläche). Die Aussenmasche läuft CW (negative Fläche).
const inner: number[][] = [];
for (const f of faces) {
const poly = f.map((n) => nodes[n].p);
if (signedArea(poly) > EPS) inner.push(f);
}
return inner;
}
// ── Innen-Offset einer Masche (halbe Wanddicke) ──────────────────────────────
/**
* Versetzt ein CCW-Polygon um `d` nach innen (jede Kante um d nach rechts, da
* CCW → Innenseite rechts der Laufrichtung? Nein: bei CCW liegt das Innere LINKS.
* Wir verschieben daher jede Kante um d in Richtung Inneres = linke Normale) via
* Miter-Join. Bei ungültigem Schnitt (fast parallel) wird der verschobene Eck-
* punkt direkt genommen.
*/
function offsetInward(poly: Vec2[], d: number): Vec2[] {
const n = poly.length;
if (n < 3 || d <= 0) return poly.slice();
// Sicherstellen: CCW (Inneres links). Falls CW, umdrehen.
const pts = signedArea(poly) > 0 ? poly : poly.slice().reverse();
// Verschobene Kanten (Inneres liegt links → linke Normale zeigt nach innen).
const shifted: { p: Vec2; dir: Vec2 }[] = [];
for (let i = 0; i < n; i++) {
const a = pts[i];
const b = pts[(i + 1) % n];
const dir = normalize(sub(b, a));
const nrm = leftNormal(dir); // zeigt ins Innere bei CCW
shifted.push({ p: add(a, scale(nrm, d)), dir });
}
const out: Vec2[] = [];
for (let i = 0; i < n; i++) {
const prev = shifted[(i - 1 + n) % n];
const curr = shifted[i];
// Schnitt der beiden verschobenen (unendlichen) Geraden.
const denom = cross(prev.dir, curr.dir);
if (Math.abs(denom) < 1e-9) {
out.push(curr.p); // (nahezu) kollinear → Startpunkt der aktuellen Kante
continue;
}
const t = cross(sub(curr.p, prev.p), curr.dir) / denom;
out.push(add(prev.p, scale(prev.dir, t)));
}
return out;
}
/** Dedupliziert aufeinanderfolgende (nahezu) gleiche Punkte im Ring. */
function dedupeRing(pts: Vec2[], tol = 1e-7): Vec2[] {
const out: Vec2[] = [];
for (const p of pts) {
const last = out[out.length - 1];
if (!last || dist(last, p) > tol) out.push(p);
}
if (out.length > 1 && dist(out[0], out[out.length - 1]) <= tol) out.pop();
return out;
}
/** Durchschnittliche Wanddicke der Segmente, die eine Kante bilden (Fallback 0). */
function averageThickness(walls: WallSegment[]): number {
if (walls.length === 0) return 0;
let s = 0;
for (const w of walls) s += w.thickness;
return s / walls.length;
}
// ── Öffentliche API ──────────────────────────────────────────────────────────
/**
* Erkennt geschlossene Räume aus einer Menge von Wandsegmenten (Mittellinien
* mit Dicke). Liefert die lichten Innen-Umrisse (Vec2[] je Raum), CCW orientiert.
*
* Ablauf: planarer Graph aus Mittellinien → Face-Extraktion → Aussenmasche
* verwerfen → jede innere Masche um halbe (mittlere) Wanddicke nach innen
* versetzen. Kleine Lücken werden über `gapTol` zusammengeschnappt.
*/
export function detectRooms(
walls: WallSegment[],
options: DetectRoomsOptions = {},
): Vec2[][] {
const gapTol = options.gapTol ?? 0.05;
const minArea = options.minArea ?? 0.05;
const offsetToInner = options.offsetToInner ?? true;
if (walls.length < 3) return [];
const { nodes, edges } = buildPlanarGraph(
walls.map((w) => ({ a: w.a, b: w.b })),
gapTol,
);
if (edges.length < 3) return [];
const faces = extractFaces(nodes, edges);
const half = averageThickness(walls) / 2;
const rooms: Vec2[][] = [];
for (const f of faces) {
let poly = f.map((n) => nodes[n].p);
poly = dedupeRing(poly);
if (poly.length < 3) continue;
if (offsetToInner && half > 0) {
poly = dedupeRing(offsetInward(poly, half));
if (poly.length < 3) continue;
}
if (polygonArea(poly) < minArea) continue;
// Kanonisch CCW zurückgeben.
if (signedArea(poly) < 0) poly = poly.reverse();
rooms.push(poly);
}
return rooms;
}
// ── Klick-in-Raum: Umlauf-Tracer um einen Saatpunkt ──────────────────────────
/** Eine Wand-Innenfläche (Face) als gerichtete/ungerichtete Strecke. */
export interface WallFace {
a: Vec2;
b: Vec2;
}
/**
* Prüft, ob ein Punkt in einem Polygon liegt (Ray-Casting, ungerade Kreuzungen).
*/
export function pointInPolygon(p: Vec2, poly: Vec2[]): boolean {
let inside = false;
const n = poly.length;
for (let i = 0, j = n - 1; i < n; j = i++) {
const a = poly[i];
const b = poly[j];
const intersects =
a.y > p.y !== b.y > p.y &&
p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y + 0) + a.x;
if (intersects) inside = !inside;
}
return inside;
}
/**
* Klick-in-Raum-Fallback: findet die Masche, die den Saatpunkt einschliesst, und
* gibt ihren lichten Innen-Umriss zurück (oder null, wenn der Punkt in keiner
* geschlossenen Zelle liegt). Robust gegen T-Stösse; kleine Lücken werden über
* `gapTol` geschlossen. Nutzt intern dieselbe Arrangement-/Face-Extraktion und
* wählt die Masche mit kleinster Fläche, die den Punkt enthält (die unmittelbar
* umgebende Zelle statt einer grösseren umschliessenden).
*/
export function roomFromPointInside(
point: Vec2,
walls: WallSegment[],
options: DetectRoomsOptions = {},
): Vec2[] | null {
const gapTol = options.gapTol ?? 0.05;
const offsetToInner = options.offsetToInner ?? true;
if (walls.length < 3) return null;
const { nodes, edges } = buildPlanarGraph(
walls.map((w) => ({ a: w.a, b: w.b })),
gapTol,
);
if (edges.length < 3) return null;
const faces = extractFaces(nodes, edges);
const half = averageThickness(walls) / 2;
let best: Vec2[] | null = null;
let bestArea = Infinity;
for (const f of faces) {
let raw = dedupeRing(f.map((n) => nodes[n].p));
if (raw.length < 3) continue;
// Test gegen die ROHE Mittellinien-Masche (schliesst den Klickpunkt weiter ein).
if (!pointInPolygon(point, raw)) continue;
const area = polygonArea(raw);
if (area >= bestArea) continue;
let inner = raw;
if (offsetToInner && half > 0) {
inner = dedupeRing(offsetInward(raw, half));
if (inner.length < 3) inner = raw;
}
if (signedArea(inner) < 0) inner = inner.reverse();
best = inner;
bestArea = area;
}
return best;
}
/**
* Variante des Klick-Tracers, die direkt auf bereits berechneten Wand-INNEN-
* FLÄCHEN (Faces) arbeitet: die Strecken bilden bereits die lichten Wandseiten.
* Kein Innen-Offset (die Faces sind schon die Innenkontur). Für Aufrufer, die
* die Wandflächen ohnehin vorhalten (z. B. aus dem Wand-Rendering).
*/
export function roomFromPointInsideFaces(
point: Vec2,
wallFaces: WallFace[],
gapTol = 0.05,
): Vec2[] | null {
if (wallFaces.length < 3) return null;
const { nodes, edges } = buildPlanarGraph(
wallFaces.map((f) => ({ a: f.a, b: f.b })),
gapTol,
);
if (edges.length < 3) return null;
const faces = extractFaces(nodes, edges);
let best: Vec2[] | null = null;
let bestArea = Infinity;
for (const f of faces) {
const poly = dedupeRing(f.map((n) => nodes[n].p));
if (poly.length < 3) continue;
if (!pointInPolygon(point, poly)) continue;
const area = polygonArea(poly);
if (area < bestArea) {
best = signedArea(poly) < 0 ? poly.reverse() : poly;
bestArea = area;
}
}
return best;
}