/** * 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, trimElement, extendElement, filletElements, } 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) + Handle-Drag (Task C3fin interaktiv) // ───────────────────────────────────────────────────────────── /** Handle-Drag-Sitzung: aktives Element + gegenüberliegende Ecke. */ let handleDrag: { el: CADElement; anchorCorner: Pt; handleIndex: number; } | null = null; /** Prüft, ob (wx,wy) eine BBox-Ecke des Elements trifft (Toleranz tol). */ function hitHandle( el: CADElement, wx: number, wy: number, tol: number, ): { handleIndex: number; anchorCorner: Pt } | null { const props = el.properties as Record; let minX: number, minY: number, maxX: number, maxY: number; if ( props.x1 !== undefined && props.y1 !== undefined && props.x2 !== undefined && props.y2 !== undefined ) { minX = Math.min(props.x1 as number, props.x2 as number); minY = Math.min(props.y1 as number, props.y2 as number); maxX = Math.max(props.x1 as number, props.x2 as number); maxY = Math.max(props.y1 as number, props.y2 as number); } else { minX = el.x - el.width / 2; minY = el.y - el.height / 2; maxX = el.x + el.width / 2; maxY = el.y + el.height / 2; } const corners: Array<{ x: number; y: number }> = [ { x: minX, y: minY }, { x: maxX, y: minY }, { x: maxX, y: maxY }, { x: minX, y: maxY }, ]; for (let i = 0; i < corners.length; i++) { if ( Math.abs(wx - corners[i].x) <= tol && Math.abs(wy - corners[i].y) <= tol ) { // Gegenüberliegende Ecke = Anker (bleibt fix beim Skalieren) const anchor = corners[(i + 2) % 4]; return { handleIndex: i, anchorCorner: anchor }; } } return null; } 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; const c = ctx as FullToolContext; if (!bridge) return; const additive = e.shift; const subtractive = e.ctrl; const hit = bridge.hitTest(e.world.x, e.world.y, 5); // ── Handle-Drag-Start (Task C3fin interaktiv) ── // Wenn bereits Elemente selektiert sind und der Klick auf eine Ecke // eines davon trifft → Handle-Drag starten statt neu zu wählen. if (!additive && !subtractive && bridge.getIds().length > 0 && c.doc) { for (const id of bridge.getIds()) { const el = c.doc.getElement(id); if (!el) continue; const h = hitHandle(el, e.world.x, e.world.y, 8); if (h) { handleDrag = { el, anchorCorner: h.anchorCorner, handleIndex: h.handleIndex }; ctx.setStatus(`Handle ${h.handleIndex + 1} — skalieren durch Ziehen`); return; } } } 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)`); }, move(e, ctx) { // Live-Skalierung während Handle-Drag: if (!handleDrag) return; const c = ctx as FullToolContext; if (!c.doc) return; const el = c.doc.getElement(handleDrag.el.id); if (!el) return; const props = el.properties as Record; // BBox aus Element abrufen (mit aktuellen Koordinaten): let minX: number, minY: number, maxX: number, maxY: number; if ( props.x1 !== undefined && props.y1 !== undefined && props.x2 !== undefined && props.y2 !== undefined ) { minX = Math.min(props.x1 as number, props.x2 as number); minY = Math.min(props.y1 as number, props.y2 as number); maxX = Math.max(props.x1 as number, props.x2 as number); maxY = Math.max(props.y1 as number, props.y2 as number); } else { minX = el.x - el.width / 2; minY = el.y - el.height / 2; maxX = el.x + el.width / 2; maxY = el.y + el.height / 2; } // Neue BBox = Anker fix, gezogene Ecke = Maus: const anchor = handleDrag.anchorCorner; const newMinX = Math.min(anchor.x, e.world.x); const newMinY = Math.min(anchor.y, e.world.y); const newMaxX = Math.max(anchor.x, e.world.x); const newMaxY = Math.max(anchor.y, e.world.y); // Preview-Linie von Anker zur Maus (vereinfachtes Feedback): c.setPreview?.({ ...el, x: (newMinX + newMaxX) / 2, y: (newMinY + newMaxY) / 2, width: newMaxX - newMinX, height: newMaxY - newMinY, } as CADElement); void minX; void minY; void maxX; void maxY; }, up(e, ctx) { if (!handleDrag) return; const c = ctx as FullToolContext; if (!c.doc) { handleDrag = null; return; } const el = c.doc.getElement(handleDrag.el.id); const anchor = handleDrag.anchorCorner; if (!el) { handleDrag = null; return; } const props = el.properties as Record; // Neue BBox berechnen: const newMinX = Math.min(anchor.x, e.world.x); const newMinY = Math.min(anchor.y, e.world.y); const newMaxX = Math.max(anchor.x, e.world.x); const newMaxY = Math.max(anchor.y, e.world.y); const newW = newMaxX - newMinX; const newH = newMaxY - newMinY; const oldW = el.width || 1; const oldH = el.height || 1; const scaleX = oldW !== 0 ? newW / oldW : 1; const scaleY = oldH !== 0 ? newH / oldH : 1; // Neues Zentrum: const newCx = (newMinX + newMaxX) / 2; const newCy = (newMinY + newMaxY) / 2; c.doc.transact(() => { // Element in neuer BBox skalieren (shallow properties überschrieben): const newProps = { ...(el.properties as Record) }; // Für line: Koordinaten skalieren relativ zur neuen BBox if ( newProps.x1 !== undefined && newProps.y1 !== undefined && newProps.x2 !== undefined && newProps.y2 !== undefined ) { newProps.x1 = newMinX + ((newProps.x1 as number) - (el.x - el.width / 2)) * scaleX; newProps.y1 = newMinY + ((newProps.y1 as number) - (el.y - el.height / 2)) * scaleY; newProps.x2 = newMinX + ((newProps.x2 as number) - (el.x - el.width / 2)) * scaleX; newProps.y2 = newMinY + ((newProps.y2 as number) - (el.y - el.height / 2)) * scaleY; } c.doc!.updateElement(el.id, { x: newCx, y: newCy, width: newW, height: newH, properties: newProps, }); }); ctx.setStatus(`Skaliert auf ${newW.toFixed(0)}×${newH.toFixed(0)}`); handleDrag = null; ctx.setPreview?.(null); }, cancel(ctx) { handleDrag = null; ctx.setPreview?.(null); }, }, }; // ───────────────────────────────────────────────────────────── // 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'); // ───────────────────────────────────────────────────────────── // Join / Explode / Align (Task D-Extensions): // - join: 2+ ausgewählte Linien → eine Polyline (Transaktion) // - explode: Polyline → einzelne Liniensegmente (Transaktion) // - align: bewegt ein Element so, dass sein Anker (Zentrum) auf das // Ziel-Element-Zentrum ausgerichtet wird (3 Klicks: Quelle, Ziel, Commit) // ───────────────────────────────────────────────────────────── let alignSourceId: string | null = null; function makeJoinExplodeAlignTool(mode: 'join' | 'explode' | 'align'): ToolExtensionV2 { const labels = { join: 'Join', explode: 'Explode', align: 'Align' } as const; return { manifest: { id: mode, label: labels[mode], icon: mode === 'join' ? '\u2795' : mode === 'explode' ? '\u2737' : '\u21c4', ribbonTab: 'canvas', tags: ['pro'], description: `${labels[mode]}: siehe Statuszeile`, }, optionsSchema: [], handlers: { down(e, ctx) { const c = ctx as FullToolContext; if (!c.doc || !c.selection) return; const ids = c.selection.getIds(); if (mode === 'join') { // Alle selektierten Linien → eine Polyline: const lines = ids .map((id) => c.doc!.getElement(id)) .filter((el): el is CADElement => !!el && el.type === 'line'); if (lines.length < 2) { ctx.setStatus('Join: mindestens 2 Linien auswählen'); return; } // Punkte der Linien in Reihenfolge sammeln: const allPts: Pt[] = []; for (const ln of lines) { const props = ln.properties as Record; if (props.x1 === undefined) continue; allPts.push({ x: props.x1 as number, y: props.y1 as number }); allPts.push({ x: props.x2 as number, y: props.y2 as number }); } if (allPts.length < 4) return; // Erste und letzte Koordinate als Start/Ende nehmen: const first = allPts[0]; const last = allPts[allPts.length - 1]; const joined: CADElement = { ...lines[0], id: uid('pl'), type: 'polyline', x: (first.x + last.x) / 2, y: (first.y + last.y) / 2, width: Math.abs(last.x - first.x), height: Math.abs(last.y - first.y), properties: { points: allPts, stroke: '#e0e0e0', strokeWidth: 1 }, }; c.doc.transact(() => { for (const ln of lines) c.doc!.deleteElements([ln.id]); c.doc!.addElement(joined); }); ctx.setStatus(`Join: ${lines.length} Linien → 1 Polyline`); return; } if (mode === 'explode') { // Polyline → einzelne Liniensegmente: const polys = ids .map((id) => c.doc!.getElement(id)) .filter((el): el is CADElement => !!el && el.type === 'polyline'); if (polys.length === 0) { ctx.setStatus('Explode: mindestens eine Polyline auswählen'); return; } c.doc.transact(() => { for (const poly of polys) { const props = poly.properties as Record; const pts = Array.isArray(props.points) ? (props.points as unknown[]).map((p) => p as Pt) : []; for (let i = 0; i < pts.length - 1; i++) { c.doc!.addElement({ ...poly, id: uid('ln'), type: 'line', x: (pts[i].x + pts[i + 1].x) / 2, y: (pts[i].y + pts[i + 1].y) / 2, width: Math.abs(pts[i + 1].x - pts[i].x), height: Math.abs(pts[i + 1].y - pts[i].y), properties: { x1: pts[i].x, y1: pts[i].y, x2: pts[i + 1].x, y2: pts[i + 1].y, stroke: '#e0e0e0', strokeWidth: 1 }, }); } c.doc!.deleteElements([poly.id]); } }); ctx.setStatus(`Explode: ${polys.length} Polyline(n) in Segmente zerlegt`); return; } // align: Klick 1 = Quelle (Element), Klick 2 = Ziel-Position if (mode === 'align') { if (ids.length === 0) { const hit = c.selection.hitTest(e.world.x, e.world.y, 5); if (!hit) return; c.selection.setIds([hit.id]); alignSourceId = hit.id; ctx.setStatus('Align: Ziel-Position klicken'); return; } const el = c.doc.getElement(ids[0]); if (!el) return; const cx = el.x; const cy = el.y; c.doc.transact(() => { c.doc!.updateElement(el.id, { x: e.world.x, y: e.world.y }); }); ctx.setStatus(`Align: ${el.id} nach (${e.world.x.toFixed(0)}, ${e.world.y.toFixed(0)}) verschoben`); alignSourceId = null; return; } }, cancel(ctx) { alignSourceId = null; ctx.setStatus(`${labels[mode]} abgebrochen`); }, }, }; } export const joinTool: ToolExtensionV2 = makeJoinExplodeAlignTool('join'); export const explodeTool: ToolExtensionV2 = makeJoinExplodeAlignTool('explode'); export const alignTool: ToolExtensionV2 = makeJoinExplodeAlignTool('align'); // ───────────────────────────────────────────────────────────── // Array rect / polar (Task D-Extensions): // Vervielfältigt die aktuelle Selektion in einem Raster bzw. im Kreis. // Klick 1: Basis-/Zentrumspunkt. Klick 2: Commit. // rect: Options rows, cols, dx, dy (Abstände). // polar: Options count, optional angleSum (Default 360). // Jede Aktion = EINE Transaktion → 1 Undo-Schritt. // ───────────────────────────────────────────────────────────── let arrayBase: Pt | null = null; function makeArrayTool(mode: 'array-rect' | 'array-polar'): ToolExtensionV2 { const labels = { 'array-rect': 'Array Rect', 'array-polar': 'Array Polar' } as const; return { manifest: { id: mode, label: labels[mode], icon: mode === 'array-rect' ? '\u25a6' : '\u273b', ribbonTab: 'canvas', tags: ['pro'], description: `${labels[mode]}: Auswahl treffen, dann Basis\u2192Ziel klicken`, }, optionsSchema: mode === 'array-rect' ? [ { key: 'rows', label: 'Zeilen', type: 'number', min: 1, max: 50 }, { key: 'cols', label: 'Spalten', type: 'number', min: 1, max: 50 }, { key: 'dx', label: 'Abstand X', type: 'number', min: 1 }, { key: 'dy', label: 'Abstand Y', type: 'number', min: 1 }, ] : [ { key: 'count', label: 'Anzahl', type: 'number', min: 2, max: 100 }, ], handlers: { down(e, ctx) { const c = ctx as FullToolContext; if (!c.doc || !c.selection) return; if (!arrayBase) { 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]); } arrayBase = e.world; ctx.setStatus(`${labels[mode]}: Zielklick zum Anwenden (${c.selection.getIds().length} Elemente)`); return; } // Zweiter Klick → Commit const ids = c.selection.getIds(); const base = arrayBase; c.doc.transact(() => { if (mode === 'array-rect') { const rows = (c.options.rows as number | undefined) ?? 3; const cols = (c.options.cols as number | undefined) ?? 3; const dx = (c.options.dx as number | undefined) ?? 50; const dy = (c.options.dy as number | undefined) ?? 50; for (const id of ids) { const el = c.doc!.getElement(id); if (!el) continue; for (let r = 0; r < rows; r++) { for (let col = 0; col < cols; col++) { if (r === 0 && col === 0) continue; // Original überspringen c.doc!.addElement({ ...el, id: uid('arr'), x: el.x + col * dx, y: el.y + r * dy, }); } } } } else { const count = (c.options.count as number | undefined) ?? 6; // Zentrum = Basispunkt for (const id of ids) { const el = c.doc!.getElement(id); if (!el) continue; for (let i = 1; i < count; i++) { const angle = (2 * Math.PI * i) / count; const cx = base.x; const cy = base.y; const cos = Math.cos(angle); const sin = Math.sin(angle); // Position rotieren um Zentrum: const relX = el.x - cx; const relY = el.y - cy; c.doc!.addElement({ ...el, id: uid('arr'), x: cx + relX * cos - relY * sin, y: cy + relX * sin + relY * cos, }); } } } }); ctx.setStatus(`${labels[mode]}: ${ids.length} Element(e) vervielfältigt`); arrayBase = null; }, cancel(ctx) { arrayBase = null; ctx.setStatus(`${labels[mode]} abgebrochen`); }, }, }; } export const arrayRectTool: ToolExtensionV2 = makeArrayTool('array-rect'); export const arrayPolarTool: ToolExtensionV2 = makeArrayTool('array-polar'); // ───────────────────────────────────────────────────────────── // Chamfer (Task D-Ext): schneidet zwei sich schneidende Linien an einer // festen Distanz vom Schnittpunkt und verbindet die Enden mit einer // geraden Fase. Legacy nutzt trimElement + Verbindungslinie. // Klick 1: erste Linie, Klick 2: zweite Linie → 3 Elemente (2 gekürzt + 1 Fase). // ───────────────────────────────────────────────────────────── let chamferFirst: CADElement | null = null; export const chamferTool: ToolExtensionV2 = { manifest: { id: 'chamfer', label: 'Chamfer', icon: '\u25e4', ribbonTab: 'canvas', tags: ['pro'], description: 'Zwei Linien anklicken — Fase wird eingefügt', }, optionsSchema: [ { key: 'chamferDist', label: 'Fase-Distanz', type: 'number', min: 1, max: 100 }, ], handlers: { down(e, ctx) { const c = ctx as FullToolContext; if (!c.doc || !c.selection) return; const hit = c.selection.hitTest(e.world.x, e.world.y, 5); if (!hit) return; if (!chamferFirst) { chamferFirst = hit; ctx.setStatus('Chamfer: zweite Linie wählen'); return; } const dist = (c.options.chamferDist as number | undefined) ?? 10; // Beide Linien auf dist vom Schnittpunkt kürzen: const t1 = trimElement(chamferFirst, hit); const t2 = trimElement(hit, chamferFirst); if (!t1 || !t2) { ctx.setStatus('Chamfer: kein Schnittpunkt'); chamferFirst = null; return; } // Endpunkte nahe am Schnittpunkt holen und um dist zurücksetzen: const props1 = t1.properties as { x1?: number; y1?: number; x2?: number; y2?: number }; const props2 = t2.properties as { x1?: number; y1?: number; x2?: number; y2?: number }; if (props1.x1 === undefined || props1.y1 === undefined || props1.x2 === undefined || props1.y2 === undefined || props2.x1 === undefined || props2.y1 === undefined || props2.x2 === undefined || props2.y2 === undefined) { chamferFirst = null; return; } // Nahe Enden (Schnittpunkt-Seite) identifizieren via Schnittpunkt: const isectPt = findIsectPoint(chamferFirst, hit); if (!isectPt) { chamferFirst = null; return; } const near1 = nearEnd(props1 as { x1: number; y1: number; x2: number; y2: number }, isectPt); const near2 = nearEnd(props2 as { x1: number; y1: number; x2: number; y2: number }, isectPt); // Kürze nahe Enden auf dist vom Schnittpunkt: const shrink1 = shrinkTowards(props1 as { x1: number; y1: number; x2: number; y2: number }, near1, isectPt, dist); const shrink2 = shrinkTowards(props2 as { x1: number; y1: number; x2: number; y2: number }, near2, isectPt, dist); // Fase: Linie zwischen den neuen Enden: const chamferLine: CADElement = { ...chamferFirst, id: uid('cham'), type: 'line', x: (shrink1.near.x + shrink2.near.x) / 2, y: (shrink1.near.y + shrink2.near.y) / 2, width: Math.abs(shrink2.near.x - shrink1.near.x), height: Math.abs(shrink2.near.y - shrink1.near.y), properties: { x1: shrink1.near.x, y1: shrink1.near.y, x2: shrink2.near.x, y2: shrink2.near.y, stroke: '#e0e0e0', strokeWidth: 1, }, }; c.doc.transact(() => { c.doc!.updateElement(chamferFirst!.id, { x: shrink1.cx, y: shrink1.cy, width: Math.abs(props1.x2! - props1.x1!), height: Math.abs(props1.y2! - props1.y1!), properties: props1, }); c.doc!.updateElement(hit.id, { x: shrink2.cx, y: shrink2.cy, width: Math.abs(props2.x2! - props2.x1!), height: Math.abs(props2.y2! - props2.y1!), properties: props2, }); c.doc!.addElement(chamferLine); }); ctx.setStatus('Chamfer erstellt'); chamferFirst = null; }, cancel(ctx) { chamferFirst = null; ctx.setStatus('Chamfer abgebrochen'); }, }, }; function findIsectPoint(a: CADElement, b: CADElement): Pt | null { const pa = a.properties as { x1?: number; y1?: number; x2?: number; y2?: number }; const pb = b.properties as { x1?: number; y1?: number; x2?: number; y2?: number }; if (pa.x1 === undefined || pb.x1 === undefined) return null; const denom = (pa.x1! - pa.x2!) * (pb.y1! - pb.y2!) - (pa.y1! - pa.y2!) * (pb.x1! - pb.x2!); if (Math.abs(denom) < 1e-10) return null; const t = ((pa.x1! - pb.x1!) * (pb.y1! - pb.y2!) - (pa.y1! - pb.y1!) * (pb.x1! - pb.x2!)) / denom; return { x: pa.x1! + t * (pa.x2! - pa.x1!), y: pa.y1! + t * (pa.y2! - pa.y1!) }; } function nearEnd(props: { x1: number; y1: number; x2: number; y2: number }, pt: Pt): 'start' | 'end' { const d1 = Math.hypot(props.x1 - pt.x, props.y1 - pt.y); const d2 = Math.hypot(props.x2 - pt.x, props.y2 - pt.y); return d1 < d2 ? 'start' : 'end'; } function shrinkTowards( props: { x1: number; y1: number; x2: number; y2: number }, near: 'start' | 'end', isectPt: Pt, dist: number, ): { near: Pt; cx: number; cy: number } { if (near === 'start') { const len = Math.hypot(props.x2 - props.x1, props.y2 - props.y1); if (len === 0) return { near: { x: props.x1, y: props.y1 }, cx: props.x1, cy: props.y1 }; const ratio = Math.min(dist / len, 1); const nx = props.x1 + (props.x2 - props.x1) * ratio; const ny = props.y1 + (props.y2 - props.y1) * ratio; return { near: { x: nx, y: ny }, cx: (nx + props.x2) / 2, cy: (ny + props.y2) / 2 }; } const len = Math.hypot(props.x2 - props.x1, props.y2 - props.y1); if (len === 0) return { near: { x: props.x2, y: props.y2 }, cx: props.x2, cy: props.y2 }; const ratio = Math.min(dist / len, 1); const nx = props.x2 + (props.x1 - props.x2) * ratio; const ny = props.y2 + (props.y1 - props.y2) * ratio; return { near: { x: nx, y: ny }, cx: (nx + props.x1) / 2, cy: (ny + props.y1) / 2 }; } // ───────────────────────────────────────────────────────────── // Lengthen (Task D-Ext): verlängert/verkürzt eine Linie am geklickten // Ende um einen Delta-Wert. Klick 1: Linie, Klick 2: Seite. // ───────────────────────────────────────────────────────────── let lengthenEl: CADElement | null = null; export const lengthenTool: ToolExtensionV2 = { manifest: { id: 'lengthen', label: 'Lengthen', icon: '\u2194', ribbonTab: 'canvas', tags: ['pro'], description: 'Linie anklicken, dann Delta-Ziel klicken (Options: lengthenDelta)', }, optionsSchema: [ { key: 'lengthenDelta', label: 'Delta', type: 'number' }, ], handlers: { down(e, ctx) { const c = ctx as FullToolContext; if (!c.doc || !c.selection) return; if (!lengthenEl) { const hit = c.selection.hitTest(e.world.x, e.world.y, 5); if (!hit || hit.type !== 'line') { ctx.setStatus('Lengthen: nur Linien'); return; } lengthenEl = hit; ctx.setStatus('Lengthen: Klick in Verlängerungsrichtung'); return; } const el = c.doc.getElement(lengthenEl.id); if (!el) { lengthenEl = null; return; } const props = el.properties as { x1?: number; y1?: number; x2?: number; y2?: number }; if (props.x1 === undefined || props.y1 === undefined || props.x2 === undefined || props.y2 === undefined) { lengthenEl = null; return; } const delta = (c.options.lengthenDelta as number | undefined) ?? 10; // Wähle das Ende, das dem Klick am nächsten ist: const dStart = Math.hypot(e.world.x - (props.x1 as number), e.world.y - (props.y1 as number)); const dEnd = Math.hypot(e.world.x - (props.x2 as number), e.world.y - (props.y2 as number)); const nearIsStart = dStart < dEnd; // Richtungsvektor (vom fernen Ende zum nahen Ende): const dirX = nearIsStart ? (props.x1! - props.x2!) : (props.x2! - props.x1!); const dirY = nearIsStart ? (props.y1! - props.y2!) : (props.y2! - props.y1!); const len = Math.hypot(dirX, dirY); if (len === 0) { lengthenEl = null; return; } const ux = dirX / len; const uy = dirY / len; if (nearIsStart) { props.x1 = (props.x1 as number) + ux * delta; props.y1 = (props.y1 as number) + uy * delta; } else { props.x2 = (props.x2 as number) + ux * delta; props.y2 = (props.y2 as number) + uy * delta; } const nx1 = props.x1 as number; const ny1 = props.y1 as number; const nx2 = props.x2 as number; const ny2 = props.y2 as number; c.doc.transact(() => { c.doc!.updateElement(el.id, { x: (nx1 + nx2) / 2, y: (ny1 + ny2) / 2, width: Math.abs(nx2 - nx1), height: Math.abs(ny2 - ny1), properties: props, }); }); ctx.setStatus(`Lengthen: Linie um ${delta} angepasst`); lengthenEl = null; }, cancel(ctx) { lengthenEl = null; ctx.setStatus('Lengthen abgebrochen'); }, }, }; // ───────────────────────────────────────────────────────────── // Trim / Extend / Fillet (Task A4.10) — baut auf BUG-2-reparierter // findIntersection-Geometrie (B2). Legacy-Ablauf: // - trim/extend: Klick1=Boundary, Klick2=Ziel → Ziel wird angepasst // - fillet: Klick1+Klick2 = zwei Elemente → Paar wird angepasst // Jede Operation committed in EINER Transaktion. // ───────────────────────────────────────────────────────────── let twoPickBoundary: CADElement | null = null; let twoPickFirst: CADElement | null = null; function make2PickGeometryTool( mode: 'trim' | 'extend' | 'fillet', filletRadius: number, ): ToolExtensionV2 { const labels = { trim: 'Trimmen', extend: 'Verlängern', fillet: 'Fillet' } as const; return { manifest: { id: mode, label: labels[mode], icon: mode === 'fillet' ? '\u23dc' : '\u2717', ribbonTab: 'canvas', tags: ['pro'], description: `${labels[mode]}: ${mode === 'fillet' ? 'zwei' : 'Kante, dann'} Element(e) anklicken`, }, optionsSchema: [], handlers: { down(e, ctx) { const c = ctx as FullToolContext; if (!c.doc || !c.selection) return; const hit = c.selection.hitTest(e.world.x, e.world.y, 5); if (!hit) return; if (mode === 'fillet') { if (!twoPickFirst) { twoPickFirst = hit; ctx.setStatus(`Fillet: zweites Element wählen`); return; } // Zweiter Pick → Paar-Commit const second = hit; const pair = filletElements(twoPickFirst, second, filletRadius); if (!pair) { ctx.setStatus('Fillet: kein Schnittpunkt gefunden'); twoPickFirst = null; return; } const idA = twoPickFirst.id; const idB = second.id; c.doc.transact(() => { c.doc!.updateElement(idA, { x: pair[0].x, y: pair[0].y, width: pair[0].width, height: pair[0].height, properties: pair[0].properties, }); c.doc!.updateElement(idB, { x: pair[1].x, y: pair[1].y, width: pair[1].width, height: pair[1].height, properties: pair[1].properties, }); }); ctx.setStatus(`Fillet angewendet (${idA}, ${idB})`); twoPickFirst = null; return; } // trim/extend: Boundary dann Ziel if (!twoPickBoundary) { twoPickBoundary = hit; ctx.setStatus(`${labels[mode]}: jetzt das zu bearbeitende Element wählen`); return; } const target = hit; const fn = mode === 'trim' ? trimElement : extendElement; const result = fn(target, twoPickBoundary); if (!result) { ctx.setStatus(`${labels[mode]}: keine Schnittmenge mit der Kante`); twoPickBoundary = null; return; } c.doc.transact(() => { c.doc!.updateElement(target.id, { x: result.x, y: result.y, width: result.width, height: result.height, properties: result.properties, }); }); ctx.setStatus(`${labels[mode]} auf ${target.id} angewendet`); twoPickBoundary = null; }, cancel(ctx) { twoPickBoundary = null; twoPickFirst = null; ctx.setStatus(`${labels[mode]} abgebrochen`); }, }, }; } export const trimTool: ToolExtensionV2 = make2PickGeometryTool('trim', 0); export const extendTool: ToolExtensionV2 = make2PickGeometryTool('extend', 0); export const filletTool: ToolExtensionV2 = make2PickGeometryTool('fillet', 5); // 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.8.0', author: 'web-cad team', description: 'Auswahl, Schraffur, Move/Copy/Rotate/Scale/Mirror/Delete/Offset/Trim/Extend/Fillet/Arrays/Chamfer/Lengthen/Join/Explode/Align (V2).', category: 'tools', enabledByDefault: true, }, tools: [selectTool, hatchTool, moveTool, copyTool, rotateTool, scaleTool, mirrorTool, deleteTool, offsetTool, trimTool, extendTool, filletTool, arrayRectTool, arrayPolarTool, chamferTool, lengthenTool, joinTool, explodeTool, alignTool], };