diff --git a/frontend/src/plugins/builtin/core-drawing/index.ts b/frontend/src/plugins/builtin/core-drawing/index.ts index 84b6691..65cd217 100644 --- a/frontend/src/plugins/builtin/core-drawing/index.ts +++ b/frontend/src/plugins/builtin/core-drawing/index.ts @@ -414,6 +414,150 @@ function buildRevCloud(pts: Pt[], ctx: FullToolContext): CADElement { } /** V2-Plugin: Kern-Zeichenwerkzeuge (A4.x schrittweise migriert). */ +// ───────────────────────────────────────────────────────────── +// Task D1: Ellipse (3-Klick: Zentrum→RadiusX→RadiusY) + +// Ellipsenbogen via startAngle/endAngle-Options. +// ───────────────────────────────────────────────────────────── + +/** Ellipsen-Sitzung: Phase 0=nichts, 1=Zentrum, 2=RadiusX fixiert. */ +let ellipsePhase = 0; +let ellipseCenter: Pt | null = null; +let ellipseRadiusX = 0; +/** Rotation aus der Klick2-Zugrichtung (Grad, 0°=+x, CW-positiv). */ +let ellipseRotationDeg = 0; + +/** + * Ellipse als Polyline-Approximation (F5-Parser-Konvention): + * Voll-Ellipse → 64 Punkte geschlossen (polygon), Bogen → proportional + * offene Polyline. rotation aus der Klick2-Zugrichtung (Grad, 0°=+x). + */ +function buildEllipse( + cx: number, + cy: number, + rx: number, + ry: number, + rotationDeg: number, + ctx: FullToolContext, + startAngle = 0, + endAngle = 360, +): CADElement { + const stroke = (ctx.options.strokeColor as string | undefined) ?? '#e0e0f0'; + const layerId = (ctx.options.layerId as string | undefined) ?? 'layer-0'; + const phi = (rotationDeg * Math.PI) / 180; + const cosP = Math.cos(phi), sinP = Math.sin(phi); + const FULL = 360; + const span = Math.min(endAngle, FULL) - Math.min(startAngle, FULL); + const isFull = span >= FULL - 0.001 || span <= 0.001; + const n = Math.max(2, Math.round(64 * (Math.max(span, 1) / FULL))); + const pts: Pt[] = []; + for (let i = 0; i < n; i++) { + const t = ((startAngle + (span * i) / (isFull ? n : (n - 1))) * Math.PI) / 180; + const x = rx * Math.cos(t); + const y = ry * Math.sin(t); + pts.push({ + x: cx + x * cosP - y * sinP, + y: cy + x * sinP + y * cosP, + }); + } + const xs = pts.map((p) => p.x), ys = pts.map((p) => p.y); + const minX = Math.min(...xs), minY = Math.min(...ys); + const type = isFull ? 'polygon' : 'polyline'; + return { + id: uid('ellipse'), + type, + layerId, + x: minX + (Math.max(...xs) - minX) / 2, + y: minY + (Math.max(...ys) - minY) / 2, + width: Math.max(...xs) - minX, + height: Math.max(...ys) - minY, + properties: { + points: pts, + stroke, + strokeWidth: 1, + cx, cy, rx, ry, + rotation: rotationDeg, + ...(isFull ? {} : { startAngle, endAngle }), + }, + } as unknown as CADElement; +} + +export const ellipseTool: ToolExtensionV2 = { + manifest: { + id: 'ellipse', + label: 'Ellipse', + icon: '\u2b2d', + ribbonTab: 'canvas', + tags: ['basic'], + description: 'Ellipse zeichnen: Zentrum klicken, RadiusX, RadiusY (Bogen via Optionen)', + }, + optionsSchema: [ + { key: 'strokeColor', label: 'Farbe', type: 'color' }, + { key: 'startAngle', label: 'Bogen-Start (°)', type: 'number', min: 0, max: 360 }, + { key: 'endAngle', label: 'Bogen-Ende (°)', type: 'number', min: 0, max: 360 }, + ], + handlers: { + down(e, ctx) { + const c = ctx as FullToolContext; + if (ellipsePhase === 0) { + ellipseCenter = e.world; + ellipsePhase = 1; + ctx.setStatus('Ellipse: Zentrum gesetzt – RadiusX klicken (Richtung = Rotation)'); + } else if (ellipsePhase === 1) { + if (!ellipseCenter) { ellipsePhase = 0; return; } + const dx = e.world.x - ellipseCenter.x; + const dy = e.world.y - ellipseCenter.y; + ellipseRadiusX = Math.hypot(dx, dy); + ellipseRotationDeg = ((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360; + ellipsePhase = 2; + ctx.setStatus('Ellipse: RadiusX gesetzt – RadiusY klicken'); + } else { + // Phase 2: Commit + if (!ellipseCenter || !c.doc) { ellipsePhase = 0; ellipseCenter = null; return; } + const dx = e.world.x - ellipseCenter.x; + const dy = e.world.y - ellipseCenter.y; + const ry = Math.hypot(dx, dy); + // Rotation: Winkel des Klick2-Vektors gegenüber der +x-Achse + const startAngle = (ctx.options.startAngle as number | undefined) ?? 0; + const endAngle = (ctx.options.endAngle as number | undefined) ?? 360; + const el = buildEllipse( + ellipseCenter.x, ellipseCenter.y, + ellipseRadiusX, ry, + ellipseRotationDeg, + c, startAngle, endAngle, + ); + c.doc.transact(() => c.doc!.addElement(el)); + ellipsePhase = 0; + ellipseCenter = null; + ctx.setPreview?.(null); + ctx.setStatus(`Ellipse erstellt (${el.id})`); + } + }, + move(e, ctx) { + const c = ctx as FullToolContext; + if (ellipsePhase === 1 && ellipseCenter) { + // RadiusX-Linie als Preview + const r = Math.hypot(e.world.x - ellipseCenter.x, e.world.y - ellipseCenter.y); + const rotDeg = ((Math.atan2(e.world.y - ellipseCenter.y, e.world.x - ellipseCenter.x) * 180) / Math.PI + 360) % 360; + const startAngle = (ctx.options.startAngle as number | undefined) ?? 0; + const endAngle = (ctx.options.endAngle as number | undefined) ?? 360; + ctx.setPreview?.(buildEllipse(ellipseCenter.x, ellipseCenter.y, r, r, rotDeg, c, startAngle, endAngle)); + } else if (ellipsePhase === 2 && ellipseCenter) { + const ry = Math.hypot(e.world.x - ellipseCenter.x, e.world.y - ellipseCenter.y); + const rotDeg = ellipseRotationDeg; + const startAngle = (ctx.options.startAngle as number | undefined) ?? 0; + const endAngle = (ctx.options.endAngle as number | undefined) ?? 360; + ctx.setPreview?.(buildEllipse(ellipseCenter.x, ellipseCenter.y, ellipseRadiusX, ry, rotDeg, c, startAngle, endAngle)); + } + }, + cancel(ctx) { + ellipsePhase = 0; + ellipseCenter = null; + ctx.setPreview?.(null); + ctx.setStatus('Ellipse abgebrochen'); + }, + }, +}; + export const coreDrawingPlugin: PluginV2 = { manifest: { id: 'core-drawing', @@ -424,5 +568,5 @@ export const coreDrawingPlugin: PluginV2 = { category: 'tools', enabledByDefault: true, }, - tools: [lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool, revcloudTool], + tools: [lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool, revcloudTool, ellipseTool], }; diff --git a/frontend/tests/ellipseTool.test.ts b/frontend/tests/ellipseTool.test.ts new file mode 100644 index 0000000..3c2994b --- /dev/null +++ b/frontend/tests/ellipseTool.test.ts @@ -0,0 +1,137 @@ +/** + * Task D1 – Ellipse+Bogen-Tool (core-drawing). + * + * 3-Klick-Interaktion: Klick1=Zentrum, Klick2=RadiusX (Richtung + * definiert die Rotation), Klick3=RadiusY. Commit als geschlossene + * Polyline (64 Punkte, F5-Parser-Konvention) mit cx/cy/rx/ry/rotation + * Metadaten. Ellipsenbogen via startAngle/endAngle-Options → offene + * Polyline mit proportionalen Punkten. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ellipseTool } 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(() => { + ellipseTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never); +}); + +describe('D1: ellipseTool', () => { + it('3 Klicks: Zentrum→RadiusX→RadiusY erzeugt geschlossene Ellipse (64 Punkte)', () => { + const { ctx, doc } = mkCtx(); + ellipseTool.handlers.down!(pe(0, 0), ctx); + ellipseTool.handlers.down!(pe(100, 0), ctx); // RadiusX=100, Rotation=0 + ellipseTool.handlers.down!(pe(0, 50), ctx); // RadiusY=50 + const els = doc.getAllElements(); + expect(els).toHaveLength(1); + const el = els[0]; + expect(el.type).toBe('polygon'); + const pts = el.properties.points as Pt[]; + expect(pts.length).toBe(64); + // BBox: rx=100, ry=50 + const xs = pts.map((p) => p.x); + const ys = pts.map((p) => p.y); + expect(Math.max(...xs)).toBeCloseTo(100, 0); + expect(Math.min(...xs)).toBeCloseTo(-100, 0); + expect(Math.max(...ys)).toBeCloseTo(50, 0); + expect(Math.min(...ys)).toBeCloseTo(-50, 0); + // Metadaten + expect(el.properties.cx).toBe(0); + expect(el.properties.cy).toBe(0); + expect(el.properties.rx).toBe(100); + expect(el.properties.ry).toBe(50); + expect(el.properties.rotation).toBe(0); + }); + + it('Zugrichtung definiert Rotation: Klick2 nach (0,100) → rotation=90°', () => { + const { ctx, doc } = mkCtx(); + ellipseTool.handlers.down!(pe(0, 0), ctx); + ellipseTool.handlers.down!(pe(0, 100), ctx); // RadiusX zeigt nach unten + ellipseTool.handlers.down!(pe(50, 0), ctx); // RadiusY + const el = doc.getAllElements()[0]; + expect(el.properties.rotation).toBeCloseTo(90, 1); + // Rotierte Ellipse: Hauptachse vertikal → maxY ≈ 100 + const ys = (el.properties.points as Pt[]).map((p) => p.y); + expect(Math.max(...ys)).toBeCloseTo(100, 0); + }); + + it('Preview bei move nach Phase 2 zeigt Ellipse', () => { + const { ctx, previews } = mkCtx(); + ellipseTool.handlers.down!(pe(0, 0), ctx); + ellipseTool.handlers.move!(pe(80, 0), ctx); // RadiusX-Linie + ellipseTool.handlers.down!(pe(80, 0), ctx); // RadiusX fixiert + ellipseTool.handlers.move!(pe(0, 40), ctx); // Ellipsen-Preview mit live ry + expect(previews.length).toBeGreaterThanOrEqual(2); + const last = previews[previews.length - 1]; + expect(last).not.toBeNull(); + expect(last!.type).toBe('polygon'); + }); + + it('Status führt durch die Phasen', () => { + const { ctx, statuses } = mkCtx(); + ellipseTool.handlers.down!(pe(0, 0), ctx); + expect(statuses[0]).toContain('Zentrum'); + ellipseTool.handlers.down!(pe(100, 0), ctx); + expect(statuses[1]).toContain('Radius'); + }); + + it('cancel resettet die Messung', () => { + const { ctx, doc } = mkCtx(); + ellipseTool.handlers.down!(pe(0, 0), ctx); + ellipseTool.handlers.down!(pe(100, 0), ctx); + ellipseTool.handlers.cancel?.(ctx); + // Nach cancel startet frisch: nächster Klick = Zentrum (kein Commit) + ellipseTool.handlers.down!(pe(0, 0), ctx); + expect(doc.getAllElements()).toHaveLength(0); + }); +}); + +describe('D1: Ellipsenbogen (startAngle/endAngle)', () => { + it('Options startAngle=0/endAngle=180 erzeugen OFFENE Polyline (32 Punkte)', () => { + const { ctx, doc } = mkCtx({ startAngle: 0, endAngle: 180 }); + ellipseTool.handlers.down!(pe(0, 0), ctx); + ellipseTool.handlers.down!(pe(100, 0), ctx); + ellipseTool.handlers.down!(pe(0, 50), ctx); + const el = doc.getAllElements()[0]; + expect(el.type).toBe('polyline'); // offen + const pts = el.properties.points as Pt[]; + expect(pts.length).toBe(32); // halbe Ellipse = 64/2 + }); + + it('Winkel-Metadaten in properties', () => { + const { ctx, doc } = mkCtx({ startAngle: 45, endAngle: 225 }); + ellipseTool.handlers.down!(pe(0, 0), ctx); + ellipseTool.handlers.down!(pe(100, 0), ctx); + ellipseTool.handlers.down!(pe(0, 50), ctx); + const el = doc.getAllElements()[0]; + expect(el.properties.startAngle).toBe(45); + expect(el.properties.endAngle).toBe(225); + }); +}); + +describe('D1: Manifest', () => { + it('id=ellipse, ribbonTab=canvas, tags basic, Version in tools-Array', () => { + expect(ellipseTool.manifest.id).toBe('ellipse'); + expect(ellipseTool.manifest.ribbonTab).toBe('canvas'); + expect((ellipseTool.manifest.tags ?? [])).toContain('basic'); + }); +});