3714a48fff
Integrations-Spike: die native 2D-Engine (src-tauri/render2d) laeuft jetzt als WASM/WebGPU-Canvas in der App-UI, mit derselben Szene (planToRenderScene) und ViewBox wie der WebGL2-Pfad; das SVG-Overlay (Text/Griffe/Auswahl) bleibt drueber. - render2d: neues Feature "web" (wasm-bindgen/web-sys), Fassade WebPlanRenderer (new/set_scene/set_view_box/set_paper_scale/resize/render) auf Canvas-Surface. Geteilte GPU-Schicht (gpu.rs), native Fensterschicht (winit) unberuehrt. - Font: cosmic-text findet auf wasm keine Systemfonts -> Inter-Bytes eingebettet (assets/Inter.ttf), Renderer.load_font + text_family aus der geladenen Familie. - Frontend: useWasmPlanRenderer (gleiche Schnittstelle wie useGlPlanRenderer); PlanView waehlt die Engine per ?engine=wasm + navigator.gpu, sonst WebGL2/SVG. - Build: npm-Script build:engine (wasm-pack, target web -> src/engine/pkg, gitignored). chromium-shell.sh mit WebGPU-Flags (--enable-unsafe-webgpu/Vulkan). - Kompat-Shim: wgpu 22 sendet das spec-entfernte Limit maxInterStageShaderComponents, das neuere Chromium ablehnen -> im Hook vor requestDevice entfernt. Verifiziert: cargo test -p render2d gruen (18), tsc gruen, wasm-Build gruen; Headless-Chromium (?engine=wasm) zeichnet den Grundriss (Waende/Schraffur/ Tuerschwenk/Treppe/Raumtext) im WASM-Viewport, Fallback ohne Flag unveraendert.
180 lines
6.3 KiB
TypeScript
180 lines
6.3 KiB
TypeScript
/**
|
|
* React-Hook für den nativen render2d-Renderer als WASM/WebGPU-Viewport.
|
|
*
|
|
* Spiegelt die Schnittstelle von {@link useGlPlanRenderer} ({ render,
|
|
* updateGeometry, ready }), damit PlanView beide Engines ohne weitere Verzweigung
|
|
* am selben Canvas + über dieselben Effekte treiben kann. Die eigentliche
|
|
* Zeichenlogik lebt in Rust (src-tauri/render2d, Feature `web`); hier wird nur das
|
|
* WASM-Modul geladen, der Renderer an das Canvas gebunden und die Szene/ViewBox
|
|
* durchgereicht.
|
|
*
|
|
* Fällt bei fehlendem WebGPU oder Init-Fehler still auf `ready:false` zurück; der
|
|
* Aufrufer (PlanView) behält dann den WebGL2-/SVG-Pfad.
|
|
*/
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import type { Plan } from "./generatePlan";
|
|
import { planToRenderScene } from "./toRenderScene";
|
|
|
|
interface ViewBox {
|
|
x: number;
|
|
y: number;
|
|
w: number;
|
|
h: number;
|
|
}
|
|
|
|
/**
|
|
* Kompatibilitäts-Shim: wgpu 22 sendet in `requestDevice` noch das Limit
|
|
* `maxInterStageShaderComponents`, das AUS DER WebGPU-Spec ENTFERNT wurde. Neuere
|
|
* Chromium-Builds lehnen ein nicht-`undefined`-Wert dafür mit `OperationError`
|
|
* ab (→ Device-Erzeugung schlägt fehl). Wir entfernen den Schlüssel EINMAL global
|
|
* aus dem Descriptor, bevor wgpu ihn stellt. Entfällt, sobald render2d auf ein
|
|
* wgpu (+ glyphon) hochgezogen wird, das dieses Limit nicht mehr mitschickt.
|
|
*/
|
|
let requestDevicePatched = false;
|
|
function patchRequestDeviceLimits(): void {
|
|
if (requestDevicePatched) return;
|
|
const AdapterCtor = (globalThis as any).GPUAdapter;
|
|
if (!AdapterCtor?.prototype?.requestDevice) return;
|
|
const orig = AdapterCtor.prototype.requestDevice;
|
|
AdapterCtor.prototype.requestDevice = function (
|
|
this: unknown,
|
|
desc?: any,
|
|
) {
|
|
if (desc?.requiredLimits && "maxInterStageShaderComponents" in desc.requiredLimits) {
|
|
const requiredLimits = { ...desc.requiredLimits };
|
|
delete requiredLimits.maxInterStageShaderComponents;
|
|
desc = { ...desc, requiredLimits };
|
|
}
|
|
return orig.call(this, desc);
|
|
};
|
|
requestDevicePatched = true;
|
|
}
|
|
|
|
/** Minimal-Typ des WASM-Exports (das echte .d.ts entsteht erst mit `build:engine`). */
|
|
interface WebPlanRendererLike {
|
|
set_scene(json: string): void;
|
|
set_view_box(x: number, y: number, w: number, h: number): void;
|
|
set_paper_scale(n: number): void;
|
|
resize(w: number, h: number): void;
|
|
render(): void;
|
|
}
|
|
|
|
/**
|
|
* WASM-Modul EINMAL laden + initialisieren (memoisiert). Wichtig: `mod.default()`
|
|
* darf nur einmal laufen — ein zweiter Init (z.B. durch React-StrictMode-Doppel-
|
|
* mount) verwirft die Closures des ersten und wirft „closure invoked recursively
|
|
* or after being dropped".
|
|
*/
|
|
let enginePromise: Promise<any> | null = null;
|
|
function loadEngine(): Promise<any> {
|
|
if (!enginePromise) {
|
|
enginePromise = (async () => {
|
|
const mod: any = await import("../engine/pkg/render2d.js");
|
|
await mod.default(); // lädt render2d_bg.wasm
|
|
return mod;
|
|
})();
|
|
}
|
|
return enginePromise;
|
|
}
|
|
|
|
/**
|
|
* Renderer je Canvas EINMAL erzeugen (memoisiert). Ein Canvas kann nur EINEN
|
|
* WebGPU-Kontext tragen; ein zweiter `WebPlanRenderer.new` auf demselben Canvas
|
|
* (StrictMode-Remount) würde kollidieren. Die Promise wird gecacht, damit der
|
|
* Doppelmount denselben Renderer teilt.
|
|
*/
|
|
const rendererCache = new WeakMap<HTMLCanvasElement, Promise<WebPlanRendererLike>>();
|
|
function getRenderer(canvas: HTMLCanvasElement): Promise<WebPlanRendererLike> {
|
|
let p = rendererCache.get(canvas);
|
|
if (!p) {
|
|
p = (async () => {
|
|
patchRequestDeviceLimits(); // wgpu-22-Limit-Shim (siehe oben)
|
|
const mod = await loadEngine();
|
|
// Canvas VOR dem Surface-Erzeugen auf die Zielpixel (DPR) bringen.
|
|
const rect = canvas.getBoundingClientRect();
|
|
const dpr = window.devicePixelRatio || 1;
|
|
canvas.width = Math.max(1, Math.round(rect.width * dpr));
|
|
canvas.height = Math.max(1, Math.round(rect.height * dpr));
|
|
return (await mod.WebPlanRenderer.new(canvas, undefined)) as WebPlanRendererLike;
|
|
})();
|
|
rendererCache.set(canvas, p);
|
|
// Bei Fehlschlag den Cache leeren, damit ein späterer Versuch neu startet.
|
|
p.catch(() => rendererCache.delete(canvas));
|
|
}
|
|
return p;
|
|
}
|
|
|
|
export function useWasmPlanRenderer(
|
|
canvasRef: React.RefObject<HTMLCanvasElement | null>,
|
|
enabled: boolean,
|
|
) {
|
|
const rendererRef = useRef<WebPlanRendererLike | null>(null);
|
|
const [ready, setReady] = useState(false);
|
|
|
|
// WASM-Renderer an das Canvas binden (memoisiert, StrictMode-fest).
|
|
useEffect(() => {
|
|
if (!enabled || !canvasRef.current) {
|
|
setReady(false);
|
|
return;
|
|
}
|
|
let disposed = false;
|
|
const canvas = canvasRef.current;
|
|
void (async () => {
|
|
try {
|
|
const renderer = await getRenderer(canvas);
|
|
if (disposed) return;
|
|
rendererRef.current = renderer;
|
|
setReady(true);
|
|
} catch (e) {
|
|
console.warn("render2d WASM/WebGPU-Init fehlgeschlagen → Fallback", e);
|
|
rendererRef.current = null;
|
|
setReady(false);
|
|
}
|
|
})();
|
|
return () => {
|
|
disposed = true;
|
|
setReady(false);
|
|
};
|
|
}, [enabled, canvasRef]);
|
|
|
|
/** Szene beim Plan-Wechsel neu setzen (re-tesselliert in Rust, gecacht). */
|
|
const updateGeometry = useCallback((plan: Plan) => {
|
|
const r = rendererRef.current;
|
|
if (!r || !plan) return;
|
|
try {
|
|
r.set_scene(JSON.stringify(planToRenderScene(plan)));
|
|
} catch (e) {
|
|
console.warn("render2d set_scene fehlgeschlagen:", e);
|
|
}
|
|
}, []);
|
|
|
|
/** Einen Frame zeichnen (Canvas-Größe + dpr hier abgleichen). */
|
|
const render = useCallback(
|
|
(viewBox: ViewBox, paperScaleN = 100) => {
|
|
const r = rendererRef.current;
|
|
const canvas = canvasRef.current;
|
|
if (!r || !canvas) return;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const w = Math.max(1, Math.round(rect.width * dpr));
|
|
const h = Math.max(1, Math.round(rect.height * dpr));
|
|
if (canvas.width !== w || canvas.height !== h) {
|
|
canvas.width = w;
|
|
canvas.height = h;
|
|
}
|
|
try {
|
|
r.resize(w, h);
|
|
r.set_paper_scale(paperScaleN);
|
|
r.set_view_box(viewBox.x, viewBox.y, viewBox.w, viewBox.h);
|
|
r.render();
|
|
} catch (e) {
|
|
console.warn("render2d render fehlgeschlagen:", e);
|
|
}
|
|
},
|
|
[canvasRef],
|
|
);
|
|
|
|
return { render, updateGeometry, ready };
|
|
}
|