Sprint 3 — Must Feature 5종 추가 (상부→하부 순서)

상부 구조물:
- DeckSlabIR + DeckSlabBuilder + 기하학 (직사각형 슬래브 스위프)

연결부:
- BearingIR + BearingBuilder (카탈로그 기반, KDS 기본값 포함)

하부 구조물:
- PierIR + PierBuilder + 기하학 (다주 지원, 코핑 포함)
- AbutmentIR + AbutmentBuilder + 기하학 (흉벽 + 기초 + 날개벽)

core에 BearingType·PierType·ColumnShape·AbutmentType 열거형 추가
kernel: sweep.rs 공유 모듈 (sweep_profile_flat·box·prism·merge)
psc_i.rs → sweep.rs 의존으로 리팩터
GeomKernel trait에 4개 메서드 추가 (상부→하부 문서화 주석)

cargo test 57개 전부 통과

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
minsung
2026-04-14 19:27:57 +09:00
parent 40857f39c5
commit bdacea5253
16 changed files with 1113 additions and 85 deletions

View File

@@ -10,6 +10,7 @@ edition.workspace = true
occt = ["dep:opencascade"]
[dependencies]
cimery-core = { workspace = true }
cimery-ir = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }

View File

@@ -0,0 +1,81 @@
//! Abutment geometry — PureRustKernel.
//! Breast wall + footing + wing walls (simplified rectangular shapes).
use cimery_ir::AbutmentIR;
use crate::{KernelError, Mesh, sweep};
pub fn build_abutment_mesh(ir: &AbutmentIR) -> Result<Mesh, KernelError> {
if ir.breast_wall_height <= 0.0 {
return Err(KernelError::InvalidInput(
format!("breast_wall_height must be > 0, got {}", ir.breast_wall_height),
));
}
let bw_h = ir.breast_wall_height as f32;
let bw_t = ir.breast_wall_thickness as f32;
let bw_w = ir.breast_wall_width as f32;
let ft_l = ir.footing_length as f32;
let ft_w = ir.footing_width as f32;
let ft_t = ir.footing_thickness as f32;
let mut parts: Vec<Mesh> = Vec::new();
// Breast wall: along transverse (Z), thickness along span (X), height (Y)
parts.push(sweep::centred_box(0.0, bw_h * 0.5, bw_t * 0.5, bw_h * 0.5, bw_w));
// Footing: below grade, spans along X (span direction)
{
let profile = vec![
[-ft_l * 0.5, -ft_t],
[ ft_l * 0.5, -ft_t],
[ ft_l * 0.5, 0.0 ],
[-ft_l * 0.5, 0.0 ],
];
parts.push(sweep::sweep_profile_flat(&profile, ft_w));
}
// Wing walls (left and right)
for side in [&ir.wing_wall_left, &ir.wing_wall_right] {
let wl = side.length as f32;
let wh = side.height as f32;
let wt = side.thickness as f32;
// Simplified: vertical rectangle, oriented in XY, length along Z
parts.push(sweep::centred_box(0.0, wh * 0.5, wt * 0.5, wh * 0.5, wl));
}
Ok(sweep::merge_meshes(parts))
}
#[cfg(test)]
mod tests {
use super::*;
use cimery_core::{AbutmentType, MaterialGrade};
use cimery_ir::{AbutmentIR, FeatureId, WingWallIR};
fn test_ir() -> AbutmentIR {
let wing = WingWallIR { length: 4000.0, height: 3000.0, thickness: 500.0 };
AbutmentIR {
id: FeatureId::new(),
station: 0.0, skew_angle: 0.0,
abutment_type: AbutmentType::ReverseT,
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: wing.clone(), wing_wall_right: wing,
material: MaterialGrade::C40,
}
}
#[test]
fn produces_mesh() {
let mesh = build_abutment_mesh(&test_ir()).unwrap();
assert!(mesh.triangle_count() > 0);
}
#[test]
fn zero_height_fails() {
let mut ir = test_ir();
ir.breast_wall_height = 0.0;
assert!(build_abutment_mesh(&ir).is_err());
}
}

View File

