feat: initial commit web-cad-neu with docker-compose, frontend and backend
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user