From cf6763573273aae469b7ee39fcbf2e7f0b84b2ab Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Thu, 27 Aug 2026 01:09:51 +0200 Subject: [PATCH] task(A4.8): migrate delete/offset tools to v2 --- .../src/plugins/builtin/core-modify/index.ts | 97 ++++++++++++++++++- frontend/tests/pilotTools.test.ts | 80 +++++++++++++++ 2 files changed, 174 insertions(+), 3 deletions(-) diff --git a/frontend/src/plugins/builtin/core-modify/index.ts b/frontend/src/plugins/builtin/core-modify/index.ts index 2b43ea7..b001ff1 100644 --- a/frontend/src/plugins/builtin/core-modify/index.ts +++ b/frontend/src/plugins/builtin/core-modify/index.ts @@ -20,6 +20,7 @@ import { mirrorElement, angleBetween, distance, + offsetElement, } from '../../../tools/modification/geometry'; import type { Pt } from '../../../tools/modification/geometry'; @@ -273,16 +274,106 @@ export const rotateTool: ToolExtensionV2 = makeTransformTool('rotate'); export const scaleTool: ToolExtensionV2 = makeTransformTool('scale'); export const mirrorTool: ToolExtensionV2 = makeTransformTool('mirror'); +// ───────────────────────────────────────────────────────────── +// Delete (Task A4.8) — Portierung aus Legacy handleDeleteDown: +// Einzelnklick auf Element löscht es sofort (kein up/Bestätigung). +// Jetzt via doc.deleteElements → undo-fähig. +// ───────────────────────────────────────────────────────────── +export const deleteTool: ToolExtensionV2 = { + manifest: { + id: 'delete', label: 'Löschen', icon: '\u2715', ribbonTab: 'canvas', tags: ['basic'], + description: 'Element anklicken zum Löschen', + }, + optionsSchema: [], + handlers: { + down(e, ctx) { + const c = ctx as FullToolContext; + if (!c.doc || !c.selection) return; + const ids = c.selection.getIds(); + if (ids.length > 0) { + // Selektion vorhanden → alles selektierte löschen + c.doc.deleteElements(ids); + c.selection.setIds([]); + ctx.setStatus(`${ids.length} Element(e) gelöscht`); + return; + } + // Sonst: direkter Klick-Treffer + const hit = c.selection.hitTest(e.world.x, e.world.y, 5); + if (!hit) return; + c.doc.deleteElements([hit.id]); + ctx.setStatus(`Gelöscht: ${hit.id}`); + }, + }, +}; + +// ───────────────────────────────────────────────────────────── +// Offset (Task A4.8) — nutzt die BUG-3-reparierte offsetElement- +// Signatur mit Seitenparameter; Legacy-Ablauf: +// Klick1 = Element wählen, Klick2 = Richtung+Distanz → NEUES Element. +// ───────────────────────────────────────────────────────────── +let offsetSource: CADElement | null = null; +let offsetBase: Pt | null = null; + +export const offsetTool: ToolExtensionV2 = { + manifest: { + id: 'offset', label: 'Versatz', icon: '\u2937', ribbonTab: 'canvas', tags: ['pro'], + description: 'Element anklicken, dann seitlichen Versatz klicken', + }, + optionsSchema: [], + handlers: { + down(e, ctx) { + const c = ctx as FullToolContext; + if (!c.doc || !c.selection) return; + if (!offsetSource) { + const hit = c.selection.hitTest(e.world.x, e.world.y, 5); + if (!hit) return; + offsetSource = hit; + offsetBase = e.world; + ctx.setStatus(`Offset: Element ${hit.id} gewählt – Versatz klicken`); + return; + } + // Zweiter Klick: Distanz + Seite (Kreuzprodukt für Linien, Radius sonst) + const src = c.doc.getElement(offsetSource.id); + if (!src) { offsetSource = null; offsetBase = null; return; } + const dist = Math.hypot(e.world.x - offsetBase!.x, e.world.y - offsetBase!.y); + let side: 1 | -1 = 1; + if (src.type === 'line' && src.properties.x1 !== undefined && src.properties.y1 !== undefined) { + const p1x = src.properties.x1 as number; + const p1y = src.properties.y1 as number; + const p2x = src.properties.x2 as number; + const p2y = src.properties.y2 as number; + side = (p2x - p1x) * (e.world.y - p1y) - (p2y - p1y) * (e.world.x - p1x) >= 0 ? 1 : -1; + } else if ((src.type === 'circle' || src.type === 'arc') && src.properties.radius !== undefined) { + side = Math.hypot(e.world.x - src.x, e.world.y - src.y) >= (src.properties.radius as number) ? 1 : -1; + } + const next = offsetElement(src, dist, side); + let newEl: CADElement | null = null; + c.doc.transact(() => { + newEl = { ...next, id: uid('off') }; + c.doc!.addElement(newEl!); + }); + ctx.setStatus(`Offset erstellt (${(newEl as unknown as CADElement).id})`); + offsetSource = null; + offsetBase = null; + }, + cancel(ctx) { + offsetSource = null; + offsetBase = null; + ctx.setStatus('Offset abgebrochen'); + }, + }, +}; + /** V2-Plugin: Kern-Bearbeitungswerkzeuge. */ export const coreModifyPlugin: PluginV2 = { manifest: { id: 'core-modify', name: 'Bearbeiten (Kern)', - version: '1.3.0', + version: '1.4.0', author: 'web-cad team', - description: 'Auswahl, Schraffur, Verschieben, Kopieren, Rotieren, Skalieren, Spiegeln (V2).', + description: 'Auswahl, Schraffur, Verschieben, Kopieren, Rotieren, Skalieren, Spiegeln, Löschen, Versatz (V2).', category: 'tools', enabledByDefault: true, }, - tools: [selectTool, hatchTool, moveTool, copyTool, rotateTool, scaleTool, mirrorTool], + tools: [selectTool, hatchTool, moveTool, copyTool, rotateTool, scaleTool, mirrorTool, deleteTool, offsetTool], }; diff --git a/frontend/tests/pilotTools.test.ts b/frontend/tests/pilotTools.test.ts index 8080475..8fff261 100644 --- a/frontend/tests/pilotTools.test.ts +++ b/frontend/tests/pilotTools.test.ts @@ -513,6 +513,86 @@ describe('A4.7 pilot: transform tools', () => { }); }); +describe('A4.8 pilot: delete tool', () => { + it('löscht alle selektierten Elemente in EINER Transaktion; undo stellt her', () => { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + doc.addElement(makeEl('d1')); + doc.addElement(makeEl('d2')); + doc.addElement(makeEl('keep')); + let selIds: string[] = ['d1', 'd2']; + const bridge = { + getIds: () => selIds, + setIds: (ids: string[]) => { selIds = ids; }, + hitTest: () => null, + }; + const ctx = makeCtx({ doc, selection: bridge }); + const del = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'delete')!; + + del.handlers.down!(pe(0, 0), ctx); + const remaining = doc.getAllElements().map((e) => e.id); + expect(remaining).toEqual(['keep']); + + expect(doc.undo()).toBe(true); + expect(doc.getAllElements().length).toBe(3); + }); + + it('Direktklick ohne Selektion löscht getroffenes Element', () => { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + const el = makeEl('hitme'); + el.x = 20; + el.y = 20; + doc.addElement(el); + const bridge = { + getIds: () => [] as string[], + setIds: () => {}, + hitTest: () => el, + }; + const ctx = makeCtx({ doc, selection: bridge }); + const del = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'delete')!; + + del.handlers.down!(pe(20, 20), ctx); + expect(doc.getAllElements().length).toBe(0); + }); +}); + +describe('A4.8 pilot: offset tool', () => { + it('erzeugt Versatz-Kopie mit Seitenparameter; Original bleibt', () => { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + const line = { + id: 'src1', type: 'line', layerId: 'l', x: 50, y: 0, + width: 100, height: 0, + properties: { x1: 0, y1: 0, x2: 100, y2: 0 }, + } as unknown as CADElement; + doc.addElement(line); + let selIds: string[] = []; + const bridge = { + getIds: () => selIds, + setIds: (ids: string[]) => { selIds = ids; }, + hitTest: () => line, + }; + const ctx = makeCtx({ doc, selection: bridge }); + const off = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'offset')!; + + off.handlers.down!(pe(50, 0), ctx); // Klick1: Quelle wählen (Basis) + off.handlers.down!(pe(50, -30), ctx); // Klick2: Versatz nach oben (side=-1) + + const all = doc.getAllElements(); + expect(all.length).toBe(2); // Original + Kopie + expect(all[0].id).toBe('src1'); // Original unberührt + expect(all[1].id.startsWith('off_')).toBe(true); + // side=-1 bei Rechtslaufender Linie → nach OBEN (y kleiner): + expect(all[1].properties.y1).toBe(-30); + expect(all[1].properties.y2).toBe(-30); + expect(all[1].properties.x1).toBe(0); // x bleibt + + expect(doc.undo()).toBe(true); + expect(doc.getAllElements().length).toBe(1); + }); +}); + describe('A3 pilot: select tool', () => { function makeBridge(elements: CADElement[]) { let ids: string[] = [];