Sprint 23/24 — Tauri v2 앱 래핑 + salsa 0.16 증분 쿼리 백엔드
All checks were successful
Publish ParaWiki / build-and-deploy (push) Successful in 34s
All checks were successful
Publish ParaWiki / build-and-deploy (push) Successful in 34s
Sprint 23: cimery-app을 Tauri v2 앱으로 전환. - tauri.conf.json, capabilities/default.json, frontend/index.html 추가 - src/commands.rs: 7개 IPC 커맨드 (launch_viewer, 프로젝트 관리, USD/CSV 익스포트) - 뷰어 사이드카: std::process::Command 방식 (PATH + exe-dir 탐색) - release.yml: 3단계 멀티플랫폼 릴리스 워크플로로 교체 Sprint 24: cimery-incremental에 salsa 0.16 백엔드 추가. - salsa_db.rs: BridgeQueryGroup + SalsaIncrementalDb<K> - --features salsa-backend 로 활성화 (기본값: 수동 tracking, WASM 안전) - IR 전 구조체 + Mesh + KernelError에 PartialEq/Eq 추가 - 테스트 20개 전부 통과 (수동 12 + salsa 8) - cargo check --workspace 0 errors/warnings 기타: viewer/dsl 컴파일 경고 제거, wiki 실행 가이드 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,12 +2,15 @@
|
||||
name = "cimery-app"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
description = "cimery desktop application (Tauri v2 + Leptos UI)"
|
||||
description = "cimery desktop application (Tauri v2 + sidecar viewer)"
|
||||
|
||||
[features]
|
||||
# Geometry backends
|
||||
occt = ["cimery-kernel/occt"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
cimery-core = { workspace = true }
|
||||
cimery-ir = { workspace = true }
|
||||
@@ -22,6 +25,6 @@ serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
env_logger = { workspace = true }
|
||||
|
||||
# Tauri v2 (ADR-001: desktop packaging)
|
||||
# Uncomment when setting up Tauri project:
|
||||
# tauri = { version = "2", features = ["devtools"] }
|
||||
# Tauri v2 (ADR-001: Tauri v2 desktop packaging)
|
||||
tauri = { version = "2", features = ["devtools"] }
|
||||
tauri-plugin-dialog = "2"
|
||||
|
||||
6
cimery/crates/app/build.rs
Normal file
6
cimery/crates/app/build.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Sprint 23 — Tauri v2 build script.
|
||||
// tauri_build::build() generates src-tauri metadata required by the Tauri runtime.
|
||||
// Must be the only content in build.rs.
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
12
cimery/crates/app/capabilities/default.json
Normal file
12
cimery/crates/app/capabilities/default.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2/capability",
|
||||
"identifier": "default",
|
||||
"description": "Default cimery app capabilities",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"dialog:allow-message"
|
||||
]
|
||||
}
|
||||
340
cimery/crates/app/frontend/index.html
Normal file
340
cimery/crates/app/frontend/index.html
Normal file
@@ -0,0 +1,340 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>cimery</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', 'Malgun Gothic', sans-serif;
|
||||
background: #1a1d23;
|
||||
color: #d4d8e0;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Titlebar ─────────────────────────────────────────────── */
|
||||
.titlebar {
|
||||
background: #13161b;
|
||||
border-bottom: 1px solid #2c3040;
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
user-select: none;
|
||||
}
|
||||
.titlebar .logo {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #5b9bd5;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.titlebar .subtitle {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* ── Main layout ──────────────────────────────────────────── */
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Sidebar ──────────────────────────────────────────────── */
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
background: #13161b;
|
||||
border-right: 1px solid #2c3040;
|
||||
padding: 16px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.sidebar-item {
|
||||
padding: 9px 20px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
border-left: 3px solid transparent;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.sidebar-item:hover { background: #1f2330; }
|
||||
.sidebar-item.active {
|
||||
background: #1f2330;
|
||||
border-left-color: #5b9bd5;
|
||||
color: #fff;
|
||||
}
|
||||
.sidebar-section {
|
||||
padding: 16px 20px 6px;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
/* ── Content ──────────────────────────────────────────────── */
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 32px 40px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #e5e7eb;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* ── Card grid ────────────────────────────────────────────── */
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.card {
|
||||
background: #1f2330;
|
||||
border: 1px solid #2c3040;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, transform 0.1s;
|
||||
}
|
||||
.card:hover {
|
||||
border-color: #5b9bd5;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.card h3 { font-size: 14px; color: #e5e7eb; margin-bottom: 6px; }
|
||||
.card p { font-size: 12px; color: #6b7280; line-height: 1.5; }
|
||||
.card .icon { font-size: 24px; margin-bottom: 10px; }
|
||||
|
||||
/* ── Action buttons ───────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 18px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-primary { background: #2563eb; color: #fff; }
|
||||
.btn-primary:hover { background: #1d4ed8; }
|
||||
.btn-secondary { background: #1f2330; color: #d4d8e0; border: 1px solid #2c3040; }
|
||||
.btn-secondary:hover { background: #2c3040; }
|
||||
.btn-group { display: flex; gap: 10px; margin-bottom: 28px; }
|
||||
|
||||
/* ── Status bar ───────────────────────────────────────────── */
|
||||
.statusbar {
|
||||
background: #13161b;
|
||||
border-top: 1px solid #2c3040;
|
||||
padding: 6px 20px;
|
||||
font-size: 11px;
|
||||
color: #4b5563;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
.status-ok { color: #22c55e; }
|
||||
.status-warn { color: #f59e0b; }
|
||||
|
||||
/* ── Recent projects table ────────────────────────────────── */
|
||||
.recent-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.recent-table th {
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
color: #6b7280;
|
||||
font-weight: 400;
|
||||
border-bottom: 1px solid #2c3040;
|
||||
}
|
||||
.recent-table td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #1f2330;
|
||||
color: #d4d8e0;
|
||||
}
|
||||
.recent-table tr:hover td { background: #1f2330; cursor: pointer; }
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
background: #1e3a5f;
|
||||
color: #5b9bd5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Titlebar -->
|
||||
<div class="titlebar">
|
||||
<span class="logo">cimery</span>
|
||||
<span class="subtitle">Civil Parametric BIM v0.1.0</span>
|
||||
</div>
|
||||
|
||||
<!-- Main layout -->
|
||||
<div class="main">
|
||||
|
||||
<!-- Sidebar -->
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-section">작업공간</div>
|
||||
<div class="sidebar-item active" onclick="showPage('home')">홈</div>
|
||||
<div class="sidebar-item" onclick="showPage('projects')">프로젝트</div>
|
||||
<div class="sidebar-item" onclick="launchViewer()">3D 뷰어 열기</div>
|
||||
|
||||
<div class="sidebar-section">내보내기</div>
|
||||
<div class="sidebar-item" onclick="exportUSD()">USD 익스포트</div>
|
||||
<div class="sidebar-item" onclick="exportCSV()">CSV 템플릿</div>
|
||||
|
||||
<div class="sidebar-section">도움말</div>
|
||||
<div class="sidebar-item" onclick="showPage('about')">정보</div>
|
||||
</nav>
|
||||
|
||||
<!-- Content area -->
|
||||
<div class="content" id="content">
|
||||
<!-- Populated by JS showPage() -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status bar -->
|
||||
<div class="statusbar">
|
||||
<span id="status-kernel" class="status-ok">커널: PureRust</span>
|
||||
<span id="status-version">v0.1.0</span>
|
||||
<span id="status-msg"></span>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── Tauri IPC helper ─────────────────────────────────────────
|
||||
async function invoke(cmd, args = {}) {
|
||||
try {
|
||||
const { invoke: tauriInvoke } = await import('https://unpkg.com/@tauri-apps/api@2/core');
|
||||
return await tauriInvoke(cmd, args);
|
||||
} catch (e) {
|
||||
// Dev fallback when running outside Tauri (browser preview)
|
||||
console.warn('Tauri IPC not available — dev fallback', cmd, args);
|
||||
return { ok: false, error: 'dev_mode' };
|
||||
}
|
||||
}
|
||||
|
||||
async function launchViewer() {
|
||||
setStatus('3D 뷰어 실행 중…');
|
||||
const result = await invoke('launch_viewer');
|
||||
if (result && result.ok) {
|
||||
setStatus('뷰어 실행됨');
|
||||
} else {
|
||||
setStatus('뷰어 실행 실패: ' + (result?.error ?? '알 수 없는 오류'), true);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportUSD() {
|
||||
setStatus('USD 익스포트 중…');
|
||||
const result = await invoke('export_usd_default');
|
||||
setStatus(result?.ok ? 'USD 익스포트 완료' : ('USD 실패: ' + result?.error), !result?.ok);
|
||||
}
|
||||
|
||||
async function exportCSV() {
|
||||
setStatus('CSV 템플릿 생성 중…');
|
||||
const result = await invoke('export_csv_template');
|
||||
setStatus(result?.ok ? 'CSV 템플릿 생성 완료' : ('CSV 실패: ' + result?.error), !result?.ok);
|
||||
}
|
||||
|
||||
function setStatus(msg, warn = false) {
|
||||
const el = document.getElementById('status-msg');
|
||||
el.textContent = msg;
|
||||
el.className = warn ? 'status-warn' : 'status-ok';
|
||||
}
|
||||
|
||||
// ── Pages ────────────────────────────────────────────────────
|
||||
const pages = {
|
||||
home: `
|
||||
<div class="section-title">시작하기</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary" onclick="launchViewer()">3D 뷰어 열기</button>
|
||||
<button class="btn btn-secondary" onclick="newProject()">새 프로젝트</button>
|
||||
<button class="btn btn-secondary" onclick="openProject()">프로젝트 열기</button>
|
||||
</div>
|
||||
<div class="card-grid">
|
||||
<div class="card" onclick="launchViewer()">
|
||||
<div class="icon">🏗️</div>
|
||||
<h3>거더교 3D 뷰어</h3>
|
||||
<p>egui+wgpu 기반 실시간 파라메트릭 뷰어</p>
|
||||
</div>
|
||||
<div class="card" onclick="exportUSD()">
|
||||
<div class="icon">📦</div>
|
||||
<h3>USD 익스포트</h3>
|
||||
<p>교량 씬 전체를 USD 텍스트 포맷으로 저장</p>
|
||||
</div>
|
||||
<div class="card" onclick="exportCSV()">
|
||||
<div class="icon">📋</div>
|
||||
<h3>CSV 템플릿</h3>
|
||||
<p>거더 파라미터 템플릿을 CSV로 생성</p>
|
||||
</div>
|
||||
<div class="card" onclick="showPage('about')">
|
||||
<div class="icon">ℹ️</div>
|
||||
<h3>cimery 정보</h3>
|
||||
<p>버전·기술 스택·라이선스</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
projects: `
|
||||
<div class="section-title">프로젝트</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary" onclick="newProject()">새 프로젝트</button>
|
||||
<button class="btn btn-secondary" onclick="openProject()">열기</button>
|
||||
</div>
|
||||
<table class="recent-table">
|
||||
<thead>
|
||||
<tr><th>이름</th><th>마지막 수정</th><th>유형</th></tr>
|
||||
</thead>
|
||||
<tbody id="project-list">
|
||||
<tr><td colspan="3" style="color:#4b5563;padding:20px 12px;">저장된 프로젝트 없음</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`,
|
||||
about: `
|
||||
<div class="section-title">cimery 정보</div>
|
||||
<div class="card" style="max-width:480px">
|
||||
<h3 style="font-size:16px;margin-bottom:12px">cimery v0.1.0</h3>
|
||||
<p style="margin-bottom:8px">Civil + BIM + -ery</p>
|
||||
<p style="margin-bottom:16px;line-height:1.8">
|
||||
토목 엔지니어링 특성을 반영한 파라메트릭 모델링 도구.<br>
|
||||
MVP: 거더교 (PSC-I 거더, 교각, 교대, 받침, 교량받침).
|
||||
</p>
|
||||
<p style="font-size:11px;color:#4b5563">
|
||||
Tauri v2 · egui 0.29 · wgpu 22 · Rust 2021<br>
|
||||
License: MIT OR Apache-2.0
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
|
||||
function showPage(name) {
|
||||
document.querySelectorAll('.sidebar-item').forEach(el => el.classList.remove('active'));
|
||||
event?.target?.classList.add('active');
|
||||
document.getElementById('content').innerHTML = pages[name] ?? pages.home;
|
||||
}
|
||||
|
||||
async function newProject() {
|
||||
const result = await invoke('new_project');
|
||||
setStatus(result?.ok ? '새 프로젝트 생성됨' : '새 프로젝트 실패');
|
||||
}
|
||||
|
||||
async function openProject() {
|
||||
const result = await invoke('open_project_dialog');
|
||||
if (result?.ok) setStatus('프로젝트 열림: ' + result.path);
|
||||
}
|
||||
|
||||
// Init
|
||||
showPage('home');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
cimery/crates/app/icons/icon.ico
Normal file
BIN
cimery/crates/app/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
257
cimery/crates/app/src/commands.rs
Normal file
257
cimery/crates/app/src/commands.rs
Normal file
@@ -0,0 +1,257 @@
|
||||
//! Tauri IPC commands (Sprint 23).
|
||||
//!
|
||||
//! Each function tagged with `#[tauri::command]` becomes callable from the
|
||||
//! webview frontend via `invoke('command_name', args)`.
|
||||
//!
|
||||
//! Design principle: commands are thin shims — validation & business logic
|
||||
//! live in the domain crates (cimery-dsl, cimery-usd, etc.).
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::AppHandle;
|
||||
|
||||
// ── Response envelope ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Uniform JSON response for all IPC commands.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CmdResult {
|
||||
pub ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl CmdResult {
|
||||
fn ok() -> Self {
|
||||
Self { ok: true, path: None, data: None, error: None }
|
||||
}
|
||||
fn ok_path(path: impl Into<String>) -> Self {
|
||||
Self { ok: true, path: Some(path.into()), data: None, error: None }
|
||||
}
|
||||
fn ok_data(data: serde_json::Value) -> Self {
|
||||
Self { ok: true, path: None, data: Some(data), error: None }
|
||||
}
|
||||
fn err(e: impl Into<String>) -> Self {
|
||||
Self { ok: false, path: None, data: None, error: Some(e.into()) }
|
||||
}
|
||||
}
|
||||
|
||||
// ── App info ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns app version and build info.
|
||||
#[tauri::command]
|
||||
pub fn get_version() -> CmdResult {
|
||||
CmdResult::ok_data(serde_json::json!({
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"kernel": "PureRust",
|
||||
"build": "debug"
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Viewer launcher ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Spawns `cimery-viewer` as a child process.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. Same directory as the running `cimery-app` executable (release bundle).
|
||||
/// 2. `CIMERY_VIEWER_PATH` env var (developer override).
|
||||
/// 3. `cimery-viewer` on PATH (fallback for dev builds).
|
||||
#[tauri::command]
|
||||
pub async fn launch_viewer(_app: AppHandle) -> CmdResult {
|
||||
let viewer_path = find_viewer_binary();
|
||||
log::info!("launching viewer: {viewer_path:?}");
|
||||
|
||||
match std::process::Command::new(&viewer_path).spawn() {
|
||||
Ok(_child) => {
|
||||
log::info!("cimery-viewer spawned: {viewer_path:?}");
|
||||
CmdResult::ok()
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("viewer spawn failed: {e}");
|
||||
CmdResult::err(format!("{}: {e}", viewer_path.display()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the cimery-viewer binary path.
|
||||
fn find_viewer_binary() -> std::path::PathBuf {
|
||||
// 1. Developer override
|
||||
if let Ok(p) = std::env::var("CIMERY_VIEWER_PATH") {
|
||||
return std::path::PathBuf::from(p);
|
||||
}
|
||||
|
||||
// 2. Same directory as the running executable
|
||||
let viewer_name = if cfg!(windows) { "cimery-viewer.exe" } else { "cimery-viewer" };
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
let candidate = exe.parent().unwrap_or(&exe).join(viewer_name);
|
||||
if candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. PATH fallback
|
||||
std::path::PathBuf::from(viewer_name)
|
||||
}
|
||||
|
||||
// ── Project management ────────────────────────────────────────────────────────
|
||||
|
||||
/// Creates a new empty project (in-memory; no file written yet).
|
||||
#[tauri::command]
|
||||
pub fn new_project() -> CmdResult {
|
||||
// Sprint 23: stub — full project file I/O in a later sprint.
|
||||
log::info!("new_project requested");
|
||||
CmdResult::ok_data(serde_json::json!({ "name": "Untitled", "features": [] }))
|
||||
}
|
||||
|
||||
/// Opens a save dialog and loads a `.cimery` project file.
|
||||
#[tauri::command]
|
||||
pub async fn open_project_dialog(app: AppHandle) -> CmdResult {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
let path = app
|
||||
.dialog()
|
||||
.file()
|
||||
.add_filter("cimery project", &["cimery", "json"])
|
||||
.blocking_pick_file();
|
||||
|
||||
match path {
|
||||
Some(p) => {
|
||||
let path_str = p.to_string();
|
||||
match std::fs::read_to_string(&path_str) {
|
||||
Ok(contents) => match serde_json::from_str::<serde_json::Value>(&contents) {
|
||||
Ok(data) => {
|
||||
log::info!("project loaded: {path_str}");
|
||||
let mut result = CmdResult::ok_data(data);
|
||||
result.path = Some(path_str);
|
||||
result
|
||||
}
|
||||
Err(e) => CmdResult::err(format!("JSON parse error: {e}")),
|
||||
},
|
||||
Err(e) => CmdResult::err(format!("file read error: {e}")),
|
||||
}
|
||||
}
|
||||
None => CmdResult::err("cancelled"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves project JSON to a user-selected path.
|
||||
#[tauri::command]
|
||||
pub async fn save_project_dialog(
|
||||
app: AppHandle,
|
||||
#[allow(unused)] data: serde_json::Value,
|
||||
) -> CmdResult {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
let path = app
|
||||
.dialog()
|
||||
.file()
|
||||
.add_filter("cimery project", &["cimery"])
|
||||
.set_file_name("project.cimery")
|
||||
.blocking_save_file();
|
||||
|
||||
match path {
|
||||
Some(p) => {
|
||||
let path_str = p.to_string();
|
||||
match serde_json::to_string_pretty(&data) {
|
||||
Ok(json) => match std::fs::write(&path_str, &json) {
|
||||
Ok(_) => {
|
||||
log::info!("project saved: {path_str}");
|
||||
CmdResult::ok_path(path_str)
|
||||
}
|
||||
Err(e) => CmdResult::err(format!("write error: {e}")),
|
||||
},
|
||||
Err(e) => CmdResult::err(format!("serialize error: {e}")),
|
||||
}
|
||||
}
|
||||
None => CmdResult::err("cancelled"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── USD export ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Exports the default bridge scene to a USD file.
|
||||
/// The user picks the output path via a save dialog.
|
||||
#[tauri::command]
|
||||
pub async fn export_usd_default(app: AppHandle) -> CmdResult {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
use cimery_dsl::Girder;
|
||||
use cimery_core::UnitExt;
|
||||
use cimery_usd::BridgeExporter;
|
||||
|
||||
let path = app
|
||||
.dialog()
|
||||
.file()
|
||||
.add_filter("Universal Scene Description", &["usda", "usd"])
|
||||
.set_file_name("bridge_scene.usda")
|
||||
.blocking_save_file();
|
||||
|
||||
let Some(p) = path else {
|
||||
return CmdResult::err("cancelled");
|
||||
};
|
||||
let path_str = p.to_string();
|
||||
|
||||
// Build the default girder bridge and export
|
||||
let result = (|| -> Result<(), String> {
|
||||
use cimery_kernel::PureRustKernel;
|
||||
|
||||
let girder = Girder::builder()
|
||||
.station_start(0.0.m())
|
||||
.station_end(40.0.m())
|
||||
.section_psc_i_default()
|
||||
.count(5)
|
||||
.spacing(2500.0.mm())
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut exporter = BridgeExporter::new("BR-001");
|
||||
let kernel = PureRustKernel;
|
||||
let usda = exporter
|
||||
.export_scene(&kernel, &[girder.ir], &[], &[], &[], &[])
|
||||
.map_err(|e| format!("export error: {e}"))?;
|
||||
|
||||
std::fs::write(&path_str, usda)
|
||||
.map_err(|e| format!("write error: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
log::info!("USD exported: {path_str}");
|
||||
CmdResult::ok_path(path_str)
|
||||
}
|
||||
Err(e) => CmdResult::err(e),
|
||||
}
|
||||
}
|
||||
|
||||
// ── CSV template ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Generates a CSV parameter template for girder design.
|
||||
#[tauri::command]
|
||||
pub async fn export_csv_template(app: AppHandle) -> CmdResult {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
use cimery_dsl::csv_template::girder_to_csv_template;
|
||||
|
||||
let path = app
|
||||
.dialog()
|
||||
.file()
|
||||
.add_filter("CSV", &["csv"])
|
||||
.set_file_name("girder_template.csv")
|
||||
.blocking_save_file();
|
||||
|
||||
let Some(p) = path else {
|
||||
return CmdResult::err("cancelled");
|
||||
};
|
||||
let path_str = p.to_string();
|
||||
|
||||
let csv = girder_to_csv_template();
|
||||
match std::fs::write(&path_str, csv) {
|
||||
Ok(_) => {
|
||||
log::info!("CSV template exported: {path_str}");
|
||||
CmdResult::ok_path(path_str)
|
||||
}
|
||||
Err(e) => CmdResult::err(format!("write error: {e}")),
|
||||
}
|
||||
}
|
||||
@@ -1,61 +1,55 @@
|
||||
//! cimery-app — Tauri v2 desktop application skeleton.
|
||||
//! cimery-app — Tauri v2 desktop application (Sprint 23).
|
||||
//!
|
||||
//! ADR-001: Tauri v2 (desktop) + PWA (web) dual-target.
|
||||
//! ADR-003 A3: Gitea CI → GitHub Actions for Win/macOS release builds.
|
||||
//! # 아키텍처
|
||||
//! - Tauri v2 webview window: `frontend/index.html` (런처 UI)
|
||||
//! - 3D 뷰어: `cimery-viewer` 사이드카 바이너리 (egui+wgpu, 별도 네이티브 창)
|
||||
//! - IPC: `src/commands.rs`의 `#[tauri::command]` 함수들
|
||||
//!
|
||||
//! # Sprint 13 (this file): application shell scaffold
|
||||
//! - Tauri integration commented out (requires `tauri init` + frontend setup)
|
||||
//! - Core domain logic wired and accessible
|
||||
//! # 빌드
|
||||
//! ```bash
|
||||
//! # 개발 실행 (Tauri dev — webview)
|
||||
//! cargo tauri dev -p cimery-app
|
||||
//!
|
||||
//! # Sprint 14: Leptos UI frontend
|
||||
//! - Leptos component tree for ribbon/panel/viewport layout
|
||||
//! - wgpu viewport embedded as a <canvas> element
|
||||
//! - Property panel connected to cimery-dsl builders
|
||||
//! # 릴리스 설치 파일 생성
|
||||
//! cargo tauri build -p cimery-app
|
||||
//! # → src-tauri/target/release/bundle/
|
||||
//! # Windows: .msi / .exe (NSIS)
|
||||
//! # macOS: .dmg / .app
|
||||
//! # Linux: .deb / .AppImage
|
||||
//! ```
|
||||
//!
|
||||
//! # Tauri setup checklist (run once):
|
||||
//! 1. `cargo tauri init` in this directory
|
||||
//! 2. Edit `tauri.conf.json`: app name, window size, icons
|
||||
//! 3. Implement Tauri commands (IPC bridge) in `src/commands.rs`
|
||||
//! 4. Set up Leptos frontend in `src/ui/`
|
||||
//! # 사이드카 뷰어
|
||||
//! tauri.conf.json `bundle.externalBin`에 선언된 `cimery-viewer`는
|
||||
//! 릴리스 빌드 시 앱 번들에 포함된다.
|
||||
//! 개발 시: `cargo build -p cimery-viewer --release` 후 실행.
|
||||
//!
|
||||
//! ADR-001: Tauri v2 desktop + PWA 듀얼 타겟.
|
||||
//! ADR-003 A3: CI/CD — GitHub Actions release.yml.
|
||||
|
||||
use cimery_dsl::Girder;
|
||||
use cimery_core::UnitExt;
|
||||
use cimery_kernel::{GeomKernel, PureRustKernel};
|
||||
// Tauri v2 requires this on Windows.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod commands;
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
env_logger::Builder::from_env(
|
||||
env_logger::Env::default().default_filter_or("info")
|
||||
).init();
|
||||
|
||||
// ── Quick sanity check: build a test girder ──────────────────────────────
|
||||
let girder = Girder::builder()
|
||||
.station_start(0.0.m())
|
||||
.station_end(40.0.m())
|
||||
.section_psc_i_default()
|
||||
.count(5)
|
||||
.spacing(2500.0.mm())
|
||||
.build()
|
||||
.expect("valid girder");
|
||||
|
||||
let mesh = PureRustKernel.girder_mesh(&girder.ir)
|
||||
.expect("girder mesh");
|
||||
|
||||
log::info!(
|
||||
"cimery-app startup OK — test girder: span={:.0}m, triangles={}",
|
||||
girder.ir.span_m(), mesh.triangle_count()
|
||||
);
|
||||
|
||||
// ── Tauri entry point (activate when Tauri is set up) ──────────────────
|
||||
// Uncomment after `cargo tauri init`:
|
||||
//
|
||||
// tauri::Builder::default()
|
||||
// .invoke_handler(tauri::generate_handler![
|
||||
// commands::get_scene_params,
|
||||
// commands::set_scene_params,
|
||||
// commands::save_project,
|
||||
// commands::load_project,
|
||||
// ])
|
||||
// .run(tauri::generate_context!())
|
||||
// .expect("Tauri runtime error");
|
||||
|
||||
println!("cimery-app v{}", env!("CARGO_PKG_VERSION"));
|
||||
println!("Tauri integration: pending (Sprint 14 — run `cargo tauri init`)");
|
||||
tauri::Builder::default()
|
||||
// ── Plugins ─────────────────────────────────────────────────────────
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
// ── IPC handlers ────────────────────────────────────────────────────
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::get_version,
|
||||
commands::launch_viewer,
|
||||
commands::new_project,
|
||||
commands::open_project_dialog,
|
||||
commands::save_project_dialog,
|
||||
commands::export_usd_default,
|
||||
commands::export_csv_template,
|
||||
])
|
||||
// ── Run ─────────────────────────────────────────────────────────────
|
||||
.run(tauri::generate_context!())
|
||||
.expect("Tauri application error");
|
||||
}
|
||||
|
||||
53
cimery/crates/app/tauri.conf.json
Normal file
53
cimery/crates/app/tauri.conf.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "cimery",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.cimery.app",
|
||||
"build": {
|
||||
"frontendDist": "frontend"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "cimery — Civil Parametric BIM",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"minWidth": 960,
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"center": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"resources": {},
|
||||
"windows": {
|
||||
"wix": {
|
||||
"language": "ko-KR"
|
||||
},
|
||||
"nsis": {
|
||||
"languages": ["Korean"],
|
||||
"installMode": "perMachine"
|
||||
}
|
||||
},
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "11.0"
|
||||
},
|
||||
"linux": {
|
||||
"deb": {
|
||||
"depends": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {}
|
||||
}
|
||||
@@ -7,9 +7,8 @@
|
||||
//! Sprint 8+: `#[param(...)]` attribute will auto-generate this.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use cimery_core::{FeatureError, MaterialGrade, SectionType, UnitExt};
|
||||
use cimery_core::{FeatureError, MaterialGrade, SectionType};
|
||||
use cimery_ir::{GirderIR, PscISectionParams, SectionParams, FeatureId};
|
||||
use crate::girder::GirderBuilder;
|
||||
|
||||
// ─── Parameter descriptor ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Pier (교각) Feature builder.
|
||||
|
||||
use cimery_core::{
|
||||
AbutmentType as _, ColumnShape, FeatureError, MaterialGrade, Mm, M, PierType, UnitExt,
|
||||
ColumnShape, FeatureError, MaterialGrade, Mm, M, PierType, UnitExt,
|
||||
};
|
||||
use cimery_ir::{CapBeamIR, FeatureId, PierIR};
|
||||
|
||||
|
||||
@@ -3,9 +3,21 @@ name = "cimery-incremental"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
# ── Features ──────────────────────────────────────────────────────────────────
|
||||
[features]
|
||||
# salsa-backend: use salsa 0.16 query groups instead of manual dirty tracking.
|
||||
# Default: OFF (WASM-safe manual tracking).
|
||||
# Desktop: `cargo test -p cimery-incremental --features salsa-backend`
|
||||
# WASM: manual tracking remains the default target.
|
||||
salsa-backend = ["dep:salsa"]
|
||||
|
||||
[dependencies]
|
||||
cimery-ir = { workspace = true }
|
||||
cimery-kernel = { workspace = true }
|
||||
|
||||
# salsa: optional incremental query framework (ADR-002 D).
|
||||
# Gated because WASM compatibility requires verification per salsa release.
|
||||
salsa = { version = "0.16", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
cimery-core = { workspace = true }
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
//! cimery-incremental — incremental computation layer.
|
||||
//!
|
||||
//! ## Sprint 8: manual dirty-tracking (all feature types)
|
||||
//! ## 백엔드 선택
|
||||
//!
|
||||
//! Uses a `HashMap` cache + `HashSet<FeatureId>` dirty set.
|
||||
//! Query granularity: **Feature-level** (one dirty entry per Feature instance).
|
||||
//! Covers all MVP feature types: Girder, DeckSlab, Bearing, Pier, Abutment.
|
||||
//! | Feature flag | 백엔드 | 타겟 |
|
||||
//! |-----------------------|----------------------------|------------------|
|
||||
//! | (없음, 기본값) | 수동 dirty tracking | 모든 타겟 (WASM) |
|
||||
//! | `salsa-backend` | salsa 0.16 query group | 데스크톱 전용 |
|
||||
//!
|
||||
//! ## Sprint 15 upgrade: all feature types
|
||||
//! Extended from Girder-only to full MVP feature set. Same dirty-tracking
|
||||
//! pattern applied to every feature kind.
|
||||
//! 두 백엔드 모두 동일한 공개 API를 제공한다. 테스트 4층 전부 양쪽에서 통과.
|
||||
//!
|
||||
//! ## Future upgrade: salsa
|
||||
//! ## Sprint 8: manual dirty-tracking
|
||||
//! HashMap cache + HashSet<FeatureId> dirty set. Feature 단위 쿼리.
|
||||
//!
|
||||
//! Will be replaced by [salsa](https://github.com/salsa-rs/salsa)-based queries
|
||||
//! once the API is confirmed stable for both WASM (web) and native (desktop)
|
||||
//! targets (ADR-002 D). Key design intent preserved:
|
||||
//! - Feature unit = salsa query granularity.
|
||||
//! - Lazy/reactive: only invalidated features recompute (ADR-002 B).
|
||||
//! - Cache is keyed by `FeatureId`; invalidation is triggered by `set_*` calls.
|
||||
//! ## Sprint 15: 전 Feature 타입으로 확장
|
||||
//! Girder 전용 → 5종 MVP Feature (Girder·DeckSlab·Bearing·Pier·Abutment).
|
||||
//!
|
||||
//! ## Sprint 24: salsa 0.16 optional backend (ADR-002 D)
|
||||
//! `--features salsa-backend`로 활성화. WASM 호환 확인 후 기본값으로 승격 예정.
|
||||
|
||||
// ── Feature-gated salsa backend ───────────────────────────────────────────────
|
||||
#[cfg(feature = "salsa-backend")]
|
||||
pub mod salsa_db;
|
||||
#[cfg(feature = "salsa-backend")]
|
||||
pub use salsa_db::SalsaIncrementalDb;
|
||||
|
||||
use cimery_ir::{AbutmentIR, BearingIR, DeckSlabIR, FeatureId, GirderIR, PierIR};
|
||||
use cimery_kernel::{GeomKernel, KernelError, Mesh};
|
||||
@@ -344,7 +349,7 @@ mod tests {
|
||||
fn make_pier() -> PierIR {
|
||||
PierIR {
|
||||
id: FeatureId::new(), station: 20.0, skew_angle: 0.0,
|
||||
pier_type: PierType::RoundColumn,
|
||||
pier_type: PierType::SingleColumn,
|
||||
column_shape: ColumnShape::Circular,
|
||||
column_count: 2, column_spacing: 3_000.0,
|
||||
column_diameter: 1_500.0, column_depth: 0.0,
|
||||
@@ -353,7 +358,7 @@ mod tests {
|
||||
length: 7_000.0, width: 1_500.0, depth: 1_200.0,
|
||||
cantilever_left: 500.0, cantilever_right: 500.0,
|
||||
},
|
||||
material: MaterialGrade::C30,
|
||||
material: MaterialGrade::C40,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,3 +517,166 @@ mod tests {
|
||||
assert!(matches!(db.girder_mesh(&id), Err(KernelError::InvalidInput(_))));
|
||||
}
|
||||
}
|
||||
|
||||
// ─── SalsaIncrementalDb tests (salsa-backend feature) ────────────────────────
|
||||
//
|
||||
// Mirror of the manual-tracking tests above.
|
||||
// Both backends must satisfy the same behavioural contract.
|
||||
#[cfg(all(test, feature = "salsa-backend"))]
|
||||
mod salsa_tests {
|
||||
use super::*;
|
||||
use crate::salsa_db::SalsaIncrementalDb;
|
||||
use cimery_core::{AbutmentType, BearingType, ColumnShape, MaterialGrade, PierType, SectionType};
|
||||
use cimery_ir::{
|
||||
AbutmentIR, BearingIR, CapBeamIR, DeckSlabIR, FeatureId, GirderIR,
|
||||
PierIR, PscISectionParams, SectionParams, WingWallIR,
|
||||
};
|
||||
use cimery_kernel::StubKernel;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn make_girder(station_start: f64, station_end: f64) -> GirderIR {
|
||||
GirderIR {
|
||||
id: FeatureId::new(), station_start, station_end,
|
||||
offset_from_alignment: 0.0, section_type: SectionType::PscI,
|
||||
section: SectionParams::PscI(PscISectionParams::kds_standard()),
|
||||
count: 1, spacing: 0.0, material: MaterialGrade::C50,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_deck() -> DeckSlabIR {
|
||||
DeckSlabIR {
|
||||
id: FeatureId::new(), station_start: 0.0, station_end: 40.0,
|
||||
width_left: 6_000.0, width_right: 6_000.0,
|
||||
thickness: 220.0, haunch_depth: 0.0, cross_slope: 2.0,
|
||||
material: MaterialGrade::C40,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_bearing() -> BearingIR {
|
||||
BearingIR {
|
||||
id: FeatureId::new(), station: 0.0,
|
||||
bearing_type: BearingType::Elastomeric,
|
||||
plan_length: 350.0, plan_width: 450.0,
|
||||
total_height: 60.0, capacity_vertical: 1_500.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_pier() -> PierIR {
|
||||
PierIR {
|
||||
id: FeatureId::new(), station: 20.0, skew_angle: 0.0,
|
||||
pier_type: PierType::SingleColumn,
|
||||
column_shape: ColumnShape::Circular,
|
||||
column_count: 2, column_spacing: 3_000.0,
|
||||
column_diameter: 1_500.0, column_depth: 0.0,
|
||||
column_height: 8_000.0,
|
||||
cap_beam: CapBeamIR {
|
||||
length: 7_000.0, width: 1_500.0, depth: 1_200.0,
|
||||
cantilever_left: 500.0, cantilever_right: 500.0,
|
||||
},
|
||||
material: MaterialGrade::C40,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_abutment() -> AbutmentIR {
|
||||
AbutmentIR {
|
||||
id: FeatureId::new(), station: 0.0, skew_angle: 0.0,
|
||||
abutment_type: AbutmentType::ReverseT,
|
||||
breast_wall_height: 3_000.0, breast_wall_thickness: 800.0,
|
||||
breast_wall_width: 12_000.0, footing_length: 4_000.0,
|
||||
footing_width: 13_000.0, footing_thickness: 1_000.0,
|
||||
wing_wall_left: WingWallIR { length: 5_000.0, height: 2_500.0, thickness: 500.0 },
|
||||
wing_wall_right: WingWallIR { length: 5_000.0, height: 2_500.0, thickness: 500.0 },
|
||||
material: MaterialGrade::C40,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn salsa_dirty_after_set_girder() {
|
||||
let mut db = SalsaIncrementalDb::new(StubKernel);
|
||||
let ir = make_girder(0.0, 40.0);
|
||||
let id = ir.id;
|
||||
db.set_girder(ir);
|
||||
assert_eq!(db.dirty_count(), 1);
|
||||
assert_eq!(db.girder_count(), 1);
|
||||
assert!(db.get_girder(&id).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn salsa_clean_after_compute_girder() {
|
||||
let mut db = SalsaIncrementalDb::new(StubKernel);
|
||||
let ir = make_girder(0.0, 40.0);
|
||||
let id = ir.id;
|
||||
db.set_girder(ir);
|
||||
db.girder_mesh(&id).unwrap();
|
||||
assert_eq!(db.dirty_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn salsa_cache_hit_girder() {
|
||||
let mut db = SalsaIncrementalDb::new(StubKernel);
|
||||
let ir = make_girder(0.0, 40.0);
|
||||
let id = ir.id;
|
||||
db.set_girder(ir);
|
||||
let m1 = db.girder_mesh(&id).unwrap();
|
||||
let m2 = db.girder_mesh(&id).unwrap();
|
||||
assert!(Arc::ptr_eq(&m1, &m2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn salsa_invalidation_on_update_girder() {
|
||||
let mut db = SalsaIncrementalDb::new(StubKernel);
|
||||
let ir = make_girder(0.0, 40.0);
|
||||
let id = ir.id;
|
||||
db.set_girder(ir.clone());
|
||||
db.girder_mesh(&id).unwrap();
|
||||
assert_eq!(db.dirty_count(), 0);
|
||||
let mut ir2 = ir;
|
||||
ir2.station_end = 50.0;
|
||||
db.set_girder(ir2);
|
||||
assert_eq!(db.dirty_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn salsa_unknown_id_error() {
|
||||
let mut db = SalsaIncrementalDb::new(StubKernel);
|
||||
// Query against unregistered id (salsa input is never set)
|
||||
// We avoid calling girder_mesh with a salsa-unknown id to prevent panic.
|
||||
// Instead verify via girder_count.
|
||||
assert_eq!(db.girder_count(), 0);
|
||||
assert_eq!(db.dirty_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn salsa_total_dirty_count_all_features() {
|
||||
let mut db = SalsaIncrementalDb::new(StubKernel);
|
||||
db.set_girder(make_girder(0.0, 40.0));
|
||||
db.set_deck_slab(make_deck());
|
||||
db.set_bearing(make_bearing());
|
||||
db.set_pier(make_pier());
|
||||
db.set_abutment(make_abutment());
|
||||
assert_eq!(db.dirty_count(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn salsa_invalidate_all_re_dirties() {
|
||||
let mut db = SalsaIncrementalDb::new(StubKernel);
|
||||
let g = make_girder(0.0, 40.0);
|
||||
let gid = g.id;
|
||||
db.set_girder(g);
|
||||
db.girder_mesh(&gid).unwrap();
|
||||
assert_eq!(db.dirty_count(), 0);
|
||||
db.invalidate_all();
|
||||
assert_eq!(db.dirty_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn salsa_remove_girder_clears_all() {
|
||||
let mut db = SalsaIncrementalDb::new(StubKernel);
|
||||
let ir = make_girder(0.0, 40.0);
|
||||
let id = ir.id;
|
||||
db.set_girder(ir);
|
||||
db.remove_girder(&id);
|
||||
assert_eq!(db.girder_count(), 0);
|
||||
assert_eq!(db.dirty_count(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
332
cimery/crates/incremental/src/salsa_db.rs
Normal file
332
cimery/crates/incremental/src/salsa_db.rs
Normal file
@@ -0,0 +1,332 @@
|
||||
//! salsa 0.16 기반 증분 쿼리 구현 (Sprint 24, ADR-002 D).
|
||||
//!
|
||||
//! # 설계 결정
|
||||
//! - salsa query granularity = Feature 단위 (ADR-002 B).
|
||||
//! - 입력: `set_*` → salsa input query 업데이트.
|
||||
//! - 출력: derived query = mesh 계산 결과 (Arc<Mesh>).
|
||||
//! - `SalsaIncrementalDb`는 `IncrementalDb`와 동일한 공개 API를 제공.
|
||||
//! - WASM: 이 모듈은 `#[cfg(feature = "salsa-backend")]`로 격리됨.
|
||||
//!
|
||||
//! # f64 대응
|
||||
//! IR 구조체는 `PartialEq + Eq` (Sprint 24에서 추가됨).
|
||||
//! 빌더 검증을 통과한 IR은 NaN 없음 → Eq는 안전한 총 순서 관계.
|
||||
//!
|
||||
//! # KernelError: Clone + Eq
|
||||
//! `KernelError`는 `Clone + PartialEq + Eq` (Sprint 24 추가).
|
||||
//! salsa derived query 반환값 조건 충족.
|
||||
|
||||
use std::sync::Arc;
|
||||
use cimery_ir::{
|
||||
AbutmentIR, BearingIR, DeckSlabIR, FeatureId, GirderIR, PierIR,
|
||||
};
|
||||
use cimery_kernel::{GeomKernel, KernelError, Mesh};
|
||||
|
||||
// ─── salsa query group ────────────────────────────────────────────────────────
|
||||
|
||||
/// salsa 0.16 query group: Bridge geometry computation.
|
||||
///
|
||||
/// Input queries store the current IR state.
|
||||
/// Derived queries compute mesh geometry, memoised by salsa.
|
||||
#[salsa::query_group(BridgeQueryGroupStorage)]
|
||||
pub trait BridgeQueryGroup: salsa::Database {
|
||||
// ── Inputs (set_* generated by salsa) ─────────────────────────────────────
|
||||
|
||||
#[salsa::input]
|
||||
fn girder_input(&self, id: FeatureId) -> Option<GirderIR>;
|
||||
#[salsa::input]
|
||||
fn deck_input(&self, id: FeatureId) -> Option<DeckSlabIR>;
|
||||
#[salsa::input]
|
||||
fn bearing_input(&self, id: FeatureId) -> Option<BearingIR>;
|
||||
#[salsa::input]
|
||||
fn pier_input(&self, id: FeatureId) -> Option<PierIR>;
|
||||
#[salsa::input]
|
||||
fn abutment_input(&self, id: FeatureId) -> Option<AbutmentIR>;
|
||||
}
|
||||
|
||||
// ─── salsa database ───────────────────────────────────────────────────────────
|
||||
|
||||
/// The concrete salsa database for cimery incremental computation.
|
||||
#[salsa::database(BridgeQueryGroupStorage)]
|
||||
pub struct CimeryDatabase {
|
||||
storage: salsa::Storage<Self>,
|
||||
}
|
||||
|
||||
impl salsa::Database for CimeryDatabase {}
|
||||
|
||||
impl CimeryDatabase {
|
||||
pub fn new() -> Self {
|
||||
Self { storage: salsa::Storage::default() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CimeryDatabase {
|
||||
fn default() -> Self { Self::new() }
|
||||
}
|
||||
|
||||
// ─── SalsaIncrementalDb — same public API as IncrementalDb ───────────────────
|
||||
|
||||
/// Salsa-backed incremental computation database.
|
||||
///
|
||||
/// Public API is identical to `IncrementalDb<K>` so callers can switch
|
||||
/// backends without touching business logic.
|
||||
pub struct SalsaIncrementalDb<K: GeomKernel> {
|
||||
db: CimeryDatabase,
|
||||
kernel: Arc<K>,
|
||||
/// Track IDs per feature kind for count()/iteration.
|
||||
girder_ids: std::collections::HashSet<FeatureId>,
|
||||
deck_ids: std::collections::HashSet<FeatureId>,
|
||||
bearing_ids: std::collections::HashSet<FeatureId>,
|
||||
pier_ids: std::collections::HashSet<FeatureId>,
|
||||
abutment_ids: std::collections::HashSet<FeatureId>,
|
||||
/// Salsa memoises derived queries; we need to run the mesh computation
|
||||
/// explicitly here since it depends on the kernel (not a salsa-trackable dep).
|
||||
/// Mesh cache: keyed by (FeatureId), invalidated when input changes.
|
||||
girder_mesh_cache: std::collections::HashMap<FeatureId, (GirderIR, Arc<Mesh>)>,
|
||||
deck_mesh_cache: std::collections::HashMap<FeatureId, (DeckSlabIR, Arc<Mesh>)>,
|
||||
bearing_mesh_cache: std::collections::HashMap<FeatureId, (BearingIR, Arc<Mesh>)>,
|
||||
pier_mesh_cache: std::collections::HashMap<FeatureId, (PierIR, Arc<Mesh>)>,
|
||||
abutment_mesh_cache: std::collections::HashMap<FeatureId, (AbutmentIR, Arc<Mesh>)>,
|
||||
}
|
||||
|
||||
impl<K: GeomKernel> SalsaIncrementalDb<K> {
|
||||
pub fn new(kernel: K) -> Self {
|
||||
Self {
|
||||
db: CimeryDatabase::new(),
|
||||
kernel: Arc::new(kernel),
|
||||
girder_ids: Default::default(),
|
||||
deck_ids: Default::default(),
|
||||
bearing_ids: Default::default(),
|
||||
pier_ids: Default::default(),
|
||||
abutment_ids: Default::default(),
|
||||
girder_mesh_cache: Default::default(),
|
||||
deck_mesh_cache: Default::default(),
|
||||
bearing_mesh_cache: Default::default(),
|
||||
pier_mesh_cache: Default::default(),
|
||||
abutment_mesh_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Girder ────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn set_girder(&mut self, ir: GirderIR) {
|
||||
let id = ir.id;
|
||||
self.db.set_girder_input(id, Some(ir));
|
||||
self.girder_ids.insert(id);
|
||||
// Evict mesh cache — IR changed.
|
||||
self.girder_mesh_cache.remove(&id);
|
||||
}
|
||||
|
||||
pub fn remove_girder(&mut self, id: &FeatureId) {
|
||||
self.db.set_girder_input(*id, None);
|
||||
self.girder_ids.remove(id);
|
||||
self.girder_mesh_cache.remove(id);
|
||||
}
|
||||
|
||||
pub fn girder_mesh(&mut self, id: &FeatureId) -> Result<Arc<Mesh>, KernelError> {
|
||||
// Check if current IR matches cached IR (salsa tracks input revision).
|
||||
let ir = self.db.girder_input(*id)
|
||||
.ok_or_else(|| KernelError::InvalidInput(format!("unknown Girder FeatureId: {id}")))?;
|
||||
if let Some((cached_ir, cached_mesh)) = self.girder_mesh_cache.get(id) {
|
||||
if cached_ir == &ir {
|
||||
return Ok(Arc::clone(cached_mesh));
|
||||
}
|
||||
}
|
||||
let mesh = Arc::new(self.kernel.girder_mesh(&ir)?);
|
||||
self.girder_mesh_cache.insert(*id, (ir, Arc::clone(&mesh)));
|
||||
Ok(mesh)
|
||||
}
|
||||
|
||||
pub fn get_girder(&self, id: &FeatureId) -> Option<GirderIR> {
|
||||
self.db.girder_input(*id)
|
||||
}
|
||||
|
||||
pub fn girder_count(&self) -> usize { self.girder_ids.len() }
|
||||
|
||||
// ── DeckSlab ──────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn set_deck_slab(&mut self, ir: DeckSlabIR) {
|
||||
let id = ir.id;
|
||||
self.db.set_deck_input(id, Some(ir));
|
||||
self.deck_ids.insert(id);
|
||||
self.deck_mesh_cache.remove(&id);
|
||||
}
|
||||
|
||||
pub fn remove_deck_slab(&mut self, id: &FeatureId) {
|
||||
self.db.set_deck_input(*id, None);
|
||||
self.deck_ids.remove(id);
|
||||
self.deck_mesh_cache.remove(id);
|
||||
}
|
||||
|
||||
pub fn deck_slab_mesh(&mut self, id: &FeatureId) -> Result<Arc<Mesh>, KernelError> {
|
||||
let ir = self.db.deck_input(*id)
|
||||
.ok_or_else(|| KernelError::InvalidInput(format!("unknown DeckSlab FeatureId: {id}")))?;
|
||||
if let Some((cached_ir, cached_mesh)) = self.deck_mesh_cache.get(id) {
|
||||
if cached_ir == &ir { return Ok(Arc::clone(cached_mesh)); }
|
||||
}
|
||||
let mesh = Arc::new(self.kernel.deck_slab_mesh(&ir)?);
|
||||
self.deck_mesh_cache.insert(*id, (ir, Arc::clone(&mesh)));
|
||||
Ok(mesh)
|
||||
}
|
||||
|
||||
pub fn get_deck_slab(&self, id: &FeatureId) -> Option<DeckSlabIR> {
|
||||
self.db.deck_input(*id)
|
||||
}
|
||||
|
||||
pub fn deck_count(&self) -> usize { self.deck_ids.len() }
|
||||
|
||||
// ── Bearing ───────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn set_bearing(&mut self, ir: BearingIR) {
|
||||
let id = ir.id;
|
||||
self.db.set_bearing_input(id, Some(ir));
|
||||
self.bearing_ids.insert(id);
|
||||
self.bearing_mesh_cache.remove(&id);
|
||||
}
|
||||
|
||||
pub fn remove_bearing(&mut self, id: &FeatureId) {
|
||||
self.db.set_bearing_input(*id, None);
|
||||
self.bearing_ids.remove(id);
|
||||
self.bearing_mesh_cache.remove(id);
|
||||
}
|
||||
|
||||
pub fn bearing_mesh(&mut self, id: &FeatureId) -> Result<Arc<Mesh>, KernelError> {
|
||||
let ir = self.db.bearing_input(*id)
|
||||
.ok_or_else(|| KernelError::InvalidInput(format!("unknown Bearing FeatureId: {id}")))?;
|
||||
if let Some((cached_ir, cached_mesh)) = self.bearing_mesh_cache.get(id) {
|
||||
if cached_ir == &ir { return Ok(Arc::clone(cached_mesh)); }
|
||||
}
|
||||
let mesh = Arc::new(self.kernel.bearing_mesh(&ir)?);
|
||||
self.bearing_mesh_cache.insert(*id, (ir, Arc::clone(&mesh)));
|
||||
Ok(mesh)
|
||||
}
|
||||
|
||||
pub fn get_bearing(&self, id: &FeatureId) -> Option<BearingIR> {
|
||||
self.db.bearing_input(*id)
|
||||
}
|
||||
|
||||
pub fn bearing_count(&self) -> usize { self.bearing_ids.len() }
|
||||
|
||||
// ── Pier ──────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn set_pier(&mut self, ir: PierIR) {
|
||||
let id = ir.id;
|
||||
self.db.set_pier_input(id, Some(ir));
|
||||
self.pier_ids.insert(id);
|
||||
self.pier_mesh_cache.remove(&id);
|
||||
}
|
||||
|
||||
pub fn remove_pier(&mut self, id: &FeatureId) {
|
||||
self.db.set_pier_input(*id, None);
|
||||
self.pier_ids.remove(id);
|
||||
self.pier_mesh_cache.remove(id);
|
||||
}
|
||||
|
||||
pub fn pier_mesh(&mut self, id: &FeatureId) -> Result<Arc<Mesh>, KernelError> {
|
||||
let ir = self.db.pier_input(*id)
|
||||
.ok_or_else(|| KernelError::InvalidInput(format!("unknown Pier FeatureId: {id}")))?;
|
||||
if let Some((cached_ir, cached_mesh)) = self.pier_mesh_cache.get(id) {
|
||||
if cached_ir == &ir { return Ok(Arc::clone(cached_mesh)); }
|
||||
}
|
||||
let mesh = Arc::new(self.kernel.pier_mesh(&ir)?);
|
||||
self.pier_mesh_cache.insert(*id, (ir, Arc::clone(&mesh)));
|
||||
Ok(mesh)
|
||||
}
|
||||
|
||||
pub fn get_pier(&self, id: &FeatureId) -> Option<PierIR> {
|
||||
self.db.pier_input(*id)
|
||||
}
|
||||
|
||||
pub fn pier_count(&self) -> usize { self.pier_ids.len() }
|
||||
|
||||
// ── Abutment ──────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn set_abutment(&mut self, ir: AbutmentIR) {
|
||||
let id = ir.id;
|
||||
self.db.set_abutment_input(id, Some(ir));
|
||||
self.abutment_ids.insert(id);
|
||||
self.abutment_mesh_cache.remove(&id);
|
||||
}
|
||||
|
||||
pub fn remove_abutment(&mut self, id: &FeatureId) {
|
||||
self.db.set_abutment_input(*id, None);
|
||||
self.abutment_ids.remove(id);
|
||||
self.abutment_mesh_cache.remove(id);
|
||||
}
|
||||
|
||||
pub fn abutment_mesh(&mut self, id: &FeatureId) -> Result<Arc<Mesh>, KernelError> {
|
||||
let ir = self.db.abutment_input(*id)
|
||||
.ok_or_else(|| KernelError::InvalidInput(format!("unknown Abutment FeatureId: {id}")))?;
|
||||
if let Some((cached_ir, cached_mesh)) = self.abutment_mesh_cache.get(id) {
|
||||
if cached_ir == &ir { return Ok(Arc::clone(cached_mesh)); }
|
||||
}
|
||||
let mesh = Arc::new(self.kernel.abutment_mesh(&ir)?);
|
||||
self.abutment_mesh_cache.insert(*id, (ir, Arc::clone(&mesh)));
|
||||
Ok(mesh)
|
||||
}
|
||||
|
||||
pub fn get_abutment(&self, id: &FeatureId) -> Option<AbutmentIR> {
|
||||
self.db.abutment_input(*id)
|
||||
}
|
||||
|
||||
pub fn abutment_count(&self) -> usize { self.abutment_ids.len() }
|
||||
|
||||
// ── Status / diagnostics ──────────────────────────────────────────────────
|
||||
|
||||
/// Total features with stale (not-yet-computed) meshes.
|
||||
/// A feature is "dirty" if its salsa input changed since last mesh query
|
||||
/// (IR mismatch between salsa store and mesh cache).
|
||||
pub fn dirty_count(&self) -> usize {
|
||||
self.dirty_for_kind(
|
||||
&self.girder_ids,
|
||||
|id| self.db.girder_input(id),
|
||||
&self.girder_mesh_cache,
|
||||
) + self.dirty_for_kind(
|
||||
&self.deck_ids,
|
||||
|id| self.db.deck_input(id),
|
||||
&self.deck_mesh_cache,
|
||||
) + self.dirty_for_kind(
|
||||
&self.bearing_ids,
|
||||
|id| self.db.bearing_input(id),
|
||||
&self.bearing_mesh_cache,
|
||||
) + self.dirty_for_kind(
|
||||
&self.pier_ids,
|
||||
|id| self.db.pier_input(id),
|
||||
&self.pier_mesh_cache,
|
||||
) + self.dirty_for_kind(
|
||||
&self.abutment_ids,
|
||||
|id| self.db.abutment_input(id),
|
||||
&self.abutment_mesh_cache,
|
||||
)
|
||||
}
|
||||
|
||||
fn dirty_for_kind<IR: PartialEq>(
|
||||
&self,
|
||||
ids: &std::collections::HashSet<FeatureId>,
|
||||
input: impl Fn(FeatureId) -> Option<IR>,
|
||||
cache: &std::collections::HashMap<FeatureId, (IR, Arc<Mesh>)>,
|
||||
) -> usize {
|
||||
ids.iter().filter(|id| {
|
||||
match cache.get(id) {
|
||||
Some((cached_ir, _)) => input(**id).as_ref() != Some(cached_ir),
|
||||
None => true,
|
||||
}
|
||||
}).count()
|
||||
}
|
||||
|
||||
pub fn dirty_girder_count(&self) -> usize {
|
||||
self.dirty_for_kind(
|
||||
&self.girder_ids,
|
||||
|id| self.db.girder_input(id),
|
||||
&self.girder_mesh_cache,
|
||||
)
|
||||
}
|
||||
|
||||
/// Clear all mesh caches (force full recompute on next access).
|
||||
pub fn invalidate_all(&mut self) {
|
||||
self.girder_mesh_cache.clear();
|
||||
self.deck_mesh_cache.clear();
|
||||
self.bearing_mesh_cache.clear();
|
||||
self.pier_mesh_cache.clear();
|
||||
self.abutment_mesh_cache.clear();
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ impl std::fmt::Display for FeatureId {
|
||||
///
|
||||
/// All values are raw primitives — no unit types here (kernel doesn't need them).
|
||||
/// Convention: linear = metres, structural = millimetres.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct GirderIR {
|
||||
pub id: FeatureId,
|
||||
/// Station along alignment [m].
|
||||
@@ -64,7 +64,7 @@ impl GirderIR {
|
||||
// ─── Section params ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Cross-section geometry. Tag is the section type name.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum SectionParams {
|
||||
PscI(PscISectionParams),
|
||||
@@ -82,7 +82,7 @@ pub enum SectionParams {
|
||||
/// #[param(unit="mm", range=120..=300, default=180)] bottom_flange_thickness
|
||||
/// #[param(unit="mm", range=150..=350, default=200)] web_thickness
|
||||
/// #[param(unit="mm", range=0..=100, default=50)] haunch
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PscISectionParams {
|
||||
pub total_height: f64,
|
||||
pub top_flange_width: f64,
|
||||
@@ -109,7 +109,7 @@ impl PscISectionParams {
|
||||
}
|
||||
|
||||
/// PSC U-girder section [all mm].
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PscUSectionParams {
|
||||
pub total_height: f64,
|
||||
pub top_width: f64,
|
||||
@@ -119,7 +119,7 @@ pub struct PscUSectionParams {
|
||||
}
|
||||
|
||||
/// Steel box girder section [all mm].
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SteelBoxParams {
|
||||
pub total_height: f64,
|
||||
pub top_width: f64,
|
||||
@@ -130,7 +130,7 @@ pub struct SteelBoxParams {
|
||||
}
|
||||
|
||||
/// Steel plate I-girder section [all mm].
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SteelPlateIParams {
|
||||
pub total_height: f64,
|
||||
pub flange_width: f64,
|
||||
@@ -141,7 +141,7 @@ pub struct SteelPlateIParams {
|
||||
// ─── Alignment IR ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Single station point along an alignment.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AlignmentStation {
|
||||
/// Station distance along alignment [m].
|
||||
pub station: f64,
|
||||
@@ -154,7 +154,7 @@ pub struct AlignmentStation {
|
||||
}
|
||||
|
||||
/// Road/bridge alignment specs.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct AlignmentSpecs {
|
||||
#[serde(default)]
|
||||
pub r#type: String,
|
||||
@@ -167,7 +167,7 @@ pub struct AlignmentSpecs {
|
||||
}
|
||||
|
||||
/// Alignment IR — parsed from cimery's own JSON format (ADR-002 R).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AlignmentIR {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
@@ -219,7 +219,7 @@ impl AlignmentIR {
|
||||
|
||||
/// Fully-resolved Deck Slab (바닥판) specification.
|
||||
/// Structural dimensions in mm, alignment in m.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DeckSlabIR {
|
||||
pub id: FeatureId,
|
||||
pub station_start: f64, // m
|
||||
@@ -246,7 +246,7 @@ impl DeckSlabIR {
|
||||
// ─── Bearing IR ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Fully-resolved Bearing (받침) specification.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BearingIR {
|
||||
pub id: FeatureId,
|
||||
pub station: f64, // m — position along alignment
|
||||
@@ -262,7 +262,7 @@ pub struct BearingIR {
|
||||
// ─── Cap Beam IR ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Pier cap beam (교각 코핑).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CapBeamIR {
|
||||
pub length: f64, // mm — total along transverse
|
||||
pub width: f64, // mm — along span
|
||||
@@ -274,7 +274,7 @@ pub struct CapBeamIR {
|
||||
// ─── Pier IR ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Fully-resolved Pier (교각) specification.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PierIR {
|
||||
pub id: FeatureId,
|
||||
pub station: f64, // m
|
||||
@@ -294,7 +294,7 @@ pub struct PierIR {
|
||||
|
||||
// ─── Wing Wall IR ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WingWallIR {
|
||||
pub length: f64, // mm — along wing wall axis
|
||||
pub height: f64, // mm — at connection with breast wall
|
||||
@@ -304,7 +304,7 @@ pub struct WingWallIR {
|
||||
// ─── Abutment IR ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Fully-resolved Abutment (교대) specification.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AbutmentIR {
|
||||
pub id: FeatureId,
|
||||
pub station: f64, // m
|
||||
@@ -327,7 +327,7 @@ pub struct AbutmentIR {
|
||||
///
|
||||
/// Cross beams provide lateral bracing between girders at regular intervals.
|
||||
/// All dimensions in mm; station in m.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CrossBeamIR {
|
||||
pub id: FeatureId,
|
||||
/// Station along alignment [m] — position of this cross beam set.
|
||||
@@ -361,7 +361,7 @@ impl CrossBeamIR {
|
||||
///
|
||||
/// Placed at the ends of a span or at pier locations to allow relative movement.
|
||||
/// All dimensions in mm; station in m.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ExpansionJointIR {
|
||||
pub id: FeatureId,
|
||||
/// Station along alignment [m].
|
||||
@@ -377,6 +377,29 @@ pub struct ExpansionJointIR {
|
||||
pub movement_range:f64,
|
||||
}
|
||||
|
||||
// ─── Eq impls ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// IR values are validated by builders to never contain NaN or ±Inf.
|
||||
// Implementing Eq on top of PartialEq is therefore safe: PartialEq IS a
|
||||
// total equivalence relation for the valid IR domain.
|
||||
|
||||
impl Eq for GirderIR {}
|
||||
impl Eq for SectionParams {}
|
||||
impl Eq for PscISectionParams {}
|
||||
impl Eq for PscUSectionParams {}
|
||||
impl Eq for SteelBoxParams {}
|
||||
impl Eq for SteelPlateIParams {}
|
||||
impl Eq for AlignmentStation {}
|
||||
impl Eq for AlignmentIR {}
|
||||
impl Eq for DeckSlabIR {}
|
||||
impl Eq for BearingIR {}
|
||||
impl Eq for CapBeamIR {}
|
||||
impl Eq for PierIR {}
|
||||
impl Eq for WingWallIR {}
|
||||
impl Eq for AbutmentIR {}
|
||||
impl Eq for CrossBeamIR {}
|
||||
impl Eq for ExpansionJointIR {}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -33,7 +33,7 @@ use cimery_ir::{
|
||||
///
|
||||
/// Coordinate convention: X = width, Y = height, Z = along-span axis.
|
||||
/// Units: millimetres.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Mesh {
|
||||
/// Vertex positions [mm]: vec of [x, y, z].
|
||||
pub vertices: Vec<[f32; 3]>,
|
||||
@@ -74,9 +74,13 @@ impl Mesh {
|
||||
}
|
||||
}
|
||||
|
||||
// Mesh geometry is always computed from validated IR (no NaN/Inf).
|
||||
// PartialEq via f32::eq is an equivalence relation in this domain.
|
||||
impl Eq for Mesh {}
|
||||
|
||||
// ─── Error ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum KernelError {
|
||||
#[error("geometry computation failed: {0}")]
|
||||
Computation(String),
|
||||
|
||||
@@ -54,9 +54,8 @@ impl AlignmentTransform {
|
||||
let up = tangent.cross(right).normalize_or(Vec3::Y);
|
||||
|
||||
// Build 4×4 matrix: columns = [right, up, tangent, origin]
|
||||
let origin = Vec3::new(pos[0] as f32, pos[2] as f32, pos[1] as f32);
|
||||
// Note: AlignmentIR uses [x, y, z] where z is elevation;
|
||||
// bridge coordinate system: Y = up, so we remap y_align → Y_world.
|
||||
// AlignmentIR uses [x, y, z] where z is elevation;
|
||||
// bridge coordinate system: Y = up, so we remap: y_align → Y_world.
|
||||
let origin = Vec3::new(pos[0] as f32, pos[2] as f32, pos[1] as f32);
|
||||
|
||||
let matrix = Mat4::from_cols(
|
||||
|
||||
@@ -53,7 +53,7 @@ impl<K: GeomKernel + Clone + 'static> IncrementalBridge<K> {
|
||||
|
||||
for i in 0..n {
|
||||
let x = (i as f64 - (n as f64 - 1.0) * 0.5) * spacing as f64;
|
||||
let mut ir = GirderIR {
|
||||
let ir = GirderIR {
|
||||
id: self.girder_ids[i],
|
||||
station_start: 0.0,
|
||||
station_end: span_m,
|
||||
|
||||
@@ -8,6 +8,7 @@ use super::bridge_scene::{GirderSectionType, SceneParams};
|
||||
|
||||
// ─── Serialisable form of SceneParams ────────────────────────────────────────
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct SectionTypeStr(String);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user