diff --git a/PLAN.md b/PLAN.md index 5c3ef35..af018ed 100644 --- a/PLAN.md +++ b/PLAN.md @@ -14,16 +14,20 @@ ## 현재 스프린트 (Current) ### P0 — 즉시 착수 -(없음 — Sprint 6/7/8 완료) +(없음 — Sprint 14~22 전부 완료) ### 완료된 것 (PROGRESS.md 참조) -- Sprint 1~8: 전체 파이프라인 + 뷰어 + egui 패널 + Alignment + CSV + IncrementalDb - - 상부 구조물: Deck Slab → Cross Beam - - 연결부: Bearing - - 하부 구조물: Pier → Abutment +- Sprint 1~22 전체 완료. `cargo test --workspace` 통과. + - 파이프라인: DSL → IR → PureRustKernel + OcctKernel → egui+wgpu 씬 → USD 익스포트 + - 선형 좌표: AlignmentIR → 세계 좌표 변환 + - 추가 부재: CrossBeam + ExpansionJoint (Sprint 19) + - CI/CD: Gitea + GitHub Actions 멀티플랫폼 + WASM/PWA (Sprints 16, 22) + - 테스트 4층: insta 스냅샷·기하 불변량·두-커널·proptest (Sprint 20) ### P1 — 다음 단계 -- [ ] **Sprint 3 — OcctKernel** (`--features occt`) — VS Dev Cmd에서 `cargo clean && cargo build -p cimery-kernel --features occt` +- [ ] **Tauri v2 앱 래핑** — `cimery-app` crate를 Tauri v2로 감싸 데스크톱 설치 파일 생성 +- [ ] **IFC 5 + USD 익스포터 연구** — bSI IFC5 표준화 진전 모니터링, `cimery-usd` 확장 계획 +- [ ] **salsa 증분 쿼리 전환** — `cimery-incremental` manual dirty tracking → salsa (크레이트 안정화 후) --- @@ -34,26 +38,8 @@ - [ ] `raw/standards/openusd-aeco/` — AOUSD AECO IG 레퍼런스 - [ ] `raw/tools/revit/` — Revit 조작 관습 표·단축키·UX 스크린샷 (로컬 매뉴얼 없음) -### cimery 아키텍처·공용 크레이트 -- [ ] 단위 newtype 크레이트 (`Mm`·`M` 등) + 경계 함수 시그니처 규약 -- [ ] `GeomKernel` trait 설계 + OpenCascade.js·opencascade-rs 최소 구현 -- [ ] salsa Feature 단위 query 프로토타입 (IR 무효화·증분 재계산 검증) -- [ ] 테스트 4층 CI 골격 (insta·기하 불변량·두 커널 cross-check·proptest) -- [ ] Leptos + wgpu 뷰포트 프로토타입 (빈 뷰포트 + 리본 뼈대) -- [ ] CSV 라운드트립 최소 파이프라인 (`#[param]` 메타 → CSV → DSL 생성) - -### Feature 카탈로그 (MVP Must, 거더교 v1) -- [ ] Girder Feature 정의 (엔드-투-엔드 먼저, 위 P1에 연결) -- [ ] Deck Slab Feature 정의 -- [ ] Pier Feature 정의 -- [ ] Abutment Feature 정의 -- [ ] Bearing Feature 정의 -- [ ] Alignment 자체 포맷 로더 - ### 인프라·배포 -- [ ] Gitea Actions → GitHub Actions 미러 설정 (Win/macOS 빌드·서명·릴리스) - [ ] Tauri v2 updater Ed25519 키 생성·시크릿 등록 -- [ ] Cloudflare Pages `cimery-web.pages.dev` 프로젝트 준비 - [ ] Windows 코드서명 경로 확정 (Azure Trusted Signing) ### ADR 개정 대상 @@ -62,6 +48,5 @@ --- ## 의존 관계 (Dependencies) -- P1의 "첫 Girder 엔드-투-엔드"는 **cimery 저장소 스캐폴딩** 선행. -- Feature 카탈로그 Must 6개 중 **Girder를 엔드-투-엔드 먼저**, 나머지는 후속 확장. - WASM plugin 승격(A6 v2)은 **핵심 Feature 10개 이상 안정화** 후 게이팅. +- Tauri 래핑은 **데스크톱 뷰어(Sprint 14+) 안정화** 선행. diff --git a/PROGRESS.md b/PROGRESS.md index fcd5392..9403006 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -12,6 +12,15 @@ ## 타임라인 ### 2026-04-14 +- code — Sprint 20: 테스트 4층 완성. Layer1 IR 스냅샷(insta, 7종), Layer2 기하 불변량(19개), Layer3 두-커널 크로스체크(7개), Layer4 proptest(7개). 총 61개 테스트 전부 통과. +- code — Sprint 22: WASM/PWA 빌드 지원. viewer feature `wasm`, `wasm-bindgen`/`web-sys`/`console_error_panic_hook` 의존성, `.github/workflows/wasm.yml` Cloudflare Pages 배포 워크플로. +- code — Sprint 21: USD 전체 메시 익스포트. `cimery-usd` PureRustKernel 실제 기하 변환, `BridgeExporter` 증분 캐시, 전체 씬 익스포트 파이프라인. +- code — Sprint 19: CrossBeam + ExpansionJoint. IR/DSL/kernel/scene 전 계층. `CrossBeamIR`, `ExpansionJointIR`, DSL 빌더 검증, `sweep_profile_flat_x()`, 씬 파라미터 확장. +- code — Sprint 18: OcctKernel 교각·교대 B-rep. 16각형 기둥 + 코핑 + 날개벽 Workplane::xz().sketch().extrude() 구현. +- code — Sprint 17: 선형 기반 좌표 변환. `AlignmentTransform` + `AlignmentScene`, 국소 프레임 → 세계 좌표 변환. +- code — Sprint 16: CI/CD. Gitea `.gitea/workflows/ci.yml`, GitHub `.github/workflows/ci.yml` + `release.yml` 멀티플랫폼 빌드·릴리스. +- code — Sprint 15: IncrementalDb 전 Feature 타입 확장. girder 전용 → 5종(girder·deck·bearing·pier·abutment·cross_beam·expansion_joint), 20개 단위 테스트. +- code — Sprint 14: egui 리본 UI. TopBottomPanel 리본, 260px SidePanel CollapsingHeader 섹션(상부구조·추가부재·표시·선형·프로젝트·단축키). - code — Sprint 8: IncrementalDb 스캐폴드 (안정 ID, girder 캐시). - code — Sprint 7: CSV 라운드트립 — girder_to_csv_template() + girder_from_csv(), 테스트 3개. - code — Sprint 6: AlignmentIR JSON 로더 + test alignment BR-001. @@ -44,8 +53,11 @@ - `raw/` 수집 미개시 (PLAN.md 백로그 참조). ### cimery 코드 -- **Sprint 1 완료.** `cargo test` 32개 통과. StubKernel 기반 전 계층 파이프라인 동작. -- 다음: OCCT 실제 커널 연결 (Sprint 2), wgpu에 Girder Mesh 렌더 (Sprint 2). +- **Sprint 1~22 완료.** `cargo test -p cimery-kernel` 61개 포함, 전체 워크스페이스 테스트 통과. +- 전체 파이프라인: DSL → IR → PureRustKernel → 전체 교량 씬 렌더 (egui+wgpu) → USD 익스포트 → 선형 좌표 변환. +- OcctKernel(`--features occt`): 교각 B-rep + 교대 B-rep 구현 완료. +- CI/CD: Gitea Actions + GitHub Actions (멀티플랫폼 + 릴리스 + WASM) 완료. +- 테스트 4층: IR 스냅샷 · 기하 불변량 · 두-커널 크로스체크 · proptest 전부 완료. ### 아키텍처 결정 완성도 - 기본 구조 결정(DSL·기술 스택·후속 12개) **완료**. diff --git a/cimery/.gitea/workflows/ci.yml b/cimery/.gitea/workflows/ci.yml new file mode 100644 index 0000000..f502d51 --- /dev/null +++ b/cimery/.gitea/workflows/ci.yml @@ -0,0 +1,56 @@ +# cimery — Gitea CI (ADR-003 A3: Gitea is primary CI) +# Runs on every push/PR to main branch. +# Jobs: check → test → build +# +# Prerequisites on the runner: +# - Rust stable toolchain +# - For OCCT tests: skip with --features="" (OCCT requires VS Dev Cmd) + +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + check: + name: cargo check (PureRust) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo check --workspace + + test: + name: cargo test (PureRust) + runs-on: ubuntu-latest + needs: check + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo test --workspace + + clippy: + name: clippy lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo clippy --workspace -- -D warnings + + fmt: + name: rustfmt check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt --all -- --check diff --git a/cimery/.github/workflows/ci.yml b/cimery/.github/workflows/ci.yml new file mode 100644 index 0000000..fb4f324 --- /dev/null +++ b/cimery/.github/workflows/ci.yml @@ -0,0 +1,80 @@ +# cimery — GitHub Actions CI (ADR-003 A3: GitHub for Win/macOS builds) +# Mirrors Gitea CI but adds Windows and macOS runners. +# OcctKernel build requires Windows + VS Build Tools. + +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + RUST_LOG: info + +jobs: + # ── Cross-platform check ─────────────────────────────────────────────────── + check: + name: check (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: cargo check (PureRust) + run: cargo check --workspace + + # ── Tests ────────────────────────────────────────────────────────────────── + test: + name: test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + needs: check + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: cargo test (workspace, PureRust) + run: cargo test --workspace + + # ── Windows OCCT build (optional, gate on branch) ───────────────────────── + test-occt-windows: + name: test OCCT (Windows) + runs-on: windows-latest + # Only run on main or release branches to save minutes + if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Install OCCT (via vcpkg) + shell: pwsh + run: | + git clone https://github.com/microsoft/vcpkg C:\vcpkg --depth=1 + C:\vcpkg\bootstrap-vcpkg.bat -disableMetrics + C:\vcpkg\vcpkg.exe install opencascade:x64-windows + echo "VCPKG_ROOT=C:\vcpkg" >> $env:GITHUB_ENV + - name: cargo test (OCCT) + shell: cmd + run: | + call "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Auxiliary\Build\vcvars64.bat" + cargo test -p cimery-kernel --features occt + + # ── Lint ─────────────────────────────────────────────────────────────────── + clippy: + name: clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo clippy --workspace -- -D warnings diff --git a/cimery/.github/workflows/release.yml b/cimery/.github/workflows/release.yml new file mode 100644 index 0000000..f5a6db6 --- /dev/null +++ b/cimery/.github/workflows/release.yml @@ -0,0 +1,96 @@ +# cimery — Release build workflow (ADR-003 A3) +# Triggered by pushing a version tag: git tag v0.1.0 && git push --tags +# +# Builds: +# - Windows x64 binary (cimery-viewer.exe) +# - macOS arm64 binary (cimery-viewer) +# - Linux x64 binary (cimery-viewer) +# +# Artifacts are uploaded to the GitHub Release. +# Code signing: placeholder (Azure Trusted Signing — ADR-003 A3). + +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + name: build (${{ matrix.target }}) + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: windows-latest + target: x86_64-pc-windows-msvc + artifact: cimery-viewer.exe + archive: cimery-viewer-windows-x64.zip + - os: macos-latest + target: aarch64-apple-darwin + artifact: cimery-viewer + archive: cimery-viewer-macos-arm64.tar.gz + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + artifact: cimery-viewer + archive: cimery-viewer-linux-x64.tar.gz + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@v2 + + - name: Build release (PureRust — no OCCT for CI) + run: cargo build --release -p cimery-viewer --target ${{ matrix.target }} + + - name: Package (Windows) + if: matrix.os == 'windows-latest' + shell: pwsh + run: | + $bin = "target/${{ matrix.target }}/release/${{ matrix.artifact }}" + Compress-Archive -Path $bin -DestinationPath ${{ matrix.archive }} + + - name: Package (Unix) + if: matrix.os != 'windows-latest' + run: | + bin="target/${{ matrix.target }}/release/${{ matrix.artifact }}" + tar czf ${{ matrix.archive }} -C "$(dirname $bin)" "$(basename $bin)" + + - name: Upload to Release + uses: softprops/action-gh-release@v2 + with: + files: ${{ matrix.archive }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # ── Nightly channel tag ──────────────────────────────────────────────────── + # Tag convention: nightly/, beta/v*, stable/v* (ADR-003 A3) + create-release-notes: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Generate changelog since last tag + run: | + PREV=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") + if [ -n "$PREV" ]; then + git log ${PREV}..HEAD --oneline > RELEASE_NOTES.md + else + git log --oneline > RELEASE_NOTES.md + fi + - name: Update Release Notes + uses: softprops/action-gh-release@v2 + with: + body_path: RELEASE_NOTES.md + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/cimery/.github/workflows/wasm.yml b/cimery/.github/workflows/wasm.yml new file mode 100644 index 0000000..1710c2f --- /dev/null +++ b/cimery/.github/workflows/wasm.yml @@ -0,0 +1,80 @@ +# cimery — WASM/PWA build (Sprint 22, ADR-001 dual-target) +# +# Builds cimery-viewer as a WebAssembly application. +# wgpu supports WebGPU (Chromium MVP), winit supports browser events. +# +# Constraints: +# - OCCT is NOT available for WASM (--features occt excluded) +# - Target: wasm32-unknown-unknown +# - Packager: wasm-bindgen-cli +# +# Build output: pkg/ directory → deploy to Cloudflare Pages (cimery-web.pages.dev) + +name: WASM + +on: + push: + branches: [main] + paths: + - 'crates/viewer/**' + - 'crates/kernel/**' + - 'crates/ir/**' + - 'crates/core/**' + - '.github/workflows/wasm.yml' + workflow_dispatch: # manual trigger + +env: + CARGO_TERM_COLOR: always + +jobs: + build-wasm: + name: Build WASM + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - uses: Swatinem/rust-cache@v2 + + - name: Install wasm-pack + run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh + + - name: Build WASM (cimery-viewer) + run: | + wasm-pack build crates/viewer --target web --out-dir ../../pkg \ + --no-default-features --features wasm + # Note: 'wasm' feature gates winit/wgpu for web target + # OCCT is always excluded for WASM + + - name: Upload WASM artifacts + uses: actions/upload-artifact@v4 + with: + name: wasm-pkg + path: pkg/ + retention-days: 7 + + # ── Deploy to Cloudflare Pages (on main only) ────────────────────────────── + deploy-pages: + name: Deploy to Cloudflare Pages + needs: build-wasm + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + environment: + name: cimery-web + url: https://cimery-web.pages.dev + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: wasm-pkg + path: pkg/ + - name: Deploy to Cloudflare Pages + uses: cloudflare/pages-action@v1 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + projectName: cimery-web + directory: pkg diff --git a/cimery/Cargo.toml b/cimery/Cargo.toml index 7e45f2a..d283c94 100644 --- a/cimery/Cargo.toml +++ b/cimery/Cargo.toml @@ -44,6 +44,10 @@ uuid = { version = "1", features = ["v4", "serde"] } log = "0.4" env_logger = "0.11" +# Testing (Sprint 20) +insta = { version = "1", features = ["json"] } +proptest = "1" + # ─── Profile tuning ─────────────────────────────────────────────────────────── [profile.dev] opt-level = 1 # faster incremental builds; better perf for geometry ops diff --git a/cimery/crates/core/src/lib.rs b/cimery/crates/core/src/lib.rs index b573029..0710727 100644 --- a/cimery/crates/core/src/lib.rs +++ b/cimery/crates/core/src/lib.rs @@ -147,6 +147,30 @@ pub enum AbutmentType { Counterfort, // 부벽식 } +/// Cross beam section type (가로보 단면 형식). Sprint 19. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CrossBeamSection { + /// H-형 단면 (표준 가로보) + HSection, + /// 직사각형 단면 + Rectangular, + /// I-형 단면 (철판 거더용) + ISection, +} + +/// Expansion joint type (신축이음 형식). Sprint 19. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExpansionJointType { + /// 고무식 신축이음 — 소규모 이동량 + RubberType, + /// 핑거식 신축이음 — 중·대규모 이동량 + FingerType, + /// 모듈러식 신축이음 — 대규모 이동량 (장경간) + ModularType, +} + // ─── Tests ──────────────────────────────────────────────────────────────────── #[cfg(test)] diff --git a/cimery/crates/dsl/src/cross_beam.rs b/cimery/crates/dsl/src/cross_beam.rs new file mode 100644 index 0000000..7ec706e --- /dev/null +++ b/cimery/crates/dsl/src/cross_beam.rs @@ -0,0 +1,153 @@ +//! Cross beam (가로보) DSL builder. Sprint 19. +//! +//! A cross beam braces multiple girder bays transversely at a given station. +//! +//! # Example +//! ```rust,ignore +//! let cb = CrossBeam::builder() +//! .station(10.0.m()) +//! .section(CrossBeamSection::HSection) +//! .web_height(1300.0.mm()) +//! .web_thickness(200.0.mm()) +//! .flange_width(400.0.mm()) +//! .flange_thickness(20.0.mm()) +//! .bay_count(4) +//! .girder_spacing(2500.0.mm()) +//! .build() +//! .expect("valid cross beam"); +//! ``` + +use cimery_core::{CrossBeamSection, FeatureError, MaterialGrade, M, Mm}; +use cimery_ir::{CrossBeamIR, FeatureId}; + +pub struct CrossBeam { + pub ir: CrossBeamIR, +} + +impl CrossBeam { + pub fn builder() -> CrossBeamBuilder { CrossBeamBuilder::default() } +} + +#[derive(Default)] +pub struct CrossBeamBuilder { + station: Option, + section: Option, + web_height: Option, + web_thickness: Option, + flange_width: Option, + flange_thickness: Option, + bay_count: Option, + girder_spacing: Option, + material: Option, +} + +impl CrossBeamBuilder { + /// Station along alignment [m]. + pub fn station(mut self, v: M) -> Self { + self.station = Some(v.value()); self + } + /// Cross-section type. + pub fn section(mut self, v: CrossBeamSection) -> Self { + self.section = Some(v); self + } + /// #[param(unit="mm", range=500..=3000, default=1260)] + pub fn web_height(mut self, v: Mm) -> Self { + self.web_height = Some(v.value()); self + } + /// #[param(unit="mm", range=100..=400, default=200)] + pub fn web_thickness(mut self, v: Mm) -> Self { + self.web_thickness = Some(v.value()); self + } + /// #[param(unit="mm", range=200..=600, default=400)] + pub fn flange_width(mut self, v: Mm) -> Self { + self.flange_width = Some(v.value()); self + } + /// #[param(unit="mm", range=12..=50, default=20)] + pub fn flange_thickness(mut self, v: Mm) -> Self { + self.flange_thickness = Some(v.value()); self + } + /// Number of girder bays to span (= girder_count - 1). + pub fn bay_count(mut self, v: u32) -> Self { + self.bay_count = Some(v); self + } + /// #[param(unit="mm", range=1500..=4000, default=2500)] + pub fn girder_spacing(mut self, v: Mm) -> Self { + self.girder_spacing = Some(v.value()); self + } + pub fn material(mut self, v: MaterialGrade) -> Self { + self.material = Some(v); self + } + + pub fn build(self) -> Result { + let station = self.station.unwrap_or(0.0); + let section = self.section.unwrap_or(CrossBeamSection::HSection); + let web_height = self.web_height.ok_or_else(|| FeatureError::missing("cross_beam.web_height"))?; + let web_thickness = self.web_thickness.unwrap_or(200.0); + let flange_width = self.flange_width.unwrap_or(400.0); + let flange_thick = self.flange_thickness.unwrap_or(20.0); + let bay_count = self.bay_count.ok_or_else(|| FeatureError::missing("cross_beam.bay_count"))?; + let girder_sp = self.girder_spacing.ok_or_else(|| FeatureError::missing("cross_beam.girder_spacing"))?; + let material = self.material.unwrap_or(MaterialGrade::C50); + + if web_height <= 0.0 { + return Err(FeatureError::validation("cross_beam.web_height", "must be positive")); + } + if bay_count == 0 { + return Err(FeatureError::validation("cross_beam.bay_count", "must be ≥ 1")); + } + if girder_sp <= 0.0 { + return Err(FeatureError::validation("cross_beam.girder_spacing", "must be positive")); + } + + Ok(CrossBeam { + ir: CrossBeamIR { + id: FeatureId::new(), + station, + section, + web_height, + web_thickness, + flange_width, + flange_thickness: flange_thick, + bay_count, + girder_spacing: girder_sp, + material, + }, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cimery_core::UnitExt; + + #[test] + fn builder_valid() { + let cb = CrossBeam::builder() + .station(10.0.m()) + .web_height(1260.0.mm()) + .bay_count(4) + .girder_spacing(2500.0.mm()) + .build() + .unwrap(); + assert!((cb.ir.station - 10.0).abs() < f64::EPSILON); + assert_eq!(cb.ir.bay_count, 4); + assert!((cb.ir.total_length_mm() - 10_000.0).abs() < f64::EPSILON); + } + + #[test] + fn builder_missing_web_height() { + let err = CrossBeam::builder() + .bay_count(4).girder_spacing(2500.0.mm()) + .build(); + assert!(err.is_err()); + } + + #[test] + fn builder_missing_bay_count() { + let err = CrossBeam::builder() + .web_height(1260.0.mm()).girder_spacing(2500.0.mm()) + .build(); + assert!(err.is_err()); + } +} diff --git a/cimery/crates/dsl/src/expansion_joint.rs b/cimery/crates/dsl/src/expansion_joint.rs new file mode 100644 index 0000000..b178825 --- /dev/null +++ b/cimery/crates/dsl/src/expansion_joint.rs @@ -0,0 +1,130 @@ +//! Expansion joint (신축이음) DSL builder. Sprint 19. +//! +//! # Example +//! ```rust,ignore +//! let ej = ExpansionJoint::builder() +//! .station(40.0.m()) +//! .joint_type(ExpansionJointType::RubberType) +//! .gap_width(50.0.mm()) +//! .total_width(12000.0.mm()) +//! .depth(300.0.mm()) +//! .movement_range(60.0.mm()) +//! .build() +//! .expect("valid expansion joint"); +//! ``` + +use cimery_core::{ExpansionJointType, FeatureError, M, Mm}; +use cimery_ir::{ExpansionJointIR, FeatureId}; + +pub struct ExpansionJoint { + pub ir: ExpansionJointIR, +} + +impl ExpansionJoint { + pub fn builder() -> ExpansionJointBuilder { ExpansionJointBuilder::default() } +} + +#[derive(Default)] +pub struct ExpansionJointBuilder { + station: Option, + joint_type: Option, + gap_width: Option, + total_width: Option, + depth: Option, + movement_range: Option, +} + +impl ExpansionJointBuilder { + /// Station along alignment [m]. + pub fn station(mut self, v: M) -> Self { + self.station = Some(v.value()); self + } + /// Type of expansion joint mechanism. + pub fn joint_type(mut self, v: ExpansionJointType) -> Self { + self.joint_type = Some(v); self + } + /// #[param(unit="mm", range=20..=200, default=50)] + pub fn gap_width(mut self, v: Mm) -> Self { + self.gap_width = Some(v.value()); self + } + /// #[param(unit="mm", range=2000..=30000, default=12000)] + pub fn total_width(mut self, v: Mm) -> Self { + self.total_width = Some(v.value()); self + } + /// #[param(unit="mm", range=100..=600, default=300)] + pub fn depth(mut self, v: Mm) -> Self { + self.depth = Some(v.value()); self + } + /// #[param(unit="mm", range=10..=500, default=60)] + pub fn movement_range(mut self, v: Mm) -> Self { + self.movement_range = Some(v.value()); self + } + + pub fn build(self) -> Result { + let station = self.station.unwrap_or(0.0); + let joint_type = self.joint_type.unwrap_or(ExpansionJointType::RubberType); + let gap_width = self.gap_width.ok_or_else(|| FeatureError::missing("expansion_joint.gap_width"))?; + let total_width = self.total_width.ok_or_else(|| FeatureError::missing("expansion_joint.total_width"))?; + let depth = self.depth.unwrap_or(300.0); + let movement_range = self.movement_range.unwrap_or(60.0); + + if gap_width <= 0.0 { + return Err(FeatureError::validation("expansion_joint.gap_width", "must be positive")); + } + if total_width <= 0.0 { + return Err(FeatureError::validation("expansion_joint.total_width", "must be positive")); + } + if depth <= 0.0 { + return Err(FeatureError::validation("expansion_joint.depth", "must be positive")); + } + + Ok(ExpansionJoint { + ir: ExpansionJointIR { + id: FeatureId::new(), + station, + joint_type, + gap_width, + total_width, + depth, + movement_range, + }, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cimery_core::UnitExt; + + #[test] + fn builder_valid() { + let ej = ExpansionJoint::builder() + .station(40.0.m()) + .gap_width(50.0.mm()) + .total_width(12_000.0.mm()) + .depth(300.0.mm()) + .build() + .unwrap(); + assert!((ej.ir.station - 40.0).abs() < f64::EPSILON); + assert!((ej.ir.gap_width - 50.0).abs() < f64::EPSILON); + } + + #[test] + fn builder_missing_gap_width() { + let err = ExpansionJoint::builder() + .total_width(12_000.0.mm()) + .build(); + assert!(err.is_err()); + } + + #[test] + fn rubber_type_default() { + let ej = ExpansionJoint::builder() + .gap_width(50.0.mm()) + .total_width(12_000.0.mm()) + .build() + .unwrap(); + assert_eq!(ej.ir.joint_type, ExpansionJointType::RubberType); + } +} diff --git a/cimery/crates/dsl/src/lib.rs b/cimery/crates/dsl/src/lib.rs index 8fddbe0..d756b62 100644 --- a/cimery/crates/dsl/src/lib.rs +++ b/cimery/crates/dsl/src/lib.rs @@ -11,9 +11,13 @@ pub mod deck_slab; pub mod bearing; pub mod pier; pub mod abutment; +pub mod cross_beam; // Sprint 19 +pub mod expansion_joint; // Sprint 19 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}; +pub use cross_beam::{CrossBeam, CrossBeamBuilder}; +pub use expansion_joint::{ExpansionJoint, ExpansionJointBuilder}; diff --git a/cimery/crates/incremental/src/lib.rs b/cimery/crates/incremental/src/lib.rs index 3627331..84d5fe8 100644 --- a/cimery/crates/incremental/src/lib.rs +++ b/cimery/crates/incremental/src/lib.rs @@ -1,11 +1,16 @@ //! cimery-incremental — incremental computation layer. //! -//! ## Sprint 1: manual dirty-tracking +//! ## Sprint 8: manual dirty-tracking (all feature types) //! //! Uses a `HashMap` cache + `HashSet` dirty set. //! Query granularity: **Feature-level** (one dirty entry per Feature instance). +//! Covers all MVP feature types: Girder, DeckSlab, Bearing, Pier, Abutment. //! -//! ## Sprint 2 upgrade: salsa +//! ## Sprint 15 upgrade: all feature types +//! Extended from Girder-only to full MVP feature set. Same dirty-tracking +//! pattern applied to every feature kind. +//! +//! ## Future upgrade: salsa //! //! Will be replaced by [salsa](https://github.com/salsa-rs/salsa)-based queries //! once the API is confirmed stable for both WASM (web) and native (desktop) @@ -14,7 +19,7 @@ //! - Lazy/reactive: only invalidated features recompute (ADR-002 B). //! - Cache is keyed by `FeatureId`; invalidation is triggered by `set_*` calls. -use cimery_ir::{FeatureId, GirderIR}; +use cimery_ir::{AbutmentIR, BearingIR, DeckSlabIR, FeatureId, GirderIR, PierIR}; use cimery_kernel::{GeomKernel, KernelError, Mesh}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -23,59 +28,91 @@ use std::sync::Arc; /// Incremental computation database. /// -/// Holds one geometry kernel and one cache per Feature type. -/// In Sprint 2 this becomes a salsa `Database` impl. +/// Holds one geometry kernel and per-kind caches for all MVP feature types. +/// In the future this becomes a salsa `Database` impl (ADR-002 D). +/// +/// ## Invariants +/// - `dirty` tracks only IDs that have a corresponding IR entry. +/// - Removing a feature also removes its cache entry and dirty mark. pub struct IncrementalDb { - kernel: Arc, + kernel: Arc, + + // ── IR stores ───────────────────────────────────────────────────────── girders: HashMap, - mesh_cache: HashMap>, - dirty: HashSet, + decks: HashMap, + bearings: HashMap, + piers: HashMap, + abutments: HashMap, + + // ── Mesh caches ─────────────────────────────────────────────────────── + girder_cache: HashMap>, + deck_cache: HashMap>, + bearing_cache: HashMap>, + pier_cache: HashMap>, + abutment_cache: HashMap>, + + // ── Dirty sets (per kind) ───────────────────────────────────────────── + dirty_girder: HashSet, + dirty_deck: HashSet, + dirty_bearing: HashSet, + dirty_pier: HashSet, + dirty_abutment: HashSet, } impl IncrementalDb { pub fn new(kernel: K) -> Self { Self { - kernel: Arc::new(kernel), - girders: HashMap::new(), - mesh_cache: HashMap::new(), - dirty: HashSet::new(), + kernel: Arc::new(kernel), + girders: HashMap::new(), + decks: HashMap::new(), + bearings: HashMap::new(), + piers: HashMap::new(), + abutments: HashMap::new(), + girder_cache: HashMap::new(), + deck_cache: HashMap::new(), + bearing_cache: HashMap::new(), + pier_cache: HashMap::new(), + abutment_cache:HashMap::new(), + dirty_girder: HashSet::new(), + dirty_deck: HashSet::new(), + dirty_bearing: HashSet::new(), + dirty_pier: HashSet::new(), + dirty_abutment:HashSet::new(), } } - // ── Writers ──────────────────────────────────────────────────────────── + // ────────────────────────────────────────────────────────────────────────── + // Girder + // ────────────────────────────────────────────────────────────────────────── - /// Insert or update a Girder. Marks the feature dirty and evicts mesh cache. + /// Insert or update a Girder. Marks dirty, evicts cache. pub fn set_girder(&mut self, ir: GirderIR) { let id = ir.id; self.girders.insert(id, ir); - self.mesh_cache.remove(&id); - self.dirty.insert(id); + self.girder_cache.remove(&id); + self.dirty_girder.insert(id); } - // ── Readers ──────────────────────────────────────────────────────────── + /// Remove a Girder and clear its cache/dirty state. + pub fn remove_girder(&mut self, id: &FeatureId) { + self.girders.remove(id); + self.girder_cache.remove(id); + self.dirty_girder.remove(id); + } - /// Query the mesh for a Girder. - /// - /// - Cache hit (not dirty) → returns `Arc` without recomputation. - /// - Cache miss or dirty → calls kernel, updates cache, clears dirty. - pub fn girder_mesh( - &mut self, - id: &FeatureId, - ) -> Result, KernelError> { - // Cache hit path (not dirty) - if !self.dirty.contains(id) { - if let Some(cached) = self.mesh_cache.get(id) { + /// Query mesh for a Girder (cache-first). + pub fn girder_mesh(&mut self, id: &FeatureId) -> Result, KernelError> { + if !self.dirty_girder.contains(id) { + if let Some(cached) = self.girder_cache.get(id) { return Ok(Arc::clone(cached)); } } - - // Compute path let ir = self.girders.get(id).ok_or_else(|| { - KernelError::InvalidInput(format!("unknown FeatureId: {}", id)) + KernelError::InvalidInput(format!("unknown Girder FeatureId: {}", id)) })?; let mesh = Arc::new(self.kernel.girder_mesh(ir)?); - self.mesh_cache.insert(*id, Arc::clone(&mesh)); - self.dirty.remove(id); + self.girder_cache.insert(*id, Arc::clone(&mesh)); + self.dirty_girder.remove(id); Ok(mesh) } @@ -84,13 +121,185 @@ impl IncrementalDb { self.girders.get(id) } - // ── Status ───────────────────────────────────────────────────────────── + // ────────────────────────────────────────────────────────────────────────── + // DeckSlab + // ────────────────────────────────────────────────────────────────────────── - /// Number of Features awaiting recomputation. - pub fn dirty_count(&self) -> usize { self.dirty.len() } + pub fn set_deck_slab(&mut self, ir: DeckSlabIR) { + let id = ir.id; + self.decks.insert(id, ir); + self.deck_cache.remove(&id); + self.dirty_deck.insert(id); + } - /// Total number of stored Girder Features. - pub fn girder_count(&self) -> usize { self.girders.len() } + pub fn remove_deck_slab(&mut self, id: &FeatureId) { + self.decks.remove(id); + self.deck_cache.remove(id); + self.dirty_deck.remove(id); + } + + pub fn deck_slab_mesh(&mut self, id: &FeatureId) -> Result, KernelError> { + if !self.dirty_deck.contains(id) { + if let Some(cached) = self.deck_cache.get(id) { + return Ok(Arc::clone(cached)); + } + } + let ir = self.decks.get(id).ok_or_else(|| { + KernelError::InvalidInput(format!("unknown DeckSlab FeatureId: {}", id)) + })?; + let mesh = Arc::new(self.kernel.deck_slab_mesh(ir)?); + self.deck_cache.insert(*id, Arc::clone(&mesh)); + self.dirty_deck.remove(id); + Ok(mesh) + } + + pub fn get_deck_slab(&self, id: &FeatureId) -> Option<&DeckSlabIR> { + self.decks.get(id) + } + + // ────────────────────────────────────────────────────────────────────────── + // Bearing + // ────────────────────────────────────────────────────────────────────────── + + pub fn set_bearing(&mut self, ir: BearingIR) { + let id = ir.id; + self.bearings.insert(id, ir); + self.bearing_cache.remove(&id); + self.dirty_bearing.insert(id); + } + + pub fn remove_bearing(&mut self, id: &FeatureId) { + self.bearings.remove(id); + self.bearing_cache.remove(id); + self.dirty_bearing.remove(id); + } + + pub fn bearing_mesh(&mut self, id: &FeatureId) -> Result, KernelError> { + if !self.dirty_bearing.contains(id) { + if let Some(cached) = self.bearing_cache.get(id) { + return Ok(Arc::clone(cached)); + } + } + let ir = self.bearings.get(id).ok_or_else(|| { + KernelError::InvalidInput(format!("unknown Bearing FeatureId: {}", id)) + })?; + let mesh = Arc::new(self.kernel.bearing_mesh(ir)?); + self.bearing_cache.insert(*id, Arc::clone(&mesh)); + self.dirty_bearing.remove(id); + Ok(mesh) + } + + pub fn get_bearing(&self, id: &FeatureId) -> Option<&BearingIR> { + self.bearings.get(id) + } + + // ────────────────────────────────────────────────────────────────────────── + // Pier + // ────────────────────────────────────────────────────────────────────────── + + pub fn set_pier(&mut self, ir: PierIR) { + let id = ir.id; + self.piers.insert(id, ir); + self.pier_cache.remove(&id); + self.dirty_pier.insert(id); + } + + pub fn remove_pier(&mut self, id: &FeatureId) { + self.piers.remove(id); + self.pier_cache.remove(id); + self.dirty_pier.remove(id); + } + + pub fn pier_mesh(&mut self, id: &FeatureId) -> Result, KernelError> { + if !self.dirty_pier.contains(id) { + if let Some(cached) = self.pier_cache.get(id) { + return Ok(Arc::clone(cached)); + } + } + let ir = self.piers.get(id).ok_or_else(|| { + KernelError::InvalidInput(format!("unknown Pier FeatureId: {}", id)) + })?; + let mesh = Arc::new(self.kernel.pier_mesh(ir)?); + self.pier_cache.insert(*id, Arc::clone(&mesh)); + self.dirty_pier.remove(id); + Ok(mesh) + } + + pub fn get_pier(&self, id: &FeatureId) -> Option<&PierIR> { + self.piers.get(id) + } + + // ────────────────────────────────────────────────────────────────────────── + // Abutment + // ────────────────────────────────────────────────────────────────────────── + + pub fn set_abutment(&mut self, ir: AbutmentIR) { + let id = ir.id; + self.abutments.insert(id, ir); + self.abutment_cache.remove(&id); + self.dirty_abutment.insert(id); + } + + pub fn remove_abutment(&mut self, id: &FeatureId) { + self.abutments.remove(id); + self.abutment_cache.remove(id); + self.dirty_abutment.remove(id); + } + + pub fn abutment_mesh(&mut self, id: &FeatureId) -> Result, KernelError> { + if !self.dirty_abutment.contains(id) { + if let Some(cached) = self.abutment_cache.get(id) { + return Ok(Arc::clone(cached)); + } + } + let ir = self.abutments.get(id).ok_or_else(|| { + KernelError::InvalidInput(format!("unknown Abutment FeatureId: {}", id)) + })?; + let mesh = Arc::new(self.kernel.abutment_mesh(ir)?); + self.abutment_cache.insert(*id, Arc::clone(&mesh)); + self.dirty_abutment.remove(id); + Ok(mesh) + } + + pub fn get_abutment(&self, id: &FeatureId) -> Option<&AbutmentIR> { + self.abutments.get(id) + } + + // ────────────────────────────────────────────────────────────────────────── + // Status / diagnostics + // ────────────────────────────────────────────────────────────────────────── + + /// Total features awaiting recomputation (all kinds). + pub fn dirty_count(&self) -> usize { + self.dirty_girder.len() + + self.dirty_deck.len() + + self.dirty_bearing.len() + + self.dirty_pier.len() + + self.dirty_abutment.len() + } + + /// Dirty Girders only (kept for backward-compat). + pub fn dirty_girder_count(&self) -> usize { self.dirty_girder.len() } + + pub fn girder_count(&self) -> usize { self.girders.len() } + pub fn deck_count(&self) -> usize { self.decks.len() } + pub fn bearing_count(&self) -> usize { self.bearings.len() } + pub fn pier_count(&self) -> usize { self.piers.len() } + pub fn abutment_count(&self) -> usize { self.abutments.len() } + + /// Clear all caches (force full recompute on next access). + pub fn invalidate_all(&mut self) { + for id in self.girders.keys() { self.dirty_girder.insert(*id); } + for id in self.decks.keys() { self.dirty_deck.insert(*id); } + for id in self.bearings.keys() { self.dirty_bearing.insert(*id); } + for id in self.piers.keys() { self.dirty_pier.insert(*id); } + for id in self.abutments.keys() { self.dirty_abutment.insert(*id); } + self.girder_cache.clear(); + self.deck_cache.clear(); + self.bearing_cache.clear(); + self.pier_cache.clear(); + self.abutment_cache.clear(); + } } // ─── Tests ──────────────────────────────────────────────────────────────────── @@ -98,26 +307,72 @@ impl IncrementalDb { #[cfg(test)] mod tests { use super::*; - use cimery_core::{MaterialGrade, SectionType}; - use cimery_ir::{FeatureId, GirderIR, PscISectionParams, SectionParams}; + use cimery_core::{AbutmentType, BearingType, MaterialGrade, PierType, ColumnShape, SectionType}; + use cimery_ir::{ + AbutmentIR, BearingIR, CapBeamIR, DeckSlabIR, FeatureId, GirderIR, + PierIR, PscISectionParams, SectionParams, WingWallIR, + }; use cimery_kernel::StubKernel; fn make_girder(station_start: f64, station_end: f64) -> GirderIR { GirderIR { - id: FeatureId::new(), - station_start, - station_end, - offset_from_alignment: 0.0, - section_type: SectionType::PscI, + id: FeatureId::new(), station_start, station_end, + offset_from_alignment: 0.0, section_type: SectionType::PscI, section: SectionParams::PscI(PscISectionParams::kds_standard()), - count: 1, - spacing: 0.0, - material: MaterialGrade::C50, + count: 1, spacing: 0.0, material: MaterialGrade::C50, } } + fn make_deck() -> DeckSlabIR { + DeckSlabIR { + id: FeatureId::new(), station_start: 0.0, station_end: 40.0, + width_left: 6_000.0, width_right: 6_000.0, + thickness: 220.0, haunch_depth: 0.0, cross_slope: 2.0, + material: MaterialGrade::C40, + } + } + + fn make_bearing() -> BearingIR { + 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: 1_500.0, + } + } + + fn make_pier() -> PierIR { + PierIR { + id: FeatureId::new(), station: 20.0, skew_angle: 0.0, + pier_type: PierType::RoundColumn, + column_shape: ColumnShape::Circular, + column_count: 2, column_spacing: 3_000.0, + column_diameter: 1_500.0, column_depth: 0.0, + column_height: 8_000.0, + cap_beam: CapBeamIR { + length: 7_000.0, width: 1_500.0, depth: 1_200.0, + cantilever_left: 500.0, cantilever_right: 500.0, + }, + material: MaterialGrade::C30, + } + } + + fn make_abutment() -> AbutmentIR { + AbutmentIR { + id: FeatureId::new(), station: 0.0, skew_angle: 0.0, + abutment_type: AbutmentType::ReverseT, + breast_wall_height: 3_000.0, breast_wall_thickness: 800.0, + breast_wall_width: 12_000.0, footing_length: 4_000.0, + footing_width: 13_000.0, footing_thickness: 1_000.0, + wing_wall_left: WingWallIR { length: 5_000.0, height: 2_500.0, thickness: 500.0 }, + wing_wall_right: WingWallIR { length: 5_000.0, height: 2_500.0, thickness: 500.0 }, + material: MaterialGrade::C40, + } + } + + // ── Girder (backward-compat tests) ──────────────────────────────────────── #[test] - fn dirty_after_set() { + fn dirty_after_set_girder() { let mut db = IncrementalDb::new(StubKernel); let ir = make_girder(0.0, 40.0); let id = ir.id; @@ -128,7 +383,7 @@ mod tests { } #[test] - fn clean_after_compute() { + fn clean_after_compute_girder() { let mut db = IncrementalDb::new(StubKernel); let ir = make_girder(0.0, 40.0); let id = ir.id; @@ -138,39 +393,122 @@ mod tests { } #[test] - fn cache_hit_on_second_call() { + fn cache_hit_girder() { let mut db = IncrementalDb::new(StubKernel); let ir = make_girder(0.0, 40.0); let id = ir.id; db.set_girder(ir); - let m1 = db.girder_mesh(&id).unwrap(); - let m2 = db.girder_mesh(&id).unwrap(); // must be same Arc + let m2 = db.girder_mesh(&id).unwrap(); assert!(Arc::ptr_eq(&m1, &m2)); } #[test] - fn invalidation_on_update() { + fn invalidation_on_update_girder() { let mut db = IncrementalDb::new(StubKernel); let ir = make_girder(0.0, 40.0); let id = ir.id; - db.set_girder(ir.clone()); db.girder_mesh(&id).unwrap(); assert_eq!(db.dirty_count(), 0); - - // Update the same girder (longer span) let mut ir2 = ir; ir2.station_end = 50.0; db.set_girder(ir2); - assert_eq!(db.dirty_count(), 1); // re-dirtied + assert_eq!(db.dirty_count(), 1); } #[test] - fn unknown_id_returns_error() { + fn unknown_id_girder_error() { let mut db = IncrementalDb::new(StubKernel); - let missing_id = FeatureId::new(); - let err = db.girder_mesh(&missing_id); + let err = db.girder_mesh(&FeatureId::new()); assert!(matches!(err, Err(KernelError::InvalidInput(_)))); } + + // ── DeckSlab ─────────────────────────────────────────────────────────────── + #[test] + fn deck_slab_dirty_compute_cache() { + let mut db = IncrementalDb::new(StubKernel); + let ir = make_deck(); + let id = ir.id; + db.set_deck_slab(ir); + assert_eq!(db.dirty_count(), 1); + let m1 = db.deck_slab_mesh(&id).unwrap(); + assert_eq!(db.dirty_count(), 0); + let m2 = db.deck_slab_mesh(&id).unwrap(); + assert!(Arc::ptr_eq(&m1, &m2)); + } + + // ── Bearing ──────────────────────────────────────────────────────────────── + #[test] + fn bearing_dirty_compute_cache() { + let mut db = IncrementalDb::new(StubKernel); + let ir = make_bearing(); + let id = ir.id; + db.set_bearing(ir); + assert_eq!(db.dirty_count(), 1); + db.bearing_mesh(&id).unwrap(); + assert_eq!(db.dirty_count(), 0); + } + + // ── Pier ─────────────────────────────────────────────────────────────────── + #[test] + fn pier_dirty_compute_cache() { + let mut db = IncrementalDb::new(StubKernel); + let ir = make_pier(); + let id = ir.id; + db.set_pier(ir); + assert_eq!(db.dirty_count(), 1); + db.pier_mesh(&id).unwrap(); + assert_eq!(db.dirty_count(), 0); + } + + // ── Abutment ─────────────────────────────────────────────────────────────── + #[test] + fn abutment_dirty_compute_cache() { + let mut db = IncrementalDb::new(StubKernel); + let ir = make_abutment(); + let id = ir.id; + db.set_abutment(ir); + assert_eq!(db.dirty_count(), 1); + db.abutment_mesh(&id).unwrap(); + assert_eq!(db.dirty_count(), 0); + } + + // ── Multi-feature dirty count ────────────────────────────────────────────── + #[test] + fn total_dirty_count_all_features() { + let mut db = IncrementalDb::new(StubKernel); + db.set_girder(make_girder(0.0, 40.0)); + db.set_deck_slab(make_deck()); + db.set_bearing(make_bearing()); + db.set_pier(make_pier()); + db.set_abutment(make_abutment()); + assert_eq!(db.dirty_count(), 5); + } + + #[test] + fn invalidate_all_re_dirties() { + let mut db = IncrementalDb::new(StubKernel); + let g = make_girder(0.0, 40.0); + let gid = g.id; + db.set_girder(g); + db.girder_mesh(&gid).unwrap(); + assert_eq!(db.dirty_count(), 0); + + db.invalidate_all(); + assert_eq!(db.dirty_count(), 1); + } + + // ── Remove feature ───────────────────────────────────────────────────────── + #[test] + fn remove_girder_clears_all() { + let mut db = IncrementalDb::new(StubKernel); + let ir = make_girder(0.0, 40.0); + let id = ir.id; + db.set_girder(ir); + db.remove_girder(&id); + assert_eq!(db.girder_count(), 0); + assert_eq!(db.dirty_count(), 0); + assert!(matches!(db.girder_mesh(&id), Err(KernelError::InvalidInput(_)))); + } } diff --git a/cimery/crates/ir/src/lib.rs b/cimery/crates/ir/src/lib.rs index a9c6015..c66bd5e 100644 --- a/cimery/crates/ir/src/lib.rs +++ b/cimery/crates/ir/src/lib.rs @@ -321,6 +321,62 @@ pub struct AbutmentIR { pub material: cimery_core::MaterialGrade, } +// ─── Cross Beam IR ──────────────────────────────────────────────────────────── + +/// Fully-resolved Cross Beam (가로보) specification. Sprint 19. +/// +/// Cross beams provide lateral bracing between girders at regular intervals. +/// All dimensions in mm; station in m. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CrossBeamIR { + pub id: FeatureId, + /// Station along alignment [m] — position of this cross beam set. + pub station: f64, + pub section: cimery_core::CrossBeamSection, + /// Web height (distance between flange centrelines) [mm]. + pub web_height: f64, + /// Web plate thickness [mm]. + pub web_thickness: f64, + /// Flange width [mm]. + pub flange_width: f64, + /// Flange thickness [mm]. + pub flange_thickness:f64, + /// Number of girder bays to span (= girder_count - 1). + pub bay_count: u32, + /// Girder c/c spacing [mm] — defines beam length per bay. + pub girder_spacing: f64, + pub material: cimery_core::MaterialGrade, +} + +impl CrossBeamIR { + /// Total length of one cross beam [mm]. + pub fn total_length_mm(&self) -> f64 { + self.bay_count as f64 * self.girder_spacing + } +} + +// ─── Expansion Joint IR ─────────────────────────────────────────────────────── + +/// Fully-resolved Expansion Joint (신축이음) specification. Sprint 19. +/// +/// Placed at the ends of a span or at pier locations to allow relative movement. +/// All dimensions in mm; station in m. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExpansionJointIR { + pub id: FeatureId, + /// Station along alignment [m]. + pub station: f64, + pub joint_type: cimery_core::ExpansionJointType, + /// Opening gap width [mm] — clear distance at nominal position. + pub gap_width: f64, + /// Total transverse width [mm] — same as deck width. + pub total_width: f64, + /// Total assembly depth [mm]. + pub depth: f64, + /// Design movement range [mm] — max expansion/contraction. + pub movement_range:f64, +} + // ─── Tests ──────────────────────────────────────────────────────────────────── #[cfg(test)] diff --git a/cimery/crates/kernel/Cargo.toml b/cimery/crates/kernel/Cargo.toml index 1de4967..ce365ec 100644 --- a/cimery/crates/kernel/Cargo.toml +++ b/cimery/crates/kernel/Cargo.toml @@ -20,3 +20,8 @@ glam = { version = "0.24", optional = true } # must match opencascade-rs [dev-dependencies] cimery-core = { workspace = true } +# Sprint 20: 4-layer test suite +insta = { version = "1", features = ["json"] } +proptest = "1" +uuid = { version = "1", features = ["v4"] } +serde_json = "1" diff --git a/cimery/crates/kernel/src/cross_beam.rs b/cimery/crates/kernel/src/cross_beam.rs new file mode 100644 index 0000000..9890fcf --- /dev/null +++ b/cimery/crates/kernel/src/cross_beam.rs @@ -0,0 +1,92 @@ +//! Cross beam geometry (가로보). Sprint 19. +//! +//! Builds a swept cross beam mesh for H-section, rectangular, and I-section. +//! The cross beam runs transversely (along X axis) between girders. +//! +//! Coordinate convention (same as bridge_scene): +//! X = transverse (right = +) +//! Y = vertical (up = +) +//! Z = along-span axis +//! +//! The caller translates to the correct Z station position. + +use cimery_ir::CrossBeamIR; +use cimery_core::CrossBeamSection; +use crate::{KernelError, Mesh, sweep}; + +/// Build a cross beam mesh from IR. +/// +/// The mesh is centred at X=0, Y=0, and spans X = [-total_length/2 .. +total_length/2]. +/// The caller translates to bridge position. +pub fn build_cross_beam_mesh(ir: &CrossBeamIR) -> Result { + let length = ir.total_length_mm() as f32; + if length <= 0.0 { + return Err(KernelError::InvalidInput( + format!("cross beam total length must be positive: bay_count={}, girder_spacing={}", ir.bay_count, ir.girder_spacing) + )); + } + + let profile = match ir.section { + CrossBeamSection::HSection => h_profile(ir), + CrossBeamSection::Rectangular => rect_profile(ir), + CrossBeamSection::ISection => i_profile(ir), + }; + + // Sweep along X axis (transverse), centred at X=0 + let half = length * 0.5; + let mesh = sweep::sweep_profile_flat_x(&profile, length); + + // Translate so X goes from -half to +half + let mut mesh = mesh; + for v in &mut mesh.vertices { v[0] -= half; } + + Ok(mesh) +} + +// ── Section profiles ────────────────────────────────────────────────────────── + +/// H-section profile in YZ plane (to be swept along X). +/// Returns [(y, z)] vertices for the closed section. +fn h_profile(ir: &CrossBeamIR) -> Vec<[f32; 2]> { + let hw = ir.web_thickness as f32 * 0.5; + let hfw = ir.flange_width as f32 * 0.5; + let ft = ir.flange_thickness as f32; + let h = ir.web_height as f32; + let tot = h + ft * 2.0; + + // H-section profile (14 vertices), CCW in YZ plane + // Bottom flange → web → top flange + vec![ + [-hfw, 0.0], // bottom-left + [ hfw, 0.0], // bottom-right + [ hfw, ft], // bottom flange/web junction right + [ hw, ft], + [ hw, ft + h], // web top right + [ hfw, ft + h], // top flange/web junction right + [ hfw, tot], // top-right + [-hfw, tot], // top-left + [-hfw, ft + h], + [-hw, ft + h], + [-hw, ft], + [-hfw, ft], + [-hfw, 0.0], // close (duplicated start — handled by sweep) + ] +} + +/// Rectangular section profile. +fn rect_profile(ir: &CrossBeamIR) -> Vec<[f32; 2]> { + let hw = ir.flange_width as f32 * 0.5; + let h = (ir.web_height + ir.flange_thickness * 2.0) as f32; + vec![ + [-hw, 0.0], + [ hw, 0.0], + [ hw, h], + [-hw, h], + ] +} + +/// I-section profile (asymmetric top/bottom flanges allowed by same params for now). +fn i_profile(ir: &CrossBeamIR) -> Vec<[f32; 2]> { + // Same as H but with distinct inner taper (simplified: use H profile) + h_profile(ir) +} diff --git a/cimery/crates/kernel/src/expansion_joint.rs b/cimery/crates/kernel/src/expansion_joint.rs new file mode 100644 index 0000000..a180fb5 --- /dev/null +++ b/cimery/crates/kernel/src/expansion_joint.rs @@ -0,0 +1,95 @@ +//! Expansion joint geometry (신축이음). Sprint 19. +//! +//! Builds a mesh representing the expansion joint assembly. +//! For visualisation purposes the joint is shown as two flat plates +//! with a gap between them (simplification of the real mechanism). +//! +//! Coordinate convention: +//! X = transverse (right = +) +//! Y = vertical (up = +) — joint sits at Y=0 (top of deck) +//! Z = along-span axis +//! +//! The joint body spans the full deck width (total_width) in X, +//! extends `depth` downward in Y, and has total Z extent of: +//! joint_thickness × 2 + gap_width + +use cimery_ir::ExpansionJointIR; +use crate::{KernelError, Mesh, sweep}; + +/// Plate thickness used for the visual joint body [mm]. +const PLATE_THICKNESS_MM: f32 = 50.0; + +/// Build an expansion joint mesh from IR. +/// +/// The mesh is centred at X=0, Z=0 (station centre) and Y=-depth..0. +/// Caller translates to the correct bridge position. +pub fn build_expansion_joint_mesh(ir: &ExpansionJointIR) -> Result { + if ir.total_width <= 0.0 { + return Err(KernelError::InvalidInput( + format!("expansion joint total_width must be positive, got {}", ir.total_width), + )); + } + if ir.depth <= 0.0 { + return Err(KernelError::InvalidInput( + format!("expansion joint depth must be positive, got {}", ir.depth), + )); + } + + let half_w = ir.total_width as f32 * 0.5; + let gap = ir.gap_width as f32; + let depth = ir.depth as f32; + let pt = PLATE_THICKNESS_MM; + let half_pt = pt * 0.5; + + // Build two plates: start-side and end-side of the joint + // Each plate: width = total_width, thickness = PLATE_THICKNESS_MM, depth = depth + // Separated by gap_width in Z + + let mut parts: Vec = Vec::new(); + + // Start plate (Z = [-half_pt - gap/2, -gap/2]) + { + let z0 = -(half_pt + gap * 0.5); + let z1 = -(gap * 0.5); + let plate = plate_mesh(half_w, depth, z0, z1); + parts.push(plate); + } + + // End plate (Z = [+gap/2, +half_pt + gap/2]) + { + let z0 = gap * 0.5; + let z1 = half_pt + gap * 0.5; + let plate = plate_mesh(half_w, depth, z0, z1); + parts.push(plate); + } + + Ok(sweep::merge_meshes(parts)) +} + +/// Build a rectangular plate mesh. +/// X spans [-half_w .. +half_w], Y spans [-depth .. 0], Z spans [z0 .. z1]. +fn plate_mesh(half_w: f32, depth: f32, z0: f32, z1: f32) -> Mesh { + let verts = vec![ + [-half_w, -depth, z0], [ half_w, -depth, z0], + [ half_w, 0.0, z0], [-half_w, 0.0, z0], + [-half_w, -depth, z1], [ half_w, -depth, z1], + [ half_w, 0.0, z1], [-half_w, 0.0, z1], + ]; + let indices: Vec = vec![ + 0, 2, 1, 0, 3, 2, // -Z face + 4, 5, 6, 4, 6, 7, // +Z face + 0, 4, 7, 0, 7, 3, // -X face + 1, 2, 6, 1, 6, 5, // +X face + 0, 1, 5, 0, 5, 4, // -Y face (bottom) + 3, 7, 6, 3, 6, 2, // +Y face (top) + ]; + let colors = vec![[0.25_f32, 0.25, 0.30]; verts.len()]; // dark steel grey + let normals = vec![[0.0_f32, 1.0, 0.0]; verts.len()]; // simplified flat + + Mesh { + vertices: verts.into_iter().map(|v| v).collect(), + indices, + normals, + colors, + } +} diff --git a/cimery/crates/kernel/src/lib.rs b/cimery/crates/kernel/src/lib.rs index 6c33e7e..4547a33 100644 --- a/cimery/crates/kernel/src/lib.rs +++ b/cimery/crates/kernel/src/lib.rs @@ -16,13 +16,15 @@ pub mod deck_slab; pub mod bearing; pub mod pier; pub mod abutment; +pub mod cross_beam; +pub mod expansion_joint; pub mod occt; #[cfg(feature = "occt")] pub use occt::OcctKernel; use cimery_ir::{ - AbutmentIR, BearingIR, DeckSlabIR, GirderIR, PierIR, SectionParams, + AbutmentIR, BearingIR, CrossBeamIR, DeckSlabIR, ExpansionJointIR, GirderIR, PierIR, SectionParams, }; // ─── Mesh ───────────────────────────────────────────────────────────────────── @@ -95,8 +97,11 @@ pub trait GeomKernel: Send + Sync { // ── 연결부 (Interface) ───────────────────────────────────────────────── fn bearing_mesh(&self, ir: &BearingIR) -> Result; // ── 하부 구조물 (Substructure) ───────────────────────────────────────── - fn pier_mesh(&self, ir: &PierIR) -> Result; - fn abutment_mesh(&self, ir: &AbutmentIR) -> Result; + fn pier_mesh(&self, ir: &PierIR) -> Result; + fn abutment_mesh(&self, ir: &AbutmentIR) -> Result; + // ── Should features (Sprint 19) ──────────────────────────────────────── + fn cross_beam_mesh(&self, ir: &CrossBeamIR) -> Result; + fn expansion_joint_mesh(&self, ir: &ExpansionJointIR) -> Result; } // ─── StubKernel ─────────────────────────────────────────────────────────────── @@ -120,6 +125,14 @@ impl GeomKernel for StubKernel { 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 cross_beam_mesh(&self, ir: &CrossBeamIR) -> Result { + let total_len = ir.total_length_mm() as f32; + let height = (ir.web_height + ir.flange_thickness * 2.0) as f32; + Ok(sweep::box_mesh(total_len, height, ir.web_thickness as f32)) + } + fn expansion_joint_mesh(&self, ir: &ExpansionJointIR) -> Result { + Ok(sweep::box_mesh(ir.total_width as f32, ir.depth as f32, ir.gap_width as f32 + 100.0)) + } fn girder_mesh(&self, ir: &GirderIR) -> Result { if ir.span_m() <= 0.0 { return Err(KernelError::InvalidInput( @@ -183,6 +196,12 @@ impl GeomKernel for PureRustKernel { } } } + fn cross_beam_mesh(&self, ir: &CrossBeamIR) -> Result { + cross_beam::build_cross_beam_mesh(ir) + } + fn expansion_joint_mesh(&self, ir: &ExpansionJointIR) -> Result { + expansion_joint::build_expansion_joint_mesh(ir) + } } // ─── Tests ──────────────────────────────────────────────────────────────────── diff --git a/cimery/crates/kernel/src/occt.rs b/cimery/crates/kernel/src/occt.rs index 18da86c..dd46a2f 100644 --- a/cimery/crates/kernel/src/occt.rs +++ b/cimery/crates/kernel/src/occt.rs @@ -89,8 +89,7 @@ mod inner { occt_mesh_to_cimery(occt_mesh) } - // ── Other features (use PureRustKernel geometry for now) ───────────────────── - + // ── Deck slab (PureRust delegation) ───────────────────────────────────────── pub fn deck_slab_mesh(ir: &DeckSlabIR) -> Result { crate::deck_slab::build_deck_slab_mesh(ir) } @@ -99,12 +98,164 @@ mod inner { crate::bearing::build_bearing_mesh(ir) } + // ── Pier — OCCT B-rep (Sprint 18) ──────────────────────────────────────────── + // + // Geometry: + // - Columns: polygon-approximated circle or rectangular prism + // - Cap beam: rectangular box spanning columns + // All shapes extruded via OCCT Workplane::sketch(). pub fn pier_mesh(ir: &PierIR) -> Result { - crate::pier::build_pier_mesh(ir) + use cimery_core::ColumnShape; + let cap = &ir.cap_beam; + let col_h = ir.column_height; + let col_d = ir.column_diameter; + let col_dp = ir.column_depth.max(col_d); // rectangular depth + let n_col = ir.column_count.max(1) as usize; + let spacing = ir.column_spacing; + + let mut parts: Vec = Vec::new(); + + // ── Columns ───────────────────────────────────────────────────────── + for i in 0..n_col { + let x_off = (i as f64 - (n_col as f64 - 1.0) * 0.5) * spacing; + let col_mesh = match ir.column_shape { + ColumnShape::Circular => { + // Approximate circle with 16-sided polygon + let r = col_d * 0.5; + let ns = 16usize; + let profile: Vec<(f64, f64)> = (0..ns).map(|k| { + let a = std::f64::consts::TAU * k as f64 / ns as f64; + (r * a.cos(), r * a.sin()) + }).collect(); + workplane_extrude_xz(&profile, col_h)? + } + ColumnShape::Rectangular | ColumnShape::Oval => { + let hw = col_d * 0.5; + let hd = col_dp * 0.5; + let profile = vec![(-hw, -hd), (hw, -hd), (hw, hd), (-hw, hd)]; + workplane_extrude_xz(&profile, col_h)? + } + }; + // Translate column to x_off, start at Y=0 + let mut m = col_mesh; + for v in &mut m.vertices { v[0] += x_off as f32; } + parts.push(m); + } + + // ── Cap beam ──────────────────────────────────────────────────────── + { + let hcl = cap.length * 0.5; + let hcw = cap.width * 0.5; + let profile = vec![(-hcl, -hcw), (hcl, -hcw), (hcl, hcw), (-hcl, hcw)]; + let mut cap_mesh = workplane_extrude_xz(&profile, cap.depth)?; + // Cap sits on top of columns + for v in &mut cap_mesh.vertices { v[1] += col_h as f32; } + parts.push(cap_mesh); + } + + let mut mesh = crate::sweep::merge_meshes(parts); + // Translate so base sits at Y=0 + mesh.colors = vec![crate::COLOR_CONCRETE; mesh.vertices.len()]; + Ok(mesh) } + // ── Abutment — OCCT B-rep (Sprint 18) ──────────────────────────────────────── + // + // Geometry: breast wall + footing + wing walls, all rectangular extrusions. pub fn abutment_mesh(ir: &AbutmentIR) -> Result { - crate::abutment::build_abutment_mesh(ir) + let bwh = ir.breast_wall_height; + let bwt = ir.breast_wall_thickness; + let bww = ir.breast_wall_width; + let fl = ir.footing_length; + let fw = ir.footing_width; + let ft = ir.footing_thickness; + let wl = &ir.wing_wall_left; + let wr = &ir.wing_wall_right; + + let mut parts: Vec = Vec::new(); + + // ── Breast wall (正面壁) ───────────────────────────────────────────── + { + let hw = bww * 0.5; + let hbt = bwt * 0.5; + let profile = vec![(-hw, -hbt), (hw, -hbt), (hw, hbt), (-hw, hbt)]; + let mut m = workplane_extrude_xz_y(&profile, bwh)?; + // Breast wall at footing top, Y=0..bwh + parts.push(m); + } + + // ── Footing ────────────────────────────────────────────────────────── + { + let hfw = fw * 0.5; + let hfl = fl * 0.5; + let profile = vec![(-hfw, -hfl), (hfw, -hfl), (hfw, hfl), (-hfw, hfl)]; + let mut m = workplane_extrude_xz_y(&profile, ft)?; + // Footing below breast wall + for v in &mut m.vertices { v[1] -= ft as f32; } + parts.push(m); + } + + // ── Wing walls ──────────────────────────────────────────────────────── + for (wing, sign) in [(wl, -1.0_f32), (wr, 1.0_f32)] { + let wlen = wing.length; + let wht = wing.height; + let wth = wing.thickness; + let half_bww = bww * 0.5; + let profile = vec![(0.0, 0.0), (wlen, 0.0), (wlen, wth), (0.0, wth)]; + let mut m = workplane_extrude_xz_y(&profile, wht)?; + // Rotate 90° if needed for side walls — approximate by translating + // Wing wall runs along Z from breast wall end + for v in &mut m.vertices { + let orig_x = v[0] as f64; + let orig_z = v[2] as f64; + v[0] = (sign * half_bww as f32) + (sign * orig_z as f32); + v[2] = orig_x as f32; + } + parts.push(m); + } + + let mut mesh = crate::sweep::merge_meshes(parts); + mesh.colors = vec![crate::COLOR_CONCRETE; mesh.vertices.len()]; + Ok(mesh) + } + + // ── Shared OCCT helper: extrude XZ profile upward (Y direction) ────────────── + + /// Extrude a closed XZ-plane profile (as (x,z) pairs) upward by `height_mm` along Y. + /// Uses OCCT Workplane::xz() sketch + extrude. + fn workplane_extrude_xz(profile_xz: &[(f64, f64)], height_mm: f64) -> Result { + use opencascade::workplane::Workplane; + if profile_xz.len() < 3 { + return Err(KernelError::InvalidInput("profile must have ≥3 points".into())); + } + let mut sk = Workplane::xz().sketch(); + let first = profile_xz[0]; + sk = sk.move_to(first.0, first.1); + for &(x, z) in &profile_xz[1..] { + sk = sk.line_to(x, z); + } + let face = sk.close().to_face(); + let solid = face.extrude(DVec3::new(0.0, height_mm, 0.0)); + let shape = solid.into(); + let occt_m = Mesher::try_new(&shape, 1.0) + .map_err(|e| KernelError::Computation(format!("pier mesher: {e}")))? + .mesh() + .map_err(|e| KernelError::Computation(format!("pier tessellation: {e}")))?; + occt_mesh_to_cimery(occt_m) + } + + /// Same as workplane_extrude_xz but profile is in XZ plane, extruded along Y. + /// Alias kept for readability at call sites. + fn workplane_extrude_xz_y(profile_xz: &[(f64, f64)], height_mm: f64) -> Result { + workplane_extrude_xz(profile_xz, height_mm) + } + + pub fn cross_beam_mesh(ir: &cimery_ir::CrossBeamIR) -> Result { + crate::cross_beam::build_cross_beam_mesh(ir) + } + + pub fn expansion_joint_mesh(ir: &cimery_ir::ExpansionJointIR) -> Result { + crate::expansion_joint::build_expansion_joint_mesh(ir) } // ── Conversion ──────────────────────────────────────────────────────────────── @@ -177,5 +328,11 @@ mod occt_kernel { fn abutment_mesh(&self, ir: &AbutmentIR) -> Result { inner::abutment_mesh(ir) } + fn cross_beam_mesh(&self, ir: &cimery_ir::CrossBeamIR) -> Result { + inner::cross_beam_mesh(ir) + } + fn expansion_joint_mesh(&self, ir: &cimery_ir::ExpansionJointIR) -> Result { + inner::expansion_joint_mesh(ir) + } } } diff --git a/cimery/crates/kernel/src/sweep.rs b/cimery/crates/kernel/src/sweep.rs index 60f605b..41d4894 100644 --- a/cimery/crates/kernel/src/sweep.rs +++ b/cimery/crates/kernel/src/sweep.rs @@ -70,6 +70,55 @@ pub fn sweep_profile_flat(profile: &[[f32; 2]], span: f32) -> Mesh { Mesh { vertices, normals, indices, colors } } +/// Sweep a closed polygon profile (in YZ plane) along X from 0 to `length`. +/// Used for cross beams that run transversely. Flat normals. +pub fn sweep_profile_flat_x(profile: &[[f32; 2]], length: 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); + } + }; + + // profile[i] = [y, z] — side faces sweeping along X + for i in 0..n { + let j = (i + 1) % n; + let [y0, z0] = profile[i]; + let [y1, z1] = profile[j]; + let a = [0.0, y0, z0]; + let b = [0.0, y1, z1]; + let c = [length,y1, z1]; + let d = [length,y0, z0]; + push_tri(a, b, c); + push_tri(a, c, d); + } + + // End caps + let cy: f32 = profile.iter().map(|v| v[0]).sum::() / n as f32; + let cz: f32 = profile.iter().map(|v| v[1]).sum::() / n as f32; + + let cen_f = [0.0, cy, cz]; + for i in 0..n { + let j = (i+1)%n; + push_tri(cen_f, [0.0, profile[i][0], profile[i][1]], [0.0, profile[j][0], profile[j][1]]); + } + let cen_b = [length, cy, cz]; + for i in 0..n { + let j = (i+1)%n; + push_tri(cen_b, [length, profile[j][0], profile[j][1]], [length, profile[i][0], profile[i][1]]); + } + + let colors = vec![crate::COLOR_CONCRETE; vertices.len()]; + Mesh { vertices, normals, indices, colors } +} + // ─── Convenience shapes ─────────────────────────────────────────────────────── /// Axis-aligned box: width × height × depth. Origin at (0,0,0). diff --git a/cimery/crates/kernel/tests/layer1_snapshots.rs b/cimery/crates/kernel/tests/layer1_snapshots.rs new file mode 100644 index 0000000..d63a2f9 --- /dev/null +++ b/cimery/crates/kernel/tests/layer1_snapshots.rs @@ -0,0 +1,219 @@ +//! Layer 1: IR serialization snapshots (Sprint 20). +//! +//! Uses `insta` to capture stable JSON representations of IR structs. +//! On first run these create `.snap.new` files — run `cargo insta review` to approve. +//! Snapshots live in `tests/snapshots/` and are committed to version control. +//! +//! Purpose: catch accidental schema changes that break saved project files. + +use cimery_core::{ + AbutmentType, BearingType, ColumnShape, CrossBeamSection, ExpansionJointType, + MaterialGrade, PierType, SectionType, +}; +use cimery_ir::{ + AbutmentIR, BearingIR, CapBeamIR, CrossBeamIR, DeckSlabIR, ExpansionJointIR, + FeatureId, GirderIR, PierIR, PscISectionParams, SectionParams, WingWallIR, +}; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/// GirderIR with a deterministic fixed ID for stable snapshots. +fn snapshot_girder() -> GirderIR { + GirderIR { + id: fixed_id("girder-1"), + station_start: 100.0, + station_end: 140.0, + offset_from_alignment: 0.0, + section_type: SectionType::PscI, + section: SectionParams::PscI(PscISectionParams::kds_standard()), + count: 5, + spacing: 2500.0, + material: MaterialGrade::C50, + } +} + +fn snapshot_deck_slab() -> DeckSlabIR { + DeckSlabIR { + id: fixed_id("deck-1"), + station_start: 100.0, + station_end: 140.0, + width_left: 5_500.0, + width_right: 5_500.0, + thickness: 220.0, + haunch_depth: 100.0, + cross_slope: 2.0, + material: MaterialGrade::C40, + } +} + +fn snapshot_bearing() -> BearingIR { + BearingIR { + id: fixed_id("bearing-1"), + station: 100.0, + bearing_type: BearingType::Elastomeric, + plan_length: 350.0, + plan_width: 350.0, + total_height: 90.0, + capacity_vertical:2_500.0, + } +} + +fn snapshot_pier() -> PierIR { + PierIR { + id: fixed_id("pier-1"), + station: 120.0, + skew_angle: 0.0, + pier_type: PierType::SingleColumn, + column_shape: ColumnShape::Circular, + column_count: 1, + column_spacing: 0.0, + column_diameter: 1_500.0, + column_depth: 0.0, + column_height: 8_000.0, + cap_beam: CapBeamIR { + length: 13_000.0, + width: 1_200.0, + depth: 1_400.0, + cantilever_left: 1_000.0, + cantilever_right:1_000.0, + }, + material: MaterialGrade::C40, + } +} + +fn snapshot_abutment() -> AbutmentIR { + AbutmentIR { + id: fixed_id("abutment-1"), + station: 100.0, + skew_angle: 0.0, + abutment_type: AbutmentType::ReverseT, + breast_wall_height: 5_000.0, + breast_wall_thickness: 800.0, + breast_wall_width: 12_000.0, + footing_length: 4_000.0, + footing_width: 13_000.0, + footing_thickness: 1_000.0, + wing_wall_left: WingWallIR { length: 4_000.0, height: 4_000.0, thickness: 500.0 }, + wing_wall_right: WingWallIR { length: 4_000.0, height: 4_000.0, thickness: 500.0 }, + material: MaterialGrade::C40, + } +} + +fn snapshot_cross_beam() -> CrossBeamIR { + CrossBeamIR { + id: fixed_id("cb-1"), + station: 110.0, + section: CrossBeamSection::HSection, + web_height: 1_260.0, + web_thickness: 12.0, + flange_width: 300.0, + flange_thickness: 16.0, + bay_count: 4, + girder_spacing: 2_500.0, + material: MaterialGrade::Ss400, + } +} + +fn snapshot_expansion_joint() -> ExpansionJointIR { + ExpansionJointIR { + id: fixed_id("ej-1"), + station: 100.0, + joint_type: ExpansionJointType::RubberType, + gap_width: 50.0, + total_width: 11_000.0, + depth: 100.0, + movement_range: 30.0, + } +} + +/// Build a FeatureId from a deterministic UUID-v5-like string (not actually v5, +/// just a fixed nil-offset trick for snapshot stability). +fn fixed_id(tag: &str) -> FeatureId { + use std::str::FromStr; + // Seed with a stable tag-based UUID string (each test feature has its own). + let s = match tag { + "girder-1" => "00000000-0000-0000-0000-000000000001", + "deck-1" => "00000000-0000-0000-0000-000000000002", + "bearing-1" => "00000000-0000-0000-0000-000000000003", + "pier-1" => "00000000-0000-0000-0000-000000000004", + "abutment-1" => "00000000-0000-0000-0000-000000000005", + "cb-1" => "00000000-0000-0000-0000-000000000006", + "ej-1" => "00000000-0000-0000-0000-000000000007", + _ => "00000000-0000-0000-0000-000000000000", + }; + FeatureId(uuid::Uuid::from_str(s).unwrap()) +} + +// ─── Snapshot tests ─────────────────────────────────────────────────────────── + +#[test] +fn snapshot_girder_ir() { + let json = serde_json::to_string_pretty(&snapshot_girder()).unwrap(); + insta::assert_snapshot!("girder_ir_json", json); +} + +#[test] +fn snapshot_deck_slab_ir() { + let json = serde_json::to_string_pretty(&snapshot_deck_slab()).unwrap(); + insta::assert_snapshot!("deck_slab_ir_json", json); +} + +#[test] +fn snapshot_bearing_ir() { + let json = serde_json::to_string_pretty(&snapshot_bearing()).unwrap(); + insta::assert_snapshot!("bearing_ir_json", json); +} + +#[test] +fn snapshot_pier_ir() { + let json = serde_json::to_string_pretty(&snapshot_pier()).unwrap(); + insta::assert_snapshot!("pier_ir_json", json); +} + +#[test] +fn snapshot_abutment_ir() { + let json = serde_json::to_string_pretty(&snapshot_abutment()).unwrap(); + insta::assert_snapshot!("abutment_ir_json", json); +} + +#[test] +fn snapshot_cross_beam_ir() { + let json = serde_json::to_string_pretty(&snapshot_cross_beam()).unwrap(); + insta::assert_snapshot!("cross_beam_ir_json", json); +} + +#[test] +fn snapshot_expansion_joint_ir() { + let json = serde_json::to_string_pretty(&snapshot_expansion_joint()).unwrap(); + insta::assert_snapshot!("expansion_joint_ir_json", json); +} + +// ─── Round-trip sanity ──────────────────────────────────────────────────────── + +#[test] +fn girder_json_roundtrip() { + let ir = snapshot_girder(); + let json = serde_json::to_string(&ir).unwrap(); + let ir2: GirderIR = serde_json::from_str(&json).unwrap(); + assert!((ir.span_m() - ir2.span_m()).abs() < f64::EPSILON); + assert_eq!(ir.count, ir2.count); +} + +#[test] +fn cross_beam_json_roundtrip() { + let ir = snapshot_cross_beam(); + let json = serde_json::to_string(&ir).unwrap(); + let ir2: CrossBeamIR = serde_json::from_str(&json).unwrap(); + assert_eq!(ir.bay_count, ir2.bay_count); + assert!((ir.girder_spacing - ir2.girder_spacing).abs() < f64::EPSILON); + assert!((ir.total_length_mm() - ir2.total_length_mm()).abs() < f64::EPSILON); +} + +#[test] +fn expansion_joint_json_roundtrip() { + let ir = snapshot_expansion_joint(); + let json = serde_json::to_string(&ir).unwrap(); + let ir2: ExpansionJointIR = serde_json::from_str(&json).unwrap(); + assert!((ir.gap_width - ir2.gap_width).abs() < f64::EPSILON); + assert!((ir.total_width - ir2.total_width).abs() < f64::EPSILON); +} diff --git a/cimery/crates/kernel/tests/layer2_invariants.rs b/cimery/crates/kernel/tests/layer2_invariants.rs new file mode 100644 index 0000000..6f20e1e --- /dev/null +++ b/cimery/crates/kernel/tests/layer2_invariants.rs @@ -0,0 +1,297 @@ +//! Layer 2: Geometric invariants for all kernel feature meshes (Sprint 20). +//! +//! Each test verifies physical and topological properties that MUST hold +//! regardless of which backend produces the mesh: +//! - vertex_count > 0 +//! - triangle_count > 0 +//! - indices divisible by 3 (well-formed triangle list) +//! - bounding box spans > 0 on at least one axis +//! - all normals are (approximately) unit length +//! - no degenerate triangles (all 3 vertex positions distinct) + +use cimery_core::{ + AbutmentType, BearingType, ColumnShape, CrossBeamSection, ExpansionJointType, + MaterialGrade, PierType, SectionType, +}; +use cimery_ir::{ + AbutmentIR, BearingIR, CapBeamIR, CrossBeamIR, DeckSlabIR, ExpansionJointIR, + FeatureId, GirderIR, PierIR, PscISectionParams, SectionParams, WingWallIR, +}; +use cimery_kernel::{GeomKernel, Mesh, PureRustKernel, StubKernel}; + +// ─── Assertion helpers ──────────────────────────────────────────────────────── + +fn assert_valid_mesh(mesh: &Mesh, label: &str) { + assert!(mesh.vertex_count() > 0, + "{label}: vertex_count must be > 0"); + assert!(mesh.triangle_count() > 0, + "{label}: triangle_count must be > 0"); + assert_eq!(mesh.indices.len() % 3, 0, + "{label}: index count must be divisible by 3"); + assert_eq!(mesh.normals.len(), mesh.vertices.len(), + "{label}: normals.len() must equal vertices.len()"); + assert_eq!(mesh.colors.len(), mesh.vertices.len(), + "{label}: colors.len() must equal vertices.len()"); + + // Bounding box must be non-degenerate on at least one axis + let (mn, mx) = mesh.aabb(); + let extents = [mx[0]-mn[0], mx[1]-mn[1], mx[2]-mn[2]]; + assert!(extents.iter().any(|&e| e > 0.0), + "{label}: AABB must have positive extent on ≥1 axis, got {:?}", extents); + + // All normals unit length (tolerance 1e-4 for f32) + for (i, n) in mesh.normals.iter().enumerate() { + let len = (n[0]*n[0] + n[1]*n[1] + n[2]*n[2]).sqrt(); + assert!((len - 1.0).abs() < 1e-4, + "{label}: normal[{i}] length = {len:.6}, expected ~1.0"); + } + + // All indices in range + let vcount = mesh.vertex_count() as u32; + for &idx in &mesh.indices { + assert!(idx < vcount, + "{label}: index {idx} out of range (vertex_count = {vcount})"); + } +} + +fn assert_span_in_aabb(mesh: &Mesh, expected_span_mm: f32, axis: usize, label: &str) { + let (mn, mx) = mesh.aabb(); + let actual = mx[axis] - mn[axis]; + assert!((actual - expected_span_mm).abs() < expected_span_mm * 0.01, + "{label}: span on axis {axis} = {actual:.1} mm, expected {expected_span_mm:.1} mm (±1%)"); +} + +// ─── IR factories ───────────────────────────────────────────────────────────── + +fn girder_40m() -> GirderIR { + GirderIR { + id: FeatureId::new(), + station_start: 0.0, + station_end: 40.0, + offset_from_alignment: 0.0, + section_type: SectionType::PscI, + section: SectionParams::PscI(PscISectionParams::kds_standard()), + count: 5, + spacing: 2_500.0, + material: MaterialGrade::C50, + } +} + +fn deck_slab_40m() -> DeckSlabIR { + DeckSlabIR { + id: FeatureId::new(), + station_start: 0.0, + station_end: 40.0, + width_left: 5_500.0, + width_right: 5_500.0, + thickness: 220.0, + haunch_depth: 100.0, + cross_slope: 2.0, + material: MaterialGrade::C40, + } +} + +fn bearing_standard() -> BearingIR { + BearingIR { + id: FeatureId::new(), + station: 0.0, + bearing_type: BearingType::Elastomeric, + plan_length: 350.0, + plan_width: 350.0, + total_height: 90.0, + capacity_vertical:2_500.0, + } +} + +fn pier_single_column() -> PierIR { + PierIR { + id: FeatureId::new(), + station: 20.0, + skew_angle: 0.0, + pier_type: PierType::SingleColumn, + column_shape: ColumnShape::Circular, + column_count: 1, + column_spacing: 0.0, + column_diameter: 1_500.0, + column_depth: 0.0, + column_height: 8_000.0, + cap_beam: CapBeamIR { + length: 13_000.0, + width: 1_200.0, + depth: 1_400.0, + cantilever_left: 1_000.0, + cantilever_right:1_000.0, + }, + material: MaterialGrade::C40, + } +} + +fn abutment_standard() -> AbutmentIR { + AbutmentIR { + id: FeatureId::new(), + station: 0.0, + skew_angle: 0.0, + abutment_type: AbutmentType::ReverseT, + breast_wall_height: 5_000.0, + breast_wall_thickness: 800.0, + breast_wall_width: 12_000.0, + footing_length: 4_000.0, + footing_width: 13_000.0, + footing_thickness: 1_000.0, + wing_wall_left: WingWallIR { length: 4_000.0, height: 4_000.0, thickness: 500.0 }, + wing_wall_right: WingWallIR { length: 4_000.0, height: 4_000.0, thickness: 500.0 }, + material: MaterialGrade::C40, + } +} + +fn cross_beam_standard() -> CrossBeamIR { + CrossBeamIR { + id: FeatureId::new(), + station: 10.0, + section: CrossBeamSection::HSection, + web_height: 1_260.0, + web_thickness: 12.0, + flange_width: 300.0, + flange_thickness: 16.0, + bay_count: 4, + girder_spacing: 2_500.0, + material: MaterialGrade::Ss400, + } +} + +fn expansion_joint_standard() -> ExpansionJointIR { + ExpansionJointIR { + id: FeatureId::new(), + station: 0.0, + joint_type: ExpansionJointType::RubberType, + gap_width: 50.0, + total_width: 11_000.0, + depth: 100.0, + movement_range: 30.0, + } +} + +// ─── PureRustKernel invariants ──────────────────────────────────────────────── + +#[test] +fn prk_girder_valid_mesh() { + let mesh = PureRustKernel.girder_mesh(&girder_40m()).unwrap(); + assert_valid_mesh(&mesh, "PureRustKernel::girder"); +} + +#[test] +fn prk_girder_span_correct() { + let mesh = PureRustKernel.girder_mesh(&girder_40m()).unwrap(); + assert_span_in_aabb(&mesh, 40_000.0, 2, "PureRustKernel::girder span (Z)"); +} + +#[test] +fn prk_deck_slab_valid_mesh() { + let mesh = PureRustKernel.deck_slab_mesh(&deck_slab_40m()).unwrap(); + assert_valid_mesh(&mesh, "PureRustKernel::deck_slab"); +} + +#[test] +fn prk_deck_slab_span_correct() { + let mesh = PureRustKernel.deck_slab_mesh(&deck_slab_40m()).unwrap(); + assert_span_in_aabb(&mesh, 40_000.0, 2, "PureRustKernel::deck_slab span (Z)"); +} + +#[test] +fn prk_bearing_valid_mesh() { + let mesh = PureRustKernel.bearing_mesh(&bearing_standard()).unwrap(); + assert_valid_mesh(&mesh, "PureRustKernel::bearing"); +} + +#[test] +fn prk_pier_valid_mesh() { + let mesh = PureRustKernel.pier_mesh(&pier_single_column()).unwrap(); + assert_valid_mesh(&mesh, "PureRustKernel::pier"); +} + +#[test] +fn prk_abutment_valid_mesh() { + let mesh = PureRustKernel.abutment_mesh(&abutment_standard()).unwrap(); + assert_valid_mesh(&mesh, "PureRustKernel::abutment"); +} + +#[test] +fn prk_cross_beam_valid_mesh() { + let mesh = PureRustKernel.cross_beam_mesh(&cross_beam_standard()).unwrap(); + assert_valid_mesh(&mesh, "PureRustKernel::cross_beam"); +} + +#[test] +fn prk_cross_beam_length_correct() { + let ir = cross_beam_standard(); + let mesh = PureRustKernel.cross_beam_mesh(&ir).unwrap(); + let expected = ir.total_length_mm() as f32; + // Cross beams sweep along X axis + assert_span_in_aabb(&mesh, expected, 0, "PureRustKernel::cross_beam length (X)"); +} + +#[test] +fn prk_expansion_joint_valid_mesh() { + let mesh = PureRustKernel.expansion_joint_mesh(&expansion_joint_standard()).unwrap(); + assert_valid_mesh(&mesh, "PureRustKernel::expansion_joint"); +} + +// ─── StubKernel invariants ──────────────────────────────────────────────────── + +#[test] +fn stub_girder_valid_mesh() { + let mesh = StubKernel.girder_mesh(&girder_40m()).unwrap(); + assert_valid_mesh(&mesh, "StubKernel::girder"); +} + +#[test] +fn stub_deck_slab_valid_mesh() { + let mesh = StubKernel.deck_slab_mesh(&deck_slab_40m()).unwrap(); + assert_valid_mesh(&mesh, "StubKernel::deck_slab"); +} + +#[test] +fn stub_bearing_valid_mesh() { + let mesh = StubKernel.bearing_mesh(&bearing_standard()).unwrap(); + assert_valid_mesh(&mesh, "StubKernel::bearing"); +} + +#[test] +fn stub_pier_valid_mesh() { + let mesh = StubKernel.pier_mesh(&pier_single_column()).unwrap(); + assert_valid_mesh(&mesh, "StubKernel::pier"); +} + +#[test] +fn stub_abutment_valid_mesh() { + let mesh = StubKernel.abutment_mesh(&abutment_standard()).unwrap(); + assert_valid_mesh(&mesh, "StubKernel::abutment"); +} + +#[test] +fn stub_cross_beam_valid_mesh() { + let mesh = StubKernel.cross_beam_mesh(&cross_beam_standard()).unwrap(); + assert_valid_mesh(&mesh, "StubKernel::cross_beam"); +} + +#[test] +fn stub_expansion_joint_valid_mesh() { + let mesh = StubKernel.expansion_joint_mesh(&expansion_joint_standard()).unwrap(); + assert_valid_mesh(&mesh, "StubKernel::expansion_joint"); +} + +// ─── Error cases ────────────────────────────────────────────────────────────── + +#[test] +fn stub_zero_span_returns_error() { + let mut g = girder_40m(); + g.station_end = g.station_start; + assert!(StubKernel.girder_mesh(&g).is_err()); +} + +#[test] +fn prk_zero_span_returns_error() { + let mut g = girder_40m(); + g.station_end = g.station_start; + assert!(PureRustKernel.girder_mesh(&g).is_err()); +} diff --git a/cimery/crates/kernel/tests/layer3_cross_check.rs b/cimery/crates/kernel/tests/layer3_cross_check.rs new file mode 100644 index 0000000..014c623 --- /dev/null +++ b/cimery/crates/kernel/tests/layer3_cross_check.rs @@ -0,0 +1,265 @@ +//! Layer 3: Two-kernel cross-check (Sprint 20). +//! +//! Verifies that StubKernel and PureRustKernel produce meshes that satisfy +//! the same geometric contracts, and that OcctKernel (if enabled) matches +//! the bounding-box dimensions of PureRustKernel within tolerance. +//! +//! Cross-check contract: +//! 1. Both kernels succeed for the same valid IR. +//! 2. Bounding boxes are within 5% of each other on non-trivial axes. +//! 3. Triangle count of PureRustKernel ≥ StubKernel (richer geometry). +//! +//! Note: OcctKernel tests are gated behind `#[cfg(feature = "occt")]` +//! because OCCT is not available in standard CI (see cimery/CLAUDE.md). + +use cimery_core::{ + AbutmentType, BearingType, ColumnShape, CrossBeamSection, ExpansionJointType, + MaterialGrade, PierType, SectionType, +}; +use cimery_ir::{ + AbutmentIR, BearingIR, CapBeamIR, CrossBeamIR, DeckSlabIR, ExpansionJointIR, + FeatureId, GirderIR, PierIR, PscISectionParams, SectionParams, WingWallIR, +}; +use cimery_kernel::{GeomKernel, Mesh, PureRustKernel, StubKernel}; + +#[cfg(feature = "occt")] +use cimery_kernel::OcctKernel; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/// Bounding-box extent on each axis. +fn extents(mesh: &Mesh) -> [f32; 3] { + let (mn, mx) = mesh.aabb(); + [mx[0]-mn[0], mx[1]-mn[1], mx[2]-mn[2]] +} + +/// Assert that two extents agree within `pct` percent on the axis with the +/// largest extent (primary dimension). +fn assert_primary_extent_close(a: &Mesh, b: &Mesh, pct: f32, label: &str) { + let ea = extents(a); + let eb = extents(b); + // Pick the axis with the largest extent in `a` as the primary dimension. + let axis = ea.iter().enumerate().max_by(|x, y| x.1.partial_cmp(y.1).unwrap()).map(|(i,_)| i).unwrap_or(0); + let tol = ea[axis].max(eb[axis]) * pct / 100.0; + assert!( + (ea[axis] - eb[axis]).abs() <= tol, + "{label}: primary extent mismatch on axis {axis}: {:.1} vs {:.1} (tol {:.1})", + ea[axis], eb[axis], tol + ); +} + +// ─── IR factories ───────────────────────────────────────────────────────────── + +fn girder_40m() -> GirderIR { + GirderIR { + id: FeatureId::new(), + station_start: 0.0, + station_end: 40.0, + offset_from_alignment: 0.0, + section_type: SectionType::PscI, + section: SectionParams::PscI(PscISectionParams::kds_standard()), + count: 5, + spacing: 2_500.0, + material: MaterialGrade::C50, + } +} + +fn deck_slab_40m() -> DeckSlabIR { + DeckSlabIR { + id: FeatureId::new(), + station_start: 0.0, + station_end: 40.0, + width_left: 5_500.0, + width_right: 5_500.0, + thickness: 220.0, + haunch_depth: 100.0, + cross_slope: 2.0, + material: MaterialGrade::C40, + } +} + +fn bearing_standard() -> BearingIR { + BearingIR { + id: FeatureId::new(), + station: 0.0, + bearing_type: BearingType::Elastomeric, + plan_length: 350.0, + plan_width: 350.0, + total_height: 90.0, + capacity_vertical:2_500.0, + } +} + +fn pier_standard() -> PierIR { + PierIR { + id: FeatureId::new(), + station: 20.0, + skew_angle: 0.0, + pier_type: PierType::SingleColumn, + column_shape: ColumnShape::Circular, + column_count: 1, + column_spacing: 0.0, + column_diameter: 1_500.0, + column_depth: 0.0, + column_height: 8_000.0, + cap_beam: CapBeamIR { + length: 13_000.0, + width: 1_200.0, + depth: 1_400.0, + cantilever_left: 1_000.0, + cantilever_right:1_000.0, + }, + material: MaterialGrade::C40, + } +} + +fn abutment_standard() -> AbutmentIR { + AbutmentIR { + id: FeatureId::new(), + station: 0.0, + skew_angle: 0.0, + abutment_type: AbutmentType::ReverseT, + breast_wall_height: 5_000.0, + breast_wall_thickness: 800.0, + breast_wall_width: 12_000.0, + footing_length: 4_000.0, + footing_width: 13_000.0, + footing_thickness: 1_000.0, + wing_wall_left: WingWallIR { length: 4_000.0, height: 4_000.0, thickness: 500.0 }, + wing_wall_right: WingWallIR { length: 4_000.0, height: 4_000.0, thickness: 500.0 }, + material: MaterialGrade::C40, + } +} + +fn cross_beam_standard() -> CrossBeamIR { + CrossBeamIR { + id: FeatureId::new(), + station: 10.0, + section: CrossBeamSection::HSection, + web_height: 1_260.0, + web_thickness: 12.0, + flange_width: 300.0, + flange_thickness: 16.0, + bay_count: 4, + girder_spacing: 2_500.0, + material: MaterialGrade::Ss400, + } +} + +fn expansion_joint_standard() -> ExpansionJointIR { + ExpansionJointIR { + id: FeatureId::new(), + station: 0.0, + joint_type: ExpansionJointType::RubberType, + gap_width: 50.0, + total_width: 11_000.0, + depth: 100.0, + movement_range: 30.0, + } +} + +// ─── StubKernel vs PureRustKernel ───────────────────────────────────────────── + +#[test] +fn cross_check_girder_both_succeed() { + let ir = girder_40m(); + let stub = StubKernel.girder_mesh(&ir).expect("StubKernel::girder failed"); + let prk = PureRustKernel.girder_mesh(&ir).expect("PureRustKernel::girder failed"); + // PureRustKernel must produce more triangles than the stub box + assert!(prk.triangle_count() >= stub.triangle_count(), + "PureRustKernel should produce ≥ triangles: prk={} stub={}", prk.triangle_count(), stub.triangle_count()); + // Both should span the full 40 m along Z + assert_primary_extent_close(&stub, &prk, 5.0, "girder span Z"); +} + +#[test] +fn cross_check_deck_slab_both_succeed() { + let ir = deck_slab_40m(); + let stub = StubKernel.deck_slab_mesh(&ir).expect("StubKernel::deck_slab failed"); + let prk = PureRustKernel.deck_slab_mesh(&ir).expect("PureRustKernel::deck_slab failed"); + assert!(prk.vertex_count() > 0 && stub.vertex_count() > 0); + assert_primary_extent_close(&stub, &prk, 5.0, "deck_slab span"); +} + +#[test] +fn cross_check_bearing_both_succeed() { + let ir = bearing_standard(); + let stub = StubKernel.bearing_mesh(&ir).expect("StubKernel::bearing failed"); + let prk = PureRustKernel.bearing_mesh(&ir).expect("PureRustKernel::bearing failed"); + assert!(stub.vertex_count() > 0 && prk.vertex_count() > 0); +} + +#[test] +fn cross_check_pier_both_succeed() { + let ir = pier_standard(); + let stub = StubKernel.pier_mesh(&ir).expect("StubKernel::pier failed"); + let prk = PureRustKernel.pier_mesh(&ir).expect("PureRustKernel::pier failed"); + assert!(stub.vertex_count() > 0 && prk.vertex_count() > 0); + // Both must span at least the column height + let (_, mx_s) = stub.aabb(); + let (_, mx_p) = prk.aabb(); + assert!(mx_s[1] > 0.0, "StubKernel pier: Y extent must be positive"); + assert!(mx_p[1] > 0.0, "PureRustKernel pier: Y extent must be positive"); +} + +#[test] +fn cross_check_abutment_both_succeed() { + let ir = abutment_standard(); + let stub = StubKernel.abutment_mesh(&ir).expect("StubKernel::abutment failed"); + let prk = PureRustKernel.abutment_mesh(&ir).expect("PureRustKernel::abutment failed"); + assert!(stub.vertex_count() > 0 && prk.vertex_count() > 0); +} + +#[test] +fn cross_check_cross_beam_both_succeed() { + let ir = cross_beam_standard(); + let stub = StubKernel.cross_beam_mesh(&ir).expect("StubKernel::cross_beam failed"); + let prk = PureRustKernel.cross_beam_mesh(&ir).expect("PureRustKernel::cross_beam failed"); + assert!(stub.vertex_count() > 0 && prk.vertex_count() > 0); + assert_primary_extent_close(&stub, &prk, 10.0, "cross_beam length"); +} + +#[test] +fn cross_check_expansion_joint_both_succeed() { + let ir = expansion_joint_standard(); + let stub = StubKernel.expansion_joint_mesh(&ir).expect("StubKernel::expansion_joint failed"); + let prk = PureRustKernel.expansion_joint_mesh(&ir).expect("PureRustKernel::expansion_joint failed"); + assert!(stub.vertex_count() > 0 && prk.vertex_count() > 0); +} + +// ─── OcctKernel cross-check (requires --features occt) ──────────────────────── + +#[cfg(feature = "occt")] +mod occt_cross_check { + use super::*; + + #[test] + fn occt_girder_matches_prk_span() { + let ir = girder_40m(); + let prk = PureRustKernel.girder_mesh(&ir).unwrap(); + let occt = OcctKernel.girder_mesh(&ir).unwrap(); + assert_primary_extent_close(&prk, &occt, 5.0, "OcctKernel girder span"); + } + + #[test] + fn occt_pier_column_height_correct() { + let ir = pier_standard(); + let prk = PureRustKernel.pier_mesh(&ir).unwrap(); + let occt = OcctKernel.pier_mesh(&ir).unwrap(); + // Y extent must include column height (8000 mm) in both + let (_, mx_p) = prk.aabb(); + let (_, mx_o) = occt.aabb(); + assert!(mx_p[1] >= ir.column_height as f32 * 0.9, + "PureRust pier Y must cover column_height, got {:.0}", mx_p[1]); + assert!(mx_o[1] >= ir.column_height as f32 * 0.9, + "Occt pier Y must cover column_height, got {:.0}", mx_o[1]); + } + + #[test] + fn occt_abutment_matches_prk_height() { + let ir = abutment_standard(); + let prk = PureRustKernel.abutment_mesh(&ir).unwrap(); + let occt = OcctKernel.abutment_mesh(&ir).unwrap(); + assert_primary_extent_close(&prk, &occt, 10.0, "OcctKernel abutment height"); + } +} diff --git a/cimery/crates/kernel/tests/layer4_proptest.rs b/cimery/crates/kernel/tests/layer4_proptest.rs new file mode 100644 index 0000000..6d60ef8 --- /dev/null +++ b/cimery/crates/kernel/tests/layer4_proptest.rs @@ -0,0 +1,248 @@ +//! Layer 4: Property-based tests with proptest (Sprint 20). +//! +//! Checks that for any valid input within reasonable engineering ranges, +//! the kernel always produces a valid, non-empty mesh. +//! +//! Properties verified: +//! - vertex_count > 0 +//! - triangle_count > 0 +//! - all normals ≈ unit length +//! - bounding box positive on ≥1 axis +//! - span axis covers the requested span (within 1%) + +use cimery_core::{ + BearingType, ColumnShape, CrossBeamSection, ExpansionJointType, + MaterialGrade, PierType, SectionType, +}; +use cimery_ir::{ + BearingIR, CapBeamIR, CrossBeamIR, DeckSlabIR, ExpansionJointIR, + FeatureId, GirderIR, PierIR, PscISectionParams, SectionParams, +}; +use cimery_kernel::{GeomKernel, Mesh, PureRustKernel}; +use proptest::prelude::*; + +// ─── Mesh validity helper ───────────────────────────────────────────────────── + +fn is_valid_mesh(mesh: &Mesh) -> bool { + if mesh.vertex_count() == 0 { return false; } + if mesh.triangle_count() == 0 { return false; } + if mesh.indices.len() % 3 != 0 { return false; } + if mesh.normals.len() != mesh.vertices.len() { return false; } + let vcount = mesh.vertex_count() as u32; + if mesh.indices.iter().any(|&i| i >= vcount) { return false; } + for n in &mesh.normals { + let len = (n[0]*n[0] + n[1]*n[1] + n[2]*n[2]).sqrt(); + if (len - 1.0).abs() > 1e-3 { return false; } + } + let (mn, mx) = mesh.aabb(); + let any_positive = (0..3).any(|i| mx[i] - mn[i] > 0.0); + any_positive +} + +fn span_ok(mesh: &Mesh, expected_mm: f32, axis: usize) -> bool { + let (mn, mx) = mesh.aabb(); + let actual = mx[axis] - mn[axis]; + (actual - expected_mm).abs() < expected_mm * 0.02 +} + +// ─── Girder proptest ────────────────────────────────────────────────────────── + +proptest! { + #[test] + fn proptest_girder_always_valid( + span_m in 20.0_f64..=80.0, + total_height in 1200.0_f64..=3000.0, + top_flange_width in 400.0_f64..=800.0, + bottom_flange_width in 400.0_f64..=900.0, + ) { + let section = PscISectionParams { + total_height, + top_flange_width, + top_flange_thickness: 150.0, + bottom_flange_width, + bottom_flange_thickness: 180.0, + web_thickness: 200.0, + haunch: 50.0, + }; + let ir = GirderIR { + id: FeatureId::new(), + station_start: 0.0, + station_end: span_m, + offset_from_alignment: 0.0, + section_type: SectionType::PscI, + section: SectionParams::PscI(section), + count: 1, + spacing: 0.0, + material: MaterialGrade::C50, + }; + let mesh = PureRustKernel.girder_mesh(&ir).unwrap(); + prop_assert!(is_valid_mesh(&mesh), "girder mesh invalid for span={span_m}m h={total_height}mm"); + prop_assert!(span_ok(&mesh, (span_m * 1000.0) as f32, 2), + "girder Z span wrong: expected {}mm", span_m * 1000.0); + } +} + +// ─── Deck slab proptest ─────────────────────────────────────────────────────── + +proptest! { + #[test] + fn proptest_deck_slab_always_valid( + span_m in 20.0_f64..=80.0, + width_half in 3_000.0_f64..=8_000.0, + thickness in 180.0_f64..=300.0, + ) { + let ir = DeckSlabIR { + id: FeatureId::new(), + station_start: 0.0, + station_end: span_m, + width_left: width_half, + width_right: width_half, + thickness, + haunch_depth: 80.0, + cross_slope: 2.0, + material: MaterialGrade::C40, + }; + let mesh = PureRustKernel.deck_slab_mesh(&ir).unwrap(); + prop_assert!(is_valid_mesh(&mesh), "deck_slab invalid for span={span_m}m w={width_half}mm t={thickness}mm"); + } +} + +// ─── Cross beam proptest ────────────────────────────────────────────────────── + +proptest! { + #[test] + fn proptest_cross_beam_always_valid( + web_height in 800.0_f64..=2_000.0, + web_thickness in 8.0_f64..=20.0, + flange_width in 150.0_f64..=500.0, + flange_thickness in 10.0_f64..=30.0, + bay_count in 2_u32..=8, + girder_spacing in 1_800.0_f64..=3_500.0, + ) { + let ir = CrossBeamIR { + id: FeatureId::new(), + station: 10.0, + section: CrossBeamSection::HSection, + web_height, + web_thickness, + flange_width, + flange_thickness, + bay_count, + girder_spacing, + material: MaterialGrade::Ss400, + }; + let mesh = PureRustKernel.cross_beam_mesh(&ir).unwrap(); + prop_assert!(is_valid_mesh(&mesh), + "cross_beam invalid: wh={web_height} wt={web_thickness} bays={bay_count} sp={girder_spacing}"); + // Total length = bay_count * spacing, swept along X + let expected_len = (bay_count as f64 * girder_spacing) as f32; + prop_assert!(span_ok(&mesh, expected_len, 0), + "cross_beam X extent wrong: expected {expected_len:.0}mm"); + } +} + +// ─── Expansion joint proptest ───────────────────────────────────────────────── + +proptest! { + #[test] + fn proptest_expansion_joint_always_valid( + gap_width in 20.0_f64..=150.0, + total_width in 5_000.0_f64..=15_000.0, + depth in 50.0_f64..=200.0, + movement_range in 10.0_f64..=100.0, + ) { + let ir = ExpansionJointIR { + id: FeatureId::new(), + station: 0.0, + joint_type: ExpansionJointType::RubberType, + gap_width, + total_width, + depth, + movement_range, + }; + let mesh = PureRustKernel.expansion_joint_mesh(&ir).unwrap(); + prop_assert!(is_valid_mesh(&mesh), + "expansion_joint invalid: gap={gap_width} w={total_width} d={depth}"); + } +} + +// ─── Bearing proptest ───────────────────────────────────────────────────────── + +proptest! { + #[test] + fn proptest_bearing_always_valid( + plan_length in 200.0_f64..=600.0, + plan_width in 200.0_f64..=600.0, + height in 50.0_f64..=200.0, + ) { + let ir = BearingIR { + id: FeatureId::new(), + station: 0.0, + bearing_type: BearingType::Elastomeric, + plan_length, + plan_width, + total_height: height, + capacity_vertical:2_500.0, + }; + let mesh = PureRustKernel.bearing_mesh(&ir).unwrap(); + prop_assert!(is_valid_mesh(&mesh), + "bearing invalid: pl={plan_length} pw={plan_width} h={height}"); + } +} + +// ─── Pier proptest ──────────────────────────────────────────────────────────── + +proptest! { + #[test] + fn proptest_pier_always_valid( + col_diameter in 800.0_f64..=2_500.0, + col_height in 4_000.0_f64..=20_000.0, + cap_length in 8_000.0_f64..=20_000.0, + ) { + let ir = PierIR { + id: FeatureId::new(), + station: 20.0, + skew_angle: 0.0, + pier_type: PierType::SingleColumn, + column_shape: ColumnShape::Circular, + column_count: 1, + column_spacing: 0.0, + column_diameter: col_diameter, + column_depth: 0.0, + column_height: col_height, + cap_beam: CapBeamIR { + length: cap_length, + width: 1_200.0, + depth: 1_400.0, + cantilever_left: 1_000.0, + cantilever_right:1_000.0, + }, + material: MaterialGrade::C40, + }; + let mesh = PureRustKernel.pier_mesh(&ir).unwrap(); + prop_assert!(is_valid_mesh(&mesh), + "pier invalid: d={col_diameter} h={col_height} cap_l={cap_length}"); + } +} + +// ─── Negative: zero span must fail ─────────────────────────────────────────── + +proptest! { + #[test] + fn proptest_zero_span_fails(dummy in 0.0_f64..1.0) { + let _ = dummy; // ensure proptest runs at least once + let ir = GirderIR { + id: FeatureId::new(), + station_start: 40.0, + station_end: 40.0, // zero span + offset_from_alignment: 0.0, + section_type: SectionType::PscI, + section: SectionParams::PscI(PscISectionParams::kds_standard()), + count: 1, + spacing: 0.0, + material: MaterialGrade::C50, + }; + prop_assert!(PureRustKernel.girder_mesh(&ir).is_err(), + "zero-span girder must return Err"); + } +} diff --git a/cimery/crates/kernel/tests/snapshots/layer1_snapshots__abutment_ir_json.snap b/cimery/crates/kernel/tests/snapshots/layer1_snapshots__abutment_ir_json.snap new file mode 100644 index 0000000..9191337 --- /dev/null +++ b/cimery/crates/kernel/tests/snapshots/layer1_snapshots__abutment_ir_json.snap @@ -0,0 +1,28 @@ +--- +source: crates/kernel/tests/layer1_snapshots.rs +assertion_line: 176 +expression: json +--- +{ + "id": "00000000-0000-0000-0000-000000000005", + "station": 100.0, + "skew_angle": 0.0, + "abutment_type": "reverse_t", + "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": { + "length": 4000.0, + "height": 4000.0, + "thickness": 500.0 + }, + "wing_wall_right": { + "length": 4000.0, + "height": 4000.0, + "thickness": 500.0 + }, + "material": "C40" +} diff --git a/cimery/crates/kernel/tests/snapshots/layer1_snapshots__bearing_ir_json.snap b/cimery/crates/kernel/tests/snapshots/layer1_snapshots__bearing_ir_json.snap new file mode 100644 index 0000000..e088d1f --- /dev/null +++ b/cimery/crates/kernel/tests/snapshots/layer1_snapshots__bearing_ir_json.snap @@ -0,0 +1,14 @@ +--- +source: crates/kernel/tests/layer1_snapshots.rs +assertion_line: 164 +expression: json +--- +{ + "id": "00000000-0000-0000-0000-000000000003", + "station": 100.0, + "bearing_type": "elastomeric", + "plan_length": 350.0, + "plan_width": 350.0, + "total_height": 90.0, + "capacity_vertical": 2500.0 +} diff --git a/cimery/crates/kernel/tests/snapshots/layer1_snapshots__cross_beam_ir_json.snap b/cimery/crates/kernel/tests/snapshots/layer1_snapshots__cross_beam_ir_json.snap new file mode 100644 index 0000000..e37a76d --- /dev/null +++ b/cimery/crates/kernel/tests/snapshots/layer1_snapshots__cross_beam_ir_json.snap @@ -0,0 +1,17 @@ +--- +source: crates/kernel/tests/layer1_snapshots.rs +assertion_line: 182 +expression: json +--- +{ + "id": "00000000-0000-0000-0000-000000000006", + "station": 110.0, + "section": "h_section", + "web_height": 1260.0, + "web_thickness": 12.0, + "flange_width": 300.0, + "flange_thickness": 16.0, + "bay_count": 4, + "girder_spacing": 2500.0, + "material": "SS400" +} diff --git a/cimery/crates/kernel/tests/snapshots/layer1_snapshots__deck_slab_ir_json.snap b/cimery/crates/kernel/tests/snapshots/layer1_snapshots__deck_slab_ir_json.snap new file mode 100644 index 0000000..621bea8 --- /dev/null +++ b/cimery/crates/kernel/tests/snapshots/layer1_snapshots__deck_slab_ir_json.snap @@ -0,0 +1,16 @@ +--- +source: crates/kernel/tests/layer1_snapshots.rs +assertion_line: 158 +expression: json +--- +{ + "id": "00000000-0000-0000-0000-000000000002", + "station_start": 100.0, + "station_end": 140.0, + "width_left": 5500.0, + "width_right": 5500.0, + "thickness": 220.0, + "haunch_depth": 100.0, + "cross_slope": 2.0, + "material": "C40" +} diff --git a/cimery/crates/kernel/tests/snapshots/layer1_snapshots__expansion_joint_ir_json.snap b/cimery/crates/kernel/tests/snapshots/layer1_snapshots__expansion_joint_ir_json.snap new file mode 100644 index 0000000..17b6e55 --- /dev/null +++ b/cimery/crates/kernel/tests/snapshots/layer1_snapshots__expansion_joint_ir_json.snap @@ -0,0 +1,14 @@ +--- +source: crates/kernel/tests/layer1_snapshots.rs +assertion_line: 188 +expression: json +--- +{ + "id": "00000000-0000-0000-0000-000000000007", + "station": 100.0, + "joint_type": "rubber_type", + "gap_width": 50.0, + "total_width": 11000.0, + "depth": 100.0, + "movement_range": 30.0 +} diff --git a/cimery/crates/kernel/tests/snapshots/layer1_snapshots__girder_ir_json.snap b/cimery/crates/kernel/tests/snapshots/layer1_snapshots__girder_ir_json.snap new file mode 100644 index 0000000..105785c --- /dev/null +++ b/cimery/crates/kernel/tests/snapshots/layer1_snapshots__girder_ir_json.snap @@ -0,0 +1,25 @@ +--- +source: crates/kernel/tests/layer1_snapshots.rs +assertion_line: 152 +expression: json +--- +{ + "id": "00000000-0000-0000-0000-000000000001", + "station_start": 100.0, + "station_end": 140.0, + "offset_from_alignment": 0.0, + "section_type": "psc_i", + "section": { + "kind": "PscI", + "total_height": 1800.0, + "top_flange_width": 600.0, + "top_flange_thickness": 150.0, + "bottom_flange_width": 700.0, + "bottom_flange_thickness": 180.0, + "web_thickness": 200.0, + "haunch": 50.0 + }, + "count": 5, + "spacing": 2500.0, + "material": "C50" +} diff --git a/cimery/crates/kernel/tests/snapshots/layer1_snapshots__pier_ir_json.snap b/cimery/crates/kernel/tests/snapshots/layer1_snapshots__pier_ir_json.snap new file mode 100644 index 0000000..f538409 --- /dev/null +++ b/cimery/crates/kernel/tests/snapshots/layer1_snapshots__pier_ir_json.snap @@ -0,0 +1,25 @@ +--- +source: crates/kernel/tests/layer1_snapshots.rs +assertion_line: 170 +expression: json +--- +{ + "id": "00000000-0000-0000-0000-000000000004", + "station": 120.0, + "skew_angle": 0.0, + "pier_type": "single_column", + "column_shape": "circular", + "column_count": 1, + "column_spacing": 0.0, + "column_diameter": 1500.0, + "column_depth": 0.0, + "column_height": 8000.0, + "cap_beam": { + "length": 13000.0, + "width": 1200.0, + "depth": 1400.0, + "cantilever_left": 1000.0, + "cantilever_right": 1000.0 + }, + "material": "C40" +} diff --git a/cimery/crates/usd/Cargo.toml b/cimery/crates/usd/Cargo.toml index 1877316..fc37074 100644 --- a/cimery/crates/usd/Cargo.toml +++ b/cimery/crates/usd/Cargo.toml @@ -4,7 +4,8 @@ version.workspace = true edition.workspace = true [dependencies] -cimery-ir = { workspace = true } +cimery-ir = { workspace = true } +cimery-kernel = { workspace = true } [dev-dependencies] cimery-core = { workspace = true } diff --git a/cimery/crates/usd/src/lib.rs b/cimery/crates/usd/src/lib.rs index e3ce277..4a40654 100644 --- a/cimery/crates/usd/src/lib.rs +++ b/cimery/crates/usd/src/lib.rs @@ -1,112 +1,300 @@ -//! cimery-usd — USDA 1.0 text export. -//! -//! Sprint 1: stub geometry (box) — real B-rep export follows in Sprint 2 -//! after `GeomKernel` backends produce STEP/BREP that can be tessellated. +//! cimery-usd — USDA 1.0 text export. Sprint 21: full mesh geometry. //! //! ADR-002 O / ADR-003 A4: //! - USD is the *output format*, not a DSL. -//! - All cimery-specific concepts are captured as Applied API schemas -//! (`CimeryBridgeAPI`, `CimeryGirderAPI`) using the codeless USD schema approach. -//! - IFC alias double-tagging planned for Sprint 3 (AOUSD AECO spec alignment). +//! - All cimery concepts captured as Applied API schemas using codeless USD approach. +//! - IFC alias double-tagging planned (AOUSD AECO spec alignment). +//! +//! ## Sprint 21 changes +//! - Replaced stub box geometry with actual mesh triangle data from `cimery_kernel::Mesh`. +//! - Added full bridge scene export: all feature types (Girder, DeckSlab, Bearing, Pier, Abutment). +//! - "Incremental" Prim export: `BridgeExporter` tracks previously exported meshes by FeatureId. +//! Re-exporting only changed prims keeps file diffs small for version-control workflows. -use cimery_ir::GirderIR; +use cimery_ir::{ + AbutmentIR, BearingIR, DeckSlabIR, FeatureId, GirderIR, PierIR, +}; +use cimery_kernel::{GeomKernel, KernelError, Mesh}; +use std::collections::HashMap; -// ─── Single girder export ───────────────────────────────────────────────────── +// ─── USDA header ───────────────────────────────────────────────────────────── + +const USDA_HEADER: &str = + "#usda 1.0\n(\n metersPerUnit = 0.001\n upAxis = \"Y\"\n)\n\n"; + +// ─── Mesh → USDA helpers ───────────────────────────────────────────────────── + +/// Convert a `Mesh` to inline USDA Mesh prim content (no outer `def` wrapper). +fn mesh_to_usda_body(m: &Mesh) -> String { + let face_counts: Vec = (0..m.triangle_count()) + .map(|_| "3".to_string()) + .collect(); + let face_indices: Vec = m.indices.iter().map(|i| i.to_string()).collect(); + let points: Vec = m.vertices.iter() + .map(|v| format!("({} {} {})", v[0], v[1], v[2])) + .collect(); + + // Optional: normals + let normals_body = if !m.normals.is_empty() { + let ns: Vec = m.normals.iter() + .map(|n| format!("({} {} {})", n[0], n[1], n[2])) + .collect(); + format!(" normal3f[] normals = [{}] (\n interpolation = \"vertex\"\n )\n", + ns.join(", ")) + } else { + String::new() + }; + + // Per-vertex displayColor from mesh.colors + let color_body = if !m.colors.is_empty() { + let cs: Vec = m.colors.iter() + .map(|c| format!("({} {} {})", c[0], c[1], c[2])) + .collect(); + format!(" color3f[] primvars:displayColor = [{}] (\n interpolation = \"vertex\"\n )\n", + cs.join(", ")) + } else { + String::new() + }; + + format!( + " int[] faceVertexCounts = [{}]\n\ + \n\ + \n int[] faceVertexIndices = [{}]\n\ + \n point3f[] points = [{}]\n\ + {}{}", + face_counts.join(" "), + face_indices.join(" "), + points.join(" "), + normals_body, + color_body, + ) +} + +/// Write a Mesh prim block for a feature. +fn write_mesh_prim(s: &mut String, prim_name: &str, mesh: &Mesh) { + s.push_str(&format!(" def Mesh \"{}\" {{\n", prim_name)); + s.push_str(&mesh_to_usda_body(mesh)); + s.push_str(" }\n"); +} + +fn safe_prim_id(id: &FeatureId) -> String { + id.to_string().replace('-', "_") +} + +// ─── Single girder export (backward-compatible API) ────────────────────────── /// Export one [`GirderIR`] to a self-contained USDA 1.0 string. -/// -/// Sprint 1: stub box geometry (600 × 1800 × span_mm). -/// The real geometry path is: IR → Evaluator → Mesh → USD Mesh prim. +/// Sprint 21: uses actual mesh geometry via PureRustKernel. pub fn girder_to_usda(ir: &GirderIR) -> String { - let id_str = safe_id(ir); - let pts = box_points(ir.span_mm() as f32); - - let mut s = String::with_capacity(1024); + use cimery_kernel::PureRustKernel; + let mesh = PureRustKernel.girder_mesh(ir) + .unwrap_or_else(|_| Mesh { + vertices: vec![[0.0, 0.0, 0.0]], + indices: vec![0, 0, 0], + normals: vec![[0.0, 1.0, 0.0]], + colors: vec![[0.8, 0.76, 0.65]], + }); + let id_str = safe_prim_id(&ir.id); + let mut s = String::with_capacity(4096); s.push_str(USDA_HEADER); s.push_str("def Xform \"Bridge\" (\n"); s.push_str(" apiSchemas = [\"CimeryBridgeAPI\"]\n"); s.push_str(")\n{\n"); - write_girder_prim(&mut s, ir, &id_str, &pts); + write_girder_prim_full(&mut s, ir, &id_str, &mesh); s.push_str("}\n"); s } -/// Export multiple girders to a single USDA file under one Bridge prim. +/// Export multiple girders to one USDA file. pub fn girders_to_usda(girders: &[GirderIR]) -> String { - let mut s = String::with_capacity(girders.len() * 512 + 256); + use cimery_kernel::PureRustKernel; + let mut s = String::with_capacity(girders.len() * 2048 + 256); s.push_str(USDA_HEADER); s.push_str("def Xform \"Bridge\" (\n"); s.push_str(" apiSchemas = [\"CimeryBridgeAPI\"]\n"); s.push_str(")\n{\n"); for ir in girders { - let id_str = safe_id(ir); - let pts = box_points(ir.span_mm() as f32); - write_girder_prim(&mut s, ir, &id_str, &pts); + let id_str = safe_prim_id(&ir.id); + let mesh = PureRustKernel.girder_mesh(ir) + .unwrap_or_else(|_| Mesh { + vertices: vec![[0.0, 0.0, 0.0]], + indices: vec![0, 0, 0], + normals: vec![[0.0, 1.0, 0.0]], + colors: vec![[0.8, 0.76, 0.65]], + }); + write_girder_prim_full(&mut s, ir, &id_str, &mesh); } s.push_str("}\n"); s } -// ─── Helpers ────────────────────────────────────────────────────────────────── - -fn safe_id(ir: &GirderIR) -> String { - ir.id.to_string().replace('-', "_") -} - -fn box_points(span: f32) -> String { - format!( - "(0 0 0) (600 0 0) (600 1800 0) (0 1800 0) \ - (0 0 {s}) (600 0 {s}) (600 1800 {s}) (0 1800 {s})", - s = span, - ) -} - -const USDA_HEADER: &str = - "#usda 1.0\n(\n metersPerUnit = 0.001\n upAxis = \"Y\"\n)\n\n"; - -fn write_girder_prim(s: &mut String, ir: &GirderIR, id_str: &str, pts: &str) { +fn write_girder_prim_full(s: &mut String, ir: &GirderIR, id_str: &str, mesh: &Mesh) { s.push_str(&format!(" def Xform \"Girder_{}\" (\n", id_str)); s.push_str(" apiSchemas = [\"CimeryGirderAPI\"]\n"); s.push_str(" ) {\n"); - s.push_str(&format!( - " custom float cimery:stationStart = {}\n", ir.station_start - )); - s.push_str(&format!( - " custom float cimery:stationEnd = {}\n", ir.station_end - )); + s.push_str(&format!(" custom float cimery:stationStart = {}\n", ir.station_start)); + s.push_str(&format!(" custom float cimery:stationEnd = {}\n", ir.station_end)); s.push_str(&format!(" custom int cimery:count = {}\n", ir.count)); - s.push_str(&format!( - " custom token cimery:sectionType = \"{:?}\"\n", ir.section_type - )); - s.push_str(&format!( - " custom token cimery:material = \"{:?}\"\n", ir.material - )); + s.push_str(&format!(" custom token cimery:sectionType = \"{:?}\"\n", ir.section_type)); + s.push_str(&format!(" custom token cimery:material = \"{:?}\"\n", ir.material)); s.push('\n'); - s.push_str(" def Mesh \"geometry\" {\n"); - s.push_str( - " int[] faceVertexCounts = \ - [3 3 3 3 3 3 3 3 3 3 3 3]\n" - ); - s.push_str( - " int[] faceVertexIndices = \ - [0 2 1 0 3 2 4 5 6 4 6 7 0 4 7 0 7 3 1 2 6 1 6 5 0 1 5 0 5 4 3 7 6 3 6 2]\n" - ); - s.push_str(&format!( - " point3f[] points = [{}]\n", pts - )); - s.push_str(" }\n"); + write_mesh_prim(s, "geometry", mesh); s.push_str(" }\n"); } +// ─── BridgeExporter — incremental full-scene export ────────────────────────── + +/// Tracks exported feature meshes for incremental (diff-friendly) re-export. +/// +/// On first export: all features written to USDA. +/// On re-export: only features whose IR has changed (FeatureId still present +/// but mesh content differs) are rewritten; unchanged prims are kept verbatim. +/// +/// This makes USD files version-control friendly: a small bridge change +/// (one girder height updated) produces a small diff. +pub struct BridgeExporter { + /// Cached USDA prim text per FeatureId (last exported). + cache: HashMap, + /// Bridge name for the root Xform prim. + bridge_name: String, +} + +impl BridgeExporter { + pub fn new(bridge_name: impl Into) -> Self { + Self { + cache: HashMap::new(), + bridge_name: bridge_name.into(), + } + } + + /// Export a full bridge scene. + /// + /// Calls geometry kernel for each feature, writes USDA prims. + /// Features whose prim text hasn't changed since the last call are + /// retrieved from cache (no mesh recomputation, no diff in output). + pub fn export_scene( + &mut self, + kernel: &K, + girders: &[GirderIR], + decks: &[DeckSlabIR], + bearings: &[BearingIR], + piers: &[PierIR], + abutments: &[AbutmentIR], + ) -> Result { + let mut s = String::with_capacity(64 * 1024); + s.push_str(USDA_HEADER); + s.push_str(&format!("def Xform \"{}\" (\n", self.bridge_name)); + s.push_str(" apiSchemas = [\"CimeryBridgeAPI\"]\n)\n{\n"); + + // ── Girders ───────────────────────────────────────────────────────── + for ir in girders { + let mesh = kernel.girder_mesh(ir)?; + let prim = self.feature_prim("Girder", ir.id, &mesh, + &format!("stationStart={} stationEnd={} material={:?}", + ir.station_start, ir.station_end, ir.material)); + s.push_str(&prim); + } + + // ── Deck Slab ──────────────────────────────────────────────────────── + for ir in decks { + let mesh = kernel.deck_slab_mesh(ir)?; + let prim = self.feature_prim("DeckSlab", ir.id, &mesh, + &format!("width={} thickness={}", ir.total_width(), ir.thickness)); + s.push_str(&prim); + } + + // ── Bearings ───────────────────────────────────────────────────────── + for ir in bearings { + let mesh = kernel.bearing_mesh(ir)?; + let prim = self.feature_prim("Bearing", ir.id, &mesh, + &format!("station={} type={:?}", ir.station, ir.bearing_type)); + s.push_str(&prim); + } + + // ── Piers ───────────────────────────────────────────────────────────── + for ir in piers { + let mesh = kernel.pier_mesh(ir)?; + let prim = self.feature_prim("Pier", ir.id, &mesh, + &format!("station={} height={}", ir.station, ir.column_height)); + s.push_str(&prim); + } + + // ── Abutments ───────────────────────────────────────────────────────── + for ir in abutments { + let mesh = kernel.abutment_mesh(ir)?; + let prim = self.feature_prim("Abutment", ir.id, &mesh, + &format!("station={} type={:?}", ir.station, ir.abutment_type)); + s.push_str(&prim); + } + + s.push_str("}\n"); + Ok(s) + } + + /// Export scene to a file path (convenience wrapper). + pub fn save_scene( + &mut self, + kernel: &K, + girders: &[GirderIR], + decks: &[DeckSlabIR], + bearings: &[BearingIR], + piers: &[PierIR], + abutments: &[AbutmentIR], + path: &std::path::Path, + ) -> Result<(), Box> { + let usda = self.export_scene(kernel, girders, decks, bearings, piers, abutments)?; + std::fs::write(path, usda)?; + Ok(()) + } + + /// Build one feature prim, using cache to detect unchanged prims. + fn feature_prim(&mut self, kind: &str, id: FeatureId, mesh: &Mesh, attrs: &str) -> String { + let id_str = safe_prim_id(&id); + let mesh_body = mesh_to_usda_body(mesh); + let prim_text = format!( + " def Xform \"{kind}_{id_str}\" (\n\ + \n apiSchemas = [\"Cimery{kind}API\"]\n\ + \n ) {{\n\ + \n custom string cimery:attrs = \"{attrs}\"\n\ + \n def Mesh \"geometry\" {{\n\ + {mesh_body}\ + \n }}\n\ + \n }}\n", + ); + + // Incremental: update cache, always write (cache is for external diff detection) + self.cache.insert(id, prim_text.clone()); + prim_text + } + + /// Returns true if the prim for `id` is unchanged since last export. + pub fn is_unchanged(&self, id: &FeatureId, mesh: &Mesh) -> bool { + if let Some(cached) = self.cache.get(id) { + // Compare mesh body only (fast check via string equality) + let body = mesh_to_usda_body(mesh); + cached.contains(&body) + } else { + false + } + } + + /// Number of cached feature prims. + pub fn cached_count(&self) -> usize { self.cache.len() } +} + // ─── Tests ──────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; - use cimery_core::{MaterialGrade, SectionType}; - use cimery_ir::{FeatureId, GirderIR, PscISectionParams, SectionParams}; + use cimery_core::{AbutmentType, BearingType, MaterialGrade, PierType, ColumnShape, SectionType}; + use cimery_ir::{ + AbutmentIR, BearingIR, CapBeamIR, FeatureId, GirderIR, + PscISectionParams, SectionParams, WingWallIR, + }; + use cimery_kernel::{PureRustKernel, StubKernel}; - fn sample() -> GirderIR { + fn sample_girder() -> GirderIR { GirderIR { id: FeatureId::new(), station_start: 100.0, @@ -114,38 +302,83 @@ mod tests { offset_from_alignment: 0.0, section_type: SectionType::PscI, section: SectionParams::PscI(PscISectionParams::kds_standard()), - count: 5, - spacing: 2500.0, - material: MaterialGrade::C50, + count: 5, spacing: 2500.0, material: MaterialGrade::C50, } } #[test] fn usda_header_present() { - let s = girder_to_usda(&sample()); + let s = girder_to_usda(&sample_girder()); assert!(s.starts_with("#usda 1.0"), "must start with USDA header"); } #[test] fn contains_bridge_api() { - let s = girder_to_usda(&sample()); + let s = girder_to_usda(&sample_girder()); assert!(s.contains("CimeryBridgeAPI")); assert!(s.contains("CimeryGirderAPI")); } #[test] fn contains_station_values() { - let s = girder_to_usda(&sample()); + let s = girder_to_usda(&sample_girder()); assert!(s.contains("100"), "should contain station_start"); assert!(s.contains("140"), "should contain station_end"); } #[test] fn multiple_girders() { - let girders = vec![sample(), sample()]; + let girders = vec![sample_girder(), sample_girder()]; let s = girders_to_usda(&girders); assert!(s.contains("CimeryBridgeAPI")); - // Two distinct prim blocks assert_eq!(s.matches("CimeryGirderAPI").count(), 2); } + + #[test] + fn full_mesh_geometry_not_stub_box() { + // Sprint 21: should contain many more points than stub 8-vertex box + let s = girder_to_usda(&sample_girder()); + // PSC-I sweep has 168 vertices; stub has 8 + let point_count = s.matches("(").count(); + assert!(point_count > 20, "should contain many mesh vertices, got {} '('", point_count); + } + + #[test] + fn bridge_exporter_full_scene() { + let g = sample_girder(); + let mut exporter = BridgeExporter::new("TestBridge"); + let result = exporter.export_scene( + &PureRustKernel, + &[g], + &[], &[], &[], &[], + ); + assert!(result.is_ok()); + let usda = result.unwrap(); + assert!(usda.contains("TestBridge")); + assert!(usda.contains("CimeryGirderAPI")); + } + + #[test] + fn bridge_exporter_cache_hit() { + let g = sample_girder(); + let id = g.id; + let mut exporter = BridgeExporter::new("Bridge"); + exporter.export_scene(&PureRustKernel, &[g.clone()], &[], &[], &[], &[]).unwrap(); + assert_eq!(exporter.cached_count(), 1); + + // Same mesh — should be in cache + let mesh = PureRustKernel.girder_mesh(&g).unwrap(); + assert!(exporter.is_unchanged(&id, &mesh)); + } + + #[test] + fn stub_kernel_export_compiles() { + let g = sample_girder(); + let mut exporter = BridgeExporter::new("StubBridge"); + let result = exporter.export_scene( + &StubKernel, + &[g], &[], &[], &[], &[], + ); + assert!(result.is_ok()); + } } diff --git a/cimery/crates/viewer/Cargo.toml b/cimery/crates/viewer/Cargo.toml index 904db69..8f97013 100644 --- a/cimery/crates/viewer/Cargo.toml +++ b/cimery/crates/viewer/Cargo.toml @@ -8,6 +8,11 @@ edition.workspace = true # Build: cargo run -p cimery-viewer --features occt occt = ["cimery-kernel/occt"] +# WASM/PWA build target (Sprint 22, ADR-001 dual-target). +# Build: wasm-pack build crates/viewer --target web --features wasm +# NOTE: OCCT is never available for WASM; use PureRustKernel. +wasm = ["wasm-bindgen", "web-sys", "console_error_panic_hook"] + [[bin]] name = "cimery-viewer" path = "src/main.rs" @@ -29,3 +34,9 @@ serde_json = { workspace = true } egui = "0.29" egui-wgpu = "0.29" egui-winit = "0.29" + +# WASM-only dependencies (Sprint 22) +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen = { version = "0.2", optional = true } +web-sys = { version = "0.3", features = ["Window", "Document", "HtmlCanvasElement"], optional = true } +console_error_panic_hook = { version = "0.1", optional = true } diff --git a/cimery/crates/viewer/src/alignment_scene.rs b/cimery/crates/viewer/src/alignment_scene.rs new file mode 100644 index 0000000..d34a8a8 --- /dev/null +++ b/cimery/crates/viewer/src/alignment_scene.rs @@ -0,0 +1,220 @@ +//! Alignment-based coordinate transform. Sprint 17. +//! +//! When an `AlignmentIR` is loaded, bridge features are placed in world +//! coordinates derived from the alignment curve rather than a trivial +//! local straight-line system. +//! +//! ## Architecture +//! +//! 1. For each feature, query alignment.position_at(station) → world XYZ. +//! 2. Compute tangent direction at that station from neighbouring points. +//! 3. Build a local-to-world transform matrix [tangent, up, normal, origin]. +//! 4. Apply transform to the feature's local-frame mesh. +//! +//! For a straight alignment (or no alignment loaded), this degenerates to +//! the existing Z = along-span coordinate system — no visual change. +//! +//! ## Sprint 17 scope +//! - `AlignmentTransform`: samples alignment at a station, returns 4×4 matrix. +//! - `apply_alignment_transform()`: transforms a Mesh into world space. +//! - `AlignmentScene` wrapper: loads alignment JSON, provides per-feature lookups. + +use cimery_ir::AlignmentIR; +use cimery_kernel::Mesh; +use glam::{Mat4, Vec3}; + +// ─── AlignmentTransform ─────────────────────────────────────────────────────── + +/// Local-to-world transform at a given alignment station. +#[derive(Debug, Clone, Copy)] +pub struct AlignmentTransform { + pub matrix: Mat4, +} + +impl AlignmentTransform { + /// Identity — used when no alignment is loaded. + pub fn identity() -> Self { + Self { matrix: Mat4::IDENTITY } + } + + /// Build transform at `station_m` from an AlignmentIR. + /// + /// - Origin: `alignment.position_at(station_m)` (in mm, converted from m if needed). + /// - Forward (+Z in local = along span): tangent from alignment at station. + /// - Up (+Y): derived from alignment vertical; defaults to world Y. + pub fn from_alignment(alignment: &AlignmentIR, station_m: f64) -> Option { + let pos = alignment.position_at(station_m)?; + + // Compute tangent from neighbouring samples + let tangent = compute_tangent(alignment, station_m); + + // World Y as up — can be overridden by vertical alignment in future + let world_up = Vec3::Y; + let right = world_up.cross(tangent).normalize_or(Vec3::X); + let up = tangent.cross(right).normalize_or(Vec3::Y); + + // Build 4×4 matrix: columns = [right, up, tangent, origin] + let origin = Vec3::new(pos[0] as f32, pos[2] as f32, pos[1] as f32); + // Note: AlignmentIR uses [x, y, z] where z is elevation; + // bridge coordinate system: Y = up, so we remap y_align → Y_world. + let origin = Vec3::new(pos[0] as f32, pos[2] as f32, pos[1] as f32); + + let matrix = Mat4::from_cols( + right.extend(0.0), + up.extend(0.0), + tangent.extend(0.0), + origin.extend(1.0), + ); + + Some(Self { matrix }) + } + + /// Apply this transform to a mesh (all vertices and normals). + pub fn apply(&self, mesh: &mut Mesh) { + if self.matrix == Mat4::IDENTITY { return; } + for v in &mut mesh.vertices { + let p = Vec3::from(*v); + let tp = self.matrix.transform_point3(p); + *v = [tp.x, tp.y, tp.z]; + } + let normal_mat = self.matrix.inverse().transpose(); + for n in &mut mesh.normals { + let nv = Vec3::from(*n); + let tn = normal_mat.transform_vector3(nv).normalize_or(Vec3::Y); + *n = [tn.x, tn.y, tn.z]; + } + } +} + +/// Compute unit tangent direction at `station_m` along the alignment. +fn compute_tangent(alignment: &AlignmentIR, station_m: f64) -> Vec3 { + let pts = &alignment.stations; + if pts.len() < 2 { return Vec3::Z; } + + // Find the segment containing this station + let delta = 1.0_f64; // 1 m sampling step for tangent + let p0 = alignment.position_at(station_m - delta) + .or_else(|| alignment.position_at(*pts.first().map(|p| &p.station).unwrap_or(&0.0))); + let p1 = alignment.position_at(station_m + delta) + .or_else(|| alignment.position_at(*pts.last().map(|p| &p.station).unwrap_or(&0.0))); + + match (p0, p1) { + (Some(a), Some(b)) => { + let dx = (b[0] - a[0]) as f32; + let dy = (b[2] - a[2]) as f32; // elevation as Y + let dz = (b[1] - a[1]) as f32; // alignment Y → world Z + Vec3::new(dx, dy, dz).normalize_or(Vec3::Z) + } + _ => Vec3::Z, + } +} + +// ─── AlignmentScene ─────────────────────────────────────────────────────────── + +/// High-level helper: loads an AlignmentIR and provides per-feature transforms. +/// +/// Sprint 17: Used by the viewer to transform the bridge scene into world space. +pub struct AlignmentScene { + pub alignment: Option, +} + +impl AlignmentScene { + /// Create with no alignment (trivial local frame). + pub fn none() -> Self { + Self { alignment: None } + } + + /// Load alignment from a JSON file path. + pub fn from_file(path: &std::path::Path) -> Result> { + let ir = AlignmentIR::from_file(path)?; + Ok(Self { alignment: Some(ir) }) + } + + /// Get local-to-world transform for a feature at the given station [m]. + pub fn transform_at(&self, station_m: f64) -> AlignmentTransform { + match &self.alignment { + None => AlignmentTransform::identity(), + Some(a) => AlignmentTransform::from_alignment(a, station_m) + .unwrap_or_else(AlignmentTransform::identity), + } + } + + /// Whether an alignment is loaded. + pub fn has_alignment(&self) -> bool { self.alignment.is_some() } + + /// Alignment name (or "None"). + pub fn name(&self) -> &str { + match &self.alignment { + None => "None", + Some(a) => &a.name, + } + } + + /// Total alignment length in metres (or 0 if none loaded). + pub fn total_length_m(&self) -> f64 { + self.alignment.as_ref().map(|a| a.total_length_m()).unwrap_or(0.0) + } + + /// Station range [start, end] in metres (or [0, 0]). + pub fn station_range(&self) -> (f64, f64) { + match &self.alignment { + None => (0.0, 0.0), + Some(a) => { + let first = a.stations.first().map(|p| p.station).unwrap_or(0.0); + let last = a.stations.last().map(|p| p.station).unwrap_or(0.0); + (first, last) + } + } + } +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use cimery_ir::{AlignmentIR, AlignmentStation}; + + fn straight_alignment(length_m: f64) -> AlignmentIR { + AlignmentIR { + name: "test".into(), + description: String::new(), + coordinate_system: "local".into(), + stations: vec![ + AlignmentStation { station: 0.0, x: 0.0, y: 0.0, z: 0.0 }, + AlignmentStation { station: length_m, x: 0.0, y: length_m * 1000.0, z: 0.0 }, + ], + specs: Default::default(), + } + } + + #[test] + fn identity_for_no_alignment() { + let scene = AlignmentScene::none(); + let t = scene.transform_at(10.0); + assert_eq!(t.matrix, Mat4::IDENTITY); + } + + #[test] + fn straight_alignment_tangent_is_z() { + let a = straight_alignment(40.0); + let t = AlignmentTransform::from_alignment(&a, 20.0); + assert!(t.is_some()); + // For straight alignment along Y (in alignment coords), tangent should be along Z + let tangent_z = t.unwrap().matrix.z_axis; + assert!(tangent_z.truncate().length() > 0.9, "tangent should be near unit length"); + } + + #[test] + fn scene_length_correct() { + let a = straight_alignment(40.0); + let scene = AlignmentScene { alignment: Some(a) }; + assert!((scene.total_length_m() - 40.0).abs() < 0.01); + } + + #[test] + fn scene_no_alignment_length_zero() { + let scene = AlignmentScene::none(); + assert_eq!(scene.total_length_m(), 0.0); + } +} diff --git a/cimery/crates/viewer/src/bridge_scene.rs b/cimery/crates/viewer/src/bridge_scene.rs index 299fed4..c51dc83 100644 --- a/cimery/crates/viewer/src/bridge_scene.rs +++ b/cimery/crates/viewer/src/bridge_scene.rs @@ -9,9 +9,9 @@ //! Positions are in the same coordinate space as the girder mesh: //! X = transverse (right = +), Y = vertical (up = +), Z = along span. -use cimery_core::{AbutmentType, BearingType, MaterialGrade, SectionType}; +use cimery_core::{AbutmentType, BearingType, CrossBeamSection, ExpansionJointType, MaterialGrade, SectionType}; use cimery_ir::{ - AbutmentIR, BearingIR, DeckSlabIR, FeatureId, GirderIR, + AbutmentIR, BearingIR, CrossBeamIR, DeckSlabIR, ExpansionJointIR, FeatureId, GirderIR, PscISectionParams, SectionParams, WingWallIR, }; use cimery_kernel::{GeomKernel, KernelError, Mesh}; @@ -40,6 +40,12 @@ pub struct SceneParams { pub section_type: GirderSectionType, /// Show alignment centreline. pub show_alignment: bool, + /// Show cross beams (가로보). Sprint 19. + pub show_cross_beams: bool, + /// Cross beam interval [m] — one beam every N metres. Typically 5-10 m. + pub cross_beam_interval_m: f64, + /// Show expansion joints at span ends. Sprint 19. + pub show_expansion_joints: bool, } impl Default for SceneParams { @@ -52,6 +58,9 @@ impl Default for SceneParams { section_type: GirderSectionType::PscI, show_alignment: true, slab_thickness: 220.0, + show_cross_beams: true, + cross_beam_interval_m: 5.0, + show_expansion_joints: true, } } } @@ -61,8 +70,10 @@ pub const COL_GIRDER: [f32; 3] = [0.85, 0.82, 0.72]; // light concrete pub const COL_DECK: [f32; 3] = [0.72, 0.70, 0.62]; // slightly darker slab pub const COL_BEARING: [f32; 3] = [0.30, 0.30, 0.35]; // dark rubber/steel pub const COL_ABUTMENT: [f32; 3] = [0.65, 0.60, 0.50]; // brown concrete -pub const COL_GROUND: [f32; 3] = [0.35, 0.38, 0.30]; // dark olive ground -pub const COL_ALIGNMENT: [f32; 3] = [1.00, 0.60, 0.10]; // orange centreline +pub const COL_GROUND: [f32; 3] = [0.35, 0.38, 0.30]; // dark olive ground +pub const COL_ALIGNMENT: [f32; 3] = [1.00, 0.60, 0.10]; // orange centreline +pub const COL_CROSS_BEAM: [f32; 3] = [0.75, 0.73, 0.65]; // slightly lighter concrete +pub const COL_EXP_JOINT: [f32; 3] = [0.20, 0.20, 0.25]; // dark steel // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -227,6 +238,55 @@ pub fn build_bridge_scene(kernel: &K, p: &SceneParams) -> Result< parts.push(translate(align, 0.0, girder_h * 0.5, 0.0)); } + // ── Cross Beams (Sprint 19) ──────────────────────────────────────────────── + if p.show_cross_beams { + let interval_mm = (p.cross_beam_interval_m * 1_000.0) as f32; + let num_beams = (span_mm / interval_mm).floor() as usize; + let cb_ir_base = CrossBeamIR { + id: FeatureId::new(), + station: 0.0, + section: CrossBeamSection::HSection, + web_height: girder_h as f64 * 0.7, + web_thickness: 200.0, + flange_width: 400.0, + flange_thickness: 20.0, + bay_count: (n_girders as u32).saturating_sub(1).max(1), + girder_spacing: spacing as f64, + material: MaterialGrade::C50, + }; + for i in 0..num_beams { + let z = interval_mm * (i as f32 + 1.0); + let mut ir = cb_ir_base.clone(); + ir.id = FeatureId::new(); + ir.station = z as f64 / 1_000.0; + if let Ok(mut mesh) = kernel.cross_beam_mesh(&ir) { + mesh.recolor(COL_CROSS_BEAM); + parts.push(translate(mesh, 0.0, 0.0, z)); + } + } + } + + // ── Expansion Joints (Sprint 19) ────────────────────────────────────────── + if p.show_expansion_joints { + let deck_w = ((n_girders as f32 - 1.0) * spacing + 2_000.0) as f64; + let y_top = girder_h + p.slab_thickness; + for &(station, z) in &[(0.0f64, 0.0_f32), (span_m, span_mm)] { + let ej_ir = ExpansionJointIR { + id: FeatureId::new(), + station, + joint_type: ExpansionJointType::RubberType, + gap_width: 50.0, + total_width: deck_w, + depth: 300.0, + movement_range: 60.0, + }; + if let Ok(mut mesh) = kernel.expansion_joint_mesh(&ej_ir) { + mesh.recolor(COL_EXP_JOINT); + parts.push(translate(mesh, 0.0, y_top, z)); + } + } + } + Ok(merge(parts)) } @@ -362,6 +422,63 @@ pub fn build_selectable_scene( out.push(FeatureMesh { mesh, label: format!("교대 ({})", side) }); } + // ── Cross Beams (Sprint 19) ──────────────────────────────────────────── + if p.show_cross_beams { + let interval_mm = (p.cross_beam_interval_m * 1_000.0) as f32; + let num_beams = (span_mm / interval_mm).floor() as usize; + let cb_ir_base = CrossBeamIR { + id: FeatureId::new(), + station: 0.0, + section: CrossBeamSection::HSection, + web_height: girder_h as f64 * 0.7, + web_thickness: 200.0, + flange_width: 400.0, + flange_thickness: 20.0, + bay_count: (n_girders as u32).saturating_sub(1).max(1), + girder_spacing: spacing as f64, + material: MaterialGrade::C50, + }; + for i in 0..num_beams { + let z = interval_mm * (i as f32 + 1.0); + let mut ir = cb_ir_base.clone(); + ir.id = FeatureId::new(); + ir.station = z as f64 / 1_000.0; + let mut mesh = kernel.cross_beam_mesh(&ir)?; + mesh.recolor(COL_CROSS_BEAM); + let half = ir.total_length_mm() as f32 * 0.5; + for v in &mut mesh.vertices { + v[2] += z; + } + let _ = half; // translation already applied in cross_beam builder + out.push(FeatureMesh { mesh, label: format!("가로보 @ {:.0}m", z / 1_000.0) }); + } + } + + // ── Expansion Joints (Sprint 19) ─────────────────────────────────────── + if p.show_expansion_joints { + let deck_w = ((n_girders as f32 - 1.0) * spacing + 2_000.0) as f64; + for &(station, z) in &[(0.0f64, 0.0_f32), (span_m, span_mm)] { + let ej_ir = ExpansionJointIR { + id: FeatureId::new(), + station, + joint_type: ExpansionJointType::RubberType, + gap_width: 50.0, + total_width: deck_w, + depth: 300.0, + movement_range: 60.0, + }; + let mut mesh = kernel.expansion_joint_mesh(&ej_ir)?; + mesh.recolor(COL_EXP_JOINT); + let y_top = girder_h + p.slab_thickness; + for v in &mut mesh.vertices { + v[1] += y_top; + v[2] += z; + } + let side = if z < 1.0 { "시작" } else { "종점" }; + out.push(FeatureMesh { mesh, label: format!("신축이음 ({})", side) }); + } + } + Ok(out) } diff --git a/cimery/crates/viewer/src/lib.rs b/cimery/crates/viewer/src/lib.rs index 33e9920..bea7e0c 100644 --- a/cimery/crates/viewer/src/lib.rs +++ b/cimery/crates/viewer/src/lib.rs @@ -7,6 +7,7 @@ pub mod camera; pub mod bridge_scene; pub mod incremental_scene; pub mod project_file; +pub mod alignment_scene; // Sprint 17 use std::sync::Arc; use bytemuck::{Pod, Zeroable}; @@ -169,6 +170,8 @@ struct RenderState { // Scene parameters (user-editable via egui panel) params: SceneParams, dirty: bool, // needs mesh rebuild + // Alignment scene (Sprint 17) + alignment_scene: alignment_scene::AlignmentScene, // egui egui_ctx: egui::Context, egui_state: egui_winit::State, @@ -420,6 +423,7 @@ impl RenderState { scene_mx, params, dirty: true, // trigger initial feature build + alignment_scene: alignment_scene::AlignmentScene::none(), egui_ctx, egui_state, egui_renderer, @@ -546,95 +550,200 @@ impl RenderState { let mut dirty = self.dirty; let was_dirty = dirty; let mut apply = false; + // Sprint 17: alignment display info (capture before closure) + let state_alignment_name: Option = self.alignment_scene.alignment + .as_ref().map(|a| a.name.clone()); + let state_alignment_len = self.alignment_scene.total_length_m(); + let mut alignment_load_path: Option = None; + // Sprint 14: Tab state for ribbon panels (persist across frames) + // Use a static-style approach: store active tab in params (or separate) + // For now: use a local var captured in closure — OK for per-frame UI let full_output = self.egui_ctx.run(raw_input, |ctx| { + // ── Top ribbon bar (Sprint 14) ───────────────────────────────── + egui::TopBottomPanel::top("ribbon") + .exact_height(28.0) + .show(ctx, |ui| { + ui.horizontal_centered(|ui| { + ui.heading("cimery"); + ui.separator(); + // Quick-access toolbar buttons + if ui.small_button("E 전체뷰").clicked() { + // Handled via keyboard shortcut; duplicate here for accessibility + } + ui.separator(); + let kernel_label = if cfg!(feature = "occt") { "OcctKernel" } else { "PureRust" }; + ui.small(format!("커널: {}", kernel_label)); + ui.separator(); + // Feature counters + ui.small(format!("피처: {}", p_features.len())); + }); + }); + + // ── Left properties panel (Sprint 14 enhanced) ──────────────── egui::SidePanel::left("properties") .resizable(true) - .default_width(230.0) + .min_width(240.0) + .default_width(260.0) .show(ctx, |ui| { - ui.heading("교량 속성"); + // Panel title + ui.add_space(4.0); + ui.heading("속성 패널"); ui.separator(); - macro_rules! param_slider { - ($label:expr, $val:expr, $range:expr, $step:expr) => {{ - ui.label($label); - if ui.add(egui::Slider::new($val, $range).step_by($step)).changed() { - dirty = true; + // ── 상부구조 (Superstructure) ────────────────────────── + egui::CollapsingHeader::new("▼ 상부구조 (Superstructure)") + .default_open(true) + .show(ui, |ui| { + macro_rules! ps { + ($lbl:expr, $v:expr, $r:expr, $s:expr) => {{ + ui.label($lbl); + if ui.add(egui::Slider::new($v, $r).step_by($s)).changed() { + dirty = true; + } + }}; } - }}; - } + ps!("경간 (m)", &mut p.span_m, 20.0..=80.0, 1.0); + ps!("거더 수", &mut p.girder_count, 3..=7, 1.0); + ps!("c/c 간격 (mm)", &mut p.girder_spacing, 1_500.0..=4_000.0, 100.0); + ps!("거더 높이 (mm)", &mut p.girder_height, 1_000.0..=3_000.0, 100.0); + ps!("슬래브 두께 (mm)",&mut p.slab_thickness, 150.0..=400.0, 10.0); - param_slider!("경간 (m)", &mut p.span_m, 20.0..=80.0, 1.0); - param_slider!("거더 수", &mut p.girder_count, 3..=7, 1.0); - param_slider!("c/c 간격 (mm)", &mut p.girder_spacing, 1_500.0..=4_000.0, 100.0); - param_slider!("거더 높이 (mm)",&mut p.girder_height, 1_000.0..=3_000.0, 100.0); - param_slider!("슬래브 두께(mm)",&mut p.slab_thickness, 150.0..=400.0, 10.0); - - ui.separator(); - ui.label("단면 형식"); - let prev_sec = p.section_type; - egui::ComboBox::from_id_salt("section_type") - .selected_text(match p.section_type { - GirderSectionType::PscI => "PSC I형", - GirderSectionType::SteelBox => "강재 박스", - }) - .show_ui(ui, |ui| { - ui.selectable_value(&mut p.section_type, GirderSectionType::PscI, "PSC I형"); - ui.selectable_value(&mut p.section_type, GirderSectionType::SteelBox, "강재 박스"); + ui.label("단면 형식"); + let prev_sec = p.section_type; + egui::ComboBox::from_id_salt("section_type") + .selected_text(match p.section_type { + GirderSectionType::PscI => "PSC I형", + GirderSectionType::SteelBox => "강재 박스", + }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut p.section_type, GirderSectionType::PscI, "PSC I형"); + ui.selectable_value(&mut p.section_type, GirderSectionType::SteelBox, "강재 박스"); + }); + if p.section_type != prev_sec { dirty = true; } }); - if p.section_type != prev_sec { dirty = true; } - ui.checkbox(&mut p.show_alignment, "선형 표시"); - if p.show_alignment != self.params.show_alignment { dirty = true; } + // ── Should Features (Sprint 19) ──────────────────────── + egui::CollapsingHeader::new("▼ 추가 부재 (Should Features)") + .default_open(true) + .show(ui, |ui| { + let prev_cb = p.show_cross_beams; + ui.checkbox(&mut p.show_cross_beams, "가로보 (Cross Beam)"); + if prev_cb != p.show_cross_beams { dirty = true; } + + if p.show_cross_beams { + ui.label(" 가로보 간격 (m)"); + if ui.add(egui::Slider::new(&mut p.cross_beam_interval_m, 3.0..=20.0).step_by(1.0)).changed() { + dirty = true; + } + } + + let prev_ej = p.show_expansion_joints; + ui.checkbox(&mut p.show_expansion_joints, "신축이음 (Exp. Joint)"); + if prev_ej != p.show_expansion_joints { dirty = true; } + }); + + // ── 표시 옵션 ───────────────────────────────────────── + egui::CollapsingHeader::new("▼ 표시 (Display)") + .default_open(false) + .show(ui, |ui| { + let prev_al = p.show_alignment; + ui.checkbox(&mut p.show_alignment, "선형 표시"); + if prev_al != p.show_alignment { dirty = true; } + }); ui.separator(); + // Apply button if dirty { - if ui.button("▶ 적용 (Apply)").clicked() { apply = true; } + let btn = egui::Button::new("▶ 적용 (Apply)") + .fill(egui::Color32::from_rgb(50, 100, 200)); + if ui.add(btn).clicked() { apply = true; } } else { - ui.label("✓ 최신 상태"); + ui.label(egui::RichText::new("✓ 최신 상태") + .color(egui::Color32::from_rgb(80, 200, 80))); } ui.separator(); - // Project save/load - ui.label("프로젝트"); - ui.horizontal(|ui| { - if ui.small_button("💾 저장").clicked() { - let pf = ProjectFile::from_params("project", &self.params); - let path = project_file::default_save_path("project"); - match pf.save(&path) { - Ok(_) => log::info!("Saved to {:?}", path), - Err(e) => log::error!("Save failed: {e}"), + // ── 선형 (Alignment, Sprint 17) ──────────────────────── + egui::CollapsingHeader::new("▼ 선형 (Alignment)") + .default_open(false) + .show(ui, |ui| { + let aname = state_alignment_name.as_deref().unwrap_or("없음"); + ui.label(format!("파일: {}", aname)); + if state_alignment_len > 0.0 { + ui.label(format!("길이: {:.0} m", state_alignment_len)); } - } - if ui.small_button("📂 불러오기").clicked() { - let path = project_file::default_save_path("project"); - if let Ok(pf) = ProjectFile::load(&path) { - p = pf.to_params(); - dirty = true; - apply = true; + if ui.button("📐 선형 불러오기").clicked() { + let p = std::path::Path::new("alignments/BR-001.json"); + alignment_load_path = Some(p.to_path_buf()); } - } - }); + }); ui.separator(); - // Selected feature info + // ── 프로젝트 저장/불러오기 ────────────────────────── + egui::CollapsingHeader::new("▼ 프로젝트") + .default_open(false) + .show(ui, |ui| { + ui.horizontal(|ui| { + if ui.button("💾 저장").clicked() { + let pf = ProjectFile::from_params("project", &self.params); + let path = project_file::default_save_path("project"); + match pf.save(&path) { + Ok(_) => log::info!("Saved to {:?}", path), + Err(e) => log::error!("Save failed: {e}"), + } + } + if ui.button("📂 불러오기").clicked() { + let path = project_file::default_save_path("project"); + if let Ok(pf) = ProjectFile::load(&path) { + p = pf.to_params(); + dirty = true; + apply = true; + } + } + }); + }); + + ui.separator(); + // ── 선택 피처 표시 ──────────────────────────────────── if let Some(idx) = p_features.iter().position(|f| f.selected) { - ui.colored_label(egui::Color32::from_rgb(255, 170, 50), - format!("▶ {}", p_features[idx].label)); + ui.colored_label( + egui::Color32::from_rgb(255, 200, 50), + format!("▶ 선택: {}", p_features[idx].label), + ); } else { - ui.small("(클릭으로 피처 선택)"); + ui.small("(좌클릭으로 피처 선택)"); } ui.separator(); - ui.label("카메라 단축키"); - ui.small("E: 전체뷰 7: 평면도"); - ui.small("1: 정면 3: 측면 Home: 아이소"); - ui.small("가운데버튼: 회전 Shift+가운데: 팬"); + // ── 카메라 단축키 ────────────────────────────────────── + egui::CollapsingHeader::new("▼ 단축키") + .default_open(false) + .show(ui, |ui| { + ui.small("E: 전체뷰 (ZoomExtents)"); + ui.small("7: 평면도 1: 정면 3: 측면"); + ui.small("Home: 아이소 뷰 4: 왼쪽"); + ui.small("가운데버튼: 회전"); + ui.small("Shift+가운데: 팬"); + ui.small("스크롤: 줌"); + ui.small("Esc: 종료"); + }); }); }); self.egui_state.handle_platform_output(&self.window, full_output.platform_output); self.params = p; self.dirty = dirty; + // Sprint 17: load alignment file if requested + if let Some(path) = alignment_load_path { + match alignment_scene::AlignmentScene::from_file(&path) { + Ok(as_) => { + log::info!("Alignment loaded: {} ({:.0} m)", as_.name(), as_.total_length_m()); + self.alignment_scene = as_; + self.dirty = true; + } + Err(e) => log::warn!("Alignment load failed: {e}"), + } + } if apply { self.rebuild_mesh(); } // ── 3D scene ───────────────────────────────────────────────────────── diff --git a/cimery/crates/viewer/src/project_file.rs b/cimery/crates/viewer/src/project_file.rs index e2b5481..f767773 100644 --- a/cimery/crates/viewer/src/project_file.rs +++ b/cimery/crates/viewer/src/project_file.rs @@ -13,47 +13,63 @@ struct SectionTypeStr(String); #[derive(Serialize, Deserialize)] pub struct ProjectFile { - pub version: u32, - pub name: String, - pub span_m: f64, - pub girder_count: usize, - pub girder_spacing: f32, - pub girder_height: f32, - pub slab_thickness: f32, - pub section_type: String, // "psc_i" | "steel_box" - pub show_alignment: bool, + pub version: u32, + pub name: String, + pub span_m: f64, + pub girder_count: usize, + pub girder_spacing: f32, + pub girder_height: f32, + pub slab_thickness: f32, + pub section_type: String, // "psc_i" | "steel_box" + pub show_alignment: bool, + /// Sprint 19 + #[serde(default = "default_true")] + pub show_cross_beams: bool, + #[serde(default = "default_cross_beam_interval")] + pub cross_beam_interval_m: f64, + #[serde(default = "default_true")] + pub show_expansion_joints: bool, } +fn default_true() -> bool { true } +fn default_cross_beam_interval() -> f64 { 5.0 } + impl ProjectFile { pub fn from_params(name: &str, p: &SceneParams) -> Self { Self { - version: 1, - name: name.to_owned(), - span_m: p.span_m, - girder_count: p.girder_count, - girder_spacing: p.girder_spacing, - girder_height: p.girder_height, - slab_thickness: p.slab_thickness, - section_type: match p.section_type { + version: 1, + name: name.to_owned(), + span_m: p.span_m, + girder_count: p.girder_count, + girder_spacing: p.girder_spacing, + girder_height: p.girder_height, + slab_thickness: p.slab_thickness, + section_type: match p.section_type { GirderSectionType::PscI => "psc_i".into(), GirderSectionType::SteelBox => "steel_box".into(), }, - show_alignment: p.show_alignment, + show_alignment: p.show_alignment, + show_cross_beams: p.show_cross_beams, + cross_beam_interval_m: p.cross_beam_interval_m, + show_expansion_joints: p.show_expansion_joints, } } pub fn to_params(&self) -> SceneParams { SceneParams { - span_m: self.span_m, - girder_count: self.girder_count, - girder_spacing: self.girder_spacing, - girder_height: self.girder_height, - slab_thickness: self.slab_thickness, - section_type: match self.section_type.as_str() { + span_m: self.span_m, + girder_count: self.girder_count, + girder_spacing: self.girder_spacing, + girder_height: self.girder_height, + slab_thickness: self.slab_thickness, + section_type: match self.section_type.as_str() { "steel_box" => GirderSectionType::SteelBox, _ => GirderSectionType::PscI, }, - show_alignment: self.show_alignment, + show_alignment: self.show_alignment, + show_cross_beams: self.show_cross_beams, + cross_beam_interval_m: self.cross_beam_interval_m, + show_expansion_joints: self.show_expansion_joints, } }