477 lines
16 KiB
TypeScript
477 lines
16 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|
||
}
|