diff --git a/frontend/src/components/CanvasArea.tsx b/frontend/src/components/CanvasArea.tsx index 32779a7..e55c481 100644 --- a/frontend/src/components/CanvasArea.tsx +++ b/frontend/src/components/CanvasArea.tsx @@ -18,6 +18,7 @@ import { CADDocument } from '../kernel/document/CADDocument'; import { YjsDocument } from '../crdt/YjsDocument'; import { setActiveDocument, getActiveDocument } from '../kernel/document/documentService'; import { V2_TOOLS_ENABLED } from '../kernel/featureFlags'; +import { PixiRenderer } from '../render/pixi/PixiRenderer'; import { pluginRegistry } from '../plugins'; const CanvasArea: React.FC = ({ @@ -34,6 +35,10 @@ const CanvasArea: React.FC = ({ const renderEngineRef = useRef(null); const interactionRef = useRef(null); const dispatcherRef = useRef(null); + /** Task C2: PixiJS-Canvas-Ref für V2-Elemente-Overlay. */ + const pixiCanvasRef = useRef(null); + /** Task C2: Aktiver PixiJS-Renderer (für Cleanup). */ + const pixiRendererRef = useRef(null); /** Bereits an onElementCreated gemeldete V2-Element-IDs (verhindert Doppelmeldung bei jedem onChanged-Frame). */ const announcedV2Ids = useRef>(new Set()); const spatialIndexRef = useRef(null); @@ -65,6 +70,60 @@ const CanvasArea: React.FC = ({ const v2Doc = new CADDocument(ydoc); // App-Level-Zugriffspunkt für HistoryPanel/Tastenkürzel (Task A4.11): setActiveDocument(v2Doc); + + // ── Task C2: PixiJS-Renderer parallel schalten ── + // DEFENSIV: Ein Ausfall von Pixi (z.B. WebGL im Headless-Browser) + // darf NIEMALS den kompletten Editor-Mount brechen. Im Fehlerfall + // läuft weiter ohne Overlay. + let pixi: PixiRenderer | null = null; + const pixiCanvas = pixiCanvasRef.current; + if (pixiCanvas) { + try { + pixi = new PixiRenderer(); + pixiRendererRef.current = pixi; + pixi.init(pixiCanvas); + pixi.waitForReady().catch((err) => { + logger.warn('Pixi init failed — V2-Overlay deaktiviert:', err); + pixi = null; + pixiRendererRef.current = null; + }); + } catch (err) { + logger.warn('Pixi init exception — V2-Overlay deaktiviert:', err); + pixi = null; + pixiRendererRef.current = null; + } + } + /** Letztes bekanntes Elementobjekt je ID (Referenzdiff statt JSON). */ + const lastKnownEl = new Map(); + const syncPixi = (): void => { + if (!pixi) return; + void pixi.waitForReady().then(() => { + const all = v2Doc.getAllElements(); + const visibleIds = new Set(); + for (const el of all) { + visibleIds.add(el.id); + // Nur neu zeichnen wenn sich die OBJEKTREFERENZ geändert hat: + if (lastKnownEl.get(el.id) !== el) { + lastKnownEl.set(el.id, el); + pixi!.drawElement(el, { + id: 'layer-0', name: 'Standard', visible: true, locked: false, + color: '#ffffff', lineType: 'solid', transparency: 0, + sortOrder: 0, parentId: null, + } as never, { + color: '#ffffff', lineType: 'solid', transparency: 0, visible: true, + }); + } + } + // Gelöschte IDs aus dem Renderer entfernen: + for (const knownId of lastKnownEl.keys()) { + if (!visibleIds.has(knownId)) { + lastKnownEl.delete(knownId); + pixi!.removeElement(knownId); + } + } + }); + }; + // Committete V2-Elemente fließen über den etablierten Kanal in UI/State: v2Doc.onChanged(() => { for (const el of v2Doc.getAllElements()) { @@ -73,6 +132,7 @@ const CanvasArea: React.FC = ({ onElementCreated(el); } } + syncPixi(); // Task C2: V2-Elemente in den PixiJS-Overlay-Renderer spiegeln }); dispatcherRef.current = new InteractionDispatcher(canvas, { @@ -115,6 +175,8 @@ const CanvasArea: React.FC = ({ interaction.detach(); dispatcherRef.current?.destroy(); dispatcherRef.current = null; + pixiRendererRef.current?.destroy(); // Task C2: WebGL-Kontext freigeben + pixiRendererRef.current = null; setActiveDocument(null); zoomPanRef.current = null; renderEngineRef.current = null; @@ -413,6 +475,22 @@ const CanvasArea: React.FC = ({ {unit} + {/* Task C2-Integration: PixiJS-Overlay für V2-Elemente — liegt ÜBER dem + Legacy-Canvas (z-index höher), rendert nur Elemente aus dem V2-Dokument. + Pointer-Events gehen durch (pointerEvents:none), damit Drag/Drop weiter + auf dem Legacy-Canvas funktioniert. */} + {V2_TOOLS_ENABLED && ( + + )} + (); 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; + private pixiModule: typeof import('pixi.js') | null = null; constructor() { - this.ready = new Promise((resolve) => { + this.ready = new Promise((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 { 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 { + 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;