From 74c47d7e77870ba022f23d3e05c38acc14105f7c Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sun, 30 Aug 2026 14:59:30 +0200 Subject: [PATCH] task(CAD-4): otrack computeTrackPoint with angle projection, divide/measure path point tools on defpoints in one undo transaction --- frontend/src/canvas/SnapEngine.ts | 41 ++++ .../src/plugins/builtin/core-modify/index.ts | 148 +++++++++++- frontend/tests/otrackDivide.test.ts | 222 ++++++++++++++++++ 3 files changed, 410 insertions(+), 1 deletion(-) create mode 100644 frontend/tests/otrackDivide.test.ts diff --git a/frontend/src/canvas/SnapEngine.ts b/frontend/src/canvas/SnapEngine.ts index 6cf9e29..57ad9cc 100644 --- a/frontend/src/canvas/SnapEngine.ts +++ b/frontend/src/canvas/SnapEngine.ts @@ -494,3 +494,44 @@ export class SnapEngine { export function fromOffset(base: { x: number; y: number }, dx: number, dy: number): { x: number; y: number } { return { x: base.x + dx, y: base.y + dy }; } + +// ──────────────────────────────────────────────────── +// Task CAD-4: OTRACK — Objekt-Fang-Tracking. computeTrackPoint +// projiziert den Cursor auf Tracking-Linien durch den Basispunkt +// (Default 0/90/180/270°, optional erweiterte Winkel) und liefert +// den nächstgelegenen Track-Punkt. Reine Funktion (UI-testbar). +// ─────────────────────────────────────────────────── + +export function computeTrackPoint( + base: { x: number; y: number }, + cursor: { x: number; y: number }, + angles?: number[], +): { x: number; y: number } | null { + const dx = cursor.x - base.x; + const dy = cursor.y - base.y; + if (dx === 0 && dy === 0) return null; + const angs = angles ?? [0, 90, 180, 270]; + let best: { x: number; y: number } | null = null; + let bestDist = Infinity; + for (const a of angs) { + const rad = (a * Math.PI) / 180; + const dxc = Math.cos(rad); + const dyc = Math.sin(rad); + let pt: { x: number; y: number }; + if (Math.abs(dxc) >= Math.abs(dyc)) { + if (dxc === 0) continue; + const t = dx / dxc; + pt = { x: cursor.x, y: base.y + t * dyc }; + } else { + if (dyc === 0) continue; + const t = dy / dyc; + pt = { x: base.x + t * dxc, y: cursor.y }; + } + const dist = Math.hypot(pt.x - cursor.x, pt.y - cursor.y); + if (dist < bestDist) { + bestDist = dist; + best = pt; + } + } + return best; +} diff --git a/frontend/src/plugins/builtin/core-modify/index.ts b/frontend/src/plugins/builtin/core-modify/index.ts index ef2afba..ea65cf9 100644 --- a/frontend/src/plugins/builtin/core-modify/index.ts +++ b/frontend/src/plugins/builtin/core-modify/index.ts @@ -1715,6 +1715,152 @@ export const offsetTool: ToolExtensionV2 = { }; /** V2-Plugin: Kern-Bearbeitungswerkzeuge. */ +// ────────────────────────────────────────────────────────── +// Task CAD-4: DIVIDE/MEASURE — Punkte entlang eines Pfads +// (Polyline) verteilen. DIVIDE: n gleiche Segmente → n+1 Punkte +// inkl. Enden. MEASURE: fester Abstand inkl. Start, Rest verworfen. +// Punkte = Konstruktionsmarker auf defpoints (1 Transaktion). +// ────────────────────────────────────────────────────────── + +/** + * Punkte entlang eines Polylinien-Pfads (Task CAD-4, rein). + * segments > 0: DIVIDE — n+1 gleichmäßige Punkte inkl. Enden. + * fixedSpacing > 0: MEASURE — Punkte bei 0, spacing, 2*spacing, … + * solange s ≤ Gesamtlänge; Rest am Ende wird verworfen. + */ +export function pointsAlongPath(pts: Pt[], segments: number, fixedSpacing?: number): Pt[] { + if (!Array.isArray(pts) || pts.length < 2) return []; + let total = 0; + for (let i = 0; i < pts.length - 1; i++) { + total += Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y); + } + if (total <= 0) return [...pts]; + const out: Pt[] = []; + if (segments > 0) { + for (let i = 0; i <= segments; i++) { + out.push(pointAlongPolyline(pts, (total * i) / segments)); + } + } else if (fixedSpacing && fixedSpacing > 0) { + for (let s = 0; s <= total + 1e-9; s += fixedSpacing) { + out.push(pointAlongPolyline(pts, s)); + } + } + return out; +} + +/** defpoints-Layer sicherstellen (CAD-4, lokal im core-modify). */ +function ensureDefpoints(ctx: FullToolContext): void { + const doc = ctx.doc as unknown as { + getLayer(id: string): unknown; + addLayer(l: unknown): void; + } | undefined; + if (!doc || doc.getLayer('defpoints')) return; + doc.addLayer({ + id: 'defpoints', name: 'defpoints', visible: true, locked: false, + color: '#8a8f98', lineType: 'dotted', transparency: 0.5, sortOrder: 999, parentId: null, + }); +} + +/** Pfad-Treffer validieren: muss polyline/polygon mit ≥2 Punkten sein. */ +function requirePathHit( + c: FullToolContext, + wx: number, + wy: number, +): { pts: Pt[]; path: CADElement } | null { + if (!c.selection) return null; + const hit = c.selection.hitTest(wx, wy, 8); + if (!hit || (hit.type !== 'polyline' && hit.type !== 'polygon')) return null; + const pts = (hit.properties as Record).points as Pt[] | undefined; + if (!pts || pts.length < 2) return null; + return { pts, path: hit }; +} + +/** Konstruktions-Marker-Punkt auf defpoints platzieren. */ +function makePathMarker(p: Pt): CADElement { + return { + id: uid('pathmark'), + type: 'circle', + layerId: 'defpoints', + x: p.x, + y: p.y, + width: 3, + height: 3, + properties: { radius: 1.5, point: true, construction: true, stroke: '#8a8f98', strokeWidth: 1 }, + } as unknown as CADElement; +} + +export const divideTool: ToolExtensionV2 = { + manifest: { + id: 'divide', + label: 'Teilen', + icon: '÷', + ribbonTab: 'canvas', + tags: ['pro'], + description: 'Pfad in n gleiche Segmente teilen (DIVIDE, Task CAD-4)', + }, + optionsSchema: [ + { key: 'count', label: 'Anzahl Segmente', type: 'number', min: 1, max: 100 }, + ], + handlers: { + down(e, ctx) { + const c = ctx as FullToolContext; + if (!c.doc) return; + const found = requirePathHit(c, e.world.x, e.world.y); + if (!found) { + ctx.setStatus('Teilen: Polyline als Pfad anklicken'); + return; + } + const segments = Math.max(1, (c.options.count as number | undefined) ?? 4); + ensureDefpoints(c); + const marks = pointsAlongPath(found.pts, segments); + c.doc.transact(() => { + for (const p of marks) c.doc!.addElement(makePathMarker(p)); + }); + ctx.setStatus(`Geteilt: ${marks.length} Punkte auf ${segments} Segmente`); + }, + cancel(ctx) { + ctx.setPreview?.(null); + ctx.setStatus('Teilen abgebrochen'); + }, + }, +}; + +export const measureTool: ToolExtensionV2 = { + manifest: { + id: 'measure', + label: 'Abmessen (Punkte)', + icon: '⋮', + ribbonTab: 'canvas', + tags: ['pro'], + description: 'Punkte im festen Abstand auf Pfad (MEASURE, Task CAD-4)', + }, + optionsSchema: [ + { key: 'spacing', label: 'Abstand', type: 'number', min: 1 }, + ], + handlers: { + down(e, ctx) { + const c = ctx as FullToolContext; + if (!c.doc) return; + const found = requirePathHit(c, e.world.x, e.world.y); + if (!found) { + ctx.setStatus('Abmessen: Polyline als Pfad anklicken'); + return; + } + const spacing = Math.max(1, (c.options.spacing as number | undefined) ?? 20); + ensureDefpoints(c); + const marks = pointsAlongPath(found.pts, 0, spacing); + c.doc.transact(() => { + for (const p of marks) c.doc!.addElement(makePathMarker(p)); + }); + ctx.setStatus(`Abgemessen: ${marks.length} Punkte alle ${spacing} Einheiten`); + }, + cancel(ctx) { + ctx.setPreview?.(null); + ctx.setStatus('Abmessen abgebrochen'); + }, + }, +}; + export const coreModifyPlugin: PluginV2 = { manifest: { id: 'core-modify', @@ -1725,5 +1871,5 @@ export const coreModifyPlugin: PluginV2 = { category: 'tools', enabledByDefault: true, }, - tools: [selectTool, hatchTool, moveTool, copyTool, rotateTool, scaleTool, mirrorTool, deleteTool, offsetTool, trimTool, extendTool, filletTool, arrayRectTool, arrayPolarTool, chamferTool, lengthenTool, joinTool, explodeTool, alignTool, polylineEditTool, stretchTool, breakTool, arrayPathTool], + tools: [selectTool, hatchTool, moveTool, copyTool, rotateTool, scaleTool, mirrorTool, deleteTool, offsetTool, trimTool, extendTool, filletTool, arrayRectTool, arrayPolarTool, chamferTool, lengthenTool, joinTool, explodeTool, alignTool, polylineEditTool, stretchTool, breakTool, arrayPathTool, divideTool, measureTool], }; diff --git a/frontend/tests/otrackDivide.test.ts b/frontend/tests/otrackDivide.test.ts new file mode 100644 index 0000000..2a0e44f --- /dev/null +++ b/frontend/tests/otrackDivide.test.ts @@ -0,0 +1,222 @@ +/** + * 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'); + }); +});