task(B2): line/circle/arc intersections (fixes BUG-2)

This commit is contained in:
Agent Zero
2026-08-26 13:47:25 +02:00
parent 1bbd653d34
commit 95b14cf86b
2 changed files with 268 additions and 12 deletions
+178 -12
View File
@@ -215,7 +215,15 @@ export function offsetElement(el: CADElement, distance: number): CADElement {
*/ */
export function trimElement(el: CADElement, boundary: CADElement): CADElement | null { export function trimElement(el: CADElement, boundary: CADElement): CADElement | null {
// Simplified: find intersection with boundary, trim line to that point // 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); const intersect = findIntersection(el, boundary);
if (!intersect) return null; if (!intersect) return null;
@@ -247,7 +255,15 @@ export function trimElement(el: CADElement, boundary: CADElement): CADElement |
* Currently supports extending lines to a boundary intersection. * Currently supports extending lines to a boundary intersection.
*/ */
export function extendElement(el: CADElement, boundary: CADElement): CADElement | null { 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); const intersect = findIntersection(el, boundary);
if (!intersect) return null; 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). * 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 02 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 p1 = el1.properties;
const p2 = el2.properties; const p2 = el2.properties;
if (p1.x1 === undefined || p1.y1 === undefined || p1.x2 === undefined || p1.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 null; 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!);
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 [];
if (Math.abs(denom) < 1e-10) return null;
const t = ((p1.x1 - p2.x1) * (p2.y1 - p2.y2) - (p1.y1 - p2.y1) * (p2.x1 - p2.x2)) / denom; 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 x = p1.x1! + t * (p1.x2! - p1.x1!);
const y = p1.y1 + t * (p1.y2 - p1.y1); 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;
} }
/** /**
+90
View File
@@ -10,6 +10,9 @@ import {
offsetElement, offsetElement,
getElementBBox, getElementBBox,
distance, distance,
lineCircleIntersections,
circleCircleIntersections,
lineArcIntersection,
angleBetween, angleBetween,
} from '../src/tools/modification/geometry'; } from '../src/tools/modification/geometry';
import type { CADElement } from '../src/types/cad.types'; 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); 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([]);
});
});