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).
This commit is contained in:
@@ -35,6 +35,8 @@ struct Globals {
|
|||||||
sky_color: [f32; 4],
|
sky_color: [f32; 4],
|
||||||
ground_color: [f32; 4],
|
ground_color: [f32; 4],
|
||||||
sun_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 {
|
impl Default for Globals {
|
||||||
@@ -49,6 +51,7 @@ impl Default for Globals {
|
|||||||
ground_color: [0.30, 0.29, 0.27, 1.0],
|
ground_color: [0.30, 0.29, 0.27, 1.0],
|
||||||
// Directional-Sonne: warmweiss, moderat.
|
// Directional-Sonne: warmweiss, moderat.
|
||||||
sun_color: [0.55, 0.53, 0.49, 1.0],
|
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];
|
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.
|
/// Stellt sicher, dass ein Tiefenpuffer passend zur Ziel-Groesse existiert.
|
||||||
fn ensure_depth(&mut self, device: &wgpu::Device, w: u32, h: u32) {
|
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);
|
let ok = matches!(&self.depth, Some(d) if d.width == w && d.height == h);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
// sky_color : vec4<f32> Himmels-Ambient (rgb; von oben)
|
// sky_color : vec4<f32> Himmels-Ambient (rgb; von oben)
|
||||||
// ground_color : vec4<f32> Boden-Ambient (rgb; von unten)
|
// ground_color : vec4<f32> Boden-Ambient (rgb; von unten)
|
||||||
// sun_color : vec4<f32> Farbe/Staerke des Directional-Lights (rgb)
|
// sun_color : vec4<f32> Farbe/Staerke des Directional-Lights (rgb)
|
||||||
|
// mode : vec4<f32> x = Darstellungsart (0 = shaded/Material, 1 = weiss/Clay)
|
||||||
//
|
//
|
||||||
// Vertex-Attribute: position (world), normal (world), color (Albedo).
|
// Vertex-Attribute: position (world), normal (world), color (Albedo).
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ struct Globals {
|
|||||||
sky_color : vec4<f32>,
|
sky_color : vec4<f32>,
|
||||||
ground_color : vec4<f32>,
|
ground_color : vec4<f32>,
|
||||||
sun_color : vec4<f32>,
|
sun_color : vec4<f32>,
|
||||||
|
mode : vec4<f32>,
|
||||||
};
|
};
|
||||||
@group(0) @binding(0) var<uniform> globals : Globals;
|
@group(0) @binding(0) var<uniform> globals : Globals;
|
||||||
|
|
||||||
@@ -65,7 +67,12 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
|
|||||||
// Gerichteter Lambert-Anteil (Sonne); Rueckseiten (n.l < 0) tragen nichts bei.
|
// Gerichteter Lambert-Anteil (Sonne); Rueckseiten (n.l < 0) tragen nichts bei.
|
||||||
let diffuse = max(dot(n, l), 0.0) * globals.sun_color.rgb;
|
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<f32>(0.82, 0.82, 0.80), globals.mode.x);
|
||||||
|
|
||||||
|
var shaded = albedo * (ambient + diffuse);
|
||||||
|
|
||||||
// Sanfte Kantenbetonung: senkrechte Flaechen (n.y nahe 0) minimal abdunkeln,
|
// Sanfte Kantenbetonung: senkrechte Flaechen (n.y nahe 0) minimal abdunkeln,
|
||||||
// damit sich Waende vom hellen Deckel/Boden abheben (ohne Postprocessing).
|
// damit sich Waende vom hellen Deckel/Boden abheben (ohne Postprocessing).
|
||||||
|
|||||||
@@ -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).
|
/// Surface an eine neue Pixelgroesse anpassen (DPR beachtet der Aufrufer).
|
||||||
pub fn resize(&mut self, width: u32, height: u32) {
|
pub fn resize(&mut self, width: u32, height: u32) {
|
||||||
let (w, h) = (width.max(1), height.max(1));
|
let (w, h) = (width.max(1), height.max(1));
|
||||||
|
|||||||
+182
-11
@@ -4,22 +4,25 @@
|
|||||||
// Sicht bleibt der unveränderte Standardpfad.
|
// Sicht bleibt der unveränderte Standardpfad.
|
||||||
//
|
//
|
||||||
// INTEGRATIONS-SPIKE (Umfang bewusst schmal): Massenmodell (Wände mit Öffnungs-
|
// INTEGRATIONS-SPIKE (Umfang bewusst schmal): Massenmodell (Wände mit Öffnungs-
|
||||||
// Teilquadern + Deckenplatten, alle Geschosse gestapelt) + Orbit-Navigation.
|
// Teilquadern + Deckenplatten, alle Geschosse gestapelt) + Orbit-Navigation +
|
||||||
// Auswahl/Raycast, Render-Modi, Kamera-Presets und Editier-Griffe der three.js-
|
// Kamera-Presets (Top/Bottom/Front/Back/Left/Right/Iso) + Darstellungsart
|
||||||
// Sicht folgen später — bis dahin gilt: LINKS ohne Funktion, RECHTS unterdrückt
|
// (shaded/white) im Overlay unten (s. VIEW_PRESETS, wasm3dOverlay.css).
|
||||||
// nur das Browser-Menü.
|
// 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):
|
// MAUS-SCHEMA wie die three.js-Sicht (docs/design/context-menu.md):
|
||||||
// MITTE ziehen = Orbit, Shift+MITTE = Pan, RAD = Zoom.
|
// MITTE ziehen = Orbit, Shift+MITTE = Pan, RAD = Zoom.
|
||||||
// Die Orbit-/Pan-Mathematik folgt den OrbitControls-Konventionen (Winkel je
|
// Die Orbit-/Pan-Mathematik folgt den OrbitControls-Konventionen (Winkel je
|
||||||
// Pixel an der Viewport-Höhe normiert), damit sich beide Pfade gleich anfühlen.
|
// 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 type { Project } from "../model/types";
|
||||||
import { projectToModel3d } from "../plan/toWalls3d";
|
import { projectToModel3d } from "../plan/toWalls3d";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useWasm3dRenderer } from "./useWasm3dRenderer";
|
import { useWasm3dRenderer } from "./useWasm3dRenderer";
|
||||||
import type { Camera3d } from "./useWasm3dRenderer";
|
import type { Camera3d } from "./useWasm3dRenderer";
|
||||||
|
import "./wasm3dOverlay.css";
|
||||||
|
|
||||||
/** Vertikaler Öffnungswinkel (Radiant) — wie die three.js-PerspectiveCamera (50°). */
|
/** Vertikaler Öffnungswinkel (Radiant) — wie die three.js-PerspectiveCamera (50°). */
|
||||||
const FOV_Y = (50 * Math.PI) / 180;
|
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 +
|
* Formatfüllendes Ziel + Distanz: Bounding-Box des geflachten Modells (Wände +
|
||||||
* Decken, world: x = Modell-x, z = Modell-y, y = Höhe) einpassen — Blick von
|
* Decken, world: x = Modell-x, z = Modell-y, y = Höhe) einpassen. Gemeinsame
|
||||||
* schräg oben (wie das Persp-Preset), Distanz aus Radius + Sichtwinkel.
|
* 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);
|
const model = projectToModel3d(project);
|
||||||
let minX = Infinity;
|
let minX = Infinity;
|
||||||
let minY = Infinity;
|
let minY = Infinity;
|
||||||
@@ -89,7 +96,7 @@ function fitOrbit(project: Project): OrbitState {
|
|||||||
}
|
}
|
||||||
// Leeres Modell: neutraler Standpunkt (entspricht dem three.js-Start).
|
// Leeres Modell: neutraler Standpunkt (entspricht dem three.js-Start).
|
||||||
if (!Number.isFinite(minX)) {
|
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] = [
|
const target: [number, number, number] = [
|
||||||
(minX + maxX) / 2,
|
(minX + maxX) / 2,
|
||||||
@@ -100,12 +107,89 @@ function fitOrbit(project: Project): OrbitState {
|
|||||||
Math.hypot(maxX - minX, maxY - minY, maxZ - minZ) / 2 || 1;
|
Math.hypot(maxX - minX, maxY - minY, maxZ - minZ) / 2 || 1;
|
||||||
// Abstand so, dass die Bounding-Kugel ins vertikale Sichtfeld passt (+ Rand).
|
// Abstand so, dass die Bounding-Kugel ins vertikale Sichtfeld passt (+ Rand).
|
||||||
const dist = Math.max(4, (radius / Math.tan(FOV_Y / 2)) * 1.2);
|
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 };
|
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 }) {
|
export function Wasm3DViewport({ project }: { project: Project }) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(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
|
// Orbit-Zustand in einem Ref (kein React-State): Mausbewegungen zeichnen
|
||||||
// direkt neu, ohne die Komponente je Frame zu re-rendern.
|
// direkt neu, ohne die Komponente je Frame zu re-rendern.
|
||||||
const orbitRef = useRef<OrbitState | null>(null);
|
const orbitRef = useRef<OrbitState | null>(null);
|
||||||
@@ -113,6 +197,12 @@ export function Wasm3DViewport({ project }: { project: Project }) {
|
|||||||
const projectRef = useRef(project);
|
const projectRef = useRef(project);
|
||||||
projectRef.current = 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<ViewPreset | null>(null);
|
||||||
|
const [renderMode, setRenderMode] = useState<"shaded" | "white">("shaded");
|
||||||
|
|
||||||
// Modell bei Bereitschaft + jeder Projektänderung neu einspielen. Der Orbit
|
// Modell bei Bereitschaft + jeder Projektänderung neu einspielen. Der Orbit
|
||||||
// wird nur beim ERSTEN Mal eingepasst — danach bleibt die Kamera beim
|
// wird nur beim ERSTEN Mal eingepasst — danach bleibt die Kamera beim
|
||||||
// Editieren stehen (gleiches Verhalten wie der three.js-Kamera-Snapshot).
|
// Editieren stehen (gleiches Verhalten wie der three.js-Kamera-Snapshot).
|
||||||
@@ -123,6 +213,27 @@ export function Wasm3DViewport({ project }: { project: Project }) {
|
|||||||
render(orbitCamera(orbitRef.current));
|
render(orbitCamera(orbitRef.current));
|
||||||
}, [ready, project, updateModel, render]);
|
}, [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
|
// Canvas-Größe beobachten: bei Layout-Änderung mit aktueller Kamera neu
|
||||||
// zeichnen (die Surface-Anpassung übernimmt der Hook im render()).
|
// zeichnen (die Surface-Anpassung übernimmt der Hook im render()).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -151,6 +262,10 @@ export function Wasm3DViewport({ project }: { project: Project }) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
drag = { mode: e.shiftKey ? "pan" : "orbit", x: e.clientX, y: e.clientY };
|
drag = { mode: e.shiftKey ? "pan" : "orbit", x: e.clientX, y: e.clientY };
|
||||||
canvas.setPointerCapture(e.pointerId);
|
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) => {
|
const onPointerMove = (e: PointerEvent) => {
|
||||||
@@ -206,6 +321,7 @@ export function Wasm3DViewport({ project }: { project: Project }) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// Exponentielles Dolly (rad-deltaY ≈ ±100 je Rastung → ~14 % je Schritt).
|
// 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)));
|
o.dist = Math.max(0.2, Math.min(2000, o.dist * Math.exp(e.deltaY * 0.0013)));
|
||||||
|
setActivePreset((prev) => (prev === null ? prev : null));
|
||||||
redraw();
|
redraw();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -233,6 +349,61 @@ export function Wasm3DViewport({ project }: { project: Project }) {
|
|||||||
{/* Canvas-Anzeigegröße kommt aus `.viewport canvas` (styles.css); die
|
{/* Canvas-Anzeigegröße kommt aus `.viewport canvas` (styles.css); die
|
||||||
Renderauflösung (DPR) gleicht der Hook je Frame ab. */}
|
Renderauflösung (DPR) gleicht der Hook je Frame ab. */}
|
||||||
<canvas ref={canvasRef} data-engine="render3d" />
|
<canvas ref={canvasRef} data-engine="render3d" />
|
||||||
|
{!failed && (
|
||||||
|
<div className="wasm3d-overlay">
|
||||||
|
{/* Kamera-Presets (Vectorworks/DOSSIER-Stil, s. VIEW_PRESETS oben) —
|
||||||
|
„dorthin springen", kein Lock: danach bleibt Orbit/Pan/Zoom frei. */}
|
||||||
|
<div
|
||||||
|
className="wasm3d-overlay-group"
|
||||||
|
role="group"
|
||||||
|
aria-label={t("view3d.group")}
|
||||||
|
>
|
||||||
|
{VIEW_PRESET_ORDER.map((key) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
className={`wasm3d-overlay-btn${activePreset === key ? " active" : ""}`}
|
||||||
|
disabled={!ready}
|
||||||
|
onClick={() => applyPreset(key)}
|
||||||
|
title={viewPresetLabel(key)}
|
||||||
|
aria-label={viewPresetLabel(key)}
|
||||||
|
>
|
||||||
|
{viewPresetLabel(key)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* 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. */}
|
||||||
|
<div
|
||||||
|
className="wasm3d-overlay-group"
|
||||||
|
role="group"
|
||||||
|
aria-label={t("display.style.group")}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`wasm3d-overlay-btn${renderMode === "shaded" ? " active" : ""}`}
|
||||||
|
disabled={!ready}
|
||||||
|
onClick={() => setRenderMode("shaded")}
|
||||||
|
title={t("display.style.shaded")}
|
||||||
|
aria-label={t("display.style.shaded")}
|
||||||
|
>
|
||||||
|
{t("display.style.shaded")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`wasm3d-overlay-btn${renderMode === "white" ? " active" : ""}`}
|
||||||
|
disabled={!ready}
|
||||||
|
onClick={() => setRenderMode("white")}
|
||||||
|
title={t("display.style.white")}
|
||||||
|
aria-label={t("display.style.white")}
|
||||||
|
>
|
||||||
|
{t("display.style.white")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{failed && (
|
{failed && (
|
||||||
<div className="viewport-fallback">
|
<div className="viewport-fallback">
|
||||||
<strong>{t("viewport3d.engineUnavailable")}</strong>
|
<strong>{t("viewport3d.engineUnavailable")}</strong>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export interface Camera3d {
|
|||||||
/** Minimal-Typ des WASM-Exports (das echte .d.ts entsteht erst mit `build:engine3d`). */
|
/** Minimal-Typ des WASM-Exports (das echte .d.ts entsteht erst mit `build:engine3d`). */
|
||||||
interface WebModelRendererLike {
|
interface WebModelRendererLike {
|
||||||
set_model(json: string): void;
|
set_model(json: string): void;
|
||||||
|
set_render_mode_white(white: boolean): void;
|
||||||
set_camera(
|
set_camera(
|
||||||
eyeX: number,
|
eyeX: number,
|
||||||
eyeY: 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). */
|
/** Einen Frame mit der gegebenen Kamera zeichnen (Canvas-Größe + DPR abgleichen). */
|
||||||
const render = useCallback(
|
const render = useCallback(
|
||||||
(cam: Camera3d) => {
|
(cam: Camera3d) => {
|
||||||
@@ -166,5 +182,5 @@ export function useWasm3dRenderer(
|
|||||||
[canvasRef],
|
[canvasRef],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { render, updateModel, ready, failed };
|
return { render, updateModel, setWhiteMode, ready, failed };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user