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]);
|
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]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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). */
|
/** 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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user