//! PSC I-girder cross-section geometry — pure Rust, no external kernel. //! //! Generates a triangulated mesh by sweeping a PSC-I polygon profile along Z. //! Flat normals (face normals, faceted appearance). Units: millimetres. //! //! This module lets cimery visualise PSC-I girders without OCCT. //! When OcctKernel is available it produces higher-quality B-rep geometry //! (fillets, accurate haunches, proper mesh density). use cimery_ir::PscISectionParams; use crate::{KernelError, Mesh}; // ─── Public API ─────────────────────────────────────────────────────────────── /// Build a closed triangulated mesh for a PSC I-girder by sweeping the profile. /// /// Coordinate system: X = width (centred on web), Y = height (0 = soffit), Z = span. pub fn build_psc_i_mesh( p: &PscISectionParams, span_mm: f64, ) -> Result { if span_mm <= 0.0 { return Err(KernelError::InvalidInput( format!("span must be positive, got {span_mm} mm"), )); } let profile = psc_i_profile(p)?; Ok(sweep_profile_flat(&profile, span_mm as f32)) } // ─── Profile ───────────────────────────────────────────────────────────────── /// 14-vertex PSC-I cross-section polygon. /// Vertices are ordered **CCW when viewed from –Z** (start face). /// Origin: bottom centre of bottom flange (X=0 is web centre, Y=0 is soffit). fn psc_i_profile(p: &PscISectionParams) -> Result, KernelError> { let hw = (p.top_flange_width / 2.0) as f32; let hbw = (p.bottom_flange_width / 2.0) as f32; let hwb = (p.web_thickness / 2.0) as f32; let h = p.total_height as f32; let tft = p.top_flange_thickness as f32; let bft = p.bottom_flange_thickness as f32; let hch = p.haunch as f32; if hw <= hwb { return Err(KernelError::InvalidInput( "top_flange_width must be > web_thickness".into(), )); } if hbw <= hwb { return Err(KernelError::InvalidInput( "bottom_flange_width must be > web_thickness".into(), )); } if tft + bft >= h { return Err(KernelError::InvalidInput( "sum of flange thicknesses must be < total_height".into(), )); } // 14 vertices, CCW from bottom-left Ok(vec![ [-hbw, 0.0 ], // 0 bottom-left outer [ hbw, 0.0 ], // 1 bottom-right outer [ hbw, bft ], // 2 bottom flange top-right [ hwb, bft ], // 3 web right, bottom [ hwb, h - tft - hch], // 4 web right, top (haunch start) [ hwb + hch, h - tft ], // 5 haunch junction right [ hw, h - tft ], // 6 top flange inner bottom-right [ hw, h ], // 7 top flange outer top-right [-hw, h ], // 8 top flange outer top-left [-hw, h - tft ], // 9 top flange inner bottom-left [-(hwb+hch), h - tft ], // 10 haunch junction left [-hwb, h - tft - hch], // 11 web left, top [-hwb, bft ], // 12 web left, bottom [-hbw, bft ], // 13 bottom flange top-left ]) } // ─── Sweep ──────────────────────────────────────────────────────────────────── /// Sweep a closed polygon profile along Z, producing a closed solid. /// /// Uses flat normals (no shared vertices between adjacent faces). /// Each triangle has 3 unique vertices with the same face normal. fn sweep_profile_flat(profile: &[[f32; 2]], span: f32) -> Mesh { let n = profile.len(); let mut vertices: Vec<[f32; 3]> = Vec::new(); let mut normals: Vec<[f32; 3]> = Vec::new(); let mut indices: Vec = Vec::new(); // Helper: push one triangle and record face normal let mut push_tri = |v0: [f32; 3], v1: [f32; 3], v2: [f32; 3]| { let normal = face_normal(v0, v1, v2); for v in [v0, v1, v2] { let idx = vertices.len() as u32; vertices.push(v); normals.push(normal); indices.push(idx); } }; // ── Side faces: one quad (2 tris) per profile edge ───────────────────── for i in 0..n { let j = (i + 1) % n; let [x0, y0] = profile[i]; let [x1, y1] = profile[j]; let a = [x0, y0, 0.0]; let b = [x1, y1, 0.0]; let c = [x1, y1, span]; let d = [x0, y0, span]; push_tri(a, b, c); push_tri(a, c, d); } // ── End caps: fan triangulation from centroid ────────────────────────── let cx: f32 = profile.iter().map(|v| v[0]).sum::() / n as f32; let cy: f32 = profile.iter().map(|v| v[1]).sum::() / n as f32; // Front cap (Z = 0, normal = –Z). CCW from –Z: centre, then CW in XY. let cen_front = [cx, cy, 0.0]; for i in 0..n { let j = (i + 1) % n; let a = [profile[i][0], profile[i][1], 0.0]; let b = [profile[j][0], profile[j][1], 0.0]; push_tri(cen_front, b, a); } // Back cap (Z = span, normal = +Z). CCW from +Z: centre, then CCW in XY. let cen_back = [cx, cy, span]; for i in 0..n { let j = (i + 1) % n; let a = [profile[i][0], profile[i][1], span]; let b = [profile[j][0], profile[j][1], span]; push_tri(cen_back, a, b); } Mesh { vertices, normals, indices } } // ─── Math helpers ───────────────────────────────────────────────────────────── fn face_normal(a: [f32; 3], b: [f32; 3], c: [f32; 3]) -> [f32; 3] { let ab = [b[0]-a[0], b[1]-a[1], b[2]-a[2]]; let ac = [c[0]-a[0], c[1]-a[1], c[2]-a[2]]; let n = [ ab[1]*ac[2] - ab[2]*ac[1], ab[2]*ac[0] - ab[0]*ac[2], ab[0]*ac[1] - ab[1]*ac[0], ]; let len = (n[0]*n[0] + n[1]*n[1] + n[2]*n[2]).sqrt(); if len < 1e-10 { return [0.0, 1.0, 0.0]; } [n[0]/len, n[1]/len, n[2]/len] } // ─── Tests ──────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use cimery_ir::PscISectionParams; fn kds() -> PscISectionParams { PscISectionParams::kds_standard() } #[test] fn profile_has_14_vertices() { let p = psc_i_profile(&kds()).unwrap(); assert_eq!(p.len(), 14); } #[test] fn mesh_has_correct_triangle_count() { // Side: 14 quads × 2 = 28 tris // Front cap: 14 tris // Back cap: 14 tris // Total: 56 tris = 168 vertices let mesh = build_psc_i_mesh(&kds(), 40_000.0).unwrap(); assert_eq!(mesh.triangle_count(), 56); assert_eq!(mesh.vertex_count(), 168); } #[test] fn aabb_spans_correct_z() { let span = 40_000.0_f64; let mesh = build_psc_i_mesh(&kds(), span).unwrap(); let (mn, mx) = mesh.aabb(); assert!((mx[2] - span as f32).abs() < 1.0); assert!(mn[2].abs() < 1.0); } #[test] fn all_normals_are_unit_length() { let mesh = build_psc_i_mesh(&kds(), 40_000.0).unwrap(); for n in &mesh.normals { let len = (n[0]*n[0] + n[1]*n[1] + n[2]*n[2]).sqrt(); assert!((len - 1.0).abs() < 1e-5, "normal not unit: {:?}", n); } } #[test] fn zero_span_fails() { assert!(build_psc_i_mesh(&kds(), 0.0).is_err()); } #[test] fn invalid_flange_width_fails() { let mut p = kds(); p.top_flange_width = 100.0; // less than web_thickness=200 assert!(build_psc_i_mesh(&p, 40_000.0).is_err()); } }