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

@@ -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());
}
}