/** * Task CAD-4 – OTRACK + DIVIDE/MEASURE. * * OTRACK: Tracking-Vektoren von Fang-Punkten (horizontal/vertikal/ * Winkelmultiples), SnapEngine-Erweiterung. * DIVIDE: n gleichmaeßige Punkte auf einem Pfad. * MEASURE: Punkte im festen Abstand auf einem Pfad. */ import { describe, it, expect, beforeEach } from 'vitest'; import { SnapEngine, computeTrackPoint } from '../src/canvas/SnapEngine'; import { divideTool, measureTool, pointsAlongPath } from '../src/plugins/builtin/core-modify'; import { CADDocument } from '../src/kernel/document/CADDocument'; import { createInMemoryDoc } from './helpers/inMemoryDoc'; import type { CADElement, Pt } from '../src/types/cad.types'; function mkCtx(elements: CADElement[], options: Record = {}) { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); for (const el of elements) doc.addElement(el); const selectedIds = new Set(); const statuses: string[] = []; const ctx: any = { doc, options, setStatus: (m: string) => statuses.push(m), setPreview: (_el: CADElement | null) => {}, selection: { getIds: () => Array.from(selectedIds), setIds: (ids: string[]) => { selectedIds.clear(); for (const id of ids) selectedIds.add(id); }, hitTest: (x: number, y: number, _tol: number) => { for (const el of elements) { const p = el.properties as Record; if (Array.isArray(p.points)) { const pts = p.points as Pt[]; for (let i = 0; i < pts.length - 1; i++) { const dx = pts[i + 1].x - pts[i].x; const dy = pts[i + 1].y - pts[i].y; const len = Math.hypot(dx, dy) || 1; const t = Math.max(0, Math.min(1, ((x - pts[i].x) * dx + (y - pts[i].y) * dy) / (len * len))); const px = pts[i].x + t * dx; const py = pts[i].y + t * dy; if (Math.hypot(x - px, y - py) < 10) return el; } } if ( x >= el.x - el.width / 2 - 5 && x <= el.x + el.width / 2 + 5 && y >= el.y - el.height / 2 - 5 && y <= el.y + el.height / 2 + 5 ) return el; } return null; }, }, }; return { ctx, doc, selectedIds, statuses }; } function pe(x: number, y: number, mods: { shift?: boolean } = {}): { world: Pt; shift: boolean; ctrl: boolean } { return { world: { x, y }, shift: mods.shift ?? false, ctrl: false } as never; } function path(pts: Array<[number, number]>): CADElement { return { id: 'path-1', type: 'polyline', layerId: 'l', x: pts.reduce((s, p) => s + p[0], 0) / pts.length, y: pts.reduce((s, p) => s + p[1], 0) / pts.length, width: Math.max(...pts.map(p => p[0])) - Math.min(...pts.map(p => p[0])), height: Math.max(...pts.map(p => p[1])) - Math.min(...pts.map(p => p[1])), properties: { points: pts.map(([x, y]) => ({ x, y })) }, } as unknown as CADElement; } beforeEach(() => { divideTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never); measureTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never); }); // ─── OTRACK: computeTrackPoint ──────────────────────────────── describe('CAD-4: computeTrackPoint (OTRACK)', () => { it('horizontal von Basis (100,100) bei Cursor (200,90): Track-Punkt (200,100)', () => { const result = computeTrackPoint({ x: 100, y: 100 }, { x: 200, y: 90 }); expect(result).not.toBeNull(); expect(result!.x).toBeCloseTo(200, 6); expect(result!.y).toBeCloseTo(100, 6); }); it('vertikal von Basis (100,100) bei Cursor (90,200): Track-Punkt (100,200)', () => { const result = computeTrackPoint({ x: 100, y: 100 }, { x: 90, y: 200 }); expect(result).not.toBeNull(); expect(result!.x).toBeCloseTo(100, 6); expect(result!.y).toBeCloseTo(200, 6); }); it('45-Grad-Richtung wird verfolgt', () => { // Basis (0,0), Cursor (100,80): 45 Grad heißt y=x → Track (100,100) const result = computeTrackPoint({ x: 0, y: 0 }, { x: 100, y: 80 }, [0, 45, 90, 135, 180, 225, 270, 315]); expect(result).not.toBeNull(); expect(result!.x).toBeCloseTo(100, 6); expect(result!.y).toBeCloseTo(100, 6); }); it('Cursor genau auf der Basis: kein Track (null)', () => { expect(computeTrackPoint({ x: 100, y: 100 }, { x: 100, y: 100 })).toBeNull(); }); it('ohne Winkel-Option: nur 0/90/180/270 (Standard-Tracking)', () => { // 30-Grad-Cursor nahe horizontal: snappt auf horizontal const result = computeTrackPoint({ x: 0, y: 0 }, { x: 100, y: 15 }); expect(result).not.toBeNull(); expect(result!.x).toBeCloseTo(100, 6); expect(result!.y).toBeCloseTo(0, 6); }); }); // ─── pointsAlongPath (reine Funktion) ───────────────────────── describe('CAD-4: pointsAlongPath', () => { it('DIVIDE: 4 Segmente auf 100-Einheiten-Pfad → 5 Punkte (inkl. Enden)', () => { const pts = [{ x: 0, y: 0 }, { x: 100, y: 0 }]; const result = pointsAlongPath(pts, 4); expect(result).toHaveLength(5); expect(result[0]).toEqual({ x: 0, y: 0 }); expect(result[2]).toEqual({ x: 50, y: 0 }); expect(result[4]).toEqual({ x: 100, y: 0 }); }); it('MEASURE: Abstand 25 auf 100-Einheiten-Pfad → 5 Punkte', () => { const pts = [{ x: 0, y: 0 }, { x: 100, y: 0 }]; const result = pointsAlongPath(pts, 0, 25); expect(result).toHaveLength(5); expect(result[3]).toEqual({ x: 75, y: 0 }); }); it('MEASURE mit Rest: 30 auf 100 → 4 Punkte (Rest 10 verworfen)', () => { const pts = [{ x: 0, y: 0 }, { x: 100, y: 0 }]; const result = pointsAlongPath(pts, 0, 30); expect(result).toHaveLength(4); expect(result[3]).toEqual({ x: 90, y: 0 }); }); it('gebogener L-Pfad: Punkte folgen der Geometrie', () => { const pts = [{ x: 0, y: 0 }, { x: 50, y: 0 }, { x: 50, y: 50 }]; const result = pointsAlongPath(pts, 3); // 3 Segmente auf 100 Gesamtlänge → 4 Punkte expect(result).toHaveLength(4); expect(result[0]).toEqual({ x: 0, y: 0 }); expect(result[2].x).toBeCloseTo(50, 0); expect(result[3]).toEqual({ x: 50, y: 50 }); }); }); // ─── divideTool ──────────────────────────────────────────── describe('CAD-4: divideTool', () => { it('Klick auf Pfad + count=4 platziert 5 Punkte', () => { const p = path([[0, 0], [100, 0]]); const { ctx, doc } = mkCtx([p], { count: 4 }); divideTool.handlers.down!(pe(50, 0), ctx); const points = doc.getAllElements().filter((e) => e.type === 'circle' && e.properties.point); expect(points).toHaveLength(5); }); it('Punkte liegen auf defpoints-Layer mit construction:true', () => { const p = path([[0, 0], [100, 0]]); const { ctx, doc } = mkCtx([p], { count: 2 }); divideTool.handlers.down!(pe(50, 0), ctx); const points = doc.getAllElements().filter((e) => e.properties.construction); expect(points.length).toBeGreaterThanOrEqual(3); expect(points.every((e) => e.layerId === 'defpoints')).toBe(true); }); it('Klick ohne Pfad-Treffer: kein Element, Status erklaert', () => { const { ctx, doc, statuses } = mkCtx([], { count: 2 }); divideTool.handlers.down!(pe(500, 500), ctx); expect(doc.getAllElements()).toHaveLength(0); expect(statuses[statuses.length - 1]).toContain('Polyline'); }); it('ALLES in EINER Transaktion (1 Undo entfernt alle Punkte)', () => { const p = path([[0, 0], [100, 0]]); const { ctx, doc } = mkCtx([p], { count: 4 }); divideTool.handlers.down!(pe(50, 0), ctx); expect(doc.getAllElements().length).toBeGreaterThan(1); doc.undo(); expect(doc.getAllElements()).toHaveLength(1); // Nur der Pfad bleibt }); }); // ─── measureTool ──────────────────────────────────────────── describe('CAD-4: measureTool', () => { it('Klick auf Pfad + Abstand 25 platziert 5 Punkte', () => { const p = path([[0, 0], [100, 0]]); const { ctx, doc } = mkCtx([p], { spacing: 25 }); measureTool.handlers.down!(pe(50, 0), ctx); const points = doc.getAllElements().filter((e) => e.type === 'circle' && e.properties.point); expect(points).toHaveLength(5); }); it('count=0 (keine Segmente) mit spacing nutzt Abstand', () => { const p = path([[0, 0], [100, 0]]); const { ctx, doc } = mkCtx([p], { spacing: 20 }); measureTool.handlers.down!(pe(50, 0), ctx); const points = doc.getAllElements().filter((e) => e.type === 'circle' && e.properties.point); expect(points).toHaveLength(6); // 0, 20, 40, 60, 80, 100 }); }); // ─── Manifeste ────────────────────────────────────────────── describe('CAD-4: Manifeste', () => { it('ids und Optionen korrekt', () => { expect(divideTool.manifest.id).toBe('divide'); expect(measureTool.manifest.id).toBe('measure'); const divKeys = (divideTool.optionsSchema ?? []).map((f) => f.key); expect(divKeys).toContain('count'); const meaKeys = (measureTool.optionsSchema ?? []).map((f) => f.key); expect(meaKeys).toContain('spacing'); }); });