/** * 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; }