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

477 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}
}