task(CAD-14): phase 1 - tool exclusivity, V2 dispatcher exclusive for V2 tools (was: both engines had same tool = multi elements per click), selection handles now render in pixi mode via overlay pass, E2E proof test added

This commit is contained in:
Agent Zero
2026-09-16 18:03:08 +02:00
parent 4e6edb51ed
commit 02ed6a5ff0
4 changed files with 103 additions and 20 deletions
@@ -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);
});
+29 -8
View File
@@ -57,6 +57,12 @@ export class RenderEngine {
private dpr = 1; private dpr = 1;
/** CAD-13-fix: true = Legacy-Canvas zeichnet KEINE Elemente (Pixi-Overlay zeichnet sie). Grid/Hintergrund bleiben. */ /** CAD-13-fix: true = Legacy-Canvas zeichnet KEINE Elemente (Pixi-Overlay zeichnet sie). Grid/Hintergrund bleiben. */
skipElementRendering = false; 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( constructor(
canvas: HTMLCanvasElement, canvas: HTMLCanvasElement,
@@ -164,16 +170,16 @@ export class RenderEngine {
if (this.options.backgroundSrc) this.drawBackground(); if (this.options.backgroundSrc) this.drawBackground();
this.drawRuler(w, h); this.drawRuler(w, h);
// CAD-13-fix: Wenn Pixi-Renderer aktiv ist (skipElementRendering=true), // CAD-14: skipElementRendering = Pixi rendert die Elemente. Das Legacy-
// zeichnet das Legacy-Canvas KEINE Elemente mehr (sonst doppelt) — // Canvas zeichnet KEINE Elemente mehr — ABER weiterhin alle Overlays
// nur Grid/Hintergrund/Lineal bleiben hier. // (Vorschau, Auswahl-Griffe, Auswahl-Rechteck, Snap-Punkte, Ortho),
if (this.skipElementRendering) { // sonst waere Auswahl im Pixi-Modus unsichtbar.
this.ctx.restore(); const skipElements = this.skipElementRendering;
return;
}
const viewport = this.zoomPan.getViewport(); const viewport = this.zoomPan.getViewport();
const visibleElements = this.spatialIndex.search({ const visibleElements = skipElements
? []
: this.spatialIndex.search({
minX: viewport.minX, minY: viewport.minY, minX: viewport.minX, minY: viewport.minY,
maxX: viewport.maxX, maxY: viewport.maxY, maxX: viewport.maxX, maxY: viewport.maxY,
}); });
@@ -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 // Draw preview element with dashed style
if (this.previewElement) { if (this.previewElement) {
this.ctx.save(); this.ctx.save();
+8 -6
View File
@@ -304,6 +304,7 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
interaction.setElements(elements); interaction.setElements(elements);
spatialIndex.clear(); spatialIndex.clear();
spatialIndex.bulkInsert(elements); spatialIndex.bulkInsert(elements);
renderEngine.setOverlayElements(elements);
renderEngine.render(); renderEngine.render();
// CAD-13-fix: Bestands-Elemente (aus DB geladen) müssen in Pixi gespiegelt // CAD-13-fix: Bestands-Elemente (aus DB geladen) müssen in Pixi gespiegelt
@@ -447,12 +448,13 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
useEffect(() => { useEffect(() => {
const interaction = interactionRef.current; const interaction = interactionRef.current;
if (!interaction) return; if (!interaction) return;
interaction.setTool(activeTool as ToolType); // CAD-14: EXKLUSIVITAET — V2-Werkzeuge laufen AUSSCHLIESSLICH ueber den
// Task A4.1: V2-Werkzeuge zusätzlich an den Dispatcher routen (Flag aktiv). // InteractionDispatcher. Die Legacy-Engine bekommt sie nicht mehr
// Legacy bleibt parallel aktiv für noch nicht migrierte Tools. // (vorher hatten BEIDE dasselbe Tool aktiv => jeder Klick erzeugte
dispatcherRef.current?.setActive( // mehrfach Elemente). Nur Tools ohne V2-Entsprechung laufen legacy.
pluginRegistry.getToolV2(activeTool) ? activeTool : null, 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTool]); }, [activeTool]);
+3 -3
View File
@@ -14,7 +14,7 @@ import { formatDistance, type UnitType } from '../utils/format';
export type ToolPhase = 'idle' | 'drawing' | 'modifying' | 'panning' | 'selecting'; export type ToolPhase = 'idle' | 'drawing' | 'modifying' | 'panning' | 'selecting';
export interface ToolState { export interface ToolState {
activeTool: ToolType; activeTool: ToolType | null;
phase: ToolPhase; phase: ToolPhase;
points: Array<{ x: number; y: number }>; // accumulated world points for current operation points: Array<{ x: number; y: number }>; // accumulated world points for current operation
previewElement: CADElement | null; // temporary element being drawn previewElement: CADElement | null; // temporary element being drawn
@@ -138,13 +138,13 @@ export class InteractionEngine {
this.selectedTemplate = templateName; this.selectedTemplate = templateName;
} }
setTool(tool: ToolType): void { setTool(tool: ToolType | null): void {
this.state.activeTool = tool; this.state.activeTool = tool;
this.state.phase = 'idle'; this.state.phase = 'idle';
this.state.points = []; this.state.points = [];
this.state.previewElement = null; 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']; 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.selectionEngine.clearSelection();
this.emitSelectionChange(); this.emitSelectionChange();
} }