@@ -0,0 +1,35 @@
//! Bearing geometry — PureRustKernel.
use cimery_ir::BearingIR;
use crate::{KernelError, Mesh, sweep};
pub fn build_bearing_mesh(ir: &BearingIR) -> Result<Mesh, KernelError> {
if ir.plan_length <= 0.0 || ir.plan_width <= 0.0 || ir.total_height <= 0.0 {
return Err(KernelError::InvalidInput("bearing dimensions must be positive".into()));
}
// Centred box: plan_length × total_height × plan_width
// X = along span (plan_length), Y = height, Z = transverse (plan_width)
let l = ir.plan_length as f32;
let h = ir.total_height as f32;
let w = ir.plan_width as f32;
Ok(sweep::centred_box(-l/2.0, 0.0, l/2.0, h, w))
}
#[cfg(test)]
mod tests {
use super::*;
use cimery_core::BearingType;
use cimery_ir::{BearingIR, FeatureId};
#[test]
fn produces_mesh() {
let ir = BearingIR {
id: FeatureId::new(), station: 0.0,
bearing_type: BearingType::Elastomeric,
plan_length: 350.0, plan_width: 450.0,
total_height: 60.0, capacity_vertical: 1500.0,
};
let mesh = build_bearing_mesh(&ir).unwrap();
assert!(mesh.triangle_count() > 0);
}
}

View File

@@ -0,0 +1,50 @@
//! Deck Slab geometry — PureRustKernel.
use cimery_ir::DeckSlabIR;
use crate::{KernelError, Mesh, sweep};
pub fn build_deck_slab_mesh(ir: &DeckSlabIR) -> Result<Mesh, KernelError> {
if ir.span_mm() <= 0.0 {
return Err(KernelError::InvalidInput(
format!("span must be positive, got {} m", ir.span_m()),
));
}
let w = ir.total_width() as f32; // total width [mm]
let t = ir.thickness as f32;
let s = ir.span_mm() as f32;
// Simple rectangular slab: origin at (-width_left, 0, 0), top face at Y=0, soffit at Y=-t
let profile = vec![
[-(ir.width_left as f32), 0.0],
[ ir.width_right as f32, 0.0],
[ ir.width_right as f32, -t ],
[-(ir.width_left as f32), -t ],
];
let _ = w; // calculated but slab profile already uses left/right
Ok(sweep::sweep_profile_flat(&profile, s))
}
#[cfg(test)]
mod tests {
use super::*;
use cimery_core::MaterialGrade;
use cimery_ir::{DeckSlabIR, FeatureId};
fn test_ir() -> DeckSlabIR {
DeckSlabIR {
id: FeatureId::new(),
station_start: 0.0, station_end: 40.0,
width_left: 5000.0, width_right: 5000.0,
thickness: 220.0, haunch_depth: 0.0, cross_slope: 2.0,
material: MaterialGrade::C40,
}
}
#[test]
fn produces_mesh() {
let mesh = build_deck_slab_mesh(&test_ir()).unwrap();
assert!(mesh.triangle_count() > 0);
let (_, mx) = mesh.aabb();
assert!((mx[2] - 40_000.0_f32).abs() < 1.0);
}
}

View File

