From 0dfe3b5870bce5f5e2a426ce1de61f674995bd3f Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Thu, 27 Aug 2026 00:39:30 +0200 Subject: [PATCH] task(A4.3): migrate circle/arc/polyline/polygon tools to v2 --- .../src/plugins/builtin/core-drawing/index.ts | 213 +++++++++++++++++- frontend/tests/pilotTools.test.ts | 117 ++++++++-- 2 files changed, 307 insertions(+), 23 deletions(-) diff --git a/frontend/src/plugins/builtin/core-drawing/index.ts b/frontend/src/plugins/builtin/core-drawing/index.ts index caab46b..6940119 100644 --- a/frontend/src/plugins/builtin/core-drawing/index.ts +++ b/frontend/src/plugins/builtin/core-drawing/index.ts @@ -18,6 +18,21 @@ const uid = (prefix: string): string => let lineStart: Pt | null = null; /** Startpunkt des laufenden Rechtecks (A4.2). */ let rectStart: Pt | null = null; +/** Zentrum des laufenden Kreises (A4.3). */ +let circleCenter: Pt | null = null; +/** Laufender Bogen: Phase 0=nichts, 1=Zentrum gesetzt, 2=Radius+Startwinkel gesetzt. */ +let arcPhase = 0; +let arcCenter: Pt | null = null; +let arcStartPt: Pt | null = null; +/** Laufende Polyline/Polygon-Punkte; Commit per ENTER. */ +let polyPoints: Pt[] = []; + +function optsOf(ctx: FullToolContext): { stroke: string; layerId: string } { + return { + stroke: (ctx.options.strokeColor as string | undefined) ?? '#e0e0f0', + layerId: (ctx.options.layerId as string | undefined) ?? 'layer-0', + }; +} function buildLine(start: Pt, end: Pt, ctx: FullToolContext): CADElement { const stroke = (ctx.options.strokeColor as string | undefined) ?? '#e0e0f0'; @@ -149,16 +164,206 @@ export const rectTool: ToolExtensionV2 = { }, }; -/** V2-Plugin: Kern-Zeichenwerkzeuge (Pilot: line; A4.2: rect). */ +/** + * circle-Werkzeug (A4.3): Zentrum→Radius wie Legacy (Radius = Distanz). + */ +export const circleTool: ToolExtensionV2 = { + manifest: { + id: 'circle', label: 'Kreis', icon: '\u25cb', ribbonTab: 'canvas', tags: ['basic'], + description: 'Kreis: Zentrum klicken, Radius ziehen', + }, + optionsSchema: [{ key: 'strokeColor', label: 'Farbe', type: 'color' }], + handlers: { + down(e, ctx) { circleCenter = e.world; ctx.setStatus('Kreiszentrum gesetzt – Radius ziehen'); }, + move(e, ctx) { + if (!circleCenter) return; + const r = Math.hypot(e.world.x - circleCenter.x, e.world.y - circleCenter.y); + ctx.setPreview?.(buildCircle(circleCenter, r, ctx)); + }, + up(e, ctx) { + const c = ctx as FullToolContext; + if (!circleCenter || !c.doc) { circleCenter = null; return; } + const r = Math.hypot(e.world.x - circleCenter.x, e.world.y - circleCenter.y); + const el = buildCircle(circleCenter, r, c); + c.doc.transact(() => c.doc!.addElement(el)); + circleCenter = null; + ctx.setPreview?.(null); + ctx.setStatus(`Kreis erstellt (${el.id})`); + }, + cancel(ctx) { circleCenter = null; ctx.setPreview?.(null); ctx.setStatus('Kreis abgebrochen'); }, + }, +}; + +/** + * arc-Werkzeug (A4.3): 3-Klick wie Legacy — Zentrum, Startpunkt (Radius+ + * Startwinkel), Endpunkt (Endwinkel). Winkel in Grad CCW. + */ +export const arcTool: ToolExtensionV2 = { + manifest: { + id: 'arc', label: 'Bogen', icon: '\u2322', ribbonTab: 'canvas', tags: ['basic'], + description: 'Bogen: Zentrum, Startpunkt, Endpunkt', + }, + optionsSchema: [{ key: 'strokeColor', label: 'Farbe', type: 'color' }], + handlers: { + down(e, ctx) { + const c = ctx as FullToolContext; + if (arcPhase === 0) { + arcCenter = e.world; + arcPhase = 1; + ctx.setStatus('Bogenzentrum gesetzt \u2013 Startpunkt w\u00e4hlen'); + } else if (arcPhase === 1) { + arcStartPt = e.world; + arcPhase = 2; + ctx.setStatus('Bogen: Endpunkt w\u00e4hlen'); + } else if (arcPhase === 2 && arcCenter && arcStartPt) { + // Dritter Klick committet (klick-progressiv wie Legacy — up feuert im + // echten Browser immer direkt nach down, darf deshalb NICHT committen) + if (!c.doc) { arcPhase = 0; arcCenter = null; arcStartPt = null; return; } + const el = buildArc(arcCenter, arcStartPt, e.world, c); + c.doc.transact(() => c.doc!.addElement(el)); + ctx.setPreview?.(null); + ctx.setStatus(`Bogen erstellt (${el.id})`); + arcPhase = 0; + arcCenter = null; + arcStartPt = null; + } + }, + move(e, ctx) { + if (arcPhase === 1 && arcCenter) { + const r = Math.hypot(e.world.x - arcCenter.x, e.world.y - arcCenter.y); + // Vollkreis-Guide in Phasen-Preview (Legacy-Konvention) + ctx.setPreview?.(buildArcFullGuide(arcCenter, r, ctx)); + } else if (arcPhase === 2 && arcCenter && arcStartPt) { + ctx.setPreview?.(buildArc(arcCenter, arcStartPt, e.world, ctx)); + } + }, + cancel(ctx) { + arcPhase = 0; arcCenter = null; arcStartPt = null; + ctx.setPreview?.(null); ctx.setStatus('Bogen abgebrochen'); + }, + }, +}; + +/** + * polyline-Werkzeug (A4.3): Mehrklick fügt Punkte; ENTER committet ab 2 Punkten. + */ +export const polylineTool: ToolExtensionV2 = makePolyTool('polyline', 'Polylinie', 2, '\u2500'); + +/** + * polygon-Werkzeug (A4.3): wie Polylinie, aber geschlossen; ENTER ab 3 Punkten. + */ +export const polygonTool: ToolExtensionV2 = makePolyTool('polygon', 'Polygon', 3, '\u2b21'); + +function makePolyTool(type: 'polyline' | 'polygon', label: string, minPoints: number, icon: string): ToolExtensionV2 { + return { + manifest: { id: type, label, icon, ribbonTab: 'canvas', tags: ['basic'], description: `${label}: Punkte klicken, ENTER beendet` }, + optionsSchema: [{ key: 'strokeColor', label: 'Farbe', type: 'color' }], + handlers: { + down(e, ctx) { + polyPoints.push(e.world); + if (!isPolyType(type)) return; + ctx.setStatus(`${label}: ${polyPoints.length} Punkte – ENTER beendet`); + }, + move(e, ctx) { + if (polyPoints.length === 0) return; + const pts = [...polyPoints, e.world]; + ctx.setPreview?.(buildPoly(type, pts, ctx)); + }, + up() { /* commit nur via ENTER */ }, + key(e, ctx) { + if (e.key !== 'Enter') return false; + if (polyPoints.length < minPoints) { + ctx.setStatus(`${label}: mindestens ${minPoints} Punkte nötig`); + return true; // konsumiert + } + const el = buildPoly(type, polyPoints, ctx as FullToolContext); + const doc = (ctx as FullToolContext).doc; + if (!doc) { resetPoly(); return true; } + doc.transact(() => doc.addElement(el)); + resetPoly(); + ctx.setPreview?.(null); + ctx.setStatus(`${label} erstellt (${el.id})`); + return true; + }, + cancel(ctx) { + resetPoly(); + ctx.setPreview?.(null); + ctx.setStatus(`${label} abgebrochen`); + }, + }, + }; +} + +function isPolyType(t: string): boolean { + return t === 'polyline' || t === 'polygon'; +} + +function resetPoly(): void { + polyPoints = []; +} + +// ─── Build-Helfer A4.3 ─────────────────────────────────────── + +function buildCircle(center: Pt, radius: number, ctx: FullToolContext): CADElement { + const o = optsOf(ctx); + return { + id: uid('circle'), type: 'circle', layerId: o.layerId, + x: center.x, y: center.y, width: radius * 2, height: radius * 2, + properties: { radius, stroke: o.stroke, strokeWidth: 1 }, + } as unknown as CADElement; +} + +function anglesOf(center: Pt, start: Pt, end: Pt): { radius: number; startAngle: number; endAngle: number } { + const radius = Math.hypot(start.x - center.x, start.y - center.y); + const startAngle = (Math.atan2(start.y - center.y, start.x - center.x) * 180) / Math.PI; + const endAngle = (Math.atan2(end.y - center.y, end.x - center.x) * 180) / Math.PI; + return { radius, startAngle, endAngle }; +} + +function buildArc(center: Pt, startPt: Pt, endPt: Pt, ctx: FullToolContext): CADElement { + const o = optsOf(ctx); + const a = anglesOf(center, startPt, endPt); + return { + id: uid('arc'), type: 'arc', layerId: o.layerId, + x: center.x, y: center.y, width: a.radius * 2, height: a.radius * 2, + properties: { radius: a.radius, startAngle: a.startAngle, endAngle: a.endAngle, stroke: o.stroke, strokeWidth: 1 }, + } as unknown as CADElement; +} + +function buildArcFullGuide(center: Pt, radius: number, ctx: FullToolContext): CADElement { + // Vollkreis-Vorschau als Radien-Leitfaden (Legacy-Verhalten Phase 1) + const o = optsOf(ctx); + return { + id: uid('arc'), type: 'circle', layerId: o.layerId, + x: center.x, y: center.y, width: radius * 2, height: radius * 2, + properties: { radius, stroke: o.stroke, strokeWidth: 1, guideOnly: true }, + } as unknown as CADElement; +} + +function buildPoly(type: 'polyline' | 'polygon', pts: Pt[], ctx: FullToolContext): CADElement { + const o = optsOf(ctx); + const xs = pts.map((p) => p.x); + const ys = pts.map((p) => p.y); + return { + id: uid(type), type, layerId: o.layerId, + x: (Math.min(...xs) + Math.max(...xs)) / 2, + y: (Math.min(...ys) + Math.max(...ys)) / 2, + width: Math.max(...xs) - Math.min(...xs), + height: Math.max(...ys) - Math.min(...ys), + properties: { points: pts.map((p) => ({ x: p.x, y: p.y })), stroke: o.stroke, strokeWidth: 1 }, + } as unknown as CADElement; +} + +/** V2-Plugin: Kern-Zeichenwerkzeuge (Pilot: line; A4.2: rect; A4.3: circle/arc/poly). */ export const coreDrawingPlugin: PluginV2 = { manifest: { id: 'core-drawing', name: 'Zeichnen (Kern)', - version: '1.1.0', + version: '1.2.0', author: 'web-cad team', - description: 'Grundlegende Zeichenwerkzeuge (V2: Linie, Rechteck).', + description: 'Grundlegende Zeichenwerkzeuge (V2: Linie, Rechteck, Kreis, Bogen, Polylinie, Polygon).', category: 'tools', enabledByDefault: true, }, - tools: [lineTool, rectTool], + tools: [lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool], }; diff --git a/frontend/tests/pilotTools.test.ts b/frontend/tests/pilotTools.test.ts index 4d13936..a54a088 100644 --- a/frontend/tests/pilotTools.test.ts +++ b/frontend/tests/pilotTools.test.ts @@ -80,46 +80,125 @@ describe('A3 pilot: line tool', () => { }); }); -describe('A4.2 pilot: rect tool', () => { - it('erzeugt Rechteck mit BBox-Konvention; ein undo entfernt es', () => { +describe('A4.3 pilot: circle tool', () => { + it('erzeugt Kreis Zentrum+Radius; ein undo entfernt ihn', () => { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); const ctx = makeCtx({ doc }); - const rect = coreDrawingPlugin.tools?.find((t) => t.manifest.id === 'rect'); - expect(rect).toBeDefined(); + const circle = coreDrawingPlugin.tools!.find((t) => t.manifest.id === 'circle')!; - rect!.handlers.down!(pe(10, 10), ctx); - rect!.handlers.move!(pe(110, 60), ctx); - expect(ctx.previews.length).toBe(1); // Live-Vorschau - rect!.handlers.up!(pe(110, 60), ctx); + circle.handlers.down!(pe(100, 100), ctx); + circle.handlers.move!(pe(200, 100), ctx); + expect(ctx.previews.length).toBe(1); + circle.handlers.up!(pe(200, 100), ctx); const all = doc.getAllElements(); expect(all.length).toBe(1); - expect(all[0].type).toBe('rect'); - // Legacy-BBox-Konvention: Zentrum zwischen beiden Ecken - expect(all[0].x).toBe(60); - expect(all[0].y).toBe(35); - expect(all[0].width).toBe(100); - expect(all[0].height).toBe(50); + expect(all[0].type).toBe('circle'); + expect(all[0].x).toBe(100); + expect(all[0].properties.radius).toBe(100); // Distanz wie Legacy + + expect(doc.undo()).toBe(true); + expect(doc.getAllElements().length).toBe(0); + }); +}); + +describe('A4.3 pilot: arc tool', () => { + it('klick-progressiv: Zentrum→Start→Ende committet beim 3. Klick mit Grad-Winkeln', () => { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + const ctx = makeCtx({ doc }); + const arc = coreDrawingPlugin.tools!.find((t) => t.manifest.id === 'arc')!; + + // Klick 1: Zentrum (up gehört zum Klick, committet NICHT — klick-progressiv) + arc.handlers.down!(pe(0, 0), ctx); + expect(doc.getAllElements().length).toBe(0); // Phase 1/2 committet nie + + // Klick 2: Startpunkt auf Radius 50 nach Osten (0°) + arc.handlers.down!(pe(50, 0), ctx); + expect(doc.getAllElements().length).toBe(0); + + // Klick 3: Endpunkt oben (90°) → JETZT committet der dritte down + arc.handlers.down!(pe(0, 50), ctx); + + const all = doc.getAllElements(); + expect(all.length).toBe(1); + expect(all[0].type).toBe('arc'); + expect(all[0].x).toBe(0); + expect(all[0].y).toBe(0); + expect(all[0].properties.radius).toBe(50); + expect(all[0].properties.startAngle).toBeCloseTo(0); + expect(all[0].properties.endAngle).toBeCloseTo(90); expect(doc.undo()).toBe(true); expect(doc.getAllElements().length).toBe(0); }); - it('cancel verwirft das laufende Rechteck', () => { + it('cancel bricht mehrphasige Sitzung vollstaendig ab', () => { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); const ctx = makeCtx({ doc }); - const rect = coreDrawingPlugin.tools!.find((t) => t.manifest.id === 'rect')!; + const arc = coreDrawingPlugin.tools!.find((t) => t.manifest.id === 'arc')!; - rect.handlers.down!(pe(5, 5), ctx); - rect.handlers.cancel!(ctx); - rect.handlers.up!(pe(50, 50), ctx); + arc.handlers.down!(pe(0, 0), ctx); // phase 1 + arc.handlers.down!(pe(30, 0), ctx); // phase 2 + arc.handlers.cancel!(ctx); // reset + arc.handlers.down!(pe(99, 99), ctx); // startet neu bei phase 1 + // Kein Commit erfolgt (nur phase 1), nichts im Doc: expect(doc.getAllElements().length).toBe(0); }); }); +describe('A4.3 pilot: polyline/polygon tools', () => { + it('polyline sammelt Punkte und committet per ENTER ab 2', () => { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + const ctx = makeCtx({ doc }); + const pl = coreDrawingPlugin.tools!.find((t) => t.manifest.id === 'polyline')!; + + pl.handlers.down!(pe(0, 0), ctx); + pl.handlers.move!(pe(10, 10), ctx); + expect(ctx.previews.length).toBeGreaterThan(0); + pl.handlers.down!(pe(20, 0), ctx); + pl.handlers.down!(pe(30, 30), ctx); + + const kev = { key: 'Enter', preventDefault: () => {} } as unknown as KeyboardEvent; + const consumed = pl.handlers.key!(kev, ctx); + expect(consumed).toBe(true); + + const all = doc.getAllElements(); + expect(all.length).toBe(1); + expect(all[0].type).toBe('polyline'); + expect((all[0].properties.points as Pt[]).length).toBe(3); + expect(all[0].x).toBe(15); // BBox-Zentrum min0 max30 + + expect(doc.undo()).toBe(true); + expect(doc.getAllElements().length).toBe(0); + }); + + it('polygon braucht mind. 3 Punkte; ENTER davor meldet Hinweis', () => { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + const pg = coreDrawingPlugin.tools!.find((t) => t.manifest.id === 'polygon')!; + const ctx = makeCtx({ doc }); + + pg.handlers.down!(pe(0, 0), ctx); + pg.handlers.down!(pe(10, 10), ctx); + const kev = { key: 'Enter', preventDefault: () => {} } as unknown as KeyboardEvent; + pg.handlers.key!(kev, ctx); // nur 2 Punkte → abgelehnt + expect(doc.getAllElements().length).toBe(0); + expect(ctx.statuses.some((s) => s.includes('mindestens 3'))).toBe(true); + + pg.handlers.down!(pe(20, -5), ctx); + pg.handlers.key!(kev, ctx); // jetzt 3 Punkte → ok + const all = doc.getAllElements(); + expect(all.length).toBe(1); + expect(all[0].type).toBe('polygon'); + expect((all[0].properties.points as Pt[]).length).toBe(3); + }); +}); + describe('A3 pilot: select tool', () => { function makeBridge(elements: CADElement[]) { let ids: string[] = [];