// 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, 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, pub indices: Vec, } /// 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 { 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 = ring .iter() .map(|&(x, y)| builder::vertex(Point3::new(x, y, 0.0))) .collect(); let edges: Vec = (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 = 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 = 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 = Vec::with_capacity(2 * n * 3); let mut indices: Vec = 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 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 = (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 { 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 { 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 { #[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 { 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"); } }