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

1094 lines
44 KiB
TypeScript
Raw Normal View History

2026-08-26 21:30:54 +02:00
/**
* core-modify Built-in V2-Plugin.
2026-08-26 21:30:54 +02:00
*
* 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.
2026-08-26 21:30:54 +02:00
*/
import type { PluginV2, ToolExtensionV2 } from '../../types';
import type { FullToolContext } from '../../types';
import type { CADElement } from '../../../types/cad.types';
import {
moveElement,
rotateElement,
scaleElement,
mirrorElement,
angleBetween,
distance,
offsetElement,
trimElement,
extendElement,
filletElements,
} from '../../../tools/modification/geometry';
import type { Pt } from '../../../tools/modification/geometry';
2026-08-26 21:30:54 +02:00
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;
}
2026-08-26 21:30:54 +02:00
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;
2026-08-26 21:30:54 +02:00
if (!bridge) return;
const additive = e.shift;
const subtractive = e.ctrl;
const hit = bridge.hitTest(e.world.x, e.world.y, 5);
// ── Handle-Drag-Start (Task C3fin interaktiv) ──
// Wenn bereits Elemente selektiert sind und der Klick auf eine Ecke
// eines davon trifft → Handle-Drag starten statt neu zu wählen.
if (!additive && !subtractive && bridge.getIds().length > 0 && c.doc) {
for (const id of bridge.getIds()) {
const el = c.doc.getElement(id);
if (!el) continue;
const h = hitHandle(el, e.world.x, e.world.y, 8);
if (h) {
handleDrag = { el, anchorCorner: h.anchorCorner, handleIndex: h.handleIndex };
ctx.setStatus(`Handle ${h.handleIndex + 1} — skalieren durch Ziehen`);
return;
}
}
}
2026-08-26 21:30:54 +02:00
if (!hit) {
if (!additive && !subtractive) bridge.setIds([]);
return;
}
let ids = new Set(bridge.getIds());
if (subtractive) {
ids.delete(hit.id);
} else {
ids.add(hit.id);
if (!additive) ids = new Set([hit.id]);
2026-08-26 21:30:54 +02:00
}
bridge.setIds(Array.from(ids));
ctx.setStatus(`Selektion: ${ids.size} Element(e)`);
},
move(e, ctx) {
// Live-Skalierung während Handle-Drag:
if (!handleDrag) return;
const c = ctx as FullToolContext;
if (!c.doc) return;
const el = c.doc.getElement(handleDrag.el.id);
if (!el) return;
const props = el.properties as Record<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) {
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;
ctx.setPreview?.(null);
},
2026-08-26 21:30:54 +02:00
},
};
// ─────────────────────────────────────────────────────────────
// hatch (Task A4.5)
// ─────────────────────────────────────────────────────────────
export const hatchTool: ToolExtensionV2 = {
manifest: {
id: 'hatch', label: 'Schraffur', icon: '\u2593', ribbonTab: 'format', tags: ['pro'],
description: 'Geschlossenes Element anklicken zum Schraffieren',
},
optionsSchema: [
{ key: 'hatchPattern', label: 'Muster', type: 'select', options: [
{ value: 'lines', label: 'Linien' },
{ value: 'cross', label: 'Kreuz' },
] },
{ key: 'hatchSpacing', label: 'Abstand', type: 'number', min: 2, max: 40 },
],
handlers: {
down(e, ctx) {
const c = ctx as FullToolContext;
const bridge = c.selection;
if (!bridge || !c.doc) return;
const hit = bridge.hitTest(e.world.x, e.world.y, 5);
if (!hit || !['rect', 'circle', 'polygon'].includes(hit.type)) {
ctx.setStatus('Schraffur: nur rect/circle/polygon');
return;
}
const pattern = (c.options.hatchPattern as string | undefined) ?? 'lines';
const spacing = (c.options.hatchSpacing as number | undefined) ?? 8;
c.doc.updateElement(hit.id, {
properties: { hatch: true, hatchPattern: pattern, hatchSpacing: spacing },
});
ctx.setStatus(`Schraffur auf ${hit.id} (${pattern})`);
},
},
};
// ─────────────────────────────────────────────────────────────
// Move/Copy (Task A4.6)
// ─────────────────────────────────────────────────────────────
let modBasePoint: Pt | null = null;
function makeMoveCopyTool(mode: 'move' | 'copy'): ToolExtensionV2 {
const label = mode === 'move' ? 'Verschieben' : 'Kopieren';
return {
manifest: {
id: mode,
label,
icon: mode === 'move' ? '\u27a4' : '\u29c9',
ribbonTab: 'canvas',
tags: ['basic'],
description: `${label}: Auswahl treffen, dann Basis\u2192Ziel klicken`,
},
optionsSchema: [],
handlers: {
down(e, ctx) {
const c = ctx as FullToolContext;
if (!c.doc || !c.selection) return;
if (!modBasePoint) {
const ids = c.selection.getIds();
if (ids.length === 0) {
const hit = c.selection.hitTest(e.world.x, e.world.y, 5);
if (!hit) {
ctx.setStatus(`${label}: zuerst Element(e) auswählen`);
return;
}
c.selection.setIds([hit.id]);
}
modBasePoint = e.world;
ctx.setStatus(`${label}: Zielklick zum ${mode === 'move' ? 'Verschieben' : 'Kopieren'} (${c.selection.getIds().length} Elemente)`);
return;
}
const dx = e.world.x - modBasePoint.x;
const dy = e.world.y - modBasePoint.y;
const ids = c.selection.getIds();
c.doc.transact(() => {
for (const id of ids) {
const el = c.doc!.getElement(id);
if (!el) continue;
if (mode === 'move') {
c.doc!.updateElement(id, { x: el.x + dx, y: el.y + dy });
} else {
c.doc!.addElement({
...el,
id: uid('cp'),
x: el.x + dx,
y: el.y + dy,
});
}
}
});
ctx.setStatus(`${label}: ${ids.length} Element(e), \u0394(${dx.toFixed(0)}, ${dy.toFixed(0)})`);
modBasePoint = null;
},
cancel(ctx) {
modBasePoint = null;
ctx.setStatus(`${label} abgebrochen`);
},
},
};
}
export const moveTool: ToolExtensionV2 = makeMoveCopyTool('move');
export const copyTool: ToolExtensionV2 = makeMoveCopyTool('copy');
// ─────────────────────────────────────────────────────────────
// Rotate / Scale / Mirror (Task A4.7)
// Konventionen exakt aus Legacy handleModifyMove:
// - rotate: Pivot = Zentrum des ERSTEN selektierten Elements,
// Δwinkel = angleBetween(pivot,zweitKlick) angleBetween(pivot,basis)
// - scale: Faktor = dist(pivot,zweitKlick) / dist(pivot,basis)
// - mirror: Spiegelachse = Linie basis→zweitKlick
// Klick-progressiv: Commit beim ZWEITEN down (Arc-Lektion).
// ─────────────────────────────────────────────────────────────
let transformBase: Pt | null = null;
function makeTransformTool(mode: 'rotate' | 'scale' | 'mirror'): ToolExtensionV2 {
const labels = { rotate: 'Rotieren', scale: 'Skalieren', mirror: 'Spiegeln' } as const;
const iconMap = { rotate: '\u21bb', scale: '\u2922', mirror: '\u21c6' } as const;
return {
manifest: {
id: mode,
label: labels[mode],
icon: iconMap[mode],
ribbonTab: 'canvas',
tags: ['pro'],
description: `${labels[mode]}: Auswahl treffen, dann Basis\u2192Referenz klicken`,
},
optionsSchema: [],
handlers: {
down(e, ctx) {
const c = ctx as FullToolContext;
if (!c.doc || !c.selection) return;
if (!transformBase) {
if (c.selection.getIds().length === 0) {
const hit = c.selection.hitTest(e.world.x, e.world.y, 5);
if (!hit) {
ctx.setStatus(`${labels[mode]}: zuerst Element(e) auswählen`);
return;
}
c.selection.setIds([hit.id]);
}
transformBase = e.world;
ctx.setStatus(`${labels[mode]}: Referenzklick zum Anwenden (${c.selection.getIds().length} Elemente)`);
return;
}
// Zweiter Klick → Commit auf ALLE selektierten Elemente, EINE Transaktion
const base = transformBase;
const ids = c.selection.getIds();
const first = c.doc.getElement(ids[0]);
if (!first) { transformBase = null; return; }
const pivot = { x: first.x, y: first.y };
c.doc.transact(() => {
for (const id of ids) {
const el = c.doc!.getElement(id);
if (!el) continue;
let next: CADElement;
if (mode === 'rotate') {
const delta = angleBetween(pivot, e.world) - angleBetween(pivot, base);
next = rotateElement(el, pivot.x, pivot.y, delta);
} else if (mode === 'scale') {
const sd = distance(pivot, base);
const cd = distance(pivot, e.world);
const factor = sd > 0 ? cd / sd : 1;
next = scaleElement(el, pivot.x, pivot.y, factor, factor);
} else {
next = mirrorElement(el, base.x, base.y, e.world.x, e.world.y);
}
c.doc!.updateElement(id, {
x: next.x, y: next.y,
width: next.width, height: next.height,
properties: next.properties,
});
}
});
ctx.setStatus(`${labels[mode]}: ${ids.length} Element(e)`);
transformBase = null;
},
move(e, ctx) {
// Live-Vorschau am ersten selektierten Element (Legacy previewElement)
const c = ctx as FullToolContext;
if (!transformBase || !c.selection || !c.doc) return;
const ids = c.selection.getIds();
if (ids.length === 0) return;
const first = c.doc.getElement(ids[0]);
if (!first) return;
const pivot = { x: first.x, y: first.y };
let next: CADElement;
if (mode === 'rotate') {
const delta = angleBetween(pivot, e.world) - angleBetween(pivot, transformBase);
next = rotateElement(first, pivot.x, pivot.y, delta);
} else if (mode === 'scale') {
const sd = distance(pivot, transformBase);
const cd = distance(pivot, e.world);
const f = sd > 0 ? cd / sd : 1;
next = scaleElement(first, pivot.x, pivot.y, f, f);
} else {
next = mirrorElement(first, transformBase.x, transformBase.y, e.world.x, e.world.y);
}
ctx.setPreview?.(next);
},
cancel(ctx) {
transformBase = null;
ctx.setPreview?.(null);
ctx.setStatus(`${labels[mode]} abgebrochen`);
},
},
};
}
export const rotateTool: ToolExtensionV2 = makeTransformTool('rotate');
export const scaleTool: ToolExtensionV2 = makeTransformTool('scale');
export const mirrorTool: ToolExtensionV2 = makeTransformTool('mirror');
// ─────────────────────────────────────────────────────────────
// Join / Explode / Align (Task D-Extensions):
// - join: 2+ ausgewählte Linien → eine Polyline (Transaktion)
// - explode: Polyline → einzelne Liniensegmente (Transaktion)
// - align: bewegt ein Element so, dass sein Anker (Zentrum) auf das
// Ziel-Element-Zentrum ausgerichtet wird (3 Klicks: Quelle, Ziel, Commit)
// ─────────────────────────────────────────────────────────────
let alignSourceId: string | null = null;
function makeJoinExplodeAlignTool(mode: 'join' | 'explode' | 'align'): ToolExtensionV2 {
const labels = { join: 'Join', explode: 'Explode', align: 'Align' } as const;
return {
manifest: {
id: mode,
label: labels[mode],
icon: mode === 'join' ? '\u2795' : mode === 'explode' ? '\u2737' : '\u21c4',
ribbonTab: 'canvas',
tags: ['pro'],
description: `${labels[mode]}: siehe Statuszeile`,
},
optionsSchema: [],
handlers: {
down(e, ctx) {
const c = ctx as FullToolContext;
if (!c.doc || !c.selection) return;
const ids = c.selection.getIds();
if (mode === 'join') {
// Alle selektierten Linien → eine Polyline:
const lines = ids
.map((id) => c.doc!.getElement(id))
.filter((el): el is CADElement => !!el && el.type === 'line');
if (lines.length < 2) {
ctx.setStatus('Join: mindestens 2 Linien auswählen');
return;
}
// Punkte der Linien in Reihenfolge sammeln:
const allPts: Pt[] = [];
for (const ln of lines) {
const props = ln.properties as Record<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');
// ─────────────────────────────────────────────────────────────
// Array rect / polar (Task D-Extensions):
// Vervielfältigt die aktuelle Selektion in einem Raster bzw. im Kreis.
// Klick 1: Basis-/Zentrumspunkt. Klick 2: Commit.
// rect: Options rows, cols, dx, dy (Abstände).
// polar: Options count, optional angleSum (Default 360).
// Jede Aktion = EINE Transaktion → 1 Undo-Schritt.
// ─────────────────────────────────────────────────────────────
let arrayBase: Pt | null = null;
function makeArrayTool(mode: 'array-rect' | 'array-polar'): ToolExtensionV2 {
const labels = { 'array-rect': 'Array Rect', 'array-polar': 'Array Polar' } as const;
return {
manifest: {
id: mode,
label: labels[mode],
icon: mode === 'array-rect' ? '\u25a6' : '\u273b',
ribbonTab: 'canvas',
tags: ['pro'],
description: `${labels[mode]}: Auswahl treffen, dann Basis\u2192Ziel klicken`,
},
optionsSchema:
mode === 'array-rect'
? [
{ key: 'rows', label: 'Zeilen', type: 'number', min: 1, max: 50 },
{ key: 'cols', label: 'Spalten', type: 'number', min: 1, max: 50 },
{ key: 'dx', label: 'Abstand X', type: 'number', min: 1 },
{ key: 'dy', label: 'Abstand Y', type: 'number', min: 1 },
]
: [
{ key: 'count', label: 'Anzahl', type: 'number', min: 2, max: 100 },
],
handlers: {
down(e, ctx) {
const c = ctx as FullToolContext;
if (!c.doc || !c.selection) return;
if (!arrayBase) {
if (c.selection.getIds().length === 0) {
const hit = c.selection.hitTest(e.world.x, e.world.y, 5);
if (!hit) {
ctx.setStatus(`${labels[mode]}: zuerst Element(e) auswählen`);
return;
}
c.selection.setIds([hit.id]);
}
arrayBase = e.world;
ctx.setStatus(`${labels[mode]}: Zielklick zum Anwenden (${c.selection.getIds().length} Elemente)`);
return;
}
// Zweiter Klick → Commit
const ids = c.selection.getIds();
const base = arrayBase;
c.doc.transact(() => {
if (mode === 'array-rect') {
const rows = (c.options.rows as number | undefined) ?? 3;
const cols = (c.options.cols as number | undefined) ?? 3;
const dx = (c.options.dx as number | undefined) ?? 50;
const dy = (c.options.dy as number | undefined) ?? 50;
for (const id of ids) {
const el = c.doc!.getElement(id);
if (!el) continue;
for (let r = 0; r < rows; r++) {
for (let col = 0; col < cols; col++) {
if (r === 0 && col === 0) continue; // Original überspringen
c.doc!.addElement({
...el,
id: uid('arr'),
x: el.x + col * dx,
y: el.y + r * dy,
});
}
}
}
} else {
const count = (c.options.count as number | undefined) ?? 6;
// Zentrum = Basispunkt
for (const id of ids) {
const el = c.doc!.getElement(id);
if (!el) continue;
for (let i = 1; i < count; i++) {
const angle = (2 * Math.PI * i) / count;
const cx = base.x;
const cy = base.y;
const cos = Math.cos(angle);
const sin = Math.sin(angle);
// Position rotieren um Zentrum:
const relX = el.x - cx;
const relY = el.y - cy;
c.doc!.addElement({
...el,
id: uid('arr'),
x: cx + relX * cos - relY * sin,
y: cy + relX * sin + relY * cos,
});
}
}
}
});
ctx.setStatus(`${labels[mode]}: ${ids.length} Element(e) vervielfältigt`);
arrayBase = null;
},
cancel(ctx) {
arrayBase = null;
ctx.setStatus(`${labels[mode]} abgebrochen`);
},
},
};
}
export const arrayRectTool: ToolExtensionV2 = makeArrayTool('array-rect');
export const arrayPolarTool: ToolExtensionV2 = makeArrayTool('array-polar');
// ─────────────────────────────────────────────────────────────
// Chamfer (Task D-Ext): schneidet zwei sich schneidende Linien an einer
// festen Distanz vom Schnittpunkt und verbindet die Enden mit einer
// geraden Fase. Legacy nutzt trimElement + Verbindungslinie.
// Klick 1: erste Linie, Klick 2: zweite Linie → 3 Elemente (2 gekürzt + 1 Fase).
// ─────────────────────────────────────────────────────────────
let chamferFirst: CADElement | null = null;
export const chamferTool: ToolExtensionV2 = {
manifest: {
id: 'chamfer', label: 'Chamfer', icon: '\u25e4', ribbonTab: 'canvas', tags: ['pro'],
description: 'Zwei Linien anklicken — Fase wird eingefügt',
},
optionsSchema: [
{ key: 'chamferDist', label: 'Fase-Distanz', type: 'number', min: 1, max: 100 },
],
handlers: {
down(e, ctx) {
const c = ctx as FullToolContext;
if (!c.doc || !c.selection) return;
const hit = c.selection.hitTest(e.world.x, e.world.y, 5);
if (!hit) return;
if (!chamferFirst) {
chamferFirst = hit;
ctx.setStatus('Chamfer: zweite Linie wählen');
return;
}
const dist = (c.options.chamferDist as number | undefined) ?? 10;
// Beide Linien auf dist vom Schnittpunkt kürzen:
const t1 = trimElement(chamferFirst, hit);
const t2 = trimElement(hit, chamferFirst);
if (!t1 || !t2) {
ctx.setStatus('Chamfer: kein Schnittpunkt');
chamferFirst = null;
return;
}
// Endpunkte nahe am Schnittpunkt holen und um dist zurücksetzen:
const props1 = t1.properties as { x1?: number; y1?: number; x2?: number; y2?: number };
const props2 = t2.properties as { x1?: number; y1?: number; x2?: number; y2?: number };
if (props1.x1 === undefined || props1.y1 === undefined || props1.x2 === undefined || props1.y2 === undefined ||
props2.x1 === undefined || props2.y1 === undefined || props2.x2 === undefined || props2.y2 === undefined) {
chamferFirst = null;
return;
}
// Nahe Enden (Schnittpunkt-Seite) identifizieren via Schnittpunkt:
const isectPt = findIsectPoint(chamferFirst, hit);
if (!isectPt) { chamferFirst = null; return; }
const near1 = nearEnd(props1 as { x1: number; y1: number; x2: number; y2: number }, isectPt);
const near2 = nearEnd(props2 as { x1: number; y1: number; x2: number; y2: number }, isectPt);
// Kürze nahe Enden auf dist vom Schnittpunkt:
const shrink1 = shrinkTowards(props1 as { x1: number; y1: number; x2: number; y2: number }, near1, isectPt, dist);
const shrink2 = shrinkTowards(props2 as { x1: number; y1: number; x2: number; y2: number }, near2, isectPt, dist);
// Fase: Linie zwischen den neuen Enden:
const chamferLine: CADElement = {
...chamferFirst,
id: uid('cham'),
type: 'line',
x: (shrink1.near.x + shrink2.near.x) / 2,
y: (shrink1.near.y + shrink2.near.y) / 2,
width: Math.abs(shrink2.near.x - shrink1.near.x),
height: Math.abs(shrink2.near.y - shrink1.near.y),
properties: {
x1: shrink1.near.x, y1: shrink1.near.y,
x2: shrink2.near.x, y2: shrink2.near.y,
stroke: '#e0e0e0', strokeWidth: 1,
},
};
c.doc.transact(() => {
c.doc!.updateElement(chamferFirst!.id, {
x: shrink1.cx, y: shrink1.cy,
width: Math.abs(props1.x2! - props1.x1!),
height: Math.abs(props1.y2! - props1.y1!),
properties: props1,
});
c.doc!.updateElement(hit.id, {
x: shrink2.cx, y: shrink2.cy,
width: Math.abs(props2.x2! - props2.x1!),
height: Math.abs(props2.y2! - props2.y1!),
properties: props2,
});
c.doc!.addElement(chamferLine);
});
ctx.setStatus('Chamfer erstellt');
chamferFirst = null;
},
cancel(ctx) {
chamferFirst = null;
ctx.setStatus('Chamfer abgebrochen');
},
},
};
function findIsectPoint(a: CADElement, b: CADElement): Pt | null {
const pa = a.properties as { x1?: number; y1?: number; x2?: number; y2?: number };
const pb = b.properties as { x1?: number; y1?: number; x2?: number; y2?: number };
if (pa.x1 === undefined || pb.x1 === undefined) return null;
const denom = (pa.x1! - pa.x2!) * (pb.y1! - pb.y2!) - (pa.y1! - pa.y2!) * (pb.x1! - pb.x2!);
if (Math.abs(denom) < 1e-10) return null;
const t = ((pa.x1! - pb.x1!) * (pb.y1! - pb.y2!) - (pa.y1! - pb.y1!) * (pb.x1! - pb.x2!)) / denom;
return { x: pa.x1! + t * (pa.x2! - pa.x1!), y: pa.y1! + t * (pa.y2! - pa.y1!) };
}
function nearEnd(props: { x1: number; y1: number; x2: number; y2: number }, pt: Pt): 'start' | 'end' {
const d1 = Math.hypot(props.x1 - pt.x, props.y1 - pt.y);
const d2 = Math.hypot(props.x2 - pt.x, props.y2 - pt.y);
return d1 < d2 ? 'start' : 'end';
}
function shrinkTowards(
props: { x1: number; y1: number; x2: number; y2: number },
near: 'start' | 'end',
isectPt: Pt,
dist: number,
): { near: Pt; cx: number; cy: number } {
if (near === 'start') {
const len = Math.hypot(props.x2 - props.x1, props.y2 - props.y1);
if (len === 0) return { near: { x: props.x1, y: props.y1 }, cx: props.x1, cy: props.y1 };
const ratio = Math.min(dist / len, 1);
const nx = props.x1 + (props.x2 - props.x1) * ratio;
const ny = props.y1 + (props.y2 - props.y1) * ratio;
return { near: { x: nx, y: ny }, cx: (nx + props.x2) / 2, cy: (ny + props.y2) / 2 };
}
const len = Math.hypot(props.x2 - props.x1, props.y2 - props.y1);
if (len === 0) return { near: { x: props.x2, y: props.y2 }, cx: props.x2, cy: props.y2 };
const ratio = Math.min(dist / len, 1);
const nx = props.x2 + (props.x1 - props.x2) * ratio;
const ny = props.y2 + (props.y1 - props.y2) * ratio;
return { near: { x: nx, y: ny }, cx: (nx + props.x1) / 2, cy: (ny + props.y1) / 2 };
}
// ─────────────────────────────────────────────────────────────
// Lengthen (Task D-Ext): verlängert/verkürzt eine Linie am geklickten
// Ende um einen Delta-Wert. Klick 1: Linie, Klick 2: Seite.
// ─────────────────────────────────────────────────────────────
let lengthenEl: CADElement | null = null;
export const lengthenTool: ToolExtensionV2 = {
manifest: {
id: 'lengthen', label: 'Lengthen', icon: '\u2194', ribbonTab: 'canvas', tags: ['pro'],
description: 'Linie anklicken, dann Delta-Ziel klicken (Options: lengthenDelta)',
},
optionsSchema: [
{ key: 'lengthenDelta', label: 'Delta', type: 'number' },
],
handlers: {
down(e, ctx) {
const c = ctx as FullToolContext;
if (!c.doc || !c.selection) return;
if (!lengthenEl) {
const hit = c.selection.hitTest(e.world.x, e.world.y, 5);
if (!hit || hit.type !== 'line') {
ctx.setStatus('Lengthen: nur Linien');
return;
}
lengthenEl = hit;
ctx.setStatus('Lengthen: Klick in Verlängerungsrichtung');
return;
}
const el = c.doc.getElement(lengthenEl.id);
if (!el) { lengthenEl = null; return; }
const props = el.properties as { x1?: number; y1?: number; x2?: number; y2?: number };
if (props.x1 === undefined || props.y1 === undefined || props.x2 === undefined || props.y2 === undefined) {
lengthenEl = null;
return;
}
const delta = (c.options.lengthenDelta as number | undefined) ?? 10;
// Wähle das Ende, das dem Klick am nächsten ist:
const dStart = Math.hypot(e.world.x - (props.x1 as number), e.world.y - (props.y1 as number));
const dEnd = Math.hypot(e.world.x - (props.x2 as number), e.world.y - (props.y2 as number));
const nearIsStart = dStart < dEnd;
// Richtungsvektor (vom fernen Ende zum nahen Ende):
const dirX = nearIsStart ? (props.x1! - props.x2!) : (props.x2! - props.x1!);
const dirY = nearIsStart ? (props.y1! - props.y2!) : (props.y2! - props.y1!);
const len = Math.hypot(dirX, dirY);
if (len === 0) { lengthenEl = null; return; }
const ux = dirX / len;
const uy = dirY / len;
if (nearIsStart) {
props.x1 = (props.x1 as number) + ux * delta;
props.y1 = (props.y1 as number) + uy * delta;
} else {
props.x2 = (props.x2 as number) + ux * delta;
props.y2 = (props.y2 as number) + uy * delta;
}
const nx1 = props.x1 as number;
const ny1 = props.y1 as number;
const nx2 = props.x2 as number;
const ny2 = props.y2 as number;
c.doc.transact(() => {
c.doc!.updateElement(el.id, {
x: (nx1 + nx2) / 2,
y: (ny1 + ny2) / 2,
width: Math.abs(nx2 - nx1),
height: Math.abs(ny2 - ny1),
properties: props,
});
});
ctx.setStatus(`Lengthen: Linie um ${delta} angepasst`);
lengthenEl = null;
},
cancel(ctx) {
lengthenEl = null;
ctx.setStatus('Lengthen abgebrochen');
},
},
};
// ─────────────────────────────────────────────────────────────
// Trim / Extend / Fillet (Task A4.10) — baut auf BUG-2-reparierter
// findIntersection-Geometrie (B2). Legacy-Ablauf:
// - trim/extend: Klick1=Boundary, Klick2=Ziel → Ziel wird angepasst
// - fillet: Klick1+Klick2 = zwei Elemente → Paar wird angepasst
// Jede Operation committed in EINER Transaktion.
// ─────────────────────────────────────────────────────────────
let twoPickBoundary: CADElement | null = null;
let twoPickFirst: CADElement | null = null;
function make2PickGeometryTool(
mode: 'trim' | 'extend' | 'fillet',
filletRadius: number,
): ToolExtensionV2 {
const labels = { trim: 'Trimmen', extend: 'Verlängern', fillet: 'Fillet' } as const;
return {
manifest: {
id: mode,
label: labels[mode],
icon: mode === 'fillet' ? '\u23dc' : '\u2717',
ribbonTab: 'canvas',
tags: ['pro'],
description: `${labels[mode]}: ${mode === 'fillet' ? 'zwei' : 'Kante, dann'} Element(e) anklicken`,
},
optionsSchema: [],
handlers: {
down(e, ctx) {
const c = ctx as FullToolContext;
if (!c.doc || !c.selection) return;
const hit = c.selection.hitTest(e.world.x, e.world.y, 5);
if (!hit) return;
if (mode === 'fillet') {
if (!twoPickFirst) {
twoPickFirst = hit;
ctx.setStatus(`Fillet: zweites Element wählen`);
return;
}
// Zweiter Pick → Paar-Commit
const second = hit;
const pair = filletElements(twoPickFirst, second, filletRadius);
if (!pair) {
ctx.setStatus('Fillet: kein Schnittpunkt gefunden');
twoPickFirst = null;
return;
}
const idA = twoPickFirst.id;
const idB = second.id;
c.doc.transact(() => {
c.doc!.updateElement(idA, {
x: pair[0].x, y: pair[0].y,
width: pair[0].width, height: pair[0].height,
properties: pair[0].properties,
});
c.doc!.updateElement(idB, {
x: pair[1].x, y: pair[1].y,
width: pair[1].width, height: pair[1].height,
properties: pair[1].properties,
});
});
ctx.setStatus(`Fillet angewendet (${idA}, ${idB})`);
twoPickFirst = null;
return;
}
// trim/extend: Boundary dann Ziel
if (!twoPickBoundary) {
twoPickBoundary = hit;
ctx.setStatus(`${labels[mode]}: jetzt das zu bearbeitende Element wählen`);
return;
}
const target = hit;
const fn = mode === 'trim' ? trimElement : extendElement;
const result = fn(target, twoPickBoundary);
if (!result) {
ctx.setStatus(`${labels[mode]}: keine Schnittmenge mit der Kante`);
twoPickBoundary = null;
return;
}
c.doc.transact(() => {
c.doc!.updateElement(target.id, {
x: result.x, y: result.y,
width: result.width, height: result.height,
properties: result.properties,
});
});
ctx.setStatus(`${labels[mode]} auf ${target.id} angewendet`);
twoPickBoundary = null;
},
cancel(ctx) {
twoPickBoundary = null;
twoPickFirst = null;
ctx.setStatus(`${labels[mode]} abgebrochen`);
},
},
};
}
export const trimTool: ToolExtensionV2 = make2PickGeometryTool('trim', 0);
export const extendTool: ToolExtensionV2 = make2PickGeometryTool('extend', 0);
export const filletTool: ToolExtensionV2 = make2PickGeometryTool('fillet', 5);
// Einzelnklick auf Element löscht es sofort (kein up/Bestätigung).
// Jetzt via doc.deleteElements → undo-fähig.
// ─────────────────────────────────────────────────────────────
export const deleteTool: ToolExtensionV2 = {
manifest: {
id: 'delete', label: 'Löschen', icon: '\u2715', ribbonTab: 'canvas', tags: ['basic'],
description: 'Element anklicken zum Löschen',
},
optionsSchema: [],
handlers: {
down(e, ctx) {
const c = ctx as FullToolContext;
if (!c.doc || !c.selection) return;
const ids = c.selection.getIds();
if (ids.length > 0) {
// Selektion vorhanden → alles selektierte löschen
c.doc.deleteElements(ids);
c.selection.setIds([]);
ctx.setStatus(`${ids.length} Element(e) gelöscht`);
return;
}
// Sonst: direkter Klick-Treffer
const hit = c.selection.hitTest(e.world.x, e.world.y, 5);
if (!hit) return;
c.doc.deleteElements([hit.id]);
ctx.setStatus(`Gelöscht: ${hit.id}`);
},
},
};
// ─────────────────────────────────────────────────────────────
// Offset (Task A4.8) — nutzt die BUG-3-reparierte offsetElement-
// Signatur mit Seitenparameter; Legacy-Ablauf:
// Klick1 = Element wählen, Klick2 = Richtung+Distanz → NEUES Element.
// ─────────────────────────────────────────────────────────────
let offsetSource: CADElement | null = null;
let offsetBase: Pt | null = null;
export const offsetTool: ToolExtensionV2 = {
manifest: {
id: 'offset', label: 'Versatz', icon: '\u2937', ribbonTab: 'canvas', tags: ['pro'],
description: 'Element anklicken, dann seitlichen Versatz klicken',
},
optionsSchema: [],
handlers: {
down(e, ctx) {
const c = ctx as FullToolContext;
if (!c.doc || !c.selection) return;
if (!offsetSource) {
const hit = c.selection.hitTest(e.world.x, e.world.y, 5);
if (!hit) return;
offsetSource = hit;
offsetBase = e.world;
ctx.setStatus(`Offset: Element ${hit.id} gewählt Versatz klicken`);
return;
}
// Zweiter Klick: Distanz + Seite (Kreuzprodukt für Linien, Radius sonst)
const src = c.doc.getElement(offsetSource.id);
if (!src) { offsetSource = null; offsetBase = null; return; }
const dist = Math.hypot(e.world.x - offsetBase!.x, e.world.y - offsetBase!.y);
let side: 1 | -1 = 1;
if (src.type === 'line' && src.properties.x1 !== undefined && src.properties.y1 !== undefined) {
const p1x = src.properties.x1 as number;
const p1y = src.properties.y1 as number;
const p2x = src.properties.x2 as number;
const p2y = src.properties.y2 as number;
side = (p2x - p1x) * (e.world.y - p1y) - (p2y - p1y) * (e.world.x - p1x) >= 0 ? 1 : -1;
} else if ((src.type === 'circle' || src.type === 'arc') && src.properties.radius !== undefined) {
side = Math.hypot(e.world.x - src.x, e.world.y - src.y) >= (src.properties.radius as number) ? 1 : -1;
}
const next = offsetElement(src, dist, side);
let newEl: CADElement | null = null;
c.doc.transact(() => {
newEl = { ...next, id: uid('off') };
c.doc!.addElement(newEl!);
});
ctx.setStatus(`Offset erstellt (${(newEl as unknown as CADElement).id})`);
offsetSource = null;
offsetBase = null;
},
cancel(ctx) {
offsetSource = null;
offsetBase = null;
ctx.setStatus('Offset abgebrochen');
},
},
};
/** V2-Plugin: Kern-Bearbeitungswerkzeuge. */
2026-08-26 21:30:54 +02:00
export const coreModifyPlugin: PluginV2 = {
manifest: {
id: 'core-modify',
name: 'Bearbeiten (Kern)',
version: '1.8.0',
2026-08-26 21:30:54 +02:00
author: 'web-cad team',
description: 'Auswahl, Schraffur, Move/Copy/Rotate/Scale/Mirror/Delete/Offset/Trim/Extend/Fillet/Arrays/Chamfer/Lengthen/Join/Explode/Align (V2).',
2026-08-26 21:30:54 +02:00
category: 'tools',
enabledByDefault: true,
},
tools: [selectTool, hatchTool, moveTool, copyTool, rotateTool, scaleTool, mirrorTool, deleteTool, offsetTool, trimTool, extendTool, filletTool, arrayRectTool, arrayPolarTool, chamferTool, lengthenTool, joinTool, explodeTool, alignTool],
2026-08-26 21:30:54 +02:00
};