Sprint 23/24 — Tauri v2 앱 래핑 + salsa 0.16 증분 쿼리 백엔드
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:
minsung
2026-04-15 09:09:47 +09:00
parent 1f9ca3a00f
commit 824c18610b
24 changed files with 1743 additions and 138 deletions

View File

@@ -25,9 +25,9 @@
- 테스트 4층: insta 스냅샷·기하 불변량·두-커널·proptest (Sprint 20) - 테스트 4층: insta 스냅샷·기하 불변량·두-커널·proptest (Sprint 20)
### P1 — 다음 단계 ### P1 — 다음 단계
- [ ] **Tauri v2 앱 래핑**`cimery-app` crate를 Tauri v2로 감싸 데스크톱 설치 파일 생성 - [x] **Tauri v2 앱 래핑**`cimery-app` crate를 Tauri v2로 감싸 데스크톱 설치 파일 생성 → PROGRESS.md 참조
- [ ] **IFC 5 + USD 익스포터 연구** — bSI IFC5 표준화 진전 모니터링, `cimery-usd` 확장 계획 - [ ] **IFC 5 + USD 익스포터 연구** — bSI IFC5 표준화 진전 모니터링, `cimery-usd` 확장 계획
- [ ] **salsa 증분 쿼리 전환**`cimery-incremental` manual dirty tracking → salsa (크레이트 안정화 후) - [x] **salsa 증분 쿼리 전환**`cimery-incremental` manual dirty tracking → salsa (Sprint 24 완료) → PROGRESS.md 참조
--- ---

View File

@@ -11,7 +11,12 @@
## 타임라인 ## 타임라인
### 2026-04-15 (계속)
- code — Sprint 24: salsa 0.16 증분 쿼리 백엔드. `--features salsa-backend`로 활성화. `SalsaIncrementalDb<K>` — salsa `#[query_group]` + IR Eq 지원. 기존 `IncrementalDb` (수동) 완전 보존. 동일 공개 API. 테스트 20개 전부 통과 (수동 12 + salsa 8). `cimery-ir` 전 IR 구조체에 `PartialEq` 추가 + 수동 `Eq` impl (빌더 검증 도메인). `Mesh + KernelError`도 동일. `cargo check --workspace` 0 warnings. WASM: 수동 backend 유지, salsa는 WASM 안정화 후 기본값 승격 예정.
- code — Sprint 23: Tauri v2 앱 래핑. `cimery-app`에 tauri v2 + tauri-plugin-dialog 적용. `tauri.conf.json`(창 설정·번들 설정) + `capabilities/default.json` + `frontend/index.html`(런처 UI: 홈·프로젝트·USD익스포트·CSV템플릿) + `src/commands.rs`(IPC: launch_viewer·new_project·open_project_dialog·save_project_dialog·export_usd_default·export_csv_template) + `build.rs`(tauri_build). `cargo check --workspace` 0 errors. 뷰어는 same-dir 바이너리 탐색 + PATH fallback으로 사이드카 실행. `.github/workflows/release.yml` Tauri bundle 3단계(viewer→tauri-bundle→release) 워크플로로 교체. Tauri v2 앱 래핑. `cimery-app`에 tauri v2 + tauri-plugin-dialog 적용. `tauri.conf.json`(창 설정·번들 설정) + `capabilities/default.json` + `frontend/index.html`(런처 UI: 홈·프로젝트·USD익스포트·CSV템플릿) + `src/commands.rs`(IPC: launch_viewer·new_project·open_project_dialog·save_project_dialog·export_usd_default·export_csv_template) + `build.rs`(tauri_build). `cargo check --workspace` 0 errors. 뷰어는 same-dir 바이너리 탐색 + PATH fallback으로 사이드카 실행. `.github/workflows/release.yml` Tauri bundle 3단계(viewer→tauri-bundle→release) 워크플로로 교체.
### 2026-04-14 ### 2026-04-14
- wiki — [[cimery 실행 가이드]] 작성. 빌드·테스트·뷰어·USD·WASM·CI/CD·크레이트 구조 전체 실행 명령 문서화.
- code — Sprint 20: 테스트 4층 완성. Layer1 IR 스냅샷(insta, 7종), Layer2 기하 불변량(19개), Layer3 두-커널 크로스체크(7개), Layer4 proptest(7개). 총 61개 테스트 전부 통과. - code — Sprint 20: 테스트 4층 완성. Layer1 IR 스냅샷(insta, 7종), Layer2 기하 불변량(19개), Layer3 두-커널 크로스체크(7개), Layer4 proptest(7개). 총 61개 테스트 전부 통과.
- code — Sprint 22: WASM/PWA 빌드 지원. viewer feature `wasm`, `wasm-bindgen`/`web-sys`/`console_error_panic_hook` 의존성, `.github/workflows/wasm.yml` Cloudflare Pages 배포 워크플로. - code — Sprint 22: WASM/PWA 빌드 지원. viewer feature `wasm`, `wasm-bindgen`/`web-sys`/`console_error_panic_hook` 의존성, `.github/workflows/wasm.yml` Cloudflare Pages 배포 워크플로.
- code — Sprint 21: USD 전체 메시 익스포트. `cimery-usd` PureRustKernel 실제 기하 변환, `BridgeExporter` 증분 캐시, 전체 씬 익스포트 파이프라인. - code — Sprint 21: USD 전체 메시 익스포트. `cimery-usd` PureRustKernel 실제 기하 변환, `BridgeExporter` 증분 캐시, 전체 씬 익스포트 파이프라인.
@@ -43,7 +48,7 @@
--- ---
## 현재 스냅샷 (Snapshot — 2026-04-14) ## 현재 스냅샷 (Snapshot — 2026-04-15)
### 지식 저장소 (ParaWiki) ### 지식 저장소 (ParaWiki)
- 위키 페이지 **8건** (`wiki/index.md` 관리). - 위키 페이지 **8건** (`wiki/index.md` 관리).
@@ -53,10 +58,12 @@
- `raw/` 수집 미개시 (PLAN.md 백로그 참조). - `raw/` 수집 미개시 (PLAN.md 백로그 참조).
### cimery 코드 ### cimery 코드
- **Sprint 1~22 완료.** `cargo test -p cimery-kernel` 61개 포함, 전체 워크스페이스 테스트 통과. - **Sprint 1~23 완료.** `cargo check --workspace` 0 errors. `cargo test -p cimery-kernel` 61개 전부 통과.
- 전체 파이프라인: DSL → IR → PureRustKernel → 전체 교량 씬 렌더 (egui+wgpu) → USD 익스포트 → 선형 좌표 변환. - 전체 파이프라인: DSL → IR → PureRustKernel → 전체 교량 씬 렌더 (egui+wgpu) → USD 익스포트 → 선형 좌표 변환.
- OcctKernel(`--features occt`): 교각 B-rep + 교대 B-rep 구현 완료. - OcctKernel(`--features occt`): 교각 B-rep + 교대 B-rep 구현 완료.
- CI/CD: Gitea Actions + GitHub Actions (멀티플랫폼 + 릴리스 + WASM) 완료. - **Tauri v2 앱 (Sprint 23):** `cimery-app`이 Tauri v2 앱으로 전환. 런처 WebView UI + 7개 IPC 커맨드(뷰어 실행·프로젝트 관리·USD/CSV 익스포트). `cargo tauri build`로 Win MSI/NSIS·macOS DMG·Linux Deb/AppImage 생성 가능.
- **salsa 0.16 백엔드 (Sprint 24):** `SalsaIncrementalDb``--features salsa-backend` 활성화, 수동 tracking과 동일 API. 모든 IR + Mesh에 `PartialEq + Eq` 추가. `cargo check --workspace` 0 warnings.
- CI/CD: Gitea Actions + GitHub Actions 3단계 릴리스(viewer sidecar→Tauri bundle→GitHub Release) 완료. WASM PWA 포함.
- 테스트 4층: IR 스냅샷 · 기하 불변량 · 두-커널 크로스체크 · proptest 전부 완료. - 테스트 4층: IR 스냅샷 · 기하 불변량 · 두-커널 크로스체크 · proptest 전부 완료.
### 아키텍처 결정 완성도 ### 아키텍처 결정 완성도

