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

@@ -0,0 +1,122 @@
//! Abutment (교대) Feature builder.
use cimery_core::{AbutmentType, FeatureError, MaterialGrade, Mm, M, UnitExt};
use cimery_ir::{AbutmentIR, FeatureId, WingWallIR};
#[derive(Debug)]
pub struct Abutment {
pub ir: AbutmentIR,
}
impl Abutment {
pub fn builder() -> AbutmentBuilder { AbutmentBuilder::default() }
}
/// Builder for an Abutment Feature.
#[derive(Default)]
pub struct AbutmentBuilder {
station: Option<M>,
skew_angle: Option<f64>,
abutment_type: Option<AbutmentType>,
breast_wall_height: Option<Mm>,
breast_wall_thickness: Option<Mm>,
breast_wall_width: Option<Mm>,
footing_length: Option<Mm>,
footing_width: Option<Mm>,
footing_thickness: Option<Mm>,
wing_length: Option<Mm>, // simplified: same left & right
material: Option<MaterialGrade>,
}
impl AbutmentBuilder {
/// #[param(unit="m")] Station along alignment
pub fn station(mut self, v: M) -> Self { self.station = Some(v); self }
/// #[param(unit="deg", range=-45.0..=45.0, default=0.0)]
pub fn skew_angle(mut self, v: f64) -> Self { self.skew_angle = Some(v); self }
/// #[param(enum=AbutmentType, default=ReverseT)]
pub fn abutment_type(mut self, t: AbutmentType) -> Self { self.abutment_type = Some(t); self }
/// #[param(unit="mm", range=2000.0..=12_000.0)] Breast wall height
pub fn breast_wall_height(mut self, v: Mm) -> Self { self.breast_wall_height = Some(v); self }
/// #[param(unit="mm", range=500.0..=3000.0, default=800.0)]
pub fn breast_wall_thickness(mut self, v: Mm) -> Self { self.breast_wall_thickness = Some(v); self }
/// #[param(unit="mm")] Breast wall transverse width
pub fn breast_wall_width(mut self, v: Mm) -> Self { self.breast_wall_width = Some(v); self }
/// #[param(unit="mm", range=2000.0..=8000.0, default=4000.0)] Footing along span
pub fn footing_length(mut self, v: Mm) -> Self { self.footing_length = Some(v); self }
/// #[param(unit="mm")] Footing transverse width
pub fn footing_width(mut self, v: Mm) -> Self { self.footing_width = Some(v); self }
/// #[param(unit="mm", range=500.0..=2000.0, default=1000.0)]
pub fn footing_thickness(mut self, v: Mm) -> Self { self.footing_thickness = Some(v); self }
/// #[param(unit="mm")] Wing wall length (both sides equal)
pub fn wing_length(mut self, v: Mm) -> Self { self.wing_length = Some(v); self }
/// #[param(enum=MaterialGrade, default=C30)]
pub fn material(mut self, m: MaterialGrade) -> Self { self.material = Some(m); self }
pub fn build(self) -> Result<Abutment, FeatureError> {
let station = self.station
.ok_or_else(|| FeatureError::missing("abutment.station"))?.value();
let bw_h = self.breast_wall_height
.ok_or_else(|| FeatureError::missing("abutment.breast_wall_height"))?.value();
let bw_w = self.breast_wall_width
.ok_or_else(|| FeatureError::missing("abutment.breast_wall_width"))?.value();
let bw_t = self.breast_wall_thickness.unwrap_or(800.0.mm()).value();
let foot_l = self.footing_length.unwrap_or(4000.0.mm()).value();
let foot_w = self.footing_width.unwrap_or_else(|| Mm(bw_w + 1000.0)).value();
let foot_t = self.footing_thickness.unwrap_or(1000.0.mm()).value();
let wing_l = self.wing_length.unwrap_or_else(|| Mm(bw_h * 0.8)).value();
let wing_h = bw_h * 0.5; // simplified: half the breast wall height
let wing_t = bw_t;
if bw_h < 1000.0 {
return Err(FeatureError::validation("abutment.breast_wall_height",
format!("minimum 1000 mm, got {bw_h:.0} mm")));
}
let wing = WingWallIR { length: wing_l, height: wing_h, thickness: wing_t };
Ok(Abutment { ir: AbutmentIR {
id: FeatureId::new(),
station,
skew_angle: self.skew_angle.unwrap_or(0.0),
abutment_type: self.abutment_type.unwrap_or(AbutmentType::ReverseT),
breast_wall_height: bw_h,
breast_wall_thickness: bw_t,
breast_wall_width: bw_w,
footing_length: foot_l,
footing_width: foot_w,
footing_thickness: foot_t,
wing_wall_left: wing.clone(),
wing_wall_right: wing,
material: self.material.unwrap_or(MaterialGrade::C40),
}})
}
}
#[cfg(test)]
mod tests {
use super::*;
use cimery_core::{AbutmentType, UnitExt};
#[test]
fn build_reverse_t() {
let a = Abutment::builder()
.station(100.0.m())
.abutment_type(AbutmentType::ReverseT)
.breast_wall_height(5000.0.mm())
.breast_wall_width(12000.0.mm())
.build()
.unwrap();
assert_eq!(a.ir.abutment_type, AbutmentType::ReverseT);
assert!((a.ir.breast_wall_height - 5000.0).abs() < f64::EPSILON);
}
#[test]
fn too_short_breast_wall_fails() {
let e = Abutment::builder()
.station(0.0.m())
.breast_wall_height(500.0.mm()) // < 1000
.breast_wall_width(10000.0.mm())
.build().unwrap_err();
assert!(matches!(e, FeatureError::Validation { .. }));
}
}

