task(CAD-5): mline tool (parallel double line with miter offsets + optional end caps) + block editor service (open copies / save back / discard) with block-edit tool

This commit is contained in:
Agent Zero
2026-08-31 00:53:08 +02:00
parent 7331f5fb88
commit 0ffe954559
4 changed files with 595 additions and 2 deletions
@@ -891,6 +891,137 @@ export const rayTool: ToolExtensionV2 = {
},
};
// ─────────────────────────────────────────────────────────────
// Task CAD-5: MLINE (Mehrfachlinie) — zwei parallele Linien
// (Wand/Rohr) mit gap-Offset ±gap/2 entlang der Normalen.
// Optionale Endkappen verbinden die beiden Linien an Start/Ende.
// Klick-Sequenz wie polyline; ENTER committet ab 2 Punkten;
// ALLE Teile in EINER Transaktion = 1 Undo-Schritt.
// ─────────────────────────────────────────────────────────────
let mlinePoints: Pt[] = [];
/**
* Normalen-Offset-Punkte für eine Punktliste ±d (CAD-5, rein).
* An inneren Punkten wird die Richtung aus dem Mittel der
* angrenzenden Segment-Normalen bestimmt (Miter wie bei Wänden).
*/
function offsetPolyline(pts: Pt[], d: number): Pt[] {
if (pts.length < 2) return [...pts];
const out: Pt[] = [];
for (let i = 0; i < pts.length; i++) {
let nx = 0, ny = 0;
if (i > 0) {
const dx = pts[i].x - pts[i - 1].x;
const dy = pts[i].y - pts[i - 1].y;
const len = Math.hypot(dx, dy) || 1;
nx += -dy / len; ny += dx / len;
}
if (i < pts.length - 1) {
const dx = pts[i + 1].x - pts[i].x;
const dy = pts[i + 1].y - pts[i].y;
const len = Math.hypot(dx, dy) || 1;
nx += -dy / len; ny += dx / len;
}
const nLen = Math.hypot(nx, ny) || 1;
out.push({ x: pts[i].x + (nx / nLen) * d, y: pts[i].y + (ny / nLen) * d });
}
return out;
}
export const mlineTool: ToolExtensionV2 = {
manifest: {
id: 'mline',
label: 'Mehrfachlinie',
icon: '\u2551',
ribbonTab: 'canvas',
tags: ['basic'],
description: 'Doppel-Linie (Wand): Punkte klicken, ENTER beendet (CAD-5)',
},
optionsSchema: [
{ key: 'gap', label: 'Gesamtstaerke', type: 'number', min: 1 },
{ key: 'cap', label: 'Endkappen', type: 'checkbox' },
{ key: 'strokeColor', label: 'Farbe', type: 'color' },
],
handlers: {
down(e, ctx) {
mlinePoints.push(e.world);
ctx.setStatus(`Mehrfachlinie: ${mlinePoints.length} Punkte — ENTER beendet`);
},
move(e, ctx) {
const c = ctx as FullToolContext;
if (mlinePoints.length === 0) return;
const gap = (c.options.gap as number | undefined) ?? 20;
const pts = [...mlinePoints, e.world];
const a = offsetPolyline(pts, gap / 2);
const b = offsetPolyline(pts, -gap / 2);
ctx.setPreview?.({
id: '__preview_mline__',
type: 'polyline',
layerId: 'layer-0',
x: e.world.x, y: e.world.y, width: 0, height: 0,
properties: { points: a, stroke: '#e0e0f0', strokeWidth: 1, mline: true },
} as unknown as CADElement);
},
up() { /* Commit nur via ENTER */ },
key(e, ctx) {
if (e.key !== 'Enter' && e.key !== 'ENTER') return false;
const c = ctx as FullToolContext;
if (mlinePoints.length < 2) {
ctx.setStatus(`Mehrfachlinie benötigt mindestens 2 Punkte (aktuell ${mlinePoints.length})`);
return true;
}
if (!c.doc) return false;
const gap = Math.max(1, (c.options.gap as number | undefined) ?? 20);
const cap = (c.options.cap as boolean | undefined) ?? false;
const stroke = (c.options.strokeColor as string | undefined) ?? '#e0e0f0';
const layerId = (c.options.layerId as string | undefined) ?? 'layer-0';
const a = offsetPolyline(mlinePoints, gap / 2);
const b = offsetPolyline(mlinePoints, -gap / 2);
const mkLine = (pts: Pt[]): CADElement => ({
id: uid('mline'),
type: 'polyline',
layerId,
x: pts.reduce((s, p) => s + p.x, 0) / pts.length,
y: pts.reduce((s, p) => s + p.y, 0) / pts.length,
width: Math.max(...pts.map((p) => p.x)) - Math.min(...pts.map((p) => p.x)),
height: Math.max(...pts.map((p) => p.y)) - Math.min(...pts.map((p) => p.y)),
properties: { points: pts, stroke, strokeWidth: 1, mline: true, gap },
} as unknown as CADElement);
const els: CADElement[] = [mkLine(a), mkLine(b)];
if (cap) {
const capAt = (i: number): CADElement => ({
id: uid('mlinecap'),
type: 'line',
layerId,
x: (a[i].x + b[i].x) / 2,
y: (a[i].y + b[i].y) / 2,
width: Math.abs(b[i].x - a[i].x),
height: Math.abs(b[i].y - a[i].y),
properties: {
x1: a[i].x, y1: a[i].y, x2: b[i].x, y2: b[i].y,
stroke, strokeWidth: 1, mlineCap: true, mline: true,
},
} as unknown as CADElement);
els.push(capAt(0), capAt(a.length - 1));
}
c.doc.transact(() => {
for (const el of els) c.doc!.addElement(el);
});
const count = mlinePoints.length;
mlinePoints = [];
ctx.setPreview?.(null);
ctx.setStatus(`Mehrfachlinie erstellt (${count} Punkte${cap ? ', mit Endkappen' : ''})`);
return true;
},
cancel(ctx) {
mlinePoints = [];
ctx.setPreview?.(null);
ctx.setStatus('Mehrfachlinie abgebrochen');
},
},
};
export const coreDrawingPlugin: PluginV2 = {
manifest: {
id: 'core-drawing',
@@ -901,5 +1032,5 @@ export const coreDrawingPlugin: PluginV2 = {
category: 'tools',
enabledByDefault: true,
},
tools: [lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool, revcloudTool, ellipseTool, splineTool, pointTool, xlineTool, rayTool],
tools: [lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool, revcloudTool, ellipseTool, splineTool, pointTool, xlineTool, rayTool, mlineTool],
};