From 464ddb91cce01fefdcd0a5431499255270deca47 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Thu, 27 Aug 2026 00:48:28 +0200 Subject: [PATCH] task(A4.5): migrate revcloud/hatch/measure tools to v2 --- .../src/plugins/builtin/core-drawing/index.ts | 67 +++++++++++++- .../src/plugins/builtin/core-measure/index.ts | 60 +++++++++++++ .../src/plugins/builtin/core-modify/index.ts | 45 +++++++++- frontend/src/plugins/index.ts | 3 + frontend/tests/pilotTools.test.ts | 88 +++++++++++++++++++ 5 files changed, 255 insertions(+), 8 deletions(-) create mode 100644 frontend/src/plugins/builtin/core-measure/index.ts diff --git a/frontend/src/plugins/builtin/core-drawing/index.ts b/frontend/src/plugins/builtin/core-drawing/index.ts index 6940119..84b6691 100644 --- a/frontend/src/plugins/builtin/core-drawing/index.ts +++ b/frontend/src/plugins/builtin/core-drawing/index.ts @@ -26,6 +26,8 @@ let arcCenter: Pt | null = null; let arcStartPt: Pt | null = null; /** Laufende Polyline/Polygon-Punkte; Commit per ENTER. */ let polyPoints: Pt[] = []; +/** Laufende RevCloud-Punkte (A4.5); Commit per ENTER. */ +let cloudPoints: Pt[] = []; function optsOf(ctx: FullToolContext): { stroke: string; layerId: string } { return { @@ -354,16 +356,73 @@ function buildPoly(type: 'polyline' | 'polygon', pts: Pt[], ctx: FullToolContext } as unknown as CADElement; } -/** V2-Plugin: Kern-Zeichenwerkzeuge (Pilot: line; A4.2: rect; A4.3: circle/arc/poly). */ +/** + * revcloud-Werkzeug (A4.5): Mehrklick-Revisionswolke wie Poly-Muster; + * ENTER committet ab 3 Punkten. Legacy-Konvention: arcHeight 8. + */ +export const revcloudTool: ToolExtensionV2 = { + manifest: { + id: 'revcloud', label: 'RevCloud', icon: '\u2611', ribbonTab: 'format', tags: ['pro'], + description: 'Revisionswolke: Punkte klicken, ENTER beendet', + }, + optionsSchema: [{ key: 'strokeColor', label: 'Farbe', type: 'color' }], + handlers: { + down(e, ctx) { + cloudPoints.push(e.world); + ctx.setStatus(`RevCloud: ${cloudPoints.length} Punkte \u2013 ENTER beendet`); + }, + move(e, ctx) { + if (cloudPoints.length === 0) return; + ctx.setPreview?.(buildRevCloud([...cloudPoints, e.world], ctx)); + }, + up() { /* commit via ENTER */ }, + key(e, ctx) { + if (e.key !== 'Enter') return false; + if (cloudPoints.length < 3) { + ctx.setStatus('RevCloud: mindestens 3 Punkte n\u00f6tig'); + return true; + } + const c = ctx as FullToolContext; + if (!c.doc) { cloudPoints = []; return true; } + const el = buildRevCloud(cloudPoints, c); + c.doc.transact(() => c.doc!.addElement(el)); + cloudPoints = []; + ctx.setPreview?.(null); + ctx.setStatus(`RevCloud erstellt (${el.id})`); + return true; + }, + cancel(ctx) { + cloudPoints = []; + ctx.setPreview?.(null); + ctx.setStatus('RevCloud abgebrochen'); + }, + }, +}; + +function buildRevCloud(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('revcloud'), type: 'revcloud', 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 })), arcHeight: 8, fill: 'none', stroke: '#e0e0e0', strokeWidth: 1.5 }, + } as unknown as CADElement; +} + +/** V2-Plugin: Kern-Zeichenwerkzeuge (A4.x schrittweise migriert). */ export const coreDrawingPlugin: PluginV2 = { manifest: { id: 'core-drawing', name: 'Zeichnen (Kern)', - version: '1.2.0', + version: '1.3.0', author: 'web-cad team', - description: 'Grundlegende Zeichenwerkzeuge (V2: Linie, Rechteck, Kreis, Bogen, Polylinie, Polygon).', + description: 'Zeichenwerkzeuge V2: Linie, Rechteck, Kreis, Bogen, Polylinie, Polygon, RevCloud.', category: 'tools', enabledByDefault: true, }, - tools: [lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool], + tools: [lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool, revcloudTool], }; diff --git a/frontend/src/plugins/builtin/core-measure/index.ts b/frontend/src/plugins/builtin/core-measure/index.ts new file mode 100644 index 0000000..8d50e85 --- /dev/null +++ b/frontend/src/plugins/builtin/core-measure/index.ts @@ -0,0 +1,60 @@ +/** + * core-measure – Built-in V2-Plugin (Task A4.5). + * + * measure: 2 Klicks → Distanz+Winkel-Anzeige im Status. KEIN Element-Commit + * (Legacy-Verhalten getreu — Messung ist Information, keine Zeichnung). + * Distanzformatierung via bestehendem formatDistance(unit, scaleFactor). + */ +import type { PluginV2, ToolExtensionV2, FullToolContext } from '../../types'; +import { formatDistance } from '../../../utils/format'; + +type MeasurePhase = 0 | 1; // 0=wartet auf Start, 1=wartet auf Ende +let measureStart: import('../../../tools/modification/geometry').Pt | null = null; + +export const measureTool: ToolExtensionV2 = { + manifest: { + id: 'measure', + label: 'Messen', + icon: '\u2194', + ribbonTab: 'view', + tags: ['basic'], + description: 'Distanz und Winkel zwischen zwei Punkten messen', + }, + optionsSchema: [], + handlers: { + down(e, ctx) { + const c = ctx as FullToolContext; + if (!measureStart) { + measureStart = e.world; + ctx.setStatus('Messung: Endpunkt klicken'); + return; + } + // Zweiter Klick → anzeigen (Legacy-Ausgabeformat) + const rawDist = Math.hypot(e.world.x - measureStart.x, e.world.y - measureStart.y); + const angle = (Math.atan2(e.world.y - measureStart.y, e.world.x - measureStart.x) * 180) / Math.PI; + const unit = (c.options.unit as string | undefined) ?? 'mm'; + const scale = (c.options.scaleFactor as number | undefined) ?? 1; + const distStr = formatDistance(rawDist, unit as never, scale); + ctx.setStatus(`distance: ${distStr} angle: ${angle.toFixed(1)}\u00b0`); + measureStart = null; + }, + cancel(ctx) { + measureStart = null; + ctx.setStatus('Messung abgebrochen'); + }, + }, +}; + +/** V2-Plugin: Messwerkzeuge. */ +export const coreMeasurePlugin: PluginV2 = { + manifest: { + id: 'core-measure', + name: 'Messen (Kern)', + version: '1.0.0', + author: 'web-cad team', + description: 'Abstandswinkel-Messung ohne Element-Erzeugung.', + category: 'tools', + enabledByDefault: true, + }, + tools: [measureTool], +}; diff --git a/frontend/src/plugins/builtin/core-modify/index.ts b/frontend/src/plugins/builtin/core-modify/index.ts index 126db53..863ce8f 100644 --- a/frontend/src/plugins/builtin/core-modify/index.ts +++ b/frontend/src/plugins/builtin/core-modify/index.ts @@ -48,16 +48,53 @@ export const selectTool: ToolExtensionV2 = { }, }; -/** V2-Plugin: Kern-Bearbeitungswerkzeuge (Pilot: nur select). */ +/** + * hatch-Werkzeug (A4.5): Klick auf geschlossenes Element (rect/circle/polygon) + * aktiviert Schraffur — Portierung aus Legacy handleHatchDown, aber jetzt via + * doc.updateElement (eigene Undo-Transaktion statt Callback-Direktaufruf). + */ +export const hatchTool: ToolExtensionV2 = { + manifest: { + id: 'hatch', label: 'Schraffur', icon: '\u2593', ribbonTab: 'format', tags: ['pro'], + description: 'Geschlossenes Element anklicken zum Schraffieren', + }, + optionsSchema: [ + { key: 'hatchPattern', label: 'Muster', type: 'select', options: [ + { value: 'lines', label: 'Linien' }, + { value: 'cross', label: 'Kreuz' }, + ] }, + { key: 'hatchSpacing', label: 'Abstand', type: 'number', min: 2, max: 40 }, + ], + handlers: { + down(e, ctx) { + const c = ctx as import('../../types').FullToolContext; + const bridge = c.selection; + if (!bridge || !c.doc) return; + const hit = bridge.hitTest(e.world.x, e.world.y, 5); + if (!hit || !['rect', 'circle', 'polygon'].includes(hit.type)) { + ctx.setStatus('Schraffur: nur rect/circle/polygon'); + return; + } + const pattern = (c.options.hatchPattern as string | undefined) ?? 'lines'; + const spacing = (c.options.hatchSpacing as number | undefined) ?? 8; + c.doc.updateElement(hit.id, { + properties: { hatch: true, hatchPattern: pattern, hatchSpacing: spacing }, + }); + ctx.setStatus(`Schraffur auf ${hit.id} (${pattern})`); + }, + }, +}; + +/** V2-Plugin: Kern-Bearbeitungswerkzeuge (Pilot: select; A4.5: hatch). */ export const coreModifyPlugin: PluginV2 = { manifest: { id: 'core-modify', name: 'Bearbeiten (Kern)', - version: '1.0.0', + version: '1.1.0', author: 'web-cad team', - description: 'Auswahl und Bearbeitung (V2-Pilot: Selektion).', + description: 'Auswahl und Bearbeitung (V2: Selektion, Schraffur).', category: 'tools', enabledByDefault: true, }, - tools: [selectTool], + tools: [selectTool, hatchTool], }; diff --git a/frontend/src/plugins/index.ts b/frontend/src/plugins/index.ts index 410de97..a515dfc 100644 --- a/frontend/src/plugins/index.ts +++ b/frontend/src/plugins/index.ts @@ -30,12 +30,14 @@ export { eventToolsPlugin } from './builtin/eventTools'; export { coreDrawingPlugin } from './builtin/core-drawing'; export { coreModifyPlugin } from './builtin/core-modify'; export { coreAnnotatePlugin } from './builtin/core-annotate'; +export { coreMeasurePlugin } from './builtin/core-measure'; import { pluginRegistry } from './PluginRegistry'; import { eventToolsPlugin } from './builtin/eventTools'; import { coreDrawingPlugin } from './builtin/core-drawing'; import { coreModifyPlugin } from './builtin/core-modify'; import { coreAnnotatePlugin } from './builtin/core-annotate'; +import { coreMeasurePlugin } from './builtin/core-measure'; /** Register all built-in plugins */ export function registerBuiltinPlugins() { @@ -44,4 +46,5 @@ export function registerBuiltinPlugins() { pluginRegistry.registerV2(coreDrawingPlugin); pluginRegistry.registerV2(coreModifyPlugin); pluginRegistry.registerV2(coreAnnotatePlugin); + pluginRegistry.registerV2(coreMeasurePlugin); } diff --git a/frontend/tests/pilotTools.test.ts b/frontend/tests/pilotTools.test.ts index b76a1bf..52cf687 100644 --- a/frontend/tests/pilotTools.test.ts +++ b/frontend/tests/pilotTools.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect, vi } from 'vitest'; import { lineTool, coreDrawingPlugin } from '../src/plugins/builtin/core-drawing'; import { selectTool, coreModifyPlugin } from '../src/plugins/builtin/core-modify'; +import { coreMeasurePlugin } from '../src/plugins/builtin/core-measure'; import { coreAnnotatePlugin } from '../src/plugins/builtin/core-annotate'; import { CADDocument } from '../src/kernel/document/CADDocument'; import { createInMemoryDoc } from './helpers/inMemoryDoc'; @@ -260,6 +261,93 @@ describe('A4.4 pilot: annotate tools', () => { }); }); +describe('A4.5 pilot: revcloud tool', () => { + it('committet per ENTER ab 3 Punkten mit Legacy-Konvention arcHeight=8', () => { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + const ctx = makeCtx({ doc }); + const cloud = coreDrawingPlugin.tools!.find((t) => t.manifest.id === 'revcloud')!; + + cloud.handlers.down!(pe(0, 0), ctx); + cloud.handlers.down!(pe(100, 0), ctx); + cloud.handlers.down!(pe(100, 50), ctx); + const kev = { key: 'Enter', preventDefault: () => {} } as unknown as KeyboardEvent; + const consumed = cloud.handlers.key!(kev, ctx); + expect(consumed).toBe(true); + + const all = doc.getAllElements(); + expect(all.length).toBe(1); + expect(all[0].type).toBe('revcloud'); + expect((all[0].properties.points as Pt[]).length).toBe(3); + expect(all[0].properties.arcHeight).toBe(8); // Legacy-Konvention + expect(all[0].properties.fill).toBe('none'); + + expect(doc.undo()).toBe(true); + expect(doc.getAllElements().length).toBe(0); + }); +}); + +describe('A4.5 pilot: measure tool', () => { + it('2 Klicks zeigen Distanz+Winkel im Status; erzeugt KEIN Element', () => { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + const ctx = makeCtx({ doc }); + const meas = coreMeasurePlugin.tools!.find((t) => t.manifest.id === 'measure')!; + + meas.handlers.down!(pe(0, 0), ctx); + expect(ctx.statuses.some((s) => s.includes('Endpunkt'))).toBe(true); + + meas.handlers.down!(pe(30, 40), ctx); // dist=50, angle≈53.13° + const last = ctx.statuses[ctx.statuses.length - 1]; + expect(last).toContain('distance:'); + expect(last).toContain('angle:'); + expect(last).toContain('53.1'); // atan2(40,30)≈53.13° + // KernLegacy-Verhalten: Messung erzeugt kein Element + expect(doc.getAllElements().length).toBe(0); + }); +}); + +describe('A4.5 pilot: hatch tool', () => { + it('setzt hatch-Properties via updateElement; undo macht rückgängig', () => { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + // Seed-Rect im Doc, das beim hitTest zurückkommt: + const rectEl = { id: 'r1', type: 'rect', layerId: 'l', x: 50, y: 50, width: 10, height: 10, properties: {} } as unknown as CADElement; + doc.addElement(rectEl); + const bridge = { + getIds: () => [], + setIds: () => {}, + hitTest: () => rectEl, + }; + const ctx = makeCtx({ doc, selection: bridge, options: { hatchPattern: 'cross', hatchSpacing: 12 } }); + const hatch = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'hatch')!; + + hatch.handlers.down!(pe(50, 50), ctx); + + const updated = doc.getElement('r1')!; + expect(updated.properties.hatch).toBe(true); + expect(updated.properties.hatchPattern).toBe('cross'); // aus Options + expect(updated.properties.hatchSpacing).toBe(12); + + expect(doc.undo()).toBe(true); // A2b-Update ist selbst Undo-Schritt + expect(doc.getElement('r1')!.properties.hatch).toBeUndefined(); + }); + + it('lehnt offene Elementtypen (line) mit Hinweis ab', () => { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + const lineEl = { id: 'l1', type: 'line', layerId: 'l', x: 0, y: 0, width: 5, height: 5, properties: { x1: 0, y1: 0, x2: 5, y2: 5 } } as unknown as CADElement; + doc.addElement(lineEl); + const bridge = { getIds: () => [], setIds: () => {}, hitTest: () => lineEl }; + const ctx = makeCtx({ doc, selection: bridge }); + const hatch = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'hatch')!; + + hatch.handlers.down!(pe(2, 2), ctx); + expect(doc.getElement('l1')!.properties.hatch).toBeUndefined(); // nichts geändert + expect(ctx.statuses.some((s) => s.includes('rect/circle/polygon'))).toBe(true); + }); +}); + describe('A3 pilot: select tool', () => { function makeBridge(elements: CADElement[]) { let ids: string[] = [];