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

380 lines
15 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,
} 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)
// ─────────────────────────────────────────────────────────────
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;
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);
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)`);
},
},
};
// ─────────────────────────────────────────────────────────────
// 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');
// ─────────────────────────────────────────────────────────────
// Delete (Task A4.8) — Portierung aus Legacy handleDeleteDown:
// 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.4.0',
2026-08-26 21:30:54 +02:00
author: 'web-cad team',
description: 'Auswahl, Schraffur, Verschieben, Kopieren, Rotieren, Skalieren, Spiegeln, Löschen, Versatz (V2).',
2026-08-26 21:30:54 +02:00
category: 'tools',
enabledByDefault: true,
},
tools: [selectTool, hatchTool, moveTool, copyTool, rotateTool, scaleTool, mirrorTool, deleteTool, offsetTool],
2026-08-26 21:30:54 +02:00
};