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:
@@ -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 }
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user