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:
10
PLAN.md
10
PLAN.md
@@ -14,11 +14,13 @@
|
||||
## 현재 스프린트 (Current)
|
||||
|
||||
### P0 — 즉시 착수
|
||||
(없음 — 사용자 신호 대기)
|
||||
- [~] **Sprint 3 — Must Feature 구현 (상부→하부 순서)** — 진행 중
|
||||
- 상부 구조물: Deck Slab → Cross Beam
|
||||
- 연결부: Bearing
|
||||
- 하부 구조물: Pier → Abutment
|
||||
|
||||
### P1 — 다음 단계 (사용자 승인 후 착수)
|
||||
- [ ] **Sprint 2 — OCCT 실제 커널 연결** — opencascade-rs 크레이트로 StubKernel 대체. PSC I 거더 실제 B-rep sweep 생성. ADR-001 참조.
|
||||
- [ ] **Sprint 2 — wgpu에 Girder Mesh 렌더** — StubKernel mesh를 viewer에서 실제 렌더링. 카메라 orbit(Revit ViewCube 방식).
|
||||
### P1 — 다음 단계
|
||||
- [ ] **Sprint 3 — OcctKernel** (`--features occt`) — VS Dev Cmd에서 `cargo clean && cargo build -p cimery-kernel --features occt`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -105,6 +105,48 @@ pub enum MaterialGrade {
|
||||
Sm490,
|
||||
}
|
||||
|
||||
/// Bearing type (support device between girder and substructure).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BearingType {
|
||||
Elastomeric, // 탄성받침 — most common, PSC bridge
|
||||
Pot, // 포트받침 — steel box, large loads
|
||||
Spherical, // 구면받침 — rotation in all directions
|
||||
Rocker, // 롤러받침 — fixed rotation, expansion
|
||||
FixedPin, // 고정핀
|
||||
}
|
||||
|
||||
/// Pier type (교각 형식).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PierType {
|
||||
SingleColumn, // 단주식
|
||||
MultiColumn, // 다주식
|
||||
Hammerhead, // 해머헤드식
|
||||
Wall, // 벽식
|
||||
Portal, // 문형
|
||||
}
|
||||
|
||||
/// Pier column cross-section.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ColumnShape {
|
||||
Circular,
|
||||
Rectangular,
|
||||
Oval,
|
||||
}
|
||||
|
||||
/// Abutment type (교대 형식).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AbutmentType {
|
||||
ReverseT, // 역T형 — most common
|
||||
Wall, // 중력식
|
||||
SemiIntegral, // 반일체식
|
||||
Integral, // 일체식
|
||||
Counterfort, // 부벽식
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
122
cimery/crates/dsl/src/abutment.rs
Normal file
122
cimery/crates/dsl/src/abutment.rs
Normal 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 { .. }));
|
||||
}
|
||||
}
|
||||
119
cimery/crates/dsl/src/bearing.rs
Normal file
119
cimery/crates/dsl/src/bearing.rs
Normal 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 { .. }));
|
||||
}
|
||||
}
|
||||
133
cimery/crates/dsl/src/deck_slab.rs
Normal file
133
cimery/crates/dsl/src/deck_slab.rs
Normal 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 { .. }));
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
143
cimery/crates/dsl/src/pier.rs
Normal file
143
cimery/crates/dsl/src/pier.rs
Normal 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 { .. }));
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,112 @@ pub struct SteelPlateIParams {
|
||||
pub web_thickness: f64,
|
||||
}
|
||||
|
||||
// ─── Deck Slab IR ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Fully-resolved Deck Slab (바닥판) specification.
|
||||
/// Structural dimensions in mm, alignment in m.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeckSlabIR {
|
||||
pub id: FeatureId,
|
||||
pub station_start: f64, // m
|
||||
pub station_end: f64, // m
|
||||
/// Width left of alignment centreline [mm].
|
||||
pub width_left: f64,
|
||||
/// Width right of alignment centreline [mm].
|
||||
pub width_right: f64,
|
||||
/// Slab thickness [mm].
|
||||
pub thickness: f64,
|
||||
/// Haunch depth over girder top flange [mm].
|
||||
pub haunch_depth: f64,
|
||||
/// Cross-slope [%]. Positive = right side lower.
|
||||
pub cross_slope: f64,
|
||||
pub material: cimery_core::MaterialGrade,
|
||||
}
|
||||
|
||||
impl DeckSlabIR {
|
||||
pub fn span_m(&self) -> f64 { self.station_end - self.station_start }
|
||||
pub fn span_mm(&self) -> f64 { self.span_m() * 1_000.0 }
|
||||
pub fn total_width(&self) -> f64 { self.width_left + self.width_right }
|
||||
}
|
||||
|
||||
// ─── Bearing IR ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Fully-resolved Bearing (받침) specification.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BearingIR {
|
||||
pub id: FeatureId,
|
||||
pub station: f64, // m — position along alignment
|
||||
pub bearing_type: cimery_core::BearingType,
|
||||
/// Dimension along span direction [mm].
|
||||
pub plan_length: f64,
|
||||
/// Dimension transverse to span [mm].
|
||||
pub plan_width: f64,
|
||||
pub total_height: f64, // mm
|
||||
pub capacity_vertical: f64, // kN
|
||||
}
|
||||
|
||||
// ─── Cap Beam IR ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Pier cap beam (교각 코핑).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CapBeamIR {
|
||||
pub length: f64, // mm — total along transverse
|
||||
pub width: f64, // mm — along span
|
||||
pub depth: f64, // mm — vertical
|
||||
pub cantilever_left: f64, // mm — overhang beyond outermost column
|
||||
pub cantilever_right: f64,
|
||||
}
|
||||
|
||||
// ─── Pier IR ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Fully-resolved Pier (교각) specification.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PierIR {
|
||||
pub id: FeatureId,
|
||||
pub station: f64, // m
|
||||
pub skew_angle: f64, // degrees — deviation from normal
|
||||
pub pier_type: cimery_core::PierType,
|
||||
pub column_shape: cimery_core::ColumnShape,
|
||||
pub column_count: u32,
|
||||
pub column_spacing: f64, // mm — centre-to-centre
|
||||
/// Diameter (circular) or width (rectangular) [mm].
|
||||
pub column_diameter: f64,
|
||||
/// Depth for rectangular section [mm]; ignored for circular.
|
||||
pub column_depth: f64,
|
||||
pub column_height: f64, // mm — footing top to cap beam soffit
|
||||
pub cap_beam: CapBeamIR,
|
||||
pub material: cimery_core::MaterialGrade,
|
||||
}
|
||||
|
||||
// ─── Wing Wall IR ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WingWallIR {
|
||||
pub length: f64, // mm — along wing wall axis
|
||||
pub height: f64, // mm — at connection with breast wall
|
||||
pub thickness: f64, // mm
|
||||
}
|
||||
|
||||
// ─── Abutment IR ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Fully-resolved Abutment (교대) specification.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AbutmentIR {
|
||||
pub id: FeatureId,
|
||||
pub station: f64, // m
|
||||
pub skew_angle: f64, // degrees
|
||||
pub abutment_type: cimery_core::AbutmentType,
|
||||
pub breast_wall_height: f64, // mm
|
||||
pub breast_wall_thickness: f64, // mm
|
||||
pub breast_wall_width: f64, // mm — transverse
|
||||
pub footing_length: f64, // mm — along span
|
||||
pub footing_width: f64, // mm — transverse
|
||||
pub footing_thickness: f64, // mm
|
||||
pub wing_wall_left: WingWallIR,
|
||||
pub wing_wall_right: WingWallIR,
|
||||
pub material: cimery_core::MaterialGrade,
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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 }
|
||||
|
||||
81
cimery/crates/kernel/src/abutment.rs
Normal file
81
cimery/crates/kernel/src/abutment.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
35
cimery/crates/kernel/src/bearing.rs
Normal file
35
cimery/crates/kernel/src/bearing.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
50
cimery/crates/kernel/src/deck_slab.rs
Normal file
50
cimery/crates/kernel/src/deck_slab.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
|
||||
97
cimery/crates/kernel/src/pier.rs
Normal file
97
cimery/crates/kernel/src/pier.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
125
cimery/crates/kernel/src/sweep.rs
Normal file
125
cimery/crates/kernel/src/sweep.rs
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user