3 Commits
Author SHA1 Message Date
etwenandClaude Opus 5.5 2caa95a69d chore(release): v0.8.0 — language switching (EN / 繁中 / 简中)
Bump version to 0.8.0, add the About-dialog changelog entry in all
three language packs, and add release notes crediting @CarmJos (#1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:51:33 +08:00
etwenandClaude Opus 5.5 fb46da29cf docs: document i18n conventions and Phase 17 (v0.8.0)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:50:03 +08:00
etwenandClaude Opus 5.5 c65fc4e67f docs: restore Chinese design-rationale comments after i18n merge
PR #1 condensed the existing Chinese comments into shorter English,
dropping the 'why / don't revert to X' notes the project relies on
(merged-mesh face lookup, Identity-not-null transforms, background
section clip, ...). Restore the original comments verbatim where the
code is unchanged; new i18n code keeps its English comments. Two
FacesContent/MergedContent notes are corrected to the current render
modes. Comment-only change (verified: comment-stripped code identical).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:48:56 +08:00
46 changed files with 579 additions and 417 deletions
+2 -2
View File
@@ -6,9 +6,9 @@ Publish/ # publish-winapp output on case-sensitive systems
*.user *.user
.vs/ .vs/
# Local development assets # AI 協作素材不入 git
For_AI/ For_AI/
# CAD samples are stored locally in For_AI/ # 客戶 CAD 料號檔不入 git(避免外流);測試模型放 For_AI/
*.stp *.stp
*.step *.step
+31 -1
View File
@@ -100,8 +100,12 @@ STPViewer/
├── STPViewer.csproj # net8.0-windows, UseWPF ├── STPViewer.csproj # net8.0-windows, UseWPF
├── App.xaml / App.xaml.cs # 全域例外處理(log + 訊息框存活;%LOCALAPPDATA%\STPViewer\error.log) ├── App.xaml / App.xaml.cs # 全域例外處理(log + 訊息框存活;%LOCALAPPDATA%\STPViewer\error.log)
├── MainWindow.xaml / .cs # 版面 + 滑鼠拾取事件 ├── MainWindow.xaml / .cs # 版面 + 滑鼠拾取事件
├── i18n/ # 語言包 en / zh_tw / zh_cn.json(輸出到 exe 旁;啟動時掃描,免重編)
├── Localization/
│ └── LocExtension.cs # XAML {loc:Loc Key} → 綁定 LocalizationService[Key](切語言即時更新)
│ │
├── Models/ ├── Models/
│ ├── LanguageDefinition.cs # 一個語言包(Id = 檔名、Key = 顯示名、Data = 翻譯表)
│ ├── FaceInfo.cs # GeometryModel3D ↔ B-rep Face 對照(STL 為 null) │ ├── FaceInfo.cs # GeometryModel3D ↔ B-rep Face 對照(STL 為 null)
│ ├── MeasureMode.cs # enum: None/Point/Distance/Edge/Face/Circle/Angle/FaceDistance/Align/Align3/Drag/Interference/Volume │ ├── MeasureMode.cs # enum: None/Point/Distance/Edge/Face/Circle/Angle/FaceDistance/Align/Align3/Drag/Interference/Volume
│ ├── MeasurementResult.cs # 量測結果(雙單位 lambda、3D 標籤同步) │ ├── MeasurementResult.cs # 量測結果(雙單位 lambda、3D 標籤同步)
@@ -112,6 +116,8 @@ STPViewer/
│ │
├── Services/ ├── Services/
│ ├── StepImportService.cs # STEP/STL/DXF 讀檔 + 三角化 + 裝配樹 │ ├── StepImportService.cs # STEP/STL/DXF 讀檔 + 三角化 + 裝配樹
│ ├── StlMeshReader.cs # STL 直接解析(binary/ASCII,不經 CADability;v0.7.1)
│ ├── LocalizationService.cs # 語言包載入、目前語言、[key] 查詢(缺字回退英文)、LanguageChanged
│ ├── MeasurementService.cs # 點/線/面/圓/角度/面距/體積質心 幾何計算(吸附含圓心) │ ├── MeasurementService.cs # 點/線/面/圓/角度/面距/體積質心 幾何計算(吸附含圓心)
│ ├── InterferenceService.cs # 干涉檢查:三角形-三角形相交(區間法)+均勻網格加速;無干涉時近似最小間隙 │ ├── InterferenceService.cs # 干涉檢查:三角形-三角形相交(區間法)+均勻網格加速;無干涉時近似最小間隙
│ ├── RigidAlign.cs # 三點對齊/旋轉的剛體變換數學(Matrix3D列向量 ↔ ModOp行向量 轉換) │ ├── RigidAlign.cs # 三點對齊/旋轉的剛體變換數學(Matrix3D列向量 ↔ ModOp行向量 轉換)
@@ -126,6 +132,8 @@ STPViewer/
├── MainViewModel.Section.cs # 剖面(v0.4.0 起背景平行裁切) ├── MainViewModel.Section.cs # 剖面(v0.4.0 起背景平行裁切)
├── MainViewModel.Interference.cs # 干涉檢查指令 ├── MainViewModel.Interference.cs # 干涉檢查指令
├── MainViewModel.Export.cs # CSV / 截圖 / STEP / STL 匯出 ├── MainViewModel.Export.cs # CSV / 截圖 / STEP / STL 匯出
├── MainViewModel.Localization.cs # 語言切換指令 + 切換後刷新量測/樹/狀態列文字
├── QuickBarViewModel.cs # 快速列按鈕註冊表(key/文字 key/預設值)
├── StlExportViewModel.cs # STL 匯出對話框 VM(選項 + 即時摘要) ├── StlExportViewModel.cs # STL 匯出對話框 VM(選項 + 即時摘要)
└── ModelNodeViewModel.cs # 裝配樹節點(可見性/顏色 cascade) └── ModelNodeViewModel.cs # 裝配樹節點(可見性/顏色 cascade)
``` ```
@@ -201,7 +209,8 @@ class MeasurementResult
| 快速鍵 | P 點 / D 距離 / E 邊 / F 面 / C 圓 / A 角度 / M 面距;同鍵再按=退出;Esc=取消進行中量測→退出模式 | | | 快速鍵 | P 點 / D 距離 / E 邊 / F 面 / C 圓 / A 角度 / M 面距;同鍵再按=退出;Esc=取消進行中量測→退出模式 | |
| 樹操作 | 搜尋框過濾節點名稱;右鍵:只顯示此節點 / 反轉顯示 / 全部顯示 / 量體積質心 | tooltip 含外形尺寸 L×W×H | | 樹操作 | 搜尋框過濾節點名稱;右鍵:只顯示此節點 / 反轉顯示 / 全部顯示 / 量體積質心 | tooltip 含外形尺寸 L×W×H |
| 體積/質心 | 樹節點右鍵「⚖ 量體積/質心」 | signed volume(封閉實體)+ 質心標記 + AABB 外形尺寸 | | 體積/質心 | 樹節點右鍵「⚖ 量體積/質心」 | signed volume(封閉實體)+ 質心標記 + AABB 外形尺寸 |
| 設定/MRU | 自動記住視窗位置大小、單位;「▾」最近 10 個檔案 | %LOCALAPPDATA%\STPViewer\settings.json | | 語言 | 視圖 → 語言 / 快速列下拉:English、繁體中文、简体中文(v0.8.0) | 即時切換(含既有量測文字)並記住;`i18n/*.json` 加檔即新增語言,缺字回退英文 |
| 設定/MRU | 自動記住視窗位置大小、單位、語言;「▾」最近 10 個檔案 | %LOCALAPPDATA%\STPViewer\settings.json |
支援格式:`.stp` / `.step`(B-rep + 裝配樹)、`.stl`(純網格,僅點/距離/角度/面距量測)、 支援格式:`.stp` / `.step`(B-rep + 裝配樹)、`.stl`(純網格,僅點/距離/角度/面距量測)、
`.dxf`(線架構檢視)。**IGES 不支援**(CADability 無 IGES reader)。 `.dxf`(線架構檢視)。**IGES 不支援**(CADability 無 IGES reader)。
@@ -489,6 +498,27 @@ WPF retained-mode 每幀重走 visual tree → frame rate 崩。瀏覽模式因
--- ---
## Development Phases — 第九輪(多語系 v0.8.0)
### Phase 17 — 執行期語言包 + 即時切換(工作量:L;外部貢獻 PR #1 by @CarmJos)
**問題:** 介面字串全部寫死繁中,非中文使用者無法使用;新增語言要改程式重編。
- [x] **語言包**:`i18n/*.json`(`key` = 選單顯示名、`data` = key → 譯文)複製到 exe 旁;
啟動時 `LocalizationService` 掃描,檔名即語言 Id(`zh_cn.json` → `zh_cn`);壞檔/重複 Id 略過記 Trace
- [x] **查詢與回退**:`LocalizationService[key]` → 目前語言 → 英文 → key 本身;`Format` 格式錯誤時回退英文格式
- [x] **XAML**:`{loc:Loc Key}`(`LocExtension` = 綁 `LocalizationService.Instance[Key]`,`Item[]` 變更即時刷新)
- [x] **程式內文字**:狀態列 `SetStatus(() => Get/Format(...))`、量測/樹節點文字延後產生,
`LanguageChanged` → `RefreshLocalizedText()` 重算(與 mm⇄inch 同一套延後產生模式)
- [x] **初始語言**:settings.json `Language`,null → 依系統 UI 語系(zh-Hant/TW/HK/MO → zh_tw,其他 zh → zh_cn)
- [x] 關於視窗版本紀錄改為 key(`Ui.About.ReleaseXYZ.Title/ChangeN`),三語各一份
- [x] SmokeTest `--localization-test`:語系解析(zh-Hans/Hant/TW…)、缺字/格式錯回退英文、壞檔容錯、
內建語言包格式參數一致、VM 切語言後既有量測/3D 標籤/快速列即時更新
**驗收:** 三語切換即時生效(含已存在的量測結果與狀態列)、重開保留;刪掉某語言檔中的 key 時顯示英文不崩潰
---
## Future Extensions(下一輪) ## Future Extensions(下一輪)
- **大檔重開快取** — 匯入成功後把三角網格 + 裝配樹序列化成 sidecar 快取檔(以來源檔 hash 驗證), - **大檔重開快取** — 匯入成功後把三角網格 + 裝配樹序列化成 sidecar 快取檔(以來源檔 hash 驗證),
+15 -5
View File
@@ -5,7 +5,7 @@
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/STL 匯出(STL 有參數對話框)、設定與 MRU 保存。 標準視圖+正交投影、量測快速鍵+Esc、mm⇄inch、多語系(英/繁/簡,執行期語言包)、CSV/截圖/STEP/STL 匯出(STL 有參數對話框)、設定與 MRU 保存。
詳細設計見 [ARCHITECTURE.md](ARCHITECTURE.md)。 詳細設計見 [ARCHITECTURE.md](ARCHITECTURE.md)。
## 技術棧 ## 技術棧
@@ -49,8 +49,8 @@ dotnet publish src/STPViewer -c Release -o publish/STPViewer
(樹搜尋框、剖面數值框要能打字),新增輸入控件不用再各自處理 (樹搜尋框、剖面數值框要能打字),新增輸入控件不用再各自處理
- UI 三層(v0.7.0):**選單列**(全部功能,分類下拉)+ **快速列**(`QuickBarViewModel` 註冊表, - UI 三層(v0.7.0):**選單列**(全部功能,分類下拉)+ **快速列**(`QuickBarViewModel` 註冊表,
使用者勾選常用按鈕,XAML 索引子綁定 `QuickBar[Key].IsChecked` 控 Visibility,按鈕本體維持靜態綁定)+ 使用者勾選常用按鈕,XAML 索引子綁定 `QuickBar[Key].IsChecked` 控 Visibility,按鈕本體維持靜態綁定)+
**剖面參數列**(只在 SectionEnabled 顯示)。新增功能要三處都接:選單分類、快速列註冊表 **剖面參數列**(只在 SectionEnabled 顯示)。新增功能要四處都接:選單分類、快速列註冊表
(含預設值)+ 對應 XAML 按鈕;勾選清單存 settings.json `QuickBarKeys`(null = 預設) (含預設值)+ 對應 XAML 按鈕、**三個語言包的文字 key**;勾選清單存 settings.json `QuickBarKeys`(null = 預設)
- 使用者設定 `SettingsService`(%LOCALAPPDATA%\STPViewer\settings.json):視窗(MainWindow 管)+ - 使用者設定 `SettingsService`(%LOCALAPPDATA%\STPViewer\settings.json):視窗(MainWindow 管)+
單位/MRU(VM `LoadSettings`/`SaveSettingsInto`);壞檔回預設、儲存失敗靜默 單位/MRU(VM `LoadSettings`/`SaveSettingsInto`);壞檔回預設、儲存失敗靜默
- 匯出 STEP 走 `CADability.ExportStep.WriteToFile(file, Project.CreateSimpleProject()+Model.Add)`; - 匯出 STEP 走 `CADability.ExportStep.WriteToFile(file, Project.CreateSimpleProject()+Model.Add)`;
@@ -65,6 +65,13 @@ dotnet publish src/STPViewer -c Release -o publish/STPViewer
OnViewportClick 最前面攔點擊);換軸/關剖面/Esc 會重置。平面位置一律用「AABB 8 角投影到法向」內插, OnViewportClick 最前面攔點擊);換軸/關剖面/Esc 會重置。平面位置一律用「AABB 8 角投影到法向」內插,
軸向與任意法向共用同一段程式,不要改回逐軸特化 軸向與任意法向共用同一段程式,不要改回逐軸特化
- 量測文字一律 `Func<UnitSystem,string>` 延後產生(mm⇄inch 即時切換);內部數值永遠存 mm - 量測文字一律 `Func<UnitSystem,string>` 延後產生(mm⇄inch 即時切換);內部數值永遠存 mm
- 🌐 **多語系(v0.8.0,PR #1 by @CarmJos)**:介面文字**一律走 key,不可寫死字串**。語言包 `src/STPViewer/i18n/{en,zh_tw,zh_cn}.json`
(`key` = 選單顯示名、`data` = key → 譯文;建置時複製到 exe 旁,啟動掃描,加檔即新增語言免重編)。
XAML 用 `{loc:Loc Ui.Xxx}`;程式用 `LocalizationService.Get(key)` / `Format(key, args)`;
狀態列用 `SetStatus(() => Get(...))`(延後產生,切語言時 `RefreshLocalizedText()` 重算 — 直接設 `StatusText` 切語言不會更新)。
新增文字 key **三個 json 都要加**(`en.json` 是 key 清單正本;缺字執行期回退英文、再缺顯示 key 本身)。
key 命名:`Ui.Main.*`(主視窗)/ `Ui.StlExport.*` / `Ui.About.*` / `QuickBar.*` / `Runtime.*`(狀態列訊息)/ `App.*`。
選用語言存 settings.json `Language`(null = 依系統 UI 語系)。**程式碼註解維持中文**(設計理由與「不要改回 X」都寫在註解,勿精簡掉)
- 裝配樹節點(`ModelNodeViewModel`)的可見性/邊線/顏色向下 cascade - 裝配樹節點(`ModelNodeViewModel`)的可見性/邊線/顏色向下 cascade
- 剖面只換 `GeometryModel3D.Geometry`(`FaceInfo.Mesh` 保留原始 frozen mesh 供還原與量測) - 剖面只換 `GeometryModel3D.Geometry`(`FaceInfo.Mesh` 保留原始 frozen mesh 供還原與量測)
- ⚡ 剖面裁切是**背景平行**(v0.4.0):`ApplySection` 在 UI 快照 (model, frozen mesh) → `Task.Run`+`Parallel.For` - ⚡ 剖面裁切是**背景平行**(v0.4.0):`ApplySection` 在 UI 快照 (model, frozen mesh) → `Task.Run`+`Parallel.For`
@@ -98,7 +105,8 @@ dotnet publish src/STPViewer -c Release -o publish/STPViewer
- 干涉/面距/對齊等運算在背景執行緒;`Freeze()` 幾何後才跨執行緒 - 干涉/面距/對齊等運算在背景執行緒;`Freeze()` 幾何後才跨執行緒
- 匯入在背景執行緒;`Freeze()` 幾何後才跨執行緒 - 匯入在背景執行緒;`Freeze()` 幾何後才跨執行緒
- 發新版:升 csproj `<Version>` 之外,記得在 `AboutDialog.xaml.cs` 的 `Changelog` 陣列**頂端**加一筆 - 發新版:升 csproj `<Version>` 之外,記得在 `AboutDialog.xaml.cs` 的 `Changelog` 陣列**頂端**加一筆
(口吻寫給一般使用者:「你會感覺到什麼」,不是技術 changelog)。 (口吻寫給一般使用者:「你會感覺到什麼」,不是技術 changelog)。v0.8.0 起內容是 key
(`Ui.About.ReleaseXYZ.Title` / `.ChangeN`),**三個語言包都要寫**。
⚠️ WPF **頂層 MenuItem 直接掛 Click 不會觸發**(實測滑鼠/鍵盤都沒反應),要開視窗的入口一律放子選單(如 說明 → 關於) ⚠️ WPF **頂層 MenuItem 直接掛 Click 不會觸發**(實測滑鼠/鍵盤都沒反應),要開視窗的入口一律放子選單(如 說明 → 關於)
- Commit 格式:Conventional Commits(`feat:` / `fix:` / `docs:` …) - Commit 格式:Conventional Commits(`feat:` / `fix:` / `docs:` …)
@@ -133,7 +141,9 @@ dotnet publish src/STPViewer -c Release -o publish/STPViewer
`--align-test`(三點對齊剛體變換 + ModOp↔Matrix3D 一致性)、`--make-dxf`(產測試檔)、 `--align-test`(三點對齊剛體變換 + ModOp↔Matrix3D 一致性)、`--make-dxf`(產測試檔)、
`--export-test <in.stp> <out.stp>`(STEP 匯出往返:寫出→回讀比對實體數)、 `--export-test <in.stp> <out.stp>`(STEP 匯出往返:寫出→回讀比對實體數)、
`--stl-export-test [file.stp]`(STL binary/ASCII 往返 + 退化濾除;給 stp 加測 B-rep 精細重算)、 `--stl-export-test [file.stp]`(STL binary/ASCII 往返 + 退化濾除;給 stp 加測 B-rep 精細重算)、
`--make-stl <out.stl> [triCount]`(產測試用 binary STL,預設 150 萬三角形,量 STL 匯入效能用) `--make-stl <out.stl> [triCount]`(產測試用 binary STL,預設 150 萬三角形,量 STL 匯入效能用)、
`--localization-test`(語言包載入/回退/壞檔容錯 + VM 即時切換;改 i18n 或加文字要跑)
- Linux 也能編 Windows 版:`~/.dotnet/dotnet build/publish ... -p:EnableWindowsTargeting=true`(SmokeTest 是 net8.0-windows,只能在 Windows 跑)
- **絕不要用 PowerShell regex/Set-Content 改 .cs 檔** — Windows PowerShell 5.1 預設編碼會把 UTF-8 中文弄成亂碼(已踩過,靠反編譯 DLL 救回)。文字取代一律用 Edit 工具 - **絕不要用 PowerShell regex/Set-Content 改 .cs 檔** — Windows PowerShell 5.1 預設編碼會把 UTF-8 中文弄成亂碼(已踩過,靠反編譯 DLL 救回)。文字取代一律用 Edit 工具
## For_AI/ ## For_AI/
+33
View File
@@ -0,0 +1,33 @@
# v0.8.0 — Switch languages: English, 繁體中文, 简体中文
## ✨ New features
**Language switching**
* Pick **English**, **繁體中文** or **简体中文** from **View → Language** or the language box on the quick toolbar. The whole interface switches instantly — menus, dialogs, status messages, and even measurements already on screen — and your choice is remembered next time.
* On first launch STPViewer follows your Windows display language (Traditional Chinese regions → 繁體中文, other Chinese → 简体中文, everything else → English).
**Add your own language — no update needed**
* Languages are plain JSON files in the `i18n` folder next to `STPViewer.exe`. Copy `en.json`, translate it, save it under a new name, and restart — the new language appears in the menu. Anything not yet translated falls back to English. See [Adding a Language](https://github.com/ETWen/STPViewer#-adding-a-language) in the README.
**Simplified Chinese README**
* The README is now also available in [简体中文](https://github.com/ETWen/STPViewer/blob/main/README.zh-CN.md).
## 🙏 Thanks
* Multi-language support was contributed by **@CarmJos** in [#1](https://github.com/ETWen/STPViewer/pull/1) — thank you!
## 📦 Downloads
| Build | Needs .NET Runtime? | Notes |
|---|---|---|
| **STPViewer_v0.8.0** (Standard) | ✅ Requires .NET 8 Desktop Runtime | Smaller download |
| **STPViewer_v0.8.0_portable** (Portable) | ❌ Runtime included | Unzip and run anywhere, no admin |
Run `STPViewer v0.8.0.exe`. Keep the `i18n` folder next to it — that's where the languages live.
## 🔗 Links
* [Architecture](https://github.com/ETWen/STPViewer/blob/main/ARCHITECTURE.md)
* [README](https://github.com/ETWen/STPViewer/blob/main/README.md)
**Full changelog:** [v0.7.1...v0.8.0](https://github.com/ETWen/STPViewer/compare/v0.7.1...v0.8.0)
+2 -2
View File
@@ -35,7 +35,7 @@
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
</Grid.RowDefinitions> </Grid.RowDefinitions>
<!-- Application, developer, and technology --> <!-- ─── 左:程式 / 開發者 / 技術棧 ─── -->
<StackPanel Grid.Column="0" Grid.Row="0" Margin="0,0,12,0"> <StackPanel Grid.Column="0" Grid.Row="0" Margin="0,0,12,0">
<Border Style="{StaticResource Card}"> <Border Style="{StaticResource Card}">
<StackPanel HorizontalAlignment="Center"> <StackPanel HorizontalAlignment="Center">
@@ -78,7 +78,7 @@
</Border> </Border>
</StackPanel> </StackPanel>
<!-- Release history --> <!-- ─── 右:Change log ─── -->
<Border Grid.Column="1" Grid.Row="0" Background="#FAFBFC" CornerRadius="6" Padding="4"> <Border Grid.Column="1" Grid.Row="0" Background="#FAFBFC" CornerRadius="6" Padding="4">
<DockPanel> <DockPanel>
<TextBlock DockPanel.Dock="Top" Text="{loc:Loc Ui.About.Changelog}" FontWeight="Bold" FontSize="14" <TextBlock DockPanel.Dock="Top" Text="{loc:Loc Ui.About.Changelog}" FontWeight="Bold" FontSize="14"
+10 -3
View File
@@ -7,8 +7,8 @@ using STPViewer.Services;
namespace STPViewer; namespace STPViewer;
/// <summary> /// <summary>
/// The About window shows application information on the left and release history on the right. /// 關於視窗:左 = 程式資訊 / 開發者 / 技術棧,右 = 版本紀錄(寫給一般使用者看,非技術 changelog)。
/// Add each new release at the start of Changelog. /// 樣式參考 ETTerms 的 AboutView。發新版時在 Changelog 陣列最上面加一筆。
/// </summary> /// </summary>
public partial class AboutDialog : Window public partial class AboutDialog : Window
{ {
@@ -26,9 +26,16 @@ public partial class AboutDialog : Window
private record ChangelogEntry(string Version, DateOnly Date, string Title, string[] Changes); private record ChangelogEntry(string Version, DateOnly Date, string Title, string[] Changes);
// Release history shown to users, newest first. // ── 版本紀錄(新版加最上面;口吻:一般使用者看得懂,講「你會感覺到什麼」而非實作細節)──
private static readonly ChangelogEntry[] Changelog = private static readonly ChangelogEntry[] Changelog =
{ {
new("0.8.0", new DateOnly(2026, 10, 20), "Ui.About.Release080.Title",
new[]
{
"Ui.About.Release080.Change1",
"Ui.About.Release080.Change2",
"Ui.About.Release080.Change3",
}),
new("0.7.1", new DateOnly(2026, 7, 12), "Ui.About.Release071.Title", new("0.7.1", new DateOnly(2026, 7, 12), "Ui.About.Release071.Title",
new[] new[]
{ {
+6 -5
View File
@@ -8,8 +8,8 @@ using STPViewer.Services;
namespace STPViewer; namespace STPViewer;
/// <summary> /// <summary>
/// Log unhandled UI and background exceptions to /// 全域例外處理:UI 執行緒未攔截例外 → 記 log + 訊息框後盡量存活(不閃退);
/// %LOCALAPPDATA%\STPViewer\error.log and show a message for UI errors. /// 背景 Task / 非 UI 執行緒例外 → 記 log。log 位於 %LOCALAPPDATA%\STPViewer\error.log。
/// </summary> /// </summary>
public partial class App : Application public partial class App : Application
{ {
@@ -22,7 +22,8 @@ public partial class App : Application
LocalizationService.Instance.SetLanguage(SettingsService.Load().Language); LocalizationService.Instance.SetLanguage(SettingsService.Load().Language);
base.OnStartup(e); base.OnStartup(e);
// Report UI exceptions and keep the window open. // UI 執行緒(含 async void 回拋)的未攔截例外:記 log、提示、標記 Handled 讓程式存活。
// 若後續狀態已壞使用者可自行重啟;至少量測結果 / 已載模型不會直接蒸發。
DispatcherUnhandledException += (_, args) => DispatcherUnhandledException += (_, args) =>
{ {
LogException("DispatcherUnhandledException", args.Exception); LogException("DispatcherUnhandledException", args.Exception);
@@ -32,14 +33,14 @@ public partial class App : Application
args.Handled = true; args.Handled = true;
}; };
// Log unobserved task exceptions. // 沒被 await 的背景 Task 例外(finalizer 階段才浮出):記 log 並標記已觀察,避免行程終止
TaskScheduler.UnobservedTaskException += (_, args) => TaskScheduler.UnobservedTaskException += (_, args) =>
{ {
LogException("UnobservedTaskException", args.Exception); LogException("UnobservedTaskException", args.Exception);
args.SetObserved(); args.SetObserved();
}; };
// Log fatal exceptions on non-UI threads. // 其他非 UI 執行緒的致命例外:無法阻止終止,但至少留下記錄
AppDomain.CurrentDomain.UnhandledException += (_, args) => AppDomain.CurrentDomain.UnhandledException += (_, args) =>
LogException("AppDomainUnhandledException", args.ExceptionObject as Exception); LogException("AppDomainUnhandledException", args.ExceptionObject as Exception);
} }
@@ -6,8 +6,8 @@ using STPViewer.Models;
namespace STPViewer.Converters; namespace STPViewer.Converters;
/// <summary> /// <summary>
/// Convert MeasureMode to ToggleButton.IsChecked using the mode name /// MeasureMode ↔ ToggleButton.IsChecked。
/// in ConverterParameter. Unchecking returns to None. /// ConverterParameter 為模式名稱字串;取消勾選時回到 None。
/// </summary> /// </summary>
public class EnumToBoolConverter : IValueConverter public class EnumToBoolConverter : IValueConverter
{ {
@@ -4,7 +4,7 @@ using System.Windows.Data;
namespace STPViewer.Converters; namespace STPViewer.Converters;
/// <summary>Show file names in the recent-files menu while retaining full paths as tooltips.</summary> /// <summary>完整路徑 → 檔名(選單「最近檔案」顯示用;tooltip 仍給完整路徑)</summary>
public class FileNameConverter : IValueConverter public class FileNameConverter : IValueConverter
{ {
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+9 -8
View File
@@ -28,7 +28,7 @@
</Window.Resources> </Window.Resources>
<DockPanel> <DockPanel>
<!-- Menu bar --> <!-- ─── 選單列(全部功能,分類下拉)─── -->
<Menu DockPanel.Dock="Top"> <Menu DockPanel.Dock="Top">
<MenuItem Header="{loc:Loc Ui.Main.FileF}"> <MenuItem Header="{loc:Loc Ui.Main.FileF}">
<MenuItem Header="{loc:Loc Ui.Main.MenuImport}" Command="{Binding ImportCommand}" <MenuItem Header="{loc:Loc Ui.Main.MenuImport}" Command="{Binding ImportCommand}"
@@ -153,7 +153,7 @@
</MenuItem> </MenuItem>
</Menu> </Menu>
<!-- Customizable Quick Bar --> <!-- ─── 快速工具列(可自訂:選單「快速列 → 自訂按鈕」勾選)─── -->
<ToolBarTray DockPanel.Dock="Top"> <ToolBarTray DockPanel.Dock="Top">
<ToolBar> <ToolBar>
<Button Style="{StaticResource QuickButton}" Command="{Binding ImportCommand}" <Button Style="{StaticResource QuickButton}" Command="{Binding ImportCommand}"
@@ -256,7 +256,7 @@
</ToolBar> </ToolBar>
</ToolBarTray> </ToolBarTray>
<!-- Section controls --> <!-- ─── 剖面參數列(只在剖面開啟時出現)─── -->
<ToolBarTray DockPanel.Dock="Top" <ToolBarTray DockPanel.Dock="Top"
Visibility="{Binding SectionEnabled, Converter={StaticResource BoolToVis}}"> Visibility="{Binding SectionEnabled, Converter={StaticResource BoolToVis}}">
<ToolBar> <ToolBar>
@@ -280,7 +280,7 @@
</ToolBar> </ToolBar>
</ToolBarTray> </ToolBarTray>
<!-- Status bar --> <!-- ─── 狀態列 ─── -->
<StatusBar DockPanel.Dock="Bottom"> <StatusBar DockPanel.Dock="Bottom">
<StatusBarItem> <StatusBarItem>
<TextBlock Text="{Binding StatusText}" /> <TextBlock Text="{Binding StatusText}" />
@@ -300,7 +300,7 @@
<ColumnDefinition Width="300" MinWidth="160" /> <ColumnDefinition Width="300" MinWidth="160" />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<!-- Assembly tree --> <!-- ─── 裝配樹面板 ─── -->
<GroupBox Grid.Column="0" Header="{loc:Loc Ui.Main.ModelsAssemblyTree}" Margin="4"> <GroupBox Grid.Column="0" Header="{loc:Loc Ui.Main.ModelsAssemblyTree}" Margin="4">
<DockPanel> <DockPanel>
<TextBox DockPanel.Dock="Top" Margin="0,2,0,4" <TextBox DockPanel.Dock="Top" Margin="0,2,0,4"
@@ -390,7 +390,7 @@
VerticalAlignment="Stretch" Background="#FFD8D8D8" VerticalAlignment="Stretch" Background="#FFD8D8D8"
ToolTip="{loc:Loc Ui.Main.DragResizePanel}" /> ToolTip="{loc:Loc Ui.Main.DragResizePanel}" />
<!-- 3D viewport --> <!-- ─── 3D 視圖 ─── -->
<h:HelixViewport3D x:Name="viewport" Grid.Column="2" <h:HelixViewport3D x:Name="viewport" Grid.Column="2"
ShowViewCube="True" ShowCoordinateSystem="True" ShowViewCube="True" ShowCoordinateSystem="True"
ShowFrameRate="False" ZoomExtentsWhenLoaded="True" ShowFrameRate="False" ZoomExtentsWhenLoaded="True"
@@ -418,7 +418,8 @@
<h:DefaultLights /> <h:DefaultLights />
</h:HelixViewport3D> </h:HelixViewport3D>
<!-- Transparent gizmo overlay above the main viewport. --> <!-- 操作器疊圖層:透明 Viewport3D 疊在主視窗上、永遠最上層。
只在操作器開啟時 IsHitTestVisible(否則空白處滑鼠才會穿透回主視窗) -->
<Viewport3D x:Name="gizmoOverlay" Grid.Column="2" <Viewport3D x:Name="gizmoOverlay" Grid.Column="2"
IsHitTestVisible="{Binding GizmoEnabled}" /> IsHitTestVisible="{Binding GizmoEnabled}" />
@@ -426,7 +427,7 @@
VerticalAlignment="Stretch" Background="#FFD8D8D8" VerticalAlignment="Stretch" Background="#FFD8D8D8"
ToolTip="{loc:Loc Ui.Main.DragResizePanel}" /> ToolTip="{loc:Loc Ui.Main.DragResizePanel}" />
<!-- Measurement results --> <!-- ─── 量測結果面板 ─── -->
<GroupBox Grid.Column="4" Header="{loc:Loc Ui.Main.Measurements}" Margin="4"> <GroupBox Grid.Column="4" Header="{loc:Loc Ui.Main.Measurements}" Margin="4">
<ListBox ItemsSource="{Binding Measurements}" HorizontalContentAlignment="Stretch" <ListBox ItemsSource="{Binding Measurements}" HorizontalContentAlignment="Stretch"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"> ScrollViewer.HorizontalScrollBarVisibility="Disabled">
+10 -10
View File
@@ -18,7 +18,7 @@ public partial class MainWindow : Window
public MainWindow() public MainWindow()
{ {
InitializeComponent(); InitializeComponent();
// Read the version from the project at runtime. // 版號單一來源 = csproj <Version>;標題執行時讀,避免寫死
UpdateTitle(); UpdateTitle();
DataContext = _vm; DataContext = _vm;
_vm.Attach(viewport); _vm.Attach(viewport);
@@ -26,7 +26,7 @@ public partial class MainWindow : Window
Loaded += MainWindow_Loaded; Loaded += MainWindow_Loaded;
Closing += MainWindow_Closing; Closing += MainWindow_Closing;
// Restore window settings here and units/recent files in the view model. // 使用者設定:視窗位置大小(View 職責)+ 單位/MRU(VM 職責)
_settings = SettingsService.Load(); _settings = SettingsService.Load();
ApplyWindowSettings(); ApplyWindowSettings();
_vm.LoadSettings(_settings); _vm.LoadSettings(_settings);
@@ -38,7 +38,7 @@ public partial class MainWindow : Window
_vm.DisposeLocalization(); _vm.DisposeLocalization();
}; };
// The gizmo overlay needs handled mouse-up events from the manipulator. // Gizmo 操作器放在疊圖層;manipulator 會把 MouseUp 標 handled,需 handledEventsToo 才收得到
gizmoOverlay.AddHandler(MouseLeftButtonUpEvent, gizmoOverlay.AddHandler(MouseLeftButtonUpEvent,
new MouseButtonEventHandler((_, _) => _vm.OnGizmoMouseUp()), handledEventsToo: true); new MouseButtonEventHandler((_, _) => _vm.OnGizmoMouseUp()), handledEventsToo: true);
} }
@@ -58,7 +58,7 @@ public partial class MainWindow : Window
Width = _settings.WindowWidth; Width = _settings.WindowWidth;
Height = _settings.WindowHeight; Height = _settings.WindowHeight;
} }
// Restore the position only when it is within the current virtual desktop. // 位置要落在目前虛擬桌面內才還原(避免拔掉外接螢幕後視窗開在螢幕外)
if (!double.IsNaN(_settings.WindowLeft) && !double.IsNaN(_settings.WindowTop) && if (!double.IsNaN(_settings.WindowLeft) && !double.IsNaN(_settings.WindowTop) &&
_settings.WindowLeft >= SystemParameters.VirtualScreenLeft - 50 && _settings.WindowLeft >= SystemParameters.VirtualScreenLeft - 50 &&
_settings.WindowTop >= SystemParameters.VirtualScreenTop - 50 && _settings.WindowTop >= SystemParameters.VirtualScreenTop - 50 &&
@@ -87,7 +87,7 @@ public partial class MainWindow : Window
SettingsService.Save(_settings); SettingsService.Save(_settings);
} }
/// <summary>Open files passed on the command line: STPViewer.exe a.stp b.stl …</summary> /// <summary>支援命令列帶檔開啟:STPViewer.exe a.stp b.stl …</summary>
private async void MainWindow_Loaded(object sender, RoutedEventArgs e) private async void MainWindow_Loaded(object sender, RoutedEventArgs e)
{ {
var files = System.Environment.GetCommandLineArgs().Skip(1) var files = System.Environment.GetCommandLineArgs().Skip(1)
@@ -96,7 +96,7 @@ public partial class MainWindow : Window
await _vm.ImportFilesAsync(files); await _vm.ImportFilesAsync(files);
} }
/// <summary>Handle Esc and measurement shortcuts without intercepting text inputs.</summary> /// <summary>Esc / 量測模式快速鍵(焦點在輸入框時不攔截,樹搜尋、剖面數值可正常打字)</summary>
private void Window_PreviewKeyDown(object sender, KeyEventArgs e) private void Window_PreviewKeyDown(object sender, KeyEventArgs e)
{ {
if (Keyboard.FocusedElement is TextBoxBase or ComboBox) return; if (Keyboard.FocusedElement is TextBoxBase or ComboBox) return;
@@ -111,7 +111,7 @@ public partial class MainWindow : Window
e.Handled = true; e.Handled = true;
} }
/// <summary>Build the recent-files menu from the view model's current list.</summary> /// <summary>最近檔案下拉(▾):即時從 VM 的 MRU 清單組選單</summary>
private void Recent_Click(object sender, RoutedEventArgs e) private void Recent_Click(object sender, RoutedEventArgs e)
{ {
var menu = new ContextMenu var menu = new ContextMenu
@@ -145,14 +145,14 @@ public partial class MainWindow : Window
private void Viewport_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) private void Viewport_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{ {
// Shift+left-click pans the view. // Shift+左鍵 = 平移(PanGesture2),不觸發量測/拖曳
if (Keyboard.Modifiers.HasFlag(ModifierKeys.Shift)) return; if (Keyboard.Modifiers.HasFlag(ModifierKeys.Shift)) return;
if (_vm.CurrentMode == STPViewer.Models.MeasureMode.Drag) if (_vm.CurrentMode == STPViewer.Models.MeasureMode.Drag)
{ {
if (_vm.OnDragStart(e.GetPosition(viewport.Viewport))) if (_vm.OnDragStart(e.GetPosition(viewport.Viewport)))
{ {
viewport.CaptureMouse(); // Keep receiving move/up events outside the window. viewport.CaptureMouse(); // 拖出視窗外也持續收到 Move/Up
e.Handled = true; e.Handled = true;
} }
return; return;
@@ -173,7 +173,7 @@ public partial class MainWindow : Window
} }
private void Viewport_LostMouseCapture(object sender, MouseEventArgs e) => private void Viewport_LostMouseCapture(object sender, MouseEventArgs e) =>
_vm.OnDragEnd(); // Commit the current position if capture is interrupted. _vm.OnDragEnd(); // 捕捉意外中斷(Alt+Tab 等)→ 以目前位置定格,不留懸空 Transform
private void Window_DragOver(object sender, DragEventArgs e) private void Window_DragOver(object sender, DragEventArgs e)
{ {
+3 -3
View File
@@ -4,13 +4,13 @@ using STPViewer.ViewModels;
namespace STPViewer.Models; namespace STPViewer.Models;
/// <summary>Maps a rendered GeometryModel3D back to a B-rep face.</summary> /// <summary>渲染物件(GeometryModel3D)反查 B-rep Face 的對照資料</summary>
public class FaceInfo public class FaceInfo
{ {
/// <summary>B-rep face, or null for mesh-only sources such as STL.</summary> /// <summary>B-rep 面;STL 等純網格來源為 null(僅支援點/距離/面距量測)</summary>
public Face? BrepFace { get; init; } public Face? BrepFace { get; init; }
/// <summary>Uncut mesh used for area and section restoration; replaced after translation.</summary> /// <summary>原始(未剖切)三角網格 — 面積計算與剖面還原用;零件平移後會換成新 mesh</summary>
public required MeshGeometry3D Mesh { get; set; } public required MeshGeometry3D Mesh { get; set; }
public required ModelNodeViewModel Owner { get; init; } public required ModelNodeViewModel Owner { get; init; }
+8 -8
View File
@@ -1,6 +1,6 @@
namespace STPViewer.Models; namespace STPViewer.Models;
/// <summary>Measurement modes; None is the browsing mode.</summary> /// <summary>量測模式(None = 純瀏覽)</summary>
public enum MeasureMode public enum MeasureMode
{ {
None, None,
@@ -9,11 +9,11 @@ public enum MeasureMode
Edge, Edge,
Face, Face,
Circle, Circle,
Angle, // Angle between two faces or edges. Angle, // 兩面 / 兩邊夾角
FaceDistance, // Shortest face-to-face distance (mesh approximation). FaceDistance, // 面到面最短距離(網格近似)
Align, // Translate a part so the first point meets the second. Align, // 兩點對齊:平移零件使點 1 貼到點 2
Align3, // Rotate and translate from three source and target points. Align3, // 三點對齊:旋轉+平移(來源 3 點 → 目標 3 點)
Drag, // Move a part in the screen plane; commit on release. Drag, // 拖曳模式:左鍵按住零件沿螢幕平面拖動(放開才烘進 B-rep)
Interference, // Result type rather than a picking mode. Interference, // 干涉檢查結果(非點擊模式,僅作為結果類型)
Volume, // Volume/centroid result triggered from the tree menu. Volume, // 體積/質心(樹右鍵選單觸發,非點擊模式,僅作為結果類型)
} }
+6 -6
View File
@@ -7,23 +7,23 @@ using HelixToolkit.Wpf;
namespace STPViewer.Models; namespace STPViewer.Models;
/// <summary> /// <summary>
/// A measurement stored in millimeters. Its title, details, and 3D labels /// 一筆量測結果。內部以 mm 儲存原始值,Title/Detail 依目前單位即時產生;
/// update when the display unit changes. /// 切換單位時 3D 標籤(DynamicLabels)也同步改字。
/// </summary> /// </summary>
public class MeasurementResult : ObservableObject public class MeasurementResult : ObservableObject
{ {
public MeasureMode Kind { get; init; } public MeasureMode Kind { get; init; }
/// <summary>Create the list title for a display unit.</summary> /// <summary>依單位產生標題(清單粗體列)</summary>
public required Func<UnitSystem, string> TitleFor { get; init; } public required Func<UnitSystem, string> TitleFor { get; init; }
/// <summary>Create the detailed text for a display unit.</summary> /// <summary>依單位產生多行明細</summary>
public required Func<UnitSystem, string> DetailFor { get; init; } public required Func<UnitSystem, string> DetailFor { get; init; }
/// <summary>View markers removed with this measurement.</summary> /// <summary>視圖中的標記(刪除量測時一併移除)</summary>
public List<Visual3D> Overlays { get; } = new(); public List<Visual3D> Overlays { get; } = new();
/// <summary>3D labels whose text changes with the display unit.</summary> /// <summary>單位相關的 3D 文字標籤(切換單位時更新 Text)</summary>
public List<(BillboardTextVisual3D Label, Func<UnitSystem, string> TextFor)> DynamicLabels { get; } = new(); public List<(BillboardTextVisual3D Label, Func<UnitSystem, string> TextFor)> DynamicLabels { get; } = new();
private UnitSystem _unit = UnitSystem.Millimeter; private UnitSystem _unit = UnitSystem.Millimeter;
+6 -6
View File
@@ -8,28 +8,28 @@ public enum UnitSystem
Inch, Inch,
} }
/// <summary>Format measurements stored in millimeters for the selected display unit.</summary> /// <summary>量測值格式化(內部一律 mm,顯示時換算)</summary>
public static class Units public static class Units
{ {
private const double MmPerInch = 25.4; private const double MmPerInch = 25.4;
/// <summary>Length.</summary> /// <summary>長度</summary>
public static string L(double mm, UnitSystem u) => public static string L(double mm, UnitSystem u) =>
u == UnitSystem.Millimeter ? $"{mm:F3} mm" : $"{mm / MmPerInch:F4} in"; u == UnitSystem.Millimeter ? $"{mm:F3} mm" : $"{mm / MmPerInch:F4} in";
/// <summary>Area.</summary> /// <summary>面積</summary>
public static string A(double mm2, UnitSystem u) => public static string A(double mm2, UnitSystem u) =>
u == UnitSystem.Millimeter ? $"{mm2:N3} mm²" : $"{mm2 / (MmPerInch * MmPerInch):N4} in²"; u == UnitSystem.Millimeter ? $"{mm2:N3} mm²" : $"{mm2 / (MmPerInch * MmPerInch):N4} in²";
/// <summary>Volume.</summary> /// <summary>體積</summary>
public static string V(double mm3, UnitSystem u) => public static string V(double mm3, UnitSystem u) =>
u == UnitSystem.Millimeter ? $"{mm3:N2} mm³" : $"{mm3 / (MmPerInch * MmPerInch * MmPerInch):N4} in³"; u == UnitSystem.Millimeter ? $"{mm3:N2} mm³" : $"{mm3 / (MmPerInch * MmPerInch * MmPerInch):N4} in³";
/// <summary>Coordinate component without a unit suffix.</summary> /// <summary>座標分量(無單位字尾)</summary>
public static string C(double mm, UnitSystem u) => public static string C(double mm, UnitSystem u) =>
u == UnitSystem.Millimeter ? $"{mm:F3}" : $"{mm / MmPerInch:F4}"; u == UnitSystem.Millimeter ? $"{mm:F3}" : $"{mm / MmPerInch:F4}";
/// <summary>Coordinate point (x, y, z).</summary> /// <summary>座標點 (x, y, z)</summary>
public static string P(Point3D p, UnitSystem u) => public static string P(Point3D p, UnitSystem u) =>
$"({C(p.X, u)}, {C(p.Y, u)}, {C(p.Z, u)})"; $"({C(p.X, u)}, {C(p.Y, u)}, {C(p.Z, u)})";
} }
+1 -1
View File
@@ -6,7 +6,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF> <UseWPF>true</UseWPF>
<Version>0.7.1</Version> <!-- Shared by the window title and publish output. --> <Version>0.8.0</Version> <!-- Shared by the window title and publish output. -->
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
+23 -19
View File
@@ -6,7 +6,8 @@ using System.Windows.Media.Media3D;
namespace STPViewer.Services; namespace STPViewer.Services;
/// <summary> /// <summary>
/// Interference result: Segments holds intersection lines, or GapA/GapB the approximate closest points. /// 干涉檢查結果:Intersects=true 時 Segments 為干涉交線;
/// false 時 GapA/GapB 為最近點對(網格近似)。
/// </summary> /// </summary>
public record InterferenceResult( public record InterferenceResult(
bool Intersects, bool Intersects,
@@ -17,8 +18,10 @@ public record InterferenceResult(
double GapDistance); double GapDistance);
/// <summary> /// <summary>
/// Checks intersections between two triangle mesh sets using a uniform spatial grid. /// 兩組三角網格的干涉檢查:
/// Coplanar contact without penetration is excluded. /// 三角形-三角形相交(區間法,回傳交線段)+ 均勻網格空間加速。
/// 共面接觸(無穿透)不會被判為干涉(近似限制)。
/// 設計為在背景執行緒呼叫。
/// </summary> /// </summary>
public static class InterferenceService public static class InterferenceService
{ {
@@ -60,7 +63,7 @@ public static class InterferenceService
return new InterferenceResult(false, 0, segments, ga, gb, d); return new InterferenceResult(false, 0, segments, ga, gb, d);
} }
// Spatial data // ─── 資料結構 ────────────────────────────────────────────────
private readonly struct Triangle private readonly struct Triangle
{ {
@@ -97,7 +100,7 @@ public static class InterferenceService
a.Min.Y <= b.Max.Y && a.Max.Y >= b.Min.Y && a.Min.Y <= b.Max.Y && a.Max.Y >= b.Min.Y &&
a.Min.Z <= b.Max.Z && a.Max.Z >= b.Min.Z; a.Min.Z <= b.Max.Z && a.Max.Z >= b.Min.Z;
/// <summary>Uniform grid from cells to triangle indices.</summary> /// <summary>均勻網格:cell → 三角形索引</summary>
private sealed class TriGrid private sealed class TriGrid
{ {
private readonly Dictionary<(int, int, int), List<int>> _cells = new(); private readonly Dictionary<(int, int, int), List<int>> _cells = new();
@@ -105,7 +108,7 @@ public static class InterferenceService
public TriGrid(Triangle[] tris) public TriGrid(Triangle[] tris)
{ {
// Size cells from the scene diagonal, with bounds for extreme sizes. // cell 大小:整體對角線 / 64(夾限避免極端值)
Point3D min = tris[0].Min, max = tris[0].Max; Point3D min = tris[0].Min, max = tris[0].Max;
foreach (Triangle t in tris) foreach (Triangle t in tris)
{ {
@@ -143,7 +146,7 @@ public static class InterferenceService
} }
} }
// Triangle intersection // ─── 三角形相交(區間法,回傳交線段)────────────────────────
private static bool TriTriIntersect(in Triangle ta, in Triangle tb, out Point3D s0, out Point3D s1) private static bool TriTriIntersect(in Triangle ta, in Triangle tb, out Point3D s0, out Point3D s1)
{ {
@@ -164,12 +167,12 @@ public static class InterferenceService
if ((db0 > 0 && db1 > 0 && db2 > 0) || (db0 < 0 && db1 < 0 && db2 < 0)) return false; if ((db0 > 0 && db1 > 0 && db2 > 0) || (db0 < 0 && db1 < 0 && db2 < 0)) return false;
Vector3D dir = Vector3D.CrossProduct(n1, n2); Vector3D dir = Vector3D.CrossProduct(n1, n2);
if (dir.LengthSquared < 1e-24) return false; // Coplanar contact. if (dir.LengthSquared < 1e-24) return false; // 共面:不視為穿透(近似)
if (!PlaneCrossSegment(ta, da0, da1, da2, out Point3D p0, out Point3D p1)) return false; if (!PlaneCrossSegment(ta, da0, da1, da2, out Point3D p0, out Point3D p1)) return false;
if (!PlaneCrossSegment(tb, db0, db1, db2, out Point3D q0, out Point3D q1)) return false; if (!PlaneCrossSegment(tb, db0, db1, db2, out Point3D q0, out Point3D q1)) return false;
// Project onto the intersection direction and overlap the intervals. // 投影到交線方向取重疊區間
double tp0 = Vector3D.DotProduct(dir, (Vector3D)p0); double tp0 = Vector3D.DotProduct(dir, (Vector3D)p0);
double tp1 = Vector3D.DotProduct(dir, (Vector3D)p1); double tp1 = Vector3D.DotProduct(dir, (Vector3D)p1);
double tq0 = Vector3D.DotProduct(dir, (Vector3D)q0); double tq0 = Vector3D.DotProduct(dir, (Vector3D)q0);
@@ -179,14 +182,14 @@ public static class InterferenceService
double lo = Math.Max(tp0, tq0); double lo = Math.Max(tp0, tq0);
double hi = Math.Min(tp1, tq1); double hi = Math.Min(tp1, tq1);
if (lo >= hi) return false; if (lo >= hi) return false; // 無重疊(或僅點接觸)
s0 = LerpOnSegment(p0, p1, tp0, tp1, lo); s0 = LerpOnSegment(p0, p1, tp0, tp1, lo);
s1 = LerpOnSegment(p0, p1, tp0, tp1, hi); s1 = LerpOnSegment(p0, p1, tp0, tp1, hi);
return true; return true;
} }
/// <summary>Finds the triangle-plane intersection segment from signed vertex distances.</summary> /// <summary>三角形與平面(頂點符號 d0..d2 已算好)的交線段(兩個交點)</summary>
private static bool PlaneCrossSegment(in Triangle t, double d0, double d1, double d2, private static bool PlaneCrossSegment(in Triangle t, double d0, double d1, double d2,
out Point3D p0, out Point3D p1) out Point3D p0, out Point3D p1)
{ {
@@ -205,8 +208,8 @@ public static class InterferenceService
Span<Point3D> pts, ref int n) Span<Point3D> pts, ref int n)
{ {
if (n >= 3) return; if (n >= 3) return;
if (da == 0 && db == 0) return; if (da == 0 && db == 0) return; // 邊在平面上 → 共面情形,略過
if (da == 0) { pts[n++] = a; return; } if (da == 0) { pts[n++] = a; return; } // 頂點剛好在平面上
if (da * db < 0) if (da * db < 0)
pts[n++] = a + (b - a) * (da / (da - db)); pts[n++] = a + (b - a) * (da / (da - db));
} }
@@ -218,11 +221,11 @@ public static class InterferenceService
return p0 + (p1 - p0) * f; return p0 + (p1 - p0) * f;
} }
// Approximate gap when meshes do not intersect // ─── 無干涉時的近似最小間隙 ─────────────────────────────────
private static (Point3D a, Point3D b, double d) ApproxMinDistance(Triangle[] trisA, Triangle[] trisB) private static (Point3D a, Point3D b, double d) ApproxMinDistance(Triangle[] trisA, Triangle[] trisB)
{ {
// Sample vertices for an initial closest pair. // 1) 抽樣頂點對頂點求初值
double best = double.MaxValue; double best = double.MaxValue;
Point3D pa = default, pb = default; Point3D pa = default, pb = default;
int strideA = Math.Max(1, trisA.Length / 2000); int strideA = Math.Max(1, trisA.Length / 2000);
@@ -234,13 +237,14 @@ public static class InterferenceService
if (d < best) { best = d; pa = trisA[i].A; pb = trisB[j].A; } if (d < best) { best = d; pa = trisA[i].A; pb = trisB[j].A; }
} }
// Refine against opposing triangles, rejecting distant AABBs. // 2) 以最佳點為中心,精修:點 → 對方所有三角形(AABB 平方距離快速拒絕 + 平行分段,
// 初值來自步驟 1 已相當接近 → 絕大多數三角形被 AABB 直接刷掉)
(pb, best) = ClosestOnTriangles(pa, trisB, pb, best); (pb, best) = ClosestOnTriangles(pa, trisB, pb, best);
(pa, best) = ClosestOnTriangles(pb, trisA, pa, best); (pa, best) = ClosestOnTriangles(pb, trisA, pa, best);
return (pa, pb, best); return (pa, pb, best);
} }
/// <summary>Refines the closest point from an initial distance.</summary> /// <summary>點 p 到三角形集的最近點(帶初值;回傳更新後的最近點與距離)</summary>
private static (Point3D pt, double d) ClosestOnTriangles(Point3D p, Triangle[] tris, Point3D seedPt, double seedD) private static (Point3D pt, double d) ClosestOnTriangles(Point3D p, Triangle[] tris, Point3D seedPt, double seedD)
{ {
object gate = new(); object gate = new();
@@ -252,7 +256,7 @@ public static class InterferenceService
(i, _, local) => (i, _, local) =>
{ {
Triangle t = tris[i]; Triangle t = tris[i];
// Reject triangles whose AABB cannot improve the current distance. // AABB 平方距離下界 ≥ 目前最佳 → 不可能更近,略過(絕大多數三角形在此被刷掉)
double dx = Math.Max(0, Math.Max(t.Min.X - p.X, p.X - t.Max.X)); double dx = Math.Max(0, Math.Max(t.Min.X - p.X, p.X - t.Max.X));
double dy = Math.Max(0, Math.Max(t.Min.Y - p.Y, p.Y - t.Max.Y)); double dy = Math.Max(0, Math.Max(t.Min.Y - p.Y, p.Y - t.Max.Y));
double dz = Math.Max(0, Math.Max(t.Min.Z - p.Z, p.Z - t.Max.Z)); double dz = Math.Max(0, Math.Max(t.Min.Z - p.Z, p.Z - t.Max.Z));
@@ -272,7 +276,7 @@ public static class InterferenceService
return (bestPt, bestD); return (bestPt, bestD);
} }
/// <summary>Closest point on a triangle, following Ericson's construction.</summary> /// <summary>點到三角形最近點(Ericson, Real-Time Collision Detection)</summary>
internal static Point3D ClosestPointOnTriangle(Point3D p, Point3D a, Point3D b, Point3D c) internal static Point3D ClosestPointOnTriangle(Point3D p, Point3D a, Point3D b, Point3D c)
{ {
Vector3D ab = b - a, ac = c - a, ap = p - a; Vector3D ab = b - a, ac = c - a, ap = p - a;
+33 -29
View File
@@ -10,13 +10,13 @@ using static STPViewer.Services.LocalizationService;
namespace STPViewer.Services; namespace STPViewer.Services;
/// <summary>One direction selected for angle measurement.</summary> /// <summary>角度量測的單次拾取:邊方向或面法向量</summary>
public record DirectionPick(Point3D At, Vector3D Direction, string Desc); public record DirectionPick(Point3D At, Vector3D Direction, string Desc);
/// <summary> /// <summary>
/// Computes measurements and their viewport overlays. /// 點/距離/邊/面/圓/角度/面距 量測幾何計算 + 視圖 overlay 產生。
/// B-rep provides exact edge lengths and radii; mesh values are approximate. /// 邊長、圓半徑以 B-rep 精確值為準;面積與面距用三角網格近似。
/// Text is deferred so unit and language changes update existing results. /// 所有文字以 Func&lt;UnitSystem,string&gt; 延後產生,支援 mm/inch 即時切換。
/// </summary> /// </summary>
public class MeasurementService public class MeasurementService
{ {
@@ -30,11 +30,13 @@ public class MeasurementService
private static GeoPoint ToGeo(Point3D p) => new(p.X, p.Y, p.Z); private static GeoPoint ToGeo(Point3D p) => new(p.X, p.Y, p.Z);
private static Point3D ToP3(GeoPoint p) => new(p.x, p.y, p.z); private static Point3D ToP3(GeoPoint p) => new(p.x, p.y, p.z);
// Picking // ─── 拾取輔助 ────────────────────────────────────────────────
/// <summary> /// <summary>
/// Snaps to the nearest vertex or circular edge within tolerance. /// 命中點吸附:點到圓形邊附近(容差內)→ 吸附**圓心**(量兩孔 pitch 的關鍵,v0.5.0);
/// A circular edge snaps to its center for hole pitch measurements. /// 否則吸附最近的 B-rep 頂點(容差內);都沒有 → 回傳原始表面點。
/// 圓心與頂點都在容差內時,比「命中點到圓形邊」與「命中點到頂點」誰近(圓弧端點常與圓重疊,
/// 點在圓邊上時圓心優先勝出才符合直覺)。
/// </summary> /// </summary>
public Point3D Snap(FaceInfo fi, Point3D hit, double tolerance) public Point3D Snap(FaceInfo fi, Point3D hit, double tolerance)
{ {
@@ -52,7 +54,7 @@ public class MeasurementService
} }
catch { } catch { }
// Compare distance to the circle edge before selecting its center. // 圓形邊:以「命中點到邊曲線」的距離參與比較,勝出時吸附到圓心
try try
{ {
foreach (Edge e in fi.BrepFace.AllEdges) foreach (Edge e in fi.BrepFace.AllEdges)
@@ -71,13 +73,14 @@ public class MeasurementService
} }
/// <summary> /// <summary>
/// Uses a nearby straight edge for direction, otherwise a face normal. /// 角度量測拾取:命中點附近有直線邊(容差內)→ 邊方向;
/// 否則取面法向量(B-rep 精確;無 B-rep 時用網格法向量)。
/// </summary> /// </summary>
public DirectionPick PickDirection(FaceInfo fi, Point3D hit, Vector3D meshNormal, double tolerance) public DirectionPick PickDirection(FaceInfo fi, Point3D hit, Vector3D meshNormal, double tolerance)
{ {
if (fi.BrepFace is not null) if (fi.BrepFace is not null)
{ {
// Prefer a nearby straight edge. // 1) 近距離直線邊優先
try try
{ {
foreach (Edge e in fi.BrepFace.AllEdges) foreach (Edge e in fi.BrepFace.AllEdges)
@@ -95,7 +98,7 @@ public class MeasurementService
} }
catch { } catch { }
// Use the B-rep face normal when available. // 2) 面法向量(精確)
try try
{ {
GeoPoint2D uv = fi.BrepFace.Surface.PositionOf(ToGeo(hit)); GeoPoint2D uv = fi.BrepFace.Surface.PositionOf(ToGeo(hit));
@@ -110,7 +113,7 @@ public class MeasurementService
return new DirectionPick(hit, mn, "Runtime.DirectionMeshFace"); return new DirectionPick(hit, mn, "Runtime.DirectionMeshFace");
} }
// Point and distance // ─── 點 / 距離 ───────────────────────────────────────────────
public MeasurementResult MeasurePoint(Point3D p, string label, double markerR) public MeasurementResult MeasurePoint(Point3D p, string label, double markerR)
{ {
@@ -146,9 +149,9 @@ public class MeasurementService
return r; return r;
} }
// Edge and circle // ─── 邊 / 圓 ─────────────────────────────────────────────────
/// <summary>Measures the nearest edge, or the nearest circular edge when circlesOnly is true.</summary> /// <summary>量測命中面上最近的邊。circlesOnly=true 時只找圓形邊。找不到回傳 null。</summary>
public MeasurementResult? MeasureEdge(FaceInfo fi, Point3D hit, string label, double markerR, bool circlesOnly) public MeasurementResult? MeasureEdge(FaceInfo fi, Point3D hit, string label, double markerR, bool circlesOnly)
{ {
if (fi.BrepFace is null) return null; if (fi.BrepFace is null) return null;
@@ -242,11 +245,11 @@ public class MeasurementService
return r; return r;
} }
// Face // ─── 面 ──────────────────────────────────────────────────────
public MeasurementResult MeasureFace(FaceInfo fi, Point3D hit, string label, double markerR) public MeasurementResult MeasureFace(FaceInfo fi, Point3D hit, string label, double markerR)
{ {
// Area is estimated from the triangle mesh. // 面積:三角網格加總(近似值,三角化精度內)
double area = MeshArea(fi.Mesh); double area = MeshArea(fi.Mesh);
int triCount = fi.Mesh.TriangleIndices.Count / 3; int triCount = fi.Mesh.TriangleIndices.Count / 3;
Func<UnitSystem, string> surfaceDesc = fi.BrepFace is not null Func<UnitSystem, string> surfaceDesc = fi.BrepFace is not null
@@ -260,7 +263,7 @@ public class MeasurementService
DetailFor = u => Format("Runtime.FaceDetail", Units.A(area, u), surfaceDesc(u), triCount), DetailFor = u => Format("Runtime.FaceDetail", Units.A(area, u), surfaceDesc(u), triCount),
}; };
// Highlight the outer edges and add a label. // overlay:外輪廓 highlight + 標籤
if (fi.BrepFace is not null) if (fi.BrepFace is not null)
{ {
try try
@@ -322,7 +325,7 @@ public class MeasurementService
var other => other, var other => other,
}; };
// Angle // ─── 角度(兩面 / 兩邊夾角)─────────────────────────────────
public MeasurementResult MeasureAngle(DirectionPick a, DirectionPick b, string label, double markerR, double arrowLen) public MeasurementResult MeasureAngle(DirectionPick a, DirectionPick b, string label, double markerR, double arrowLen)
{ {
@@ -357,7 +360,7 @@ public class MeasurementService
Thickness = 2, Thickness = 2,
}; };
// Face distance // ─── 面到面最短距離(網格近似)──────────────────────────────
public MeasurementResult MeasureFaceDistance(FaceInfo a, FaceInfo b, string label, double markerR) public MeasurementResult MeasureFaceDistance(FaceInfo a, FaceInfo b, string label, double markerR)
{ {
@@ -381,7 +384,7 @@ public class MeasurementService
return r; return r;
} }
/// <summary>Approximates the closest points by probing vertices against triangles in both directions.</summary> /// <summary>兩網格最近點對:頂點→三角形雙向(頂點過多時抽樣,結果為近似值)</summary>
private static (Point3D a, Point3D b) MeshMinDistance(MeshGeometry3D ma, MeshGeometry3D mb) private static (Point3D a, Point3D b) MeshMinDistance(MeshGeometry3D ma, MeshGeometry3D mb)
{ {
double best = double.MaxValue; double best = double.MaxValue;
@@ -414,18 +417,19 @@ public class MeasurementService
return (pa, pb); return (pa, pb);
} }
// Volume and centroid // ─── 體積 / 質心(封閉網格 signed volume)────────────────────
/// <summary> /// <summary>
/// Computes signed volume and centroid from origin-based tetrahedra. /// 網格體積與質心:各三角形與原點構成的四面體有向體積加總(divergence theorem)。
/// The result is reliable only for closed meshes. /// 只對「封閉」網格有意義(開放殼會得到不可靠的值 → IsClosedReliable=false 供呼叫端提示)。
/// 多個 mesh(多實體)直接加總。
/// </summary> /// </summary>
public static (double VolumeMm3, Point3D Centroid, bool Reliable) MeshVolume( public static (double VolumeMm3, Point3D Centroid, bool Reliable) MeshVolume(
System.Collections.Generic.IEnumerable<MeshGeometry3D> meshes) System.Collections.Generic.IEnumerable<MeshGeometry3D> meshes)
{ {
double vol6 = 0; double vol6 = 0; // Σ 6×有向體積
double absVol6 = 0; double absVol6 = 0; // Σ |6×有向體積|(封閉性 sanity check 用)
double cx = 0, cy = 0, cz = 0; double cx = 0, cy = 0, cz = 0; // Σ 四面體質心 × 6×有向體積
foreach (MeshGeometry3D mesh in meshes) foreach (MeshGeometry3D mesh in meshes)
{ {
Point3DCollection pos = mesh.Positions; Point3DCollection pos = mesh.Positions;
@@ -439,14 +443,14 @@ public class MeasurementService
+ a.Z * (b.X * c.Y - b.Y * c.X); + a.Z * (b.X * c.Y - b.Y * c.X);
vol6 += v; vol6 += v;
absVol6 += Math.Abs(v); absVol6 += Math.Abs(v);
// Tetrahedron centroid = (a + b + c) / 4. // 四面體(a,b,c,原點)質心 = (a+b+c)/4
cx += (a.X + b.X + c.X) / 4 * v; cx += (a.X + b.X + c.X) / 4 * v;
cy += (a.Y + b.Y + c.Y) / 4 * v; cy += (a.Y + b.Y + c.Y) / 4 * v;
cz += (a.Z + b.Z + c.Z) / 4 * v; cz += (a.Z + b.Z + c.Z) / 4 * v;
} }
} }
double volume = Math.Abs(vol6) / 6; double volume = Math.Abs(vol6) / 6;
// Large signed cancellation suggests an open mesh or inconsistent normals. // 有向體積相對正負抵銷比例過高 → 可能是開放殼/法向不一致,數值不可靠
bool reliable = absVol6 > 0 && Math.Abs(vol6) / absVol6 > 0.01 && volume > 1e-9; bool reliable = absVol6 > 0 && Math.Abs(vol6) / absVol6 > 0.01 && volume > 1e-9;
Point3D centroid = Math.Abs(vol6) > 1e-12 Point3D centroid = Math.Abs(vol6) > 1e-12
? new Point3D(cx / vol6, cy / vol6, cz / vol6) ? new Point3D(cx / vol6, cy / vol6, cz / vol6)
@@ -454,7 +458,7 @@ public class MeasurementService
return (volume, centroid, reliable); return (volume, centroid, reliable);
} }
// Shares the triangle closest-point calculation with InterferenceService. // 點到三角形最近點:共用 InterferenceService.ClosestPointOnTriangle(v0.4.0 去重複)
// ─── overlay helpers ───────────────────────────────────────── // ─── overlay helpers ─────────────────────────────────────────
+8 -7
View File
@@ -4,14 +4,15 @@ using System.Windows.Media.Media3D;
namespace STPViewer.Services; namespace STPViewer.Services;
/// <summary> /// <summary>
/// Rigid transformations for three-point alignment and rotation. /// 三點對齊 / 旋轉的剛體變換數學。
/// <see cref="ToModOp"/> converts WPF <see cref="Matrix3D"/> to CADability's matrix convention. /// 同一個變換需要兩種表示:WPF <see cref="Matrix3D"/>(列向量約定,給網格/邊線)與
/// CADability <see cref="CADability.ModOp"/>(行向量約定,給 B-rep Modify)— 用 <see cref="ToModOp"/> 轉換。
/// </summary> /// </summary>
public static class RigidAlign public static class RigidAlign
{ {
/// <summary> /// <summary>
/// Computes a rigid transform from three source points to three target points. /// 來源 3 點 → 目標 3 點 的剛體變換(不縮放):
/// Returns false for collinear or degenerate points. /// p1→q1 精確貼合、p1p2 方向對齊 q1q2、三點平面對齊。共線/退化回傳 false。
/// </summary> /// </summary>
public static bool TryRigidTransform( public static bool TryRigidTransform(
Point3D p1, Point3D p2, Point3D p3, Point3D p1, Point3D p2, Point3D p3,
@@ -21,7 +22,7 @@ public static class RigidAlign
if (!TryFrame(p1, p2, p3, out Vector3D xp, out Vector3D yp, out Vector3D zp)) return false; if (!TryFrame(p1, p2, p3, out Vector3D xp, out Vector3D yp, out Vector3D zp)) return false;
if (!TryFrame(q1, q2, q3, out Vector3D xq, out Vector3D yq, out Vector3D zq)) return false; if (!TryFrame(q1, q2, q3, out Vector3D xq, out Vector3D yq, out Vector3D zq)) return false;
// R = Bq · Bpᵀ, transposed for Matrix3D's convention. // R = Bq · Bpᵀ(行向量約定);WPF Matrix3D 為列向量約定 → 轉置擺放
double R(int r, int c) double R(int r, int c)
{ {
double xpc = c == 0 ? xp.X : c == 1 ? xp.Y : xp.Z; double xpc = c == 0 ? xp.X : c == 1 ? xp.Y : xp.Z;
@@ -46,7 +47,7 @@ public static class RigidAlign
return true; return true;
} }
/// <summary>Converts a WPF matrix to a CADability ModOp.</summary> /// <summary>WPF Matrix3D(列向量約定)→ CADability ModOp(行向量約定,3×4)</summary>
public static CADability.ModOp ToModOp(Matrix3D m) => new(new double[3, 4] public static CADability.ModOp ToModOp(Matrix3D m) => new(new double[3, 4]
{ {
{ m.M11, m.M21, m.M31, m.OffsetX }, { m.M11, m.M21, m.M31, m.OffsetX },
@@ -57,7 +58,7 @@ public static class RigidAlign
public static bool Collinear(Point3D a, Point3D b, Point3D c) => public static bool Collinear(Point3D a, Point3D b, Point3D c) =>
Vector3D.CrossProduct(b - a, c - a).LengthSquared < 1e-12; Vector3D.CrossProduct(b - a, c - a).LengthSquared < 1e-12;
/// <summary>Builds an orthogonal frame from three points; returns false for collinear points.</summary> /// <summary>三點 → 正交座標架(x=1→2 方向、z=平面法向、y=z×x);共線回傳 false</summary>
private static bool TryFrame(Point3D a, Point3D b, Point3D c, private static bool TryFrame(Point3D a, Point3D b, Point3D c,
out Vector3D x, out Vector3D y, out Vector3D z) out Vector3D x, out Vector3D y, out Vector3D z)
{ {
+5 -5
View File
@@ -4,8 +4,8 @@ using System.Windows.Media.Media3D;
namespace STPViewer.Services; namespace STPViewer.Services;
/// <summary> /// <summary>
/// Clips meshes and line segments to the half-space dot(p - p0, n) ≤ 0. /// 剖面:把三角網格 / 線段集用半空間裁切(保留 dot(p - p0, n) ≤ 0 的一側)。
/// The original geometry remains available for restoration. /// WPF 3D 沒有 GPU clip plane,採 CPU 裁切後置換 Geometry(原始 mesh 保留可還原)。
/// </summary> /// </summary>
public static class SectionService public static class SectionService
{ {
@@ -23,7 +23,7 @@ public static class SectionService
Point3DCollection pos = src.Positions; Point3DCollection pos = src.Positions;
Int32Collection idx = src.TriangleIndices; Int32Collection idx = src.TriangleIndices;
// Cache signed distances for each vertex. // 頂點距離快取
var dist = new double[pos.Count]; var dist = new double[pos.Count];
for (int i = 0; i < pos.Count; i++) for (int i = 0; i < pos.Count; i++)
dist[i] = Vector3D.DotProduct(pos[i] - planePoint, normal); dist[i] = Vector3D.DotProduct(pos[i] - planePoint, normal);
@@ -47,7 +47,7 @@ public static class SectionService
continue; continue;
} }
// Clip the polygon and triangulate it as a fan. // 部分在保留側:Sutherland-Hodgman 裁出 3 或 4 邊形,扇形分割
int n = 0; int n = 0;
for (int k = 0; k < 3; k++) for (int k = 0; k < 3; k++)
{ {
@@ -78,7 +78,7 @@ public static class SectionService
idx.Add(i); idx.Add(i + 1); idx.Add(i + 2); idx.Add(i); idx.Add(i + 1); idx.Add(i + 2);
} }
/// <summary>Clips paired line segment endpoints.</summary> /// <summary>裁切線段集(成對端點)</summary>
public static Point3DCollection ClipSegments(Point3DCollection segments, Point3D planePoint, Vector3D normal) public static Point3DCollection ClipSegments(Point3DCollection segments, Point3D planePoint, Vector3D normal)
{ {
var result = new Point3DCollection(); var result = new Point3DCollection();
+3 -3
View File
@@ -5,7 +5,7 @@ using System.Text.Json;
namespace STPViewer.Services; namespace STPViewer.Services;
/// <summary>User settings stored in %LOCALAPPDATA%\STPViewer\settings.json.</summary> /// <summary>使用者設定(%LOCALAPPDATA%\STPViewer\settings.json)</summary>
public class AppSettings public class AppSettings
{ {
public double WindowWidth { get; set; } = double.NaN; public double WindowWidth { get; set; } = double.NaN;
@@ -17,11 +17,11 @@ public class AppSettings
public string? Language { get; set; } public string? Language { get; set; }
public List<string> RecentFiles { get; set; } = new(); public List<string> RecentFiles { get; set; } = new();
/// <summary>Visible quick bar keys; null uses registry defaults.</summary> /// <summary>快速工具列顯示的按鈕 key 清單;null = 用預設(QuickBarViewModel.Registry)</summary>
public List<string>? QuickBarKeys { get; set; } public List<string>? QuickBarKeys { get; set; }
} }
/// <summary>Loads defaults for missing or invalid settings and ignores save failures.</summary> /// <summary>設定讀寫:壞檔/缺檔一律回預設值,儲存失敗靜默(設定不值得讓程式閃退)</summary>
public static class SettingsService public static class SettingsService
{ {
public const int MaxRecentFiles = 10; public const int MaxRecentFiles = 10;
+37 -31
View File
@@ -6,15 +6,15 @@ using System.Windows.Media;
using System.Windows.Media.Media3D; using System.Windows.Media.Media3D;
using CADability; using CADability;
using CADability.GeoObject; using CADability.GeoObject;
using IOPath = System.IO.Path; using IOPath = System.IO.Path; // CADability.GeoObject.Path(曲線)撞名
using static STPViewer.Services.LocalizationService; using static STPViewer.Services.LocalizationService;
namespace STPViewer.Services; namespace STPViewer.Services;
/// <summary>A frozen triangle mesh for one B-rep face; BrepFace is null for STL.</summary> /// <summary>單一 B-rep Face 的三角網格(mesh 已 Freeze,可跨執行緒);STL 來源 BrepFace 為 null</summary>
public record ImportedFace(Face? BrepFace, MeshGeometry3D Mesh); public record ImportedFace(Face? BrepFace, MeshGeometry3D Mesh);
/// <summary>An imported assembly group or geometry leaf.</summary> /// <summary>匯入後的階層節點:group(裝配)或 leaf(零件幾何)</summary>
public class ImportedNode public class ImportedNode
{ {
public string Name { get; set; } = ""; public string Name { get; set; } = "";
@@ -22,15 +22,15 @@ public class ImportedNode
public int NameNumber { get; set; } public int NameNumber { get; set; }
public List<ImportedNode> Children { get; } = new(); public List<ImportedNode> Children { get; } = new();
// Leaf geometry // ── leaf 幾何 ──
public List<ImportedFace> Faces { get; } = new(); public List<ImportedFace> Faces { get; } = new();
public Point3DCollection? EdgeSegments { get; set; } // Frozen paired endpoints. public Point3DCollection? EdgeSegments { get; set; } // 輪廓線(成對端點,已 Freeze)
public bool HasBrep { get; set; } = true; // False for STL and curves. public bool HasBrep { get; set; } = true; // STL / 純曲線 = false
/// <summary>Source B-rep objects used for whole-part transforms.</summary> /// <summary>來源 B-rep 物件(Solid/Shell/Face/曲線)— 零件平移時整體 Modify 用</summary>
public List<CADability.GeoObject.IGeoObject> SourceGeos { get; } = new(); public List<CADability.GeoObject.IGeoObject> SourceGeos { get; } = new();
// Group totals are calculated by Aggregate. // ── 統計(group 節點由 Aggregate 彙總)──
public int SolidCount { get; set; } public int SolidCount { get; set; }
public int FaceCount { get; set; } public int FaceCount { get; set; }
public int TriangleCount { get; set; } public int TriangleCount { get; set; }
@@ -42,12 +42,12 @@ public class ImportedNode
public record ImportedFileData(string FilePath, ImportedNode Root); public record ImportedFileData(string FilePath, ImportedNode Root);
/// <summary> /// <summary>
/// Imports STEP, STL and DXF files on a background thread. /// CAD 檔匯入(STEP / STL / DXF)。STEP 以 HierarchyToBlocks 還原裝配樹。
/// Returned WPF Freezables are frozen. /// 設計為在背景執行緒呼叫;回傳的 Freezable 全部已 Freeze。
/// </summary> /// </summary>
public class StepImportService public class StepImportService
{ {
/// <summary>Import progress for the status bar.</summary> /// <summary>匯入階段回報(耗時分解),UI 可顯示於狀態列;null 則靜默</summary>
public Action<string>? Progress { get; set; } public Action<string>? Progress { get; set; }
public Action<Func<string>>? ProgressTextFor { get; set; } public Action<Func<string>>? ProgressTextFor { get; set; }
@@ -57,14 +57,15 @@ public class StepImportService
ProgressTextFor?.Invoke(textFor); ProgressTextFor?.Invoke(textFor);
} }
private long _triTicks, _edgeTicks; // Accumulated tessellation and edge sampling ticks. private long _triTicks, _edgeTicks; // 三角化 / 邊取樣 累計耗時(TimeSpan ticks,避免逐面 ms 截斷歸零)
private double TriMs => new TimeSpan(_triTicks).TotalMilliseconds; private double TriMs => new TimeSpan(_triTicks).TotalMilliseconds;
private double EdgeMs => new TimeSpan(_edgeTicks).TotalMilliseconds; private double EdgeMs => new TimeSpan(_edgeTicks).TotalMilliseconds;
/// <summary>Geometry work queued per leaf, then run in parallel. Faces within a leaf share edges.</summary> /// <summary>延後的 leaf 幾何工作(三角化+邊取樣):樹走訪時收集,之後平行執行。
/// 平行粒度 = 整個 leaf(同 leaf 的面共用 Edge,不可面級平行)。</summary>
private readonly List<Action> _leafWork = new(); private readonly List<Action> _leafWork = new();
/// <summary>Faces to retry sequentially after parallel tessellation.</summary> /// <summary>平行三角化偶發失敗的面(CADability 跨 leaf 仍有少量共享狀態)→ 平行結束後循序重試</summary>
private readonly System.Collections.Concurrent.ConcurrentBag<(ImportedNode Leaf, HashSet<Edge> EdgeSet, List<(Face F, double Prec)> Failed)> _retry = new(); private readonly System.Collections.Concurrent.ConcurrentBag<(ImportedNode Leaf, HashSet<Edge> EdgeSet, List<(Face F, double Prec)> Failed)> _retry = new();
public ImportedFileData Import(string filePath) public ImportedFileData Import(string filePath)
@@ -97,7 +98,7 @@ public class StepImportService
}); });
_leafWork.Clear(); _leafWork.Clear();
// Retry failed faces sequentially after the parallel pass. // 平行下偶發失敗的面:競爭已結束,循序重試一輪(FinishLeaf 也延到此時才跑)
int retried = 0; int retried = 0;
foreach ((ImportedNode leaf, HashSet<Edge> edgeSet, var failed) in _retry) foreach ((ImportedNode leaf, HashSet<Edge> edgeSet, var failed) in _retry)
{ {
@@ -136,7 +137,12 @@ public class StepImportService
BuildChildren(root, EnumerateGeo(list)); BuildChildren(root, EnumerateGeo(list));
} }
// STL is read directly as mesh geometry without B-rep objects. // ─── STL(純網格,無 B-rep)──
// 直接解析三角形(StlMeshReader),**不經過 CADability**。CADability 的 ImportSTL 會為每個
// 三角形建一個 B-rep Face(含 Edge/Vertex 拓樸),百萬三角形需數十分鐘且吃光記憶體
// (實測 100k 三角形:CADability ~41 s,直接解析 ~11 ms)。STL 本就無 B-rep,讀三角形即可。
// ⚠️ 因此 STL leaf 沒有 SourceGeos(CADability B-rep)→ 不進 STEP 匯出(本就非 STEP B-rep 幾何);
// 剛體變換仍走網格層(TransformRoot 的 SourceGeos 迴圈空轉、mesh 照樣變換),量測走網格近似不受影響。
private void ImportStlFile(string path, ImportedNode root) private void ImportStlFile(string path, ImportedNode root)
{ {
@@ -161,7 +167,7 @@ public class StepImportService
NameNumber = g.NameNumber, NameNumber = g.NameNumber,
HasBrep = false, HasBrep = false,
SolidCount = 1, SolidCount = 1,
FaceCount = tris, FaceCount = tris, // STL 無 B-rep 面 → 以三角形數代表
TriangleCount = tris, TriangleCount = tris,
Bounds = BoundsOfMesh(mesh), Bounds = BoundsOfMesh(mesh),
}; };
@@ -173,7 +179,7 @@ public class StepImportService
ReportProgress(() => Format("Runtime.StlParsed", elapsed, meshCount)); ReportProgress(() => Format("Runtime.StlParsed", elapsed, meshCount));
} }
// DXF // ─── DXF(多為線架構;Solid/Face 走同一套,曲線取樣成線段)──
private void ImportDxfFile(string path, ImportedNode root) private void ImportDxfFile(string path, ImportedNode root)
{ {
@@ -184,7 +190,7 @@ public class StepImportService
BuildChildren(root, EnumerateGeo(model.AllObjects)); BuildChildren(root, EnumerateGeo(model.AllObjects));
} }
// Assembly traversal // ─── 階層走訪 ────────────────────────────────────────────────
private static IEnumerable<IGeoObject> EnumerateGeo(System.Collections.IEnumerable list) private static IEnumerable<IGeoObject> EnumerateGeo(System.Collections.IEnumerable list)
{ {
@@ -201,7 +207,7 @@ public class StepImportService
private void BuildChildren(ImportedNode parent, IEnumerable<IGeoObject> objects) private void BuildChildren(ImportedNode parent, IEnumerable<IGeoObject> objects)
{ {
var looseFaces = new List<Face>(); var looseFaces = new List<Face>();
var looseCurves = new List<IGeoObject>(); var looseCurves = new List<IGeoObject>(); // 皆為 ICurve
foreach (IGeoObject go in objects) foreach (IGeoObject go in objects)
{ {
@@ -245,7 +251,7 @@ public class StepImportService
if (failed.Count > 0) _retry.Add((leaf, edgeSet, failed)); if (failed.Count > 0) _retry.Add((leaf, edgeSet, failed));
else FinishLeaf(leaf, edgeSet); else FinishLeaf(leaf, edgeSet);
}); });
parent.Children.Add(leaf); parent.Children.Add(leaf); // 三角化延後,空 leaf 由 Prune 收掉
} }
if (looseCurves.Count > 0) if (looseCurves.Count > 0)
{ {
@@ -269,7 +275,7 @@ public class StepImportService
private ImportedNode? BlockNode(Block block) private ImportedNode? BlockNode(Block block)
{ {
// Collapse a block containing one solid into a leaf. // Block 只有單一同名 Solid → 折疊成 leaf(STEP 常見的 product→solid 包裝)
if (block.NumChildren == 1 && block.Child(0) is Solid s) if (block.NumChildren == 1 && block.Child(0) is Solid s)
{ {
ImportedNode leaf = SolidNode(s); ImportedNode leaf = SolidNode(s);
@@ -290,7 +296,7 @@ public class StepImportService
BuildChildren(node, ChildrenOf(block)); BuildChildren(node, ChildrenOf(block));
if (node.Children.Count == 0) return null; if (node.Children.Count == 0) return null;
// Drop a redundant group when it has one child with the same name. // 單鏈折疊:group 只有一個子節點且名稱相同/空白 → 去掉中間層
if (node.Children.Count == 1) if (node.Children.Count == 1)
{ {
ImportedNode only = node.Children[0]; ImportedNode only = node.Children[0];
@@ -354,7 +360,7 @@ public class StepImportService
return leaf; return leaf;
} }
// Geometry processing // ─── 幾何處理 ────────────────────────────────────────────────
private static BoundingCube SafeBounds(Func<BoundingCube> get) private static BoundingCube SafeBounds(Func<BoundingCube> get)
{ {
@@ -362,7 +368,7 @@ public class StepImportService
catch { return new BoundingCube(); } catch { return new BoundingCube(); }
} }
/// <summary>Tessellation precision: bounding diagonal × 0.0015, clamped to [0.02, 0.5] mm.</summary> /// <summary>三角化精度依物件大小調整:對角線 × 0.0015,限制在 [0.02, 0.5] mm</summary>
private static double PrecisionFor(BoundingCube bc) private static double PrecisionFor(BoundingCube bc)
{ {
double diag; double diag;
@@ -372,7 +378,7 @@ public class StepImportService
return Math.Clamp(diag * 0.0015, 0.02, 0.5); return Math.Clamp(diag * 0.0015, 0.02, 0.5);
} }
/// <returns>False when tessellation fails and the face needs a retry.</returns> /// <returns>false = 三角化失敗(平行下偶發,可循序重試)</returns>
private bool AddFace(Face face, double precision, ImportedNode leaf, HashSet<Edge> edgeSet) private bool AddFace(Face face, double precision, ImportedNode leaf, HashSet<Edge> edgeSet)
{ {
GeoPoint[] points; int[] indices; GeoPoint[] points; int[] indices;
@@ -383,7 +389,7 @@ public class StepImportService
} }
catch catch
{ {
return false; return false; // 個別面三角化失敗時跳過,不讓整檔失敗
} }
finally finally
{ {
@@ -411,7 +417,7 @@ public class StepImportService
return true; return true;
} }
/// <summary>Completes edge sampling, counts and bounds for one leaf.</summary> /// <summary>leaf 收尾:邊取樣 + 統計 + 邊界</summary>
private void FinishLeaf(ImportedNode leaf, HashSet<Edge> edgeSet) private void FinishLeaf(ImportedNode leaf, HashSet<Edge> edgeSet)
{ {
var sw = System.Diagnostics.Stopwatch.StartNew(); var sw = System.Diagnostics.Stopwatch.StartNew();
@@ -448,7 +454,7 @@ public class StepImportService
return segments; return segments;
} }
/// <summary>Samples curves into paired line endpoints.</summary> /// <summary>把曲線取樣成線段對(直線 1 段、曲線 12 段)</summary>
private static void AppendCurveSegments(Point3DCollection segments, ICurve curve) private static void AppendCurveSegments(Point3DCollection segments, ICurve curve)
{ {
int n = curve is Line ? 1 : 12; int n = curve is Line ? 1 : 12;
@@ -483,7 +489,7 @@ public class StepImportService
Math.Max(maxX - minX, 1e-6), Math.Max(maxY - minY, 1e-6), Math.Max(maxZ - minZ, 1e-6)); Math.Max(maxX - minX, 1e-6), Math.Max(maxY - minY, 1e-6), Math.Max(maxZ - minZ, 1e-6));
} }
/// <summary>Removes nodes left empty after tessellation.</summary> /// <summary>移除沒有任何幾何的節點(延後三角化全失敗的 leaf、因此變空的 group)</summary>
private static void Prune(ImportedNode node) private static void Prune(ImportedNode node)
{ {
for (int i = node.Children.Count - 1; i >= 0; i--) for (int i = node.Children.Count - 1; i >= 0; i--)
@@ -495,7 +501,7 @@ public class StepImportService
} }
} }
/// <summary>Aggregates counts and bounds from children.</summary> /// <summary>group 節點統計彙總(bottom-up)</summary>
private static void Aggregate(ImportedNode node) private static void Aggregate(ImportedNode node)
{ {
if (node.IsLeaf) return; if (node.IsLeaf) return;
+21 -12
View File
@@ -11,22 +11,27 @@ using CADability.GeoObject;
namespace STPViewer.Services; namespace STPViewer.Services;
/// <summary> /// <summary>
/// Mesh quality: Current uses the displayed mesh; Fine retessellates B-rep geometry. /// 網格精度:Current = 目前顯示網格;Fine = 由 B-rep 以較細精度重新三角化(無 B-rep 的來源維持現有網格)。
/// 不提供「較粗」:CADability 的 GetTriangulation 對「比快取粗」的精度要求會直接回傳既有較細快取,
/// 匯入時已三角化過 → 要求粗化實際上拿到同一份網格(實測驗證),故誠實不提供。
/// </summary> /// </summary>
public enum StlMeshQuality { Current, Fine } public enum StlMeshQuality { Current, Fine }
/// <summary> /// <summary>
/// Writes WPF triangle meshes as binary or ASCII STL; scale=1 uses millimeters. /// STL 匯出:WPF 三角網格 → binary / ASCII STL。
/// STL 本身無單位,約定輸出 mm(scale=1);inch 交接用 scale = 1/25.4。
/// </summary> /// </summary>
public static class StlExportService public static class StlExportService
{ {
/// <summary> /// <summary>
/// Tessellation factor relative to import precision; 0.15 produces a finer mesh. /// 三角化精度係數(相對匯入預設):Fine 較細(三角形多、曲面平滑)。
/// 實測 CADability 三角形數對精度非單調(0.4× 重算反而比 1× 少 — 重算網格較有效率),
/// 要明顯更細需 ≤0.15×(test.stp:1× = 30,784 → 0.1× = 42,220 三角形)。
/// </summary> /// </summary>
public static double PrecisionFactor(StlMeshQuality q) => public static double PrecisionFactor(StlMeshQuality q) =>
q == StlMeshQuality.Fine ? 0.15 : 1.0; q == StlMeshQuality.Fine ? 0.15 : 1.0;
/// <summary>Estimated file size: 84 + 50 bytes per binary triangle, about 230 per ASCII triangle.</summary> /// <summary>預估檔案大小:binary = 84 + 50×n;ASCII 每三角形約 230 bytes</summary>
public static long EstimateBytes(long triangles, bool ascii) => public static long EstimateBytes(long triangles, bool ascii) =>
ascii ? 20 + triangles * 230 : 84 + triangles * 50; ascii ? 20 + triangles * 230 : 84 + triangles * 50;
@@ -39,7 +44,8 @@ public static class StlExportService
}; };
/// <summary> /// <summary>
/// Writes meshes to one STL file, skipping zero-area triangles. Returns the triangle count. /// 把多個網格寫成一個 STL 檔。退化(零面積)三角形濾除。
/// 回傳實際寫出的三角形數。
/// </summary> /// </summary>
public static int Write(string path, IReadOnlyList<MeshGeometry3D> meshes, bool ascii, double scale) => public static int Write(string path, IReadOnlyList<MeshGeometry3D> meshes, bool ascii, double scale) =>
ascii ? WriteAscii(path, meshes, scale) : WriteBinary(path, meshes, scale); ascii ? WriteAscii(path, meshes, scale) : WriteBinary(path, meshes, scale);
@@ -49,11 +55,11 @@ public static class StlExportService
using var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, 1 << 16); using var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, 1 << 16);
using var bw = new BinaryWriter(fs); using var bw = new BinaryWriter(fs);
// Avoid "solid" in the header; some readers would mistake it for ASCII. // 80-byte header(不可以 "solid" 開頭,部分讀取器會誤判為 ASCII)
var header = new byte[80]; var header = new byte[80];
Encoding.ASCII.GetBytes("STPViewer binary STL (unit: mm x scale)").CopyTo(header, 0); Encoding.ASCII.GetBytes("STPViewer binary STL (unit: mm x scale)").CopyTo(header, 0);
bw.Write(header); bw.Write(header);
bw.Write(0u); // Backfilled after writing triangles. bw.Write(0u); // 三角形數 placeholder,寫完回頭補
int count = 0; int count = 0;
foreach (MeshGeometry3D mesh in meshes) foreach (MeshGeometry3D mesh in meshes)
@@ -119,7 +125,7 @@ public static class StlExportService
sw.WriteLine(string.Format(inv, " vertex {0:e6} {1:e6} {2:e6}", sw.WriteLine(string.Format(inv, " vertex {0:e6} {1:e6} {2:e6}",
p.X * scale, p.Y * scale, p.Z * scale)); p.X * scale, p.Y * scale, p.Z * scale));
/// <returns>False for a zero-area triangle.</returns> /// <returns>false = 退化三角形(面積 ≈ 0)</returns>
private static bool TryNormal(Point3D a, Point3D b, Point3D c, out Vector3D n) private static bool TryNormal(Point3D a, Point3D b, Point3D c, out Vector3D n)
{ {
n = Vector3D.CrossProduct(b - a, c - a); n = Vector3D.CrossProduct(b - a, c - a);
@@ -129,11 +135,12 @@ public static class StlExportService
return true; return true;
} }
// B-rep retessellation // ─── B-rep 重新三角化(精細/較粗匯出用)────────────────────────
/// <summary> /// <summary>
/// Retessellates B-rep geometry into frozen meshes, one per face. /// 由 B-rep 幾何以指定精度係數重新三角化,回傳每面一個 frozen mesh。
/// Call sequentially because CADability tessellation is not thread-safe. /// CADability 非執行緒安全 → 呼叫端維持循序(可在單一背景執行緒)。
/// 個別面三角化失敗時跳過(與匯入一致的容錯策略)。
/// </summary> /// </summary>
public static List<MeshGeometry3D> Tessellate(IEnumerable<IGeoObject> geos, double precisionFactor) public static List<MeshGeometry3D> Tessellate(IEnumerable<IGeoObject> geos, double precisionFactor)
{ {
@@ -163,7 +170,9 @@ public static class StlExportService
} }
/// <summary> /// <summary>
/// Clamp the import precision before applying factor so Fine is finer than the cached mesh. /// 匯入預設精度(與 StepImportService.PrecisionFor 同款 clamp)再乘 factor —
/// 先 clamp 再乘,保證 factor&lt;1 時嚴格比匯入快取細(否則大殼在匯入被 0.5 上限截住,
/// 直接乘在 clamp 前會算出比快取粗的值 → CADability 回傳快取、精細無效)。
/// </summary> /// </summary>
private static double PrecisionFor(BoundingCube bc, double factor) private static double PrecisionFor(BoundingCube bc, double factor)
{ {
+12 -7
View File
@@ -8,11 +8,14 @@ using System.Windows.Media.Media3D;
namespace STPViewer.Services; namespace STPViewer.Services;
/// <summary> /// <summary>
/// Reads binary and ASCII STL triangles directly into WPF meshes. /// 直接解析 STL 三角網格(binary / ASCII)→ WPF mesh,**不經過 CADability**。
/// CADability 的 ImportSTL 會為每個三角形建一個 B-rep Face(含 Edge/Vertex 拓樸),
/// 大檔(百萬三角形)需數十分鐘且吃光記憶體;STL 本就無 B-rep,直接讀三角形即可。
/// 實測 100k 三角形:CADability 約 41,000 ms,直接解析約 11 ms。
/// </summary> /// </summary>
public static class StlMeshReader public static class StlMeshReader
{ {
/// <summary>Mesh for one solid section; vertices remain unwelded.</summary> /// <summary>一個 solid 區塊的網格(頂點未焊接:每三角形 3 個獨立頂點,與 STL 原生一致)</summary>
public record MeshGroup(string Name, Point3DCollection Positions, Int32Collection Indices, public record MeshGroup(string Name, Point3DCollection Positions, Int32Collection Indices,
string? NameKey = null, int NameNumber = 0); string? NameKey = null, int NameNumber = 0);
@@ -22,12 +25,13 @@ public static class StlMeshReader
} }
/// <summary> /// <summary>
/// Binary STL has exactly 84 + 50 × triangle count bytes; its header may also start with "solid". /// binary STL 判定:檔案大小必須剛好等於 84 + 50×count(count = offset 80 的 uint32)。
/// 這是最可靠的判別法 — binary 的 80-byte header 常也以 "solid" 開頭,不能只看開頭字串。
/// </summary> /// </summary>
private static bool IsBinary(string path) private static bool IsBinary(string path)
{ {
var fi = new FileInfo(path); var fi = new FileInfo(path);
if (fi.Length < 84) return false; if (fi.Length < 84) return false; // 太小,當 ASCII 讓後續拋有意義的錯
Span<byte> head = stackalloc byte[84]; Span<byte> head = stackalloc byte[84];
using (FileStream fs = File.OpenRead(path)) using (FileStream fs = File.OpenRead(path))
{ {
@@ -55,8 +59,8 @@ public static class StlMeshReader
int off = 84, vi = 0; int off = 84, vi = 0;
for (uint t = 0; t < count; t++) for (uint t = 0; t < count; t++)
{ {
if (off + 50 > bytes.Length) break; if (off + 50 > bytes.Length) break; // 檔案被截斷:保住已解析的部分
off += 12; // WPF computes the normal. off += 12; // 跳過 normal(渲染用不到;WPF 自算法向)
for (int v = 0; v < 3; v++) for (int v = 0; v < 3; v++)
{ {
float x = BitConverter.ToSingle(span[off..]); float x = BitConverter.ToSingle(span[off..]);
@@ -72,7 +76,8 @@ public static class StlMeshReader
} }
/// <summary> /// <summary>
/// ASCII STL: read vertex triples and start a new group at each solid section. /// ASCII STL:逐行掃 "vertex x y z",每 3 個湊成一個三角形;"solid &lt;name&gt;" 起新群組。
/// ASCII 大檔罕見(同樣三角數約為 binary 的 5 倍體積),故以正確性為主。
/// </summary> /// </summary>
private static List<MeshGroup> ReadAscii(string path) private static List<MeshGroup> ReadAscii(string path)
{ {
+1 -1
View File
@@ -3,7 +3,7 @@ using STPViewer.ViewModels;
namespace STPViewer; namespace STPViewer;
/// <summary>STL export settings dialog; the view model owns the options.</summary> /// <summary>STL 匯出參數對話框 — 選項邏輯在 StlExportViewModel,這裡只做確認/取消</summary>
public partial class StlExportDialog : Window public partial class StlExportDialog : Window
{ {
public StlExportViewModel ViewModel { get; } public StlExportViewModel ViewModel { get; }
+13 -12
View File
@@ -6,22 +6,22 @@ using static STPViewer.Services.LocalizationService;
namespace STPViewer.ViewModels; namespace STPViewer.ViewModels;
// Drag mode // ─── 拖曳模式(左鍵按住零件沿螢幕平面拖;放開才烘進 B-rep)──────────
public partial class MainViewModel public partial class MainViewModel
{ {
// Preview with a transform, then commit to B-rep on release. // 拖曳模式:暫時 Transform 跟著滑鼠,放開才一次性烘進 B-rep(TranslateRoot)
private ModelNodeViewModel? _dragRoot; private ModelNodeViewModel? _dragRoot;
private Point3D _dragAnchor; private Point3D _dragAnchor; // 命中點(世界座標),拖曳平面的錨點
private Vector3D _dragApplied; private Vector3D _dragApplied; // 目前累計位移
private TranslateTransform3D? _dragTransform; private TranslateTransform3D? _dragTransform;
/// <summary>Starts dragging a hit part; returns true when the mouse should be captured.</summary> /// <summary>左鍵按下:命中零件則開始拖曳(回傳 true 表示要捕捉滑鼠)</summary>
public bool OnDragStart(Point position) public bool OnDragStart(Point position)
{ {
if (_viewport is null || IsBusy || CurrentMode != MeasureMode.Drag || _dragRoot is not null) if (_viewport is null || IsBusy || CurrentMode != MeasureMode.Drag || _dragRoot is not null)
return false; return false;
// Hits can come from merged meshes or section faces. // 兩種渲染內容都可能在場:合併網格查 _mergedMap、逐面(剖面時)查 _faceMap
ModelNodeViewModel? leaf = null; ModelNodeViewModel? leaf = null;
Point3D anchor = default; Point3D anchor = default;
foreach (var h in _viewport.Viewport.FindHits(position)) foreach (var h in _viewport.Viewport.FindHits(position))
@@ -42,14 +42,14 @@ public partial class MainViewModel
if (l.BodyVisual is not null) if (l.BodyVisual is not null)
l.BodyVisual.Transform = _dragTransform; l.BodyVisual.Transform = _dragTransform;
// Hide edges while moving to avoid rebuilding them each frame. // 拖曳中隱藏邊線(LinesVisual3D 隨 Transform 變更逐幀重建會卡)
_edgesSuspended = true; _edgesSuspended = true;
SetEdgesActive(false); SetEdgesActive(false);
SetStatus(() => Format("Runtime.Dragging", root.Name)); SetStatus(() => Format("Runtime.Dragging", root.Name));
return true; return true;
} }
/// <summary>Projects pointer movement onto the camera-facing plane through the anchor.</summary> /// <summary>滑鼠移動:把 2D 位移投影到「過錨點、面向相機」的平面上 → 暫時 Transform</summary>
public void OnDragMove(Point position) public void OnDragMove(Point position)
{ {
if (_viewport?.Camera is not System.Windows.Media.Media3D.ProjectionCamera cam || if (_viewport?.Camera is not System.Windows.Media.Media3D.ProjectionCamera cam ||
@@ -63,14 +63,15 @@ public partial class MainViewModel
_dragTransform.OffsetZ = _dragApplied.Z; _dragTransform.OffsetZ = _dragApplied.Z;
} }
/// <summary>Commits the temporary transform to B-rep on release.</summary> /// <summary>放開:移除暫時 Transform,一次性烘進 B-rep(量測精度不受拖曳影響)</summary>
public bool OnDragEnd() public bool OnDragEnd()
{ {
if (_dragRoot is null) return false; if (_dragRoot is null) return false;
ModelNodeViewModel root = _dragRoot; ModelNodeViewModel root = _dragRoot;
Vector3D delta = _dragApplied; Vector3D delta = _dragApplied;
// HelixToolkit hit testing requires Identity rather than a null transform. // 清除暫時位移:必須用 Identity,不可用 null —— HelixToolkit GetTransform 對 child.Transform
// 不做 null 檢查(Children.Add(null) 會拋「無法新增空值到集合中」),下次 FindHits 即 crash
foreach (ModelNodeViewModel l in root.Leaves()) foreach (ModelNodeViewModel l in root.Leaves())
if (l.BodyVisual is not null) if (l.BodyVisual is not null)
l.BodyVisual.Transform = Transform3D.Identity; l.BodyVisual.Transform = Transform3D.Identity;
@@ -78,11 +79,11 @@ public partial class MainViewModel
_dragTransform = null; _dragTransform = null;
_dragApplied = default; _dragApplied = default;
_edgesSuspended = false; _edgesSuspended = false;
SetEdgesActive(true); SetEdgesActive(true); // 恢復所有檔案的邊線(被拖檔案的點隨後由 TranslateRoot 更新,期間不會渲染)
if (delta.LengthSquared > 1e-12) if (delta.LengthSquared > 1e-12)
{ {
TranslateRoot(root, delta); TranslateRoot(root, delta); // 烘進 B-rep + 網格 + 邊線 + 邊界(量測清空)
SetStatus(() => Format("Runtime.Dragged", root.Name, delta.X, delta.Y, delta.Z)); SetStatus(() => Format("Runtime.Dragged", root.Name, delta.X, delta.Y, delta.Z));
} }
return true; return true;
@@ -16,7 +16,7 @@ using static STPViewer.Services.LocalizationService;
namespace STPViewer.ViewModels; namespace STPViewer.ViewModels;
// CSV, STEP, STL, and screenshot export // ─── 匯出(量測 CSV / 視圖 PNG 截圖 / STEP / STL)────────────────────
public partial class MainViewModel public partial class MainViewModel
{ {
[RelayCommand] [RelayCommand]
@@ -42,14 +42,16 @@ public partial class MainViewModel
static string Esc(string s) => $"\"{s.Replace("\"", "\"\"")}\""; static string Esc(string s) => $"\"{s.Replace("\"", "\"\"")}\"";
sb.AppendLine($"{Esc(m.Title)},{Esc(Get($"Runtime.Kind{m.Kind}"))},{Esc(m.Detail.Replace("\n", " | "))}"); sb.AppendLine($"{Esc(m.Title)},{Esc(Get($"Runtime.Kind{m.Kind}"))},{Esc(m.Detail.Replace("\n", " | "))}");
} }
File.WriteAllText(dlg.FileName, sb.ToString(), new UTF8Encoding(true)); // BOM for Excel. File.WriteAllText(dlg.FileName, sb.ToString(), new UTF8Encoding(true)); // BOM:Excel 中文不亂碼
int count = Measurements.Count; int count = Measurements.Count;
string fileName = Path.GetFileName(dlg.FileName); string fileName = Path.GetFileName(dlg.FileName);
SetStatus(() => Format("Runtime.CsvExported", count, fileName)); SetStatus(() => Format("Runtime.CsvExported", count, fileName));
} }
/// <summary> /// <summary>
/// Exports visible B-rep Solid and Shell objects at their current positions. /// 把可見檔案(目前位置,含對齊/旋轉/拖曳結果)匯出成「新的」STEP 檔 —
/// 對齊插合後的配合結果可交接給 CAD;不動任何原始檔(唯讀原則不變)。
/// 只含 B-rep Solid/Shell(STL 網格、DXF 線架構無法進 STEP B-rep)。
/// </summary> /// </summary>
[RelayCommand(CanExecute = nameof(IsIdle))] [RelayCommand(CanExecute = nameof(IsIdle))]
private void ExportStepFile() private void ExportStepFile()
@@ -75,7 +77,7 @@ public partial class MainViewModel
try try
{ {
// B-rep objects already contain their transformed positions. // B-rep 已含目前位置(TransformRoot 對 Solid/Shell 整體 Modify)→ 直接寫出
CADability.Project project = CADability.Project.CreateSimpleProject(); CADability.Project project = CADability.Project.CreateSimpleProject();
CADability.Model model = project.GetActiveModel(); CADability.Model model = project.GetActiveModel();
foreach (CADability.GeoObject.IGeoObject g in geos) model.Add(g); foreach (CADability.GeoObject.IGeoObject g in geos) model.Add(g);
@@ -92,12 +94,13 @@ public partial class MainViewModel
} }
/// <summary> /// <summary>
/// Exports visible meshes as STL using the selected format, scale and quality. /// 匯出可見檔案(目前位置)為 STL 網格檔 — 先開參數對話框(範圍/格式/單位/精度 + 三角形數與大小預估)
/// 確認後才選路徑寫檔。精細/較粗 = 由 B-rep 重新三角化(背景執行緒);STL/DXF 來源一律用現有網格。
/// </summary> /// </summary>
[RelayCommand(CanExecute = nameof(IsIdle))] [RelayCommand(CanExecute = nameof(IsIdle))]
private async Task ExportStlFile() private async Task ExportStlFile()
{ {
// Only visible leaves with meshes can be exported. // 可匯出 = 可見 leaf 且有網格(DXF 純線架構無網格 → 排除)
var groups = Roots.Where(r => r.IsVisible) var groups = Roots.Where(r => r.IsVisible)
.Select(r => (Root: r, Leaves: r.Leaves() .Select(r => (Root: r, Leaves: r.Leaves()
.Where(l => l.IsVisible && GetMergedMesh(l) is not null).ToList())) .Where(l => l.IsVisible && GetMergedMesh(l) is not null).ToList()))
@@ -113,7 +116,7 @@ public partial class MainViewModel
.Sum(l => GetMergedMesh(l)!.TriangleIndices.Count / 3L); .Sum(l => GetMergedMesh(l)!.TriangleIndices.Count / 3L);
bool hasBrep = groups.SelectMany(g => g.Leaves).Any(l => l.HasBrep && l.SourceGeos.Count > 0); bool hasBrep = groups.SelectMany(g => g.Leaves).Any(l => l.HasBrep && l.SourceGeos.Count > 0);
// Collect export options. // 參數對話框(確認再匯出)
var vm = new StlExportViewModel(groups.Count, triangles, hasBrep); var vm = new StlExportViewModel(groups.Count, triangles, hasBrep);
var dialog = new StlExportDialog(vm) { Owner = Application.Current.MainWindow }; var dialog = new StlExportDialog(vm) { Owner = Application.Current.MainWindow };
if (dialog.ShowDialog() != true || vm.Scale is not double scale) return; if (dialog.ShowDialog() != true || vm.Scale is not double scale) return;
@@ -128,7 +131,8 @@ public partial class MainViewModel
}; };
if (dlg.ShowDialog() != true) return; if (dlg.ShowDialog() != true) return;
// Snapshot WPF geometry on the UI thread before background export. // UI 執行緒先快照:GeometryModel3D/Model3DGroup 是 DispatcherObject 不可跨執行緒,
// 背景只能碰 frozen MeshGeometry3D 與 CADability B-rep(SourceGeos)
var snapshot = groups var snapshot = groups
.Select(g => (g.Root.Name, Leaves: g.Leaves .Select(g => (g.Root.Name, Leaves: g.Leaves
.Select(l => (Mesh: GetMergedMesh(l)!, l.HasBrep, l.SourceGeos)) .Select(l => (Mesh: GetMergedMesh(l)!, l.HasBrep, l.SourceGeos))
@@ -182,14 +186,16 @@ public partial class MainViewModel
} }
} }
/// <summary>Current merged mesh for an exportable leaf.</summary> /// <summary>leaf 的合併網格(frozen、未剖切、目前位置)— STL 匯出的「目前顯示網格」來源</summary>
private static MeshGeometry3D? GetMergedMesh(ModelNodeViewModel leaf) => private static MeshGeometry3D? GetMergedMesh(ModelNodeViewModel leaf) =>
leaf.MergedContent?.Children.Count > 0 leaf.MergedContent?.Children.Count > 0
? (leaf.MergedContent.Children[0] as GeometryModel3D)?.Geometry as MeshGeometry3D ? (leaf.MergedContent.Children[0] as GeometryModel3D)?.Geometry as MeshGeometry3D
: null; : null;
/// <summary> /// <summary>
/// Selects frozen current meshes or sequentially retessellates B-rep geometry. /// 收集要寫進 STL 的網格(背景執行緒;輸入是 UI 快照):
/// 精細且有 B-rep → 重新三角化(循序,CADability 非執行緒安全);
/// 其餘(目前網格 / STL 來源)→ 快照的合併網格(frozen,可安全跨執行緒讀取)。
/// </summary> /// </summary>
private static List<MeshGeometry3D> CollectMeshes( private static List<MeshGeometry3D> CollectMeshes(
IReadOnlyList<(MeshGeometry3D Mesh, bool HasBrep, List<CADability.GeoObject.IGeoObject> SourceGeos)> leaves, IReadOnlyList<(MeshGeometry3D Mesh, bool HasBrep, List<CADability.GeoObject.IGeoObject> SourceGeos)> leaves,
@@ -202,7 +208,7 @@ public partial class MainViewModel
{ {
List<MeshGeometry3D> tess = StlExportService.Tessellate(sourceGeos, precisionFactor); List<MeshGeometry3D> tess = StlExportService.Tessellate(sourceGeos, precisionFactor);
if (tess.Count > 0) { meshes.AddRange(tess); continue; } if (tess.Count > 0) { meshes.AddRange(tess); continue; }
// Fall back to the current mesh if retessellation yields nothing. // 重新三角化全數失敗 → 退回現有網格,不讓零件憑空消失
} }
meshes.Add(mesh); meshes.Add(mesh);
} }
@@ -229,7 +235,7 @@ public partial class MainViewModel
}; };
if (dlg.ShowDialog() != true) return; if (dlg.ShowDialog() != true) return;
const double scale = 2.0; const double scale = 2.0; // 2x 解析度
var rtb = new RenderTargetBitmap( var rtb = new RenderTargetBitmap(
(int)(_viewport.ActualWidth * scale), (int)(_viewport.ActualHeight * scale), (int)(_viewport.ActualWidth * scale), (int)(_viewport.ActualHeight * scale),
96 * scale, 96 * scale, PixelFormats.Pbgra32); 96 * scale, 96 * scale, PixelFormats.Pbgra32);
+28 -26
View File
@@ -8,37 +8,39 @@ using static STPViewer.Services.LocalizationService;
namespace STPViewer.ViewModels; namespace STPViewer.ViewModels;
// Gizmo translation and rotation // ─── Gizmo 三軸操作器(XYZ 箭頭 + 旋轉環;每次放開滑鼠烘進 B-rep)──────
public partial class MainViewModel public partial class MainViewModel
{ {
[ObservableProperty] [ObservableProperty]
private bool gizmoEnabled; private bool gizmoEnabled;
private readonly List<HelixToolkit.Wpf.Manipulator> _gizmoParts = new(); private readonly List<HelixToolkit.Wpf.Manipulator> _gizmoParts = new();
private ModelVisual3D? _gizmoProxy; private ModelVisual3D? _gizmoProxy; // 操作器綁定的代理 visual(其 Transform = 使用者拖出的變換)
private ModelNodeViewModel? _gizmoTarget; private ModelNodeViewModel? _gizmoTarget;
private bool _gizmoDragActive; private bool _gizmoDragActive; // 拖動中(邊線已暫停)
private bool _gizmoBaking; private bool _gizmoBaking; // 烘焙中,忽略 Transform 變更回呼
private bool _gizmoBakePending; private bool _gizmoBakePending; // 已排程烘焙,防同一次放開重複觸發
// A synchronized overlay viewport keeps the gizmo above scene geometry. // 操作器疊圖層:另一個透明 Viewport3D 疊在主視窗上、相機同步、只放操作器 →
// 操作器不在主場景,永不被實體遮擋(always-on-top);空白處滑鼠穿透回主視窗
private System.Windows.Controls.Viewport3D? _overlayViewport; private System.Windows.Controls.Viewport3D? _overlayViewport;
private ProjectionCamera? _overlayCamera; private ProjectionCamera? _overlayCamera; // 型別跟隨主相機(透視/正交切換,v0.5.0)
/// <summary>Attaches the transparent gizmo overlay viewport.</summary> /// <summary>注入操作器疊圖層(透明 Viewport3D,疊在主視窗上、永遠最上層)</summary>
public void AttachOverlay(System.Windows.Controls.Viewport3D overlay) public void AttachOverlay(System.Windows.Controls.Viewport3D overlay)
{ {
_overlayViewport = overlay; _overlayViewport = overlay;
_overlayCamera = new PerspectiveCamera(); _overlayCamera = new PerspectiveCamera();
overlay.Camera = _overlayCamera; overlay.Camera = _overlayCamera;
// Manipulator materials need lighting. // 操作器材質需打光(manipulator 用 DiffuseMaterial)
overlay.Children.Add(new ModelVisual3D { Content = new AmbientLight(Color.FromRgb(0x80, 0x80, 0x80)) }); overlay.Children.Add(new ModelVisual3D { Content = new AmbientLight(Color.FromRgb(0x80, 0x80, 0x80)) });
overlay.Children.Add(new ModelVisual3D { Content = new DirectionalLight(Colors.White, new Vector3D(-1, -1, -3)) }); overlay.Children.Add(new ModelVisual3D { Content = new DirectionalLight(Colors.White, new Vector3D(-1, -1, -3)) });
SyncOverlayCamera(); SyncOverlayCamera();
} }
/// <summary> /// <summary>
/// Keeps the overlay camera in sync with the main camera and projection mode. /// 疊圖層相機跟隨主相機(每次主相機變更時呼叫,讓操作器疊在正確螢幕位置)。
/// 主相機可能在透視/正交間切換(Orthographic 屬性)→ 疊圖層相機「型別」也要跟著換,投影才一致。
/// </summary> /// </summary>
private void SyncOverlayCamera() private void SyncOverlayCamera()
{ {
@@ -77,7 +79,7 @@ public partial class MainViewModel
partial void OnSelectedNodeChanged(ModelNodeViewModel? value) partial void OnSelectedNodeChanged(ModelNodeViewModel? value)
{ {
if (GizmoEnabled) UpdateGizmo(); if (GizmoEnabled) UpdateGizmo(); // 換選取 → 操作器跟著換目標
} }
private void UpdateGizmo() private void UpdateGizmo()
@@ -94,7 +96,7 @@ public partial class MainViewModel
return; return;
} }
_gizmoTarget = root; _gizmoTarget = root;
SyncOverlayCamera(); SyncOverlayCamera(); // 操作器出現前先對齊相機
Rect3D b = root.Bounds; Rect3D b = root.Bounds;
var center = new Point3D(b.X + b.SizeX / 2, b.Y + b.SizeY / 2, b.Z + b.SizeZ / 2); var center = new Point3D(b.X + b.SizeX / 2, b.Y + b.SizeY / 2, b.Z + b.SizeZ / 2);
@@ -111,13 +113,13 @@ public partial class MainViewModel
man.Position = center; man.Position = center;
man.Bind(_gizmoProxy); man.Bind(_gizmoProxy);
_gizmoParts.Add(man); _gizmoParts.Add(man);
_overlayViewport!.Children.Add(man); _overlayViewport!.Children.Add(man); // 放疊圖層 → 永不被實體遮擋
} }
// Translation arrows: X red, Y green, Z blue. // 平移箭頭(X 紅 / Y 綠 / Z 藍 — 業界慣例)
AddPart(new TranslateManipulator { Direction = new Vector3D(1, 0, 0), Color = Colors.Red, Length = diag * 0.22, Diameter = diag * 0.016 }); AddPart(new TranslateManipulator { Direction = new Vector3D(1, 0, 0), Color = Colors.Red, Length = diag * 0.22, Diameter = diag * 0.016 });
AddPart(new TranslateManipulator { Direction = new Vector3D(0, 1, 0), Color = Colors.Green, Length = diag * 0.22, Diameter = diag * 0.016 }); AddPart(new TranslateManipulator { Direction = new Vector3D(0, 1, 0), Color = Colors.Green, Length = diag * 0.22, Diameter = diag * 0.016 });
AddPart(new TranslateManipulator { Direction = new Vector3D(0, 0, 1), Color = Colors.Blue, Length = diag * 0.22, Diameter = diag * 0.016 }); AddPart(new TranslateManipulator { Direction = new Vector3D(0, 0, 1), Color = Colors.Blue, Length = diag * 0.22, Diameter = diag * 0.016 });
// Rotation rings // 旋轉環
AddPart(new RotateManipulator { Axis = new Vector3D(1, 0, 0), Color = Colors.Red, Diameter = diag * 0.34, InnerDiameter = diag * 0.30, Length = diag * 0.012 }); AddPart(new RotateManipulator { Axis = new Vector3D(1, 0, 0), Color = Colors.Red, Diameter = diag * 0.34, InnerDiameter = diag * 0.30, Length = diag * 0.012 });
AddPart(new RotateManipulator { Axis = new Vector3D(0, 1, 0), Color = Colors.Green, Diameter = diag * 0.34, InnerDiameter = diag * 0.30, Length = diag * 0.012 }); AddPart(new RotateManipulator { Axis = new Vector3D(0, 1, 0), Color = Colors.Green, Diameter = diag * 0.34, InnerDiameter = diag * 0.30, Length = diag * 0.012 });
AddPart(new RotateManipulator { Axis = new Vector3D(0, 0, 1), Color = Colors.Blue, Diameter = diag * 0.34, InnerDiameter = diag * 0.30, Length = diag * 0.012 }); AddPart(new RotateManipulator { Axis = new Vector3D(0, 0, 1), Color = Colors.Blue, Diameter = diag * 0.34, InnerDiameter = diag * 0.30, Length = diag * 0.012 });
@@ -142,7 +144,7 @@ public partial class MainViewModel
_gizmoParts.Clear(); _gizmoParts.Clear();
_gizmoProxy = null; _gizmoProxy = null;
if (_gizmoTarget is not null) if (_gizmoTarget is not null) // 清掉殘留的暫時 Transform(用 Identity,不可 null → 否則 FindHits crash)
foreach (ModelNodeViewModel l in _gizmoTarget.Leaves()) foreach (ModelNodeViewModel l in _gizmoTarget.Leaves())
if (l.BodyVisual is not null) if (l.BodyVisual is not null)
l.BodyVisual.Transform = Transform3D.Identity; l.BodyVisual.Transform = Transform3D.Identity;
@@ -155,7 +157,7 @@ public partial class MainViewModel
} }
} }
/// <summary>Applies the proxy transform to target visuals during dragging.</summary> /// <summary>操作器拖動中:把代理的 Transform 套到目標檔案的所有 BodyVisual(暫時、GPU 端)</summary>
private void GizmoTransformChanged(object? sender, EventArgs e) private void GizmoTransformChanged(object? sender, EventArgs e)
{ {
if (_gizmoBaking || _gizmoTarget is null || _gizmoProxy is null) return; if (_gizmoBaking || _gizmoTarget is null || _gizmoProxy is null) return;
@@ -164,21 +166,21 @@ public partial class MainViewModel
{ {
_gizmoDragActive = true; _gizmoDragActive = true;
_edgesSuspended = true; _edgesSuspended = true;
SetEdgesActive(false); SetEdgesActive(false); // 邊線逐幀重建會卡,拖動中暫停
} }
foreach (ModelNodeViewModel l in _gizmoTarget.Leaves()) foreach (ModelNodeViewModel l in _gizmoTarget.Leaves())
if (l.BodyVisual is not null) if (l.BodyVisual is not null)
l.BodyVisual.Transform = t; l.BodyVisual.Transform = t;
} }
/// <summary>Commits the accumulated transform and resets the gizmo on mouse release.</summary> /// <summary>滑鼠放開(MainWindow 轉發,handledEventsToo):把累積變換烘進 B-rep 並重置操作器</summary>
public void OnGizmoMouseUp() public void OnGizmoMouseUp()
{ {
if (_gizmoTarget is null || _gizmoProxy is null || _gizmoBakePending) return; if (_gizmoTarget is null || _gizmoProxy is null || _gizmoBakePending) return;
Matrix3D m = _gizmoProxy.Transform?.Value ?? Matrix3D.Identity; Matrix3D m = _gizmoProxy.Transform?.Value ?? Matrix3D.Identity;
if (m.IsIdentity) return; if (m.IsIdentity) return; // 只是點一下、沒拖操作器 → 不烘焙
// Commit after the manipulator completes its mouse-up handling. // 延後到 manipulator 自身的 mouse-up 處理(釋放捕捉等)完成後再烘焙,避免在其事件中改動視覺樹造成 reentrancy
_gizmoBakePending = true; _gizmoBakePending = true;
_viewport?.Dispatcher.BeginInvoke(new Action(() => BakeGizmo(m)), _viewport?.Dispatcher.BeginInvoke(new Action(() => BakeGizmo(m)),
System.Windows.Threading.DispatcherPriority.Background); System.Windows.Threading.DispatcherPriority.Background);
@@ -190,19 +192,19 @@ public partial class MainViewModel
if (_gizmoTarget is null) return; if (_gizmoTarget is null) return;
ModelNodeViewModel root = _gizmoTarget; ModelNodeViewModel root = _gizmoTarget;
_gizmoBaking = true; _gizmoBaking = true; // 防 proxy 歸零觸發 GizmoTransformChanged 重入
foreach (ModelNodeViewModel l in root.Leaves()) foreach (ModelNodeViewModel l in root.Leaves())
if (l.BodyVisual is not null) if (l.BodyVisual is not null)
l.BodyVisual.Transform = Transform3D.Identity; l.BodyVisual.Transform = Transform3D.Identity; // 清暫時位移(用 Identity,不可 null)
if (_gizmoProxy is not null) _gizmoProxy.Transform = Transform3D.Identity; if (_gizmoProxy is not null) _gizmoProxy.Transform = Transform3D.Identity; // 綁定 → 操作器歸零
_gizmoBaking = false; _gizmoBaking = false;
_gizmoDragActive = false; _gizmoDragActive = false;
_edgesSuspended = false; _edgesSuspended = false;
SetEdgesActive(true); SetEdgesActive(true);
TransformRoot(root, Services.RigidAlign.ToModOp(m), m); TransformRoot(root, Services.RigidAlign.ToModOp(m), m); // 烘進 B-rep(量測精度不受影響)
// Move the gizmo to the transformed center. // 操作器移到變換後的新中心
Rect3D b = root.Bounds; Rect3D b = root.Bounds;
if (!b.IsEmpty) if (!b.IsEmpty)
{ {
@@ -13,7 +13,7 @@ using static STPViewer.Services.LocalizationService;
namespace STPViewer.ViewModels; namespace STPViewer.ViewModels;
// Interference checking // ─── 干涉檢查 ────────────────────────────────────────────────────────
public partial class MainViewModel public partial class MainViewModel
{ {
[RelayCommand(CanExecute = nameof(IsIdle))] [RelayCommand(CanExecute = nameof(IsIdle))]
@@ -35,7 +35,7 @@ public partial class MainViewModel
.Select(m => _faceMap[(GeometryModel3D)m].Mesh) .Select(m => _faceMap[(GeometryModel3D)m].Mesh)
.ToList(); .ToList();
// Snapshot meshes before background calculations. // 網格快照(開跑後零件被改走也不會半途換料;v0.4.0 起運算期間指令已鎖,這裡是雙保險)
var meshes = visibleRoots.ToDictionary(r => r, MeshesOf); var meshes = visibleRoots.ToDictionary(r => r, MeshesOf);
IsBusy = true; IsBusy = true;
@@ -13,15 +13,16 @@ using static STPViewer.Services.LocalizationService;
namespace STPViewer.ViewModels; namespace STPViewer.ViewModels;
// Section clipping // ─── 剖面(CPU 網格裁切;v0.4.0 起裁切在背景執行緒平行計算,UI 不凍結)──
public partial class MainViewModel public partial class MainViewModel
{ {
private readonly DispatcherTimer _sectionTimer; private readonly DispatcherTimer _sectionTimer;
private RectangleVisual3D? _sectionPlaneVisual; private RectangleVisual3D? _sectionPlaneVisual;
private Point3D _sectionPlanePoint; private Point3D _sectionPlanePoint; // SectionEnabled 時的剖切平面(合併邊線重建用)
private Vector3D _sectionNormal; private Vector3D _sectionNormal;
// Reapply clipping if parameters change while background work is running. // 背景裁切 guard:進行中又有新變更(拉滑桿/換軸/零件平移)→ 完成後用最新參數重跑一輪。
// 不要改回同步呼叫 ClipMesh —— 大檔(64k 面)整場景裁切會凍結 UI 數秒
private bool _sectionApplying; private bool _sectionApplying;
private bool _sectionReapply; private bool _sectionReapply;
@@ -32,20 +33,20 @@ public partial class MainViewModel
[ObservableProperty] [ObservableProperty]
private int sectionAxisIndex; private int sectionAxisIndex;
/// <summary>Section position along the selected axis, from 0 to 100%.</summary> /// <summary>剖面位置(沿軸 0~100%)</summary>
[ObservableProperty] [ObservableProperty]
private double sectionPosition = 50; private double sectionPosition = 50;
[ObservableProperty] [ObservableProperty]
private bool sectionFlip; private bool sectionFlip;
/// <summary>Closes the section parameter bar.</summary> /// <summary>剖面參數列的「✕ 關閉」按鈕(參數列只在剖面開啟時顯示)</summary>
[RelayCommand] [RelayCommand]
private void CloseSection() => SectionEnabled = false; private void CloseSection() => SectionEnabled = false;
// Three-point custom cutting plane // ── 3點自訂剖切平面(SectionAxisIndex == 3,v0.5.0)──
private const int CustomAxisIndex = 3; private const int CustomAxisIndex = 3;
private Vector3D? _customNormal; private Vector3D? _customNormal; // null = 尚未定義(等使用者點 3 點)
private readonly List<Point3D> _sectionPicks = new(); private readonly List<Point3D> _sectionPicks = new();
private readonly List<Visual3D> _sectionPickOverlays = new(); private readonly List<Visual3D> _sectionPickOverlays = new();
@@ -64,7 +65,7 @@ public partial class MainViewModel
} }
else else
{ {
// Leaving custom mode discards the old plane. // 離開自訂平面 → 丟棄舊平面(再選「3點」即重新定義)
_customNormal = null; _customNormal = null;
ClearSectionPicks(); ClearSectionPicks();
} }
@@ -73,14 +74,14 @@ public partial class MainViewModel
partial void OnSectionPositionChanged(double value) partial void OnSectionPositionChanged(double value)
{ {
if (value < 0) { SectionPosition = 0; return; } if (value < 0) { SectionPosition = 0; return; } // 位置數值輸入框防呆
if (value > 100) { SectionPosition = 100; return; } if (value > 100) { SectionPosition = 100; return; }
ScheduleSection(); ScheduleSection();
} }
partial void OnSectionFlipChanged(bool value) => ScheduleSection(); partial void OnSectionFlipChanged(bool value) => ScheduleSection();
/// <summary>Handles a custom plane pick; returns true when the click is consumed.</summary> /// <summary>3點剖面定義模式中的點擊(OnViewportClick 轉入)。回傳 true = 已消費此點擊。</summary>
public bool HandleSectionPlanePick(System.Windows.Point position) public bool HandleSectionPlanePick(System.Windows.Point position)
{ {
if (!SectionEnabled || SectionAxisIndex != CustomAxisIndex || _customNormal is not null) return false; if (!SectionEnabled || SectionAxisIndex != CustomAxisIndex || _customNormal is not null) return false;
@@ -136,14 +137,14 @@ public partial class MainViewModel
return true; return true;
} }
/// <summary>Cancels custom plane picking; returns true if it was active.</summary> /// <summary>取消 3 點平面定義(Esc / 關剖面)。回傳 true = 有取消動作。</summary>
private bool CancelSectionPickIfActive(bool quiet = false) private bool CancelSectionPickIfActive(bool quiet = false)
{ {
bool picking = SectionEnabled && SectionAxisIndex == CustomAxisIndex && _customNormal is null; bool picking = SectionEnabled && SectionAxisIndex == CustomAxisIndex && _customNormal is null;
if (_sectionPicks.Count == 0 && !picking) return false; if (_sectionPicks.Count == 0 && !picking) return false;
ClearSectionPicks(); ClearSectionPicks();
if (SectionAxisIndex == CustomAxisIndex && _customNormal is null) if (SectionAxisIndex == CustomAxisIndex && _customNormal is null)
SectionAxisIndex = 0; SectionAxisIndex = 0; // 回 X 軸
if (!quiet) SetStatus(() => Get("Runtime.SectionCancelled")); if (!quiet) SetStatus(() => Get("Runtime.SectionCancelled"));
return true; return true;
} }
@@ -167,7 +168,8 @@ public partial class MainViewModel
if (!SectionEnabled) if (!SectionEnabled)
{ {
// Restore original meshes. Background clipping checks SectionEnabled before applying. // 還原原始幾何(換回 fi.Mesh 參照,便宜、同步即可)。
// 若背景裁切還在跑,其 await 之後會看到 SectionEnabled=false 而放棄套用
foreach ((Model3D model, FaceInfo fi) in _faceMap) foreach ((Model3D model, FaceInfo fi) in _faceMap)
if (model is GeometryModel3D gm && !ReferenceEquals(gm.Geometry, fi.Mesh)) if (model is GeometryModel3D gm && !ReferenceEquals(gm.Geometry, fi.Mesh))
gm.Geometry = fi.Mesh; gm.Geometry = fi.Mesh;
@@ -177,7 +179,7 @@ public partial class MainViewModel
_viewport.Children.Remove(_sectionPlaneVisual); _viewport.Children.Remove(_sectionPlaneVisual);
_sectionPlaneVisual = null; _sectionPlaneVisual = null;
} }
ApplyRenderMode(); ApplyRenderMode(); // 剖面關閉 → 瀏覽模式可切回合併網格
SetStatus(() => Get("Runtime.SectionClosed")); SetStatus(() => Get("Runtime.SectionClosed"));
return; return;
} }
@@ -196,7 +198,7 @@ public partial class MainViewModel
Vector3D axis; Vector3D axis;
if (SectionAxisIndex == CustomAxisIndex) if (SectionAxisIndex == CustomAxisIndex)
{ {
if (_customNormal is null) return; if (_customNormal is null) return; // 平面尚未定義(等使用者點 3 點)
axis = _customNormal.Value; axis = _customNormal.Value;
} }
else else
@@ -212,7 +214,8 @@ public partial class MainViewModel
Point3D min = bounds.Location; Point3D min = bounds.Location;
var size = new Vector3D(bounds.SizeX, bounds.SizeY, bounds.SizeZ); var size = new Vector3D(bounds.SizeX, bounds.SizeY, bounds.SizeZ);
Point3D center = min + size / 2; Point3D center = min + size / 2;
// Interpolate along the projected bounds of the scene AABB. // 平面位置:場景 AABB 8 個角投影到法向的 [dmin, dmax],滑桿 t 內插
//(軸向法向 = 與舊版逐軸計算等價;任意法向也成立)
double dmin = double.MaxValue, dmax = double.MinValue; double dmin = double.MaxValue, dmax = double.MinValue;
for (int ci = 0; ci < 8; ci++) for (int ci = 0; ci < 8; ci++)
{ {
@@ -228,7 +231,9 @@ public partial class MainViewModel
Point3D planePoint = center + axis * (dStar - Vector3D.DotProduct((Vector3D)center, axis)); Point3D planePoint = center + axis * (dStar - Vector3D.DotProduct((Vector3D)center, axis));
Vector3D normal = SectionFlip ? -axis : axis; Vector3D normal = SectionFlip ? -axis : axis;
// Clip frozen meshes in the background, then apply on the UI thread. // UI 執行緒快照(原始 mesh 皆已 Freeze,可跨執行緒),背景平行裁切,回 UI 一次換上。
// 裁切期間若參數又變 / 零件被平移(TransformRoot 會呼叫 ApplySection)→
// _sectionReapply 讓迴圈用最新的 fi.Mesh 與平面重跑,最終狀態必為最新
var models = new List<GeometryModel3D>(_faceMap.Count); var models = new List<GeometryModel3D>(_faceMap.Count);
var sources = new List<MeshGeometry3D>(_faceMap.Count); var sources = new List<MeshGeometry3D>(_faceMap.Count);
foreach ((Model3D model, FaceInfo fi) in _faceMap) foreach ((Model3D model, FaceInfo fi) in _faceMap)
@@ -246,16 +251,16 @@ public partial class MainViewModel
return result; return result;
}); });
if (!SectionEnabled) return; if (!SectionEnabled) return; // 裁切期間被關掉 → 還原分支已處理,丟棄結果
for (int i = 0; i < models.Count; i++) for (int i = 0; i < models.Count; i++)
models[i].Geometry = clipped[i]; models[i].Geometry = clipped[i]; // 已移除檔案的殘留 model 賦值無害(不在視覺樹上)
_sectionPlanePoint = planePoint; _sectionPlanePoint = planePoint;
_sectionNormal = normal; _sectionNormal = normal;
foreach (ModelNodeViewModel root in Roots) RefreshRootEdges(root); foreach (ModelNodeViewModel root in Roots) RefreshRootEdges(root);
ApplyRenderMode(); ApplyRenderMode(); // 剖面開啟 → 改用逐面(裁切後幾何)
UpdateSectionPlaneVisual(planePoint, axis, size); UpdateSectionPlaneVisual(planePoint, axis, size);
int axisIndex = SectionAxisIndex; int axisIndex = SectionAxisIndex;
double position = SectionPosition; double position = SectionPosition;
@@ -296,7 +301,7 @@ public partial class MainViewModel
_viewport!.Children.Add(_sectionPlaneVisual); _viewport!.Children.Add(_sectionPlaneVisual);
} }
/// <summary>Returns a unit vector perpendicular to n for the custom plane visual.</summary> /// <summary>取任一與 n 垂直的單位向量(自訂平面的指示矩形方向用)</summary>
private static Vector3D AnyPerpendicular(Vector3D n) private static Vector3D AnyPerpendicular(Vector3D n)
{ {
Vector3D v = Math.Abs(n.Z) < 0.9 Vector3D v = Math.Abs(n.Z) < 0.9
+101 -84
View File
@@ -23,33 +23,34 @@ public partial class MainViewModel : ObservableObject
private readonly StepImportService _importService = new(); private readonly StepImportService _importService = new();
private readonly MeasurementService _measureService = new(); private readonly MeasurementService _measureService = new();
/// <summary>Maps rendered face models to their B-rep face data for hit testing.</summary> /// <summary>渲染物件 → B-rep Face 反查表(HitTest 用;overlay 不在表內所以自然被忽略)</summary>
private readonly Dictionary<Model3D, FaceInfo> _faceMap = new(); private readonly Dictionary<Model3D, FaceInfo> _faceMap = new();
/// <summary>Maps merged meshes to leaves for drag hit testing.</summary> /// <summary>合併網格 → leaf 反查表(拖曳模式在瀏覽渲染下 hit-test 用)</summary>
private readonly Dictionary<Model3D, ModelNodeViewModel> _mergedMap = new(); private readonly Dictionary<Model3D, ModelNodeViewModel> _mergedMap = new();
/// <summary>Vertex boundaries and faces used to resolve hits on merged meshes. /// <summary>合併網格 → (每面頂點起始邊界, 對應 FaceInfo)。命中合併網格時用三角形頂點 index 二分搜尋反查回是哪個面,
/// The order matches BuildMergedMesh; transforms do not change vertex counts.</summary> /// 讓量測模式也能 render 合併網格(62 個 model)而非逐面(數萬個 model)。starts 長度 = faces+1,最後一格為總頂點數。
/// 邊界由匯入時依面序串接而成,與 BuildMergedMesh 串接順序一致;平移不改各面頂點數,故邊界永久有效。</summary>
private readonly Dictionary<Model3D, (int[] Starts, FaceInfo[] Faces)> _mergedFaceRanges = new(); private readonly Dictionary<Model3D, (int[] Starts, FaceInfo[] Faces)> _mergedFaceRanges = new();
private HelixViewport3D? _viewport; private HelixViewport3D? _viewport;
private int _paletteIndex; private int _paletteIndex;
private readonly Dictionary<MeasureMode, int> _counters = new(); private readonly Dictionary<MeasureMode, int> _counters = new();
// First picks for multi-step measurements. // 兩段式量測的第一次拾取暫存
private Point3D? _pendingPoint; private Point3D? _pendingPoint; // 距離
private DirectionPick? _pendingDirection; private DirectionPick? _pendingDirection; // 角度
private FaceInfo? _pendingFace; private FaceInfo? _pendingFace; // 面距
private (ModelNodeViewModel Root, Point3D Point)? _pendingAlign; private (ModelNodeViewModel Root, Point3D Point)? _pendingAlign; // 兩點對齊
private readonly List<(ModelNodeViewModel Root, Point3D P)> _align3 = new(); private readonly List<(ModelNodeViewModel Root, Point3D P)> _align3 = new(); // 三點對齊(前3=來源、後3=目標)
private readonly List<Visual3D> _pendingOverlays = new(); private readonly List<Visual3D> _pendingOverlays = new();
/// <summary>Selected tree node used as the target for commands.</summary> /// <summary>樹面板目前選取的節點(MainWindow SelectedItemChanged 轉發;旋轉指令的目標)</summary>
[ObservableProperty] [ObservableProperty]
private ModelNodeViewModel? selectedNode; private ModelNodeViewModel? selectedNode;
// Suspend edge rendering during camera interaction. // 互動中暫停邊線:轉動/縮放/平移時隱藏 LinesVisual3D,停下再顯示 → 大組件流暢
private readonly DispatcherTimer _interactionTimer; private readonly DispatcherTimer _interactionTimer;
private bool _edgesSuspended; private bool _edgesSuspended;
@@ -88,7 +89,7 @@ public partial class MainViewModel : ObservableObject
[ObservableProperty] [ObservableProperty]
private bool useInch; private bool useInch;
/// <summary>Tree name filter; does not affect viewport visibility.</summary> /// <summary>樹面板名稱過濾字串(只影響樹顯示,不影響 3D 可見性)</summary>
[ObservableProperty] [ObservableProperty]
private string? treeFilter; private string? treeFilter;
@@ -97,16 +98,16 @@ public partial class MainViewModel : ObservableObject
foreach (ModelNodeViewModel root in Roots) root.ApplyFilter(value); foreach (ModelNodeViewModel root in Roots) root.ApplyFilter(value);
} }
/// <summary>Recently opened files saved in settings.json.</summary> /// <summary>最近開啟檔案(MRU,最多 10 筆;隨 settings.json 保存)</summary>
public ObservableCollection<string> RecentFiles { get; } = new(); public ObservableCollection<string> RecentFiles { get; } = new();
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>Quick bar button selections.</summary> /// <summary>快速工具列自訂(選單列有全部功能,快速列只放勾選的常用按鈕)</summary>
public QuickBarViewModel QuickBar { get; } = new(); public QuickBarViewModel QuickBar { get; } = new();
// User settings // ─── 使用者設定(視窗由 MainWindow 處理;VM 管單位 + MRU)─────
public void LoadSettings(AppSettings s) public void LoadSettings(AppSettings s)
{ {
@@ -139,18 +140,23 @@ public partial class MainViewModel : ObservableObject
_sectionTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(100) }; _sectionTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(100) };
_sectionTimer.Tick += (_, _) => { _sectionTimer.Stop(); ApplySection(); }; _sectionTimer.Tick += (_, _) => { _sectionTimer.Stop(); ApplySection(); };
// Restore edges after camera movement stops. // 相機停止移動 180ms 後恢復邊線
_interactionTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(180) }; _interactionTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(180) };
_interactionTimer.Tick += (_, _) => { _interactionTimer.Stop(); ResumeEdges(); }; _interactionTimer.Tick += (_, _) => { _interactionTimer.Stop(); ResumeEdges(); };
} }
private UnitSystem CurrentUnit => UseInch ? UnitSystem.Inch : UnitSystem.Millimeter; private UnitSystem CurrentUnit => UseInch ? UnitSystem.Inch : UnitSystem.Millimeter;
/// <summary>Attaches the viewport from MainWindow.</summary> /// <summary>由 MainWindow 注入 viewport(桌面工具,不做過度抽象)</summary>
public void Attach(HelixViewport3D viewport) public void Attach(HelixViewport3D viewport)
{ {
_viewport = viewport; _viewport = viewport;
// The viewport event also catches camera instance replacements by HelixToolkit. // 相機一動就暫停邊線/逐面(轉動/縮放/平移皆觸發),停下 180ms 再恢復。
// 兩個事件都訂閱,互補保底:
// 1) viewport.CameraChanged(控制項層級 routed event)— 相機實例若被 Helix 內部換掉也接得住;
// 2) Camera.Changed(Freezable,相機 Position/方向一被改就觸發)— 確保每次移動都收到。
// 舊寫法只訂 Camera.Changed 且包在 null 檢查裡,萬一相機實例被換就永久失聯 → 暫停機制全失效。
// 重複觸發 OnCameraMoved 無害(idempotent,內部有 _edgesSuspended guard)。
viewport.CameraChanged += (_, _) => OnCameraMoved(); viewport.CameraChanged += (_, _) => OnCameraMoved();
if (viewport.Camera is not null) if (viewport.Camera is not null)
viewport.Camera.Changed += (_, _) => OnCameraMoved(); viewport.Camera.Changed += (_, _) => OnCameraMoved();
@@ -158,7 +164,7 @@ public partial class MainViewModel : ObservableObject
private void OnCameraMoved() private void OnCameraMoved()
{ {
SyncOverlayCamera(); SyncOverlayCamera(); // 操作器疊圖層跟著主相機
if (!_edgesSuspended) { _edgesSuspended = true; SetEdgesActive(false); } if (!_edgesSuspended) { _edgesSuspended = true; SetEdgesActive(false); }
_interactionTimer.Stop(); _interactionTimer.Stop();
_interactionTimer.Start(); _interactionTimer.Start();
@@ -170,7 +176,7 @@ public partial class MainViewModel : ObservableObject
SetEdgesActive(true); SetEdgesActive(true);
} }
/// <summary>Shows or hides merged root edges without rebuilding points.</summary> /// <summary>只切換 root 合併邊線的 viewport 掛載(不重建 Points)</summary>
private void SetEdgesActive(bool active) private void SetEdgesActive(bool active)
{ {
if (_viewport is null) return; if (_viewport is null) return;
@@ -179,10 +185,10 @@ public partial class MainViewModel : ObservableObject
SyncVisual(root.EdgeVisual, active && root.EdgeVisual.Points.Count > 0); SyncVisual(root.EdgeVisual, active && root.EdgeVisual.Points.Count > 0);
} }
/// <summary>Disables scene-changing commands while background work runs.</summary> /// <summary>IsBusy 時停用會改動幾何/場景的指令(匯入、旋轉、干涉),避免背景運算期間狀態被改走造成結果錯位</summary>
private bool IsIdle() => !IsBusy; private bool IsIdle() => !IsBusy;
// Import // ─── 匯入 ────────────────────────────────────────────────────
[RelayCommand(CanExecute = nameof(IsIdle))] [RelayCommand(CanExecute = nameof(IsIdle))]
private async Task ImportAsync() private async Task ImportAsync()
@@ -197,24 +203,25 @@ public partial class MainViewModel : ObservableObject
await ImportFilesAsync(dlg.FileNames); await ImportFilesAsync(dlg.FileNames);
} }
/// <summary>Imports a recently opened file.</summary> /// <summary>MRU 下拉點選(檔案可能已被移走 → ImportFilesAsync 會報匯入失敗)</summary>
[RelayCommand(CanExecute = nameof(IsIdle))] [RelayCommand(CanExecute = nameof(IsIdle))]
private Task ImportRecentAsync(string path) => ImportFilesAsync(new[] { path }); private Task ImportRecentAsync(string path) => ImportFilesAsync(new[] { path });
public async Task ImportFilesAsync(IEnumerable<string> paths) public async Task ImportFilesAsync(IEnumerable<string> paths)
{ {
if (_viewport is null) return; if (_viewport is null) return;
// Reject overlapping imports because StepImportService has shared state. // 拖放/命令列可繞過指令 CanExecute 直接進來 → 匯入或背景運算期間直接擋掉,
// 避免兩批匯入交錯(StepImportService 有共享狀態,非重入安全)
if (IsBusy) { SetStatus(() => Get("Runtime.ImportBusy")); return; } if (IsBusy) { SetStatus(() => Get("Runtime.ImportBusy")); return; }
int ok = 0, fail = 0; int ok = 0, fail = 0;
IsBusy = true; IsBusy = true; // 整批匯入期間維持 busy(v0.4.0:不再逐檔開關,杜絕檔案之間的重入空窗)
try try
{ {
foreach (string path in paths) foreach (string path in paths)
{ {
string name = Path.GetFileNameWithoutExtension(path); string name = Path.GetFileNameWithoutExtension(path);
SetStatus(() => Format("Runtime.Importing", name)); SetStatus(() => Format("Runtime.Importing", name));
// Marshal progress from the import thread to the UI thread. // 匯入階段回報(從背景執行緒來 → 切回 UI 執行緒)
_importService.ProgressTextFor = textFor => _importService.ProgressTextFor = textFor =>
System.Windows.Application.Current?.Dispatcher.BeginInvoke( System.Windows.Application.Current?.Dispatcher.BeginInvoke(
() => SetStatus(() => Format("Runtime.ImportProgress", name, textFor()))); () => SetStatus(() => Format("Runtime.ImportProgress", name, textFor())));
@@ -257,13 +264,13 @@ public partial class MainViewModel : ObservableObject
ModelNodeViewModel root = BuildNode(data.Root, isRoot: true, data.FilePath, name); ModelNodeViewModel root = BuildNode(data.Root, isRoot: true, data.FilePath, name);
// Disable edges by default for very large files. // 邊線總量過大 → 整檔預設關閉(cascade;使用者可再開)
int totalSegments = root.Leaves().Sum(l => (l.OriginalEdgePoints?.Count ?? 0) / 2); int totalSegments = root.Leaves().Sum(l => (l.OriginalEdgePoints?.Count ?? 0) / 2);
if (totalSegments > 30_000) if (totalSegments > 30_000)
root.ShowEdges = false; root.ShowEdges = false;
Roots.Add(root); Roots.Add(root);
if (!string.IsNullOrWhiteSpace(TreeFilter)) root.ApplyFilter(TreeFilter); if (!string.IsNullOrWhiteSpace(TreeFilter)) root.ApplyFilter(TreeFilter); // 新檔套用目前過濾
foreach (ModelNodeViewModel leaf in root.Leaves()) foreach (ModelNodeViewModel leaf in root.Leaves())
{ {
leaf.VisualStateChanged += _ => SyncLeafVisuals(root); leaf.VisualStateChanged += _ => SyncLeafVisuals(root);
@@ -271,7 +278,7 @@ public partial class MainViewModel : ObservableObject
_viewport!.Children.Add(leaf.BodyVisual); _viewport!.Children.Add(leaf.BodyVisual);
} }
// One merged edge visual per file avoids per-leaf rebuilds. // 整檔合併成「一條」LinesVisual3D(轉動時只重建 1 條,避免逐零件 N 條造成卡頓)
if (root.Leaves().Any(l => l.OriginalEdgePoints is { Count: > 0 })) if (root.Leaves().Any(l => l.OriginalEdgePoints is { Count: > 0 }))
{ {
root.EdgeVisual = new LinesVisual3D root.EdgeVisual = new LinesVisual3D
@@ -300,7 +307,7 @@ public partial class MainViewModel : ObservableObject
if (n.Faces.Count > 0) if (n.Faces.Count > 0)
{ {
// Share material across leaf faces for color changes. // leaf 共用材質(換色時只改 Brush)
var diffuseBrush = new SolidColorBrush(vm.Color); var diffuseBrush = new SolidColorBrush(vm.Color);
diffuseBrush.Freeze(); diffuseBrush.Freeze();
var diffuse = new DiffuseMaterial(diffuseBrush); var diffuse = new DiffuseMaterial(diffuseBrush);
@@ -311,7 +318,8 @@ public partial class MainViewModel : ObservableObject
backMat.Freeze(); backMat.Freeze();
vm.SharedMaterial = diffuse; vm.SharedMaterial = diffuse;
// Keep one model per face for section picking and record merged-mesh face boundaries. // 一個 B-rep Face = 一個 GeometryModel3D,登錄反查表(剖面模式逐面拾取用)。
// 同時依面序記錄每面頂點起始邊界 + 對應 FaceInfo,供「命中合併網格反查面」(量測模式拾取用)。
var group = new Model3DGroup(); var group = new Model3DGroup();
var faceInfos = new FaceInfo[n.Faces.Count]; var faceInfos = new FaceInfo[n.Faces.Count];
var vertStarts = new int[n.Faces.Count + 1]; var vertStarts = new int[n.Faces.Count + 1];
@@ -330,9 +338,11 @@ public partial class MainViewModel : ObservableObject
vertStarts[n.Faces.Count] = vacc; vertStarts[n.Faces.Count] = vacc;
vm.FacesContent = group; vm.FacesContent = group;
// BuildMergedMesh concatenates faces in the same order as vertStarts. // 合併網格:整零件所有面併成 1 個 GeometryModel3D(draw call 從每面 1 個降為每檔 1 個)。
// BuildMergedMesh 依 n.Faces 同序串接(baseIdx += positions.Count),與上面 vertStarts 對齊 → 命中頂點 index 可反查回面。
var mergedModel = new GeometryModel3D(BuildMergedMesh(n.Faces), matGroup); var mergedModel = new GeometryModel3D(BuildMergedMesh(n.Faces), matGroup);
// Closed solids render front faces only; open shells and STL render both sides. // 封閉實體背面不可見 → 不設 BackMaterial(WPF 兩面渲染成本砍半);開放殼/STL 仍需背面。
// 拾取不受材質影響(WPF 3D hit-test 純幾何、不剔背面),故孔內壁仍可命中。
if (n.SolidCount == 0 || !n.HasBrep) if (n.SolidCount == 0 || !n.HasBrep)
mergedModel.BackMaterial = backMat; mergedModel.BackMaterial = backMat;
vm.MergedContent = new Model3DGroup(); vm.MergedContent = new Model3DGroup();
@@ -340,14 +350,14 @@ public partial class MainViewModel : ObservableObject
_mergedMap[mergedModel] = vm; _mergedMap[mergedModel] = vm;
_mergedFaceRanges[mergedModel] = (vertStarts, faceInfos); _mergedFaceRanges[mergedModel] = (vertStarts, faceInfos);
// Use merged meshes except in section mode, which needs separate clipped faces. // 非剖面一律顯示合併網格(瀏覽/量測都是,量測靠 _mergedFaceRanges 反查面);只有剖面才逐面(裁切後幾何)
vm.BodyVisual = new ModelVisual3D vm.BodyVisual = new ModelVisual3D
{ {
Content = SectionEnabled ? group : vm.MergedContent, Content = SectionEnabled ? group : vm.MergedContent,
}; };
} }
// Leaves hold edge data; roots hold the merged edge visual. // 邊線端點僅保存於 leaf(資料);實際 LinesVisual3D 由 root 合併建立(見 BuildRoot)
if (n.EdgeSegments is { Count: > 0 }) if (n.EdgeSegments is { Count: > 0 })
vm.OriginalEdgePoints = n.EdgeSegments; vm.OriginalEdgePoints = n.EdgeSegments;
@@ -366,7 +376,8 @@ public partial class MainViewModel : ObservableObject
} }
/// <summary> /// <summary>
/// Rebuilds one edge visual from visible leaves, clipping segments in section mode. /// 重建整檔的合併邊線:收集所有「可見且開邊線」的 leaf 端點為單一 Point3DCollection。
/// 剖面開啟時即時裁切。一檔只有一條 LinesVisual3D → 轉動視角不再逐零件重建。
/// </summary> /// </summary>
private void RefreshRootEdges(ModelNodeViewModel root) private void RefreshRootEdges(ModelNodeViewModel root)
{ {
@@ -393,9 +404,9 @@ public partial class MainViewModel : ObservableObject
else if (!shouldShow && contains) _viewport.Children.Remove(visual); else if (!shouldShow && contains) _viewport.Children.Remove(visual);
} }
// Merged meshes // ─── 合併網格(瀏覽模式降 draw call)────────────────────────
/// <summary>Concatenates face meshes in the given order.</summary> /// <summary>把多個面網格依序併成單一 MeshGeometry3D(索引位移後串接;順序 = 呼叫端給的面序)</summary>
private static MeshGeometry3D MergeMeshes(IReadOnlyList<MeshGeometry3D> meshes) private static MeshGeometry3D MergeMeshes(IReadOnlyList<MeshGeometry3D> meshes)
{ {
int nv = 0, nt = 0; int nv = 0, nt = 0;
@@ -422,7 +433,7 @@ public partial class MainViewModel : ObservableObject
return mesh; return mesh;
} }
/// <summary>Builds the import mesh in face order for merged hit resolution.</summary> /// <summary>匯入時建合併網格(依 faces 面序串接,與 _mergedFaceRanges 的頂點邊界對齊)</summary>
private static MeshGeometry3D BuildMergedMesh(IReadOnlyList<ImportedFace> faces) private static MeshGeometry3D BuildMergedMesh(IReadOnlyList<ImportedFace> faces)
{ {
var meshes = new List<MeshGeometry3D>(faces.Count); var meshes = new List<MeshGeometry3D>(faces.Count);
@@ -430,7 +441,8 @@ public partial class MainViewModel : ObservableObject
return MergeMeshes(meshes); return MergeMeshes(meshes);
} }
/// <summary>Rebuilds a merged mesh after a transform without changing face vertex boundaries.</summary> /// <summary>重建合併網格(位置變更後,如零件平移)。由 leaf 逐面 fi.Mesh(未剖切原始)依 FacesContent 子序串接,
/// 與匯入時 BuildMergedMesh 的面序一致 → _mergedFaceRanges 頂點邊界維持有效。</summary>
private void RebuildMerged(ModelNodeViewModel leaf) private void RebuildMerged(ModelNodeViewModel leaf)
{ {
if (leaf.MergedContent is null || leaf.FacesContent is null) return; if (leaf.MergedContent is null || leaf.FacesContent is null) return;
@@ -442,11 +454,13 @@ public partial class MainViewModel : ObservableObject
} }
/// <summary> /// <summary>
/// Uses merged meshes for browsing and measurement; section mode uses separate clipped faces. /// 依目前狀態切換每個 leaf 的渲染內容:
/// 非剖面(瀏覽 + 量測皆是)→ 合併網格(每檔 1 個 model,快);量測拾取靠命中合併網格反查面(_mergedFaceRanges)。
/// 剖面 → 逐面(要顯示各面裁切後的幾何)。
/// </summary> /// </summary>
private void ApplyRenderMode() private void ApplyRenderMode()
{ {
// Measurement resolves merged-mesh hits back to their source faces. // 量測不再需要逐面渲染(拾取打合併網格反查面)→ 只有剖面才逐面。永不在量測模式掛數萬個 GeometryModel3D。
bool merged = !SectionEnabled; bool merged = !SectionEnabled;
foreach (ModelNodeViewModel leaf in Roots.SelectMany(r => r.Leaves())) foreach (ModelNodeViewModel leaf in Roots.SelectMany(r => r.Leaves()))
{ {
@@ -457,7 +471,7 @@ public partial class MainViewModel : ObservableObject
} }
} }
// Node commands // ─── 節點操作 ────────────────────────────────────────────────
[RelayCommand] [RelayCommand]
private void ZoomNode(ModelNodeViewModel node) private void ZoomNode(ModelNodeViewModel node)
@@ -469,14 +483,14 @@ public partial class MainViewModel : ObservableObject
[RelayCommand] [RelayCommand]
private void ZoomAll() => _viewport?.ZoomExtents(400); private void ZoomAll() => _viewport?.ZoomExtents(400);
/// <summary>Standard views with Z up.</summary> /// <summary>標準視圖(機構慣例 Z 向上):Iso / Front / Back / Top / Bottom / Left / Right</summary>
[RelayCommand] [RelayCommand]
private void SetStandardView(string name) private void SetStandardView(string name)
{ {
if (_viewport?.Camera is not ProjectionCamera cam) return; if (_viewport?.Camera is not ProjectionCamera cam) return;
(Vector3D dir, Vector3D up) = name switch (Vector3D dir, Vector3D up) = name switch
{ {
"Front" => (new Vector3D(0, 1, 0), new Vector3D(0, 0, 1)), "Front" => (new Vector3D(0, 1, 0), new Vector3D(0, 0, 1)), // 從 -Y 往 +Y 看
"Back" => (new Vector3D(0, -1, 0), new Vector3D(0, 0, 1)), "Back" => (new Vector3D(0, -1, 0), new Vector3D(0, 0, 1)),
"Top" => (new Vector3D(0, 0, -1), new Vector3D(0, 1, 0)), "Top" => (new Vector3D(0, 0, -1), new Vector3D(0, 1, 0)),
"Bottom" => (new Vector3D(0, 0, 1), new Vector3D(0, -1, 0)), "Bottom" => (new Vector3D(0, 0, 1), new Vector3D(0, -1, 0)),
@@ -490,13 +504,13 @@ public partial class MainViewModel : ObservableObject
: new Point3D(b.X + b.SizeX / 2, b.Y + b.SizeY / 2, b.Z + b.SizeZ / 2); : new Point3D(b.X + b.SizeX / 2, b.Y + b.SizeY / 2, b.Z + b.SizeZ / 2);
double dist = Math.Max(SceneDiagonal() * 1.8, 10); double dist = Math.Max(SceneDiagonal() * 1.8, 10);
cam.Position = center - dir * dist; cam.Position = center - dir * dist;
cam.LookDirection = dir * dist; cam.LookDirection = dir * dist; // 目標點 = Position + LookDirection = 場景中心
cam.UpDirection = up; cam.UpDirection = up;
_viewport.ZoomExtents(400); _viewport.ZoomExtents(400);
SetStatus(() => Format("Runtime.View", Get($"Runtime.View{name}"))); SetStatus(() => Format("Runtime.View", Get($"Runtime.View{name}")));
} }
/// <summary>Toggles orthographic projection while preserving the view angle.</summary> /// <summary>正交投影切換(平行投影,量尺寸不受透視變形;Helix 保持視角換相機型別)</summary>
[ObservableProperty] [ObservableProperty]
private bool orthographicView; private bool orthographicView;
@@ -512,16 +526,16 @@ public partial class MainViewModel : ObservableObject
{ {
if (_viewport is null || !root.IsRoot) return; if (_viewport is null || !root.IsRoot) return;
if (ReferenceEquals(root, _gizmoTarget)) GizmoEnabled = false; if (ReferenceEquals(root, _gizmoTarget)) GizmoEnabled = false; // 目標被移除 → 收掉操作器
// Clear measurements that may be attached to the removed file. // 量測 overlay 可能標在此檔模型上 → 一併清除(簡化:全清)
if (Measurements.Count > 0) ClearMeasurements(); if (Measurements.Count > 0) ClearMeasurements();
foreach (ModelNodeViewModel leaf in root.Leaves()) foreach (ModelNodeViewModel leaf in root.Leaves())
{ {
if (leaf.BodyVisual is not null) if (leaf.BodyVisual is not null)
_viewport.Children.Remove(leaf.BodyVisual); _viewport.Children.Remove(leaf.BodyVisual);
if (leaf.FacesContent is not null) if (leaf.FacesContent is not null) // 逐面群組才在 _faceMap(與目前顯示哪種內容無關)
foreach (Model3D m in leaf.FacesContent.Children) foreach (Model3D m in leaf.FacesContent.Children)
_faceMap.Remove(m); _faceMap.Remove(m);
if (leaf.MergedContent is not null) if (leaf.MergedContent is not null)
@@ -538,9 +552,9 @@ public partial class MainViewModel : ObservableObject
SetStatus(() => Format("Runtime.Removed", removedName)); SetStatus(() => Format("Runtime.Removed", removedName));
} }
// Visibility and volume // ─── 顯示控制 / 體積(樹右鍵選單)────────────────────────────
/// <summary>Shows only this node until ShowAll is called.</summary> /// <summary>只顯示此節點(其餘全部隱藏;「全部顯示」復原)</summary>
[RelayCommand] [RelayCommand]
private void IsolateNode(ModelNodeViewModel node) private void IsolateNode(ModelNodeViewModel node)
{ {
@@ -564,14 +578,15 @@ public partial class MainViewModel : ObservableObject
SetStatus(() => Get("Runtime.AllVisible")); SetStatus(() => Get("Runtime.AllVisible"));
} }
/// <summary>Sets visibility for every node, including mixed child states.</summary> /// <summary>逐節點顯式遞迴設定可見性。不能只設 root(setter 同值不觸發 cascade,
/// 之前的隔離操作可能留下「父關子開」的混合狀態)</summary>
private static void SetVisibleRecursive(ModelNodeViewModel node, bool visible) private static void SetVisibleRecursive(ModelNodeViewModel node, bool visible)
{ {
node.IsVisible = visible; node.IsVisible = visible;
foreach (ModelNodeViewModel c in node.Children) SetVisibleRecursive(c, visible); foreach (ModelNodeViewModel c in node.Children) SetVisibleRecursive(c, visible);
} }
/// <summary>Adds a volume and centroid measurement for a closed mesh.</summary> /// <summary>體積/質心(網格 signed volume,封閉實體才可靠)→ 加進量測結果 + 質心標記</summary>
[RelayCommand] [RelayCommand]
private void MeasureVolume(ModelNodeViewModel node) private void MeasureVolume(ModelNodeViewModel node)
{ {
@@ -621,12 +636,12 @@ public partial class MainViewModel : ObservableObject
AddMeasurement(m); AddMeasurement(m);
} }
// Measurement // ─── 量測 ────────────────────────────────────────────────────
partial void OnCurrentModeChanged(MeasureMode value) partial void OnCurrentModeChanged(MeasureMode value)
{ {
CancelPending(); CancelPending();
ApplyRenderMode(); ApplyRenderMode(); // 非剖面一律合併網格(量測拾取靠反查面);不再因切量測模式而掛逐面
string? key = value switch string? key = value switch
{ {
MeasureMode.None => "Runtime.ModeBrowse", MeasureMode.None => "Runtime.ModeBrowse",
@@ -652,10 +667,10 @@ public partial class MainViewModel : ObservableObject
SetStatus(() => Get(value ? "Runtime.UnitInch" : "Runtime.UnitMillimeter")); SetStatus(() => Get(value ? "Runtime.UnitInch" : "Runtime.UnitMillimeter"));
} }
/// <summary>Cancels a pending pick, then exits measurement mode or closes the gizmo.</summary> /// <summary>Esc(MainWindow 轉發):先取消進行中的多段量測(保留模式),再按退回瀏覽模式;再退操作器</summary>
public void OnEscape() public void OnEscape()
{ {
if (CancelSectionPickIfActive()) return; if (CancelSectionPickIfActive()) return; // 3點剖面定義中 → 先取消定義
bool hadPending = _pendingPoint is not null || _pendingDirection is not null || bool hadPending = _pendingPoint is not null || _pendingDirection is not null ||
_pendingFace is not null || _pendingAlign is not null || _align3.Count > 0; _pendingFace is not null || _pendingAlign is not null || _align3.Count > 0;
if (hadPending) if (hadPending)
@@ -673,7 +688,7 @@ public partial class MainViewModel : ObservableObject
} }
} }
/// <summary>Handles measurement hotkeys; repeating a key returns to browse mode.</summary> /// <summary>量測模式快速鍵(MainWindow 轉發;無修飾鍵)。同鍵再按 = 退回瀏覽。回傳 false 表示非快速鍵。</summary>
public bool OnHotKey(System.Windows.Input.Key key) public bool OnHotKey(System.Windows.Input.Key key)
{ {
MeasureMode? m = key switch MeasureMode? m = key switch
@@ -692,11 +707,11 @@ public partial class MainViewModel : ObservableObject
return true; return true;
} }
/// <summary>Handles left-clicks forwarded by MainWindow.</summary> /// <summary>MainWindow 滑鼠左鍵點擊轉發進來</summary>
public void OnViewportClick(Point position) public void OnViewportClick(Point position)
{ {
if (_viewport is null || IsBusy) return; if (_viewport is null || IsBusy) return;
if (HandleSectionPlanePick(position)) return; if (HandleSectionPlanePick(position)) return; // 3點剖面定義模式優先吃點擊(v0.5.0)
if (CurrentMode is MeasureMode.None or MeasureMode.Drag) return; if (CurrentMode is MeasureMode.None or MeasureMode.Drag) return;
var hits = _viewport.Viewport.FindHits(position); var hits = _viewport.Viewport.FindHits(position);
@@ -706,13 +721,13 @@ public partial class MainViewModel : ObservableObject
foreach (var h in hits) foreach (var h in hits)
{ {
if (h.Model is null) continue; if (h.Model is null) continue;
// Section mode renders individual faces. // 剖面模式:渲染逐面 → 直接查 _faceMap
if (_faceMap.TryGetValue(h.Model, out FaceInfo? f)) if (_faceMap.TryGetValue(h.Model, out FaceInfo? f))
{ {
fi = f; hitPoint = h.Position; hitNormal = h.Normal; fi = f; hitPoint = h.Position; hitNormal = h.Normal;
break; break;
} }
// Other modes resolve merged-mesh vertex indices to faces. // 量測/瀏覽模式:渲染合併網格 → 用命中三角形頂點 index 反查回是哪個面
if (h.RayHit is not null && _mergedFaceRanges.TryGetValue(h.Model, out var range)) if (h.RayHit is not null && _mergedFaceRanges.TryGetValue(h.Model, out var range))
{ {
FaceInfo? mf = ResolveMergedFace(range, h.RayHit.VertexIndex1); FaceInfo? mf = ResolveMergedFace(range, h.RayHit.VertexIndex1);
@@ -740,13 +755,13 @@ public partial class MainViewModel : ObservableObject
} }
} }
/// <summary>Finds the source face for a merged-mesh vertex by binary search.</summary> /// <summary>命中合併網格的某頂點 index → 反查回是哪個面(依面序的頂點起始邊界二分搜尋)。</summary>
private static FaceInfo? ResolveMergedFace((int[] Starts, FaceInfo[] Faces) range, int vertexIndex) private static FaceInfo? ResolveMergedFace((int[] Starts, FaceInfo[] Faces) range, int vertexIndex)
{ {
int[] starts = range.Starts; int[] starts = range.Starts;
FaceInfo[] faces = range.Faces; FaceInfo[] faces = range.Faces;
if (vertexIndex < 0 || faces.Length == 0 || vertexIndex >= starts[faces.Length]) return null; if (vertexIndex < 0 || faces.Length == 0 || vertexIndex >= starts[faces.Length]) return null;
// Find the largest face start not exceeding the hit vertex index. // 找最大的 k 使 starts[k] <= vertexIndex(face k 佔頂點 [starts[k], starts[k+1]))
int lo = 0, hi = faces.Length - 1, ans = 0; int lo = 0, hi = faces.Length - 1, ans = 0;
while (lo <= hi) while (lo <= hi)
{ {
@@ -763,7 +778,7 @@ public partial class MainViewModel : ObservableObject
double snapTol = diag * 0.02; double snapTol = diag * 0.02;
double markerR = Math.Clamp(diag * 0.004, 0.01, 5.0); double markerR = Math.Clamp(diag * 0.004, 0.01, 5.0);
// STL has no B-rep edges or faces for these modes. // 無 B-rep 來源(STL)只支援部分模式
if (fi.BrepFace is null && CurrentMode is MeasureMode.Edge or MeasureMode.Circle or MeasureMode.Face) if (fi.BrepFace is null && CurrentMode is MeasureMode.Edge or MeasureMode.Circle or MeasureMode.Face)
{ {
SetStatus(() => Get("Runtime.MeshMeasurementLimited")); SetStatus(() => Get("Runtime.MeshMeasurementLimited"));
@@ -885,7 +900,7 @@ public partial class MainViewModel : ObservableObject
ModelNodeViewModel? root = FindRootOf(fi.Owner); ModelNodeViewModel? root = FindRootOf(fi.Owner);
if (root is null) { SetStatus(() => Get("Runtime.RootNotFound")); break; } if (root is null) { SetStatus(() => Get("Runtime.RootNotFound")); break; }
if (_align3.Count < 3) if (_align3.Count < 3) // 來源 3 點(要移動的檔案)
{ {
if (_align3.Count > 0 && !ReferenceEquals(_align3[0].Root, root)) if (_align3.Count > 0 && !ReferenceEquals(_align3[0].Root, root))
{ {
@@ -905,7 +920,7 @@ public partial class MainViewModel : ObservableObject
? Format("Runtime.Align3SourcePoint", sourceCount, root.Name) ? Format("Runtime.Align3SourcePoint", sourceCount, root.Name)
: Get("Runtime.Align3SourceComplete")); : Get("Runtime.Align3SourceComplete"));
} }
else else // 目標 3 點(另一個檔案,順序對應來源點)
{ {
if (ReferenceEquals(_align3[0].Root, root)) if (ReferenceEquals(_align3[0].Root, root))
{ {
@@ -1007,7 +1022,7 @@ public partial class MainViewModel : ObservableObject
_align3.Clear(); _align3.Clear();
} }
// Rigid part transforms // ─── 零件剛體變換(兩點對齊=平移、旋轉 90°、三點對齊=旋轉+平移)──
private void TranslateRoot(ModelNodeViewModel root, Vector3D v) private void TranslateRoot(ModelNodeViewModel root, Vector3D v)
{ {
@@ -1018,17 +1033,19 @@ public partial class MainViewModel : ObservableObject
} }
/// <summary> /// <summary>
/// Transforms B-rep objects, meshes, edges and bounds for one file. /// 對整個檔案(root)套剛體變換:B-rep 用 ModOp 整體 Modify(量測維持精確)、
/// op and m must represent the same transform in CADability and WPF conventions. /// 渲染網格 / 合併網格 / 邊線端點同步重算、邊界重新計算。
/// op 與 m 必須是同一個變換(CADability 與 WPF 兩種表示)。
/// </summary> /// </summary>
private void TransformRoot(ModelNodeViewModel root, CADability.ModOp op, Matrix3D m) private void TransformRoot(ModelNodeViewModel root, CADability.ModOp op, Matrix3D m)
{ {
if (_viewport is null) return; if (_viewport is null) return;
ClearMeasurements(); ClearMeasurements(); // 量測標記位置已失效
var leaves = root.Leaves().ToList(); var leaves = root.Leaves().ToList();
// Transform whole B-rep objects sequentially to avoid shared-edge changes. // B-rep:對 Solid/Shell 整體 Modify(避免共用邊被逐面重複變換)。
// CADability 物件非執行緒安全 → 維持循序
foreach (ModelNodeViewModel leaf in leaves) foreach (ModelNodeViewModel leaf in leaves)
foreach (CADability.GeoObject.IGeoObject g in leaf.SourceGeos) foreach (CADability.GeoObject.IGeoObject g in leaf.SourceGeos)
{ {
@@ -1036,7 +1053,7 @@ public partial class MainViewModel : ObservableObject
catch { } catch { }
} }
// Transform independent frozen face meshes in parallel. // 渲染網格:面級平行變換(來源/輸出皆 frozen、各面獨立 → 安全;大檔 64k 面吃滿多核)
var faceItems = new List<(GeometryModel3D Gm, FaceInfo Fi)>(); var faceItems = new List<(GeometryModel3D Gm, FaceInfo Fi)>();
foreach (ModelNodeViewModel leaf in leaves) foreach (ModelNodeViewModel leaf in leaves)
if (leaf.FacesContent is not null) if (leaf.FacesContent is not null)
@@ -1046,7 +1063,7 @@ public partial class MainViewModel : ObservableObject
var moved = new MeshGeometry3D[faceItems.Count]; var moved = new MeshGeometry3D[faceItems.Count];
Parallel.For(0, faceItems.Count, i => moved[i] = TransformMesh(faceItems[i].Fi.Mesh, m)); Parallel.For(0, faceItems.Count, i => moved[i] = TransformMesh(faceItems[i].Fi.Mesh, m));
for (int i = 0; i < faceItems.Count; i++) for (int i = 0; i < faceItems.Count; i++) // 視覺樹賦值回 UI 執行緒循序做
{ {
faceItems[i].Fi.Mesh = moved[i]; faceItems[i].Fi.Mesh = moved[i];
faceItems[i].Gm.Geometry = moved[i]; faceItems[i].Gm.Geometry = moved[i];
@@ -1055,15 +1072,15 @@ public partial class MainViewModel : ObservableObject
foreach (ModelNodeViewModel leaf in leaves) foreach (ModelNodeViewModel leaf in leaves)
{ {
if (leaf.FacesContent is not null) if (leaf.FacesContent is not null)
RebuildMerged(leaf); RebuildMerged(leaf); // 合併網格同步
// Update edge data before rebuilding the merged visual. // 邊線端點(資料);實際合併線於迴圈後重建
if (leaf.OriginalEdgePoints is not null) if (leaf.OriginalEdgePoints is not null)
leaf.OriginalEdgePoints = TransformPoints(leaf.OriginalEdgePoints, m); leaf.OriginalEdgePoints = TransformPoints(leaf.OriginalEdgePoints, m);
} }
RecomputeBounds(root); RecomputeBounds(root);
if (SectionEnabled) ApplySection(); if (SectionEnabled) ApplySection(); // 內含 RefreshRootEdges
else RefreshRootEdges(root); else RefreshRootEdges(root);
} }
@@ -1074,7 +1091,7 @@ public partial class MainViewModel : ObservableObject
var mesh = new MeshGeometry3D var mesh = new MeshGeometry3D
{ {
Positions = positions, Positions = positions,
TriangleIndices = src.TriangleIndices, TriangleIndices = src.TriangleIndices, // frozen,可共用(法向量缺省由 WPF 自算)
}; };
mesh.Freeze(); mesh.Freeze();
return mesh; return mesh;
@@ -1088,7 +1105,7 @@ public partial class MainViewModel : ObservableObject
return pts; return pts;
} }
/// <summary>Recomputes bounds from transformed meshes and edges.</summary> /// <summary>旋轉後 AABB 不能只平移 → 由網格/邊線重新計算(bottom-up)</summary>
private Rect3D RecomputeBounds(ModelNodeViewModel node) private Rect3D RecomputeBounds(ModelNodeViewModel node)
{ {
Rect3D b = Rect3D.Empty; Rect3D b = Rect3D.Empty;
@@ -1110,7 +1127,7 @@ public partial class MainViewModel : ObservableObject
return b; return b;
} }
// Rotate selected file // ─── 旋轉 90°(樹面板選取的檔案,繞其中心)──────────────────
[RelayCommand(CanExecute = nameof(IsIdle))] [RelayCommand(CanExecute = nameof(IsIdle))]
private void RotateRoot(string axisName) private void RotateRoot(string axisName)
@@ -1144,9 +1161,9 @@ public partial class MainViewModel : ObservableObject
private static bool ContainsNode(ModelNodeViewModel parent, ModelNodeViewModel target) => private static bool ContainsNode(ModelNodeViewModel parent, ModelNodeViewModel target) =>
parent.Children.Any(c => ReferenceEquals(c, target) || ContainsNode(c, target)); parent.Children.Any(c => ReferenceEquals(c, target) || ContainsNode(c, target));
// Three-point alignment math is in RigidAlign. // 三點對齊的剛體變換數學在 Services/RigidAlign.cs(SmokeTest --align-test 驗證)
// Shared helpers // ─── 共用 ────────────────────────────────────────────────────
private Rect3D UnionBounds() private Rect3D UnionBounds()
{ {
+30 -29
View File
@@ -12,30 +12,30 @@ using STPViewer.Services;
namespace STPViewer.ViewModels; namespace STPViewer.ViewModels;
/// <summary> /// <summary>
/// Assembly tree node: imported file, STEP group, or geometry leaf. /// 裝配樹節點:root = 匯入檔、group = STEP 裝配(Block)、leaf = 零件幾何。
/// Visibility, edges, and color cascade to children. /// 可見性 / 邊線 / 顏色設定向下層疊(cascade)。
/// </summary> /// </summary>
public partial class ModelNodeViewModel : ObservableObject public partial class ModelNodeViewModel : ObservableObject
{ {
/// <summary>Palette used when assigning and cycling leaf colors.</summary> /// <summary>調色盤(每個 leaf 依序取色,點色塊循環切換)</summary>
public static readonly Color[] Palette = public static readonly Color[] Palette =
{ {
Color.FromRgb(0x90, 0xA4, 0xAE), Color.FromRgb(0x90, 0xA4, 0xAE), // 藍灰(金屬感)
Color.FromRgb(0x4F, 0xC3, 0xF7), Color.FromRgb(0x4F, 0xC3, 0xF7), // 淺藍
Color.FromRgb(0xFF, 0xB7, 0x4D), Color.FromRgb(0xFF, 0xB7, 0x4D), // 橙
Color.FromRgb(0x81, 0xC7, 0x84), Color.FromRgb(0x81, 0xC7, 0x84), // 綠
Color.FromRgb(0xBA, 0x68, 0xC8), Color.FromRgb(0xBA, 0x68, 0xC8), // 紫
Color.FromRgb(0xE5, 0x73, 0x73), Color.FromRgb(0xE5, 0x73, 0x73), // 紅
Color.FromRgb(0xFF, 0xD5, 0x4F), Color.FromRgb(0xFF, 0xD5, 0x4F), // 黃
Color.FromRgb(0x4D, 0xB6, 0xAC), Color.FromRgb(0x4D, 0xB6, 0xAC), // 藍綠
Color.FromRgb(0xA1, 0x88, 0x7F), Color.FromRgb(0xA1, 0x88, 0x7F), // 棕
Color.FromRgb(0x79, 0x86, 0xCB), Color.FromRgb(0x79, 0x86, 0xCB), // 靛藍
}; };
[ObservableProperty] [ObservableProperty]
private bool isVisible = true; private bool isVisible = true;
/// <summary>Outline visibility.</summary> /// <summary>輪廓邊線開關(邊線量過大的檔案匯入時自動關閉,避免轉動視角卡頓)</summary>
[ObservableProperty] [ObservableProperty]
private bool showEdges = true; private bool showEdges = true;
@@ -55,7 +55,7 @@ public partial class ModelNodeViewModel : ObservableObject
public string? NameKey { get; init; } public string? NameKey { get; init; }
public int NameNumber { get; init; } public int NameNumber { get; init; }
/// <summary>Set only on imported file roots.</summary> /// <summary>root(檔案層級)才有值</summary>
public string? FilePath { get; init; } public string? FilePath { get; init; }
public bool IsRoot => FilePath is not null; public bool IsRoot => FilePath is not null;
@@ -66,7 +66,7 @@ public partial class ModelNodeViewModel : ObservableObject
public int FaceCount { get; init; } public int FaceCount { get; init; }
public int TriangleCount { get; init; } public int TriangleCount { get; init; }
/// <summary>World-space bounds updated after part transforms.</summary> /// <summary>世界座標邊界(零件平移/旋轉後由 RecomputeBounds 更新;連動 Stats/Tooltip 的外形尺寸)</summary>
public Rect3D Bounds public Rect3D Bounds
{ {
get => _bounds; get => _bounds;
@@ -80,15 +80,15 @@ public partial class ModelNodeViewModel : ObservableObject
} }
private Rect3D _bounds = Rect3D.Empty; private Rect3D _bounds = Rect3D.Empty;
/// <summary>Axis-aligned size in millimeters.</summary> /// <summary>外形尺寸 L×W×H(AABB,mm)</summary>
public string SizeText => Bounds.IsEmpty public string SizeText => Bounds.IsEmpty
? "" ? ""
: $"{Bounds.SizeX:F1} × {Bounds.SizeY:F1} × {Bounds.SizeZ:F1} mm"; : $"{Bounds.SizeX:F1} × {Bounds.SizeY:F1} × {Bounds.SizeZ:F1} mm";
/// <summary>Source B-rep objects transformed as whole parts.</summary> /// <summary>來源 B-rep 物件(零件平移時整體 Modify,避免共用邊被重複位移)</summary>
public List<CADability.GeoObject.IGeoObject> SourceGeos { get; } = new(); public List<CADability.GeoObject.IGeoObject> SourceGeos { get; } = new();
/// <summary>False for STL or curve-only sources without B-rep geometry.</summary> /// <summary>false = STL/曲線等無 B-rep 來源(邊/面/圓量測不可用)</summary>
public bool HasBrep { get; init; } = true; public bool HasBrep { get; init; } = true;
public string Stats => public string Stats =>
@@ -106,13 +106,14 @@ public partial class ModelNodeViewModel : ObservableObject
foreach (ModelNodeViewModel child in Children) child.RefreshLocalizedText(); foreach (ModelNodeViewModel child in Children) child.RefreshLocalizedText();
} }
// Tree filtering does not change viewport visibility. // ── 樹搜尋/過濾(只影響樹面板顯示,不影響 3D 可見性)──
[ObservableProperty] [ObservableProperty]
private bool isFilterVisible = true; private bool isFilterVisible = true;
/// <summary> /// <summary>
/// Shows matching nodes, their ancestors and descendants; expands matching branches. /// 套用名稱過濾:符合者 + 其祖先 + 其子樹 顯示。空字串 = 全顯。
/// 過濾中有符合的子孫時自動展開。回傳此節點是否顯示。
/// </summary> /// </summary>
public bool ApplyFilter(string? filter, bool ancestorMatched = false) public bool ApplyFilter(string? filter, bool ancestorMatched = false)
{ {
@@ -126,17 +127,17 @@ public partial class ModelNodeViewModel : ObservableObject
return IsFilterVisible; return IsFilterVisible;
} }
// Viewport objects // ── 視覺物件 ──
public ModelVisual3D? BodyVisual { get; set; } public ModelVisual3D? BodyVisual { get; set; } // leaf:面網格容器(內容在兩種模式間切換)
public Model3DGroup? FacesContent { get; set; } public Model3DGroup? FacesContent { get; set; } // leaf:逐面 GeometryModel3D(剖面用;量測打合併網格)
public Model3DGroup? MergedContent { get; set; } public Model3DGroup? MergedContent { get; set; } // leaf:整零件合併成 1 個 GeometryModel3D(瀏覽+量測,draw call 大減)
public LinesVisual3D? EdgeVisual { get; set; } public LinesVisual3D? EdgeVisual { get; set; } // root:整檔合併邊線(一檔一條,避免轉動卡頓)
public DiffuseMaterial? SharedMaterial { get; set; } public DiffuseMaterial? SharedMaterial { get; set; }
/// <summary>Unclipped edge endpoints used for section restoration.</summary> /// <summary>原始(未剖切)邊線端點 — 剖面還原用</summary>
public Point3DCollection? OriginalEdgePoints { get; set; } public Point3DCollection? OriginalEdgePoints { get; set; }
/// <summary>Signals the ViewModel to update leaf viewport objects.</summary> /// <summary>leaf 的可見性/邊線狀態變更(MainViewModel 同步 viewport)</summary>
public event Action<ModelNodeViewModel>? VisualStateChanged; public event Action<ModelNodeViewModel>? VisualStateChanged;
private int _paletteIndex; private int _paletteIndex;
@@ -147,7 +148,7 @@ public partial class ModelNodeViewModel : ObservableObject
color = Palette[_paletteIndex]; color = Palette[_paletteIndex];
} }
/// <summary>All descendant leaves, including this node when it is a leaf.</summary> /// <summary>所有 leaf 後代(含自身若為 leaf)</summary>
public IEnumerable<ModelNodeViewModel> Leaves() public IEnumerable<ModelNodeViewModel> Leaves()
{ {
if (IsLeafNode) { yield return this; yield break; } if (IsLeafNode) { yield return this; yield break; }
@@ -7,7 +7,7 @@ using STPViewer.Services;
namespace STPViewer.ViewModels; namespace STPViewer.ViewModels;
/// <summary>Visibility toggle for one quick bar button.</summary> /// <summary>快速列單一按鈕的顯示開關(選單「快速列 → 自訂按鈕」的 checkbox 項)</summary>
public partial class QuickBarItemViewModel : ObservableObject public partial class QuickBarItemViewModel : ObservableObject
{ {
public string Key { get; } public string Key { get; }
@@ -30,11 +30,13 @@ public partial class QuickBarItemViewModel : ObservableObject
} }
/// <summary> /// <summary>
/// Quick bar button registry and saved visibility selections. /// 快速工具列自訂:所有功能都在選單列,快速列只放使用者勾選的常用按鈕。
/// XAML 以索引子綁定:Visibility="{Binding QuickBar[Point].IsChecked, Converter=…}"。
/// 勾選狀態隨 settings.json(QuickBarKeys)保存;null = 用預設。
/// </summary> /// </summary>
public partial class QuickBarViewModel : ObservableObject public partial class QuickBarViewModel : ObservableObject
{ {
/// <summary>Button order, label key, and default visibility.</summary> /// <summary>註冊表 = 快速列順序 + 選單顯示名 + 是否預設顯示(一般 user 常用)</summary>
private static readonly (string Key, string Label, bool Default)[] Registry = private static readonly (string Key, string Label, bool Default)[] Registry =
{ {
("Import", "QuickBar.Import", true), ("Import", "QuickBar.Import", true),
@@ -72,7 +74,7 @@ public partial class QuickBarViewModel : ObservableObject
private readonly Dictionary<string, QuickBarItemViewModel> _byKey; private readonly Dictionary<string, QuickBarItemViewModel> _byKey;
/// <summary>All buttons in quick bar order.</summary> /// <summary>依快速列順序的全部項目(選單自訂清單用)</summary>
public IReadOnlyList<QuickBarItemViewModel> Items { get; } public IReadOnlyList<QuickBarItemViewModel> Items { get; }
public QuickBarViewModel() public QuickBarViewModel()
@@ -81,10 +83,10 @@ public partial class QuickBarViewModel : ObservableObject
_byKey = Items.ToDictionary(i => i.Key); _byKey = Items.ToDictionary(i => i.Key);
} }
/// <summary>Indexer used by XAML visibility bindings.</summary> /// <summary>XAML 索引子綁定入口:QuickBar[Point].IsChecked</summary>
public QuickBarItemViewModel this[string key] => _byKey[key]; public QuickBarItemViewModel this[string key] => _byKey[key];
/// <summary>Loads saved visibility keys; null retains defaults.</summary> /// <summary>套用保存的勾選清單;null = 維持預設(含未知 key 容錯:新版本新增按鈕用預設值)</summary>
public void Load(List<string>? visibleKeys) public void Load(List<string>? visibleKeys)
{ {
if (visibleKeys is null) return; if (visibleKeys is null) return;
@@ -93,7 +95,7 @@ public partial class QuickBarViewModel : ObservableObject
item.IsChecked = set.Contains(item.Key); item.IsChecked = set.Contains(item.Key);
} }
/// <summary>Selected keys saved in settings.json.</summary> /// <summary>目前勾選清單(存 settings.json)</summary>
public List<string> ToKeys() => public List<string> ToKeys() =>
Items.Where(i => i.IsChecked).Select(i => i.Key).ToList(); Items.Where(i => i.IsChecked).Select(i => i.Key).ToList();
+10 -9
View File
@@ -5,17 +5,18 @@ using STPViewer.Services;
namespace STPViewer.ViewModels; namespace STPViewer.ViewModels;
/// <summary> /// <summary>
/// Options and live size estimate for the STL export dialog. /// STL 匯出參數對話框的 ViewModel:範圍 / 格式 / 單位縮放 / 網格精度 + 即時摘要(三角形數、預估大小)。
/// 對話框按「匯出」後由 MainViewModel.Export 讀取選項執行。
/// </summary> /// </summary>
public partial class StlExportViewModel : ObservableObject public partial class StlExportViewModel : ObservableObject
{ {
/// <summary>Number of visible files available for export.</summary> /// <summary>可見(可匯出)檔案數</summary>
public int FileCount { get; } public int FileCount { get; }
/// <summary>Triangle count of the current display meshes.</summary> /// <summary>目前顯示網格的三角形總數(精細/較粗會重新三角化,實際數量以結果為準)</summary>
public long TriangleCount { get; } public long TriangleCount { get; }
/// <summary>Whether B-rep geometry is available for retessellation.</summary> /// <summary>是否有 B-rep 來源(全 STL/DXF 時精度選項無意義 → 停用)</summary>
public bool HasBrep { get; } public bool HasBrep { get; }
public StlExportViewModel(int fileCount, long triangleCount, bool hasBrep) public StlExportViewModel(int fileCount, long triangleCount, bool hasBrep)
@@ -25,17 +26,17 @@ public partial class StlExportViewModel : ObservableObject
HasBrep = hasBrep; HasBrep = hasBrep;
} }
/// <summary>0 = merged STL; 1 = one STL per visible file.</summary> /// <summary>0 = 全部可見合併成單一 STL;1 = 每個可見檔案各一個 STL</summary>
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(Summary))] [NotifyPropertyChangedFor(nameof(Summary))]
private int scopeIndex; private int scopeIndex;
/// <summary>0 = binary; 1 = ASCII.</summary> /// <summary>0 = Binary(建議,檔案小);1 = ASCII(文字可讀,檔案大)</summary>
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(Summary))] [NotifyPropertyChangedFor(nameof(Summary))]
private int formatIndex; private int formatIndex;
/// <summary>0 = millimeters; 1 = inches; 2 = custom scale.</summary> /// <summary>0 = mm(1:1);1 = inch(÷25.4);2 = 自訂縮放</summary>
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(Summary))] [NotifyPropertyChangedFor(nameof(Summary))]
[NotifyPropertyChangedFor(nameof(IsCustomScale))] [NotifyPropertyChangedFor(nameof(IsCustomScale))]
@@ -47,7 +48,7 @@ public partial class StlExportViewModel : ObservableObject
[NotifyPropertyChangedFor(nameof(CanConfirm))] [NotifyPropertyChangedFor(nameof(CanConfirm))]
private string customScaleText = "1.0"; private string customScaleText = "1.0";
/// <summary>0 = current mesh; 1 = finer B-rep tessellation.</summary> /// <summary>0 = 目前顯示網格;1 = 精細(B-rep 重新三角化)</summary>
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(Summary))] [NotifyPropertyChangedFor(nameof(Summary))]
private int qualityIndex; private int qualityIndex;
@@ -58,7 +59,7 @@ public partial class StlExportViewModel : ObservableObject
public bool PerFile => ScopeIndex == 1; public bool PerFile => ScopeIndex == 1;
public StlMeshQuality Quality => (StlMeshQuality)QualityIndex; public StlMeshQuality Quality => (StlMeshQuality)QualityIndex;
/// <summary>Coordinate scale, or null for an invalid custom value.</summary> /// <summary>座標縮放;自訂值無效時回 null(擋確認)</summary>
public double? Scale => UnitIndex switch public double? Scale => UnitIndex switch
{ {
0 => 1.0, 0 => 1.0,
+4
View File
@@ -250,6 +250,10 @@
"Ui.About.Release071.Change1": "• Large STL mesh files with millions of triangles now open almost instantly instead of taking several minutes.", "Ui.About.Release071.Change1": "• Large STL mesh files with millions of triangles now open almost instantly instead of taking several minutes.",
"Ui.About.Release071.Change2": "• For example, a 75 MB STL with 1.5 million triangles now appears in under a second.", "Ui.About.Release071.Change2": "• For example, a 75 MB STL with 1.5 million triangles now appears in under a second.",
"Ui.About.Release071.Title": "Open Large STL Files in Seconds", "Ui.About.Release071.Title": "Open Large STL Files in Seconds",
"Ui.About.Release080.Change1": "• Choose English, 繁體中文 or 简体中文 from View → Language or the quick toolbar. The whole interface switches instantly — including measurements already on screen — and your choice is remembered.",
"Ui.About.Release080.Change2": "• Languages are plain files in the i18n folder next to the program: drop in a new one and it shows up in the menu, no update needed. Anything not yet translated falls back to English.",
"Ui.About.Release080.Change3": "• Thanks to @CarmJos for contributing this feature.",
"Ui.About.Release080.Title": "Switch Languages: English, Traditional & Simplified Chinese",
"Ui.About.Technology": ".NET 8 · WPF · C#; CADability (STEP / B-rep geometry); HelixToolkit (3D display and camera)", "Ui.About.Technology": ".NET 8 · WPF · C#; CADability (STEP / B-rep geometry); HelixToolkit (3D display and camera)",
"Ui.About.TechnologyTitle": "🔧 Technology", "Ui.About.TechnologyTitle": "🔧 Technology",
"Ui.About.Title": "About STPViewer", "Ui.About.Title": "About STPViewer",
+4
View File
@@ -250,6 +250,10 @@
"Ui.About.Release071.Change1": "• 打开大型 STL 网格文件(数十 MB、上百万三角形)现在几乎瞬间完成,不用再等好几分钟。", "Ui.About.Release071.Change1": "• 打开大型 STL 网格文件(数十 MB、上百万三角形)现在几乎瞬间完成,不用再等好几分钟。",
"Ui.About.Release071.Change2": "• 例如一个 75 MB、150 万个三角形的 STL,以前要等到天荒地老,现在不到 1 秒就显示出来。", "Ui.About.Release071.Change2": "• 例如一个 75 MB、150 万个三角形的 STL,以前要等到天荒地老,现在不到 1 秒就显示出来。",
"Ui.About.Release071.Title": "大型 STL 秒开", "Ui.About.Release071.Title": "大型 STL 秒开",
"Ui.About.Release080.Change1": "• 在「视图 → 语言」或快速栏即可切换 English、繁體中文、简体中文,整个界面立即更换(连画面上已经测量好的结果也会跟着换),下次打开会记住。",
"Ui.About.Release080.Change2": "• 语言是程序旁 i18n 文件夹里的普通文件:放一个新的进去,菜单就多一种语言,无需更新程序。尚未翻译的文字会先以英文显示。",
"Ui.About.Release080.Change3": "• 感谢 @CarmJos 贡献这项功能。",
"Ui.About.Release080.Title": "语言切换:英文/繁体中文/简体中文",
"Ui.About.Technology": ".NET 8 · WPF · C#;CADability(STEP / B-rep 几何内核);HelixToolkit(3D 显示与相机)", "Ui.About.Technology": ".NET 8 · WPF · C#;CADability(STEP / B-rep 几何内核);HelixToolkit(3D 显示与相机)",
"Ui.About.TechnologyTitle": "🔧 技术栈", "Ui.About.TechnologyTitle": "🔧 技术栈",
"Ui.About.Title": "关于 STPViewer", "Ui.About.Title": "关于 STPViewer",
+4
View File
@@ -250,6 +250,10 @@
"Ui.About.Release071.Change1": "• 開啟大型 STL 網格檔(數十 MB、上百萬三角形)現在幾乎瞬間完成,不用再等好幾分鐘。", "Ui.About.Release071.Change1": "• 開啟大型 STL 網格檔(數十 MB、上百萬三角形)現在幾乎瞬間完成,不用再等好幾分鐘。",
"Ui.About.Release071.Change2": "• 例如一個 75 MB、150 萬個三角形的 STL,以前要等到天荒地老,現在不到 1 秒就顯示出來。", "Ui.About.Release071.Change2": "• 例如一個 75 MB、150 萬個三角形的 STL,以前要等到天荒地老,現在不到 1 秒就顯示出來。",
"Ui.About.Release071.Title": "大型 STL 秒開", "Ui.About.Release071.Title": "大型 STL 秒開",
"Ui.About.Release080.Change1": "• 到「視圖 → 語言」或快速列就能切換 English、繁體中文、简体中文,整個介面立刻換掉(連畫面上已經量好的結果也會跟著換),下次開啟會記住。",
"Ui.About.Release080.Change2": "• 語言是程式旁 i18n 資料夾裡的一般檔案:放一個新的進去,選單就多一種語言,不用更新程式。還沒翻譯到的文字會先用英文顯示。",
"Ui.About.Release080.Change3": "• 感謝 @CarmJos 貢獻這項功能。",
"Ui.About.Release080.Title": "語言切換:英文/繁體中文/簡體中文",
"Ui.About.Technology": ".NET 8 · WPF · C#;CADability(STEP / B-rep 幾何核心);HelixToolkit(3D 顯示與相機)", "Ui.About.Technology": ".NET 8 · WPF · C#;CADability(STEP / B-rep 幾何核心);HelixToolkit(3D 顯示與相機)",
"Ui.About.TechnologyTitle": "🔧 技術棧", "Ui.About.TechnologyTitle": "🔧 技術棧",
"Ui.About.Title": "關於 STPViewer", "Ui.About.Title": "關於 STPViewer",
+8 -7
View File
@@ -1,4 +1,5 @@
// Three-point alignment checks: known transform, Matrix3D/ModOp agreement, and collinear rejection. // 三點對齊剛體變換數學驗證:
// (1) 已知變換還原 (2) Matrix3D ↔ ModOp 兩種表示一致 (3) 共線拒絕
using System.Windows.Media.Media3D; using System.Windows.Media.Media3D;
using STPViewer.Services; using STPViewer.Services;
@@ -10,29 +11,29 @@ internal static class AlignTest
{ {
int failures = 0; int failures = 0;
// Recover a known 90-degree Z rotation followed by translation (5,-3,2). // ── (1) 已知剛體變換:繞 Z 軸 90° + 平移 (5,-3,2),三點求解後應還原同一變換 ──
var truth = Matrix3D.Identity; var truth = Matrix3D.Identity;
truth.RotateAt(new Quaternion(new Vector3D(0, 0, 1), 90), new Point3D(2, 1, 0)); truth.RotateAt(new Quaternion(new Vector3D(0, 0, 1), 90), new Point3D(2, 1, 0));
truth.Translate(new Vector3D(5, -3, 2)); truth.Translate(new Vector3D(5, -3, 2));
var p1 = new Point3D(0, 0, 0); var p1 = new Point3D(0, 0, 0);
var p2 = new Point3D(10, 0, 0); var p2 = new Point3D(10, 0, 0);
var p3 = new Point3D(0, 7, 3); // Not collinear with the first two points. var p3 = new Point3D(0, 7, 3); // 不共線、不在同平面軸上
Point3D q1 = truth.Transform(p1), q2 = truth.Transform(p2), q3 = truth.Transform(p3); Point3D q1 = truth.Transform(p1), q2 = truth.Transform(p2), q3 = truth.Transform(p3);
bool ok = RigidAlign.TryRigidTransform(p1, p2, p3, q1, q2, q3, out Matrix3D m); bool ok = RigidAlign.TryRigidTransform(p1, p2, p3, q1, q2, q3, out Matrix3D m);
failures += Check("three-point transform solved", ok, true); failures += Check("three-point transform solved", ok, true);
// Corresponding points must coincide. // 三個對應點要精確貼合
failures += CheckPoint("p1→q1", m.Transform(p1), q1); failures += CheckPoint("p1→q1", m.Transform(p1), q1);
failures += CheckPoint("p2→q2", m.Transform(p2), q2); failures += CheckPoint("p2→q2", m.Transform(p2), q2);
failures += CheckPoint("p3→q3", m.Transform(p3), q3); failures += CheckPoint("p3→q3", m.Transform(p3), q3);
// An independent fourth point checks the full rigid transform. // 剛體變換唯一性:任意第 4 點也應該跟著正確變換
var r = new Point3D(-4, 12, 8); var r = new Point3D(-4, 12, 8);
failures += CheckPoint("fourth point", m.Transform(r), truth.Transform(r)); failures += CheckPoint("fourth point", m.Transform(r), truth.Transform(r));
// CADability ModOp and WPF Matrix3D must transform points identically. // ── (2) ToModOp:CADability ModOp 與 WPF Matrix3D 對同一點要算出同樣結果 ──
CADability.ModOp op = RigidAlign.ToModOp(m); CADability.ModOp op = RigidAlign.ToModOp(m);
foreach (Point3D p in new[] { p1, p2, p3, r, new Point3D(1.5, -2.25, 9) }) foreach (Point3D p in new[] { p1, p2, p3, r, new Point3D(1.5, -2.25, 9) })
{ {
@@ -47,7 +48,7 @@ internal static class AlignTest
} }
System.Console.WriteLine($" {(failures == 0 ? "PASS" : "----")}: ModOp/Matrix3D agree at five points"); System.Console.WriteLine($" {(failures == 0 ? "PASS" : "----")}: ModOp/Matrix3D agree at five points");
// Collinear source points must be rejected. // ── (3) 共線拒絕 ──
bool collinearRejected = !RigidAlign.TryRigidTransform( bool collinearRejected = !RigidAlign.TryRigidTransform(
new Point3D(0, 0, 0), new Point3D(1, 0, 0), new Point3D(2, 0, 0), // Collinear. new Point3D(0, 0, 0), new Point3D(1, 0, 0), new Point3D(2, 0, 0), // Collinear.
q1, q2, q3, out _); q1, q2, q3, out _);
+7 -7
View File
@@ -1,4 +1,4 @@
// Section clipping checks the retained side and triangle area. // 剖面裁切數學驗證:對單位幾何裁切,檢查保留側與面積守恆
using System.Windows.Media; using System.Windows.Media;
using System.Windows.Media.Media3D; using System.Windows.Media.Media3D;
using STPViewer.Services; using STPViewer.Services;
@@ -11,7 +11,7 @@ internal static class ClipTest
{ {
int failures = 0; int failures = 0;
// A single triangle with area 50. // 單一三角形 (0,0,0)(10,0,0)(0,10,0),面積 50
var mesh = new MeshGeometry3D var mesh = new MeshGeometry3D
{ {
Positions = new Point3DCollection Positions = new Point3DCollection
@@ -22,25 +22,25 @@ internal static class ClipTest
}; };
mesh.Freeze(); mesh.Freeze();
// A plane at x=20 retains the full triangle. // case 1: 平面 x=20,全保留
var m1 = SectionService.ClipMesh(mesh, new Point3D(20, 0, 0), new Vector3D(1, 0, 0)); var m1 = SectionService.ClipMesh(mesh, new Point3D(20, 0, 0), new Vector3D(1, 0, 0));
failures += Check("fully retained", Area(m1), 50.0); failures += Check("fully retained", Area(m1), 50.0);
// A plane at x=-1 removes the full triangle. // case 2: 平面 x=-1,全裁掉
var m2 = SectionService.ClipMesh(mesh, new Point3D(-1, 0, 0), new Vector3D(1, 0, 0)); var m2 = SectionService.ClipMesh(mesh, new Point3D(-1, 0, 0), new Vector3D(1, 0, 0));
failures += Check("fully removed", Area(m2), 0.0); failures += Check("fully removed", Area(m2), 0.0);
// At x=5, the retained trapezoid has area 50 - 12.5 = 37.5. // case 3: 平面 x=5 → 保留 x≤5:梯形面積 = 50 - 右側小三角形(底5高5)/2 = 50 - 12.5 = 37.5
var m3 = SectionService.ClipMesh(mesh, new Point3D(5, 0, 0), new Vector3D(1, 0, 0)); var m3 = SectionService.ClipMesh(mesh, new Point3D(5, 0, 0), new Vector3D(1, 0, 0));
failures += Check("half clipped (x<=5)", Area(m3), 37.5); failures += Check("half clipped (x<=5)", Area(m3), 37.5);
foreach (Point3D p in m3.Positions) foreach (Point3D p in m3.Positions)
if (p.X > 5 + 1e-9) { Console.WriteLine($" FAIL: vertex x={p.X} is outside retained side"); failures++; } if (p.X > 5 + 1e-9) { Console.WriteLine($" FAIL: vertex x={p.X} is outside retained side"); failures++; }
// Reverse the plane normal to retain the smaller triangle. // case 4: 反向(保留 x≥5)→ 12.5
var m4 = SectionService.ClipMesh(mesh, new Point3D(5, 0, 0), new Vector3D(-1, 0, 0)); var m4 = SectionService.ClipMesh(mesh, new Point3D(5, 0, 0), new Vector3D(-1, 0, 0));
failures += Check("half clipped (x>=5)", Area(m4), 12.5); failures += Check("half clipped (x>=5)", Area(m4), 12.5);
// Clip a line segment. // case 5: 線段裁切
var segs = new Point3DCollection { new(0, 0, 0), new(10, 0, 0) }; var segs = new Point3DCollection { new(0, 0, 0), new(10, 0, 0) };
Point3DCollection s1 = SectionService.ClipSegments(segs, new Point3D(4, 0, 0), new Vector3D(1, 0, 0)); Point3DCollection s1 = SectionService.ClipSegments(segs, new Point3D(4, 0, 0), new Vector3D(1, 0, 0));
double segLen = s1.Count == 2 ? (s1[1] - s1[0]).Length : -1; double segLen = s1.Count == 2 ? (s1[1] - s1[0]).Length : -1;
+3 -3
View File
@@ -5,13 +5,13 @@ using System.Linq;
using CADability; using CADability;
using CADability.GeoObject; using CADability.GeoObject;
using STPViewer.Services; using STPViewer.Services;
using IOPath = System.IO.Path; // Avoid the CADability.GeoObject.Path name collision. using IOPath = System.IO.Path; // CADability.GeoObject.Path(曲線)撞名
namespace SmokeTest; namespace SmokeTest;
/// <summary> /// <summary>
/// Headless STEP export round trip: import, export through the same CADability path, /// STEP 匯出往返驗證(v0.5.0 匯出 STEP 功能的無 UI 驗證):
/// then import the result and compare solid counts. /// 匯入來源 STEP → 走與 MainViewModel.ExportStepFile 相同路徑寫出新檔 → 重新匯入比對實體數。
/// </summary> /// </summary>
public static class ExportTest public static class ExportTest
{ {
+5 -5
View File
@@ -1,4 +1,4 @@
// Interference checks for intersecting, separated, and touching meshes. // 干涉檢查數學驗證:相交 / 分離 / 貼合三種情境
using System.Windows.Media; using System.Windows.Media;
using System.Windows.Media.Media3D; using System.Windows.Media.Media3D;
using STPViewer.Services; using STPViewer.Services;
@@ -11,14 +11,14 @@ internal static class InterferenceTest
{ {
int failures = 0; int failures = 0;
// Two tetrahedra overlap when B is offset by 5 along X. // 兩個互穿的四面體(B 比 A 平移 +5,邊長 10 → 必相交)
MeshGeometry3D a = Tetra(new Vector3D(0, 0, 0)); MeshGeometry3D a = Tetra(new Vector3D(0, 0, 0));
MeshGeometry3D b = Tetra(new Vector3D(5, 0, 0)); MeshGeometry3D b = Tetra(new Vector3D(5, 0, 0));
InterferenceResult r1 = InterferenceService.Check(new[] { a }, new[] { b }); InterferenceResult r1 = InterferenceService.Check(new[] { a }, new[] { b });
failures += Check("overlap intersects", r1.Intersects, true); failures += Check("overlap intersects", r1.Intersects, true);
failures += Check("overlap has intersection segments", r1.Segments.Count > 0, true); failures += Check("overlap has intersection segments", r1.Segments.Count > 0, true);
// B at X=30 leaves a gap of about 20. // 分離(B 平移 +30,gap 應 ≈ 30 - 10 = 20)
MeshGeometry3D c = Tetra(new Vector3D(30, 0, 0)); MeshGeometry3D c = Tetra(new Vector3D(30, 0, 0));
InterferenceResult r2 = InterferenceService.Check(new[] { a }, new[] { c }); InterferenceResult r2 = InterferenceService.Check(new[] { a }, new[] { c });
failures += Check("separated meshes do not intersect", r2.Intersects, false); failures += Check("separated meshes do not intersect", r2.Intersects, false);
@@ -26,7 +26,7 @@ internal static class InterferenceTest
Console.WriteLine($" {(gapOk ? "PASS" : "FAIL")}: separated gap = {r2.GapDistance:F4} (expected ~20)"); Console.WriteLine($" {(gapOk ? "PASS" : "FAIL")}: separated gap = {r2.GapDistance:F4} (expected ~20)");
if (!gapOk) failures++; if (!gapOk) failures++;
// B at X=10 touches a vertex without penetrating A. // 貼合(B 平移 +10,頂點剛好接觸 → 不算穿透、gap ≈ 0)
MeshGeometry3D d = Tetra(new Vector3D(10, 0, 0)); MeshGeometry3D d = Tetra(new Vector3D(10, 0, 0));
InterferenceResult r3 = InterferenceService.Check(new[] { a }, new[] { d }); InterferenceResult r3 = InterferenceService.Check(new[] { a }, new[] { d });
failures += Check("touching is not penetration", r3.Intersects, false); failures += Check("touching is not penetration", r3.Intersects, false);
@@ -45,7 +45,7 @@ internal static class InterferenceTest
return ok ? 0 : 1; return ok ? 0 : 1;
} }
/// <summary>A tetrahedron with side length 10, translated by offset.</summary> /// <summary>邊長 10 的四面體,offset 平移</summary>
private static MeshGeometry3D Tetra(Vector3D offset) private static MeshGeometry3D Tetra(Vector3D offset)
{ {
Point3D P(double x, double y, double z) => new Point3D(x, y, z) + offset; Point3D P(double x, double y, double z) => new Point3D(x, y, z) + offset;
+1 -1
View File
@@ -1,4 +1,4 @@
// Generate a small standard DXF test file with the bundled netDxf library. // 測試輔助:用 CADability 內嵌的 netDxf 產生標準 DXF 測試檔
using netDxf; using netDxf;
using netDxf.Entities; using netDxf.Entities;
+2 -2
View File
@@ -2,12 +2,12 @@ using System.IO;
namespace SmokeTest; namespace SmokeTest;
/// <summary>Generate a binary STL grid with two triangles per cell and noncoplanar vertices.</summary> /// <summary>產生測試用 binary STL:一片 gridN×gridN 的網格,每格 2 個三角形(波浪起伏,避免全共面)。</summary>
internal static class MakeStl internal static class MakeStl
{ {
public static void Run(string path, int triCount) public static void Run(string path, int triCount)
{ {
// Two triangles per cell; use a square grid large enough for the requested count. // 每格 2 三角,格數 = triCount/2;邊長 grid = ceil(sqrt(cells))
int cells = System.Math.Max(1, triCount / 2); int cells = System.Math.Max(1, triCount / 2);
int grid = (int)System.Math.Ceiling(System.Math.Sqrt(cells)); int grid = (int)System.Math.Ceiling(System.Math.Sqrt(cells));
int tris = grid * grid * 2; int tris = grid * grid * 2;
+1 -1
View File
@@ -1,4 +1,4 @@
// Headless smoke tests for CAD import, triangulation, and the assembly tree. // 無 UI smoke test:驗證 CAD 檔解析 → 三角化 → 裝配樹 整條匯入管線
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using STPViewer.Services; using STPViewer.Services;
+14 -11
View File
@@ -5,13 +5,15 @@ using System.Text;
using System.Windows.Media; using System.Windows.Media;
using System.Windows.Media.Media3D; using System.Windows.Media.Media3D;
using STPViewer.Services; using STPViewer.Services;
using IOPath = System.IO.Path; // Avoid the CADability.GeoObject.Path name collision. using IOPath = System.IO.Path; // CADability.GeoObject.Path(曲線)撞名
namespace SmokeTest; namespace SmokeTest;
/// <summary> /// <summary>
/// Headless STL export checks: /// STL 匯出驗證(無 UI):
/// binary round trip, ASCII facet structure, and degenerate triangle filtering. /// 1) binary — 已知立方體網格寫出 → 解析 header/三角形數/座標範圍比對(含縮放)
/// 2) ASCII — facet 數與 solid/endsolid 結構
/// 3) 退化三角形(零面積)自動濾除
/// </summary> /// </summary>
public static class StlExportTest public static class StlExportTest
{ {
@@ -22,9 +24,9 @@ public static class StlExportTest
string dir = IOPath.Combine(IOPath.GetTempPath(), "stpviewer_stl_test"); string dir = IOPath.Combine(IOPath.GetTempPath(), "stpviewer_stl_test");
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
MeshGeometry3D cube = BuildCube(10); // 10x10x10 mm, 12 triangles. MeshGeometry3D cube = BuildCube(10); // 10×10×10 mm、12 三角形
// Binary triangle count and scaled coordinate range. // 1) binary:三角形數 + 縮放後座標範圍
{ {
string f = IOPath.Combine(dir, "cube_bin.stl"); string f = IOPath.Combine(dir, "cube_bin.stl");
double scale = 1.0 / 25.4; // mm to inches. double scale = 1.0 / 25.4; // mm to inches.
@@ -35,7 +37,7 @@ public static class StlExportTest
Report(ref failed, ok, $"binary round trip: 12 triangles, inch range [{min:F4}, {max:F4}]"); Report(ref failed, ok, $"binary round trip: 12 triangles, inch range [{min:F4}, {max:F4}]");
} }
// ASCII facet count and structure. // 2) ASCII:facet 數 + 結構
{ {
string f = IOPath.Combine(dir, "cube_ascii.stl"); string f = IOPath.Combine(dir, "cube_ascii.stl");
int written = StlExportService.Write(f, new[] { cube }, ascii: true, scale: 1.0); int written = StlExportService.Write(f, new[] { cube }, ascii: true, scale: 1.0);
@@ -47,12 +49,12 @@ public static class StlExportTest
Report(ref failed, ok, $"ASCII structure valid, facets = {facets}"); Report(ref failed, ok, $"ASCII structure valid, facets = {facets}");
} }
// A zero-area triangle must be filtered from the export. // 3) 退化三角形濾除:加一個零面積三角形 → 仍是 12
{ {
var degenerate = new MeshGeometry3D(); var degenerate = new MeshGeometry3D();
degenerate.Positions.Add(new Point3D(0, 0, 0)); degenerate.Positions.Add(new Point3D(0, 0, 0));
degenerate.Positions.Add(new Point3D(1, 1, 1)); degenerate.Positions.Add(new Point3D(1, 1, 1));
degenerate.Positions.Add(new Point3D(2, 2, 2)); // Collinear: zero area. degenerate.Positions.Add(new Point3D(2, 2, 2)); // 共線 → 面積 0
degenerate.TriangleIndices.Add(0); degenerate.TriangleIndices.Add(0);
degenerate.TriangleIndices.Add(1); degenerate.TriangleIndices.Add(1);
degenerate.TriangleIndices.Add(2); degenerate.TriangleIndices.Add(2);
@@ -65,7 +67,8 @@ public static class StlExportTest
Report(ref failed, ok, $"degenerate triangle filtered ({written} written)"); Report(ref failed, ok, $"degenerate triangle filtered ({written} written)");
} }
// Optional real STEP input: retessellate B-rep geometry at fine precision. // 4)(可選)真實 STEP:B-rep 精細重新三角化 → 寫 binary STL 驗證
//(不測「較粗」:CADability GetTriangulation 對比快取粗的精度回傳既有快取,粗化無效 → 功能已移除)
if (stepPath is not null) if (stepPath is not null)
{ {
var data = new StepImportService().Import(stepPath); var data = new StepImportService().Import(stepPath);
@@ -80,7 +83,7 @@ public static class StlExportTest
(uint count, _, _) = ReadBinary(f); (uint count, _, _) = ReadBinary(f);
Report(ref failed, meshes.Count > 0 && written > 0 && count == written, Report(ref failed, meshes.Count > 0 && written > 0 && count == written,
$"fine retessellation: {meshes.Count} faces, {written:N0} triangles"); $"fine retessellation: {meshes.Count} faces, {written:N0} triangles");
// CADability's triangle count is not monotonic in precision; 0.15x is measurably finer. // CADability 三角形數對精度非單調(0.4× 反而比 1× 少),Fine=0.15× 實測明顯更細
Report(ref failed, written > importTris, Report(ref failed, written > importTris,
$"fine ({written:N0}) > imported default ({importTris:N0})"); $"fine ({written:N0}) > imported default ({importTris:N0})");
} }
@@ -103,7 +106,7 @@ public static class StlExportTest
if (!ok) failed++; if (!ok) failed++;
} }
/// <summary>Axis-aligned cube from 0 to size, with 12 triangles.</summary> /// <summary>0..size 的軸對齊立方體,12 個三角形(法向不講究,STL 寫出時自算)</summary>
private static MeshGeometry3D BuildCube(double s) private static MeshGeometry3D BuildCube(double s)
{ {
var m = new MeshGeometry3D(); var m = new MeshGeometry3D();
+1 -1
View File
@@ -1,4 +1,4 @@
// Print the STEP object hierarchy after import with HierarchyToBlocks enabled. // 實驗工具:印出 STEP 匯入後的物件階層(HierarchyToBlocks=true)
using CADability; using CADability;
using CADability.GeoObject; using CADability.GeoObject;