task(D2): spline tool - CV-based Catmull-Rom interpolation through all control points, ENTER commit from 3 CVs, controlPoints metadata

This commit is contained in:
Agent Zero
2026-08-29 03:14:45 +02:00
parent 40fa7891d5
commit fa3dc7d7e1
2 changed files with 253 additions and 2 deletions
@@ -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 = { export const coreDrawingPlugin: PluginV2 = {
manifest: { manifest: {
id: 'core-drawing', id: 'core-drawing',
name: 'Zeichnen (Kern)', name: 'Zeichnen (Kern)',
version: '1.3.0', version: '1.4.0',
author: 'web-cad team', author: 'web-cad team',
description: 'Zeichenwerkzeuge V2: Linie, Rechteck, Kreis, Bogen, Polylinie, Polygon, RevCloud.', description: 'Zeichenwerkzeuge V2: Linie, Rechteck, Kreis, Bogen, Polylinie, Polygon, RevCloud.',
category: 'tools', category: 'tools',
enabledByDefault: true, enabledByDefault: true,
}, },
tools: [lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool, revcloudTool, ellipseTool], tools: [lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool, revcloudTool, ellipseTool, splineTool],
}; };
+147
View File
@@ -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<string, unknown> = {}) {
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');
});
});