Author SHA1 Message Date
etwenandClaude Opus 4.8 89ed1270a8 docs: add v0.7.1 release notes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XgHf5MW39U3Xo8y2uA229K
2026-07-14 14:20:36 +08:00
etwenandClaude Opus 4.8 060ca4ecd0 feat(perf): direct STL parsing for near-instant large-file load (v0.7.1)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XgHf5MW39U3Xo8y2uA229K
2026-07-14 14:20:36 +08:00
etwenandClaude Fable 5 45b27d51cb feat(ui): Add About dialog with user-facing changelog
- Help menu (Help > About STPViewer) opens a dialog styled after
  ETTerms AboutView: app card (name/version/description), developer
  card, tech stack card on the left; scrollable version history on
  the right, written for end users in plain language (v0.1.0-v0.7.0)
- Version read from assembly (single source: csproj <Version>)
- Note in CLAUDE.md: top-level WPF MenuItem with a Click handler does
  not fire (verified with real mouse input) - dialog entries must
  live in a submenu

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 09:17:06 +08:00
etwenandClaude Fable 5 3d124782d5 feat(ui): Add menu bar and customizable quick toolbar (v0.7.0)
- Menu bar groups all functions: File (import/MRU/exports), View
  (zoom/standard views/ortho/unit), Measure (7 checkable modes with
  hotkey hints), Transform (align/drag/gizmo/rotate), Tools
  (interference/section), QuickBar (customize/reset)
- QuickBarViewModel registry: per-button visibility via XAML indexer
  binding QuickBar[Key].IsChecked; buttons keep static bindings
- Common-user defaults visible (import/recent/zoom/iso/ortho/point/
  distance/circle/angle/interference/section/clear/unit); the rest
  live in menus; selection persists in settings.json QuickBarKeys
- Section parameter bar is its own row, shown only while section is
  enabled (with close button); no longer occupies space permanently
- Bump version to 0.7.0; update ARCHITECTURE/CLAUDE docs (Phase 16)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 08:52:55 +08:00
etwen e4485667e2 merge: feature/v0.6.0-stl-export - STL export with parameter dialog 2026-07-04 08:44:43 +08:00
etwenandClaude Fable 5 f5ccc13e9c feat(export): Add STL export with parameter dialog (v0.6.0)
- StlExportService: binary/ASCII STL writer (degenerate triangle
  filtering, per-triangle normals) + B-rep re-tessellation
- StlExportDialog + StlExportViewModel: scope (merged/per-file),
  format, unit scale (mm/inch/custom), mesh quality (current/fine),
  live triangle count + size estimate; confirm before export
- Background export with UI-thread mesh snapshot (GeometryModel3D is
  DispatcherObject; only frozen MeshGeometry3D crosses threads)
- Fine quality = 0.15x import precision: CADability GetTriangulation
  returns cached mesh for coarser requests (no coarse option) and
  triangle count is non-monotonic in precision (0.4x < 1x)
- SmokeTest --stl-export-test: binary round-trip incl. inch scaling,
  ASCII structure, degenerate filtering, real-file fine re-tessellation
