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 {
|
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 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 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user