task(C2): pixi renderer with incremental element rendering behind adapter

This commit is contained in:
Agent Zero
2026-08-27 08:53:51 +02:00
parent b412a65c33
commit dde5077bf9
3 changed files with 289 additions and 0 deletions
+8
View File
@@ -85,6 +85,14 @@ export class MockAdapter implements RenderAdapter {
this.rec('drawElement', [el, layer, style]); this.rec('drawElement', [el, layer, style]);
} }
removeElement(id: string): void {
this.rec('removeElement', [id]);
}
clearElements(): void {
this.rec('clearElements', []);
}
drawPreview(el: CADElement | null): void { drawPreview(el: CADElement | null): void {
this.rec('drawPreview', [el]); this.rec('drawPreview', [el]);
} }
+275
View File
@@ -0,0 +1,275 @@
/**
* PixiRenderer WebGL-Implementierung des RenderAdapter (Task C2).
*
* - PixiJS v8: async Application.init(); danach synchron nutzbar
* (Integration wartet auf waitForReady() vor dem ersten Frame)
* - 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 {
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; // Weltkoordinaten-Container (skaliert)
private overlayLayer: Container | null = null; // Grid/Ruler/Snaps — immer oben
private graphicsCache = new Map<string, Container>();
private viewport: Viewport = { x: 0, y: 0, scale: 1, width: 800, height: 600 };
private readyResolve: (() => void) | null = null;
readonly ready: Promise<void>;
constructor() {
this.ready = new Promise<void>((resolve) => {
this.readyResolve = resolve;
});
}
/** Wartet bis Pixi initialisiert ist (vor dem ersten Frame aufrufen). */
async waitForReady(): Promise<void> {
await this.ready;
}
// ── Lifecycle ────────────────────────────────────────────
init(canvas: HTMLCanvasElement): void {
const app = new Application();
this.app = app;
this.canvas = canvas;
void app.init({
canvas,
width: this.viewport.width,
height: this.viewport.height,
backgroundAlpha: 0,
antialias: true,
autoDensity: false,
preference: 'webgl',
}).then(() => {
this.root = new Container();
app.stage.addChild(this.root);
this.overlayLayer = new Container();
app.stage.addChild(this.overlayLayer);
this.applyViewportTransform();
this.readyResolve?.();
});
}
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 {
this.app?.destroy(true, { children: true });
this.app = null;
this.root = null;
this.overlayLayer = null;
this.graphicsCache.clear();
this.canvas = null;
}
// ── Basis-Zeichenoperationen ────────────────────────────
clear(_color: string): void {
// Hintergrundfarbe kommt vom Canvas-CSS/Stage-Hintergrund.
// Pixi's render()-Aufruf leert den Buffer selbst.
}
drawGrid(cfg: GridConfig): void {
if (!cfg.enabled || !this.overlayLayer || !this.root) return;
const g = new Graphics();
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); // skaliert mit, damit die Rasterlinien zur Welt passen
}
drawBackground(_img: HTMLImageElement | null, _cfg: BackgroundDrawConfig): void {
// Sprite-/Texturintegration folgt in Task C3 (BUG-4-Korrelat in WebGL).
}
drawRuler(_cfg: RulerConfig): void {
// Lineal-Rendering folgt in Task C3 (DOM-Layer oder Pixi-Text).
}
// ── 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
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;
existing.destroy({ children: true });
this.graphicsCache.delete(id);
}
clearElements(): void {
for (const c of this.graphicsCache.values()) {
c.destroy({ children: true });
}
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 });
}
}
if (!el) return;
const g = this.buildGraphicsContainer(el);
if (g) {
g.label = '__preview__';
g.alpha = 0.6; // halbtransparent wie Legacy-Vorschau
this.overlayLayer.addChild(g);
}
}
setSelection(_sel: SelectionState): void {
// Selektionshandles in C3 (braucht BBox-Zugriff pro Element)
}
setSnapPoints(_pts: Array<{ x: number; y: number; type: string }>): void {
// Snap-Markierungen in C3
}
overlay(draw: (g: OverlayPainter) => void): void {
if (!this.overlayLayer) return;
const g = new Graphics();
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 Text({ 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 (benötigt Elementliste).
return null;
}
// ── 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 container = new Container();
for (const prim of prims) {
const g = new Graphics();
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 Text({ 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;
}
}
+6
View File
@@ -98,6 +98,12 @@ export interface RenderAdapter {
/** Zeichnet ein Element im Kontext seines Layers (Stil, Sichtbarkeit). */ /** Zeichnet ein Element im Kontext seines Layers (Stil, Sichtbarkeit). */
drawElement(el: CADElement, layer: CADLayer, style: LayerStyle): void; drawElement(el: CADElement, layer: CADLayer, style: LayerStyle): void;
/** Entfernt ein Element aus dem Rendering (z.B. nach Löschen in Yjs). */
removeElement(id: string): void;
/** Entfernt ALLE gerenderten Elemente (Reset bei Dokumentwechsel). */
clearElements(): void;
/** Zeichnet ein Element in Transformation / Live-Vorschau. */ /** Zeichnet ein Element in Transformation / Live-Vorschau. */
drawPreview(el: CADElement | null): void; drawPreview(el: CADElement | null): void;