Files
DOSSIER-STANDALONE/docs/design/truck-plan.md
T

13 KiB
Raw Blame History

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:

[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

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)

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)

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")

#[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:

#[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:

"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:

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):

// 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:

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):

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:trucksrc/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.