품질 강화 — ADR-004 + IFC snapshot 테스트 + helper 유닛 + clippy 경고 정리

## ADR-004 (Output/reports/ADR-004-sprint-25-39-decisions.md)
Sprint 25~39 기간의 **15개 아키텍처 결정** 정리:
- D1~D9: 거더교 MVP 확장 (단면 분기·다경간·Skew 관례·방호벽·격벽·Camber·헌치·UI)
- D10~D13: IFC4X3 Add2 익스포터 4 결정 (크레이트 분리·형상 전략 3단계·GUID·Camber 근사)
- D14: proc-macro 스캐폴딩 (전면 #[param] 는 Feature 10+ 안정 후)
- D15: 변단면 거더 알고리즘 (소핏 lift + Y 선형보간)
- 미결 6항목 (Pset 확장·LinearPlacement·ElementAssembly·IfcPile·#[param] 전면·변단면 IFC)
- 테스트 커버리지 101개 현황표

## IFC 스냅샷 테스트 (crates/ifc/tests/snapshot_tests.rs)
insta 기반 회귀 방지, 8개 baseline:
- mask_guids(): 22자 IFC GUID 를 'GUID' 로 정규화 (결정적 비교 가능)
- 시나리오: 기본 단경간 PSC-I / 2경간 π형 / skew 15° / camber 50mm /
  Rectangle 단면 / parapets off
- mask_guids 자체 유닛 테스트 2개

## Mesh helper 유닛 테스트 (crates/viewer/src/bridge_scene.rs helper_tests)
순수 함수 9개 검증:
- apply_camber_mesh: zero 항등·midspan 도달값·경간 밖 미영향
- rotate_y_around_z: 0 회전 항등·90° 피봇 회전·정점 개수 보존
- apply_variable_depth: zero 항등·소핏 lift · 지점 0 lift

## clippy lib 경고 15+ → 0
- map_identity (kernel/expansion_joint.rs)
- unnecessary_lazy_evaluations ×4 (dsl/abutment·pier·csv_template — auto-fix)
- too_many_arguments (usd save_scene — allow with justification)
- clamp-like 패턴 ×7 (viewer bridge_scene/incremental_scene 의 .max(1).min(N) → .clamp(1, N))
- redundant_closure ×2 (project_file 의 `|e| Error::other(e)` → `Error::other`)
- redundant_guard ×1 (viewer KeyboardInput match guard → 패턴 내 직접 매치)

cargo clippy --workspace --lib: 0 경고.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
minsung
2026-04-16 08:37:11 +09:00
parent 0e4701de79
commit e32c09df2d
18 changed files with 3131 additions and 19 deletions

View File

@@ -228,10 +228,10 @@ fn merge(meshes: Vec<Mesh>) -> Mesh {
pub fn build_bridge_scene<K: GeomKernel>(kernel: &K, p: &SceneParams) -> Result<Mesh, KernelError> {
let span_m = p.span_m;
let span_mm = (p.span_m * 1_000.0) as f32;
let span_count = p.span_count.max(1).min(5);
let span_count = p.span_count.clamp(1, 5);
let total_m = span_m * span_count as f64;
let total_mm = span_mm * span_count as f32;
let n_girders = p.girder_count.max(1).min(10);
let n_girders = p.girder_count.clamp(1, 10);
let spacing = p.girder_spacing;
let girder_h = p.girder_height;
const BEARING_H: f32 = 60.0; // mm
@@ -536,10 +536,10 @@ pub fn build_bridge_scene<K: GeomKernel>(kernel: &K, p: &SceneParams) -> Result<
/// Sprint 26: 다경간 지원 — ground·alignment 가 전체 교량 길이 기준.
pub fn build_background_scene(p: &SceneParams) -> Mesh {
let span_mm = (p.span_m * 1_000.0) as f32;
let span_count = p.span_count.max(1).min(5);
let span_count = p.span_count.clamp(1, 5);
let total_mm = span_mm * span_count as f32;
let girder_h = p.girder_height;
let n_girders = p.girder_count.max(1).min(10);
let n_girders = p.girder_count.clamp(1, 10);
let spacing = p.girder_spacing;
const BEARING_H: f32 = 60.0;
let breast_wall_h = (girder_h + BEARING_H) as f64;
@@ -580,7 +580,7 @@ pub fn build_selectable_scene<K: GeomKernel>(
) -> Result<Vec<FeatureMesh>, KernelError> {
let span_m = p.span_m;
let span_mm = (p.span_m * 1_000.0) as f32;
let n_girders = p.girder_count.max(1).min(10);
let n_girders = p.girder_count.clamp(1, 10);
let spacing = p.girder_spacing;
let girder_h = p.girder_height;
const BEARING_H: f32 = 60.0;
@@ -616,7 +616,7 @@ pub fn build_selectable_scene<K: GeomKernel>(
}
};
let span_count = p.span_count.max(1).min(5);
let span_count = p.span_count.clamp(1, 5);
let total_m = span_m * span_count as f64;
let total_mm = span_mm * span_count as f32;
@@ -877,3 +877,139 @@ pub fn scene_extents(p: &SceneParams) -> ([f32; 3], [f32; 3]) {
let bot_y = -(p.girder_height + 3_000.0 + 1_000.0);
([-half_w, bot_y, -2_000.0], [half_w, top_y, total_mm + 2_000.0])
}
// ─── Mesh helper unit tests (Sprint 39 품질 강화) ─────────────────────────────
#[cfg(test)]
mod helper_tests {
use super::*;
fn unit_cube(z_offset: f32) -> Mesh {
// 1×1×1 박스, Y 는 [0, 1], Z 는 [z_offset, z_offset+1].
Mesh {
vertices: vec![
[0.0, 0.0, z_offset ],
[1.0, 0.0, z_offset ],
[1.0, 1.0, z_offset ],
[0.0, 1.0, z_offset ],
[0.0, 0.0, z_offset + 1.0 ],
[1.0, 0.0, z_offset + 1.0 ],
[1.0, 1.0, z_offset + 1.0 ],
[0.0, 1.0, z_offset + 1.0 ],
],
indices: vec![],
normals: vec![[0.0, 1.0, 0.0]; 8],
colors: vec![[0.5; 3]; 8],
}
}
// ── apply_camber_mesh ─────────────────────────────────────────────────────
#[test]
fn camber_zero_does_nothing() {
let mut m = unit_cube(0.0);
let orig = m.vertices.clone();
apply_camber_mesh(&mut m, 0.0, 1.0, 0.0);
assert_eq!(m.vertices, orig);
}
#[test]
fn camber_at_midspan_reaches_mid_mm() {
// span=10, mid_mm=100 → u=5 에서 lift = 4·100·5·5/100 = 100.
let mut m = Mesh {
vertices: vec![[0.0, 0.0, 5.0]],
indices: vec![],
normals: vec![[0.0, 1.0, 0.0]],
colors: vec![[0.0; 3]],
};
apply_camber_mesh(&mut m, 0.0, 10.0, 100.0);
assert!((m.vertices[0][1] - 100.0).abs() < 1e-4);
}
#[test]
fn camber_outside_span_unaffected() {
let mut m = Mesh {
vertices: vec![[0.0, 0.0, -1.0], [0.0, 0.0, 20.0]],
indices: vec![],
normals: vec![[0.0, 1.0, 0.0]; 2],
colors: vec![[0.0; 3]; 2],
};
apply_camber_mesh(&mut m, 0.0, 10.0, 100.0);
assert_eq!(m.vertices[0][1], 0.0);
assert_eq!(m.vertices[1][1], 0.0);
}
// ── rotate_y_around_z ─────────────────────────────────────────────────────
#[test]
fn rotate_zero_angle_is_identity() {
let m = unit_cube(5.0);
let orig_v = m.vertices.clone();
let rotated = rotate_y_around_z(m, 0.0, 5.0);
assert_eq!(rotated.vertices, orig_v);
}
#[test]
fn rotate_90_around_pivot_swaps_x_z() {
// π/2 회전: pivot 기준, (x=1, z=pivot) → (x=0, z=pivot-1).
let m = Mesh {
vertices: vec![[1.0, 0.0, 5.0]],
indices: vec![],
normals: vec![[1.0, 0.0, 0.0]],
colors: vec![[0.0; 3]],
};
let rotated = rotate_y_around_z(m, std::f32::consts::FRAC_PI_2, 5.0);
assert!(rotated.vertices[0][0].abs() < 1e-5, "x should go to 0, got {}", rotated.vertices[0][0]);
assert!((rotated.vertices[0][2] - 4.0).abs() < 1e-5, "z should go to 4, got {}", rotated.vertices[0][2]);
// normals 도 회전: (1,0,0) → (0,0,-1).
assert!((rotated.normals[0][2] + 1.0).abs() < 1e-5);
}
#[test]
fn rotate_preserves_vertex_count() {
let m = unit_cube(0.0);
let n = m.vertices.len();
let rotated = rotate_y_around_z(m, 0.5, 0.0);
assert_eq!(rotated.vertices.len(), n);
}
// ── apply_variable_depth ──────────────────────────────────────────────────
#[test]
fn variable_depth_zero_does_nothing() {
let mut m = unit_cube(0.0);
let orig = m.vertices.clone();
apply_variable_depth(&mut m, 0.0, 1.0, 0.0, 1.0);
assert_eq!(m.vertices, orig);
}
#[test]
fn variable_depth_lifts_soffit_more_than_top() {
// girder_h=1, max=0.2, span=10, midspan u=5 → lift = 4·0.2·5·5/100 = 0.2.
// y=0 vertex (소핏): +0.2 · (1 - 0/1) = +0.2 → Y=0.2.
// y=1 vertex (상면): +0.2 · (1 - 1/1) = 0 → Y=1 (변화 없음).
let mut m = Mesh {
vertices: vec![[0.0, 0.0, 5.0], [0.0, 1.0, 5.0]],
indices: vec![],
normals: vec![[0.0, 1.0, 0.0]; 2],
colors: vec![[0.0; 3]; 2],
};
apply_variable_depth(&mut m, 0.0, 10.0, 0.2, 1.0);
assert!((m.vertices[0][1] - 0.2).abs() < 1e-5, "soffit lift mismatch: {}", m.vertices[0][1]);
assert!((m.vertices[1][1] - 1.0).abs() < 1e-5, "top should stay: {}", m.vertices[1][1]);
}
#[test]
fn variable_depth_at_support_is_zero() {
// 지점(u=0 또는 u=span): lift=0, 변화 없음.
let mut m = Mesh {
vertices: vec![[0.0, 0.0, 0.0], [0.0, 0.0, 10.0]],
indices: vec![],
normals: vec![[0.0, 1.0, 0.0]; 2],
colors: vec![[0.0; 3]; 2],
};
apply_variable_depth(&mut m, 0.0, 10.0, 500.0, 1.0);
assert_eq!(m.vertices[0][1], 0.0);
assert_eq!(m.vertices[1][1], 0.0);
}
}

View File

@@ -35,7 +35,7 @@ impl<K: GeomKernel + Clone + 'static> IncrementalBridge<K> {
/// Build the full bridge scene, using IncrementalDb for girder caching.
pub fn build_scene(&mut self, params: &SceneParams) -> Result<Mesh, KernelError> {
let n = params.girder_count.max(1).min(MAX_GIRDERS);
let n = params.girder_count.clamp(1, MAX_GIRDERS);
let spacing = params.girder_spacing;
let span_m = params.span_m;
let h = params.girder_height as f64;

View File

@@ -24,7 +24,6 @@ use cimery_kernel::OcctKernel;
#[cfg(not(feature = "occt"))]
use cimery_kernel::PureRustKernel;
use camera::{Camera, Projection, StandardView};
use glam;
use bridge_scene::{
GirderSectionType, SceneParams,
build_bridge_scene, build_selectable_scene, build_background_scene, scene_extents,
@@ -547,7 +546,7 @@ impl RenderState {
let mut best: Option<(f32, usize)> = None;
for (i, feat) in self.features.iter().enumerate() {
if let Some(t) = ray_aabb(near, ray_dir, feat.aabb_min, feat.aabb_max) {
if best.map_or(true, |(bt, _)| t < bt) { best = Some((t, i)); }
if best.is_none_or(|(bt, _)| t < bt) { best = Some((t, i)); }
}
}
// Update selection + apply highlight
@@ -973,9 +972,7 @@ impl ApplicationHandler for CimeryApp {
}
// ── Keyboard shortcuts ────────────────────────────────────────────
WindowEvent::KeyboardInput { event: KeyEvent { physical_key, state: key_state, .. }, .. }
if key_state == ElementState::Pressed =>
{
WindowEvent::KeyboardInput { event: KeyEvent { physical_key, state: ElementState::Pressed, .. }, .. } => {
match physical_key {
PhysicalKey::Code(KeyCode::Escape) => event_loop.exit(),
// E → ZoomExtents (fit all)

View File

@@ -118,14 +118,14 @@ impl ProjectFile {
pub fn save(&self, path: &std::path::Path) -> std::io::Result<()> {
let json = serde_json::to_string_pretty(self)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
.map_err(std::io::Error::other)?;
std::fs::write(path, json)
}
pub fn load(path: &std::path::Path) -> std::io::Result<Self> {
let json = std::fs::read_to_string(path)?;
serde_json::from_str(&json)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
.map_err(std::io::Error::other)
}
}