Sprint 14~22 — egui 리본 UI + OcctKernel B-rep + 가로보/신축이음 + 선형 좌표 + USD 익스포트 + WASM + CI/CD + 테스트 4층

Sprint 14: egui TopBottomPanel 리본 + CollapsingHeader SidePanel (상부구조·추가부재·선형·프로젝트)
Sprint 15: IncrementalDb 전 Feature 타입 확장 (girder→7종), dirty-tracking 20 unit tests
Sprint 16: Gitea + GitHub Actions CI/CD (check/test/clippy/fmt + 멀티플랫폼 릴리스)
Sprint 17: AlignmentTransform + AlignmentScene — 선형 국소 프레임 → 세계 좌표 변환
Sprint 18: OcctKernel 교각(16각형 기둥+코핑) + 교대(흉벽+푸팅+날개벽) B-rep
Sprint 19: CrossBeamIR + ExpansionJointIR — IR/DSL/kernel/scene 전 계층, sweep_profile_flat_x
Sprint 20: 테스트 4층 — Layer1 insta 스냅샷(7종), Layer2 기하 불변량(19), Layer3 두-커널(7), Layer4 proptest(7) — 61 tests pass
Sprint 21: cimery-usd PureRustKernel 실제 기하 변환 + BridgeExporter 증분 캐시
Sprint 22: viewer wasm feature + wasm-bindgen/web-sys + GitHub Actions Cloudflare Pages 배포

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
minsung
2026-04-15 08:18:06 +09:00
parent 81349c97d2
commit 1f9ca3a00f
37 changed files with 3569 additions and 259 deletions

View File

@@ -0,0 +1,92 @@
//! Cross beam geometry (가로보). Sprint 19.
//!
//! Builds a swept cross beam mesh for H-section, rectangular, and I-section.
//! The cross beam runs transversely (along X axis) between girders.
//!
//! Coordinate convention (same as bridge_scene):
//! X = transverse (right = +)
//! Y = vertical (up = +)
//! Z = along-span axis
//!
//! The caller translates to the correct Z station position.
use cimery_ir::CrossBeamIR;
use cimery_core::CrossBeamSection;
use crate::{KernelError, Mesh, sweep};
/// Build a cross beam mesh from IR.
///
/// The mesh is centred at X=0, Y=0, and spans X = [-total_length/2 .. +total_length/2].
/// The caller translates to bridge position.
pub fn build_cross_beam_mesh(ir: &CrossBeamIR) -> Result<Mesh, KernelError> {
let length = ir.total_length_mm() as f32;
if length <= 0.0 {
return Err(KernelError::InvalidInput(
format!("cross beam total length must be positive: bay_count={}, girder_spacing={}", ir.bay_count, ir.girder_spacing)
));
}
let profile = match ir.section {
CrossBeamSection::HSection => h_profile(ir),
CrossBeamSection::Rectangular => rect_profile(ir),
CrossBeamSection::ISection => i_profile(ir),
};
// Sweep along X axis (transverse), centred at X=0
let half = length * 0.5;
let mesh = sweep::sweep_profile_flat_x(&profile, length);
// Translate so X goes from -half to +half
let mut mesh = mesh;
for v in &mut mesh.vertices { v[0] -= half; }
Ok(mesh)
}
// ── Section profiles ──────────────────────────────────────────────────────────
/// H-section profile in YZ plane (to be swept along X).
/// Returns [(y, z)] vertices for the closed section.
fn h_profile(ir: &CrossBeamIR) -> Vec<[f32; 2]> {
let hw = ir.web_thickness as f32 * 0.5;
let hfw = ir.flange_width as f32 * 0.5;
let ft = ir.flange_thickness as f32;
let h = ir.web_height as f32;
let tot = h + ft * 2.0;
// H-section profile (14 vertices), CCW in YZ plane
// Bottom flange → web → top flange
vec![
[-hfw, 0.0], // bottom-left
[ hfw, 0.0], // bottom-right
[ hfw, ft], // bottom flange/web junction right
[ hw, ft],
[ hw, ft + h], // web top right
[ hfw, ft + h], // top flange/web junction right
[ hfw, tot], // top-right
[-hfw, tot], // top-left
[-hfw, ft + h],
[-hw, ft + h],
[-hw, ft],
[-hfw, ft],
[-hfw, 0.0], // close (duplicated start — handled by sweep)
]
}
/// Rectangular section profile.
fn rect_profile(ir: &CrossBeamIR) -> Vec<[f32; 2]> {
let hw = ir.flange_width as f32 * 0.5;
let h = (ir.web_height + ir.flange_thickness * 2.0) as f32;
vec![
[-hw, 0.0],
[ hw, 0.0],
[ hw, h],
[-hw, h],
]
}
/// I-section profile (asymmetric top/bottom flanges allowed by same params for now).
fn i_profile(ir: &CrossBeamIR) -> Vec<[f32; 2]> {
// Same as H but with distinct inner taper (simplified: use H profile)
h_profile(ir)
}

