Files
DOSSIER-STANDALONE/src-tauri/render3d/src/web.rs
T
karim 35299307d6 Akkumulierten grünen Arbeitsstand landen (Basis für Weiterarbeit)
Bündelt den über mehrere Sessions gewachsenen, uncommitteten Stand in
einem Basis-Commit, damit Folge-Features isoliert darauf aufsetzen.
Verifikation: tsc --noEmit sauber, vitest 600/600 grün.

Enthalten (Details in PENDENZEN.md -Liste / HANDOVER.md):
- truck-Integration: Profil-Extrusion + Verjüngung + Boolean-CSG (csgrs),
  Crate src-tauri/trucksolid, Werkzeug `extrude`, ExtrudedSolid-Modell.
- kernel2d-Port nach Rust/WASM (Phasen 1–5, Diff-Harness).
- render3d 3D-Live-Schnitt = 2D-Schnitt: geschichteter Bodenaufbau,
  Prioritäts-Verschneidung (section_boolean.rs), einstellbare
  Schichttrennlinien, per-Hatch-Strichstärke, relativeToWall-Orientierung.
- Interop-Export IFC4/STL/OBJ (Loch-Ausschnitt wallMeshCut), Schnellexport.
- Projektdatei .obp + OS-Lock (lock.rs, LockConflictDialog).
- Layout-Blätter (Modell/Editor/Panel/PDF), Ausschnitte, Override-Engine,
  Tragwerk-Stützen (Column), BIM-Tree-Panel.
- Bauteil-Typsystem (Tür/Fenster/Treppe-Typen), Betontreppe mit schräger
  Laufplatte, Text-/Textbox-Werkzeug, Mess-Werkzeug, 2D/3D-Griffe für
  Öffnungen/Treppen, Snap-Symbol-Restyle.
2026-07-09 00:57:29 +02:00

