// 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 };