View File

@@ -0,0 +1,119 @@
//! Bearing (받침) Feature builder.
//!
//! Bearing is a catalogue-based Feature: select a type and verify capacity
//! rather than computing section geometry from scratch.
use cimery_core::{BearingType, FeatureError, Mm, M};
use cimery_ir::{BearingIR, FeatureId};
#[derive(Debug)]
pub struct Bearing {
pub ir: BearingIR,
}
impl Bearing {
pub fn builder() -> BearingBuilder { BearingBuilder::default() }
/// KDS 24.14.3 elastomeric bearing defaults for PSC-I girder.
pub fn elastomeric_kds() -> Result<Self, FeatureError> {
Self::builder()
.station(M(0.0))
.bearing_type(BearingType::Elastomeric)
.plan_length(Mm(350.0))
.plan_width(Mm(450.0))
.total_height(Mm(60.0))
.capacity_vertical(1500.0)
.build()
}
}
/// Builder for a Bearing Feature.
///
/// Bearings are typically selected from a catalogue.
/// Provide dimensions and capacity from the manufacturer/catalogue data.
#[derive(Default)]
pub struct BearingBuilder {
station: Option<M>,
bearing_type: Option<BearingType>,
plan_length: Option<Mm>,
plan_width: Option<Mm>,
total_height: Option<Mm>,
capacity_vertical: Option<f64>,
}
impl BearingBuilder {
/// #[param(unit="m")] Station along alignment
pub fn station(mut self, v: M) -> Self { self.station = Some(v); self }
/// #[param(enum=BearingType, default=Elastomeric)]
pub fn bearing_type(mut self, t: BearingType) -> Self { self.bearing_type = Some(t); self }
/// #[param(unit="mm", range=150.0..=800.0, default=350.0)] Along span
pub fn plan_length(mut self, v: Mm) -> Self { self.plan_length = Some(v); self }
/// #[param(unit="mm", range=150.0..=800.0, default=450.0)] Transverse
pub fn plan_width(mut self, v: Mm) -> Self { self.plan_width = Some(v); self }
/// #[param(unit="mm", range=30.0..=300.0, default=60.0)]
pub fn total_height(mut self, v: Mm) -> Self { self.total_height = Some(v); self }
/// #[param(unit="kN", range=100.0..=50_000.0)]
pub fn capacity_vertical(mut self, v: f64) -> Self { self.capacity_vertical = Some(v); self }
pub fn build(self) -> Result<Bearing, FeatureError> {
let station = self.station.ok_or_else(|| FeatureError::missing("bearing.station"))?.value();
let bearing_type = self.bearing_type.ok_or_else(|| FeatureError::missing("bearing.bearing_type"))?;
let plan_length = self.plan_length.ok_or_else(|| FeatureError::missing("bearing.plan_length"))?.value();
let plan_width = self.plan_width.ok_or_else(|| FeatureError::missing("bearing.plan_width"))?.value();
let total_height = self.total_height.ok_or_else(|| FeatureError::missing("bearing.total_height"))?.value();
let cap_v = self.capacity_vertical.ok_or_else(|| FeatureError::missing("bearing.capacity_vertical"))?;
if plan_length < 100.0 || plan_width < 100.0 {
return Err(FeatureError::validation("bearing.plan", "minimum plan dimension 100 mm"));
}
if cap_v <= 0.0 {
return Err(FeatureError::validation("bearing.capacity_vertical", "must be > 0 kN"));
}
Ok(Bearing { ir: BearingIR {
id: FeatureId::new(),
station,
bearing_type,
plan_length,
plan_width,
total_height,
capacity_vertical: cap_v,
}})
}
}
#[cfg(test)]
mod tests {
use super::*;
use cimery_core::BearingType;
#[test]
fn build_elastomeric() {
let b = Bearing::builder()
.station(M(100.0))
.bearing_type(BearingType::Elastomeric)
.plan_length(Mm(350.0))
.plan_width(Mm(450.0))
.total_height(Mm(60.0))
.capacity_vertical(1500.0)
.build()
.unwrap();
assert_eq!(b.ir.bearing_type, BearingType::Elastomeric);
}
#[test]
fn kds_default() {
assert!(Bearing::elastomeric_kds().is_ok());
}
#[test]
fn zero_capacity_fails() {
let e = Bearing::builder()
.station(M(0.0))
.bearing_type(BearingType::Pot)
.plan_length(Mm(400.0)).plan_width(Mm(400.0)).total_height(Mm(100.0))
.capacity_vertical(0.0)
.build().unwrap_err();
assert!(matches!(e, FeatureError::Validation { .. }));
}
}

