Files
ParaWiki/cimery/crates/kernel/src/psc_i.rs
minsung 9cbe76cc5e 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>
2026-04-14 18:48:10 +09:00

214 lines
8.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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());
}
}