feat: initial commit web-cad-neu with docker-compose, frontend and backend

This commit is contained in:
2026-06-26 10:50:24 +02:00
commit 4ec76fe406
102 changed files with 25722 additions and 0 deletions
@@ -0,0 +1,133 @@
/**
* Group management for CAD elements.
* Groups are collections of element IDs that can be manipulated together.
*/
export interface ElementGroup {
id: string;
name: string;
elementIds: string[];
parentGroupId: string | null;
}
export class GroupManager {
private groups: Map<string, ElementGroup> = new Map();
private counter = 0;
/**
* Create a new group from a set of element IDs.
*/
createGroup(elementIds: string[], name?: string): ElementGroup {
const id = `grp_${Date.now()}_${this.counter++}`;
const group: ElementGroup = {
id,
name: name ?? `Group ${this.groups.size + 1}`,
elementIds: [...elementIds],
parentGroupId: null,
};
this.groups.set(id, group);
return group;
}
/**
* Remove a group (ungroup). Returns the element IDs that were in the group.
*/
ungroup(groupId: string): string[] {
const group = this.groups.get(groupId);
if (!group) return [];
this.groups.delete(groupId);
return group.elementIds;
}
/**
* Get a group by ID.
*/
getGroup(id: string): ElementGroup | undefined {
return this.groups.get(id);
}
/**
* Get all groups.
*/
getGroups(): ElementGroup[] {
return Array.from(this.groups.values());
}
/**
* Get all element IDs that belong to any group.
*/
getGroupedElements(): Set<string> {
const ids = new Set<string>();
for (const group of this.groups.values()) {
for (const id of group.elementIds) {
ids.add(id);
}
}
return ids;
}
/**
* Get the group ID for a given element ID, if any.
*/
getGroupForElement(elementId: string): string | null {
for (const [groupId, group] of this.groups) {
if (group.elementIds.includes(elementId)) {
return groupId;
}
}
return null;
}
/**
* Move all elements in a group by dx, dy.
* Returns the list of element IDs that need to be modified.
*/
moveGroup(groupId: string, _dx: number, _dy: number): string[] {
const group = this.groups.get(groupId);
if (!group) return [];
return [...group.elementIds];
}
/**
* Set parent group (nesting).
*/
setParent(groupId: string, parentGroupId: string | null): void {
const group = this.groups.get(groupId);
if (!group) return;
// Prevent circular references
if (parentGroupId) {
let current: string | null = parentGroupId;
while (current) {
if (current === groupId) return; // Would create a cycle
const parent = this.groups.get(current);
if (!parent) break;
current = parent.parentGroupId;
}
}
group.parentGroupId = parentGroupId;
}
/**
* Clear all groups.
*/
clear(): void {
this.groups.clear();
}
/**
* Serialize groups to JSON.
*/
toJSON(): ElementGroup[] {
return this.getGroups();
}
/**
* Restore groups from JSON.
*/
fromJSON(groups: ElementGroup[]): void {
this.groups.clear();
for (const group of groups) {
this.groups.set(group.id, group);
}
}
}
+350
View File
@@ -0,0 +1,350 @@
/**
* 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.
*/
export function offsetElement(el: CADElement, distance: number): CADElement {
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 * distance;
const oy = ny * distance;
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 + distance);
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 * distance, y: p.y + ny * distance };
});
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
if (el.type !== 'line' || !el.properties.x1 || !el.properties.x2) 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 {
if (el.type !== 'line' || !el.properties.x1 || !el.properties.x2) 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).
*/
function findIntersection(el1: CADElement, el2: CADElement): { x: number; y: number } | null {
const p1 = el1.properties;
const p2 = el2.properties;
if (p1.x1 === undefined || p1.y1 === undefined || p1.x2 === undefined || p1.y2 === undefined) return null;
if (p2.x1 === undefined || p2.y1 === undefined || p2.x2 === undefined || p2.y2 === undefined) return null;
// Line-line intersection
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 null;
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 };
}
/**
* 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;
}