View File

@@ -0,0 +1,95 @@
//! Expansion joint geometry (신축이음). Sprint 19.
//!
//! Builds a mesh representing the expansion joint assembly.
//! For visualisation purposes the joint is shown as two flat plates
//! with a gap between them (simplification of the real mechanism).
//!
//! Coordinate convention:
//! X = transverse (right = +)
//! Y = vertical (up = +) — joint sits at Y=0 (top of deck)
//! Z = along-span axis
//!
//! The joint body spans the full deck width (total_width) in X,
//! extends `depth` downward in Y, and has total Z extent of:
//! joint_thickness × 2 + gap_width
use cimery_ir::ExpansionJointIR;
use crate::{KernelError, Mesh, sweep};
/// Plate thickness used for the visual joint body [mm].
const PLATE_THICKNESS_MM: f32 = 50.0;
/// Build an expansion joint mesh from IR.
///
/// The mesh is centred at X=0, Z=0 (station centre) and Y=-depth..0.
/// Caller translates to the correct bridge position.
pub fn build_expansion_joint_mesh(ir: &ExpansionJointIR) -> Result<Mesh, KernelError> {
if ir.total_width <= 0.0 {
return Err(KernelError::InvalidInput(
format!("expansion joint total_width must be positive, got {}", ir.total_width),
));
}
if ir.depth <= 0.0 {
return Err(KernelError::InvalidInput(
format!("expansion joint depth must be positive, got {}", ir.depth),
));
}
let half_w = ir.total_width as f32 * 0.5;
let gap = ir.gap_width as f32;
let depth = ir.depth as f32;
let pt = PLATE_THICKNESS_MM;
let half_pt = pt * 0.5;
// Build two plates: start-side and end-side of the joint
// Each plate: width = total_width, thickness = PLATE_THICKNESS_MM, depth = depth
// Separated by gap_width in Z
let mut parts: Vec<Mesh> = Vec::new();
// Start plate (Z = [-half_pt - gap/2, -gap/2])
{
let z0 = -(half_pt + gap * 0.5);
let z1 = -(gap * 0.5);
let plate = plate_mesh(half_w, depth, z0, z1);
parts.push(plate);
}
// End plate (Z = [+gap/2, +half_pt + gap/2])
{
let z0 = gap * 0.5;
let z1 = half_pt + gap * 0.5;
let plate = plate_mesh(half_w, depth, z0, z1);
parts.push(plate);
}
Ok(sweep::merge_meshes(parts))
}
/// Build a rectangular plate mesh.
/// X spans [-half_w .. +half_w], Y spans [-depth .. 0], Z spans [z0 .. z1].
fn plate_mesh(half_w: f32, depth: f32, z0: f32, z1: f32) -> Mesh {
let verts = vec![
[-half_w, -depth, z0], [ half_w, -depth, z0],
[ half_w, 0.0, z0], [-half_w, 0.0, z0],
[-half_w, -depth, z1], [ half_w, -depth, z1],
[ half_w, 0.0, z1], [-half_w, 0.0, z1],
];
let indices: Vec<u32> = vec![
0, 2, 1, 0, 3, 2, // -Z face
4, 5, 6, 4, 6, 7, // +Z face
0, 4, 7, 0, 7, 3, // -X face
1, 2, 6, 1, 6, 5, // +X face
0, 1, 5, 0, 5, 4, // -Y face (bottom)
3, 7, 6, 3, 6, 2, // +Y face (top)
];
let colors = vec![[0.25_f32, 0.25, 0.30]; verts.len()]; // dark steel grey
let normals = vec![[0.0_f32, 1.0, 0.0]; verts.len()]; // simplified flat
Mesh {
vertices: verts.into_iter().map(|v| v).collect(),
indices,
normals,
colors,
}
}

View File

