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:
minsung
2026-04-15 08:18:06 +09:00
parent 81349c97d2
commit 1f9ca3a00f
37 changed files with 3569 additions and 259 deletions

View 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);
}

View 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());
}

View 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");
}
}

View 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");
}
}

View File

@@ -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"
}

View File

@@ -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
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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
}

View File

@@ -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"
}

View File

@@ -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"
}