Author SHA1 Message Date
etwenandClaude Fable 5 e355cb4363 docs: Record Phase 13 (v0.4.0 perf/stability) and prioritized feature backlog
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 14:39:09 +08:00
etwenandClaude Fable 5 96e15013c3 perf: Background parallel section clip, parallel transform bake, faster gap refine (v0.4.0)
- ApplySection: snapshot on UI thread, clip whole scene with Task.Run +
  Parallel.For, swap once; _sectionApplying/_sectionReapply guard reruns
  with latest params when settings change mid-clip
- TransformRoot: per-face parallel mesh transform (frozen in/out);
  B-rep Modify and visual-tree assignment stay sequential
- InterferenceService.ApproxMinDistance: AABB squared-distance early
  reject + Parallel.For refine with thread-local best
- Stability: global exception handlers in App.xaml.cs (dispatcher/task/
  appdomain -> %LOCALAPPDATA%/STPViewer/error.log); IsBusy CanExecute
  guards on Import/Rotate/Interference; whole-batch busy + reentrancy
  guard in ImportFilesAsync
- Refactor: split MainViewModel into partial classes (Drag/Gizmo/
  Section/Interference/Export); dedupe merged-mesh build and
  ClosestPointOnTriangle
- Bump version to 0.4.0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 14:39:08 +08:00
etwen d5a0f5ef57 docs: Add v0.3.2 release notes 2026-06-18 17:00:00 +08:00
etwenandClaude Opus 4.8 3556e25e96 chore: Add --bbox SmokeTest diagnostic and v0.3.1 release notes
BBoxDump prints per-leaf world-space bounding boxes (to work out each
connector's mating-face axis); wired as `SmokeTest --bbox <file>`. Also tracks
the previously untracked v0.3.1 release notes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:49:07 +08:00
etwenandClaude Opus 4.8 e27fd17eb8 perf: Render merged mesh in measure mode to fix large-assembly orbit lag (v0.3.2)
Measurement modes used to render per-face content (one GeometryModel3D per
B-rep face). On a large assembly (64k faces measured) that is 64k draw calls,
and WPF re-walks the whole visual tree every frame, so orbiting in a
measurement tool was severely laggy while plain browse mode (which renders the
merged mesh, one model per file) stayed smooth.

Render the merged mesh whenever not sectioning (browse AND measure), and
resolve the picked B-rep face from the hit triangle's vertex index via
_mergedFaceRanges + ResolveMergedFace (binary search over per-face vertex-start
boundaries recorded at import — BuildMergedMesh appends faces in order with no
vertex welding, so the ranges are exact and stay valid across translation).
Per-face FacesContent is now rendered only in section mode (needs cut
geometry). WPF 3D hit testing is geometric and does not cull back faces, so
single-sided merged meshes still pick hole inner walls.

Also subscribe camera-interaction suspension to HelixViewport3D.CameraChanged
(control-level routed event) in addition to Camera.Changed, so it can't be
orphaned if Helix replaces the camera instance.

Verified: orbit in measure mode is now as smooth as browse; circle/face/edge/
angle/face-distance and section measurements unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:48:42 +08:00
etwenandClaude Opus 4.8 34afaa6941 chore: Ignore Publish/ (publish-winapp output)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:21:27 +08:00
etwenandClaude Opus 4.8 d00b4e3316 chore: Single-source version from csproj <Version>
Add <Version>0.3.1</Version> to STPViewer.csproj and read the assembly
version at runtime for the window title, instead of hardcoding it in
XAML. Now bumping <Version> alone updates the title and the
publish-winapp output folder / exe name in one place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:11:04 +08:00
etwenandClaude Opus 4.8 ac08c129bd chore: Remove unused secret/ scaffolding
The project has no runtime or compile-time secrets (offline desktop
tool, no DB/API keys), so the secret/ folder held only example launch
scripts that were never used. Remove it and the related .gitignore
rules; trim references in ARCHITECTURE.md and CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 21:56:56 +08:00
24 changed files with 1498 additions and 840 deletions
+1 -6
View File
@@ -2,15 +2,10 @@
bin/
obj/
publish/
Publish/ # publish-winapp 打包產物(大小寫明確,Linux/CI 也忽略)
*.user
.vs/
# secret/ 全部忽略,但保留 README 與範本
secret/*
!secret/README.md
!secret/*.example
!secret/.gitkeep
# AI 協作素材不入 git
For_AI/
+98 -19
View File
@@ -65,8 +65,11 @@ STEP`.stp` / `.step`)檔案做確認與量測,不需要安裝 SolidWorks
資料流:`*.stp → CADability B-rep → 三角網格(渲染) + B-rep 參照(量測) → Helix Viewport`
每個 Face 對應一個 `GeometryModel3D`,並登錄到 `Dictionary<GeometryModel3D, FaceInfo>`
HitTest 命中後可反查回 B-rep Face / Edge 做精確量測。
每個 Face 對應一個 `GeometryModel3D``FacesContent`,剖面模式渲染用),並登錄到 `_faceMap` 反查 B-rep Face / Edge。
效能:非剖面(瀏覽+量測)渲染**整零件合併網格**(`MergedContent`,每檔 1 個 model),量測 HitTest 改打合併網格、
用命中三角形的頂點 index 經 `_mergedFaceRanges` 二分搜尋(`ResolveMergedFace`)反查回是哪個面 —
量測模式不再掛數萬個逐面 model,大組件量測下轉動視角與瀏覽同樣流暢(v0.3.2)。
---
@@ -78,17 +81,12 @@ STPViewer/
├── CLAUDE.md # 專案記憶 & 給 Claude 的指令
├── README.md # 快速上手
├── ARCHITECTURE.md # 本文件
├── .gitignore # 含 secret/ 與 For_AI/ 規則
├── .gitignore # For_AI/ + *.stp/*.step(客戶料號)規則
├── docs/
│ ├── decisions/ # 技術決策紀錄(ADR)
│ └── runbooks/ # 操作手冊
├── secret/ # 🚫 gitignoredREADME.md / *.example 除外)
│ ├── README.md
│ ├── run-STPViewer.ps1.example
│ └── run-STPViewer.sh.example
├── For_AI/ # 🚫 gitignored — AI 協作素材 + 測試模型 *.stp(客戶料號不入 git)
├── STPViewer.sln
@@ -96,7 +94,7 @@ STPViewer/
└── src/
└── STPViewer/
├── STPViewer.csproj # net8.0-windows, UseWPF
├── App.xaml / App.xaml.cs
├── App.xaml / App.xaml.cs # 全域例外處理(log + 訊息框存活;%LOCALAPPDATA%\STPViewer\error.log
├── MainWindow.xaml / .cs # 版面 + 滑鼠拾取事件
├── Models/
@@ -112,9 +110,14 @@ STPViewer/
│ ├── RigidAlign.cs # 三點對齊/旋轉的剛體變換數學(Matrix3D列向量 ↔ ModOp行向量 轉換)
│ └── SectionService.cs # 剖面:網格/線段半空間裁切
└── ViewModels/
├── MainViewModel.cs # 樹集合、量測集合、剖面、單位、匯出
── ModelNodeViewModel.cs # 裝配樹節點(可見性/顏色 cascade)
└── ViewModels/ # MainViewModel 為 partial class,依職責分檔
├── MainViewModel.cs # 核心:匯入、裝配樹、量測、剛體變換、邊線、單位
── MainViewModel.Drag.cs # 拖曳模式
├── MainViewModel.Gizmo.cs # 三軸操作器 + 疊圖層相機
├── MainViewModel.Section.cs # 剖面(v0.4.0 起背景平行裁切)
├── MainViewModel.Interference.cs # 干涉檢查指令
├── MainViewModel.Export.cs # CSV / 截圖匯出
└── ModelNodeViewModel.cs # 裝配樹節點(可見性/顏色 cascade)
```
---
@@ -203,12 +206,9 @@ class MeasurementResult
## Security Considerations
- 純離線桌面工具,無網路、無帳號、無資料庫
- 本專案目前無任何 runtime secret`secret/` 仍依專案慣例建立:
- `secret/*` gitignore,僅 `README.md``*.example` 進 git
- `run-STPViewer.*.example` 為啟動腳本範本(本專案無 DB/ApiKey,腳本僅做 build+run
- 無 compile-time secret → 不需 `publish-*.example` 腳本
- `For_AI/` 收 AI 協作素材(截圖、筆記),整夾 gitignore
- 純離線桌面工具,無網路、無帳號、無資料庫、無任何 runtime / compile-time secret(故未建 `secret/` 資料夾)
- `For_AI/` 收 AI 協作素材(截圖、筆記)+ 測試模型 `*.stp`(客戶料號),整夾 gitignore
- `*.stp` / `*.step` gitignore:客戶 CAD 料號檔不入 git,避免外流
---
@@ -344,9 +344,88 @@ NuGet 相依(自動還原):`CADability`、`HelixToolkit.Wpf`、`CommunityT
---
## Development Phases — 第四輪(效能,全部完成)
### Phase 12 — 大組件量測流暢度(工作量:M)
**問題:** 大檔(39MB / 64k 面)瀏覽轉動順,但一進量測模式轉動就嚴重卡頓。
**根因:** 量測模式渲染逐面(每個 B-rep 面 1 個 `GeometryModel3D`),64k 面 = 64k draw call
WPF retained-mode 每幀重走 visual tree → frame rate 崩。瀏覽模式因渲染合併網格(每檔 1 個 model)所以順。
- [x] 量測拾取改打**合併網格** + 三角形頂點 index 反查面(`_mergedFaceRanges` + `ResolveMergedFace` 二分搜尋)
- [x] `ApplyRenderMode()` 改為:非剖面(瀏覽+量測)→ 合併網格;剖面 → 逐面。量測模式不再掛數萬個逐面 model
- [x] `BuildMergedMesh` 依面序串接(無焊接共用頂點),匯入時同步記錄每面頂點起始邊界 + `FaceInfo`
- [x] 相機暫停事件改訂 `HelixViewport3D.CameraChanged`(控制項層級)+ `Camera.Changed` 保底,避免相機實例被換掉時訂閱孤兒化
- [x] 驗證:WPF 3D hit-test 純幾何不剔背面 → 打單面合併網格仍命中孔內壁;圓/面/邊/角度/面距/剖面量測數值與逐面一致
**驗收:** 大組件量測模式下轉動視角與瀏覽同樣流暢;圓孔/面/邊/角度/面距量測精度不變;剖面模式量測仍正常
---
## Development Phases — 第五輪(效能 + 穩定性 v0.4.0,全部完成)
### Phase 13 — 效能 + 穩定性(工作量:M)
**效能:**
- [x] 剖面裁切背景化 + 平行化 — `ApplySection` 改 asyncUI 執行緒快照 (model, frozen mesh)、
`Task.Run` + `Parallel.For` 全場景裁切、回 UI 一次換上。`_sectionApplying`/`_sectionReapply` guard
裁切期間的新變更(拉滑桿/換軸/零件平移)→ 完成後用最新參數重跑一輪,最終狀態必為最新。
大檔(64k 面)拉剖面滑桿不再凍結 UI
- [x] `TransformRoot` 網格變換面級平行化 — 來源/輸出 mesh 皆 frozen、各面獨立所以安全;
B-rep `Modify` 與視覺樹賦值維持循序(CADability 非執行緒安全 / WPF 執行緒親和)。
大組件拖曳/操作器放開的烘焙時間大減
- [x] 干涉間隙精修加速 — `ApproxMinDistance` 精修改為 AABB 平方距離下界快速拒絕 + `Parallel.For`
分段掃描(thread-local bestlock 只在合併時),大檔全三角形掃描成本大減
- [x] 合併網格邏輯去重複 — `BuildMergedMesh`/`RebuildMerged` 共用 `MergeMeshes`(面序不變,
`_mergedFaceRanges` 頂點邊界維持有效);`MeasurementService.ClosestPointOnTriangle`
改用 `InterferenceService` 的同一實作
**穩定性:**
- [x] 全域例外處理(App.xaml.cs)— `DispatcherUnhandledException`(記 log + 訊息框 + Handled 讓程式存活)、
`TaskScheduler.UnobservedTaskException`SetObserved)、`AppDomain.UnhandledException`(致命前留記錄);
log 位於 `%LOCALAPPDATA%\STPViewer\error.log`
- [x] `IsBusy` 指令防護 — 匯入 / 旋轉 90° / 干涉檢查在背景運算期間停用(`CanExecute`),
避免干涉檢查中零件被轉走造成結果錯位;`ImportFilesAsync` 整批維持 busy、
重入(拖放/命令列)直接擋掉(StepImportService 有共享狀態、非重入安全)
- [x] `MainViewModel`(原 1,476 行)拆 partial class — Drag / Gizmo / Section / Interference / Export 各自成檔
**驗收:** ClipTest 5 項、AlignTest 8 項、InterferenceTest 3 情境全過;test.stp 匯入 smoke test 通過;建置 0 警告
---
## Future Extensions(下一輪)
- 剖切面封口(cap)填實(目前剖開處可見內部背面材質)
依價值/成本排序(v0.4.0 盤點):
### 低成本高價值(建議下一輪優先)
- **圓心吸附** — 距離模式點到圓邊時吸附到圓心 → 直接量兩孔 pitch(心到心距離),
連接器 pin pitch 確認的殺手級量測;實作只需在 `MeasurementService.Snap` 加掃該面的圓形 Edge
- **Esc 取消** — 兩段式量測(距離/角度/三點對齊)按 Esc 清除 pending、再按退回瀏覽模式;
順手加量測模式快速鍵(P 點 / D 距離 / E 邊 / F 面 / C 圓…)
- **GridSplitter** — 樹面板 / 量測面板寬度可調(現為固定 280 / 300 px,深層裝配樹名稱會被截斷)
- **標準視圖 + 正交投影** — 前/上/右/等角視圖按鈕 + `OrthographicCamera` 切換(Helix 原生支援);
工程師確認尺寸時正交投影是剛需,透視會騙眼睛
- **節點外形尺寸** — 樹節點顯示該零件/組件 L×W×H(`Bounds` 已有,加進 Stats/tooltip 或做成一種量測)
- **隔離顯示(Isolate)** — 樹節點右鍵「只顯示此節點」/「反轉顯示」,大組件找零件比逐個勾 checkbox 快
- **記住設定 + 最近檔案** — 視窗大小、mm/inch、邊線開關存 user settings;匯入按鈕加 MRU 下拉
### 中等成本
- **體積/質心** — 封閉實體用網格 signed volume(各三角形對原點的四面體有向體積加總)近似,
對 match 驗證與料號確認有用
- **樹搜尋/過濾** — 裝配樹上百零件時依名稱過濾
- **干涉支援 >2 個可見檔** — 跳選擇配對對話框或兩兩檢查(現硬性要求剛好 2 個)
- **對齊結果匯出 STEP** — 把目前場景(對齊後位置)輸出成新 STEP 檔,把配合結果交接出去;
需先驗證 CADability 有無 STEP writer;寫「新檔」不違反唯讀原則
- **任意剖面** — 三點定義剖切平面 + 位置數值輸入框(`ClipMesh` 本就吃任意 plane point/normal,純 UI 工作)
- **大檔重開快取** — 匯入成功後把三角網格 + 裝配樹序列化成 sidecar 快取檔(以來源檔 hash 驗證),
重開同檔免等 CADability 解析(39MB 實測 276 秒)。取捨:B-rep 無法快取 → 量測退化成網格模式
或背景補載;是否值得取決於「重複開同一大檔」的頻率
### 既有清單(維持)
- 剖切面封口(cap)填實(目前剖開處可見內部背面材質;需對裁切輪廓三角化補面,難度最高,建議最後做)
- 樹節點三態 checkbox(部分子節點隱藏時顯示中間態)
- IGES 支援(需引入其他幾何核心或自寫 reader)
- 量測結果匯出含截圖的 PDF 報告
+30 -12
View File
@@ -27,19 +27,37 @@ dotnet publish src/STPViewer -c Release -o publish/STPViewer
## 開發慣例
- MVVM:邏輯寫 ViewModel/Servicecode-behind 只做純 View 事件(滑鼠拾取轉發)
- 一個 B-rep Face = 一個 `GeometryModel3D`,用 `Dictionary<Model3D, FaceInfo>` 反查(量測拾取靠這個,**不可移除逐面結構**)
- 渲染雙模式(降 draw call):每個 leaf 同時持有 `FacesContent`(逐面,量測用)與 `MergedContent`(整零件合併成 1 個 `GeometryModel3D`,瀏覽用)。
`ApplyRenderMode()` 依狀態切 `BodyVisual.Content`:**瀏覽(None)且未剖面 → 合併網格**;量測模式或剖面 → 逐面。
合併網格只代表「未剖切、目前位置」幾何;平移後呼叫 `RebuildMerged(leaf)` 同步。`_faceMap`/量測/剖面/干涉一律走 `FacesContent`,與目前顯示哪種內容無關
- 合併網格的 `BackMaterial`:封閉實體(`SolidCount≥1``HasBrep`)**不設**(WPF 兩面渲染成本砍半);開放殼/STL 才設。逐面 `FacesContent` 一律保留 BackMaterial(剖切要看內部
- `MainViewModel`**partial class 分檔**(v0.4.0):主檔(匯入/樹/量測/變換/邊線)+
`.Drag` / `.Gizmo` / `.Section` / `.Interference` / `.Export`。加新功能先看職責放哪個檔,勿全塞回主檔
- 全域例外處理在 `App.xaml.cs`:UI 執行緒未攔截例外 → 記 log(`%LOCALAPPDATA%\STPViewer\error.log`+
訊息框 + `Handled=true` 存活;背景 Task 例外記 log。個別功能仍應自行 try/catch 給出有意義的狀態列訊息
- 一個 B-rep Face = 一個 `GeometryModel3D`,用 `Dictionary<Model3D, FaceInfo>` (`_faceMap`) 反查(剖面模式逐面拾取靠這個,**不可移除逐面結構**
- 渲染雙模式(降 draw call):每個 leaf 同時持有 `FacesContent`(逐面,剖面用)與 `MergedContent`(整零件合併成 1 個 `GeometryModel3D`)。
`ApplyRenderMode()` 依狀態切 `BodyVisual.Content`:**非剖面(瀏覽+量測皆是)→ 合併網格**;**剖面 → 逐面**(要顯示各面裁切後幾何)。
合併網格只代表「未剖切、目前位置」幾何;平移後呼叫 `RebuildMerged(leaf)` 同步。剖面/干涉/`RebuildMerged` 一律走 `FacesContent`,與目前顯示哪種內容無關
-**量測拾取打合併網格、用三角形頂點 index 反查面**`_mergedFaceRanges`:合併 Model → (每面頂點起始邊界, FaceInfo[]))。
`BuildMergedMesh` 依面序串接(`baseIdx += positions.Count`,無焊接共用頂點),故命中 `RayHit.VertexIndex1` 可二分搜尋(`ResolveMergedFace`)回是哪個面。
**這是 v0.3.2 修大檔量測卡頓的關鍵**:量測模式不再掛數萬個逐面 `GeometryModel3D`64k 面實測 = 64k draw call,轉動/停下重繪爆量)→ 改成每檔 1 個 model,量測模式 orbit 與瀏覽同樣順。
邊界由面頂點數決定、平移不改 → 永久有效,`RebuildMerged` 不需重算。**不要因為「量測要逐面」而把渲染改回逐面**
- 合併網格的 `BackMaterial`:封閉實體(`SolidCount≥1``HasBrep`)**不設**(WPF 兩面渲染成本砍半);開放殼/STL 才設。
**拾取不受材質影響**WPF 3D hit-test 純幾何、不剔背面),故打合併網格仍命中孔內壁等背向面。逐面 `FacesContent` 一律保留 BackMaterial(剖切要看內部)
- 量測值以 B-rep 為準(圓半徑、邊長、角度),面積/面距用網格近似
- 量測文字一律 `Func<UnitSystem,string>` 延後產生(mm⇄inch 即時切換);內部數值永遠存 mm
- 裝配樹節點(`ModelNodeViewModel`)的可見性/邊線/顏色向下 cascade
- 剖面只換 `GeometryModel3D.Geometry``FaceInfo.Mesh` 保留原始 frozen mesh 供還原與量測)
- ⚡ 剖面裁切是**背景平行**(v0.4.0):`ApplySection` 在 UI 快照 (model, frozen mesh) → `Task.Run`+`Parallel.For`
裁切 → 回 UI 一次換上;`_sectionApplying`/`_sectionReapply` guard 保證裁切中的新變更(滑桿/換軸/`TransformRoot`
完成後用最新參數重跑。**不要改回同步呼叫 ClipMesh**(64k 面整場景裁切會凍結 UI 數秒);
await 之後一定要檢查 `SectionEnabled`(期間可能被關掉,還原分支已處理)
- 剛體變換(兩點對齊/旋轉 90°/三點對齊)統一走 `TransformRoot(root, ModOp, Matrix3D)`B-rep 用 ModOp 對 Solid/Shell 整體 `Modify`
(勿逐面位移,會重複位移共用邊),網格/合併網格/邊線/邊界同步重算;變換後量測已失效要 `ClearMeasurements()`
**op 與 m 必須是同一個變換** — 數學在 `Services/RigidAlign.cs`WPF `Matrix3D` 是「列向量」約定、CADability `ModOp` 是「行向量」約定,
`ToModOp` 負責轉置轉換,改動務必跑 `SmokeTest --align-test` 驗證兩種表示一致,否則 B-rep 與顯示網格會悄悄分家
`ToModOp` 負責轉置轉換,改動務必跑 `SmokeTest --align-test` 驗證兩種表示一致,否則 B-rep 與顯示網格會悄悄分家
`TransformRoot` 的網格變換是**面級平行**(v0.4.0;來源/輸出 mesh 皆 frozen 所以安全);
B-rep `Modify` 與視覺樹賦值必須維持循序(CADability 非執行緒安全 / WPF 執行緒親和)
- IsBusy 期間匯入/旋轉 90°/干涉檢查指令停用(`CanExecute = nameof(IsIdle)` + isBusy 的
`NotifyCanExecuteChangedFor`);`ImportFilesAsync` 整批維持 busy 且開頭擋重入
(拖放/命令列會繞過 CanExecute)。新增「會改動幾何的指令」時記得比照辦理
- 拖曳模式(`MeasureMode.Drag`):拖曳中只掛**暫時 `TranslateTransform3D`**GPU 免費),放開才一次性 `TranslateRoot` 烘進 B-rep —
**不要改成拖曳中逐幀 TransformRoot**(大檔每幀重建網格會卡死)。2D→3D 用 Helix `UnProject`(過錨點、法向=相機 LookDirection 的平面)。
合併網格的 hit-test 走 `_mergedMap`(合併 Model → leaf);拖曳中邊線用 `_edgesSuspended` 暫停
@@ -72,8 +90,9 @@ dotnet publish src/STPViewer -c Release -o publish/STPViewer
- 邊線採「**一檔一條合併 `LinesVisual3D`**」(掛在 root `EdgeVisual`,由 `RefreshRootEdges` 收集各 leaf `OriginalEdgePoints` 重建)。
**不要改回逐 leaf 一條** — 裝配樹零件多時,N 條線每幀重建會嚴重卡頓(實測主因)。leaf 只保留邊線「資料」,渲染統一在 root;
可見性/ShowEdges/剖面/平移變更時呼叫 `RefreshRootEdges(root)` 重組合併線
- 互動中暫停邊線:`Attach``Camera.Changed``OnCameraMoved` 隱藏邊線、`_interactionTimer`(180ms) 停下後 `ResumeEdges` 顯示;
`_edgesSuspended` 為真時 `RefreshRootEdges` 不把線掛回。轉動/縮放/平移時不付邊線重建成本
- 互動中暫停邊線:`Attach` **`HelixViewport3D.CameraChanged`(控制項層級 routed event)+ `Camera.Changed`(保底)**`OnCameraMoved` 隱藏邊線、`_interactionTimer`(180ms) 停下後 `ResumeEdges` 顯示;
`_edgesSuspended` 為真時 `RefreshRootEdges` 不把線掛回。轉動/縮放/平移時不付邊線重建成本
**只訂 `Camera.Changed` 不夠**`Attach` 在建構式呼叫,相機實例若被 Helix 換掉訂閱會孤兒化 → 暫停永不觸發;故加訂控制項層級事件保底(重複觸發 `OnCameraMoved` 無害,有 guard
- CADability `ImportStep` 對少數 AP242 檔案支援不完整;匯入失敗要 catch 顯示訊息,不可閃退
- IGES 無 readerSTL 無 B-repFaceInfo.BrepFace == null 的分支要保留)
- 不寫入原始檔(唯讀工具);WPF 限 Windows,不要嘗試移植 vbox/Linux
@@ -82,8 +101,7 @@ dotnet publish src/STPViewer -c Release -o publish/STPViewer
`--align-test`(三點對齊剛體變換 + ModOp↔Matrix3D 一致性)、`--make-dxf`(產測試檔)
- **絕不要用 PowerShell regex/Set-Content 改 .cs 檔** — Windows PowerShell 5.1 預設編碼會把 UTF-8 中文弄成亂碼(已踩過,靠反編譯 DLL 救回)。文字取代一律用 Edit 工具
## secret/ 與 For_AI/
## For_AI/
- `secret/`:本機敏感資料集中地,`secret/*` gitignored`README.md``*.example` 除外)
本專案無 runtime secret,僅有啟動腳本範本
- `For_AI/`:AI 協作素材(截圖、草稿),整夾 gitignored。
- `For_AI/`:AI 協作素材(截圖、草稿)+ 測試模型 `*.stp`(客戶料號),整夾 gitignored
- 本專案無 runtime secret(離線桌面工具,無 DB/ApiKey),故未保留 `secret/` 資料夾
+175 -47
View File
@@ -1,71 +1,199 @@
# STPViewer
STP/STEP 3D 檢視器(Windows 桌面程式,C# .NET 8 WPF)— 多檔匯入、圖層管理、點/距離/邊/面/圓量測。
[![English](https://img.shields.io/badge/English-2ea043.svg)](README.md) [![繁體中文](https://img.shields.io/badge/%E7%B9%81%E9%AB%94%E4%B8%AD%E6%96%87-lightgrey.svg)](README.zh-TW.md)
![.NET](https://img.shields.io/badge/.NET-8.0-512BD4.svg) ![Platform](https://img.shields.io/badge/platform-Windows-0078D6.svg) ![UI](https://img.shields.io/badge/UI-WPF-blueviolet.svg)
> A Windows desktop 3D viewer for STP/STEP CAD files — multi-file import, assembly tree, and point / distance / edge / face / circle measurement. Built with C# .NET 8 WPF.
## 功能
![version](https://img.shields.io/badge/version-0.3.2-blue.svg) ![platform](https://img.shields.io/badge/platform-Windows-0078D6.svg) ![.NET](https://img.shields.io/badge/.NET-8.0-512BD4.svg) ![UI](https://img.shields.io/badge/UI-WPF-blueviolet.svg)
- **多檔匯入**STEP / STL / DXF — 工具列匯入(可複選)、拖放到視窗、或命令列 `STPViewer.exe a.stp b.stl`
- **裝配樹**STEP product structure 還原成樹(組件→零件),逐節點 顯示/隱藏、換色(cascade)、Zoom-to;檔案層級可移除、開關輪廓邊線
- **量測**(工具列切換模式,點擊模型):
| 模式 | 輸出 |
---
## 📖 Table of Contents
- [✨ Features](#-features)
- [💻 System Requirements](#-system-requirements)
- [📥 Installation](#-installation)
- [🚀 Quick Start](#-quick-start)
- [📚 Usage Guide](#-usage-guide)
- [🔨 Building from Source](#-building-from-source)
- [📁 Project Structure](#-project-structure)
- [⚠️ Known Limitations](#-known-limitations)
- [🤝 Contributing](#-contributing)
- [📜 Version History](#-version-history)
- [🙏 Acknowledgments](#-acknowledgments)
---
## ✨ Features
Open mechanical STEP parts for quick review and measurement without a heavyweight CAD suite (SolidWorks / Creo).
- **Multi-file import** — STEP / STL / DXF, via the toolbar (multi-select), drag-and-drop, or command line: `STPViewer.exe a.stp b.stl`
- **Assembly tree** — STEP product structure restored as a tree (assembly → part); per-node show/hide, recolor (cascades to children), zoom-to; file-level remove and outline-edge toggle
- **Measurement** (toolbar mode toggle, then click the model):
| Mode | Output |
|---|---|
| 📍 | XYZ 座標(自動吸附鄰近 B-rep 頂點) |
| 📏 距離 | 兩點直線距離 + ΔX/ΔY/ΔZ |
| 📐 邊 | 直線長 / 曲線長 / 圓弧長+半徑 |
| ⬛ 面 | 面積(網格近似)+ 曲面類型(平面法向量、圓柱半徑/軸向) |
| ⭕ 圓 | 圓心 / 半徑 / 直徑 / 周長 |
| ∠ 角度 | 兩面(法向量)/ 兩直線邊 夾角 + 補角 |
| ⇔ 面距 | 面到面最短距離(網格近似)+ 最近點對 |
| ⤚ 對齊 | 點「要移動零件」一點 + 目標點 → 純平移該檔案使兩點貼合(B-rep 整體位移) |
| 🎯 三點 | 來源檔 3 特徵點 + 目標檔 3 對應點 → 旋轉+平移一次貼合(方向不同也能對) |
- **旋轉**:樹面板選檔案 + 工具列 ↻X/↻Y/↻Z 繞中心 +90°(擺正方向用,連按累加)
- **拖曳** 🖐:手形游標模式,左鍵按住零件沿螢幕平面拖動、放開定位(粗定位用;精確貼合用對齊);右鍵轉視角不受影響
- **操作器** ⊹:樹面板選檔案 → 顯示 XYZ 三色箭頭 + 旋轉環(Fusion 360 風格);拖箭頭沿該軸移動(與視角無關,適合沿插合軸推進)、拖環繞軸轉任意角度,放開即定位。操作器永遠浮在最上層、不被實體遮擋
- **干涉檢查** 🧩:勾選剛好 2 個可見檔案 → 相交時顯示紅色干涉交線 + 相交三角形對數;無相交時回報最小間隙 gap(gap≈0 即為配合 match,共面貼合不算干涉)
- **剖面**:✂ 開關 + X/Y/Z 軸 + 位置滑桿 + 反向;CPU 網格裁切,原始幾何保留(量測不受影響)
- **單位**:mm ⇄ inch 一鍵切換,既有量測(清單與 3D 標籤)即時換算
- **匯出**:量測結果 CSV(Excel 中文不亂碼)、3D 視圖 PNG 截圖(2x 解析度)
- **視角**:右鍵旋轉、滾輪縮放、中鍵平移、ViewCube
| 📍 Point | XYZ coordinate (auto-snaps to nearby B-rep vertex) |
| 📏 Distance | Straight-line distance + ΔX/ΔY/ΔZ |
| 📐 Edge | Line length / curve length / arc length + radius |
| ⬛ Face | Area (mesh approximation) + surface type (plane normal, cylinder radius/axis) |
| ⭕ Circle | Center / radius / diameter / circumference |
| ∠ Angle | Angle between two faces (normals) or two straight edges + supplement |
| ⇔ Face distance | Shortest face-to-face distance (mesh approximation) + closest point pair |
| ⤚ Align (2-pt) | Pick a point on the moving part + a target point → pure translation so the two points coincide |
| 🎯 Align (3-pt) | 3 source points + 3 target points → rotation + translation in one shot |
## 快速開始
- **Rotate** — select a file in the tree, then ↻X / ↻Y / ↻Z to rotate +90° about its center (for re-orienting; repeat to accumulate)
- **Drag** 🖐 — hand-cursor mode; hold the left button to drag a part along the screen plane, release to place (right-button view orbit unaffected)
- **Gizmo** ⊹ — select a file → XYZ tri-color arrows + rotation rings (Fusion 360 style); drag an arrow to move along that axis (view-independent), drag a ring to rotate. Always floats on top, never occluded
- **Interference check** 🧩 — with exactly 2 visible files, shows red intersection curves + intersecting triangle-pair count; otherwise reports the minimum gap (gap ≈ 0 means a fit/match; coplanar contact is not interference)
- **Section plane** ✂ — X/Y/Z axis + position slider + flip; CPU mesh clipping, original geometry preserved (measurement stays exact)
- **Units** — one-click mm ⇄ inch; existing measurements (list + 3D labels) convert live
- **Export** — measurement results to CSV (UTF-8 BOM, no mojibake in Excel) and a 2× PNG screenshot of the 3D view
- **View** — right-button orbit, wheel zoom, middle-button pan, ViewCube
Measurement principle: edge length, circle radius and angles use **exact B-rep values**; area is a triangle-mesh sum approximation (triangulation precision adapts to model size, 0.020.5 mm).
---
## 💻 System Requirements
| Item | Requirement |
|------|-------------|
| OS | Windows 10 / 11 (x64) |
| Runtime | .NET 8 Desktop Runtime (framework-dependent build) — or none for the portable build |
| Build SDK | .NET 8 SDK (only to build from source) |
---
## 📥 Installation
Download a release build and run it — no install required.
- **Framework-dependent** (smaller): requires the .NET 8 Desktop Runtime. Run `STPViewer v0.3.2.exe`.
- **Portable** (self-contained): runtime bundled, no install / admin. Run `STPViewer v0.3.2.exe`.
Or build from source (see below).
```bash
git clone https://github.com/ETWen/STPViewer.git
cd STPViewer
dotnet build STPViewer.sln
```
---
## 🚀 Quick Start
```bash
# Build and run
dotnet build STPViewer.sln
dotnet run --project src/STPViewer
# 發佈免安裝資料夾
# Publish a no-install folder
dotnet publish src/STPViewer -c Release -o publish/STPViewer
```
無 UI 匯入管線與幾何數學驗證:
Then import a `.stp` file (toolbar **Import**, drag-and-drop, or command-line argument), pick a measurement mode, and click the model.
---
## 📚 Usage Guide
1. **Import** one or more CAD files. Each file becomes a root in the assembly tree and the view zooms to fit.
2. **Navigate** the tree — toggle visibility, recolor, zoom to a node, or remove a file.
3. **Measure** — pick a mode on the toolbar (Point / Distance / Edge / Face / Circle / Angle / Face-distance), then click the model. Results appear in the right-hand panel; delete individually or clear all.
4. **Assemble** — use Align (2-pt / 3-pt), Rotate, Drag, or the Gizmo to position parts; then run the Interference check to verify fit.
5. **Section** — toggle ✂, choose an axis, and slide to cut through the model; measurement stays exact on the original geometry.
6. **Export** — save measurements to CSV or capture a 2× PNG of the view.
Headless import-pipeline and geometry-math verification (no UI):
```bash
dotnet run --project tools/SmokeTest -- "path\to\model.stp" # 匯入 + 裝配樹
dotnet run --project tools/SmokeTest -- --clip-test # 剖切裁切數學
dotnet run --project tools/SmokeTest -- --interference-test # 干涉 相交/分離/貼合
dotnet run --project tools/SmokeTest -- --align-test # 三點對齊剛體變換數學
dotnet run --project tools/SmokeTest -- "path\to\model.stp" # import + assembly tree
dotnet run --project tools/SmokeTest -- --clip-test # section clipping math
dotnet run --project tools/SmokeTest -- --interference-test # interference: intersect / separate / contact
dotnet run --project tools/SmokeTest -- --align-test # 3-point rigid-transform math
```
## 技術棧
---
| 元件 | 用途 |
|---|---|
| [CADability](https://github.com/SOFAgh/CADability)(純 C# | STEP 匯入、B-rep 幾何核心、面三角化 |
| [HelixToolkit.Wpf](https://github.com/helix-toolkit/helix-toolkit) | 3D viewport、相機操作、HitTest |
| CommunityToolkit.Mvvm | MVVM |
## 🔨 Building from Source
量測原則:邊長、圓半徑等取 **B-rep 精確值**;面積為三角網格加總近似(三角化精度依模型尺寸自適應 0.02–0.5 mm)。
```bash
dotnet build STPViewer.sln -c Debug
dotnet run --project src/STPViewer
dotnet publish src/STPViewer -c Release -o publish/STPViewer
```
## 已知限制
NuGet dependencies (restored automatically): `CADability`, `HelixToolkit.Wpf`, `CommunityToolkit.Mvvm`.
- 大型 STEP(數千面)匯入需數十秒(CADability 解析成本),匯入期間 UI 有進度提示不凍結
- 輪廓邊線超過 30,000 線段的檔案預設關閉邊線(WPF LinesVisual3D 轉動視角時效能限制),可在樹面板手動開啟
- **IGES 不支援**CADability 無 IGES reader);STL 無 B-rep,僅支援 點/距離/角度/面距 量測;DXF 為線架構檢視
- 剖切面無封口(cap),剖開處顯示內部背面材質(深灰)
- 面積與面距為三角網格近似值;邊長/圓半徑/角度為 B-rep 精確值
- 少數 AP242 檔案 CADability 支援不完整,匯入失敗會提示訊息(不閃退)
- 唯讀檢視器,不寫入/修改原始檔案
---
詳細設計與開發 Phase 見 [ARCHITECTURE.md](ARCHITECTURE.md)。
## 📁 Project Structure
```
STPViewer/
├── ARCHITECTURE.md # Design, data flow, development phases
├── CLAUDE.md # Project memory & engineering conventions
├── STPViewer.sln
├── src/STPViewer/
│ ├── STPViewer.csproj # net8.0-windows, UseWPF, single-source <Version>
│ ├── MainWindow.xaml / .cs # Layout + mouse-pick forwarding
│ ├── Models/ # FaceInfo, MeasureMode, MeasurementResult, UnitSystem
│ ├── Services/ # StepImport, Measurement, Interference, Section, RigidAlign
│ └── ViewModels/ # MainViewModel, ModelNodeViewModel (assembly tree)
└── tools/SmokeTest/ # Headless import + geometry-math verification
```
---
## ⚠️ Known Limitations
- Large STEP files (thousands of faces) take tens of seconds to import (CADability parse cost); a progress indicator keeps the UI responsive.
- Files with more than 30,000 outline segments disable edges by default (WPF `LinesVisual3D` cost while orbiting); re-enable per file in the tree.
- **IGES is not supported** (CADability has no IGES reader). STL has no B-rep (point / distance / angle / face-distance only). DXF is wireframe view.
- Section cuts have no cap fill — the opened face shows the interior back material (dark gray).
- Area and face-distance are mesh approximations; edge length / circle radius / angle are exact B-rep values.
- A few AP242 files are incompletely supported by CADability; failed imports show a message (no crash).
- Read-only viewer — never writes to or modifies the source file.
---
## 🤝 Contributing
1. Fork and create a feature branch: `git checkout -b feature/your-feature`
2. Follow [Conventional Commits](https://www.conventionalcommits.org/): `feat(scope): summary`
3. Push and open a Pull Request
---
## 📜 Version History
### v0.3.2
- **Perf:** large-assembly measurement no longer lags while orbiting. Measurement modes now render the merged mesh (one model per file) and resolve the picked face from the hit triangle's vertex index, instead of rendering tens of thousands of per-face models. Per-face rendering is kept only for section mode.
- Camera-interaction suspension now also subscribes to `HelixViewport3D.CameraChanged` so it can't be orphaned if the camera instance is replaced.
### v0.3.1
- Gizmo always-on-top overlay (manipulator floats above parts, never occluded).
### v0.3.0
- Rotation alignment: axis rotate (↻X/↻Y/↻Z), 3-point align, and the unified `TransformRoot` rigid-transform path.
### v0.2.x
- Drag mode, 2-point align, interference check, section plane, angle / face-distance measurement, assembly tree, STL / DXF support, mm ⇄ inch.
---
## 🙏 Acknowledgments
- [CADability](https://github.com/SOFAgh/CADability) — pure-C# CAD kernel: STEP import, B-rep geometry, face triangulation
- [HelixToolkit.Wpf](https://github.com/helix-toolkit/helix-toolkit) — 3D viewport, camera control, hit testing
- [CommunityToolkit.Mvvm](https://github.com/CommunityToolkit/dotnet) — MVVM
See [ARCHITECTURE.md](ARCHITECTURE.md) for the full design and development phases.
+199
View File
@@ -0,0 +1,199 @@
# STPViewer
[![English](https://img.shields.io/badge/English-lightgrey.svg)](README.md) [![繁體中文](https://img.shields.io/badge/%E7%B9%81%E9%AB%94%E4%B8%AD%E6%96%87-2ea043.svg)](README.zh-TW.md)
> STP/STEP 3D 檢視器(Windows 桌面程式,C# .NET 8 WPF)— 多檔匯入、裝配樹、點/距離/邊/面/圓量測。
![version](https://img.shields.io/badge/version-0.3.2-blue.svg) ![platform](https://img.shields.io/badge/platform-Windows-0078D6.svg) ![.NET](https://img.shields.io/badge/.NET-8.0-512BD4.svg) ![UI](https://img.shields.io/badge/UI-WPF-blueviolet.svg)
---
## 📖 目錄
- [✨ 功能特色](#-功能特色)
- [💻 系統需求](#-系統需求)
- [📥 安裝](#-安裝)
- [🚀 快速開始](#-快速開始)
- [📚 使用指南](#-使用指南)
- [🔨 從原始碼建置](#-從原始碼建置)
- [📁 專案結構](#-專案結構)
- [⚠️ 已知限制](#-已知限制)
- [🤝 貢獻](#-貢獻)
- [📜 版本紀錄](#-版本紀錄)
- [🙏 致謝](#-致謝)
---
## ✨ 功能特色
快速開啟機構件 STEP 檔做確認與量測,不需安裝 SolidWorks / Creo 等重量級 CAD。
- **多檔匯入** — STEP / STL / DXF,工具列匯入(可複選)、拖放到視窗,或命令列 `STPViewer.exe a.stp b.stl`
- **裝配樹** — STEP product structure 還原成樹(組件→零件);逐節點 顯示/隱藏、換色(cascade)、Zoom-to;檔案層級可移除、開關輪廓邊線
- **量測**(工具列切換模式,點擊模型):
| 模式 | 輸出 |
|---|---|
| 📍 點 | XYZ 座標(自動吸附鄰近 B-rep 頂點) |
| 📏 距離 | 兩點直線距離 + ΔX/ΔY/ΔZ |
| 📐 邊 | 直線長 / 曲線長 / 圓弧長 + 半徑 |
| ⬛ 面 | 面積(網格近似)+ 曲面類型(平面法向量、圓柱半徑/軸向) |
| ⭕ 圓 | 圓心 / 半徑 / 直徑 / 周長 |
| ∠ 角度 | 兩面(法向量)/ 兩直線邊 夾角 + 補角 |
| ⇔ 面距 | 面到面最短距離(網格近似)+ 最近點對 |
| ⤚ 對齊(兩點) | 點「要移動零件」一點 + 目標點 → 純平移使兩點貼合 |
| 🎯 對齊(三點) | 來源檔 3 點 + 目標檔 3 對應點 → 旋轉+平移一次貼合 |
- **旋轉** — 樹面板選檔案 + 工具列 ↻X/↻Y/↻Z 繞中心 +90°(擺正方向用,連按累加)
- **拖曳** 🖐 — 手形游標模式,左鍵按住零件沿螢幕平面拖動、放開定位(右鍵轉視角不受影響)
- **操作器** ⊹ — 樹面板選檔案 → XYZ 三色箭頭 + 旋轉環(Fusion 360 風格);拖箭頭沿該軸移動(與視角無關)、拖環繞軸轉。永遠浮在最上層、不被實體遮擋
- **干涉檢查** 🧩 — 勾選剛好 2 個可見檔案 → 相交時顯示紅色干涉交線 + 相交三角形對數;無相交時回報最小間隙 gap(gap≈0 即為配合 match,共面貼合不算干涉)
- **剖面** ✂ — X/Y/Z 軸 + 位置滑桿 + 反向;CPU 網格裁切,原始幾何保留(量測仍精確)
- **單位** — mm ⇄ inch 一鍵切換,既有量測(清單與 3D 標籤)即時換算
- **匯出** — 量測結果 CSVUTF-8 BOMExcel 中文不亂碼)、3D 視圖 2× PNG 截圖
- **視角** — 右鍵旋轉、滾輪縮放、中鍵平移、ViewCube
量測原則:邊長、圓半徑、角度取 **B-rep 精確值**;面積為三角網格加總近似(三角化精度依模型尺寸自適應 0.02–0.5 mm)。
---
## 💻 系統需求
| 項目 | 需求 |
|------|------|
| 作業系統 | Windows 10 / 11x64 |
| 執行階段 | .NET 8 Desktop Runtime(框架相依版)— Portable 版則免裝 |
| 建置 SDK | .NET 8 SDK(僅從原始碼建置時需要) |
---
## 📥 安裝
下載發佈版直接執行,免安裝。
- **框架相依版**(檔案較小):需 .NET 8 Desktop Runtime,執行 `STPViewer v0.3.2.exe`
- **Portable 版**self-contained):內含 runtime,免安裝 / 免系統管理員,執行 `STPViewer v0.3.2.exe`
或從原始碼建置(見下)。
```bash
git clone https://github.com/ETWen/STPViewer.git
cd STPViewer
dotnet build STPViewer.sln
```
---
## 🚀 快速開始
```bash
# 建置並執行
dotnet build STPViewer.sln
dotnet run --project src/STPViewer
# 發佈免安裝資料夾
dotnet publish src/STPViewer -c Release -o publish/STPViewer
```
接著匯入 `.stp` 檔(工具列「匯入」、拖放、或命令列帶檔),選一個量測模式,點擊模型即可。
---
## 📚 使用指南
1. **匯入** 一個或多個 CAD 檔,每個檔案成為裝配樹的一個 root,視角自動 Zoom 到全景。
2. **操作樹** — 顯示/隱藏、換色、Zoom-to 某節點,或移除檔案。
3. **量測** — 工具列選模式(點 / 距離 / 邊 / 面 / 圓 / 角度 / 面距),點擊模型;結果顯示於右側面板,可單筆刪除或全部清除。
4. **裝配** — 用 對齊(兩點/三點)、旋轉、拖曳、操作器擺位,再用干涉檢查驗證配合。
5. **剖面** — 開啟 ✂、選軸向、滑動剖切;量測仍以原始幾何精確計算。
6. **匯出** — 量測結果存 CSV,或截 2× PNG。
無 UI 匯入管線與幾何數學驗證:
```bash
dotnet run --project tools/SmokeTest -- "path\to\model.stp" # 匯入 + 裝配樹
dotnet run --project tools/SmokeTest -- --clip-test # 剖切裁切數學
dotnet run --project tools/SmokeTest -- --interference-test # 干涉 相交/分離/貼合
dotnet run --project tools/SmokeTest -- --align-test # 三點對齊剛體變換數學
```
---
## 🔨 從原始碼建置
```bash
dotnet build STPViewer.sln -c Debug
dotnet run --project src/STPViewer
dotnet publish src/STPViewer -c Release -o publish/STPViewer
```
NuGet 相依(自動還原):`CADability``HelixToolkit.Wpf``CommunityToolkit.Mvvm`
---
## 📁 專案結構
```
STPViewer/
├── ARCHITECTURE.md # 設計、資料流、開發 Phase
├── CLAUDE.md # 專案記憶 & 開發慣例
├── STPViewer.sln
├── src/STPViewer/
│ ├── STPViewer.csproj # net8.0-windows、UseWPF、單一版號來源 <Version>
│ ├── MainWindow.xaml / .cs # 版面 + 滑鼠拾取轉發
│ ├── Models/ # FaceInfo、MeasureMode、MeasurementResult、UnitSystem
│ ├── Services/ # StepImport、Measurement、Interference、Section、RigidAlign
│ └── ViewModels/ # MainViewModel、ModelNodeViewModel(裝配樹)
└── tools/SmokeTest/ # 無 UI 匯入 + 幾何數學驗證
```
---
## ⚠️ 已知限制
- 大型 STEP(數千面)匯入需數十秒(CADability 解析成本),匯入期間有進度提示、UI 不凍結。
- 輪廓邊線超過 30,000 線段的檔案預設關閉邊線(WPF `LinesVisual3D` 轉動視角時效能限制),可在樹面板手動開啟。
- **IGES 不支援**CADability 無 IGES reader);STL 無 B-rep,僅支援 點/距離/角度/面距 量測;DXF 為線架構檢視。
- 剖切面無封口(cap),剖開處顯示內部背面材質(深灰)。
- 面積與面距為三角網格近似值;邊長/圓半徑/角度為 B-rep 精確值。
- 少數 AP242 檔案 CADability 支援不完整,匯入失敗會提示訊息(不閃退)。
- 唯讀檢視器,不寫入/修改原始檔案。
---
## 🤝 貢獻
1. Fork 並建立 feature 分支:`git checkout -b feature/your-feature`
2. 遵循 [Conventional Commits](https://www.conventionalcommits.org/)`feat(scope): summary`
3. Push 後開 Pull Request
---
## 📜 版本紀錄
### v0.3.2
- **效能:** 大組件量測模式下轉動視角不再卡頓。量測模式改為渲染合併網格(每檔 1 個 model),並用命中三角形的頂點 index 反查回是哪個面,不再掛數萬個逐面 model;逐面渲染只保留給剖面模式。
- 相機互動暫停事件加訂 `HelixViewport3D.CameraChanged`,避免相機實例被換掉時訂閱失效。
### v0.3.1
- 操作器 always-on-top 疊圖層(永遠浮在零件上、不被實體遮擋)。
### v0.3.0
- 旋轉對齊:軸向旋轉(↻X/↻Y/↻Z)、三點對齊,以及統一的 `TransformRoot` 剛體變換路徑。
### v0.2.x
- 拖曳模式、兩點對齊、干涉檢查、剖面、角度/面距量測、裝配樹、STL/DXF 支援、mm ⇄ inch。
---
## 🙏 致謝
- [CADability](https://github.com/SOFAgh/CADability) — 純 C# CAD kernelSTEP 匯入、B-rep 幾何、面三角化
- [HelixToolkit.Wpf](https://github.com/helix-toolkit/helix-toolkit) — 3D viewport、相機操作、HitTest
- [CommunityToolkit.Mvvm](https://github.com/CommunityToolkit/dotnet) — MVVM
詳細設計與開發 Phase 見 [ARCHITECTURE.md](ARCHITECTURE.md)。
+56
View File
@@ -0,0 +1,56 @@
# v0.3.1 — First public release: 3D STEP viewer with measurement & assembly fit-check
A lightweight Windows desktop viewer for STEP / STL / DXF — open a part, measure it,
and check how two parts fit together, without firing up a heavyweight CAD suite.
## ✨ New features
**View & assembly tree**
* Import multiple CAD files at once (STEP / STL / DXF) via the toolbar, drag-and-drop,
or the command line (`STPViewer.exe a.stp b.stp`).
* STEP product structure is rebuilt into an **assembly tree** — toggle visibility, recolor
(cascades to children), and zoom-to any node; whole-file remove and outline-edge toggle.
* Smooth orbit / zoom / pan even on large assemblies (hundreds of thousands of triangles).
**Measurement** (pick a mode, click the model)
* Point (snaps to nearby vertices), two-point distance + ΔX/ΔY/ΔZ, edge length
(line / curve / arc with radius), face area + surface type, circle center / radius /
diameter / circumference, angle between two faces or edges, and face-to-face minimum gap.
* Edge length, radius and angle use **exact B-rep values**; area and gap are mesh-based.
* Switch units **mm ⇄ inch** anytime — existing measurements and 3D labels convert live.
**Assembly fit-check** (mate a male/female pair and verify)
* **Two-point align** — click a point on the part to move, then the target point, to slide
the whole file into place.
* **Three-point align** — pick 3 feature points on each file to solve a full rotate+translate
in one shot, for parts that aren't already oriented the same way.
* **Axis rotate** — rotate the selected file ±90° about X / Y / Z to square it up.
* **Drag mode** (hand cursor) — left-press a part and drag it along the screen plane.
* **Gizmo** — XYZ arrows to move along an axis (independent of view angle, ideal for pushing
along a mating axis) and rings to rotate any angle. The gizmo always renders on top, so it
is never hidden inside a solid part.
* **Interference check** — for two visible files, shows red intersection lines where they
collide, or the minimum gap when they don't (gap ≈ 0 means a clean fit).
**Section & export**
* Section view along X / Y / Z with a position slider and flip, to inspect mating internals.
* Export measurements to CSV (opens cleanly in Excel) and save a 2× PNG screenshot of the view.
## 📦 Downloads
Two builds are produced:
| Build | Needs .NET 8 Desktop Runtime? | Notes |
|-------|-------------------------------|-------|
| **Standard** (`STPViewer_v0.3.1`) | ✅ Yes | Smaller; for machines that already have the runtime |
| **Portable** (`STPViewer_v0.3.1_portable`) | ❌ No | Runtime bundled — unzip and run, no install / admin needed |
Run `STPViewer v0.3.1.exe`.
## 🔗 Links
* Overview & usage:
[README.md](https://github.com/ETWen/STPViewer/blob/main/README.md)
* Architecture & design:
[ARCHITECTURE.md](https://github.com/ETWen/STPViewer/blob/main/ARCHITECTURE.md)
**Full changelog:** first public release — [commit history](https://github.com/ETWen/STPViewer/commits/v0.3.1)
+26
View File
@@ -0,0 +1,26 @@
# v0.3.2 — Measuring large assemblies no longer lags when you orbit
## 🐛 Bug fixes
**Smooth orbit while measuring large models**
* Fixed: with a large assembly loaded, switching to any measurement tool
(Distance, Edge, Face, Circle, Angle, Face-distance) and then rotating the
view used to lag badly — it hitched every time the view came to rest. Plain
browsing stayed smooth; only measuring was affected.
* Measuring now rotates just as smoothly as browsing, at any model size.
* Picking accuracy is unchanged — circles/holes, faces, edges, angles and
face-distance all read exactly as before, including inner hole walls.
* Section-mode measurement is unaffected.
## 📦 Downloads
Two builds are produced:
| Build | Needs .NET 8 Desktop Runtime? | Notes |
|-------|-------------------------------|-------|
| **Standard** (`STPViewer_v0.3.2`) | ✅ Yes | Smaller; for machines that already have the runtime |
| **Portable** (`STPViewer_v0.3.2_portable`) | ❌ No | Runtime bundled — unzip and run, no install / admin needed |
Run `STPViewer v0.3.2.exe`.
**Full changelog:** [v0.3.1...v0.3.2](https://github.com/ETWen/STPViewer/compare/v0.3.1...v0.3.2)
-24
View File
@@ -1,24 +0,0 @@
# secret/ — 本機敏感資料集中地
本資料夾除 `README.md``*.example` 外全部 gitignored(規則見根目錄 `.gitignore`)。
## 內含物件
| 檔案 | git | 用途 |
|---|---|---|
| `README.md` | ✅ committed | 本說明 |
| `run-STPViewer.ps1.example` | ✅ committed | Windows 啟動腳本範本 |
| `run-STPViewer.sh.example` | ✅ committed | Git Bash 啟動腳本範本 |
| `run-STPViewer.ps1` / `.sh` | 🚫 ignored | 從範本複製後的本機實值版 |
> STPViewer 為離線桌面工具,目前**沒有任何 DB 密碼 / ApiKey**
> 腳本僅做 build + run。未來若加入需要 secret 的功能(雲端授權、回報伺服器等),
> 依範本內註解加上 env var 注入。
## 第一次使用
```powershell
cd secret
Copy-Item run-STPViewer.ps1.example run-STPViewer.ps1
.\run-STPViewer.ps1
```
-31
View File
@@ -1,31 +0,0 @@
# ══════════════════════════════════════════════════════════════════
# run-STPViewer.ps1 範本
#
# 第一次 clone 下來:
# cd secret
# Copy-Item run-STPViewer.ps1.example run-STPViewer.ps1
# .\run-STPViewer.ps1
#
# 若 PowerShell 擋執行 script,執行一次:
# Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
#
# secret/ 下除 README.md 和 .example 外皆已 gitignore。
# ══════════════════════════════════════════════════════════════════
$ErrorActionPreference = 'Stop'
# ─── secret(本專案目前無 DB / ApiKey;未來需要時在此加 env var) ──
# $env:SomeService__ApiKey = '__CHANGE_ME__'
# ══════════════════════════════════════════════════════════════════
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$RepoRoot = Resolve-Path (Join-Path $ScriptDir '..')
Push-Location $RepoRoot
try {
dotnet run --project src/STPViewer
}
finally {
Pop-Location
}
-25
View File
@@ -1,25 +0,0 @@
#!/usr/bin/env bash
# ══════════════════════════════════════════════════════════════════
# run-STPViewer.sh 範本(Git Bash on Windows
#
# 第一次 clone 下來:
# cd secret
# cp run-STPViewer.sh.example run-STPViewer.sh
# chmod +x run-STPViewer.sh
# ./run-STPViewer.sh
#
# secret/ 下除 README.md 和 .example 外皆已 gitignore。
# ══════════════════════════════════════════════════════════════════
set -euo pipefail
# ─── secret(本專案目前無 DB / ApiKey;未來需要時在此 export) ────
# export SomeService__ApiKey="__CHANGE_ME__"
# ══════════════════════════════════════════════════════════════════
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${REPO_ROOT}"
exec dotnet run --project src/STPViewer
+51 -4
View File
@@ -1,13 +1,60 @@
using System.Configuration;
using System.Data;
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
namespace STPViewer;
/// <summary>
/// Interaction logic for App.xaml
/// 全域例外處理:UI 執行緒未攔截例外 → 記 log + 訊息框後盡量存活(不閃退);
/// 背景 Task / 非 UI 執行緒例外 → 記 log。log 位於 %LOCALAPPDATA%\STPViewer\error.log。
/// </summary>
public partial class App : Application
{
}
private static readonly string LogPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"STPViewer", "error.log");
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
// UI 執行緒(含 async void 回拋)的未攔截例外:記 log、提示、標記 Handled 讓程式存活。
// 若後續狀態已壞使用者可自行重啟;至少量測結果 / 已載模型不會直接蒸發。
DispatcherUnhandledException += (_, args) =>
{
LogException("DispatcherUnhandledException", args.Exception);
MessageBox.Show(
$"發生未預期的錯誤,操作已中止(程式將嘗試繼續運作)。\n\n{args.Exception.Message}\n\n" +
$"詳細記錄:{LogPath}",
"STPViewer 錯誤", MessageBoxButton.OK, MessageBoxImage.Warning);
args.Handled = true;
};
// 沒被 await 的背景 Task 例外(finalizer 階段才浮出):記 log 並標記已觀察,避免行程終止
TaskScheduler.UnobservedTaskException += (_, args) =>
{
LogException("UnobservedTaskException", args.Exception);
args.SetObserved();
};
// 其他非 UI 執行緒的致命例外:無法阻止終止,但至少留下記錄
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
LogException("AppDomainUnhandledException", args.ExceptionObject as Exception);
}
private static void LogException(string source, Exception? ex)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(LogPath)!);
var sb = new StringBuilder();
sb.AppendLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {source}");
sb.AppendLine(ex?.ToString() ?? "(null exception)");
sb.AppendLine(new string('-', 80));
File.AppendAllText(LogPath, sb.ToString(), Encoding.UTF8);
}
catch { /* logging 本身失敗不能再拋 */ }
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
xmlns:h="http://helix-toolkit.org/wpf"
xmlns:conv="clr-namespace:STPViewer.Converters"
xmlns:vm="clr-namespace:STPViewer.ViewModels"
Title="STPViewer V0.3.1 — STP/STEP 3D 檢視器"
Title="STPViewer — STP/STEP 3D 檢視器"
Height="820" Width="1320"
WindowStartupLocation="CenterScreen"
AllowDrop="True" Drop="Window_Drop" DragOver="Window_DragOver">
+4
View File
@@ -1,4 +1,5 @@
using System.Linq;
using System.Reflection;
using System.Windows;
using System.Windows.Input;
using STPViewer.Services;
@@ -13,6 +14,9 @@ public partial class MainWindow : Window
public MainWindow()
{
InitializeComponent();
// 版號單一來源 = csproj <Version>;標題執行時讀,避免寫死
var v = Assembly.GetExecutingAssembly().GetName().Version!;
Title = $"STPViewer V{v.Major}.{v.Minor}.{v.Build} — STP/STEP 3D 檢視器";
DataContext = _vm;
_vm.Attach(viewport);
_vm.AttachOverlay(gizmoOverlay);
+1
View File
@@ -6,6 +6,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<Version>0.4.0</Version> <!-- 單一版號來源:標題與 publish-winapp 打包資料夾都讀這個 -->
</PropertyGroup>
<ItemGroup>
+34 -12
View File
@@ -237,21 +237,43 @@ public static class InterferenceService
if (d < best) { best = d; pa = trisA[i].A; pb = trisB[j].A; }
}
// 2) 以最佳點為中心,精修:點 → 對方所有三角形
Point3D RefineAgainst(Point3D p, Triangle[] tris, ref double bestD, Point3D current)
{
Point3D bestPt = current;
foreach (Triangle t in tris)
// 2) 以最佳點為中心,精修:點 → 對方所有三角形(AABB 平方距離快速拒絕 + 平行分段,
// 初值來自步驟 1 已相當接近 → 絕大多數三角形被 AABB 直接刷掉)
(pb, best) = ClosestOnTriangles(pa, trisB, pb, best);
(pa, best) = ClosestOnTriangles(pb, trisA, pa, best);
return (pa, pb, best);
}
/// <summary>點 p 到三角形集的最近點(帶初值;回傳更新後的最近點與距離)</summary>
private static (Point3D pt, double d) ClosestOnTriangles(Point3D p, Triangle[] tris, Point3D seedPt, double seedD)
{
object gate = new();
Point3D bestPt = seedPt;
double bestD = seedD;
System.Threading.Tasks.Parallel.For(0, tris.Length,
() => (D: seedD, Pt: seedPt, Found: false),
(i, _, local) =>
{
Triangle t = tris[i];
// AABB 平方距離下界 ≥ 目前最佳 → 不可能更近,略過(絕大多數三角形在此被刷掉)
double dx = Math.Max(0, Math.Max(t.Min.X - p.X, p.X - t.Max.X));
double dy = Math.Max(0, Math.Max(t.Min.Y - p.Y, p.Y - t.Max.Y));
double dz = Math.Max(0, Math.Max(t.Min.Z - p.Z, p.Z - t.Max.Z));
if (dx * dx + dy * dy + dz * dz >= local.D * local.D) return local;
Point3D q = ClosestPointOnTriangle(p, t.A, t.B, t.C);
double d = (q - p).Length;
if (d < bestD) { bestD = d; bestPt = q; }
}
return bestPt;
}
pb = RefineAgainst(pa, trisB, ref best, pb);
pa = RefineAgainst(pb, trisA, ref best, pa);
return (pa, pb, best);
return d < local.D ? (d, q, true) : local;
},
local =>
{
if (!local.Found) return;
lock (gate)
if (local.D < bestD) { bestD = local.D; bestPt = local.Pt; }
});
return (bestPt, bestD);
}
/// <summary>點到三角形最近點(Ericson, Real-Time Collision Detection</summary>
+2 -34
View File
@@ -390,7 +390,7 @@ public class MeasurementService
Point3D v = vp[i];
for (int t = 0; t + 2 < ti.Count; t += 3)
{
Point3D q = ClosestPointOnTriangle(v, tp[ti[t]], tp[ti[t + 1]], tp[ti[t + 2]]);
Point3D q = InterferenceService.ClosestPointOnTriangle(v, tp[ti[t]], tp[ti[t + 1]], tp[ti[t + 2]]);
double d = (q - v).Length;
if (d < best)
{
@@ -406,39 +406,7 @@ public class MeasurementService
return (pa, pb);
}
/// <summary>點到三角形最近點(Ericson, Real-Time Collision Detection</summary>
private static Point3D ClosestPointOnTriangle(Point3D p, Point3D a, Point3D b, Point3D c)
{
Vector3D ab = b - a, ac = c - a, ap = p - a;
double d1 = Vector3D.DotProduct(ab, ap);
double d2 = Vector3D.DotProduct(ac, ap);
if (d1 <= 0 && d2 <= 0) return a;
Vector3D bp = p - b;
double d3 = Vector3D.DotProduct(ab, bp);
double d4 = Vector3D.DotProduct(ac, bp);
if (d3 >= 0 && d4 <= d3) return b;
double vc = d1 * d4 - d3 * d2;
if (vc <= 0 && d1 >= 0 && d3 <= 0)
return a + ab * (d1 / (d1 - d3));
Vector3D cp = p - c;
double d5 = Vector3D.DotProduct(ab, cp);
double d6 = Vector3D.DotProduct(ac, cp);
if (d6 >= 0 && d5 <= d6) return c;
double vb = d5 * d2 - d1 * d6;
if (vb <= 0 && d2 >= 0 && d6 <= 0)
return a + ac * (d2 / (d2 - d6));
double va = d3 * d6 - d5 * d4;
if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0)
return b + (c - b) * ((d4 - d3) / ((d4 - d3) + (d5 - d6)));
double denom = 1 / (va + vb + vc);
return a + ab * (vb * denom) + ac * (vc * denom);
}
// 點到三角形最近點:共用 InterferenceService.ClosestPointOnTrianglev0.4.0 去重複)
// ─── overlay helpers ─────────────────────────────────────────
@@ -0,0 +1,90 @@
using System.Windows;
using System.Windows.Media.Media3D;
using HelixToolkit.Wpf;
using STPViewer.Models;
namespace STPViewer.ViewModels;
// ─── 拖曳模式(左鍵按住零件沿螢幕平面拖;放開才烘進 B-rep)──────────
public partial class MainViewModel
{
// 拖曳模式:暫時 Transform 跟著滑鼠,放開才一次性烘進 B-repTranslateRoot
private ModelNodeViewModel? _dragRoot;
private Point3D _dragAnchor; // 命中點(世界座標),拖曳平面的錨點
private Vector3D _dragApplied; // 目前累計位移
private TranslateTransform3D? _dragTransform;
/// <summary>左鍵按下:命中零件則開始拖曳(回傳 true 表示要捕捉滑鼠)</summary>
public bool OnDragStart(Point position)
{
if (_viewport is null || IsBusy || CurrentMode != MeasureMode.Drag || _dragRoot is not null)
return false;
// 兩種渲染內容都可能在場:合併網格查 _mergedMap、逐面(剖面時)查 _faceMap
ModelNodeViewModel? leaf = null;
Point3D anchor = default;
foreach (var h in _viewport.Viewport.FindHits(position))
{
if (h.Model is null) continue;
if (_faceMap.TryGetValue(h.Model, out FaceInfo? fi)) { leaf = fi.Owner; anchor = h.Position; break; }
if (_mergedMap.TryGetValue(h.Model, out ModelNodeViewModel? ml)) { leaf = ml; anchor = h.Position; break; }
}
if (leaf is null) return false;
ModelNodeViewModel? root = FindRootOf(leaf) ?? RootContaining(leaf);
if (root is null) return false;
_dragRoot = root;
_dragAnchor = anchor;
_dragApplied = default;
_dragTransform = new TranslateTransform3D();
foreach (ModelNodeViewModel l in root.Leaves())
if (l.BodyVisual is not null)
l.BodyVisual.Transform = _dragTransform;
// 拖曳中隱藏邊線(LinesVisual3D 隨 Transform 變更逐幀重建會卡)
_edgesSuspended = true;
SetEdgesActive(false);
StatusText = $"拖曳「{root.Name}」中…(放開定位)";
return true;
}
/// <summary>滑鼠移動:把 2D 位移投影到「過錨點、面向相機」的平面上 → 暫時 Transform</summary>
public void OnDragMove(Point position)
{
if (_viewport?.Camera is not System.Windows.Media.Media3D.ProjectionCamera cam ||
_dragRoot is null || _dragTransform is null) return;
Point3D? p = _viewport.Viewport.UnProject(position, _dragAnchor, cam.LookDirection);
if (p is null) return;
_dragApplied = p.Value - _dragAnchor;
_dragTransform.OffsetX = _dragApplied.X;
_dragTransform.OffsetY = _dragApplied.Y;
_dragTransform.OffsetZ = _dragApplied.Z;
}
/// <summary>放開:移除暫時 Transform,一次性烘進 B-rep(量測精度不受拖曳影響)</summary>
public bool OnDragEnd()
{
if (_dragRoot is null) return false;
ModelNodeViewModel root = _dragRoot;
Vector3D delta = _dragApplied;
// 清除暫時位移:必須用 Identity,不可用 null —— HelixToolkit GetTransform 對 child.Transform
// 不做 null 檢查(Children.Add(null) 會拋「無法新增空值到集合中」),下次 FindHits 即 crash
foreach (ModelNodeViewModel l in root.Leaves())
if (l.BodyVisual is not null)
l.BodyVisual.Transform = Transform3D.Identity;
_dragRoot = null;
_dragTransform = null;
_dragApplied = default;
_edgesSuspended = false;
SetEdgesActive(true); // 恢復所有檔案的邊線(被拖檔案的點隨後由 TranslateRoot 更新,期間不會渲染)
if (delta.LengthSquared > 1e-12)
{
TranslateRoot(root, delta); // 烘進 B-rep + 網格 + 邊線 + 邊界(量測清空)
StatusText = $"已拖曳「{root.Name}」 Δ({delta.X:F2}, {delta.Y:F2}, {delta.Z:F2}) mm(可用 🧩干涉 驗證)";
}
return true;
}
}
@@ -0,0 +1,65 @@
using System;
using System.IO;
using System.Text;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Win32;
using STPViewer.Models;
namespace STPViewer.ViewModels;
// ─── 匯出(量測 CSV / 視圖 PNG 截圖)────────────────────────────────
public partial class MainViewModel
{
[RelayCommand]
private void ExportCsv()
{
if (Measurements.Count == 0)
{
StatusText = "沒有量測結果可匯出";
return;
}
var dlg = new SaveFileDialog
{
Title = "匯出量測結果",
Filter = "CSV 檔案 (*.csv)|*.csv",
FileName = $"measurements_{DateTime.Now:yyyyMMdd_HHmmss}.csv",
};
if (dlg.ShowDialog() != true) return;
var sb = new StringBuilder();
sb.AppendLine("標籤,類型,明細");
foreach (MeasurementResult m in Measurements)
{
static string Esc(string s) => $"\"{s.Replace("\"", "\"\"")}\"";
sb.AppendLine($"{Esc(m.Title)},{m.Kind},{Esc(m.Detail.Replace("\n", " | "))}");
}
File.WriteAllText(dlg.FileName, sb.ToString(), new UTF8Encoding(true)); // BOMExcel 中文不亂碼
StatusText = $"已匯出 {Measurements.Count} 筆量測 → {Path.GetFileName(dlg.FileName)}";
}
[RelayCommand]
private void SaveScreenshot()
{
if (_viewport is null || _viewport.ActualWidth < 1) return;
var dlg = new SaveFileDialog
{
Title = "儲存視圖截圖",
Filter = "PNG 圖片 (*.png)|*.png",
FileName = $"stpviewer_{DateTime.Now:yyyyMMdd_HHmmss}.png",
};
if (dlg.ShowDialog() != true) return;
const double scale = 2.0; // 2x 解析度
var rtb = new RenderTargetBitmap(
(int)(_viewport.ActualWidth * scale), (int)(_viewport.ActualHeight * scale),
96 * scale, 96 * scale, PixelFormats.Pbgra32);
rtb.Render(_viewport);
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(rtb));
using FileStream fs = File.Create(dlg.FileName);
encoder.Save(fs);
StatusText = $"已儲存截圖 → {Path.GetFileName(dlg.FileName)}";
}
}
@@ -0,0 +1,191 @@
using System;
using System.Collections.Generic;
using System.Windows.Media;
using System.Windows.Media.Media3D;
using CommunityToolkit.Mvvm.ComponentModel;
using HelixToolkit.Wpf;
namespace STPViewer.ViewModels;
// ─── Gizmo 三軸操作器(XYZ 箭頭 + 旋轉環;每次放開滑鼠烘進 B-rep)──────
public partial class MainViewModel
{
[ObservableProperty]
private bool gizmoEnabled;
private readonly List<HelixToolkit.Wpf.Manipulator> _gizmoParts = new();
private ModelVisual3D? _gizmoProxy; // 操作器綁定的代理 visual(其 Transform = 使用者拖出的變換)
private ModelNodeViewModel? _gizmoTarget;
private bool _gizmoDragActive; // 拖動中(邊線已暫停)
private bool _gizmoBaking; // 烘焙中,忽略 Transform 變更回呼
private bool _gizmoBakePending; // 已排程烘焙,防同一次放開重複觸發
// 操作器疊圖層:另一個透明 Viewport3D 疊在主視窗上、相機同步、只放操作器 →
// 操作器不在主場景,永不被實體遮擋(always-on-top);空白處滑鼠穿透回主視窗
private System.Windows.Controls.Viewport3D? _overlayViewport;
private PerspectiveCamera? _overlayCamera;
/// <summary>注入操作器疊圖層(透明 Viewport3D,疊在主視窗上、永遠最上層)</summary>
public void AttachOverlay(System.Windows.Controls.Viewport3D overlay)
{
_overlayViewport = overlay;
_overlayCamera = new PerspectiveCamera();
overlay.Camera = _overlayCamera;
// 操作器材質需打光(manipulator 用 DiffuseMaterial
overlay.Children.Add(new ModelVisual3D { Content = new AmbientLight(Color.FromRgb(0x80, 0x80, 0x80)) });
overlay.Children.Add(new ModelVisual3D { Content = new DirectionalLight(Colors.White, new Vector3D(-1, -1, -3)) });
SyncOverlayCamera();
}
/// <summary>疊圖層相機跟隨主相機(每次主相機變更時呼叫,讓操作器疊在正確螢幕位置)</summary>
private void SyncOverlayCamera()
{
if (_overlayCamera is null || _viewport?.Camera is not ProjectionCamera src) return;
_overlayCamera.Position = src.Position;
_overlayCamera.LookDirection = src.LookDirection;
_overlayCamera.UpDirection = src.UpDirection;
_overlayCamera.NearPlaneDistance = src.NearPlaneDistance;
_overlayCamera.FarPlaneDistance = src.FarPlaneDistance;
if (src is PerspectiveCamera p) _overlayCamera.FieldOfView = p.FieldOfView;
}
partial void OnGizmoEnabledChanged(bool value) => UpdateGizmo();
partial void OnSelectedNodeChanged(ModelNodeViewModel? value)
{
if (GizmoEnabled) UpdateGizmo(); // 換選取 → 操作器跟著換目標
}
private void UpdateGizmo()
{
RemoveGizmo();
if (!GizmoEnabled || _viewport is null || _overlayViewport is null) return;
ModelNodeViewModel? root = SelectedNode is not null ? RootContaining(SelectedNode)
: Roots.Count == 1 ? Roots[0] : null;
if (root is null || root.Bounds.IsEmpty)
{
StatusText = "操作器:請先在樹面板點選要操作的檔案";
GizmoEnabled = false;
return;
}
_gizmoTarget = root;
SyncOverlayCamera(); // 操作器出現前先對齊相機
Rect3D b = root.Bounds;
var center = new Point3D(b.X + b.SizeX / 2, b.Y + b.SizeY / 2, b.Z + b.SizeZ / 2);
double diag = new Vector3D(b.SizeX, b.SizeY, b.SizeZ).Length;
_gizmoProxy = new ModelVisual3D();
_overlayViewport.Children.Add(_gizmoProxy);
System.ComponentModel.DependencyPropertyDescriptor
.FromProperty(Visual3D.TransformProperty, typeof(Visual3D))
.AddValueChanged(_gizmoProxy, GizmoTransformChanged);
void AddPart(HelixToolkit.Wpf.Manipulator man)
{
man.Position = center;
man.Bind(_gizmoProxy);
_gizmoParts.Add(man);
_overlayViewport!.Children.Add(man); // 放疊圖層 → 永不被實體遮擋
}
// 平移箭頭(X 紅 / Y 綠 / Z 藍 — 業界慣例)
AddPart(new TranslateManipulator { Direction = new Vector3D(1, 0, 0), Color = Colors.Red, Length = diag * 0.22, Diameter = diag * 0.016 });
AddPart(new TranslateManipulator { Direction = new Vector3D(0, 1, 0), Color = Colors.Green, Length = diag * 0.22, Diameter = diag * 0.016 });
AddPart(new TranslateManipulator { Direction = new Vector3D(0, 0, 1), Color = Colors.Blue, Length = diag * 0.22, Diameter = diag * 0.016 });
// 旋轉環
AddPart(new RotateManipulator { Axis = new Vector3D(1, 0, 0), Color = Colors.Red, Diameter = diag * 0.34, InnerDiameter = diag * 0.30, Length = diag * 0.012 });
AddPart(new RotateManipulator { Axis = new Vector3D(0, 1, 0), Color = Colors.Green, Diameter = diag * 0.34, InnerDiameter = diag * 0.30, Length = diag * 0.012 });
AddPart(new RotateManipulator { Axis = new Vector3D(0, 0, 1), Color = Colors.Blue, Diameter = diag * 0.34, InnerDiameter = diag * 0.30, Length = diag * 0.012 });
StatusText = $"操作器:拖箭頭沿軸移動「{root.Name}」、拖環繞軸旋轉;放開即定位";
}
private void RemoveGizmo()
{
if (_gizmoProxy is not null)
{
System.ComponentModel.DependencyPropertyDescriptor
.FromProperty(Visual3D.TransformProperty, typeof(Visual3D))
.RemoveValueChanged(_gizmoProxy, GizmoTransformChanged);
_overlayViewport?.Children.Remove(_gizmoProxy);
}
foreach (HelixToolkit.Wpf.Manipulator man in _gizmoParts)
{
man.UnBind();
_overlayViewport?.Children.Remove(man);
}
_gizmoParts.Clear();
_gizmoProxy = null;
if (_gizmoTarget is not null) // 清掉殘留的暫時 Transform(用 Identity,不可 null → 否則 FindHits crash
foreach (ModelNodeViewModel l in _gizmoTarget.Leaves())
if (l.BodyVisual is not null)
l.BodyVisual.Transform = Transform3D.Identity;
_gizmoTarget = null;
if (_gizmoDragActive)
{
_gizmoDragActive = false;
_edgesSuspended = false;
SetEdgesActive(true);
}
}
/// <summary>操作器拖動中:把代理的 Transform 套到目標檔案的所有 BodyVisual(暫時、GPU 端)</summary>
private void GizmoTransformChanged(object? sender, EventArgs e)
{
if (_gizmoBaking || _gizmoTarget is null || _gizmoProxy is null) return;
Transform3D t = _gizmoProxy.Transform;
if (!_gizmoDragActive && t is not null && !t.Value.IsIdentity)
{
_gizmoDragActive = true;
_edgesSuspended = true;
SetEdgesActive(false); // 邊線逐幀重建會卡,拖動中暫停
}
foreach (ModelNodeViewModel l in _gizmoTarget.Leaves())
if (l.BodyVisual is not null)
l.BodyVisual.Transform = t;
}
/// <summary>滑鼠放開(MainWindow 轉發,handledEventsToo):把累積變換烘進 B-rep 並重置操作器</summary>
public void OnGizmoMouseUp()
{
if (_gizmoTarget is null || _gizmoProxy is null || _gizmoBakePending) return;
Matrix3D m = _gizmoProxy.Transform?.Value ?? Matrix3D.Identity;
if (m.IsIdentity) return; // 只是點一下、沒拖操作器 → 不烘焙
// 延後到 manipulator 自身的 mouse-up 處理(釋放捕捉等)完成後再烘焙,避免在其事件中改動視覺樹造成 reentrancy
_gizmoBakePending = true;
_viewport?.Dispatcher.BeginInvoke(new Action(() => BakeGizmo(m)),
System.Windows.Threading.DispatcherPriority.Background);
}
private void BakeGizmo(Matrix3D m)
{
_gizmoBakePending = false;
if (_gizmoTarget is null) return;
ModelNodeViewModel root = _gizmoTarget;
_gizmoBaking = true; // 防 proxy 歸零觸發 GizmoTransformChanged 重入
foreach (ModelNodeViewModel l in root.Leaves())
if (l.BodyVisual is not null)
l.BodyVisual.Transform = Transform3D.Identity; // 清暫時位移(用 Identity,不可 null
if (_gizmoProxy is not null) _gizmoProxy.Transform = Transform3D.Identity; // 綁定 → 操作器歸零
_gizmoBaking = false;
_gizmoDragActive = false;
_edgesSuspended = false;
SetEdgesActive(true);
TransformRoot(root, Services.RigidAlign.ToModOp(m), m); // 烘進 B-rep(量測精度不受影響)
// 操作器移到變換後的新中心
Rect3D b = root.Bounds;
if (!b.IsEmpty)
{
var center = new Point3D(b.X + b.SizeX / 2, b.Y + b.SizeY / 2, b.Z + b.SizeZ / 2);
foreach (HelixToolkit.Wpf.Manipulator man in _gizmoParts)
man.Position = center;
}
StatusText = $"已套用操作器變換「{root.Name}」(可用 🧩干涉 驗證)";
}
}
@@ -0,0 +1,125 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Media3D;
using CommunityToolkit.Mvvm.Input;
using HelixToolkit.Wpf;
using STPViewer.Models;
using STPViewer.Services;
namespace STPViewer.ViewModels;
// ─── 干涉檢查 ────────────────────────────────────────────────────────
public partial class MainViewModel
{
[RelayCommand(CanExecute = nameof(IsIdle))]
private async Task CheckInterferenceAsync()
{
if (_viewport is null) return;
var visibleRoots = Roots.Where(r => r.IsVisible).ToList();
if (visibleRoots.Count != 2)
{
StatusText = $"干涉檢查需要剛好 2 個可見檔案(目前 {visibleRoots.Count} 個)— 用樹面板勾選";
return;
}
List<MeshGeometry3D> MeshesOf(ModelNodeViewModel root) =>
root.Leaves().Where(l => l.IsVisible && l.FacesContent is not null)
.SelectMany(l => l.FacesContent!.Children)
.Where(m => m is GeometryModel3D gm && _faceMap.ContainsKey(gm))
.Select(m => _faceMap[(GeometryModel3D)m].Mesh)
.ToList();
var a = MeshesOf(visibleRoots[0]);
var b = MeshesOf(visibleRoots[1]);
string nameA = visibleRoots[0].Name, nameB = visibleRoots[1].Name;
IsBusy = true;
StatusText = $"干涉檢查中:{nameA} ⟷ {nameB} …";
try
{
InterferenceResult result = await Task.Run(() => InterferenceService.Check(a, b));
AddInterferenceResult(result, nameA, nameB);
}
catch (Exception ex)
{
StatusText = $"干涉檢查失敗:{ex.Message}";
}
finally
{
IsBusy = false;
}
}
private void AddInterferenceResult(InterferenceResult result, string nameA, string nameB)
{
double diag = SceneDiagonal();
double markerR = Math.Clamp(diag * 0.004, 0.01, 5.0);
MeasurementResult m;
if (result.Intersects)
{
int pairs = result.PairCount;
int segCount = result.Segments.Count;
m = new MeasurementResult
{
Kind = MeasureMode.Interference,
TitleFor = _ => $"🧩 干涉!{nameA} ⟷ {nameB}",
DetailFor = _ => $"兩零件相交(不 match\n相交三角形對 = {pairs:N0}\n" +
$"紅色線 = 干涉交線({segCount:N0} 段)\n(共面貼合不會被算為干涉)",
};
var pts = new Point3DCollection(Math.Min(result.Segments.Count, 20000) * 2);
foreach ((Point3D s0, Point3D s1) in result.Segments) { pts.Add(s0); pts.Add(s1); }
m.Overlays.Add(new LinesVisual3D { Points = pts, Color = Colors.Red, Thickness = 3 });
if (result.Segments.Count > 0)
{
Point3D at = result.Segments[0].A;
m.Overlays.Add(new BillboardTextVisual3D
{
Position = at + new Vector3D(0, 0, markerR * 3),
Text = "干涉",
Foreground = Brushes.White,
Background = Brushes.Red,
Padding = new Thickness(5, 2, 5, 2),
FontSize = 14,
});
}
}
else
{
Point3D ga = result.GapA, gb = result.GapB;
Vector3D d = gb - ga;
double gap = result.GapDistance;
m = new MeasurementResult
{
Kind = MeasureMode.Interference,
TitleFor = u => $"🧩 無干涉 gap ≈ {Units.L(gap, u)}",
DetailFor = u => $"{nameA} ⟷ {nameB} 無相交\n最小間隙 ≈ {Units.L(gap, u)}(網格近似)\n" +
$"gap ≈ 0 即為貼合(match\n點 1 {Units.P(ga, u)}\n點 2 {Units.P(gb, u)}",
};
m.Overlays.Add(new SphereVisual3D { Center = ga, Radius = markerR, Fill = Brushes.OrangeRed });
m.Overlays.Add(new SphereVisual3D { Center = gb, Radius = markerR, Fill = Brushes.OrangeRed });
m.Overlays.Add(new LinesVisual3D
{
Points = new Point3DCollection { ga, gb },
Color = Colors.OrangeRed,
Thickness = 2,
});
var label = new BillboardTextVisual3D
{
Position = ga + d / 2 + new Vector3D(0, 0, markerR * 2),
Text = $"gap {Units.L(gap, UnitSystem.Millimeter)}",
Foreground = Brushes.Black,
Background = new SolidColorBrush(Color.FromArgb(200, 255, 255, 210)),
Padding = new Thickness(4, 2, 4, 2),
FontSize = 14,
};
m.Overlays.Add(label);
m.DynamicLabels.Add((label, u => $"gap {Units.L(gap, u)}"));
}
AddMeasurement(m);
}
}
@@ -0,0 +1,171 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Windows.Media;
using System.Windows.Media.Media3D;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using HelixToolkit.Wpf;
using STPViewer.Models;
using STPViewer.Services;
namespace STPViewer.ViewModels;
// ─── 剖面(CPU 網格裁切;v0.4.0 起裁切在背景執行緒平行計算,UI 不凍結)──
public partial class MainViewModel
{
private readonly DispatcherTimer _sectionTimer;
private RectangleVisual3D? _sectionPlaneVisual;
private Point3D _sectionPlanePoint; // SectionEnabled 時的剖切平面(合併邊線重建用)
private Vector3D _sectionNormal;
// 背景裁切 guard:進行中又有新變更(拉滑桿/換軸/零件平移)→ 完成後用最新參數重跑一輪。
// 不要改回同步呼叫 ClipMesh —— 大檔(64k 面)整場景裁切會凍結 UI 數秒
private bool _sectionApplying;
private bool _sectionReapply;
[ObservableProperty]
private bool sectionEnabled;
/// <summary>0=X 1=Y 2=Z</summary>
[ObservableProperty]
private int sectionAxisIndex;
/// <summary>剖面位置(沿軸 0~100%</summary>
[ObservableProperty]
private double sectionPosition = 50;
[ObservableProperty]
private bool sectionFlip;
partial void OnSectionEnabledChanged(bool value) => ScheduleSection();
partial void OnSectionAxisIndexChanged(int value) => ScheduleSection();
partial void OnSectionPositionChanged(double value) => ScheduleSection();
partial void OnSectionFlipChanged(bool value) => ScheduleSection();
private void ScheduleSection()
{
_sectionTimer.Stop();
_sectionTimer.Start();
}
private async void ApplySection()
{
if (_viewport is null) return;
if (!SectionEnabled)
{
// 還原原始幾何(換回 fi.Mesh 參照,便宜、同步即可)。
// 若背景裁切還在跑,其 await 之後會看到 SectionEnabled=false 而放棄套用
foreach ((Model3D model, FaceInfo fi) in _faceMap)
if (model is GeometryModel3D gm && !ReferenceEquals(gm.Geometry, fi.Mesh))
gm.Geometry = fi.Mesh;
foreach (ModelNodeViewModel root in Roots) RefreshRootEdges(root);
if (_sectionPlaneVisual is not null)
{
_viewport.Children.Remove(_sectionPlaneVisual);
_sectionPlaneVisual = null;
}
ApplyRenderMode(); // 剖面關閉 → 瀏覽模式可切回合併網格
StatusText = "剖面已關閉";
return;
}
if (_sectionApplying) { _sectionReapply = true; return; }
_sectionApplying = true;
try
{
do
{
_sectionReapply = false;
Rect3D bounds = UnionBounds();
if (bounds.IsEmpty) return;
Vector3D axis = SectionAxisIndex switch
{
0 => new Vector3D(1, 0, 0),
1 => new Vector3D(0, 1, 0),
_ => new Vector3D(0, 0, 1),
};
double t = SectionPosition / 100.0;
Point3D min = bounds.Location;
var size = new Vector3D(bounds.SizeX, bounds.SizeY, bounds.SizeZ);
double planeCoord = SectionAxisIndex switch
{
0 => min.X + size.X * t,
1 => min.Y + size.Y * t,
_ => min.Z + size.Z * t,
};
Point3D center = min + size / 2;
Point3D planePoint = SectionAxisIndex switch
{
0 => new Point3D(planeCoord, center.Y, center.Z),
1 => new Point3D(center.X, planeCoord, center.Z),
_ => new Point3D(center.X, center.Y, planeCoord),
};
Vector3D normal = SectionFlip ? -axis : axis;
// UI 執行緒快照(原始 mesh 皆已 Freeze,可跨執行緒),背景平行裁切,回 UI 一次換上。
// 裁切期間若參數又變 / 零件被平移(TransformRoot 會呼叫 ApplySection)→
// _sectionReapply 讓迴圈用最新的 fi.Mesh 與平面重跑,最終狀態必為最新
var models = new List<GeometryModel3D>(_faceMap.Count);
var sources = new List<MeshGeometry3D>(_faceMap.Count);
foreach ((Model3D model, FaceInfo fi) in _faceMap)
if (model is GeometryModel3D gm)
{
models.Add(gm);
sources.Add(fi.Mesh);
}
MeshGeometry3D[] clipped = await Task.Run(() =>
{
var result = new MeshGeometry3D[sources.Count];
Parallel.For(0, sources.Count, i =>
result[i] = SectionService.ClipMesh(sources[i], planePoint, normal));
return result;
});
if (!SectionEnabled) return; // 裁切期間被關掉 → 還原分支已處理,丟棄結果
for (int i = 0; i < models.Count; i++)
models[i].Geometry = clipped[i]; // 已移除檔案的殘留 model 賦值無害(不在視覺樹上)
_sectionPlanePoint = planePoint;
_sectionNormal = normal;
foreach (ModelNodeViewModel root in Roots) RefreshRootEdges(root);
ApplyRenderMode(); // 剖面開啟 → 改用逐面(裁切後幾何)
UpdateSectionPlaneVisual(planePoint, axis, size);
StatusText = $"剖面:{"XYZ"[SectionAxisIndex]} 軸 {SectionPosition:F0}%" + (SectionFlip ? "(反向)" : "");
} while (_sectionReapply);
}
finally
{
_sectionApplying = false;
}
}
private void UpdateSectionPlaneVisual(Point3D planePoint, Vector3D axis, Vector3D size)
{
if (_sectionPlaneVisual is not null)
_viewport!.Children.Remove(_sectionPlaneVisual);
(Vector3D lenDir, double len, double wid) = SectionAxisIndex switch
{
0 => (new Vector3D(0, 1, 0), size.Y, size.Z),
1 => (new Vector3D(1, 0, 0), size.X, size.Z),
_ => (new Vector3D(1, 0, 0), size.X, size.Y),
};
_sectionPlaneVisual = new RectangleVisual3D
{
Origin = planePoint,
Normal = axis,
LengthDirection = lenDir,
Length = len * 1.05,
Width = wid * 1.05,
Fill = new SolidColorBrush(Color.FromArgb(40, 30, 120, 255)),
};
_viewport!.Children.Add(_sectionPlaneVisual);
}
}
+147 -625
View File
@@ -3,11 +3,9 @@ using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Media.Media3D;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -30,11 +28,10 @@ public partial class MainViewModel : ObservableObject
/// <summary>合併網格 → leaf 反查表(拖曳模式在瀏覽渲染下 hit-test 用)</summary>
private readonly Dictionary<Model3D, ModelNodeViewModel> _mergedMap = new();
// 拖曳模式:暫時 Transform 跟著滑鼠,放開才一次性烘進 B-repTranslateRoot
private ModelNodeViewModel? _dragRoot;
private Point3D _dragAnchor; // 命中點(世界座標),拖曳平面的錨點
private Vector3D _dragApplied; // 目前累計位移
private TranslateTransform3D? _dragTransform;
/// <summary>合併網格 → (每面頂點起始邊界, 對應 FaceInfo)。命中合併網格時用三角形頂點 index 二分搜尋反查回是哪個面,
/// 讓量測模式也能 render 合併網格(62 個 model)而非逐面(數萬個 model)。starts 長度 = faces+1,最後一格為總頂點數。
/// 邊界由匯入時依面序串接而成,與 BuildMergedMesh 串接順序一致;平移不改各面頂點數,故邊界永久有效。</summary>
private readonly Dictionary<Model3D, (int[] Starts, FaceInfo[] Faces)> _mergedFaceRanges = new();
private HelixViewport3D? _viewport;
private int _paletteIndex;
@@ -52,12 +49,6 @@ public partial class MainViewModel : ObservableObject
[ObservableProperty]
private ModelNodeViewModel? selectedNode;
// 剖面
private readonly DispatcherTimer _sectionTimer;
private RectangleVisual3D? _sectionPlaneVisual;
private Point3D _sectionPlanePoint; // SectionEnabled 時的剖切平面(合併邊線重建用)
private Vector3D _sectionNormal;
// 互動中暫停邊線:轉動/縮放/平移時隱藏 LinesVisual3D,停下再顯示 → 大組件流暢
private readonly DispatcherTimer _interactionTimer;
private bool _edgesSuspended;
@@ -69,25 +60,14 @@ public partial class MainViewModel : ObservableObject
private string statusText = "就緒 — 匯入 STP/STEP/STL/DXF(拖放亦可)。右鍵旋轉、滾輪縮放、中鍵或 Shift+左鍵平移";
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ImportCommand))]
[NotifyCanExecuteChangedFor(nameof(RotateRootCommand))]
[NotifyCanExecuteChangedFor(nameof(CheckInterferenceCommand))]
private bool isBusy;
[ObservableProperty]
private bool useInch;
[ObservableProperty]
private bool sectionEnabled;
/// <summary>0=X 1=Y 2=Z</summary>
[ObservableProperty]
private int sectionAxisIndex;
/// <summary>剖面位置(沿軸 0~100%</summary>
[ObservableProperty]
private double sectionPosition = 50;
[ObservableProperty]
private bool sectionFlip;
public ObservableCollection<ModelNodeViewModel> Roots { get; } = new();
public ObservableCollection<MeasurementResult> Measurements { get; } = new();
@@ -107,35 +87,17 @@ public partial class MainViewModel : ObservableObject
public void Attach(HelixViewport3D viewport)
{
_viewport = viewport;
// 相機一動就暫停邊線(轉動/縮放/平移皆觸發),停下 180ms 再恢復
// 相機一動就暫停邊線/逐面(轉動/縮放/平移皆觸發),停下 180ms 再恢復
// 兩個事件都訂閱,互補保底:
// 1) viewport.CameraChanged(控制項層級 routed event)— 相機實例若被 Helix 內部換掉也接得住;
// 2) Camera.ChangedFreezable,相機 Position/方向一被改就觸發)— 確保每次移動都收到。
// 舊寫法只訂 Camera.Changed 且包在 null 檢查裡,萬一相機實例被換就永久失聯 → 暫停機制全失效。
// 重複觸發 OnCameraMoved 無害(idempotent,內部有 _edgesSuspended guard)。
viewport.CameraChanged += (_, _) => OnCameraMoved();
if (viewport.Camera is not null)
viewport.Camera.Changed += (_, _) => OnCameraMoved();
}
/// <summary>注入操作器疊圖層(透明 Viewport3D,疊在主視窗上、永遠最上層)</summary>
public void AttachOverlay(System.Windows.Controls.Viewport3D overlay)
{
_overlayViewport = overlay;
_overlayCamera = new PerspectiveCamera();
overlay.Camera = _overlayCamera;
// 操作器材質需打光(manipulator 用 DiffuseMaterial
overlay.Children.Add(new ModelVisual3D { Content = new AmbientLight(Color.FromRgb(0x80, 0x80, 0x80)) });
overlay.Children.Add(new ModelVisual3D { Content = new DirectionalLight(Colors.White, new Vector3D(-1, -1, -3)) });
SyncOverlayCamera();
}
/// <summary>疊圖層相機跟隨主相機(每次主相機變更時呼叫,讓操作器疊在正確螢幕位置)</summary>
private void SyncOverlayCamera()
{
if (_overlayCamera is null || _viewport?.Camera is not ProjectionCamera src) return;
_overlayCamera.Position = src.Position;
_overlayCamera.LookDirection = src.LookDirection;
_overlayCamera.UpDirection = src.UpDirection;
_overlayCamera.NearPlaneDistance = src.NearPlaneDistance;
_overlayCamera.FarPlaneDistance = src.FarPlaneDistance;
if (src is PerspectiveCamera p) _overlayCamera.FieldOfView = p.FieldOfView;
}
private void OnCameraMoved()
{
SyncOverlayCamera(); // 操作器疊圖層跟著主相機
@@ -159,9 +121,12 @@ public partial class MainViewModel : ObservableObject
SyncVisual(root.EdgeVisual, active && root.EdgeVisual.Points.Count > 0);
}
/// <summary>IsBusy 時停用會改動幾何/場景的指令(匯入、旋轉、干涉),避免背景運算期間狀態被改走造成結果錯位</summary>
private bool IsIdle() => !IsBusy;
// ─── 匯入 ────────────────────────────────────────────────────
[RelayCommand]
[RelayCommand(CanExecute = nameof(IsIdle))]
private async Task ImportAsync()
{
var dlg = new OpenFileDialog
@@ -178,34 +143,40 @@ public partial class MainViewModel : ObservableObject
public async Task ImportFilesAsync(IEnumerable<string> paths)
{
if (_viewport is null) return;
// 拖放/命令列可繞過指令 CanExecute 直接進來 → 匯入或背景運算期間直接擋掉,
// 避免兩批匯入交錯(StepImportService 有共享狀態,非重入安全)
if (IsBusy) { StatusText = "忙碌中(匯入或運算進行中),請稍候再匯入"; return; }
int ok = 0, fail = 0;
foreach (string path in paths)
IsBusy = true; // 整批匯入期間維持 busy(v0.4.0:不再逐檔開關,杜絕檔案之間的重入空窗)
try
{
string name = Path.GetFileNameWithoutExtension(path);
IsBusy = true;
StatusText = $"匯入中:{name} …";
// 匯入階段回報(從背景執行緒來 → 切回 UI 執行緒)
_importService.Progress = msg =>
System.Windows.Application.Current?.Dispatcher.BeginInvoke(
() => StatusText = $"匯入 {name}{msg}");
try
foreach (string path in paths)
{
ImportedFileData data = await Task.Run(() => _importService.Import(path));
BuildRoot(data);
ok++;
}
catch (Exception ex)
{
fail++;
StatusText = $"匯入失敗:{name} — {ex.Message}";
MessageBox.Show($"{path}\n\n{ex.Message}", "匯入失敗",
MessageBoxButton.OK, MessageBoxImage.Warning);
}
finally
{
IsBusy = false;
string name = Path.GetFileNameWithoutExtension(path);
StatusText = $"匯入中:{name} …";
// 匯入階段回報(從背景執行緒來 → 切回 UI 執行緒)
_importService.Progress = msg =>
System.Windows.Application.Current?.Dispatcher.BeginInvoke(
() => StatusText = $"匯入 {name}{msg}");
try
{
ImportedFileData data = await Task.Run(() => _importService.Import(path));
BuildRoot(data);
ok++;
}
catch (Exception ex)
{
fail++;
StatusText = $"匯入失敗:{name} — {ex.Message}";
MessageBox.Show($"{path}\n\n{ex.Message}", "匯入失敗",
MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
}
finally
{
IsBusy = false;
}
if (ok > 0)
{
if (SectionEnabled) ApplySection();
@@ -274,30 +245,42 @@ public partial class MainViewModel : ObservableObject
backMat.Freeze();
vm.SharedMaterial = diffuse;
// 一個 B-rep Face = 一個 GeometryModel3D,登錄反查表(量測拾取用)
// 一個 B-rep Face = 一個 GeometryModel3D,登錄反查表(剖面模式逐面拾取用)
// 同時依面序記錄每面頂點起始邊界 + 對應 FaceInfo,供「命中合併網格反查面」(量測模式拾取用)。
var group = new Model3DGroup();
foreach (ImportedFace f in n.Faces)
var faceInfos = new FaceInfo[n.Faces.Count];
var vertStarts = new int[n.Faces.Count + 1];
int vacc = 0;
for (int i = 0; i < n.Faces.Count; i++)
{
ImportedFace f = n.Faces[i];
var gm = new GeometryModel3D(f.Mesh, matGroup) { BackMaterial = backMat };
group.Children.Add(gm);
_faceMap[gm] = new FaceInfo { BrepFace = f.BrepFace, Mesh = f.Mesh, Owner = vm };
var info = new FaceInfo { BrepFace = f.BrepFace, Mesh = f.Mesh, Owner = vm };
_faceMap[gm] = info;
faceInfos[i] = info;
vertStarts[i] = vacc;
vacc += f.Mesh.Positions.Count;
}
vertStarts[n.Faces.Count] = vacc;
vm.FacesContent = group;
// 合併網格:整零件所有面併成 1 個 GeometryModel3D瀏覽時只 1 個 draw call
// 合併網格:整零件所有面併成 1 個 GeometryModel3Ddraw call 從每面 1 個降為每檔 1 個)。
// BuildMergedMesh 依 n.Faces 同序串接(baseIdx += positions.Count),與上面 vertStarts 對齊 → 命中頂點 index 可反查回面。
var mergedModel = new GeometryModel3D(BuildMergedMesh(n.Faces), matGroup);
// 封閉實體背面不可見 → 不設 BackMaterial(WPF 兩面渲染成本砍半);開放殼/STL 仍需背面
// 封閉實體背面不可見 → 不設 BackMaterial(WPF 兩面渲染成本砍半);開放殼/STL 仍需背面
// 拾取不受材質影響(WPF 3D hit-test 純幾何、不剔背面),故孔內壁仍可命中。
if (n.SolidCount == 0 || !n.HasBrep)
mergedModel.BackMaterial = backMat;
vm.MergedContent = new Model3DGroup();
vm.MergedContent.Children.Add(mergedModel);
_mergedMap[mergedModel] = vm;
_mergedFaceRanges[mergedModel] = (vertStarts, faceInfos);
// 預設瀏覽模式 → 顯示合併網格(量測模式時由 ApplyRenderMode 換回逐面
// 非剖面一律顯示合併網格(瀏覽/量測都是,量測靠 _mergedFaceRanges 反查面);只有剖面才逐面(裁切後幾何
vm.BodyVisual = new ModelVisual3D
{
Content = (CurrentMode is MeasureMode.None or MeasureMode.Drag && !SectionEnabled)
? vm.MergedContent : group,
Content = SectionEnabled ? group : vm.MergedContent,
};
}
@@ -350,20 +333,19 @@ public partial class MainViewModel : ObservableObject
// ─── 合併網格(瀏覽模式降 draw call)────────────────────────
/// <summary>把一個零件的所有面網格併成單一 MeshGeometry3D(索引位移後串接)</summary>
private static MeshGeometry3D BuildMergedMesh(IReadOnlyList<ImportedFace> faces)
/// <summary>把多個面網格依序併成單一 MeshGeometry3D(索引位移後串接;順序 = 呼叫端給的面序</summary>
private static MeshGeometry3D MergeMeshes(IReadOnlyList<MeshGeometry3D> meshes)
{
int nv = 0, nt = 0;
foreach (ImportedFace f in faces) { nv += f.Mesh.Positions.Count; nt += f.Mesh.TriangleIndices.Count; }
foreach (MeshGeometry3D m in meshes) { nv += m.Positions.Count; nt += m.TriangleIndices.Count; }
var pos = new Point3DCollection(nv);
var nrm = new Vector3DCollection(nv);
var idx = new Int32Collection(nt);
int baseIdx = 0;
bool hasNormals = true;
foreach (ImportedFace f in faces)
foreach (MeshGeometry3D m in meshes)
{
MeshGeometry3D m = f.Mesh;
foreach (Point3D p in m.Positions) pos.Add(p);
if (m.Normals.Count == m.Positions.Count)
foreach (Vector3D v in m.Normals) nrm.Add(v);
@@ -378,46 +360,39 @@ public partial class MainViewModel : ObservableObject
return mesh;
}
/// <summary>建合併網格(位置變更後,如零件平移)。由 leaf 逐面 fi.Mesh(未剖切原始)串接。</summary>
/// <summary>匯入時建合併網格(依 faces 面序串接,與 _mergedFaceRanges 的頂點邊界對齊)</summary>
private static MeshGeometry3D BuildMergedMesh(IReadOnlyList<ImportedFace> faces)
{
var meshes = new List<MeshGeometry3D>(faces.Count);
foreach (ImportedFace f in faces) meshes.Add(f.Mesh);
return MergeMeshes(meshes);
}
/// <summary>重建合併網格(位置變更後,如零件平移)。由 leaf 逐面 fi.Mesh(未剖切原始)依 FacesContent 子序串接,
/// 與匯入時 BuildMergedMesh 的面序一致 → _mergedFaceRanges 頂點邊界維持有效。</summary>
private void RebuildMerged(ModelNodeViewModel leaf)
{
if (leaf.MergedContent is null || leaf.FacesContent is null) return;
int nv = 0, nt = 0;
var meshes = new List<MeshGeometry3D>(leaf.FacesContent.Children.Count);
foreach (Model3D mm in leaf.FacesContent.Children)
if (mm is GeometryModel3D g && _faceMap.TryGetValue(g, out FaceInfo? fi))
{ nv += fi.Mesh.Positions.Count; nt += fi.Mesh.TriangleIndices.Count; }
var pos = new Point3DCollection(nv);
var nrm = new Vector3DCollection(nv);
var idx = new Int32Collection(nt);
int baseIdx = 0; bool hasNormals = true;
foreach (Model3D mm in leaf.FacesContent.Children)
{
if (mm is not GeometryModel3D g || !_faceMap.TryGetValue(g, out FaceInfo? fi)) continue;
MeshGeometry3D m = fi.Mesh;
foreach (Point3D p in m.Positions) pos.Add(p);
if (m.Normals.Count == m.Positions.Count) foreach (Vector3D v in m.Normals) nrm.Add(v);
else hasNormals = false;
foreach (int ti in m.TriangleIndices) idx.Add(ti + baseIdx);
baseIdx += m.Positions.Count;
}
var mesh = new MeshGeometry3D { Positions = pos, TriangleIndices = idx };
if (hasNormals && nrm.Count == pos.Count) mesh.Normals = nrm;
mesh.Freeze();
((GeometryModel3D)leaf.MergedContent.Children[0]).Geometry = mesh;
meshes.Add(fi.Mesh);
((GeometryModel3D)leaf.MergedContent.Children[0]).Geometry = MergeMeshes(meshes);
}
/// <summary>
/// 依目前狀態切換每個 leaf 的渲染內容:
/// 瀏覽(None)且未剖面 → 合併網格(快);量測模式或剖面 → 逐面(拾取/裁切需要)。
/// 非剖面(瀏覽 + 量測皆是)→ 合併網格(每檔 1 個 model,快);量測拾取靠命中合併網格反查面(_mergedFaceRanges)。
/// 剖面 → 逐面(要顯示各面裁切後的幾何)。
/// </summary>
private void ApplyRenderMode()
{
bool browse = CurrentMode is MeasureMode.None or MeasureMode.Drag && !SectionEnabled;
// 量測不再需要逐面渲染(拾取打合併網格反查面)→ 只有剖面才逐面。永不在量測模式掛數萬個 GeometryModel3D。
bool merged = !SectionEnabled;
foreach (ModelNodeViewModel leaf in Roots.SelectMany(r => r.Leaves()))
{
if (leaf.BodyVisual is null) continue;
Model3DGroup? target = browse ? leaf.MergedContent : leaf.FacesContent;
Model3DGroup? target = merged ? leaf.MergedContent : leaf.FacesContent;
if (target is not null && !ReferenceEquals(leaf.BodyVisual.Content, target))
leaf.BodyVisual.Content = target;
}
@@ -454,7 +429,10 @@ public partial class MainViewModel : ObservableObject
_faceMap.Remove(m);
if (leaf.MergedContent is not null)
foreach (Model3D m in leaf.MergedContent.Children)
{
_mergedMap.Remove(m);
_mergedFaceRanges.Remove(m);
}
}
if (root.EdgeVisual is not null)
_viewport.Children.Remove(root.EdgeVisual);
@@ -467,7 +445,7 @@ public partial class MainViewModel : ObservableObject
partial void OnCurrentModeChanged(MeasureMode value)
{
CancelPending();
ApplyRenderMode(); // None → 合併網格(快);量測模式 → 逐面(可拾取)
ApplyRenderMode(); // 非剖面一律合併網格(量測拾取靠反查面);不再因切量測模式而掛逐面
StatusText = value switch
{
MeasureMode.None => "瀏覽模式(右鍵旋轉、滾輪縮放、中鍵或 Shift+左鍵拖曳平移)",
@@ -503,13 +481,23 @@ public partial class MainViewModel : ObservableObject
Vector3D hitNormal = default;
foreach (var h in hits)
{
if (h.Model is not null && _faceMap.TryGetValue(h.Model, out FaceInfo? f))
if (h.Model is null) continue;
// 剖面模式:渲染逐面 → 直接查 _faceMap
if (_faceMap.TryGetValue(h.Model, out FaceInfo? f))
{
fi = f;
hitPoint = h.Position;
hitNormal = h.Normal;
fi = f; hitPoint = h.Position; hitNormal = h.Normal;
break;
}
// 量測/瀏覽模式:渲染合併網格 → 用命中三角形頂點 index 反查回是哪個面
if (h.RayHit is not null && _mergedFaceRanges.TryGetValue(h.Model, out var range))
{
FaceInfo? mf = ResolveMergedFace(range, h.RayHit.VertexIndex1);
if (mf is not null)
{
fi = mf; hitPoint = h.Position; hitNormal = h.Normal;
break;
}
}
}
if (fi is null)
{
@@ -527,6 +515,23 @@ public partial class MainViewModel : ObservableObject
}
}
/// <summary>命中合併網格的某頂點 index → 反查回是哪個面(依面序的頂點起始邊界二分搜尋)。</summary>
private static FaceInfo? ResolveMergedFace((int[] Starts, FaceInfo[] Faces) range, int vertexIndex)
{
int[] starts = range.Starts;
FaceInfo[] faces = range.Faces;
if (vertexIndex < 0 || faces.Length == 0 || vertexIndex >= starts[faces.Length]) return null;
// 找最大的 k 使 starts[k] <= vertexIndexface k 佔頂點 [starts[k], starts[k+1])
int lo = 0, hi = faces.Length - 1, ans = 0;
while (lo <= hi)
{
int mid = (lo + hi) / 2;
if (starts[mid] <= vertexIndex) { ans = mid; lo = mid + 1; }
else hi = mid - 1;
}
return faces[ans];
}
private void HandleMeasureClick(FaceInfo fi, Point3D hit, Vector3D hitNormal)
{
double diag = SceneDiagonal();
@@ -794,27 +799,37 @@ public partial class MainViewModel : ObservableObject
if (_viewport is null) return;
ClearMeasurements(); // 量測標記位置已失效
foreach (ModelNodeViewModel leaf in root.Leaves())
{
// B-rep:對 Solid/Shell 整體 Modify(避免共用邊被逐面重複變換)
var leaves = root.Leaves().ToList();
// B-rep:對 Solid/Shell 整體 Modify(避免共用邊被逐面重複變換)
// CADability 物件非執行緒安全 → 維持循序
foreach (ModelNodeViewModel leaf in leaves)
foreach (CADability.GeoObject.IGeoObject g in leaf.SourceGeos)
{
try { g.Modify(op); }
catch { /* 個別物件變換失敗不中斷 */ }
}
// 渲染網格(逐面,與目前顯示哪種內容無關
// 渲染網格:面級平行變換(來源/輸出皆 frozen、各面獨立 → 安全;大檔 64k 面吃滿多核
var faceItems = new List<(GeometryModel3D Gm, FaceInfo Fi)>();
foreach (ModelNodeViewModel leaf in leaves)
if (leaf.FacesContent is not null)
{
foreach (Model3D mm in leaf.FacesContent.Children)
{
if (mm is not GeometryModel3D gm || !_faceMap.TryGetValue(gm, out FaceInfo? fi)) continue;
MeshGeometry3D moved = TransformMesh(fi.Mesh, m);
fi.Mesh = moved;
gm.Geometry = moved;
}
if (mm is GeometryModel3D gm && _faceMap.TryGetValue(gm, out FaceInfo? fi))
faceItems.Add((gm, fi));
var moved = new MeshGeometry3D[faceItems.Count];
Parallel.For(0, faceItems.Count, i => moved[i] = TransformMesh(faceItems[i].Fi.Mesh, m));
for (int i = 0; i < faceItems.Count; i++) // 視覺樹賦值回 UI 執行緒循序做
{
faceItems[i].Fi.Mesh = moved[i];
faceItems[i].Gm.Geometry = moved[i];
}
foreach (ModelNodeViewModel leaf in leaves)
{
if (leaf.FacesContent is not null)
RebuildMerged(leaf); // 合併網格同步
}
// 邊線端點(資料);實際合併線於迴圈後重建
if (leaf.OriginalEdgePoints is not null)
@@ -871,7 +886,7 @@ public partial class MainViewModel : ObservableObject
// ─── 旋轉 90°(樹面板選取的檔案,繞其中心)──────────────────
[RelayCommand]
[RelayCommand(CanExecute = nameof(IsIdle))]
private void RotateRoot(string axisName)
{
ModelNodeViewModel? root = SelectedNode is not null ? RootContaining(SelectedNode)
@@ -905,499 +920,6 @@ public partial class MainViewModel : ObservableObject
// 三點對齊的剛體變換數學在 Services/RigidAlign.csSmokeTest --align-test 驗證)
// ─── 拖曳模式(左鍵按住零件沿螢幕平面拖;放開才烘進 B-rep)──
/// <summary>左鍵按下:命中零件則開始拖曳(回傳 true 表示要捕捉滑鼠)</summary>
public bool OnDragStart(Point position)
{
if (_viewport is null || IsBusy || CurrentMode != MeasureMode.Drag || _dragRoot is not null)
return false;
// 兩種渲染內容都可能在場:合併網格查 _mergedMap、逐面(剖面時)查 _faceMap
ModelNodeViewModel? leaf = null;
Point3D anchor = default;
foreach (var h in _viewport.Viewport.FindHits(position))
{
if (h.Model is null) continue;
if (_faceMap.TryGetValue(h.Model, out FaceInfo? fi)) { leaf = fi.Owner; anchor = h.Position; break; }
if (_mergedMap.TryGetValue(h.Model, out ModelNodeViewModel? ml)) { leaf = ml; anchor = h.Position; break; }
}
if (leaf is null) return false;
ModelNodeViewModel? root = FindRootOf(leaf) ?? RootContaining(leaf);
if (root is null) return false;
_dragRoot = root;
_dragAnchor = anchor;
_dragApplied = default;
_dragTransform = new TranslateTransform3D();
foreach (ModelNodeViewModel l in root.Leaves())
if (l.BodyVisual is not null)
l.BodyVisual.Transform = _dragTransform;
// 拖曳中隱藏邊線(LinesVisual3D 隨 Transform 變更逐幀重建會卡)
_edgesSuspended = true;
SetEdgesActive(false);
StatusText = $"拖曳「{root.Name}」中…(放開定位)";
return true;
}
/// <summary>滑鼠移動:把 2D 位移投影到「過錨點、面向相機」的平面上 → 暫時 Transform</summary>
public void OnDragMove(Point position)
{
if (_viewport?.Camera is not System.Windows.Media.Media3D.ProjectionCamera cam ||
_dragRoot is null || _dragTransform is null) return;
Point3D? p = _viewport.Viewport.UnProject(position, _dragAnchor, cam.LookDirection);
if (p is null) return;
_dragApplied = p.Value - _dragAnchor;
_dragTransform.OffsetX = _dragApplied.X;
_dragTransform.OffsetY = _dragApplied.Y;
_dragTransform.OffsetZ = _dragApplied.Z;
}
/// <summary>放開:移除暫時 Transform,一次性烘進 B-rep(量測精度不受拖曳影響)</summary>
public bool OnDragEnd()
{
if (_dragRoot is null) return false;
ModelNodeViewModel root = _dragRoot;
Vector3D delta = _dragApplied;
// 清除暫時位移:必須用 Identity,不可用 null —— HelixToolkit GetTransform 對 child.Transform
// 不做 null 檢查(Children.Add(null) 會拋「無法新增空值到集合中」),下次 FindHits 即 crash
foreach (ModelNodeViewModel l in root.Leaves())
if (l.BodyVisual is not null)
l.BodyVisual.Transform = Transform3D.Identity;
_dragRoot = null;
_dragTransform = null;
_dragApplied = default;
_edgesSuspended = false;
SetEdgesActive(true); // 恢復所有檔案的邊線(被拖檔案的點隨後由 TranslateRoot 更新,期間不會渲染)
if (delta.LengthSquared > 1e-12)
{
TranslateRoot(root, delta); // 烘進 B-rep + 網格 + 邊線 + 邊界(量測清空)
StatusText = $"已拖曳「{root.Name}」 Δ({delta.X:F2}, {delta.Y:F2}, {delta.Z:F2}) mm(可用 🧩干涉 驗證)";
}
return true;
}
// ─── Gizmo 三軸操作器(XYZ 箭頭 + 旋轉環;每次放開滑鼠烘進 B-rep)──
[ObservableProperty]
private bool gizmoEnabled;
private readonly List<HelixToolkit.Wpf.Manipulator> _gizmoParts = new();
private ModelVisual3D? _gizmoProxy; // 操作器綁定的代理 visual(其 Transform = 使用者拖出的變換)
private ModelNodeViewModel? _gizmoTarget;
private bool _gizmoDragActive; // 拖動中(邊線已暫停)
private bool _gizmoBaking; // 烘焙中,忽略 Transform 變更回呼
private bool _gizmoBakePending; // 已排程烘焙,防同一次放開重複觸發
// 操作器疊圖層:另一個透明 Viewport3D 疊在主視窗上、相機同步、只放操作器 →
// 操作器不在主場景,永不被實體遮擋(always-on-top);空白處滑鼠穿透回主視窗
private System.Windows.Controls.Viewport3D? _overlayViewport;
private PerspectiveCamera? _overlayCamera;
partial void OnGizmoEnabledChanged(bool value) => UpdateGizmo();
partial void OnSelectedNodeChanged(ModelNodeViewModel? value)
{
if (GizmoEnabled) UpdateGizmo(); // 換選取 → 操作器跟著換目標
}
private void UpdateGizmo()
{
RemoveGizmo();
if (!GizmoEnabled || _viewport is null || _overlayViewport is null) return;
ModelNodeViewModel? root = SelectedNode is not null ? RootContaining(SelectedNode)
: Roots.Count == 1 ? Roots[0] : null;
if (root is null || root.Bounds.IsEmpty)
{
StatusText = "操作器:請先在樹面板點選要操作的檔案";
GizmoEnabled = false;
return;
}
_gizmoTarget = root;
SyncOverlayCamera(); // 操作器出現前先對齊相機
Rect3D b = root.Bounds;
var center = new Point3D(b.X + b.SizeX / 2, b.Y + b.SizeY / 2, b.Z + b.SizeZ / 2);
double diag = new Vector3D(b.SizeX, b.SizeY, b.SizeZ).Length;
_gizmoProxy = new ModelVisual3D();
_overlayViewport.Children.Add(_gizmoProxy);
System.ComponentModel.DependencyPropertyDescriptor
.FromProperty(Visual3D.TransformProperty, typeof(Visual3D))
.AddValueChanged(_gizmoProxy, GizmoTransformChanged);
void AddPart(HelixToolkit.Wpf.Manipulator man)
{
man.Position = center;
man.Bind(_gizmoProxy);
_gizmoParts.Add(man);
_overlayViewport!.Children.Add(man); // 放疊圖層 → 永不被實體遮擋
}
// 平移箭頭(X 紅 / Y 綠 / Z 藍 — 業界慣例)
AddPart(new TranslateManipulator { Direction = new Vector3D(1, 0, 0), Color = Colors.Red, Length = diag * 0.22, Diameter = diag * 0.016 });
AddPart(new TranslateManipulator { Direction = new Vector3D(0, 1, 0), Color = Colors.Green, Length = diag * 0.22, Diameter = diag * 0.016 });
AddPart(new TranslateManipulator { Direction = new Vector3D(0, 0, 1), Color = Colors.Blue, Length = diag * 0.22, Diameter = diag * 0.016 });
// 旋轉環
AddPart(new RotateManipulator { Axis = new Vector3D(1, 0, 0), Color = Colors.Red, Diameter = diag * 0.34, InnerDiameter = diag * 0.30, Length = diag * 0.012 });
AddPart(new RotateManipulator { Axis = new Vector3D(0, 1, 0), Color = Colors.Green, Diameter = diag * 0.34, InnerDiameter = diag * 0.30, Length = diag * 0.012 });
AddPart(new RotateManipulator { Axis = new Vector3D(0, 0, 1), Color = Colors.Blue, Diameter = diag * 0.34, InnerDiameter = diag * 0.30, Length = diag * 0.012 });
StatusText = $"操作器:拖箭頭沿軸移動「{root.Name}」、拖環繞軸旋轉;放開即定位";
}
private void RemoveGizmo()
{
if (_gizmoProxy is not null)
{
System.ComponentModel.DependencyPropertyDescriptor
.FromProperty(Visual3D.TransformProperty, typeof(Visual3D))
.RemoveValueChanged(_gizmoProxy, GizmoTransformChanged);
_overlayViewport?.Children.Remove(_gizmoProxy);
}
foreach (HelixToolkit.Wpf.Manipulator man in _gizmoParts)
{
man.UnBind();
_overlayViewport?.Children.Remove(man);
}
_gizmoParts.Clear();
_gizmoProxy = null;
if (_gizmoTarget is not null) // 清掉殘留的暫時 Transform(用 Identity,不可 null → 否則 FindHits crash
foreach (ModelNodeViewModel l in _gizmoTarget.Leaves())
if (l.BodyVisual is not null)
l.BodyVisual.Transform = Transform3D.Identity;
_gizmoTarget = null;
if (_gizmoDragActive)
{
_gizmoDragActive = false;
_edgesSuspended = false;
SetEdgesActive(true);
}
}
/// <summary>操作器拖動中:把代理的 Transform 套到目標檔案的所有 BodyVisual(暫時、GPU 端)</summary>
private void GizmoTransformChanged(object? sender, EventArgs e)
{
if (_gizmoBaking || _gizmoTarget is null || _gizmoProxy is null) return;
Transform3D t = _gizmoProxy.Transform;
if (!_gizmoDragActive && t is not null && !t.Value.IsIdentity)
{
_gizmoDragActive = true;
_edgesSuspended = true;
SetEdgesActive(false); // 邊線逐幀重建會卡,拖動中暫停
}
foreach (ModelNodeViewModel l in _gizmoTarget.Leaves())
if (l.BodyVisual is not null)
l.BodyVisual.Transform = t;
}
/// <summary>滑鼠放開(MainWindow 轉發,handledEventsToo):把累積變換烘進 B-rep 並重置操作器</summary>
public void OnGizmoMouseUp()
{
if (_gizmoTarget is null || _gizmoProxy is null || _gizmoBakePending) return;
Matrix3D m = _gizmoProxy.Transform?.Value ?? Matrix3D.Identity;
if (m.IsIdentity) return; // 只是點一下、沒拖操作器 → 不烘焙
// 延後到 manipulator 自身的 mouse-up 處理(釋放捕捉等)完成後再烘焙,避免在其事件中改動視覺樹造成 reentrancy
_gizmoBakePending = true;
_viewport?.Dispatcher.BeginInvoke(new Action(() => BakeGizmo(m)),
System.Windows.Threading.DispatcherPriority.Background);
}
private void BakeGizmo(Matrix3D m)
{
_gizmoBakePending = false;
if (_gizmoTarget is null) return;
ModelNodeViewModel root = _gizmoTarget;
_gizmoBaking = true; // 防 proxy 歸零觸發 GizmoTransformChanged 重入
foreach (ModelNodeViewModel l in root.Leaves())
if (l.BodyVisual is not null)
l.BodyVisual.Transform = Transform3D.Identity; // 清暫時位移(用 Identity,不可 null
if (_gizmoProxy is not null) _gizmoProxy.Transform = Transform3D.Identity; // 綁定 → 操作器歸零
_gizmoBaking = false;
_gizmoDragActive = false;
_edgesSuspended = false;
SetEdgesActive(true);
TransformRoot(root, RigidAlign.ToModOp(m), m); // 烘進 B-rep(量測精度不受影響)
// 操作器移到變換後的新中心
Rect3D b = root.Bounds;
if (!b.IsEmpty)
{
var center = new Point3D(b.X + b.SizeX / 2, b.Y + b.SizeY / 2, b.Z + b.SizeZ / 2);
foreach (HelixToolkit.Wpf.Manipulator man in _gizmoParts)
man.Position = center;
}
StatusText = $"已套用操作器變換「{root.Name}」(可用 🧩干涉 驗證)";
}
// ─── 干涉檢查 ────────────────────────────────────────────────
[RelayCommand]
private async Task CheckInterferenceAsync()
{
if (_viewport is null) return;
var visibleRoots = Roots.Where(r => r.IsVisible).ToList();
if (visibleRoots.Count != 2)
{
StatusText = $"干涉檢查需要剛好 2 個可見檔案(目前 {visibleRoots.Count} 個)— 用樹面板勾選";
return;
}
List<MeshGeometry3D> MeshesOf(ModelNodeViewModel root) =>
root.Leaves().Where(l => l.IsVisible && l.FacesContent is not null)
.SelectMany(l => l.FacesContent!.Children)
.Where(m => m is GeometryModel3D gm && _faceMap.ContainsKey(gm))
.Select(m => _faceMap[(GeometryModel3D)m].Mesh)
.ToList();
var a = MeshesOf(visibleRoots[0]);
var b = MeshesOf(visibleRoots[1]);
string nameA = visibleRoots[0].Name, nameB = visibleRoots[1].Name;
IsBusy = true;
StatusText = $"干涉檢查中:{nameA} ⟷ {nameB} …";
try
{
InterferenceResult result = await Task.Run(() => InterferenceService.Check(a, b));
AddInterferenceResult(result, nameA, nameB);
}
catch (Exception ex)
{
StatusText = $"干涉檢查失敗:{ex.Message}";
}
finally
{
IsBusy = false;
}
}
private void AddInterferenceResult(InterferenceResult result, string nameA, string nameB)
{
double diag = SceneDiagonal();
double markerR = Math.Clamp(diag * 0.004, 0.01, 5.0);
MeasurementResult m;
if (result.Intersects)
{
int pairs = result.PairCount;
int segCount = result.Segments.Count;
m = new MeasurementResult
{
Kind = MeasureMode.Interference,
TitleFor = _ => $"🧩 干涉!{nameA} ⟷ {nameB}",
DetailFor = _ => $"兩零件相交(不 match\n相交三角形對 = {pairs:N0}\n" +
$"紅色線 = 干涉交線({segCount:N0} 段)\n(共面貼合不會被算為干涉)",
};
var pts = new Point3DCollection(Math.Min(result.Segments.Count, 20000) * 2);
foreach ((Point3D s0, Point3D s1) in result.Segments) { pts.Add(s0); pts.Add(s1); }
m.Overlays.Add(new LinesVisual3D { Points = pts, Color = Colors.Red, Thickness = 3 });
if (result.Segments.Count > 0)
{
Point3D at = result.Segments[0].A;
m.Overlays.Add(new BillboardTextVisual3D
{
Position = at + new Vector3D(0, 0, markerR * 3),
Text = "干涉",
Foreground = Brushes.White,
Background = Brushes.Red,
Padding = new Thickness(5, 2, 5, 2),
FontSize = 14,
});
}
}
else
{
Point3D ga = result.GapA, gb = result.GapB;
Vector3D d = gb - ga;
double gap = result.GapDistance;
m = new MeasurementResult
{
Kind = MeasureMode.Interference,
TitleFor = u => $"🧩 無干涉 gap ≈ {Units.L(gap, u)}",
DetailFor = u => $"{nameA} ⟷ {nameB} 無相交\n最小間隙 ≈ {Units.L(gap, u)}(網格近似)\n" +
$"gap ≈ 0 即為貼合(match\n點 1 {Units.P(ga, u)}\n點 2 {Units.P(gb, u)}",
};
m.Overlays.Add(new SphereVisual3D { Center = ga, Radius = markerR, Fill = Brushes.OrangeRed });
m.Overlays.Add(new SphereVisual3D { Center = gb, Radius = markerR, Fill = Brushes.OrangeRed });
m.Overlays.Add(new LinesVisual3D
{
Points = new Point3DCollection { ga, gb },
Color = Colors.OrangeRed,
Thickness = 2,
});
var label = new BillboardTextVisual3D
{
Position = ga + d / 2 + new Vector3D(0, 0, markerR * 2),
Text = $"gap {Units.L(gap, UnitSystem.Millimeter)}",
Foreground = Brushes.Black,
Background = new SolidColorBrush(Color.FromArgb(200, 255, 255, 210)),
Padding = new Thickness(4, 2, 4, 2),
FontSize = 14,
};
m.Overlays.Add(label);
m.DynamicLabels.Add((label, u => $"gap {Units.L(gap, u)}"));
}
AddMeasurement(m);
}
// ─── 剖面 ────────────────────────────────────────────────────
partial void OnSectionEnabledChanged(bool value) => ScheduleSection();
partial void OnSectionAxisIndexChanged(int value) => ScheduleSection();
partial void OnSectionPositionChanged(double value) => ScheduleSection();
partial void OnSectionFlipChanged(bool value) => ScheduleSection();
private void ScheduleSection()
{
_sectionTimer.Stop();
_sectionTimer.Start();
}
private void ApplySection()
{
if (_viewport is null) return;
if (!SectionEnabled)
{
// 還原原始幾何
foreach ((Model3D model, FaceInfo fi) in _faceMap)
if (model is GeometryModel3D gm && !ReferenceEquals(gm.Geometry, fi.Mesh))
gm.Geometry = fi.Mesh;
foreach (ModelNodeViewModel root in Roots) RefreshRootEdges(root);
if (_sectionPlaneVisual is not null)
{
_viewport.Children.Remove(_sectionPlaneVisual);
_sectionPlaneVisual = null;
}
ApplyRenderMode(); // 剖面關閉 → 瀏覽模式可切回合併網格
StatusText = "剖面已關閉";
return;
}
Rect3D bounds = UnionBounds();
if (bounds.IsEmpty) return;
Vector3D axis = SectionAxisIndex switch
{
0 => new Vector3D(1, 0, 0),
1 => new Vector3D(0, 1, 0),
_ => new Vector3D(0, 0, 1),
};
double t = SectionPosition / 100.0;
Point3D min = bounds.Location;
var size = new Vector3D(bounds.SizeX, bounds.SizeY, bounds.SizeZ);
double planeCoord = SectionAxisIndex switch
{
0 => min.X + size.X * t,
1 => min.Y + size.Y * t,
_ => min.Z + size.Z * t,
};
Point3D center = min + size / 2;
Point3D planePoint = SectionAxisIndex switch
{
0 => new Point3D(planeCoord, center.Y, center.Z),
1 => new Point3D(center.X, planeCoord, center.Z),
_ => new Point3D(center.X, center.Y, planeCoord),
};
Vector3D normal = SectionFlip ? -axis : axis;
foreach ((Model3D model, FaceInfo fi) in _faceMap)
if (model is GeometryModel3D gm)
gm.Geometry = SectionService.ClipMesh(fi.Mesh, planePoint, normal);
_sectionPlanePoint = planePoint;
_sectionNormal = normal;
foreach (ModelNodeViewModel root in Roots) RefreshRootEdges(root);
ApplyRenderMode(); // 剖面開啟 → 改用逐面(裁切後幾何)
UpdateSectionPlaneVisual(planePoint, axis, size);
StatusText = $"剖面:{"XYZ"[SectionAxisIndex]} 軸 {SectionPosition:F0}%" + (SectionFlip ? "(反向)" : "");
}
private void UpdateSectionPlaneVisual(Point3D planePoint, Vector3D axis, Vector3D size)
{
if (_sectionPlaneVisual is not null)
_viewport!.Children.Remove(_sectionPlaneVisual);
(Vector3D lenDir, double len, double wid) = SectionAxisIndex switch
{
0 => (new Vector3D(0, 1, 0), size.Y, size.Z),
1 => (new Vector3D(1, 0, 0), size.X, size.Z),
_ => (new Vector3D(1, 0, 0), size.X, size.Y),
};
_sectionPlaneVisual = new RectangleVisual3D
{
Origin = planePoint,
Normal = axis,
LengthDirection = lenDir,
Length = len * 1.05,
Width = wid * 1.05,
Fill = new SolidColorBrush(Color.FromArgb(40, 30, 120, 255)),
};
_viewport!.Children.Add(_sectionPlaneVisual);
}
// ─── 匯出 ────────────────────────────────────────────────────
[RelayCommand]
private void ExportCsv()
{
if (Measurements.Count == 0)
{
StatusText = "沒有量測結果可匯出";
return;
}
var dlg = new SaveFileDialog
{
Title = "匯出量測結果",
Filter = "CSV 檔案 (*.csv)|*.csv",
FileName = $"measurements_{DateTime.Now:yyyyMMdd_HHmmss}.csv",
};
if (dlg.ShowDialog() != true) return;
var sb = new StringBuilder();
sb.AppendLine("標籤,類型,明細");
foreach (MeasurementResult m in Measurements)
{
static string Esc(string s) => $"\"{s.Replace("\"", "\"\"")}\"";
sb.AppendLine($"{Esc(m.Title)},{m.Kind},{Esc(m.Detail.Replace("\n", " | "))}");
}
File.WriteAllText(dlg.FileName, sb.ToString(), new UTF8Encoding(true)); // BOMExcel 中文不亂碼
StatusText = $"已匯出 {Measurements.Count} 筆量測 → {Path.GetFileName(dlg.FileName)}";
}
[RelayCommand]
private void SaveScreenshot()
{
if (_viewport is null || _viewport.ActualWidth < 1) return;
var dlg = new SaveFileDialog
{
Title = "儲存視圖截圖",
Filter = "PNG 圖片 (*.png)|*.png",
FileName = $"stpviewer_{DateTime.Now:yyyyMMdd_HHmmss}.png",
};
if (dlg.ShowDialog() != true) return;
const double scale = 2.0; // 2x 解析度
var rtb = new RenderTargetBitmap(
(int)(_viewport.ActualWidth * scale), (int)(_viewport.ActualHeight * scale),
96 * scale, 96 * scale, PixelFormats.Pbgra32);
rtb.Render(_viewport);
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(rtb));
using FileStream fs = File.Create(dlg.FileName);
encoder.Save(fs);
StatusText = $"已儲存截圖 → {Path.GetFileName(dlg.FileName)}";
}
// ─── 共用 ────────────────────────────────────────────────────
private Rect3D UnionBounds()
+28
View File
@@ -0,0 +1,28 @@
// One-shot diagnostic: print per-leaf world-space bounding boxes so we can
// figure out each connector's mating-face normal (axis the guide posts / holes point).
using STPViewer.Services;
namespace SmokeTest;
internal static class BBoxDump
{
public static int Run(string path)
{
var svc = new StepImportService();
ImportedFileData data = svc.Import(path);
System.Console.WriteLine($"=== {System.IO.Path.GetFileName(path)} ===");
Print(data.Root, 0);
return 0;
}
private static void Print(ImportedNode n, int depth)
{
var b = n.Bounds;
string box = b.IsEmpty
? "(empty)"
: $"X[{b.X,8:F1}..{b.X + b.SizeX,8:F1}] Y[{b.Y,8:F1}..{b.Y + b.SizeY,8:F1}] Z[{b.Z,8:F1}..{b.Z + b.SizeZ,8:F1}] " +
$"size {b.SizeX,7:F1} x {b.SizeY,7:F1} x {b.SizeZ,7:F1}";
System.Console.WriteLine($"{new string(' ', depth * 2)}{n.Name,-28} {box}");
foreach (ImportedNode c in n.Children) Print(c, depth + 1);
}
}
+3
View File
@@ -32,6 +32,9 @@ if (args[0] == "--interference-test")
if (args[0] == "--align-test")
return SmokeTest.AlignTest.Run() == 0 ? 0 : 2;
if (args[0] == "--bbox")
return SmokeTest.BBoxDump.Run(args[1]);
var service = new StepImportService { Progress = s => Console.WriteLine($" [階段] {s}") };
int failed = 0;