- Bump version to 0.6.0; update ARCHITECTURE/CLAUDE docs (Phase 15)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 08:44:34 +08:00
23 changed files with 1758 additions and 285 deletions
+58 -4
View File
@@ -19,7 +19,8 @@ STEP`.stp` / `.step`)檔案做確認與量測,不需要安裝 SolidWorks
快速鍵 P/D/E/F/C/A/M + Esc;單位 mm ⇄ inch 即時切換 快速鍵 P/D/E/F/C/A/M + Esc;單位 mm ⇄ inch 即時切換
- **剖面**X/Y/Z 軸向 + **3點任意平面**,位置滑桿/數值輸入 + 反向,CPU 網格裁切(量測不受影響) - **剖面**X/Y/Z 軸向 + **3點任意平面**,位置滑桿/數值輸入 + 反向,CPU 網格裁切(量測不受影響)
- **視圖**:標準視圖(等角/前/上/右)+ 正交⇄透視投影 - **視圖**:標準視圖(等角/前/上/右)+ 正交⇄透視投影
- **匯出**:量測結果 CSV、3D 視圖 PNG 截圖(2x)、**目前對齊位置寫成新 STEP 檔** - **匯出**:量測結果 CSV、3D 視圖 PNG 截圖(2x)、**目前對齊位置寫成新 STEP 檔**
**STL 網格檔**(參數對話框:合併/每檔、binary/ASCII、mm/inch/自訂縮放、精度,確認再匯出)
- 單機離線執行,無網路相依;視窗/單位/最近檔案自動保存(settings.json - 單機離線執行,無網路相依;視窗/單位/最近檔案自動保存(settings.json
使用者:單一桌面使用者(無登入 / 角色系統)。 使用者:單一桌面使用者(無登入 / 角色系統)。
@@ -106,13 +107,17 @@ STPViewer/
│ ├── MeasurementResult.cs # 量測結果(雙單位 lambda、3D 標籤同步) │ ├── MeasurementResult.cs # 量測結果(雙單位 lambda、3D 標籤同步)
│ └── UnitSystem.cs # mm/inch + Units 格式化 │ └── UnitSystem.cs # mm/inch + Units 格式化
├── StlExportDialog.xaml / .cs # STL 匯出參數對話框(範圍/格式/單位/精度 + 預估,確認再匯出)
├── AboutDialog.xaml / .cs # 關於視窗(程式/開發者/技術棧 + 使用者版 changelog
├── Services/ ├── Services/
│ ├── StepImportService.cs # STEP/STL/DXF 讀檔 + 三角化 + 裝配樹 │ ├── StepImportService.cs # STEP/STL/DXF 讀檔 + 三角化 + 裝配樹
│ ├── MeasurementService.cs # 點/線/面/圓/角度/面距/體積質心 幾何計算(吸附含圓心) │ ├── MeasurementService.cs # 點/線/面/圓/角度/面距/體積質心 幾何計算(吸附含圓心)
│ ├── InterferenceService.cs # 干涉檢查:三角形-三角形相交(區間法)+均勻網格加速;無干涉時近似最小間隙 │ ├── InterferenceService.cs # 干涉檢查:三角形-三角形相交(區間法)+均勻網格加速;無干涉時近似最小間隙
│ ├── RigidAlign.cs # 三點對齊/旋轉的剛體變換數學(Matrix3D列向量 ↔ ModOp行向量 轉換) │ ├── RigidAlign.cs # 三點對齊/旋轉的剛體變換數學(Matrix3D列向量 ↔ ModOp行向量 轉換)
│ ├── SectionService.cs # 剖面:網格/線段半空間裁切 │ ├── SectionService.cs # 剖面:網格/線段半空間裁切
── SettingsService.cs # 使用者設定 settings.json(視窗/單位/MRU%LOCALAPPDATA%\STPViewer ── SettingsService.cs # 使用者設定 settings.json(視窗/單位/MRU%LOCALAPPDATA%\STPViewer
│ └── StlExportService.cs # STL 匯出:binary/ASCII 寫檔 + B-rep 重新三角化(精細)
└── ViewModels/ # MainViewModel 為 partial class,依職責分檔 └── ViewModels/ # MainViewModel 為 partial class,依職責分檔
├── MainViewModel.cs # 核心:匯入、裝配樹、量測、剛體變換、邊線、單位 ├── MainViewModel.cs # 核心:匯入、裝配樹、量測、剛體變換、邊線、單位
@@ -120,7 +125,8 @@ STPViewer/
├── MainViewModel.Gizmo.cs # 三軸操作器 + 疊圖層相機 ├── MainViewModel.Gizmo.cs # 三軸操作器 + 疊圖層相機
├── MainViewModel.Section.cs # 剖面(v0.4.0 起背景平行裁切) ├── MainViewModel.Section.cs # 剖面(v0.4.0 起背景平行裁切)
├── MainViewModel.Interference.cs # 干涉檢查指令 ├── MainViewModel.Interference.cs # 干涉檢查指令
├── MainViewModel.Export.cs # CSV / 截圖匯出 ├── MainViewModel.Export.cs # CSV / 截圖 / STEP / STL 匯出
├── StlExportViewModel.cs # STL 匯出對話框 VM(選項 + 即時摘要)
└── ModelNodeViewModel.cs # 裝配樹節點(可見性/顏色 cascade) └── ModelNodeViewModel.cs # 裝配樹節點(可見性/顏色 cascade)
``` ```
@@ -190,7 +196,7 @@ class MeasurementResult
| 干涉 | 工具列「🧩 干涉」(≥2 個可見檔案,兩兩配對檢查) | 每組配對:相交→紅色交線+相交三角形對數;無相交→最小間隙 gap(≈0 即配合 match | | 干涉 | 工具列「🧩 干涉」(≥2 個可見檔案,兩兩配對檢查) | 每組配對:相交→紅色交線+相交三角形對數;無相交→最小間隙 gap(≈0 即配合 match |
| 剖面 | 工具列「✂ 剖面」+ 軸向(X/Y/Z/**3點任意平面**)/位置(滑桿+數值輸入)/反向 | CPU 裁切渲染網格(原始幾何保留,量測仍精確);3點=在模型上點 3 點定義平面 | | 剖面 | 工具列「✂ 剖面」+ 軸向(X/Y/Z/**3點任意平面**)/位置(滑桿+數值輸入)/反向 | CPU 裁切渲染網格(原始幾何保留,量測仍精確);3點=在模型上點 3 點定義平面 |
| 單位 | 工具列 mm ⇄ in(隨設定保存) | 既有量測(清單+3D 標籤)即時換算 | | 單位 | 工具列 mm ⇄ in(隨設定保存) | 既有量測(清單+3D 標籤)即時換算 |
| 匯出 | 💾 CSV / 📷 截圖 / 📤 STEP | UTF-8 BOM CSV2x PNG;可見檔案目前位置寫成**新** STEP 檔(對齊結果交接 CAD | | 匯出 | 💾 CSV / 📷 截圖 / 📤 STEP / 📐 STL | UTF-8 BOM CSV2x PNG;可見檔案目前位置寫成**新** STEP 檔(對齊結果交接 CAD);STL 先開參數對話框(範圍/格式/單位/精度 + 三角形數與大小預估)確認再匯出 |
| 視圖 | 滑鼠右鍵旋轉/滾輪縮放/中鍵平移(Helix 預設)、ViewCube、工具列 等角/前/上/右、正交⇄透視 | | | 視圖 | 滑鼠右鍵旋轉/滾輪縮放/中鍵平移(Helix 預設)、ViewCube、工具列 等角/前/上/右、正交⇄透視 | |
| 快速鍵 | P 點 / D 距離 / E 邊 / F 面 / C 圓 / A 角度 / M 面距;同鍵再按=退出;Esc=取消進行中量測→退出模式 | | | 快速鍵 | P 點 / D 距離 / E 邊 / F 面 / C 圓 / A 角度 / M 面距;同鍵再按=退出;Esc=取消進行中量測→退出模式 | |
| 樹操作 | 搜尋框過濾節點名稱;右鍵:只顯示此節點 / 反轉顯示 / 全部顯示 / 量體積質心 | tooltip 含外形尺寸 L×W×H | | 樹操作 | 搜尋框過濾節點名稱;右鍵:只顯示此節點 / 反轉顯示 / 全部顯示 / 量體積質心 | tooltip 含外形尺寸 L×W×H |
@@ -435,6 +441,54 @@ WPF retained-mode 每幀重走 visual tree → frame rate 崩。瀏覽模式因
--- ---
## Development Phases — 第七輪(STL 匯出 v0.6.0,全部完成)
### Phase 15 — STL 匯出(工作量:M)
- [x] `StlExportService` — binary80B header + placeholder 補三角形數)/ ASCII 寫檔,
退化(零面積)三角形濾除、逐三角形自算法向量;`Tessellate` 由 B-rep 重新三角化
- [x] 參數對話框(`StlExportDialog` + `StlExportViewModel`)— 範圍(合併單檔/每檔一個)、
格式(binary/ASCII)、單位縮放(mm / inch ÷25.4 / 自訂)、網格精度(目前網格/精細)、
即時摘要(檔案數/三角形數/預估大小),**確認才選路徑匯出**
- [x] 背景執行緒寫檔 — UI 先快照 frozen mesh`GeometryModel3D` 是 DispatcherObject
不可跨執行緒,v0.6.0 修正),背景只碰 frozen `MeshGeometry3D` + CADability B-rep
- [x] SmokeTest `--stl-export-test [file.stp]`binary 往返(含 inch 縮放)、ASCII 結構、
退化三角形濾除、真檔精細重算三角形數 > 匯入預設
**CADability 三角化兩個實測特性(設計依據):**
1. `GetTriangulation` 對「比快取粗」的精度要求直接回傳既有較細快取 → 「較粗」選項無效,誠實不提供
2. 三角形數對精度**非單調**(0.4× 重算反而比 1× 少 — 重算網格較有效率),
要明顯更細需 ≤0.15×(test.stp1× = 30,784 → 0.15× = 35,228)→ 精細係數定 0.15
**驗收:** StlExportTest 5 項全過;既有 ClipTest/AlignTest/InterferenceTest 回歸全過;建置 0 警告
---
## Development Phases — 第八輪(UI 重整 v0.7.0
### Phase 16 — 選單列 + 可自訂快速列(工作量:M)
**問題:** 功能累積後單一工具列 30+ 顆按鈕擠在一排,視窗不夠寬就溢出到「▾」,難找難按。
- [x] **選單列**(Menu):全部功能分類下拉 — 檔案(匯入/MRU/四種匯出)、視圖(全覽/標準視圖/正交/單位)、
量測(7 種模式 checkable + 快速鍵提示 + 清除)、變換(對齊/三點/拖曳/操作器/旋轉XYZ)、
工具(干涉/剖面)、快速列(自訂按鈕勾選 + 恢復預設)
- [x] **快速工具列**`QuickBarViewModel`):只顯示使用者勾選的常用按鈕;
註冊表定義全部按鈕(key/選單名/預設值),XAML 以索引子綁定
`Visibility="{Binding QuickBar[Key].IsChecked, Converter=BoolToVis}"`(按鈕本體與綁定保持靜態)
- [x] 預設常用:匯入、最近、全覽、等角、正交、點、距離、圓、角度、干涉、剖面、清除、mm⇄in;
其餘(邊/面/面距/對齊/三點/旋轉/拖曳/操作器/四種匯出/前上右視圖)預設收進選單
- [x] 勾選清單隨 settings.json`QuickBarKeys`)保存;null/新按鈕用註冊表預設值
- [x] **剖面參數列**獨立成第三列,只在 `SectionEnabled` 時顯示(含「✕ 關閉」);
平常整條隱藏,不再常駐佔位
- [x] **關於視窗**`AboutDialog`,選單「說明 → 關於 STPViewer」)— 程式資訊/開發者/技術棧 +
給一般使用者看的版本紀錄(樣式參考 ETTerms AboutView);發新版在 Changelog 陣列頂端加一筆
**驗收:** 選單所有功能可用(checkable 與工具列 toggle 同步);快速列勾選即時增減、重開保留;
既有快速鍵 P/D/E/F/C/A/M + Esc 不受影響
---
## Future Extensions(下一輪) ## Future Extensions(下一輪)
- **大檔重開快取** — 匯入成功後把三角網格 + 裝配樹序列化成 sidecar 快取檔(以來源檔 hash 驗證), - **大檔重開快取** — 匯入成功後把三角網格 + 裝配樹序列化成 sidecar 快取檔(以來源檔 hash 驗證),
+142 -120
View File
@@ -1,120 +1,142 @@
# STPViewer — Claude 專案記憶 # STPViewer — Claude 專案記憶
## 專案簡介 ## 專案簡介
CAD 3D 檢視器(Windows 桌面 WPF, .NET 8):STEP/STL/DXF 匯入、STEP 裝配樹(搜尋/隔離顯示)、 CAD 3D 檢視器(Windows 桌面 WPF, .NET 8):STEP/STL/DXF 匯入、STEP 裝配樹(搜尋/隔離顯示)、
點/距離/邊/面/圓/角度/面距/體積質心量測(吸附含圓心)、兩點對齊(平移)、三點對齊(旋轉+平移)、 點/距離/邊/面/圓/角度/面距/體積質心量測(吸附含圓心)、兩點對齊(平移)、三點對齊(旋轉+平移)、
軸向旋轉 90°、拖曳模式、干涉檢查(≥2 檔兩兩配對)、剖面(X/Y/Z + 3點任意平面)、 軸向旋轉 90°、拖曳模式、干涉檢查(≥2 檔兩兩配對)、剖面(X/Y/Z + 3點任意平面)、
標準視圖+正交投影、量測快速鍵+Esc、mm⇄inch、CSV/截圖/STEP 匯出、設定與 MRU 保存。 標準視圖+正交投影、量測快速鍵+Esc、mm⇄inch、CSV/截圖/STEP/STL 匯出(STL 有參數對話框)、設定與 MRU 保存。
詳細設計見 [ARCHITECTURE.md](ARCHITECTURE.md)。 詳細設計見 [ARCHITECTURE.md](ARCHITECTURE.md)。
## 技術棧 ## 技術棧
- .NET 8 WPF`net8.0-windows`)、MVVMCommunityToolkit.Mvvm - .NET 8 WPF`net8.0-windows`)、MVVMCommunityToolkit.Mvvm
- **CADability**(純 C# CAD kernel):STEP 匯入、B-rep 幾何、Face 三角化 - **CADability**(純 C# CAD kernel):STEP 匯入、B-rep 幾何、Face 三角化
- **HelixToolkit.Wpf**3D viewport、相機、HitTest - **HelixToolkit.Wpf**3D viewport、相機、HitTest
## 常用指令 ## 常用指令
```bash ```bash
dotnet build STPViewer.sln dotnet build STPViewer.sln
dotnet run --project src/STPViewer dotnet run --project src/STPViewer
dotnet publish src/STPViewer -c Release -o publish/STPViewer dotnet publish src/STPViewer -c Release -o publish/STPViewer
``` ```
測試模型放 `For_AI/`gitignored**客戶料號不入 git**):`For_AI/test.stp`Amphenol RA PHD 座端,小檔)與 測試模型放 `For_AI/`gitignored**客戶料號不入 git**):`For_AI/test.stp`Amphenol RA PHD 座端,小檔)與
`For_AI/Amphenol PHD to PHD Cable 10201248 1.stp`39MB 大組件,效能測試用)。`*.stp`/`*.step` 已全域 gitignore `For_AI/Amphenol PHD to PHD Cable 10201248 1.stp`39MB 大組件,效能測試用)。`*.stp`/`*.step` 已全域 gitignore
## 開發慣例 ## 開發慣例
- MVVM:邏輯寫 ViewModel/Servicecode-behind 只做純 View 事件(滑鼠拾取轉發) - MVVM:邏輯寫 ViewModel/Servicecode-behind 只做純 View 事件(滑鼠拾取轉發)
- `MainViewModel`**partial class 分檔**(v0.4.0):主檔(匯入/樹/量測/變換/邊線)+ - `MainViewModel`**partial class 分檔**(v0.4.0):主檔(匯入/樹/量測/變換/邊線)+
`.Drag` / `.Gizmo` / `.Section` / `.Interference` / `.Export`。加新功能先看職責放哪個檔,勿全塞回主檔 `.Drag` / `.Gizmo` / `.Section` / `.Interference` / `.Export`。加新功能先看職責放哪個檔,勿全塞回主檔
- 全域例外處理在 `App.xaml.cs`:UI 執行緒未攔截例外 → 記 log(`%LOCALAPPDATA%\STPViewer\error.log`+ - 全域例外處理在 `App.xaml.cs`:UI 執行緒未攔截例外 → 記 log(`%LOCALAPPDATA%\STPViewer\error.log`+
訊息框 + `Handled=true` 存活;背景 Task 例外記 log。個別功能仍應自行 try/catch 給出有意義的狀態列訊息 訊息框 + `Handled=true` 存活;背景 Task 例外記 log。個別功能仍應自行 try/catch 給出有意義的狀態列訊息
- 一個 B-rep Face = 一個 `GeometryModel3D`,用 `Dictionary<Model3D, FaceInfo>` (`_faceMap`) 反查(剖面模式逐面拾取靠這個,**不可移除逐面結構**) - 一個 B-rep Face = 一個 `GeometryModel3D`,用 `Dictionary<Model3D, FaceInfo>` (`_faceMap`) 反查(剖面模式逐面拾取靠這個,**不可移除逐面結構**)
- 渲染雙模式(降 draw call):每個 leaf 同時持有 `FacesContent`(逐面,剖面用)與 `MergedContent`(整零件合併成 1 個 `GeometryModel3D`)。 - 渲染雙模式(降 draw call):每個 leaf 同時持有 `FacesContent`(逐面,剖面用)與 `MergedContent`(整零件合併成 1 個 `GeometryModel3D`)。
`ApplyRenderMode()` 依狀態切 `BodyVisual.Content`:**非剖面(瀏覽+量測皆是)→ 合併網格**;**剖面 → 逐面**(要顯示各面裁切後幾何)。 `ApplyRenderMode()` 依狀態切 `BodyVisual.Content`:**非剖面(瀏覽+量測皆是)→ 合併網格**;**剖面 → 逐面**(要顯示各面裁切後幾何)。
合併網格只代表「未剖切、目前位置」幾何;平移後呼叫 `RebuildMerged(leaf)` 同步。剖面/干涉/`RebuildMerged` 一律走 `FacesContent`,與目前顯示哪種內容無關 合併網格只代表「未剖切、目前位置」幾何;平移後呼叫 `RebuildMerged(leaf)` 同步。剖面/干涉/`RebuildMerged` 一律走 `FacesContent`,與目前顯示哪種內容無關
-**量測拾取打合併網格、用三角形頂點 index 反查面**`_mergedFaceRanges`:合併 Model → (每面頂點起始邊界, FaceInfo[]))。 -**量測拾取打合併網格、用三角形頂點 index 反查面**`_mergedFaceRanges`:合併 Model → (每面頂點起始邊界, FaceInfo[]))。
`BuildMergedMesh` 依面序串接(`baseIdx += positions.Count`,無焊接共用頂點),故命中 `RayHit.VertexIndex1` 可二分搜尋(`ResolveMergedFace`)回是哪個面。 `BuildMergedMesh` 依面序串接(`baseIdx += positions.Count`,無焊接共用頂點),故命中 `RayHit.VertexIndex1` 可二分搜尋(`ResolveMergedFace`)回是哪個面。
**這是 v0.3.2 修大檔量測卡頓的關鍵**:量測模式不再掛數萬個逐面 `GeometryModel3D`64k 面實測 = 64k draw call,轉動/停下重繪爆量)→ 改成每檔 1 個 model,量測模式 orbit 與瀏覽同樣順。 **這是 v0.3.2 修大檔量測卡頓的關鍵**:量測模式不再掛數萬個逐面 `GeometryModel3D`64k 面實測 = 64k draw call,轉動/停下重繪爆量)→ 改成每檔 1 個 model,量測模式 orbit 與瀏覽同樣順。
邊界由面頂點數決定、平移不改 → 永久有效,`RebuildMerged` 不需重算。**不要因為「量測要逐面」而把渲染改回逐面** 邊界由面頂點數決定、平移不改 → 永久有效,`RebuildMerged` 不需重算。**不要因為「量測要逐面」而把渲染改回逐面**
- 合併網格的 `BackMaterial`:封閉實體(`SolidCount≥1``HasBrep`)**不設**(WPF 兩面渲染成本砍半);開放殼/STL 才設。 - 合併網格的 `BackMaterial`:封閉實體(`SolidCount≥1``HasBrep`)**不設**(WPF 兩面渲染成本砍半);開放殼/STL 才設。
**拾取不受材質影響**WPF 3D hit-test 純幾何、不剔背面),故打合併網格仍命中孔內壁等背向面。逐面 `FacesContent` 一律保留 BackMaterial(剖切要看內部) **拾取不受材質影響**WPF 3D hit-test 純幾何、不剔背面),故打合併網格仍命中孔內壁等背向面。逐面 `FacesContent` 一律保留 BackMaterial(剖切要看內部)
- 量測值以 B-rep 為準(圓半徑、邊長、角度),面積/面距/體積/質心用網格近似 - 量測值以 B-rep 為準(圓半徑、邊長、角度),面積/面距/體積/質心用網格近似
- `Snap` 吸附順序(v0.5.0):頂點與「圓形邊」都在容差內比誰離命中點近,圓邊勝出時回傳**圓心** - `Snap` 吸附順序(v0.5.0):頂點與「圓形邊」都在容差內比誰離命中點近,圓邊勝出時回傳**圓心**
(量孔對孔 pitch 靠這個)。不要把圓心吸附拿掉或改成永遠優先頂點 (量孔對孔 pitch 靠這個)。不要把圓心吸附拿掉或改成永遠優先頂點
- 鍵盤快速鍵在 `MainWindow.Window_PreviewKeyDown`**焦點在 TextBoxBase/ComboBox 時直接 return** - 鍵盤快速鍵在 `MainWindow.Window_PreviewKeyDown`**焦點在 TextBoxBase/ComboBox 時直接 return**
(樹搜尋框、剖面數值框要能打字),新增輸入控件不用再各自處理 (樹搜尋框、剖面數值框要能打字),新增輸入控件不用再各自處理
- 使用者設定 `SettingsService`%LOCALAPPDATA%\STPViewer\settings.json):視窗(MainWindow 管)+ - UI 三層(v0.7.0):**選單列**(全部功能,分類下拉)+ **快速列**`QuickBarViewModel` 註冊表,
單位/MRUVM `LoadSettings`/`SaveSettingsInto`);壞檔回預設、儲存失敗靜默 使用者勾選常用按鈕,XAML 索引子綁定 `QuickBar[Key].IsChecked` 控 Visibility,按鈕本體維持靜態綁定)+
- 匯出 STEP 走 `CADability.ExportStep.WriteToFile(file, Project.CreateSimpleProject()+Model.Add)` **剖面參數列**(只在 SectionEnabled 顯示)。新增功能要三處都接:選單分類、快速列註冊表
只收 Solid/ShellSTL/DXF 無 B-rep 進不了);改動要跑 `SmokeTest --export-test` 往返驗證 (含預設值)+ 對應 XAML 按鈕;勾選清單存 settings.json `QuickBarKeys`null = 預設)
- 3點剖面(SectionAxisIndex==3):`_customNormal` null = 拾取中(`HandleSectionPlanePick` - 使用者設定 `SettingsService`%LOCALAPPDATA%\STPViewer\settings.json):視窗(MainWindow 管)+
OnViewportClick 最前面攔點擊);換軸/關剖面/Esc 會重置。平面位置一律用「AABB 8 角投影到法向」內插, 單位/MRUVM `LoadSettings`/`SaveSettingsInto`);壞檔回預設、儲存失敗靜默
軸向與任意法向共用同一段程式,不要改回逐軸特化 - 匯出 STEP 走 `CADability.ExportStep.WriteToFile(file, Project.CreateSimpleProject()+Model.Add)`
- 量測文字一律 `Func<UnitSystem,string>` 延後產生(mm⇄inch 即時切換);內部數值永遠存 mm 只收 Solid/ShellSTL/DXF 無 B-rep 進不了);改動要跑 `SmokeTest --export-test` 往返驗證
- 裝配樹節點(`ModelNodeViewModel`)的可見性/邊線/顏色向下 cascade - 匯出 STL`StlExportService` + 參數對話框):**背景寫檔前必須在 UI 執行緒快照 mesh** —
- 剖面只換 `GeometryModel3D.Geometry``FaceInfo.Mesh` 保留原始 frozen mesh 供還原與量測) `GeometryModel3D`/`Model3DGroup` 是 DispatcherObject 跨執行緒會炸(v0.6.0 踩過),
- ⚡ 剖面裁切是**背景平行**(v0.4.0):`ApplySection` 在 UI 快照 (model, frozen mesh) → `Task.Run`+`Parallel.For` 背景只能碰 frozen `MeshGeometry3D` 與 CADability B-rep。精度只有「目前網格/精細」兩檔:
裁切 → 回 UI 一次換上;`_sectionApplying`/`_sectionReapply` guard 保證裁切中的新變更(滑桿/換軸/`TransformRoot` CADability `GetTriangulation` 對比快取粗的精度回傳既有快取(粗化無效),且三角形數對精度
完成後用最新參數重跑。**不要改回同步呼叫 ClipMesh**(64k 面整場景裁切會凍結 UI 數秒); **非單調**(0.4× 反而比 1× 少),精細 = 0.15×(匯入 clamp 後再乘,保證嚴格更細)。
await 之後一定要檢查 `SectionEnabled`(期間可能被關掉,還原分支已處理) 改動要跑 `SmokeTest --stl-export-test For_AI/test.stp`
- 剛體變換(兩點對齊/旋轉 90°/三點對齊)統一走 `TransformRoot(root, ModOp, Matrix3D)`B-rep 用 ModOp 對 Solid/Shell 整體 `Modify` - 3點剖面(SectionAxisIndex==3):`_customNormal` null = 拾取中(`HandleSectionPlanePick`
(勿逐面位移,會重複位移共用邊),網格/合併網格/邊線/邊界同步重算;變換後量測已失效要 `ClearMeasurements()` OnViewportClick 最前面攔點擊);換軸/關剖面/Esc 會重置。平面位置一律用「AABB 8 角投影到法向」內插,
**op 與 m 必須是同一個變換** — 數學在 `Services/RigidAlign.cs`WPF `Matrix3D` 是「列向量」約定、CADability `ModOp` 是「行向量」約定, 軸向與任意法向共用同一段程式,不要改回逐軸特化
`ToModOp` 負責轉置轉換,改動務必跑 `SmokeTest --align-test` 驗證兩種表示一致,否則 B-rep 與顯示網格會悄悄分家。 - 量測文字一律 `Func<UnitSystem,string>` 延後產生(mm⇄inch 即時切換);內部數值永遠存 mm
`TransformRoot` 的網格變換是**面級平行**(v0.4.0;來源/輸出 mesh 皆 frozen 所以安全); - 裝配樹節點(`ModelNodeViewModel`)的可見性/邊線/顏色向下 cascade
B-rep `Modify` 與視覺樹賦值必須維持循序(CADability 非執行緒安全 / WPF 執行緒親和 - 剖面只換 `GeometryModel3D.Geometry``FaceInfo.Mesh` 保留原始 frozen mesh 供還原與量測
- IsBusy 期間匯入/旋轉 90°/干涉檢查指令停用(`CanExecute = nameof(IsIdle)` + isBusy 的 - ⚡ 剖面裁切是**背景平行**(v0.4.0):`ApplySection` 在 UI 快照 (model, frozen mesh) → `Task.Run`+`Parallel.For`
`NotifyCanExecuteChangedFor`);`ImportFilesAsync` 整批維持 busy 且開頭擋重入 裁切 → 回 UI 一次換上;`_sectionApplying`/`_sectionReapply` guard 保證裁切中的新變更(滑桿/換軸/`TransformRoot`
(拖放/命令列會繞過 CanExecute)。新增「會改動幾何的指令」時記得比照辦理 完成後用最新參數重跑。**不要改回同步呼叫 ClipMesh**(64k 面整場景裁切會凍結 UI 數秒);
- 拖曳模式(`MeasureMode.Drag`):拖曳中只掛**暫時 `TranslateTransform3D`**GPU 免費),放開才一次性 `TranslateRoot` 烘進 B-rep — await 之後一定要檢查 `SectionEnabled`(期間可能被關掉,還原分支已處理)
**不要改成拖曳中逐幀 TransformRoot**(大檔每幀重建網格會卡死)。2D→3D 用 Helix `UnProject`(過錨點、法向=相機 LookDirection 的平面)。 - 剛體變換(兩點對齊/旋轉 90°/三點對齊)統一走 `TransformRoot(root, ModOp, Matrix3D)`B-rep 用 ModOp 對 Solid/Shell 整體 `Modify`
合併網格的 hit-test 走 `_mergedMap`(合併 Model → leaf);拖曳中邊線用 `_edgesSuspended` 暫停 (勿逐面位移,會重複位移共用邊),網格/合併網格/邊線/邊界同步重算;變換後量測已失效要 `ClearMeasurements()`
- ⚠️ **`Visual3D.Transform` 永遠不要設成 `null`,清除要用 `Transform3D.Identity`**。HelixToolkit `Viewport3DHelper.GetTransform` **op 與 m 必須是同一個變換** — 數學在 `Services/RigidAlign.cs`WPF `Matrix3D` 是「列向量」約定、CADability `ModOp` 是「行向量」約定,
`child.Transform` 沒做 null 檢查(`GeneralTransform3DGroup.Children.Add(null)` → 拋「無法新增空值到集合中」), `ToModOp` 負責轉置轉換,改動務必跑 `SmokeTest --align-test` 驗證兩種表示一致,否則 B-rep 與顯示網格會悄悄分家。
之後任何 `FindHits` 都會 crash。v0.2.1 修的就是拖曳放開時把 BodyVisual.Transform 設 null(拖過一次後再點擊就炸)。 `TransformRoot` 的網格變換是**面級平行**(v0.4.0;來源/輸出 mesh 皆 frozen 所以安全);
Gizmo 的暫時 Transform 清除同理一律用 Identity B-rep `Modify` 與視覺樹賦值必須維持循序(CADability 非執行緒安全 / WPF 執行緒親和)
- Gizmo 操作器:Helix `TranslateManipulator`/`RotateManipulator` ×6 `Bind` 到代理 `ModelVisual3D` - IsBusy 期間匯入/旋轉 90°/干涉檢查指令停用(`CanExecute = nameof(IsIdle)` + isBusy 的
代理 Transform 變更(`DependencyPropertyDescriptor.AddValueChanged`)即時套到目標 BodyVisual(暫時); `NotifyCanExecuteChangedFor`);`ImportFilesAsync` 整批維持 busy 且開頭擋重入
**放開滑鼠才 `TransformRoot` 烘焙** — MouseUp 被 manipulator 標 handledMainWindow 用 `AddHandler(..., handledEventsToo: true)` 才收得到。 (拖放/命令列會繞過 CanExecute)。新增「會改動幾何的指令」時記得比照辦理
烘焙用 `Dispatcher.BeginInvoke` 延後到 manipulator 自身事件處理完,避免 reentrancy;`_gizmoBaking` 旗標防 Transform 歸零的回呼重入 - 拖曳模式(`MeasureMode.Drag`):拖曳中只掛**暫時 `TranslateTransform3D`**GPU 免費),放開才一次性 `TranslateRoot` 烘進 B-rep —
- Gizmo always-on-topv0.3.1):操作器放在獨立透明 `Viewport3D``gizmoOverlay`)疊在主視窗上,**不在主場景所以永不被實體遮擋** **不要改成拖曳中逐幀 TransformRoot**(大檔每幀重建網格會卡死)。2D→3D 用 Helix `UnProject`(過錨點、法向=相機 LookDirection 的平面)
`_overlayCamera` 在主 `Camera.Changed` 時同步主相機(Position/方向/FOV/near-far);raw Viewport3D 空白處不吃滑鼠 → 穿透回主視窗(orbit/量測正常); 合併網格的 hit-test 走 `_mergedMap`(合併 Model → leaf);拖曳中邊線用 `_edgesSuspended` 暫停
`IsHitTestVisible``GizmoEnabled`。Manipulator 是 `UIElement3D`、用 `GetViewport3D()` 抓所在層相機,故在疊圖層用同步相機運作。 - ⚠️ **`Visual3D.Transform` 永遠不要設成 `null`,清除要用 `Transform3D.Identity`**。HelixToolkit `Viewport3DHelper.GetTransform`
放開事件靠 overlay 的 `AddHandler(MouseLeftButtonUp, handledEventsToo:true)`manipulator 會標 handled),`_gizmoBakePending` 防同次重複烘焙 `child.Transform` 沒做 null 檢查(`GeneralTransform3DGroup.Children.Add(null)` → 拋「無法新增空值到集合中」),
- 干涉/面距/對齊等運算在背景執行緒;`Freeze()` 幾何後才跨執行緒 之後任何 `FindHits` 都會 crash。v0.2.1 修的就是拖曳放開時把 BodyVisual.Transform 設 null(拖過一次後再點擊就炸)。
- 匯入在背景執行緒;`Freeze()` 幾何後才跨執行緒 Gizmo 的暫時 Transform 清除同理一律用 Identity
- Commit 格式:Conventional Commits`feat:` / `fix:` / `docs:` …) - Gizmo 操作器:Helix `TranslateManipulator`/`RotateManipulator` ×6 `Bind` 到代理 `ModelVisual3D`
代理 Transform 變更(`DependencyPropertyDescriptor.AddValueChanged`)即時套到目標 BodyVisual(暫時);
## 注意事項 / 已知限制 **放開滑鼠才 `TransformRoot` 烘焙** — MouseUp 被 manipulator 標 handledMainWindow 用 `AddHandler(..., handledEventsToo: true)` 才收得到。
烘焙用 `Dispatcher.BeginInvoke` 延後到 manipulator 自身事件處理完,避免 reentrancy;`_gizmoBaking` 旗標防 Transform 歸零的回呼重入
- `Path` 在 service 會與 `CADability.GeoObject.Path` 撞名 → 用 `IOPath` alias - Gizmo always-on-topv0.3.1):操作器放在獨立透明 `Viewport3D``gizmoOverlay`)疊在主視窗上,**不在主場景所以永不被實體遮擋**。
- CADability 解析大 STEP 慢(39MB/64k 面實測:解析約 276 秒 + 幾何處理),不要改成同步呼叫。 `_overlayCamera` 在主 `Camera.Changed` 時同步主相機(Position/方向/FOV/near-far);raw Viewport3D 空白處不吃滑鼠 → 穿透回主視窗(orbit/量測正常);
解析(`ImportStep.Read`)單執行緒無解;三角化/邊取樣已按 **leaf 平行化**`_leafWork` 收集 → `Parallel.ForEach` `IsHitTestVisible``GizmoEnabled`。Manipulator 是 `UIElement3D`、用 `GetViewport3D()` 抓所在層相機,故在疊圖層用同步相機運作
**平行粒度只能到 leaf**:同 leaf 的面共用 Edge 物件,面級平行會 race。空 leaf 由 `Prune` 收掉(延後三角化可能全失敗)。 放開事件靠 overlay 的 `AddHandler(MouseLeftButtonUp, handledEventsToo:true)`manipulator 會標 handled),`_gizmoBakePending` 防同次重複烘焙
平行下 `GetTriangulation` 偶發失敗(實測 64k 面丟 ~8 面,跨 leaf 仍有共享狀態)→ 失敗面收進 `_retry`,平行結束後**循序重試**補回, - 干涉/面距/對齊等運算在背景執行緒;`Freeze()` 幾何後才跨執行緒
該 leaf 的 `FinishLeaf` 也延到重試後才跑。**不要移除重試機制**,也不要把平行度開到面級 - 匯入在背景執行緒;`Freeze()` 幾何後才跨執行緒
- `StepImportService.Progress` 回報匯入階段(解析/三角化耗時),UI 已接狀態列;訊息來自背景執行緒,要 `Dispatcher.BeginInvoke` - 發新版:升 csproj `<Version>` 之外,記得在 `AboutDialog.xaml.cs``Changelog` 陣列**頂端**加一筆
- `LinesVisual3D` 轉動視角逐幀重建,>30k 線段會卡 → 邊線自動關閉邏輯不要移除 (口吻寫給一般使用者:「你會感覺到什麼」,不是技術 changelog)。
- 邊線採「**一檔一條合併 `LinesVisual3D`**」(掛在 root `EdgeVisual`,由 `RefreshRootEdges` 收集各 leaf `OriginalEdgePoints` 重建)。 ⚠️ WPF **頂層 MenuItem 直接掛 Click 不會觸發**(實測滑鼠/鍵盤都沒反應),要開視窗的入口一律放子選單(如 說明 → 關於)
**不要改回逐 leaf 一條** — 裝配樹零件多時,N 條線每幀重建會嚴重卡頓(實測主因)。leaf 只保留邊線「資料」,渲染統一在 root; - Commit 格式:Conventional Commits`feat:` / `fix:` / `docs:` …)
可見性/ShowEdges/剖面/平移變更時呼叫 `RefreshRootEdges(root)` 重組合併線
- 互動中暫停邊線:`Attach`**`HelixViewport3D.CameraChanged`(控制項層級 routed event)+ `Camera.Changed`(保底)** → `OnCameraMoved` 隱藏邊線、`_interactionTimer`(180ms) 停下後 `ResumeEdges` 顯示; ## 注意事項 / 已知限制
`_edgesSuspended` 為真時 `RefreshRootEdges` 不把線掛回。轉動/縮放/平移時不付邊線重建成本。
**只訂 `Camera.Changed` 不夠**`Attach` 在建構式呼叫,相機實例若被 Helix 換掉訂閱會孤兒化 → 暫停永不觸發;故加訂控制項層級事件保底(重複觸發 `OnCameraMoved` 無害,有 guard - `Path` 在 service 會與 `CADability.GeoObject.Path` 撞名 → 用 `IOPath` alias
- CADability `ImportStep` 對少數 AP242 檔案支援不完整;匯入失敗要 catch 顯示訊息,不可閃退 - CADability 解析大 STEP 慢(39MB/64k 面實測:解析約 276 秒 + 幾何處理),不要改成同步呼叫。
- IGES 無 readerSTL 無 B-repFaceInfo.BrepFace == null 的分支要保留) 解析(`ImportStep.Read`)單執行緒無解;三角化/邊取樣已按 **leaf 平行化**`_leafWork` 收集 → `Parallel.ForEach`)。
- 不寫入原始檔(唯讀工具);WPF 限 Windows,不要嘗試移植 vbox/Linux **平行粒度只能到 leaf**:同 leaf 的面共用 Edge 物件,面級平行會 race。空 leaf 由 `Prune` 收掉(延後三角化可能全失敗)。
- 干涉檢查需剛好 2 個可見檔案(樹面板勾選);共面貼合(無穿透)不算干涉、gap≈0 視為配合(match) 平行下 `GetTriangulation` 偶發失敗(實測 64k 面丟 ~8 面,跨 leaf 仍有共享狀態)→ 失敗面收進 `_retry`,平行結束後**循序重試**補回,
- SmokeTest 工具:`--tree`(裝配樹)、`--clip-test`(剖切數學)、`--interference-test`(干涉相交/分離/貼合)、 該 leaf 的 `FinishLeaf` 也延到重試後才跑。**不要移除重試機制**,也不要把平行度開到面級
`--align-test`(三點對齊剛體變換 + ModOp↔Matrix3D 一致性)、`--make-dxf`(產測試檔)、 - `StepImportService.Progress` 回報匯入階段(解析/三角化耗時),UI 已接狀態列;訊息來自背景執行緒,要 `Dispatcher.BeginInvoke`
`--export-test <in.stp> <out.stp>`(STEP 匯出往返:寫出→回讀比對實體數) - `LinesVisual3D` 轉動視角逐幀重建,>30k 線段會卡 → 邊線自動關閉邏輯不要移除
- **絕不要用 PowerShell regex/Set-Content 改 .cs 檔** — Windows PowerShell 5.1 預設編碼會把 UTF-8 中文弄成亂碼(已踩過,靠反編譯 DLL 救回)。文字取代一律用 Edit 工具 - 邊線採「**一檔一條合併 `LinesVisual3D`**」(掛在 root `EdgeVisual`,由 `RefreshRootEdges` 收集各 leaf `OriginalEdgePoints` 重建)。
**不要改回逐 leaf 一條** — 裝配樹零件多時,N 條線每幀重建會嚴重卡頓(實測主因)。leaf 只保留邊線「資料」,渲染統一在 root;
## For_AI/ 可見性/ShowEdges/剖面/平移變更時呼叫 `RefreshRootEdges(root)` 重組合併線
- 互動中暫停邊線:`Attach`**`HelixViewport3D.CameraChanged`(控制項層級 routed event)+ `Camera.Changed`(保底)** → `OnCameraMoved` 隱藏邊線、`_interactionTimer`(180ms) 停下後 `ResumeEdges` 顯示;
- `For_AI/`:AI 協作素材(截圖、草稿)+ 測試模型 `*.stp`(客戶料號),整夾 gitignored `_edgesSuspended` 為真時 `RefreshRootEdges` 不把線掛回。轉動/縮放/平移時不付邊線重建成本
- 本專案無 runtime secret(離線桌面工具,無 DB/ApiKey),故未保留 `secret/` 資料夾。 **只訂 `Camera.Changed` 不夠**`Attach` 在建構式呼叫,相機實例若被 Helix 換掉訂閱會孤兒化 → 暫停永不觸發;故加訂控制項層級事件保底(重複觸發 `OnCameraMoved` 無害,有 guard
- CADability `ImportStep` 對少數 AP242 檔案支援不完整;匯入失敗要 catch 顯示訊息,不可閃退
-**STL 直接解析(`StlMeshReader`v0.7.1),不經過 CADability `ImportSTL`**。CADability 會為 STL 每個三角形
建一個 B-rep Face(含 Edge/Vertex 拓樸)→ 百萬三角形要數十分鐘且吃光記憶體(實測 100k 三角形:CADability ~41 s
vs 直接解析 ~11 ms75 MB/150 萬三角形整條匯入管線 ~0.77 s)。binary/ASCII 皆自解;binary 判定用
**檔案大小 == 84 + 50×count**header 常也以 "solid" 開頭,不能只看字串)。頂點不焊接(STL 原生就無共用頂點)。
**後果**STL leaf **沒有 `SourceGeos`CADability B-rep** → (1) 不進 STEP 匯出(STL 本就非 B-rep 幾何,符合原設計意圖;
v0.7.0 以前會把 STL shell 當 Shell 混進 STEP,現移除);(2) 剛體變換照常 — `TransformRoot` 的 SourceGeos 迴圈空轉、
網格層照樣變換;(3) 量測走網格近似不受影響。**不要為了「STL 也要進 STEP」而改回 CADability ImportSTL**(會退回龜速)
- IGES 無 readerSTL 無 B-repFaceInfo.BrepFace == null 的分支要保留)
- 不寫入原始檔(唯讀工具);WPF 限 Windows,不要嘗試移植 vbox/Linux
- 干涉檢查需剛好 2 個可見檔案(樹面板勾選);共面貼合(無穿透)不算干涉、gap≈0 視為配合(match)
- SmokeTest 工具:`--tree`(裝配樹)、`--clip-test`(剖切數學)、`--interference-test`(干涉相交/分離/貼合)、
`--align-test`(三點對齊剛體變換 + ModOp↔Matrix3D 一致性)、`--make-dxf`(產測試檔)、
`--export-test <in.stp> <out.stp>`(STEP 匯出往返:寫出→回讀比對實體數)、
`--stl-export-test [file.stp]`STL binary/ASCII 往返 + 退化濾除;給 stp 加測 B-rep 精細重算)、
`--make-stl <out.stl> [triCount]`(產測試用 binary STL,預設 150 萬三角形,量 STL 匯入效能用)
- **絕不要用 PowerShell regex/Set-Content 改 .cs 檔** — Windows PowerShell 5.1 預設編碼會把 UTF-8 中文弄成亂碼(已踩過,靠反編譯 DLL 救回)。文字取代一律用 Edit 工具
## For_AI/
- `For_AI/`:AI 協作素材(截圖、草稿)+ 測試模型 `*.stp`(客戶料號),整夾 gitignored。
- 本專案無 runtime secret(離線桌面工具,無 DB/ApiKey),故未保留 `secret/` 資料夾。
+34
View File
@@ -0,0 +1,34 @@
# v0.7.1 — Large STL files open in a blink
## ✨ New features
**Large STL files open in a blink**
* Opening big STL meshes (tens of MB, millions of triangles) is now near-instant instead of a multi-minute wait. A 75 MB / 1.5-million-triangle STL that used to take ages now shows in **under a second**.
* STL is now parsed directly instead of going through the CAD kernel's importer, which used to build a full B-rep face per triangle (100k triangles: **~41 s → ~11 ms**). Both binary and ASCII STL are detected and read automatically.
**Customizable menu bar & quick toolbar**
* A full menu bar groups every feature into categorized dropdowns, and a quick toolbar lets you tick just the buttons you use most — your selection is remembered between sessions.
**About dialog with a changelog**
* Help → About now shows a per-version, plain-language changelog so you can see what each release brought.
## 🐛 Bug fixes
**STL geometry no longer leaks into STEP export**
* STL is pure mesh (no B-rep), so STL parts are now correctly excluded from STEP export — earlier versions wrongly mixed STL shells into the exported STEP. Rigid transforms and measurement on STL parts are unaffected.
## 📦 Downloads
| Build | Needs .NET Runtime? | Notes |
|---|---|---|
| **STPViewer_v0.7.1** (Standard) | ✅ Requires .NET 8 Desktop Runtime | Smaller download |
| **STPViewer_v0.7.1_portable** (Portable) | ❌ Runtime included | Unzip and run anywhere, no admin |
Run `STPViewer v0.7.1.exe`.
## 🔗 Links
* [Architecture](https://github.com/ETWen/STPViewer/blob/main/ARCHITECTURE.md)
* [README](https://github.com/ETWen/STPViewer/blob/main/README.md)
**Full changelog:** [v0.6.0...v0.7.1](https://github.com/ETWen/STPViewer/compare/v0.6.0...v0.7.1)
+118
View File
@@ -0,0 +1,118 @@
<Window x:Class="STPViewer.AboutDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="關於 STPViewer"
Width="860" Height="600"
WindowStartupLocation="CenterOwner"
ResizeMode="CanResize" MinWidth="700" MinHeight="420"
ShowInTaskbar="False">
<Window.Resources>
<Style x:Key="Card" TargetType="Border">
<Setter Property="Background" Value="#F0F4F8" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="Padding" Value="14,10" />
<Setter Property="Margin" Value="0,0,0,10" />
</Style>
<Style x:Key="CardTitle" TargetType="TextBlock">
<Setter Property="FontWeight" Value="Bold" />
<Setter Property="FontSize" Value="13" />
<Setter Property="Margin" Value="0,0,0,6" />
</Style>
<Style x:Key="DimText" TargetType="TextBlock">
<Setter Property="Foreground" Value="#667788" />
<Setter Property="FontSize" Value="12" />
</Style>
</Window.Resources>
<Grid Margin="14">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="300" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- ─── 左:程式 / 開發者 / 技術棧 ─── -->
<StackPanel Grid.Column="0" Grid.Row="0" Margin="0,0,12,0">
<Border Style="{StaticResource Card}">
<StackPanel HorizontalAlignment="Center">
<TextBlock Text="STPViewer" FontSize="22" FontWeight="Bold"
Foreground="#1565C0" HorizontalAlignment="Center" />
<TextBlock x:Name="VersionText" Text="v0.0.0" Style="{StaticResource DimText}"
HorizontalAlignment="Center" Margin="0,2,0,8" />
<TextBlock TextAlignment="Center" FontSize="12" LineHeight="18">
STP / STEP 3D 檢視器<LineBreak />
免開重量級 CAD,快速開檔、量測、<LineBreak />
對齊插合、干涉檢查、剖面、匯出
</TextBlock>
</StackPanel>
</Border>
<Border Style="{StaticResource Card}">
<StackPanel>
<TextBlock Text="👤 開發者" Style="{StaticResource CardTitle}" />
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="60" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition /><RowDefinition /><RowDefinition />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Name" Style="{StaticResource DimText}" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="ET Wen" FontSize="12" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Email" Style="{StaticResource DimText}" />
<TextBlock Grid.Row="1" Grid.Column="1" Text="eric441151893@gmail.com" FontSize="12" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="GitHub" Style="{StaticResource DimText}" />
<TextBlock Grid.Row="2" Grid.Column="1" Text="github.com/ETWen" FontSize="12" />
</Grid>
</StackPanel>
</Border>
<Border Style="{StaticResource Card}">
<StackPanel>
<TextBlock Text="🔧 技術棧" Style="{StaticResource CardTitle}" />
<TextBlock Style="{StaticResource DimText}" LineHeight="18">
.NET 8 · WPF · C#<LineBreak />
CADabilitySTEP / B-rep 幾何核心)<LineBreak />
HelixToolkit3D 顯示與相機)
</TextBlock>
</StackPanel>
</Border>
</StackPanel>
<!-- ─── 右:Change log ─── -->
<Border Grid.Column="1" Grid.Row="0" Background="#FAFBFC" CornerRadius="6" Padding="4">
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="📋 版本紀錄" FontWeight="Bold" FontSize="14"
Margin="12,8,0,4" />
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="12,0,12,8">
<ItemsControl x:Name="ChangelogList">
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Margin="0,8,0,4">
<TextBlock FontWeight="Bold" Foreground="#1565C0" FontSize="13">
<Run Text="●" /> <Run Text="{Binding Header, Mode=OneWay}" />
</TextBlock>
<ItemsControl ItemsSource="{Binding Changes}" Margin="16,3,0,0">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" TextWrapping="Wrap" FontSize="12"
LineHeight="17" Margin="0,1,0,1" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</Border>
<Button Grid.Column="1" Grid.Row="1" Content="關閉" Width="90" Margin="0,10,0,0"
HorizontalAlignment="Right" IsCancel="True" IsDefault="True" />
</Grid>
</Window>
+97
View File
@@ -0,0 +1,97 @@
using System;
using System.Linq;
using System.Reflection;
using System.Windows;
namespace STPViewer;
/// <summary>
/// 關於視窗:左 = 程式資訊 / 開發者 / 技術棧,右 = 版本紀錄(寫給一般使用者看,非技術 changelog)。
/// 樣式參考 ETTerms 的 AboutView。發新版時在 Changelog 陣列最上面加一筆。
/// </summary>
public partial class AboutDialog : Window
{
public AboutDialog()
{
InitializeComponent();
Version v = Assembly.GetExecutingAssembly().GetName().Version!;
VersionText.Text = $"v{v.Major}.{v.Minor}.{v.Build}";
ChangelogList.ItemsSource = Changelog.Select(e => new
{
Header = $"v{e.Version} · {e.Title} ({e.Date:yyyy-MM-dd})",
e.Changes,
}).ToList();
}
private record ChangelogEntry(string Version, DateOnly Date, string Title, string[] Changes);
// ── 版本紀錄(新版加最上面;口吻:一般使用者看得懂,講「你會感覺到什麼」而非實作細節)──
private static readonly ChangelogEntry[] Changelog =
{
new("0.7.1", new DateOnly(2026, 7, 12), "大型 STL 秒開",
new[]
{
"• 開啟大型 STL 網格檔(數十 MB、上百萬三角形)現在幾乎瞬間完成,不用再等好幾分鐘。",
"• 例如一個 75 MB、150 萬個三角形的 STL,以前要等到天荒地老,現在不到 1 秒就顯示出來。",
}),
new("0.7.0", new DateOnly(2026, 7, 4), "介面大整理 — 選單列+可自訂快速列",
new[]
{
"• 上方新增選單列,全部功能依「檔案/視圖/量測/變換/工具」分類,不用再在一長排按鈕裡找。",
"• 原本的工具列變成「快速列」:只放常用按鈕。想放哪些自己決定 — 到「快速列 → 自訂按鈕」勾選,下次開啟會記住。",
"• 剖面的軸向/位置控制列改成只在開啟剖面時出現,平常不佔空間。",
"• 新增「關於」視窗(就是這個畫面),可以看版本紀錄。",
}),
new("0.6.0", new DateOnly(2026, 7, 4), "匯出 STL",
new[]
{
"• 新增匯出 STL:把畫面上的零件(含對齊、旋轉後的位置)存成 STL 網格檔,可拿去 3D 列印或給其他軟體用。",
"• 匯出前會先跳出設定視窗:全部合併成一個檔或每個檔案各存一個、Binary 或文字格式、mm 或 inch、網格精細度 — 確認後才匯出。",
"• 設定視窗會即時顯示三角形數量與預估檔案大小。",
}),
new("0.5.0", new DateOnly(2026, 7, 3), "量測、視圖、樹狀面板大升級",
new[]
{
"• 量距離時點到圓孔邊緣會自動吸到圓心 — 量兩孔中心距(pitch)超方便。",
"• 量測快速鍵:P 點、D 距離、E 邊、F 面、C 圓、A 角度、M 面距;Esc 取消。",
"• 樹狀面板可搜尋零件名稱;右鍵可「只顯示此節點」隔離觀察,也可以量體積與質心。",
"• 新增標準視圖(等角/前/上/右)與正交投影,量尺寸不受透視變形。",
"• 剖面新增「3點」模式:在模型上點 3 個點,就能沿任意斜面剖開。",
"• 新增匯出 STEP:把對齊好的零件位置存成新 STEP 檔,交接給正式 CAD。",
"• 會記住視窗大小、單位設定與最近開啟的 10 個檔案。",
}),
new("0.4.0", new DateOnly(2026, 7, 2), "更快、更穩",
new[]
{
"• 大檔案拉剖面滑桿不再卡住畫面(改到背景計算)。",
"• 拖曳/操作器放開零件後的定位計算大幅加速。",
"• 程式遇到錯誤會提示訊息並繼續運作,不再整個閃退。",
}),
new("0.3.2", new DateOnly(2026, 6, 18), "大組件量測不再卡",
new[]
{
"• 修正大型組件(數萬個面)進入量測模式後,轉動視角嚴重卡頓的問題 — 現在量測跟瀏覽一樣順。",
}),
new("0.3.1", new DateOnly(2026, 6, 13), "操作器永遠可見",
new[]
{
"• 三軸操作器改成永遠浮在畫面最上層,不會被零件擋住,任何角度都抓得到。",
}),
new("0.3.0", new DateOnly(2026, 6, 13), "三軸操作器",
new[]
{
"• 新增操作器:選取檔案後出現 XYZ 三色箭頭與旋轉環,箭頭沿軸精確移動、旋轉環繞軸轉任意角度。",
}),
new("0.2.0", new DateOnly(2026, 6, 13), "拖曳模式",
new[]
{
"• 新增拖曳模式:左鍵按住零件直接拖到想要的位置,放開就定位。",
"• 0.2.1 修正拖曳過的零件再點擊會當掉的問題。",
}),
new("0.1.0", new DateOnly(2026, 6, 13), "首次釋出",
new[]
{
"• STEPSTL/DXF 匯入與裝配樹、點/距離/邊/面/圓/角度/面距量測、兩點與三點對齊、干涉檢查、剖面、mm ⇄ inch、CSV 與截圖匯出。",
}),
};
}
@@ -0,0 +1,15 @@
using System;
using System.Globalization;
using System.Windows.Data;
namespace STPViewer.Converters;
/// <summary>完整路徑 → 檔名(選單「最近檔案」顯示用;tooltip 仍給完整路徑)</summary>
public class FileNameConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is string s ? System.IO.Path.GetFileName(s) : "";
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
throw new NotSupportedException();
}
+183 -39
View File
@@ -12,109 +12,253 @@
<Window.Resources> <Window.Resources>
<conv:EnumToBoolConverter x:Key="ModeConv" /> <conv:EnumToBoolConverter x:Key="ModeConv" />
<conv:FileNameConverter x:Key="FileNameConv" />
<BooleanToVisibilityConverter x:Key="BoolToVis" /> <BooleanToVisibilityConverter x:Key="BoolToVis" />
<Style x:Key="ModeButton" TargetType="ToggleButton"> <Style x:Key="ModeButton" TargetType="ToggleButton">
<Setter Property="Padding" Value="8,4" /> <Setter Property="Padding" Value="8,4" />
<Setter Property="Margin" Value="2,0" /> <Setter Property="Margin" Value="2,0" />
<Setter Property="FontSize" Value="13" /> <Setter Property="FontSize" Value="13" />
</Style> </Style>
<Style x:Key="QuickButton" TargetType="Button">
<Setter Property="Padding" Value="8,4" />
<Setter Property="Margin" Value="2,0" />
<Setter Property="FontSize" Value="13" />
</Style>
</Window.Resources> </Window.Resources>
<DockPanel> <DockPanel>
<!-- ─── 工具列 ─── --> <!-- ─── 選單列(全部功能,分類下拉)─── -->
<Menu DockPanel.Dock="Top">
<MenuItem Header="檔案(_F)">
<MenuItem Header="📂 匯入…" Command="{Binding ImportCommand}"
ToolTip="匯入 STP/STEP/STL/DXF(可複選,也可直接拖放到視窗)" />
<MenuItem Header="最近檔案" ItemsSource="{Binding RecentFiles}">
<MenuItem.ItemContainerStyle>
<Style TargetType="MenuItem">
<Setter Property="Header" Value="{Binding Converter={StaticResource FileNameConv}}" />
<Setter Property="ToolTip" Value="{Binding}" />
<Setter Property="Command"
Value="{Binding DataContext.ImportRecentCommand, RelativeSource={RelativeSource AncestorType=Menu}}" />
<Setter Property="CommandParameter" Value="{Binding}" />
</Style>
</MenuItem.ItemContainerStyle>
</MenuItem>
<Separator />
<MenuItem Header="💾 匯出量測 CSV…" Command="{Binding ExportCsvCommand}" />
<MenuItem Header="📷 視圖截圖 PNG…" Command="{Binding SaveScreenshotCommand}"
ToolTip="3D 視圖截圖存 PNG2x 解析度)" />
<MenuItem Header="📤 匯出 STEP…" Command="{Binding ExportStepFileCommand}"
ToolTip="把可見檔案(目前對齊位置)匯出成新 STEP 檔;不動原始檔" />
<MenuItem Header="📐 匯出 STL…" Command="{Binding ExportStlFileCommand}"
ToolTip="先設定範圍/格式/單位/精度,確認再匯出" />
</MenuItem>
<MenuItem Header="視圖(_V)">
<MenuItem Header="🔍 縮放至全部" Command="{Binding ZoomAllCommand}" />
<Separator />
<MenuItem Header="等角視圖" Command="{Binding SetStandardViewCommand}" CommandParameter="Iso" />
<MenuItem Header="前視圖" Command="{Binding SetStandardViewCommand}" CommandParameter="Front"
ToolTip="-Y 方向看,Z 向上" />
<MenuItem Header="上視圖" Command="{Binding SetStandardViewCommand}" CommandParameter="Top"
ToolTip="+Z 往下看" />
<MenuItem Header="右視圖" Command="{Binding SetStandardViewCommand}" CommandParameter="Right"
ToolTip="+X 方向看,Z 向上" />
<Separator />
<MenuItem Header="正交投影" IsCheckable="True" IsChecked="{Binding OrthographicView}"
ToolTip="平行投影,量尺寸不受透視變形;取消勾選切回透視" />
<MenuItem Header="單位顯示 inchmm ⇄ in" IsCheckable="True" IsChecked="{Binding UseInch}"
ToolTip="既有量測即時換算;內部一律存 mm" />
</MenuItem>
<MenuItem Header="量測(_M)">
<MenuItem Header="📍 點座標" IsCheckable="True" InputGestureText="P"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Point}"
ToolTip="量測點座標(吸附頂點/圓心)" />
<MenuItem Header="📏 兩點距離" IsCheckable="True" InputGestureText="D"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Distance}"
ToolTip="兩點距離 + ΔXYZ(點到圓邊會吸圓心 → 可量孔對孔 pitch" />
<MenuItem Header="📐 邊長" IsCheckable="True" InputGestureText="E"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Edge}"
ToolTip="邊長(直線/曲線/圓弧)" />
<MenuItem Header="⬛ 面" IsCheckable="True" InputGestureText="F"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Face}"
ToolTip="面積 + 曲面類型/法向量" />
<MenuItem Header="⭕ 圓" IsCheckable="True" InputGestureText="C"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Circle}"
ToolTip="圓心/半徑/直徑/周長" />
<MenuItem Header="∠ 角度" IsCheckable="True" InputGestureText="A"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Angle}"
ToolTip="兩面/兩邊夾角(點兩個面或靠近直線邊)" />
<MenuItem Header="⇔ 面距" IsCheckable="True" InputGestureText="M"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=FaceDistance}"
ToolTip="面到面最短距離(網格近似)" />
<Separator />
<MenuItem Header="🧹 清除全部量測" Command="{Binding ClearMeasurementsCommand}"
InputGestureText="Esc" ToolTip="Esc = 取消進行中量測/退出模式" />
</MenuItem>
<MenuItem Header="變換(_T)">
<MenuItem Header="🎯 兩點對齊(平移)" IsCheckable="True"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Align}"
ToolTip="先點要移動零件上的點,再點目標點" />
<MenuItem Header="🎯 三點對齊(旋轉+平移)" IsCheckable="True"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Align3}"
ToolTip="先在要移動的檔案點 3 個特徵點,再到目標檔案點 3 個對應點(順序對應)" />
<MenuItem Header="🖐 拖曳模式" IsCheckable="True"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Drag}"
ToolTip="左鍵按住零件沿螢幕平面拖動,放開定位" />
<MenuItem Header="⊹ 三軸操作器" IsCheckable="True" IsChecked="{Binding GizmoEnabled}"
ToolTip="對樹面板選取的檔案顯示 XYZ 箭頭與旋轉環,每次放開即定位" />
<Separator />
<MenuItem Header="↻ 繞 X 軸 +90°" Command="{Binding RotateRootCommand}" CommandParameter="X"
ToolTip="對樹面板選取的檔案,繞其中心旋轉(先在樹面板點選檔案)" />
<MenuItem Header="↻ 繞 Y 軸 +90°" Command="{Binding RotateRootCommand}" CommandParameter="Y" />
<MenuItem Header="↻ 繞 Z 軸 +90°" Command="{Binding RotateRootCommand}" CommandParameter="Z" />
</MenuItem>
<MenuItem Header="工具(_L)">
<MenuItem Header="🧩 干涉檢查" Command="{Binding CheckInterferenceCommand}"
ToolTip="2 個以上可見檔案兩兩檢查是否相交;無相交時回報最小間隙" />
<MenuItem Header="✂ 剖面檢視" IsCheckable="True" IsChecked="{Binding SectionEnabled}"
ToolTip="開啟後下方出現剖面參數列(軸向/位置/反向)" />
</MenuItem>
<MenuItem Header="快速列(_Q)">
<MenuItem Header="自訂按鈕" ItemsSource="{Binding QuickBar.Items}"
ToolTip="勾選要顯示在快速列的常用按鈕(隨設定保存)">
<MenuItem.ItemContainerStyle>
<Style TargetType="MenuItem">
<Setter Property="Header" Value="{Binding Label}" />
<Setter Property="IsCheckable" Value="True" />
<Setter Property="IsChecked" Value="{Binding IsChecked}" />
<Setter Property="StaysOpenOnClick" Value="True" />
</Style>
</MenuItem.ItemContainerStyle>
</MenuItem>
<Separator />
<MenuItem Header="恢復預設按鈕" Command="{Binding QuickBar.ResetDefaultsCommand}" />
</MenuItem>
<MenuItem Header="說明(_H)">
<MenuItem Header=" 關於 STPViewer…" Click="About_Click"
ToolTip="程式資訊與版本紀錄" />
</MenuItem>
</Menu>
<!-- ─── 快速工具列(可自訂:選單「快速列 → 自訂按鈕」勾選)─── -->
<ToolBarTray DockPanel.Dock="Top"> <ToolBarTray DockPanel.Dock="Top">
<ToolBar> <ToolBar>
<Button Command="{Binding ImportCommand}" Padding="8,4" FontSize="13" <Button Style="{StaticResource QuickButton}" Command="{Binding ImportCommand}"
Visibility="{Binding QuickBar[Import].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="匯入 STP/STEP/STL/DXF(可複選,也可直接拖放到視窗)">📂 匯入</Button> ToolTip="匯入 STP/STEP/STL/DXF(可複選,也可直接拖放到視窗)">📂 匯入</Button>
<Button Click="Recent_Click" Padding="4,4" FontSize="13" <Button Style="{StaticResource QuickButton}" Click="Recent_Click" Padding="4,4"
Visibility="{Binding QuickBar[Recent].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="最近開啟的檔案">▾</Button> ToolTip="最近開啟的檔案">▾</Button>
<Button Command="{Binding ZoomAllCommand}" Padding="8,4" FontSize="13" <Button Style="{StaticResource QuickButton}" Command="{Binding ZoomAllCommand}"
Visibility="{Binding QuickBar[ZoomAll].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="縮放至全部模型">🔍 全覽</Button> ToolTip="縮放至全部模型">🔍 全覽</Button>
<Separator /> <Button Style="{StaticResource QuickButton}" Command="{Binding SetStandardViewCommand}" CommandParameter="Iso"
<TextBlock Text="視圖:" VerticalAlignment="Center" Margin="4,0,2,0" /> Visibility="{Binding QuickBar[ViewIso].IsChecked, Converter={StaticResource BoolToVis}}"
<Button Command="{Binding SetStandardViewCommand}" CommandParameter="Iso" Padding="6,4" FontSize="13"
ToolTip="等角視圖">等角</Button> ToolTip="等角視圖">等角</Button>
<Button Command="{Binding SetStandardViewCommand}" CommandParameter="Front" Padding="6,4" FontSize="13" <Button Style="{StaticResource QuickButton}" Command="{Binding SetStandardViewCommand}" CommandParameter="Front"
Visibility="{Binding QuickBar[ViewFront].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="前視圖(-Y 方向看,Z 向上)">前</Button> ToolTip="前視圖(-Y 方向看,Z 向上)">前</Button>
<Button Command="{Binding SetStandardViewCommand}" CommandParameter="Top" Padding="6,4" FontSize="13" <Button Style="{StaticResource QuickButton}" Command="{Binding SetStandardViewCommand}" CommandParameter="Top"
Visibility="{Binding QuickBar[ViewTop].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="上視圖(+Z 往下看)">上</Button> ToolTip="上視圖(+Z 往下看)">上</Button>
<Button Command="{Binding SetStandardViewCommand}" CommandParameter="Right" Padding="6,4" FontSize="13" <Button Style="{StaticResource QuickButton}" Command="{Binding SetStandardViewCommand}" CommandParameter="Right"
Visibility="{Binding QuickBar[ViewRight].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="右視圖(+X 方向看,Z 向上)">右</Button> ToolTip="右視圖(+X 方向看,Z 向上)">右</Button>
<ToggleButton IsChecked="{Binding OrthographicView}" Padding="6,4" FontSize="13" Margin="2,0" <ToggleButton Style="{StaticResource ModeButton}" IsChecked="{Binding OrthographicView}"
Visibility="{Binding QuickBar[Ortho].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="正交投影(平行投影,量尺寸不受透視變形;再按切回透視)">正交</ToggleButton> ToolTip="正交投影(平行投影,量尺寸不受透視變形;再按切回透視)">正交</ToggleButton>
<Separator />
<TextBlock Text="量測:" VerticalAlignment="Center" Margin="4,0,2,0" />
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="量測點座標(吸附頂點/圓心)— 快速鍵 P" <ToggleButton Style="{StaticResource ModeButton}" ToolTip="量測點座標(吸附頂點/圓心)— 快速鍵 P"
Visibility="{Binding QuickBar[Point].IsChecked, Converter={StaticResource BoolToVis}}"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Point}">📍 點</ToggleButton> IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Point}">📍 點</ToggleButton>
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="兩點距離 + ΔXYZ(點到圓邊會吸圓心 → 可量孔對孔 pitch)— 快速鍵 D" <ToggleButton Style="{StaticResource ModeButton}" ToolTip="兩點距離 + ΔXYZ(點到圓邊會吸圓心 → 可量孔對孔 pitch)— 快速鍵 D"
Visibility="{Binding QuickBar[Distance].IsChecked, Converter={StaticResource BoolToVis}}"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Distance}">📏 距離</ToggleButton> IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Distance}">📏 距離</ToggleButton>
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="邊長(直線/曲線/圓弧)— 快速鍵 E" <ToggleButton Style="{StaticResource ModeButton}" ToolTip="邊長(直線/曲線/圓弧)— 快速鍵 E"
Visibility="{Binding QuickBar[Edge].IsChecked, Converter={StaticResource BoolToVis}}"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Edge}">📐 邊</ToggleButton> IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Edge}">📐 邊</ToggleButton>
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="面積 + 曲面類型/法向量 — 快速鍵 F" <ToggleButton Style="{StaticResource ModeButton}" ToolTip="面積 + 曲面類型/法向量 — 快速鍵 F"
Visibility="{Binding QuickBar[Face].IsChecked, Converter={StaticResource BoolToVis}}"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Face}">⬛ 面</ToggleButton> IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Face}">⬛ 面</ToggleButton>
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="圓心/半徑/直徑/周長 — 快速鍵 C" <ToggleButton Style="{StaticResource ModeButton}" ToolTip="圓心/半徑/直徑/周長 — 快速鍵 C"
Visibility="{Binding QuickBar[Circle].IsChecked, Converter={StaticResource BoolToVis}}"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Circle}">⭕ 圓</ToggleButton> IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Circle}">⭕ 圓</ToggleButton>
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="兩面/兩邊夾角(點兩個面或靠近直線邊)— 快速鍵 A" <ToggleButton Style="{StaticResource ModeButton}" ToolTip="兩面/兩邊夾角(點兩個面或靠近直線邊)— 快速鍵 A"
Visibility="{Binding QuickBar[Angle].IsChecked, Converter={StaticResource BoolToVis}}"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Angle}">∠ 角度</ToggleButton> IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Angle}">∠ 角度</ToggleButton>
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="面到面最短距離(網格近似)— 快速鍵 M" <ToggleButton Style="{StaticResource ModeButton}" ToolTip="面到面最短距離(網格近似)— 快速鍵 M"
Visibility="{Binding QuickBar[FaceDistance].IsChecked, Converter={StaticResource BoolToVis}}"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=FaceDistance}">⇔ 面距</ToggleButton> IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=FaceDistance}">⇔ 面距</ToggleButton>
<Separator />
<ToggleButton Style="{StaticResource ModeButton}" <ToggleButton Style="{StaticResource ModeButton}"
ToolTip="兩點對齊(平移零件):先點要移動零件上的點,再點目標點" ToolTip="兩點對齊(平移零件):先點要移動零件上的點,再點目標點"
Visibility="{Binding QuickBar[Align].IsChecked, Converter={StaticResource BoolToVis}}"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Align}">🎯 對齊</ToggleButton> IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Align}">🎯 對齊</ToggleButton>
<ToggleButton Style="{StaticResource ModeButton}" <ToggleButton Style="{StaticResource ModeButton}"
ToolTip="三點對齊(旋轉+平移):先在要移動的檔案點 3 個特徵點,再到目標檔案點 3 個對應點(順序對應)" ToolTip="三點對齊(旋轉+平移):先在要移動的檔案點 3 個特徵點,再到目標檔案點 3 個對應點(順序對應)"
Visibility="{Binding QuickBar[Align3].IsChecked, Converter={StaticResource BoolToVis}}"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Align3}">🎯 三點</ToggleButton> IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Align3}">🎯 三點</ToggleButton>
<Separator /> <Button Style="{StaticResource QuickButton}" Command="{Binding RotateRootCommand}" CommandParameter="X"
<TextBlock Text="旋轉:" VerticalAlignment="Center" Margin="4,0,2,0" Visibility="{Binding QuickBar[RotateX].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="對樹面板選取的檔案,繞其中心旋轉 +90°" />
<Button Command="{Binding RotateRootCommand}" CommandParameter="X" Padding="6,4" FontSize="13"
ToolTip="選取的檔案繞 X 軸 +90°(先在樹面板點選檔案)">↻X</Button> ToolTip="選取的檔案繞 X 軸 +90°(先在樹面板點選檔案)">↻X</Button>
<Button Command="{Binding RotateRootCommand}" CommandParameter="Y" Padding="6,4" FontSize="13" <Button Style="{StaticResource QuickButton}" Command="{Binding RotateRootCommand}" CommandParameter="Y"
Visibility="{Binding QuickBar[RotateY].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="選取的檔案繞 Y 軸 +90°">↻Y</Button> ToolTip="選取的檔案繞 Y 軸 +90°">↻Y</Button>
<Button Command="{Binding RotateRootCommand}" CommandParameter="Z" Padding="6,4" FontSize="13" <Button Style="{StaticResource QuickButton}" Command="{Binding RotateRootCommand}" CommandParameter="Z"
Visibility="{Binding QuickBar[RotateZ].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="選取的檔案繞 Z 軸 +90°">↻Z</Button> ToolTip="選取的檔案繞 Z 軸 +90°">↻Z</Button>
<ToggleButton Style="{StaticResource ModeButton}" <ToggleButton Style="{StaticResource ModeButton}"
ToolTip="拖曳模式:左鍵按住零件沿螢幕平面拖動,放開定位(深度方向先轉視角再拖)" ToolTip="拖曳模式:左鍵按住零件沿螢幕平面拖動,放開定位(深度方向先轉視角再拖)"
Visibility="{Binding QuickBar[Drag].IsChecked, Converter={StaticResource BoolToVis}}"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Drag}">🖐 拖曳</ToggleButton> IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Drag}">🖐 拖曳</ToggleButton>
<ToggleButton IsChecked="{Binding GizmoEnabled}" Padding="8,4" FontSize="13" <ToggleButton Style="{StaticResource ModeButton}" IsChecked="{Binding GizmoEnabled}"
Visibility="{Binding QuickBar[Gizmo].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="三軸操作器:對樹面板選取的檔案顯示 XYZ 箭頭(沿軸移動)與旋轉環(繞軸轉任意角度),每次放開即定位">⊹ 操作器</ToggleButton> ToolTip="三軸操作器:對樹面板選取的檔案顯示 XYZ 箭頭(沿軸移動)與旋轉環(繞軸轉任意角度),每次放開即定位">⊹ 操作器</ToggleButton>
<Button Command="{Binding CheckInterferenceCommand}" Padding="8,4" FontSize="13" <Button Style="{StaticResource QuickButton}" Command="{Binding CheckInterferenceCommand}"
Visibility="{Binding QuickBar[Interference].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="干涉檢查:2 個以上可見檔案兩兩檢查是否相交(match 判定);無相交時回報最小間隙">🧩 干涉</Button> ToolTip="干涉檢查:2 個以上可見檔案兩兩檢查是否相交(match 判定);無相交時回報最小間隙">🧩 干涉</Button>
<Separator /> <ToggleButton Style="{StaticResource ModeButton}" IsChecked="{Binding SectionEnabled}"
<Button Command="{Binding ClearMeasurementsCommand}" Padding="8,4" FontSize="13" Visibility="{Binding QuickBar[Section].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="開啟/關閉剖面檢視(開啟後下方出現參數列)">✂ 剖面</ToggleButton>
<Button Style="{StaticResource QuickButton}" Command="{Binding ClearMeasurementsCommand}"
Visibility="{Binding QuickBar[Clear].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="清除全部量測與標記(Esc = 取消進行中量測/退出模式)">🧹 清除</Button> ToolTip="清除全部量測與標記(Esc = 取消進行中量測/退出模式)">🧹 清除</Button>
<Separator /> <ToggleButton Style="{StaticResource ModeButton}" IsChecked="{Binding UseInch}"
<ToggleButton IsChecked="{Binding UseInch}" Padding="8,4" FontSize="13" Visibility="{Binding QuickBar[Unit].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="切換顯示單位 mm ⇄ inch(既有量測即時換算)">mm ⇄ in</ToggleButton> ToolTip="切換顯示單位 mm ⇄ inch(既有量測即時換算)">mm ⇄ in</ToggleButton>
<Separator /> <Button Style="{StaticResource QuickButton}" Command="{Binding ExportCsvCommand}"
<Button Command="{Binding ExportCsvCommand}" Padding="8,4" FontSize="13" Visibility="{Binding QuickBar[ExportCsv].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="量測結果匯出 CSV">💾 CSV</Button> ToolTip="量測結果匯出 CSV">💾 CSV</Button>
<Button Command="{Binding SaveScreenshotCommand}" Padding="8,4" FontSize="13" <Button Style="{StaticResource QuickButton}" Command="{Binding SaveScreenshotCommand}"
Visibility="{Binding QuickBar[Screenshot].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="3D 視圖截圖存 PNG2x 解析度)">📷 截圖</Button> ToolTip="3D 視圖截圖存 PNG2x 解析度)">📷 截圖</Button>
<Button Command="{Binding ExportStepFileCommand}" Padding="8,4" FontSize="13" <Button Style="{StaticResource QuickButton}" Command="{Binding ExportStepFileCommand}"
Visibility="{Binding QuickBar[ExportStep].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="把可見檔案(目前對齊位置)匯出成新 STEP 檔 — 對齊插合結果可交接 CAD;不動原始檔">📤 STEP</Button> ToolTip="把可見檔案(目前對齊位置)匯出成新 STEP 檔 — 對齊插合結果可交接 CAD;不動原始檔">📤 STEP</Button>
<Button Style="{StaticResource QuickButton}" Command="{Binding ExportStlFileCommand}"
Visibility="{Binding QuickBar[ExportStl].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="把可見檔案(目前位置)匯出成 STL 網格檔 — 先設定範圍/格式/單位/精度,確認再匯出">📐 STL</Button>
</ToolBar> </ToolBar>
</ToolBarTray>
<!-- ─── 剖面參數列(只在剖面開啟時出現)─── -->
<ToolBarTray DockPanel.Dock="Top"
Visibility="{Binding SectionEnabled, Converter={StaticResource BoolToVis}}">
<ToolBar> <ToolBar>
<ToggleButton IsChecked="{Binding SectionEnabled}" Padding="8,4" FontSize="13" <TextBlock Text="✂ 剖面:" VerticalAlignment="Center" Margin="4,0,2,0" />
ToolTip="開啟/關閉剖面檢視">✂ 剖面</ToggleButton>
<ComboBox SelectedIndex="{Binding SectionAxisIndex}" Width="60" Margin="4,0" <ComboBox SelectedIndex="{Binding SectionAxisIndex}" Width="60" Margin="4,0"
VerticalAlignment="Center" ToolTip="剖切軸向(3點 = 在模型上點 3 點定義任意平面)" VerticalAlignment="Center" ToolTip="剖切軸向(3點 = 在模型上點 3 點定義任意平面)">
IsEnabled="{Binding SectionEnabled}">
<ComboBoxItem>X</ComboBoxItem> <ComboBoxItem>X</ComboBoxItem>
<ComboBoxItem>Y</ComboBoxItem> <ComboBoxItem>Y</ComboBoxItem>
<ComboBoxItem>Z</ComboBoxItem> <ComboBoxItem>Z</ComboBoxItem>
<ComboBoxItem>3點</ComboBoxItem> <ComboBoxItem>3點</ComboBoxItem>
</ComboBox> </ComboBox>
<Slider Value="{Binding SectionPosition}" Minimum="0" Maximum="100" Width="180" <Slider Value="{Binding SectionPosition}" Minimum="0" Maximum="100" Width="180"
VerticalAlignment="Center" Margin="4,0" ToolTip="剖面位置(沿法向 %" VerticalAlignment="Center" Margin="4,0" ToolTip="剖面位置(沿法向 %" />
IsEnabled="{Binding SectionEnabled}" />
<TextBox Text="{Binding SectionPosition, StringFormat={}{0:F0}}" Width="34" <TextBox Text="{Binding SectionPosition, StringFormat={}{0:F0}}" Width="34"
VerticalAlignment="Center" ToolTip="剖面位置 %(0–100,可直接輸入,離開欄位生效)" VerticalAlignment="Center" ToolTip="剖面位置 %(0–100,可直接輸入,離開欄位生效)" />
IsEnabled="{Binding SectionEnabled}" />
<TextBlock Text="%" VerticalAlignment="Center" Margin="1,0,0,0" /> <TextBlock Text="%" VerticalAlignment="Center" Margin="1,0,0,0" />
<CheckBox IsChecked="{Binding SectionFlip}" Content="反向" VerticalAlignment="Center" <CheckBox IsChecked="{Binding SectionFlip}" Content="反向" VerticalAlignment="Center"
Margin="4,0" ToolTip="切換保留側" IsEnabled="{Binding SectionEnabled}" /> Margin="4,0" ToolTip="切換保留側" />
<Button Style="{StaticResource QuickButton}" Command="{Binding CloseSectionCommand}"
ToolTip="關閉剖面檢視">✕ 關閉</Button>
</ToolBar> </ToolBar>
</ToolBarTray> </ToolBarTray>
+3
View File
@@ -123,6 +123,9 @@ public partial class MainWindow : Window
menu.IsOpen = true; menu.IsOpen = true;
} }
private void About_Click(object sender, RoutedEventArgs e) =>
new AboutDialog { Owner = this }.ShowDialog();
private void Tree_SelectedItemChanged(object sender, RoutedPropertyChangedEventArgs<object> e) => private void Tree_SelectedItemChanged(object sender, RoutedPropertyChangedEventArgs<object> e) =>
_vm.SelectedNode = e.NewValue as ModelNodeViewModel; _vm.SelectedNode = e.NewValue as ModelNodeViewModel;
+18 -18
View File
@@ -1,18 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework> <TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF> <UseWPF>true</UseWPF>
<Version>0.5.0</Version> <!-- 單一版號來源:標題與 publish-winapp 打包資料夾都讀這個 --> <Version>0.7.1</Version> <!-- 單一版號來源:標題與 publish-winapp 打包資料夾都讀這個 -->
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CADability" Version="1.0.33" /> <PackageReference Include="CADability" Version="1.0.33" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" /> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="HelixToolkit.Wpf" Version="3.1.2" /> <PackageReference Include="HelixToolkit.Wpf" Version="3.1.2" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -15,6 +15,9 @@ public class AppSettings
public bool WindowMaximized { get; set; } public bool WindowMaximized { get; set; }
public bool UseInch { get; set; } public bool UseInch { get; set; }
public List<string> RecentFiles { get; set; } = new(); public List<string> RecentFiles { get; set; } = new();
/// <summary>快速工具列顯示的按鈕 key 清單;null = 用預設(QuickBarViewModel.Registry</summary>
public List<string>? QuickBarKeys { get; set; }
} }
/// <summary>設定讀寫:壞檔/缺檔一律回預設值,儲存失敗靜默(設定不值得讓程式閃退)</summary> /// <summary>設定讀寫:壞檔/缺檔一律回預設值,儲存失敗靜默(設定不值得讓程式閃退)</summary>
+22 -26
View File
@@ -123,46 +123,42 @@ public class StepImportService
BuildChildren(root, EnumerateGeo(list)); BuildChildren(root, EnumerateGeo(list));
} }
// ─── STL(純網格,無 B-rep;整個 shell 合併成單一 mesh 避免模型數爆炸)── // ─── STL(純網格,無 B-rep)──
// 直接解析三角形(StlMeshReader),**不經過 CADability**。CADability 的 ImportSTL 會為每個
// 三角形建一個 B-rep Face(含 Edge/Vertex 拓樸),百萬三角形需數十分鐘且吃光記憶體
// (實測 100k 三角形:CADability ~41 s,直接解析 ~11 ms)。STL 本就無 B-rep,讀三角形即可。
// ⚠️ 因此 STL leaf 沒有 SourceGeosCADability B-rep)→ 不進 STEP 匯出(本就非 STEP B-rep 幾何);
// 剛體變換仍走網格層(TransformRoot 的 SourceGeos 迴圈空轉、mesh 照樣變換),量測走網格近似不受影響。
private void ImportStlFile(string path, ImportedNode root) private void ImportStlFile(string path, ImportedNode root)
{ {
Shell[] shells = new ImportSTL().Read(path) Progress?.Invoke("解析 STL(直接讀三角形)…");
?? throw new InvalidDataException("無法解析 STL 檔"); var sw = System.Diagnostics.Stopwatch.StartNew();
int i = 1; List<StlMeshReader.MeshGroup> groups = StlMeshReader.Read(path);
foreach (Shell sh in shells) if (groups.Count == 0)
{ throw new InvalidDataException("STL 檔內沒有三角形");
var positions = new Point3DCollection();
var indices = new Int32Collection();
double precision = PrecisionFor(SafeBounds(() => sh.GetBoundingCube()));
foreach (Face f in sh.Faces)
{
GeoPoint[] pts; int[] ind;
try { f.GetTriangulation(precision, out pts, out _, out ind, out _); }
catch { continue; }
if (pts is null || ind is null) continue;
int offset = positions.Count;
foreach (GeoPoint p in pts) positions.Add(new Point3D(p.x, p.y, p.z));
foreach (int k in ind) indices.Add(offset + k);
}
if (positions.Count == 0) continue;
var mesh = new MeshGeometry3D { Positions = positions, TriangleIndices = indices }; foreach (StlMeshReader.MeshGroup g in groups)
{
if (g.Positions.Count == 0) continue;
g.Positions.Freeze();
g.Indices.Freeze();
var mesh = new MeshGeometry3D { Positions = g.Positions, TriangleIndices = g.Indices };
mesh.Freeze(); mesh.Freeze();
int tris = g.Indices.Count / 3;
var leaf = new ImportedNode var leaf = new ImportedNode
{ {
Name = shells.Length == 1 ? "網格" : $"網格 {i}", Name = g.Name,
HasBrep = false, HasBrep = false,
SolidCount = 1, SolidCount = 1,
FaceCount = sh.Faces.Length, FaceCount = tris, // STL 無 B-rep 面 → 以三角形數代表
TriangleCount = indices.Count / 3, TriangleCount = tris,
Bounds = BoundsOfMesh(mesh), Bounds = BoundsOfMesh(mesh),
}; };
leaf.SourceGeos.Add(sh);
leaf.Faces.Add(new ImportedFace(null, mesh)); leaf.Faces.Add(new ImportedFace(null, mesh));
root.Children.Add(leaf); root.Children.Add(leaf);
i++;
} }
Progress?.Invoke($"STL 解析 {sw.ElapsedMilliseconds:N0} ms{root.Children.Count} 個網格)");
} }
// ─── DXF(多為線架構;Solid/Face 走同一套,曲線取樣成線段)── // ─── DXF(多為線架構;Solid/Face 走同一套,曲線取樣成線段)──
+209
View File
@@ -0,0 +1,209 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using System.Windows.Media;
using System.Windows.Media.Media3D;
using CADability;
using CADability.GeoObject;
namespace STPViewer.Services;
/// <summary>
/// 網格精度:Current = 目前顯示網格;Fine = 由 B-rep 以較細精度重新三角化(無 B-rep 的來源維持現有網格)。
/// 不提供「較粗」:CADability 的 GetTriangulation 對「比快取粗」的精度要求會直接回傳既有較細快取,
/// 匯入時已三角化過 → 要求粗化實際上拿到同一份網格(實測驗證),故誠實不提供。
/// </summary>
public enum StlMeshQuality { Current, Fine }
/// <summary>
/// STL 匯出:WPF 三角網格 → binary / ASCII STL。
/// STL 本身無單位,約定輸出 mmscale=1);inch 交接用 scale = 1/25.4。
/// </summary>
public static class StlExportService
{
/// <summary>
/// 三角化精度係數(相對匯入預設):Fine 較細(三角形多、曲面平滑)。
/// 實測 CADability 三角形數對精度非單調(0.4× 重算反而比 1× 少 — 重算網格較有效率),
/// 要明顯更細需 ≤0.15×(test.stp1× = 30,784 → 0.1× = 42,220 三角形)。
/// </summary>
public static double PrecisionFactor(StlMeshQuality q) =>
q == StlMeshQuality.Fine ? 0.15 : 1.0;
/// <summary>預估檔案大小:binary = 84 + 50×nASCII 每三角形約 230 bytes</summary>
public static long EstimateBytes(long triangles, bool ascii) =>
ascii ? 20 + triangles * 230 : 84 + triangles * 50;
public static string FormatBytes(long bytes) => bytes switch
{
>= 1L << 30 => $"{bytes / (double)(1L << 30):F1} GB",
>= 1L << 20 => $"{bytes / (double)(1L << 20):F1} MB",
>= 1L << 10 => $"{bytes / (double)(1L << 10):F1} KB",
_ => $"{bytes} B",
};
/// <summary>
/// 把多個網格寫成一個 STL 檔。退化(零面積)三角形濾除。
/// 回傳實際寫出的三角形數。
/// </summary>
public static int Write(string path, IReadOnlyList<MeshGeometry3D> meshes, bool ascii, double scale) =>
ascii ? WriteAscii(path, meshes, scale) : WriteBinary(path, meshes, scale);
private static int WriteBinary(string path, IReadOnlyList<MeshGeometry3D> meshes, double scale)
{
using var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, 1 << 16);
using var bw = new BinaryWriter(fs);
// 80-byte header(不可以 "solid" 開頭,部分讀取器會誤判為 ASCII)
var header = new byte[80];
Encoding.ASCII.GetBytes("STPViewer binary STL (unit: mm x scale)").CopyTo(header, 0);
bw.Write(header);
bw.Write(0u); // 三角形數 placeholder,寫完回頭補
int count = 0;
foreach (MeshGeometry3D mesh in meshes)
{
var pos = mesh.Positions;
var idx = mesh.TriangleIndices;
for (int i = 0; i + 2 < idx.Count; i += 3)
{
Point3D a = pos[idx[i]], b = pos[idx[i + 1]], c = pos[idx[i + 2]];
if (!TryNormal(a, b, c, out Vector3D n)) continue; // 退化三角形
bw.Write((float)n.X); bw.Write((float)n.Y); bw.Write((float)n.Z);
WriteVertex(bw, a, scale);
WriteVertex(bw, b, scale);
WriteVertex(bw, c, scale);
bw.Write((ushort)0); // attribute byte count
count++;
}
}
fs.Seek(80, SeekOrigin.Begin);
bw.Write((uint)count);
return count;
}
private static void WriteVertex(BinaryWriter bw, Point3D p, double scale)
{
bw.Write((float)(p.X * scale));
bw.Write((float)(p.Y * scale));
bw.Write((float)(p.Z * scale));
}
private static int WriteAscii(string path, IReadOnlyList<MeshGeometry3D> meshes, double scale)
{
var inv = CultureInfo.InvariantCulture;
using var sw = new StreamWriter(path, append: false, Encoding.ASCII, 1 << 16);
sw.WriteLine("solid STPViewer");
int count = 0;
foreach (MeshGeometry3D mesh in meshes)
{
var pos = mesh.Positions;
var idx = mesh.TriangleIndices;
for (int i = 0; i + 2 < idx.Count; i += 3)
{
Point3D a = pos[idx[i]], b = pos[idx[i + 1]], c = pos[idx[i + 2]];
if (!TryNormal(a, b, c, out Vector3D n)) continue;
sw.WriteLine(string.Format(inv, " facet normal {0:e6} {1:e6} {2:e6}", n.X, n.Y, n.Z));
sw.WriteLine(" outer loop");
WriteAsciiVertex(sw, a, scale, inv);
WriteAsciiVertex(sw, b, scale, inv);
WriteAsciiVertex(sw, c, scale, inv);
sw.WriteLine(" endloop");
sw.WriteLine(" endfacet");
count++;
}
}
sw.WriteLine("endsolid STPViewer");
return count;
}
private static void WriteAsciiVertex(StreamWriter sw, Point3D p, double scale, CultureInfo inv) =>
sw.WriteLine(string.Format(inv, " vertex {0:e6} {1:e6} {2:e6}",
p.X * scale, p.Y * scale, p.Z * scale));
/// <returns>false = 退化三角形(面積 ≈ 0</returns>
private static bool TryNormal(Point3D a, Point3D b, Point3D c, out Vector3D n)
{
n = Vector3D.CrossProduct(b - a, c - a);
double len = n.Length;
if (len < 1e-12) return false;
n /= len;
return true;
}
// ─── B-rep 重新三角化(精細/較粗匯出用)────────────────────────
/// <summary>
/// 由 B-rep 幾何以指定精度係數重新三角化,回傳每面一個 frozen mesh。
/// CADability 非執行緒安全 → 呼叫端維持循序(可在單一背景執行緒)。
/// 個別面三角化失敗時跳過(與匯入一致的容錯策略)。
/// </summary>
public static List<MeshGeometry3D> Tessellate(IEnumerable<IGeoObject> geos, double precisionFactor)
{
var meshes = new List<MeshGeometry3D>();
foreach (IGeoObject g in geos)
{
switch (g)
{
case Solid solid:
foreach (Shell sh in solid.Shells) TessellateShell(sh, precisionFactor, meshes);
break;
case Shell shell:
TessellateShell(shell, precisionFactor, meshes);
break;
case Face face:
AddFaceMesh(face, PrecisionFor(SafeBounds(face), precisionFactor), meshes);
break;
}
}
return meshes;
}
private static void TessellateShell(Shell shell, double factor, List<MeshGeometry3D> meshes)
{
double precision = PrecisionFor(SafeBounds(shell), factor);
foreach (Face f in shell.Faces) AddFaceMesh(f, precision, meshes);
}
/// <summary>
/// 匯入預設精度(與 StepImportService.PrecisionFor 同款 clamp)再乘 factor —
/// 先 clamp 再乘,保證 factor&lt;1 時嚴格比匯入快取細(否則大殼在匯入被 0.5 上限截住,
/// 直接乘在 clamp 前會算出比快取粗的值 → CADability 回傳快取、精細無效)。
/// </summary>
private static double PrecisionFor(BoundingCube bc, double factor)
{
double diag;
try { diag = bc.DiagonalLength; }
catch { diag = 100; }
if (diag <= 0 || double.IsNaN(diag)) diag = 100;
double importDefault = Math.Clamp(diag * 0.0015, 0.02, 0.5);
return Math.Max(importDefault * factor, 0.005);
}
private static BoundingCube SafeBounds(IGeoObject g)
{
try { return g.GetBoundingCube(); }
catch { return new BoundingCube(); }
}
private static void AddFaceMesh(Face face, double precision, List<MeshGeometry3D> meshes)
{
GeoPoint[] pts; int[] ind;
try { face.GetTriangulation(precision, out pts, out _, out ind, out _); }
catch { return; }
if (pts is null || ind is null || ind.Length < 3) return;
var positions = new Point3DCollection(pts.Length);
foreach (GeoPoint p in pts) positions.Add(new Point3D(p.x, p.y, p.z));
var indices = new Int32Collection(ind.Length);
foreach (int i in ind) indices.Add(i);
var mesh = new MeshGeometry3D { Positions = positions, TriangleIndices = indices };
mesh.Freeze();
meshes.Add(mesh);
}
}
+130
View File
@@ -0,0 +1,130 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Windows.Media;
using System.Windows.Media.Media3D;
namespace STPViewer.Services;
/// <summary>
/// 直接解析 STL 三角網格(binary / ASCII)→ WPF mesh**不經過 CADability**。
/// CADability 的 ImportSTL 會為每個三角形建一個 B-rep Face(含 Edge/Vertex 拓樸),
/// 大檔(百萬三角形)需數十分鐘且吃光記憶體;STL 本就無 B-rep,直接讀三角形即可。
/// 實測 100k 三角形:CADability 約 41,000 ms,直接解析約 11 ms。
/// </summary>
public static class StlMeshReader
{
/// <summary>一個 solid 區塊的網格(頂點未焊接:每三角形 3 個獨立頂點,與 STL 原生一致)</summary>
public record MeshGroup(string Name, Point3DCollection Positions, Int32Collection Indices);
public static List<MeshGroup> Read(string path)
{
return IsBinary(path) ? ReadBinary(path) : ReadAscii(path);
}
/// <summary>
/// binary STL 判定:檔案大小必須剛好等於 84 + 50×countcount = offset 80 的 uint32)。
/// 這是最可靠的判別法 — binary 的 80-byte header 常也以 "solid" 開頭,不能只看開頭字串。
/// </summary>
private static bool IsBinary(string path)
{
var fi = new FileInfo(path);
if (fi.Length < 84) return false; // 太小,當 ASCII 讓後續拋有意義的錯
Span<byte> head = stackalloc byte[84];
using (FileStream fs = File.OpenRead(path))
{
int read = 0;
while (read < 84)
{
int n = fs.Read(head[read..]);
if (n <= 0) break;
read += n;
}
if (read < 84) return false;
}
uint count = BitConverter.ToUInt32(head[80..]);
return fi.Length == 84L + 50L * count;
}
private static List<MeshGroup> ReadBinary(string path)
{
byte[] bytes = File.ReadAllBytes(path);
uint count = BitConverter.ToUInt32(bytes, 80);
var positions = new Point3DCollection((int)Math.Min(count * 3, int.MaxValue));
var indices = new Int32Collection((int)Math.Min(count * 3, int.MaxValue));
var span = bytes.AsSpan();
int off = 84, vi = 0;
for (uint t = 0; t < count; t++)
{
if (off + 50 > bytes.Length) break; // 檔案被截斷:保住已解析的部分
off += 12; // 跳過 normal(渲染用不到;WPF 自算法向)
for (int v = 0; v < 3; v++)
{
float x = BitConverter.ToSingle(span[off..]);
float y = BitConverter.ToSingle(span[(off + 4)..]);
float z = BitConverter.ToSingle(span[(off + 8)..]);
positions.Add(new Point3D(x, y, z));
indices.Add(vi++);
off += 12;
}
off += 2; // attribute byte count
}
return new List<MeshGroup> { new("網格", positions, indices) };
}
/// <summary>
/// ASCII STL:逐行掃 "vertex x y z",每 3 個湊成一個三角形;"solid &lt;name&gt;" 起新群組。
/// ASCII 大檔罕見(同樣三角數約為 binary 的 5 倍體積),故以正確性為主。
/// </summary>
private static List<MeshGroup> ReadAscii(string path)
{
var groups = new List<MeshGroup>();
Point3DCollection? pos = null;
Int32Collection? idx = null;
int vi = 0;
void FlushGroup()
{
if (pos is { Count: > 0 } && idx is not null)
groups.Add(new MeshGroup(groups.Count == 0 ? "網格" : $"網格 {groups.Count + 1}", pos, idx));
pos = null; idx = null; vi = 0;
}
char[] sep = { ' ', '\t' };
foreach (string raw in File.ReadLines(path))
{
string line = raw.Trim();
if (line.Length == 0) continue;
if (line.StartsWith("solid", StringComparison.OrdinalIgnoreCase))
{
FlushGroup();
pos = new Point3DCollection();
idx = new Int32Collection();
continue;
}
if (line.StartsWith("endsolid", StringComparison.OrdinalIgnoreCase))
{
FlushGroup();
continue;
}
if (!line.StartsWith("vertex", StringComparison.OrdinalIgnoreCase)) continue;
pos ??= new Point3DCollection();
idx ??= new Int32Collection();
string[] tok = line.Split(sep, StringSplitOptions.RemoveEmptyEntries);
if (tok.Length < 4) continue;
if (double.TryParse(tok[1], NumberStyles.Float, CultureInfo.InvariantCulture, out double x) &&
double.TryParse(tok[2], NumberStyles.Float, CultureInfo.InvariantCulture, out double y) &&
double.TryParse(tok[3], NumberStyles.Float, CultureInfo.InvariantCulture, out double z))
{
pos.Add(new Point3D(x, y, z));
idx.Add(vi++);
}
}
FlushGroup();
return groups;
}
}
+86
View File
@@ -0,0 +1,86 @@
<Window x:Class="STPViewer.StlExportDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:STPViewer.ViewModels"
Title="匯出 STL — 參數設定"
Width="440" SizeToContent="Height"
WindowStartupLocation="CenterOwner"
ResizeMode="NoResize" ShowInTaskbar="False"
d:DataContext="{d:DesignInstance Type=vm:StlExportViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Window.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVis" />
</Window.Resources>
<Grid Margin="16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.Resources>
<Style TargetType="TextBlock">
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Margin" Value="0,0,12,10" />
</Style>
<Style TargetType="ComboBox">
<Setter Property="Margin" Value="0,0,0,10" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
</Grid.Resources>
<TextBlock Grid.Row="0" Grid.Column="0" Text="匯出範圍" />
<ComboBox Grid.Row="0" Grid.Column="1" SelectedIndex="{Binding ScopeIndex}"
ToolTip="STL 無裝配結構;合併 = 所有可見零件進同一檔">
<ComboBoxItem Content="全部可見檔案 → 合併成單一 STL" />
<ComboBoxItem Content="每個可見檔案 → 各一個 STL" />
</ComboBox>
<TextBlock Grid.Row="1" Grid.Column="0" Text="格式" />
<ComboBox Grid.Row="1" Grid.Column="1" SelectedIndex="{Binding FormatIndex}">
<ComboBoxItem Content="Binary(建議,檔案小)" />
<ComboBoxItem Content="ASCII(文字可讀,檔案約 4 倍大)" />
</ComboBox>
<TextBlock Grid.Row="2" Grid.Column="0" Text="單位縮放" />
<ComboBox Grid.Row="2" Grid.Column="1" SelectedIndex="{Binding UnitIndex}"
ToolTip="STL 檔本身無單位;內部幾何為 mm,選 inch 會把座標 ÷25.4">
<ComboBoxItem Content="mm1:1,預設)" />
<ComboBoxItem Content="inch(座標 ÷ 25.4" />
<ComboBoxItem Content="自訂縮放係數…" />
</ComboBox>
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding CustomScaleText, UpdateSourceTrigger=PropertyChanged}"
Margin="0,0,0,10" Padding="4,2"
Visibility="{Binding IsCustomScale, Converter={StaticResource BoolToVis}}"
ToolTip="座標乘上此係數(例:0.001 = mm→m" />
<TextBlock Grid.Row="4" Grid.Column="0" Text="網格精度" />
<ComboBox Grid.Row="4" Grid.Column="1" SelectedIndex="{Binding QualityIndex}"
IsEnabled="{Binding HasBrep}"
ToolTip="精細會由 B-rep 重新三角化(僅 STEP 來源;STL/DXF 維持現有網格),大組件需要時間">
<ComboBoxItem Content="目前顯示網格(快,與畫面一致)" />
<ComboBoxItem Content="精細 — 重新三角化,曲面更平滑(慢、檔案大)" />
</ComboBox>
<Border Grid.Row="5" Grid.ColumnSpan="2" Background="#F0F4F8" CornerRadius="4"
Padding="10,8" Margin="0,2,0,12">
<TextBlock Text="{Binding Summary}" TextWrapping="Wrap" Margin="0" FontSize="12" />
</Border>
<StackPanel Grid.Row="6" Grid.ColumnSpan="2" Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="取消" Width="80" Margin="0,0,8,0" IsCancel="True" />
<Button Content="匯出…" Width="96" IsDefault="True"
IsEnabled="{Binding CanConfirm}" Click="Confirm_Click" />
</StackPanel>
</Grid>
</Window>
+22
View File
@@ -0,0 +1,22 @@
using System.Windows;
using STPViewer.ViewModels;
namespace STPViewer;
/// <summary>STL 匯出參數對話框 — 選項邏輯在 StlExportViewModel,這裡只做確認/取消</summary>
public partial class StlExportDialog : Window
{
public StlExportViewModel ViewModel { get; }
public StlExportDialog(StlExportViewModel vm)
{
InitializeComponent();
ViewModel = vm;
DataContext = vm;
}
private void Confirm_Click(object sender, RoutedEventArgs e)
{
DialogResult = true;
}
}
@@ -1,16 +1,21 @@
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Media; using System.Windows.Media;
using System.Windows.Media.Imaging; using System.Windows.Media.Imaging;
using System.Windows.Media.Media3D;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.Win32; using Microsoft.Win32;
using STPViewer.Models; using STPViewer.Models;
using STPViewer.Services;
namespace STPViewer.ViewModels; namespace STPViewer.ViewModels;
// ─── 匯出(量測 CSV / 視圖 PNG 截圖)──────────────────────────────── // ─── 匯出(量測 CSV / 視圖 PNG 截圖 / STEP / STL────────────────────
public partial class MainViewModel public partial class MainViewModel
{ {
[RelayCommand] [RelayCommand]
@@ -82,6 +87,134 @@ public partial class MainViewModel
} }
} }
/// <summary>
/// 匯出可見檔案(目前位置)為 STL 網格檔 — 先開參數對話框(範圍/格式/單位/精度 + 三角形數與大小預估)
/// 確認後才選路徑寫檔。精細/較粗 = 由 B-rep 重新三角化(背景執行緒);STL/DXF 來源一律用現有網格。
/// </summary>
[RelayCommand(CanExecute = nameof(IsIdle))]
private async Task ExportStlFile()
{
// 可匯出 = 可見 leaf 且有網格(DXF 純線架構無網格 → 排除)
var groups = Roots.Where(r => r.IsVisible)
.Select(r => (Root: r, Leaves: r.Leaves()
.Where(l => l.IsVisible && GetMergedMesh(l) is not null).ToList()))
.Where(g => g.Leaves.Count > 0)
.ToList();
if (groups.Count == 0)
{
StatusText = "沒有可匯出的網格(請勾選至少一個含實體網格的檔案)";
return;
}
long triangles = groups.SelectMany(g => g.Leaves)
.Sum(l => GetMergedMesh(l)!.TriangleIndices.Count / 3L);
bool hasBrep = groups.SelectMany(g => g.Leaves).Any(l => l.HasBrep && l.SourceGeos.Count > 0);
// 參數對話框(確認再匯出)
var vm = new StlExportViewModel(groups.Count, triangles, hasBrep);
var dialog = new StlExportDialog(vm) { Owner = Application.Current.MainWindow };
if (dialog.ShowDialog() != true || vm.Scale is not double scale) return;
var dlg = new SaveFileDialog
{
Title = vm.PerFile ? "匯出 STL(每個檔案各一個,以此為基底檔名)" : "匯出 STL(合併單一檔)",
Filter = "STL 檔案 (*.stl)|*.stl",
FileName = vm.PerFile
? $"stl_{DateTime.Now:yyyyMMdd_HHmmss}.stl"
: $"assembly_{DateTime.Now:yyyyMMdd_HHmmss}.stl",
};
if (dlg.ShowDialog() != true) return;
// UI 執行緒先快照:GeometryModel3D/Model3DGroup 是 DispatcherObject 不可跨執行緒,
// 背景只能碰 frozen MeshGeometry3D 與 CADability B-repSourceGeos
var snapshot = groups
.Select(g => (g.Root.Name, Leaves: g.Leaves
.Select(l => (Mesh: GetMergedMesh(l)!, l.HasBrep, l.SourceGeos))
.ToList()))
.ToList();
IsBusy = true;
StatusText = vm.Quality == StlMeshQuality.Current
? "匯出 STL 中…"
: "重新三角化 + 匯出 STL 中…(大組件需要時間)";
try
{
double factor = StlExportService.PrecisionFactor(vm.Quality);
var written = await Task.Run(() =>
{
var results = new List<(string File, int Triangles)>();
if (vm.PerFile)
{
string dir = Path.GetDirectoryName(dlg.FileName)!;
string baseName = Path.GetFileNameWithoutExtension(dlg.FileName);
foreach ((string rootName, var leaves) in snapshot)
{
string file = Path.Combine(dir, $"{baseName}_{SafeFileName(rootName)}.stl");
int n = StlExportService.Write(file, CollectMeshes(leaves, vm.Quality, factor), vm.Ascii, scale);
results.Add((file, n));
}
}
else
{
var meshes = CollectMeshes(snapshot.SelectMany(g => g.Leaves).ToList(), vm.Quality, factor);
int n = StlExportService.Write(dlg.FileName, meshes, vm.Ascii, scale);
results.Add((dlg.FileName, n));
}
return results;
});
long total = written.Sum(w => (long)w.Triangles);
StatusText = written.Count == 1
? $"已匯出 STL{total:N0} 三角形)→ {Path.GetFileName(written[0].File)}"
: $"已匯出 {written.Count} 個 STL(共 {total:N0} 三角形)→ {Path.GetDirectoryName(dlg.FileName)}";
}
catch (Exception ex)
{
StatusText = $"STL 匯出失敗:{ex.Message}";
}
finally
{
IsBusy = false;
}
}
/// <summary>leaf 的合併網格(frozen、未剖切、目前位置)— STL 匯出的「目前顯示網格」來源</summary>
private static MeshGeometry3D? GetMergedMesh(ModelNodeViewModel leaf) =>
leaf.MergedContent?.Children.Count > 0
? (leaf.MergedContent.Children[0] as GeometryModel3D)?.Geometry as MeshGeometry3D
: null;
/// <summary>
/// 收集要寫進 STL 的網格(背景執行緒;輸入是 UI 快照):
/// 精細且有 B-rep → 重新三角化(循序,CADability 非執行緒安全);
/// 其餘(目前網格 / STL 來源)→ 快照的合併網格(frozen,可安全跨執行緒讀取)。
/// </summary>
private static List<MeshGeometry3D> CollectMeshes(
IReadOnlyList<(MeshGeometry3D Mesh, bool HasBrep, List<CADability.GeoObject.IGeoObject> SourceGeos)> leaves,
StlMeshQuality quality, double precisionFactor)
{
var meshes = new List<MeshGeometry3D>();
foreach ((MeshGeometry3D mesh, bool hasBrep, var sourceGeos) in leaves)
{
if (quality != StlMeshQuality.Current && hasBrep && sourceGeos.Count > 0)
{
List<MeshGeometry3D> tess = StlExportService.Tessellate(sourceGeos, precisionFactor);
if (tess.Count > 0) { meshes.AddRange(tess); continue; }
// 重新三角化全數失敗 → 退回現有網格,不讓零件憑空消失
}
meshes.Add(mesh);
}
return meshes;
}
private static string SafeFileName(string name)
{
string stem = Path.GetFileNameWithoutExtension(name);
if (string.IsNullOrWhiteSpace(stem)) stem = name;
foreach (char c in Path.GetInvalidFileNameChars()) stem = stem.Replace(c, '_');
return stem;
}
[RelayCommand] [RelayCommand]
private void SaveScreenshot() private void SaveScreenshot()
{ {
@@ -5,6 +5,7 @@ using System.Windows.Media;
using System.Windows.Media.Media3D; using System.Windows.Media.Media3D;
using System.Windows.Threading; using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using HelixToolkit.Wpf; using HelixToolkit.Wpf;
using STPViewer.Models; using STPViewer.Models;
using STPViewer.Services; using STPViewer.Services;
@@ -38,6 +39,10 @@ public partial class MainViewModel
[ObservableProperty] [ObservableProperty]
private bool sectionFlip; private bool sectionFlip;
/// <summary>剖面參數列的「✕ 關閉」按鈕(參數列只在剖面開啟時顯示)</summary>
[RelayCommand]
private void CloseSection() => SectionEnabled = false;
// ── 3點自訂剖切平面(SectionAxisIndex == 3v0.5.0)── // ── 3點自訂剖切平面(SectionAxisIndex == 3v0.5.0)──
private const int CustomAxisIndex = 3; private const int CustomAxisIndex = 3;
private Vector3D? _customNormal; // null = 尚未定義(等使用者點 3 點) private Vector3D? _customNormal; // null = 尚未定義(等使用者點 3 點)
@@ -65,6 +65,7 @@ public partial class MainViewModel : ObservableObject
[NotifyCanExecuteChangedFor(nameof(RotateRootCommand))] [NotifyCanExecuteChangedFor(nameof(RotateRootCommand))]
[NotifyCanExecuteChangedFor(nameof(CheckInterferenceCommand))] [NotifyCanExecuteChangedFor(nameof(CheckInterferenceCommand))]
[NotifyCanExecuteChangedFor(nameof(ExportStepFileCommand))] [NotifyCanExecuteChangedFor(nameof(ExportStepFileCommand))]
[NotifyCanExecuteChangedFor(nameof(ExportStlFileCommand))]
private bool isBusy; private bool isBusy;
[ObservableProperty] [ObservableProperty]
@@ -85,6 +86,9 @@ public partial class MainViewModel : ObservableObject
public ObservableCollection<ModelNodeViewModel> Roots { get; } = new(); public ObservableCollection<ModelNodeViewModel> Roots { get; } = new();
public ObservableCollection<MeasurementResult> Measurements { get; } = new(); public ObservableCollection<MeasurementResult> Measurements { get; } = new();
/// <summary>快速工具列自訂(選單列有全部功能,快速列只放勾選的常用按鈕)</summary>
public QuickBarViewModel QuickBar { get; } = new();
// ─── 使用者設定(視窗由 MainWindow 處理;VM 管單位 + MRU)───── // ─── 使用者設定(視窗由 MainWindow 處理;VM 管單位 + MRU)─────
public void LoadSettings(AppSettings s) public void LoadSettings(AppSettings s)
@@ -93,12 +97,14 @@ public partial class MainViewModel : ObservableObject
RecentFiles.Clear(); RecentFiles.Clear();
foreach (string f in s.RecentFiles.Where(File.Exists).Take(SettingsService.MaxRecentFiles)) foreach (string f in s.RecentFiles.Where(File.Exists).Take(SettingsService.MaxRecentFiles))
RecentFiles.Add(f); RecentFiles.Add(f);
QuickBar.Load(s.QuickBarKeys);
} }
public void SaveSettingsInto(AppSettings s) public void SaveSettingsInto(AppSettings s)
{ {
s.UseInch = UseInch; s.UseInch = UseInch;
s.RecentFiles = RecentFiles.ToList(); s.RecentFiles = RecentFiles.ToList();
s.QuickBarKeys = QuickBar.ToKeys();
} }
private void TouchRecent(string path) private void TouchRecent(string path)
@@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace STPViewer.ViewModels;
/// <summary>快速列單一按鈕的顯示開關(選單「快速列 → 自訂按鈕」的 checkbox 項)</summary>
public partial class QuickBarItemViewModel : ObservableObject
{
public string Key { get; }
public string Label { get; }
public bool Default { get; }
[ObservableProperty]
private bool isChecked;
public QuickBarItemViewModel(string key, string label, bool @default)
{
Key = key;
Label = label;
Default = @default;
isChecked = @default;
}
}
/// <summary>
/// 快速工具列自訂:所有功能都在選單列,快速列只放使用者勾選的常用按鈕。
/// XAML 以索引子綁定:Visibility="{Binding QuickBar[Point].IsChecked, Converter=…}"。
/// 勾選狀態隨 settings.jsonQuickBarKeys)保存;null = 用預設。
/// </summary>
public partial class QuickBarViewModel : ObservableObject
{
/// <summary>註冊表 = 快速列順序 + 選單顯示名 + 是否預設顯示(一般 user 常用)</summary>
private static readonly (string Key, string Label, bool Default)[] Registry =
{
("Import", "📂 匯入", true),
("Recent", "▾ 最近檔案", true),
("ZoomAll", "🔍 全覽", true),
("ViewIso", "視圖:等角", true),
("ViewFront", "視圖:前", false),
("ViewTop", "視圖:上", false),
("ViewRight", "視圖:右", false),
("Ortho", "正交投影", true),
("Point", "📍 點", true),
("Distance", "📏 距離", true),
("Edge", "📐 邊", false),
("Face", "⬛ 面", false),
("Circle", "⭕ 圓", true),
("Angle", "∠ 角度", true),
("FaceDistance", "⇔ 面距", false),
("Align", "🎯 兩點對齊", false),
("Align3", "🎯 三點對齊", false),
("RotateX", "旋轉 ↻X", false),
("RotateY", "旋轉 ↻Y", false),
("RotateZ", "旋轉 ↻Z", false),
("Drag", "🖐 拖曳", false),
("Gizmo", "⊹ 操作器", false),
("Interference", "🧩 干涉", true),
("Section", "✂ 剖面", true),
("Clear", "🧹 清除量測", true),
("Unit", "mm ⇄ in", true),
("ExportCsv", "💾 匯出 CSV", false),
("Screenshot", "📷 截圖", false),
("ExportStep", "📤 匯出 STEP", false),
("ExportStl", "📐 匯出 STL", false),
};
private readonly Dictionary<string, QuickBarItemViewModel> _byKey;
/// <summary>依快速列順序的全部項目(選單自訂清單用)</summary>
public IReadOnlyList<QuickBarItemViewModel> Items { get; }
public QuickBarViewModel()
{
Items = Registry.Select(r => new QuickBarItemViewModel(r.Key, r.Label, r.Default)).ToList();
_byKey = Items.ToDictionary(i => i.Key);
}
/// <summary>XAML 索引子綁定入口:QuickBar[Point].IsChecked</summary>
public QuickBarItemViewModel this[string key] => _byKey[key];
/// <summary>套用保存的勾選清單;null = 維持預設(含未知 key 容錯:新版本新增按鈕用預設值)</summary>
public void Load(List<string>? visibleKeys)
{
if (visibleKeys is null) return;
var set = new HashSet<string>(visibleKeys, StringComparer.Ordinal);
foreach (QuickBarItemViewModel item in Items)
item.IsChecked = set.Contains(item.Key);
}
/// <summary>目前勾選清單(存 settings.json</summary>
public List<string> ToKeys() =>
Items.Where(i => i.IsChecked).Select(i => i.Key).ToList();
[RelayCommand]
private void ResetDefaults()
{
foreach (QuickBarItemViewModel item in Items)
item.IsChecked = item.Default;
}
}
@@ -0,0 +1,85 @@
using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using STPViewer.Services;
namespace STPViewer.ViewModels;
/// <summary>
/// STL 匯出參數對話框的 ViewModel:範圍 / 格式 / 單位縮放 / 網格精度 + 即時摘要(三角形數、預估大小)。
/// 對話框按「匯出」後由 MainViewModel.Export 讀取選項執行。
/// </summary>
public partial class StlExportViewModel : ObservableObject
{
/// <summary>可見(可匯出)檔案數</summary>
public int FileCount { get; }
/// <summary>目前顯示網格的三角形總數(精細/較粗會重新三角化,實際數量以結果為準)</summary>
public long TriangleCount { get; }
/// <summary>是否有 B-rep 來源(全 STL/DXF 時精度選項無意義 → 停用)</summary>
public bool HasBrep { get; }
public StlExportViewModel(int fileCount, long triangleCount, bool hasBrep)
{
FileCount = fileCount;
TriangleCount = triangleCount;
HasBrep = hasBrep;
}
/// <summary>0 = 全部可見合併成單一 STL;1 = 每個可見檔案各一個 STL</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Summary))]
private int scopeIndex;
/// <summary>0 = Binary(建議,檔案小);1 = ASCII(文字可讀,檔案大)</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Summary))]
private int formatIndex;
/// <summary>0 = mm1:1);1 = inch(÷25.4);2 = 自訂縮放</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Summary))]
[NotifyPropertyChangedFor(nameof(IsCustomScale))]
[NotifyPropertyChangedFor(nameof(CanConfirm))]
private int unitIndex;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Summary))]
[NotifyPropertyChangedFor(nameof(CanConfirm))]
private string customScaleText = "1.0";
/// <summary>0 = 目前顯示網格;1 = 精細(B-rep 重新三角化)</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Summary))]
private int qualityIndex;
public bool IsCustomScale => UnitIndex == 2;
public bool Ascii => FormatIndex == 1;
public bool PerFile => ScopeIndex == 1;
public StlMeshQuality Quality => (StlMeshQuality)QualityIndex;
/// <summary>座標縮放;自訂值無效時回 null(擋確認)</summary>
public double? Scale => UnitIndex switch
{
0 => 1.0,
1 => 1.0 / 25.4,
_ => double.TryParse(CustomScaleText, NumberStyles.Float, CultureInfo.InvariantCulture, out double s)
&& s > 0 && double.IsFinite(s) ? s : null,
};
public bool CanConfirm => Scale is not null;
public string Summary
{
get
{
long est = StlExportService.EstimateBytes(TriangleCount, Ascii);
string size = StlExportService.FormatBytes(est);
string basis = QualityIndex == 0
? $"預估 {size}"
: $"以目前網格預估 {size}(實際依重新三角化結果)";
return $"{FileCount} 個可見檔案 · {TriangleCount:N0} 三角形 · {basis}";
}
}
}
+41
View File
@@ -0,0 +1,41 @@
using System.IO;
namespace SmokeTest;
/// <summary>產生測試用 binary STL:一片 gridN×gridN 的網格,每格 2 個三角形(波浪起伏,避免全共面)。</summary>
internal static class MakeStl
{
public static void Run(string path, int triCount)
{
// 每格 2 三角,格數 = triCount/2;邊長 grid = ceil(sqrt(cells))
int cells = System.Math.Max(1, triCount / 2);
int grid = (int)System.Math.Ceiling(System.Math.Sqrt(cells));
int tris = grid * grid * 2;
using var fs = File.Create(path);
using var bw = new BinaryWriter(fs);
bw.Write(new byte[80]); // header
bw.Write((uint)tris); // triangle count
static void WriteTri(BinaryWriter w, float ax, float ay, float az,
float bx, float by, float bz, float cx, float cy, float cz)
{
for (int i = 0; i < 3; i++) w.Write(0f); // normal (0,0,0)
w.Write(ax); w.Write(ay); w.Write(az);
w.Write(bx); w.Write(by); w.Write(bz);
w.Write(cx); w.Write(cy); w.Write(cz);
w.Write((ushort)0); // attribute byte count
}
float Z(int x, int y) => (float)(System.Math.Sin(x * 0.1) * System.Math.Cos(y * 0.1) * 5.0);
for (int y = 0; y < grid; y++)
for (int x = 0; x < grid; x++)
{
float x0 = x, x1 = x + 1, y0 = y, y1 = y + 1;
WriteTri(bw, x0, y0, Z(x, y), x1, y0, Z(x + 1, y), x1, y1, Z(x + 1, y + 1));
WriteTri(bw, x0, y0, Z(x, y), x1, y1, Z(x + 1, y + 1), x0, y1, Z(x, y + 1));
}
Console.WriteLine($"寫出 {tris:N0} 三角形 → {path} ({new FileInfo(path).Length:N0} bytes)");
}
}
+86 -77
View File
@@ -1,77 +1,86 @@
// 無 UI smoke test:驗證 CAD 檔解析 → 三角化 → 裝配樹 整條匯入管線 // 無 UI smoke test:驗證 CAD 檔解析 → 三角化 → 裝配樹 整條匯入管線
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using STPViewer.Services; using STPViewer.Services;
Console.OutputEncoding = System.Text.Encoding.UTF8; Console.OutputEncoding = System.Text.Encoding.UTF8;
if (args.Length == 0) if (args.Length == 0)
{ {
Console.WriteLine("用法: SmokeTest [--tree] <file.stp|.stl|.dxf> [...]"); Console.WriteLine("用法: SmokeTest [--tree] <file.stp|.stl|.dxf> [...]");
return 1; return 1;
} }
if (args[0] == "--tree") if (args[0] == "--tree")
{ {
foreach (string p in args.Skip(1)) SmokeTest.TreeDump.Run(p); foreach (string p in args.Skip(1)) SmokeTest.TreeDump.Run(p);
return 0; return 0;
} }
if (args[0] == "--make-dxf") if (args[0] == "--make-dxf")
{ {
SmokeTest.MakeDxf.Run(args[1]); SmokeTest.MakeDxf.Run(args[1]);
return 0; return 0;
} }
if (args[0] == "--clip-test") if (args[0] == "--make-stl")
return SmokeTest.ClipTest.Run() == 0 ? 0 : 2; {
SmokeTest.MakeStl.Run(args[1], args.Length > 2 ? int.Parse(args[2]) : 1_500_000);
if (args[0] == "--interference-test") return 0;
return SmokeTest.InterferenceTest.Run() == 0 ? 0 : 2; }
if (args[0] == "--align-test") if (args[0] == "--clip-test")
return SmokeTest.AlignTest.Run() == 0 ? 0 : 2; return SmokeTest.ClipTest.Run() == 0 ? 0 : 2;
if (args[0] == "--bbox") if (args[0] == "--interference-test")
return SmokeTest.BBoxDump.Run(args[1]); return SmokeTest.InterferenceTest.Run() == 0 ? 0 : 2;
if (args[0] == "--export-test") if (args[0] == "--align-test")
return SmokeTest.ExportTest.Run(args[1], args[2]) == 0 ? 0 : 2; return SmokeTest.AlignTest.Run() == 0 ? 0 : 2;
var service = new StepImportService { Progress = s => Console.WriteLine($" [階段] {s}") }; if (args[0] == "--bbox")
int failed = 0; return SmokeTest.BBoxDump.Run(args[1]);
foreach (string path in args) if (args[0] == "--export-test")
{ return SmokeTest.ExportTest.Run(args[1], args[2]) == 0 ? 0 : 2;
Console.WriteLine($"=== {Path.GetFileName(path)} ===");
var sw = Stopwatch.StartNew(); if (args[0] == "--stl-export-test")
try return SmokeTest.StlExportTest.Run(args.Length > 1 ? args[1] : null) == 0 ? 0 : 2;
{
ImportedFileData data = service.Import(path); var service = new StepImportService { Progress = s => Console.WriteLine($" [階段] {s}") };
sw.Stop(); int failed = 0;
ImportedNode r = data.Root;
Console.WriteLine($" Solids : {r.SolidCount}"); foreach (string path in args)
Console.WriteLine($" Faces : {r.FaceCount}"); {
Console.WriteLine($" Triangles : {r.TriangleCount:N0}"); Console.WriteLine($"=== {Path.GetFileName(path)} ===");
Console.WriteLine($" Bounds : {r.Bounds.SizeX:F2} x {r.Bounds.SizeY:F2} x {r.Bounds.SizeZ:F2} mm"); var sw = Stopwatch.StartNew();
Console.WriteLine($" Time : {sw.ElapsedMilliseconds} ms"); try
Console.WriteLine(" --- 裝配樹 ---"); {
PrintNode(r, 1); ImportedFileData data = service.Import(path);
} sw.Stop();
catch (Exception ex) ImportedNode r = data.Root;
{ Console.WriteLine($" Solids : {r.SolidCount}");
sw.Stop(); Console.WriteLine($" Faces : {r.FaceCount}");
failed++; Console.WriteLine($" Triangles : {r.TriangleCount:N0}");
Console.WriteLine($" FAILED ({sw.ElapsedMilliseconds} ms): {ex.GetType().Name}: {ex.Message}"); Console.WriteLine($" Bounds : {r.Bounds.SizeX:F2} x {r.Bounds.SizeY:F2} x {r.Bounds.SizeZ:F2} mm");
} Console.WriteLine($" Time : {sw.ElapsedMilliseconds} ms");
} Console.WriteLine(" --- 裝配樹 ---");
PrintNode(r, 1);
return failed == 0 ? 0 : 2; }
catch (Exception ex)
static void PrintNode(ImportedNode n, int indent) {
{ sw.Stop();
string seg = n.EdgeSegments is null ? "" : $", edgeSegs={n.EdgeSegments.Count / 2:N0}"; failed++;
Console.WriteLine($"{new string(' ', indent * 2)}{n.Name} " + Console.WriteLine($" FAILED ({sw.ElapsedMilliseconds} ms): {ex.GetType().Name}: {ex.Message}");
$"[solids={n.SolidCount}, faces={n.FaceCount}, tris={n.TriangleCount:N0}{seg}{(n.HasBrep ? "" : ", B-rep")}]"); }
foreach (ImportedNode c in n.Children) PrintNode(c, indent + 1); }
}
return failed == 0 ? 0 : 2;
static void PrintNode(ImportedNode n, int indent)
{
string seg = n.EdgeSegments is null ? "" : $", edgeSegs={n.EdgeSegments.Count / 2:N0}";
Console.WriteLine($"{new string(' ', indent * 2)}{n.Name} " +
$"[solids={n.SolidCount}, faces={n.FaceCount}, tris={n.TriangleCount:N0}{seg}{(n.HasBrep ? "" : ", B-rep")}]");
foreach (ImportedNode c in n.Children) PrintNode(c, indent + 1);
}
+158
View File
@@ -0,0 +1,158 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Windows.Media;
using System.Windows.Media.Media3D;
using STPViewer.Services;
using IOPath = System.IO.Path; // CADability.GeoObject.Path(曲線)撞名
namespace SmokeTest;
/// <summary>
/// STL 匯出驗證(無 UI):
/// 1) binary — 已知立方體網格寫出 → 解析 header/三角形數/座標範圍比對(含縮放)
/// 2) ASCII — facet 數與 solid/endsolid 結構
/// 3) 退化三角形(零面積)自動濾除
/// </summary>
public static class StlExportTest
{
public static int Run(string? stepPath = null)
{
Console.WriteLine("=== StlExportTest ===");
int failed = 0;
string dir = IOPath.Combine(IOPath.GetTempPath(), "stpviewer_stl_test");
Directory.CreateDirectory(dir);
MeshGeometry3D cube = BuildCube(10); // 10×10×10 mm、12 三角形
// 1) binary:三角形數 + 縮放後座標範圍
{
string f = IOPath.Combine(dir, "cube_bin.stl");
double scale = 1.0 / 25.4; // mm → inch
int written = StlExportService.Write(f, new[] { cube }, ascii: false, scale);
(uint count, float min, float max) = ReadBinary(f);
bool ok = written == 12 && count == 12
&& Math.Abs(min - 0f) < 1e-5 && Math.Abs(max - 10f / 25.4f) < 1e-4;
Report(ref failed, ok, $"binary 寫出/回讀 12 三角形、inch 縮放範圍 [{min:F4}, {max:F4}]");
}
// 2) ASCIIfacet 數 + 結構
{
string f = IOPath.Combine(dir, "cube_ascii.stl");
int written = StlExportService.Write(f, new[] { cube }, ascii: true, scale: 1.0);
string text = File.ReadAllText(f);
int facets = CountOccurrences(text, "facet normal");
bool ok = written == 12 && facets == 12
&& text.StartsWith("solid ", StringComparison.Ordinal)
&& text.Contains("endsolid");
Report(ref failed, ok, $"ASCII 結構完整、facet = {facets}");
}
// 3) 退化三角形濾除:加一個零面積三角形 → 仍是 12
{
var degenerate = new MeshGeometry3D();
degenerate.Positions.Add(new Point3D(0, 0, 0));
degenerate.Positions.Add(new Point3D(1, 1, 1));
degenerate.Positions.Add(new Point3D(2, 2, 2)); // 共線 → 面積 0
degenerate.TriangleIndices.Add(0);
degenerate.TriangleIndices.Add(1);
degenerate.TriangleIndices.Add(2);
degenerate.Freeze();
string f = IOPath.Combine(dir, "cube_degen.stl");
int written = StlExportService.Write(f, new[] { cube, degenerate }, ascii: false, scale: 1.0);
(uint count, _, _) = ReadBinary(f);
bool ok = written == 12 && count == 12;
Report(ref failed, ok, $"退化三角形濾除(寫出 {written}");
}
// 4)(可選)真實 STEP:B-rep 精細重新三角化 → 寫 binary STL 驗證
//(不測「較粗」:CADability GetTriangulation 對比快取粗的精度回傳既有快取,粗化無效 → 功能已移除)
if (stepPath is not null)
{
var data = new StepImportService().Import(stepPath);
var geos = Collect(data.Root).ToList();
int importTris = data.Root.TriangleCount;
Console.WriteLine($" 來源 {IOPath.GetFileName(stepPath)}{geos.Count} 個 B-rep 物件、匯入網格 {importTris:N0} 三角形");
var meshes = StlExportService.Tessellate(
geos, StlExportService.PrecisionFactor(StlMeshQuality.Fine));
string f = IOPath.Combine(dir, "step_fine.stl");
int written = StlExportService.Write(f, meshes, ascii: false, scale: 1.0);
(uint count, _, _) = ReadBinary(f);
Report(ref failed, meshes.Count > 0 && written > 0 && count == written,
$"精細重新三角化 {meshes.Count} 面 → {written:N0} 三角形");
// CADability 三角形數對精度非單調(0.4× 反而比 1× 少),Fine=0.15× 實測明顯更細
Report(ref failed, written > importTris,
$"精細({written:N0}> 匯入預設({importTris:N0}");
}
Console.WriteLine(failed == 0 ? "StlExportTest: PASS" : $"StlExportTest: FAIL{failed} 項)");
return failed;
}
private static IEnumerable<CADability.GeoObject.IGeoObject> Collect(ImportedNode n)
{
foreach (CADability.GeoObject.IGeoObject g in n.SourceGeos) yield return g;
foreach (ImportedNode c in n.Children)
foreach (CADability.GeoObject.IGeoObject g in Collect(c))
yield return g;
}
private static void Report(ref int failed, bool ok, string what)
{
Console.WriteLine($" [{(ok ? "PASS" : "FAIL")}] {what}");
if (!ok) failed++;
}
/// <summary>0..size 的軸對齊立方體,12 個三角形(法向不講究,STL 寫出時自算)</summary>
private static MeshGeometry3D BuildCube(double s)
{
var m = new MeshGeometry3D();
Point3D[] v =
{
new(0, 0, 0), new(s, 0, 0), new(s, s, 0), new(0, s, 0),
new(0, 0, s), new(s, 0, s), new(s, s, s), new(0, s, s),
};
foreach (Point3D p in v) m.Positions.Add(p);
int[] quads = { 0,3,2,1, 4,5,6,7, 0,1,5,4, 1,2,6,5, 2,3,7,6, 3,0,4,7 };
for (int q = 0; q < quads.Length; q += 4)
{
m.TriangleIndices.Add(quads[q]); m.TriangleIndices.Add(quads[q + 1]); m.TriangleIndices.Add(quads[q + 2]);
m.TriangleIndices.Add(quads[q]); m.TriangleIndices.Add(quads[q + 2]); m.TriangleIndices.Add(quads[q + 3]);
}
m.Freeze();
return m;
}
private static (uint Count, float Min, float Max) ReadBinary(string path)
{
using var br = new BinaryReader(File.OpenRead(path));
byte[] header = br.ReadBytes(80);
if (Encoding.ASCII.GetString(header, 0, 5) == "solid")
throw new InvalidDataException("binary STL header 不可以 solid 開頭");
uint count = br.ReadUInt32();
float min = float.MaxValue, max = float.MinValue;
for (uint t = 0; t < count; t++)
{
br.ReadBytes(12); // normal
for (int k = 0; k < 9; k++)
{
float f = br.ReadSingle();
min = Math.Min(min, f);
max = Math.Max(max, f);
}
br.ReadUInt16(); // attribute
}
return (count, min, max);
}
private static int CountOccurrences(string text, string token)
{
int n = 0;
for (int i = text.IndexOf(token, StringComparison.Ordinal); i >= 0;
i = text.IndexOf(token, i + token.Length, StringComparison.Ordinal)) n++;
return n;
}
}