Übergabe-Dokument: truck-Integration Profil-Extrusion (docs/design/truck-plan.md)
This commit is contained in:
@@ -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<f64>, // 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<f32>,
|
||||
pub indices: Vec<u32>,
|
||||
}
|
||||
```
|
||||
|
||||
#### Polygon-Extrusion (truck-API)
|
||||
|
||||
```rust
|
||||
use truck_geometry::prelude::*;
|
||||
use truck_topology::*;
|
||||
|
||||
pub fn extrude_polygon_core(pts: &[(f64, f64)], height: f64) -> Result<MeshOutput, String> {
|
||||
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<Vertex<Point3>> = 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<Edge<_, Line<Point3>>> = 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<f32> = mesh.positions().iter()
|
||||
.flat_map(|p| [p.x as f32, p.y as f32, p.z as f32])
|
||||
.collect();
|
||||
let indices: Vec<u32> = 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<MeshOutput, String> {
|
||||
// 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<String, JsError> {
|
||||
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<String, JsError> {
|
||||
#[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<typeof import("../../engine/pkgTruck/trucksolid")> | 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<ExtrudedMesh> {
|
||||
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<ExtrudedMesh> {
|
||||
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.
|
||||
Reference in New Issue
Block a user