View File

@@ -0,0 +1,133 @@
//! Deck Slab (바닥판) Feature builder.
use cimery_core::{FeatureError, MaterialGrade, Mm, M, UnitExt};
use cimery_ir::{DeckSlabIR, FeatureId};
#[derive(Debug)]
pub struct DeckSlab {
pub ir: DeckSlabIR,
}
impl DeckSlab {
pub fn builder() -> DeckSlabBuilder { DeckSlabBuilder::default() }
}
/// Builder for a Deck Slab Feature.
///
/// Example:
/// ```rust
/// use cimery_core::UnitExt;
/// use cimery_dsl::DeckSlab;
///
/// let s = DeckSlab::builder()
/// .station_start(100.0.m())
/// .station_end(140.0.m())
/// .width_left(5000.0.mm())
/// .width_right(5000.0.mm())
/// .thickness(220.0.mm())
/// .build()
/// .unwrap();
/// assert!((s.ir.span_m() - 40.0).abs() < f64::EPSILON);
/// ```
#[derive(Default)]
pub struct DeckSlabBuilder {
station_start: Option<M>,
station_end: Option<M>,
width_left: Option<Mm>,
width_right: Option<Mm>,
thickness: Option<Mm>,
haunch_depth: Option<Mm>,
cross_slope: Option<f64>,
material: Option<MaterialGrade>,
}
impl DeckSlabBuilder {
/// #[param(unit="m")]
pub fn station_start(mut self, v: M) -> Self { self.station_start = Some(v); self }
/// #[param(unit="m")]
pub fn station_end(mut self, v: M) -> Self { self.station_end = Some(v); self }
/// #[param(unit="mm", range=500.0..=20_000.0, default=5000.0)] Width left of centreline
pub fn width_left(mut self, v: Mm) -> Self { self.width_left = Some(v); self }
/// #[param(unit="mm", range=500.0..=20_000.0, default=5000.0)] Width right of centreline
pub fn width_right(mut self, v: Mm) -> Self { self.width_right = Some(v); self }
/// #[param(unit="mm", range=150.0..=500.0, default=220.0)]
pub fn thickness(mut self, v: Mm) -> Self { self.thickness = Some(v); self }
/// #[param(unit="mm", range=0.0..=300.0, default=0.0)]
pub fn haunch_depth(mut self, v: Mm) -> Self { self.haunch_depth = Some(v); self }
/// #[param(unit="%", range=-5.0..=5.0, default=2.0)]
pub fn cross_slope(mut self, v: f64) -> Self { self.cross_slope = Some(v); self }
/// #[param(enum=MaterialGrade, default=C40)]
pub fn material(mut self, m: MaterialGrade) -> Self { self.material = Some(m); self }
pub fn build(self) -> Result<DeckSlab, FeatureError> {
let station_start = self.station_start
.ok_or_else(|| FeatureError::missing("deck_slab.station_start"))?.value();
let station_end = self.station_end
.ok_or_else(|| FeatureError::missing("deck_slab.station_end"))?.value();
let width_left = self.width_left
.ok_or_else(|| FeatureError::missing("deck_slab.width_left"))?.value();
let width_right = self.width_right
.ok_or_else(|| FeatureError::missing("deck_slab.width_right"))?.value();
let thickness = self.thickness
.ok_or_else(|| FeatureError::missing("deck_slab.thickness"))?.value();
if station_end <= station_start {
return Err(FeatureError::validation("deck_slab.station_end",
format!("must be > station_start ({station_start:.3} m)")));
}
if thickness < 150.0 {
return Err(FeatureError::validation("deck_slab.thickness",
format!("minimum 150 mm, got {thickness:.0} mm")));
}
if width_left + width_right < 1000.0 {
return Err(FeatureError::validation("deck_slab.width",
"total width must be ≥ 1000 mm"));
}
Ok(DeckSlab { ir: DeckSlabIR {
id: FeatureId::new(),
station_start,
station_end,
width_left,
width_right,
thickness,
haunch_depth: self.haunch_depth.unwrap_or(0.0.mm()).value(),
cross_slope: self.cross_slope.unwrap_or(2.0),
material: self.material.unwrap_or(MaterialGrade::C40),
}})
}
}
#[cfg(test)]
mod tests {
use super::*;
use cimery_core::UnitExt;
fn slab() -> DeckSlab {
DeckSlab::builder()
.station_start(100.0.m())
.station_end(140.0.m())
.width_left(5000.0.mm())
.width_right(5000.0.mm())
.thickness(220.0.mm())
.build()
.unwrap()
}
#[test]
fn build_valid() {
let s = slab();
assert!((s.ir.span_m() - 40.0).abs() < f64::EPSILON);
assert!((s.ir.total_width() - 10_000.0).abs() < f64::EPSILON);
}
#[test]
fn too_thin_fails() {
let e = DeckSlab::builder()
.station_start(0.0.m()).station_end(40.0.m())
.width_left(5000.0.mm()).width_right(5000.0.mm())
.thickness(100.0.mm()) // < 150
.build().unwrap_err();
assert!(matches!(e, FeatureError::Validation { .. }));
}
}