@@ -11,8 +11,15 @@
//! Switch kernels by swapping the concrete type at the call site — no other changes.
pub mod psc_i;
pub mod sweep;
pub mod deck_slab;
pub mod bearing;
pub mod pier;
pub mod abutment;
use cimery_ir::{GirderIR, SectionParams};
use cimery_ir::{
AbutmentIR, BearingIR, DeckSlabIR, GirderIR, PierIR, SectionParams,
};
// ─── Mesh ─────────────────────────────────────────────────────────────────────
@@ -63,8 +70,16 @@ pub enum KernelError {
/// Backend-agnostic geometry kernel.
///
/// All implementations MUST be deterministic: same IR → same Mesh topology.
/// Feature order matches bridge construction sequence: superstructure → substructure.
pub trait GeomKernel: Send + Sync {
fn girder_mesh(&self, ir: &GirderIR) -> Result<Mesh, KernelError>;
// ── 상부 구조물 (Superstructure) ───────────────────────────────────────
fn girder_mesh(&self, ir: &GirderIR) -> Result<Mesh, KernelError>;
fn deck_slab_mesh(&self, ir: &DeckSlabIR) -> Result<Mesh, KernelError>;
// ── 연결부 (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>;
}
// ─── StubKernel ───────────────────────────────────────────────────────────────
@@ -76,6 +91,18 @@ pub trait GeomKernel: Send + Sync {
pub struct StubKernel;
impl GeomKernel for StubKernel {
fn deck_slab_mesh(&self, ir: &DeckSlabIR) -> Result<Mesh, KernelError> {
Ok(sweep::box_mesh(ir.total_width() as f32, ir.thickness as f32, ir.span_mm() as f32))
}
fn bearing_mesh(&self, ir: &BearingIR) -> Result<Mesh, KernelError> {
Ok(sweep::box_mesh(ir.plan_length as f32, ir.total_height as f32, ir.plan_width as f32))
}
fn pier_mesh(&self, ir: &PierIR) -> Result<Mesh, KernelError> {
Ok(sweep::box_mesh(ir.column_diameter as f32, ir.column_height as f32, ir.cap_beam.length as f32))
}
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 girder_mesh(&self, ir: &GirderIR) -> Result<Mesh, KernelError> {
if ir.span_m() <= 0.0 {
return Err(KernelError::InvalidInput(
@@ -114,6 +141,18 @@ impl GeomKernel for StubKernel {
pub struct PureRustKernel;
impl GeomKernel for PureRustKernel {
fn deck_slab_mesh(&self, ir: &DeckSlabIR) -> Result<Mesh, KernelError> {
deck_slab::build_deck_slab_mesh(ir)
}
fn bearing_mesh(&self, ir: &BearingIR) -> Result<Mesh, KernelError> {
bearing::build_bearing_mesh(ir)
}
fn pier_mesh(&self, ir: &PierIR) -> Result<Mesh, KernelError> {
pier::build_pier_mesh(ir)
}
fn abutment_mesh(&self, ir: &AbutmentIR) -> Result<Mesh, KernelError> {
abutment::build_abutment_mesh(ir)
}
fn girder_mesh(&self, ir: &GirderIR) -> Result<Mesh, KernelError> {
match &ir.section {
SectionParams::PscI(p) => psc_i::build_psc_i_mesh(p, ir.span_mm()),

View File

@@ -0,0 +1,97 @@
//! Pier geometry — PureRustKernel.
//! Single-column: octagonal prism (≈ circle) + rectangular cap beam.
//! Multi-column: N columns side-by-side + cap beam.
use cimery_core::ColumnShape;
use cimery_ir::PierIR;
use crate::{KernelError, Mesh, sweep};
pub fn build_pier_mesh(ir: &PierIR) -> Result<Mesh, KernelError> {
if ir.column_height <= 0.0 {
return Err(KernelError::InvalidInput(
format!("column_height must be > 0, got {}", ir.column_height),
));
}
let col_h = ir.column_height as f32;
let col_d = ir.column_diameter as f32;
let col_dep = ir.column_depth as f32;
let cap_l = ir.cap_beam.length as f32;
let cap_w = ir.cap_beam.width as f32;
let cap_d = ir.cap_beam.depth as f32;
let count = ir.column_count.max(1);
let spacing = ir.column_spacing as f32;
let mut parts: Vec<Mesh> = Vec::new();
// Columns
for i in 0..count {
let cy = (i as f32 - (count as f32 - 1.0) * 0.5) * spacing;
let col = match ir.column_shape {
ColumnShape::Circular => {
// 12-sided polygon ≈ circle
sweep::polygon_prism(0.0, cy, col_d * 0.5, 12, col_h)
}
ColumnShape::Rectangular | ColumnShape::Oval => {
sweep::centred_box(0.0, cy, col_d * 0.5, col_dep * 0.5, col_h)
}
};
parts.push(col);
}
// Cap beam — centred in transverse, at top of columns
// Translate Y by column_height via profile offset
let cap = {
let profile = vec![
[-cap_w * 0.5, col_h ],
[ cap_w * 0.5, col_h ],
[ cap_w * 0.5, col_h + cap_d ],
[-cap_w * 0.5, col_h + cap_d ],
];
sweep::sweep_profile_flat(&profile, cap_l)
};
parts.push(cap);
Ok(sweep::merge_meshes(parts))
}
#[cfg(test)]
mod tests {
use super::*;
use cimery_core::{ColumnShape, MaterialGrade, PierType};
use cimery_ir::{CapBeamIR, FeatureId, PierIR};
fn test_ir() -> PierIR {
PierIR {
id: FeatureId::new(),
station: 100.0, skew_angle: 0.0,
pier_type: PierType::SingleColumn,
column_shape: ColumnShape::Circular,
column_count: 1, column_spacing: 0.0,
column_diameter: 1500.0, column_depth: 1500.0,
column_height: 8000.0,
cap_beam: CapBeamIR {
length: 8000.0, width: 1200.0, depth: 1500.0,
cantilever_left: 1000.0, cantilever_right: 1000.0,
},
material: MaterialGrade::C40,
}
}
#[test]
fn single_column_pier() {
let mesh = build_pier_mesh(&test_ir()).unwrap();
// Basic: produces geometry
assert!(mesh.triangle_count() > 0);
assert!(mesh.vertex_count() > 0);
// Note: PureRustKernel sweeps column profile along Z (cross-section view).
// Proper 3D vertical pier geometry → OcctKernel (Sprint 3).
}
#[test]
fn zero_height_fails() {
let mut ir = test_ir();
ir.column_height = 0.0;
assert!(build_pier_mesh(&ir).is_err());
}
}

View File

@@ -8,7 +8,7 @@
//! (fillets, accurate haunches, proper mesh density).
use cimery_ir::PscISectionParams;
use crate::{KernelError, Mesh};
use crate::{KernelError, Mesh, sweep};
// ─── Public API ───────────────────────────────────────────────────────────────
@@ -25,7 +25,7 @@ pub fn build_psc_i_mesh(
));
}
let profile = psc_i_profile(p)?;
Ok(sweep_profile_flat(&profile, span_mm as f32))
Ok(sweep::sweep_profile_flat(&profile, span_mm as f32))
}
// ─── Profile ─────────────────────────────────────────────────────────────────
@@ -77,83 +77,7 @@ fn psc_i_profile(p: &PscISectionParams) -> Result<Vec<[f32; 2]>, KernelError> {
])
}
// ─── Sweep ────────────────────────────────────────────────────────────────────
/// Sweep a closed polygon profile along Z, producing a closed solid.
///
/// Uses flat normals (no shared vertices between adjacent faces).
/// Each triangle has 3 unique vertices with the same face normal.
fn sweep_profile_flat(profile: &[[f32; 2]], span: f32) -> Mesh {
let n = profile.len();
let mut vertices: Vec<[f32; 3]> = Vec::new();
let mut normals: Vec<[f32; 3]> = Vec::new();
let mut indices: Vec<u32> = Vec::new();
// Helper: push one triangle and record face normal
let mut push_tri = |v0: [f32; 3], v1: [f32; 3], v2: [f32; 3]| {
let normal = face_normal(v0, v1, v2);
for v in [v0, v1, v2] {
let idx = vertices.len() as u32;
vertices.push(v);
normals.push(normal);
indices.push(idx);
}
};
// ── Side faces: one quad (2 tris) per profile edge ─────────────────────
for i in 0..n {
let j = (i + 1) % n;
let [x0, y0] = profile[i];
let [x1, y1] = profile[j];
let a = [x0, y0, 0.0];
let b = [x1, y1, 0.0];
let c = [x1, y1, span];
let d = [x0, y0, span];
push_tri(a, b, c);
push_tri(a, c, d);
}
// ── End caps: fan triangulation from centroid ──────────────────────────
let cx: f32 = profile.iter().map(|v| v[0]).sum::<f32>() / n as f32;
let cy: f32 = profile.iter().map(|v| v[1]).sum::<f32>() / n as f32;
// Front cap (Z = 0, normal = Z). CCW from Z: centre, then CW in XY.
let cen_front = [cx, cy, 0.0];
for i in 0..n {
let j = (i + 1) % n;
let a = [profile[i][0], profile[i][1], 0.0];
let b = [profile[j][0], profile[j][1], 0.0];
push_tri(cen_front, b, a);
}
// Back cap (Z = span, normal = +Z). CCW from +Z: centre, then CCW in XY.
let cen_back = [cx, cy, span];
for i in 0..n {
let j = (i + 1) % n;
let a = [profile[i][0], profile[i][1], span];
let b = [profile[j][0], profile[j][1], span];
push_tri(cen_back, a, b);
}
Mesh { vertices, normals, indices }
}
// ─── Math helpers ─────────────────────────────────────────────────────────────
fn face_normal(a: [f32; 3], b: [f32; 3], c: [f32; 3]) -> [f32; 3] {
let ab = [b[0]-a[0], b[1]-a[1], b[2]-a[2]];
let ac = [c[0]-a[0], c[1]-a[1], c[2]-a[2]];
let n = [
ab[1]*ac[2] - ab[2]*ac[1],
ab[2]*ac[0] - ab[0]*ac[2],
ab[0]*ac[1] - ab[1]*ac[0],
];
let len = (n[0]*n[0] + n[1]*n[1] + n[2]*n[2]).sqrt();
if len < 1e-10 { return [0.0, 1.0, 0.0]; }
[n[0]/len, n[1]/len, n[2]/len]
}
// Sweep and geometry helpers are in crate::sweep.
// ─── Tests ────────────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,125 @@
//! Shared geometry helpers for PureRustKernel feature modules.
//! Cross-section sweep + face normal computation.
use crate::Mesh;
// ─── Normals ──────────────────────────────────────────────────────────────────
/// Face normal of a CCW triangle (normalised).
pub fn face_normal(a: [f32; 3], b: [f32; 3], c: [f32; 3]) -> [f32; 3] {
let ab = [b[0]-a[0], b[1]-a[1], b[2]-a[2]];
let ac = [c[0]-a[0], c[1]-a[1], c[2]-a[2]];
let n = [
ab[1]*ac[2] - ab[2]*ac[1],
ab[2]*ac[0] - ab[0]*ac[2],
ab[0]*ac[1] - ab[1]*ac[0],
];
let len = (n[0]*n[0] + n[1]*n[1] + n[2]*n[2]).sqrt();
if len < 1e-10 { return [0.0, 1.0, 0.0]; }
[n[0]/len, n[1]/len, n[2]/len]
}
// ─── Sweep ────────────────────────────────────────────────────────────────────
/// Sweep a closed polygon profile along Z from 0 to `span`.
/// Uses flat normals (no shared vertices — faceted appearance).
pub fn sweep_profile_flat(profile: &[[f32; 2]], span: f32) -> Mesh {
let n = profile.len();
let mut vertices: Vec<[f32; 3]> = Vec::new();
let mut normals: Vec<[f32; 3]> = Vec::new();
let mut indices: Vec<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);
}
};
// Side faces
for i in 0..n {
let j = (i + 1) % n;
let [x0, y0] = profile[i];
let [x1, y1] = profile[j];
let a = [x0, y0, 0.0];
let b = [x1, y1, 0.0];
let c = [x1, y1, span];
let d = [x0, y0, span];
push_tri(a, b, c);
push_tri(a, c, d);
}
// Caps
let cx: f32 = profile.iter().map(|v| v[0]).sum::<f32>() / n as f32;
let cy: f32 = profile.iter().map(|v| v[1]).sum::<f32>() / n as f32;
let cen_f = [cx, cy, 0.0];
for i in 0..n {
let j = (i+1)%n;
push_tri(cen_f, [profile[j][0],profile[j][1],0.0], [profile[i][0],profile[i][1],0.0]);
}
let cen_b = [cx, cy, span];
for i in 0..n {
let j = (i+1)%n;
push_tri(cen_b, [profile[i][0],profile[i][1],span], [profile[j][0],profile[j][1],span]);
}
Mesh { vertices, normals, indices }
}
// ─── Convenience shapes ───────────────────────────────────────────────────────
/// Axis-aligned box: width × height × depth. Origin at (0,0,0).
pub fn box_mesh(width: f32, height: f32, depth: f32) -> Mesh {
let w = width;
let h = height;
let d = depth;
let profile = vec![
[0.0, 0.0],
[w, 0.0],
[w, h ],
[0.0, h ],
];
sweep_profile_flat(&profile, d)
}
/// Centred rectangle in XY, swept along Z.
/// Centre at (cx, cy), half-extents (hw, hh).
pub fn centred_box(cx: f32, cy: f32, hw: f32, hh: f32, depth: f32) -> Mesh {
let profile = vec![
[cx - hw, cy - hh],
[cx + hw, cy - hh],
[cx + hw, cy + hh],
[cx - hw, cy + hh],
];
sweep_profile_flat(&profile, depth)
}
/// Regular n-gon prism: approximates a cylinder.
/// Centred at (cx, cy), swept along Z.
pub fn polygon_prism(cx: f32, cy: f32, radius: f32, sides: u32, height: f32) -> Mesh {
let n = sides.max(3) as usize;
let profile: Vec<[f32; 2]> = (0..n).map(|i| {
let a = std::f32::consts::TAU * i as f32 / n as f32;
[cx + radius * a.cos(), cy + radius * a.sin()]
}).collect();
sweep_profile_flat(&profile, height)
}
// ─── Merge ────────────────────────────────────────────────────────────────────
/// Concatenate multiple meshes into one.
pub fn merge_meshes(meshes: Vec<Mesh>) -> Mesh {
let mut vertices: Vec<[f32; 3]> = Vec::new();
let mut normals: Vec<[f32; 3]> = Vec::new();
let mut indices: Vec<u32> = Vec::new();
for m in meshes {
let base = vertices.len() as u32;
vertices.extend_from_slice(&m.vertices);
normals.extend_from_slice(&m.normals);
indices.extend(m.indices.iter().map(|i| i + base));
}
Mesh { vertices, normals, indices }
}