0cfddd8930
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).
278 lines
9.5 KiB
TypeScript
278 lines
9.5 KiB
TypeScript
// Editier-Operationen Split / Join / Segment-Löschen für 2D-Zeichenelemente
|
|
// (Drawing2D). Wandeln Drawing2D ↔ {pts, closed}, rufen den reinen Geometrie-
|
|
// Kernel (`../geometry/kernel2d`) und liefern neue Drawing2D-Listen. Stil-,
|
|
// Kategorie- und Level-Attribute der Quelle werden geerbt.
|
|
//
|
|
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
|
|
|
import type { Drawing2D, Drawing2DGeom, Vec2 } from "../model/types";
|
|
import {
|
|
joinChains,
|
|
removeSegment,
|
|
splitAtIntersections,
|
|
splitClosedByChord,
|
|
splitPolylineAtParam,
|
|
vecEqual,
|
|
} from "../geometry/kernel2d";
|
|
|
|
/** Eine Polylinien-Darstellung eines Drawing2D (für die Kernel-Funktionen). */
|
|
export interface PolyForm {
|
|
pts: Vec2[];
|
|
closed: boolean;
|
|
}
|
|
|
|
/**
|
|
* Wandelt die Geometrie eines Drawing2D in eine Polylinien-Form `{pts, closed}`.
|
|
* Nur line/polyline/rect werden unterstützt (circle/arc/text → `null`).
|
|
*/
|
|
export function toPolyForm(geom: Drawing2DGeom): PolyForm | null {
|
|
switch (geom.shape) {
|
|
case "line":
|
|
return { pts: [geom.a, geom.b], closed: false };
|
|
case "polyline":
|
|
return { pts: geom.pts.slice(), closed: geom.closed };
|
|
case "rect":
|
|
return {
|
|
pts: [
|
|
geom.min,
|
|
{ x: geom.max.x, y: geom.min.y },
|
|
geom.max,
|
|
{ x: geom.min.x, y: geom.max.y },
|
|
],
|
|
closed: true,
|
|
};
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Baut aus einer Polylinien-Form die passende Drawing2D-Geometrie. Zwei Punkte +
|
|
* offen → `line`; sonst `polyline` (geschlossen oder offen). Rechtecke werden
|
|
* NICHT rekonstruiert (nach dem Schneiden sind die Teile beliebige Polygone).
|
|
*/
|
|
export function fromPolyForm(form: PolyForm): Drawing2DGeom {
|
|
const pts = form.pts;
|
|
if (pts.length === 2 && !form.closed) {
|
|
return { shape: "line", a: pts[0], b: pts[1] };
|
|
}
|
|
return { shape: "polyline", pts, closed: form.closed };
|
|
}
|
|
|
|
let counter = 0;
|
|
/** Eindeutige Drawing2D-ID (zeitbasiert + Zähler, damit ein Split nicht kollidiert). */
|
|
function newId(prefix: string): string {
|
|
counter += 1;
|
|
return `${prefix}-${Date.now()}-${counter}`;
|
|
}
|
|
|
|
/** Übernimmt Stil-/Kategorie-/Level-Attribute der Quelle auf ein neues Drawing2D. */
|
|
function inherit(src: Drawing2D, geom: Drawing2DGeom): Drawing2D {
|
|
const out: Drawing2D = {
|
|
id: newId("d2d"),
|
|
type: "drawing2d",
|
|
levelId: src.levelId,
|
|
categoryCode: src.categoryCode,
|
|
geom,
|
|
};
|
|
if (src.lineStyleId !== undefined) out.lineStyleId = src.lineStyleId;
|
|
if (src.color !== undefined) out.color = src.color;
|
|
if (src.weightMm !== undefined) out.weightMm = src.weightMm;
|
|
// Füllung/Schraffur nur an geschlossene Ergebnisse weitergeben.
|
|
const closed =
|
|
geom.shape === "polyline" ? geom.closed : geom.shape === "rect";
|
|
if (closed) {
|
|
if (src.hatchId !== undefined) out.hatchId = src.hatchId;
|
|
if (src.fillColor !== undefined) out.fillColor = src.fillColor;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Split eines Drawing2D gegen alle ANDEREN Elemente (`others`, als PolyForm).
|
|
* • Trifft das Ziel andere Elemente, wird an den Schnittpunkten geteilt
|
|
* (geschlossen + 2 Schnitte → zwei GESCHLOSSENE Polygone via Sehne).
|
|
* • Ohne Schnitt: Fallback — an der Kanten-MITTE der „längsten" Kante teilen
|
|
* (offen → zwei Stücke; geschlossen → an einer Kantenmitte + der gegenüber-
|
|
* liegenden Kantenmitte zu zwei geschlossenen Hälften).
|
|
* Liefert ≥2 neue Drawing2D, oder `[src]` (unverändert), wenn keine Teilung
|
|
* möglich war (z. B. nicht-polyline Form). Stil/Kategorie/Level werden geerbt.
|
|
*/
|
|
export function splitDrawing(
|
|
src: Drawing2D,
|
|
others: PolyForm[],
|
|
): Drawing2D[] {
|
|
const form = toPolyForm(src.geom);
|
|
if (!form) return [src];
|
|
|
|
const otherPts = others.map((o) =>
|
|
// Geschlossene als Ring (erster==letzter) übergeben, damit der Kernel die
|
|
// Schlusskante als Kante erkennt.
|
|
o.closed && o.pts.length > 2 ? [...o.pts, o.pts[0]] : o.pts,
|
|
);
|
|
|
|
let pieces = splitAtIntersections(form.pts, form.closed, otherPts);
|
|
|
|
// Kein Schnitt-Ergebnis (eine Liste, identisch zur Quelle) → Fallback.
|
|
const noCut = pieces.length < 2;
|
|
if (noCut) {
|
|
pieces = fallbackSplit(form);
|
|
}
|
|
|
|
if (pieces.length < 2) return [src];
|
|
|
|
// Jedes Stück: geschlossen bleibt geschlossen NUR, wenn das Original
|
|
// geschlossen war UND der Schnitt zwei geschlossene Hälften erzeugte
|
|
// (Sehnen-Split / Fallback-geschlossen). Bei offenem Aufschneiden sind die
|
|
// Stücke offen.
|
|
const piecesClosed = form.closed && isClosedSplit(form, pieces);
|
|
return pieces.map((pts) =>
|
|
inherit(src, fromPolyForm({ pts, closed: piecesClosed })),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Heuristik: erzeugte der Schnitt zwei GESCHLOSSENE Hälften (Sehnen-Split)?
|
|
* Wahr, wenn das Original geschlossen war und genau zwei Stücke entstanden
|
|
* (Chord-Split) — diese sollen wieder geschlossen sein. Bei mehr Stücken
|
|
* (geschlossen an >2 Schnitten aufgetrennt) bleiben sie offen.
|
|
*/
|
|
function isClosedSplit(form: PolyForm, pieces: Vec2[][]): boolean {
|
|
return form.closed && pieces.length === 2;
|
|
}
|
|
|
|
/**
|
|
* Fallback ohne Schnitt mit anderen Elementen: an der Mitte teilen.
|
|
* • Offen: längste Kante an t=0.5 in zwei offene Stücke.
|
|
* • Geschlossen: an den Mitten zweier „gegenüberliegender" Kanten in zwei
|
|
* geschlossene Polygone (Sehne zwischen den beiden Kantenmitten).
|
|
*/
|
|
function fallbackSplit(form: PolyForm): Vec2[][] {
|
|
const n = form.pts.length;
|
|
if (n < 2) return [form.pts];
|
|
if (!form.closed) {
|
|
// Längste Kante suchen.
|
|
let best = 0;
|
|
let bestLen = -1;
|
|
for (let i = 0; i < n - 1; i++) {
|
|
const d = dist(form.pts[i], form.pts[i + 1]);
|
|
if (d > bestLen) {
|
|
bestLen = d;
|
|
best = i;
|
|
}
|
|
}
|
|
return splitPolylineAtParam(form.pts, false, best, 0.5);
|
|
}
|
|
// Geschlossen: Kante 0 und die „gegenüberliegende" Kante.
|
|
const i = 0;
|
|
const j = Math.max(1, Math.floor(n / 2)) % n;
|
|
if (i === j) return [form.pts];
|
|
// Direkt den Kernel-Chord-Split nutzen.
|
|
const res = splitClosedHalf(form.pts, i, j);
|
|
return res ?? [form.pts];
|
|
}
|
|
|
|
/** Geschlossenes Polygon an den Mitten der Kanten i und j halbieren. */
|
|
function splitClosedHalf(pts: Vec2[], i: number, j: number): Vec2[][] | null {
|
|
// Reuse: splitClosedByChord ist im Kernel; hier über splitAtIntersections-Form
|
|
// nachzubauen wäre umständlich — direkt importieren.
|
|
const res = chordSplit(pts, i, 0.5, j, 0.5);
|
|
return res;
|
|
}
|
|
|
|
function chordSplit(
|
|
pts: Vec2[],
|
|
i: number,
|
|
ti: number,
|
|
j: number,
|
|
tj: number,
|
|
): Vec2[][] | null {
|
|
const r = splitClosedByChord(pts, i, ti, j, tj);
|
|
return r ? [r[0], r[1]] : null;
|
|
}
|
|
|
|
const dist = (a: Vec2, b: Vec2): number =>
|
|
Math.hypot(a.x - b.x, a.y - b.y);
|
|
|
|
/**
|
|
* Join: verbindet mehrere Drawing2D an koinzidenten Endpunkten zu Polylinien
|
|
* (geschlossen, wenn sich eine Kette schließt). Nicht-polyline Elemente
|
|
* (circle/arc/text) werden unverändert durchgereicht. Liefert die NEUE Menge
|
|
* der Drawing2D, die die Eingabe-Elemente ersetzt. Attribute werden vom ersten
|
|
* verbundenen Quell-Element geerbt.
|
|
*/
|
|
export function joinDrawings(srcs: Drawing2D[]): Drawing2D[] {
|
|
const joinable: { form: PolyForm; src: Drawing2D }[] = [];
|
|
const passthrough: Drawing2D[] = [];
|
|
for (const d of srcs) {
|
|
const form = toPolyForm(d.geom);
|
|
if (form) joinable.push({ form, src: d });
|
|
else passthrough.push(d);
|
|
}
|
|
if (joinable.length === 0) return srcs;
|
|
|
|
const chains = joinChains(joinable.map((j) => j.form));
|
|
const template = joinable[0].src;
|
|
const joined = chains.map((c) =>
|
|
inherit(template, fromPolyForm({ pts: c.pts, closed: c.closed })),
|
|
);
|
|
return [...joined, ...passthrough];
|
|
}
|
|
|
|
/**
|
|
* Segment-Löschen (Schere): entfernt das Segment `edgeIndex` aus einem
|
|
* Drawing2D. Geschlossene Form → offene Polylinie. Liefert das geänderte
|
|
* Drawing2D, oder `null`, wenn weniger als 2 Punkte übrig blieben (Element
|
|
* komplett löschen) bzw. die Form nicht unterstützt wird.
|
|
*/
|
|
export function cutSegment(src: Drawing2D, edgeIndex: number): Drawing2D | null {
|
|
const form = toPolyForm(src.geom);
|
|
if (!form) return src; // nicht unterstützte Form unverändert lassen
|
|
const res = removeSegment(form.pts, form.closed, edgeIndex);
|
|
if (res.pts.length < 2) return null;
|
|
return inherit(src, fromPolyForm(res));
|
|
}
|
|
|
|
/**
|
|
* Liefert den Index der Kante eines Drawing2D, die dem Modellpunkt `p` am
|
|
* nächsten liegt (für Alt+Klick = Segment-Löschen). `null`, wenn die Form keine
|
|
* Kanten hat (circle/arc/text) oder zu wenige Punkte. Reuse: Polyline-Form +
|
|
* Punkt-Strecke-Distanz.
|
|
*/
|
|
export function nearestEdgeIndex(geom: Drawing2DGeom, p: Vec2): number | null {
|
|
const form = toPolyForm(geom);
|
|
if (!form || form.pts.length < 2) return null;
|
|
const edges: [number, Vec2, Vec2][] = [];
|
|
for (let i = 0; i < form.pts.length - 1; i++) {
|
|
edges.push([i, form.pts[i], form.pts[i + 1]]);
|
|
}
|
|
if (form.closed && form.pts.length > 2) {
|
|
edges.push([form.pts.length - 1, form.pts[form.pts.length - 1], form.pts[0]]);
|
|
}
|
|
let best: number | null = null;
|
|
let bestD = Infinity;
|
|
for (const [i, a, b] of edges) {
|
|
const d = segDist(p, a, b);
|
|
if (d < bestD) {
|
|
bestD = d;
|
|
best = i;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
/** Abstand Punkt → Strecke (lokal, klein gehalten). */
|
|
function segDist(p: Vec2, a: Vec2, b: Vec2): number {
|
|
const abx = b.x - a.x;
|
|
const aby = b.y - a.y;
|
|
const l2 = abx * abx + aby * aby;
|
|
let t = l2 > 0 ? ((p.x - a.x) * abx + (p.y - a.y) * aby) / l2 : 0;
|
|
t = Math.max(0, Math.min(1, t));
|
|
const cx = a.x + abx * t;
|
|
const cy = a.y + aby * t;
|
|
return Math.hypot(p.x - cx, p.y - cy);
|
|
}
|
|
|
|
// `vecEqual` re-export für etwaige Aufrufer (Auswahl-Vergleich).
|
|
export { vecEqual };
|