diff --git a/frontend/src/plugins/builtin/core-drawing/index.ts b/frontend/src/plugins/builtin/core-drawing/index.ts index 65cd217..62677ab 100644 --- a/frontend/src/plugins/builtin/core-drawing/index.ts +++ b/frontend/src/plugins/builtin/core-drawing/index.ts @@ -558,15 +558,119 @@ export const ellipseTool: ToolExtensionV2 = { }, }; +// ───────────────────────────────────────────────────────────── +// Task D2: Spline (CV-basiert) — Kontrollpunkt-Klick-Sequenz, +// ENTER committet ab 3 CVs. Catmull-Rom-Interpolation (Kurve läuft +// DURCH alle CVs, schwingt an Ecken = echte Glättung). +// ───────────────────────────────────────────────────────────── + +let splinePoints: Pt[] = []; + +/** + * Catmull-Rom-Spline-Interpolation: Für n CVs werden n-1 Segmente + * mit je SEG Samples erzeugt. Tangenten aus Nachbarn (Endpunkte + * geklemmt). Ergibt eine glatte Kurve durch alle CVs. + */ +function catmullRom(cvs: Pt[], seg = 16): Pt[] { + if (cvs.length < 2) return [...cvs]; + const out: Pt[] = []; + const n = cvs.length; + for (let i = 0; i < n - 1; i++) { + const p0 = cvs[Math.max(0, i - 1)]; + const p1 = cvs[i]; + const p2 = cvs[i + 1]; + const p3 = cvs[Math.min(n - 1, i + 2)]; + for (let s = (i === 0 ? 0 : 1); s <= seg; s++) { + const t = s / seg; + const t2 = t * t; + const t3 = t2 * t; + const x = 0.5 * ((2 * p1.x) + (-p0.x + p2.x) * t + (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 + (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3); + const y = 0.5 * ((2 * p1.y) + (-p0.y + p2.y) * t + (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 + (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3); + out.push({ x, y }); + } + } + return out; +} + +function buildSpline(cvs: Pt[], ctx: FullToolContext): CADElement { + const stroke = (ctx.options.strokeColor as string | undefined) ?? '#e0e0f0'; + const layerId = (ctx.options.layerId as string | undefined) ?? 'layer-0'; + const pts = catmullRom(cvs); + const xs = pts.map((p) => p.x), ys = pts.map((p) => p.y); + const minX = Math.min(...xs), minY = Math.min(...ys); + const maxX = Math.max(...xs), maxY = Math.max(...ys); + return { + id: uid('spline'), + type: 'polyline', + layerId, + x: minX + (maxX - minX) / 2, + y: minY + (maxY - minY) / 2, + width: maxX - minX, + height: maxY - minY, + properties: { + points: pts, + controlPoints: [...cvs], + stroke, + strokeWidth: 1, + }, + } as unknown as CADElement; +} + +export const splineTool: ToolExtensionV2 = { + manifest: { + id: 'spline', + label: 'Spline', + icon: '\u2248', + ribbonTab: 'canvas', + tags: ['basic'], + description: 'Spline zeichnen: Kontrollpunkte klicken, ENTER beendet', + }, + optionsSchema: [{ key: 'strokeColor', label: 'Farbe', type: 'color' }], + handlers: { + down(e, ctx) { + splinePoints.push(e.world); + ctx.setStatus(`Spline: ${splinePoints.length} Kontrollpunkte – ENTER beendet`); + }, + move(e, ctx) { + const c = ctx as FullToolContext; + if (splinePoints.length === 0) return; + const pts = [...splinePoints, e.world]; + ctx.setPreview?.(buildSpline(pts, c)); + }, + up() { /* commit nur via ENTER */ }, + key(e, ctx) { + if (e.key !== 'Enter' && e.key !== 'ENTER') return false; + const c = ctx as FullToolContext; + if (splinePoints.length < 3) { + ctx.setStatus(`Spline benötigt mindestens 3 Kontrollpunkte (aktuell ${splinePoints.length})`); + return true; + } + if (!c.doc) return false; + const el = buildSpline(splinePoints, c); + c.doc.transact(() => c.doc!.addElement(el)); + const count = splinePoints.length; + splinePoints = []; + ctx.setPreview?.(null); + ctx.setStatus(`Spline erstellt (${count} CVs, ${el.id})`); + return true; + }, + cancel(ctx) { + splinePoints = []; + ctx.setPreview?.(null); + ctx.setStatus('Spline abgebrochen'); + }, + }, +}; + export const coreDrawingPlugin: PluginV2 = { manifest: { id: 'core-drawing', name: 'Zeichnen (Kern)', - version: '1.3.0', + version: '1.4.0', author: 'web-cad team', description: 'Zeichenwerkzeuge V2: Linie, Rechteck, Kreis, Bogen, Polylinie, Polygon, RevCloud.', category: 'tools', enabledByDefault: true, }, - tools: [lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool, revcloudTool, ellipseTool], + tools: [lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool, revcloudTool, ellipseTool, splineTool], }; diff --git a/frontend/tests/splineTool.test.ts b/frontend/tests/splineTool.test.ts new file mode 100644 index 0000000..f12842b --- /dev/null +++ b/frontend/tests/splineTool.test.ts @@ -0,0 +1,147 @@ +/** + * Task D2 – Spline-Tool (CV-basiert): Kontrollpunkt-Klick-Sequenz, + * ENTER committet ab 3 CVs. Render als interpolierte Polyline mit + * Catmull-Rom-Glättung (Kurve läuft DURCH alle CVs), CVs in + * properties.controlPoints gespeichert (DXF-SPLINE-Export-ready). + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { splineTool } from '../src/plugins/builtin/core-drawing'; +import { CADDocument } from '../src/kernel/document/CADDocument'; +import { createInMemoryDoc } from './helpers/inMemoryDoc'; +import type { CADElement, Pt } from '../src/types/cad.types'; + +function mkCtx(options: Record = {}) { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + const previews: (CADElement | null)[] = []; + const statuses: string[] = []; + const ctx: any = { + doc, + options, + setStatus: (m: string) => statuses.push(m), + setPreview: (el: CADElement | null) => previews.push(el), + }; + return { ctx, doc, previews, statuses }; +} + +function pe(x: number, y: number): { world: Pt } { + return { world: { x, y } } as never; +} + +beforeEach(() => { + splineTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never); +}); + +describe('D2: splineTool', () => { + it('4 CVs + ENTER erzeugt EINE geglaettete Polyline mit mehr Punkten als CVs', () => { + const { ctx, doc } = mkCtx(); + const cvs = [[0, 0], [100, 100], [200, 0], [300, 100]]; + for (const [x, y] of cvs) splineTool.handlers.down!(pe(x, y), ctx); + const committed = splineTool.handlers.key!({ key: 'Enter' } as never, ctx); + expect(committed).toBe(true); + const els = doc.getAllElements(); + expect(els).toHaveLength(1); + const el = els[0]; + expect(el.type).toBe('polyline'); + const pts = el.properties.points as Pt[]; + expect(pts.length).toBeGreaterThan(8); // geglaettet, nicht nur CVs + }); + + it('Kurve interpoliert DURCH die CVs: Start/Ende exakt, Zwischen-CVs liegen auf der Kurve', () => { + const { ctx, doc } = mkCtx(); + for (const [x, y] of [[0, 0], [100, 100], [200, 0], [300, 100]]) { + splineTool.handlers.down!(pe(x, y), ctx); + } + splineTool.handlers.key!({ key: 'Enter' } as never, ctx); + const el = doc.getAllElements()[0]; + const pts = el.properties.points as Pt[]; + expect(pts[0]).toEqual({ x: 0, y: 0 }); + const last = pts[pts.length - 1]; + expect(last.x).toBeCloseTo(300, 5); + expect(last.y).toBeCloseTo(100, 5); + // Zwischenpunkt (100,100) muss auf der Kurve liegen (Catmull-Rom interpoliert): + const near = pts.find((p) => Math.abs(p.x - 100) < 1.5 && Math.abs(p.y - 100) < 1.5); + expect(near).toBeDefined(); + }); + + it('CVs werden in properties.controlPoints gespeichert', () => { + const { ctx, doc } = mkCtx(); + for (const [x, y] of [[0, 0], [50, 80], [120, 40]]) { + splineTool.handlers.down!(pe(x, y), ctx); + } + splineTool.handlers.key!({ key: 'Enter' } as never, ctx); + const el = doc.getAllElements()[0]; + const cvs = el.properties.controlPoints as Pt[]; + expect(cvs).toHaveLength(3); + expect(cvs[1]).toEqual({ x: 50, y: 80 }); + }); + + it('Glättung: Kurve weicht von der CV-Sehnen-Linie ab (keine Polyline durch CVs)', () => { + const { ctx, doc } = mkCtx(); + // S-artige CVs: (0,0) (0,100) (100,100) (100,0) — Catmull-Rom schwingt um die Ecken + for (const [x, y] of [[0, 0], [0, 100], [100, 100], [100, 0]]) { + splineTool.handlers.down!(pe(x, y), ctx); + } + splineTool.handlers.key!({ key: 'Enter' } as never, ctx); + const el = doc.getAllElements()[0]; + const pts = el.properties.points as Pt[]; + // Es muss Punkte geben, die ausserhalb der BBox der Sehnen-Verbindung liegen + // (Glattung schwingt): minY < 0 oder maxY > 100 an irgendeiner Stelle + const ys = pts.map((p) => p.y); + const swingsBelow = Math.min(...ys) < -0.5; + const swingsAbove = Math.max(...ys) > 100.5; + expect(swingsBelow || swingsAbove).toBe(true); + }); + + it('ENTER mit weniger als 3 CVs committet NICHT (Status erklaert Mindestzahl)', () => { + const { ctx, doc, statuses } = mkCtx(); + splineTool.handlers.down!(pe(0, 0), ctx); + splineTool.handlers.down!(pe(50, 50), ctx); + const consumed = splineTool.handlers.key!({ key: 'Enter' } as never, ctx); + expect(doc.getAllElements()).toHaveLength(0); + expect(statuses[statuses.length - 1]).toContain('3'); + }); + + it('Nicht-ENTER-Keys werden nicht konsumiert (return false)', () => { + const { ctx } = mkCtx(); + splineTool.handlers.down!(pe(0, 0), ctx); + const consumed = splineTool.handlers.key!({ key: 'a' } as never, ctx); + expect(consumed).toBe(false); + }); + + it('Preview bei move ab 1 CV', () => { + const { ctx, previews } = mkCtx(); + splineTool.handlers.down!(pe(0, 0), ctx); + splineTool.handlers.move!(pe(50, 50), ctx); + expect(previews.length).toBeGreaterThanOrEqual(1); + const last = previews[previews.length - 1]; + expect(last).not.toBeNull(); + }); + + it('cancel resettet: naechste Sitzung startet frisch', () => { + const { ctx, doc } = mkCtx(); + splineTool.handlers.down!(pe(0, 0), ctx); + splineTool.handlers.down!(pe(50, 50), ctx); + splineTool.handlers.cancel?.(ctx); + splineTool.handlers.down!(pe(0, 0), ctx); + splineTool.handlers.down!(pe(50, 50), ctx); + splineTool.handlers.down!(pe(100, 0), ctx); + splineTool.handlers.key!({ key: 'Enter' } as never, ctx); + // 3 CVs der NEUEN Sitzung — controlPoints == 3 (alte 2 nicht gemischt) + const el = doc.getAllElements()[0]; + expect((el.properties.controlPoints as Pt[]).length).toBe(3); + }); + + it('Status zaehlt CVs mit', () => { + const { ctx, statuses } = mkCtx(); + splineTool.handlers.down!(pe(0, 0), ctx); + expect(statuses[0]).toContain('1'); + expect(statuses[0]).toContain('Spline'); + }); + + it('Manifest: id=spline, ribbonTab=canvas, tags basic', () => { + expect(splineTool.manifest.id).toBe('spline'); + expect(splineTool.manifest.ribbonTab).toBe('canvas'); + expect((splineTool.manifest.tags ?? [])).toContain('basic'); + }); +});