/** * PixiRenderer – WebGL-Implementierung des RenderAdapter (Task C2). * * - PixiJS v8 wird LAZY per dynamischem import() geladen — ein Ausfall des * Pixi-Moduls (z.B. fehlendes WebGL im Headless-Browser) beeinflusst damit * NICHT den Modul-Import der App (kein weißer Screen). * - async Application.init(); danach synchron nutzbar über waitForReady() * - Inkrementell: gerenderte Container pro Element-ID gecacht; * removeElement/clearElements halten Cache und Szene konsistent * - drawElement konsumiert die testbaren Shape-Primitives (elementDrawers) */ import type { Application, Container, Graphics, Text } from 'pixi.js'; import type { RenderAdapter, Viewport, GridConfig, BackgroundDrawConfig, RulerConfig, LayerStyle, OverlayPainter, SelectionState, } from '../types'; import type { CADElement, CADLayer } from '../../types/cad.types'; import type { Pt } from '../../tools/modification/geometry'; import { elementToPrimitives } from './elementDrawers'; export class PixiRenderer implements RenderAdapter { private app: Application | null = null; private canvas: HTMLCanvasElement | null = null; private root: Container | null = null; private overlayLayer: Container | null = null; private graphicsCache = new Map(); private viewport: Viewport = { x: 0, y: 0, scale: 1, width: 800, height: 600 }; private readyResolve: (() => void) | null = null; private readyReject: ((err: unknown) => void) | null = null; readonly ready: Promise; private pixiModule: typeof import('pixi.js') | null = null; constructor() { this.ready = new Promise((resolve, reject) => { this.readyResolve = resolve; this.readyReject = reject; }); } /** Wartet auf Abschluss der Initialisierung; rejected bei Pixi-Fehler. */ async waitForReady(): Promise { await this.ready; } /** Task C4: true sobald setupApp fertig ist (via readyResolve gesetzt). */ private initialized = false; // ── Lifecycle ──────────────────────────────────────────── init(canvas: HTMLCanvasElement): void { this.canvas = canvas; // LAZY-IMPORT: pixi.js wird erst jetzt geholt. Bei Fehler → ready rejected, // CanvasArea deaktiviert das Overlay und die App läuft normal weiter. void import('pixi.js') .then((mod) => { this.pixiModule = mod; return this.setupApp(mod.Application, mod.Container, canvas); }) .then(() => this.readyResolve?.()) .catch((err) => { this.readyReject?.(err); }); } private setupApp( ApplicationCtor: typeof Application, ContainerCtor: typeof Container, canvas: HTMLCanvasElement, ): Promise { const app = new ApplicationCtor(); this.app = app; return app.init({ canvas, width: this.viewport.width, height: this.viewport.height, backgroundAlpha: 0, antialias: true, autoDensity: false, preference: 'webgl', }).then(() => { if (!this.app) return; const root = new ContainerCtor(); this.root = root; this.app.stage.addChild(root); const overlay = new ContainerCtor(); this.overlayLayer = overlay; this.app.stage.addChild(overlay); this.applyViewportTransform(); this.initialized = true; // Task C4: fuer getDebugInfo }); } resize(width: number, height: number, _dpr: number): void { if (!this.app?.renderer) return; this.viewport.width = width; this.viewport.height = height; this.app.renderer.resize(width, height); } setViewport(vp: Viewport): void { this.viewport = vp; this.applyViewportTransform(); } destroy(): void { try { this.app?.destroy(true, { children: true }); } catch { // Pixi darf im Teardown nichts mehr werfen } this.app = null; this.root = null; this.overlayLayer = null; this.graphicsCache.clear(); this.canvas = null; } // ── Basis-Zeichenoperationen ──────────────────────────── clear(_color: string): void { // Hintergrund kommt vom Canvas-CSS; Pixi leert Buffer beim render. } drawGrid(cfg: GridConfig): void { if (!cfg.enabled || !this.overlayLayer || !this.root) return; const GraphicsCtor = this.pixiModule?.Graphics; if (!GraphicsCtor) return; const g = new GraphicsCtor(); const vp = this.viewport; const worldLeft = vp.x; const worldTop = vp.y; const worldRight = vp.x + vp.width / vp.scale; const worldBottom = vp.y + vp.height / vp.scale; for ( let gx = Math.floor(worldLeft / cfg.size) * cfg.size; gx < worldRight; gx += cfg.size ) { g.moveTo(gx, worldTop).lineTo(gx, worldBottom) .stroke({ color: cfg.color, width: 0.5 / vp.scale }); } for ( let gy = Math.floor(worldTop / cfg.size) * cfg.size; gy < worldBottom; gy += cfg.size ) { g.moveTo(worldLeft, gy).lineTo(worldRight, gy) .stroke({ color: cfg.color, width: 0.5 / vp.scale }); } this.root.addChild(g); } private bgSprite: unknown | null = null; private bgSpriteSrc = ''; drawBackground(img: HTMLImageElement | null, cfg: BackgroundDrawConfig): void { // Task CAD-6: Hintergrund-Grundriss als Sprite unter den Elementen. if (!this.root || !img || !cfg.src) { // Ohne Bild: vorhandenes Sprite entfernen (Hintergrund aus) if (this.bgSprite) { (this.bgSprite as { destroy: () => void }).destroy(); this.bgSprite = null; this.bgSpriteSrc = ''; } return; } // Bild-Cache: Neuaufbau nur bei Quellwechsel if (this.bgSpriteSrc !== cfg.src) { if (this.bgSprite) (this.bgSprite as { destroy: () => void }).destroy(); // Lazy-Pixi (Modul-Fehler-Isolation wie in init): Sprite dynamisch const PixiNS = (this as unknown as { pixiNS: unknown }).pixiNS as { Sprite: new (src: string) => unknown } | undefined; if (!PixiNS) return; const sprite = new PixiNS.Sprite(cfg.src); (sprite as unknown as { alpha: number }).alpha = cfg.opacity ?? 0.5; this.bgSprite = sprite; this.bgSpriteSrc = cfg.src; this.root.addChildAt(sprite as never, 0); // ganz unten } // Transform aktualisieren (Legacy-Paritaet: Zentrum bei offset, scaled) const s = cfg.scale ?? 1; const sprite = this.bgSprite as unknown as { position: { set: (x: number, y: number) => void }; width: number; height: number; anchor: { set: (x: number, y: number) => void }; rotation: number; scale: { set: (x: number, y: number) => void }; alpha: number; }; sprite.anchor.set(0, 0); sprite.width = img.width * s; sprite.height = img.height * s; sprite.position.set(cfg.offsetX ?? 0, cfg.offsetY ?? 0); sprite.rotation = cfg.rotation ?? 0; sprite.alpha = cfg.opacity ?? 0.5; } drawRuler(_cfg: RulerConfig): void { // Lineal-Rendering folgt in Task C3. } // ── Elemente ───────────────────────────────────────────── drawElement(el: CADElement, layer: CADLayer, _style: LayerStyle): void { if (!this.root || !layer.visible || layer.locked) return; this.removeElement(el.id); const container = this.buildGraphicsContainer(el); if (!container) return; this.graphicsCache.set(el.id, container); this.root.addChild(container); } removeElement(id: string): void { const existing = this.graphicsCache.get(id); if (!existing) return; try { existing.destroy({ children: true }); } catch { // teardown-safe } this.graphicsCache.delete(id); } clearElements(): void { for (const c of this.graphicsCache.values()) { try { c.destroy({ children: true }); } catch { // teardown-safe } } this.graphicsCache.clear(); } drawPreview(el: CADElement | null): void { if (!this.overlayLayer) return; for (const child of [...this.overlayLayer.children]) { if ((child as Container).label === '__preview__') { try { child.destroy({ children: true }); } catch { // teardown-safe } } } if (!el) return; const g = this.buildGraphicsContainer(el); if (g) { g.label = '__preview__'; g.alpha = 0.6; this.overlayLayer.addChild(g); } } setSelection(sel: SelectionState): void { if (!this.overlayLayer) return; // Bestehende Selektions-Grafik entfernen: for (const child of [...this.overlayLayer.children]) { if ((child as Container).label === '__selection__') { try { child.destroy({ children: true }); } catch { // teardown-safe } } } if (!sel.selectedIds.length || !sel.elements || !this.pixiModule) return; const GraphicsCtor = this.pixiModule.Graphics; const g = new GraphicsCtor(); const HANDLE = 4; // halbe Kantenlänge der Eck-Handles (in Weltkoordinaten) for (const el of sel.elements) { if (!sel.selectedIds.includes(el.id)) continue; // Legacy-Konvention getElementBBox: x/y = Zentrum für rects, sonst BBox let minX: number, minY: number, maxX: number, maxY: number; if (el.type === 'rect' || el.type === 'stage') { minX = el.x - el.width / 2; minY = el.y - el.height / 2; maxX = el.x + el.width / 2; maxY = el.y + el.height / 2; } else if ( el.properties && el.properties.x1 !== undefined && el.properties.y1 !== undefined && el.properties.x2 !== undefined && el.properties.y2 !== undefined ) { minX = Math.min(el.properties.x1 as number, el.properties.x2 as number); minY = Math.min(el.properties.y1 as number, el.properties.y2 as number); maxX = Math.max(el.properties.x1 as number, el.properties.x2 as number); maxY = Math.max(el.properties.y1 as number, el.properties.y2 as number); } else { minX = el.x - el.width / 2; minY = el.y - el.height / 2; maxX = el.x + el.width / 2; maxY = el.y + el.height / 2; } // Umrandung (gestrichelt wird via Linien-Segmenten simuliert): const dashLen = 6; const drawDashed = (ax: number, ay: number, bx: number, by: number) => { const len = Math.hypot(bx - ax, by - ay); const n = Math.max(1, Math.floor(len / dashLen)); for (let i = 0; i <= n; i += 2) { const t0 = i / n; const t1 = Math.min(1, (i + 1) / n); g.moveTo(ax + (bx - ax) * t0, ay + (by - ay) * t0) .lineTo(ax + (bx - ax) * t1, ay + (by - ay) * t1) .stroke({ color: '#50fa7b', width: 1 / this.viewport.scale }); } }; drawDashed(minX, minY, maxX, minY); drawDashed(maxX, minY, maxX, maxY); drawDashed(maxX, maxY, minX, maxY); drawDashed(minX, maxY, minX, minY); // 4 Eck-Handles (gefüllte Quadrate): const corners = [ { x: minX, y: minY }, { x: maxX, y: minY }, { x: maxX, y: maxY }, { x: minX, y: maxY }, ]; for (const c of corners) { g.rect(c.x - HANDLE, c.y - HANDLE, HANDLE * 2, HANDLE * 2) .fill({ color: '#50fa7b' }) .stroke({ color: '#1e1e2e', width: 1 / this.viewport.scale }); } } g.label = '__selection__'; this.overlayLayer.addChild(g); } setSnapPoints(pts: Array<{ x: number; y: number; type: string }>): void { if (!this.overlayLayer || !this.pixiModule || pts.length === 0) return; const GraphicsCtor = this.pixiModule.Graphics; const g = new GraphicsCtor(); g.label = '__snaps__'; // Bestehende Snap-Marks entfernen: for (const child of [...this.overlayLayer.children]) { if ((child as Container).label === '__snaps__') { try { child.destroy({ children: true }); } catch { // teardown-safe } } } // Snap-Punkt farbig nach Typ (Legacy-Ähnlich): const colorByType: Record = { endpoint: '#ff79c6', midpoint: '#8be9fd', center: '#f1fa8c', intersection: '#ffb86c', grid: '#6272a4', }; const scaleComp = 1 / this.viewport.scale; // konstante Bildschirmgröße for (const pt of pts) { const color = colorByType[pt.type] ?? '#bd93f9'; g.circle(pt.x, pt.y, 4 * scaleComp) .fill({ color }) .stroke({ color: '#1e1e2e', width: 1 * scaleComp }); } this.overlayLayer.addChild(g); } overlay(draw: (g: OverlayPainter) => void): void { if (!this.overlayLayer) return; const GraphicsCtor = this.pixiModule?.Graphics; const TextCtor = this.pixiModule?.Text; if (!GraphicsCtor || !TextCtor) return; const g = new GraphicsCtor(); const painter: OverlayPainter = { line: (a: Pt, b: Pt, color: string, width?: number) => { g.moveTo(a.x, a.y).lineTo(b.x, b.y).stroke({ color, width: width ?? 1 }); }, rect: (x: number, y: number, w: number, h: number, strokeColor: string) => { g.rect(x, y, w, h).stroke({ color: strokeColor }); }, circle: (center: Pt, radius: number, strokeColor: string) => { g.circle(center.x, center.y, radius).stroke({ color: strokeColor }); }, text: (text: string, at: Pt, color: string, fontSize?: number) => { const t = new TextCtor({ text, style: { fontSize: fontSize ?? 12, fill: color } }); t.position.set(at.x, at.y); g.addChild(t); }, }; draw(painter); this.overlayLayer.addChild(g); } hitTest(_world: Pt, _tolerancePx: number): CADElement | null { // Delegation an SpatialIndex folgt in C3. return null; } /** * Task C4: Debug-Info für Playwright-E2E-Assertions. * Ermöglicht Testabfragen wie 'wie viele Elemente sind gerendert?' */ getDebugInfo(): { cachedElements: number; ready: boolean } { return { cachedElements: this.graphicsCache.size, ready: this.initialized, }; } // ── Internes ───────────────────────────────────────────── private applyViewportTransform(): void { if (!this.root) return; this.root.position.set( -this.viewport.x * this.viewport.scale, -this.viewport.y * this.viewport.scale, ); this.root.scale.set(this.viewport.scale); } private buildGraphicsContainer(el: CADElement): Container | null { const prims = elementToPrimitives(el); if (prims.length === 0) return null; const GraphicsCtor = this.pixiModule?.Graphics; const TextCtor = this.pixiModule?.Text; if (!GraphicsCtor || !TextCtor) return null; const ContainerCtor = this.pixiModule!.Container; const container = new ContainerCtor(); for (const prim of prims) { const g = new GraphicsCtor(); switch (prim.kind) { case 'line': g.moveTo(prim.from.x, prim.from.y) .lineTo(prim.to.x, prim.to.y) .stroke({ color: prim.stroke, width: prim.strokeWidth }); break; case 'rect': g.rect(prim.x, prim.y, prim.w, prim.h); if (prim.fill) g.fill({ color: prim.fill }); g.stroke({ color: prim.stroke, width: prim.strokeWidth }); break; case 'circle': g.circle(prim.center.x, prim.center.y, prim.radius); if (prim.fill) g.fill({ color: prim.fill }); g.stroke({ color: prim.stroke, width: prim.strokeWidth }); break; case 'polyline': { if (prim.points.length === 0) break; g.moveTo(prim.points[0].x, prim.points[0].y); for (let i = 1; i < prim.points.length; i++) { g.lineTo(prim.points[i].x, prim.points[i].y); } if (prim.close) g.closePath(); g.stroke({ color: prim.stroke, width: prim.strokeWidth }); break; } case 'arc': { const startRad = (prim.startDeg * Math.PI) / 180; const endRad = (prim.endDeg * Math.PI) / 180; g.arc(prim.center.x, prim.center.y, prim.radius, startRad, endRad) .stroke({ color: prim.stroke, width: prim.strokeWidth }); break; } case 'text': { const t = new TextCtor({ text: prim.text, style: { fontSize: prim.fontSize, fill: prim.color } }); t.position.set(prim.at.x, prim.at.y); g.addChild(t); break; } } container.addChild(g); } return container; } }