Sprint 14~22 — egui 리본 UI + OcctKernel B-rep + 가로보/신축이음 + 선형 좌표 + USD 익스포트 + WASM + CI/CD + 테스트 4층
Sprint 14: egui TopBottomPanel 리본 + CollapsingHeader SidePanel (상부구조·추가부재·선형·프로젝트) Sprint 15: IncrementalDb 전 Feature 타입 확장 (girder→7종), dirty-tracking 20 unit tests Sprint 16: Gitea + GitHub Actions CI/CD (check/test/clippy/fmt + 멀티플랫폼 릴리스) Sprint 17: AlignmentTransform + AlignmentScene — 선형 국소 프레임 → 세계 좌표 변환 Sprint 18: OcctKernel 교각(16각형 기둥+코핑) + 교대(흉벽+푸팅+날개벽) B-rep Sprint 19: CrossBeamIR + ExpansionJointIR — IR/DSL/kernel/scene 전 계층, sweep_profile_flat_x Sprint 20: 테스트 4층 — Layer1 insta 스냅샷(7종), Layer2 기하 불변량(19), Layer3 두-커널(7), Layer4 proptest(7) — 61 tests pass Sprint 21: cimery-usd PureRustKernel 실제 기하 변환 + BridgeExporter 증분 캐시 Sprint 22: viewer wasm feature + wasm-bindgen/web-sys + GitHub Actions Cloudflare Pages 배포 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,3 +20,8 @@ glam = { version = "0.24", optional = true } # must match opencascade-rs
|
||||
|
||||
[dev-dependencies]
|
||||
cimery-core = { workspace = true }
|
||||
# Sprint 20: 4-layer test suite
|
||||
insta = { version = "1", features = ["json"] }
|
||||
proptest = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
serde_json = "1"
|
||||
|
||||
92
cimery/crates/kernel/src/cross_beam.rs
Normal file
92
cimery/crates/kernel/src/cross_beam.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
//! Cross beam geometry (가로보). Sprint 19.
|
||||
//!
|
||||
//! Builds a swept cross beam mesh for H-section, rectangular, and I-section.
|
||||
//! The cross beam runs transversely (along X axis) between girders.
|
||||
//!
|
||||
//! Coordinate convention (same as bridge_scene):
|
||||
//! X = transverse (right = +)
|
||||
//! Y = vertical (up = +)
|
||||
//! Z = along-span axis
|
||||
//!
|
||||
//! The caller translates to the correct Z station position.
|
||||
|
||||
use cimery_ir::CrossBeamIR;
|
||||
use cimery_core::CrossBeamSection;
|
||||
use crate::{KernelError, Mesh, sweep};
|
||||
|
||||
/// Build a cross beam mesh from IR.
|
||||
///
|
||||
/// The mesh is centred at X=0, Y=0, and spans X = [-total_length/2 .. +total_length/2].
|
||||
/// The caller translates to bridge position.
|
||||
pub fn build_cross_beam_mesh(ir: &CrossBeamIR) -> Result<Mesh, KernelError> {
|
||||
let length = ir.total_length_mm() as f32;
|
||||
if length <= 0.0 {
|
||||
return Err(KernelError::InvalidInput(
|
||||
format!("cross beam total length must be positive: bay_count={}, girder_spacing={}", ir.bay_count, ir.girder_spacing)
|
||||
));
|
||||
}
|
||||
|
||||
let profile = match ir.section {
|
||||
CrossBeamSection::HSection => h_profile(ir),
|
||||
CrossBeamSection::Rectangular => rect_profile(ir),
|
||||
CrossBeamSection::ISection => i_profile(ir),
|
||||
};
|
||||
|
||||
// Sweep along X axis (transverse), centred at X=0
|
||||
let half = length * 0.5;
|
||||
let mesh = sweep::sweep_profile_flat_x(&profile, length);
|
||||
|
||||
// Translate so X goes from -half to +half
|
||||
let mut mesh = mesh;
|
||||
for v in &mut mesh.vertices { v[0] -= half; }
|
||||
|
||||
Ok(mesh)
|
||||
}
|
||||
|
||||
// ── Section profiles ──────────────────────────────────────────────────────────
|
||||
|
||||
/// H-section profile in YZ plane (to be swept along X).
|
||||
/// Returns [(y, z)] vertices for the closed section.
|
||||
fn h_profile(ir: &CrossBeamIR) -> Vec<[f32; 2]> {
|
||||
let hw = ir.web_thickness as f32 * 0.5;
|
||||
let hfw = ir.flange_width as f32 * 0.5;
|
||||
let ft = ir.flange_thickness as f32;
|
||||
let h = ir.web_height as f32;
|
||||
let tot = h + ft * 2.0;
|
||||
|
||||
// H-section profile (14 vertices), CCW in YZ plane
|
||||
// Bottom flange → web → top flange
|
||||
vec![
|
||||
[-hfw, 0.0], // bottom-left
|
||||
[ hfw, 0.0], // bottom-right
|
||||
[ hfw, ft], // bottom flange/web junction right
|
||||
[ hw, ft],
|
||||
[ hw, ft + h], // web top right
|
||||
[ hfw, ft + h], // top flange/web junction right
|
||||
[ hfw, tot], // top-right
|
||||
[-hfw, tot], // top-left
|
||||
[-hfw, ft + h],
|
||||
[-hw, ft + h],
|
||||
[-hw, ft],
|
||||
[-hfw, ft],
|
||||
[-hfw, 0.0], // close (duplicated start — handled by sweep)
|
||||
]
|
||||
}
|
||||
|
||||
/// Rectangular section profile.
|
||||
fn rect_profile(ir: &CrossBeamIR) -> Vec<[f32; 2]> {
|
||||
let hw = ir.flange_width as f32 * 0.5;
|
||||
let h = (ir.web_height + ir.flange_thickness * 2.0) as f32;
|
||||
vec![
|
||||
[-hw, 0.0],
|
||||
[ hw, 0.0],
|
||||
[ hw, h],
|
||||
[-hw, h],
|
||||
]
|
||||
}
|
||||
|
||||
/// I-section profile (asymmetric top/bottom flanges allowed by same params for now).
|
||||
fn i_profile(ir: &CrossBeamIR) -> Vec<[f32; 2]> {
|
||||
// Same as H but with distinct inner taper (simplified: use H profile)
|
||||
h_profile(ir)
|
||||
}
|
||||
95
cimery/crates/kernel/src/expansion_joint.rs
Normal file
95
cimery/crates/kernel/src/expansion_joint.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
//! Expansion joint geometry (신축이음). Sprint 19.
|
||||
//!
|
||||
//! Builds a mesh representing the expansion joint assembly.
|
||||
//! For visualisation purposes the joint is shown as two flat plates
|
||||
//! with a gap between them (simplification of the real mechanism).
|
||||
//!
|
||||
//! Coordinate convention:
|
||||
//! X = transverse (right = +)
|
||||
//! Y = vertical (up = +) — joint sits at Y=0 (top of deck)
|
||||
//! Z = along-span axis
|
||||
//!
|
||||
//! The joint body spans the full deck width (total_width) in X,
|
||||
//! extends `depth` downward in Y, and has total Z extent of:
|
||||
//! joint_thickness × 2 + gap_width
|
||||
|
||||
use cimery_ir::ExpansionJointIR;
|
||||
use crate::{KernelError, Mesh, sweep};
|
||||
|
||||
/// Plate thickness used for the visual joint body [mm].
|
||||
const PLATE_THICKNESS_MM: f32 = 50.0;
|
||||
|
||||
/// Build an expansion joint mesh from IR.
|
||||
///
|
||||
/// The mesh is centred at X=0, Z=0 (station centre) and Y=-depth..0.
|
||||
/// Caller translates to the correct bridge position.
|
||||
pub fn build_expansion_joint_mesh(ir: &ExpansionJointIR) -> Result<Mesh, KernelError> {
|
||||
if ir.total_width <= 0.0 {
|
||||
return Err(KernelError::InvalidInput(
|
||||
format!("expansion joint total_width must be positive, got {}", ir.total_width),
|
||||
));
|
||||
}
|
||||
if ir.depth <= 0.0 {
|
||||
return Err(KernelError::InvalidInput(
|
||||
format!("expansion joint depth must be positive, got {}", ir.depth),
|
||||
));
|
||||
}
|
||||
|
||||
let half_w = ir.total_width as f32 * 0.5;
|
||||
let gap = ir.gap_width as f32;
|
||||
let depth = ir.depth as f32;
|
||||
let pt = PLATE_THICKNESS_MM;
|
||||
let half_pt = pt * 0.5;
|
||||
|
||||
// Build two plates: start-side and end-side of the joint
|
||||
// Each plate: width = total_width, thickness = PLATE_THICKNESS_MM, depth = depth
|
||||
// Separated by gap_width in Z
|
||||
|
||||
let mut parts: Vec<Mesh> = Vec::new();
|
||||
|
||||
// Start plate (Z = [-half_pt - gap/2, -gap/2])
|
||||
{
|
||||
let z0 = -(half_pt + gap * 0.5);
|
||||
let z1 = -(gap * 0.5);
|
||||
let plate = plate_mesh(half_w, depth, z0, z1);
|
||||
parts.push(plate);
|
||||
}
|
||||
|
||||
// End plate (Z = [+gap/2, +half_pt + gap/2])
|
||||
{
|
||||
let z0 = gap * 0.5;
|
||||
let z1 = half_pt + gap * 0.5;
|
||||
let plate = plate_mesh(half_w, depth, z0, z1);
|
||||
parts.push(plate);
|
||||
}
|
||||
|
||||
Ok(sweep::merge_meshes(parts))
|
||||
}
|
||||
|
||||
/// Build a rectangular plate mesh.
|
||||
/// X spans [-half_w .. +half_w], Y spans [-depth .. 0], Z spans [z0 .. z1].
|
||||
fn plate_mesh(half_w: f32, depth: f32, z0: f32, z1: f32) -> Mesh {
|
||||
let verts = vec![
|
||||
[-half_w, -depth, z0], [ half_w, -depth, z0],
|
||||
[ half_w, 0.0, z0], [-half_w, 0.0, z0],
|
||||
[-half_w, -depth, z1], [ half_w, -depth, z1],
|
||||
[ half_w, 0.0, z1], [-half_w, 0.0, z1],
|
||||
];
|
||||
let indices: Vec<u32> = vec![
|
||||
0, 2, 1, 0, 3, 2, // -Z face
|
||||
4, 5, 6, 4, 6, 7, // +Z face
|
||||
0, 4, 7, 0, 7, 3, // -X face
|
||||
1, 2, 6, 1, 6, 5, // +X face
|
||||
0, 1, 5, 0, 5, 4, // -Y face (bottom)
|
||||
3, 7, 6, 3, 6, 2, // +Y face (top)
|
||||
];
|
||||
let colors = vec![[0.25_f32, 0.25, 0.30]; verts.len()]; // dark steel grey
|
||||
let normals = vec![[0.0_f32, 1.0, 0.0]; verts.len()]; // simplified flat
|
||||
|
||||
Mesh {
|
||||
vertices: verts.into_iter().map(|v| v).collect(),
|
||||
indices,
|
||||
normals,
|
||||
colors,
|
||||
}
|
||||
}
|
||||
@@ -16,13 +16,15 @@ pub mod deck_slab;
|
||||
pub mod bearing;
|
||||
pub mod pier;
|
||||
pub mod abutment;
|
||||
pub mod cross_beam;
|
||||
pub mod expansion_joint;
|
||||
pub mod occt;
|
||||
|
||||
#[cfg(feature = "occt")]
|
||||
pub use occt::OcctKernel;
|
||||
|
||||
use cimery_ir::{
|
||||
AbutmentIR, BearingIR, DeckSlabIR, GirderIR, PierIR, SectionParams,
|
||||
AbutmentIR, BearingIR, CrossBeamIR, DeckSlabIR, ExpansionJointIR, GirderIR, PierIR, SectionParams,
|
||||
};
|
||||
|
||||
// ─── Mesh ─────────────────────────────────────────────────────────────────────
|
||||
@@ -95,8 +97,11 @@ pub trait GeomKernel: Send + Sync {
|
||||
// ── 연결부 (Interface) ─────────────────────────────────────────────────
|
||||
fn bearing_mesh(&self, ir: &BearingIR) -> Result<Mesh, KernelError>;
|
||||
// ── 하부 구조물 (Substructure) ─────────────────────────────────────────
|
||||
fn pier_mesh(&self, ir: &PierIR) -> Result<Mesh, KernelError>;
|
||||
fn abutment_mesh(&self, ir: &AbutmentIR) -> Result<Mesh, KernelError>;
|
||||
fn pier_mesh(&self, ir: &PierIR) -> Result<Mesh, KernelError>;
|
||||
fn abutment_mesh(&self, ir: &AbutmentIR) -> Result<Mesh, KernelError>;
|
||||
// ── Should features (Sprint 19) ────────────────────────────────────────
|
||||
fn cross_beam_mesh(&self, ir: &CrossBeamIR) -> Result<Mesh, KernelError>;
|
||||
fn expansion_joint_mesh(&self, ir: &ExpansionJointIR) -> Result<Mesh, KernelError>;
|
||||
}
|
||||
|
||||
// ─── StubKernel ───────────────────────────────────────────────────────────────
|
||||
@@ -120,6 +125,14 @@ impl GeomKernel for StubKernel {
|
||||
fn abutment_mesh(&self, ir: &AbutmentIR) -> Result<Mesh, KernelError> {
|
||||
Ok(sweep::box_mesh(ir.breast_wall_thickness as f32, ir.breast_wall_height as f32, ir.breast_wall_width as f32))
|
||||
}
|
||||
fn cross_beam_mesh(&self, ir: &CrossBeamIR) -> Result<Mesh, KernelError> {
|
||||
let total_len = ir.total_length_mm() as f32;
|
||||
let height = (ir.web_height + ir.flange_thickness * 2.0) as f32;
|
||||
Ok(sweep::box_mesh(total_len, height, ir.web_thickness as f32))
|
||||
}
|
||||
fn expansion_joint_mesh(&self, ir: &ExpansionJointIR) -> Result<Mesh, KernelError> {
|
||||
Ok(sweep::box_mesh(ir.total_width as f32, ir.depth as f32, ir.gap_width as f32 + 100.0))
|
||||
}
|
||||
fn girder_mesh(&self, ir: &GirderIR) -> Result<Mesh, KernelError> {
|
||||
if ir.span_m() <= 0.0 {
|
||||
return Err(KernelError::InvalidInput(
|
||||
@@ -183,6 +196,12 @@ impl GeomKernel for PureRustKernel {
|
||||
}
|
||||
}
|
||||
}
|
||||
fn cross_beam_mesh(&self, ir: &CrossBeamIR) -> Result<Mesh, KernelError> {
|
||||
cross_beam::build_cross_beam_mesh(ir)
|
||||
}
|
||||
fn expansion_joint_mesh(&self, ir: &ExpansionJointIR) -> Result<Mesh, KernelError> {
|
||||
expansion_joint::build_expansion_joint_mesh(ir)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -89,8 +89,7 @@ mod inner {
|
||||
occt_mesh_to_cimery(occt_mesh)
|
||||
}
|
||||
|
||||
// ── Other features (use PureRustKernel geometry for now) ─────────────────────
|
||||
|
||||
// ── Deck slab (PureRust delegation) ─────────────────────────────────────────
|
||||
pub fn deck_slab_mesh(ir: &DeckSlabIR) -> Result<Mesh, KernelError> {
|
||||
crate::deck_slab::build_deck_slab_mesh(ir)
|
||||
}
|
||||
@@ -99,12 +98,164 @@ mod inner {
|
||||
crate::bearing::build_bearing_mesh(ir)
|
||||
}
|
||||
|
||||
// ── Pier — OCCT B-rep (Sprint 18) ────────────────────────────────────────────
|
||||
//
|
||||
// Geometry:
|
||||
// - Columns: polygon-approximated circle or rectangular prism
|
||||
// - Cap beam: rectangular box spanning columns
|
||||
// All shapes extruded via OCCT Workplane::sketch().
|
||||
pub fn pier_mesh(ir: &PierIR) -> Result<Mesh, KernelError> {
|
||||
crate::pier::build_pier_mesh(ir)
|
||||
use cimery_core::ColumnShape;
|
||||
let cap = &ir.cap_beam;
|
||||
let col_h = ir.column_height;
|
||||
let col_d = ir.column_diameter;
|
||||
let col_dp = ir.column_depth.max(col_d); // rectangular depth
|
||||
let n_col = ir.column_count.max(1) as usize;
|
||||
let spacing = ir.column_spacing;
|
||||
|
||||
let mut parts: Vec<Mesh> = Vec::new();
|
||||
|
||||
// ── Columns ─────────────────────────────────────────────────────────
|
||||
for i in 0..n_col {
|
||||
let x_off = (i as f64 - (n_col as f64 - 1.0) * 0.5) * spacing;
|
||||
let col_mesh = match ir.column_shape {
|
||||
ColumnShape::Circular => {
|
||||
// Approximate circle with 16-sided polygon
|
||||
let r = col_d * 0.5;
|
||||
let ns = 16usize;
|
||||
let profile: Vec<(f64, f64)> = (0..ns).map(|k| {
|
||||
let a = std::f64::consts::TAU * k as f64 / ns as f64;
|
||||
(r * a.cos(), r * a.sin())
|
||||
}).collect();
|
||||
workplane_extrude_xz(&profile, col_h)?
|
||||
}
|
||||
ColumnShape::Rectangular | ColumnShape::Oval => {
|
||||
let hw = col_d * 0.5;
|
||||
let hd = col_dp * 0.5;
|
||||
let profile = vec![(-hw, -hd), (hw, -hd), (hw, hd), (-hw, hd)];
|
||||
workplane_extrude_xz(&profile, col_h)?
|
||||
}
|
||||
};
|
||||
// Translate column to x_off, start at Y=0
|
||||
let mut m = col_mesh;
|
||||
for v in &mut m.vertices { v[0] += x_off as f32; }
|
||||
parts.push(m);
|
||||
}
|
||||
|
||||
// ── Cap beam ────────────────────────────────────────────────────────
|
||||
{
|
||||
let hcl = cap.length * 0.5;
|
||||
let hcw = cap.width * 0.5;
|
||||
let profile = vec![(-hcl, -hcw), (hcl, -hcw), (hcl, hcw), (-hcl, hcw)];
|
||||
let mut cap_mesh = workplane_extrude_xz(&profile, cap.depth)?;
|
||||
// Cap sits on top of columns
|
||||
for v in &mut cap_mesh.vertices { v[1] += col_h as f32; }
|
||||
parts.push(cap_mesh);
|
||||
}
|
||||
|
||||
let mut mesh = crate::sweep::merge_meshes(parts);
|
||||
// Translate so base sits at Y=0
|
||||
mesh.colors = vec![crate::COLOR_CONCRETE; mesh.vertices.len()];
|
||||
Ok(mesh)
|
||||
}
|
||||
|
||||
// ── Abutment — OCCT B-rep (Sprint 18) ────────────────────────────────────────
|
||||
//
|
||||
// Geometry: breast wall + footing + wing walls, all rectangular extrusions.
|
||||
pub fn abutment_mesh(ir: &AbutmentIR) -> Result<Mesh, KernelError> {
|
||||
crate::abutment::build_abutment_mesh(ir)
|
||||
let bwh = ir.breast_wall_height;
|
||||
let bwt = ir.breast_wall_thickness;
|
||||
let bww = ir.breast_wall_width;
|
||||
let fl = ir.footing_length;
|
||||
let fw = ir.footing_width;
|
||||
let ft = ir.footing_thickness;
|
||||
let wl = &ir.wing_wall_left;
|
||||
let wr = &ir.wing_wall_right;
|
||||
|
||||
let mut parts: Vec<Mesh> = Vec::new();
|
||||
|
||||
// ── Breast wall (正面壁) ─────────────────────────────────────────────
|
||||
{
|
||||
let hw = bww * 0.5;
|
||||
let hbt = bwt * 0.5;
|
||||
let profile = vec![(-hw, -hbt), (hw, -hbt), (hw, hbt), (-hw, hbt)];
|
||||
let mut m = workplane_extrude_xz_y(&profile, bwh)?;
|
||||
// Breast wall at footing top, Y=0..bwh
|
||||
parts.push(m);
|
||||
}
|
||||
|
||||
// ── Footing ──────────────────────────────────────────────────────────
|
||||
{
|
||||
let hfw = fw * 0.5;
|
||||
let hfl = fl * 0.5;
|
||||
let profile = vec![(-hfw, -hfl), (hfw, -hfl), (hfw, hfl), (-hfw, hfl)];
|
||||
let mut m = workplane_extrude_xz_y(&profile, ft)?;
|
||||
// Footing below breast wall
|
||||
for v in &mut m.vertices { v[1] -= ft as f32; }
|
||||
parts.push(m);
|
||||
}
|
||||
|
||||
// ── Wing walls ────────────────────────────────────────────────────────
|
||||
for (wing, sign) in [(wl, -1.0_f32), (wr, 1.0_f32)] {
|
||||
let wlen = wing.length;
|
||||
let wht = wing.height;
|
||||
let wth = wing.thickness;
|
||||
let half_bww = bww * 0.5;
|
||||
let profile = vec![(0.0, 0.0), (wlen, 0.0), (wlen, wth), (0.0, wth)];
|
||||
let mut m = workplane_extrude_xz_y(&profile, wht)?;
|
||||
// Rotate 90° if needed for side walls — approximate by translating
|
||||
// Wing wall runs along Z from breast wall end
|
||||
for v in &mut m.vertices {
|
||||
let orig_x = v[0] as f64;
|
||||
let orig_z = v[2] as f64;
|
||||
v[0] = (sign * half_bww as f32) + (sign * orig_z as f32);
|
||||
v[2] = orig_x as f32;
|
||||
}
|
||||
parts.push(m);
|
||||
}
|
||||
|
||||
let mut mesh = crate::sweep::merge_meshes(parts);
|
||||
mesh.colors = vec![crate::COLOR_CONCRETE; mesh.vertices.len()];
|
||||
Ok(mesh)
|
||||
}
|
||||
|
||||
// ── Shared OCCT helper: extrude XZ profile upward (Y direction) ──────────────
|
||||
|
||||
/// Extrude a closed XZ-plane profile (as (x,z) pairs) upward by `height_mm` along Y.
|
||||
/// Uses OCCT Workplane::xz() sketch + extrude.
|
||||
fn workplane_extrude_xz(profile_xz: &[(f64, f64)], height_mm: f64) -> Result<Mesh, KernelError> {
|
||||
use opencascade::workplane::Workplane;
|
||||
if profile_xz.len() < 3 {
|
||||
return Err(KernelError::InvalidInput("profile must have ≥3 points".into()));
|
||||
}
|
||||
let mut sk = Workplane::xz().sketch();
|
||||
let first = profile_xz[0];
|
||||
sk = sk.move_to(first.0, first.1);
|
||||
for &(x, z) in &profile_xz[1..] {
|
||||
sk = sk.line_to(x, z);
|
||||
}
|
||||
let face = sk.close().to_face();
|
||||
let solid = face.extrude(DVec3::new(0.0, height_mm, 0.0));
|
||||
let shape = solid.into();
|
||||
let occt_m = Mesher::try_new(&shape, 1.0)
|
||||
.map_err(|e| KernelError::Computation(format!("pier mesher: {e}")))?
|
||||
.mesh()
|
||||
.map_err(|e| KernelError::Computation(format!("pier tessellation: {e}")))?;
|
||||
occt_mesh_to_cimery(occt_m)
|
||||
}
|
||||
|
||||
/// Same as workplane_extrude_xz but profile is in XZ plane, extruded along Y.
|
||||
/// Alias kept for readability at call sites.
|
||||
fn workplane_extrude_xz_y(profile_xz: &[(f64, f64)], height_mm: f64) -> Result<Mesh, KernelError> {
|
||||
workplane_extrude_xz(profile_xz, height_mm)
|
||||
}
|
||||
|
||||
pub fn cross_beam_mesh(ir: &cimery_ir::CrossBeamIR) -> Result<Mesh, KernelError> {
|
||||
crate::cross_beam::build_cross_beam_mesh(ir)
|
||||
}
|
||||
|
||||
pub fn expansion_joint_mesh(ir: &cimery_ir::ExpansionJointIR) -> Result<Mesh, KernelError> {
|
||||
crate::expansion_joint::build_expansion_joint_mesh(ir)
|
||||
}
|
||||
|
||||
// ── Conversion ────────────────────────────────────────────────────────────────
|
||||
@@ -177,5 +328,11 @@ mod occt_kernel {
|
||||
fn abutment_mesh(&self, ir: &AbutmentIR) -> Result<Mesh, KernelError> {
|
||||
inner::abutment_mesh(ir)
|
||||
}
|
||||
fn cross_beam_mesh(&self, ir: &cimery_ir::CrossBeamIR) -> Result<Mesh, KernelError> {
|
||||
inner::cross_beam_mesh(ir)
|
||||
}
|
||||
fn expansion_joint_mesh(&self, ir: &cimery_ir::ExpansionJointIR) -> Result<Mesh, KernelError> {
|
||||
inner::expansion_joint_mesh(ir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,55 @@ pub fn sweep_profile_flat(profile: &[[f32; 2]], span: f32) -> Mesh {
|
||||
Mesh { vertices, normals, indices, colors }
|
||||
}
|
||||
|
||||
/// Sweep a closed polygon profile (in YZ plane) along X from 0 to `length`.
|
||||
/// Used for cross beams that run transversely. Flat normals.
|
||||
pub fn sweep_profile_flat_x(profile: &[[f32; 2]], length: 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<u32> = Vec::new();
|
||||
|
||||
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] {
|
||||
indices.push(vertices.len() as u32);
|
||||
vertices.push(v);
|
||||
normals.push(normal);
|
||||
}
|
||||
};
|
||||
|
||||
// profile[i] = [y, z] — side faces sweeping along X
|
||||
for i in 0..n {
|
||||
let j = (i + 1) % n;
|
||||
let [y0, z0] = profile[i];
|
||||
let [y1, z1] = profile[j];
|
||||
let a = [0.0, y0, z0];
|
||||
let b = [0.0, y1, z1];
|
||||
let c = [length,y1, z1];
|
||||
let d = [length,y0, z0];
|
||||
push_tri(a, b, c);
|
||||
push_tri(a, c, d);
|
||||
}
|
||||
|
||||
// End caps
|
||||
let cy: f32 = profile.iter().map(|v| v[0]).sum::<f32>() / n as f32;
|
||||
let cz: f32 = profile.iter().map(|v| v[1]).sum::<f32>() / n as f32;
|
||||
|
||||
let cen_f = [0.0, cy, cz];
|
||||
for i in 0..n {
|
||||
let j = (i+1)%n;
|
||||
push_tri(cen_f, [0.0, profile[i][0], profile[i][1]], [0.0, profile[j][0], profile[j][1]]);
|
||||
}
|
||||
let cen_b = [length, cy, cz];
|
||||
for i in 0..n {
|
||||
let j = (i+1)%n;
|
||||
push_tri(cen_b, [length, profile[j][0], profile[j][1]], [length, profile[i][0], profile[i][1]]);
|
||||
}
|
||||
|
||||
let colors = vec![crate::COLOR_CONCRETE; vertices.len()];
|
||||
Mesh { vertices, normals, indices, colors }
|
||||
}
|
||||
|
||||
// ─── Convenience shapes ───────────────────────────────────────────────────────
|
||||
|
||||
/// Axis-aligned box: width × height × depth. Origin at (0,0,0).
|
||||
|
||||
219
cimery/crates/kernel/tests/layer1_snapshots.rs
Normal file
219
cimery/crates/kernel/tests/layer1_snapshots.rs
Normal file
@@ -0,0 +1,219 @@
|
||||
//! Layer 1: IR serialization snapshots (Sprint 20).
|
||||
//!
|
||||
//! Uses `insta` to capture stable JSON representations of IR structs.
|
||||
//! On first run these create `.snap.new` files — run `cargo insta review` to approve.
|
||||
//! Snapshots live in `tests/snapshots/` and are committed to version control.
|
||||
//!
|
||||
//! Purpose: catch accidental schema changes that break saved project files.
|
||||
|
||||
use cimery_core::{
|
||||
AbutmentType, BearingType, ColumnShape, CrossBeamSection, ExpansionJointType,
|
||||
MaterialGrade, PierType, SectionType,
|
||||
};
|
||||
use cimery_ir::{
|
||||
AbutmentIR, BearingIR, CapBeamIR, CrossBeamIR, DeckSlabIR, ExpansionJointIR,
|
||||
FeatureId, GirderIR, PierIR, PscISectionParams, SectionParams, WingWallIR,
|
||||
};
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// GirderIR with a deterministic fixed ID for stable snapshots.
|
||||
fn snapshot_girder() -> GirderIR {
|
||||
GirderIR {
|
||||
id: fixed_id("girder-1"),
|
||||
station_start: 100.0,
|
||||
station_end: 140.0,
|
||||
offset_from_alignment: 0.0,
|
||||
section_type: SectionType::PscI,
|
||||
section: SectionParams::PscI(PscISectionParams::kds_standard()),
|
||||
count: 5,
|
||||
spacing: 2500.0,
|
||||
material: MaterialGrade::C50,
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_deck_slab() -> DeckSlabIR {
|
||||
DeckSlabIR {
|
||||
id: fixed_id("deck-1"),
|
||||
station_start: 100.0,
|
||||
station_end: 140.0,
|
||||
width_left: 5_500.0,
|
||||
width_right: 5_500.0,
|
||||
thickness: 220.0,
|
||||
haunch_depth: 100.0,
|
||||
cross_slope: 2.0,
|
||||
material: MaterialGrade::C40,
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_bearing() -> BearingIR {
|
||||
BearingIR {
|
||||
id: fixed_id("bearing-1"),
|
||||
station: 100.0,
|
||||
bearing_type: BearingType::Elastomeric,
|
||||
plan_length: 350.0,
|
||||
plan_width: 350.0,
|
||||
total_height: 90.0,
|
||||
capacity_vertical:2_500.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_pier() -> PierIR {
|
||||
PierIR {
|
||||
id: fixed_id("pier-1"),
|
||||
station: 120.0,
|
||||
skew_angle: 0.0,
|
||||
pier_type: PierType::SingleColumn,
|
||||
column_shape: ColumnShape::Circular,
|
||||
column_count: 1,
|
||||
column_spacing: 0.0,
|
||||
column_diameter: 1_500.0,
|
||||
column_depth: 0.0,
|
||||
column_height: 8_000.0,
|
||||
cap_beam: CapBeamIR {
|
||||
length: 13_000.0,
|
||||
width: 1_200.0,
|
||||
depth: 1_400.0,
|
||||
cantilever_left: 1_000.0,
|
||||
cantilever_right:1_000.0,
|
||||
},
|
||||
material: MaterialGrade::C40,
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_abutment() -> AbutmentIR {
|
||||
AbutmentIR {
|
||||
id: fixed_id("abutment-1"),
|
||||
station: 100.0,
|
||||
skew_angle: 0.0,
|
||||
abutment_type: AbutmentType::ReverseT,
|
||||
breast_wall_height: 5_000.0,
|
||||
breast_wall_thickness: 800.0,
|
||||
breast_wall_width: 12_000.0,
|
||||
footing_length: 4_000.0,
|
||||
footing_width: 13_000.0,
|
||||
footing_thickness: 1_000.0,
|
||||
wing_wall_left: WingWallIR { length: 4_000.0, height: 4_000.0, thickness: 500.0 },
|
||||
wing_wall_right: WingWallIR { length: 4_000.0, height: 4_000.0, thickness: 500.0 },
|
||||
material: MaterialGrade::C40,
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_cross_beam() -> CrossBeamIR {
|
||||
CrossBeamIR {
|
||||
id: fixed_id("cb-1"),
|
||||
station: 110.0,
|
||||
section: CrossBeamSection::HSection,
|
||||
web_height: 1_260.0,
|
||||
web_thickness: 12.0,
|
||||
flange_width: 300.0,
|
||||
flange_thickness: 16.0,
|
||||
bay_count: 4,
|
||||
girder_spacing: 2_500.0,
|
||||
material: MaterialGrade::Ss400,
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_expansion_joint() -> ExpansionJointIR {
|
||||
ExpansionJointIR {
|
||||
id: fixed_id("ej-1"),
|
||||
station: 100.0,
|
||||
joint_type: ExpansionJointType::RubberType,
|
||||
gap_width: 50.0,
|
||||
total_width: 11_000.0,
|
||||
depth: 100.0,
|
||||
movement_range: 30.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a FeatureId from a deterministic UUID-v5-like string (not actually v5,
|
||||
/// just a fixed nil-offset trick for snapshot stability).
|
||||
fn fixed_id(tag: &str) -> FeatureId {
|
||||
use std::str::FromStr;
|
||||
// Seed with a stable tag-based UUID string (each test feature has its own).
|
||||
let s = match tag {
|
||||
"girder-1" => "00000000-0000-0000-0000-000000000001",
|
||||
"deck-1" => "00000000-0000-0000-0000-000000000002",
|
||||
"bearing-1" => "00000000-0000-0000-0000-000000000003",
|
||||
"pier-1" => "00000000-0000-0000-0000-000000000004",
|
||||
"abutment-1" => "00000000-0000-0000-0000-000000000005",
|
||||
"cb-1" => "00000000-0000-0000-0000-000000000006",
|
||||
"ej-1" => "00000000-0000-0000-0000-000000000007",
|
||||
_ => "00000000-0000-0000-0000-000000000000",
|
||||
};
|
||||
FeatureId(uuid::Uuid::from_str(s).unwrap())
|
||||
}
|
||||
|
||||
// ─── Snapshot tests ───────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn snapshot_girder_ir() {
|
||||
let json = serde_json::to_string_pretty(&snapshot_girder()).unwrap();
|
||||
insta::assert_snapshot!("girder_ir_json", json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_deck_slab_ir() {
|
||||
let json = serde_json::to_string_pretty(&snapshot_deck_slab()).unwrap();
|
||||
insta::assert_snapshot!("deck_slab_ir_json", json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_bearing_ir() {
|
||||
let json = serde_json::to_string_pretty(&snapshot_bearing()).unwrap();
|
||||
insta::assert_snapshot!("bearing_ir_json", json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_pier_ir() {
|
||||
let json = serde_json::to_string_pretty(&snapshot_pier()).unwrap();
|
||||
insta::assert_snapshot!("pier_ir_json", json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_abutment_ir() {
|
||||
let json = serde_json::to_string_pretty(&snapshot_abutment()).unwrap();
|
||||
insta::assert_snapshot!("abutment_ir_json", json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_cross_beam_ir() {
|
||||
let json = serde_json::to_string_pretty(&snapshot_cross_beam()).unwrap();
|
||||
insta::assert_snapshot!("cross_beam_ir_json", json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_expansion_joint_ir() {
|
||||
let json = serde_json::to_string_pretty(&snapshot_expansion_joint()).unwrap();
|
||||
insta::assert_snapshot!("expansion_joint_ir_json", json);
|
||||
}
|
||||
|
||||
// ─── Round-trip sanity ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn girder_json_roundtrip() {
|
||||
let ir = snapshot_girder();
|
||||
let json = serde_json::to_string(&ir).unwrap();
|
||||
let ir2: GirderIR = serde_json::from_str(&json).unwrap();
|
||||
assert!((ir.span_m() - ir2.span_m()).abs() < f64::EPSILON);
|
||||
assert_eq!(ir.count, ir2.count);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_beam_json_roundtrip() {
|
||||
let ir = snapshot_cross_beam();
|
||||
let json = serde_json::to_string(&ir).unwrap();
|
||||
let ir2: CrossBeamIR = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(ir.bay_count, ir2.bay_count);
|
||||
assert!((ir.girder_spacing - ir2.girder_spacing).abs() < f64::EPSILON);
|
||||
assert!((ir.total_length_mm() - ir2.total_length_mm()).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expansion_joint_json_roundtrip() {
|
||||
let ir = snapshot_expansion_joint();
|
||||
let json = serde_json::to_string(&ir).unwrap();
|
||||
let ir2: ExpansionJointIR = serde_json::from_str(&json).unwrap();
|
||||
assert!((ir.gap_width - ir2.gap_width).abs() < f64::EPSILON);
|
||||
assert!((ir.total_width - ir2.total_width).abs() < f64::EPSILON);
|
||||
}
|
||||
297
cimery/crates/kernel/tests/layer2_invariants.rs
Normal file
297
cimery/crates/kernel/tests/layer2_invariants.rs
Normal file
@@ -0,0 +1,297 @@
|
||||
//! Layer 2: Geometric invariants for all kernel feature meshes (Sprint 20).
|
||||
//!
|
||||
//! Each test verifies physical and topological properties that MUST hold
|
||||
//! regardless of which backend produces the mesh:
|
||||
//! - vertex_count > 0
|
||||
//! - triangle_count > 0
|
||||
//! - indices divisible by 3 (well-formed triangle list)
|
||||
//! - bounding box spans > 0 on at least one axis
|
||||
//! - all normals are (approximately) unit length
|
||||
//! - no degenerate triangles (all 3 vertex positions distinct)
|
||||
|
||||
use cimery_core::{
|
||||
AbutmentType, BearingType, ColumnShape, CrossBeamSection, ExpansionJointType,
|
||||
MaterialGrade, PierType, SectionType,
|
||||
};
|
||||
use cimery_ir::{
|
||||
AbutmentIR, BearingIR, CapBeamIR, CrossBeamIR, DeckSlabIR, ExpansionJointIR,
|
||||
FeatureId, GirderIR, PierIR, PscISectionParams, SectionParams, WingWallIR,
|
||||
};
|
||||
use cimery_kernel::{GeomKernel, Mesh, PureRustKernel, StubKernel};
|
||||
|
||||
// ─── Assertion helpers ────────────────────────────────────────────────────────
|
||||
|
||||
fn assert_valid_mesh(mesh: &Mesh, label: &str) {
|
||||
assert!(mesh.vertex_count() > 0,
|
||||
"{label}: vertex_count must be > 0");
|
||||
assert!(mesh.triangle_count() > 0,
|
||||
"{label}: triangle_count must be > 0");
|
||||
assert_eq!(mesh.indices.len() % 3, 0,
|
||||
"{label}: index count must be divisible by 3");
|
||||
assert_eq!(mesh.normals.len(), mesh.vertices.len(),
|
||||
"{label}: normals.len() must equal vertices.len()");
|
||||
assert_eq!(mesh.colors.len(), mesh.vertices.len(),
|
||||
"{label}: colors.len() must equal vertices.len()");
|
||||
|
||||
// Bounding box must be non-degenerate on at least one axis
|
||||
let (mn, mx) = mesh.aabb();
|
||||
let extents = [mx[0]-mn[0], mx[1]-mn[1], mx[2]-mn[2]];
|
||||
assert!(extents.iter().any(|&e| e > 0.0),
|
||||
"{label}: AABB must have positive extent on ≥1 axis, got {:?}", extents);
|
||||
|
||||
// All normals unit length (tolerance 1e-4 for f32)
|
||||
for (i, n) in mesh.normals.iter().enumerate() {
|
||||
let len = (n[0]*n[0] + n[1]*n[1] + n[2]*n[2]).sqrt();
|
||||
assert!((len - 1.0).abs() < 1e-4,
|
||||
"{label}: normal[{i}] length = {len:.6}, expected ~1.0");
|
||||
}
|
||||
|
||||
// All indices in range
|
||||
let vcount = mesh.vertex_count() as u32;
|
||||
for &idx in &mesh.indices {
|
||||
assert!(idx < vcount,
|
||||
"{label}: index {idx} out of range (vertex_count = {vcount})");
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_span_in_aabb(mesh: &Mesh, expected_span_mm: f32, axis: usize, label: &str) {
|
||||
let (mn, mx) = mesh.aabb();
|
||||
let actual = mx[axis] - mn[axis];
|
||||
assert!((actual - expected_span_mm).abs() < expected_span_mm * 0.01,
|
||||
"{label}: span on axis {axis} = {actual:.1} mm, expected {expected_span_mm:.1} mm (±1%)");
|
||||
}
|
||||
|
||||
// ─── IR factories ─────────────────────────────────────────────────────────────
|
||||
|
||||
fn girder_40m() -> GirderIR {
|
||||
GirderIR {
|
||||
id: FeatureId::new(),
|
||||
station_start: 0.0,
|
||||
station_end: 40.0,
|
||||
offset_from_alignment: 0.0,
|
||||
section_type: SectionType::PscI,
|
||||
section: SectionParams::PscI(PscISectionParams::kds_standard()),
|
||||
count: 5,
|
||||
spacing: 2_500.0,
|
||||
material: MaterialGrade::C50,
|
||||
}
|
||||
}
|
||||
|
||||
fn deck_slab_40m() -> DeckSlabIR {
|
||||
DeckSlabIR {
|
||||
id: FeatureId::new(),
|
||||
station_start: 0.0,
|
||||
station_end: 40.0,
|
||||
width_left: 5_500.0,
|
||||
width_right: 5_500.0,
|
||||
thickness: 220.0,
|
||||
haunch_depth: 100.0,
|
||||
cross_slope: 2.0,
|
||||
material: MaterialGrade::C40,
|
||||
}
|
||||
}
|
||||
|
||||
fn bearing_standard() -> BearingIR {
|
||||
BearingIR {
|
||||
id: FeatureId::new(),
|
||||
station: 0.0,
|
||||
bearing_type: BearingType::Elastomeric,
|
||||
plan_length: 350.0,
|
||||
plan_width: 350.0,
|
||||
total_height: 90.0,
|
||||
capacity_vertical:2_500.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn pier_single_column() -> PierIR {
|
||||
PierIR {
|
||||
id: FeatureId::new(),
|
||||
station: 20.0,
|
||||
skew_angle: 0.0,
|
||||
pier_type: PierType::SingleColumn,
|
||||
column_shape: ColumnShape::Circular,
|
||||
column_count: 1,
|
||||
column_spacing: 0.0,
|
||||
column_diameter: 1_500.0,
|
||||
column_depth: 0.0,
|
||||
column_height: 8_000.0,
|
||||
cap_beam: CapBeamIR {
|
||||
length: 13_000.0,
|
||||
width: 1_200.0,
|
||||
depth: 1_400.0,
|
||||
cantilever_left: 1_000.0,
|
||||
cantilever_right:1_000.0,
|
||||
},
|
||||
material: MaterialGrade::C40,
|
||||
}
|
||||
}
|
||||
|
||||
fn abutment_standard() -> AbutmentIR {
|
||||
AbutmentIR {
|
||||
id: FeatureId::new(),
|
||||
station: 0.0,
|
||||
skew_angle: 0.0,
|
||||
abutment_type: AbutmentType::ReverseT,
|
||||
breast_wall_height: 5_000.0,
|
||||
breast_wall_thickness: 800.0,
|
||||
breast_wall_width: 12_000.0,
|
||||
footing_length: 4_000.0,
|
||||
footing_width: 13_000.0,
|
||||
footing_thickness: 1_000.0,
|
||||
wing_wall_left: WingWallIR { length: 4_000.0, height: 4_000.0, thickness: 500.0 },
|
||||
wing_wall_right: WingWallIR { length: 4_000.0, height: 4_000.0, thickness: 500.0 },
|
||||
material: MaterialGrade::C40,
|
||||
}
|
||||
}
|
||||
|
||||
fn cross_beam_standard() -> CrossBeamIR {
|
||||
CrossBeamIR {
|
||||
id: FeatureId::new(),
|
||||
station: 10.0,
|
||||
section: CrossBeamSection::HSection,
|
||||
web_height: 1_260.0,
|
||||
web_thickness: 12.0,
|
||||
flange_width: 300.0,
|
||||
flange_thickness: 16.0,
|
||||
bay_count: 4,
|
||||
girder_spacing: 2_500.0,
|
||||
material: MaterialGrade::Ss400,
|
||||
}
|
||||
}
|
||||
|
||||
fn expansion_joint_standard() -> ExpansionJointIR {
|
||||
ExpansionJointIR {
|
||||
id: FeatureId::new(),
|
||||
station: 0.0,
|
||||
joint_type: ExpansionJointType::RubberType,
|
||||
gap_width: 50.0,
|
||||
total_width: 11_000.0,
|
||||
depth: 100.0,
|
||||
movement_range: 30.0,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PureRustKernel invariants ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn prk_girder_valid_mesh() {
|
||||
let mesh = PureRustKernel.girder_mesh(&girder_40m()).unwrap();
|
||||
assert_valid_mesh(&mesh, "PureRustKernel::girder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prk_girder_span_correct() {
|
||||
let mesh = PureRustKernel.girder_mesh(&girder_40m()).unwrap();
|
||||
assert_span_in_aabb(&mesh, 40_000.0, 2, "PureRustKernel::girder span (Z)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prk_deck_slab_valid_mesh() {
|
||||
let mesh = PureRustKernel.deck_slab_mesh(&deck_slab_40m()).unwrap();
|
||||
assert_valid_mesh(&mesh, "PureRustKernel::deck_slab");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prk_deck_slab_span_correct() {
|
||||
let mesh = PureRustKernel.deck_slab_mesh(&deck_slab_40m()).unwrap();
|
||||
assert_span_in_aabb(&mesh, 40_000.0, 2, "PureRustKernel::deck_slab span (Z)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prk_bearing_valid_mesh() {
|
||||
let mesh = PureRustKernel.bearing_mesh(&bearing_standard()).unwrap();
|
||||
assert_valid_mesh(&mesh, "PureRustKernel::bearing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prk_pier_valid_mesh() {
|
||||
let mesh = PureRustKernel.pier_mesh(&pier_single_column()).unwrap();
|
||||
assert_valid_mesh(&mesh, "PureRustKernel::pier");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prk_abutment_valid_mesh() {
|
||||
let mesh = PureRustKernel.abutment_mesh(&abutment_standard()).unwrap();
|
||||
assert_valid_mesh(&mesh, "PureRustKernel::abutment");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prk_cross_beam_valid_mesh() {
|
||||
let mesh = PureRustKernel.cross_beam_mesh(&cross_beam_standard()).unwrap();
|
||||
assert_valid_mesh(&mesh, "PureRustKernel::cross_beam");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prk_cross_beam_length_correct() {
|
||||
let ir = cross_beam_standard();
|
||||
let mesh = PureRustKernel.cross_beam_mesh(&ir).unwrap();
|
||||
let expected = ir.total_length_mm() as f32;
|
||||
// Cross beams sweep along X axis
|
||||
assert_span_in_aabb(&mesh, expected, 0, "PureRustKernel::cross_beam length (X)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prk_expansion_joint_valid_mesh() {
|
||||
let mesh = PureRustKernel.expansion_joint_mesh(&expansion_joint_standard()).unwrap();
|
||||
assert_valid_mesh(&mesh, "PureRustKernel::expansion_joint");
|
||||
}
|
||||
|
||||
// ─── StubKernel invariants ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn stub_girder_valid_mesh() {
|
||||
let mesh = StubKernel.girder_mesh(&girder_40m()).unwrap();
|
||||
assert_valid_mesh(&mesh, "StubKernel::girder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stub_deck_slab_valid_mesh() {
|
||||
let mesh = StubKernel.deck_slab_mesh(&deck_slab_40m()).unwrap();
|
||||
assert_valid_mesh(&mesh, "StubKernel::deck_slab");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stub_bearing_valid_mesh() {
|
||||
let mesh = StubKernel.bearing_mesh(&bearing_standard()).unwrap();
|
||||
assert_valid_mesh(&mesh, "StubKernel::bearing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stub_pier_valid_mesh() {
|
||||
let mesh = StubKernel.pier_mesh(&pier_single_column()).unwrap();
|
||||
assert_valid_mesh(&mesh, "StubKernel::pier");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stub_abutment_valid_mesh() {
|
||||
let mesh = StubKernel.abutment_mesh(&abutment_standard()).unwrap();
|
||||
assert_valid_mesh(&mesh, "StubKernel::abutment");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stub_cross_beam_valid_mesh() {
|
||||
let mesh = StubKernel.cross_beam_mesh(&cross_beam_standard()).unwrap();
|
||||
assert_valid_mesh(&mesh, "StubKernel::cross_beam");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stub_expansion_joint_valid_mesh() {
|
||||
let mesh = StubKernel.expansion_joint_mesh(&expansion_joint_standard()).unwrap();
|
||||
assert_valid_mesh(&mesh, "StubKernel::expansion_joint");
|
||||
}
|
||||
|
||||
// ─── Error cases ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn stub_zero_span_returns_error() {
|
||||
let mut g = girder_40m();
|
||||
g.station_end = g.station_start;
|
||||
assert!(StubKernel.girder_mesh(&g).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prk_zero_span_returns_error() {
|
||||
let mut g = girder_40m();
|
||||
g.station_end = g.station_start;
|
||||
assert!(PureRustKernel.girder_mesh(&g).is_err());
|
||||
}
|
||||
265
cimery/crates/kernel/tests/layer3_cross_check.rs
Normal file
265
cimery/crates/kernel/tests/layer3_cross_check.rs
Normal file
@@ -0,0 +1,265 @@
|
||||
//! Layer 3: Two-kernel cross-check (Sprint 20).
|
||||
//!
|
||||
//! Verifies that StubKernel and PureRustKernel produce meshes that satisfy
|
||||
//! the same geometric contracts, and that OcctKernel (if enabled) matches
|
||||
//! the bounding-box dimensions of PureRustKernel within tolerance.
|
||||
//!
|
||||
//! Cross-check contract:
|
||||
//! 1. Both kernels succeed for the same valid IR.
|
||||
//! 2. Bounding boxes are within 5% of each other on non-trivial axes.
|
||||
//! 3. Triangle count of PureRustKernel ≥ StubKernel (richer geometry).
|
||||
//!
|
||||
//! Note: OcctKernel tests are gated behind `#[cfg(feature = "occt")]`
|
||||
//! because OCCT is not available in standard CI (see cimery/CLAUDE.md).
|
||||
|
||||
use cimery_core::{
|
||||
AbutmentType, BearingType, ColumnShape, CrossBeamSection, ExpansionJointType,
|
||||
MaterialGrade, PierType, SectionType,
|
||||
};
|
||||
use cimery_ir::{
|
||||
AbutmentIR, BearingIR, CapBeamIR, CrossBeamIR, DeckSlabIR, ExpansionJointIR,
|
||||
FeatureId, GirderIR, PierIR, PscISectionParams, SectionParams, WingWallIR,
|
||||
};
|
||||
use cimery_kernel::{GeomKernel, Mesh, PureRustKernel, StubKernel};
|
||||
|
||||
#[cfg(feature = "occt")]
|
||||
use cimery_kernel::OcctKernel;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Bounding-box extent on each axis.
|
||||
fn extents(mesh: &Mesh) -> [f32; 3] {
|
||||
let (mn, mx) = mesh.aabb();
|
||||
[mx[0]-mn[0], mx[1]-mn[1], mx[2]-mn[2]]
|
||||
}
|
||||
|
||||
/// Assert that two extents agree within `pct` percent on the axis with the
|
||||
/// largest extent (primary dimension).
|
||||
fn assert_primary_extent_close(a: &Mesh, b: &Mesh, pct: f32, label: &str) {
|
||||
let ea = extents(a);
|
||||
let eb = extents(b);
|
||||
// Pick the axis with the largest extent in `a` as the primary dimension.
|
||||
let axis = ea.iter().enumerate().max_by(|x, y| x.1.partial_cmp(y.1).unwrap()).map(|(i,_)| i).unwrap_or(0);
|
||||
let tol = ea[axis].max(eb[axis]) * pct / 100.0;
|
||||
assert!(
|
||||
(ea[axis] - eb[axis]).abs() <= tol,
|
||||
"{label}: primary extent mismatch on axis {axis}: {:.1} vs {:.1} (tol {:.1})",
|
||||
ea[axis], eb[axis], tol
|
||||
);
|
||||
}
|
||||
|
||||
// ─── IR factories ─────────────────────────────────────────────────────────────
|
||||
|
||||
fn girder_40m() -> GirderIR {
|
||||
GirderIR {
|
||||
id: FeatureId::new(),
|
||||
station_start: 0.0,
|
||||
station_end: 40.0,
|
||||
offset_from_alignment: 0.0,
|
||||
section_type: SectionType::PscI,
|
||||
section: SectionParams::PscI(PscISectionParams::kds_standard()),
|
||||
count: 5,
|
||||
spacing: 2_500.0,
|
||||
material: MaterialGrade::C50,
|
||||
}
|
||||
}
|
||||
|
||||
fn deck_slab_40m() -> DeckSlabIR {
|
||||
DeckSlabIR {
|
||||
id: FeatureId::new(),
|
||||
station_start: 0.0,
|
||||
station_end: 40.0,
|
||||
width_left: 5_500.0,
|
||||
width_right: 5_500.0,
|
||||
thickness: 220.0,
|
||||
haunch_depth: 100.0,
|
||||
cross_slope: 2.0,
|
||||
material: MaterialGrade::C40,
|
||||
}
|
||||
}
|
||||
|
||||
fn bearing_standard() -> BearingIR {
|
||||
BearingIR {
|
||||
id: FeatureId::new(),
|
||||
station: 0.0,
|
||||
bearing_type: BearingType::Elastomeric,
|
||||
plan_length: 350.0,
|
||||
plan_width: 350.0,
|
||||
total_height: 90.0,
|
||||
capacity_vertical:2_500.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn pier_standard() -> PierIR {
|
||||
PierIR {
|
||||
id: FeatureId::new(),
|
||||
station: 20.0,
|
||||
skew_angle: 0.0,
|
||||
pier_type: PierType::SingleColumn,
|
||||
column_shape: ColumnShape::Circular,
|
||||
column_count: 1,
|
||||
column_spacing: 0.0,
|
||||
column_diameter: 1_500.0,
|
||||
column_depth: 0.0,
|
||||
column_height: 8_000.0,
|
||||
cap_beam: CapBeamIR {
|
||||
length: 13_000.0,
|
||||
width: 1_200.0,
|
||||
depth: 1_400.0,
|
||||
cantilever_left: 1_000.0,
|
||||
cantilever_right:1_000.0,
|
||||
},
|
||||
material: MaterialGrade::C40,
|
||||
}
|
||||
}
|
||||
|
||||
fn abutment_standard() -> AbutmentIR {
|
||||
AbutmentIR {
|
||||
id: FeatureId::new(),
|
||||
station: 0.0,
|
||||
skew_angle: 0.0,
|
||||
abutment_type: AbutmentType::ReverseT,
|
||||
breast_wall_height: 5_000.0,
|
||||
breast_wall_thickness: 800.0,
|
||||
breast_wall_width: 12_000.0,
|
||||
footing_length: 4_000.0,
|
||||
footing_width: 13_000.0,
|
||||
footing_thickness: 1_000.0,
|
||||
wing_wall_left: WingWallIR { length: 4_000.0, height: 4_000.0, thickness: 500.0 },
|
||||
wing_wall_right: WingWallIR { length: 4_000.0, height: 4_000.0, thickness: 500.0 },
|
||||
material: MaterialGrade::C40,
|
||||
}
|
||||
}
|
||||
|
||||
fn cross_beam_standard() -> CrossBeamIR {
|
||||
CrossBeamIR {
|
||||
id: FeatureId::new(),
|
||||
station: 10.0,
|
||||
section: CrossBeamSection::HSection,
|
||||
web_height: 1_260.0,
|
||||
web_thickness: 12.0,
|
||||
flange_width: 300.0,
|
||||
flange_thickness: 16.0,
|
||||
bay_count: 4,
|
||||
girder_spacing: 2_500.0,
|
||||
material: MaterialGrade::Ss400,
|
||||
}
|
||||
}
|
||||
|
||||
fn expansion_joint_standard() -> ExpansionJointIR {
|
||||
ExpansionJointIR {
|
||||
id: FeatureId::new(),
|
||||
station: 0.0,
|
||||
joint_type: ExpansionJointType::RubberType,
|
||||
gap_width: 50.0,
|
||||
total_width: 11_000.0,
|
||||
depth: 100.0,
|
||||
movement_range: 30.0,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── StubKernel vs PureRustKernel ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn cross_check_girder_both_succeed() {
|
||||
let ir = girder_40m();
|
||||
let stub = StubKernel.girder_mesh(&ir).expect("StubKernel::girder failed");
|
||||
let prk = PureRustKernel.girder_mesh(&ir).expect("PureRustKernel::girder failed");
|
||||
// PureRustKernel must produce more triangles than the stub box
|
||||
assert!(prk.triangle_count() >= stub.triangle_count(),
|
||||
"PureRustKernel should produce ≥ triangles: prk={} stub={}", prk.triangle_count(), stub.triangle_count());
|
||||
// Both should span the full 40 m along Z
|
||||
assert_primary_extent_close(&stub, &prk, 5.0, "girder span Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_check_deck_slab_both_succeed() {
|
||||
let ir = deck_slab_40m();
|
||||
let stub = StubKernel.deck_slab_mesh(&ir).expect("StubKernel::deck_slab failed");
|
||||
let prk = PureRustKernel.deck_slab_mesh(&ir).expect("PureRustKernel::deck_slab failed");
|
||||
assert!(prk.vertex_count() > 0 && stub.vertex_count() > 0);
|
||||
assert_primary_extent_close(&stub, &prk, 5.0, "deck_slab span");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_check_bearing_both_succeed() {
|
||||
let ir = bearing_standard();
|
||||
let stub = StubKernel.bearing_mesh(&ir).expect("StubKernel::bearing failed");
|
||||
let prk = PureRustKernel.bearing_mesh(&ir).expect("PureRustKernel::bearing failed");
|
||||
assert!(stub.vertex_count() > 0 && prk.vertex_count() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_check_pier_both_succeed() {
|
||||
let ir = pier_standard();
|
||||
let stub = StubKernel.pier_mesh(&ir).expect("StubKernel::pier failed");
|
||||
let prk = PureRustKernel.pier_mesh(&ir).expect("PureRustKernel::pier failed");
|
||||
assert!(stub.vertex_count() > 0 && prk.vertex_count() > 0);
|
||||
// Both must span at least the column height
|
||||
let (_, mx_s) = stub.aabb();
|
||||
let (_, mx_p) = prk.aabb();
|
||||
assert!(mx_s[1] > 0.0, "StubKernel pier: Y extent must be positive");
|
||||
assert!(mx_p[1] > 0.0, "PureRustKernel pier: Y extent must be positive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_check_abutment_both_succeed() {
|
||||
let ir = abutment_standard();
|
||||
let stub = StubKernel.abutment_mesh(&ir).expect("StubKernel::abutment failed");
|
||||
let prk = PureRustKernel.abutment_mesh(&ir).expect("PureRustKernel::abutment failed");
|
||||
assert!(stub.vertex_count() > 0 && prk.vertex_count() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_check_cross_beam_both_succeed() {
|
||||
let ir = cross_beam_standard();
|
||||
let stub = StubKernel.cross_beam_mesh(&ir).expect("StubKernel::cross_beam failed");
|
||||
let prk = PureRustKernel.cross_beam_mesh(&ir).expect("PureRustKernel::cross_beam failed");
|
||||
assert!(stub.vertex_count() > 0 && prk.vertex_count() > 0);
|
||||
assert_primary_extent_close(&stub, &prk, 10.0, "cross_beam length");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_check_expansion_joint_both_succeed() {
|
||||
let ir = expansion_joint_standard();
|
||||
let stub = StubKernel.expansion_joint_mesh(&ir).expect("StubKernel::expansion_joint failed");
|
||||
let prk = PureRustKernel.expansion_joint_mesh(&ir).expect("PureRustKernel::expansion_joint failed");
|
||||
assert!(stub.vertex_count() > 0 && prk.vertex_count() > 0);
|
||||
}
|
||||
|
||||
// ─── OcctKernel cross-check (requires --features occt) ────────────────────────
|
||||
|
||||
#[cfg(feature = "occt")]
|
||||
mod occt_cross_check {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn occt_girder_matches_prk_span() {
|
||||
let ir = girder_40m();
|
||||
let prk = PureRustKernel.girder_mesh(&ir).unwrap();
|
||||
let occt = OcctKernel.girder_mesh(&ir).unwrap();
|
||||
assert_primary_extent_close(&prk, &occt, 5.0, "OcctKernel girder span");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occt_pier_column_height_correct() {
|
||||
let ir = pier_standard();
|
||||
let prk = PureRustKernel.pier_mesh(&ir).unwrap();
|
||||
let occt = OcctKernel.pier_mesh(&ir).unwrap();
|
||||
// Y extent must include column height (8000 mm) in both
|
||||
let (_, mx_p) = prk.aabb();
|
||||
let (_, mx_o) = occt.aabb();
|
||||
assert!(mx_p[1] >= ir.column_height as f32 * 0.9,
|
||||
"PureRust pier Y must cover column_height, got {:.0}", mx_p[1]);
|
||||
assert!(mx_o[1] >= ir.column_height as f32 * 0.9,
|
||||
"Occt pier Y must cover column_height, got {:.0}", mx_o[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occt_abutment_matches_prk_height() {
|
||||
let ir = abutment_standard();
|
||||
let prk = PureRustKernel.abutment_mesh(&ir).unwrap();
|
||||
let occt = OcctKernel.abutment_mesh(&ir).unwrap();
|
||||
assert_primary_extent_close(&prk, &occt, 10.0, "OcctKernel abutment height");
|
||||
}
|
||||
}
|
||||
248
cimery/crates/kernel/tests/layer4_proptest.rs
Normal file
248
cimery/crates/kernel/tests/layer4_proptest.rs
Normal file
@@ -0,0 +1,248 @@
|
||||
//! Layer 4: Property-based tests with proptest (Sprint 20).
|
||||
//!
|
||||
//! Checks that for any valid input within reasonable engineering ranges,
|
||||
//! the kernel always produces a valid, non-empty mesh.
|
||||
//!
|
||||
//! Properties verified:
|
||||
//! - vertex_count > 0
|
||||
//! - triangle_count > 0
|
||||
//! - all normals ≈ unit length
|
||||
//! - bounding box positive on ≥1 axis
|
||||
//! - span axis covers the requested span (within 1%)
|
||||
|
||||
use cimery_core::{
|
||||
BearingType, ColumnShape, CrossBeamSection, ExpansionJointType,
|
||||
MaterialGrade, PierType, SectionType,
|
||||
};
|
||||
use cimery_ir::{
|
||||
BearingIR, CapBeamIR, CrossBeamIR, DeckSlabIR, ExpansionJointIR,
|
||||
FeatureId, GirderIR, PierIR, PscISectionParams, SectionParams,
|
||||
};
|
||||
use cimery_kernel::{GeomKernel, Mesh, PureRustKernel};
|
||||
use proptest::prelude::*;
|
||||
|
||||
// ─── Mesh validity helper ─────────────────────────────────────────────────────
|
||||
|
||||
fn is_valid_mesh(mesh: &Mesh) -> bool {
|
||||
if mesh.vertex_count() == 0 { return false; }
|
||||
if mesh.triangle_count() == 0 { return false; }
|
||||
if mesh.indices.len() % 3 != 0 { return false; }
|
||||
if mesh.normals.len() != mesh.vertices.len() { return false; }
|
||||
let vcount = mesh.vertex_count() as u32;
|
||||
if mesh.indices.iter().any(|&i| i >= vcount) { return false; }
|
||||
for n in &mesh.normals {
|
||||
let len = (n[0]*n[0] + n[1]*n[1] + n[2]*n[2]).sqrt();
|
||||
if (len - 1.0).abs() > 1e-3 { return false; }
|
||||
}
|
||||
let (mn, mx) = mesh.aabb();
|
||||
let any_positive = (0..3).any(|i| mx[i] - mn[i] > 0.0);
|
||||
any_positive
|
||||
}
|
||||
|
||||
fn span_ok(mesh: &Mesh, expected_mm: f32, axis: usize) -> bool {
|
||||
let (mn, mx) = mesh.aabb();
|
||||
let actual = mx[axis] - mn[axis];
|
||||
(actual - expected_mm).abs() < expected_mm * 0.02
|
||||
}
|
||||
|
||||
// ─── Girder proptest ──────────────────────────────────────────────────────────
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn proptest_girder_always_valid(
|
||||
span_m in 20.0_f64..=80.0,
|
||||
total_height in 1200.0_f64..=3000.0,
|
||||
top_flange_width in 400.0_f64..=800.0,
|
||||
bottom_flange_width in 400.0_f64..=900.0,
|
||||
) {
|
||||
let section = PscISectionParams {
|
||||
total_height,
|
||||
top_flange_width,
|
||||
top_flange_thickness: 150.0,
|
||||
bottom_flange_width,
|
||||
bottom_flange_thickness: 180.0,
|
||||
web_thickness: 200.0,
|
||||
haunch: 50.0,
|
||||
};
|
||||
let ir = GirderIR {
|
||||
id: FeatureId::new(),
|
||||
station_start: 0.0,
|
||||
station_end: span_m,
|
||||
offset_from_alignment: 0.0,
|
||||
section_type: SectionType::PscI,
|
||||
section: SectionParams::PscI(section),
|
||||
count: 1,
|
||||
spacing: 0.0,
|
||||
material: MaterialGrade::C50,
|
||||
};
|
||||
let mesh = PureRustKernel.girder_mesh(&ir).unwrap();
|
||||
prop_assert!(is_valid_mesh(&mesh), "girder mesh invalid for span={span_m}m h={total_height}mm");
|
||||
prop_assert!(span_ok(&mesh, (span_m * 1000.0) as f32, 2),
|
||||
"girder Z span wrong: expected {}mm", span_m * 1000.0);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Deck slab proptest ───────────────────────────────────────────────────────
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn proptest_deck_slab_always_valid(
|
||||
span_m in 20.0_f64..=80.0,
|
||||
width_half in 3_000.0_f64..=8_000.0,
|
||||
thickness in 180.0_f64..=300.0,
|
||||
) {
|
||||
let ir = DeckSlabIR {
|
||||
id: FeatureId::new(),
|
||||
station_start: 0.0,
|
||||
station_end: span_m,
|
||||
width_left: width_half,
|
||||
width_right: width_half,
|
||||
thickness,
|
||||
haunch_depth: 80.0,
|
||||
cross_slope: 2.0,
|
||||
material: MaterialGrade::C40,
|
||||
};
|
||||
let mesh = PureRustKernel.deck_slab_mesh(&ir).unwrap();
|
||||
prop_assert!(is_valid_mesh(&mesh), "deck_slab invalid for span={span_m}m w={width_half}mm t={thickness}mm");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Cross beam proptest ──────────────────────────────────────────────────────
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn proptest_cross_beam_always_valid(
|
||||
web_height in 800.0_f64..=2_000.0,
|
||||
web_thickness in 8.0_f64..=20.0,
|
||||
flange_width in 150.0_f64..=500.0,
|
||||
flange_thickness in 10.0_f64..=30.0,
|
||||
bay_count in 2_u32..=8,
|
||||
girder_spacing in 1_800.0_f64..=3_500.0,
|
||||
) {
|
||||
let ir = CrossBeamIR {
|
||||
id: FeatureId::new(),
|
||||
station: 10.0,
|
||||
section: CrossBeamSection::HSection,
|
||||
web_height,
|
||||
web_thickness,
|
||||
flange_width,
|
||||
flange_thickness,
|
||||
bay_count,
|
||||
girder_spacing,
|
||||
material: MaterialGrade::Ss400,
|
||||
};
|
||||
let mesh = PureRustKernel.cross_beam_mesh(&ir).unwrap();
|
||||
prop_assert!(is_valid_mesh(&mesh),
|
||||
"cross_beam invalid: wh={web_height} wt={web_thickness} bays={bay_count} sp={girder_spacing}");
|
||||
// Total length = bay_count * spacing, swept along X
|
||||
let expected_len = (bay_count as f64 * girder_spacing) as f32;
|
||||
prop_assert!(span_ok(&mesh, expected_len, 0),
|
||||
"cross_beam X extent wrong: expected {expected_len:.0}mm");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Expansion joint proptest ─────────────────────────────────────────────────
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn proptest_expansion_joint_always_valid(
|
||||
gap_width in 20.0_f64..=150.0,
|
||||
total_width in 5_000.0_f64..=15_000.0,
|
||||
depth in 50.0_f64..=200.0,
|
||||
movement_range in 10.0_f64..=100.0,
|
||||
) {
|
||||
let ir = ExpansionJointIR {
|
||||
id: FeatureId::new(),
|
||||
station: 0.0,
|
||||
joint_type: ExpansionJointType::RubberType,
|
||||
gap_width,
|
||||
total_width,
|
||||
depth,
|
||||
movement_range,
|
||||
};
|
||||
let mesh = PureRustKernel.expansion_joint_mesh(&ir).unwrap();
|
||||
prop_assert!(is_valid_mesh(&mesh),
|
||||
"expansion_joint invalid: gap={gap_width} w={total_width} d={depth}");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Bearing proptest ─────────────────────────────────────────────────────────
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn proptest_bearing_always_valid(
|
||||
plan_length in 200.0_f64..=600.0,
|
||||
plan_width in 200.0_f64..=600.0,
|
||||
height in 50.0_f64..=200.0,
|
||||
) {
|
||||
let ir = BearingIR {
|
||||
id: FeatureId::new(),
|
||||
station: 0.0,
|
||||
bearing_type: BearingType::Elastomeric,
|
||||
plan_length,
|
||||
plan_width,
|
||||
total_height: height,
|
||||
capacity_vertical:2_500.0,
|
||||
};
|
||||
let mesh = PureRustKernel.bearing_mesh(&ir).unwrap();
|
||||
prop_assert!(is_valid_mesh(&mesh),
|
||||
"bearing invalid: pl={plan_length} pw={plan_width} h={height}");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Pier proptest ────────────────────────────────────────────────────────────
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn proptest_pier_always_valid(
|
||||
col_diameter in 800.0_f64..=2_500.0,
|
||||
col_height in 4_000.0_f64..=20_000.0,
|
||||
cap_length in 8_000.0_f64..=20_000.0,
|
||||
) {
|
||||
let ir = PierIR {
|
||||
id: FeatureId::new(),
|
||||
station: 20.0,
|
||||
skew_angle: 0.0,
|
||||
pier_type: PierType::SingleColumn,
|
||||
column_shape: ColumnShape::Circular,
|
||||
column_count: 1,
|
||||
column_spacing: 0.0,
|
||||
column_diameter: col_diameter,
|
||||
column_depth: 0.0,
|
||||
column_height: col_height,
|
||||
cap_beam: CapBeamIR {
|
||||
length: cap_length,
|
||||
width: 1_200.0,
|
||||
depth: 1_400.0,
|
||||
cantilever_left: 1_000.0,
|
||||
cantilever_right:1_000.0,
|
||||
},
|
||||
material: MaterialGrade::C40,
|
||||
};
|
||||
let mesh = PureRustKernel.pier_mesh(&ir).unwrap();
|
||||
prop_assert!(is_valid_mesh(&mesh),
|
||||
"pier invalid: d={col_diameter} h={col_height} cap_l={cap_length}");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Negative: zero span must fail ───────────────────────────────────────────
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn proptest_zero_span_fails(dummy in 0.0_f64..1.0) {
|
||||
let _ = dummy; // ensure proptest runs at least once
|
||||
let ir = GirderIR {
|
||||
id: FeatureId::new(),
|
||||
station_start: 40.0,
|
||||
station_end: 40.0, // zero span
|
||||
offset_from_alignment: 0.0,
|
||||
section_type: SectionType::PscI,
|
||||
section: SectionParams::PscI(PscISectionParams::kds_standard()),
|
||||
count: 1,
|
||||
spacing: 0.0,
|
||||
material: MaterialGrade::C50,
|
||||
};
|
||||
prop_assert!(PureRustKernel.girder_mesh(&ir).is_err(),
|
||||
"zero-span girder must return Err");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
source: crates/kernel/tests/layer1_snapshots.rs
|
||||
assertion_line: 176
|
||||
expression: json
|
||||
---
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000005",
|
||||
"station": 100.0,
|
||||
"skew_angle": 0.0,
|
||||
"abutment_type": "reverse_t",
|
||||
"breast_wall_height": 5000.0,
|
||||
"breast_wall_thickness": 800.0,
|
||||
"breast_wall_width": 12000.0,
|
||||
"footing_length": 4000.0,
|
||||
"footing_width": 13000.0,
|
||||
"footing_thickness": 1000.0,
|
||||
"wing_wall_left": {
|
||||
"length": 4000.0,
|
||||
"height": 4000.0,
|
||||
"thickness": 500.0
|
||||
},
|
||||
"wing_wall_right": {
|
||||
"length": 4000.0,
|
||||
"height": 4000.0,
|
||||
"thickness": 500.0
|
||||
},
|
||||
"material": "C40"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: crates/kernel/tests/layer1_snapshots.rs
|
||||
assertion_line: 164
|
||||
expression: json
|
||||
---
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000003",
|
||||
"station": 100.0,
|
||||
"bearing_type": "elastomeric",
|
||||
"plan_length": 350.0,
|
||||
"plan_width": 350.0,
|
||||
"total_height": 90.0,
|
||||
"capacity_vertical": 2500.0
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
source: crates/kernel/tests/layer1_snapshots.rs
|
||||
assertion_line: 182
|
||||
expression: json
|
||||
---
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000006",
|
||||
"station": 110.0,
|
||||
"section": "h_section",
|
||||
"web_height": 1260.0,
|
||||
"web_thickness": 12.0,
|
||||
"flange_width": 300.0,
|
||||
"flange_thickness": 16.0,
|
||||
"bay_count": 4,
|
||||
"girder_spacing": 2500.0,
|
||||
"material": "SS400"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
source: crates/kernel/tests/layer1_snapshots.rs
|
||||
assertion_line: 158
|
||||
expression: json
|
||||
---
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000002",
|
||||
"station_start": 100.0,
|
||||
"station_end": 140.0,
|
||||
"width_left": 5500.0,
|
||||
"width_right": 5500.0,
|
||||
"thickness": 220.0,
|
||||
"haunch_depth": 100.0,
|
||||
"cross_slope": 2.0,
|
||||
"material": "C40"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: crates/kernel/tests/layer1_snapshots.rs
|
||||
assertion_line: 188
|
||||
expression: json
|
||||
---
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000007",
|
||||
"station": 100.0,
|
||||
"joint_type": "rubber_type",
|
||||
"gap_width": 50.0,
|
||||
"total_width": 11000.0,
|
||||
"depth": 100.0,
|
||||
"movement_range": 30.0
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
source: crates/kernel/tests/layer1_snapshots.rs
|
||||
assertion_line: 152
|
||||
expression: json
|
||||
---
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000001",
|
||||
"station_start": 100.0,
|
||||
"station_end": 140.0,
|
||||
"offset_from_alignment": 0.0,
|
||||
"section_type": "psc_i",
|
||||
"section": {
|
||||
"kind": "PscI",
|
||||
"total_height": 1800.0,
|
||||
"top_flange_width": 600.0,
|
||||
"top_flange_thickness": 150.0,
|
||||
"bottom_flange_width": 700.0,
|
||||
"bottom_flange_thickness": 180.0,
|
||||
"web_thickness": 200.0,
|
||||
"haunch": 50.0
|
||||
},
|
||||
"count": 5,
|
||||
"spacing": 2500.0,
|
||||
"material": "C50"
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
source: crates/kernel/tests/layer1_snapshots.rs
|
||||
assertion_line: 170
|
||||
expression: json
|
||||
---
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000004",
|
||||
"station": 120.0,
|
||||
"skew_angle": 0.0,
|
||||
"pier_type": "single_column",
|
||||
"column_shape": "circular",
|
||||
"column_count": 1,
|
||||
"column_spacing": 0.0,
|
||||
"column_diameter": 1500.0,
|
||||
"column_depth": 0.0,
|
||||
"column_height": 8000.0,
|
||||
"cap_beam": {
|
||||
"length": 13000.0,
|
||||
"width": 1200.0,
|
||||
"depth": 1400.0,
|
||||
"cantilever_left": 1000.0,
|
||||
"cantilever_right": 1000.0
|
||||
},
|
||||
"material": "C40"
|
||||
}
|
||||
Reference in New Issue
Block a user