/** * 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 { openBlockForEdit, saveBlockEdit, discardBlockEdit } from '../../../services/blockEditorService'; import { BLOCK_EDIT_MARKER } from '../../../services/blockEditorService'; export { BLOCK_EDIT_MARKER }; 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; } // ───────────────────────────────────────────────────────────── // Task D9: GripEditing — Vertex-/Center-Grips (reine Funktion). // ───────────────────────────────────────────────────────────── export interface GripPoint { kind: 'vertex' | 'center' | 'corner'; point: Pt; index: number; } /** * Element-spezifische Griff-Punkte (Task D9): * line = beide Endpunkte, polyline/polygon = alle Vertices, * circle/arc = Zentrum, rect/default = 4 BBox-Ecken (C3fin-Skalierung). */ export function getGripPoints(el: CADElement): GripPoint[] { const props = el.properties as Record; const type = String(el.type); if ( type === 'line' && props.x1 !== undefined && props.y1 !== undefined && props.x2 !== undefined && props.y2 !== undefined ) { return [ { kind: 'vertex', point: { x: props.x1 as number, y: props.y1 as number }, index: 0 }, { kind: 'vertex', point: { x: props.x2 as number, y: props.y2 as number }, index: 1 }, ]; } if (type === 'polyline' || type === 'polygon') { const pts = (props.points as Pt[] | undefined) ?? []; return pts.map((p, i) => ({ kind: 'vertex' as const, point: { x: p.x, y: p.y }, index: i })); } if (type === 'circle' || type === 'arc') { return [{ kind: 'center', point: { x: el.x, y: el.y }, index: 0 }]; } // rect und alle anderen: BBox-Ecken (Corner-Drag/Skalierung, C3fin) const minX = el.x - el.width / 2; const minY = el.y - el.height / 2; const maxX = el.x + el.width / 2; const maxY = el.y + el.height / 2; return [ { kind: 'corner', point: { x: minX, y: minY }, index: 0 }, { kind: 'corner', point: { x: maxX, y: minY }, index: 1 }, { kind: 'corner', point: { x: maxX, y: maxY }, index: 2 }, { kind: 'corner', point: { x: minX, y: maxY }, index: 3 }, ]; } /** * Wendet eine Vertex-/Center-Drag-Position an (Task D9) und liefert * das aktualisierte Element (reine Funktion — für Preview UND Commit). */ function applyVertexDrag( el: CADElement, drag: { el: CADElement; gripKind: 'vertex' | 'center'; index: number }, wx: number, wy: number, ): CADElement { const props = { ...(el.properties as Record) }; const type = String(el.type); if (drag.gripKind === 'center') { return { ...el, x: wx, y: wy, properties: props as CADElement['properties'] } as CADElement; } if (type === 'line') { if (drag.index === 0) { props.x1 = wx; props.y1 = wy; } else { props.x2 = wx; props.y2 = wy; } // BBox aktualisieren const x1 = props.x1 as number, y1 = props.y1 as number, x2 = props.x2 as number, y2 = props.y2 as number; return { ...el, x: (x1 + x2) / 2, y: (y1 + y2) / 2, width: Math.abs(x2 - x1), height: Math.abs(y2 - y1), properties: props as CADElement['properties'], } as CADElement; } if (type === 'polyline' || type === 'polygon') { const pts = [...((props.points as Pt[] | undefined) ?? [])]; if (drag.index < pts.length) pts[drag.index] = { x: wx, y: wy }; props.points = pts; const xs = pts.map((p) => p.x), ys = pts.map((p) => p.y); const minX = Math.min(...xs), minY = Math.min(...ys); const maxX = Math.max(...xs), maxY = Math.max(...ys); return { ...el, x: minX + (maxX - minX) / 2, y: minY + (maxY - minY) / 2, width: maxX - minX, height: maxY - minY, properties: props as CADElement['properties'], } as CADElement; } return el; } // ───────────────────────────────────────────────────────────── // Task CAD-3: Fenster-/Kreuz-Selektion (Box-Select). // Links-nach-rechts ziehen = 'window' (Element muss VOLLSTAENDIG // in der Box liegen), rechts-nach-links = 'crossing' (Beruehrung // reicht). Leerer down startet Box-Drag statt Selektion zu leeren. // ───────────────────────────────────────────────────────────── /** Richtung bestimmt den Modus: to.x >= from.x → window, sonst crossing. */ export function boxModeFor(from: Pt, to: Pt): 'window' | 'crossing' { return to.x >= from.x ? 'window' : 'crossing'; } /** * Selektiert alle Elemente, die die Box erfuellen. * window: BBox des Elements VOLLSTAENDIG in der Box. * crossing: BBox des Elements ueberschneidet/beruehrt die Box. */ export function boxSelectElements( elements: CADElement[], box: { x1: number; y1: number; x2: number; y2: number }, mode: 'window' | 'crossing', ): string[] { const minX = Math.min(box.x1, box.x2); const maxX = Math.max(box.x1, box.x2); const minY = Math.min(box.y1, box.y2); const maxY = Math.max(box.y1, box.y2); const hits: string[] = []; for (const el of elements) { const b = elementBBox(el); if (!b) continue; if (mode === 'window') { if (b.minX >= minX && b.maxX <= maxX && b.minY >= minY && b.maxY <= maxY) { hits.push(el.id); } } else { // crossing: Ueberschneidung (Beruehrung inklusive) if (b.minX <= maxX && b.maxX >= minX && b.minY <= maxY && b.maxY >= minY) { hits.push(el.id); } } } return hits; } /** BBox eines Elements (line aus Koordinaten, sonst x/y±w/2). */ function elementBBox(el: CADElement): { minX: number; minY: number; maxX: number; maxY: number } | null { const props = el.properties as Record; if ( props.x1 !== undefined && props.y1 !== undefined && props.x2 !== undefined && props.y2 !== undefined ) { const x1 = props.x1 as number, y1 = props.y1 as number; const x2 = props.x2 as number, y2 = props.y2 as number; return { minX: Math.min(x1, x2), minY: Math.min(y1, y2), maxX: Math.max(x1, x2), maxY: Math.max(y1, y2) }; } const w = el.width ?? 0, h = el.height ?? 0; return { minX: el.x - w / 2, minY: el.y - h / 2, maxX: el.x + w / 2, maxY: el.y + h / 2 }; } /** Box-Drag-Sitzung (Task CAD-3). */ let boxDrag: { start: Pt; shift: boolean } | null = null; /** Mindestgroesse (Welt-Einheiten) ab der ein Zug als Box gilt. */ const BOX_MIN_SIZE = 3; /** Vertex-/Center-Drag-Sitzung (Task D9). */ let vertexDrag: { el: CADElement; gripKind: 'vertex' | 'center'; index: number; } | null = 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; // CAD-3: verlorene Box-Sitzungen abräumen (defensive State-Hygiene, // schützt auch vor verlorenen up-Events); wird unten bei Leerraum neu gesetzt. boxDrag = null; const additive = e.shift; const subtractive = e.ctrl; const hit = bridge.hitTest(e.world.x, e.world.y, 5); // ── Task D9: Vertex-/Center-Grip-Prüfung VOR BBox-Corners ── // (spezifischere Grips gewinnen gegenüber Corner-Skalierung) if (!additive && !subtractive && c.doc) { const GRIP_TOL = 8; for (const id of bridge.getIds()) { const el = c.doc.getElement(id); if (!el) continue; for (const g of getGripPoints(el)) { if (g.kind === 'corner') continue; // Corner unten (C3fin) if ( Math.abs(e.world.x - g.point.x) <= GRIP_TOL && Math.abs(e.world.y - g.point.y) <= GRIP_TOL ) { vertexDrag = { el, gripKind: g.kind, index: g.index }; const what = g.kind === 'center' ? 'Zentrum' : `Punkt ${g.index + 1}`; ctx.setStatus(`${what} gegriffen — ziehen zum Verschieben`); return; } } } } // ── 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) { // ── Task CAD-3: Leerraum-Klick startet Box-Select-Drag ── // (statt sofort zu leeren; das Leeren passiert nur bei Klick OHNE Zug) boxDrag = { start: e.world, shift: e.shift }; ctx.setStatus('Box-Auswahl: ziehen (rechts=fenster, links=kreuzend)'); 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) { // ── Task CAD-3: Box-Select-Preview ── if (boxDrag) { const c = ctx as FullToolContext; const x = Math.min(boxDrag.start.x, e.world.x); const y = Math.min(boxDrag.start.y, e.world.y); const w = Math.abs(e.world.x - boxDrag.start.x); const h = Math.abs(e.world.y - boxDrag.start.y); const mode = boxModeFor(boxDrag.start, e.world); c.setPreview?.({ id: '__preview_boxselect__', type: 'rect', layerId: 'layer-0', x: x + w / 2, y: y + h / 2, width: w, height: h, properties: { stroke: mode === 'window' ? '#4a90d9' : '#22c55e', strokeWidth: 1, fill: mode === 'window' ? 'rgba(74,144,217,0.08)' : 'rgba(34,197,94,0.08)', boxSelectMode: mode, }, } as unknown as CADElement); return; } // ── Task D9: Vertex-Drag-Preview (nur der Punkt aktualisiert) ── if (vertexDrag) { const c = ctx as FullToolContext; const el = c.doc?.getElement(vertexDrag.el.id); if (!el) return; const updated = applyVertexDrag(el, vertexDrag, e.world.x, e.world.y); c.setPreview?.(updated); return; } // 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) { // ── Task CAD-3: Box-Select-Abschluss ── if (boxDrag) { const c = ctx as FullToolContext; const start = boxDrag.start; const shift = boxDrag.shift || e.shift; boxDrag = null; ctx.setPreview?.(null); const w = Math.abs(e.world.x - start.x); const h = Math.abs(e.world.y - start.y); // Winzige Box = Klick ohne Zug → Legacy: Selektion leeren if (w < BOX_MIN_SIZE && h < BOX_MIN_SIZE) { if (!shift) (c.selection ?? { setIds: () => {} }).setIds([]); ctx.setStatus('Selektion geleert'); return; } const mode = boxModeFor(start, e.world); const all = c.doc ? c.doc.getAllElements() : []; const hits = boxSelectElements(all, { x1: start.x, y1: start.y, x2: e.world.x, y2: e.world.y, }, mode); const bridge = (ctx as FullToolContext).selection; if (!bridge) return; if (shift) { const merged = new Set([...bridge.getIds(), ...hits]); bridge.setIds(Array.from(merged)); } else { bridge.setIds(hits); } const count = shift ? new Set([...(bridge.getIds())]).size : hits.length; ctx.setStatus(`Box-Auswahl (${mode === 'window' ? 'Fenster' : 'Kreuzend'}): ${count} Element(e)`); return; } // ── Task D9: Vertex-Commit (nur der Punkt, 1 Undo-Schritt) ── if (vertexDrag) { const c = ctx as FullToolContext; const el = c.doc?.getElement(vertexDrag.el.id); if (el && c.doc) { const updated = applyVertexDrag(el, vertexDrag, e.world.x, e.world.y); c.doc.transact(() => { c.doc!.updateElement(el.id, { x: updated.x, y: updated.y, properties: updated.properties as CADElement['properties'], }); }); const what = vertexDrag.gripKind === 'center' ? 'Zentrum' : `Punkt ${vertexDrag.index + 1}`; ctx.setStatus(`${what} verschoben`); } vertexDrag = null; ctx.setPreview?.(null); return; } 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; vertexDrag = null; boxDrag = 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: 'ANSI31', label: 'ANSI31 (45°)' }, { value: 'ANSI32', label: 'ANSI32 (Eisen)' }, { value: 'ANSI37', label: 'ANSI37 (Beton)' }, { value: 'NET', label: 'Netz' }, { value: 'GRASS', label: 'Gras' }, { value: 'DOTS', label: 'Punkte' }, { value: 'SOLID', label: 'Voll' }, { value: 'lines', label: 'Linien (alt)' }, { value: 'cross', label: 'Kreuz (alt)' }, ] }, { 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'); // ───────────────────────────────────────────────────────────── // Polyline-Editor (Task D-Finish): Vertices einer Polyline verschieben. // Klick 1: Polyline wählen. Klick 2+: Vertex wählen (Toleranz) und ziehen. // ESC beendet. // ───────────────────────────────────────────────────────────── let editPoly: CADElement | null = null; let editVertexIdx = -1; export const polylineEditTool: ToolExtensionV2 = { manifest: { id: 'polyline-edit', label: 'Polyline-Editor', icon: '\u2b1a', ribbonTab: 'canvas', tags: ['pro'], description: 'Polyline wählen, dann Vertex ziehen', }, optionsSchema: [], handlers: { down(e, ctx) { const c = ctx as FullToolContext; if (!c.doc || !c.selection) return; if (!editPoly) { const hit = c.selection.hitTest(e.world.x, e.world.y, 5); if (!hit || hit.type !== 'polyline') { ctx.setStatus('Polyline-Editor: erst eine Polyline anklicken'); return; } editPoly = hit; ctx.setStatus('Polyline-Editor: Vertex ziehen, ESC beendet'); return; } // Vertex finden in der Nähe: const props = editPoly.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; i++) { if (Math.hypot(pts[i].x - e.world.x, pts[i].y - e.world.y) < 8) { editVertexIdx = i; ctx.setStatus(`Vertex ${i + 1} — ziehen`); return; } } ctx.setStatus('Kein Vertex in der Nähe'); }, move(e, ctx) { if (!editPoly || editVertexIdx < 0) return; const c = ctx as FullToolContext; const props = editPoly.properties as Record; const pts = Array.isArray(props.points) ? (props.points as unknown[]).map((p) => p as Pt) : []; pts[editVertexIdx] = e.world; const preview: CADElement = { ...editPoly, properties: { ...props, points: pts }, }; c.setPreview?.(preview); }, up(e, ctx) { if (!editPoly || editVertexIdx < 0) return; const c = ctx as FullToolContext; if (!c.doc) { editVertexIdx = -1; return; } const current = c.doc.getElement(editPoly.id); if (!current) { editVertexIdx = -1; return; } const props = current.properties as Record; const pts = Array.isArray(props.points) ? (props.points as unknown[]).map((p) => ({ ...(p as Pt) })) : []; if (editVertexIdx < pts.length) { pts[editVertexIdx] = e.world; const xs = pts.map((p) => p.x); const ys = pts.map((p) => p.y); c.doc.transact(() => { c.doc!.updateElement(editPoly!.id, { 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: { ...props, points: pts }, }); }); ctx.setStatus(`Vertex ${editVertexIdx + 1} verschoben`); } editVertexIdx = -1; }, cancel(ctx) { editPoly = null; editVertexIdx = -1; ctx.setStatus('Polyline-Editor beendet'); }, }, }; // ───────────────────────────────────────────────────────────── // Stretch (Task D-Finish): Endpunkte von Linien in einem Auswahlbereich // verschieben. Klick 1: Basis, Klick 2: Ziel (Delta auf alle selektierten // Endpunkte, die in der Nähe des Basispunkts liegen). // ───────────────────────────────────────────────────────────── let stretchBase: Pt | null = null; export const stretchTool: ToolExtensionV2 = { manifest: { id: 'stretch', label: 'Stretch', icon: '\u2197', ribbonTab: 'canvas', tags: ['pro'], description: 'Linien auswählen, dann Basis→Ziel (Endpunkte in Basisnähe werden gestreckt)', }, optionsSchema: [], handlers: { down(e, ctx) { const c = ctx as FullToolContext; if (!c.doc || !c.selection) return; if (!stretchBase) { if (c.selection.getIds().length === 0) { const hit = c.selection.hitTest(e.world.x, e.world.y, 5); if (!hit) { ctx.setStatus('Stretch: zuerst Linien auswählen'); return; } c.selection.setIds([hit.id]); } stretchBase = e.world; ctx.setStatus(`Stretch: Zielklick (${c.selection.getIds().length} Linien)`); return; } // Commit: alle Endpunkte, die dem Basispunkt am nächsten sind, verschieben: const dx = e.world.x - stretchBase.x; const dy = e.world.y - stretchBase.y; const ids = c.selection.getIds(); c.doc.transact(() => { for (const id of ids) { const el = c.doc!.getElement(id); if (!el || el.type !== 'line') continue; 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) continue; // Endpunkte die dem Basispunkt am nächsten sind verschieben: const d1 = Math.hypot(props.x1! - stretchBase!.x, props.y1! - stretchBase!.y); const d2 = Math.hypot(props.x2! - stretchBase!.x, props.y2! - stretchBase!.y); if (d1 < d2) { props.x1 = props.x1! + dx; props.y1 = props.y1! + dy; } else { props.x2 = props.x2! + dx; props.y2 = props.y2! + dy; } const nx1 = props.x1!; const ny1 = props.y1!; const nx2 = props.x2!; const ny2 = props.y2!; c.doc!.updateElement(id, { x: (nx1 + nx2) / 2, y: (ny1 + ny2) / 2, width: Math.abs(nx2 - nx1), height: Math.abs(ny2 - ny1), properties: props, }); } }); ctx.setStatus(`Stretch: ${ids.length} Linie(n) gestreckt \u0394(${dx.toFixed(0)}, ${dy.toFixed(0)})`); stretchBase = null; }, cancel(ctx) { stretchBase = null; ctx.setStatus('Stretch abgebrochen'); }, }, }; // ───────────────────────────────────────────────────────────── // Break (Task D-Finish): Linie an einem Punkt in zwei Teile splitten. // Klick auf Linie → zwei neue Linien, Original wird entfernt. // ───────────────────────────────────────────────────────────── export const breakTool: ToolExtensionV2 = { manifest: { id: 'break', label: 'Break', icon: '\u2702', ribbonTab: 'canvas', tags: ['pro'], description: 'Linie anklicken — wird am Klickpunkt geteilt', }, 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 || hit.type !== 'line') { ctx.setStatus('Break: Linie anklicken'); return; } const props = hit.properties as { x1?: number; y1?: number; x2?: number; y2?: number }; if (props.x1 === undefined || props.y1 === undefined || props.x2 === undefined || props.y2 === undefined) return; // Klick auf die Linie projizieren (nächster Punkt auf der Linie): const x1 = props.x1 as number; const y1 = props.y1 as number; const x2 = props.x2 as number; const y2 = props.y2 as number; const dx = x2 - x1; const dy = y2 - y1; const lenSq = dx * dx + dy * dy; if (lenSq === 0) return; const t = Math.max(0, Math.min(1, ((e.world.x - x1) * dx + (e.world.y - y1) * dy) / lenSq)); const breakX = x1 + t * dx; const breakY = y1 + t * dy; c.doc.transact(() => { // Original entfernen: c.doc!.deleteElements([hit.id]); // Zwei neue Linien hinzufügen: c.doc!.addElement({ ...hit, id: uid('brk'), x: (x1 + breakX) / 2, y: (y1 + breakY) / 2, width: Math.abs(breakX - x1), height: Math.abs(breakY - y1), properties: { ...props, x2: breakX, y2: breakY }, }); c.doc!.addElement({ ...hit, id: uid('brk'), x: (breakX + x2) / 2, y: (breakY + y2) / 2, width: Math.abs(x2 - breakX), height: Math.abs(y2 - breakY), properties: { ...props, x1: breakX, y1: breakY }, }); }); ctx.setStatus(`Break: Linie bei (${breakX.toFixed(0)}, ${breakY.toFixed(0)}) geteilt`); }, }, }; // ───────────────────────────────────────────────────────────── // 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'); // ───────────────────────────────────────────────────────────── // Task D10-Rest: array-path — Elemente entlang einer Polyline // (Pfad) verteilen. Klick1: Quelle (Auto-HitSelect), Klick2: auf // die Pfad-Polyline klicken. count Kopien gleichmäßig über die // Gesamtlänge (s = i/(count-1)·total), ALLES in 1 Transaktion. // ───────────────────────────────────────────────────────────── let arrayPathBase: Pt | null = null; /** Punkt auf einer Polyline bei kumulierter Länge s (0..total). */ function pointAlongPolyline(pts: Pt[], s: number): Pt { if (pts.length === 0) return { x: 0, y: 0 }; if (pts.length === 1 || s <= 0) return { ...pts[0] }; let remaining = s; for (let i = 0; i < pts.length - 1; i++) { const dx = pts[i + 1].x - pts[i].x; const dy = pts[i + 1].y - pts[i].y; const segLen = Math.hypot(dx, dy); if (segLen <= 0) continue; if (remaining <= segLen) { const t = remaining / segLen; return { x: pts[i].x + dx * t, y: pts[i].y + dy * t }; } remaining -= segLen; } return { ...pts[pts.length - 1] }; } export const arrayPathTool: ToolExtensionV2 = { manifest: { id: 'array-path', label: 'Array Pfad', icon: '∿', ribbonTab: 'canvas', tags: ['pro'], description: 'Array entlang Pfad: Auswahl treffen, dann auf Pfad-Polyline klicken (Task D10)', }, optionsSchema: [ { 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 (!arrayPathBase) { if (c.selection.getIds().length === 0) { const hit = c.selection.hitTest(e.world.x, e.world.y, 5); if (!hit) { ctx.setStatus('Array Pfad: zuerst Element(e) auswählen'); return; } c.selection.setIds([hit.id]); } arrayPathBase = e.world; ctx.setStatus(`Array Pfad: auf Pfad-Polyline klicken (${c.selection.getIds().length} Element(e))`); return; } // Zweiter Klick: muss eine Polyline als Pfad treffen const pathHit = c.selection.hitTest(e.world.x, e.world.y, 8); if (!pathHit || (pathHit.type !== 'polyline' && pathHit.type !== 'polygon')) { ctx.setStatus('Array Pfad: zweiter Klick muss eine Polyline als Pfad treffen'); arrayPathBase = null; return; } const pathPts = (pathHit.properties as Record).points as Pt[] | undefined; if (!pathPts || pathPts.length < 2) { ctx.setStatus('Array Pfad: Pfad-Polyline hat zu wenige Punkte'); arrayPathBase = null; return; } const count = Math.max(2, (c.options.count as number | undefined) ?? 5); const ids = c.selection.getIds().filter((id) => id !== pathHit.id); // Gesamtlänge des Pfads let total = 0; for (let i = 0; i < pathPts.length - 1; i++) { total += Math.hypot(pathPts[i + 1].x - pathPts[i].x, pathPts[i + 1].y - pathPts[i].y); } c.doc.transact(() => { for (const id of ids) { const el = c.doc!.getElement(id); if (!el) continue; for (let i = 0; i < count; i++) { const s = total * (i / Math.max(1, count - 1)); const pos = pointAlongPolyline(pathPts, s); c.doc!.addElement({ ...el, id: uid('arrp'), x: pos.x, y: pos.y, } as CADElement); } } }); arrayPathBase = null; ctx.setStatus(`Array Pfad: ${ids.length * count} Kopien entlang Pfad verteilt`); }, cancel(ctx) { arrayPathBase = null; ctx.setPreview?.(null); ctx.setStatus('Array Pfad abgebrochen'); }, }, }; // ───────────────────────────────────────────────────────────── // 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. */ // ────────────────────────────────────────────────────────── // Task CAD-4: DIVIDE/MEASURE — Punkte entlang eines Pfads // (Polyline) verteilen. DIVIDE: n gleiche Segmente → n+1 Punkte // inkl. Enden. MEASURE: fester Abstand inkl. Start, Rest verworfen. // Punkte = Konstruktionsmarker auf defpoints (1 Transaktion). // ────────────────────────────────────────────────────────── /** * Punkte entlang eines Polylinien-Pfads (Task CAD-4, rein). * segments > 0: DIVIDE — n+1 gleichmäßige Punkte inkl. Enden. * fixedSpacing > 0: MEASURE — Punkte bei 0, spacing, 2*spacing, … * solange s ≤ Gesamtlänge; Rest am Ende wird verworfen. */ export function pointsAlongPath(pts: Pt[], segments: number, fixedSpacing?: number): Pt[] { if (!Array.isArray(pts) || pts.length < 2) return []; let total = 0; for (let i = 0; i < pts.length - 1; i++) { total += Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y); } if (total <= 0) return [...pts]; const out: Pt[] = []; if (segments > 0) { for (let i = 0; i <= segments; i++) { out.push(pointAlongPolyline(pts, (total * i) / segments)); } } else if (fixedSpacing && fixedSpacing > 0) { for (let s = 0; s <= total + 1e-9; s += fixedSpacing) { out.push(pointAlongPolyline(pts, s)); } } return out; } /** defpoints-Layer sicherstellen (CAD-4, lokal im core-modify). */ function ensureDefpoints(ctx: FullToolContext): void { const doc = ctx.doc as unknown as { getLayer(id: string): unknown; addLayer(l: unknown): void; } | undefined; if (!doc || doc.getLayer('defpoints')) return; doc.addLayer({ id: 'defpoints', name: 'defpoints', visible: true, locked: false, color: '#8a8f98', lineType: 'dotted', transparency: 0.5, sortOrder: 999, parentId: null, }); } /** Pfad-Treffer validieren: muss polyline/polygon mit ≥2 Punkten sein. */ function requirePathHit( c: FullToolContext, wx: number, wy: number, ): { pts: Pt[]; path: CADElement } | null { if (!c.selection) return null; const hit = c.selection.hitTest(wx, wy, 8); if (!hit || (hit.type !== 'polyline' && hit.type !== 'polygon')) return null; const pts = (hit.properties as Record).points as Pt[] | undefined; if (!pts || pts.length < 2) return null; return { pts, path: hit }; } /** Konstruktions-Marker-Punkt auf defpoints platzieren. */ function makePathMarker(p: Pt): CADElement { return { id: uid('pathmark'), type: 'circle', layerId: 'defpoints', x: p.x, y: p.y, width: 3, height: 3, properties: { radius: 1.5, point: true, construction: true, stroke: '#8a8f98', strokeWidth: 1 }, } as unknown as CADElement; } export const divideTool: ToolExtensionV2 = { manifest: { id: 'divide', label: 'Teilen', icon: '÷', ribbonTab: 'canvas', tags: ['pro'], description: 'Pfad in n gleiche Segmente teilen (DIVIDE, Task CAD-4)', }, optionsSchema: [ { key: 'count', label: 'Anzahl Segmente', type: 'number', min: 1, max: 100 }, ], handlers: { down(e, ctx) { const c = ctx as FullToolContext; if (!c.doc) return; const found = requirePathHit(c, e.world.x, e.world.y); if (!found) { ctx.setStatus('Teilen: Polyline als Pfad anklicken'); return; } const segments = Math.max(1, (c.options.count as number | undefined) ?? 4); ensureDefpoints(c); const marks = pointsAlongPath(found.pts, segments); c.doc.transact(() => { for (const p of marks) c.doc!.addElement(makePathMarker(p)); }); ctx.setStatus(`Geteilt: ${marks.length} Punkte auf ${segments} Segmente`); }, cancel(ctx) { ctx.setPreview?.(null); ctx.setStatus('Teilen abgebrochen'); }, }, }; export const measureTool: ToolExtensionV2 = { manifest: { id: 'measure', label: 'Abmessen (Punkte)', icon: '⋮', ribbonTab: 'canvas', tags: ['pro'], description: 'Punkte im festen Abstand auf Pfad (MEASURE, Task CAD-4)', }, optionsSchema: [ { key: 'spacing', label: 'Abstand', type: 'number', min: 1 }, ], handlers: { down(e, ctx) { const c = ctx as FullToolContext; if (!c.doc) return; const found = requirePathHit(c, e.world.x, e.world.y); if (!found) { ctx.setStatus('Abmessen: Polyline als Pfad anklicken'); return; } const spacing = Math.max(1, (c.options.spacing as number | undefined) ?? 20); ensureDefpoints(c); const marks = pointsAlongPath(found.pts, 0, spacing); c.doc.transact(() => { for (const p of marks) c.doc!.addElement(makePathMarker(p)); }); ctx.setStatus(`Abgemessen: ${marks.length} Punkte alle ${spacing} Einheiten`); }, cancel(ctx) { ctx.setPreview?.(null); ctx.setStatus('Abmessen abgebrochen'); }, }, }; // ───────────────────────────────────────────────────────────── // Task CAD-5: Block-Editor — Blockdefinition in-place bearbeiten. // Klick 1 auf Block-Instanz oeffnet (markierte Kopien erscheinen), // Kopien normal bearbeiten, Klick 2 speichert zurueck in die // Definition, cancel verwirft. (Service: blockEditorService) // ───────────────────────────────────────────────────────────── export const blockEditTool: ToolExtensionV2 = { manifest: { id: 'block-edit', label: 'Block bearbeiten', icon: '\u2699', ribbonTab: 'canvas', tags: ['pro'], description: 'Block-Instanz anklicken um die Definition zu bearbeiten (CAD-5)', }, optionsSchema: [], handlers: { down(e, ctx) { const c = ctx as FullToolContext; if (!c.doc || !c.selection) return; // Offene Sitzung? → speichern if ((c.doc.getAllElements() as CADElement[]).some( (el) => (el.properties as Record)[BLOCK_EDIT_MARKER], )) { const saved = saveBlockEdit(c.doc as never); ctx.setStatus(saved ? `Block "${saved}" gespeichert` : 'Speichern fehlgeschlagen'); return; } const hit = c.selection.hitTest(e.world.x, e.world.y, 8); if (!hit || hit.type !== 'block_instance') { ctx.setStatus('Block bearbeiten: Block-Instanz anklicken'); return; } const blockId = (hit.properties as Record).blockId as string | undefined; if (!blockId) { ctx.setStatus('Block bearbeiten: Instanz ohne Block-Referenz'); return; } const opened = openBlockForEdit(c.doc as never, blockId); ctx.setStatus(opened ? `Block "${blockId}" geöffnet — Elemente bearbeiten, erneut klicken speichert` : 'Block konnte nicht geöffnet werden'); }, cancel(ctx) { const c = ctx as FullToolContext; if (c.doc) discardBlockEdit(c.doc as never); ctx.setPreview?.(null); ctx.setStatus('Block-Bearbeitung verworfen'); }, }, }; export const coreModifyPlugin: PluginV2 = { manifest: { id: 'core-modify', name: 'Bearbeiten (Kern)', version: '1.9.0', author: 'web-cad team', description: 'Auswahl, Schraffur, Move/Copy/Rotate/Scale/Mirror/Delete/Offset/Trim/Extend/Fillet/Arrays/Chamfer/Lengthen/Join/Explode/Align/Polyline-Edit/Stretch/Break (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, polylineEditTool, stretchTool, breakTool, arrayPathTool, divideTool, measureTool, blockEditTool], };