diff --git a/PROGRESS.md b/PROGRESS.md index 3e75ac0..bb027fc 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -12,6 +12,11 @@ ## 타임라인 ### 2026-04-15 (계속) +- code — Sprint 36~39: IFC Alignment + Camber + proc-macro 스캐폴딩 + 변단면 거더. + - Sprint 36 (IFC Phase 3b): IfcAlignment + 직선 horizontal/constantgradient vertical segment + IfcRelNests 계층. `write_straight_alignment()` helper. Site 가 Bridge·Alignment 동시 aggregate. + - Sprint 37 (IFC Camber): `BridgeExportParams.camber_mid_mm` 추가. camber > 0 일 때 거더를 `CAMBER_SEGMENTS`(=10)개 세그먼트로 분할, 각 세그먼트 Y 오프셋에 포물선 값 적용. Pset 는 첫 세그먼트에만 부착(전체 거더 대표). viewer scene_params_to_ifc 에 매핑. + - Sprint 38 (proc-macro 스캐폴딩): `cimery-macros` 크레이트 신설 (proc-macro=true, syn/quote/proc-macro2). `#[derive(ParamSummary)]` 구현 — struct named field 개수·이름 compile-time 상수 생성. 테스트 3개 (count/names/empty). ADR-002 D `#[param(unit,range,default)]` 전면 구현은 후속 스프린트. + - Sprint 39 (변단면 거더): `SceneParams.variable_depth_mm`(0~800mm) 추가. `apply_variable_depth()` — 경간 [z0,z1]에서 포물선 soffit lift 를 정점 Y 에 선형 보간 적용(`y_new = y + lift(u)·(1 - y/h)`). 거더 상면은 고정, 소핏이 중앙부에서 올라가 web 축소 → 연속교 관례 형상. camber 와 독립 조합 가능. - code — Sprint 35: IFC 뷰어 통합 + Pset_BeamCommon. - `cimery-viewer` 에 `cimery-ifc` 의존성 추가. `project_file::scene_params_to_ifc()` 변환 함수 (SceneParams → BridgeExportParams 전 필드 매핑). - 프로젝트 섹션에 "📤 IFC4X3 익스포트" 버튼. `projects/bridge.ifc` 로 저장, 현재 파라미터(경간 수·교각 형식·skew·헌치·단면 등) 그대로 반영. diff --git a/cimery/Cargo.toml b/cimery/Cargo.toml index fcb819e..67eed16 100644 --- a/cimery/Cargo.toml +++ b/cimery/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/viewer", "crates/usd", "crates/ifc", + "crates/macros", "crates/app", ] resolver = "2" @@ -30,8 +31,14 @@ cimery-incremental = { path = "crates/incremental" } cimery-evaluator = { path = "crates/evaluator" } cimery-usd = { path = "crates/usd" } cimery-ifc = { path = "crates/ifc" } +cimery-macros = { path = "crates/macros" } cimery-app = { path = "crates/app" } +# proc-macro support (Sprint 38) +syn = { version = "2", features = ["full", "extra-traits"] } +quote = "1" +proc-macro2 = "1" + # Serialization serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/cimery/crates/ifc/src/bridge_export.rs b/cimery/crates/ifc/src/bridge_export.rs index 75c2587..018603c 100644 --- a/cimery/crates/ifc/src/bridge_export.rs +++ b/cimery/crates/ifc/src/bridge_export.rs @@ -50,8 +50,15 @@ pub struct BridgeExportParams { pub haunch_depth: f64, // mm pub show_parapets: bool, pub show_joints: bool, + /// Sprint 37: 거더 중앙 camber 솟음량 [mm]. 0 = 직선. + /// > 0 일 때는 beam 을 `CAMBER_SEGMENTS` 개로 분할해 포물선 근사. + pub camber_mid_mm: f64, } +/// Sprint 37: camber > 0 일 때 각 거더를 N 개 세그먼트로 분할. +/// 각 세그먼트는 독립 IfcBeam(extrude) 로 Y 오프셋 차등 적용. +const CAMBER_SEGMENTS: usize = 10; + impl Default for BridgeExportParams { fn default() -> Self { Self { @@ -68,6 +75,7 @@ impl Default for BridgeExportParams { haunch_depth: 0.0, show_parapets: true, show_joints: true, + camber_mid_mm: 0.0, } } } @@ -115,18 +123,28 @@ pub fn export_bridge(p: &BridgeExportParams) -> String { ), ); - // Project aggregates Site, Site aggregates Bridge + // Project aggregates Site, Site aggregates Bridge + Alignment w.write(&format!( "IFCRELAGGREGATES({},$,$,$,{},({}))", lit(&new_ifc_guid()), project, site, )); + + // Sprint 36: IfcAlignment — 교량 선형(직선 horizontal + 평지 vertical). + // Phase 3b 단순화: 교량 시점(0,0,0) 에서 total_mm 만큼 직선, 경사 0. + let alignment = write_straight_alignment( + &mut w, world_placement, geom_ctx, + p.span_m * p.span_count as f64 * 1_000.0, + ); + + // Site 가 Bridge 와 Alignment 를 동시에 집계. w.write(&format!( - "IFCRELAGGREGATES({},$,$,$,{},({}))", + "IFCRELAGGREGATES({},$,$,$,{},({},{}))", lit(&new_ifc_guid()), site, bridge, + alignment, )); // ── Bridge elements ─────────────────────────────────────────────────── @@ -137,37 +155,60 @@ pub fn export_bridge(p: &BridgeExportParams) -> String { let total_mm = span_mm * span_count as f64; let skew_rad = p.skew_deg.to_radians(); + let use_camber = p.camber_mid_mm.abs() > 0.1; - // Girders (span_count × girder_count) — Phase 2: PSC-I 실제 단면. - // 거더는 직선 유지(precast 관례) → skew 미적용. + // Girders (span_count × girder_count) — 거더 직선 유지(precast 관례). + // Sprint 37: camber 있으면 CAMBER_SEGMENTS 분할 근사. for s in 0..span_count { let z0 = span_mm * s as f64; for i in 0..p.girder_count { let x = (i as f64 - (p.girder_count as f64 - 1.0) * 0.5) * p.girder_spacing; - let placement = write_local_placement( - &mut w, - world_placement, - x, - p.bearing_height + p.girder_height * 0.5, - z0 + span_mm * 0.5, - ); - let profile = write_girder_profile(&mut w, p.section_kind, p.girder_height); - let shape = write_extrude_shape(&mut w, geom_ctx, profile, span_mm); - let beam = w.alloc(); + let beam_center_y = p.bearing_height + p.girder_height * 0.5; let girder_label = format!("Girder S{}-G{}", s + 1, i + 1); - w.emit( - beam, - &format!( + + if !use_camber { + // 직선 — 단일 extrude. + let placement = write_local_placement( + &mut w, world_placement, + x, beam_center_y, z0 + span_mm * 0.5, + ); + let profile = write_girder_profile(&mut w, p.section_kind, p.girder_height); + let shape = write_extrude_shape(&mut w, geom_ctx, profile, span_mm); + let beam = w.alloc(); + w.emit(beam, &format!( "IFCBEAM({},$,{},$,$,{},{},$,.BEAM.)", - lit(&new_ifc_guid()), - lit(&girder_label), - placement, - shape, - ), - ); - // Pset_BeamCommon (Sprint 35). - write_pset_beam_common(&mut w, beam, &girder_label, span_mm); - elements.push(beam); + lit(&new_ifc_guid()), lit(&girder_label), placement, shape, + )); + write_pset_beam_common(&mut w, beam, &girder_label, span_mm); + elements.push(beam); + } else { + // Camber — N 세그먼트로 포물선 Y 오프셋 근사. + let seg_len = span_mm / CAMBER_SEGMENTS as f64; + for k in 0..CAMBER_SEGMENTS { + let u_mid = (k as f64 + 0.5) * seg_len; // 세그먼트 중심 u + let y_off = 4.0 * p.camber_mid_mm * u_mid * (span_mm - u_mid) + / (span_mm * span_mm); + let placement = write_local_placement( + &mut w, world_placement, + x, beam_center_y + y_off, + z0 + (k as f64 + 0.5) * seg_len, + ); + let profile = write_girder_profile(&mut w, p.section_kind, p.girder_height); + let shape = write_extrude_shape(&mut w, geom_ctx, profile, seg_len); + let beam = w.alloc(); + w.emit(beam, &format!( + "IFCBEAM({},$,{},$,$,{},{},$,.BEAM.)", + lit(&new_ifc_guid()), + lit(&format!("{}-seg{}", girder_label, k + 1)), + placement, shape, + )); + // Pset 는 첫 세그먼트에만 (전체 거더 속성으로 처리). + if k == 0 { + write_pset_beam_common(&mut w, beam, &girder_label, span_mm); + } + elements.push(beam); + } + } } } @@ -441,6 +482,96 @@ fn write_girder_profile( } } +/// Sprint 36 Phase 3b: 직선 수평·평지 수직 IfcAlignment 생성. +/// +/// # 구조 (IFC4X3) +/// ```text +/// IfcAlignment +/// └ IfcRelNests +/// ├ IfcAlignmentHorizontal +/// │ └ IfcRelNests → IfcAlignmentSegment(LINE) +/// └ IfcAlignmentVertical +/// └ IfcRelNests → IfcAlignmentSegment(CONSTANTGRADIENT) +/// ``` +/// +/// Phase 3b 단순화: 직선·평지 고정. curve/grade 는 SceneParams 확장 후 Phase 3c. +fn write_straight_alignment( + w: &mut IfcWriter, + world_placement: Ref, + _geom_ctx: Ref, + total_mm: f64, +) -> Ref { + // ── Horizontal alignment segment (LINE) ─────────────────────────────── + let start_pt = w.write(&format!("IFCCARTESIANPOINT(({},{}))", real(0.0), real(0.0))); + let h_seg_def = w.write(&format!( + "IFCALIGNMENTHORIZONTALSEGMENT($,$,{},{},$,{},{},$,.LINE.)", + start_pt, // StartPoint (2D) + real(0.0), // StartDirection [rad] + real(total_mm), // SegmentLength + real(0.0), // StartRadiusOfCurvature (0 = line) + )); + let h_seg = w.write(&format!( + "IFCALIGNMENTSEGMENT({},$,$,$,$,{},$,{})", + lit(&new_ifc_guid()), + world_placement, + h_seg_def, + )); + let horizontal = w.write(&format!( + "IFCALIGNMENTHORIZONTAL({},$,'Horizontal',$,$,{},$)", + lit(&new_ifc_guid()), + world_placement, + )); + w.write(&format!( + "IFCRELNESTS({},$,$,$,{},({}))", + lit(&new_ifc_guid()), + horizontal, + h_seg, + )); + + // ── Vertical alignment segment (constant gradient = 0) ──────────────── + let v_seg_def = w.write(&format!( + "IFCALIGNMENTVERTICALSEGMENT($,$,{},{},{},{},{},$,.CONSTANTGRADIENT.)", + real(0.0), // StartDistAlong + real(total_mm), // HorizontalLength + real(0.0), // StartHeight + real(0.0), // StartGradient + real(0.0), // EndGradient + )); + let v_seg = w.write(&format!( + "IFCALIGNMENTSEGMENT({},$,$,$,$,{},$,{})", + lit(&new_ifc_guid()), + world_placement, + v_seg_def, + )); + let vertical = w.write(&format!( + "IFCALIGNMENTVERTICAL({},$,'Vertical',$,$,{},$)", + lit(&new_ifc_guid()), + world_placement, + )); + w.write(&format!( + "IFCRELNESTS({},$,$,$,{},({}))", + lit(&new_ifc_guid()), + vertical, + v_seg, + )); + + // ── Alignment aggregate ─────────────────────────────────────────────── + let alignment = w.write(&format!( + "IFCALIGNMENT({},$,'Bridge Alignment',$,$,{},$,.USERDEFINED.,$)", + lit(&new_ifc_guid()), + world_placement, + )); + w.write(&format!( + "IFCRELNESTS({},$,$,$,{},({},{}))", + lit(&new_ifc_guid()), + alignment, + horizontal, + vertical, + )); + + alignment +} + /// Pset_BeamCommon 생성 + `IfcRelDefinesByProperties` 로 beam 에 연결 (Sprint 35 Phase 3a). /// /// # 속성 @@ -611,6 +742,45 @@ mod tests { assert_ne!(zero, skewed); } + #[test] + fn camber_zero_produces_single_beam_per_girder() { + let p = BridgeExportParams { + camber_mid_mm: 0.0, + girder_count: 1, + span_count: 1, + ..Default::default() + }; + let ifc = export_bridge(&p); + // 거더 1개 + 경간 1 → IFCBEAM 1 개. + assert_eq!(ifc.matches("IFCBEAM(").count(), 1); + } + + #[test] + fn camber_positive_subdivides_beam() { + let p = BridgeExportParams { + camber_mid_mm: 50.0, + girder_count: 1, + span_count: 1, + ..Default::default() + }; + let ifc = export_bridge(&p); + // 10 세그먼트 → IFCBEAM 10 개. + assert_eq!(ifc.matches("IFCBEAM(").count(), CAMBER_SEGMENTS); + // Pset 는 첫 세그먼트 1개만. + assert_eq!(ifc.matches("'Pset_BeamCommon'").count(), 1); + } + + #[test] + fn alignment_present_in_output() { + let ifc = export_bridge(&BridgeExportParams::default()); + assert!(ifc.contains("IFCALIGNMENT("), "IfcAlignment missing"); + assert!(ifc.contains("IFCALIGNMENTHORIZONTAL("), "horizontal missing"); + assert!(ifc.contains("IFCALIGNMENTVERTICAL("), "vertical missing"); + assert!(ifc.contains("IFCALIGNMENTSEGMENT("), "segment missing"); + assert!(ifc.contains(".LINE."), "horizontal LINE predefined type missing"); + assert!(ifc.contains(".CONSTANTGRADIENT."), "vertical CONSTANTGRADIENT missing"); + } + #[test] fn pset_beam_common_attached_to_girders() { let ifc = export_bridge(&BridgeExportParams::default()); diff --git a/cimery/crates/macros/Cargo.toml b/cimery/crates/macros/Cargo.toml new file mode 100644 index 0000000..ed7b232 --- /dev/null +++ b/cimery/crates/macros/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "cimery-macros" +version.workspace = true +edition.workspace = true +description = "proc-macro helpers for cimery DSL (Sprint 38 scaffolding)." + +[lib] +proc-macro = true + +[dependencies] +syn = { workspace = true } +quote = { workspace = true } +proc-macro2 = { workspace = true } diff --git a/cimery/crates/macros/src/lib.rs b/cimery/crates/macros/src/lib.rs new file mode 100644 index 0000000..7c6e866 --- /dev/null +++ b/cimery/crates/macros/src/lib.rs @@ -0,0 +1,82 @@ +//! cimery-macros — Feature DSL proc-macro 인프라 (Sprint 38). +//! +//! # 현재 범위 +//! - `#[derive(ParamSummary)]` — 구조체의 필드 이름·개수·타입을 컴파일 타임에 +//! 요약 제공. UI 자동생성·파라미터 인스펙터의 기반. +//! +//! # ADR-002 D 로드맵 (미래) +//! - `#[param(unit="mm", range=1000..=3000, default=1800)]` 속성 attribute +//! - `#[derive(Feature)]` — validation / builder / IFC 매핑 자동 생성 +//! - 선언적 `feature!` 매크로 — DSL 설탕 +//! +//! # 현 시점 사용 예 +//! ```ignore +//! use cimery_macros::ParamSummary; +//! +//! #[derive(ParamSummary)] +//! pub struct GirderParams { +//! pub total_height: f64, +//! pub top_flange_width: f64, +//! } +//! +//! fn main() { +//! assert_eq!(GirderParams::PARAM_COUNT, 2); +//! assert_eq!(GirderParams::PARAM_NAMES, &["total_height", "top_flange_width"]); +//! } +//! ``` + +use proc_macro::TokenStream; +use quote::quote; +use syn::{parse_macro_input, Data, DeriveInput, Fields}; + +/// `#[derive(ParamSummary)]` — named field 구조체의 필드 이름·개수 상수 생성. +/// +/// - `Self::PARAM_COUNT: usize` +/// - `Self::PARAM_NAMES: &'static [&'static str]` +/// +/// # 제약 +/// - named field 구조체만 지원 (tuple/unit/enum 은 컴파일 에러). +/// - 모든 필드가 노출됨 (private 필드 포함). 필터링은 Phase 2. +#[proc_macro_derive(ParamSummary)] +pub fn derive_param_summary(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + let name = &input.ident; + + let fields = match &input.data { + Data::Struct(s) => match &s.fields { + Fields::Named(named) => &named.named, + _ => { + return syn::Error::new_spanned( + name, + "ParamSummary only supports structs with named fields", + ) + .to_compile_error() + .into(); + } + }, + _ => { + return syn::Error::new_spanned( + name, + "ParamSummary only supports structs, not enums or unions", + ) + .to_compile_error() + .into(); + } + }; + + let names: Vec = fields + .iter() + .filter_map(|f| f.ident.as_ref().map(|i| i.to_string())) + .collect(); + let count = names.len(); + + let expanded = quote! { + impl #name { + /// `#[derive(ParamSummary)]` 로 생성된 필드 개수. + pub const PARAM_COUNT: usize = #count; + /// `#[derive(ParamSummary)]` 로 생성된 필드 이름 목록 (선언 순서). + pub const PARAM_NAMES: &'static [&'static str] = &[ #( #names ),* ]; + } + }; + expanded.into() +} diff --git a/cimery/crates/macros/tests/derive_test.rs b/cimery/crates/macros/tests/derive_test.rs new file mode 100644 index 0000000..d3c13d7 --- /dev/null +++ b/cimery/crates/macros/tests/derive_test.rs @@ -0,0 +1,34 @@ +//! `#[derive(ParamSummary)]` 기본 동작 검증. + +use cimery_macros::ParamSummary; + +#[derive(ParamSummary)] +#[allow(dead_code)] +struct GirderParams { + pub total_height: f64, + pub top_flange_width: f64, + pub web_thickness: f64, +} + +#[test] +fn param_count_matches_field_count() { + assert_eq!(GirderParams::PARAM_COUNT, 3); +} + +#[test] +fn param_names_in_declaration_order() { + assert_eq!( + GirderParams::PARAM_NAMES, + &["total_height", "top_flange_width", "web_thickness"], + ); +} + +#[derive(ParamSummary)] +#[allow(dead_code)] +struct Empty {} + +#[test] +fn empty_struct_handled() { + assert_eq!(Empty::PARAM_COUNT, 0); + assert_eq!(Empty::PARAM_NAMES, &[] as &[&str]); +} diff --git a/cimery/crates/viewer/src/bridge_scene.rs b/cimery/crates/viewer/src/bridge_scene.rs index e2fe1e5..da4f9c5 100644 --- a/cimery/crates/viewer/src/bridge_scene.rs +++ b/cimery/crates/viewer/src/bridge_scene.rs @@ -65,6 +65,10 @@ pub struct SceneParams { /// 데크 헌치 (Haunch) 깊이 [mm]. Sprint 31. /// 거더 상부와 데크 soffit 사이 전환부 두께. 0 = 헌치 없음(데크 = 거더 상부 직접 접촉). pub haunch_depth: f32, + /// 변단면 거더 (Variable Depth, Sprint 39). + /// 연속교의 경우 지점부가 높고 경간 중앙이 낮은 포물선 변화. + /// 값 = 지점 대비 중앙부 단면 높이 **감소량** [mm]. 0 = 상수 단면. + pub variable_depth_mm: f32, } impl Default for SceneParams { @@ -86,6 +90,7 @@ impl Default for SceneParams { show_diaphragms: true, camber_mid_mm: 0.0, haunch_depth: 0.0, + variable_depth_mm: 0.0, } } } @@ -154,6 +159,27 @@ fn translate(mut mesh: Mesh, dx: f32, dy: f32, dz: f32) -> Mesh { mesh } +/// Sprint 39: 변단면 거더 — 지점부 대비 중앙 단면 높이 감소 (연속교 관례). +/// 경간 [z0, z1] 에서 soffit 을 포물선으로 들어올림: +/// u = z - z0 ∈ [0, span] +/// soffit_lift(u) = 4·max·u·(span-u) / span² (u=span/2 에서 최대) +/// 각 정점 Y 를 선형 보간: +/// y_new = y + lift(u) · (1 - y/h) +/// → y=0(소핏) 에서 lift 만큼 들어올리고, y=h(상면) 에서 0. +fn apply_variable_depth(mesh: &mut Mesh, z0: f32, z1: f32, max_mm: f32, girder_h: f32) { + if max_mm.abs() < 1e-3 || girder_h <= 0.0 { return; } + let span = z1 - z0; + if span <= 0.0 { return; } + for v in &mut mesh.vertices { + let u = v[2] - z0; + if u > 0.0 && u < span { + let lift = 4.0 * max_mm * u * (span - u) / (span * span); + let t = 1.0 - (v[1] / girder_h).clamp(0.0, 1.0); + v[1] += lift * t; + } + } +} + /// Sprint 30: Camber(솟음) 포물선 Y 오프셋. /// 경간 [z0, z1] 내에서 중앙에서 `mid_mm` 만큼 위로 솟음. /// z - z0 = u ∈ [0, span]. y_off = 4 · mid · u · (span - u) / span² (중앙 u=span/2 에서 최대 = mid) @@ -261,6 +287,10 @@ pub fn build_bridge_scene(kernel: &K, p: &SceneParams) -> Result< }; let mut mesh = kernel.girder_mesh(&ir)?; mesh.recolor(COL_GIRDER); + // Variable depth 는 거더 local 좌표계(Y=0 소핏 기준)에서 먼저 적용, 그 후 translate. + apply_variable_depth( + &mut mesh, 0.0, span_mm, p.variable_depth_mm, girder_h, + ); let mut placed = translate(mesh, x, 0.0, z_base); apply_camber_mesh(&mut placed, z_base, z_end, p.camber_mid_mm); parts.push(placed); @@ -607,6 +637,10 @@ pub fn build_selectable_scene( }; let mut mesh = kernel.girder_mesh(&ir)?; mesh.recolor(COL_GIRDER); + // Variable depth: local 좌표(Z: 0~span_mm)에서 먼저 적용. + apply_variable_depth( + &mut mesh, 0.0, span_mm, p.variable_depth_mm, girder_h, + ); for v in &mut mesh.vertices { v[0] += x; v[2] += z_base; } apply_camber_mesh(&mut mesh, z_base, z_end, p.camber_mid_mm); let label = if span_count > 1 { diff --git a/cimery/crates/viewer/src/lib.rs b/cimery/crates/viewer/src/lib.rs index 854b0ea..b0cf21e 100644 --- a/cimery/crates/viewer/src/lib.rs +++ b/cimery/crates/viewer/src/lib.rs @@ -656,6 +656,8 @@ impl RenderState { .show(ui, |ui| { ps!(ui, "경사각 (°)", &mut p.skew_deg, -30.0..=30.0, 1.0); ps!(ui, "솟음 (mm)", &mut p.camber_mid_mm, 0.0..=200.0, 5.0); + // Sprint 39: 변단면 거더 + ps!(ui, "변단면 (mm)", &mut p.variable_depth_mm, 0.0..=800.0, 20.0); }); // ── 하부구조 (Substructure) ─────────────────────────── diff --git a/cimery/crates/viewer/src/project_file.rs b/cimery/crates/viewer/src/project_file.rs index a5986d7..0cf3d7c 100644 --- a/cimery/crates/viewer/src/project_file.rs +++ b/cimery/crates/viewer/src/project_file.rs @@ -47,6 +47,9 @@ pub struct ProjectFile { /// Sprint 31: 데크 헌치(Haunch) 깊이 [mm] #[serde(default)] pub haunch_depth: f32, + /// Sprint 39: 변단면 거더 중앙부 높이 감소 [mm] + #[serde(default)] + pub variable_depth_mm: f32, } fn default_true() -> bool { true } @@ -81,6 +84,7 @@ impl ProjectFile { show_diaphragms: p.show_diaphragms, camber_mid_mm: p.camber_mid_mm, haunch_depth: p.haunch_depth, + variable_depth_mm: p.variable_depth_mm, } } @@ -108,6 +112,7 @@ impl ProjectFile { show_diaphragms: self.show_diaphragms, camber_mid_mm: self.camber_mid_mm, haunch_depth: self.haunch_depth, + variable_depth_mm: self.variable_depth_mm, } } @@ -163,5 +168,6 @@ pub fn scene_params_to_ifc(p: &SceneParams, name: &str) -> cimery_ifc::BridgeExp haunch_depth: p.haunch_depth as f64, show_parapets: true, show_joints: p.show_expansion_joints, + camber_mid_mm: p.camber_mid_mm as f64, } }