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:
@@ -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],
|
||||
};
|
||||
|
||||
@@ -25,6 +25,9 @@ import {
|
||||
extendElement,
|
||||
filletElements,
|
||||
} from '../../../tools/modification/geometry';
|
||||
import { openBlockForEdit, saveBlockEdit, discardBlockEdit } from '../../../services/blockEditorService';
|
||||
import { BLOCK_EDIT_MARKER } from '../../../services/blockEditorService';
|
||||
export { BLOCK_EDIT_MARKER };
|
||||
import type { Pt } from '../../../tools/modification/geometry';
|
||||
|
||||
const uid = (prefix: string): string =>
|
||||
@@ -1861,6 +1864,57 @@ export const measureTool: ToolExtensionV2 = {
|
||||
},
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 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');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const coreModifyPlugin: PluginV2 = {
|
||||
manifest: {
|
||||
id: 'core-modify',
|
||||
@@ -1871,5 +1925,5 @@ export const coreModifyPlugin: PluginV2 = {
|
||||
category: 'tools',
|
||||
enabledByDefault: true,
|
||||
},
|
||||
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],
|
||||
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],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Task CAD-5 – Block-Editor-Service.
|
||||
*
|
||||
* openBlockForEdit legt markierte KOPIEN der Block-Elemente ins
|
||||
* Dokument (Original unangetastet); saveBlockEdit schreibt die
|
||||
* bearbeiteten Kopien als neue Blockdefinition zurueck und
|
||||
* raeumt die Edit-Kopien auf; discardBlockEdit verwirft alles.
|
||||
* Marker: BLOCK_EDIT_MARKER (properties-Flag + __blockEdit-IDs).
|
||||
*/
|
||||
import type { CADElement, BlockDefinition } from '../types/cad.types';
|
||||
import type { CADDocument } from '../kernel/document/CADDocument';
|
||||
|
||||
/** Marker-Flag in properties der Edit-Kopien. */
|
||||
export const BLOCK_EDIT_MARKER = '__blockEdit';
|
||||
|
||||
/** Aktive Edit-Sitzung (Service-State). */
|
||||
let editSession: { blockId: string; copyIds: string[] } | null = null;
|
||||
|
||||
/**
|
||||
* Oeffnet einen Block zur Bearbeitung: Kopien aller Elemente mit
|
||||
* Edit-Marker werden ins Dokument gelegt (Original bleibt).
|
||||
* @returns true wenn geoeffnet, false bei unbekannter ID oder
|
||||
* bereits offener Sitzung.
|
||||
*/
|
||||
export function openBlockForEdit(doc: CADDocument, blockId: string): boolean {
|
||||
if (editSession) return false;
|
||||
const block = doc.getBlock(blockId);
|
||||
if (!block) return false;
|
||||
const copyIds: string[] = [];
|
||||
doc.transact(() => {
|
||||
for (const el of block.elements) {
|
||||
const copy: CADElement = {
|
||||
...el,
|
||||
id: `__blockEdit_${blockId}_${copyIds.length}`,
|
||||
properties: {
|
||||
...(el.properties as Record<string, unknown>),
|
||||
[BLOCK_EDIT_MARKER]: true,
|
||||
},
|
||||
} as unknown as CADElement;
|
||||
doc.addElement(copy);
|
||||
copyIds.push(copy.id);
|
||||
}
|
||||
});
|
||||
editSession = { blockId, copyIds };
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Speichert die Bearbeitung: die markierten Kopien werden als
|
||||
* neue Elementliste in die Blockdefinition geschrieben (Marker
|
||||
* entfernt); Edit-Kopien werden aus dem Dokument geloescht.
|
||||
* @returns blockId der gespeicherten Definition oder null.
|
||||
*/
|
||||
export function saveBlockEdit(doc: CADDocument): string | null {
|
||||
if (!editSession) return null;
|
||||
const { blockId, copyIds } = editSession;
|
||||
const elements: CADElement[] = [];
|
||||
for (const id of copyIds) {
|
||||
const el = doc.getElement(id);
|
||||
if (!el) continue;
|
||||
const { [BLOCK_EDIT_MARKER]: _marker, ...cleanProps } =
|
||||
el.properties as Record<string, unknown>;
|
||||
elements.push({ ...el, id: uid('blk'), properties: cleanProps } as unknown as CADElement);
|
||||
}
|
||||
const block = doc.getBlock(blockId);
|
||||
if (block) {
|
||||
doc.addBlock({ ...block, elements });
|
||||
}
|
||||
doc.deleteElements(copyIds);
|
||||
editSession = null;
|
||||
return blockId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verwirft die Bearbeitung: Edit-Kopien werden geloescht, die
|
||||
* Blockdefinition bleibt unveraendert.
|
||||
* @returns true wenn verworfen, false ohne offene Sitzung.
|
||||
*/
|
||||
export function discardBlockEdit(doc: CADDocument): boolean {
|
||||
if (!editSession) return false;
|
||||
doc.deleteElements(editSession.copyIds);
|
||||
editSession = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Ist gerade eine Block-Bearbeitung offen? */
|
||||
export function isBlockEditOpen(): boolean {
|
||||
return editSession !== null;
|
||||
}
|
||||
|
||||
let uidCounter = 0;
|
||||
function uid(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}_${uidCounter++}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user