diff --git a/frontend/src/plugins/builtin/core-drawing/index.ts b/frontend/src/plugins/builtin/core-drawing/index.ts index a15009a..2c48c5b 100644 --- a/frontend/src/plugins/builtin/core-drawing/index.ts +++ b/frontend/src/plugins/builtin/core-drawing/index.ts @@ -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], }; diff --git a/frontend/src/plugins/builtin/core-modify/index.ts b/frontend/src/plugins/builtin/core-modify/index.ts index ea65cf9..5b69698 100644 --- a/frontend/src/plugins/builtin/core-modify/index.ts +++ b/frontend/src/plugins/builtin/core-modify/index.ts @@ -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)[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).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], }; diff --git a/frontend/src/services/blockEditorService.ts b/frontend/src/services/blockEditorService.ts new file mode 100644 index 0000000..e09cb30 --- /dev/null +++ b/frontend/src/services/blockEditorService.ts @@ -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), + [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; + 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)}`; +} diff --git a/frontend/tests/mlineBlockEditor.test.ts b/frontend/tests/mlineBlockEditor.test.ts new file mode 100644 index 0000000..ea2a77d --- /dev/null +++ b/frontend/tests/mlineBlockEditor.test.ts @@ -0,0 +1,314 @@ +/** + * Task CAD-5 - MLINE (Mehrfachlinie) + Block-Editor. + * + * MLINE: Zwei parallele Linien (Wand/Rohr) mit gap-Offset ±gap/2 + * entlang der Normalen, optionale Endkappen, ENTER-Commit ab + * 2 Punkten, ALLES in EINER Transaktion. + * Block-Editor: openBlockForEdit (markierte Kopien), saveBlockEdit + * (zurueckschreiben + aufraeumen), discard, blockEditTool-Flow. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mlineTool } from '../src/plugins/builtin/core-drawing'; +import { blockEditTool, BLOCK_EDIT_MARKER } from '../src/plugins/builtin/core-modify'; +import { + openBlockForEdit, + saveBlockEdit, + discardBlockEdit, +} from '../src/services/blockEditorService'; +import { CADDocument } from '../src/kernel/document/CADDocument'; +import { createInMemoryDoc } from './helpers/inMemoryDoc'; +import type { CADElement, Pt, BlockDefinition } from '../src/types/cad.types'; + +function mkCtx(options: Record = {}) { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + const statuses: string[] = []; + const previews: (CADElement | null)[] = []; + const ctx: any = { + doc, + options, + setStatus: (m: string) => statuses.push(m), + setPreview: (el: CADElement | null) => previews.push(el), + }; + return { ctx, doc, statuses, previews }; +} + +function pe(x: number, y: number): { world: Pt } { + return { world: { x, y } } as never; +} + +function key(k: string): { key: string } { + return { key: k } as never; + +} + +const BLOCK: BlockDefinition = { + id: 'blk-1', name: 'Muster', description: '', category: 'test', + elements: [ + { id: 'e1', type: 'rect', layerId: 'l', x: 50, y: 25, width: 100, height: 50, properties: {} }, + { id: 'e2', type: 'line', layerId: 'l', x: 50, y: 50, width: 100, height: 0, properties: { x1: 0, y1: 50, x2: 100, y2: 50 } }, + ], +}; + +afterEach(() => { + // Service-State-Leak verhindern: offene Edit-Sitzungen immer verwerfen + // (Dummy-Doc: deleteElements ignoriert fehlende IDs still) + const { ydoc: y2 } = createInMemoryDoc(); + discardBlockEdit(new CADDocument(y2)); +}); + +beforeEach(() => { + mlineTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never); + blockEditTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never); +}); + +// ─── MLINE ──────────────────────────────────────────────────── + +describe('CAD-5: mlineTool', () => { + it('horizontale Mittellinie: 2 parallele Linien mit gap/2 Abstand', () => { + const { ctx, doc } = mkCtx({ gap: 20 }); + mlineTool.handlers.down!(pe(0, 0), ctx); + mlineTool.handlers.down!(pe(100, 0), ctx); + mlineTool.handlers.key!(key('Enter'), ctx); + const lines = doc.getAllElements().filter((e) => e.type === 'polyline'); + expect(lines).toHaveLength(2); + const ys = lines.map((l) => (l.properties.points as Pt[])[0].y).sort(); + expect(ys[0]).toBeCloseTo(-10, 5); + expect(ys[1]).toBeCloseTo(10, 5); + }); + + it('gap-Option steuert den Abstand (gap=10 → ±5)', () => { + const { ctx, doc } = mkCtx({ gap: 10 }); + mlineTool.handlers.down!(pe(0, 0), ctx); + mlineTool.handlers.down!(pe(50, 0), ctx); + mlineTool.handlers.key!(key('Enter'), ctx); + const lines = doc.getAllElements().filter((e) => e.type === 'polyline'); + const ys = lines.map((l) => (l.properties.points as Pt[])[0].y).sort(); + expect(ys[0]).toBeCloseTo(-5, 5); + expect(ys[1]).toBeCloseTo(5, 5); + }); + + it('Endkappen-Option: 2 zusaetzliche Linien an Start und Ende', () => { + const { ctx, doc } = mkCtx({ gap: 20, cap: true }); + mlineTool.handlers.down!(pe(0, 0), ctx); + mlineTool.handlers.down!(pe(100, 0), ctx); + mlineTool.handlers.key!(key('Enter'), ctx); + const caps = doc.getAllElements().filter((e) => e.properties.mlineCap); + expect(caps).toHaveLength(2); + // Startkappe verbindet beide Linien (y=±10) bei x=0 — Zuordnung a/b + // ist Vorzeichenkonvention, daher reihenfolge-agnostisch pruefen: + const startCap = caps.find((c) => (c.properties.x1 as number) === 0); + expect(startCap).toBeDefined(); + const ys = [startCap!.properties.y1, startCap!.properties.y2].sort((p, q) => (p as number) - (q as number)) as number[]; + expect(ys[0]).toBeCloseTo(-10, 5); + expect(ys[1]).toBeCloseTo(10, 5); + }); + + it('ohne cap: nur 2 Linien, keine Kappen', () => { + const { ctx, doc } = mkCtx({ gap: 20 }); + mlineTool.handlers.down!(pe(0, 0), ctx); + mlineTool.handlers.down!(pe(100, 0), ctx); + mlineTool.handlers.key!(key('Enter'), ctx); + expect(doc.getAllElements().filter((e) => e.properties.mlineCap)).toHaveLength(0); + }); + + it('EINE Transaktion: 1 Undo entfernt alle MLINE-Teile', () => { + const { ctx, doc } = mkCtx({ gap: 20, cap: true }); + mlineTool.handlers.down!(pe(0, 0), ctx); + mlineTool.handlers.down!(pe(100, 0), ctx); + mlineTool.handlers.key!(key('Enter'), ctx); + expect(doc.getAllElements().length).toBe(4); // 2 Linien + 2 Kappen + doc.undo(); + expect(doc.getAllElements()).toHaveLength(0); + }); + + it('ENTER mit weniger als 2 Punkten committet NICHT', () => { + const { ctx, doc, statuses } = mkCtx({ gap: 20 }); + mlineTool.handlers.down!(pe(0, 0), ctx); + mlineTool.handlers.key!(key('Enter'), ctx); + expect(doc.getAllElements()).toHaveLength(0); + expect(statuses[statuses.length - 1]).toContain('2'); + }); + + it('L-foermiger Pfad: beide Linien folgen der Geometrie', () => { + const { ctx, doc } = mkCtx({ gap: 20 }); + mlineTool.handlers.down!(pe(0, 0), ctx); + mlineTool.handlers.down!(pe(100, 0), ctx); + mlineTool.handlers.down!(pe(100, 100), ctx); + mlineTool.handlers.key!(key('Enter'), ctx); + const lines = doc.getAllElements().filter((e) => e.type === 'polyline'); + expect(lines).toHaveLength(2); + for (const l of lines) { + const pts = l.properties.points as Pt[]; + expect(pts).toHaveLength(3); // gleiche Punktanzahl wie Mittellinie + } + }); + + it('Metadaten: mline:true und gap in properties', () => { + const { ctx, doc } = mkCtx({ gap: 20 }); + mlineTool.handlers.down!(pe(0, 0), ctx); + mlineTool.handlers.down!(pe(100, 0), ctx); + mlineTool.handlers.key!(key('Enter'), ctx); + const line = doc.getAllElements().find((e) => e.properties.mline)!; + expect(line.properties.mline).toBe(true); + expect(line.properties.gap).toBe(20); + }); + + it('cancel resettet die Sitzung', () => { + const { ctx, doc } = mkCtx({ gap: 20 }); + mlineTool.handlers.down!(pe(0, 0), ctx); + mlineTool.handlers.cancel?.(ctx); + mlineTool.handlers.down!(pe(0, 0), ctx); + mlineTool.handlers.key!(key('Enter'), ctx); // nur 1 Punkt der NEUEN Sitzung + expect(doc.getAllElements()).toHaveLength(0); + }); + + it('Manifest: id=mline, Optionen gap und cap', () => { + expect(mlineTool.manifest.id).toBe('mline'); + const keys = (mlineTool.optionsSchema ?? []).map((f) => f.key); + expect(keys).toContain('gap'); + expect(keys).toContain('cap'); + }); +}); + +// ─── Block-Editor Service ────────────────────────────────────── + +describe('CAD-5: blockEditorService', () => { + function mkDoc(): CADDocument { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + doc.addBlock(BLOCK); + return doc; + } + + it('openBlockForEdit erzeugt markierte Kopien im Dokument', () => { + const doc = mkDoc(); + expect(openBlockForEdit(doc, 'blk-1')).toBe(true); + const copies = doc.getAllElements().filter((e) => (e.properties as Record)[BLOCK_EDIT_MARKER]); + expect(copies).toHaveLength(2); + expect(doc.getBlock('blk-1')!.elements).toHaveLength(2); // Original unangetastet + }); + + it('openBlockForEdit mit unbekannter ID: false', () => { + const doc = mkDoc(); + expect(openBlockForEdit(doc, 'gibts-nicht')).toBe(false); + }); + + it('saveBlockEdit schreibt bearbeitete Elemente zurueck und raeumt auf', () => { + const doc = mkDoc(); + openBlockForEdit(doc, 'blk-1'); + // Eine Kopie bearbeiten: rect verbreitern + const copies = doc.getAllElements().filter((e) => (e.properties as Record)[BLOCK_EDIT_MARKER]); + const rectCopy = copies.find((c) => c.type === 'rect')!; + doc.updateElement(rectCopy.id, { width: 200 }); + const saved = saveBlockEdit(doc); + expect(saved).toBe('blk-1'); + // Blockdefinition hat die neue Breite + const savedRect = doc.getBlock('blk-1')!.elements.find((e) => e.type === 'rect')!; + expect(savedRect.width).toBe(200); + // Edit-Kopien sind aus dem Dokument entfernt + const remaining = doc.getAllElements().filter((e) => (e.properties as Record)[BLOCK_EDIT_MARKER]); + expect(remaining).toHaveLength(0); + }); + + it('saveBlockEdit ohne offene Sitzung: null', () => { + const doc = mkDoc(); + expect(saveBlockEdit(doc)).toBeNull(); + }); + + it('discardBlockEdit verwirft Aenderungen (Block unveraendert, Kopien weg)', () => { + const doc = mkDoc(); + openBlockForEdit(doc, 'blk-1'); + const copies = doc.getAllElements().filter((e) => (e.properties as Record)[BLOCK_EDIT_MARKER]); + doc.updateElement(copies[0].id, { width: 999 }); + expect(discardBlockEdit(doc)).toBe(true); + expect(doc.getBlock('blk-1')!.elements.find((e) => e.type === 'rect')!.width).toBe(100); + expect(doc.getAllElements().filter((e) => (e.properties as Record)[BLOCK_EDIT_MARKER])).toHaveLength(0); + }); + + it('saveBlockEdit entfernt Marker aus den gespeicherten Elementen', () => { + const doc = mkDoc(); + openBlockForEdit(doc, 'blk-1'); + saveBlockEdit(doc); + for (const el of doc.getBlock('blk-1')!.elements) { + expect((el.properties as Record)[BLOCK_EDIT_MARKER]).toBeUndefined(); + } + }); +}); + +// ─── blockEditTool ───────────────────────────────────────────── + +describe('CAD-5: blockEditTool', () => { + function mkToolCtx() { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + doc.addBlock(BLOCK); + doc.addElement({ + id: 'inst-1', type: 'block_instance', layerId: 'l', + x: 0, y: 0, width: 0, height: 0, + properties: { blockId: 'blk-1' }, + } as unknown as CADElement); + const statuses: string[] = []; + const ctx: any = { + doc, + options: {}, + setStatus: (m: string) => statuses.push(m), + setPreview: (_el: CADElement | null) => {}, + selection: { + getIds: () => [] as string[], + setIds: (_ids: string[]) => {}, + hitTest: (_x: number, _y: number, _tol: number) => doc.getElement('inst-1'), + }, + }; + return { ctx, doc, statuses }; + } + + it('erster Klick auf Instanz oeffnet den Block-Editor (Kopien erscheinen)', () => { + const { ctx, doc, statuses } = mkToolCtx(); + blockEditTool.handlers.down!(pe(0, 0), ctx); + expect(doc.getAllElements().filter((e) => (e.properties as Record)[BLOCK_EDIT_MARKER])).toHaveLength(2); + expect(statuses[statuses.length - 1]).toContain('Block'); + }); + + it('zweiter Klick speichert (Block aktualisiert, Kopien weg)', () => { + const { ctx, doc } = mkToolCtx(); + blockEditTool.handlers.down!(pe(0, 0), ctx); + // Kopie bearbeiten + const copy = doc.getAllElements().find((e) => (e.properties as Record)[BLOCK_EDIT_MARKER] && e.type === 'rect')!; + doc.updateElement(copy.id, { width: 150 }); + blockEditTool.handlers.down!(pe(0, 0), ctx); // speichern + expect(doc.getBlock('blk-1')!.elements.find((e) => e.type === 'rect')!.width).toBe(150); + expect(doc.getAllElements().filter((e) => (e.properties as Record)[BLOCK_EDIT_MARKER])).toHaveLength(0); + }); + + it('cancel verwirft die Bearbeitung', () => { + const { ctx, doc } = mkToolCtx(); + blockEditTool.handlers.down!(pe(0, 0), ctx); + const copy = doc.getAllElements().find((e) => (e.properties as Record)[BLOCK_EDIT_MARKER] && e.type === 'rect')!; + doc.updateElement(copy.id, { width: 999 }); + blockEditTool.handlers.cancel?.(ctx); + expect(doc.getBlock('blk-1')!.elements.find((e) => e.type === 'rect')!.width).toBe(100); + expect(doc.getAllElements().filter((e) => (e.properties as Record)[BLOCK_EDIT_MARKER])).toHaveLength(0); + }); + + it('Klick ohne Instanz-Treffer: Status erklaert, nichts passiert', () => { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + doc.addBlock(BLOCK); + const statuses: string[] = []; + const ctx: any = { + doc, + options: {}, + setStatus: (m: string) => statuses.push(m), + setPreview: () => {}, + selection: { getIds: () => [], setIds: () => {}, hitTest: () => null }, + }; + blockEditTool.handlers.down!(pe(0, 0), ctx); + expect(doc.getAllElements()).toHaveLength(0); + expect(statuses[statuses.length - 1]).toContain('Block-Instanz'); + }); + + it('Manifest: id=block-edit', () => { + expect(blockEditTool.manifest.id).toBe('block-edit'); + }); +});