2026-06-26 10:50:24 +02:00
|
|
|
import type { CADElement } from '../types/cad.types';
|
|
|
|
|
import type { SnapPoint } from './RenderEngine';
|
|
|
|
|
|
|
|
|
|
export type SnapMode =
|
|
|
|
|
| 'endpoint' | 'midpoint' | 'center' | 'intersection'
|
|
|
|
|
| 'nearest' | 'perpendicular' | 'tangent' | 'quadrant'
|
|
|
|
|
| 'grid' | 'none';
|
|
|
|
|
|
|
|
|
|
export interface SnapConfig {
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
modes: Set<SnapMode>;
|
|
|
|
|
tolerance: number; // world units
|
|
|
|
|
gridSpacing: number;
|
|
|
|
|
polarEnabled: boolean;
|
|
|
|
|
polarAngles: number[]; // angles in degrees for polar tracking
|
|
|
|
|
polarTolerance: number; // angular tolerance in degrees
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface SnapResult {
|
|
|
|
|
point: SnapPoint | null;
|
|
|
|
|
preview: SnapPoint[]; // nearby candidates for visual feedback
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class SnapEngine {
|
|
|
|
|
private config: SnapConfig;
|
|
|
|
|
private elements: CADElement[] = [];
|
|
|
|
|
|
|
|
|
|
constructor(config?: Partial<SnapConfig>) {
|
|
|
|
|
this.config = {
|
|
|
|
|
enabled: true,
|
|
|
|
|
modes: new Set<SnapMode>(['endpoint', 'midpoint', 'center', 'intersection', 'nearest']),
|
|
|
|
|
tolerance: 10,
|
|
|
|
|
gridSpacing: 20,
|
|
|
|
|
polarEnabled: false,
|
|
|
|
|
polarAngles: [0, 30, 45, 60, 90, 120, 135, 150, 180, 210, 225, 240, 270, 300, 315, 330],
|
|
|
|
|
polarTolerance: 5,
|
|
|
|
|
...config,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setElements(elements: CADElement[]): void {
|
|
|
|
|
this.elements = elements;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setConfig(config: Partial<SnapConfig>): void {
|
|
|
|
|
this.config = { ...this.config, ...config };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getConfig(): SnapConfig {
|
|
|
|
|
return { ...this.config, modes: new Set(this.config.modes) };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
toggleMode(mode: SnapMode): void {
|
|
|
|
|
if (this.config.modes.has(mode)) {
|
|
|
|
|
this.config.modes.delete(mode);
|
|
|
|
|
} else {
|
|
|
|
|
this.config.modes.add(mode);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Find the best snap point near the given world coordinates.
|
|
|
|
|
* Returns null if no snap point is within tolerance.
|
|
|
|
|
* If refPoint is provided and polar tracking is enabled, snaps to polar angles.
|
|
|
|
|
*/
|
|
|
|
|
snap(worldX: number, worldY: number, refPoint?: { x: number; y: number }): SnapResult {
|
|
|
|
|
if (!this.config.enabled || this.config.modes.size === 0) {
|
|
|
|
|
return { point: null, preview: [] };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Polar tracking: if we have a reference point, check polar angles first
|
|
|
|
|
if (this.config.polarEnabled && refPoint) {
|
|
|
|
|
const polarResult = this.polarSnap(worldX, worldY, refPoint);
|
|
|
|
|
if (polarResult) {
|
|
|
|
|
return { point: polarResult, preview: [polarResult] };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const candidates: SnapPoint[] = [];
|
|
|
|
|
const tol = this.config.tolerance;
|
|
|
|
|
|
|
|
|
|
// Grid snap (lowest priority)
|
|
|
|
|
if (this.config.modes.has('grid')) {
|
|
|
|
|
const gs = this.config.gridSpacing;
|
|
|
|
|
const gx = Math.round(worldX / gs) * gs;
|
|
|
|
|
const gy = Math.round(worldY / gs) * gs;
|
|
|
|
|
const dist = Math.sqrt((worldX - gx) ** 2 + (worldY - gy) ** 2);
|
|
|
|
|
if (dist < tol) {
|
2026-07-04 16:43:55 +02:00
|
|
|
candidates.push({ x: gx, y: gy, type: 'grid' });
|
2026-06-26 10:50:24 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Element-based snaps
|
|
|
|
|
for (const el of this.elements) {
|
|
|
|
|
if (this.config.modes.has('endpoint')) {
|
|
|
|
|
this.collectEndpoints(el, worldX, worldY, tol, candidates);
|
|
|
|
|
}
|
|
|
|
|
if (this.config.modes.has('midpoint')) {
|
|
|
|
|
this.collectMidpoints(el, worldX, worldY, tol, candidates);
|
|
|
|
|
}
|
|
|
|
|
if (this.config.modes.has('center')) {
|
|
|
|
|
this.collectCenters(el, worldX, worldY, tol, candidates);
|
|
|
|
|
}
|
|
|
|
|
if (this.config.modes.has('nearest')) {
|
|
|
|
|
this.collectNearest(el, worldX, worldY, tol, candidates);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Intersection snap (between pairs)
|
|
|
|
|
if (this.config.modes.has('intersection')) {
|
|
|
|
|
this.collectIntersections(worldX, worldY, tol, candidates);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (candidates.length === 0) {
|
|
|
|
|
return { point: null, preview: [] };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Sort by distance, pick closest
|
|
|
|
|
candidates.sort((a, b) => {
|
|
|
|
|
const da = (a.x - worldX) ** 2 + (a.y - worldY) ** 2;
|
|
|
|
|
const db = (b.x - worldX) ** 2 + (b.y - worldY) ** 2;
|
|
|
|
|
return da - db;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Priority: endpoint > intersection > center > midpoint > nearest > grid
|
|
|
|
|
const priority: Record<string, number> = {
|
|
|
|
|
endpoint: 0, intersection: 1, center: 2, midpoint: 3, nearest: 4, grid: 5,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Find best within tolerance — prefer higher priority if distances are close
|
|
|
|
|
const best = candidates[0];
|
|
|
|
|
const closeOnes = candidates.filter(c => {
|
|
|
|
|
const d = Math.sqrt((c.x - worldX) ** 2 + (c.y - worldY) ** 2);
|
|
|
|
|
return d < tol * 1.5;
|
|
|
|
|
});
|
|
|
|
|
closeOnes.sort((a, b) => {
|
|
|
|
|
const pa = priority[a.type] ?? 99;
|
|
|
|
|
const pb = priority[b.type] ?? 99;
|
|
|
|
|
if (pa !== pb) return pa - pb;
|
|
|
|
|
const da = (a.x - worldX) ** 2 + (a.y - worldY) ** 2;
|
|
|
|
|
const db = (b.x - worldX) ** 2 + (b.y - worldY) ** 2;
|
|
|
|
|
return da - db;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
point: closeOnes[0] || best,
|
|
|
|
|
preview: candidates.slice(0, 10),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private collectEndpoints(el: CADElement, wx: number, wy: number, tol: number, out: SnapPoint[]): void {
|
|
|
|
|
const p = el.properties;
|
|
|
|
|
const check = (x: number, y: number) => {
|
|
|
|
|
const d = Math.sqrt((wx - x) ** 2 + (wy - y) ** 2);
|
|
|
|
|
if (d < tol) out.push({ x, y, type: 'endpoint' });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
switch (el.type) {
|
|
|
|
|
case 'line':
|
|
|
|
|
check(p.x1 ?? el.x, p.y1 ?? el.y);
|
|
|
|
|
check(p.x2 ?? el.x + el.width, p.y2 ?? el.y + el.height);
|
|
|
|
|
break;
|
|
|
|
|
case 'polyline':
|
|
|
|
|
case 'polygon': {
|
|
|
|
|
const pts = p.points || [];
|
|
|
|
|
for (const pt of pts) check(pt.x, pt.y);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'rect':
|
|
|
|
|
check(el.x - el.width / 2, el.y - el.height / 2);
|
|
|
|
|
check(el.x + el.width / 2, el.y - el.height / 2);
|
|
|
|
|
check(el.x - el.width / 2, el.y + el.height / 2);
|
|
|
|
|
check(el.x + el.width / 2, el.y + el.height / 2);
|
|
|
|
|
break;
|
|
|
|
|
case 'arc': {
|
|
|
|
|
const r = p.radius || el.width / 2;
|
|
|
|
|
const sa = (p.startAngle || 0) * Math.PI / 180;
|
|
|
|
|
const ea = (p.endAngle || 360) * Math.PI / 180;
|
|
|
|
|
check(el.x + r * Math.cos(sa), el.y + r * Math.sin(sa));
|
|
|
|
|
check(el.x + r * Math.cos(ea), el.y + r * Math.sin(ea));
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private collectMidpoints(el: CADElement, wx: number, wy: number, tol: number, out: SnapPoint[]): void {
|
|
|
|
|
const p = el.properties;
|
|
|
|
|
const check = (x: number, y: number) => {
|
|
|
|
|
const d = Math.sqrt((wx - x) ** 2 + (wy - y) ** 2);
|
|
|
|
|
if (d < tol) out.push({ x, y, type: 'midpoint' });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
switch (el.type) {
|
|
|
|
|
case 'line': {
|
|
|
|
|
const x1 = p.x1 ?? el.x, y1 = p.y1 ?? el.y;
|
|
|
|
|
const x2 = p.x2 ?? el.x + el.width, y2 = p.y2 ?? el.y + el.height;
|
|
|
|
|
check((x1 + x2) / 2, (y1 + y2) / 2);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'polyline':
|
|
|
|
|
case 'polygon': {
|
|
|
|
|
const pts = p.points || [];
|
|
|
|
|
for (let i = 0; i < pts.length - 1; i++) {
|
|
|
|
|
check((pts[i].x + pts[i + 1].x) / 2, (pts[i].y + pts[i + 1].y) / 2);
|
|
|
|
|
}
|
|
|
|
|
if (el.type === 'polygon' && pts.length > 2) {
|
|
|
|
|
check((pts[pts.length - 1].x + pts[0].x) / 2, (pts[pts.length - 1].y + pts[0].y) / 2);
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'rect':
|
|
|
|
|
check(el.x, el.y - el.height / 2);
|
|
|
|
|
check(el.x, el.y + el.height / 2);
|
|
|
|
|
check(el.x - el.width / 2, el.y);
|
|
|
|
|
check(el.x + el.width / 2, el.y);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private collectCenters(el: CADElement, wx: number, wy: number, tol: number, out: SnapPoint[]): void {
|
|
|
|
|
const check = (x: number, y: number) => {
|
|
|
|
|
const d = Math.sqrt((wx - x) ** 2 + (wy - y) ** 2);
|
|
|
|
|
if (d < tol) out.push({ x, y, type: 'center' });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
switch (el.type) {
|
|
|
|
|
case 'circle':
|
|
|
|
|
case 'arc':
|
|
|
|
|
check(el.x, el.y);
|
|
|
|
|
break;
|
|
|
|
|
case 'rect':
|
|
|
|
|
check(el.x, el.y);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private collectNearest(el: CADElement, wx: number, wy: number, tol: number, out: SnapPoint[]): void {
|
|
|
|
|
const p = el.properties;
|
|
|
|
|
const check = (x: number, y: number) => {
|
|
|
|
|
const d = Math.sqrt((wx - x) ** 2 + (wy - y) ** 2);
|
|
|
|
|
if (d < tol) out.push({ x, y, type: 'nearest' });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
switch (el.type) {
|
|
|
|
|
case 'line': {
|
|
|
|
|
const x1 = p.x1 ?? el.x, y1 = p.y1 ?? el.y;
|
|
|
|
|
const x2 = p.x2 ?? el.x + el.width, y2 = p.y2 ?? el.y + el.height;
|
|
|
|
|
const np = this.nearestOnSegment(wx, wy, x1, y1, x2, y2);
|
|
|
|
|
check(np.x, np.y);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'circle': {
|
|
|
|
|
const r = p.radius || el.width / 2;
|
|
|
|
|
const d = Math.sqrt((wx - el.x) ** 2 + (wy - el.y) ** 2);
|
|
|
|
|
if (d > 0) {
|
|
|
|
|
check(el.x + r * (wx - el.x) / d, el.y + r * (wy - el.y) / d);
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'polyline':
|
|
|
|
|
case 'polygon': {
|
|
|
|
|
const pts = p.points || [];
|
|
|
|
|
for (let i = 0; i < pts.length - 1; i++) {
|
|
|
|
|
const np = this.nearestOnSegment(wx, wy, pts[i].x, pts[i].y, pts[i + 1].x, pts[i + 1].y);
|
|
|
|
|
check(np.x, np.y);
|
|
|
|
|
}
|
|
|
|
|
if (el.type === 'polygon' && pts.length > 2) {
|
|
|
|
|
const np = this.nearestOnSegment(wx, wy, pts[pts.length - 1].x, pts[pts.length - 1].y, pts[0].x, pts[0].y);
|
|
|
|
|
check(np.x, np.y);
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private collectIntersections(wx: number, wy: number, tol: number, out: SnapPoint[]): void {
|
|
|
|
|
// Check pairs of elements near the cursor
|
|
|
|
|
const nearby = this.elements.filter(el => {
|
|
|
|
|
const halfW = el.width / 2 + tol;
|
|
|
|
|
const halfH = el.height / 2 + tol;
|
|
|
|
|
return Math.abs(wx - el.x) < halfW && Math.abs(wy - el.y) < halfH;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < nearby.length; i++) {
|
|
|
|
|
for (let j = i + 1; j < nearby.length; j++) {
|
|
|
|
|
const pts = this.findIntersection(nearby[i], nearby[j]);
|
|
|
|
|
for (const pt of pts) {
|
|
|
|
|
const d = Math.sqrt((wx - pt.x) ** 2 + (wy - pt.y) ** 2);
|
|
|
|
|
if (d < tol) out.push({ x: pt.x, y: pt.y, type: 'intersection' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private findIntersection(a: CADElement, b: CADElement): Array<{ x: number; y: number }> {
|
|
|
|
|
// Get line segments from both elements
|
|
|
|
|
const segsA = this.getElementSegments(a);
|
|
|
|
|
const segsB = this.getElementSegments(b);
|
|
|
|
|
const results: Array<{ x: number; y: number }> = [];
|
|
|
|
|
|
|
|
|
|
for (const sa of segsA) {
|
|
|
|
|
for (const sb of segsB) {
|
|
|
|
|
const pt = this.segmentIntersection(sa.x1, sa.y1, sa.x2, sa.y2, sb.x1, sb.y1, sb.x2, sb.y2);
|
|
|
|
|
if (pt) results.push(pt);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return results;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getElementSegments(el: CADElement): Array<{ x1: number; y1: number; x2: number; y2: number }> {
|
|
|
|
|
const p = el.properties;
|
|
|
|
|
switch (el.type) {
|
|
|
|
|
case 'line':
|
|
|
|
|
return [{
|
|
|
|
|
x1: p.x1 ?? el.x, y1: p.y1 ?? el.y,
|
|
|
|
|
x2: p.x2 ?? el.x + el.width, y2: p.y2 ?? el.y + el.height,
|
|
|
|
|
}];
|
|
|
|
|
case 'rect': {
|
|
|
|
|
const hw = el.width / 2, hh = el.height / 2;
|
|
|
|
|
return [
|
|
|
|
|
{ x1: el.x - hw, y1: el.y - hh, x2: el.x + hw, y2: el.y - hh },
|
|
|
|
|
{ x1: el.x + hw, y1: el.y - hh, x2: el.x + hw, y2: el.y + hh },
|
|
|
|
|
{ x1: el.x + hw, y1: el.y + hh, x2: el.x - hw, y2: el.y + hh },
|
|
|
|
|
{ x1: el.x - hw, y1: el.y + hh, x2: el.x - hw, y2: el.y - hh },
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
case 'polyline':
|
|
|
|
|
case 'polygon': {
|
|
|
|
|
const pts = p.points || [];
|
|
|
|
|
const segs: Array<{ x1: number; y1: number; x2: number; y2: number }> = [];
|
|
|
|
|
for (let i = 0; i < pts.length - 1; i++) {
|
|
|
|
|
segs.push({ x1: pts[i].x, y1: pts[i].y, x2: pts[i + 1].x, y2: pts[i + 1].y });
|
|
|
|
|
}
|
|
|
|
|
if (el.type === 'polygon' && pts.length > 2) {
|
|
|
|
|
segs.push({ x1: pts[pts.length - 1].x, y1: pts[pts.length - 1].y, x2: pts[0].x, y2: pts[0].y });
|
|
|
|
|
}
|
|
|
|
|
return segs;
|
|
|
|
|
}
|
|
|
|
|
default:
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private segmentIntersection(
|
|
|
|
|
x1: number, y1: number, x2: number, y2: number,
|
|
|
|
|
x3: number, y3: number, x4: number, y4: number,
|
|
|
|
|
): { x: number; y: number } | null {
|
|
|
|
|
const denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
|
|
|
|
|
if (Math.abs(denom) < 1e-10) return null;
|
|
|
|
|
const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom;
|
|
|
|
|
const u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom;
|
|
|
|
|
if (t >= 0 && t <= 1 && u >= 0 && u <= 1) {
|
|
|
|
|
return { x: x1 + t * (x2 - x1), y: y1 + t * (y2 - y1) };
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private nearestOnSegment(px: number, py: number, x1: number, y1: number, x2: number, y2: number): { x: number; y: number } {
|
|
|
|
|
const dx = x2 - x1;
|
|
|
|
|
const dy = y2 - y1;
|
|
|
|
|
const lenSq = dx * dx + dy * dy;
|
|
|
|
|
if (lenSq === 0) return { x: x1, y: y1 };
|
|
|
|
|
let t = ((px - x1) * dx + (py - y1) * dy) / lenSq;
|
|
|
|
|
t = Math.max(0, Math.min(1, t));
|
|
|
|
|
return { x: x1 + t * dx, y: y1 + t * dy };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Polar tracking: snap cursor to the nearest polar angle from a reference point.
|
|
|
|
|
* Returns a SnapPoint if the cursor is close to a polar angle, or null.
|
|
|
|
|
*/
|
|
|
|
|
private polarSnap(worldX: number, worldY: number, refPoint: { x: number; y: number }): SnapPoint | null {
|
|
|
|
|
const dx = worldX - refPoint.x;
|
|
|
|
|
const dy = worldY - refPoint.y;
|
|
|
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
|
if (dist < 1) return null; // too close to reference point
|
|
|
|
|
|
|
|
|
|
const cursorAngle = (Math.atan2(dy, dx) * 180) / Math.PI;
|
|
|
|
|
const normalizedCursor = ((cursorAngle % 360) + 360) % 360;
|
|
|
|
|
|
|
|
|
|
// Find closest polar angle
|
|
|
|
|
let bestAngle: number | null = null;
|
|
|
|
|
let bestDiff = Infinity;
|
|
|
|
|
for (const angle of this.config.polarAngles) {
|
|
|
|
|
let diff = Math.abs(normalizedCursor - angle);
|
|
|
|
|
if (diff > 180) diff = 360 - diff;
|
|
|
|
|
if (diff < bestDiff) {
|
|
|
|
|
bestDiff = diff;
|
|
|
|
|
bestAngle = angle;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (bestAngle === null || bestDiff > this.config.polarTolerance) return null;
|
|
|
|
|
|
|
|
|
|
// Project cursor position onto the polar angle line at the same distance
|
|
|
|
|
const rad = (bestAngle * Math.PI) / 180;
|
|
|
|
|
const snapX = refPoint.x + dist * Math.cos(rad);
|
|
|
|
|
const snapY = refPoint.y + dist * Math.sin(rad);
|
|
|
|
|
|
|
|
|
|
return { x: snapX, y: snapY, type: 'nearest' };
|
|
|
|
|
}
|
|
|
|
|
}
|