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.
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
// Boolesche Operationen (Union/Differenz/Schnitt) zwischen zwei Dreiecks-Meshes.
|
||||
// Mesh-Ebenen-CSG via csgrs (BSP-Baum) statt truck-modeling-Booleans — letztere
|
||||
// sind bei koinzidenten/tangentialen Flächen instabil (siehe PENDENZEN.md,
|
||||
// truck-Integration Phase 4, monstertruck-solid-Spike). csgrs erwies sich im
|
||||
// Spike an genau diesem Fall (Extrusion bündig/eingebunden in eine Wand,
|
||||
// deckungsgleicher Querschnitt) als exakt korrekt.
|
||||
|
||||
use csgrs::csg::CSG;
|
||||
use csgrs::mesh::Mesh as CsgMesh;
|
||||
use csgrs::polygon::Polygon;
|
||||
use csgrs::triangulated::Triangulated3D;
|
||||
use csgrs::vertex::Vertex;
|
||||
use nalgebra::Point3;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Eingabe-Mesh für eine boolesche Operation: flache f64-Positionen (Modell-
|
||||
/// Meter) + Dreiecks-Indizes. Getrennt von `MeshOutput` (dort f32, da Render-
|
||||
/// Ausgabe) — hier f64, da Eingabe aus Modelldaten und Präzision für die BSP-
|
||||
/// Klassifikation an koinzidenten Flächen zählt.
|
||||
#[derive(Deserialize)]
|
||||
pub struct BooleanMeshInput {
|
||||
pub a_positions: Vec<f64>,
|
||||
pub a_indices: Vec<u32>,
|
||||
pub b_positions: Vec<f64>,
|
||||
pub b_indices: Vec<u32>,
|
||||
/// "union" | "difference" | "intersection"
|
||||
pub op: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct BooleanMeshOutput {
|
||||
pub positions: Vec<f32>,
|
||||
pub indices: Vec<u32>,
|
||||
/// Ob das Ergebnis leer ist (z. B. Schnitt zweier sich nur berührender
|
||||
/// Körper) — csgrs liefert das als Trimesh-Fehler statt eines leeren
|
||||
/// Meshes zurück, hier auf einen sauberen Fall normalisiert.
|
||||
pub empty: bool,
|
||||
}
|
||||
|
||||
fn mesh_from_triangles(positions: &[f64], indices: &[u32]) -> Result<CsgMesh<()>, String> {
|
||||
if indices.len() % 3 != 0 {
|
||||
return Err("indices müssen Dreiecke sein (Vielfaches von 3)".into());
|
||||
}
|
||||
let n_verts = positions.len() / 3;
|
||||
let mut polygons = Vec::with_capacity(indices.len() / 3);
|
||||
for tri in indices.chunks(3) {
|
||||
let mut pts = [Point3::origin(); 3];
|
||||
for (k, &i) in tri.iter().enumerate() {
|
||||
let idx = i as usize;
|
||||
if idx >= n_verts {
|
||||
return Err(format!("Index {idx} außerhalb der Positions-Liste"));
|
||||
}
|
||||
let o = idx * 3;
|
||||
pts[k] = Point3::new(positions[o], positions[o + 1], positions[o + 2]);
|
||||
}
|
||||
let normal = (pts[1] - pts[0]).cross(&(pts[2] - pts[0]));
|
||||
let verts = vec![
|
||||
Vertex::new(pts[0], normal),
|
||||
Vertex::new(pts[1], normal),
|
||||
Vertex::new(pts[2], normal),
|
||||
];
|
||||
polygons.push(Polygon::new(verts, ()));
|
||||
}
|
||||
Ok(CsgMesh::from_polygons(&polygons, ()))
|
||||
}
|
||||
|
||||
fn triangles_from_mesh(m: &CsgMesh<()>) -> (Vec<f32>, Vec<u32>) {
|
||||
let mut positions: Vec<f32> = Vec::new();
|
||||
let mut indices: Vec<u32> = Vec::new();
|
||||
let mut next = 0u32;
|
||||
m.visit_triangles(|tri| {
|
||||
for v in &tri {
|
||||
positions.push(v.position.x as f32);
|
||||
positions.push(v.position.y as f32);
|
||||
positions.push(v.position.z as f32);
|
||||
}
|
||||
indices.push(next);
|
||||
indices.push(next + 1);
|
||||
indices.push(next + 2);
|
||||
next += 3;
|
||||
});
|
||||
(positions, indices)
|
||||
}
|
||||
|
||||
pub fn boolean_mesh_core(
|
||||
a_positions: &[f64],
|
||||
a_indices: &[u32],
|
||||
b_positions: &[f64],
|
||||
b_indices: &[u32],
|
||||
op: &str,
|
||||
) -> Result<BooleanMeshOutput, String> {
|
||||
let a = mesh_from_triangles(a_positions, a_indices)?;
|
||||
let b = mesh_from_triangles(b_positions, b_indices)?;
|
||||
let result = match op {
|
||||
"union" => a.union(&b),
|
||||
"difference" => a.difference(&b),
|
||||
"intersection" => a.intersection(&b),
|
||||
other => return Err(format!("unbekannte boolesche Operation: {other}")),
|
||||
};
|
||||
let (positions, indices) = triangles_from_mesh(&result);
|
||||
Ok(BooleanMeshOutput {
|
||||
empty: indices.is_empty(),
|
||||
positions,
|
||||
indices,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cuboid(x: f64, y: f64, z: f64, w: f64, l: f64, h: f64) -> (Vec<f64>, Vec<u32>) {
|
||||
let corners = [
|
||||
(x, y, z),
|
||||
(x + w, y, z),
|
||||
(x + w, y + l, z),
|
||||
(x, y + l, z),
|
||||
(x, y, z + h),
|
||||
(x + w, y, z + h),
|
||||
(x + w, y + l, z + h),
|
||||
(x, y + l, z + h),
|
||||
];
|
||||
let mut positions = Vec::with_capacity(24);
|
||||
for (cx, cy, cz) in corners {
|
||||
positions.push(cx);
|
||||
positions.push(cy);
|
||||
positions.push(cz);
|
||||
}
|
||||
// 12 Dreiecke, konsistent nach außen orientiert (Rechte-Hand-Regel).
|
||||
let indices: Vec<u32> = vec![
|
||||
0, 2, 1, 0, 3, 2, // unten (-z)
|
||||
4, 5, 6, 4, 6, 7, // oben (+z)
|
||||
0, 5, 4, 0, 1, 5, // -y
|
||||
1, 6, 5, 1, 2, 6, // +x
|
||||
2, 7, 6, 2, 3, 7, // +y
|
||||
3, 4, 7, 3, 0, 4, // -x
|
||||
];
|
||||
(positions, indices)
|
||||
}
|
||||
|
||||
fn volume_of(positions: &[f32], indices: &[u32]) -> f64 {
|
||||
// Divergenztheorem (Tetraeder vom Ursprung), robust für beliebige geschlossene Dreiecksmeshes.
|
||||
let mut vol = 0.0f64;
|
||||
for tri in indices.chunks(3) {
|
||||
let mut p = [[0.0f64; 3]; 3];
|
||||
for (k, &i) in tri.iter().enumerate() {
|
||||
let o = i as usize * 3;
|
||||
p[k] = [
|
||||
positions[o] as f64,
|
||||
positions[o + 1] as f64,
|
||||
positions[o + 2] as f64,
|
||||
];
|
||||
}
|
||||
vol += (p[0][0] * (p[1][1] * p[2][2] - p[2][1] * p[1][2])
|
||||
- p[0][1] * (p[1][0] * p[2][2] - p[2][0] * p[1][2])
|
||||
+ p[0][2] * (p[1][0] * p[2][1] - p[2][0] * p[1][1]))
|
||||
/ 6.0;
|
||||
}
|
||||
vol.abs()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn union_of_overlapping_cuboids() {
|
||||
let (ap, ai) = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
|
||||
let (bp, bi) = cuboid(0.5, 0.5, 0.5, 1.0, 1.0, 1.0);
|
||||
let r = boolean_mesh_core(&ap, &ai, &bp, &bi, "union").unwrap();
|
||||
assert!(!r.empty);
|
||||
assert!((volume_of(&r.positions, &r.indices) - 1.875).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intersection_of_overlapping_cuboids() {
|
||||
let (ap, ai) = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
|
||||
let (bp, bi) = cuboid(0.5, 0.5, 0.5, 1.0, 1.0, 1.0);
|
||||
let r = boolean_mesh_core(&ap, &ai, &bp, &bi, "intersection").unwrap();
|
||||
assert!(!r.empty);
|
||||
assert!((volume_of(&r.positions, &r.indices) - 0.125).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// Praxisfall: Extrusion 0.1m in eine Wand eingebunden, deckungsgleicher
|
||||
/// Querschnitt — genau der Fall, an dem monstertruck-solid scheiterte.
|
||||
#[test]
|
||||
fn wall_minus_embedded_extrusion_matching_cross_section() {
|
||||
let wall = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
|
||||
let embedded = cuboid(0.9, 0.0, 0.0, 1.0, 1.0, 1.0);
|
||||
let r = boolean_mesh_core(&wall.0, &wall.1, &embedded.0, &embedded.1, "difference").unwrap();
|
||||
assert!(!r.empty);
|
||||
assert!((volume_of(&r.positions, &r.indices) - 0.9).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_touching_union_is_exact() {
|
||||
let a = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
|
||||
let b = cuboid(1.0, 0.0, 0.0, 1.0, 1.0, 1.0);
|
||||
let r = boolean_mesh_core(&a.0, &a.1, &b.0, &b.1, "union").unwrap();
|
||||
assert!(!r.empty);
|
||||
assert!((volume_of(&r.positions, &r.indices) - 2.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_triangle_indices() {
|
||||
let (ap, _) = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
|
||||
let bad_indices = vec![0u32, 1, 2, 3];
|
||||
assert!(boolean_mesh_core(&ap, &bad_indices, &ap, &bad_indices, "union").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_op() {
|
||||
let (ap, ai) = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
|
||||
assert!(boolean_mesh_core(&ap, &ai, &ap, &ai, "xor").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
// Profil-Extrusion via truck B-Rep. Erzeugt ein tesselliertes Mesh aus einem
|
||||
// 2D-Polygon-Querschnitt (XY-Ebene, Modell-Meter) durch lineare Extrusion
|
||||
// entlang +Z. Ausgabe ist kompatibel mit render3d::types::MeshInput.
|
||||
//
|
||||
// truck-Architektur:
|
||||
// truck_modeling::builder — Vertex/Edge/Wire/Face bauen + tsweep (Validierung)
|
||||
// Tessellierung — direkt aus den Eingangskoordinaten (Prismen-Geometrie)
|
||||
// Bewusst NICHT genutzt: truck-modeling Booleans (instabil upstream).
|
||||
// truck-polymesh wird nicht benötigt (keine Tess.-API für Solids in 0.3).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use truck_modeling::*;
|
||||
|
||||
mod boolean;
|
||||
pub use boolean::{boolean_mesh_core, BooleanMeshInput, BooleanMeshOutput};
|
||||
|
||||
/// Flaches [x0,y0, x1,y1, …] Array + Höhe → Extrusion.
|
||||
#[derive(Deserialize)]
|
||||
pub struct ExtrudePolyInput {
|
||||
pub points: Vec<f64>,
|
||||
pub height: f64,
|
||||
/// Verjüngung 0.0 (Prisma, Default) … 1.0 (Spitze/Kegel-Pyramide). Fehlt
|
||||
/// das Feld (ältere Aufrufer), greift serde-Default 0.0 — unverändertes
|
||||
/// Prisma-Verhalten.
|
||||
#[serde(default)]
|
||||
pub taper: f64,
|
||||
}
|
||||
|
||||
/// Tesselliertes Mesh, kompatibel mit render3d::types::MeshInput.
|
||||
/// positions: flat [x,y,z, …] in Modell-Metern; indices: Dreiecks-Indizes.
|
||||
#[derive(Serialize)]
|
||||
pub struct MeshOutput {
|
||||
pub positions: Vec<f32>,
|
||||
pub indices: Vec<u32>,
|
||||
}
|
||||
|
||||
/// Extrudiert ein geschlossenes Polygon (≥3 Punkte) um `height` Meter nach +Z,
|
||||
/// optional verjüngt (`taper` 0.0 Prisma … 1.0 Spitze/Kegel-Pyramide — linear
|
||||
/// zum Schwerpunkt skaliert). Nutzt truck::builder zur Validierung der
|
||||
/// Grundfläche; tesselliert dann direkt aus den Koordinaten.
|
||||
pub fn extrude_polygon_core(
|
||||
pts: &[(f64, f64)],
|
||||
height: f64,
|
||||
taper: f64,
|
||||
) -> std::result::Result<MeshOutput, String> {
|
||||
if pts.len() < 3 {
|
||||
return Err("min 3 Punkte".into());
|
||||
}
|
||||
if height <= 0.0 {
|
||||
return Err("height muss > 0 sein".into());
|
||||
}
|
||||
if !(0.0..=1.0).contains(&taper) {
|
||||
return Err("taper muss zwischen 0 und 1 liegen".into());
|
||||
}
|
||||
|
||||
// Letzten Punkt entfernen falls er den ersten wiederholt (geschlossener Ring).
|
||||
let ring: Vec<(f64, f64)> = {
|
||||
let mut r = pts.to_vec();
|
||||
if r.len() >= 2 {
|
||||
let first = r[0];
|
||||
let last = *r.last().unwrap();
|
||||
if (first.0 - last.0).abs() < 1e-10 && (first.1 - last.1).abs() < 1e-10 {
|
||||
r.pop();
|
||||
}
|
||||
}
|
||||
r
|
||||
};
|
||||
if ring.len() < 3 {
|
||||
return Err("min 3 eindeutige Punkte".into());
|
||||
}
|
||||
let n = ring.len();
|
||||
|
||||
// ── truck B-Rep: try_attach_plane validiert Planarität und Degenerierung ──
|
||||
let verts: Vec<Vertex> = ring
|
||||
.iter()
|
||||
.map(|&(x, y)| builder::vertex(Point3::new(x, y, 0.0)))
|
||||
.collect();
|
||||
let edges: Vec<Edge> = (0..n)
|
||||
.map(|i| builder::line(&verts[i], &verts[(i + 1) % n]))
|
||||
.collect();
|
||||
let wire = Wire::from_iter(edges);
|
||||
// Gibt Err zurück bei degeneriertem / nicht-ebenem Profil.
|
||||
let face = builder::try_attach_plane(&[wire]).map_err(|e| format!("{e}"))?;
|
||||
// tsweep erzeugt den Solid — wir nutzen ihn zur Vollständigkeit, auch wenn
|
||||
// wir ihn für die Tessellierung nicht direkt traversieren (nur fürs
|
||||
// ungetaperte Prisma sinnvoll validiert; Verjüngung ist reine Tessellierung).
|
||||
let _solid: Solid = builder::tsweep(&face, Vector3::new(0.0, 0.0, height));
|
||||
|
||||
let centroid = polygon_centroid(&ring);
|
||||
|
||||
// Deckel-/Boden-Triangulierung fuer BELIEBIGE (auch konkave) Profile per
|
||||
// Ohr-Clipping — NICHT per Fächer-ab-Vertex-0 (der nur fuer konvexe bzw.
|
||||
// von Vertex 0 aus sternfoermige Polygone korrekt ist; bei einem T-Traeger
|
||||
// o.ae. erzeugt ein Fächer Phantom-Dreiecke quer durch die konkave Kerbe).
|
||||
// `cap_tris` ist bereits in der Konvention orientiert, die der bisherige
|
||||
// "Fächer ab Vertex 0" fuer KONVEXE Ringe erzeugt hat (a,b,c mit
|
||||
// aufsteigendem Index), sodass die Deck-/Boden-Zuordnung unten unveraendert
|
||||
// bleibt.
|
||||
let cap_tris = ear_triangulate(&ring);
|
||||
|
||||
// Volle Verjüngung (taper ≈ 1): Spitze statt entartetem Deck-Ring — Kegel/
|
||||
// Pyramide als n Boden-Vertices + 1 Spitzen-Vertex, sonst gäbe es
|
||||
// Nulldreiecke (Deckfläche + halbe Seitenflächen) mit Kreuzprodukt-Normale
|
||||
// (0,0,0) am Deck.
|
||||
if taper >= 1.0 - 1e-9 {
|
||||
let mut positions: Vec<f32> = Vec::with_capacity((n + 1) * 3);
|
||||
for &(x, y) in &ring {
|
||||
positions.extend_from_slice(&[x as f32, y as f32, 0.0_f32]);
|
||||
}
|
||||
positions.extend_from_slice(&[centroid.0 as f32, centroid.1 as f32, height as f32]);
|
||||
let apex = n as u32;
|
||||
|
||||
let mut indices: Vec<u32> = Vec::with_capacity((cap_tris.len() + n) * 3);
|
||||
// Bodenfläche: wie im Prisma-Fall die zu "oben" umgekehrte Wicklung
|
||||
// (CCW von unten).
|
||||
for &(a, b, c) in &cap_tris {
|
||||
indices.extend_from_slice(&[a as u32, c as u32, b as u32]);
|
||||
}
|
||||
// Seitenflächen: ein Dreieck je Kante zur Spitze.
|
||||
for i in 0..n as u32 {
|
||||
let j = (i + 1) % n as u32;
|
||||
indices.extend_from_slice(&[i, j, apex]);
|
||||
}
|
||||
return Ok(MeshOutput { positions, indices });
|
||||
}
|
||||
|
||||
// ── Tessellierung (Prisma bei taper=0, sonst Pyramidenstumpf) ──
|
||||
// Für ein n-Eck ergeben sich:
|
||||
// Bodenfläche: len(cap_tris) Dreiecke (Ohr-Clipping, konkav-sicher)
|
||||
// Deckfläche: len(cap_tris) Dreiecke
|
||||
// Seitenflächen: n * 2 Dreiecke (je Kante ein Rechteck → 2 Dreiecke)
|
||||
|
||||
let mut positions: Vec<f32> = Vec::with_capacity(2 * n * 3);
|
||||
let mut indices: Vec<u32> = Vec::with_capacity((cap_tris.len() * 2 + n * 2) * 3);
|
||||
|
||||
// Vertices: Boden (0..n-1) gefolgt von Dach (n..2n-1) — Dach zum
|
||||
// Schwerpunkt hin um (1-taper) skaliert (taper=0 ⇒ Prisma, unveränderte
|
||||
// Kontur; 0<taper<1 ⇒ Pyramidenstumpf).
|
||||
let scale = 1.0 - taper;
|
||||
for &(x, y) in &ring {
|
||||
positions.extend_from_slice(&[x as f32, y as f32, 0.0_f32]);
|
||||
}
|
||||
for &(x, y) in &ring {
|
||||
let tx = centroid.0 + (x - centroid.0) * scale;
|
||||
let ty = centroid.1 + (y - centroid.1) * scale;
|
||||
positions.extend_from_slice(&[tx as f32, ty as f32, height as f32]);
|
||||
}
|
||||
|
||||
let base = 0u32;
|
||||
let top = n as u32;
|
||||
|
||||
// Bodenfläche: umgekehrte Wicklung relativ zur Deckfläche (CCW von unten).
|
||||
for &(a, b, c) in &cap_tris {
|
||||
indices.extend_from_slice(&[base + a as u32, base + c as u32, base + b as u32]);
|
||||
}
|
||||
|
||||
// Deckfläche: gleiche Wicklung wie `cap_tris` (CCW von oben).
|
||||
for &(a, b, c) in &cap_tris {
|
||||
indices.extend_from_slice(&[top + a as u32, top + b as u32, top + c as u32]);
|
||||
}
|
||||
|
||||
// Seitenflächen: Rechteck pro Kante → 2 Dreiecke
|
||||
for i in 0..n as u32 {
|
||||
let j = (i + 1) % n as u32;
|
||||
// Unteres Dreieck
|
||||
indices.extend_from_slice(&[base + i, base + j, top + i]);
|
||||
// Oberes Dreieck
|
||||
indices.extend_from_slice(&[base + j, top + j, top + i]);
|
||||
}
|
||||
|
||||
Ok(MeshOutput { positions, indices })
|
||||
}
|
||||
|
||||
/// Signierte Fläche (Shoelace) — positiv bei CCW-Umlauf.
|
||||
fn signed_area_f64(ring: &[(f64, f64)]) -> f64 {
|
||||
let n = ring.len();
|
||||
let mut a = 0.0;
|
||||
let mut j = n - 1;
|
||||
for i in 0..n {
|
||||
a += ring[j].0 * ring[i].1 - ring[i].0 * ring[j].1;
|
||||
j = i;
|
||||
}
|
||||
a * 0.5
|
||||
}
|
||||
|
||||
/// Flächen-gewichteter Schwerpunkt eines einfachen Polygons (nicht der reine
|
||||
/// Vertex-Mittelwert, der bei asymmetrischen/konkaven Profilen verzerrt ist).
|
||||
/// Fallback auf Vertex-Mittelwert bei (nahezu) entarteter Fläche.
|
||||
fn polygon_centroid(ring: &[(f64, f64)]) -> (f64, f64) {
|
||||
let n = ring.len();
|
||||
let a = signed_area_f64(ring);
|
||||
if a.abs() < 1e-12 {
|
||||
let (sx, sy) = ring.iter().fold((0.0, 0.0), |(sx, sy), &(x, y)| (sx + x, sy + y));
|
||||
return (sx / n as f64, sy / n as f64);
|
||||
}
|
||||
let mut cx = 0.0;
|
||||
let mut cy = 0.0;
|
||||
for i in 0..n {
|
||||
let (x0, y0) = ring[i];
|
||||
let (x1, y1) = ring[(i + 1) % n];
|
||||
let cross = x0 * y1 - x1 * y0;
|
||||
cx += (x0 + x1) * cross;
|
||||
cy += (y0 + y1) * cross;
|
||||
}
|
||||
(cx / (6.0 * a), cy / (6.0 * a))
|
||||
}
|
||||
|
||||
/// Kreuzprodukt (b-a) x (c-a) im Grundriss.
|
||||
#[inline]
|
||||
fn cross2_f64(a: (f64, f64), b: (f64, f64), c: (f64, f64)) -> f64 {
|
||||
(b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0)
|
||||
}
|
||||
|
||||
/// Liegt p im (a,b,c)-Dreieck? (CCW-orientiert).
|
||||
fn point_in_tri_f64(a: (f64, f64), b: (f64, f64), c: (f64, f64), p: (f64, f64)) -> bool {
|
||||
let d1 = cross2_f64(a, b, p);
|
||||
let d2 = cross2_f64(b, c, p);
|
||||
let d3 = cross2_f64(c, a, p);
|
||||
let has_neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0;
|
||||
let has_pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0;
|
||||
!(has_neg && has_pos)
|
||||
}
|
||||
|
||||
/// Ear-Clipping-Triangulierung eines einfachen (lochfreien) Rings — robust fuer
|
||||
/// konvexe UND konkave Profile (z. B. T-Traeger, L-Profil, Freiform). Portiert
|
||||
/// von `render3d::mesh::triangulate` (dort fuer Decken/Slabs genutzt) auf f64.
|
||||
/// O(n^2), fuer Extrusionsprofile mit wenigen Ecken voellig ausreichend.
|
||||
/// Liefert Dreiecke als (a,b,c)-Indextripel (0-basiert auf `ring`), in der
|
||||
/// Wicklung des (intern auf CCW normalisierten) Rings.
|
||||
fn ear_triangulate(ring: &[(f64, f64)]) -> Vec<(usize, usize, usize)> {
|
||||
let n = ring.len();
|
||||
if n < 3 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut idx: Vec<usize> = (0..n).collect();
|
||||
if signed_area_f64(ring) < 0.0 {
|
||||
idx.reverse();
|
||||
}
|
||||
|
||||
let mut tris: Vec<(usize, usize, usize)> = Vec::new();
|
||||
let mut guard = 0usize;
|
||||
let max_guard = n * n + 16;
|
||||
|
||||
while idx.len() > 3 && guard < max_guard {
|
||||
guard += 1;
|
||||
let mut clipped = false;
|
||||
let m = idx.len();
|
||||
for i in 0..m {
|
||||
let i_prev = idx[(i + m - 1) % m];
|
||||
let i_cur = idx[i];
|
||||
let i_next = idx[(i + 1) % m];
|
||||
let a = ring[i_prev];
|
||||
let b = ring[i_cur];
|
||||
let c = ring[i_next];
|
||||
if cross2_f64(a, b, c) <= 0.0 {
|
||||
continue; // konkav/kollinear -> kein Ohr
|
||||
}
|
||||
let mut contains = false;
|
||||
for &vi in &idx {
|
||||
if vi == i_prev || vi == i_cur || vi == i_next {
|
||||
continue;
|
||||
}
|
||||
if point_in_tri_f64(a, b, c, ring[vi]) {
|
||||
contains = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if contains {
|
||||
continue;
|
||||
}
|
||||
tris.push((i_prev, i_cur, i_next));
|
||||
idx.remove(i);
|
||||
clipped = true;
|
||||
break;
|
||||
}
|
||||
if !clipped {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if idx.len() == 3 {
|
||||
tris.push((idx[0], idx[1], idx[2]));
|
||||
}
|
||||
tris
|
||||
}
|
||||
|
||||
/// Zylinder-Extrusion (Kreis-Querschnitt), optional verjüngt (Kegel bei
|
||||
/// `taper=1.0`). Tesselliert den Kreis in N Segmente und ruft
|
||||
/// `extrude_polygon_core` auf.
|
||||
pub fn extrude_circle_core(
|
||||
cx: f64,
|
||||
cy: f64,
|
||||
r: f64,
|
||||
height: f64,
|
||||
taper: f64,
|
||||
) -> std::result::Result<MeshOutput, String> {
|
||||
if r <= 0.0 {
|
||||
return Err("Radius muss > 0 sein".into());
|
||||
}
|
||||
let n = ((2.0 * std::f64::consts::PI * r / 0.02).ceil() as usize).max(16);
|
||||
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, taper)
|
||||
}
|
||||
|
||||
// ── WASM-Bindings (nur mit 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 }`
|
||||
/// Output JSON: `{ "positions": […], "indices": […] }` oder JsError.
|
||||
#[wasm_bindgen]
|
||||
pub fn extrude_polygon(input_json: &str) -> std::result::Result<String, JsError> {
|
||||
let input: ExtrudePolyInput =
|
||||
serde_json::from_str(input_json).map_err(|e| JsError::new(&e.to_string()))?;
|
||||
if input.points.len() % 2 != 0 {
|
||||
return Err(JsError::new("points muss x/y-Paare enthalten"));
|
||||
}
|
||||
let pts: Vec<(f64, f64)> = input
|
||||
.points
|
||||
.chunks(2)
|
||||
.map(|c| (c[0], c[1]))
|
||||
.collect();
|
||||
let mesh = extrude_polygon_core(&pts, input.height, input.taper)
|
||||
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||
serde_json::to_string(&mesh).map_err(|e| JsError::new(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Extrudiert einen Kreis-Querschnitt (Zylinder, oder Kegel bei `taper=1`).
|
||||
/// Input JSON: `{ "cx": 0, "cy": 0, "r": 0.15, "height": 3.0, "taper": 0.0 }`
|
||||
#[wasm_bindgen]
|
||||
pub fn extrude_circle(input_json: &str) -> std::result::Result<String, JsError> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct In {
|
||||
cx: f64,
|
||||
cy: f64,
|
||||
r: f64,
|
||||
height: f64,
|
||||
#[serde(default)]
|
||||
taper: 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, i.taper)
|
||||
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||
serde_json::to_string(&mesh).map_err(|e| JsError::new(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Boolesche Operation zwischen zwei Dreiecks-Meshes (Mesh-Ebenen-CSG via
|
||||
/// csgrs). Input JSON: `{ "a_positions": […], "a_indices": […],
|
||||
/// "b_positions": […], "b_indices": […], "op": "union"|"difference"|"intersection" }`
|
||||
/// Output JSON: `{ "positions": […], "indices": […], "empty": bool }`
|
||||
#[wasm_bindgen]
|
||||
pub fn boolean_mesh(input_json: &str) -> std::result::Result<String, JsError> {
|
||||
let input: BooleanMeshInput =
|
||||
serde_json::from_str(input_json).map_err(|e| JsError::new(&e.to_string()))?;
|
||||
let mesh = boolean_mesh_core(
|
||||
&input.a_positions,
|
||||
&input.a_indices,
|
||||
&input.b_positions,
|
||||
&input.b_indices,
|
||||
&input.op,
|
||||
)
|
||||
.map_err(|e| JsError::new(&e))?;
|
||||
serde_json::to_string(&mesh).map_err(|e| JsError::new(&e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn quad_extrusion_vertex_count() {
|
||||
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, 0.0).unwrap();
|
||||
// 2 * 4 = 8 Vertices → 24 Floats
|
||||
assert_eq!(m.positions.len(), 8 * 3, "Vertex-Anzahl");
|
||||
assert_eq!(m.indices.len() % 3, 0, "unvollständige Dreiecke");
|
||||
// Quader: 2*(4-2) + 4*2 = 4 + 8 = 12 Dreiecke
|
||||
assert_eq!(m.indices.len(), 12 * 3, "Dreieck-Anzahl für Quader");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn l_profile_extrusion() {
|
||||
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, 0.0).unwrap();
|
||||
assert_eq!(m.indices.len() % 3, 0);
|
||||
assert!(!m.positions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_extrusion() {
|
||||
let m = extrude_circle_core(0.0, 0.0, 0.15, 3.0, 0.0).unwrap();
|
||||
assert_eq!(m.indices.len() % 3, 0);
|
||||
assert!(!m.positions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_too_few_points() {
|
||||
assert!(extrude_polygon_core(&[(0.0, 0.0), (1.0, 0.0)], 1.0, 0.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, 0.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_taper_out_of_range() {
|
||||
let pts = vec![(0.0, 0.0), (1.0, 0.0), (0.5, 1.0)];
|
||||
assert!(extrude_polygon_core(&pts, 1.0, 1.5).is_err());
|
||||
assert!(extrude_polygon_core(&pts, 1.0, -0.1).is_err());
|
||||
}
|
||||
|
||||
/// Kegel-Volumen (taper=1, Kreis-Querschnitt) via Divergenzsatz gegen die
|
||||
/// analytische Formel V = π·r²·h/3 geprüft.
|
||||
#[test]
|
||||
fn cone_taper_one_has_correct_volume() {
|
||||
let r = 0.5;
|
||||
let h = 2.0;
|
||||
let m = extrude_circle_core(0.0, 0.0, r, h, 1.0).unwrap();
|
||||
// Spitze ist der letzte Vertex — Boden-Vertices sind alle bei z=0, kein
|
||||
// separater Deck-Ring mehr (kein Nulldreieck-Risiko).
|
||||
assert_eq!(m.positions.len() % 3, 0);
|
||||
let n_verts = m.positions.len() / 3;
|
||||
// Boden-N-Eck + 1 Spitze.
|
||||
let apex_z = m.positions[(n_verts - 1) * 3 + 2];
|
||||
assert!((apex_z as f64 - h).abs() < 1e-6, "Spitze muss bei z=height liegen");
|
||||
|
||||
let mut vol = 0.0f64;
|
||||
for tri in m.indices.chunks(3) {
|
||||
let mut p = [[0.0f64; 3]; 3];
|
||||
for (k, &i) in tri.iter().enumerate() {
|
||||
let o = i as usize * 3;
|
||||
p[k] = [m.positions[o] as f64, m.positions[o + 1] as f64, m.positions[o + 2] as f64];
|
||||
}
|
||||
vol += (p[0][0] * (p[1][1] * p[2][2] - p[2][1] * p[1][2])
|
||||
- p[0][1] * (p[1][0] * p[2][2] - p[2][0] * p[1][2])
|
||||
+ p[0][2] * (p[1][0] * p[2][1] - p[2][0] * p[1][1]))
|
||||
/ 6.0;
|
||||
}
|
||||
let expected = std::f64::consts::PI * r * r * h / 3.0;
|
||||
// Kreis ist n-eck-tesselliert (nicht analytisch exakt) → grobe Toleranz.
|
||||
assert!((vol.abs() - expected).abs() / expected < 0.01, "Kegel-Volumen: {} vs {}", vol.abs(), expected);
|
||||
}
|
||||
|
||||
/// Pyramidenstumpf (taper=0.5, Quadrat-Querschnitt): Deckfläche muss auf
|
||||
/// die Hälfte der Kantenlänge geschrumpft sein, zentriert um den
|
||||
/// Schwerpunkt der Grundfläche.
|
||||
#[test]
|
||||
fn frustum_taper_half_shrinks_top_toward_centroid() {
|
||||
let pts = vec![(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)];
|
||||
let m = extrude_polygon_core(&pts, 1.0, 0.5).unwrap();
|
||||
// Prisma-Topologie bleibt erhalten (2n Vertices, kein Spitzen-Sonderfall).
|
||||
assert_eq!(m.positions.len(), 8 * 3);
|
||||
// Deck-Ring: Vertices 4..7. Schwerpunkt der Grundfläche ist (1,1).
|
||||
// scale=0.5 ⇒ Ecke (0,0)→(1,1)+0.5*((0,0)-(1,1))=(0.5,0.5).
|
||||
let top0 = (m.positions[4 * 3] as f64, m.positions[4 * 3 + 1] as f64);
|
||||
assert!((top0.0 - 0.5).abs() < 1e-6 && (top0.1 - 0.5).abs() < 1e-6, "Deck-Ecke: {:?}", top0);
|
||||
}
|
||||
|
||||
/// Punkt-in-Polygon (Ray-Casting) für die Korrektheits-Prüfung unten.
|
||||
fn point_in_polygon(p: (f64, f64), poly: &[(f64, f64)]) -> bool {
|
||||
let n = poly.len();
|
||||
let mut inside = false;
|
||||
let mut j = n - 1;
|
||||
for i in 0..n {
|
||||
let (xi, yi) = poly[i];
|
||||
let (xj, yj) = poly[j];
|
||||
if ((yi > p.1) != (yj > p.1)) && (p.0 < (xj - xi) * (p.1 - yi) / (yj - yi) + xi) {
|
||||
inside = !inside;
|
||||
}
|
||||
j = i;
|
||||
}
|
||||
inside
|
||||
}
|
||||
|
||||
/// T-Träger-Querschnitt (konkav, NICHT von Vertex 0 aus sternförmig) —
|
||||
/// genau das Zielprofil aus PENDENZEN/truck-plan.md. Eine Fächer-Triangu-
|
||||
/// lierung ab Vertex 0 erzeugt hier Phantom-Dreiecke quer durch die
|
||||
/// konkave Kerbe (nachgerechnet: 2 von 6 Fächer-Dreiecken liegen mit ihrem
|
||||
/// Schwerpunkt ausserhalb des Profils). Regressionstest fürs Ohr-Clipping.
|
||||
#[test]
|
||||
fn t_beam_caps_stay_inside_polygon() {
|
||||
let pts = vec![
|
||||
(1.0, 0.0),
|
||||
(2.0, 0.0),
|
||||
(2.0, 1.0),
|
||||
(3.0, 1.0),
|
||||
(3.0, 2.0),
|
||||
(0.0, 2.0),
|
||||
(0.0, 1.0),
|
||||
(1.0, 1.0),
|
||||
];
|
||||
let m = extrude_polygon_core(&pts, 1.0, 0.0).unwrap();
|
||||
let mut checked = 0;
|
||||
for tri in m.indices.chunks(3) {
|
||||
let p: Vec<[f32; 3]> = tri
|
||||
.iter()
|
||||
.map(|&i| {
|
||||
let o = i as usize * 3;
|
||||
[m.positions[o], m.positions[o + 1], m.positions[o + 2]]
|
||||
})
|
||||
.collect();
|
||||
// Nur Deckel/Boden (alle 3 Ecken auf derselben Höhe) prüfen, nicht
|
||||
// die Seitenwand-Quads (die spannen z zwischen 0 und height).
|
||||
if (p[0][2] - p[1][2]).abs() > 1e-6 || (p[1][2] - p[2][2]).abs() > 1e-6 {
|
||||
continue;
|
||||
}
|
||||
let centroid = (
|
||||
(p[0][0] + p[1][0] + p[2][0]) as f64 / 3.0,
|
||||
(p[0][1] + p[1][1] + p[2][1]) as f64 / 3.0,
|
||||
);
|
||||
assert!(
|
||||
point_in_polygon(centroid, &pts),
|
||||
"Deckel-/Boden-Dreieck-Schwerpunkt {:?} liegt ausserhalb des T-Profils",
|
||||
centroid
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
assert!(checked > 0, "kein Deckel-/Boden-Dreieck gefunden");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user