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