/** * core-modify – Built-in V2-Plugin. * * 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, offsetElement, } from '../../../tools/modification/geometry'; import type { Pt } from '../../../tools/modification/geometry'; 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', label: 'Auswahl', icon: '\u2196', ribbonTab: 'canvas', tags: ['basic'], description: 'Elemente anklicken zum Auswählen', }, optionsSchema: [], handlers: { down(e, ctx) { 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) { 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]); } bridge.setIds(Array.from(ids)); ctx.setStatus(`Selektion: ${ids.size} Element(e)`); }, }, }; // ───────────────────────────────────────────────────────────── // hatch (Task A4.5) // ───────────────────────────────────────────────────────────── 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 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})`); }, }, }; // ───────────────────────────────────────────────────────────── // Move/Copy (Task A4.6) // ───────────────────────────────────────────────────────────── let modBasePoint: Pt | null = null; function makeMoveCopyTool(mode: 'move' | 'copy'): ToolExtensionV2 { const label = mode === 'move' ? 'Verschieben' : 'Kopieren'; return { manifest: { id: mode, label, icon: mode === 'move' ? '\u27a4' : '\u29c9', ribbonTab: 'canvas', tags: ['basic'], description: `${label}: Auswahl treffen, dann Basis\u2192Ziel klicken`, }, optionsSchema: [], handlers: { down(e, ctx) { const c = ctx as FullToolContext; if (!c.doc || !c.selection) return; if (!modBasePoint) { const ids = c.selection.getIds(); if (ids.length === 0) { const hit = c.selection.hitTest(e.world.x, e.world.y, 5); if (!hit) { ctx.setStatus(`${label}: zuerst Element(e) auswählen`); return; } c.selection.setIds([hit.id]); } modBasePoint = e.world; ctx.setStatus(`${label}: Zielklick zum ${mode === 'move' ? 'Verschieben' : 'Kopieren'} (${c.selection.getIds().length} Elemente)`); return; } const dx = e.world.x - modBasePoint.x; const dy = e.world.y - modBasePoint.y; const ids = c.selection.getIds(); c.doc.transact(() => { for (const id of ids) { const el = c.doc!.getElement(id); if (!el) continue; if (mode === 'move') { c.doc!.updateElement(id, { x: el.x + dx, y: el.y + dy }); } else { c.doc!.addElement({ ...el, id: uid('cp'), x: el.x + dx, y: el.y + dy, }); } } }); ctx.setStatus(`${label}: ${ids.length} Element(e), \u0394(${dx.toFixed(0)}, ${dy.toFixed(0)})`); modBasePoint = null; }, cancel(ctx) { modBasePoint = null; ctx.setStatus(`${label} abgebrochen`); }, }, }; } export const moveTool: ToolExtensionV2 = makeMoveCopyTool('move'); export const copyTool: ToolExtensionV2 = makeMoveCopyTool('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'); // ───────────────────────────────────────────────────────────── // 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.4.0', author: 'web-cad team', description: 'Auswahl, Schraffur, Verschieben, Kopieren, Rotieren, Skalieren, Spiegeln, Löschen, Versatz (V2).', category: 'tools', enabledByDefault: true, }, tools: [selectTool, hatchTool, moveTool, copyTool, rotateTool, scaleTool, mirrorTool, deleteTool, offsetTool], };