Files
web-cad/frontend/src/plugins/builtin/core-modify/index.ts
T
2026-08-27 20:03:14 +02:00

1094 lines
44 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 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;
}
export const selectTool: ToolExtensionV2 = {
manifest: {
id: 'select',
label: 'Auswahl',
icon: '\u2196',
ribbonTab: 'canvas',
tags: ['basic'],
description: 'Elemente anklicken zum Auswählen',
},
optionsSchema: [],
handlers: {
down(e, ctx) {
const bridge = (ctx as FullToolContext).selection;
const c = ctx as FullToolContext;
if (!bridge) return;
const additive = e.shift;
const subtractive = e.ctrl;
const hit = bridge.hitTest(e.world.x, e.world.y, 5);
// ── Handle-Drag-Start (Task C3fin interaktiv) ──
// Wenn bereits Elemente selektiert sind und der Klick auf eine Ecke
// eines davon trifft → Handle-Drag starten statt neu zu wählen.
if (!additive && !subtractive && bridge.getIds().length > 0 && c.doc) {
for (const id of bridge.getIds()) {
const el = c.doc.getElement(id);
if (!el) continue;
const h = hitHandle(el, e.world.x, e.world.y, 8);
if (h) {
handleDrag = { el, anchorCorner: h.anchorCorner, handleIndex: h.handleIndex };
ctx.setStatus(`Handle ${h.handleIndex + 1} — skalieren durch Ziehen`);
return;
}
}
}
if (!hit) {
if (!additive && !subtractive) bridge.setIds([]);
return;
}
let ids = new Set(bridge.getIds());
if (subtractive) {
ids.delete(hit.id);
} else {
ids.add(hit.id);
if (!additive) ids = new Set([hit.id]);
}
bridge.setIds(Array.from(ids));
ctx.setStatus(`Selektion: ${ids.size} Element(e)`);
},
move(e, ctx) {
// Live-Skalierung während Handle-Drag:
if (!handleDrag) return;
const c = ctx as FullToolContext;
if (!c.doc) return;
const el = c.doc.getElement(handleDrag.el.id);
if (!el) return;
const props = el.properties as Record<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);
},
},
};
// ─────────────────────────────────────────────────────────────
// 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. */
export const coreModifyPlugin: PluginV2 = {
manifest: {
id: 'core-modify',
name: 'Bearbeiten (Kern)',
version: '1.8.0',
author: 'web-cad team',
description: 'Auswahl, Schraffur, Move/Copy/Rotate/Scale/Mirror/Delete/Offset/Trim/Extend/Fillet/Arrays/Chamfer/Lengthen/Join/Explode/Align (V2).',
category: 'tools',
enabledByDefault: true,
},
tools: [selectTool, hatchTool, moveTool, copyTool, rotateTool, scaleTool, mirrorTool, deleteTool, offsetTool, trimTool, extendTool, filletTool, arrayRectTool, arrayPolarTool, chamferTool, lengthenTool, joinTool, explodeTool, alignTool],
};