Files
DOSSIER-STANDALONE/src/viewport/useWasm3dRenderer.ts
T
karim 62c8a39a8e Fenster/Tür 3D: tiefer, Detailgrad im 3D, kein Fenster→Tür-Umschalten
Nutzer-Feedback:
- Fenster wirkten im 3D flach: die Rahmen-Einbautiefe wurde fälschlich aus
  frameThickness (Profil-Breite, ~6 cm) statt aus der Wanddicke bestimmt.
  Jetzt füllt der Rahmen die VOLLE Wanddicke (tiefe Laibung), die Scheibe
  ist eine dünne Tafel mittig darin → plastische Fenster mit sichtbarer
  Laibung. frameDepth übersteuert weiterhin.
- Detailgrad grob/mittel/fein wirkt jetzt auch im 3D: grob = nur Loch +
  Scheibe, mittel = Rahmen (+Oberlicht-Kämpfer), fein = zusätzlich
  Flügel-Mittelpfosten/Kämpfer-Sprossen. Durchgereicht App → Viewport3D →
  Wasm3DViewport → useWasm3dRenderer → projectToModel3d({detail}).
- Fenster↔Tür-Umschalter im Objekt-Info entfernt (nur noch Anzeige): die
  Gattung wird beim Platzieren festgelegt, nicht nachträglich getauscht.

+1 Test (Detailgrad-Gating grob/mittel/fein). tsc + vitest grün.
2026-07-09 20:59:21 +02:00

