diff --git a/frontend/src/plugins/builtin/core-modify/index.ts b/frontend/src/plugins/builtin/core-modify/index.ts index 7255047..2b43ea7 100644 --- a/frontend/src/plugins/builtin/core-modify/index.ts +++ b/frontend/src/plugins/builtin/core-modify/index.ts @@ -1,16 +1,34 @@ /** - * core-modify – Built-in V2-Plugin (Task A3 Pilot). + * core-modify – Built-in V2-Plugin. * - * Werkzeuge: select (Pilotportierung aus Legacy handleSelectDown). - * Pilot-Scope: Klick-Auswahl inkl. Group-Bewusstheit via SelectionBridge; - * Marquee/Box-Auswahl Migration folgt in A4 (Legacy bleibt parallel aktiv). + * Werkzeuge: + * - select (A3): Klick-Auswahl mit Shift/Ctrl + * - hatch (A4.5): Schraffur via doc.updateElement + * - move/copy (A4.6): selektionsbasiert, Basis→Ziel + * - rotate/scale/mirror (A4.7): gleiche Selektions-Konvention, + * Legacy-Transform-Mathematik aus handleModifyMove übernommen + * + * Alle Commits laufen über CADDocument → kollaborativ korrektes Undo. */ import type { PluginV2, ToolExtensionV2 } from '../../types'; +import type { FullToolContext } from '../../types'; import type { CADElement } from '../../../types/cad.types'; +import { + moveElement, + rotateElement, + scaleElement, + mirrorElement, + angleBetween, + distance, +} from '../../../tools/modification/geometry'; +import type { Pt } from '../../../tools/modification/geometry'; -/** - * select-Werkzeug: Klick wählt Element direkt (Shift=additiv, Ctrl=subtraktiv). - */ +const uid = (prefix: string): string => + `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`; + +// ───────────────────────────────────────────────────────────── +// select (Task A3 – Pilot) +// ───────────────────────────────────────────────────────────── export const selectTool: ToolExtensionV2 = { manifest: { id: 'select', @@ -23,25 +41,21 @@ export const selectTool: ToolExtensionV2 = { optionsSchema: [], handlers: { down(e, ctx) { - const bridge = (ctx as import('../../types').FullToolContext).selection; + const bridge = (ctx as FullToolContext).selection; if (!bridge) return; - const additive = e.shift; const subtractive = e.ctrl; const hit = bridge.hitTest(e.world.x, e.world.y, 5); - if (!hit) { - // Leerer Klick: Selektion leeren (außer additiv) if (!additive && !subtractive) bridge.setIds([]); return; } - let ids = new Set(bridge.getIds()); if (subtractive) { ids.delete(hit.id); } else { ids.add(hit.id); - if (!additive) ids = new Set([hit.id]); // ohne Shift: nur das getroffene + if (!additive) ids = new Set([hit.id]); } bridge.setIds(Array.from(ids)); ctx.setStatus(`Selektion: ${ids.size} Element(e)`); @@ -49,11 +63,9 @@ export const selectTool: ToolExtensionV2 = { }, }; -/** - * 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). - */ +// ───────────────────────────────────────────────────────────── +// hatch (Task A4.5) +// ───────────────────────────────────────────────────────────── export const hatchTool: ToolExtensionV2 = { manifest: { id: 'hatch', label: 'Schraffur', icon: '\u2593', ribbonTab: 'format', tags: ['pro'], @@ -68,7 +80,7 @@ export const hatchTool: ToolExtensionV2 = { ], handlers: { down(e, ctx) { - const c = ctx as import('../../types').FullToolContext; + const c = ctx as FullToolContext; const bridge = c.selection; if (!bridge || !c.doc) return; const hit = bridge.hitTest(e.world.x, e.world.y, 5); @@ -86,20 +98,11 @@ export const hatchTool: ToolExtensionV2 = { }, }; -/** - * Move/Copy-Basispunkt (A4.6): erster down setzt ihn, zweiter down committet. - * Gemeinsam für beide Tools (klick-progressiv wie arc-Lektion). - */ -let modBasePoint: import('../../../tools/modification/geometry').Pt | null = null; +// ───────────────────────────────────────────────────────────── +// Move/Copy (Task A4.6) +// ───────────────────────────────────────────────────────────── +let modBasePoint: Pt | null = null; -/** - * Fabrik für move/copy-Werkzeuge (A4.6). - * Wirkt auf die AKTUELLE SELEKTION (SelectionBridge): - * - Klick 1: Basispunkt setzen - * - Klick 2: Commit — move: updateElement(x±dx,y±dy); copy: addElement der - * versetzten Kopien (neue IDs). Alles in EINER Transaktion = 1 Undo-Schritt. - * Legacy-Konvention: Versatzvektor = zweiterKlick − basis. - */ function makeMoveCopyTool(mode: 'move' | 'copy'): ToolExtensionV2 { const label = mode === 'move' ? 'Verschieben' : 'Kopieren'; return { @@ -109,18 +112,16 @@ function makeMoveCopyTool(mode: 'move' | 'copy'): ToolExtensionV2 { icon: mode === 'move' ? '\u27a4' : '\u29c9', ribbonTab: 'canvas', tags: ['basic'], - description: `${label}: Auswahl treffen, dann Basis→Ziel klicken`, + description: `${label}: Auswahl treffen, dann Basis\u2192Ziel klicken`, }, optionsSchema: [], handlers: { down(e, ctx) { - const c = ctx as import('../../types').FullToolContext; + const c = ctx as FullToolContext; if (!c.doc || !c.selection) return; - if (!modBasePoint) { const ids = c.selection.getIds(); if (ids.length === 0) { - // Komfort: nichts selektiert → hitTest und sofort auswählen const hit = c.selection.hitTest(e.world.x, e.world.y, 5); if (!hit) { ctx.setStatus(`${label}: zuerst Element(e) auswählen`); @@ -132,8 +133,6 @@ function makeMoveCopyTool(mode: 'move' | 'copy'): ToolExtensionV2 { ctx.setStatus(`${label}: Zielklick zum ${mode === 'move' ? 'Verschieben' : 'Kopieren'} (${c.selection.getIds().length} Elemente)`); return; } - - // Zweiter Klick → Commit const dx = e.world.x - modBasePoint.x; const dy = e.world.y - modBasePoint.y; const ids = c.selection.getIds(); @@ -144,13 +143,12 @@ function makeMoveCopyTool(mode: 'move' | 'copy'): ToolExtensionV2 { if (mode === 'move') { c.doc!.updateElement(id, { x: el.x + dx, y: el.y + dy }); } else { - const copy: CADElement = { + c.doc!.addElement({ ...el, - id: `cp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}_${ids.indexOf(id)}`, + id: uid('cp'), x: el.x + dx, y: el.y + dy, - }; - c.doc!.addElement(copy); + }); } } }); @@ -164,20 +162,127 @@ function makeMoveCopyTool(mode: 'move' | 'copy'): ToolExtensionV2 { }, }; } - export const moveTool: ToolExtensionV2 = makeMoveCopyTool('move'); export const copyTool: ToolExtensionV2 = makeMoveCopyTool('copy'); -/** V2-Plugin: Kern-Bearbeitungswerkzeuge (select, hatch; A4.6: move/copy). */ +// ───────────────────────────────────────────────────────────── +// Rotate / Scale / Mirror (Task A4.7) +// Konventionen exakt aus Legacy handleModifyMove: +// - rotate: Pivot = Zentrum des ERSTEN selektierten Elements, +// Δwinkel = angleBetween(pivot,zweitKlick) − angleBetween(pivot,basis) +// - scale: Faktor = dist(pivot,zweitKlick) / dist(pivot,basis) +// - mirror: Spiegelachse = Linie basis→zweitKlick +// Klick-progressiv: Commit beim ZWEITEN down (Arc-Lektion). +// ───────────────────────────────────────────────────────────── +let transformBase: Pt | null = null; + +function makeTransformTool(mode: 'rotate' | 'scale' | 'mirror'): ToolExtensionV2 { + const labels = { rotate: 'Rotieren', scale: 'Skalieren', mirror: 'Spiegeln' } as const; + const iconMap = { rotate: '\u21bb', scale: '\u2922', mirror: '\u21c6' } as const; + return { + manifest: { + id: mode, + label: labels[mode], + icon: iconMap[mode], + ribbonTab: 'canvas', + tags: ['pro'], + description: `${labels[mode]}: Auswahl treffen, dann Basis\u2192Referenz klicken`, + }, + optionsSchema: [], + handlers: { + down(e, ctx) { + const c = ctx as FullToolContext; + if (!c.doc || !c.selection) return; + if (!transformBase) { + if (c.selection.getIds().length === 0) { + const hit = c.selection.hitTest(e.world.x, e.world.y, 5); + if (!hit) { + ctx.setStatus(`${labels[mode]}: zuerst Element(e) auswählen`); + return; + } + c.selection.setIds([hit.id]); + } + transformBase = e.world; + ctx.setStatus(`${labels[mode]}: Referenzklick zum Anwenden (${c.selection.getIds().length} Elemente)`); + return; + } + // Zweiter Klick → Commit auf ALLE selektierten Elemente, EINE Transaktion + const base = transformBase; + const ids = c.selection.getIds(); + const first = c.doc.getElement(ids[0]); + if (!first) { transformBase = null; return; } + const pivot = { x: first.x, y: first.y }; + c.doc.transact(() => { + for (const id of ids) { + const el = c.doc!.getElement(id); + if (!el) continue; + let next: CADElement; + if (mode === 'rotate') { + const delta = angleBetween(pivot, e.world) - angleBetween(pivot, base); + next = rotateElement(el, pivot.x, pivot.y, delta); + } else if (mode === 'scale') { + const sd = distance(pivot, base); + const cd = distance(pivot, e.world); + const factor = sd > 0 ? cd / sd : 1; + next = scaleElement(el, pivot.x, pivot.y, factor, factor); + } else { + next = mirrorElement(el, base.x, base.y, e.world.x, e.world.y); + } + c.doc!.updateElement(id, { + x: next.x, y: next.y, + width: next.width, height: next.height, + properties: next.properties, + }); + } + }); + ctx.setStatus(`${labels[mode]}: ${ids.length} Element(e)`); + transformBase = null; + }, + move(e, ctx) { + // Live-Vorschau am ersten selektierten Element (Legacy previewElement) + const c = ctx as FullToolContext; + if (!transformBase || !c.selection || !c.doc) return; + const ids = c.selection.getIds(); + if (ids.length === 0) return; + const first = c.doc.getElement(ids[0]); + if (!first) return; + const pivot = { x: first.x, y: first.y }; + let next: CADElement; + if (mode === 'rotate') { + const delta = angleBetween(pivot, e.world) - angleBetween(pivot, transformBase); + next = rotateElement(first, pivot.x, pivot.y, delta); + } else if (mode === 'scale') { + const sd = distance(pivot, transformBase); + const cd = distance(pivot, e.world); + const f = sd > 0 ? cd / sd : 1; + next = scaleElement(first, pivot.x, pivot.y, f, f); + } else { + next = mirrorElement(first, transformBase.x, transformBase.y, e.world.x, e.world.y); + } + ctx.setPreview?.(next); + }, + cancel(ctx) { + transformBase = null; + ctx.setPreview?.(null); + ctx.setStatus(`${labels[mode]} abgebrochen`); + }, + }, + }; +} +export const rotateTool: ToolExtensionV2 = makeTransformTool('rotate'); +export const scaleTool: ToolExtensionV2 = makeTransformTool('scale'); +export const mirrorTool: ToolExtensionV2 = makeTransformTool('mirror'); + +/** V2-Plugin: Kern-Bearbeitungswerkzeuge. */ export const coreModifyPlugin: PluginV2 = { manifest: { id: 'core-modify', name: 'Bearbeiten (Kern)', - version: '1.2.0', + version: '1.3.0', author: 'web-cad team', - description: 'Auswahl, Schraffur, Verschieben, Kopieren (V2).', + description: 'Auswahl, Schraffur, Verschieben, Kopieren, Rotieren, Skalieren, Spiegeln (V2).', category: 'tools', enabledByDefault: true, }, - tools: [selectTool, hatchTool, moveTool, copyTool], + tools: [selectTool, hatchTool, moveTool, copyTool, rotateTool, scaleTool, mirrorTool], }; diff --git a/frontend/tests/pilotTools.test.ts b/frontend/tests/pilotTools.test.ts index a0158a4..8080475 100644 --- a/frontend/tests/pilotTools.test.ts +++ b/frontend/tests/pilotTools.test.ts @@ -433,6 +433,86 @@ describe('A4.6 pilot: move/copy tools', () => { }); }); +describe('A4.7 pilot: transform tools', () => { + it('rotate dreht um Pivot des ersten Elements; ein undo stellt zurück', () => { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + const rotEl = makeEl('r1'); + doc.addElement(rotEl); + let selIds: string[] = ['r1']; + const bridge = { + getIds: () => selIds, + setIds: (ids: string[]) => { selIds = ids; }, + hitTest: () => null, + }; + const ctx = makeCtx({ doc, selection: bridge }); + const rotate = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'rotate')!; + + // Basispunkt rechts vom Zentrum (0°-Richtung), Element x=10 + rotate.handlers.down!(pe(60, 10), ctx); + // Referenzklick oben → Δwinkel ≈ +90° (CCW) + rotate.handlers.down!(pe(10, 60), ctx); + + expect(doc.undo()).toBe(true); + expect(doc.getAllElements().length).toBe(1); + }); + + it('scale vergrößert width/height um Faktor dist(pivot,zweiteKlick)/dist(pivot,basis)', () => { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + const sq = makeEl('s1'); + sq.width = 40; + sq.height = 40; + sq.x = 0; + sq.y = 0; + doc.addElement(sq); + let selIds: string[] = ['s1']; + const bridge = { + getIds: () => selIds, + setIds: (ids: string[]) => { selIds = ids; }, + hitTest: () => null, + }; + const ctx = makeCtx({ doc, selection: bridge }); + const scale = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'scale')!; + + // Basis bei Distanz 50 vom Pivot, Ziel bei 100 → Faktor 2 + scale.handlers.down!(pe(50, 0), ctx); + scale.handlers.down!(pe(100, 0), ctx); + + expect(doc.getElement('s1')!.width).toBeCloseTo(80); // 40×2 + expect(doc.getElement('s1')!.height).toBeCloseTo(80); + + expect(doc.undo()).toBe(true); + expect(doc.getElement('s1')!.width).toBeCloseTo(40); + }); + + it('mirror spiegelt Element an Achse basis→referenz', () => { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + const el = makeEl('mi'); + el.x = 30; + el.y = 0; + doc.addElement(el); + let selIds: string[] = ['mi']; + const bridge = { + getIds: () => selIds, + setIds: (ids: string[]) => { selIds = ids; }, + hitTest: () => null, + }; + const ctx = makeCtx({ doc, selection: bridge }); + const mirror = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'mirror')!; + + // Spiegelachse = Y-Achse (basis (0,0)→ref (0,100)): Punkt (30,0)→(−30,0) + mirror.handlers.down!(pe(0, 0), ctx); + mirror.handlers.down!(pe(0, 100), ctx); + + const mirrored = doc.getElement('mi')!; + expect(mirrored.x).toBeLessThan(0); // jenseits der Achse + expect(Math.abs(mirrored.x)).toBeGreaterThanOrEqual(29); // ~spiegelsymmetrisch + expect(doc.undo()).toBe(true); + }); +}); + describe('A3 pilot: select tool', () => { function makeBridge(elements: CADElement[]) { let ids: string[] = [];