// 2D-Geometrie-Kernel — die „echte Arbeit" für Offset / Trim / Extend / Fillet / // Split (Rhino-artige Editierbefehle, Tier 1). Reine Funktionen über `Vec2`, // kein externer Kernel. Baut auf den Helfern in `../model/geometry` auf. // // Konvention: Polylinien sind `Vec2[]`; `closed` heißt, die letzte Kante geht // von pts[n-1] zu pts[0] (der Schlusspunkt wird NICHT dupliziert). Offsets sind // vorzeichenbehaftet: positiv = nach LINKS bezogen auf die Laufrichtung // (`leftNormal`-Konvention des Projekts), negativ = nach rechts. // // Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). import type { Vec2 } from "../model/types"; import { add, cross, leftNormal, len, lineIntersect, normalize, scale, sub, } from "../model/geometry"; /** Numerische Toleranz (Meter) für Schnitt-/Gleichheitstests. */ export const EPS = 1e-7; const dot = (a: Vec2, b: Vec2): number => a.x * b.x + a.y * b.y; const dist = (a: Vec2, b: Vec2): number => len(sub(a, b)); /** Punkt-Gleichheit innerhalb Toleranz. */ export function vecEqual(a: Vec2, b: Vec2, eps = EPS): boolean { return Math.abs(a.x - b.x) <= eps && Math.abs(a.y - b.y) <= eps; } // ── Punkt/Strecke ──────────────────────────────────────────────────────────── /** Projektionsparameter t von p auf die Gerade a→b (nicht geklemmt). */ export function projectParam(p: Vec2, a: Vec2, b: Vec2): number { const ab = sub(b, a); const l2 = dot(ab, ab); if (l2 < EPS) return 0; return dot(sub(p, a), ab) / l2; } /** Nächster Punkt auf der STRECKE a→b zu p (t auf [0,1] geklemmt). */ export function closestPointOnSegment(p: Vec2, a: Vec2, b: Vec2): Vec2 { const t = Math.max(0, Math.min(1, projectParam(p, a, b))); return add(a, scale(sub(b, a), t)); } /** Abstand von p zur Strecke a→b. */ export function pointSegmentDistance(p: Vec2, a: Vec2, b: Vec2): number { return dist(p, closestPointOnSegment(p, a, b)); } // ── Schnitt ────────────────────────────────────────────────────────────────── /** Ergebnis eines Strecken-/Linienschnitts. */ export interface Hit { point: Vec2; /** Parameter auf der ersten Strecke (0 = a1, 1 = a2). */ t: number; /** Parameter auf der zweiten Strecke (0 = b1, 1 = b2). */ s: number; } /** * Schnitt zweier STRECKEN a1→a2 und b1→b2. Liefert den Schnittpunkt nur, wenn er * auf BEIDEN Strecken liegt (t,s ∈ [-eps, 1+eps]); sonst null. Parallel → null. */ export function segmentIntersect( a1: Vec2, a2: Vec2, b1: Vec2, b2: Vec2, eps = EPS, ): Hit | null { const da = sub(a2, a1); const db = sub(b2, b1); const denom = cross(da, db); if (Math.abs(denom) < EPS) return null; // parallel/kollinear const t = cross(sub(b1, a1), db) / denom; const s = cross(sub(b1, a1), da) / denom; if (t < -eps || t > 1 + eps || s < -eps || s > 1 + eps) return null; return { point: add(a1, scale(da, t)), t, s }; } /** * Schnitt der unendlichen GERADE durch a1→a2 mit der STRECKE b1→b2 (für Extend/ * Trim gegen einen Schneider): Schnittpunkt muss auf der Strecke b liegen * (s ∈ [0,1]), t (auf a) ist frei. Null bei parallel / kein Treffer auf b. */ export function lineSegmentIntersect( a1: Vec2, a2: Vec2, b1: Vec2, b2: Vec2, eps = EPS, ): Hit | null { const da = sub(a2, a1); const db = sub(b2, b1); const denom = cross(da, db); if (Math.abs(denom) < EPS) return null; const t = cross(sub(b1, a1), db) / denom; const s = cross(sub(b1, a1), da) / denom; if (s < -eps || s > 1 + eps) return null; return { point: add(a1, scale(da, t)), t, s }; } /** Kanten einer Polylinie als [from,to]-Paare (Schlusskante bei `closed`). */ export function polylineEdges(pts: Vec2[], closed: boolean): [Vec2, Vec2][] { const out: [Vec2, Vec2][] = []; for (let i = 0; i < pts.length - 1; i++) out.push([pts[i], pts[i + 1]]); if (closed && pts.length > 2) out.push([pts[pts.length - 1], pts[0]]); return out; } /** * Alle Schnittpunkte einer STRECKE a1→a2 mit den Kanten einer Polylinie, nach * Parameter t auf der Strecke sortiert (für Trim/Split). Endpunkt-Duplikate * (gleicher Punkt aufeinanderfolgender Kanten) werden zusammengefasst. */ export function segmentPolylineHits( a1: Vec2, a2: Vec2, pts: Vec2[], closed: boolean, ): Hit[] { const hits: Hit[] = []; for (const [b1, b2] of polylineEdges(pts, closed)) { const h = segmentIntersect(a1, a2, b1, b2); if (h) hits.push(h); } hits.sort((p, q) => p.t - q.t); const dedup: Hit[] = []; for (const h of hits) { if (dedup.length === 0 || Math.abs(dedup[dedup.length - 1].t - h.t) > 1e-6) { dedup.push(h); } } return dedup; } // ── Offset (DAS Architektur-Primitiv) ─────────────────────────────────────── /** Offset einer einzelnen Strecke um `d` (links positiv). */ export function offsetSegment(a: Vec2, b: Vec2, d: number): [Vec2, Vec2] { const n = leftNormal(normalize(sub(b, a))); const off = scale(n, d); return [add(a, off), add(b, off)]; } /** * Offset einer Polylinie um `d` (links positiv) mit GEHRUNG (miter): jede Kante * wird parallel verschoben, benachbarte verschobene Kanten werden geschnitten. * Bei (nahezu) geraden/parallelen Nachbarn wird der verschobene Endpunkt direkt * genommen. Funktioniert für offene und geschlossene Polylinien; degenerierte * (zu kurze) Kanten werden übersprungen. Selbstüberschneidungen bei starkem * Offset werden NICHT bereinigt (genügt für den Architektur-Workflow; ein * vollständiger Polygon-Offset mit Selbstschnitt-Heilung wäre Tier-2-Arbeit). */ export function offsetPolyline(pts: Vec2[], d: number, closed: boolean): Vec2[] { // Auf signifikante Kanten reduzieren (Duplikate verwerfen). const clean: Vec2[] = []; for (const p of pts) { if (clean.length === 0 || dist(clean[clean.length - 1], p) > EPS) clean.push(p); } if (closed && clean.length > 1 && dist(clean[0], clean[clean.length - 1]) <= EPS) { clean.pop(); } const n = clean.length; if (n < 2) return [...pts]; const edges = polylineEdges(clean, closed).map(([a, b]) => offsetSegment(a, b, d)); if (edges.length === 0) return [...pts]; const result: Vec2[] = []; // Hilfsfunktion: Schnitt zweier (verschobener) Kanten als unendliche Geraden. const join = (e1: [Vec2, Vec2], e2: [Vec2, Vec2], fallback: Vec2): Vec2 => { const d1 = sub(e1[1], e1[0]); const d2 = sub(e2[1], e2[0]); const p = lineIntersect(e1[0], d1, e2[0], d2); return p ?? fallback; }; if (!closed) { // Offener Zug: erster Punkt = Start der ersten Kante; innere Punkte = // Schnitt aufeinanderfolgender Kanten; letzter Punkt = Ende der letzten Kante. result.push(edges[0][0]); for (let i = 0; i < edges.length - 1; i++) { result.push(join(edges[i], edges[i + 1], edges[i][1])); } result.push(edges[edges.length - 1][1]); return result; } // Geschlossen: jeder Ausgabepunkt = Schnitt von Kante i mit Kante i+1 (zyklisch). for (let i = 0; i < edges.length; i++) { const prev = edges[(i - 1 + edges.length) % edges.length]; const curr = edges[i]; result.push(join(prev, curr, curr[0])); } return result; } // ── Trim / Split ───────────────────────────────────────────────────────────── /** * Schneidet eine STRECKE seg=[a1,a2] an allen Schnittpunkten mit den Schneider- * Kurven (Polylinien) und liefert die resultierenden Teilstrecken. `cutters` * sind Polylinien (jede mit `closed`-Flag). Reine Geometrie; welche Teile * behalten/verworfen werden, entscheidet der Aufrufer (z. B. Trim verwirft das * Stück, das den Pick-Punkt enthält — siehe {@link trimSegment}). */ export function splitSegmentByCutters( a1: Vec2, a2: Vec2, cutters: { pts: Vec2[]; closed: boolean }[], ): [Vec2, Vec2][] { const ts: number[] = [0, 1]; for (const c of cutters) { for (const h of segmentPolylineHits(a1, a2, c.pts, c.closed)) { if (h.t > EPS && h.t < 1 - EPS) ts.push(h.t); } } ts.sort((p, q) => p - q); const da = sub(a2, a1); const pieces: [Vec2, Vec2][] = []; for (let i = 0; i < ts.length - 1; i++) { if (ts[i + 1] - ts[i] < 1e-6) continue; pieces.push([add(a1, scale(da, ts[i])), add(a1, scale(da, ts[i + 1]))]); } return pieces; } /** * Trim: schneidet seg an den Cuttern und VERWIRFT das Teilstück, das `pick` * am nächsten liegt (das angeklickte „wegzuschneidende" Stück). Liefert die * verbleibenden Teilstücke (0..n). */ export function trimSegment( a1: Vec2, a2: Vec2, cutters: { pts: Vec2[]; closed: boolean }[], pick: Vec2, ): [Vec2, Vec2][] { const pieces = splitSegmentByCutters(a1, a2, cutters); if (pieces.length <= 1) return pieces; let best = 0; let bestD = Infinity; for (let i = 0; i < pieces.length; i++) { const d = pointSegmentDistance(pick, pieces[i][0], pieces[i][1]); if (d < bestD) { bestD = d; best = i; } } return pieces.filter((_, i) => i !== best); } /** * Quick-Trim einer ganzen Kurve (offen ODER geschlossen) an einem Klickpunkt. * Die Kurve `pts`/`closed` wird an ALLEN Schnittpunkten mit den Cuttern in * Bögen zerlegt; der Bogen, der `pick` am nächsten liegt (das angeklickte „weg- * zuschneidende" Stück), wird ENTFERNT. Liefert die verbleibenden Ketten als * `{pts, closed}`-Liste (0..n Stücke): * • OFFEN: der getroffene Bogen fällt weg → 0, 1 oder 2 offene Reststücke. * • GESCHLOSSEN: der Ring wird am Schnitt aufgetrennt; das angeklickte Stück * entfällt → genau ein offenes Reststück (oder leer, wenn alles entfällt). * Ohne Schnitt (oder nur randständige) bleibt die Kurve unverändert (eine Kette). */ export function trimPolyline( pts: Vec2[], closed: boolean, cutters: { pts: Vec2[]; closed: boolean }[], pick: Vec2, ): { pts: Vec2[]; closed: boolean }[] { const edges = polylineEdges(pts, closed); if (edges.length === 0) return [{ pts: pts.slice(), closed }]; // Schnittpunkte je Kante (innen, t ∈ (0,1)) als globale „Knoten" sammeln — // beschrieben durch (edgeIndex, t) und in Kettenreihenfolge sortiert. interface Cut { edge: number; t: number; point: Vec2; } const cuts: Cut[] = []; for (let ei = 0; ei < edges.length; ei++) { const [a1, a2] = edges[ei]; for (const c of cutters) { for (const h of segmentPolylineHits(a1, a2, c.pts, c.closed)) { if (h.t > EPS && h.t < 1 - EPS) { cuts.push({ edge: ei, t: h.t, point: h.point }); } } } } cuts.sort((p, q) => (p.edge - q.edge) || (p.t - q.t)); // Dedup koinzidenter Schnitte (gleiche Kante+t). const cut: Cut[] = []; for (const h of cuts) { const prev = cut[cut.length - 1]; if (prev && prev.edge === h.edge && Math.abs(prev.t - h.t) < 1e-6) continue; cut.push(h); } if (cut.length === 0) return [{ pts: pts.slice(), closed }]; // Hilfsfunktion: Kette von Knoten X (auf Kante eX, tX) zu Knoten Y entlang der // Kurve (vorwärts in Kantenrichtung) als Punktliste materialisieren. const span = (eX: number, pX: Vec2, eY: number, pY: Vec2): Vec2[] => { const out: Vec2[] = [pX]; let e = eX; // Über die Vertices NACH dem Startknoten bis zum Endknoten laufen. // Anzahl Kanten (modular über die Kettenlänge), damit auch der Wrap bei // geschlossenen Kurven funktioniert. const total = edges.length; let steps = 0; while (steps <= total) { if (e === eY) break; // Endpunkt der aktuellen Kante hinzufügen, dann zur nächsten Kante. out.push(edges[e][1]); e = (e + 1) % total; steps++; } out.push(pY); return dedupeConsecutive(out); }; if (!closed) { // Offen: der Pick liegt zwischen zwei Knoten ODER zwischen einem Knoten und // einem Element-Ende. Wir finden das Intervall [lo, hi] der Knoten, die den // Pick einklammern (lo = letzter Knoten vor dem Pick, hi = erster danach), // bzw. das Element-Ende, wenn einseitig kein Schnitt liegt. const pickPos = nearestParamOnChain(edges, pick); // globaler Lauf-Param const cutPos = cut.map((c) => c.edge + c.t); let loIdx = -1; let hiIdx = cut.length; for (let i = 0; i < cut.length; i++) { if (cutPos[i] <= pickPos) loIdx = i; else { hiIdx = i; break; } } // Reststück A: Anfang … loIdx-Knoten. Reststück B: hiIdx-Knoten … Ende. const result: { pts: Vec2[]; closed: boolean }[] = []; if (loIdx >= 0) { const c = cut[loIdx]; const head = pts.slice(0, c.edge + 1); head.push(c.point); const d = dedupeConsecutive(head); if (d.length >= 2) result.push({ pts: d, closed: false }); } if (hiIdx < cut.length) { const c = cut[hiIdx]; const tail: Vec2[] = [c.point]; for (let k = c.edge + 1; k < pts.length; k++) tail.push(pts[k]); const d = dedupeConsecutive(tail); if (d.length >= 2) result.push({ pts: d, closed: false }); } return result; } // Geschlossen: der Pick liegt in einem Bogen zwischen zwei aufeinanderfolgenden // Knoten (zyklisch). Diesen Bogen entfernen → der Rest ist EINE offene Kette, // die vom Knoten NACH dem Pick einmal herum bis zum Knoten VOR dem Pick läuft. if (cut.length === 1) { // Nur ein Schnitt am Ring: am Schnitt auftrennen, Pick liegt auf der ganzen // (nun offenen) Kette → nichts Sinnvolles zu entfernen; Ring bleibt. return [{ pts: pts.slice(), closed: true }]; } const pickPos = nearestParamOnChain(edges, pick); const cutPos = cut.map((c) => c.edge + c.t); // Bogenindex i: zwischen cut[i] und cut[i+1] (mod), der den Pick enthält. let seg = -1; for (let i = 0; i < cut.length; i++) { const a = cutPos[i]; const b = cutPos[(i + 1) % cut.length]; const inside = i === cut.length - 1 ? pickPos >= a || pickPos <= b // Wrap-Bogen über das Ketten-Ende : pickPos >= a && pickPos <= b; if (inside) { seg = i; break; } } if (seg < 0) seg = 0; const from = cut[(seg + 1) % cut.length]; // Knoten NACH dem entfernten Bogen const to = cut[seg]; // Knoten VOR dem entfernten Bogen const chain = span(from.edge, from.point, to.edge, to.point); if (chain.length < 2) return []; return [{ pts: chain, closed: false }]; } /** * Globaler Lauf-Parameter (edgeIndex + t) des dem Punkt `p` nächstgelegenen * Punktes auf der Kantenfolge — für Quick-Trim, um die Klick-Position entlang der * Kurve zu lokalisieren. */ function nearestParamOnChain(edges: [Vec2, Vec2][], p: Vec2): number { let best = 0; let bestD = Infinity; for (let ei = 0; ei < edges.length; ei++) { const [a, b] = edges[ei]; const t = Math.max(0, Math.min(1, projectParam(p, a, b))); const q = add(a, scale(sub(b, a), t)); const d = dist(p, q); if (d < bestD) { bestD = d; best = ei + t; } } return best; } // ── Extend ─────────────────────────────────────────────────────────────────── /** * Verlängert das gewählte Ende (`end`: "start" → a1, "end" → a2) der Strecke * a1→a2 bis zur NÄCHSTGELEGENEN Schneider-Kante in Verlängerungsrichtung. * Liefert die neue Strecke oder null, wenn kein Treffer in der korrekten * Richtung existiert. */ export function extendSegment( a1: Vec2, a2: Vec2, end: "start" | "end", cutters: { pts: Vec2[]; closed: boolean }[], ): [Vec2, Vec2] | null { // Richtung der Verlängerung: bei "end" über a2 hinaus (t>1), bei "start" // über a1 hinaus (t<0). Wir parametrisieren stets entlang a1→a2. let bestT: number | null = null; for (const c of cutters) { for (const [b1, b2] of polylineEdges(c.pts, c.closed)) { const h = lineSegmentIntersect(a1, a2, b1, b2); if (!h) continue; if (end === "end" && h.t > 1 + EPS) { if (bestT === null || h.t < bestT) bestT = h.t; // nächstgelegen über a2 } else if (end === "start" && h.t < -EPS) { if (bestT === null || h.t > bestT) bestT = h.t; // nächstgelegen unter a1 } } } if (bestT === null) return null; const da = sub(a2, a1); const hitPoint = add(a1, scale(da, bestT)); return end === "end" ? [a1, hitPoint] : [hitPoint, a2]; } // ── Kreis-Schnitte (für Circle/Arc, 2P/3P/Tangenten) ──────────────────────── /** Schnittpunkte einer unendlichen GERADE a→b mit einem Kreis (0/1/2 Punkte). */ export function lineCircleIntersect( a: Vec2, b: Vec2, center: Vec2, r: number, ): Vec2[] { const d = sub(b, a); const f = sub(a, center); const A = dot(d, d); if (A < EPS) return []; const B = 2 * dot(f, d); const C = dot(f, f) - r * r; let disc = B * B - 4 * A * C; if (disc < -EPS) return []; if (disc < 0) disc = 0; const sq = Math.sqrt(disc); const t1 = (-B - sq) / (2 * A); const t2 = (-B + sq) / (2 * A); const out = [add(a, scale(d, t1))]; if (Math.abs(t1 - t2) > EPS) out.push(add(a, scale(d, t2))); return out; } /** Schnittpunkte einer STRECKE a→b mit einem Kreis (nur t ∈ [0,1]). */ export function segmentCircleIntersect( a: Vec2, b: Vec2, center: Vec2, r: number, ): Vec2[] { return lineCircleIntersect(a, b, center, r).filter((p) => { const t = projectParam(p, a, b); return t >= -EPS && t <= 1 + EPS; }); } /** Schnittpunkte zweier Kreise (0/1/2 Punkte). */ export function circleCircleIntersect( c1: Vec2, r1: number, c2: Vec2, r2: number, ): Vec2[] { const d = dist(c1, c2); if (d < EPS) return []; // konzentrisch if (d > r1 + r2 + EPS || d < Math.abs(r1 - r2) - EPS) return []; // getrennt/innen const a = (r1 * r1 - r2 * r2 + d * d) / (2 * d); const h2 = r1 * r1 - a * a; const h = h2 > 0 ? Math.sqrt(h2) : 0; const u = normalize(sub(c2, c1)); const mid = add(c1, scale(u, a)); const n = leftNormal(u); if (h < EPS) return [mid]; return [add(mid, scale(n, h)), add(mid, scale(n, -h))]; } // ── Polygon-Fläche / Wicklung ─────────────────────────────────────────────── /** Vorzeichenbehaftete Polygonfläche (Shoelace); >0 = CCW, <0 = CW. */ export function signedArea(pts: Vec2[]): number { let s = 0; for (let i = 0; i < pts.length; i++) { const a = pts[i]; const b = pts[(i + 1) % pts.length]; s += a.x * b.y - b.x * a.y; } return s / 2; } /** Ob ein Polygonzug gegen den Uhrzeigersinn (CCW) gewickelt ist. */ export function isCCW(pts: Vec2[]): boolean { return signedArea(pts) > 0; } // ── Fillet (Eck-Verrundung) ───────────────────────────────────────────────── /** Ergebnis einer Eck-Verrundung. */ export interface Fillet { center: Vec2; radius: number; /** Tangentenpunkt auf dem ersten Schenkel (corner→p1). */ tangentA: Vec2; /** Tangentenpunkt auf dem zweiten Schenkel (corner→p2). */ tangentB: Vec2; /** Bogen-Endwinkel in Radiant (für ein Arc-Primitiv). */ startAngle: number; endAngle: number; } /** * Verrundet die Ecke bei `corner`, gebildet von den beiden Schenkeln corner→p1 * und corner→p2, mit Radius `r`. Liefert Bogenmittelpunkt + Tangentenpunkte * (an denen die Schenkel zurückgetrimmt werden) oder null, wenn die Schenkel * (nahezu) kollinear sind oder zu kurz für den Radius. Standard-Kanten-Fillet * (Distanz vom Eck = r / tan(θ/2)). */ export function filletCorner( corner: Vec2, p1: Vec2, p2: Vec2, r: number, ): Fillet | null { const u1 = normalize(sub(p1, corner)); const u2 = normalize(sub(p2, corner)); const cosTheta = Math.max(-1, Math.min(1, dot(u1, u2))); const theta = Math.acos(cosTheta); if (theta < 1e-4 || Math.PI - theta < 1e-4) return null; // kollinear const tanHalf = Math.tan(theta / 2); if (tanHalf < EPS) return null; const setback = r / tanHalf; // Abstand Eck → Tangentenpunkt entlang je Schenkel // Genügend Schenkellänge? if (setback > len(sub(p1, corner)) + EPS || setback > len(sub(p2, corner)) + EPS) { return null; } const tangentA = add(corner, scale(u1, setback)); const tangentB = add(corner, scale(u2, setback)); // Mittelpunkt liegt auf der Winkelhalbierenden, Abstand r/sin(θ/2) vom Eck. const bis = normalize(add(u1, u2)); const centerDist = r / Math.sin(theta / 2); const center = add(corner, scale(bis, centerDist)); const startAngle = Math.atan2(tangentA.y - center.y, tangentA.x - center.x); const endAngle = Math.atan2(tangentB.y - center.y, tangentB.x - center.x); return { center, radius: r, tangentA, tangentB, startAngle, endAngle }; } // ── Split / Join / Segment-Löschen (Drawing2D-Editieren) ───────────────────── // Reine Geometrie auf Polylinien (`Vec2[]` + `closed`). Die Operations-Schicht // (`src/editors/splitJoin.ts`) wandelt Drawing2D ↔ {pts, closed} und ruft diese // Funktionen. Punkt P einer Kante: `lerp(pts[i], pts[i+1], t)`. /** Lineare Interpolation zweier Punkte. */ const lerp = (a: Vec2, b: Vec2, t: number): Vec2 => add(a, scale(sub(b, a), t)); /** * Teilt eine Polylinie an EINEM Punkt P = lerp(pts[edgeIndex], pts[edgeIndex+1], * t) (t ∈ (0,1) entlang der Kante; bei geschlossenen Zügen ist die Schlusskante * `edgeIndex === pts.length-1`). * • OFFEN → zwei offene Polylinien [start…P] und [P…ende]. * • GESCHLOSSEN → EINE offene Polylinie, am Punkt P aufgeschnitten (der Zug * läuft von P einmal um die Schleife zurück nach P). * Liegt P (nahezu) auf einem vorhandenen Vertex, wird KEIN Duplikat eingefügt. */ export function splitPolylineAtParam( pts: Vec2[], closed: boolean, edgeIndex: number, t: number, ): Vec2[][] { const n = pts.length; if (n < 2) return [pts.slice()]; const a = pts[edgeIndex]; const b = pts[(edgeIndex + 1) % n]; const p = lerp(a, b, t); const atStart = t <= EPS; const atEnd = t >= 1 - EPS; if (!closed) { // Offen: [0..edgeIndex] + P + [edgeIndex+1..n-1]. const left = pts.slice(0, edgeIndex + 1); if (!atStart && !atEnd) left.push(p); else if (atEnd) left.push(b); // P == b → b gehört noch links dazu const right: Vec2[] = []; if (!atStart && !atEnd) right.push(p); else if (atStart) right.push(a); for (let i = edgeIndex + 1; i < n; i++) right.push(pts[i]); const pieces = [left, right].filter((s) => s.length >= 2); return pieces.length ? pieces : [pts.slice()]; } // Geschlossen: an P aufschneiden → eine offene Kette, die einmal herumläuft. const out: Vec2[] = []; if (!atStart) out.push(p); // Ab dem Punkt NACH der Schnittkante einmal komplett um die Schleife. for (let k = 1; k <= n; k++) { out.push(pts[(edgeIndex + k) % n]); } // out endet bei pts[edgeIndex] (== a); P schließt den Zug am Ende ab. if (!atStart) out.push(p); else out.push(a); // bei P==a: a doppelt (Start==Ende) ergäbe eine Schleife; // dedupe identische aufeinanderfolgende Punkte return [dedupeConsecutive(out)]; } /** Entfernt aufeinanderfolgende (nahezu) gleiche Punkte (kein Ringschluss). */ function dedupeConsecutive(pts: Vec2[]): Vec2[] { const out: Vec2[] = []; for (const p of pts) { if (out.length === 0 || !vecEqual(out[out.length - 1], p)) out.push(p); } return out; } /** * Teilt ein GESCHLOSSENES Polygon an zwei Randpunkten (auf den Kanten `i` bzw. * `j`, Parameter `ti`/`tj`) in ZWEI GESCHLOSSENE Polygone: jedes besteht aus dem * jeweiligen Rand-Bogen plus der Sehne (Verbindung Pi—Pj). Liefert `null`, wenn * i==j (dieselbe Kante → keine sinnvolle Sehne). */ export function splitClosedByChord( pts: Vec2[], i: number, ti: number, j: number, tj: number, ): [Vec2[], Vec2[]] | null { const n = pts.length; if (n < 3 || i === j) return null; // Reihenfolge sicherstellen: a vor b entlang des Ringes. let ia = i, ta = ti, ib = j, tb = tj; if (i > j) { ia = j; ta = tj; ib = i; tb = ti; } const Pa = lerp(pts[ia], pts[(ia + 1) % n], ta); const Pb = lerp(pts[ib], pts[(ib + 1) % n], tb); // Bogen 1: Pa → (Vertices ia+1 … ib) → Pb. const arc1: Vec2[] = [Pa]; for (let k = ia + 1; k <= ib; k++) arc1.push(pts[k % n]); arc1.push(Pb); // Bogen 2: Pb → (Vertices ib+1 … ia, modular) → Pa. const arc2: Vec2[] = [Pb]; for (let k = ib + 1; k <= ia + n; k++) arc2.push(pts[k % n]); arc2.push(Pa); return [dedupeRing(arc1), dedupeRing(arc2)]; } /** * Dedupliziert aufeinanderfolgende gleiche Punkte UND einen schließenden * Duplikat-Endpunkt (Ring: pts[last]==pts[0] → letzten verwerfen). */ function dedupeRing(pts: Vec2[]): Vec2[] { const out = dedupeConsecutive(pts); if (out.length > 1 && vecEqual(out[0], out[out.length - 1])) out.pop(); return out; } /** * Entfernt das Segment `edgeIndex` aus einer Polylinie. Liefert die neue * Punktliste plus neues `closed`-Flag: * • OFFEN: innere Kante → die Polylinie zerfällt in zwei Stücke; hier wird das * LÄNGERE Stück (mehr Punkte) zurückgegeben (Schere). Erste/letzte Kante → * einfach den End-Vertex abschneiden. * • GESCHLOSSEN: die Schleife wird an dieser Kante zur OFFENEN Polylinie * aufgetrennt (Start = Punkt nach der Kante, einmal herum bis zum Punkt davor). */ export function removeSegment( pts: Vec2[], closed: boolean, edgeIndex: number, ): { pts: Vec2[]; closed: boolean } { const n = pts.length; if (closed) { // Auftrennen: Start = (edgeIndex+1) … einmal herum bis edgeIndex. const out: Vec2[] = []; for (let k = 1; k <= n; k++) out.push(pts[(edgeIndex + k) % n]); return { pts: dedupeConsecutive(out), closed: false }; } // Offen. if (edgeIndex <= 0) return { pts: pts.slice(1), closed: false }; if (edgeIndex >= n - 1) return { pts: pts.slice(0, n - 1), closed: false }; const left = pts.slice(0, edgeIndex + 1); const right = pts.slice(edgeIndex + 1); // Das punktreichere (geometrisch i. d. R. „größere") Teilstück behalten. return { pts: left.length >= right.length ? left : right, closed: false }; } /** Ein Schnitt-Treffer auf einer Ziel-Kante: Kantenindex + Parameter + Punkt. */ interface EdgeHit { edge: number; t: number; point: Vec2; } /** * Teilt das Ziel (`targetPts`, `closed`) an allen Schnittpunkten mit den anderen * Polylinien (`others`). Schnittpunkte entstehen je Kantenpaar via * {@link segmentIntersect}. * • GESCHLOSSEN + genau zwei Schnitte → {@link splitClosedByChord} (zwei * geschlossene Polygone). Bei !=2 Schnitten: unverändert (eine Liste). * • OFFEN → an den (nach Bogenlänge geordneten) Schnittpunkten in Stücke * zerschnitten. * Schnitte an vorhandenen Vertices/Endpunkten werden zusammengefasst. */ export function splitAtIntersections( targetPts: Vec2[], closed: boolean, others: Vec2[][], ): Vec2[][] { const edges = polylineEdges(targetPts, closed); const hits: EdgeHit[] = []; for (let ei = 0; ei < edges.length; ei++) { const [a1, a2] = edges[ei]; for (const o of others) { // jede Kante jeder anderen Polylinie (offen UND geschlossen behandeln wir // gleich: die Aufrufer-Schicht liefert `others` bereits als Polylinien- // Punktlisten; geschlossene Schließkante über vecEqual-Heuristik unnötig, // da der Aufrufer Endpunkt nicht dupliziert — wir testen offene Kanten). const oEdges = polylineEdgesAuto(o); for (const [b1, b2] of oEdges) { const h = segmentIntersect(a1, a2, b1, b2); if (!h) continue; // Nur „echte" innere Schnitte zählen (nicht exakt am Ziel-Vertex 0/1, // die ohnehin Trennstellen sind). hits.push({ edge: ei, t: h.t, point: h.point }); } } } // Pro Ziel-Kante nach t sortieren + dedupen. const dedup: EdgeHit[] = []; hits.sort((p, q) => (p.edge - q.edge) || (p.t - q.t)); for (const h of hits) { const prev = dedup[dedup.length - 1]; if (prev && prev.edge === h.edge && Math.abs(prev.t - h.t) < 1e-6) continue; // Schnitte exakt auf einem Ziel-Vertex (t≈0/1) ignorieren — keine echte Teilung. if (h.t <= EPS || h.t >= 1 - EPS) continue; dedup.push(h); } if (dedup.length === 0) return [targetPts.slice()]; if (closed) { if (dedup.length === 2) { const [h1, h2] = dedup; const res = splitClosedByChord(targetPts, h1.edge, h1.t, h2.edge, h2.t); return res ? res : [targetPts.slice()]; } // Mehr/weniger als 2 Schnitte: geschlossen an allen Schnitten zur offenen // Polylinie aufgetrennt (am ersten Schnitt aufgeschnitten, dann in Stücke). // Pragmatisch: am ersten Schnitt aufschneiden, dann wie offen weiter. const opened = splitPolylineAtParam(targetPts, true, dedup[0].edge, dedup[0].t)[0]; return splitOpenAtHits(opened, dedup.slice(1).map((h) => h.point)); } // Offen: an den geordneten Schnittpunkten zerschneiden. return splitOpenByEdgeHits(targetPts, dedup); } /** Kanten einer (offenen ODER implizit geschlossenen) Punktliste. */ function polylineEdgesAuto(pts: Vec2[]): [Vec2, Vec2][] { // Heuristik: ist der erste == letzter Punkt, gilt sie als geschlossen. const closed = pts.length > 2 && vecEqual(pts[0], pts[pts.length - 1]); return polylineEdges(closed ? pts.slice(0, -1) : pts, closed); } /** Offene Polylinie an gegebenen EdgeHits (auf ihren Kanten) zerschneiden. */ function splitOpenByEdgeHits(pts: Vec2[], hits: EdgeHit[]): Vec2[][] { const pieces: Vec2[][] = []; let cur: Vec2[] = [pts[0]]; let hi = 0; for (let ei = 0; ei < pts.length - 1; ei++) { while (hi < hits.length && hits[hi].edge === ei) { const p = hits[hi].point; cur.push(p); pieces.push(cur); cur = [p]; hi++; } cur.push(pts[ei + 1]); } pieces.push(cur); return pieces.map(dedupeConsecutive).filter((s) => s.length >= 2); } /** Offene Polylinie an einer Liste von Schnitt-PUNKTEN (auf dem Zug) zerschneiden. */ function splitOpenAtHits(pts: Vec2[], cutPoints: Vec2[]): Vec2[][] { if (cutPoints.length === 0) return [pts.slice()]; const hits: EdgeHit[] = []; for (let ei = 0; ei < pts.length - 1; ei++) { const a = pts[ei]; const b = pts[ei + 1]; for (const cp of cutPoints) { const t = projectParam(cp, a, b); if (t > EPS && t < 1 - EPS && pointSegmentDistance(cp, a, b) < 1e-6) { hits.push({ edge: ei, t, point: cp }); } } } hits.sort((p, q) => (p.edge - q.edge) || (p.t - q.t)); return splitOpenByEdgeHits(pts, hits); } /** * Verschmilzt Polylinien an koinzidenten Endpunkten zu längeren Ketten. Jeder * Eintrag ist `{pts, closed}`. Bereits geschlossene Ketten bleiben unverändert. * Offene Ketten werden an gemeinsamen Endpunkten (vecEqual) aneinandergehängt; * schließt sich eine Kette (erster == letzter Punkt), wird sie als `closed` * markiert (der Schlusspunkt-Duplikat entfernt). Reihenfolge-/Richtungs-stabil * genug für den Editier-Workflow (kein Verzweigungs-Handling: Knoten mit >2 * Enden werden greedy verbunden). */ export function joinChains( polylines: { pts: Vec2[]; closed: boolean }[], ): { pts: Vec2[]; closed: boolean }[] { const closedOut: { pts: Vec2[]; closed: boolean }[] = []; // Offene Ketten als veränderbare Punktlisten sammeln (geschlossene bleiben). const open: Vec2[][] = []; for (const pl of polylines) { if (pl.closed) closedOut.push({ pts: pl.pts.slice(), closed: true }); else if (pl.pts.length >= 2) open.push(pl.pts.slice()); else if (pl.pts.length === 1) open.push(pl.pts.slice()); } let merged = true; while (merged) { merged = false; outer: for (let i = 0; i < open.length; i++) { for (let j = i + 1; j < open.length; j++) { const A = open[i]; const B = open[j]; const aS = A[0], aE = A[A.length - 1]; const bS = B[0], bE = B[B.length - 1]; let combined: Vec2[] | null = null; if (vecEqual(aE, bS)) combined = [...A, ...B.slice(1)]; else if (vecEqual(aE, bE)) combined = [...A, ...B.slice(0, -1).reverse()]; else if (vecEqual(aS, bE)) combined = [...B, ...A.slice(1)]; else if (vecEqual(aS, bS)) combined = [...B.slice().reverse(), ...A.slice(1)]; if (combined) { open.splice(j, 1); open.splice(i, 1, combined); merged = true; break outer; } } } } const out: { pts: Vec2[]; closed: boolean }[] = [...closedOut]; for (const chain of open) { if (chain.length > 2 && vecEqual(chain[0], chain[chain.length - 1])) { out.push({ pts: chain.slice(0, -1), closed: true }); } else { out.push({ pts: chain, closed: false }); } } return out; }