2026-08-26 21:30:54 +02:00
|
|
|
|
/**
|
2026-08-27 01:04:48 +02:00
|
|
|
|
* core-modify – Built-in V2-Plugin.
|
2026-08-26 21:30:54 +02:00
|
|
|
|
*
|
2026-08-27 01:04:48 +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';
|
2026-08-27 01:04:48 +02:00
|
|
|
|
import type { FullToolContext } from '../../types';
|
2026-08-27 00:53:16 +02:00
|
|
|
|
import type { CADElement } from '../../../types/cad.types';
|
2026-08-27 01:04:48 +02:00
|
|
|
|
import {
|
|
|
|
|
|
moveElement,
|
|
|
|
|
|
rotateElement,
|
|
|
|
|
|
scaleElement,
|
|
|
|
|
|
mirrorElement,
|
|
|
|
|
|
angleBetween,
|
|
|
|
|
|
distance,
|
2026-08-27 01:09:51 +02:00
|
|
|
|
offsetElement,
|
2026-08-27 01:21:44 +02:00
|
|
|
|
trimElement,
|
|
|
|
|
|
extendElement,
|
|
|
|
|
|
filletElements,
|
2026-08-27 01:04:48 +02:00
|
|
|
|
} from '../../../tools/modification/geometry';
|
|
|
|
|
|
import type { Pt } from '../../../tools/modification/geometry';
|
2026-08-26 21:30:54 +02:00
|
|
|
|
|
2026-08-27 01:04:48 +02:00
|
|
|
|
const uid = (prefix: string): string =>
|
|
|
|
|
|
`${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`;
|
|
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
// select (Task A3 – Pilot)
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
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) {
|
2026-08-27 01:04:48 +02:00
|
|
|
|
const bridge = (ctx as FullToolContext).selection;
|
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);
|
|
|
|
|
|
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);
|
2026-08-27 01:04:48 +02:00
|
|
|
|
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)`);
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-27 01:04:48 +02:00
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
// hatch (Task A4.5)
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
2026-08-27 00:48:28 +02:00
|
|
|
|
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) {
|
2026-08-27 01:04:48 +02:00
|
|
|
|
const c = ctx as FullToolContext;
|
2026-08-27 00:48:28 +02:00
|
|
|
|
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})`);
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-27 01:04:48 +02:00
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
// Move/Copy (Task A4.6)
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
let modBasePoint: Pt | null = null;
|
2026-08-27 00:53:16 +02:00
|
|
|
|
|
|
|
|
|
|
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'],
|
2026-08-27 01:04:48 +02:00
|
|
|
|
description: `${label}: Auswahl treffen, dann Basis\u2192Ziel klicken`,
|
2026-08-27 00:53:16 +02:00
|
|
|
|
},
|
|
|
|
|
|
optionsSchema: [],
|
|
|
|
|
|
handlers: {
|
|
|
|
|
|
down(e, ctx) {
|
2026-08-27 01:04:48 +02:00
|
|
|
|
const c = ctx as FullToolContext;
|
2026-08-27 00:53:16 +02:00
|
|
|
|
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 {
|
2026-08-27 01:04:48 +02:00
|
|
|
|
c.doc!.addElement({
|
2026-08-27 00:53:16 +02:00
|
|
|
|
...el,
|
2026-08-27 01:04:48 +02:00
|
|
|
|
id: uid('cp'),
|
2026-08-27 00:53:16 +02:00
|
|
|
|
x: el.x + dx,
|
|
|
|
|
|
y: el.y + dy,
|
2026-08-27 01:04:48 +02:00
|
|
|
|
});
|
2026-08-27 00:53:16 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
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');
|
|
|
|
|
|
|
2026-08-27 01:04:48 +02:00
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
// 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');
|
|
|
|
|
|
|
2026-08-27 18:40:58 +02:00
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
// 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');
|
|
|
|
|
|
|
2026-08-27 01:09:51 +02:00
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
2026-08-27 01:21:44 +02:00
|
|
|
|
// 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);
|
2026-08-27 01:09:51 +02:00
|
|
|
|
// 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');
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-27 01:04:48 +02:00
|
|
|
|
/** V2-Plugin: Kern-Bearbeitungswerkzeuge. */
|
2026-08-26 21:30:54 +02:00
|
|
|
|
export const coreModifyPlugin: PluginV2 = {
|
|
|
|
|
|
manifest: {
|
|
|
|
|
|
id: 'core-modify',
|
|
|
|
|
|
name: 'Bearbeiten (Kern)',
|
2026-08-27 18:40:58 +02:00
|
|
|
|
version: '1.6.0',
|
2026-08-26 21:30:54 +02:00
|
|
|
|
author: 'web-cad team',
|
2026-08-27 18:40:58 +02:00
|
|
|
|
description: 'Auswahl, Schraffur, Move/Copy/Rotate/Scale/Mirror/Delete/Offset/Trim/Extend/Fillet/Arrays (V2).',
|
2026-08-26 21:30:54 +02:00
|
|
|
|
category: 'tools',
|
|
|
|
|
|
enabledByDefault: true,
|
|
|
|
|
|
},
|
2026-08-27 18:40:58 +02:00
|
|
|
|
tools: [selectTool, hatchTool, moveTool, copyTool, rotateTool, scaleTool, mirrorTool, deleteTool, offsetTool, trimTool, extendTool, filletTool, arrayRectTool, arrayPolarTool],
|
2026-08-26 21:30:54 +02:00
|
|
|
|
};
|