View File

@@ -6,4 +6,13 @@
//! for a future macro-upgrade path and do not affect compilation.
pub mod girder;
pub mod deck_slab;
pub mod bearing;
pub mod pier;
pub mod abutment;
pub use girder::{Girder, GirderBuilder};
pub use deck_slab::{DeckSlab, DeckSlabBuilder};
pub use bearing::{Bearing, BearingBuilder};
pub use pier::{Pier, PierBuilder};
pub use abutment::{Abutment, AbutmentBuilder};

View File

@@ -0,0 +1,143 @@
//! Pier (교각) Feature builder.
use cimery_core::{
AbutmentType as _, ColumnShape, FeatureError, MaterialGrade, Mm, M, PierType, UnitExt,
};
use cimery_ir::{CapBeamIR, FeatureId, PierIR};
#[derive(Debug)]
pub struct Pier {
pub ir: PierIR,
}
impl Pier {
pub fn builder() -> PierBuilder { PierBuilder::default() }
}
/// Builder for a Pier Feature.
#[derive(Default)]
pub struct PierBuilder {
station: Option<M>,
skew_angle: Option<f64>,
pier_type: Option<PierType>,
column_shape: Option<ColumnShape>,
column_count: Option<u32>,
column_spacing: Option<Mm>,
column_diameter: Option<Mm>,
column_depth: Option<Mm>,
column_height: Option<Mm>,
cap_length: Option<Mm>,
cap_width: Option<Mm>,
cap_depth: Option<Mm>,
material: Option<MaterialGrade>,
}
impl PierBuilder {
/// #[param(unit="m")] Station along alignment
pub fn station(mut self, v: M) -> Self { self.station = Some(v); self }
/// #[param(unit="deg", range=-45.0..=45.0, default=0.0)]
pub fn skew_angle(mut self, v: f64) -> Self { self.skew_angle = Some(v); self }
/// #[param(enum=PierType, default=SingleColumn)]
pub fn pier_type(mut self, t: PierType) -> Self { self.pier_type = Some(t); self }
/// #[param(enum=ColumnShape, default=Circular)]
pub fn column_shape(mut self, s: ColumnShape) -> Self { self.column_shape = Some(s); self }
/// #[param(unit="count", range=1..=6, default=1)]
pub fn column_count(mut self, n: u32) -> Self { self.column_count = Some(n); self }
/// #[param(unit="mm", range=500.0..=5000.0, default=3000.0)] Column c/c spacing
pub fn column_spacing(mut self, v: Mm) -> Self { self.column_spacing = Some(v); self }
/// #[param(unit="mm", range=800.0..=5000.0, default=1500.0)] Diameter or width
pub fn column_diameter(mut self, v: Mm) -> Self { self.column_diameter = Some(v); self }
/// #[param(unit="mm", range=800.0..=5000.0, default=1500.0)] Depth (rectangular only)
pub fn column_depth(mut self, v: Mm) -> Self { self.column_depth = Some(v); self }
/// #[param(unit="mm", range=3000.0..=40_000.0)] Foundation top to cap soffit
pub fn column_height(mut self, v: Mm) -> Self { self.column_height = Some(v); self }
/// #[param(unit="mm")] Cap beam total transverse length
pub fn cap_length(mut self, v: Mm) -> Self { self.cap_length = Some(v); self }
/// #[param(unit="mm", range=800.0..=3000.0, default=1200.0)] Along span
pub fn cap_width(mut self, v: Mm) -> Self { self.cap_width = Some(v); self }
/// #[param(unit="mm", range=800.0..=3000.0, default=1500.0)]
pub fn cap_depth(mut self, v: Mm) -> Self { self.cap_depth = Some(v); self }
/// #[param(enum=MaterialGrade, default=C40)]
pub fn material(mut self, m: MaterialGrade) -> Self { self.material = Some(m); self }
pub fn build(self) -> Result<Pier, FeatureError> {
let station = self.station.ok_or_else(|| FeatureError::missing("pier.station"))?.value();
let pier_type = self.pier_type.unwrap_or(PierType::SingleColumn);
let col_shape = self.column_shape.unwrap_or(ColumnShape::Circular);
let col_count = self.column_count.unwrap_or(1);
let col_dia = self.column_diameter
.ok_or_else(|| FeatureError::missing("pier.column_diameter"))?.value();
let col_h = self.column_height
.ok_or_else(|| FeatureError::missing("pier.column_height"))?.value();
let cap_w = self.cap_width.unwrap_or(1200.0.mm()).value();
let cap_d = self.cap_depth.unwrap_or(1500.0.mm()).value();
let cap_l = self.cap_length.unwrap_or_else(|| {
let span = col_count as f64 * col_dia + 2.0 * 1000.0;
Mm(span)
}).value();
if col_dia < 500.0 {
return Err(FeatureError::validation("pier.column_diameter",
format!("minimum 500 mm, got {col_dia:.0} mm")));
}
if col_h < 2000.0 {
return Err(FeatureError::validation("pier.column_height",
format!("minimum 2000 mm, got {col_h:.0} mm")));
}
if col_count > 1 && self.column_spacing.is_none() {
return Err(FeatureError::missing("pier.column_spacing"));
}
Ok(Pier { ir: PierIR {
id: FeatureId::new(),
station,
skew_angle: self.skew_angle.unwrap_or(0.0),
pier_type,
column_shape: col_shape,
column_count: col_count,
column_spacing: self.column_spacing.unwrap_or(0.0.mm()).value(),
column_diameter: col_dia,
column_depth: self.column_depth.unwrap_or_else(|| Mm(col_dia)).value(),
column_height: col_h,
cap_beam: CapBeamIR {
length: cap_l,
width: cap_w,
depth: cap_d,
cantilever_left: 1000.0,
cantilever_right: 1000.0,
},
material: self.material.unwrap_or(MaterialGrade::C40),
}})
}
}
#[cfg(test)]
mod tests {
use super::*;
use cimery_core::{ColumnShape, PierType, UnitExt};
#[test]
fn single_column_pier() {
let p = Pier::builder()
.station(100.0.m())
.pier_type(PierType::SingleColumn)
.column_shape(ColumnShape::Circular)
.column_diameter(1500.0.mm())
.column_height(8000.0.mm())
.build()
.unwrap();
assert_eq!(p.ir.column_count, 1);
assert!((p.ir.column_height - 8000.0).abs() < f64::EPSILON);
}
#[test]
fn multi_column_needs_spacing() {
let e = Pier::builder()
.station(100.0.m())
.column_count(3)
.column_diameter(1200.0.mm())
.column_height(8000.0.mm())
.build().unwrap_err();
assert!(matches!(e, FeatureError::MissingField { .. }));
}
}