Files
web-cad/frontend/src/tools/modification/geometry.ts
T
2026-08-26 13:51:02 +02:00

524 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 02 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;
}