From bdacea5253526ee917522d5e0f5565afb3b49a4c Mon Sep 17 00:00:00 2001 From: minsung Date: Tue, 14 Apr 2026 19:27:57 +0900 Subject: [PATCH] =?UTF-8?q?Sprint=203=20=E2=80=94=20Must=20Feature=205?= =?UTF-8?q?=EC=A2=85=20=EC=B6=94=EA=B0=80=20(=EC=83=81=EB=B6=80=E2=86=92?= =?UTF-8?q?=ED=95=98=EB=B6=80=20=EC=88=9C=EC=84=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 상부 구조물: - 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) --- PLAN.md | 10 +- cimery/crates/core/src/lib.rs | 42 ++++++++ cimery/crates/dsl/src/abutment.rs | 122 ++++++++++++++++++++++ cimery/crates/dsl/src/bearing.rs | 119 +++++++++++++++++++++ cimery/crates/dsl/src/deck_slab.rs | 133 ++++++++++++++++++++++++ cimery/crates/dsl/src/lib.rs | 9 ++ cimery/crates/dsl/src/pier.rs | 143 ++++++++++++++++++++++++++ cimery/crates/ir/src/lib.rs | 106 +++++++++++++++++++ cimery/crates/kernel/Cargo.toml | 1 + cimery/crates/kernel/src/abutment.rs | 81 +++++++++++++++ cimery/crates/kernel/src/bearing.rs | 35 +++++++ cimery/crates/kernel/src/deck_slab.rs | 50 +++++++++ cimery/crates/kernel/src/lib.rs | 43 +++++++- cimery/crates/kernel/src/pier.rs | 97 +++++++++++++++++ cimery/crates/kernel/src/psc_i.rs | 82 +-------------- cimery/crates/kernel/src/sweep.rs | 125 ++++++++++++++++++++++ 16 files changed, 1113 insertions(+), 85 deletions(-) create mode 100644 cimery/crates/dsl/src/abutment.rs create mode 100644 cimery/crates/dsl/src/bearing.rs create mode 100644 cimery/crates/dsl/src/deck_slab.rs create mode 100644 cimery/crates/dsl/src/pier.rs create mode 100644 cimery/crates/kernel/src/abutment.rs create mode 100644 cimery/crates/kernel/src/bearing.rs create mode 100644 cimery/crates/kernel/src/deck_slab.rs create mode 100644 cimery/crates/kernel/src/pier.rs create mode 100644 cimery/crates/kernel/src/sweep.rs diff --git a/PLAN.md b/PLAN.md index 76b47bd..6f443d6 100644 --- a/PLAN.md +++ b/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` --- diff --git a/cimery/crates/core/src/lib.rs b/cimery/crates/core/src/lib.rs index 0e35d3e..b573029 100644 --- a/cimery/crates/core/src/lib.rs +++ b/cimery/crates/core/src/lib.rs @@ -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)] diff --git a/cimery/crates/dsl/src/abutment.rs b/cimery/crates/dsl/src/abutment.rs new file mode 100644 index 0000000..4b86d9f --- /dev/null +++ b/cimery/crates/dsl/src/abutment.rs @@ -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, + skew_angle: Option, + abutment_type: Option, + breast_wall_height: Option, + breast_wall_thickness: Option, + breast_wall_width: Option, + footing_length: Option, + footing_width: Option, + footing_thickness: Option, + wing_length: Option, // simplified: same left & right + material: Option, +} + +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 { + 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 { .. })); + } +} diff --git a/cimery/crates/dsl/src/bearing.rs b/cimery/crates/dsl/src/bearing.rs new file mode 100644 index 0000000..818fae3 --- /dev/null +++ b/cimery/crates/dsl/src/bearing.rs @@ -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::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, + bearing_type: Option, + plan_length: Option, + plan_width: Option, + total_height: Option, + capacity_vertical: Option, +} + +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 { + 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 { .. })); + } +} diff --git a/cimery/crates/dsl/src/deck_slab.rs b/cimery/crates/dsl/src/deck_slab.rs new file mode 100644 index 0000000..a14f38f --- /dev/null +++ b/cimery/crates/dsl/src/deck_slab.rs @@ -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, + station_end: Option, + width_left: Option, + width_right: Option, + thickness: Option, + haunch_depth: Option, + cross_slope: Option, + material: Option, +} + +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 { + 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 { .. })); + } +} diff --git a/cimery/crates/dsl/src/lib.rs b/cimery/crates/dsl/src/lib.rs index 518b33a..5bef9e9 100644 --- a/cimery/crates/dsl/src/lib.rs +++ b/cimery/crates/dsl/src/lib.rs @@ -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}; diff --git a/cimery/crates/dsl/src/pier.rs b/cimery/crates/dsl/src/pier.rs new file mode 100644 index 0000000..d3d8651 --- /dev/null +++ b/cimery/crates/dsl/src/pier.rs @@ -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, + skew_angle: Option, + pier_type: Option, + column_shape: Option, + column_count: Option, + column_spacing: Option, + column_diameter: Option, + column_depth: Option, + column_height: Option, + cap_length: Option, + cap_width: Option, + cap_depth: Option, + material: Option, +} + +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 { + 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 { .. })); + } +} diff --git a/cimery/crates/ir/src/lib.rs b/cimery/crates/ir/src/lib.rs index eac0116..42342a2 100644 --- a/cimery/crates/ir/src/lib.rs +++ b/cimery/crates/ir/src/lib.rs @@ -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)] diff --git a/cimery/crates/kernel/Cargo.toml b/cimery/crates/kernel/Cargo.toml index 411b705..7871f16 100644 --- a/cimery/crates/kernel/Cargo.toml +++ b/cimery/crates/kernel/Cargo.toml @@ -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 } diff --git a/cimery/crates/kernel/src/abutment.rs b/cimery/crates/kernel/src/abutment.rs new file mode 100644 index 0000000..68c4d25 --- /dev/null +++ b/cimery/crates/kernel/src/abutment.rs @@ -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 { + 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 = 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()); + } +} diff --git a/cimery/crates/kernel/src/bearing.rs b/cimery/crates/kernel/src/bearing.rs new file mode 100644 index 0000000..bdd999a --- /dev/null +++ b/cimery/crates/kernel/src/bearing.rs @@ -0,0 +1,35 @@ +//! Bearing geometry — PureRustKernel. + +use cimery_ir::BearingIR; +use crate::{KernelError, Mesh, sweep}; + +pub fn build_bearing_mesh(ir: &BearingIR) -> Result { + 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); + } +} diff --git a/cimery/crates/kernel/src/deck_slab.rs b/cimery/crates/kernel/src/deck_slab.rs new file mode 100644 index 0000000..cb821d8 --- /dev/null +++ b/cimery/crates/kernel/src/deck_slab.rs @@ -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 { + 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); + } +} diff --git a/cimery/crates/kernel/src/lib.rs b/cimery/crates/kernel/src/lib.rs index ed680f4..0e165f9 100644 --- a/cimery/crates/kernel/src/lib.rs +++ b/cimery/crates/kernel/src/lib.rs @@ -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; + // ── 상부 구조물 (Superstructure) ─────────────────────────────────────── + fn girder_mesh(&self, ir: &GirderIR) -> Result; + fn deck_slab_mesh(&self, ir: &DeckSlabIR) -> Result; + // ── 연결부 (Interface) ───────────────────────────────────────────────── + fn bearing_mesh(&self, ir: &BearingIR) -> Result; + // ── 하부 구조물 (Substructure) ───────────────────────────────────────── + fn pier_mesh(&self, ir: &PierIR) -> Result; + fn abutment_mesh(&self, ir: &AbutmentIR) -> Result; } // ─── 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 { + 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 { + 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 { + 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 { + 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 { 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 { + deck_slab::build_deck_slab_mesh(ir) + } + fn bearing_mesh(&self, ir: &BearingIR) -> Result { + bearing::build_bearing_mesh(ir) + } + fn pier_mesh(&self, ir: &PierIR) -> Result { + pier::build_pier_mesh(ir) + } + fn abutment_mesh(&self, ir: &AbutmentIR) -> Result { + abutment::build_abutment_mesh(ir) + } fn girder_mesh(&self, ir: &GirderIR) -> Result { match &ir.section { SectionParams::PscI(p) => psc_i::build_psc_i_mesh(p, ir.span_mm()), diff --git a/cimery/crates/kernel/src/pier.rs b/cimery/crates/kernel/src/pier.rs new file mode 100644 index 0000000..e33dabd --- /dev/null +++ b/cimery/crates/kernel/src/pier.rs @@ -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 { + 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 = 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()); + } +} diff --git a/cimery/crates/kernel/src/psc_i.rs b/cimery/crates/kernel/src/psc_i.rs index 5f464a6..07959c1 100644 --- a/cimery/crates/kernel/src/psc_i.rs +++ b/cimery/crates/kernel/src/psc_i.rs @@ -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, 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 = 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::() / n as f32; - let cy: f32 = profile.iter().map(|v| v[1]).sum::() / 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 ──────────────────────────────────────────────────────────────────── diff --git a/cimery/crates/kernel/src/sweep.rs b/cimery/crates/kernel/src/sweep.rs new file mode 100644 index 0000000..bd9a1e0 --- /dev/null +++ b/cimery/crates/kernel/src/sweep.rs @@ -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 = 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::() / n as f32; + let cy: f32 = profile.iter().map(|v| v[1]).sum::() / 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 { + let mut vertices: Vec<[f32; 3]> = Vec::new(); + let mut normals: Vec<[f32; 3]> = Vec::new(); + let mut indices: Vec = 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 } +}