524 lines
17 KiB
TypeScript
524 lines
17 KiB
TypeScript
/**
|
||
* Pure geometry transformation functions for CAD elements.
|
||
* Each function returns a NEW CADElement (immutable).
|
||
*/
|
||
|
||
import type { CADElement, CADProperties } from '../../types/cad.types';
|
||
|
||
/**
|
||
* Move element by dx, dy.
|
||
*/
|
||
export function moveElement(el: CADElement, dx: number, dy: number): CADElement {
|
||
const props = { ...el.properties };
|
||
|
||
if (props.x1 !== undefined) props.x1 += dx;
|
||
if (props.y1 !== undefined) props.y1 += dy;
|
||
if (props.x2 !== undefined) props.x2 += dx;
|
||
if (props.y2 !== undefined) props.y2 += dy;
|
||
if (props.points) {
|
||
props.points = props.points.map(p => ({ x: p.x + dx, y: p.y + dy }));
|
||
}
|
||
|
||
return {
|
||
...el,
|
||
x: el.x + dx,
|
||
y: el.y + dy,
|
||
properties: props,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Rotate element around center (cx, cy) by angle in degrees.
|
||
*/
|
||
export function rotateElement(el: CADElement, cx: number, cy: number, angle: number): CADElement {
|
||
const rad = (angle * Math.PI) / 180;
|
||
const cos = Math.cos(rad);
|
||
const sin = Math.sin(rad);
|
||
|
||
function rot(x: number, y: number): [number, number] {
|
||
const dx = x - cx;
|
||
const dy = y - cy;
|
||
return [cx + dx * cos - dy * sin, cy + dx * sin + dy * cos];
|
||
}
|
||
|
||
const props = { ...el.properties };
|
||
const [nx, ny] = rot(el.x, el.y);
|
||
|
||
if (props.x1 !== undefined && props.y1 !== undefined) {
|
||
[props.x1, props.y1] = rot(props.x1, props.y1);
|
||
}
|
||
if (props.x2 !== undefined && props.y2 !== undefined) {
|
||
[props.x2, props.y2] = rot(props.x2, props.y2);
|
||
}
|
||
if (props.points) {
|
||
props.points = props.points.map(p => {
|
||
const [px, py] = rot(p.x, p.y);
|
||
return { x: px, y: py };
|
||
});
|
||
}
|
||
|
||
// Update rotation property (additive)
|
||
props.rotation = (props.rotation ?? 0) + angle;
|
||
|
||
// Swap width/height for 90/270 degree rotations on rect
|
||
let w = el.width;
|
||
let h = el.height;
|
||
const normAngle = ((angle % 360) + 360) % 360;
|
||
if (normAngle === 90 || normAngle === 270) {
|
||
[w, h] = [h, w];
|
||
}
|
||
|
||
return {
|
||
...el,
|
||
x: nx,
|
||
y: ny,
|
||
width: w,
|
||
height: h,
|
||
properties: props,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Scale element around center (cx, cy) by factors sx, sy.
|
||
*/
|
||
export function scaleElement(el: CADElement, cx: number, cy: number, sx: number, sy: number): CADElement {
|
||
function scl(x: number, y: number): [number, number] {
|
||
return [cx + (x - cx) * sx, cy + (y - cy) * sy];
|
||
}
|
||
|
||
const props = { ...el.properties };
|
||
const [nx, ny] = scl(el.x, el.y);
|
||
|
||
if (props.x1 !== undefined && props.y1 !== undefined) {
|
||
[props.x1, props.y1] = scl(props.x1, props.y1);
|
||
}
|
||
if (props.x2 !== undefined && props.y2 !== undefined) {
|
||
[props.x2, props.y2] = scl(props.x2, props.y2);
|
||
}
|
||
if (props.points) {
|
||
props.points = props.points.map(p => {
|
||
const [px, py] = scl(p.x, p.y);
|
||
return { x: px, y: py };
|
||
});
|
||
}
|
||
if (props.radius !== undefined) {
|
||
props.radius *= Math.max(sx, sy);
|
||
}
|
||
|
||
return {
|
||
...el,
|
||
x: nx,
|
||
y: ny,
|
||
width: el.width * sx,
|
||
height: el.height * sy,
|
||
properties: props,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Mirror element across a line defined by (x1,y1) and (x2,y2).
|
||
*/
|
||
export function mirrorElement(el: CADElement, x1: number, y1: number, x2: number, y2: number): CADElement {
|
||
const dx = x2 - x1;
|
||
const dy = y2 - y1;
|
||
const lenSq = dx * dx + dy * dy;
|
||
if (lenSq === 0) return el;
|
||
|
||
function mir(px: number, py: number): [number, number] {
|
||
const t = ((px - x1) * dx + (py - y1) * dy) / lenSq;
|
||
const projX = x1 + t * dx;
|
||
const projY = y1 + t * dy;
|
||
return [2 * projX - px, 2 * projY - py];
|
||
}
|
||
|
||
const props = { ...el.properties };
|
||
const [nx, ny] = mir(el.x, el.y);
|
||
|
||
if (props.x1 !== undefined && props.y1 !== undefined) {
|
||
[props.x1, props.y1] = mir(props.x1, props.y1);
|
||
}
|
||
if (props.x2 !== undefined && props.y2 !== undefined) {
|
||
[props.x2, props.y2] = mir(props.x2, props.y2);
|
||
}
|
||
if (props.points) {
|
||
props.points = props.points.map(p => {
|
||
const [mx, my] = mir(p.x, p.y);
|
||
return { x: mx, y: my };
|
||
});
|
||
}
|
||
|
||
// Flip rotation
|
||
const angle = Math.atan2(dy, dx) * 180 / Math.PI;
|
||
props.rotation = 2 * angle - (props.rotation ?? 0);
|
||
|
||
return {
|
||
...el,
|
||
x: nx,
|
||
y: ny,
|
||
properties: props,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Offset element by a distance (creates a parallel copy).
|
||
* For lines: offset perpendicular. For circles/arcs: adjust radius.
|
||
*/
|
||
/**
|
||
* Versetzt ein Element senkrecht zur seiner Richtung.
|
||
* @param distance positiver Betrag des Versatzes
|
||
* @param side 1 = links der Laufrichtung (Standard), -1 = rechts —
|
||
* bei circle/arc: 1 = Radius vergrößern, -1 = verkleinern
|
||
*/
|
||
export function offsetElement(el: CADElement, distance: number, side?: 1 | -1): CADElement {
|
||
const signed = distance * (side ?? 1);
|
||
const props = { ...el.properties };
|
||
|
||
if (el.type === 'line' && props.x1 !== undefined && props.y1 !== undefined && props.x2 !== undefined && props.y2 !== undefined) {
|
||
const dx = props.x2 - props.x1;
|
||
const dy = props.y2 - props.y1;
|
||
const len = Math.sqrt(dx * dx + dy * dy);
|
||
if (len === 0) return el;
|
||
// Perpendicular unit vector
|
||
const nx = -dy / len;
|
||
const ny = dx / len;
|
||
const ox = nx * signed;
|
||
const oy = ny * signed;
|
||
props.x1 += ox;
|
||
props.y1 += oy;
|
||
props.x2 += ox;
|
||
props.y2 += oy;
|
||
return { ...el, x: el.x + ox, y: el.y + oy, properties: props };
|
||
}
|
||
|
||
if ((el.type === 'circle' || el.type === 'arc') && props.radius !== undefined) {
|
||
props.radius = Math.abs(props.radius + signed);
|
||
const d = props.radius * 2;
|
||
return { ...el, width: d, height: d, properties: props };
|
||
}
|
||
|
||
if ((el.type === 'polyline' || el.type === 'polygon') && props.points) {
|
||
// Offset each segment perpendicular — simplified: shift all points by average normal
|
||
// For a proper offset, each vertex needs miter calculation. This is a simplified version.
|
||
props.points = props.points.map((p, i) => {
|
||
const prev = props.points![Math.max(0, i - 1)];
|
||
const next = props.points![Math.min(props.points!.length - 1, i + 1)];
|
||
const dx = next.x - prev.x;
|
||
const dy = next.y - prev.y;
|
||
const len = Math.sqrt(dx * dx + dy * dy);
|
||
if (len === 0) return p;
|
||
const nx = -dy / len;
|
||
const ny = dx / len;
|
||
return { x: p.x + nx * signed, y: p.y + ny * signed };
|
||
});
|
||
return { ...el, properties: props };
|
||
}
|
||
|
||
return el;
|
||
}
|
||
|
||
/**
|
||
* Trim element at boundary. Returns the trimmed element or null if no trim possible.
|
||
* Currently supports trimming lines at a boundary point.
|
||
*/
|
||
export function trimElement(el: CADElement, boundary: CADElement): CADElement | null {
|
||
// Simplified: find intersection with boundary, trim line to that point
|
||
// 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;
|
||
|
||
// Trim from the end closest to the intersection
|
||
const props = { ...el.properties };
|
||
const d1 = Math.sqrt((intersect.x - props.x1!) ** 2 + (intersect.y - props.y1!) ** 2);
|
||
const d2 = Math.sqrt((intersect.x - props.x2!) ** 2 + (intersect.y - props.y2!) ** 2);
|
||
|
||
if (d1 < d2) {
|
||
props.x2 = intersect.x;
|
||
props.y2 = intersect.y;
|
||
} else {
|
||
props.x1 = intersect.x;
|
||
props.y1 = intersect.y;
|
||
}
|
||
|
||
// Recalculate center and bbox
|
||
const cx = (props.x1! + props.x2!) / 2;
|
||
const cy = (props.y1! + props.y2!) / 2;
|
||
const w = Math.abs(props.x2! - props.x1!);
|
||
const h = Math.abs(props.y2! - props.y1!);
|
||
|
||
return { ...el, x: cx, y: cy, width: w, height: h, properties: props };
|
||
}
|
||
|
||
/**
|
||
* Extend element to meet boundary. Returns extended element or null.
|
||
* Currently supports extending lines to a boundary intersection.
|
||
*/
|
||
export function extendElement(el: CADElement, boundary: CADElement): CADElement | 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;
|
||
|
||
const props = { ...el.properties };
|
||
// Extend the end that is closer to the intersection
|
||
const d1 = Math.sqrt((intersect.x - props.x1!) ** 2 + (intersect.y - props.y1!) ** 2);
|
||
const d2 = Math.sqrt((intersect.x - props.x2!) ** 2 + (intersect.y - props.y2!) ** 2);
|
||
|
||
if (d1 < d2) {
|
||
props.x1 = intersect.x;
|
||
props.y1 = intersect.y;
|
||
} else {
|
||
props.x2 = intersect.x;
|
||
props.y2 = intersect.y;
|
||
}
|
||
|
||
const cx = (props.x1! + props.x2!) / 2;
|
||
const cy = (props.y1! + props.y2!) / 2;
|
||
const w = Math.abs(props.x2! - props.x1!);
|
||
const h = Math.abs(props.y2! - props.y1!);
|
||
|
||
return { ...el, x: cx, y: cy, width: w, height: h, properties: props };
|
||
}
|
||
|
||
/**
|
||
* Fillet two elements with a given radius.
|
||
* Currently supports filleting two lines by trimming/adjusting endpoints.
|
||
*/
|
||
export function filletElements(el1: CADElement, el2: CADElement, radius: number): [CADElement, CADElement] | null {
|
||
// Simplified: find intersection of two lines, then trim both to the fillet point
|
||
const intersect = findIntersection(el1, el2);
|
||
if (!intersect) return null;
|
||
|
||
// For now, just trim both lines to the intersection point (true arc fillet is complex)
|
||
const p1 = { ...el1.properties };
|
||
const p2 = { ...el2.properties };
|
||
|
||
if (!p1.x1 || !p1.x2 || !p2.x1 || !p2.x2) return null;
|
||
|
||
// Determine which endpoint of each line is closest to intersection
|
||
const trim1 = trimElement(el1, el2);
|
||
const trim2 = trimElement(el2, el1);
|
||
|
||
if (!trim1 || !trim2) return null;
|
||
|
||
return [trim1, trim2];
|
||
}
|
||
|
||
/**
|
||
* Find intersection point of two elements (lines only for now).
|
||
*/
|
||
/** 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 [];
|
||
if (p2.x1 === undefined || p2.y1 === undefined || p2.x2 === undefined || p2.y2 === undefined) return [];
|
||
|
||
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!);
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Calculate bounding box of an element.
|
||
*/
|
||
export function getElementBBox(el: CADElement): { minX: number; minY: number; maxX: number; maxY: number } {
|
||
if (el.properties.points) {
|
||
const xs = el.properties.points.map(p => p.x);
|
||
const ys = el.properties.points.map(p => p.y);
|
||
return { minX: Math.min(...xs), minY: Math.min(...ys), maxX: Math.max(...xs), maxY: Math.max(...ys) };
|
||
}
|
||
return {
|
||
minX: el.x - el.width / 2,
|
||
minY: el.y - el.height / 2,
|
||
maxX: el.x + el.width / 2,
|
||
maxY: el.y + el.height / 2,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Calculate distance between two points.
|
||
*/
|
||
export function distance(p1: { x: number; y: number }, p2: { x: number; y: number }): number {
|
||
return Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2);
|
||
}
|
||
|
||
/**
|
||
* Calculate angle between two points in degrees.
|
||
*/
|
||
export function angleBetween(p1: { x: number; y: number }, p2: { x: number; y: number }): number {
|
||
return (Math.atan2(p2.y - p1.y, p2.x - p1.x) * 180) / Math.PI;
|
||
}
|