Files
DOSSIER-STANDALONE/src/editors/booleanOps.ts
T
karim 8fd8987b70 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

202 lines
7.6 KiB
TypeScript

// 2D-Boolean-Operationen (Union / Subtract / Intersect) für GESCHLOSSENE
// 2D-Zeichenelemente (Drawing2D). Ergänzt Split/Join (`splitJoin.ts`). Wandelt
// Drawing2D ↔ polygon-clipping-Format (Martinez-Algorithmus, robust) und liefert
// neue Drawing2D-Listen. Stil-, Kategorie- und Level-Attribute werden geerbt
// (vom ersten bzw. Basis-Element).
//
// Loch-Handling: Das Drawing2D-Modell kennt KEINE Innenringe (Löcher). Liefert
// polygon-clipping ein Polygon mit Innenringen, wird der Außenring als gefüllte
// Form emittiert und JEDER Innenring als SEPARATE, ungefüllte geschlossene
// Polylinie (Loch-Umriss). So geht keine Geometrie verloren und nichts crasht;
// der Lochrand bleibt sichtbar, ohne dass eine gefüllte Insel entsteht.
//
// Nur geschlossene Elemente (rect / polyline closed) zählen; offene Linien/
// Polylinien und Kreise/Bögen/Text werden ignoriert. Wände sind kein Drawing2D
// und damit ohnehin ausgenommen (No-op).
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import polygonClipping from "polygon-clipping";
import type { MultiPolygon, Polygon, Ring } from "polygon-clipping";
import type { Drawing2D, Drawing2DGeom, Vec2 } from "../model/types";
import { toPolyForm } from "./splitJoin";
import type { PolyForm } from "./splitJoin";
/** Ist die Geometrie eines Drawing2D eine GESCHLOSSENE Fläche (für Boolean)? */
export function isClosedArea(geom: Drawing2DGeom): boolean {
if (geom.shape === "rect") return true;
if (geom.shape === "polyline") return geom.closed && geom.pts.length >= 3;
return false;
}
/**
* Wandelt ein geschlossenes Drawing2D in ein polygon-clipping-`Polygon`
* (Ring-Liste). Reuse: `toPolyForm` aus splitJoin liefert die Stützpunkte. Der
* Ring wird als `[x,y]`-Paare gebildet; polygon-clipping schließt selbst
* (erster==letzter ist nicht erforderlich, aber unschädlich). Liefert `null`,
* wenn die Form nicht geschlossen ist oder zu wenige Punkte hat.
*/
export function drawingToPolygon(geom: Drawing2DGeom): Polygon | null {
if (!isClosedArea(geom)) return null;
const form = toPolyForm(geom);
if (!form) return null;
const ring = formToRing(form);
if (ring.length < 3) return null;
return [ring];
}
/** PolyForm → polygon-clipping-Ring (`[x,y]`-Paare). */
function formToRing(form: PolyForm): Ring {
return form.pts.map((p): [number, number] => [p.x, p.y]);
}
/** Ein polygon-clipping-Ring → Vec2-Punktliste (für fromPolyForm/Drawing2D). */
function ringToPts(ring: Ring): Vec2[] {
// polygon-clipping liefert geschlossene Ringe (letzter == erster Punkt). Den
// doppelten Schlusspunkt entfernen, damit unsere Polyline-Form sauber ist.
const pts = ring.map((p): Vec2 => ({ x: p[0], y: p[1] }));
if (
pts.length > 1 &&
pts[0].x === pts[pts.length - 1].x &&
pts[0].y === pts[pts.length - 1].y
) {
pts.pop();
}
return pts;
}
let counter = 0;
/** Eindeutige Drawing2D-ID (zeitbasiert + Zähler). */
function newId(prefix: string): string {
counter += 1;
return `${prefix}-${Date.now()}-${counter}`;
}
/**
* Erzeugt aus einem Punktring ein neues geschlossenes Drawing2D, das Stil/
* Kategorie/Level der Quelle erbt. `filled` steuert, ob Füllung/Schraffur
* mitwandern (Außenring = ja, Loch-Umriss = nein).
*/
function ringDrawing(src: Drawing2D, pts: Vec2[], filled: boolean): Drawing2D {
const out: Drawing2D = {
id: newId("bool"),
type: "drawing2d",
levelId: src.levelId,
categoryCode: src.categoryCode,
geom: { shape: "polyline", pts, closed: true },
};
if (src.lineStyleId !== undefined) out.lineStyleId = src.lineStyleId;
if (src.color !== undefined) out.color = src.color;
if (src.weightMm !== undefined) out.weightMm = src.weightMm;
if (filled) {
if (src.hatchId !== undefined) out.hatchId = src.hatchId;
if (src.fillColor !== undefined) out.fillColor = src.fillColor;
}
return out;
}
/**
* Wandelt eine polygon-clipping-`MultiPolygon` in Drawing2D[] zurück. Je Polygon
* wird der Außenring (filled) emittiert; jeder Innenring (Loch) als separater
* ungefüllter geschlossener Umriss. Quelle liefert Stil/Kategorie/Level.
*/
function multiPolygonToDrawings(
src: Drawing2D,
mp: MultiPolygon,
): Drawing2D[] {
const out: Drawing2D[] = [];
for (const poly of mp) {
poly.forEach((ring, i) => {
const pts = ringToPts(ring);
if (pts.length < 3) return;
// Erster Ring = Außenkontur (gefüllt), weitere = Löcher (nur Umriss).
out.push(ringDrawing(src, pts, i === 0));
});
}
return out;
}
/** Geschlossene Quellen → polygon-clipping-Polygone (mit Drawing-Zuordnung). */
function collectPolygons(
srcs: Drawing2D[],
): { poly: Polygon; src: Drawing2D }[] {
const out: { poly: Polygon; src: Drawing2D }[] = [];
for (const d of srcs) {
const poly = drawingToPolygon(d.geom);
if (poly) out.push({ poly, src: d });
}
return out;
}
/**
* Vereinigung (Union) der gegebenen geschlossenen Drawing2D. Liefert die neuen
* Drawing2D (≥1 — MultiPolygon → mehrere) und die IDs der ERSETZTEN Quellen.
* Stil/Kategorie/Level werden vom ersten geschlossenen Element geerbt. Weniger
* als 2 geschlossene Quellen → No-op (`{ created: [], replacedIds: [] }`).
*/
export function unionDrawings(srcs: Drawing2D[]): BooleanResult {
const polys = collectPolygons(srcs);
if (polys.length < 2) return EMPTY_RESULT;
const first = polys[0];
const rest = polys.slice(1).map((p) => p.poly);
const mp = polygonClipping.union(first.poly, ...rest);
return {
created: multiPolygonToDrawings(first.src, mp),
replacedIds: polys.map((p) => p.src.id),
};
}
/**
* Differenz (Subtract): das ERSTE geschlossene Element ist die Basis, alle
* übrigen werden abgezogen. Liefert neue Drawing2D + ersetzte IDs (nur die
* tatsächlich beteiligten geschlossenen Quellen). Stil/Kategorie/Level von der
* Basis. Weniger als 2 geschlossene Quellen → No-op.
*/
export function differenceDrawings(srcs: Drawing2D[]): BooleanResult {
const polys = collectPolygons(srcs);
if (polys.length < 2) return EMPTY_RESULT;
const base = polys[0];
const clips = polys.slice(1).map((p) => p.poly);
const mp = polygonClipping.difference(base.poly, ...clips);
return {
created: multiPolygonToDrawings(base.src, mp),
replacedIds: polys.map((p) => p.src.id),
};
}
/**
* Schnittmenge (Intersection): nur die gemeinsame Überlappung aller geschlossenen
* Elemente bleibt. Liefert neue Drawing2D + ersetzte IDs. Stil/Kategorie/Level
* vom ersten Element. Weniger als 2 geschlossene Quellen → No-op. Ohne
* Überlappung ist `created` leer (die Quellen werden trotzdem ersetzt = entfernt,
* weil der leere Schnitt das gewünschte Ergebnis ist).
*/
export function intersectionDrawings(srcs: Drawing2D[]): BooleanResult {
const polys = collectPolygons(srcs);
if (polys.length < 2) return EMPTY_RESULT;
const first = polys[0];
const rest = polys.slice(1).map((p) => p.poly);
const mp = polygonClipping.intersection(first.poly, ...rest);
return {
created: multiPolygonToDrawings(first.src, mp),
replacedIds: polys.map((p) => p.src.id),
};
}
/** Ergebnis einer Boolean-Operation: neue Elemente + zu ersetzende Quell-IDs. */
export interface BooleanResult {
/** Neu erzeugte Drawing2D (ersetzen die Quellen). */
created: Drawing2D[];
/** IDs der geschlossenen Quellen, die durch das Ergebnis ersetzt werden. */
replacedIds: string[];
}
const EMPTY_RESULT: BooleanResult = { created: [], replacedIds: [] };
/** Zählt geschlossene Flächen in einer Auswahl (für Menü-Aktivierung). */
export function countClosedAreas(srcs: Drawing2D[]): number {
let n = 0;
for (const d of srcs) if (isClosedArea(d.geom)) n++;
return n;
}