@@ -16,13 +16,15 @@ pub mod deck_slab;
pub mod bearing;
pub mod pier;
pub mod abutment;
pub mod cross_beam;
pub mod expansion_joint;
pub mod occt;
#[cfg(feature = "occt")]
pub use occt::OcctKernel;
use cimery_ir::{
AbutmentIR, BearingIR, DeckSlabIR, GirderIR, PierIR, SectionParams,
AbutmentIR, BearingIR, CrossBeamIR, DeckSlabIR, ExpansionJointIR, GirderIR, PierIR, SectionParams,
};
// ─── Mesh ─────────────────────────────────────────────────────────────────────
@@ -95,8 +97,11 @@ pub trait GeomKernel: Send + Sync {
// ── 연결부 (Interface) ─────────────────────────────────────────────────
fn bearing_mesh(&self, ir: &BearingIR) -> Result<Mesh, KernelError>;
// ── 하부 구조물 (Substructure) ─────────────────────────────────────────
fn pier_mesh(&self, ir: &PierIR) -> Result<Mesh, KernelError>;
fn abutment_mesh(&self, ir: &AbutmentIR) -> Result<Mesh, KernelError>;
fn pier_mesh(&self, ir: &PierIR) -> Result<Mesh, KernelError>;
fn abutment_mesh(&self, ir: &AbutmentIR) -> Result<Mesh, KernelError>;
// ── Should features (Sprint 19) ────────────────────────────────────────
fn cross_beam_mesh(&self, ir: &CrossBeamIR) -> Result<Mesh, KernelError>;
fn expansion_joint_mesh(&self, ir: &ExpansionJointIR) -> Result<Mesh, KernelError>;
}
// ─── StubKernel ───────────────────────────────────────────────────────────────
@@ -120,6 +125,14 @@ impl GeomKernel for StubKernel {
fn abutment_mesh(&self, ir: &AbutmentIR) -> Result<Mesh, KernelError> {
Ok(sweep::box_mesh(ir.breast_wall_thickness as f32, ir.breast_wall_height as f32, ir.breast_wall_width as f32))
}
fn cross_beam_mesh(&self, ir: &CrossBeamIR) -> Result<Mesh, KernelError> {
let total_len = ir.total_length_mm() as f32;
let height = (ir.web_height + ir.flange_thickness * 2.0) as f32;
Ok(sweep::box_mesh(total_len, height, ir.web_thickness as f32))
}
fn expansion_joint_mesh(&self, ir: &ExpansionJointIR) -> Result<Mesh, KernelError> {
Ok(sweep::box_mesh(ir.total_width as f32, ir.depth as f32, ir.gap_width as f32 + 100.0))
}
fn girder_mesh(&self, ir: &GirderIR) -> Result<Mesh, KernelError> {
if ir.span_m() <= 0.0 {
return Err(KernelError::InvalidInput(
@@ -183,6 +196,12 @@ impl GeomKernel for PureRustKernel {
}
}
}
fn cross_beam_mesh(&self, ir: &CrossBeamIR) -> Result<Mesh, KernelError> {
cross_beam::build_cross_beam_mesh(ir)
}
fn expansion_joint_mesh(&self, ir: &ExpansionJointIR) -> Result<Mesh, KernelError> {
expansion_joint::build_expansion_joint_mesh(ir)
}
}
// ─── Tests ────────────────────────────────────────────────────────────────────

View File

@@ -89,8 +89,7 @@ mod inner {
occt_mesh_to_cimery(occt_mesh)
}
// ── Other features (use PureRustKernel geometry for now) ─────────────────────
// ── Deck slab (PureRust delegation) ─────────────────────────────────────────
pub fn deck_slab_mesh(ir: &DeckSlabIR) -> Result<Mesh, KernelError> {
crate::deck_slab::build_deck_slab_mesh(ir)
}
@@ -99,12 +98,164 @@ mod inner {
crate::bearing::build_bearing_mesh(ir)
}
// ── Pier — OCCT B-rep (Sprint 18) ────────────────────────────────────────────
//
// Geometry:
// - Columns: polygon-approximated circle or rectangular prism
// - Cap beam: rectangular box spanning columns
// All shapes extruded via OCCT Workplane::sketch().
pub fn pier_mesh(ir: &PierIR) -> Result<Mesh, KernelError> {
crate::pier::build_pier_mesh(ir)
use cimery_core::ColumnShape;
let cap = &ir.cap_beam;
let col_h = ir.column_height;
let col_d = ir.column_diameter;
let col_dp = ir.column_depth.max(col_d); // rectangular depth
let n_col = ir.column_count.max(1) as usize;
let spacing = ir.column_spacing;
let mut parts: Vec<Mesh> = Vec::new();
// ── Columns ─────────────────────────────────────────────────────────
for i in 0..n_col {
let x_off = (i as f64 - (n_col as f64 - 1.0) * 0.5) * spacing;
let col_mesh = match ir.column_shape {
ColumnShape::Circular => {
// Approximate circle with 16-sided polygon
let r = col_d * 0.5;
let ns = 16usize;
let profile: Vec<(f64, f64)> = (0..ns).map(|k| {
let a = std::f64::consts::TAU * k as f64 / ns as f64;
(r * a.cos(), r * a.sin())
}).collect();
workplane_extrude_xz(&profile, col_h)?
}
ColumnShape::Rectangular | ColumnShape::Oval => {
let hw = col_d * 0.5;
let hd = col_dp * 0.5;
let profile = vec![(-hw, -hd), (hw, -hd), (hw, hd), (-hw, hd)];
workplane_extrude_xz(&profile, col_h)?
}
};
// Translate column to x_off, start at Y=0
let mut m = col_mesh;
for v in &mut m.vertices { v[0] += x_off as f32; }
parts.push(m);
}
// ── Cap beam ────────────────────────────────────────────────────────
{
let hcl = cap.length * 0.5;
let hcw = cap.width * 0.5;
let profile = vec![(-hcl, -hcw), (hcl, -hcw), (hcl, hcw), (-hcl, hcw)];
let mut cap_mesh = workplane_extrude_xz(&profile, cap.depth)?;
// Cap sits on top of columns
for v in &mut cap_mesh.vertices { v[1] += col_h as f32; }
parts.push(cap_mesh);
}
let mut mesh = crate::sweep::merge_meshes(parts);
// Translate so base sits at Y=0
mesh.colors = vec![crate::COLOR_CONCRETE; mesh.vertices.len()];
Ok(mesh)
}
// ── Abutment — OCCT B-rep (Sprint 18) ────────────────────────────────────────
//
// Geometry: breast wall + footing + wing walls, all rectangular extrusions.
pub fn abutment_mesh(ir: &AbutmentIR) -> Result<Mesh, KernelError> {
crate::abutment::build_abutment_mesh(ir)
let bwh = ir.breast_wall_height;
let bwt = ir.breast_wall_thickness;
let bww = ir.breast_wall_width;
let fl = ir.footing_length;
let fw = ir.footing_width;
let ft = ir.footing_thickness;
let wl = &ir.wing_wall_left;
let wr = &ir.wing_wall_right;
let mut parts: Vec<Mesh> = Vec::new();
// ── Breast wall (正面壁) ─────────────────────────────────────────────
{
let hw = bww * 0.5;
let hbt = bwt * 0.5;
let profile = vec![(-hw, -hbt), (hw, -hbt), (hw, hbt), (-hw, hbt)];
let mut m = workplane_extrude_xz_y(&profile, bwh)?;
// Breast wall at footing top, Y=0..bwh
parts.push(m);
}
// ── Footing ──────────────────────────────────────────────────────────
{
let hfw = fw * 0.5;
let hfl = fl * 0.5;
let profile = vec![(-hfw, -hfl), (hfw, -hfl), (hfw, hfl), (-hfw, hfl)];
let mut m = workplane_extrude_xz_y(&profile, ft)?;
// Footing below breast wall
for v in &mut m.vertices { v[1] -= ft as f32; }
parts.push(m);
}
// ── Wing walls ────────────────────────────────────────────────────────
for (wing, sign) in [(wl, -1.0_f32), (wr, 1.0_f32)] {
let wlen = wing.length;
let wht = wing.height;
let wth = wing.thickness;
let half_bww = bww * 0.5;
let profile = vec![(0.0, 0.0), (wlen, 0.0), (wlen, wth), (0.0, wth)];
let mut m = workplane_extrude_xz_y(&profile, wht)?;
// Rotate 90° if needed for side walls — approximate by translating
// Wing wall runs along Z from breast wall end
for v in &mut m.vertices {
let orig_x = v[0] as f64;
let orig_z = v[2] as f64;
v[0] = (sign * half_bww as f32) + (sign * orig_z as f32);
v[2] = orig_x as f32;
}
parts.push(m);
}
let mut mesh = crate::sweep::merge_meshes(parts);
mesh.colors = vec![crate::COLOR_CONCRETE; mesh.vertices.len()];
Ok(mesh)
}
// ── Shared OCCT helper: extrude XZ profile upward (Y direction) ──────────────
/// Extrude a closed XZ-plane profile (as (x,z) pairs) upward by `height_mm` along Y.
/// Uses OCCT Workplane::xz() sketch + extrude.
fn workplane_extrude_xz(profile_xz: &[(f64, f64)], height_mm: f64) -> Result<Mesh, KernelError> {
use opencascade::workplane::Workplane;
if profile_xz.len() < 3 {
return Err(KernelError::InvalidInput("profile must have ≥3 points".into()));
}
let mut sk = Workplane::xz().sketch();
let first = profile_xz[0];
sk = sk.move_to(first.0, first.1);
for &(x, z) in &profile_xz[1..] {
sk = sk.line_to(x, z);
}
let face = sk.close().to_face();
let solid = face.extrude(DVec3::new(0.0, height_mm, 0.0));
let shape = solid.into();
let occt_m = Mesher::try_new(&shape, 1.0)
.map_err(|e| KernelError::Computation(format!("pier mesher: {e}")))?
.mesh()
.map_err(|e| KernelError::Computation(format!("pier tessellation: {e}")))?;
occt_mesh_to_cimery(occt_m)
}
/// Same as workplane_extrude_xz but profile is in XZ plane, extruded along Y.
/// Alias kept for readability at call sites.
fn workplane_extrude_xz_y(profile_xz: &[(f64, f64)], height_mm: f64) -> Result<Mesh, KernelError> {
workplane_extrude_xz(profile_xz, height_mm)
}
pub fn cross_beam_mesh(ir: &cimery_ir::CrossBeamIR) -> Result<Mesh, KernelError> {
crate::cross_beam::build_cross_beam_mesh(ir)
}
pub fn expansion_joint_mesh(ir: &cimery_ir::ExpansionJointIR) -> Result<Mesh, KernelError> {
crate::expansion_joint::build_expansion_joint_mesh(ir)
}
// ── Conversion ────────────────────────────────────────────────────────────────
@@ -177,5 +328,11 @@ mod occt_kernel {
fn abutment_mesh(&self, ir: &AbutmentIR) -> Result<Mesh, KernelError> {
inner::abutment_mesh(ir)
}
fn cross_beam_mesh(&self, ir: &cimery_ir::CrossBeamIR) -> Result<Mesh, KernelError> {
inner::cross_beam_mesh(ir)
}
fn expansion_joint_mesh(&self, ir: &cimery_ir::ExpansionJointIR) -> Result<Mesh, KernelError> {
inner::expansion_joint_mesh(ir)
}
}
}

View File

@@ -70,6 +70,55 @@ pub fn sweep_profile_flat(profile: &[[f32; 2]], span: f32) -> Mesh {
Mesh { vertices, normals, indices, colors }
}
/// Sweep a closed polygon profile (in YZ plane) along X from 0 to `length`.
/// Used for cross beams that run transversely. Flat normals.
pub fn sweep_profile_flat_x(profile: &[[f32; 2]], length: 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();
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] {
indices.push(vertices.len() as u32);
vertices.push(v);
normals.push(normal);
}
};
// profile[i] = [y, z] — side faces sweeping along X
for i in 0..n {
let j = (i + 1) % n;
let [y0, z0] = profile[i];
let [y1, z1] = profile[j];
let a = [0.0, y0, z0];
let b = [0.0, y1, z1];
let c = [length,y1, z1];
let d = [length,y0, z0];
push_tri(a, b, c);
push_tri(a, c, d);
}
// End caps
let cy: f32 = profile.iter().map(|v| v[0]).sum::<f32>() / n as f32;
let cz: f32 = profile.iter().map(|v| v[1]).sum::<f32>() / n as f32;
let cen_f = [0.0, cy, cz];
for i in 0..n {
let j = (i+1)%n;
push_tri(cen_f, [0.0, profile[i][0], profile[i][1]], [0.0, profile[j][0], profile[j][1]]);
}
let cen_b = [length, cy, cz];
for i in 0..n {
let j = (i+1)%n;
push_tri(cen_b, [length, profile[j][0], profile[j][1]], [length, profile[i][0], profile[i][1]]);
}
let colors = vec![crate::COLOR_CONCRETE; vertices.len()];
Mesh { vertices, normals, indices, colors }
}
// ─── Convenience shapes ───────────────────────────────────────────────────────
/// Axis-aligned box: width × height × depth. Origin at (0,0,0).