/** * SnapEngine Tests – Snapping modes, grid snap, polar tracking */ import { describe, it, expect, beforeEach } from 'vitest'; import { SnapEngine } from '../src/canvas/SnapEngine'; import type { SnapMode, SnapConfig } from '../src/canvas/SnapEngine'; import type { CADElement } from '../src/types/cad.types'; function makeLine(id: string, x1: number, y1: number, x2: number, y2: number): CADElement { const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; return { id, type: 'line', layerId: 'layer-1', x: cx, y: cy, width: Math.abs(x2 - x1), height: Math.abs(y2 - y1), properties: { x1, y1, x2, y2 }, }; } function makeCircle(id: string, cx: number, cy: number, r: number): CADElement { return { id, type: 'circle', layerId: 'layer-1', x: cx, y: cy, width: r * 2, height: r * 2, properties: { radius: r }, }; } function makeRect(id: string, cx: number, cy: number, w: number, h: number): CADElement { return { id, type: 'rect', layerId: 'layer-1', x: cx, y: cy, width: w, height: h, properties: {}, }; } describe('SnapEngine', () => { let engine: SnapEngine; beforeEach(() => { engine = new SnapEngine(); }); describe('constructor & config', () => { it('should have default config with enabled=true', () => { const cfg = engine.getConfig(); expect(cfg.enabled).toBe(true); }); it('should have default modes including endpoint, midpoint, center, intersection, nearest', () => { const cfg = engine.getConfig(); expect(cfg.modes.has('endpoint')).toBe(true); expect(cfg.modes.has('midpoint')).toBe(true); expect(cfg.modes.has('center')).toBe(true); expect(cfg.modes.has('intersection')).toBe(true); expect(cfg.modes.has('nearest')).toBe(true); }); it('should accept custom config overrides', () => { const eng = new SnapEngine({ tolerance: 25, gridSpacing: 50 }); const cfg = eng.getConfig(); expect(cfg.tolerance).toBe(25); expect(cfg.gridSpacing).toBe(50); }); }); describe('setConfig & toggleMode', () => { it('should update config partially', () => { engine.setConfig({ tolerance: 30 }); expect(engine.getConfig().tolerance).toBe(30); }); it('should toggle mode on/off', () => { expect(engine.getConfig().modes.has('endpoint')).toBe(true); engine.toggleMode('endpoint'); expect(engine.getConfig().modes.has('endpoint')).toBe(false); engine.toggleMode('endpoint'); expect(engine.getConfig().modes.has('endpoint')).toBe(true); }); it('should toggle grid mode on', () => { engine.toggleMode('grid'); expect(engine.getConfig().modes.has('grid')).toBe(true); }); }); describe('snap – disabled', () => { it('should return null point when disabled', () => { engine.setConfig({ enabled: false }); engine.setElements([makeLine('l1', 0, 0, 100, 0)]); const result = engine.snap(0, 0); expect(result.point).toBeNull(); expect(result.preview).toEqual([]); }); it('should return null point when no modes are active', () => { const eng = new SnapEngine({ modes: new Set(), }); eng.setElements([makeLine('l1', 0, 0, 100, 0)]); const result = eng.snap(0, 0); expect(result.point).toBeNull(); }); }); describe('snap – endpoint mode', () => { it('should snap to line endpoint when within tolerance', () => { engine.setElements([makeLine('l1', 0, 0, 100, 0)]); const result = engine.snap(2, 2); expect(result.point).not.toBeNull(); expect(result.point!.x).toBe(0); expect(result.point!.y).toBe(0); expect(result.point!.type).toBe('endpoint'); }); it('should snap to the second endpoint of a line', () => { engine.setElements([makeLine('l1', 0, 0, 100, 0)]); const result = engine.snap(98, 1); expect(result.point).not.toBeNull(); expect(result.point!.x).toBe(100); expect(result.point!.y).toBe(0); expect(result.point!.type).toBe('endpoint'); }); it('should not snap when outside tolerance', () => { engine.setElements([makeLine('l1', 0, 0, 100, 0)]); engine.setConfig({ tolerance: 5 }); const result = engine.snap(50, 20); expect(result.point).toBeNull(); }); it('should snap to rect corners', () => { engine.setElements([makeRect('r1', 50, 50, 40, 40)]); const result = engine.snap(32, 32); expect(result.point).not.toBeNull(); expect(result.point!.x).toBe(30); expect(result.point!.y).toBe(30); expect(result.point!.type).toBe('endpoint'); }); }); describe('snap – midpoint mode', () => { it('should snap to line midpoint', () => { engine.setConfig({ modes: new Set(['midpoint']) }); engine.setElements([makeLine('l1', 0, 0, 100, 0)]); const result = engine.snap(50, 2); expect(result.point).not.toBeNull(); expect(result.point!.x).toBe(50); expect(result.point!.y).toBe(0); expect(result.point!.type).toBe('midpoint'); }); it('should snap to rect edge midpoints', () => { engine.setConfig({ modes: new Set(['midpoint']) }); engine.setElements([makeRect('r1', 50, 50, 40, 40)]); const result = engine.snap(50, 31); expect(result.point).not.toBeNull(); expect(result.point!.x).toBe(50); expect(result.point!.y).toBe(30); expect(result.point!.type).toBe('midpoint'); }); }); describe('snap – center mode', () => { it('should snap to circle center', () => { engine.setConfig({ modes: new Set(['center']) }); engine.setElements([makeCircle('c1', 50, 50, 30)]); const result = engine.snap(52, 48); expect(result.point).not.toBeNull(); expect(result.point!.x).toBe(50); expect(result.point!.y).toBe(50); expect(result.point!.type).toBe('center'); }); it('should snap to rect center', () => { engine.setConfig({ modes: new Set(['center']) }); engine.setElements([makeRect('r1', 50, 50, 40, 40)]); const result = engine.snap(48, 52); expect(result.point).not.toBeNull(); expect(result.point!.x).toBe(50); expect(result.point!.y).toBe(50); expect(result.point!.type).toBe('center'); }); }); describe('snap – grid mode', () => { it('should snap to nearest grid point', () => { engine.setConfig({ modes: new Set(['grid']), gridSpacing: 20, tolerance: 10, }); const result = engine.snap(18, 2); expect(result.point).not.toBeNull(); expect(result.point!.x).toBe(20); expect(result.point!.y).toBe(0); }); it('should snap to grid point at origin', () => { engine.setConfig({ modes: new Set(['grid']), gridSpacing: 20, tolerance: 10, }); const result = engine.snap(3, 3); expect(result.point).not.toBeNull(); expect(result.point!.x).toBe(0); expect(result.point!.y).toBe(0); }); it('should not snap when too far from grid point', () => { engine.setConfig({ modes: new Set(['grid']), gridSpacing: 20, tolerance: 5, }); const result = engine.snap(13, 13); expect(result.point).toBeNull(); }); }); describe('snap – nearest mode', () => { it('should snap to nearest point on a line', () => { engine.setConfig({ modes: new Set(['nearest']) }); engine.setElements([makeLine('l1', 0, 0, 100, 0)]); const result = engine.snap(50, 5); expect(result.point).not.toBeNull(); expect(result.point!.x).toBe(50); expect(result.point!.y).toBe(0); expect(result.point!.type).toBe('nearest'); }); }); describe('snap – intersection mode', () => { it('should snap to intersection of two lines', () => { engine.setConfig({ modes: new Set(['intersection']), tolerance: 10 }); engine.setElements([ makeLine('l1', 0, 0, 100, 100), makeLine('l2', 0, 100, 100, 0), ]); const result = engine.snap(52, 50); expect(result.point).not.toBeNull(); expect(result.point!.x).toBeCloseTo(50, 0); expect(result.point!.y).toBeCloseTo(50, 0); expect(result.point!.type).toBe('intersection'); }); }); describe('snap – priority', () => { it('should prefer endpoint over grid when both are in range', () => { engine.setConfig({ modes: new Set(['endpoint', 'grid']), gridSpacing: 20, tolerance: 10, }); engine.setElements([makeLine('l1', 0, 0, 100, 0)]); const result = engine.snap(2, 2); expect(result.point).not.toBeNull(); expect(result.point!.type).toBe('endpoint'); }); }); describe('snap – preview', () => { it('should return preview candidates', () => { engine.setElements([makeLine('l1', 0, 0, 100, 0)]); const result = engine.snap(2, 2); expect(result.preview.length).toBeGreaterThan(0); }); }); describe('polar tracking', () => { it('should snap to 0-degree polar angle from reference point', () => { engine.setConfig({ polarEnabled: true, polarAngles: [0, 90, 180, 270], polarTolerance: 5, modes: new Set(['nearest']), }); // Reference at (0,0), cursor near 0 degrees (horizontal right) at distance ~100 const result = engine.snap(100, 5, { x: 0, y: 0 }); expect(result.point).not.toBeNull(); if (result.point) { expect(result.point.y).toBeCloseTo(0, 0); } }); it('should snap to 90-degree polar angle from reference point', () => { engine.setConfig({ polarEnabled: true, polarAngles: [0, 90, 180, 270], polarTolerance: 5, modes: new Set(['nearest']), }); // Reference at (0,0), cursor near 90 degrees (down) at distance ~100 const result = engine.snap(5, 100, { x: 0, y: 0 }); expect(result.point).not.toBeNull(); if (result.point) { expect(result.point.x).toBeCloseTo(0, 0); } }); it('should not polar-snap when polarEnabled is false', () => { engine.setConfig({ polarEnabled: false, modes: new Set(['nearest']), }); engine.setElements([makeLine('l1', 0, 0, 200, 0)]); const result = engine.snap(100, 5, { x: 0, y: 0 }); // Should snap to nearest on line, not polar if (result.point) { expect(result.point.y).toBe(0); } }); it('should not polar-snap when cursor too close to reference', () => { engine.setConfig({ polarEnabled: true, polarAngles: [0], polarTolerance: 5, modes: new Set(['nearest']), }); const result = engine.snap(0.5, 0.5, { x: 0, y: 0 }); // dist < 1, so no polar snap; no elements either, so null expect(result.point).toBeNull(); }); }); });