From 95b14cf86b8a349327a7a56feda8cfe7c25a82bc Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Wed, 26 Aug 2026 13:47:25 +0200 Subject: [PATCH] task(B2): line/circle/arc intersections (fixes BUG-2) --- frontend/src/tools/modification/geometry.ts | 190 ++++++++++++++++++-- frontend/tests/geometry.test.ts | 90 ++++++++++ 2 files changed, 268 insertions(+), 12 deletions(-) diff --git a/frontend/src/tools/modification/geometry.ts b/frontend/src/tools/modification/geometry.ts index 96a3b9d..f752d84 100644 --- a/frontend/src/tools/modification/geometry.ts +++ b/frontend/src/tools/modification/geometry.ts @@ -215,7 +215,15 @@ export function offsetElement(el: CADElement, distance: number): CADElement { */ export function trimElement(el: CADElement, boundary: CADElement): CADElement | null { // Simplified: find intersection with boundary, trim line to that point - if (el.type !== 'line' || !el.properties.x1 || !el.properties.x2) return null; + // Hinweis: explizit auf undefined prüfen — Koordinate 0 ist gültig (nicht falsy behandeln) + const p = el.properties; + if ( + el.type !== 'line' || + p.x1 === undefined || p.y1 === undefined || + p.x2 === undefined || p.y2 === undefined + ) { + return null; + } const intersect = findIntersection(el, boundary); if (!intersect) return null; @@ -247,7 +255,15 @@ export function trimElement(el: CADElement, boundary: CADElement): CADElement | * Currently supports extending lines to a boundary intersection. */ export function extendElement(el: CADElement, boundary: CADElement): CADElement | null { - if (el.type !== 'line' || !el.properties.x1 || !el.properties.x2) return null; + // Explizit auf undefined prüfen — Koordinate 0 ist gültig (nicht falsy behandeln) + const p = el.properties; + if ( + el.type !== 'line' || + p.x1 === undefined || p.y1 === undefined || + p.x2 === undefined || p.y2 === undefined + ) { + return null; + } const intersect = findIntersection(el, boundary); if (!intersect) return null; @@ -300,22 +316,172 @@ export function filletElements(el1: CADElement, el2: CADElement, radius: number) /** * Find intersection point of two elements (lines only for now). */ -function findIntersection(el1: CADElement, el2: CADElement): { x: number; y: number } | null { +/** Punkt im 2D-Raum. */ +export interface Pt { + x: number; + y: number; +} + +/** + * Alle Schnittpunkte einer Strecke p1→p2 mit einem Kreis (Zentrum c, Radius r). + * Liefert nur Punkte innerhalb des Streckensegments (t ∈ [0,1]); [] wenn keine. + */ +export function lineCircleIntersections(p1: Pt, p2: Pt, c: Pt, r: number): Pt[] { + const dx = p2.x - p1.x; + const dy = p2.y - p1.y; + const fx = p1.x - c.x; + const fy = p1.y - c.y; + + const a = dx * dx + dy * dy; + const b = 2 * (fx * dx + fy * dy); + const cc = fx * fx + fy * fy - r * r; + + let discriminant = b * b - 4 * a * cc; + if (discriminant < 0) return []; // keine reale Lösung + + discriminant = Math.sqrt(discriminant); + const t1 = (-b - discriminant) / (2 * a); + const t2 = (-b + discriminant) / (2 * a); + // Tangente: beide Lösungen identisch → nur einen Punkt liefern + const ts = Math.abs(t1 - t2) < 1e-7 ? [t1] : [t1, t2]; + const eps = 1e-9; + const results: Pt[] = []; + for (const t of ts) { + if (t >= -eps && t <= 1 + eps) { + results.push({ x: p1.x + t * dx, y: p1.y + t * dy }); + } + } + return results; +} + +/** + * Schnittpunkte zweier Kreise (Zentren c1/c2, Radien r1/r2). + * Liefert 0–2 Punkte; [] wenn getrennt oder identisch. + */ +export function circleCircleIntersections(c1: Pt, r1: number, c2: Pt, r2: number): Pt[] { + const dVec = { x: c2.x - c1.x, y: c2.y - c1.y }; + const d = Math.hypot(dVec.x, dVec.y); + if (d === 0) return []; // identische Zentren + if (d > r1 + r2 || d < Math.abs(r1 - r2)) return []; // getrennt / ineinander + + const a = (r1 * r1 - r2 * r2 + d * d) / (2 * d); + const hSq = r1 * r1 - a * a; + const h = Math.sqrt(Math.max(0, hSq)); + const baseX = c1.x + (a * dVec.x) / d; + const baseY = c1.y + (a * dVec.y) / d; + if (h === 0) return [{ x: baseX, y: baseY }]; // tangentiale Berührung + return [ + { x: baseX + (h * dVec.y) / d, y: baseY - (h * dVec.x) / d }, + { x: baseX - (h * dVec.y) / d, y: baseY + (h * dVec.x) / d }, + ]; +} + +/** + * Liest die Arc-Parameter eines CADElements nach der Konvention von + * RenderEngine.drawArc: Zentrum = el.x/el.y, radius/startAngle/endAngle aus + * properties; Winkel in GRAD, 0° = 3 Uhr, gegen Uhrzeigersinn. + */ +function readArc(el: CADElement): { c: Pt; r: number; startDeg: number; endDeg: number } | null { + const r = (el.properties?.radius as number | undefined) ?? (el as unknown as { width?: number }).width! / 2; + if (!Number.isFinite(r) || r <= 0) return null; + return { + c: { x: el.x, y: el.y }, + r, + startDeg: ((el.properties?.startAngle as number | undefined) ?? 0), + endDeg: ((el.properties?.endAngle as number | undefined) ?? 360), + }; +} + +/** Prüft, ob ein Winkel (Grad) innerhalb eines Bogens liegt (CCW von start bis end). */ +function angleInArcRange(deg: number, startDeg: number, endDeg: number): boolean { + const norm = (v: number) => ((v % 360) + 360) % 360; + let a = norm(deg); + const s = norm(startDeg); + const e = norm(endDeg); + if (s <= e) return a >= s && a <= e; + return a >= s || a <= e; // Bogen über 0° +} + +/** + * Alle Schnittpunkte einer Strecke p1→p2 mit dem Bogen eines Elements + * (Konvention wie RenderEngine.drawArc). Nur Treffer innerhalb des Segments + * UND im Winkelbereich des Bogens werden zurückgegeben. + */ +export function lineArcIntersection(p1: Pt, p2: Pt, el: CADElement): Pt[] { + const arc = readArc(el); + if (!arc) return []; + return lineCircleIntersections(p1, p2, arc.c, arc.r) + .filter((pt) => angleInArcRange((Math.atan2(pt.y - arc.c.y, pt.x - arc.c.x) * 180) / Math.PI, arc.startDeg, arc.endDeg)); +} + +function lineLineIntersections(el1: CADElement, el2: CADElement): Pt[] { const p1 = el1.properties; const p2 = el2.properties; - if (p1.x1 === undefined || p1.y1 === undefined || p1.x2 === undefined || p1.y2 === undefined) return null; - if (p2.x1 === undefined || p2.y1 === undefined || p2.x2 === undefined || p2.y2 === undefined) return null; + if (p1.x1 === undefined || p1.y1 === undefined || p1.x2 === undefined || p1.y2 === undefined) return []; + if (p2.x1 === undefined || p2.y1 === undefined || p2.x2 === undefined || p2.y2 === undefined) return []; - // Line-line intersection - const denom = (p1.x1 - p1.x2) * (p2.y1 - p2.y2) - (p1.y1 - p1.y2) * (p2.x1 - p2.x2); - if (Math.abs(denom) < 1e-10) return null; + const denom = (p1.x1! - p1.x2!) * (p2.y1! - p2.y2!) - (p1.y1! - p1.y2!) * (p2.x1! - p2.x2!); + if (Math.abs(denom) < 1e-10) return []; - const t = ((p1.x1 - p2.x1) * (p2.y1 - p2.y2) - (p1.y1 - p2.y1) * (p2.x1 - p2.x2)) / denom; - const x = p1.x1 + t * (p1.x2 - p1.x1); - const y = p1.y1 + t * (p1.y2 - p1.y1); + const t = ((p1.x1! - p2.x1!) * (p2.y1! - p2.y2!) - (p1.y1! - p2.y1!) * (p2.x1! - p2.x2!)) / denom; + const x = p1.x1! + t * (p1.x2! - p1.x1!); + const y = p1.y1! + t * (p1.y2! - p1.y1!); + return [{ x, y }]; +} - return { x, y }; +/** + * Erster Schnittpunkt zweier CAD-Elemente (line/circle/arc), intern für + * trim/extend/fillet. Bei mehreren Treffern wird der zu el1 nächstgelegene + * gewählt. null wenn keine Kombination unterstützt oder kein Schnitt. + */ +function findIntersection(el1: CADElement, el2: CADElement): { x: number; y: number } | null { + const types = new Set([el1.type, el2.type]); + // Linie immer als erstes Argument normalisieren (reihenfolgeunabhängig) + const [lineEl, otherEl] = el1.type === 'line' ? [el1, el2] : [el2, el1]; + let candidates: Pt[] = []; + let refFromFirst = true; // Referenzpunkt von el1 nehmen? + + if (types.has('line') && types.size === 1) { + candidates = lineLineIntersections(el1, el2); + refFromFirst = false; // beide sind Linien; Referenz unten via el1-Startpunkt + } else if (types.has('line') && types.has('circle')) { + candidates = lineCircleIntersections( + { x: lineEl.properties.x1!, y: lineEl.properties.y1! }, + { x: lineEl.properties.x2!, y: lineEl.properties.y2! }, + { x: otherEl.x, y: otherEl.y }, + (otherEl.properties.radius as number | undefined) ?? otherEl.width / 2, + ); + } else if (types.has('line') && types.has('arc')) { + candidates = lineArcIntersection( + { x: lineEl.properties.x1!, y: lineEl.properties.y1! }, + { x: lineEl.properties.x2!, y: lineEl.properties.y2! }, + otherEl, + ); + } else if (types.has('circle') && types.size === 1) { + candidates = circleCircleIntersections( + { x: el1.x, y: el1.y }, + (el1.properties.radius as number | undefined) ?? el1.width / 2, + { x: el2.x, y: el2.y }, + (el2.properties.radius as number | undefined) ?? el2.width / 2, + ); + refFromFirst = false; + } + + if (candidates.length === 0) return null; + // Nächstliegenden Treffer zu el1 wählen (Startpunkt bei Linie, sonst Zentrum) + const isFirstLine = el1.type === 'line'; + const refX = refFromFirst || isFirstLine + ? (isFirstLine ? el1.properties.x1 : el1.x) + : el1.x; + const refY = refFromFirst || isFirstLine + ? (isFirstLine ? el1.properties.y1 : el1.y) + : el1.y; + let best = candidates[0]; + for (const c of candidates) { + if (Math.hypot(c.x - refX!, c.y - refY!) < Math.hypot(best.x - refX!, best.y - refY!)) best = c; + } + return best; } /** diff --git a/frontend/tests/geometry.test.ts b/frontend/tests/geometry.test.ts index 806d302..1248de2 100644 --- a/frontend/tests/geometry.test.ts +++ b/frontend/tests/geometry.test.ts @@ -10,6 +10,9 @@ import { offsetElement, getElementBBox, distance, + lineCircleIntersections, + circleCircleIntersections, + lineArcIntersection, angleBetween, } from '../src/tools/modification/geometry'; import type { CADElement } from '../src/types/cad.types'; @@ -293,3 +296,90 @@ describe('geometry – angleBetween', () => { expect(angleBetween({ x: 0, y: 0 }, { x: 0, y: 100 })).toBe(90); }); }); + +describe('geometry – lineCircleIntersections', () => { + const p1 = { x: 0, y: 0 }; + const p2 = { x: 200, y: 0 }; + const c = { x: 100, y: 0 }; + + it('findet zwei Schnittpunkte bei Sekante', () => { + const pts = lineCircleIntersections(p1, p2, c, 40); + expect(pts.length).toBe(2); + const xs = pts.map((p) => Math.round(p.x)).sort((a, b) => a - b); + expect(xs).toEqual([60, 140]); + }); + + it('findet genau einen Punkt bei Tangente', () => { + // Strecke entlang y=40 tangiert Kreis (100,0,r=40) + const pts = lineCircleIntersections({ x: 50, y: 40 }, { x: 150, y: 40 }, { x: 100, y: 0 }, 40); + expect(pts.length).toBe(1); + expect(pts[0].x).toBeCloseTo(100); + expect(pts[0].y).toBeCloseTo(40); + }); + + it('liefert leeres Array ohne Schnitt (Diskriminante < 0)', () => { + expect(lineCircleIntersections(p1, p2, { x: 100, y: 500 }, 10)).toEqual([]); + }); + + it('respektiert Segmentgrenzen t∈[0,1] (Verlaengerung schneidet nicht)', () => { + // Kreis liegt hinter dem Endpunkt der Strecke + expect(lineCircleIntersections({ x: 0, y: 0 }, { x: 50, y: 0 }, { x: 100, y: 0 }, 30)).toEqual([]); + }); +}); + +describe('geometry – circleCircleIntersections', () => { + it('findet zwei Punkte bei Ueberlappung', () => { + const pts = circleCircleIntersections({ x: 0, y: 0 }, 50, { x: 80, y: 0 }, 50); + expect(pts.length).toBe(2); + for (const pt of pts) { + expect(pt.x).toBeCloseTo(40); // Symmetrieachse + expect(Math.abs(pt.y)).toBeCloseTo(30); // Höhe via Satz des Pythagoras + } + }); + + it('liefert einen Punkt bei Tangenz', () => { + const pts = circleCircleIntersections({ x: 0, y: 0 }, 50, { x: 100, y: 0 }, 50); + expect(pts.length).toBe(1); + expect(pts[0]).toEqual({ x: 50, y: 0 }); + }); + + it('liefert leer bei getrennten Kreisen und identischen Zentren', () => { + expect(circleCircleIntersections({ x: 0, y: 0 }, 20, { x: 500, y: 0 }, 20)).toEqual([]); + expect(circleCircleIntersections({ x: 0, y: 0 }, 20, { x: 0, y: 0 }, 20)).toEqual([]); + }); +}); + +describe('geometry – lineArcIntersection', () => { + function makeArc(id: string, cx: number, cy: number, r: number, startDeg: number, endDeg: number): CADElement { + return { + id, + type: 'arc', + layerId: 'layer-1', + x: cx, + y: cy, + width: r * 2, + height: r * 2, + properties: { radius: r, startAngle: startDeg, endAngle: endDeg }, + } as unknown as CADElement; + } + + it('schneidet Strecke mit Halbkreis (0°..180°)', () => { + const arc = makeArc('a1', 100, 0, 40, 0, 180); // obere Hälfte (CCW von 3Uhr) + const pts = lineArcIntersection({ x: 60, y: 0 }, { x: 140, y: 40 }, arc); + // Linie von (60,0) nach (140,40): schneidet Bogen einmal oben + expect(pts.length).toBeGreaterThanOrEqual(1); + }); + + it('filtert Treffer außerhalb des Winkelbereichs heraus', () => { + const arc = makeArc('a2', 100, 0, 40, 180, 360); // untere Hälfte + // Strecke verläuft komplett oberhalb der Mittellinie → Schnitte lägen nur + // im oberen Halbkreis (Winkel < 180°), der nicht Teil des Bogens ist. + const pts = lineArcIntersection({ x: 60, y: 20 }, { x: 140, y: 40 }, arc); + expect(pts).toEqual([]); + }); + + it('liefert leer bei fehlendem Radius (ungueltiges Element)', () => { + const bad = { id: 'x', type: 'arc', layerId: 'l', x: 0, y: 0, width: 0, height: 0, properties: {} } as unknown as CADElement; + expect(lineArcIntersection({ x: -10, y: -10 }, { x: 10, y: 10 }, bad)).toEqual([]); + }); +});