task(C2): pixi renderer with incremental element rendering behind adapter
This commit is contained in:
@@ -85,6 +85,14 @@ export class MockAdapter implements RenderAdapter {
|
||||
this.rec('drawElement', [el, layer, style]);
|
||||
}
|
||||
|
||||
removeElement(id: string): void {
|
||||
this.rec('removeElement', [id]);
|
||||
}
|
||||
|
||||
clearElements(): void {
|
||||
this.rec('clearElements', []);
|
||||
}
|
||||
|
||||
drawPreview(el: CADElement | null): void {
|
||||
this.rec('drawPreview', [el]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,12 @@ export interface RenderAdapter {
|
||||
/** Zeichnet ein Element im Kontext seines Layers (Stil, Sichtbarkeit). */
|
||||
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. */
|
||||
drawPreview(el: CADElement | null): void;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user