From b028fc6b31a2798c3d693c5f7a7d71c81395d82f Mon Sep 17 00:00:00 2001 From: Karim Date: Fri, 3 Jul 2026 20:56:56 +0200 Subject: [PATCH] Engine-3D: Kamera-Presets + Weiss-Modus im render3d-Viewport Der Engine-3D-Viewport (render3d/wgpu) war reines Ansehen (Orbit/Zoom). Jetzt: - Kamera-Presets als Overlay: Top/Unten/Vorne/Hinten/Links/Rechts/Iso. Richtungen exakt wie der three.js-Pfad (applyView3d); framen die aktuelle Modell-Bounding-Box (fitTargetDist) und springen auf den Preset-Winkel, danach Orbit/Pan/Zoom weiter frei. Rein JS. - Render-Modus Schattiert/Weiss: mode-Uniform in Globals (WGSL/Rust), Weiss = beleuchtetes Clay-Grau; wasm-Binding set_render_mode_white, durchgereicht via useWasm3dRenderer. - Overlay-CSS self-contained (wasm3dOverlay.css), Theme-aware. three.js unangetastet. Auswahl/Grips/Zeichnen + Wireframe/HLR bewusst noch offen (naechste Scheibe). --- src-tauri/render3d/src/gpu.rs | 9 ++ src-tauri/render3d/src/shaders.rs | 9 +- src-tauri/render3d/src/web.rs | 7 ++ src/viewport/Wasm3DViewport.tsx | 193 ++++++++++++++++++++++++++++-- src/viewport/useWasm3dRenderer.ts | 18 ++- src/viewport/wasm3dOverlay.css | 53 ++++++++ 6 files changed, 276 insertions(+), 13 deletions(-) create mode 100644 src/viewport/wasm3dOverlay.css diff --git a/src-tauri/render3d/src/gpu.rs b/src-tauri/render3d/src/gpu.rs index 9ff2d4c..5df675f 100644 --- a/src-tauri/render3d/src/gpu.rs +++ b/src-tauri/render3d/src/gpu.rs @@ -35,6 +35,8 @@ struct Globals { sky_color: [f32; 4], ground_color: [f32; 4], sun_color: [f32; 4], + /// x = Darstellungsart (0 = shaded/Material, 1 = weiss/Clay); siehe shaders.rs. + mode: [f32; 4], } impl Default for Globals { @@ -49,6 +51,7 @@ impl Default for Globals { ground_color: [0.30, 0.29, 0.27, 1.0], // Directional-Sonne: warmweiss, moderat. sun_color: [0.55, 0.53, 0.49, 1.0], + mode: [0.0, 0.0, 0.0, 0.0], } } } @@ -258,6 +261,12 @@ impl Renderer { self.globals.sun_color = [sun[0], sun[1], sun[2], 1.0]; } + /// Darstellungsart umschalten: `true` = weisses Clay-Material (Beleuchtung + /// bleibt aktiv, siehe shaders.rs), `false` = normale Bauteil-/Vertexfarbe. + pub fn set_white_mode(&mut self, white: bool) { + self.globals.mode[0] = if white { 1.0 } else { 0.0 }; + } + /// Stellt sicher, dass ein Tiefenpuffer passend zur Ziel-Groesse existiert. fn ensure_depth(&mut self, device: &wgpu::Device, w: u32, h: u32) { let ok = matches!(&self.depth, Some(d) if d.width == w && d.height == h); diff --git a/src-tauri/render3d/src/shaders.rs b/src-tauri/render3d/src/shaders.rs index bfaa912..32706d0 100644 --- a/src-tauri/render3d/src/shaders.rs +++ b/src-tauri/render3d/src/shaders.rs @@ -16,6 +16,7 @@ // sky_color : vec4 Himmels-Ambient (rgb; von oben) // ground_color : vec4 Boden-Ambient (rgb; von unten) // sun_color : vec4 Farbe/Staerke des Directional-Lights (rgb) +// mode : vec4 x = Darstellungsart (0 = shaded/Material, 1 = weiss/Clay) // // Vertex-Attribute: position (world), normal (world), color (Albedo). @@ -28,6 +29,7 @@ struct Globals { sky_color : vec4, ground_color : vec4, sun_color : vec4, + mode : vec4, }; @group(0) @binding(0) var globals : Globals; @@ -65,7 +67,12 @@ fn fs_main(in : VsOut) -> @location(0) vec4 { // Gerichteter Lambert-Anteil (Sonne); Rueckseiten (n.l < 0) tragen nichts bei. let diffuse = max(dot(n, l), 0.0) * globals.sun_color.rgb; - var shaded = in.color * (ambient + diffuse); + // Weiss-Modus (Clay-Look): Material-/Vertexfarbe durch ein einheitliches + // Hellgrau ersetzen, Beleuchtung (Ambient + Directional) bleibt aktiv — so + // liest sich die Volumetrie ohne Bauteil-Farben ab (Vectorworks „white"). + let albedo = mix(in.color, vec3(0.82, 0.82, 0.80), globals.mode.x); + + var shaded = albedo * (ambient + diffuse); // Sanfte Kantenbetonung: senkrechte Flaechen (n.y nahe 0) minimal abdunkeln, // damit sich Waende vom hellen Deckel/Boden abheben (ohne Postprocessing). diff --git a/src-tauri/render3d/src/web.rs b/src-tauri/render3d/src/web.rs index 4d9d7b7..13609ad 100644 --- a/src-tauri/render3d/src/web.rs +++ b/src-tauri/render3d/src/web.rs @@ -216,6 +216,13 @@ impl WebModelRenderer { }; } + /// Darstellungsart umschalten: `true` = weisses Clay-Material (beleuchtet, + /// ohne Bauteil-Farben — Vectorworks „white"), `false` = normale Material- + /// farbe (shaded, Default). Wirkt erst beim naechsten `render`. + pub fn set_render_mode_white(&mut self, white: bool) { + self.renderer.set_white_mode(white); + } + /// Surface an eine neue Pixelgroesse anpassen (DPR beachtet der Aufrufer). pub fn resize(&mut self, width: u32, height: u32) { let (w, h) = (width.max(1), height.max(1)); diff --git a/src/viewport/Wasm3DViewport.tsx b/src/viewport/Wasm3DViewport.tsx index 9403ce2..9c099a9 100644 --- a/src/viewport/Wasm3DViewport.tsx +++ b/src/viewport/Wasm3DViewport.tsx @@ -4,22 +4,25 @@ // Sicht bleibt der unveränderte Standardpfad. // // INTEGRATIONS-SPIKE (Umfang bewusst schmal): Massenmodell (Wände mit Öffnungs- -// Teilquadern + Deckenplatten, alle Geschosse gestapelt) + Orbit-Navigation. -// Auswahl/Raycast, Render-Modi, Kamera-Presets und Editier-Griffe der three.js- -// Sicht folgen später — bis dahin gilt: LINKS ohne Funktion, RECHTS unterdrückt -// nur das Browser-Menü. +// Teilquadern + Deckenplatten, alle Geschosse gestapelt) + Orbit-Navigation + +// Kamera-Presets (Top/Bottom/Front/Back/Left/Right/Iso) + Darstellungsart +// (shaded/white) im Overlay unten (s. VIEW_PRESETS, wasm3dOverlay.css). +// Auswahl/Raycast, Griffe/Zeichnen sowie die übrigen Darstellungsarten der +// three.js-Sicht (texturiert/Drahtgitter/Kanten, HLR) folgen später — bis +// dahin gilt: LINKS ohne Funktion, RECHTS unterdrückt nur das Browser-Menü. // // MAUS-SCHEMA wie die three.js-Sicht (docs/design/context-menu.md): // MITTE ziehen = Orbit, Shift+MITTE = Pan, RAD = Zoom. // Die Orbit-/Pan-Mathematik folgt den OrbitControls-Konventionen (Winkel je // Pixel an der Viewport-Höhe normiert), damit sich beide Pfade gleich anfühlen. -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import type { Project } from "../model/types"; import { projectToModel3d } from "../plan/toWalls3d"; import { t } from "../i18n"; import { useWasm3dRenderer } from "./useWasm3dRenderer"; import type { Camera3d } from "./useWasm3dRenderer"; +import "./wasm3dOverlay.css"; /** Vertikaler Öffnungswinkel (Radiant) — wie die three.js-PerspectiveCamera (50°). */ const FOV_Y = (50 * Math.PI) / 180; @@ -57,11 +60,15 @@ function orbitCamera(o: OrbitState): Camera3d { } /** - * Formatfüllender Start-Orbit: Bounding-Box des geflachten Modells (Wände + - * Decken, world: x = Modell-x, z = Modell-y, y = Höhe) einpassen — Blick von - * schräg oben (wie das Persp-Preset), Distanz aus Radius + Sichtwinkel. + * Formatfüllendes Ziel + Distanz: Bounding-Box des geflachten Modells (Wände + + * Decken, world: x = Modell-x, z = Modell-y, y = Höhe) einpassen. Gemeinsame + * Grundlage für den Start-Orbit UND die Kamera-Presets (beide sollen das + * Modell formatfüllend zeigen, nur der Blickwinkel unterscheidet sich). */ -function fitOrbit(project: Project): OrbitState { +function fitTargetDist(project: Project): { + target: [number, number, number]; + dist: number; +} { const model = projectToModel3d(project); let minX = Infinity; let minY = Infinity; @@ -89,7 +96,7 @@ function fitOrbit(project: Project): OrbitState { } // Leeres Modell: neutraler Standpunkt (entspricht dem three.js-Start). if (!Number.isFinite(minX)) { - return { yaw: Math.PI / 4, pitch: 0.5, dist: 14, target: [2.5, 1, 2] }; + return { dist: 14, target: [2.5, 1, 2] }; } const target: [number, number, number] = [ (minX + maxX) / 2, @@ -100,12 +107,89 @@ function fitOrbit(project: Project): OrbitState { Math.hypot(maxX - minX, maxY - minY, maxZ - minZ) / 2 || 1; // Abstand so, dass die Bounding-Kugel ins vertikale Sichtfeld passt (+ Rand). const dist = Math.max(4, (radius / Math.tan(FOV_Y / 2)) * 1.2); + return { target, dist }; +} + +/** + * Formatfüllender Start-Orbit: Blick von schräg oben (wie das three.js- + * Perspektive-Preset). + */ +function fitOrbit(project: Project): OrbitState { + const { target, dist } = fitTargetDist(project); return { yaw: Math.PI / 4, pitch: 0.5, dist, target }; } +/** + * Kanonische Blickwinkel-Presets (Vectorworks/DOSSIER-Stil, wie + * `applyView3d` in Viewport3D.tsx — hier als Kugelkoordinaten yaw/pitch statt + * eines Richtungsvektors, weil der Engine-Pfad die Kamera über den Orbit- + * Zustand treibt, s. o.). Herleitung (Y-up, world x = Plan-x, world z = + * Plan-y): + * front/back: Blick entlang ±Z (yaw = ±90°, pitch 0). + * left/right: Blick entlang ±X (yaw = 0°/180°, pitch 0). + * top/bottom: senkrecht von oben/unten; pitch ans Gimbal-Limit geklemmt + * (exakt 90° macht die Kamerabasis singulär — dieselbe Klemme wie beim + * freien Orbit, s. PITCH_LIMIT), mit demselben leichten Yaw-Versatz wie + * front/back (stabile, vorhersagbare „oben"-Achse am Bildschirm, analog + * zum three.js-Kniff `dir=(0,1,0.0001)`). + * iso: Ecklage von schräg oben (SW-Iso) — Richtung (1,1,1) normiert, exakt + * wie das three.js-Iso-Preset (Elevationswinkel arcsin(1/√3) ≈ 35.26°). + */ +const ISO_PITCH = Math.asin(1 / Math.sqrt(3)); +const VIEW_PRESETS: Record< + "top" | "bottom" | "front" | "back" | "left" | "right" | "iso", + { yaw: number; pitch: number } +> = { + top: { yaw: Math.PI / 2, pitch: PITCH_LIMIT }, + bottom: { yaw: -Math.PI / 2, pitch: -PITCH_LIMIT }, + front: { yaw: Math.PI / 2, pitch: 0 }, + back: { yaw: -Math.PI / 2, pitch: 0 }, + right: { yaw: 0, pitch: 0 }, + left: { yaw: Math.PI, pitch: 0 }, + iso: { yaw: Math.PI / 4, pitch: ISO_PITCH }, +}; +type ViewPreset = keyof typeof VIEW_PRESETS; + +/** Labels der Preset-Knöpfe: bestehende i18n-Schlüssel wiederverwendet, wo + * vorhanden (view3d.*, aus der three.js-Oberleiste); für die im engine-Pfad + * zusätzlichen Ansichten (bottom/back/left/right, dort nicht vorhanden) ein + * kurzer inline-deutscher Text — keine neuen i18n-Schlüssel (Projektregel). + * Als Funktion (nicht Modul-Konstante) aufgerufen, damit `t()` bei jedem + * Render die aktuelle Sprache liest (wie der bestehende Fallback-Text unten). */ +function viewPresetLabel(preset: ViewPreset): string { + switch (preset) { + case "top": + return t("view3d.top"); + case "bottom": + return "Unten"; + case "front": + return t("view3d.front"); + case "back": + return "Hinten"; + case "left": + return "Links"; + case "right": + return "Rechts"; + case "iso": + return t("view3d.iso"); + } +} +const VIEW_PRESET_ORDER: ViewPreset[] = [ + "top", + "bottom", + "front", + "back", + "left", + "right", + "iso", +]; + export function Wasm3DViewport({ project }: { project: Project }) { const canvasRef = useRef(null); - const { render, updateModel, ready, failed } = useWasm3dRenderer(canvasRef, true); + const { render, updateModel, setWhiteMode, ready, failed } = useWasm3dRenderer( + canvasRef, + true, + ); // Orbit-Zustand in einem Ref (kein React-State): Mausbewegungen zeichnen // direkt neu, ohne die Komponente je Frame zu re-rendern. const orbitRef = useRef(null); @@ -113,6 +197,12 @@ export function Wasm3DViewport({ project }: { project: Project }) { const projectRef = useRef(project); projectRef.current = project; + // Aktiver Blickwinkel-Preset (nur fürs Knopf-Hervorheben) + Darstellungsart + // (Overlay-UI, s. u.). Bewusst React-State — anders als der Orbit ändern + // sich beide selten (Klick, nicht Mausbewegung), ein Re-Render ist unkritisch. + const [activePreset, setActivePreset] = useState(null); + const [renderMode, setRenderMode] = useState<"shaded" | "white">("shaded"); + // Modell bei Bereitschaft + jeder Projektänderung neu einspielen. Der Orbit // wird nur beim ERSTEN Mal eingepasst — danach bleibt die Kamera beim // Editieren stehen (gleiches Verhalten wie der three.js-Kamera-Snapshot). @@ -123,6 +213,27 @@ export function Wasm3DViewport({ project }: { project: Project }) { render(orbitCamera(orbitRef.current)); }, [ready, project, updateModel, render]); + // Darstellungsart (shaded/weiss) an den Renderer weiterreichen, sobald sie + // wechselt (Overlay-Klick) oder der Renderer bereit wird — dann neu zeichnen. + useEffect(() => { + if (!ready) return; + setWhiteMode(renderMode === "white"); + if (orbitRef.current) render(orbitCamera(orbitRef.current)); + }, [ready, renderMode, setWhiteMode, render]); + + // Blickwinkel-Preset anwenden: formatfüllend auf die aktuelle Modell- + // Bounding-Box (wie fitOrbit), aber mit dem Preset-Blickwinkel statt des + // Standard-Schrägblicks. „Preset = dorthin springen, kein Lock" — danach + // bleibt Orbit/Pan/Zoom frei (gleiche Philosophie wie Viewport3D.applyView3d). + const applyPreset = (name: ViewPreset) => { + if (!ready) return; + const { yaw, pitch } = VIEW_PRESETS[name]; + const { target, dist } = fitTargetDist(projectRef.current); + orbitRef.current = { yaw, pitch, dist, target }; + setActivePreset(name); + render(orbitCamera(orbitRef.current)); + }; + // Canvas-Größe beobachten: bei Layout-Änderung mit aktueller Kamera neu // zeichnen (die Surface-Anpassung übernimmt der Hook im render()). useEffect(() => { @@ -151,6 +262,10 @@ export function Wasm3DViewport({ project }: { project: Project }) { e.preventDefault(); drag = { mode: e.shiftKey ? "pan" : "orbit", x: e.clientX, y: e.clientY }; canvas.setPointerCapture(e.pointerId); + // Freies Navigieren verlässt den Preset-Blickwinkel — Knopf-Hervorhebung + // löschen (EINMAL je Geste, nicht je Pointermove: kein State-Update im + // Bewegungs-Hotpath, s. Kommentar oben zum Orbit-Ref). + setActivePreset((prev) => (prev === null ? prev : null)); }; const onPointerMove = (e: PointerEvent) => { @@ -206,6 +321,7 @@ export function Wasm3DViewport({ project }: { project: Project }) { e.preventDefault(); // Exponentielles Dolly (rad-deltaY ≈ ±100 je Rastung → ~14 % je Schritt). o.dist = Math.max(0.2, Math.min(2000, o.dist * Math.exp(e.deltaY * 0.0013))); + setActivePreset((prev) => (prev === null ? prev : null)); redraw(); }; @@ -233,6 +349,61 @@ export function Wasm3DViewport({ project }: { project: Project }) { {/* Canvas-Anzeigegröße kommt aus `.viewport canvas` (styles.css); die Renderauflösung (DPR) gleicht der Hook je Frame ab. */} + {!failed && ( +
+ {/* Kamera-Presets (Vectorworks/DOSSIER-Stil, s. VIEW_PRESETS oben) — + „dorthin springen", kein Lock: danach bleibt Orbit/Pan/Zoom frei. */} +
+ {VIEW_PRESET_ORDER.map((key) => ( + + ))} +
+ {/* Darstellungsart: shaded (Bauteil-/Materialfarbe, Default) vs. white + (einheitliches beleuchtetes Hellgrau, Clay-Look) — Rust-Uniform + `mode.x` (shaders.rs). Texturiert/Drahtgitter/Kanten der three.js- + Sicht sind hier (noch) nicht gebaut, s. Datei-Kommentar. */} +
+ + +
+
+ )} {failed && (
{t("viewport3d.engineUnavailable")} diff --git a/src/viewport/useWasm3dRenderer.ts b/src/viewport/useWasm3dRenderer.ts index 208c285..a891df1 100644 --- a/src/viewport/useWasm3dRenderer.ts +++ b/src/viewport/useWasm3dRenderer.ts @@ -37,6 +37,7 @@ export interface Camera3d { /** Minimal-Typ des WASM-Exports (das echte .d.ts entsteht erst mit `build:engine3d`). */ interface WebModelRendererLike { set_model(json: string): void; + set_render_mode_white(white: boolean): void; set_camera( eyeX: number, eyeY: number, @@ -128,6 +129,21 @@ export function useWasm3dRenderer( } }, []); + /** + * Darstellungsart umschalten: `true` = weisses Clay-Material (beleuchtet, + * ohne Bauteil-Farben), `false` = normale Material-/Vertexfarbe (shaded, + * Default). Wirkt erst beim nächsten `render`. + */ + const setWhiteMode = useCallback((white: boolean) => { + const r = rendererRef.current; + if (!r) return; + try { + r.set_render_mode_white(white); + } catch (e) { + console.warn("render3d set_render_mode_white fehlgeschlagen:", e); + } + }, []); + /** Einen Frame mit der gegebenen Kamera zeichnen (Canvas-Größe + DPR abgleichen). */ const render = useCallback( (cam: Camera3d) => { @@ -166,5 +182,5 @@ export function useWasm3dRenderer( [canvasRef], ); - return { render, updateModel, ready, failed }; + return { render, updateModel, setWhiteMode, ready, failed }; } diff --git a/src/viewport/wasm3dOverlay.css b/src/viewport/wasm3dOverlay.css new file mode 100644 index 0000000..fb07f38 --- /dev/null +++ b/src/viewport/wasm3dOverlay.css @@ -0,0 +1,53 @@ +/* Steuer-Overlay des engine-3D-Viewports (Wasm3DViewport): Kamera-Presets + + * Darstellungsart. Bewusst eine EIGENE Datei (nicht styles.css) — dieser + * Viewport-Pfad wird unabhängig von der three.js-Sicht weiterentwickelt. + * Nutzt dieselben CSS-Variablen wie die übrige App (in :root definiert), damit + * sich der Overlay optisch nahtlos einfügt (auch im Dunkelmodus). */ + +.wasm3d-overlay { + position: absolute; + top: 10px; + left: 10px; + z-index: 3; + display: flex; + flex-direction: column; + gap: 6px; + pointer-events: none; +} + +.wasm3d-overlay-group { + display: flex; + flex-wrap: wrap; + gap: 3px; + padding: 4px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); + pointer-events: auto; +} + +.wasm3d-overlay-btn { + min-width: 34px; + padding: 4px 8px; + font-size: 11px; + font-weight: 600; + line-height: 1.4; + color: var(--ink, #ddd); + background: transparent; + border: 1px solid var(--border); + border-radius: 5px; + cursor: pointer; +} + +.wasm3d-overlay-btn:hover { + background: var(--accent-dim); + border-color: var(--accent-border); + color: var(--accent); +} + +.wasm3d-overlay-btn.active { + background: var(--accent-dim); + border-color: var(--accent); + color: var(--accent); +}