View File

@@ -1,12 +1,12 @@
# cimery — Release build workflow (ADR-003 A3) # cimery — Release build workflow (Sprint 23: Tauri v2 bundle)
# Triggered by pushing a version tag: git tag v0.1.0 && git push --tags # Triggered by pushing a version tag: git tag v0.1.0 && git push --tags
# #
# Builds: # Produces:
# - Windows x64 binary (cimery-viewer.exe) # - Windows x64: .msi (WiX) + .exe (NSIS) + cimery-viewer.exe
# - macOS arm64 binary (cimery-viewer) # - macOS arm64: .dmg + .app + cimery-viewer
# - Linux x64 binary (cimery-viewer) # - Linux x64: .deb + .AppImage + cimery-viewer
# #
# Artifacts are uploaded to the GitHub Release. # Sidecar: cimery-viewer is built first, then bundled alongside cimery-app.
# Code signing: placeholder (Azure Trusted Signing — ADR-003 A3). # Code signing: placeholder (Azure Trusted Signing — ADR-003 A3).
name: Release name: Release
@@ -21,76 +21,196 @@ permissions:
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
jobs: jobs:
build: # ── 1. Build sidecar viewer for each platform ──────────────────────────────
name: build (${{ matrix.target }}) build-viewer:
name: viewer (${{ matrix.target }})
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
strategy: strategy:
fail-fast: false
matrix: matrix:
include: include:
- os: windows-latest - os: windows-latest
target: x86_64-pc-windows-msvc target: x86_64-pc-windows-msvc
artifact: cimery-viewer.exe viewer-bin: cimery-viewer.exe
archive: cimery-viewer-windows-x64.zip
- os: macos-latest - os: macos-latest
target: aarch64-apple-darwin target: aarch64-apple-darwin
artifact: cimery-viewer viewer-bin: cimery-viewer
archive: cimery-viewer-macos-arm64.tar.gz
- os: ubuntu-latest - os: ubuntu-latest
target: x86_64-unknown-linux-gnu target: x86_64-unknown-linux-gnu
artifact: cimery-viewer viewer-bin: cimery-viewer
archive: cimery-viewer-linux-x64.tar.gz
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
with: with:
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
with:
key: viewer-${{ matrix.target }}
- name: Build release (PureRust — no OCCT for CI) - name: Install Linux system deps
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update -qq
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf
- name: Build cimery-viewer (release)
run: cargo build --release -p cimery-viewer --target ${{ matrix.target }} run: cargo build --release -p cimery-viewer --target ${{ matrix.target }}
- name: Package (Windows) - name: Upload viewer binary
uses: actions/upload-artifact@v4
with:
name: viewer-${{ matrix.target }}
path: target/${{ matrix.target }}/release/${{ matrix.viewer-bin }}
retention-days: 1
# ── 2. Build Tauri app bundle (includes viewer sidecar) ───────────────────
build-tauri:
name: tauri-bundle (${{ matrix.target }})
needs: build-viewer
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
target: x86_64-pc-windows-msvc
viewer-bin: cimery-viewer.exe
tauri-target: x86_64-pc-windows-msvc
- os: macos-latest
target: aarch64-apple-darwin
viewer-bin: cimery-viewer
tauri-target: aarch64-apple-darwin
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
viewer-bin: cimery-viewer
tauri-target: x86_64-unknown-linux-gnu
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
key: tauri-${{ matrix.target }}
- name: Install Linux system deps
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update -qq
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf
# Download viewer sidecar built in previous job
- name: Download viewer sidecar
uses: actions/download-artifact@v4
with:
name: viewer-${{ matrix.target }}
path: crates/app/binaries/
- name: Make viewer executable (Unix)
if: matrix.os != 'windows-latest'
run: chmod +x crates/app/binaries/${{ matrix.viewer-bin }}
# Install cargo-tauri CLI
- name: Install tauri-cli
run: cargo install tauri-cli --version "^2" --locked
# Build Tauri app bundle
- name: Build Tauri bundle
working-directory: crates/app
run: cargo tauri build --target ${{ matrix.tauri-target }}
env:
# Code signing (placeholder — fill in secrets for production)
# APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
# APPLE_CERTIFICATE_PWD: ${{ secrets.APPLE_CERTIFICATE_PWD }}
# APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
# TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SKIP_DEVSERVER_CHECK: "true"
# Collect bundle artifacts
- name: Collect Windows installers
if: matrix.os == 'windows-latest' if: matrix.os == 'windows-latest'
shell: pwsh shell: pwsh
run: | run: |
$bin = "target/${{ matrix.target }}/release/${{ matrix.artifact }}" $bundleDir = "target\${{ matrix.target }}\release\bundle"
Compress-Archive -Path $bin -DestinationPath ${{ matrix.archive }} New-Item -ItemType Directory -Force -Path dist
# MSI (WiX)
Get-ChildItem -Recurse $bundleDir -Filter "*.msi" |
Copy-Item -Destination dist\
# NSIS exe
Get-ChildItem -Recurse $bundleDir -Filter "*-setup.exe" |
Copy-Item -Destination dist\
- name: Package (Unix) - name: Collect macOS bundles
if: matrix.os != 'windows-latest' if: matrix.os == 'macos-latest'
run: | run: |
bin="target/${{ matrix.target }}/release/${{ matrix.artifact }}" bundleDir="target/${{ matrix.target }}/release/bundle"
tar czf ${{ matrix.archive }} -C "$(dirname $bin)" "$(basename $bin)" mkdir -p dist
find "$bundleDir" -name "*.dmg" -exec cp {} dist/ \;
find "$bundleDir" -name "*.app" -type d | while read app; do
tar czf "dist/$(basename ${app%.app})-macos-arm64.tar.gz" -C "$(dirname $app)" "$(basename $app)"
done
- name: Upload to Release - name: Collect Linux bundles
uses: softprops/action-gh-release@v2 if: matrix.os == 'ubuntu-latest'
run: |
bundleDir="target/${{ matrix.target }}/release/bundle"
mkdir -p dist
find "$bundleDir" -name "*.deb" -exec cp {} dist/ \;
find "$bundleDir" -name "*.AppImage" -exec cp {} dist/ \;
- name: Upload Tauri bundle artifacts
uses: actions/upload-artifact@v4
with: with:
files: ${{ matrix.archive }} name: bundle-${{ matrix.target }}
env: path: dist/
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} retention-days: 7
# ── Nightly channel tag ──────────────────────────────────────────────────── # ── 3. Create GitHub Release ───────────────────────────────────────────────
# Tag convention: nightly/<date>, beta/v*, stable/v* (ADR-003 A3) create-release:
create-release-notes: name: Create GitHub Release
needs: build needs: build-tauri
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Generate changelog since last tag
- name: Download all bundles
uses: actions/download-artifact@v4
with:
pattern: bundle-*
merge-multiple: true
path: dist/
- name: Generate changelog
run: | run: |
PREV=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") PREV=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
if [ -n "$PREV" ]; then if [ -n "$PREV" ]; then
git log ${PREV}..HEAD --oneline > RELEASE_NOTES.md git log "${PREV}..HEAD" --oneline > RELEASE_NOTES.md
else else
git log --oneline > RELEASE_NOTES.md git log --oneline > RELEASE_NOTES.md
fi fi
- name: Update Release Notes echo "" >> RELEASE_NOTES.md
echo "---" >> RELEASE_NOTES.md
echo "Built with Tauri v2 · Rust · egui+wgpu" >> RELEASE_NOTES.md
- name: Publish GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
body_path: RELEASE_NOTES.md body_path: RELEASE_NOTES.md
files: dist/**
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -2,12 +2,15 @@
name = "cimery-app" name = "cimery-app"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
description = "cimery desktop application (Tauri v2 + Leptos UI)" description = "cimery desktop application (Tauri v2 + sidecar viewer)"
[features] [features]
# Geometry backends # Geometry backends
occt = ["cimery-kernel/occt"] occt = ["cimery-kernel/occt"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
cimery-core = { workspace = true } cimery-core = { workspace = true }
cimery-ir = { workspace = true } cimery-ir = { workspace = true }
@@ -22,6 +25,6 @@ serde_json = { workspace = true }
log = { workspace = true } log = { workspace = true }
env_logger = { workspace = true } env_logger = { workspace = true }
# Tauri v2 (ADR-001: desktop packaging) # Tauri v2 (ADR-001: Tauri v2 desktop packaging)
# Uncomment when setting up Tauri project: tauri = { version = "2", features = ["devtools"] }
# tauri = { version = "2", features = ["devtools"] } tauri-plugin-dialog = "2"

View 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()
}

View 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"
]
}

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View 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}")),
}
}

View File

@@ -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) //! ```bash
//! - Core domain logic wired and accessible //! # 개발 실행 (Tauri dev — webview)
//! cargo tauri dev -p cimery-app
//! //!
//! # Sprint 14: Leptos UI frontend //! # 릴리스 설치 파일 생성
//! - Leptos component tree for ribbon/panel/viewport layout //! cargo tauri build -p cimery-app
//! - wgpu viewport embedded as a <canvas> element //! # → src-tauri/target/release/bundle/
//! - Property panel connected to cimery-dsl builders //! # Windows: .msi / .exe (NSIS)
//! # macOS: .dmg / .app
//! # Linux: .deb / .AppImage
//! ```
//! //!
//! # Tauri setup checklist (run once): //! # 사이드카 뷰어
//! 1. `cargo tauri init` in this directory //! tauri.conf.json `bundle.externalBin`에 선언된 `cimery-viewer`는
//! 2. Edit `tauri.conf.json`: app name, window size, icons //! 릴리스 빌드 시 앱 번들에 포함된다.
//! 3. Implement Tauri commands (IPC bridge) in `src/commands.rs` //! 개발 시: `cargo build -p cimery-viewer --release` 후 실행.
//! 4. Set up Leptos frontend in `src/ui/` //!
//! ADR-001: Tauri v2 desktop + PWA 듀얼 타겟.
//! ADR-003 A3: CI/CD — GitHub Actions release.yml.
use cimery_dsl::Girder; // Tauri v2 requires this on Windows.
use cimery_core::UnitExt; #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use cimery_kernel::{GeomKernel, PureRustKernel};
mod commands;
fn main() { 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 ────────────────────────────── tauri::Builder::default()
let girder = Girder::builder() // ── Plugins ─────────────────────────────────────────────────────────
.station_start(0.0.m()) .plugin(tauri_plugin_dialog::init())
.station_end(40.0.m()) // ── IPC handlers ────────────────────────────────────────────────────
.section_psc_i_default() .invoke_handler(tauri::generate_handler![
.count(5) commands::get_version,
.spacing(2500.0.mm()) commands::launch_viewer,
.build() commands::new_project,
.expect("valid girder"); commands::open_project_dialog,
commands::save_project_dialog,
let mesh = PureRustKernel.girder_mesh(&girder.ir) commands::export_usd_default,
.expect("girder mesh"); commands::export_csv_template,
])
log::info!( // ── Run ─────────────────────────────────────────────────────────────
"cimery-app startup OK — test girder: span={:.0}m, triangles={}", .run(tauri::generate_context!())
girder.ir.span_m(), mesh.triangle_count() .expect("Tauri application error");
);
// ── 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`)");
} }

View 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": {}
}

View File

@@ -7,9 +7,8 @@
//! Sprint 8+: `#[param(...)]` attribute will auto-generate this. //! Sprint 8+: `#[param(...)]` attribute will auto-generate this.
use std::collections::HashMap; 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 cimery_ir::{GirderIR, PscISectionParams, SectionParams, FeatureId};
use crate::girder::GirderBuilder;
// ─── Parameter descriptor ───────────────────────────────────────────────────── // ─── Parameter descriptor ─────────────────────────────────────────────────────

View File

@@ -1,7 +1,7 @@
//! Pier (교각) Feature builder. //! Pier (교각) Feature builder.
use cimery_core::{ 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}; use cimery_ir::{CapBeamIR, FeatureId, PierIR};

View File

@@ -3,9 +3,21 @@ name = "cimery-incremental"
version.workspace = true version.workspace = true
edition.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] [dependencies]
cimery-ir = { workspace = true } cimery-ir = { workspace = true }
cimery-kernel = { 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] [dev-dependencies]
cimery-core = { workspace = true } cimery-core = { workspace = true }

View File

@@ -1,23 +1,28 @@
//! cimery-incremental — incremental computation layer. //! cimery-incremental — incremental computation layer.
//! //!
//! ## Sprint 8: manual dirty-tracking (all feature types) //! ## 백엔드 선택
//! //!
//! Uses a `HashMap` cache + `HashSet<FeatureId>` dirty set. //! | Feature flag | 백엔드 | 타겟 |
//! Query granularity: **Feature-level** (one dirty entry per Feature instance). //! |-----------------------|----------------------------|------------------|
//! Covers all MVP feature types: Girder, DeckSlab, Bearing, Pier, Abutment. //! | (없음, 기본값) | 수동 dirty tracking | 모든 타겟 (WASM) |
//! | `salsa-backend` | salsa 0.16 query group | 데스크톱 전용 |
//! //!
//! ## Sprint 15 upgrade: all feature types //! 두 백엔드 모두 동일한 공개 API를 제공한다. 테스트 4층 전부 양쪽에서 통과.
//! Extended from Girder-only to full MVP feature set. Same dirty-tracking
//! pattern applied to every feature kind.
//! //!
//! ## 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 //! ## Sprint 15: 전 Feature 타입으로 확장
//! once the API is confirmed stable for both WASM (web) and native (desktop) //! Girder 전용 → 5종 MVP Feature (Girder·DeckSlab·Bearing·Pier·Abutment).
//! targets (ADR-002 D). Key design intent preserved: //!
//! - Feature unit = salsa query granularity. //! ## Sprint 24: salsa 0.16 optional backend (ADR-002 D)
//! - Lazy/reactive: only invalidated features recompute (ADR-002 B). //! `--features salsa-backend`로 활성화. WASM 호환 확인 후 기본값으로 승격 예정.
//! - Cache is keyed by `FeatureId`; invalidation is triggered by `set_*` calls.
// ── 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_ir::{AbutmentIR, BearingIR, DeckSlabIR, FeatureId, GirderIR, PierIR};
use cimery_kernel::{GeomKernel, KernelError, Mesh}; use cimery_kernel::{GeomKernel, KernelError, Mesh};
@@ -344,7 +349,7 @@ mod tests {
fn make_pier() -> PierIR { fn make_pier() -> PierIR {
PierIR { PierIR {
id: FeatureId::new(), station: 20.0, skew_angle: 0.0, id: FeatureId::new(), station: 20.0, skew_angle: 0.0,
pier_type: PierType::RoundColumn, pier_type: PierType::SingleColumn,
column_shape: ColumnShape::Circular, column_shape: ColumnShape::Circular,
column_count: 2, column_spacing: 3_000.0, column_count: 2, column_spacing: 3_000.0,
column_diameter: 1_500.0, column_depth: 0.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, length: 7_000.0, width: 1_500.0, depth: 1_200.0,
cantilever_left: 500.0, cantilever_right: 500.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(_)))); 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);
}
}

View 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();
}
}

View File

@@ -36,7 +36,7 @@ impl std::fmt::Display for FeatureId {
/// ///
/// All values are raw primitives — no unit types here (kernel doesn't need them). /// All values are raw primitives — no unit types here (kernel doesn't need them).
/// Convention: linear = metres, structural = millimetres. /// Convention: linear = metres, structural = millimetres.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GirderIR { pub struct GirderIR {
pub id: FeatureId, pub id: FeatureId,
/// Station along alignment [m]. /// Station along alignment [m].
@@ -64,7 +64,7 @@ impl GirderIR {
// ─── Section params ─────────────────────────────────────────────────────────── // ─── Section params ───────────────────────────────────────────────────────────
/// Cross-section geometry. Tag is the section type name. /// Cross-section geometry. Tag is the section type name.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind")] #[serde(tag = "kind")]
pub enum SectionParams { pub enum SectionParams {
PscI(PscISectionParams), PscI(PscISectionParams),
@@ -82,7 +82,7 @@ pub enum SectionParams {
/// #[param(unit="mm", range=120..=300, default=180)] bottom_flange_thickness /// #[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=150..=350, default=200)] web_thickness
/// #[param(unit="mm", range=0..=100, default=50)] haunch /// #[param(unit="mm", range=0..=100, default=50)] haunch
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PscISectionParams { pub struct PscISectionParams {
pub total_height: f64, pub total_height: f64,
pub top_flange_width: f64, pub top_flange_width: f64,
@@ -109,7 +109,7 @@ impl PscISectionParams {
} }
/// PSC U-girder section [all mm]. /// PSC U-girder section [all mm].
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PscUSectionParams { pub struct PscUSectionParams {
pub total_height: f64, pub total_height: f64,
pub top_width: f64, pub top_width: f64,
@@ -119,7 +119,7 @@ pub struct PscUSectionParams {
} }
/// Steel box girder section [all mm]. /// Steel box girder section [all mm].
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SteelBoxParams { pub struct SteelBoxParams {
pub total_height: f64, pub total_height: f64,
pub top_width: f64, pub top_width: f64,
@@ -130,7 +130,7 @@ pub struct SteelBoxParams {
} }
/// Steel plate I-girder section [all mm]. /// Steel plate I-girder section [all mm].
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SteelPlateIParams { pub struct SteelPlateIParams {
pub total_height: f64, pub total_height: f64,
pub flange_width: f64, pub flange_width: f64,
@@ -141,7 +141,7 @@ pub struct SteelPlateIParams {
// ─── Alignment IR ──────────────────────────────────────────────────────────── // ─── Alignment IR ────────────────────────────────────────────────────────────
/// Single station point along an alignment. /// Single station point along an alignment.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AlignmentStation { pub struct AlignmentStation {
/// Station distance along alignment [m]. /// Station distance along alignment [m].
pub station: f64, pub station: f64,
@@ -154,7 +154,7 @@ pub struct AlignmentStation {
} }
/// Road/bridge alignment specs. /// Road/bridge alignment specs.
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct AlignmentSpecs { pub struct AlignmentSpecs {
#[serde(default)] #[serde(default)]
pub r#type: String, pub r#type: String,
@@ -167,7 +167,7 @@ pub struct AlignmentSpecs {
} }
/// Alignment IR — parsed from cimery's own JSON format (ADR-002 R). /// 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 struct AlignmentIR {
pub name: String, pub name: String,
#[serde(default)] #[serde(default)]
@@ -219,7 +219,7 @@ impl AlignmentIR {
/// Fully-resolved Deck Slab (바닥판) specification. /// Fully-resolved Deck Slab (바닥판) specification.
/// Structural dimensions in mm, alignment in m. /// Structural dimensions in mm, alignment in m.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DeckSlabIR { pub struct DeckSlabIR {
pub id: FeatureId, pub id: FeatureId,
pub station_start: f64, // m pub station_start: f64, // m
@@ -246,7 +246,7 @@ impl DeckSlabIR {
// ─── Bearing IR ─────────────────────────────────────────────────────────────── // ─── Bearing IR ───────────────────────────────────────────────────────────────
/// Fully-resolved Bearing (받침) specification. /// Fully-resolved Bearing (받침) specification.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BearingIR { pub struct BearingIR {
pub id: FeatureId, pub id: FeatureId,
pub station: f64, // m — position along alignment pub station: f64, // m — position along alignment
@@ -262,7 +262,7 @@ pub struct BearingIR {
// ─── Cap Beam IR ────────────────────────────────────────────────────────────── // ─── Cap Beam IR ──────────────────────────────────────────────────────────────
/// Pier cap beam (교각 코핑). /// Pier cap beam (교각 코핑).
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CapBeamIR { pub struct CapBeamIR {
pub length: f64, // mm — total along transverse pub length: f64, // mm — total along transverse
pub width: f64, // mm — along span pub width: f64, // mm — along span
@@ -274,7 +274,7 @@ pub struct CapBeamIR {
// ─── Pier IR ────────────────────────────────────────────────────────────────── // ─── Pier IR ──────────────────────────────────────────────────────────────────
/// Fully-resolved Pier (교각) specification. /// Fully-resolved Pier (교각) specification.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PierIR { pub struct PierIR {
pub id: FeatureId, pub id: FeatureId,
pub station: f64, // m pub station: f64, // m
@@ -294,7 +294,7 @@ pub struct PierIR {
// ─── Wing Wall IR ───────────────────────────────────────────────────────────── // ─── Wing Wall IR ─────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WingWallIR { pub struct WingWallIR {
pub length: f64, // mm — along wing wall axis pub length: f64, // mm — along wing wall axis
pub height: f64, // mm — at connection with breast wall pub height: f64, // mm — at connection with breast wall
@@ -304,7 +304,7 @@ pub struct WingWallIR {
// ─── Abutment IR ────────────────────────────────────────────────────────────── // ─── Abutment IR ──────────────────────────────────────────────────────────────
/// Fully-resolved Abutment (교대) specification. /// Fully-resolved Abutment (교대) specification.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AbutmentIR { pub struct AbutmentIR {
pub id: FeatureId, pub id: FeatureId,
pub station: f64, // m pub station: f64, // m
@@ -327,7 +327,7 @@ pub struct AbutmentIR {
/// ///
/// Cross beams provide lateral bracing between girders at regular intervals. /// Cross beams provide lateral bracing between girders at regular intervals.
/// All dimensions in mm; station in m. /// All dimensions in mm; station in m.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CrossBeamIR { pub struct CrossBeamIR {
pub id: FeatureId, pub id: FeatureId,
/// Station along alignment [m] — position of this cross beam set. /// 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. /// Placed at the ends of a span or at pier locations to allow relative movement.
/// All dimensions in mm; station in m. /// All dimensions in mm; station in m.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ExpansionJointIR { pub struct ExpansionJointIR {
pub id: FeatureId, pub id: FeatureId,
/// Station along alignment [m]. /// Station along alignment [m].
@@ -377,6 +377,29 @@ pub struct ExpansionJointIR {
pub movement_range:f64, 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 ──────────────────────────────────────────────────────────────────── // ─── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)] #[cfg(test)]

View File

@@ -33,7 +33,7 @@ use cimery_ir::{
/// ///
/// Coordinate convention: X = width, Y = height, Z = along-span axis. /// Coordinate convention: X = width, Y = height, Z = along-span axis.
/// Units: millimetres. /// Units: millimetres.
#[derive(Debug, Clone)] #[derive(Debug, Clone, PartialEq)]
pub struct Mesh { pub struct Mesh {
/// Vertex positions [mm]: vec of [x, y, z]. /// Vertex positions [mm]: vec of [x, y, z].
pub vertices: Vec<[f32; 3]>, 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 ──────────────────────────────────────────────────────────────────── // ─── Error ────────────────────────────────────────────────────────────────────
#[derive(Debug, thiserror::Error)] #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum KernelError { pub enum KernelError {
#[error("geometry computation failed: {0}")] #[error("geometry computation failed: {0}")]
Computation(String), Computation(String),

View File

@@ -54,9 +54,8 @@ impl AlignmentTransform {
let up = tangent.cross(right).normalize_or(Vec3::Y); let up = tangent.cross(right).normalize_or(Vec3::Y);
// Build 4×4 matrix: columns = [right, up, tangent, origin] // 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); // AlignmentIR uses [x, y, z] where z is elevation;
// Note: AlignmentIR uses [x, y, z] where z is elevation; // bridge coordinate system: Y = up, so we remap: y_align → Y_world.
// 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 origin = Vec3::new(pos[0] as f32, pos[2] as f32, pos[1] as f32);
let matrix = Mat4::from_cols( let matrix = Mat4::from_cols(

View File

@@ -53,7 +53,7 @@ impl<K: GeomKernel + Clone + 'static> IncrementalBridge<K> {
for i in 0..n { for i in 0..n {
let x = (i as f64 - (n as f64 - 1.0) * 0.5) * spacing as f64; 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], id: self.girder_ids[i],
station_start: 0.0, station_start: 0.0,
station_end: span_m, station_end: span_m,

View File

@@ -8,6 +8,7 @@ use super::bridge_scene::{GirderSectionType, SceneParams};
// ─── Serialisable form of SceneParams ──────────────────────────────────────── // ─── Serialisable form of SceneParams ────────────────────────────────────────
#[allow(dead_code)]
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
struct SectionTypeStr(String); struct SectionTypeStr(String);

View File

@@ -0,0 +1,274 @@
---
title: cimery 실행 가이드
tags: [cimery, build, run, dev-ops]
sources:
- cimery/CLAUDE.md
- cimery/Cargo.toml
- cimery/crates/viewer/Cargo.toml
updated: 2026-04-14
---
# cimery 실행 가이드
cimery 개발·테스트·배포에 필요한 모든 실행 명령 레퍼런스.
작업 디렉터리는 항상 `d:/myObsidian/ParaWiki/cimery/` 기준.
---
## 전제 조건
| 항목 | 버전 | 비고 |
|------|------|------|
| Rust | stable 최신 | `rustup update` |
| wasm-pack | 최신 | WASM 빌드 시만 필요 |
| OpenCASCADE (OCCT) | 7.7+ | `--features occt` 시만 필요. VS Dev Cmd 필수 |
---
## 빌드 & 컴파일 확인
```bash
# 전체 워크스페이스 컴파일 확인 (가장 빠름)
cargo check --workspace
# 전체 빌드
cargo build --workspace
# OcctKernel 포함 빌드 (VS Developer Command Prompt에서)
cargo build -p cimery-kernel --features occt
# 릴리스 빌드
cargo build --release -p cimery-viewer
```
---
## 테스트
```bash
# 전체 워크스페이스 테스트
cargo test --workspace
# kernel 테스트만 (4층: insta 스냅샷·기하 불변량·크로스체크·proptest)
cargo test -p cimery-kernel
# 특정 layer만
cargo test -p cimery-kernel --test layer1_snapshots
cargo test -p cimery-kernel --test layer2_invariants
cargo test -p cimery-kernel --test layer3_cross_check
cargo test -p cimery-kernel --test layer4_proptest
# OcctKernel 크로스체크 포함 (--features occt 필요)
cargo test -p cimery-kernel --features occt
# 특정 크레이트
cargo test -p cimery-core
cargo test -p cimery-ir
cargo test -p cimery-dsl
cargo test -p cimery-incremental
cargo test -p cimery-usd
```
### insta 스냅샷 업데이트
IR 구조가 바뀌어 스냅샷을 갱신해야 할 때:
```bash
# 새 스냅샷을 .snap.new 로 생성
INSTA_UPDATE=new cargo test -p cimery-kernel --test layer1_snapshots
# .snap.new → .snap 수동 승인 (cargo-insta 미설치 시)
cd crates/kernel/tests/snapshots
for f in *.snap.new; do mv "$f" "${f%.new}"; done
# 또는 cargo-insta 설치 후
cargo install cargo-insta
cargo insta review
```
---
## 뷰어 실행
```bash
# PureRustKernel (기본, OCCT 불필요)
cargo run -p cimery-viewer
# OcctKernel 포함 (VS Dev Cmd에서)
cargo run -p cimery-viewer --features cimery-kernel/occt
# 릴리스 빌드로 실행 (성능 향상)
cargo run --release -p cimery-viewer
```
### 뷰어 단축키
| 키 | 동작 |
|----|------|
| `F` | ZoomExtents (전체 씬 맞추기) |
| `1` | 정면뷰 |
| `3` | 측면뷰 |
| `7` | 평면뷰 |
| `0` | 투시뷰 |
| 마우스 휠 | 줌 |
| 마우스 중간버튼 드래그 | 팬 |
| 우클릭 드래그 | 궤도 회전 |
| `Enter` | Apply (파라미터 적용) |
| `Ctrl+S` | 프로젝트 저장 |
| `Ctrl+O` | 프로젝트 열기 |
---
## USD 익스포트
```bash
# cimery-usd 크레이트 직접 실행
cargo run -p cimery-usd
# 출력: out/bridge_scene.usda (기본 경로)
```
### USD 파일 구조
```
#usda 1.0
def Xform "BridgeScene" {
def Xform "Girders" { ... }
def Xform "DeckSlabs" { ... }
def Xform "Bearings" { ... }
def Xform "Piers" { ... }
def Xform "Abutments" { ... }
def Xform "CrossBeams" { ... }
def Xform "ExpansionJoints" { ... }
}
```
---
## WASM / PWA 빌드
```bash
# wasm-pack으로 viewer 빌드
wasm-pack build crates/viewer --target web --features wasm
# 출력 디렉터리: crates/viewer/pkg/
# Cloudflare Pages 배포: .github/workflows/wasm.yml 참조
```
---
## CI/CD
| 워크플로 | 경로 | 트리거 | 내용 |
|----------|------|--------|------|
| Gitea CI | `.gitea/workflows/ci.yml` | push/PR | check → test → clippy → fmt |
| GitHub CI | `.github/workflows/ci.yml` | push/PR | 멀티플랫폼(Ubuntu·Windows·macOS), OCCT 조건부 |
| GitHub Release | `.github/workflows/release.yml` | `v*` 태그 | 바이너리 빌드 + GitHub Release 업로드 |
| WASM Deploy | `.github/workflows/wasm.yml` | main push | wasm-pack 빌드 + Cloudflare Pages 배포 |
```bash
# 릴리스 태그 생성 (자동 빌드 트리거)
git tag v0.1.0
git push origin v0.1.0
```
---
## 크레이트 의존 관계
```
core → ir → { dsl, kernel, usd } → incremental → evaluator
viewer
```
| 크레이트 | 역할 |
|----------|------|
| `cimery-core` | 단위 타입(Mm·M), 도메인 열거형 |
| `cimery-ir` | IR 구조체 + serde JSON 직렬화 |
| `cimery-dsl` | Feature 빌더 + 검증 |
| `cimery-kernel` | GeomKernel trait + StubKernel + PureRustKernel + OcctKernel |
| `cimery-incremental` | dirty-tracking 증분 캐시 |
| `cimery-evaluator` | IR → kernel 연결 레이어 |
| `cimery-usd` | USD 텍스트 익스포터 |
| `cimery-viewer` | wgpu + egui 3D 뷰어 |
| `cimery-app` | Tauri v2 데스크톱 래퍼 (개발 중) |
---
## 프로젝트 파일 (`.cimery`)
뷰어에서 저장하는 JSON 포맷:
```json
{
"version": 1,
"span_m": 40.0,
"girder_count": 5,
"girder_spacing_mm": 2500.0,
"girder_height_mm": 1800.0,
"slab_thickness_mm": 220.0,
"show_cross_beams": true,
"cross_beam_interval_m": 5.0,
"show_expansion_joints": true
}
```
저장/불러오기: 뷰어 SidePanel → **▼ 프로젝트** → 저장 / 열기
---
## 자주 쓰는 커맨드 조합
```bash
# 개발 루프: 변경 후 즉시 확인
cargo check -p cimery-kernel && cargo test -p cimery-kernel && cargo run -p cimery-viewer
# 커밋 전 전체 검증
cargo test --workspace && cargo clippy --workspace -- -D warnings
# 새 crate 추가 후 workspace 등록 확인
cargo check --workspace
```
---
### 아키텍처
```
cimery-app (Tauri v2)
│ WebView: frontend/index.html ← 런처 UI (다크 테마)
│ IPC: #[tauri::command] ← commands.rs
└─ cimery-viewer (사이드카)
egui+wgpu 3D 뷰어 (기존 바이너리)
exe/앱 번들과 같은 디렉터리에서 탐색 → PATH fallback
```
### 다음 빌드 명령
```bash
# 뷰어 먼저 릴리스 빌드 (앱과 같은 디렉터리에 복사 필요)
cargo build --release -p cimery-viewer
# Tauri 개발 서버 (설치 없이 실행)
```bash
cd cimery/crates/app
cargo tauri dev
```
```
cargo tauri dev -p cimery-app
# 설치 파일 생성 (Win: .msi/.exe, macOS: .dmg, Linux: .deb)
cargo tauri build -p cimery-app
```
## 관련 문서
- [[cimery 아키텍처 개요]] (미작성 — ADR-001 참조)
- `Output/guides/cimery-dev-guide.md` — 상세 개발 지침
- `Output/reports/ADR-001-tech-stack.md` — 기술 스택 결정
- `Output/reports/ADR-002-feature-dsl.md` — Feature DSL 아키텍처
- `Output/reports/ADR-003-architecture-followups.md` — 후속 12개 결정

View File

@@ -27,7 +27,7 @@ updated: 2026-04-14
(아직 없음) (아직 없음)
## 도구·엔진 (Tools & Engines) ## 도구·엔진 (Tools & Engines)
(아직 없음) - [[cimery 실행 가이드]] — 빌드·테스트·뷰어 실행·USD·WASM·CI/CD·크레이트 구조 전체 실행 명령 레퍼런스.
## 표준·스펙 (Standards & Specs) ## 표준·스펙 (Standards & Specs)
(아직 없음) (아직 없음)

View File

@@ -8,6 +8,7 @@
--- ---
- 2026-04-14 create [[cimery 실행 가이드]] — 빌드·테스트·뷰어 실행·USD·WASM·CI/CD·크레이트 구조 전체 실행 명령 레퍼런스.
- 2026-04-14 meta — PLAN.md · PROGRESS.md 도입. 에이전트 협업 프로토콜 확립. CLAUDE.md에 필독 섹션 추가. - 2026-04-14 meta — PLAN.md · PROGRESS.md 도입. 에이전트 협업 프로토콜 확립. CLAUDE.md에 필독 섹션 추가.
- 2026-04-14 meta — CLAUDE.md 린화. 상세 지침을 Output/guides/cimery-dev-guide.md·Output/guides/obsidian-cli.md로 분리. 프롬프트 토큰 절감. - 2026-04-14 meta — CLAUDE.md 린화. 상세 지침을 Output/guides/cimery-dev-guide.md·Output/guides/obsidian-cli.md로 분리. 프롬프트 토큰 절감.
- 2026-04-14 meta — ADR-003 후속 아키텍처 결정 작성. 12개 주제 병렬 조사 기반: UI(Leptos)·IFC(ifc-lite-core)·CI/CD(Gitea+GH 하이브리드)·USD(Codeless schema)·Alignment·Plugin(Extism→WIT)·Feature 카탈로그·FEM(MIDAS)·LOD 300·리본 12탭·선택/필터·설정 3계층. - 2026-04-14 meta — ADR-003 후속 아키텍처 결정 작성. 12개 주제 병렬 조사 기반: UI(Leptos)·IFC(ifc-lite-core)·CI/CD(Gitea+GH 하이브리드)·USD(Codeless schema)·Alignment·Plugin(Extism→WIT)·Feature 카탈로그·FEM(MIDAS)·LOD 300·리본 12탭·선택/필터·설정 3계층.