diff --git a/docs/design/truck-plan.md b/docs/design/truck-plan.md new file mode 100644 index 0000000..bf7faea --- /dev/null +++ b/docs/design/truck-plan.md @@ -0,0 +1,403 @@ +# truck-Integration — Profil-Extrusion (B-Rep → 3D-Mesh) + +> Übergabe-Dokument für die Implementierung. Lies zuerst CONVENTIONS.md. + +## Ziel + +Nutzer zeichnen im Grundriss ein geschlossenes Polygon (z. B. L-Profil einer Stütze) +und können es als 3D-Körper auf eine Höhe extrudieren. Das Ergebnis erscheint sofort +im 3D-Viewport neben Wänden und Decken. + +**MVP-Scope (dieser Auftrag):** +- Neue Rust/WASM-Crate `trucksolid` mit zwei Funktionen: `extrude_polygon` + `extrude_circle` +- Neue TS-Wrapper-Datei `src/engine/truckSolid.ts` (WASM laden + typisierte API) +- KEIN neues Werkzeug, KEIN UI — nur die Geometrie-Schicht. UI kommt in einem + separaten Folgeauftrag. + +--- + +## Architektur-Kontext + +``` +src-tauri/ + render3d/ ← bestehend: wgpu-Renderer (wasm-pack → pkg3d/) + kernel2d/ ← bestehend: 2D-Geometrie-Kern + trucksolid/ ← NEU (dieser Auftrag) + Cargo.toml + src/ + lib.rs + +src/engine/ + pkg3d/ ← render3d WASM-Output + pkgTruck/ ← NEU: trucksolid WASM-Output (wasm-pack Ziel) + truckSolid.ts ← NEU: TS-Wrapper + +src/plan/ + toWalls3d.ts ← bestehend: baut RenderScene; emitMeshes() muss erweitert werden +``` + +Das Koordinatensystem in DOSSIER ist **Modell-Meter: X = rechts, Y = oben (Grundriss), +Z = Höhe**. render3d erwartet `positions` im gleichen System — `mesh.rs` macht intern +`(x, y, z) → (x, z, y)` für wgpu (Y-up world). Truck arbeitet in 3D; wir bauen +das Polygon in der XY-Ebene (z=0) und extrudieren nach +Z. + +--- + +## 1 — Rust-Crate `src-tauri/trucksolid/` + +### 1.1 Cargo.toml + +Exakt dasselbe Muster wie `src-tauri/kernel2d/Cargo.toml`: + +```toml +[workspace] # entkoppelt vom cad-tauri-Workspace (Pflicht) + +[package] +name = "trucksolid" +version = "0.1.0" +edition = "2021" +description = "Profil-Extrusion via truck (B-Rep → tesselliertes Mesh für render3d)" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +default = [] +web = [ + "dep:wasm-bindgen", + "dep:serde_json", + "dep:console_error_panic_hook", +] + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", optional = true } +wasm-bindgen = { version = "0.2", optional = true } +console_error_panic_hook = { version = "0.1", optional = true } + +# truck: nur die stabilen Crates (KEIN truck-modeling — Booleans instabil) +truck-geometry = "0.3" +truck-topology = "0.3" +truck-rendimesh = "0.3" + +[dev-dependencies] +serde_json = "1" +``` + +**Wichtig:** `truck-modeling` (die Crate mit Boolean-Operatoren) wird NICHT eingebunden. +Nur `truck-geometry`, `truck-topology` und `truck-rendimesh` — die sind stabil. + +### 1.2 src/lib.rs — Kern-Logik + +#### Eingabe/Ausgabe-Typen + +```rust +use serde::{Deserialize, Serialize}; + +/// Input: flaches Array [x0,y0, x1,y1, ...] in Modell-Metern (Grundriss-XY). +/// Muss ≥ 3 Punkte enthalten; Wiederholung des ersten Punkts am Ende optional. +/// Reihenfolge CCW oder CW — truck normalisiert selbst. +#[derive(Deserialize)] +pub struct ExtrudePolyInput { + pub points: Vec, // flat: [x0,y0, x1,y1, ...] + pub height: f64, // Extrusionshöhe in Metern (> 0) +} + +/// Output: trianguliertes Mesh, kompatibel mit render3d::types::MeshInput. +/// positions: flat [x0,y0,z0, x1,y1,z1, ...] in Modell-Metern +/// indices: Dreiecks-Indizes (je 3 = 1 Dreieck), 0-basiert +#[derive(Serialize)] +pub struct MeshOutput { + pub positions: Vec, + pub indices: Vec, +} +``` + +#### Polygon-Extrusion (truck-API) + +```rust +use truck_geometry::prelude::*; +use truck_topology::*; + +pub fn extrude_polygon_core(pts: &[(f64, f64)], height: f64) -> Result { + if pts.len() < 3 { return Err("min 3 Punkte".into()); } + if height <= 0.0 { return Err("height muss > 0 sein".into()); } + + // 1. Punkte in der XY-Ebene (z=0) als truck-Vertices + let verts: Vec> = pts.iter() + .map(|(x, y)| Vertex::new(Point3::new(*x, *y, 0.0))) + .collect(); + + // 2. Kanten: je zwei aufeinanderfolgende Vertices verbinden, Ring schliessen + let edges: Vec>> = verts.windows(2) + .chain(std::iter::once([verts.last().unwrap(), &verts[0]].as_slice())) + .map(|w| { + let p0 = *w[0].point(); + let p1 = *w[1].point(); + Edge::new(&w[0], &w[1], Line(p0, p1)) + }) + .collect(); + + // 3. Wire (geschlossener Kantenzug) + let wire = Wire::from_iter(edges); + + // 4. Planare Face aus dem Wire + // truck-topology::Face::new braucht die äussere Boundary + optional Holes + let face = Face::new(vec![wire]); + + // 5. Lineare Extrusion: tsweep entlang +Z um `height` + let solid = face.tsweep(&Vector3::new(0.0, 0.0, height)); + + // 6. Tessellieren (chord-tolerance in Metern — 0.005 = 5 mm) + use truck_rendimesh::MeshedShape; + let mesh = solid.triangulation(0.005).to_polygon(); + + // 7. positions + indices extrahieren + let positions: Vec = mesh.positions().iter() + .flat_map(|p| [p.x as f32, p.y as f32, p.z as f32]) + .collect(); + let indices: Vec = mesh.tri_faces().iter() + .flat_map(|tri| tri.iter().map(|idx| idx.pos as u32)) + .collect(); + + Ok(MeshOutput { positions, indices }) +} +``` + +> **Achtung:** Die exakte truck-API (Methoden-Namen, Trait-Imports, tsweep-Signatur) +> kann je nach veröffentlichter Version leicht abweichen. Prüfe die Docs von +> `truck-topology 0.3` und `truck-rendimesh 0.3` auf docs.rs. Die Struktur oben +> ist das Ziel-Pattern — passe Methoden-Signaturen an, wenn der Compiler es verlangt. +> Ändere NICHT die Eingabe/Ausgabe-JSON-Struktur. + +#### Kreis-Extrusion (Zylinder) + +```rust +pub fn extrude_circle_core(cx: f64, cy: f64, r: f64, height: f64) -> Result { + // Kreis in XY-Ebene tessellieren (N Segmente je nach r), + // dann wie extrude_polygon_core aufrufen. + // Alternativ: truck-geometry::Ellipse/Circle direkt nutzen, falls vorhanden. + let n = (2.0 * std::f64::consts::PI * r / 0.02).ceil().max(16.0) as usize; + let pts: Vec<(f64, f64)> = (0..n) + .map(|i| { + let a = 2.0 * std::f64::consts::PI * i as f64 / n as f64; + (cx + r * a.cos(), cy + r * a.sin()) + }) + .collect(); + extrude_polygon_core(&pts, height) +} +``` + +#### WASM-Bindings (Feature "web") + +```rust +#[cfg(feature = "web")] +mod web { + use super::*; + use wasm_bindgen::prelude::*; + + #[wasm_bindgen(start)] + pub fn init() { + console_error_panic_hook::set_once(); + } + + /// Extrudiert ein Polygon-Profil. + /// `input_json`: `{ "points": [x0,y0,…], "height": 2.5 }` + /// Rückgabe: `{ "positions": […], "indices": […] }` oder throws JsError. + #[wasm_bindgen] + pub fn extrude_polygon(input_json: &str) -> Result { + let input: ExtrudePolyInput = serde_json::from_str(input_json) + .map_err(|e| JsError::new(&e.to_string()))?; + let pts: Vec<(f64, f64)> = input.points.chunks(2) + .map(|c| (c[0], c[1])) + .collect(); + let mesh = extrude_polygon_core(&pts, input.height) + .map_err(|e| JsError::new(&e))?; + serde_json::to_string(&mesh).map_err(|e| JsError::new(&e.to_string())) + } + + /// Extrudiert einen Kreis-Querschnitt (Zylinder). + /// `input_json`: `{ "cx": 0, "cy": 0, "r": 0.15, "height": 3.0 }` + #[wasm_bindgen] + pub fn extrude_circle(input_json: &str) -> Result { + #[derive(serde::Deserialize)] + struct In { cx: f64, cy: f64, r: f64, height: f64 } + let i: In = serde_json::from_str(input_json) + .map_err(|e| JsError::new(&e.to_string()))?; + let mesh = extrude_circle_core(i.cx, i.cy, i.r, i.height) + .map_err(|e| JsError::new(&e))?; + serde_json::to_string(&mesh).map_err(|e| JsError::new(&e.to_string())) + } +} +``` + +### 1.3 Tests (headless, ohne Feature "web") + +Mindestens diese drei Tests müssen mit `cargo test` grün sein: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quad_extrusion_vertex_count() { + // 1m × 1m Quadrat, 2m hoch → 8 Eckpunkte minimum (6 Flächen × 2 Dreiecke) + let pts = vec![(0.0,0.0),(1.0,0.0),(1.0,1.0),(0.0,1.0)]; + let m = extrude_polygon_core(&pts, 2.0).unwrap(); + assert!(m.positions.len() >= 8 * 3); // ≥ 8 Vertices × 3 floats + assert_eq!(m.indices.len() % 3, 0); // vollständige Dreiecke + assert!(m.indices.len() >= 12 * 3); // ≥ 12 Dreiecke (Quader) + } + + #[test] + fn l_profile_extrusion() { + // L-Profil: 6 Punkte + let pts = vec![ + (0.0,0.0),(0.3,0.0),(0.3,0.1),(0.1,0.1),(0.1,0.3),(0.0,0.3), + ]; + let m = extrude_polygon_core(&pts, 3.0).unwrap(); + assert_eq!(m.indices.len() % 3, 0); + assert!(m.positions.len() > 0); + } + + #[test] + fn cylinder_extrusion() { + let m = extrude_circle_core(0.0, 0.0, 0.15, 3.0).unwrap(); + assert_eq!(m.indices.len() % 3, 0); + assert!(m.positions.len() > 0); + } + + #[test] + fn rejects_too_few_points() { + assert!(extrude_polygon_core(&[(0.0,0.0),(1.0,0.0)], 1.0).is_err()); + } + + #[test] + fn rejects_zero_height() { + let pts = vec![(0.0,0.0),(1.0,0.0),(0.5,1.0)]; + assert!(extrude_polygon_core(&pts, 0.0).is_err()); + } +} +``` + +--- + +## 2 — Build-Script (package.json) + +Füge in `package.json` unter `"scripts"` hinzu: + +```json +"build:truck": "wasm-pack build src-tauri/trucksolid --release --target web --out-dir ../../src/engine/pkgTruck --out-name trucksolid --no-default-features --features web" +``` + +Und in `src-tauri/Cargo.toml` unter `exclude`: + +```toml +exclude = ["render2d", "render3d", "geometry", "kernel2d", "dwgimport", "trucksolid"] +``` + +--- + +## 3 — TS-Wrapper `src/engine/truckSolid.ts` + +Gleiche Lade-Pattern wie `src/plan/useWasmPlanRenderer.ts` (render2d) und +`src/viewport/useWasm3dRenderer.ts` (render3d): + +```typescript +// Lazy-Singleton: WASM einmalig laden, dann gecacht. +let modulePromise: Promise | null = null; + +async function getModule() { + if (!modulePromise) { + modulePromise = import("../../engine/pkgTruck/trucksolid").then(async (m) => { + await m.default(); // WASM-Binary initialisieren + return m; + }); + } + return modulePromise; +} + +export interface ExtrudedMesh { + positions: number[]; + indices: number[]; +} + +/** Extrudiert ein geschlossenes Polygon-Profil (Modell-Meter XY) um `height` m. */ +export async function extrudePolygon( + points: number[], // flat [x0,y0, x1,y1, ...] + height: number, +): Promise { + const m = await getModule(); + const json = m.extrude_polygon(JSON.stringify({ points, height })); + return JSON.parse(json) as ExtrudedMesh; +} + +/** Extrudiert einen Kreis-Querschnitt (Zylinder). */ +export async function extrudeCircle( + cx: number, cy: number, r: number, height: number, +): Promise { + const m = await getModule(); + const json = m.extrude_circle(JSON.stringify({ cx, cy, r, height })); + return JSON.parse(json) as ExtrudedMesh; +} +``` + +--- + +## 4 — Integration in toWalls3d.ts (Vorbereitung) + +In `src/plan/toWalls3d.ts` ist `RMeshKind` bereits definiert: + +```typescript +export type RMeshKind = "terrain" | "imported"; +``` + +Erweitere auf `"extrusion"` (damit der Renderer später eine eigene Farbe/Darstellung +wählen kann, auch wenn heute noch kein Unterschied besteht): + +```typescript +export type RMeshKind = "terrain" | "imported" | "extrusion"; +``` + +Die `emitMeshes()`-Funktion liest bereits `project.drawings2d` und ähnliche Arrays. +Für extrudierte Körper wird es später ein `project.extrudedSolids`-Array geben +(oder ähnlich — das ist Teil des UI-Folgeauftrags). Die `emitMeshes()`-Erweiterung +kommt dann. + +--- + +## 5 — Verifikations-Checkliste + +Bevor du als fertig meldest, müssen alle Punkte grün sein: + +- [ ] `cd src-tauri/trucksolid && cargo test` → alle 5 Tests grün, keine Warnings +- [ ] `npm run build:truck` → `src/engine/pkgTruck/trucksolid.js` + `.wasm` erzeugt +- [ ] `npx tsc --noEmit` (aus Root) → 0 Fehler +- [ ] `npx vitest run` → alle bestehenden Tests weiterhin grün (keine Regression) +- [ ] `src-tauri/Cargo.toml` hat `"trucksolid"` im `exclude`-Array +- [ ] `package.json` hat `"build:truck"` im `"scripts"`-Block + +--- + +## 6 — Was NICHT in diesem Auftrag + +- Kein neues UI/Werkzeug (kommt später) +- Kein `truck-modeling` (Boolean-Operationen — instabil upstream) +- Kein STEP-Export (kommt in Folgeauftrag wenn Basisschicht steht) +- Kein Eintrag in `project.extrudedSolids` oder Store (UI-Auftrag) +- Keine Änderungen an render3d, generatePlan, App.tsx + +--- + +## 7 — Koordinatensystem-Reminder + +| DOSSIER-Modell | truck (im Code) | render3d (wgpu) | +|---|---|---| +| X = rechts | X = rechts | X = rechts | +| Y = oben (Grundriss) | Y = oben (Grundriss) | Z = oben (Y-up swapped) | +| Z = Höhe | Z = Höhe | Y = Höhe | + +`mesh.rs` in render3d macht bereits `(model.x, model.y, model.z) → (x, z, y)` beim +Hochladen. Du musst also in truck DOSSIER-Koordinaten verwenden (XY-Ebene = Grundriss, +Z = Höhe) — das ist konsistent mit der bestehenden `MeshInput.positions`-Konvention.