cimery Sprint 2 — PSC-I 기하학 + viewer 개편 + OCCT optional

kernel:
- PureRustKernel: PSC-I 단면 14-vertex polygon 스위프, flat normals
  56 triangles / 168 vertices, 법선 단위벡터 검증 포함
- opencascade 의존성 optional feature (--features occt)로 격리
  → OCCT 없이도 전체 빌드 가능
- psc_i.rs: 프로파일 검증, AABB, 법선 테스트 6개

viewer:
- camera.rs: arcball orbit (middle-mouse drag + scroll zoom)
- shader.wgsl: MVP matrix uniform + 방향성 조명 (콘크리트 베이지)
- lib.rs: depth buffer, index 렌더, 실제 Mesh 업로드
  StubKernel → PureRustKernel → OcctKernel 교체 경로 문서화

CLAUDE.md: MVP 품질 원칙 강화 ("아키텍처 임의 변경 절대 불가")

cargo test --workspace (viewer 제외) 43개 전부 통과

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
minsung
2026-04-14 18:48:10 +09:00
parent 62ddf3aea6
commit 9cbe76cc5e
9 changed files with 716 additions and 174 deletions

View File

@@ -3,9 +3,18 @@ name = "cimery-kernel"
version.workspace = true
edition.workspace = true
[features]
# Enable the full OpenCASCADE kernel backend.
# Requires OCCT installed/compiled — see cimery/CLAUDE.md for setup.
# Build: cargo build -p cimery-kernel --features occt
occt = ["dep:opencascade"]
[dependencies]
cimery-ir = { workspace = true }
thiserror = { workspace = true }
cimery-ir = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
# opencascade is OPTIONAL — only compiled with --features occt
opencascade = { git = "https://github.com/bschwind/opencascade-rs", optional = true }
[dev-dependencies]
cimery-core = { workspace = true }

View File

