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';
|
2026-08-31 00:53:08 +02:00
|
|
|
|
import { openBlockForEdit, saveBlockEdit, discardBlockEdit } from '../../../services/blockEditorService';
|
|
|
|
|
|
import { BLOCK_EDIT_MARKER } from '../../../services/blockEditorService';
|
|
|
|
|
|
export { BLOCK_EDIT_MARKER };
|
2026-08-27 01:04:48 +02:00
|
|
|
|
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)}`;
|
|
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
2026-08-27 20:00:33 +02:00
|
|
|
|
// select (Task A3 – Pilot) + Handle-Drag (Task C3fin interaktiv)
|
2026-08-27 01:04:48 +02:00
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
2026-08-27 20:00:33 +02:00
|
|
|
|
/** 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-29 03:25:38 +02:00
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
// Task D9: GripEditing — Vertex-/Center-Grips (reine Funktion).
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
export interface GripPoint {
|
|
|
|
|
|
kind: 'vertex' | 'center' | 'corner';
|
|
|
|
|
|
point: Pt;
|
|
|
|
|
|
index: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Element-spezifische Griff-Punkte (Task D9):
|
|
|
|
|
|
* line = beide Endpunkte, polyline/polygon = alle Vertices,
|
|
|
|
|
|
* circle/arc = Zentrum, rect/default = 4 BBox-Ecken (C3fin-Skalierung).
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function getGripPoints(el: CADElement): GripPoint[] {
|
|
|
|
|
|
const props = el.properties as Record<string, unknown>;
|
|
|
|
|
|
const type = String(el.type);
|
|
|
|
|
|
if (
|
|
|
|
|
|
type === 'line' &&
|
|
|
|
|
|
props.x1 !== undefined && props.y1 !== undefined &&
|
|
|
|
|
|
props.x2 !== undefined && props.y2 !== undefined
|
|
|
|
|
|
) {
|
|
|
|
|
|
return [
|
|
|
|
|
|
{ kind: 'vertex', point: { x: props.x1 as number, y: props.y1 as number }, index: 0 },
|
|
|
|
|
|
{ kind: 'vertex', point: { x: props.x2 as number, y: props.y2 as number }, index: 1 },
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
if (type === 'polyline' || type === 'polygon') {
|
|
|
|
|
|
const pts = (props.points as Pt[] | undefined) ?? [];
|
|
|
|
|
|
return pts.map((p, i) => ({ kind: 'vertex' as const, point: { x: p.x, y: p.y }, index: i }));
|
|
|
|
|
|
}
|
|
|
|
|
|
if (type === 'circle' || type === 'arc') {
|
|
|
|
|
|
return [{ kind: 'center', point: { x: el.x, y: el.y }, index: 0 }];
|
|
|
|
|
|
}
|
|
|
|
|
|
// rect und alle anderen: BBox-Ecken (Corner-Drag/Skalierung, C3fin)
|
|
|
|
|
|
const minX = el.x - el.width / 2;
|
|
|
|
|
|
const minY = el.y - el.height / 2;
|
|
|
|
|
|
const maxX = el.x + el.width / 2;
|
|
|
|
|
|
const maxY = el.y + el.height / 2;
|
|
|
|
|
|
return [
|
|
|
|
|
|
{ kind: 'corner', point: { x: minX, y: minY }, index: 0 },
|
|
|
|
|
|
{ kind: 'corner', point: { x: maxX, y: minY }, index: 1 },
|
|
|
|
|
|
{ kind: 'corner', point: { x: maxX, y: maxY }, index: 2 },
|
|
|
|
|
|
{ kind: 'corner', point: { x: minX, y: maxY }, index: 3 },
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Wendet eine Vertex-/Center-Drag-Position an (Task D9) und liefert
|
|
|
|
|
|
* das aktualisierte Element (reine Funktion — für Preview UND Commit).
|
|
|
|
|
|
*/
|
|
|
|
|
|
function applyVertexDrag(
|
|
|
|
|
|
el: CADElement,
|
|
|
|
|
|
drag: { el: CADElement; gripKind: 'vertex' | 'center'; index: number },
|
|
|
|
|
|
wx: number,
|
|
|
|
|
|
wy: number,
|
|
|
|
|
|
): CADElement {
|
|
|
|
|
|
const props = { ...(el.properties as Record<string, unknown>) };
|
|
|
|
|
|
const type = String(el.type);
|
|
|
|
|
|
if (drag.gripKind === 'center') {
|
|
|
|
|
|
return { ...el, x: wx, y: wy, properties: props as CADElement['properties'] } as CADElement;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (type === 'line') {
|
|
|
|
|
|
if (drag.index === 0) { props.x1 = wx; props.y1 = wy; }
|
|
|
|
|
|
else { props.x2 = wx; props.y2 = wy; }
|
|
|
|
|
|
// BBox aktualisieren
|
|
|
|
|
|
const x1 = props.x1 as number, y1 = props.y1 as number, x2 = props.x2 as number, y2 = props.y2 as number;
|
|
|
|
|
|
return {
|
|
|
|
|
|
...el,
|
|
|
|
|
|
x: (x1 + x2) / 2,
|
|
|
|
|
|
y: (y1 + y2) / 2,
|
|
|
|
|
|
width: Math.abs(x2 - x1),
|
|
|
|
|
|
height: Math.abs(y2 - y1),
|
|
|
|
|
|
properties: props as CADElement['properties'],
|
|
|
|
|
|
} as CADElement;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (type === 'polyline' || type === 'polygon') {
|
|
|
|
|
|
const pts = [...((props.points as Pt[] | undefined) ?? [])];
|
|
|
|
|
|
if (drag.index < pts.length) pts[drag.index] = { x: wx, y: wy };
|
|
|
|
|
|
props.points = pts;
|
|
|
|
|
|
const xs = pts.map((p) => p.x), ys = pts.map((p) => p.y);
|
|
|
|
|
|
const minX = Math.min(...xs), minY = Math.min(...ys);
|
|
|
|
|
|
const maxX = Math.max(...xs), maxY = Math.max(...ys);
|
|
|
|
|
|
return {
|
|
|
|
|
|
...el,
|
|
|
|
|
|
x: minX + (maxX - minX) / 2,
|
|
|
|
|
|
y: minY + (maxY - minY) / 2,
|
|
|
|
|
|
width: maxX - minX,
|
|
|
|
|
|
height: maxY - minY,
|
|
|
|
|
|
properties: props as CADElement['properties'],
|
|
|
|
|
|
} as CADElement;
|
|
|
|
|
|
}
|
|
|
|
|
|
return el;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-30 14:11:07 +02:00
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
// Task CAD-3: Fenster-/Kreuz-Selektion (Box-Select).
|
|
|
|
|
|
// Links-nach-rechts ziehen = 'window' (Element muss VOLLSTAENDIG
|
|
|
|
|
|
// in der Box liegen), rechts-nach-links = 'crossing' (Beruehrung
|
|
|
|
|
|
// reicht). Leerer down startet Box-Drag statt Selektion zu leeren.
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/** Richtung bestimmt den Modus: to.x >= from.x → window, sonst crossing. */
|
|
|
|
|
|
export function boxModeFor(from: Pt, to: Pt): 'window' | 'crossing' {
|
|
|
|
|
|
return to.x >= from.x ? 'window' : 'crossing';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Selektiert alle Elemente, die die Box erfuellen.
|
|
|
|
|
|
* window: BBox des Elements VOLLSTAENDIG in der Box.
|
|
|
|
|
|
* crossing: BBox des Elements ueberschneidet/beruehrt die Box.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function boxSelectElements(
|
|
|
|
|
|
elements: CADElement[],
|
|
|
|
|
|
box: { x1: number; y1: number; x2: number; y2: number },
|
|
|
|
|
|
mode: 'window' | 'crossing',
|
|
|
|
|
|
): string[] {
|
|
|
|
|
|
const minX = Math.min(box.x1, box.x2);
|
|
|
|
|
|
const maxX = Math.max(box.x1, box.x2);
|
|
|
|
|
|
const minY = Math.min(box.y1, box.y2);
|
|
|
|
|
|
const maxY = Math.max(box.y1, box.y2);
|
|
|
|
|
|
const hits: string[] = [];
|
|
|
|
|
|
for (const el of elements) {
|
|
|
|
|
|
const b = elementBBox(el);
|
|
|
|
|
|
if (!b) continue;
|
|
|
|
|
|
if (mode === 'window') {
|
|
|
|
|
|
if (b.minX >= minX && b.maxX <= maxX && b.minY >= minY && b.maxY <= maxY) {
|
|
|
|
|
|
hits.push(el.id);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// crossing: Ueberschneidung (Beruehrung inklusive)
|
|
|
|
|
|
if (b.minX <= maxX && b.maxX >= minX && b.minY <= maxY && b.maxY >= minY) {
|
|
|
|
|
|
hits.push(el.id);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return hits;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** BBox eines Elements (line aus Koordinaten, sonst x/y±w/2). */
|
|
|
|
|
|
function elementBBox(el: CADElement): { minX: number; minY: number; maxX: number; maxY: number } | null {
|
|
|
|
|
|
const props = el.properties as Record<string, unknown>;
|
|
|
|
|
|
if (
|
|
|
|
|
|
props.x1 !== undefined && props.y1 !== undefined &&
|
|
|
|
|
|
props.x2 !== undefined && props.y2 !== undefined
|
|
|
|
|
|
) {
|
|
|
|
|
|
const x1 = props.x1 as number, y1 = props.y1 as number;
|
|
|
|
|
|
const x2 = props.x2 as number, y2 = props.y2 as number;
|
|
|
|
|
|
return { minX: Math.min(x1, x2), minY: Math.min(y1, y2), maxX: Math.max(x1, x2), maxY: Math.max(y1, y2) };
|
|
|
|
|
|
}
|
|
|
|
|
|
const w = el.width ?? 0, h = el.height ?? 0;
|
|
|
|
|
|
return { minX: el.x - w / 2, minY: el.y - h / 2, maxX: el.x + w / 2, maxY: el.y + h / 2 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Box-Drag-Sitzung (Task CAD-3). */
|
|
|
|
|
|
let boxDrag: { start: Pt; shift: boolean } | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
/** Mindestgroesse (Welt-Einheiten) ab der ein Zug als Box gilt. */
|
|
|
|
|
|
const BOX_MIN_SIZE = 3;
|
|
|
|
|
|
|
2026-08-29 03:25:38 +02:00
|
|
|
|
/** Vertex-/Center-Drag-Sitzung (Task D9). */
|
|
|
|
|
|
let vertexDrag: {
|
|
|
|
|
|
el: CADElement;
|
|
|
|
|
|
gripKind: 'vertex' | 'center';
|
|
|
|
|
|
index: number;
|
|
|
|
|
|
} | null = 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) {
|
2026-08-27 01:04:48 +02:00
|
|
|
|
const bridge = (ctx as FullToolContext).selection;
|
2026-08-27 20:00:33 +02:00
|
|
|
|
const c = ctx as FullToolContext;
|
2026-08-26 21:30:54 +02:00
|
|
|
|
if (!bridge) return;
|
2026-08-30 14:11:07 +02:00
|
|
|
|
// CAD-3: verlorene Box-Sitzungen abräumen (defensive State-Hygiene,
|
|
|
|
|
|
// schützt auch vor verlorenen up-Events); wird unten bei Leerraum neu gesetzt.
|
|
|
|
|
|
boxDrag = null;
|
2026-08-26 21:30:54 +02:00
|
|
|
|
const additive = e.shift;
|
|
|
|
|
|
const subtractive = e.ctrl;
|
|
|
|
|
|
const hit = bridge.hitTest(e.world.x, e.world.y, 5);
|
2026-08-27 20:00:33 +02:00
|
|
|
|
|
2026-08-29 03:25:38 +02:00
|
|
|
|
// ── Task D9: Vertex-/Center-Grip-Prüfung VOR BBox-Corners ──
|
|
|
|
|
|
// (spezifischere Grips gewinnen gegenüber Corner-Skalierung)
|
|
|
|
|
|
if (!additive && !subtractive && c.doc) {
|
|
|
|
|
|
const GRIP_TOL = 8;
|
|
|
|
|
|
for (const id of bridge.getIds()) {
|
|
|
|
|
|
const el = c.doc.getElement(id);
|
|
|
|
|
|
if (!el) continue;
|
|
|
|
|
|
for (const g of getGripPoints(el)) {
|
|
|
|
|
|
if (g.kind === 'corner') continue; // Corner unten (C3fin)
|
|
|
|
|
|
if (
|
|
|
|
|
|
Math.abs(e.world.x - g.point.x) <= GRIP_TOL &&
|
|
|
|
|
|
Math.abs(e.world.y - g.point.y) <= GRIP_TOL
|
|
|
|
|
|
) {
|
|
|
|
|
|
vertexDrag = { el, gripKind: g.kind, index: g.index };
|
|
|
|
|
|
const what = g.kind === 'center' ? 'Zentrum' : `Punkt ${g.index + 1}`;
|
|
|
|
|
|
ctx.setStatus(`${what} gegriffen — ziehen zum Verschieben`);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-27 20:00:33 +02:00
|
|
|
|
// ── 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) {
|
2026-08-30 14:11:07 +02:00
|
|
|
|
// ── Task CAD-3: Leerraum-Klick startet Box-Select-Drag ──
|
|
|
|
|
|
// (statt sofort zu leeren; das Leeren passiert nur bei Klick OHNE Zug)
|
|
|
|
|
|
boxDrag = { start: e.world, shift: e.shift };
|
|
|
|
|
|
ctx.setStatus('Box-Auswahl: ziehen (rechts=fenster, links=kreuzend)');
|
2026-08-26 21:30:54 +02:00
|
|
|
|
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 20:00:33 +02:00
|
|
|
|
|
|
|
|
|
|
move(e, ctx) {
|
2026-08-30 14:11:07 +02:00
|
|
|
|
// ── Task CAD-3: Box-Select-Preview ──
|
|
|
|
|
|
if (boxDrag) {
|
|
|
|
|
|
const c = ctx as FullToolContext;
|
|
|
|
|
|
const x = Math.min(boxDrag.start.x, e.world.x);
|
|
|
|
|
|
const y = Math.min(boxDrag.start.y, e.world.y);
|
|
|
|
|
|
const w = Math.abs(e.world.x - boxDrag.start.x);
|
|
|
|
|
|
const h = Math.abs(e.world.y - boxDrag.start.y);
|
|
|
|
|
|
const mode = boxModeFor(boxDrag.start, e.world);
|
|
|
|
|
|
c.setPreview?.({
|
|
|
|
|
|
id: '__preview_boxselect__',
|
|
|
|
|
|
type: 'rect',
|
|
|
|
|
|
layerId: 'layer-0',
|
|
|
|
|
|
x: x + w / 2,
|
|
|
|
|
|
y: y + h / 2,
|
|
|
|
|
|
width: w,
|
|
|
|
|
|
height: h,
|
|
|
|
|
|
properties: {
|
|
|
|
|
|
stroke: mode === 'window' ? '#4a90d9' : '#22c55e',
|
|
|
|
|
|
strokeWidth: 1,
|
|
|
|
|
|
fill: mode === 'window' ? 'rgba(74,144,217,0.08)' : 'rgba(34,197,94,0.08)',
|
|
|
|
|
|
boxSelectMode: mode,
|
|
|
|
|
|
},
|
|
|
|
|
|
} as unknown as CADElement);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-08-29 03:25:38 +02:00
|
|
|
|
// ── Task D9: Vertex-Drag-Preview (nur der Punkt aktualisiert) ──
|
|
|
|
|
|
if (vertexDrag) {
|
|
|
|
|
|
const c = ctx as FullToolContext;
|
|
|
|
|
|
const el = c.doc?.getElement(vertexDrag.el.id);
|
|
|
|
|
|
if (!el) return;
|
|
|
|
|
|
const updated = applyVertexDrag(el, vertexDrag, e.world.x, e.world.y);
|
|
|
|
|
|
c.setPreview?.(updated);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-08-27 20:00:33 +02:00
|
|
|
|
// 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) {
|
2026-08-30 14:11:07 +02:00
|
|
|
|
// ── Task CAD-3: Box-Select-Abschluss ──
|
|
|
|
|
|
if (boxDrag) {
|
|
|
|
|
|
const c = ctx as FullToolContext;
|
|
|
|
|
|
const start = boxDrag.start;
|
|
|
|
|
|
const shift = boxDrag.shift || e.shift;
|
|
|
|
|
|
boxDrag = null;
|
|
|
|
|
|
ctx.setPreview?.(null);
|
|
|
|
|
|
const w = Math.abs(e.world.x - start.x);
|
|
|
|
|
|
const h = Math.abs(e.world.y - start.y);
|
|
|
|
|
|
// Winzige Box = Klick ohne Zug → Legacy: Selektion leeren
|
|
|
|
|
|
if (w < BOX_MIN_SIZE && h < BOX_MIN_SIZE) {
|
|
|
|
|
|
if (!shift) (c.selection ?? { setIds: () => {} }).setIds([]);
|
|
|
|
|
|
ctx.setStatus('Selektion geleert');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const mode = boxModeFor(start, e.world);
|
|
|
|
|
|
const all = c.doc ? c.doc.getAllElements() : [];
|
|
|
|
|
|
const hits = boxSelectElements(all, {
|
|
|
|
|
|
x1: start.x, y1: start.y, x2: e.world.x, y2: e.world.y,
|
|
|
|
|
|
}, mode);
|
|
|
|
|
|
const bridge = (ctx as FullToolContext).selection;
|
|
|
|
|
|
if (!bridge) return;
|
|
|
|
|
|
if (shift) {
|
|
|
|
|
|
const merged = new Set([...bridge.getIds(), ...hits]);
|
|
|
|
|
|
bridge.setIds(Array.from(merged));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
bridge.setIds(hits);
|
|
|
|
|
|
}
|
|
|
|
|
|
const count = shift
|
|
|
|
|
|
? new Set([...(bridge.getIds())]).size
|
|
|
|
|
|
: hits.length;
|
|
|
|
|
|
ctx.setStatus(`Box-Auswahl (${mode === 'window' ? 'Fenster' : 'Kreuzend'}): ${count} Element(e)`);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-08-29 03:25:38 +02:00
|
|
|
|
// ── Task D9: Vertex-Commit (nur der Punkt, 1 Undo-Schritt) ──
|
|
|
|
|
|
if (vertexDrag) {
|
|
|
|
|
|
const c = ctx as FullToolContext;
|
|
|
|
|
|
const el = c.doc?.getElement(vertexDrag.el.id);
|
|
|
|
|
|
if (el && c.doc) {
|
|
|
|
|
|
const updated = applyVertexDrag(el, vertexDrag, e.world.x, e.world.y);
|
|
|
|
|
|
c.doc.transact(() => {
|
|
|
|
|
|
c.doc!.updateElement(el.id, {
|
|
|
|
|
|
x: updated.x,
|
|
|
|
|
|
y: updated.y,
|
|
|
|
|
|
properties: updated.properties as CADElement['properties'],
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
const what = vertexDrag.gripKind === 'center' ? 'Zentrum' : `Punkt ${vertexDrag.index + 1}`;
|
|
|
|
|
|
ctx.setStatus(`${what} verschoben`);
|
|
|
|
|
|
}
|
|
|
|
|
|
vertexDrag = null;
|
|
|
|
|
|
ctx.setPreview?.(null);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-08-27 20:00:33 +02:00
|
|
|
|
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;
|
2026-08-29 03:25:38 +02:00
|
|
|
|
vertexDrag = null;
|
2026-08-30 14:11:07 +02:00
|
|
|
|
boxDrag = null;
|
2026-08-27 20:00:33 +02:00
|
|
|
|
ctx.setPreview?.(null);
|
|
|
|
|
|
},
|
2026-08-26 21:30:54 +02:00
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
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: [
|
2026-08-29 10:03:01 +02:00
|
|
|
|
{ value: 'ANSI31', label: 'ANSI31 (45°)' },
|
|
|
|
|
|
{ value: 'ANSI32', label: 'ANSI32 (Eisen)' },
|
|
|
|
|
|
{ value: 'ANSI37', label: 'ANSI37 (Beton)' },
|
|
|
|
|
|
{ value: 'NET', label: 'Netz' },
|
|
|
|
|
|
{ value: 'GRASS', label: 'Gras' },
|
|
|
|
|
|
{ value: 'DOTS', label: 'Punkte' },
|
|
|
|
|
|
{ value: 'SOLID', label: 'Voll' },
|
|
|
|
|
|
{ value: 'lines', label: 'Linien (alt)' },
|
|
|
|
|
|
{ value: 'cross', label: 'Kreuz (alt)' },
|
2026-08-27 00:48:28 +02:00
|
|
|
|
] },
|
|
|
|
|
|
{ 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 20:03:14 +02:00
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
// 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');
|
|
|
|
|
|
|
2026-08-27 22:14:01 +02:00
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
// Polyline-Editor (Task D-Finish): Vertices einer Polyline verschieben.
|
|
|
|
|
|
// Klick 1: Polyline wählen. Klick 2+: Vertex wählen (Toleranz) und ziehen.
|
|
|
|
|
|
// ESC beendet.
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
let editPoly: CADElement | null = null;
|
|
|
|
|
|
let editVertexIdx = -1;
|
|
|
|
|
|
|
|
|
|
|
|
export const polylineEditTool: ToolExtensionV2 = {
|
|
|
|
|
|
manifest: {
|
|
|
|
|
|
id: 'polyline-edit',
|
|
|
|
|
|
label: 'Polyline-Editor',
|
|
|
|
|
|
icon: '\u2b1a',
|
|
|
|
|
|
ribbonTab: 'canvas',
|
|
|
|
|
|
tags: ['pro'],
|
|
|
|
|
|
description: 'Polyline wählen, dann Vertex ziehen',
|
|
|
|
|
|
},
|
|
|
|
|
|
optionsSchema: [],
|
|
|
|
|
|
handlers: {
|
|
|
|
|
|
down(e, ctx) {
|
|
|
|
|
|
const c = ctx as FullToolContext;
|
|
|
|
|
|
if (!c.doc || !c.selection) return;
|
|
|
|
|
|
if (!editPoly) {
|
|
|
|
|
|
const hit = c.selection.hitTest(e.world.x, e.world.y, 5);
|
|
|
|
|
|
if (!hit || hit.type !== 'polyline') {
|
|
|
|
|
|
ctx.setStatus('Polyline-Editor: erst eine Polyline anklicken');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
editPoly = hit;
|
|
|
|
|
|
ctx.setStatus('Polyline-Editor: Vertex ziehen, ESC beendet');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Vertex finden in der Nähe:
|
|
|
|
|
|
const props = editPoly.properties as Record<string, unknown>;
|
|
|
|
|
|
const pts = Array.isArray(props.points)
|
|
|
|
|
|
? (props.points as unknown[]).map((p) => p as Pt)
|
|
|
|
|
|
: [];
|
|
|
|
|
|
for (let i = 0; i < pts.length; i++) {
|
|
|
|
|
|
if (Math.hypot(pts[i].x - e.world.x, pts[i].y - e.world.y) < 8) {
|
|
|
|
|
|
editVertexIdx = i;
|
|
|
|
|
|
ctx.setStatus(`Vertex ${i + 1} — ziehen`);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
ctx.setStatus('Kein Vertex in der Nähe');
|
|
|
|
|
|
},
|
|
|
|
|
|
move(e, ctx) {
|
|
|
|
|
|
if (!editPoly || editVertexIdx < 0) return;
|
|
|
|
|
|
const c = ctx as FullToolContext;
|
|
|
|
|
|
const props = editPoly.properties as Record<string, unknown>;
|
|
|
|
|
|
const pts = Array.isArray(props.points)
|
|
|
|
|
|
? (props.points as unknown[]).map((p) => p as Pt)
|
|
|
|
|
|
: [];
|
|
|
|
|
|
pts[editVertexIdx] = e.world;
|
|
|
|
|
|
const preview: CADElement = {
|
|
|
|
|
|
...editPoly,
|
|
|
|
|
|
properties: { ...props, points: pts },
|
|
|
|
|
|
};
|
|
|
|
|
|
c.setPreview?.(preview);
|
|
|
|
|
|
},
|
|
|
|
|
|
up(e, ctx) {
|
|
|
|
|
|
if (!editPoly || editVertexIdx < 0) return;
|
|
|
|
|
|
const c = ctx as FullToolContext;
|
|
|
|
|
|
if (!c.doc) { editVertexIdx = -1; return; }
|
|
|
|
|
|
const current = c.doc.getElement(editPoly.id);
|
|
|
|
|
|
if (!current) { editVertexIdx = -1; return; }
|
|
|
|
|
|
const props = current.properties as Record<string, unknown>;
|
|
|
|
|
|
const pts = Array.isArray(props.points)
|
|
|
|
|
|
? (props.points as unknown[]).map((p) => ({ ...(p as Pt) }))
|
|
|
|
|
|
: [];
|
|
|
|
|
|
if (editVertexIdx < pts.length) {
|
|
|
|
|
|
pts[editVertexIdx] = e.world;
|
|
|
|
|
|
const xs = pts.map((p) => p.x);
|
|
|
|
|
|
const ys = pts.map((p) => p.y);
|
|
|
|
|
|
c.doc.transact(() => {
|
|
|
|
|
|
c.doc!.updateElement(editPoly!.id, {
|
|
|
|
|
|
x: (Math.min(...xs) + Math.max(...xs)) / 2,
|
|
|
|
|
|
y: (Math.min(...ys) + Math.max(...ys)) / 2,
|
|
|
|
|
|
width: Math.max(...xs) - Math.min(...xs),
|
|
|
|
|
|
height: Math.max(...ys) - Math.min(...ys),
|
|
|
|
|
|
properties: { ...props, points: pts },
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
ctx.setStatus(`Vertex ${editVertexIdx + 1} verschoben`);
|
|
|
|
|
|
}
|
|
|
|
|
|
editVertexIdx = -1;
|
|
|
|
|
|
},
|
|
|
|
|
|
cancel(ctx) {
|
|
|
|
|
|
editPoly = null;
|
|
|
|
|
|
editVertexIdx = -1;
|
|
|
|
|
|
ctx.setStatus('Polyline-Editor beendet');
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
// Stretch (Task D-Finish): Endpunkte von Linien in einem Auswahlbereich
|
|
|
|
|
|
// verschieben. Klick 1: Basis, Klick 2: Ziel (Delta auf alle selektierten
|
|
|
|
|
|
// Endpunkte, die in der Nähe des Basispunkts liegen).
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
let stretchBase: Pt | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
export const stretchTool: ToolExtensionV2 = {
|
|
|
|
|
|
manifest: {
|
|
|
|
|
|
id: 'stretch',
|
|
|
|
|
|
label: 'Stretch',
|
|
|
|
|
|
icon: '\u2197',
|
|
|
|
|
|
ribbonTab: 'canvas',
|
|
|
|
|
|
tags: ['pro'],
|
|
|
|
|
|
description: 'Linien auswählen, dann Basis→Ziel (Endpunkte in Basisnähe werden gestreckt)',
|
|
|
|
|
|
},
|
|
|
|
|
|
optionsSchema: [],
|
|
|
|
|
|
handlers: {
|
|
|
|
|
|
down(e, ctx) {
|
|
|
|
|
|
const c = ctx as FullToolContext;
|
|
|
|
|
|
if (!c.doc || !c.selection) return;
|
|
|
|
|
|
if (!stretchBase) {
|
|
|
|
|
|
if (c.selection.getIds().length === 0) {
|
|
|
|
|
|
const hit = c.selection.hitTest(e.world.x, e.world.y, 5);
|
|
|
|
|
|
if (!hit) {
|
|
|
|
|
|
ctx.setStatus('Stretch: zuerst Linien auswählen');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
c.selection.setIds([hit.id]);
|
|
|
|
|
|
}
|
|
|
|
|
|
stretchBase = e.world;
|
|
|
|
|
|
ctx.setStatus(`Stretch: Zielklick (${c.selection.getIds().length} Linien)`);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Commit: alle Endpunkte, die dem Basispunkt am nächsten sind, verschieben:
|
|
|
|
|
|
const dx = e.world.x - stretchBase.x;
|
|
|
|
|
|
const dy = e.world.y - stretchBase.y;
|
|
|
|
|
|
const ids = c.selection.getIds();
|
|
|
|
|
|
c.doc.transact(() => {
|
|
|
|
|
|
for (const id of ids) {
|
|
|
|
|
|
const el = c.doc!.getElement(id);
|
|
|
|
|
|
if (!el || el.type !== 'line') continue;
|
|
|
|
|
|
const props = el.properties as { x1?: number; y1?: number; x2?: number; y2?: number };
|
|
|
|
|
|
if (props.x1 === undefined || props.y1 === undefined || props.x2 === undefined || props.y2 === undefined) continue;
|
|
|
|
|
|
// Endpunkte die dem Basispunkt am nächsten sind verschieben:
|
|
|
|
|
|
const d1 = Math.hypot(props.x1! - stretchBase!.x, props.y1! - stretchBase!.y);
|
|
|
|
|
|
const d2 = Math.hypot(props.x2! - stretchBase!.x, props.y2! - stretchBase!.y);
|
|
|
|
|
|
if (d1 < d2) {
|
|
|
|
|
|
props.x1 = props.x1! + dx;
|
|
|
|
|
|
props.y1 = props.y1! + dy;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
props.x2 = props.x2! + dx;
|
|
|
|
|
|
props.y2 = props.y2! + dy;
|
|
|
|
|
|
}
|
|
|
|
|
|
const nx1 = props.x1!;
|
|
|
|
|
|
const ny1 = props.y1!;
|
|
|
|
|
|
const nx2 = props.x2!;
|
|
|
|
|
|
const ny2 = props.y2!;
|
|
|
|
|
|
c.doc!.updateElement(id, {
|
|
|
|
|
|
x: (nx1 + nx2) / 2,
|
|
|
|
|
|
y: (ny1 + ny2) / 2,
|
|
|
|
|
|
width: Math.abs(nx2 - nx1),
|
|
|
|
|
|
height: Math.abs(ny2 - ny1),
|
|
|
|
|
|
properties: props,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
ctx.setStatus(`Stretch: ${ids.length} Linie(n) gestreckt \u0394(${dx.toFixed(0)}, ${dy.toFixed(0)})`);
|
|
|
|
|
|
stretchBase = null;
|
|
|
|
|
|
},
|
|
|
|
|
|
cancel(ctx) {
|
|
|
|
|
|
stretchBase = null;
|
|
|
|
|
|
ctx.setStatus('Stretch abgebrochen');
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
// Break (Task D-Finish): Linie an einem Punkt in zwei Teile splitten.
|
|
|
|
|
|
// Klick auf Linie → zwei neue Linien, Original wird entfernt.
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
export const breakTool: ToolExtensionV2 = {
|
|
|
|
|
|
manifest: {
|
|
|
|
|
|
id: 'break',
|
|
|
|
|
|
label: 'Break',
|
|
|
|
|
|
icon: '\u2702',
|
|
|
|
|
|
ribbonTab: 'canvas',
|
|
|
|
|
|
tags: ['pro'],
|
|
|
|
|
|
description: 'Linie anklicken — wird am Klickpunkt geteilt',
|
|
|
|
|
|
},
|
|
|
|
|
|
optionsSchema: [],
|
|
|
|
|
|
handlers: {
|
|
|
|
|
|
down(e, ctx) {
|
|
|
|
|
|
const c = ctx as FullToolContext;
|
|
|
|
|
|
if (!c.doc || !c.selection) return;
|
|
|
|
|
|
const hit = c.selection.hitTest(e.world.x, e.world.y, 5);
|
|
|
|
|
|
if (!hit || hit.type !== 'line') {
|
|
|
|
|
|
ctx.setStatus('Break: Linie anklicken');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const props = hit.properties as { x1?: number; y1?: number; x2?: number; y2?: number };
|
|
|
|
|
|
if (props.x1 === undefined || props.y1 === undefined || props.x2 === undefined || props.y2 === undefined) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Klick auf die Linie projizieren (nächster Punkt auf der Linie):
|
|
|
|
|
|
const x1 = props.x1 as number;
|
|
|
|
|
|
const y1 = props.y1 as number;
|
|
|
|
|
|
const x2 = props.x2 as number;
|
|
|
|
|
|
const y2 = props.y2 as number;
|
|
|
|
|
|
const dx = x2 - x1;
|
|
|
|
|
|
const dy = y2 - y1;
|
|
|
|
|
|
const lenSq = dx * dx + dy * dy;
|
|
|
|
|
|
if (lenSq === 0) return;
|
|
|
|
|
|
const t = Math.max(0, Math.min(1, ((e.world.x - x1) * dx + (e.world.y - y1) * dy) / lenSq));
|
|
|
|
|
|
const breakX = x1 + t * dx;
|
|
|
|
|
|
const breakY = y1 + t * dy;
|
|
|
|
|
|
|
|
|
|
|
|
c.doc.transact(() => {
|
|
|
|
|
|
// Original entfernen:
|
|
|
|
|
|
c.doc!.deleteElements([hit.id]);
|
|
|
|
|
|
// Zwei neue Linien hinzufügen:
|
|
|
|
|
|
c.doc!.addElement({
|
|
|
|
|
|
...hit,
|
|
|
|
|
|
id: uid('brk'),
|
|
|
|
|
|
x: (x1 + breakX) / 2,
|
|
|
|
|
|
y: (y1 + breakY) / 2,
|
|
|
|
|
|
width: Math.abs(breakX - x1),
|
|
|
|
|
|
height: Math.abs(breakY - y1),
|
|
|
|
|
|
properties: { ...props, x2: breakX, y2: breakY },
|
|
|
|
|
|
});
|
|
|
|
|
|
c.doc!.addElement({
|
|
|
|
|
|
...hit,
|
|
|
|
|
|
id: uid('brk'),
|
|
|
|
|
|
x: (breakX + x2) / 2,
|
|
|
|
|
|
y: (breakY + y2) / 2,
|
|
|
|
|
|
width: Math.abs(x2 - breakX),
|
|
|
|
|
|
height: Math.abs(y2 - breakY),
|
|
|
|
|
|
properties: { ...props, x1: breakX, y1: breakY },
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
ctx.setStatus(`Break: Linie bei (${breakX.toFixed(0)}, ${breakY.toFixed(0)}) geteilt`);
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
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-29 03:29:05 +02:00
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
// Task D10-Rest: array-path — Elemente entlang einer Polyline
|
|
|
|
|
|
// (Pfad) verteilen. Klick1: Quelle (Auto-HitSelect), Klick2: auf
|
|
|
|
|
|
// die Pfad-Polyline klicken. count Kopien gleichmäßig über die
|
|
|
|
|
|
// Gesamtlänge (s = i/(count-1)·total), ALLES in 1 Transaktion.
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
let arrayPathBase: Pt | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
/** Punkt auf einer Polyline bei kumulierter Länge s (0..total). */
|
|
|
|
|
|
function pointAlongPolyline(pts: Pt[], s: number): Pt {
|
|
|
|
|
|
if (pts.length === 0) return { x: 0, y: 0 };
|
|
|
|
|
|
if (pts.length === 1 || s <= 0) return { ...pts[0] };
|
|
|
|
|
|
let remaining = s;
|
|
|
|
|
|
for (let i = 0; i < pts.length - 1; i++) {
|
|
|
|
|
|
const dx = pts[i + 1].x - pts[i].x;
|
|
|
|
|
|
const dy = pts[i + 1].y - pts[i].y;
|
|
|
|
|
|
const segLen = Math.hypot(dx, dy);
|
|
|
|
|
|
if (segLen <= 0) continue;
|
|
|
|
|
|
if (remaining <= segLen) {
|
|
|
|
|
|
const t = remaining / segLen;
|
|
|
|
|
|
return { x: pts[i].x + dx * t, y: pts[i].y + dy * t };
|
|
|
|
|
|
}
|
|
|
|
|
|
remaining -= segLen;
|
|
|
|
|
|
}
|
|
|
|
|
|
return { ...pts[pts.length - 1] };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const arrayPathTool: ToolExtensionV2 = {
|
|
|
|
|
|
manifest: {
|
|
|
|
|
|
id: 'array-path',
|
|
|
|
|
|
label: 'Array Pfad',
|
|
|
|
|
|
icon: '∿',
|
|
|
|
|
|
ribbonTab: 'canvas',
|
|
|
|
|
|
tags: ['pro'],
|
|
|
|
|
|
description: 'Array entlang Pfad: Auswahl treffen, dann auf Pfad-Polyline klicken (Task D10)',
|
|
|
|
|
|
},
|
|
|
|
|
|
optionsSchema: [
|
|
|
|
|
|
{ key: 'count', label: 'Anzahl', type: 'number', min: 2, max: 100 },
|
|
|
|
|
|
],
|
|
|
|
|
|
handlers: {
|
|
|
|
|
|
down(e, ctx) {
|
|
|
|
|
|
const c = ctx as FullToolContext;
|
|
|
|
|
|
if (!c.doc || !c.selection) return;
|
|
|
|
|
|
if (!arrayPathBase) {
|
|
|
|
|
|
if (c.selection.getIds().length === 0) {
|
|
|
|
|
|
const hit = c.selection.hitTest(e.world.x, e.world.y, 5);
|
|
|
|
|
|
if (!hit) {
|
|
|
|
|
|
ctx.setStatus('Array Pfad: zuerst Element(e) auswählen');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
c.selection.setIds([hit.id]);
|
|
|
|
|
|
}
|
|
|
|
|
|
arrayPathBase = e.world;
|
|
|
|
|
|
ctx.setStatus(`Array Pfad: auf Pfad-Polyline klicken (${c.selection.getIds().length} Element(e))`);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Zweiter Klick: muss eine Polyline als Pfad treffen
|
|
|
|
|
|
const pathHit = c.selection.hitTest(e.world.x, e.world.y, 8);
|
|
|
|
|
|
if (!pathHit || (pathHit.type !== 'polyline' && pathHit.type !== 'polygon')) {
|
|
|
|
|
|
ctx.setStatus('Array Pfad: zweiter Klick muss eine Polyline als Pfad treffen');
|
|
|
|
|
|
arrayPathBase = null;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const pathPts = (pathHit.properties as Record<string, unknown>).points as Pt[] | undefined;
|
|
|
|
|
|
if (!pathPts || pathPts.length < 2) {
|
|
|
|
|
|
ctx.setStatus('Array Pfad: Pfad-Polyline hat zu wenige Punkte');
|
|
|
|
|
|
arrayPathBase = null;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const count = Math.max(2, (c.options.count as number | undefined) ?? 5);
|
|
|
|
|
|
const ids = c.selection.getIds().filter((id) => id !== pathHit.id);
|
|
|
|
|
|
// Gesamtlänge des Pfads
|
|
|
|
|
|
let total = 0;
|
|
|
|
|
|
for (let i = 0; i < pathPts.length - 1; i++) {
|
|
|
|
|
|
total += Math.hypot(pathPts[i + 1].x - pathPts[i].x, pathPts[i + 1].y - pathPts[i].y);
|
|
|
|
|
|
}
|
|
|
|
|
|
c.doc.transact(() => {
|
|
|
|
|
|
for (const id of ids) {
|
|
|
|
|
|
const el = c.doc!.getElement(id);
|
|
|
|
|
|
if (!el) continue;
|
|
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
|
|
|
|
const s = total * (i / Math.max(1, count - 1));
|
|
|
|
|
|
const pos = pointAlongPolyline(pathPts, s);
|
|
|
|
|
|
c.doc!.addElement({
|
|
|
|
|
|
...el,
|
|
|
|
|
|
id: uid('arrp'),
|
|
|
|
|
|
x: pos.x,
|
|
|
|
|
|
y: pos.y,
|
|
|
|
|
|
} as CADElement);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
arrayPathBase = null;
|
|
|
|
|
|
ctx.setStatus(`Array Pfad: ${ids.length * count} Kopien entlang Pfad verteilt`);
|
|
|
|
|
|
},
|
|
|
|
|
|
cancel(ctx) {
|
|
|
|
|
|
arrayPathBase = null;
|
|
|
|
|
|
ctx.setPreview?.(null);
|
|
|
|
|
|
ctx.setStatus('Array Pfad abgebrochen');
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-27 18:56:26 +02:00
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
// 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');
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
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-30 14:59:30 +02:00
|
|
|
|
// ──────────────────────────────────────────────────────────
|
|
|
|
|
|
// Task CAD-4: DIVIDE/MEASURE — Punkte entlang eines Pfads
|
|
|
|
|
|
// (Polyline) verteilen. DIVIDE: n gleiche Segmente → n+1 Punkte
|
|
|
|
|
|
// inkl. Enden. MEASURE: fester Abstand inkl. Start, Rest verworfen.
|
|
|
|
|
|
// Punkte = Konstruktionsmarker auf defpoints (1 Transaktion).
|
|
|
|
|
|
// ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Punkte entlang eines Polylinien-Pfads (Task CAD-4, rein).
|
|
|
|
|
|
* segments > 0: DIVIDE — n+1 gleichmäßige Punkte inkl. Enden.
|
|
|
|
|
|
* fixedSpacing > 0: MEASURE — Punkte bei 0, spacing, 2*spacing, …
|
|
|
|
|
|
* solange s ≤ Gesamtlänge; Rest am Ende wird verworfen.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function pointsAlongPath(pts: Pt[], segments: number, fixedSpacing?: number): Pt[] {
|
|
|
|
|
|
if (!Array.isArray(pts) || pts.length < 2) return [];
|
|
|
|
|
|
let total = 0;
|
|
|
|
|
|
for (let i = 0; i < pts.length - 1; i++) {
|
|
|
|
|
|
total += Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (total <= 0) return [...pts];
|
|
|
|
|
|
const out: Pt[] = [];
|
|
|
|
|
|
if (segments > 0) {
|
|
|
|
|
|
for (let i = 0; i <= segments; i++) {
|
|
|
|
|
|
out.push(pointAlongPolyline(pts, (total * i) / segments));
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (fixedSpacing && fixedSpacing > 0) {
|
|
|
|
|
|
for (let s = 0; s <= total + 1e-9; s += fixedSpacing) {
|
|
|
|
|
|
out.push(pointAlongPolyline(pts, s));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** defpoints-Layer sicherstellen (CAD-4, lokal im core-modify). */
|
|
|
|
|
|
function ensureDefpoints(ctx: FullToolContext): void {
|
|
|
|
|
|
const doc = ctx.doc as unknown as {
|
|
|
|
|
|
getLayer(id: string): unknown;
|
|
|
|
|
|
addLayer(l: unknown): void;
|
|
|
|
|
|
} | undefined;
|
|
|
|
|
|
if (!doc || doc.getLayer('defpoints')) return;
|
|
|
|
|
|
doc.addLayer({
|
|
|
|
|
|
id: 'defpoints', name: 'defpoints', visible: true, locked: false,
|
|
|
|
|
|
color: '#8a8f98', lineType: 'dotted', transparency: 0.5, sortOrder: 999, parentId: null,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Pfad-Treffer validieren: muss polyline/polygon mit ≥2 Punkten sein. */
|
|
|
|
|
|
function requirePathHit(
|
|
|
|
|
|
c: FullToolContext,
|
|
|
|
|
|
wx: number,
|
|
|
|
|
|
wy: number,
|
|
|
|
|
|
): { pts: Pt[]; path: CADElement } | null {
|
|
|
|
|
|
if (!c.selection) return null;
|
|
|
|
|
|
const hit = c.selection.hitTest(wx, wy, 8);
|
|
|
|
|
|
if (!hit || (hit.type !== 'polyline' && hit.type !== 'polygon')) return null;
|
|
|
|
|
|
const pts = (hit.properties as Record<string, unknown>).points as Pt[] | undefined;
|
|
|
|
|
|
if (!pts || pts.length < 2) return null;
|
|
|
|
|
|
return { pts, path: hit };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Konstruktions-Marker-Punkt auf defpoints platzieren. */
|
|
|
|
|
|
function makePathMarker(p: Pt): CADElement {
|
|
|
|
|
|
return {
|
|
|
|
|
|
id: uid('pathmark'),
|
|
|
|
|
|
type: 'circle',
|
|
|
|
|
|
layerId: 'defpoints',
|
|
|
|
|
|
x: p.x,
|
|
|
|
|
|
y: p.y,
|
|
|
|
|
|
width: 3,
|
|
|
|
|
|
height: 3,
|
|
|
|
|
|
properties: { radius: 1.5, point: true, construction: true, stroke: '#8a8f98', strokeWidth: 1 },
|
|
|
|
|
|
} as unknown as CADElement;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const divideTool: ToolExtensionV2 = {
|
|
|
|
|
|
manifest: {
|
|
|
|
|
|
id: 'divide',
|
|
|
|
|
|
label: 'Teilen',
|
|
|
|
|
|
icon: '÷',
|
|
|
|
|
|
ribbonTab: 'canvas',
|
|
|
|
|
|
tags: ['pro'],
|
|
|
|
|
|
description: 'Pfad in n gleiche Segmente teilen (DIVIDE, Task CAD-4)',
|
|
|
|
|
|
},
|
|
|
|
|
|
optionsSchema: [
|
|
|
|
|
|
{ key: 'count', label: 'Anzahl Segmente', type: 'number', min: 1, max: 100 },
|
|
|
|
|
|
],
|
|
|
|
|
|
handlers: {
|
|
|
|
|
|
down(e, ctx) {
|
|
|
|
|
|
const c = ctx as FullToolContext;
|
|
|
|
|
|
if (!c.doc) return;
|
|
|
|
|
|
const found = requirePathHit(c, e.world.x, e.world.y);
|
|
|
|
|
|
if (!found) {
|
|
|
|
|
|
ctx.setStatus('Teilen: Polyline als Pfad anklicken');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const segments = Math.max(1, (c.options.count as number | undefined) ?? 4);
|
|
|
|
|
|
ensureDefpoints(c);
|
|
|
|
|
|
const marks = pointsAlongPath(found.pts, segments);
|
|
|
|
|
|
c.doc.transact(() => {
|
|
|
|
|
|
for (const p of marks) c.doc!.addElement(makePathMarker(p));
|
|
|
|
|
|
});
|
|
|
|
|
|
ctx.setStatus(`Geteilt: ${marks.length} Punkte auf ${segments} Segmente`);
|
|
|
|
|
|
},
|
|
|
|
|
|
cancel(ctx) {
|
|
|
|
|
|
ctx.setPreview?.(null);
|
|
|
|
|
|
ctx.setStatus('Teilen abgebrochen');
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export const measureTool: ToolExtensionV2 = {
|
|
|
|
|
|
manifest: {
|
|
|
|
|
|
id: 'measure',
|
|
|
|
|
|
label: 'Abmessen (Punkte)',
|
|
|
|
|
|
icon: '⋮',
|
|
|
|
|
|
ribbonTab: 'canvas',
|
|
|
|
|
|
tags: ['pro'],
|
|
|
|
|
|
description: 'Punkte im festen Abstand auf Pfad (MEASURE, Task CAD-4)',
|
|
|
|
|
|
},
|
|
|
|
|
|
optionsSchema: [
|
|
|
|
|
|
{ key: 'spacing', label: 'Abstand', type: 'number', min: 1 },
|
|
|
|
|
|
],
|
|
|
|
|
|
handlers: {
|
|
|
|
|
|
down(e, ctx) {
|
|
|
|
|
|
const c = ctx as FullToolContext;
|
|
|
|
|
|
if (!c.doc) return;
|
|
|
|
|
|
const found = requirePathHit(c, e.world.x, e.world.y);
|
|
|
|
|
|
if (!found) {
|
|
|
|
|
|
ctx.setStatus('Abmessen: Polyline als Pfad anklicken');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const spacing = Math.max(1, (c.options.spacing as number | undefined) ?? 20);
|
|
|
|
|
|
ensureDefpoints(c);
|
|
|
|
|
|
const marks = pointsAlongPath(found.pts, 0, spacing);
|
|
|
|
|
|
c.doc.transact(() => {
|
|
|
|
|
|
for (const p of marks) c.doc!.addElement(makePathMarker(p));
|
|
|
|
|
|
});
|
|
|
|
|
|
ctx.setStatus(`Abgemessen: ${marks.length} Punkte alle ${spacing} Einheiten`);
|
|
|
|
|
|
},
|
|
|
|
|
|
cancel(ctx) {
|
|
|
|
|
|
ctx.setPreview?.(null);
|
|
|
|
|
|
ctx.setStatus('Abmessen abgebrochen');
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-31 00:53:08 +02:00
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
// Task CAD-5: Block-Editor — Blockdefinition in-place bearbeiten.
|
|
|
|
|
|
// Klick 1 auf Block-Instanz oeffnet (markierte Kopien erscheinen),
|
|
|
|
|
|
// Kopien normal bearbeiten, Klick 2 speichert zurueck in die
|
|
|
|
|
|
// Definition, cancel verwirft. (Service: blockEditorService)
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
export const blockEditTool: ToolExtensionV2 = {
|
|
|
|
|
|
manifest: {
|
|
|
|
|
|
id: 'block-edit',
|
|
|
|
|
|
label: 'Block bearbeiten',
|
|
|
|
|
|
icon: '\u2699',
|
|
|
|
|
|
ribbonTab: 'canvas',
|
|
|
|
|
|
tags: ['pro'],
|
|
|
|
|
|
description: 'Block-Instanz anklicken um die Definition zu bearbeiten (CAD-5)',
|
|
|
|
|
|
},
|
|
|
|
|
|
optionsSchema: [],
|
|
|
|
|
|
handlers: {
|
|
|
|
|
|
down(e, ctx) {
|
|
|
|
|
|
const c = ctx as FullToolContext;
|
|
|
|
|
|
if (!c.doc || !c.selection) return;
|
|
|
|
|
|
// Offene Sitzung? → speichern
|
|
|
|
|
|
if ((c.doc.getAllElements() as CADElement[]).some(
|
|
|
|
|
|
(el) => (el.properties as Record<string, unknown>)[BLOCK_EDIT_MARKER],
|
|
|
|
|
|
)) {
|
|
|
|
|
|
const saved = saveBlockEdit(c.doc as never);
|
|
|
|
|
|
ctx.setStatus(saved ? `Block "${saved}" gespeichert` : 'Speichern fehlgeschlagen');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const hit = c.selection.hitTest(e.world.x, e.world.y, 8);
|
|
|
|
|
|
if (!hit || hit.type !== 'block_instance') {
|
|
|
|
|
|
ctx.setStatus('Block bearbeiten: Block-Instanz anklicken');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const blockId = (hit.properties as Record<string, unknown>).blockId as string | undefined;
|
|
|
|
|
|
if (!blockId) {
|
|
|
|
|
|
ctx.setStatus('Block bearbeiten: Instanz ohne Block-Referenz');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const opened = openBlockForEdit(c.doc as never, blockId);
|
|
|
|
|
|
ctx.setStatus(opened ? `Block "${blockId}" geöffnet — Elemente bearbeiten, erneut klicken speichert` : 'Block konnte nicht geöffnet werden');
|
|
|
|
|
|
},
|
|
|
|
|
|
cancel(ctx) {
|
|
|
|
|
|
const c = ctx as FullToolContext;
|
|
|
|
|
|
if (c.doc) discardBlockEdit(c.doc as never);
|
|
|
|
|
|
ctx.setPreview?.(null);
|
|
|
|
|
|
ctx.setStatus('Block-Bearbeitung verworfen');
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-26 21:30:54 +02:00
|
|
|
|
export const coreModifyPlugin: PluginV2 = {
|
|
|
|
|
|
manifest: {
|
|
|
|
|
|
id: 'core-modify',
|
|
|
|
|
|
name: 'Bearbeiten (Kern)',
|
2026-08-27 22:14:01 +02:00
|
|
|
|
version: '1.9.0',
|
2026-08-26 21:30:54 +02:00
|
|
|
|
author: 'web-cad team',
|
2026-08-27 22:14:01 +02:00
|
|
|
|
description: 'Auswahl, Schraffur, Move/Copy/Rotate/Scale/Mirror/Delete/Offset/Trim/Extend/Fillet/Arrays/Chamfer/Lengthen/Join/Explode/Align/Polyline-Edit/Stretch/Break (V2).',
|
2026-08-26 21:30:54 +02:00
|
|
|
|
category: 'tools',
|
|
|
|
|
|
enabledByDefault: true,
|
|
|
|
|
|
},
|
2026-08-31 00:53:08 +02:00
|
|
|
|
tools: [selectTool, hatchTool, moveTool, copyTool, rotateTool, scaleTool, mirrorTool, deleteTool, offsetTool, trimTool, extendTool, filletTool, arrayRectTool, arrayPolarTool, chamferTool, lengthenTool, joinTool, explodeTool, alignTool, polylineEditTool, stretchTool, breakTool, arrayPathTool, divideTool, measureTool, blockEditTool],
|
2026-08-26 21:30:54 +02:00
|
|
|
|
};
|