From dde5077bf9340960c81906b1251a0f80624a9d82 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Thu, 27 Aug 2026 08:53:51 +0200 Subject: [PATCH] task(C2): pixi renderer with incremental element rendering behind adapter --- frontend/src/render/mock/MockAdapter.ts | 8 + frontend/src/render/pixi/PixiRenderer.ts | 275 +++++++++++++++++++++++ frontend/src/render/types.ts | 6 + 3 files changed, 289 insertions(+) create mode 100644 frontend/src/render/pixi/PixiRenderer.ts diff --git a/frontend/src/render/mock/MockAdapter.ts b/frontend/src/render/mock/MockAdapter.ts index ad2019e..1a13981 100644 --- a/frontend/src/render/mock/MockAdapter.ts +++ b/frontend/src/render/mock/MockAdapter.ts @@ -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]); } diff --git a/frontend/src/render/pixi/PixiRenderer.ts b/frontend/src/render/pixi/PixiRenderer.ts new file mode 100644 index 0000000..348f13b --- /dev/null +++ b/frontend/src/render/pixi/PixiRenderer.ts @@ -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(); + private viewport: Viewport = { x: 0, y: 0, scale: 1, width: 800, height: 600 }; + private readyResolve: (() => void) | null = null; + readonly ready: Promise; + + constructor() { + this.ready = new Promise((resolve) => { + this.readyResolve = resolve; + }); + } + + /** Wartet bis Pixi initialisiert ist (vor dem ersten Frame aufrufen). */ + async waitForReady(): Promise { + 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; + } +} diff --git a/frontend/src/render/types.ts b/frontend/src/render/types.ts index b7a97a6..905dbd5 100644 --- a/frontend/src/render/types.ts +++ b/frontend/src/render/types.ts @@ -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;