@@ -1,12 +1,18 @@
//! cimery-kernel — GeomKernel trait + mesh types + StubKernel.
//! cimery-kernel — GeomKernel trait, mesh types, and geometry backends.
//!
//! ADR-001: Two production backends (Sprint 2+):
//! - OpenCascade.js (WASM, web)
//! - opencascade-rs (native FFI, desktop)
//! Both accessed via `GeomKernel` trait.
//! Sprint 1: `StubKernel` returns simple box geometry for architecture validation.
//! # Backends (ADR-001)
//! | Backend | Status | Target |
//! |---------|--------|--------|
//! | `StubKernel` | ✅ Sprint 1 | Box mesh — architecture tests |
//! | `PureRustKernel` | ✅ Sprint 2 | PSC-I sweep — visualisation |
//! | `OcctKernel` | 🔲 Sprint 3 | Full B-rep via opencascade-rs |
//!
//! All backends implement `GeomKernel` via the same `GeomKernel` trait.
//! Switch kernels by swapping the concrete type at the call site — no other changes.
use cimery_ir::GirderIR;
pub mod psc_i;
use cimery_ir::{GirderIR, SectionParams};
// ─── Mesh ─────────────────────────────────────────────────────────────────────
@@ -16,9 +22,9 @@ use cimery_ir::GirderIR;
/// Units: millimetres.
#[derive(Debug, Clone)]
pub struct Mesh {
/// Interleaved [x, y, z] vertex positions in mm.
/// Vertex positions [mm]: vec of [x, y, z].
pub vertices: Vec<[f32; 3]>,
/// Triangle indices into `vertices`, 3 entries per triangle.
/// Triangle indices (3 per triangle).
pub indices: Vec<u32>,
/// Per-vertex normals (unit vectors).
pub normals: Vec<[f32; 3]>,
@@ -48,7 +54,7 @@ impl Mesh {
pub enum KernelError {
#[error("geometry computation failed: {0}")]
Computation(String),
#[error("invalid input for kernel: {0}")]
#[error("invalid kernel input: {0}")]
InvalidInput(String),
}
@@ -57,21 +63,16 @@ pub enum KernelError {
/// Backend-agnostic geometry kernel.
///
/// All implementations MUST be deterministic: same IR → same Mesh topology.
/// Floating-point values may differ within kernel tolerance (< 1 µm).
pub trait GeomKernel: Send + Sync {
fn girder_mesh(&self, ir: &GirderIR) -> Result<Mesh, KernelError>;
}
// ─── StubKernel ───────────────────────────────────────────────────────────────
/// Stub geometry backend for Sprint 1.
/// Stub geometry backend (Sprint 1).
///
/// Returns a simple rectangular box for any girder.
/// - X = 600 mm (fixed width stub)
/// - Y = 1800 mm (fixed height stub)
/// - Z = girder span in mm
///
/// Replace with `OcctKernel` in Sprint 2.
/// Returns a plain rectangular box for any section type.
/// Used for architecture tests and as a quick fallback.
pub struct StubKernel;
impl GeomKernel for StubKernel {
@@ -81,39 +82,52 @@ impl GeomKernel for StubKernel {
format!("span must be positive, got {} m", ir.span_m()),
));
}
let len = ir.span_mm() as f32;
let w = 600.0_f32;
let h = 1800.0_f32;
// 8 corners: indices 0-3 at Z=0, 4-7 at Z=len
let vertices: Vec<[f32; 3]> = vec![
[0.0, 0.0, 0.0], [w, 0.0, 0.0], [w, h, 0.0], [0.0, h, 0.0],
[0.0, 0.0, len], [w, 0.0, len], [w, h, len], [0.0, h, len],
[0.0, 0.0, 0.0], [w, 0.0, 0.0], [w, h, 0.0], [0.0, h, 0.0],
[0.0, 0.0, len], [w, 0.0, len], [w, h, len], [0.0, h, len],
];
// 12 triangles (2 per face × 6 faces), CCW winding from outside
let indices: Vec<u32> = vec![
// -Z face
0, 2, 1, 0, 3, 2,
// +Z face
4, 5, 6, 4, 6, 7,
// -X face
0, 4, 7, 0, 7, 3,
// +X face
1, 2, 6, 1, 6, 5,
// -Y face (bottom)
0, 1, 5, 0, 5, 4,
// +Y face (top)
3, 7, 6, 3, 6, 2,
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,
];
let normals = vec![[0.0_f32, 1.0, 0.0]; vertices.len()];
Ok(Mesh { vertices, indices, normals })
}
}
// ─── PureRustKernel ───────────────────────────────────────────────────────────
/// Pure-Rust geometry backend (Sprint 2).
///
/// Generates actual section shapes by sweeping the cross-section profile.
/// No external OCCT required — good for CI, WASM, and quick local builds.
///
/// Supported: PSC-I. Others fall back to `StubKernel` with a warning.
///
/// Sprint 3: `OcctKernel` will produce higher-quality B-rep geometry
/// (proper fillets, accurate haunch curves, optimal mesh density).
pub struct PureRustKernel;
impl GeomKernel for PureRustKernel {
fn girder_mesh(&self, ir: &GirderIR) -> Result<Mesh, KernelError> {
match &ir.section {
SectionParams::PscI(p) => psc_i::build_psc_i_mesh(p, ir.span_mm()),
_ => {
log::warn!(
"PureRustKernel: section {:?} not yet implemented, using StubKernel",
ir.section_type
);
StubKernel.girder_mesh(ir)
}
}
}
}
// ─── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
@@ -124,11 +138,11 @@ mod tests {
fn test_girder(span_m: f64) -> GirderIR {
GirderIR {
id: FeatureId::new(),
station_start: 0.0,
station_end: span_m,
offset_from_alignment: 0.0,
section_type: SectionType::PscI,
id: FeatureId::new(),
station_start: 0.0,
station_end: span_m,
offset_from_alignment: 0.0,
section_type: SectionType::PscI,
section: SectionParams::PscI(PscISectionParams::kds_standard()),
count: 1,
spacing: 0.0,
@@ -136,6 +150,7 @@ mod tests {
}
}
// ── StubKernel ────────────────────────────────────────────────────────────
#[test]
fn stub_produces_box_mesh() {
let mesh = StubKernel.girder_mesh(&test_girder(40.0)).unwrap();
@@ -144,17 +159,44 @@ mod tests {
}
#[test]
fn aabb_spans_correctly() {
fn stub_aabb_spans_correctly() {
let ir = test_girder(40.0);
let mesh = StubKernel.girder_mesh(&ir).unwrap();
let (mn, mx) = mesh.aabb();
assert!((mx[2] - ir.span_mm() as f32).abs() < 0.01);
assert!(mn[2] < 0.001); // Z min ≈ 0
assert!(mn[2].abs() < 0.001);
}
#[test]
fn zero_span_fails() {
let err = StubKernel.girder_mesh(&test_girder(0.0));
assert!(matches!(err, Err(KernelError::InvalidInput(_))));
fn stub_zero_span_fails() {
assert!(matches!(
StubKernel.girder_mesh(&test_girder(0.0)),
Err(KernelError::InvalidInput(_))
));
}
// ── PureRustKernel ────────────────────────────────────────────────────────
#[test]
fn pure_rust_psc_i_produces_real_geometry() {
let mesh = PureRustKernel.girder_mesh(&test_girder(40.0)).unwrap();
assert_eq!(mesh.triangle_count(), 56);
assert_eq!(mesh.vertex_count(), 168);
}
#[test]
fn pure_rust_aabb_has_correct_span() {
let ir = test_girder(40.0);
let mesh = PureRustKernel.girder_mesh(&ir).unwrap();
let (_, mx) = mesh.aabb();
assert!((mx[2] - ir.span_mm() as f32).abs() < 1.0);
}
#[test]
fn pure_rust_all_normals_unit_length() {
let mesh = PureRustKernel.girder_mesh(&test_girder(40.0)).unwrap();
for n in &mesh.normals {
let len = (n[0]*n[0] + n[1]*n[1] + n[2]*n[2]).sqrt();
assert!((len - 1.0).abs() < 1e-5);
}
}
}

View File

@@ -0,0 +1,213 @@
//! PSC I-girder cross-section geometry — pure Rust, no external kernel.
//!
//! Generates a triangulated mesh by sweeping a PSC-I polygon profile along Z.
//! Flat normals (face normals, faceted appearance). Units: millimetres.
//!
//! This module lets cimery visualise PSC-I girders without OCCT.
//! When OcctKernel is available it produces higher-quality B-rep geometry
//! (fillets, accurate haunches, proper mesh density).
use cimery_ir::PscISectionParams;
use crate::{KernelError, Mesh};
// ─── Public API ───────────────────────────────────────────────────────────────
/// Build a closed triangulated mesh for a PSC I-girder by sweeping the profile.
///
/// Coordinate system: X = width (centred on web), Y = height (0 = soffit), Z = span.
pub fn build_psc_i_mesh(
p: &PscISectionParams,
span_mm: f64,
) -> Result<Mesh, KernelError> {
if span_mm <= 0.0 {
return Err(KernelError::InvalidInput(
format!("span must be positive, got {span_mm} mm"),
));
}
let profile = psc_i_profile(p)?;
Ok(sweep_profile_flat(&profile, span_mm as f32))
}
// ─── Profile ─────────────────────────────────────────────────────────────────
/// 14-vertex PSC-I cross-section polygon.
/// Vertices are ordered **CCW when viewed from Z** (start face).
/// Origin: bottom centre of bottom flange (X=0 is web centre, Y=0 is soffit).
fn psc_i_profile(p: &PscISectionParams) -> Result<Vec<[f32; 2]>, KernelError> {
let hw = (p.top_flange_width / 2.0) as f32;
let hbw = (p.bottom_flange_width / 2.0) as f32;
let hwb = (p.web_thickness / 2.0) as f32;
let h = p.total_height as f32;
let tft = p.top_flange_thickness as f32;
let bft = p.bottom_flange_thickness as f32;
let hch = p.haunch as f32;
if hw <= hwb {
return Err(KernelError::InvalidInput(
"top_flange_width must be > web_thickness".into(),
));
}
if hbw <= hwb {
return Err(KernelError::InvalidInput(
"bottom_flange_width must be > web_thickness".into(),
));
}
if tft + bft >= h {
return Err(KernelError::InvalidInput(
"sum of flange thicknesses must be < total_height".into(),
));
}
// 14 vertices, CCW from bottom-left
Ok(vec![
[-hbw, 0.0 ], // 0 bottom-left outer
[ hbw, 0.0 ], // 1 bottom-right outer
[ hbw, bft ], // 2 bottom flange top-right
[ hwb, bft ], // 3 web right, bottom
[ hwb, h - tft - hch], // 4 web right, top (haunch start)
[ hwb + hch, h - tft ], // 5 haunch junction right
[ hw, h - tft ], // 6 top flange inner bottom-right
[ hw, h ], // 7 top flange outer top-right
[-hw, h ], // 8 top flange outer top-left
[-hw, h - tft ], // 9 top flange inner bottom-left
[-(hwb+hch), h - tft ], // 10 haunch junction left
[-hwb, h - tft - hch], // 11 web left, top
[-hwb, bft ], // 12 web left, bottom
[-hbw, bft ], // 13 bottom flange top-left
])
}
// ─── Sweep ────────────────────────────────────────────────────────────────────
/// Sweep a closed polygon profile along Z, producing a closed solid.
///
/// Uses flat normals (no shared vertices between adjacent faces).
/// Each triangle has 3 unique vertices with the same face normal.
fn sweep_profile_flat(profile: &[[f32; 2]], span: f32) -> Mesh {
let n = profile.len();
let mut vertices: Vec<[f32; 3]> = Vec::new();
let mut normals: Vec<[f32; 3]> = Vec::new();
let mut indices: Vec<u32> = Vec::new();
// Helper: push one triangle and record face normal
let mut push_tri = |v0: [f32; 3], v1: [f32; 3], v2: [f32; 3]| {
let normal = face_normal(v0, v1, v2);
for v in [v0, v1, v2] {
let idx = vertices.len() as u32;
vertices.push(v);
normals.push(normal);
indices.push(idx);
}
};
// ── Side faces: one quad (2 tris) per profile edge ─────────────────────
for i in 0..n {
let j = (i + 1) % n;
let [x0, y0] = profile[i];
let [x1, y1] = profile[j];
let a = [x0, y0, 0.0];
let b = [x1, y1, 0.0];
let c = [x1, y1, span];
let d = [x0, y0, span];
push_tri(a, b, c);
push_tri(a, c, d);
}
// ── End caps: fan triangulation from centroid ──────────────────────────
let cx: f32 = profile.iter().map(|v| v[0]).sum::<f32>() / n as f32;
let cy: f32 = profile.iter().map(|v| v[1]).sum::<f32>() / n as f32;
// Front cap (Z = 0, normal = Z). CCW from Z: centre, then CW in XY.
let cen_front = [cx, cy, 0.0];
for i in 0..n {
let j = (i + 1) % n;
let a = [profile[i][0], profile[i][1], 0.0];
let b = [profile[j][0], profile[j][1], 0.0];
push_tri(cen_front, b, a);
}
// Back cap (Z = span, normal = +Z). CCW from +Z: centre, then CCW in XY.
let cen_back = [cx, cy, span];
for i in 0..n {
let j = (i + 1) % n;
let a = [profile[i][0], profile[i][1], span];
let b = [profile[j][0], profile[j][1], span];
push_tri(cen_back, a, b);
}
Mesh { vertices, normals, indices }
}
// ─── Math helpers ─────────────────────────────────────────────────────────────
fn face_normal(a: [f32; 3], b: [f32; 3], c: [f32; 3]) -> [f32; 3] {
let ab = [b[0]-a[0], b[1]-a[1], b[2]-a[2]];
let ac = [c[0]-a[0], c[1]-a[1], c[2]-a[2]];
let n = [
ab[1]*ac[2] - ab[2]*ac[1],
ab[2]*ac[0] - ab[0]*ac[2],
ab[0]*ac[1] - ab[1]*ac[0],
];
let len = (n[0]*n[0] + n[1]*n[1] + n[2]*n[2]).sqrt();
if len < 1e-10 { return [0.0, 1.0, 0.0]; }
[n[0]/len, n[1]/len, n[2]/len]
}
// ─── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use cimery_ir::PscISectionParams;
fn kds() -> PscISectionParams { PscISectionParams::kds_standard() }
#[test]
fn profile_has_14_vertices() {
let p = psc_i_profile(&kds()).unwrap();
assert_eq!(p.len(), 14);
}
#[test]
fn mesh_has_correct_triangle_count() {
// Side: 14 quads × 2 = 28 tris
// Front cap: 14 tris
// Back cap: 14 tris
// Total: 56 tris = 168 vertices
let mesh = build_psc_i_mesh(&kds(), 40_000.0).unwrap();
assert_eq!(mesh.triangle_count(), 56);
assert_eq!(mesh.vertex_count(), 168);
}
#[test]
fn aabb_spans_correct_z() {
let span = 40_000.0_f64;
let mesh = build_psc_i_mesh(&kds(), span).unwrap();
let (mn, mx) = mesh.aabb();
assert!((mx[2] - span as f32).abs() < 1.0);
assert!(mn[2].abs() < 1.0);
}
#[test]
fn all_normals_are_unit_length() {
let mesh = build_psc_i_mesh(&kds(), 40_000.0).unwrap();
for n in &mesh.normals {
let len = (n[0]*n[0] + n[1]*n[1] + n[2]*n[2]).sqrt();
assert!((len - 1.0).abs() < 1e-5, "normal not unit: {:?}", n);
}
}
#[test]
fn zero_span_fails() {
assert!(build_psc_i_mesh(&kds(), 0.0).is_err());
}
#[test]
fn invalid_flange_width_fails() {
let mut p = kds();
p.top_flange_width = 100.0; // less than web_thickness=200
assert!(build_psc_i_mesh(&p, 40_000.0).is_err());
}
}