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:
2026-07-09 00:57:29 +02:00
parent 889cbb2c12
commit 35299307d6
131 changed files with 26501 additions and 852 deletions
+1488
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
[workspace]
[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-modeling bringt truck-geometry/topology/polymesh transitiv mit (alle
# auf derselben kompatiblen Version). Nur truck-modeling explizit pinnen —
# keine separaten geometry/topology-Einträge, die einen Versions-Konflikt erzeugen.
truck-modeling = "0.3"
# Mesh-Ebenen-CSG (Boolesche Operationen) — bewusst NICHT truck-modeling-Booleans
# (instabil bei koinzidenten Flächen, siehe PENDENZEN.md truck-Integration Phase 4).
# csgrs arbeitet auf Dreiecks-Ebene (BSP-Baum), robust genau in diesem Fall.
# Gepinnt auf einen Git-Commit (crates.io-Veröffentlichungen 0.160.20.1 sind alle
# wegen einer harten, zurückgezogenen core2-Abhängigkeit nicht installierbar; auf
# dem unveröffentlichten main-Branch bereits behoben). Nutzerautorisiert (Spike).
csgrs = { git = "https://github.com/timschmidt/csgrs", rev = "5e7a37a8803d4e56617734687edc9b98f4ebeed7", default-features = false, features = ["f64", "bmesh", "mesh", "earcut"] }
nalgebra = "0.34"
[dev-dependencies]
serde_json = "1"
[package.metadata.wasm-pack.profile.release]
wasm-opt = false
+212
View File
@@ -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());
}
}
+546
View File
@@ -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");
}
}