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>
249 lines
9.5 KiB
Rust
249 lines
9.5 KiB
Rust
//! 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");
|
|
}
|
|
}
|