diff --git a/frontend/playwright-tests/one-engine-proof.test.ts b/frontend/playwright-tests/one-engine-proof.test.ts new file mode 100644 index 0000000..582fe8d --- /dev/null +++ b/frontend/playwright-tests/one-engine-proof.test.ts @@ -0,0 +1,60 @@ +/** + * CAD-14 Beweis-Test: Der Workflow, den der Nutzer jeden Tag macht. + * 1. Werkzeug waehlen (Sidebar), 2. zeichnen (2 Klicks), + * 3. EXAKT EIN Element an der geklickten Position erwarten. + * ROT-Zustand (jetzt): 2 Elemente (Legacy + V2 je eins) = Doppelung belegt. + * GRUEN nach Schnitt: 1 Element, Position = Klickposition. + */ +import { test, expect } from '@playwright/test'; + +const BASE_URL = process.env.FRONTEND_URL || 'https://web-cad-neu.server.media-on.de'; + +interface VerifyWindow { + __v2Debug?: { info: () => { cachedElements: number; ready: boolean } | null }; +} + +test('zeichnen erzeugt GENAU EIN Element an der Klick-Position', async ({ page }) => { + await page.goto(BASE_URL); + await page.fill('input[type=email]', 'admin@media-on.de'); + await page.fill('input[type=password]', 'WebCAD2026!'); + await page.click('button[type=submit]'); + await page.waitForTimeout(3000); + + // Neues leeres Projekt anlegen (damit der Count deterministisch ist) + const newBtn = page.locator('button:has-text("Neu"), [title*="Neues Projekt"]').first(); + const cards = await page.$$('.dashboard-project-card, [class*=project-card]'); + if (cards.length === 0) { await newBtn.click(); } // Dashboard-Variante + // Falls Dashboard: einfach das erste Projekt oeffnen und Delta messen + if (cards.length > 0) { await cards[0].click(); } + await page.waitForTimeout(2500); + + const verify = () => page.evaluate(() => { + const w = window as unknown as VerifyWindow; + return w.__v2Debug?.info?.() ?? null; + }); + + const before = await verify(); + const beforeCount = before?.cachedElements ?? -1; + expect(beforeCount, 'Pixi muss ready sein').toBeGreaterThanOrEqual(0); + + // Linie-Werkzeug aus der SIDEBAR (der richtige Ort) + await page.locator('[data-tool="line"]').first().click(); + await page.waitForTimeout(300); + + // Auf die Zeichenflaeche klicken: zwei Klicks = eine Linie + const canvas = page.locator('canvas').last(); + const box = await canvas.boundingBox(); + if (!box) throw new Error('Kein Canvas gefunden'); + const cx = box.x + box.width / 2; + const cy = box.y + box.height / 2; + await page.mouse.click(cx, cy); + await page.waitForTimeout(200); + await page.mouse.click(cx + 120, cy + 60); + await page.waitForTimeout(600); + + const after = await verify(); + const afterCount = after?.cachedElements ?? -1; + + // ABNAHME: genau +1 Element (jetzt faehlt der Test, weil 2 entstehen) + expect(afterCount - beforeCount, `Erwartet genau 1 neues Element, got ${afterCount - beforeCount}`).toBe(1); +}); diff --git a/frontend/src/canvas/RenderEngine.ts b/frontend/src/canvas/RenderEngine.ts index 1357318..b1eca4d 100644 --- a/frontend/src/canvas/RenderEngine.ts +++ b/frontend/src/canvas/RenderEngine.ts @@ -57,6 +57,12 @@ export class RenderEngine { private dpr = 1; /** CAD-13-fix: true = Legacy-Canvas zeichnet KEINE Elemente (Pixi-Overlay zeichnet sie). Grid/Hintergrund bleiben. */ skipElementRendering = false; + /** CAD-14: Elemente-Spiegel fuer Auswahl-Griffe im Pixi-Modus (RenderEngine zeichnet Elemente nicht, aber die Griffe). */ + private overlayElements: CADElement[] = []; + /** CAD-14: Setzt die Elemente, deren Auswahl-Griffe im Overlay gezeichnet werden. */ + setOverlayElements(els: CADElement[]): void { + this.overlayElements = els; + } constructor( canvas: HTMLCanvasElement, @@ -164,19 +170,19 @@ export class RenderEngine { if (this.options.backgroundSrc) this.drawBackground(); this.drawRuler(w, h); - // CAD-13-fix: Wenn Pixi-Renderer aktiv ist (skipElementRendering=true), - // zeichnet das Legacy-Canvas KEINE Elemente mehr (sonst doppelt) — - // nur Grid/Hintergrund/Lineal bleiben hier. - if (this.skipElementRendering) { - this.ctx.restore(); - return; - } + // CAD-14: skipElementRendering = Pixi rendert die Elemente. Das Legacy- + // Canvas zeichnet KEINE Elemente mehr — ABER weiterhin alle Overlays + // (Vorschau, Auswahl-Griffe, Auswahl-Rechteck, Snap-Punkte, Ortho), + // sonst waere Auswahl im Pixi-Modus unsichtbar. + const skipElements = this.skipElementRendering; const viewport = this.zoomPan.getViewport(); - const visibleElements = this.spatialIndex.search({ - minX: viewport.minX, minY: viewport.minY, - maxX: viewport.maxX, maxY: viewport.maxY, - }); + const visibleElements = skipElements + ? [] + : this.spatialIndex.search({ + minX: viewport.minX, minY: viewport.minY, + maxX: viewport.maxX, maxY: viewport.maxY, + }); const layers = this.layerManager.getLayers(); for (const layer of layers) { @@ -187,6 +193,21 @@ export class RenderEngine { } } + // CAD-14: Auswahl-Griffe auch fuer Pixi-gerenderte Elemente weiterhin + // auf dem Overlay-Canvas zeigen (Elemente selbst kommen von Pixi). + if (skipElements && this.selection.selectedIds.size > 0) { + const hs = this.zoomPan.getScale(); + const hox = this.zoomPan.getTransform().e; + const hoy = this.zoomPan.getTransform().f; + const hsx = (val: number) => val * hs + hox; + const hsy = (val: number) => val * hs + hoy; + for (const el of this.overlayElements) { + if (this.selection.selectedIds.has(el.id)) { + this.drawSelectionHandles(el, hsx, hsy, hs); + } + } + } + // Draw preview element with dashed style if (this.previewElement) { this.ctx.save(); diff --git a/frontend/src/components/CanvasArea.tsx b/frontend/src/components/CanvasArea.tsx index 46aaeca..c46a585 100644 --- a/frontend/src/components/CanvasArea.tsx +++ b/frontend/src/components/CanvasArea.tsx @@ -304,6 +304,7 @@ const CanvasArea: React.FC = ({ interaction.setElements(elements); spatialIndex.clear(); spatialIndex.bulkInsert(elements); + renderEngine.setOverlayElements(elements); renderEngine.render(); // CAD-13-fix: Bestands-Elemente (aus DB geladen) müssen in Pixi gespiegelt @@ -447,12 +448,13 @@ const CanvasArea: React.FC = ({ useEffect(() => { const interaction = interactionRef.current; if (!interaction) return; - interaction.setTool(activeTool as ToolType); - // Task A4.1: V2-Werkzeuge zusätzlich an den Dispatcher routen (Flag aktiv). - // Legacy bleibt parallel aktiv für noch nicht migrierte Tools. - dispatcherRef.current?.setActive( - pluginRegistry.getToolV2(activeTool) ? activeTool : null, - ); + // CAD-14: EXKLUSIVITAET — V2-Werkzeuge laufen AUSSCHLIESSLICH ueber den + // InteractionDispatcher. Die Legacy-Engine bekommt sie nicht mehr + // (vorher hatten BEIDE dasselbe Tool aktiv => jeder Klick erzeugte + // mehrfach Elemente). Nur Tools ohne V2-Entsprechung laufen legacy. + const isV2 = !!pluginRegistry.getToolV2(activeTool); + interaction.setTool(isV2 ? null : (activeTool as ToolType)); + dispatcherRef.current?.setActive(isV2 ? activeTool : null); // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeTool]); diff --git a/frontend/src/interaction/index.ts b/frontend/src/interaction/index.ts index dd798e3..43e8470 100644 --- a/frontend/src/interaction/index.ts +++ b/frontend/src/interaction/index.ts @@ -14,7 +14,7 @@ import { formatDistance, type UnitType } from '../utils/format'; export type ToolPhase = 'idle' | 'drawing' | 'modifying' | 'panning' | 'selecting'; export interface ToolState { - activeTool: ToolType; + activeTool: ToolType | null; phase: ToolPhase; points: Array<{ x: number; y: number }>; // accumulated world points for current operation previewElement: CADElement | null; // temporary element being drawn @@ -138,13 +138,13 @@ export class InteractionEngine { this.selectedTemplate = templateName; } - setTool(tool: ToolType): void { + setTool(tool: ToolType | null): void { this.state.activeTool = tool; this.state.phase = 'idle'; this.state.points = []; this.state.previewElement = null; const drawingTools: ToolType[] = ['line', 'rect', 'circle', 'arc', 'polyline', 'polygon', 'revcloud', 'text', 'dimension', 'leader', 'chair', 'seating-row', 'seating-block', 'table', 'stage', 'seating-template', 'hatch']; - if (drawingTools.includes(tool)) { + if (tool && drawingTools.includes(tool)) { this.selectionEngine.clearSelection(); this.emitSelectionChange(); }