Files
web-cad/frontend/src/render/pixi/PixiRenderer.ts
T

477 lines
16 KiB
TypeScript
Raw Normal View History

/**
* 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<string, Container>();
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<void>;
private pixiModule: typeof import('pixi.js') | null = null;
constructor() {
this.ready = new Promise<void>((resolve, reject) => {
this.readyResolve = resolve;
this.readyReject = reject;
});
}
/** Wartet auf Abschluss der Initialisierung; rejected bei Pixi-Fehler. */
async waitForReady(): Promise<void> {
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<void> {
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<string, string> = {
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;
}
}