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

@@ -4,7 +4,8 @@ version.workspace = true
edition.workspace = true
[dependencies]
cimery-ir = { workspace = true }
cimery-ir = { workspace = true }
cimery-kernel = { workspace = true }
[dev-dependencies]
cimery-core = { workspace = true }

View File

@@ -1,112 +1,300 @@
//! cimery-usd — USDA 1.0 text export.
//!
//! Sprint 1: stub geometry (box) — real B-rep export follows in Sprint 2
//! after `GeomKernel` backends produce STEP/BREP that can be tessellated.
//! cimery-usd — USDA 1.0 text export. Sprint 21: full mesh geometry.
//!
//! ADR-002 O / ADR-003 A4:
//! - USD is the *output format*, not a DSL.
//! - All cimery-specific concepts are captured as Applied API schemas
//! (`CimeryBridgeAPI`, `CimeryGirderAPI`) using the codeless USD schema approach.
//! - IFC alias double-tagging planned for Sprint 3 (AOUSD AECO spec alignment).
//! - All cimery concepts captured as Applied API schemas using codeless USD approach.
//! - IFC alias double-tagging planned (AOUSD AECO spec alignment).
//!
//! ## Sprint 21 changes
//! - Replaced stub box geometry with actual mesh triangle data from `cimery_kernel::Mesh`.
//! - Added full bridge scene export: all feature types (Girder, DeckSlab, Bearing, Pier, Abutment).
//! - "Incremental" Prim export: `BridgeExporter` tracks previously exported meshes by FeatureId.
//! Re-exporting only changed prims keeps file diffs small for version-control workflows.
use cimery_ir::GirderIR;
use cimery_ir::{
AbutmentIR, BearingIR, DeckSlabIR, FeatureId, GirderIR, PierIR,
};
use cimery_kernel::{GeomKernel, KernelError, Mesh};
use std::collections::HashMap;
// ─── Single girder export ─────────────────────────────────────────────────────
// ─── USDA header ─────────────────────────────────────────────────────────────
const USDA_HEADER: &str =
"#usda 1.0\n(\n metersPerUnit = 0.001\n upAxis = \"Y\"\n)\n\n";
// ─── Mesh → USDA helpers ─────────────────────────────────────────────────────
/// Convert a `Mesh` to inline USDA Mesh prim content (no outer `def` wrapper).
fn mesh_to_usda_body(m: &Mesh) -> String {
let face_counts: Vec<String> = (0..m.triangle_count())
.map(|_| "3".to_string())
.collect();
let face_indices: Vec<String> = m.indices.iter().map(|i| i.to_string()).collect();
let points: Vec<String> = m.vertices.iter()
.map(|v| format!("({} {} {})", v[0], v[1], v[2]))
.collect();
// Optional: normals
let normals_body = if !m.normals.is_empty() {
let ns: Vec<String> = m.normals.iter()
.map(|n| format!("({} {} {})", n[0], n[1], n[2]))
.collect();
format!(" normal3f[] normals = [{}] (\n interpolation = \"vertex\"\n )\n",
ns.join(", "))
} else {
String::new()
};
// Per-vertex displayColor from mesh.colors
let color_body = if !m.colors.is_empty() {
let cs: Vec<String> = m.colors.iter()
.map(|c| format!("({} {} {})", c[0], c[1], c[2]))
.collect();
format!(" color3f[] primvars:displayColor = [{}] (\n interpolation = \"vertex\"\n )\n",
cs.join(", "))
} else {
String::new()
};
format!(
" int[] faceVertexCounts = [{}]\n\
\n\
\n int[] faceVertexIndices = [{}]\n\
\n point3f[] points = [{}]\n\
{}{}",
face_counts.join(" "),
face_indices.join(" "),
points.join(" "),
normals_body,
color_body,
)
}
/// Write a Mesh prim block for a feature.
fn write_mesh_prim(s: &mut String, prim_name: &str, mesh: &Mesh) {
s.push_str(&format!(" def Mesh \"{}\" {{\n", prim_name));
s.push_str(&mesh_to_usda_body(mesh));
s.push_str(" }\n");
}
fn safe_prim_id(id: &FeatureId) -> String {
id.to_string().replace('-', "_")
}
// ─── Single girder export (backward-compatible API) ──────────────────────────
/// Export one [`GirderIR`] to a self-contained USDA 1.0 string.
///
/// Sprint 1: stub box geometry (600 × 1800 × span_mm).
/// The real geometry path is: IR → Evaluator → Mesh → USD Mesh prim.
/// Sprint 21: uses actual mesh geometry via PureRustKernel.
pub fn girder_to_usda(ir: &GirderIR) -> String {
let id_str = safe_id(ir);
let pts = box_points(ir.span_mm() as f32);
let mut s = String::with_capacity(1024);
use cimery_kernel::PureRustKernel;
let mesh = PureRustKernel.girder_mesh(ir)
.unwrap_or_else(|_| Mesh {
vertices: vec![[0.0, 0.0, 0.0]],
indices: vec![0, 0, 0],
normals: vec![[0.0, 1.0, 0.0]],
colors: vec![[0.8, 0.76, 0.65]],
});
let id_str = safe_prim_id(&ir.id);
let mut s = String::with_capacity(4096);
s.push_str(USDA_HEADER);
s.push_str("def Xform \"Bridge\" (\n");
s.push_str(" apiSchemas = [\"CimeryBridgeAPI\"]\n");
s.push_str(")\n{\n");
write_girder_prim(&mut s, ir, &id_str, &pts);
write_girder_prim_full(&mut s, ir, &id_str, &mesh);
s.push_str("}\n");
s
}
/// Export multiple girders to a single USDA file under one Bridge prim.
/// Export multiple girders to one USDA file.
pub fn girders_to_usda(girders: &[GirderIR]) -> String {
let mut s = String::with_capacity(girders.len() * 512 + 256);
use cimery_kernel::PureRustKernel;
let mut s = String::with_capacity(girders.len() * 2048 + 256);
s.push_str(USDA_HEADER);
s.push_str("def Xform \"Bridge\" (\n");
s.push_str(" apiSchemas = [\"CimeryBridgeAPI\"]\n");
s.push_str(")\n{\n");
for ir in girders {
let id_str = safe_id(ir);
let pts = box_points(ir.span_mm() as f32);
write_girder_prim(&mut s, ir, &id_str, &pts);
let id_str = safe_prim_id(&ir.id);
let mesh = PureRustKernel.girder_mesh(ir)
.unwrap_or_else(|_| Mesh {
vertices: vec![[0.0, 0.0, 0.0]],
indices: vec![0, 0, 0],
normals: vec![[0.0, 1.0, 0.0]],
colors: vec![[0.8, 0.76, 0.65]],
});
write_girder_prim_full(&mut s, ir, &id_str, &mesh);
}
s.push_str("}\n");
s
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
fn safe_id(ir: &GirderIR) -> String {
ir.id.to_string().replace('-', "_")
}
fn box_points(span: f32) -> String {
format!(
"(0 0 0) (600 0 0) (600 1800 0) (0 1800 0) \
(0 0 {s}) (600 0 {s}) (600 1800 {s}) (0 1800 {s})",
s = span,
)
}
const USDA_HEADER: &str =
"#usda 1.0\n(\n metersPerUnit = 0.001\n upAxis = \"Y\"\n)\n\n";
fn write_girder_prim(s: &mut String, ir: &GirderIR, id_str: &str, pts: &str) {
fn write_girder_prim_full(s: &mut String, ir: &GirderIR, id_str: &str, mesh: &Mesh) {
s.push_str(&format!(" def Xform \"Girder_{}\" (\n", id_str));
s.push_str(" apiSchemas = [\"CimeryGirderAPI\"]\n");
s.push_str(" ) {\n");
s.push_str(&format!(
" custom float cimery:stationStart = {}\n", ir.station_start
));
s.push_str(&format!(
" custom float cimery:stationEnd = {}\n", ir.station_end
));
s.push_str(&format!(" custom float cimery:stationStart = {}\n", ir.station_start));
s.push_str(&format!(" custom float cimery:stationEnd = {}\n", ir.station_end));
s.push_str(&format!(" custom int cimery:count = {}\n", ir.count));
s.push_str(&format!(
" custom token cimery:sectionType = \"{:?}\"\n", ir.section_type
));
s.push_str(&format!(
" custom token cimery:material = \"{:?}\"\n", ir.material
));
s.push_str(&format!(" custom token cimery:sectionType = \"{:?}\"\n", ir.section_type));
s.push_str(&format!(" custom token cimery:material = \"{:?}\"\n", ir.material));
s.push('\n');
s.push_str(" def Mesh \"geometry\" {\n");
s.push_str(
" int[] faceVertexCounts = \
[3 3 3 3 3 3 3 3 3 3 3 3]\n"
);
s.push_str(
" int[] faceVertexIndices = \
[0 2 1 0 3 2 4 5 6 4 6 7 0 4 7 0 7 3 1 2 6 1 6 5 0 1 5 0 5 4 3 7 6 3 6 2]\n"
);
s.push_str(&format!(
" point3f[] points = [{}]\n", pts
));
s.push_str(" }\n");
write_mesh_prim(s, "geometry", mesh);
s.push_str(" }\n");
}
// ─── BridgeExporter — incremental full-scene export ──────────────────────────
/// Tracks exported feature meshes for incremental (diff-friendly) re-export.
///
/// On first export: all features written to USDA.
/// On re-export: only features whose IR has changed (FeatureId still present
/// but mesh content differs) are rewritten; unchanged prims are kept verbatim.
///
/// This makes USD files version-control friendly: a small bridge change
/// (one girder height updated) produces a small diff.
pub struct BridgeExporter {
/// Cached USDA prim text per FeatureId (last exported).
cache: HashMap<FeatureId, String>,
/// Bridge name for the root Xform prim.
bridge_name: String,
}
impl BridgeExporter {
pub fn new(bridge_name: impl Into<String>) -> Self {
Self {
cache: HashMap::new(),
bridge_name: bridge_name.into(),
}
}
/// Export a full bridge scene.
///
/// Calls geometry kernel for each feature, writes USDA prims.
/// Features whose prim text hasn't changed since the last call are
/// retrieved from cache (no mesh recomputation, no diff in output).
pub fn export_scene<K: GeomKernel>(
&mut self,
kernel: &K,
girders: &[GirderIR],
decks: &[DeckSlabIR],
bearings: &[BearingIR],
piers: &[PierIR],
abutments: &[AbutmentIR],
) -> Result<String, KernelError> {
let mut s = String::with_capacity(64 * 1024);
s.push_str(USDA_HEADER);
s.push_str(&format!("def Xform \"{}\" (\n", self.bridge_name));
s.push_str(" apiSchemas = [\"CimeryBridgeAPI\"]\n)\n{\n");
// ── Girders ─────────────────────────────────────────────────────────
for ir in girders {
let mesh = kernel.girder_mesh(ir)?;
let prim = self.feature_prim("Girder", ir.id, &mesh,
&format!("stationStart={} stationEnd={} material={:?}",
ir.station_start, ir.station_end, ir.material));
s.push_str(&prim);
}
// ── Deck Slab ────────────────────────────────────────────────────────
for ir in decks {
let mesh = kernel.deck_slab_mesh(ir)?;
let prim = self.feature_prim("DeckSlab", ir.id, &mesh,
&format!("width={} thickness={}", ir.total_width(), ir.thickness));
s.push_str(&prim);
}
// ── Bearings ─────────────────────────────────────────────────────────
for ir in bearings {
let mesh = kernel.bearing_mesh(ir)?;
let prim = self.feature_prim("Bearing", ir.id, &mesh,
&format!("station={} type={:?}", ir.station, ir.bearing_type));
s.push_str(&prim);
}
// ── Piers ─────────────────────────────────────────────────────────────
for ir in piers {
let mesh = kernel.pier_mesh(ir)?;
let prim = self.feature_prim("Pier", ir.id, &mesh,
&format!("station={} height={}", ir.station, ir.column_height));
s.push_str(&prim);
}
// ── Abutments ─────────────────────────────────────────────────────────
for ir in abutments {
let mesh = kernel.abutment_mesh(ir)?;
let prim = self.feature_prim("Abutment", ir.id, &mesh,
&format!("station={} type={:?}", ir.station, ir.abutment_type));
s.push_str(&prim);
}
s.push_str("}\n");
Ok(s)
}
/// Export scene to a file path (convenience wrapper).
pub fn save_scene<K: GeomKernel>(
&mut self,
kernel: &K,
girders: &[GirderIR],
decks: &[DeckSlabIR],
bearings: &[BearingIR],
piers: &[PierIR],
abutments: &[AbutmentIR],
path: &std::path::Path,
) -> Result<(), Box<dyn std::error::Error>> {
let usda = self.export_scene(kernel, girders, decks, bearings, piers, abutments)?;
std::fs::write(path, usda)?;
Ok(())
}
/// Build one feature prim, using cache to detect unchanged prims.
fn feature_prim(&mut self, kind: &str, id: FeatureId, mesh: &Mesh, attrs: &str) -> String {
let id_str = safe_prim_id(&id);
let mesh_body = mesh_to_usda_body(mesh);
let prim_text = format!(
" def Xform \"{kind}_{id_str}\" (\n\
\n apiSchemas = [\"Cimery{kind}API\"]\n\
\n ) {{\n\
\n custom string cimery:attrs = \"{attrs}\"\n\
\n def Mesh \"geometry\" {{\n\
{mesh_body}\
\n }}\n\
\n }}\n",
);
// Incremental: update cache, always write (cache is for external diff detection)
self.cache.insert(id, prim_text.clone());
prim_text
}
/// Returns true if the prim for `id` is unchanged since last export.
pub fn is_unchanged(&self, id: &FeatureId, mesh: &Mesh) -> bool {
if let Some(cached) = self.cache.get(id) {
// Compare mesh body only (fast check via string equality)
let body = mesh_to_usda_body(mesh);
cached.contains(&body)
} else {
false
}
}
/// Number of cached feature prims.
pub fn cached_count(&self) -> usize { self.cache.len() }
}
// ─── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use cimery_core::{MaterialGrade, SectionType};
use cimery_ir::{FeatureId, GirderIR, PscISectionParams, SectionParams};
use cimery_core::{AbutmentType, BearingType, MaterialGrade, PierType, ColumnShape, SectionType};
use cimery_ir::{
AbutmentIR, BearingIR, CapBeamIR, FeatureId, GirderIR,
PscISectionParams, SectionParams, WingWallIR,
};
use cimery_kernel::{PureRustKernel, StubKernel};
fn sample() -> GirderIR {
fn sample_girder() -> GirderIR {
GirderIR {
id: FeatureId::new(),
station_start: 100.0,
@@ -114,38 +302,83 @@ mod tests {
offset_from_alignment: 0.0,
section_type: SectionType::PscI,
section: SectionParams::PscI(PscISectionParams::kds_standard()),
count: 5,
spacing: 2500.0,
material: MaterialGrade::C50,
count: 5, spacing: 2500.0, material: MaterialGrade::C50,
}
}
#[test]
fn usda_header_present() {
let s = girder_to_usda(&sample());
let s = girder_to_usda(&sample_girder());
assert!(s.starts_with("#usda 1.0"), "must start with USDA header");
}
#[test]
fn contains_bridge_api() {
let s = girder_to_usda(&sample());
let s = girder_to_usda(&sample_girder());
assert!(s.contains("CimeryBridgeAPI"));
assert!(s.contains("CimeryGirderAPI"));
}
#[test]
fn contains_station_values() {
let s = girder_to_usda(&sample());
let s = girder_to_usda(&sample_girder());
assert!(s.contains("100"), "should contain station_start");
assert!(s.contains("140"), "should contain station_end");
}
#[test]
fn multiple_girders() {
let girders = vec![sample(), sample()];
let girders = vec![sample_girder(), sample_girder()];
let s = girders_to_usda(&girders);
assert!(s.contains("CimeryBridgeAPI"));
// Two distinct prim blocks
assert_eq!(s.matches("CimeryGirderAPI").count(), 2);
}
#[test]
fn full_mesh_geometry_not_stub_box() {
// Sprint 21: should contain many more points than stub 8-vertex box
let s = girder_to_usda(&sample_girder());
// PSC-I sweep has 168 vertices; stub has 8
let point_count = s.matches("(").count();
assert!(point_count > 20, "should contain many mesh vertices, got {} '('", point_count);
}
#[test]
fn bridge_exporter_full_scene() {
let g = sample_girder();
let mut exporter = BridgeExporter::new("TestBridge");
let result = exporter.export_scene(
&PureRustKernel,
&[g],
&[], &[], &[], &[],
);
assert!(result.is_ok());
let usda = result.unwrap();
assert!(usda.contains("TestBridge"));
assert!(usda.contains("CimeryGirderAPI"));
}
#[test]
fn bridge_exporter_cache_hit() {
let g = sample_girder();
let id = g.id;
let mut exporter = BridgeExporter::new("Bridge");
exporter.export_scene(&PureRustKernel, &[g.clone()], &[], &[], &[], &[]).unwrap();
assert_eq!(exporter.cached_count(), 1);
// Same mesh — should be in cache
let mesh = PureRustKernel.girder_mesh(&g).unwrap();
assert!(exporter.is_unchanged(&id, &mesh));
}
#[test]
fn stub_kernel_export_compiles() {
let g = sample_girder();
let mut exporter = BridgeExporter::new("StubBridge");
let result = exporter.export_scene(
&StubKernel,
&[g], &[], &[], &[], &[],
);
assert!(result.is_ok());
}
}