Files
web-cad/frontend/src/plugins/builtin/core-modify/index.ts
T

1930 lines
76 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, unknown>;
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<string, unknown>;
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<string, unknown>) };
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<string, unknown>;
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<string, unknown>;
// 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<string, unknown>;
// 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<string, unknown>) };
// 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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>).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<string, unknown>).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<string, unknown>)[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<string, unknown>).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],
};