Browser-BIM (cad): semantisches Modell, abgeleitete 2D/3D-Sichten, Zeichenwerkzeuge

Standalone-Browser-Port von DOSSIER. Enthaelt das semantische Modell mit
Plan-/3D-Ableitung, Zeichen- und Editierwerkzeuge, Rhino-artiges Befehlssystem,
dockbares Panel-System, Resource-Manager, DXF/.lin/.pat-Import, i18n (de/en)
sowie Projektdokumentation und Probe-Harness.
This commit is contained in:
2026-06-30 20:52:27 +02:00
commit ca859c4aa4
157 changed files with 37921 additions and 0 deletions
+735
View File
@@ -0,0 +1,735 @@
// 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);
}
// ── 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;
}