Files
web-cad/frontend/src/tools/modification/geometry.ts
T

524 lines
17 KiB
TypeScript
Raw Normal View History

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