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
+90
View File
@@ -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([]);
});
});