363 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* React-Hook für den nativen render3d-Renderer als WASM/WebGPU-Viewport.
*
* Gegenstück zu {@link useWasmPlanRenderer} (2D): lädt das WASM-Modul aus
* src/engine/pkg3d (`npm run build:engine3d`), bindet den Renderer an ein
* Canvas und reicht Modell + Kamera durch. Die eigentliche Zeichenlogik lebt
* in Rust (src-tauri/render3d, Feature `web`) — dieselbe Extrusion/Pipeline
* wie das native wgpu-Fenster.
*
* Modell-Sync: `updateModel(project)` flacht das Projekt über
* {@link projectToModel3d} (Wände mit Öffnungs-Teilquadern + Deckenplatten)
* und schiebt es als JSON an `set_model` — exakt dasselbe Format wie der
* native Tauri-Push (nativeSync.ts).
*
* Fällt bei fehlendem WebGPU oder Init-Fehler auf `ready:false` + `failed:true`
* zurück; der Aufrufer (Wasm3DViewport) zeigt dann den Hinweis-Fallback.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import type { Project } from "../model/types";
import { projectToModel3d } from "../plan/toWalls3d";
import type { DetailLevel } from "../ui/TopBar";
import { loadEngine3d } from "../engine/engine3d";
import { decodeMaterialTextures } from "./materialTextures";
/** Fertige Kamera für einen Frame (world-Meter, Y-up — wie render3d::types::Camera). */
export interface Camera3d {
eye: [number, number, number];
target: [number, number, number];
up: [number, number, number];
/** true = perspektivisch (fovY), false = orthografisch (orthoHalfHeight). */
perspective: boolean;
/** Vertikaler Öffnungswinkel in RADIANT (nur perspektivisch). */
fovY: number;
/** Halbe Sichthöhe in Metern (nur orthografisch). */
orthoHalfHeight: number;
}
/** Minimal-Typ des WASM-Exports (das echte .d.ts entsteht erst mit `build:engine3d`). */
interface WebModelRendererLike {
set_model(json: string): void;
/** Darstellungsart setzen — `style` ∈ "shaded"|"white"|"wireframe"|"hidden"|
* "textured" (deckt die volle `RenderMode`-Union der Oberleiste ab;
* "textured" faellt engine-seitig vorerst auf "shaded" zurueck). */
set_render_style(style: string): void;
set_view_preset(preset: string): void;
/** Boden-Referenzraster in der Welt-XZ-Ebene ein-/ausblenden (Rust-seitig,
* `web::set_ground_grid`). `visible=false` zeichnet kein Raster; sonst ein
* Quadrat der Kantenlaenge `extent_m` (zentriert um den Ursprung, XZ) auf
* Hoehe `elevation_m` (world-Y) mit Linienabstand `spacing_m`. Wirkt erst
* beim naechsten `render`. */
set_ground_grid(
visible: boolean,
elevation_m: number,
extent_m: number,
spacing_m: number,
): void;
/** Live-Schnitt ein-/ausschalten (Rust-seitig, `web::set_section_plane`).
* `active=false` hebt den Schnitt auf. `p*` = Punkt auf der Ebene, `n*` =
* Ebenennormale = Blickrichtung (world-Meter, Y-up; nur vertikale Ebenen).
* Kappt das Modell per Shader-`discard` und fuellt die Schnittflaeche mit der
* Diagonalschraffur (Caps). Wirkt erst beim naechsten `render`. */
set_section_plane(
active: boolean,
px: number,
py: number,
pz: number,
nx: number,
ny: number,
nz: number,
): void;
/**
* Auswahl-Hervorhebung setzen: ein LineList-Vertex-Array (interleaved
* `[px,py,pz, r,g,b, ...]`, world-Meter, je 2 Vertices = 1 Linie), das die
* Engine OHNE Tiefentest zeichnet (immer sichtbar obenauf) — der Umriss des
* aktuell gewählten Bauteils. Leeres Array löscht das Highlight. Wirkt erst
* beim nächsten `render`. */
set_highlight_lines(vertices: Float32Array): void;
/** Material-Farbkarten (Stil „textured") als Textur-Array hochladen
* (`web::set_material_textures`). `rgba` = gepackte RGBA8-Bytes von
* `layerCount` 256×256-Ebenen; Ebene 0 bleibt intern das Fallback-Schachbrett,
* die Karten belegen Ebenen 1..N (siehe `WallInput.materialIndex`). Optional —
* ältere WASM-Builds ohne diesen Export lassen den Aufrufer still zurückfallen. */
set_material_textures?(rgba: Uint8Array, layerCount: number): void;
set_camera(
eyeX: number,
eyeY: number,
eyeZ: number,
targetX: number,
targetY: number,
targetZ: number,
upX: number,
upY: number,
upZ: number,
perspective: boolean,
fovY: number,
orthoHalfHeight: number,
): void;
resize(w: number, h: number): void;
render(): void;
}
/**
* Renderer je Canvas EINMAL erzeugen (memoisiert). Ein Canvas kann nur EINEN
* WebGPU-Kontext tragen; ein zweiter `WebModelRenderer.new` auf demselben
* Canvas (StrictMode-Remount) würde kollidieren — die gecachte Promise lässt
* den Doppelmount denselben Renderer teilen.
*/
const rendererCache = new WeakMap<HTMLCanvasElement, Promise<WebModelRendererLike>>();
function getRenderer(canvas: HTMLCanvasElement): Promise<WebModelRendererLike> {
let p = rendererCache.get(canvas);
if (!p) {
p = (async () => {
const mod = await loadEngine3d();
// 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.WebModelRenderer.new(canvas)) as WebModelRendererLike;
})();
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 useWasm3dRenderer(
canvasRef: React.RefObject<HTMLCanvasElement | null>,
enabled: boolean,
) {
const rendererRef = useRef<WebModelRendererLike | null>(null);
const [ready, setReady] = useState(false);
const [failed, setFailed] = 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);
setFailed(false);
} catch (e) {
console.warn("render3d WASM/WebGPU-Init fehlgeschlagen", e);
rendererRef.current = null;
setReady(false);
if (!disposed) setFailed(true);
}
})();
return () => {
disposed = true;
setReady(false);
};
}, [enabled, canvasRef]);
// Signatur der zuletzt hochgeladenen Material-Textur-Menge — verhindert ein
// erneutes (teures) Dekodieren/Hochladen, wenn sich die Materialien nicht
// geändert haben (updateModel läuft bei JEDER Projektänderung).
const materialSigRef = useRef<string | null>(null);
/** Modell bei Projektänderung neu setzen (re-extrudiert in Rust). `detail`
* steuert den Öffnungs-Detailgrad (grob/mittel/fein) im 3D wie im Grundriss. */
const updateModel = useCallback((project: Project, detail?: DetailLevel) => {
const r = rendererRef.current;
if (!r || !project) return;
try {
r.set_model(JSON.stringify(projectToModel3d(project, { detail })));
} catch (e) {
console.warn("render3d set_model fehlgeschlagen:", e);
}
// Material-Farbkarten (Stil „textured") ASYNCHRON dekodieren + hochladen.
// Fire-and-forget: bis die Texturen ankommen, zeigt render3d für die
// betroffenen Bänder das Fallback-Schachbrett (graceful). Nur neu laden,
// wenn sich die Material-Menge geändert hat.
if (typeof r.set_material_textures !== "function") return;
void (async () => {
try {
const decoded = await decodeMaterialTextures(project);
const sig = decoded ? decoded.signature : "";
if (sig === materialSigRef.current) return; // unverändert
materialSigRef.current = sig;
// Renderer könnte zwischenzeitlich getauscht worden sein.
const cur = rendererRef.current;
if (!cur || typeof cur.set_material_textures !== "function") return;
if (decoded) cur.set_material_textures(decoded.data, decoded.count);
else cur.set_material_textures(new Uint8Array(0), 0); // zurück auf Schachbrett
} catch (e) {
console.warn("render3d set_material_textures fehlgeschlagen:", e);
}
})();
}, []);
/**
* Darstellungsart umschalten — reicht die volle `RenderMode`-Union der
* Oberleiste ("shaded"/"white"/"textured"/"wireframe"/"hidden") an Rust
* durch (`web::set_render_style`); die Engine entscheidet je Stil, wie
* gezeichnet wird ("textured" faellt dort vorerst auf "shaded" zurueck).
* Wirkt erst beim nächsten `render`.
*/
const setRenderStyle = useCallback((style: string) => {
const r = rendererRef.current;
if (!r) return;
try {
r.set_render_style(style);
} catch (e) {
console.warn("render3d set_render_style fehlgeschlagen:", e);
}
}, []);
/**
* Boden-Referenzraster ein-/ausblenden (Overlay-Umschalter im Viewport,
* s. Wasm3DViewport.tsx). `elevationM` = OKFF des aktiven Geschosses
* (`baseElevation`), `extentM`/`spacingM` = Kantenlaenge/Linienabstand des
* Quadrats in Metern. Wirkt erst beim naechsten `render`.
*/
const setGroundGrid = useCallback(
(visible: boolean, elevationM: number, extentM: number, spacingM: number) => {
const r = rendererRef.current;
if (!r) return;
try {
r.set_ground_grid(visible, elevationM, extentM, spacingM);
} catch (e) {
console.warn("render3d set_ground_grid fehlgeschlagen:", e);
}
},
[],
);
/**
* Live-Schnitt setzen/aufheben (Overlay-Umschalter im Viewport, s.
* Wasm3DViewport.tsx). `active=false` zeigt das Modell wieder komplett.
* `point` = Punkt auf der Schnittebene, `normal` = Ebenennormale =
* Blickrichtung (world-Meter, Y-up; nur vertikale Ebenen sinnvoll). Wirkt
* erst beim nächsten `render`.
*/
const setSectionPlane = useCallback(
(
active: boolean,
point: [number, number, number],
normal: [number, number, number],
) => {
const r = rendererRef.current;
if (!r) return;
try {
r.set_section_plane(
active,
point[0],
point[1],
point[2],
normal[0],
normal[1],
normal[2],
);
} catch (e) {
console.warn("render3d set_section_plane fehlgeschlagen:", e);
}
},
[],
);
/**
* Auswahl-Hervorhebung setzen (Umriss-Linien des gewählten Bauteils, immer
* obenauf) — reicht das fertige LineList-Vertex-Array 1:1 an
* `set_highlight_lines` durch (s. WebModelRendererLike). Der Aufrufer
* (Wasm3DViewport) übergibt ein leeres Float32Array, um das Highlight zu
* löschen (leere Auswahl). Wirkt erst beim nächsten `render`.
*/
const setHighlightLines = useCallback((vertices: Float32Array) => {
const r = rendererRef.current;
if (!r) return;
try {
r.set_highlight_lines(vertices);
} catch (e) {
console.warn("render3d set_highlight_lines fehlgeschlagen:", e);
}
}, []);
/**
* Kamera-Praeset anwenden (`front`/`top`/`side`/`iso`/`perspective`, wie
* `View3d` in TopBar.tsx). Delegiert die formatfuellende Ziel-/Distanz-
* Berechnung + Projektionsart (ortho fuer front/top/side) an Rust
* (`web::set_view_preset` -> `math::preset_camera`) — dieselbe Logik wie das
* native Fenster, statt einer zweiten Praeset-Tabelle in TS. Wirkt erst beim
* naechsten `render`.
*/
const applyViewPreset = useCallback((preset: string) => {
const r = rendererRef.current;
if (!r) return;
try {
r.set_view_preset(preset);
} catch (e) {
console.warn("render3d set_view_preset fehlgeschlagen:", e);
}
}, []);
/**
* Einen Frame zeichnen (Canvas-Größe + DPR abgleichen). `cam` = null lässt
* die zuletzt in Rust gesetzte Kamera unverändert — Fall für `render(null)`
* direkt nach `applyViewPreset` (die Kamera kommt dann aus `preset_camera`,
* ein `set_camera` hier würde das freie Orbit-Ergebnis darüberschreiben und
* die orthografische Projektion der Presets zunichtemachen).
*/
const render = useCallback(
(cam: Camera3d | null) => {
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);
if (cam) {
r.set_camera(
cam.eye[0],
cam.eye[1],
cam.eye[2],
cam.target[0],
cam.target[1],
cam.target[2],
cam.up[0],
cam.up[1],
cam.up[2],
cam.perspective,
cam.fovY,
cam.orthoHalfHeight,
);
}
r.render();
} catch (e) {
console.warn("render3d render fehlgeschlagen:", e);
}
},
[canvasRef],
);
return {
render,
updateModel,
setRenderStyle,
applyViewPreset,
setGroundGrid,
setSectionPlane,
setHighlightLines,
ready,
failed,
};
}