497 lines
21 KiB
Rust
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.
// Browser-Bindings (wasm32 + WebGPU): fuehrt die native GPU-Schicht (`gpu::Renderer`)
// als WASM-Modul in die App-UI, mit einer HTML-Canvas als wgpu-Surface. Bewusst
// duenn — die gesamte Zeichenlogik (Wand-/Decken-Extrusion, Pipeline, Licht) ist
// mit dem nativen Fenster-Spike (bin/spike3d) geteilt; hier liegt nur die Canvas-/
// WebGPU-Anbindung + die wasm-bindgen-Fassade. Muster: render2d/src/web.rs.
//
// Ablauf im Frontend (siehe src/viewport/useWasm3dRenderer.ts):
// const r = await WebModelRenderer.new(canvas);
// r.set_model(JSON.stringify(projectToModel3d(project))); // bei Modelländerung
// r.set_camera(eye.x, eye.y, eye.z, target.x, target.y, target.z,
// up.x, up.y, up.z, perspective, fovY, orthoHalfHeight);
// r.resize(px_w, px_h); r.render(); // pro Frame
//
// KAMERA: zwei Wege, dieselbe `Camera`. `set_camera` nimmt eine FERTIGE Kamera
// (eye/target/up + Projektionsart) entgegen — die freie Orbit/Pan/Zoom-Interaktion
// (Yaw/Pitch/Distanz) rechnet weiterhin die TS-Seite (`math::orbit_eye`-Konvention),
// damit die Maus-UX 1:1 der bestehenden three.js-OrbitControls-Bedienung folgt.
// `set_view_preset` wendet stattdessen eines der `View3d`-Praesete (front/back/
// top/side/left/iso/isoFrontLeft/isoBackRight/isoBackLeft/perspective) direkt
// ueber `math::preset_camera` an — alle ausser Perspective werden dabei ECHT
// orthografisch (`preset_camera`; die Iso-Varianten = echte Isometrie ohne
// perspektivische Verzerrung), was eine reine TS-Orbit-Naeherung nicht leisten
// kann. Nach einem Praeset-Sprung bleibt die weitere Navigation frei ueber
// `set_camera` (kein Lock).
//
// MODELL-FORMAT: JSON-Struct { walls: WallInput[], slabs: SlabInput[] } — identisch
// zu `RModel3d` (src/plan/toWalls3d.ts) und zum nativen Tauri-Push (nativeSync.ts).
use serde::Deserialize;
use wasm_bindgen::prelude::*;
use web_sys::HtmlCanvasElement;
use crate::gpu::Renderer;
use crate::math::preset_camera;
use crate::section::{cut_section, SectionPlane};
use crate::types::{Camera, CameraPreset, MeshInput, Projection, SlabInput, WallInput};
/// JSON-Eingabe des Modells: Waende (bereits in Teilquader zerlegt, siehe
/// `projectToWalls3d`) + Deckenplatten. `slabs` optional (leeres Modell ohne
/// Decken bleibt gueltig).
#[derive(Deserialize)]
struct ModelInput {
walls: Vec<WallInput>,
#[serde(default)]
slabs: Vec<SlabInput>,
/// Rohe Kontext-Meshes (Terrain/importierte Volumen). `#[serde(default)]`
/// haelt aeltere Payloads ohne `meshes` gueltig.
#[serde(default)]
meshes: Vec<MeshInput>,
}
/// Berechnet einen Schnitt/eine Ansicht (`section::cut_section`) gegen das Modell
/// und gibt das `SectionOutput` als JSON-String zurueck. GPU-frei — reine
/// Prismen-Arithmetik (siehe `section.rs`); braucht KEINEN `WebModelRenderer`
/// (kein WebGPU-Kontext), nur das geladene WASM-Modul. Aufruf aus JS:
///
/// ```js
/// const json = mod.cut_section_json(
/// JSON.stringify({ walls, slabs }), // gleiches Format wie set_model
/// px, py, pz, // Punkt auf der Schnittebene (world-Meter, Y-up)
/// nx, ny, nz, // Ebenennormale = Blickrichtung (wird normalisiert)
/// );
/// const out = JSON.parse(json); // { cutPolygons, visibleEdges, hiddenEdges }
/// ```
///
/// Koordinaten der Ausgabe: (u, v) in Metern — `u` horizontal entlang der
/// Ebene, `v = world.y` (absolute Hoehe). Siehe `section.rs`-Moduldoc.
#[wasm_bindgen]
#[allow(clippy::too_many_arguments)]
pub fn cut_section_json(
model_json: &str,
px: f32,
py: f32,
pz: f32,
nx: f32,
ny: f32,
nz: f32,
) -> Result<String, JsValue> {
let model: ModelInput = serde_json::from_str(model_json)
.map_err(|e| JsValue::from_str(&format!("Modell parsen: {e}")))?;
let plane = SectionPlane::new([px, py, pz], [nx, ny, nz]);
let out = cut_section(&plane, &model.walls, &model.slabs);
serde_json::to_string(&out)
.map_err(|e| JsValue::from_str(&format!("SectionOutput serialisieren: {e}")))
}
/// Der Browser-Renderer: haelt Surface + Device/Queue + den geteilten `Renderer`
/// und die aktuelle Kamera. Wird per `WebModelRenderer.new(canvas)` aus JS erzeugt.
#[wasm_bindgen]
pub struct WebModelRenderer {
surface: wgpu::Surface<'static>,
device: wgpu::Device,
queue: wgpu::Queue,
config: wgpu::SurfaceConfiguration,
renderer: Renderer,
camera: Camera,
/// Achsparallele Bounding-Box (min, max) des zuletzt gesetzten Modells,
/// world-Meter (Y-up) — Grundlage fuer `set_view_preset` (formatfuellendes
/// Ziel/Distanz, mirror der TS-`fitTargetDist` in Wasm3DViewport.tsx).
/// `None` bei leerem Modell (kein Wall-/Slab-Vertex) oder vor `set_model`.
bounds: Option<([f32; 3], [f32; 3])>,
/// Gecachte Wand-/Decken-Eingabe des zuletzt gesetzten Modells. `set_model`
/// verwirft sie nach dem GPU-Upload NICHT (anders als der Renderer selbst),
/// weil der Live-Schnitt (`set_section_plane`) daraus jederzeit die
/// Cut-Cap-Geometrie neu berechnen koennen muss (GPU-frei, `cut_section`).
walls: Vec<WallInput>,
slabs: Vec<SlabInput>,
/// Aktueller Live-Schnitt-Zustand: aktiv? + Ebene (point/normal). Gemerkt,
/// damit ein `set_model` bei aktivem Schnitt die Caps automatisch neu baut.
section: Option<([f32; 3], [f32; 3])>,
}
/// Achsparallele Bounding-Box des Modells in Welt-Metern (Y-up), aus Waenden +
/// Decken. Mirror der TS-`fitTargetDist`-Grow-Schleife (Wasm3DViewport.tsx):
/// Wand-Achsenpunkte auf Basis-/Firsthoehe, Decken-Umriss auf Unter-/Oberkante.
/// `None` bei leerem Modell.
fn model_bounds(walls: &[WallInput], slabs: &[SlabInput]) -> Option<([f32; 3], [f32; 3])> {
let mut min = [f32::INFINITY; 3];
let mut max = [f32::NEG_INFINITY; 3];
let mut grow = |x: f32, y: f32, z: f32| {
if x < min[0] {
min[0] = x;
}
if x > max[0] {
max[0] = x;
}
if y < min[1] {
min[1] = y;
}
if y > max[1] {
max[1] = y;
}
if z < min[2] {
min[2] = z;
}
if z > max[2] {
max[2] = z;
}
};
for w in walls {
grow(w.start[0], w.base_elevation, w.start[1]);
grow(w.end[0], w.base_elevation + w.height, w.end[1]);
}
for s in slabs {
for p in &s.outline {
grow(p[0], s.z_bottom, p[1]);
grow(p[0], s.z_top, p[1]);
}
}
if !min[0].is_finite() {
return None;
}
Some((min, max))
}
#[wasm_bindgen]
impl WebModelRenderer {
/// Erzeugt den Renderer asynchron (Adapter/Device via WebGPU).
/// Aus JS: `await WebModelRenderer.new(canvasEl)`.
#[allow(clippy::new_ret_no_self)]
pub async fn new(canvas: HtmlCanvasElement) -> Result<WebModelRenderer, JsValue> {
// Panics als lesbare Konsolen-Fehler statt "unreachable" ausgeben.
console_error_panic_hook::set_once();
let width = canvas.width().max(1);
let height = canvas.height().max(1);
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends: wgpu::Backends::BROWSER_WEBGPU,
..wgpu::InstanceDescriptor::new_without_display_handle()
});
let surface = instance
.create_surface(wgpu::SurfaceTarget::Canvas(canvas))
.map_err(|e| JsValue::from_str(&format!("create_surface: {e:?}")))?;
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
force_fallback_adapter: false,
compatible_surface: Some(&surface),
})
.await
.map_err(|e| {
JsValue::from_str(&format!("kein WebGPU-Adapter (navigator.gpu?): {e}"))
})?;
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor {
label: Some("render3d.web.device"),
required_features: wgpu::Features::empty(),
required_limits: wgpu::Limits::downlevel_webgl2_defaults()
.using_resolution(adapter.limits()),
experimental_features: wgpu::ExperimentalFeatures::disabled(),
memory_hints: wgpu::MemoryHints::Performance,
trace: wgpu::Trace::Off,
})
.await
.map_err(|e| JsValue::from_str(&format!("request_device: {e:?}")))?;
// WebGPU-Canvas liefert i.d.R. bgra8unorm (kein sRGB-Format fuer die
// Surface) — Format vom Kontext abfragen statt hart zu kodieren (dieselbe
// Wahl wie render2d: erstes NICHT-sRGB-Format, sonst das erste angebotene).
let caps = surface.get_capabilities(&adapter);
let format = caps
.formats
.iter()
.copied()
.find(|f| !f.is_srgb())
.unwrap_or(caps.formats[0]);
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format,
width,
height,
present_mode: wgpu::PresentMode::Fifo,
alpha_mode: caps.alpha_modes[0],
view_formats: vec![],
desired_maximum_frame_latency: 2,
};
surface.configure(&device, &config);
let renderer = Renderer::new(&device, format);
web_sys::console::log_1(&JsValue::from_str(&format!(
"render3d WebGPU-Viewport initialisiert ({}x{}, {:?})",
width, height, format
)));
Ok(WebModelRenderer {
surface,
device,
queue,
config,
renderer,
camera: Camera::default(),
bounds: None,
walls: Vec::new(),
slabs: Vec::new(),
section: None,
})
}
/// Setzt/aktualisiert das darzustellende Modell aus einem JSON-String
/// (Format = `{ walls: WallInput[], slabs?: SlabInput[] }`, identisch zum
/// nativen Push). Laedt die GPU-Puffer neu hoch; ruft NICHT selbst `render`.
/// Merkt sich zusaetzlich die Bounding-Box (fuer `set_view_preset`).
pub fn set_model(&mut self, json: &str) -> Result<(), JsValue> {
let model: ModelInput = serde_json::from_str(json)
.map_err(|e| JsValue::from_str(&format!("Modell parsen: {e}")))?;
self.bounds = model_bounds(&model.walls, &model.slabs);
self.renderer
.upload_model(&self.device, &model.walls, &model.slabs, &model.meshes);
// Wand-/Decken-Eingabe fuer den Live-Schnitt cachen (Klon, da der Renderer
// sie nur zu GPU-Puffern verarbeitet und verwirft).
self.walls = model.walls;
self.slabs = model.slabs;
// Ist der Schnitt aktiv, die Caps am neuen Modell neu aufbauen.
if let Some((point, normal)) = self.section {
self.renderer.update_section(
&self.device,
true,
point,
normal,
&self.walls,
&self.slabs,
);
}
Ok(())
}
/// Live-Schnitt ein-/ausschalten und die Schnittebene setzen. `active=false`
/// hebt den Schnitt auf (Modell wieder komplett, keine Caps). `p*` = Punkt auf
/// der Ebene, `n*` = Ebenennormale = Blickrichtung (world-Meter, Y-up; wird
/// normalisiert). NUR VERTIKALE Ebenen sind sinnvoll (Normale ohne Y-Anteil —
/// Annahme von `section.rs`). Kappt Mesh/Kanten/Grid per Shader-`discard` und
/// baut aus den gecachten Waenden/Decken die Schnittflaechen-Schraffur (Caps).
/// Wirkt erst beim naechsten `render`.
#[allow(clippy::too_many_arguments)]
pub fn set_section_plane(
&mut self,
active: bool,
px: f32,
py: f32,
pz: f32,
nx: f32,
ny: f32,
nz: f32,
) -> Result<(), JsValue> {
let point = [px, py, pz];
let normal = [nx, ny, nz];
self.section = if active { Some((point, normal)) } else { None };
self.renderer
.update_section(&self.device, active, point, normal, &self.walls, &self.slabs);
Ok(())
}
/// Setzt die Kamera fuer den naechsten `render`-Aufruf. `eye`/`target`/`up`
/// in world-Metern (Y-up, wie `types::Camera`). `perspective=true` waehlt eine
/// perspektivische Projektion (`fov_y` in Radiant), sonst orthografisch
/// (`ortho_half_height` = halbe Sichthoehe in Metern). Die TS-Seite berechnet
/// `eye` aus Orbit-Winkeln/Distanz oder einem Praeset (front/top/side/iso/persp)
/// — siehe `useWasm3dRenderer`/`Wasm3DViewport`.
#[allow(clippy::too_many_arguments)]
pub fn set_camera(
&mut self,
eye_x: f32,
eye_y: f32,
eye_z: f32,
target_x: f32,
target_y: f32,
target_z: f32,
up_x: f32,
up_y: f32,
up_z: f32,
perspective: bool,
fov_y: f32,
ortho_half_height: f32,
) {
self.camera = Camera {
eye: [eye_x, eye_y, eye_z],
target: [target_x, target_y, target_z],
up: [up_x, up_y, up_z],
projection: if perspective {
Projection::Perspective
} else {
Projection::Orthographic
},
fov_y,
ortho_half_height,
..Camera::default()
};
}
/// Wendet eines der Kardinal-/Iso-Kamera-Praesets der three.js-Sicht
/// (`front`/`back`/`top`/`side`/`left`/`iso`/`isoFrontLeft`/`isoBackRight`/
/// `isoBackLeft`/`perspective`, s. `View3d` in TopBar.tsx) auf das aktuelle
/// Modell an: Ziel = Modell-Mitte, Distanz = formatfuellend (mirror der
/// TS-`fitTargetDist`); alle Presets ausser Perspective werden orthografisch,
/// nur Perspective ist perspektivisch (`math::preset_camera`). Wirkt erst beim
/// naechsten `render`. „Praeset = hinspringen, kein Lock" — manuelles
/// Orbit/Pan/Zoom bleibt danach frei ueber `set_camera` (s. Wasm3DViewport.tsx).
pub fn set_view_preset(&mut self, preset: &str) -> Result<(), JsValue> {
let preset = match preset {
"front" => CameraPreset::Front,
"back" => CameraPreset::Back,
"top" => CameraPreset::Top,
"side" => CameraPreset::Side,
"left" => CameraPreset::Left,
"iso" => CameraPreset::Iso,
"isoFrontLeft" => CameraPreset::IsoFrontLeft,
"isoBackRight" => CameraPreset::IsoBackRight,
"isoBackLeft" => CameraPreset::IsoBackLeft,
"perspective" => CameraPreset::Persp,
other => {
return Err(JsValue::from_str(&format!(
"unbekanntes Kamera-Praeset: {other}"
)))
}
};
let (target, dist) = match self.bounds {
Some((min, max)) => {
let center = [
(min[0] + max[0]) * 0.5,
(min[1] + max[1]) * 0.5,
(min[2] + max[2]) * 0.5,
];
let (dx, dy, dz) = (max[0] - min[0], max[1] - min[1], max[2] - min[2]);
let radius = (dx * dx + dy * dy + dz * dz).sqrt() * 0.5;
let radius = if radius > 0.0 { radius } else { 1.0 };
// Abstand so, dass die Bounding-Kugel ins vertikale Sichtfeld
// passt (+ Rand) — identische Formel wie `fitTargetDist`.
let fov_y = crate::types::default_fov();
let dist = ((radius / (fov_y * 0.5).tan()) * 1.2).max(4.0);
(center, dist)
}
// Leeres Modell: neutraler Standpunkt (entspricht dem three.js-Start).
None => ([2.5_f32, 1.0, 2.0], 14.0_f32),
};
self.camera = preset_camera(preset, target, dist);
Ok(())
}
/// Darstellungsart der Oberleiste setzen. Gueltige Werte:
/// - `"shaded"` — beleuchtete Bauteilfarben (Default).
/// - `"white"` — Clay-Look: einheitlich helles Material, beleuchtet
/// (identisch zum bisherigen `set_render_mode_white(true)`).
/// - `"textured"` — Material-Farbkarten je Wand-Band aus dem Textur-Array
/// (`set_material_textures`), Fallback-Schachbrett fuer Baender ohne Material.
/// - `"wireframe"`— nur Modell-Kanten (Feature-Edges), keine Flaechen.
/// - `"hidden"` — Hidden-Line: flach-weisse Flaechen fuellen den Depth-
/// Buffer, dunkle Kanten liegen tiefengetestet obenauf (nur sichtbare).
/// Unbekannte Werte fallen auf `"shaded"` zurueck. Wirkt erst beim naechsten
/// `render`. Loest `set_render_mode_white` ab (das bleibt kompatibel).
pub fn set_render_style(&mut self, style: &str) -> Result<(), JsValue> {
self.renderer
.set_render_style(crate::gpu::RenderStyle::from_str_or_shaded(style));
Ok(())
}
/// Darstellungsart umschalten: `true` = weisses Clay-Material (beleuchtet,
/// ohne Bauteil-Farben — Vectorworks „white"), `false` = normale Material-
/// farbe (shaded, Default). Wirkt erst beim naechsten `render`. RUECKWAERTS-
/// KOMPATIBEL — bildet intern auf denselben `render_style`-Zustand ab wie
/// `set_render_style` (true→„white", false→„shaded"), sodass es keine zwei
/// widersprechenden Zustaende gibt.
pub fn set_render_mode_white(&mut self, white: bool) {
self.renderer.set_white_mode(white);
}
/// Schaltet das Referenz-Bodengitter (grid.rs) ein/aus und setzt seine
/// Parameter. `elevation_m` = Hoehe der XZ-Gitterebene (world-Meter, i. d. R.
/// die z=0-Ebene des aktiven Geschosses); `extent_m` = quadratische Kantenlaenge
/// (auf ein Vielfaches von `spacing_m` gerundet); `spacing_m` = Linienabstand.
/// Das Gitter ist zentriert um den Welt-Ursprung (x=0, z=0). Wirkt erst beim
/// naechsten `render`. Default ohne Aufruf: unsichtbar (bestehende Aufrufer
/// bleiben unveraendert). Jede 5. Linie (inkl. Mittelachse) ist eine Major-Linie.
pub fn set_ground_grid(
&mut self,
visible: bool,
elevation_m: f32,
extent_m: f32,
spacing_m: f32,
) -> Result<(), JsValue> {
self.renderer
.set_ground_grid(&self.device, visible, elevation_m, extent_m, spacing_m);
Ok(())
}
/// Setzt/loescht die Auswahl-Highlight-Linien (Umriss/Kanten des selektierten
/// Bauteils). `vertices` = interleaved LineList-Vertices
/// `[px,py,pz, r,g,b, ...]` (world-Meter, je 2 Vertices = 1 Segment) — exakt
/// dasselbe Format wie die Grid-/Edge-Vertices. Ein leeres Slice loescht das
/// Highlight (nichts wird gezeichnet). Die Linien liegen im Render-Pass ohne
/// Tiefentest immer sichtbar obenauf (scheinen auch hinter Waenden durch).
/// Wirkt erst beim naechsten `render`.
pub fn set_highlight_lines(&mut self, vertices: &[f32]) -> Result<(), JsValue> {
self.renderer.set_highlight_lines(&self.device, vertices);
Ok(())
}
/// Laedt die Material-Farbkarten fuer den Stil `Textured` als Textur-Array hoch
/// (`Renderer::set_material_textures`). `rgba` = dicht gepackte RGBA8-Bytes von
/// `layer_count` Ebenen, JEDE exakt 256×256 (der JS-Aufrufer dekodiert/skaliert
/// die Material-Bilder, siehe `viewport/materialTextures.ts`). Ebene 0 bleibt
/// intern das Fallback-Schachbrett; die hochgeladenen Karten belegen die Ebenen
/// 1..N, auf die `WallInput::materialIndex` (1-basiert) zeigt. `layer_count == 0`
/// oder zu kleine Daten setzen auf das reine Schachbrett zurueck. Wirkt erst beim
/// naechsten `render`.
pub fn set_material_textures(&mut self, rgba: &[u8], layer_count: u32) -> Result<(), JsValue> {
self.renderer
.set_material_textures(&self.device, &self.queue, rgba, layer_count);
Ok(())
}
/// 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));
if self.config.width == w && self.config.height == h {
return;
}
self.config.width = w;
self.config.height = h;
self.surface.configure(&self.device, &self.config);
}
/// Zeichnet EINEN Frame in die Surface-Textur.
pub fn render(&mut self) {
// wgpu 29: `get_current_texture` liefert ein Enum statt eines Results —
// Suboptimal ist weiterhin praesentierbar, Lost/Outdated -> rekonfigurieren.
let frame = match self.surface.get_current_texture() {
wgpu::CurrentSurfaceTexture::Success(f)
| wgpu::CurrentSurfaceTexture::Suboptimal(f) => f,
wgpu::CurrentSurfaceTexture::Lost | wgpu::CurrentSurfaceTexture::Outdated => {
self.surface.configure(&self.device, &self.config);
return;
}
other => {
web_sys::console::warn_1(&JsValue::from_str(&format!(
"render3d Surface-Fehler: {other:?}"
)));
return;
}
};
let view = frame
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
self.renderer.render(
&self.device,
&self.queue,
&view,
&self.camera,
(self.config.width, self.config.height),
);
frame.present();
}
}