# 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.