task(C2int): integrate pixi renderer as overlay with lazy import

This commit is contained in:
Agent Zero
2026-08-27 10:02:05 +02:00
parent 8cde5d7f35
commit 8b1cca0324
2 changed files with 160 additions and 37 deletions
+82 -37
View File
@@ -1,15 +1,15 @@
/**
* PixiRenderer WebGL-Implementierung des RenderAdapter (Task C2).
*
* - PixiJS v8: async Application.init(); danach synchron nutzbar
* (Integration wartet auf waitForReady() vor dem ersten Frame)
* - 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)
* - drawGrid nutzt Overlay-Layer für Linien; Ruler/Background/Selection
* folgen in Task C3
*/
import { Application, Container, Graphics, Text } from 'pixi.js';
import type { Application, Container, Graphics, Text } from 'pixi.js';
import type {
RenderAdapter,
Viewport,
@@ -27,20 +27,23 @@ import { elementToPrimitives } from './elementDrawers';
export class PixiRenderer implements RenderAdapter {
private app: Application | null = null;
private canvas: HTMLCanvasElement | null = null;
private root: Container | null = null; // Weltkoordinaten-Container (skaliert)
private overlayLayer: Container | null = null; // Grid/Ruler/Snaps — immer oben
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) => {
this.ready = new Promise<void>((resolve, reject) => {
this.readyResolve = resolve;
this.readyReject = reject;
});
}
/** Wartet bis Pixi initialisiert ist (vor dem ersten Frame aufrufen). */
/** Wartet auf Abschluss der Initialisierung; rejected bei Pixi-Fehler. */
async waitForReady(): Promise<void> {
await this.ready;
}
@@ -48,11 +51,28 @@ export class PixiRenderer implements RenderAdapter {
// ── Lifecycle ────────────────────────────────────────────
init(canvas: HTMLCanvasElement): void {
const app = new Application();
this.app = app;
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);
});
}
void app.init({
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,
@@ -61,12 +81,14 @@ export class PixiRenderer implements RenderAdapter {
autoDensity: false,
preference: 'webgl',
}).then(() => {
this.root = new Container();
app.stage.addChild(this.root);
this.overlayLayer = new Container();
app.stage.addChild(this.overlayLayer);
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.readyResolve?.();
});
}
@@ -83,7 +105,11 @@ export class PixiRenderer implements RenderAdapter {
}
destroy(): void {
this.app?.destroy(true, { children: true });
try {
this.app?.destroy(true, { children: true });
} catch {
// Pixi darf im Teardown nichts mehr werfen
}
this.app = null;
this.root = null;
this.overlayLayer = null;
@@ -94,13 +120,14 @@ export class PixiRenderer implements RenderAdapter {
// ── Basis-Zeichenoperationen ────────────────────────────
clear(_color: string): void {
// Hintergrundfarbe kommt vom Canvas-CSS/Stage-Hintergrund.
// Pixi's render()-Aufruf leert den Buffer selbst.
// Hintergrund kommt vom Canvas-CSS; Pixi leert Buffer beim render.
}
drawGrid(cfg: GridConfig): void {
if (!cfg.enabled || !this.overlayLayer || !this.root) return;
const g = new Graphics();
const GraphicsCtor = this.pixiModule?.Graphics;
if (!GraphicsCtor) return;
const g = new GraphicsCtor();
const vp = this.viewport;
const worldLeft = vp.x;
const worldTop = vp.y;
@@ -122,22 +149,22 @@ export class PixiRenderer implements RenderAdapter {
g.moveTo(worldLeft, gy).lineTo(worldRight, gy)
.stroke({ color: cfg.color, width: 0.5 / vp.scale });
}
this.root.addChild(g); // skaliert mit, damit die Rasterlinien zur Welt passen
this.root.addChild(g);
}
drawBackground(_img: HTMLImageElement | null, _cfg: BackgroundDrawConfig): void {
// Sprite-/Texturintegration folgt in Task C3 (BUG-4-Korrelat in WebGL).
// Sprite-/Texturintegration folgt in Task C3.
}
drawRuler(_cfg: RulerConfig): void {
// Lineal-Rendering folgt in Task C3 (DOM-Layer oder Pixi-Text).
// 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); // alter Zustand raus, neuer rein
this.removeElement(el.id);
const container = this.buildGraphicsContainer(el);
if (!container) return;
this.graphicsCache.set(el.id, container);
@@ -147,36 +174,47 @@ export class PixiRenderer implements RenderAdapter {
removeElement(id: string): void {
const existing = this.graphicsCache.get(id);
if (!existing) return;
existing.destroy({ children: true });
try {
existing.destroy({ children: true });
} catch {
// teardown-safe
}
this.graphicsCache.delete(id);
}
clearElements(): void {
for (const c of this.graphicsCache.values()) {
c.destroy({ children: true });
try {
c.destroy({ children: true });
} catch {
// teardown-safe
}
}
this.graphicsCache.clear();
}
drawPreview(el: CADElement | null): void {
if (!this.overlayLayer) return;
// Bestehende Preview-Container am Ende des Layers entfernen:
for (const child of [...this.overlayLayer.children]) {
if ((child as Container).label === '__preview__') {
child.destroy({ children: true });
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; // halbtransparent wie Legacy-Vorschau
g.alpha = 0.6;
this.overlayLayer.addChild(g);
}
}
setSelection(_sel: SelectionState): void {
// Selektionshandles in C3 (braucht BBox-Zugriff pro Element)
// Selektionshandles in C3
}
setSnapPoints(_pts: Array<{ x: number; y: number; type: string }>): void {
@@ -185,7 +223,10 @@ export class PixiRenderer implements RenderAdapter {
overlay(draw: (g: OverlayPainter) => void): void {
if (!this.overlayLayer) return;
const g = new Graphics();
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 });
@@ -197,7 +238,7 @@ export class PixiRenderer implements RenderAdapter {
g.circle(center.x, center.y, radius).stroke({ color: strokeColor });
},
text: (text: string, at: Pt, color: string, fontSize?: number) => {
const t = new Text({ text, style: { fontSize: fontSize ?? 12, fill: color } });
const t = new TextCtor({ text, style: { fontSize: fontSize ?? 12, fill: color } });
t.position.set(at.x, at.y);
g.addChild(t);
},
@@ -207,7 +248,7 @@ export class PixiRenderer implements RenderAdapter {
}
hitTest(_world: Pt, _tolerancePx: number): CADElement | null {
// Delegation an SpatialIndex folgt in C3 (benötigt Elementliste).
// Delegation an SpatialIndex folgt in C3.
return null;
}
@@ -225,9 +266,13 @@ export class PixiRenderer implements RenderAdapter {
private buildGraphicsContainer(el: CADElement): Container | null {
const prims = elementToPrimitives(el);
if (prims.length === 0) return null;
const container = new Container();
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 Graphics();
const g = new GraphicsCtor();
switch (prim.kind) {
case 'line':
g.moveTo(prim.from.x, prim.from.y)
@@ -262,7 +307,7 @@ export class PixiRenderer implements RenderAdapter {
break;
}
case 'text': {
const t = new Text({ text: prim.text, style: { fontSize: prim.fontSize, fill: prim.color } });
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;