task(B2): line/circle/arc intersections (fixes BUG-2)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user