From 86b1e0b8db658bd62925be06f2bb57d26364b516 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Thu, 27 Aug 2026 22:46:51 +0200 Subject: [PATCH] task(E1): seating-row/block drag interaction with live preview and options --- .../src/plugins/builtin/event-tools/index.ts | 153 ++++++++++++++++-- frontend/tests/pilotTools.test.ts | 72 ++++++++- 2 files changed, 208 insertions(+), 17 deletions(-) diff --git a/frontend/src/plugins/builtin/event-tools/index.ts b/frontend/src/plugins/builtin/event-tools/index.ts index d833110..b0e67a7 100644 --- a/frontend/src/plugins/builtin/event-tools/index.ts +++ b/frontend/src/plugins/builtin/event-tools/index.ts @@ -1,19 +1,31 @@ /** - * event-tools – Built-in V2-Plugin (Task A4.9). + * event-tools – Built-in V2-Plugin (Tasks A4.9 + E1). * - * Fachwerkzeuge für Veranstaltungsplanung: chair, seating-row, - * seating-block, table, stage — alle Einzelklick-Platzierer über - * den (durch BUG-1-Fix korrigierten) SeatingService. + * Fachwerkzeuge für Veranstaltungsplanung: chair (Einzelnklick), + * seating-row + seating-block (Drag-Interaktion mit Live-Preview + * und Options-Schema), table + stage (Einzelnklick). * * Jede Platzierung = doc.transact → 1 Undo-Schritt (Multi-Element-Sets * wie seating-block erscheinen/verschwinden gemeinsam). + * + * SeatingService ist durch BUG-1-Fix deterministisch (rowIds unique). */ import type { PluginV2, ToolExtensionV2, FullToolContext } from '../../types'; import type { CADElement } from '../../../types/cad.types'; +import type { Pt } from '../../../tools/modification/geometry'; import { SeatingService } from '../../../services/seatingService'; const seating = new SeatingService(); +// ─── Drag-Sitzungs-State für row/block ───────────────────── +let dragStart: Pt | null = null; + +function optsOf(ctx: FullToolContext): { layerId: string } { + return { + layerId: (ctx.options.layerId as string | undefined) ?? 'layer-0', + }; +} + /** Erzeugt ein Einzelklick-Platzierer-Werkzeug über einen placer. */ function makePlaceTool( id: string, @@ -36,7 +48,7 @@ function makePlaceTool( down(e, ctx) { const c = ctx as FullToolContext; if (!c.doc) return; - const layerId = (c.options.layerId as string | undefined) ?? 'layer-0'; + const { layerId } = optsOf(ctx); const placed = placer(e.world.x, e.world.y, layerId); const els = Array.isArray(placed) ? placed : [placed]; c.doc.transact(() => { @@ -48,31 +60,142 @@ function makePlaceTool( }; } +/** + * seating-row (Task E1): Klick1 = Start, Drag = Länge/Vorschau, + * up = Commit. Anzahl Stühle wird aus Distanz berechnet (spacing aus Options). + */ +export const seatingRowToolDrag: ToolExtensionV2 = { + manifest: { + id: 'seating-row', + label: 'Stuhldreihe', + icon: '\u2500\u2570\u2500', + ribbonTab: 'insert', + tags: ['basic'], + description: 'Reihe: Start klicken, Ende ziehen. Stuhlzahl aus Länge.', + }, + optionsSchema: [ + { key: 'spacing', label: 'Abstand (cm)', type: 'number', min: 20, max: 200 }, + ], + handlers: { + down(e, ctx) { + dragStart = e.world; + ctx.setStatus('Reihe: Endpunkt ziehen'); + }, + move(e, ctx) { + if (!dragStart) return; + const c = ctx as FullToolContext; + const { layerId } = optsOf(c); + const spacing = (c.options.spacing as number | undefined) ?? 50; + const length = Math.hypot(e.world.x - dragStart.x, e.world.y - dragStart.y); + const count = Math.max(2, Math.floor(length / spacing)); + const preview = seating.createSeatingRow(dragStart.x, dragStart.y, layerId, { count, spacing }); + c.setPreview?.(preview[0] ?? null); // Legacy-Vorschau zeigt ersten Stuhl als Referenz + ctx.setStatus(`Reihe: ${count} Stühle (${length.toFixed(0)}mm)`); + }, + up(e, ctx) { + const c = ctx as FullToolContext; + if (!dragStart || !c.doc) { dragStart = null; return; } + const { layerId } = optsOf(c); + const spacing = (c.options.spacing as number | undefined) ?? 50; + const length = Math.hypot(e.world.x - dragStart.x, e.world.y - dragStart.y); + const count = Math.max(2, Math.floor(length / spacing)); + const els = seating.createSeatingRow(dragStart.x, dragStart.y, layerId, { count, spacing }); + c.doc.transact(() => { + for (const el of els) c.doc!.addElement(el); + }); + ctx.setStatus(`Reihe: ${count} Stühle platziert`); + dragStart = null; + c.setPreview?.(null); + }, + cancel(ctx) { + dragStart = null; + ctx.setPreview?.(null); + ctx.setStatus('Reihe abgebrochen'); + }, + }, +}; + +/** + * seating-block (Task E1): Klick1 = Start-Ecke, Drag = Block-Dimension, + * up = Commit. rows/cols aus Distanz + spacing berechnet. + */ +export const seatingBlockToolDrag: ToolExtensionV2 = { + manifest: { + id: 'seating-block', + label: 'Stuhlblock', + icon: '\u2565', + ribbonTab: 'insert', + tags: ['basic'], + description: 'Block: Start-Ecke klicken, Gegenecke ziehen.', + }, + optionsSchema: [ + { key: 'colSpacing', label: 'Sitzabstand X (cm)', type: 'number', min: 30, max: 200 }, + { key: 'rowSpacing', label: 'Reihenabstand Y (cm)', type: 'number', min: 30, max: 200 }, + ], + handlers: { + down(e, ctx) { + dragStart = e.world; + ctx.setStatus('Block: Gegenecke ziehen'); + }, + move(e, ctx) { + if (!dragStart) return; + const c = ctx as FullToolContext; + const { layerId } = optsOf(c); + const colSpacing = (c.options.colSpacing as number | undefined) ?? 50; + const rowSpacing = (c.options.rowSpacing as number | undefined) ?? 50; + const w = Math.abs(e.world.x - dragStart.x); + const h = Math.abs(e.world.y - dragStart.y); + const cols = Math.max(2, Math.floor(w / colSpacing)); + const rows = Math.max(2, Math.floor(h / rowSpacing)); + ctx.setStatus(`Block: ${rows} Reihen × ${cols} Stühle = ${rows * cols} Sitzplätze`); + }, + up(e, ctx) { + const c = ctx as FullToolContext; + if (!dragStart || !c.doc) { dragStart = null; return; } + const { layerId } = optsOf(c); + const colSpacing = (c.options.colSpacing as number | undefined) ?? 50; + const rowSpacing = (c.options.rowSpacing as number | undefined) ?? 50; + const w = Math.abs(e.world.x - dragStart.x); + const h = Math.abs(e.world.y - dragStart.y); + const cols = Math.max(2, Math.floor(w / colSpacing)); + const rows = Math.max(2, Math.floor(h / rowSpacing)); + const centerX = (dragStart.x + e.world.x) / 2; + const centerY = (dragStart.y + e.world.y) / 2; + const els = seating.createSeatingBlock(centerX, centerY, layerId, { rows, cols, colSpacing, rowSpacing }); + c.doc.transact(() => { + for (const el of els) c.doc!.addElement(el); + }); + ctx.setStatus(`Block: ${rows}×${cols} = ${els.length} Stühle platziert`); + dragStart = null; + c.setPreview?.(null); + }, + cancel(ctx) { + dragStart = null; + ctx.setPreview?.(null); + ctx.setStatus('Block abgebrochen'); + }, + }, +}; + export const chairTool = makePlaceTool('chair', 'Stuhl', '\u2570', (x, y, layerId) => seating.createChair(x, y, layerId), 'Stuhl'); -export const seatingRowTool = makePlaceTool('seating-row', 'Stuhldreihe', '\u2500\u2570\u2500', - (x, y, layerId) => seating.createSeatingRow(x, y, layerId), 'Reihe'); - -export const seatingBlockTool = makePlaceTool('seating-block', 'Stuhlblock', '\u2565', - (x, y, layerId) => seating.createSeatingBlock(x, y, layerId), 'Block'); - export const tableTool = makePlaceTool('table', 'Tisch', '\u25cb', (x, y, layerId) => seating.createTable(x, y, layerId), 'Tisch'); export const stageTool = makePlaceTool('stage', 'Bühne', '\u25ad', (x, y, layerId) => seating.createStage(x, y, layerId), 'Bühne'); -/** V2-Plugin: Event-Domain (Task A4.9). */ +/** V2-Plugin: Event-Domain (Tasks A4.9 + E1). */ export const eventToolsV2Plugin: PluginV2 = { manifest: { id: 'event-tools', name: 'Event-Bestuhlung', - version: '2.0.0', + version: '2.1.0', author: 'web-cad team', - description: 'Stühle, Reihen, Blöcke, Tische, Bühne (V2).', + description: 'Stühle, Reihen (Drag), Blöcke (Drag), Tische, Bühne (V2).', category: 'elements', enabledByDefault: true, }, - tools: [chairTool, seatingRowTool, seatingBlockTool, tableTool, stageTool], + tools: [chairTool, seatingRowToolDrag, seatingBlockToolDrag, tableTool, stageTool], }; diff --git a/frontend/tests/pilotTools.test.ts b/frontend/tests/pilotTools.test.ts index f5e3ff8..fcaba7d 100644 --- a/frontend/tests/pilotTools.test.ts +++ b/frontend/tests/pilotTools.test.ts @@ -370,14 +370,16 @@ describe('A4.9 pilot: event tools', () => { const ctx = makeCtx({ doc }); const block = eventToolsV2Plugin.tools!.find((t) => t.manifest.id === 'seating-block')!; - block.handlers.down!(pe(0, 0), ctx); // Legacy-Default: rows=3, cols=8 + // E1: Drag-Interaktion (down + up) mit Distanz 200×100, spacing 50 + block.handlers.down!(pe(0, 0), ctx); + block.handlers.up!(pe(200, 100), ctx); // 4 cols × 2 rows = 8 Stühle const all = doc.getAllElements(); expect(all.length).toBeGreaterThan(1); // Multi-Element-Satz expect(all.every((e) => e.type === 'chair')).toBe(true); // BUG-1-Regressionsschutz: alle Reihen im Block haben eigene rowIds const rowIds = new Set(all.map((e) => e.properties.rowId)); - expect(rowIds.size).toBeGreaterThanOrEqual(3); + expect(rowIds.size).toBe(2); // Drag 100mm bei spacing 50 → 2 Reihen // Kern-Akzeptanz: EIN undo entfernt den GANZEN Block expect(doc.undo()).toBe(true); @@ -390,7 +392,9 @@ describe('A4.9 pilot: event tools', () => { const ctx = makeCtx({ doc }); const row = eventToolsV2Plugin.tools!.find((t) => t.manifest.id === 'seating-row')!; + // E1: Drag-Interaktion (down + up statt nur down) row.handlers.down!(pe(0, 0), ctx); + row.handlers.up!(pe(200, 0), ctx); // Länge 200, spacing 50 → 4 Stühle const all = doc.getAllElements(); expect(all.length).toBeGreaterThanOrEqual(2); const rowIds = new Set(all.map((e) => e.properties.rowId)); @@ -922,3 +926,67 @@ describe('C3fin interaktiv: handle-drag on select tool', () => { expect(selIds).toEqual(['r3']); }); }); + +describe('E1: seating-row drag', () => { + it('berechnet count aus Laenge und committet in EINER Transaktion', () => { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + const ctx = makeCtx({ doc, options: { spacing: 50 } }); + const row = eventToolsV2Plugin.tools!.find((t) => t.manifest.id === 'seating-row')!; + + // Start bei (0,0), Ende bei (300,0): Länge 300, spacing 50 → 6 Stühle + row.handlers.down!(pe(0, 0), ctx); + row.handlers.move!(pe(150, 0), ctx); // Live-Preview + expect(ctx.previews.length).toBe(1); + row.handlers.up!(pe(300, 0), ctx); + + const all = doc.getAllElements(); + expect(all.length).toBe(6); + expect(all.every((e) => e.type === 'chair')).toBe(true); + // Eine rowId für alle: + const rowIds = new Set(all.map((e) => e.properties.rowId)); + expect(rowIds.size).toBe(1); + + // EIN Undo entfernt die ganze Reihe: + expect(doc.undo()).toBe(true); + expect(doc.getAllElements().length).toBe(0); + }); + + it('cancel verwirft die laufende Reihe', () => { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + const ctx = makeCtx({ doc }); + const row = eventToolsV2Plugin.tools!.find((t) => t.manifest.id === 'seating-row')!; + + row.handlers.down!(pe(0, 0), ctx); + row.handlers.cancel!(ctx); + row.handlers.up!(pe(100, 0), ctx); // nach cancel: kein dragStart + + expect(doc.getAllElements().length).toBe(0); + }); +}); + +describe('E1: seating-block drag', () => { + it('berechnet rows×cols aus Distanz und committet Multi-Element-Set', () => { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + const ctx = makeCtx({ doc, options: { colSpacing: 50, rowSpacing: 50 } }); + const block = eventToolsV2Plugin.tools!.find((t) => t.manifest.id === 'seating-block')!; + + // Start (0,0), Ende (150,100): w=150/50=3 cols, h=100/50=2 rows → 6 Stühle + block.handlers.down!(pe(0, 0), ctx); + block.handlers.up!(pe(150, 100), ctx); + + const all = doc.getAllElements(); + expect(all.length).toBe(6); + expect(all.every((e) => e.type === 'chair')).toBe(true); + + // BUG-1-Regressionsschutz: rowIds pro Reihe eindeutig + const rowIds = new Set(all.map((e) => e.properties.rowId)); + expect(rowIds.size).toBe(2); + + // EIN Undo entfernt den ganzen Block: + expect(doc.undo()).toBe(true); + expect(doc.getAllElements().length).toBe(0); + }); +});