Compare commits
8
Commits
v0.5.0
...
6d48d7f852
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d48d7f852 | ||
|
|
39ed6d7bba | ||
|
|
89ed1270a8 | ||
|
|
060ca4ecd0 | ||
|
|
45b27d51cb | ||
|
|
3d124782d5 | ||
|
|
e4485667e2 | ||
|
|
f5ccc13e9c |
+3
-3
@@ -2,13 +2,13 @@
|
|||||||
bin/
|
bin/
|
||||||
obj/
|
obj/
|
||||||
publish/
|
publish/
|
||||||
Publish/ # publish-winapp 打包產物(大小寫明確,Linux/CI 也忽略)
|
Publish/ # publish-winapp output on case-sensitive systems
|
||||||
*.user
|
*.user
|
||||||
.vs/
|
.vs/
|
||||||
|
|
||||||
# AI 協作素材不入 git
|
# Local development assets
|
||||||
For_AI/
|
For_AI/
|
||||||
|
|
||||||
# 客戶 CAD 料號檔不入 git(避免外流);測試模型放 For_AI/
|
# CAD samples are stored locally in For_AI/
|
||||||
*.stp
|
*.stp
|
||||||
*.step
|
*.step
|
||||||
|
|||||||
+58
-4
@@ -19,7 +19,8 @@ STEP(`.stp` / `.step`)檔案做確認與量測,不需要安裝 SolidWorks
|
|||||||
快速鍵 P/D/E/F/C/A/M + Esc;單位 mm ⇄ inch 即時切換
|
快速鍵 P/D/E/F/C/A/M + Esc;單位 mm ⇄ inch 即時切換
|
||||||
- **剖面**:X/Y/Z 軸向 + **3點任意平面**,位置滑桿/數值輸入 + 反向,CPU 網格裁切(量測不受影響)
|
- **剖面**:X/Y/Z 軸向 + **3點任意平面**,位置滑桿/數值輸入 + 反向,CPU 網格裁切(量測不受影響)
|
||||||
- **視圖**:標準視圖(等角/前/上/右)+ 正交⇄透視投影
|
- **視圖**:標準視圖(等角/前/上/右)+ 正交⇄透視投影
|
||||||
- **匯出**:量測結果 CSV、3D 視圖 PNG 截圖(2x)、**目前對齊位置寫成新 STEP 檔**
|
- **匯出**:量測結果 CSV、3D 視圖 PNG 截圖(2x)、**目前對齊位置寫成新 STEP 檔**、
|
||||||
|
**STL 網格檔**(參數對話框:合併/每檔、binary/ASCII、mm/inch/自訂縮放、精度,確認再匯出)
|
||||||
- 單機離線執行,無網路相依;視窗/單位/最近檔案自動保存(settings.json)
|
- 單機離線執行,無網路相依;視窗/單位/最近檔案自動保存(settings.json)
|
||||||
|
|
||||||
使用者:單一桌面使用者(無登入 / 角色系統)。
|
使用者:單一桌面使用者(無登入 / 角色系統)。
|
||||||
@@ -106,13 +107,17 @@ STPViewer/
|
|||||||
│ ├── MeasurementResult.cs # 量測結果(雙單位 lambda、3D 標籤同步)
|
│ ├── MeasurementResult.cs # 量測結果(雙單位 lambda、3D 標籤同步)
|
||||||
│ └── UnitSystem.cs # mm/inch + Units 格式化
|
│ └── UnitSystem.cs # mm/inch + Units 格式化
|
||||||
│
|
│
|
||||||
|
├── StlExportDialog.xaml / .cs # STL 匯出參數對話框(範圍/格式/單位/精度 + 預估,確認再匯出)
|
||||||
|
├── AboutDialog.xaml / .cs # 關於視窗(程式/開發者/技術棧 + 使用者版 changelog)
|
||||||
|
│
|
||||||
├── Services/
|
├── Services/
|
||||||
│ ├── StepImportService.cs # STEP/STL/DXF 讀檔 + 三角化 + 裝配樹
|
│ ├── StepImportService.cs # STEP/STL/DXF 讀檔 + 三角化 + 裝配樹
|
||||||
│ ├── MeasurementService.cs # 點/線/面/圓/角度/面距/體積質心 幾何計算(吸附含圓心)
|
│ ├── MeasurementService.cs # 點/線/面/圓/角度/面距/體積質心 幾何計算(吸附含圓心)
|
||||||
│ ├── InterferenceService.cs # 干涉檢查:三角形-三角形相交(區間法)+均勻網格加速;無干涉時近似最小間隙
|
│ ├── InterferenceService.cs # 干涉檢查:三角形-三角形相交(區間法)+均勻網格加速;無干涉時近似最小間隙
|
||||||
│ ├── RigidAlign.cs # 三點對齊/旋轉的剛體變換數學(Matrix3D列向量 ↔ ModOp行向量 轉換)
|
│ ├── RigidAlign.cs # 三點對齊/旋轉的剛體變換數學(Matrix3D列向量 ↔ ModOp行向量 轉換)
|
||||||
│ ├── SectionService.cs # 剖面:網格/線段半空間裁切
|
│ ├── SectionService.cs # 剖面:網格/線段半空間裁切
|
||||||
│ └── SettingsService.cs # 使用者設定 settings.json(視窗/單位/MRU;%LOCALAPPDATA%\STPViewer)
|
│ ├── SettingsService.cs # 使用者設定 settings.json(視窗/單位/MRU;%LOCALAPPDATA%\STPViewer)
|
||||||
|
│ └── StlExportService.cs # STL 匯出:binary/ASCII 寫檔 + B-rep 重新三角化(精細)
|
||||||
│
|
│
|
||||||
└── ViewModels/ # MainViewModel 為 partial class,依職責分檔
|
└── ViewModels/ # MainViewModel 為 partial class,依職責分檔
|
||||||
├── MainViewModel.cs # 核心:匯入、裝配樹、量測、剛體變換、邊線、單位
|
├── MainViewModel.cs # 核心:匯入、裝配樹、量測、剛體變換、邊線、單位
|
||||||
@@ -120,7 +125,8 @@ STPViewer/
|
|||||||
├── MainViewModel.Gizmo.cs # 三軸操作器 + 疊圖層相機
|
├── MainViewModel.Gizmo.cs # 三軸操作器 + 疊圖層相機
|
||||||
├── MainViewModel.Section.cs # 剖面(v0.4.0 起背景平行裁切)
|
├── MainViewModel.Section.cs # 剖面(v0.4.0 起背景平行裁切)
|
||||||
├── MainViewModel.Interference.cs # 干涉檢查指令
|
├── MainViewModel.Interference.cs # 干涉檢查指令
|
||||||
├── MainViewModel.Export.cs # CSV / 截圖匯出
|
├── MainViewModel.Export.cs # CSV / 截圖 / STEP / STL 匯出
|
||||||
|
├── StlExportViewModel.cs # STL 匯出對話框 VM(選項 + 即時摘要)
|
||||||
└── ModelNodeViewModel.cs # 裝配樹節點(可見性/顏色 cascade)
|
└── ModelNodeViewModel.cs # 裝配樹節點(可見性/顏色 cascade)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -190,7 +196,7 @@ class MeasurementResult
|
|||||||
| 干涉 | 工具列「🧩 干涉」(≥2 個可見檔案,兩兩配對檢查) | 每組配對:相交→紅色交線+相交三角形對數;無相交→最小間隙 gap(≈0 即配合 match) |
|
| 干涉 | 工具列「🧩 干涉」(≥2 個可見檔案,兩兩配對檢查) | 每組配對:相交→紅色交線+相交三角形對數;無相交→最小間隙 gap(≈0 即配合 match) |
|
||||||
| 剖面 | 工具列「✂ 剖面」+ 軸向(X/Y/Z/**3點任意平面**)/位置(滑桿+數值輸入)/反向 | CPU 裁切渲染網格(原始幾何保留,量測仍精確);3點=在模型上點 3 點定義平面 |
|
| 剖面 | 工具列「✂ 剖面」+ 軸向(X/Y/Z/**3點任意平面**)/位置(滑桿+數值輸入)/反向 | CPU 裁切渲染網格(原始幾何保留,量測仍精確);3點=在模型上點 3 點定義平面 |
|
||||||
| 單位 | 工具列 mm ⇄ in(隨設定保存) | 既有量測(清單+3D 標籤)即時換算 |
|
| 單位 | 工具列 mm ⇄ in(隨設定保存) | 既有量測(清單+3D 標籤)即時換算 |
|
||||||
| 匯出 | 💾 CSV / 📷 截圖 / 📤 STEP | UTF-8 BOM CSV;2x PNG;可見檔案目前位置寫成**新** STEP 檔(對齊結果交接 CAD) |
|
| 匯出 | 💾 CSV / 📷 截圖 / 📤 STEP / 📐 STL | UTF-8 BOM CSV;2x PNG;可見檔案目前位置寫成**新** STEP 檔(對齊結果交接 CAD);STL 先開參數對話框(範圍/格式/單位/精度 + 三角形數與大小預估)確認再匯出 |
|
||||||
| 視圖 | 滑鼠右鍵旋轉/滾輪縮放/中鍵平移(Helix 預設)、ViewCube、工具列 等角/前/上/右、正交⇄透視 | |
|
| 視圖 | 滑鼠右鍵旋轉/滾輪縮放/中鍵平移(Helix 預設)、ViewCube、工具列 等角/前/上/右、正交⇄透視 | |
|
||||||
| 快速鍵 | P 點 / D 距離 / E 邊 / F 面 / C 圓 / A 角度 / M 面距;同鍵再按=退出;Esc=取消進行中量測→退出模式 | |
|
| 快速鍵 | P 點 / D 距離 / E 邊 / F 面 / C 圓 / A 角度 / M 面距;同鍵再按=退出;Esc=取消進行中量測→退出模式 | |
|
||||||
| 樹操作 | 搜尋框過濾節點名稱;右鍵:只顯示此節點 / 反轉顯示 / 全部顯示 / 量體積質心 | tooltip 含外形尺寸 L×W×H |
|
| 樹操作 | 搜尋框過濾節點名稱;右鍵:只顯示此節點 / 反轉顯示 / 全部顯示 / 量體積質心 | tooltip 含外形尺寸 L×W×H |
|
||||||
@@ -435,6 +441,54 @@ WPF retained-mode 每幀重走 visual tree → frame rate 崩。瀏覽模式因
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Development Phases — 第七輪(STL 匯出 v0.6.0,全部完成)
|
||||||
|
|
||||||
|
### Phase 15 — STL 匯出(工作量:M)
|
||||||
|
|
||||||
|
- [x] `StlExportService` — binary(80B header + placeholder 補三角形數)/ ASCII 寫檔,
|
||||||
|
退化(零面積)三角形濾除、逐三角形自算法向量;`Tessellate` 由 B-rep 重新三角化
|
||||||
|
- [x] 參數對話框(`StlExportDialog` + `StlExportViewModel`)— 範圍(合併單檔/每檔一個)、
|
||||||
|
格式(binary/ASCII)、單位縮放(mm / inch ÷25.4 / 自訂)、網格精度(目前網格/精細)、
|
||||||
|
即時摘要(檔案數/三角形數/預估大小),**確認才選路徑匯出**
|
||||||
|
- [x] 背景執行緒寫檔 — UI 先快照 frozen mesh(`GeometryModel3D` 是 DispatcherObject
|
||||||
|
不可跨執行緒,v0.6.0 修正),背景只碰 frozen `MeshGeometry3D` + CADability B-rep
|
||||||
|
- [x] SmokeTest `--stl-export-test [file.stp]`:binary 往返(含 inch 縮放)、ASCII 結構、
|
||||||
|
退化三角形濾除、真檔精細重算三角形數 > 匯入預設
|
||||||
|
|
||||||
|
**CADability 三角化兩個實測特性(設計依據):**
|
||||||
|
1. `GetTriangulation` 對「比快取粗」的精度要求直接回傳既有較細快取 → 「較粗」選項無效,誠實不提供
|
||||||
|
2. 三角形數對精度**非單調**(0.4× 重算反而比 1× 少 — 重算網格較有效率),
|
||||||
|
要明顯更細需 ≤0.15×(test.stp:1× = 30,784 → 0.15× = 35,228)→ 精細係數定 0.15
|
||||||
|
|
||||||
|
**驗收:** StlExportTest 5 項全過;既有 ClipTest/AlignTest/InterferenceTest 回歸全過;建置 0 警告
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Phases — 第八輪(UI 重整 v0.7.0)
|
||||||
|
|
||||||
|
### Phase 16 — 選單列 + 可自訂快速列(工作量:M)
|
||||||
|
|
||||||
|
**問題:** 功能累積後單一工具列 30+ 顆按鈕擠在一排,視窗不夠寬就溢出到「▾」,難找難按。
|
||||||
|
|
||||||
|
- [x] **選單列**(Menu):全部功能分類下拉 — 檔案(匯入/MRU/四種匯出)、視圖(全覽/標準視圖/正交/單位)、
|
||||||
|
量測(7 種模式 checkable + 快速鍵提示 + 清除)、變換(對齊/三點/拖曳/操作器/旋轉XYZ)、
|
||||||
|
工具(干涉/剖面)、快速列(自訂按鈕勾選 + 恢復預設)
|
||||||
|
- [x] **快速工具列**(`QuickBarViewModel`):只顯示使用者勾選的常用按鈕;
|
||||||
|
註冊表定義全部按鈕(key/選單名/預設值),XAML 以索引子綁定
|
||||||
|
`Visibility="{Binding QuickBar[Key].IsChecked, Converter=BoolToVis}"`(按鈕本體與綁定保持靜態)
|
||||||
|
- [x] 預設常用:匯入、最近、全覽、等角、正交、點、距離、圓、角度、干涉、剖面、清除、mm⇄in;
|
||||||
|
其餘(邊/面/面距/對齊/三點/旋轉/拖曳/操作器/四種匯出/前上右視圖)預設收進選單
|
||||||
|
- [x] 勾選清單隨 settings.json(`QuickBarKeys`)保存;null/新按鈕用註冊表預設值
|
||||||
|
- [x] **剖面參數列**獨立成第三列,只在 `SectionEnabled` 時顯示(含「✕ 關閉」);
|
||||||
|
平常整條隱藏,不再常駐佔位
|
||||||
|
- [x] **關於視窗**(`AboutDialog`,選單「說明 → 關於 STPViewer」)— 程式資訊/開發者/技術棧 +
|
||||||
|
給一般使用者看的版本紀錄(樣式參考 ETTerms AboutView);發新版在 Changelog 陣列頂端加一筆
|
||||||
|
|
||||||
|
**驗收:** 選單所有功能可用(checkable 與工具列 toggle 同步);快速列勾選即時增減、重開保留;
|
||||||
|
既有快速鍵 P/D/E/F/C/A/M + Esc 不受影響
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Future Extensions(下一輪)
|
## Future Extensions(下一輪)
|
||||||
|
|
||||||
- **大檔重開快取** — 匯入成功後把三角網格 + 裝配樹序列化成 sidecar 快取檔(以來源檔 hash 驗證),
|
- **大檔重開快取** — 匯入成功後把三角網格 + 裝配樹序列化成 sidecar 快取檔(以來源檔 hash 驗證),
|
||||||
|
|||||||
@@ -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 匯出、設定與 MRU 保存。
|
標準視圖+正交投影、量測快速鍵+Esc、mm⇄inch、CSV/截圖/STEP/STL 匯出(STL 有參數對話框)、設定與 MRU 保存。
|
||||||
詳細設計見 [ARCHITECTURE.md](ARCHITECTURE.md)。
|
詳細設計見 [ARCHITECTURE.md](ARCHITECTURE.md)。
|
||||||
|
|
||||||
## 技術棧
|
## 技術棧
|
||||||
@@ -47,10 +47,20 @@ dotnet publish src/STPViewer -c Release -o publish/STPViewer
|
|||||||
(量孔對孔 pitch 靠這個)。不要把圓心吸附拿掉或改成永遠優先頂點
|
(量孔對孔 pitch 靠這個)。不要把圓心吸附拿掉或改成永遠優先頂點
|
||||||
- 鍵盤快速鍵在 `MainWindow.Window_PreviewKeyDown`:**焦點在 TextBoxBase/ComboBox 時直接 return**
|
- 鍵盤快速鍵在 `MainWindow.Window_PreviewKeyDown`:**焦點在 TextBoxBase/ComboBox 時直接 return**
|
||||||
(樹搜尋框、剖面數值框要能打字),新增輸入控件不用再各自處理
|
(樹搜尋框、剖面數值框要能打字),新增輸入控件不用再各自處理
|
||||||
|
- UI 三層(v0.7.0):**選單列**(全部功能,分類下拉)+ **快速列**(`QuickBarViewModel` 註冊表,
|
||||||
|
使用者勾選常用按鈕,XAML 索引子綁定 `QuickBar[Key].IsChecked` 控 Visibility,按鈕本體維持靜態綁定)+
|
||||||
|
**剖面參數列**(只在 SectionEnabled 顯示)。新增功能要三處都接:選單分類、快速列註冊表
|
||||||
|
(含預設值)+ 對應 XAML 按鈕;勾選清單存 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)`;
|
||||||
只收 Solid/Shell(STL/DXF 無 B-rep 進不了);改動要跑 `SmokeTest --export-test` 往返驗證
|
只收 Solid/Shell(STL/DXF 無 B-rep 進不了);改動要跑 `SmokeTest --export-test` 往返驗證
|
||||||
|
- 匯出 STL(`StlExportService` + 參數對話框):**背景寫檔前必須在 UI 執行緒快照 mesh** —
|
||||||
|
`GeometryModel3D`/`Model3DGroup` 是 DispatcherObject 跨執行緒會炸(v0.6.0 踩過),
|
||||||
|
背景只能碰 frozen `MeshGeometry3D` 與 CADability B-rep。精度只有「目前網格/精細」兩檔:
|
||||||
|
CADability `GetTriangulation` 對比快取粗的精度回傳既有快取(粗化無效),且三角形數對精度
|
||||||
|
**非單調**(0.4× 反而比 1× 少),精細 = 0.15×(匯入 clamp 後再乘,保證嚴格更細)。
|
||||||
|
改動要跑 `SmokeTest --stl-export-test For_AI/test.stp`
|
||||||
- 3點剖面(SectionAxisIndex==3):`_customNormal` null = 拾取中(`HandleSectionPlanePick` 在
|
- 3點剖面(SectionAxisIndex==3):`_customNormal` null = 拾取中(`HandleSectionPlanePick` 在
|
||||||
OnViewportClick 最前面攔點擊);換軸/關剖面/Esc 會重置。平面位置一律用「AABB 8 角投影到法向」內插,
|
OnViewportClick 最前面攔點擊);換軸/關剖面/Esc 會重置。平面位置一律用「AABB 8 角投影到法向」內插,
|
||||||
軸向與任意法向共用同一段程式,不要改回逐軸特化
|
軸向與任意法向共用同一段程式,不要改回逐軸特化
|
||||||
@@ -87,6 +97,9 @@ dotnet publish src/STPViewer -c Release -o publish/STPViewer
|
|||||||
放開事件靠 overlay 的 `AddHandler(MouseLeftButtonUp, handledEventsToo:true)`(manipulator 會標 handled),`_gizmoBakePending` 防同次重複烘焙
|
放開事件靠 overlay 的 `AddHandler(MouseLeftButtonUp, handledEventsToo:true)`(manipulator 會標 handled),`_gizmoBakePending` 防同次重複烘焙
|
||||||
- 干涉/面距/對齊等運算在背景執行緒;`Freeze()` 幾何後才跨執行緒
|
- 干涉/面距/對齊等運算在背景執行緒;`Freeze()` 幾何後才跨執行緒
|
||||||
- 匯入在背景執行緒;`Freeze()` 幾何後才跨執行緒
|
- 匯入在背景執行緒;`Freeze()` 幾何後才跨執行緒
|
||||||
|
- 發新版:升 csproj `<Version>` 之外,記得在 `AboutDialog.xaml.cs` 的 `Changelog` 陣列**頂端**加一筆
|
||||||
|
(口吻寫給一般使用者:「你會感覺到什麼」,不是技術 changelog)。
|
||||||
|
⚠️ WPF **頂層 MenuItem 直接掛 Click 不會觸發**(實測滑鼠/鍵盤都沒反應),要開視窗的入口一律放子選單(如 說明 → 關於)
|
||||||
- Commit 格式:Conventional Commits(`feat:` / `fix:` / `docs:` …)
|
- Commit 格式:Conventional Commits(`feat:` / `fix:` / `docs:` …)
|
||||||
|
|
||||||
## 注意事項 / 已知限制
|
## 注意事項 / 已知限制
|
||||||
@@ -106,12 +119,21 @@ dotnet publish src/STPViewer -c Release -o publish/STPViewer
|
|||||||
`_edgesSuspended` 為真時 `RefreshRootEdges` 不把線掛回。轉動/縮放/平移時不付邊線重建成本。
|
`_edgesSuspended` 為真時 `RefreshRootEdges` 不把線掛回。轉動/縮放/平移時不付邊線重建成本。
|
||||||
**只訂 `Camera.Changed` 不夠**:`Attach` 在建構式呼叫,相機實例若被 Helix 換掉訂閱會孤兒化 → 暫停永不觸發;故加訂控制項層級事件保底(重複觸發 `OnCameraMoved` 無害,有 guard)
|
**只訂 `Camera.Changed` 不夠**:`Attach` 在建構式呼叫,相機實例若被 Helix 換掉訂閱會孤兒化 → 暫停永不觸發;故加訂控制項層級事件保底(重複觸發 `OnCameraMoved` 無害,有 guard)
|
||||||
- CADability `ImportStep` 對少數 AP242 檔案支援不完整;匯入失敗要 catch 顯示訊息,不可閃退
|
- CADability `ImportStep` 對少數 AP242 檔案支援不完整;匯入失敗要 catch 顯示訊息,不可閃退
|
||||||
|
- ⚡ **STL 直接解析(`StlMeshReader`,v0.7.1),不經過 CADability `ImportSTL`**。CADability 會為 STL 每個三角形
|
||||||
|
建一個 B-rep Face(含 Edge/Vertex 拓樸)→ 百萬三角形要數十分鐘且吃光記憶體(實測 100k 三角形:CADability ~41 s
|
||||||
|
vs 直接解析 ~11 ms;75 MB/150 萬三角形整條匯入管線 ~0.77 s)。binary/ASCII 皆自解;binary 判定用
|
||||||
|
**檔案大小 == 84 + 50×count**(header 常也以 "solid" 開頭,不能只看字串)。頂點不焊接(STL 原生就無共用頂點)。
|
||||||
|
**後果**:STL leaf **沒有 `SourceGeos`(CADability B-rep)** → (1) 不進 STEP 匯出(STL 本就非 B-rep 幾何,符合原設計意圖;
|
||||||
|
v0.7.0 以前會把 STL shell 當 Shell 混進 STEP,現移除);(2) 剛體變換照常 — `TransformRoot` 的 SourceGeos 迴圈空轉、
|
||||||
|
網格層照樣變換;(3) 量測走網格近似不受影響。**不要為了「STL 也要進 STEP」而改回 CADability ImportSTL**(會退回龜速)
|
||||||
- IGES 無 reader;STL 無 B-rep(FaceInfo.BrepFace == null 的分支要保留)
|
- IGES 無 reader;STL 無 B-rep(FaceInfo.BrepFace == null 的分支要保留)
|
||||||
- 不寫入原始檔(唯讀工具);WPF 限 Windows,不要嘗試移植 vbox/Linux
|
- 不寫入原始檔(唯讀工具);WPF 限 Windows,不要嘗試移植 vbox/Linux
|
||||||
- 干涉檢查需剛好 2 個可見檔案(樹面板勾選);共面貼合(無穿透)不算干涉、gap≈0 視為配合(match)
|
- 干涉檢查需剛好 2 個可見檔案(樹面板勾選);共面貼合(無穿透)不算干涉、gap≈0 視為配合(match)
|
||||||
- SmokeTest 工具:`--tree`(裝配樹)、`--clip-test`(剖切數學)、`--interference-test`(干涉相交/分離/貼合)、
|
- SmokeTest 工具:`--tree`(裝配樹)、`--clip-test`(剖切數學)、`--interference-test`(干涉相交/分離/貼合)、
|
||||||
`--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 精細重算)、
|
||||||
|
`--make-stl <out.stl> [triCount]`(產測試用 binary STL,預設 150 萬三角形,量 STL 匯入效能用)
|
||||||
- **絕不要用 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/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# STPViewer
|
# STPViewer
|
||||||
|
|
||||||
[](README.md) [](README.zh-TW.md)
|
[](README.md) [](README.zh-CN.md) [](README.zh-TW.md)
|
||||||
|
|
||||||
> A Windows desktop 3D viewer for STP/STEP CAD files — multi-file import, assembly tree, and point / distance / edge / face / circle measurement. Built with C# .NET 8 WPF.
|
> A Windows desktop 3D viewer for STP/STEP CAD files — multi-file import, assembly tree, and point / distance / edge / face / circle measurement. Built with C# .NET 8 WPF.
|
||||||
|
|
||||||
@@ -52,6 +52,7 @@ Open mechanical STEP parts for quick review and measurement without a heavyweigh
|
|||||||
- **Interference check** 🧩 — with 2+ visible files, checks **every pair**: red intersection curves + intersecting triangle-pair count, or the minimum gap (gap ≈ 0 means a fit/match; coplanar contact is not interference)
|
- **Interference check** 🧩 — with 2+ visible files, checks **every pair**: red intersection curves + intersecting triangle-pair count, or the minimum gap (gap ≈ 0 means a fit/match; coplanar contact is not interference)
|
||||||
- **Section plane** ✂ — X/Y/Z axis **or an arbitrary plane picked from 3 points on the model**; position slider + numeric input + flip; CPU mesh clipping (parallel, in the background), original geometry preserved (measurement stays exact)
|
- **Section plane** ✂ — X/Y/Z axis **or an arbitrary plane picked from 3 points on the model**; position slider + numeric input + flip; CPU mesh clipping (parallel, in the background), original geometry preserved (measurement stays exact)
|
||||||
- **Units** — one-click mm ⇄ inch; existing measurements (list + 3D labels) convert live
|
- **Units** — one-click mm ⇄ inch; existing measurements (list + 3D labels) convert live
|
||||||
|
- **Language** — choose a language from **View → Language**; available languages come from the `i18n/` JSON files at startup, and your choice is remembered
|
||||||
- **Export** — measurement results to CSV (UTF-8 BOM, no mojibake in Excel), a 2× PNG screenshot, and 📤 **STEP export**: writes all visible parts at their current aligned positions into a new STEP file (originals untouched)
|
- **Export** — measurement results to CSV (UTF-8 BOM, no mojibake in Excel), a 2× PNG screenshot, and 📤 **STEP export**: writes all visible parts at their current aligned positions into a new STEP file (originals untouched)
|
||||||
- **View** — right-button orbit, wheel zoom, middle-button pan, ViewCube; one-click **Iso / Front / Top / Right** views and an **orthographic ⇄ perspective** toggle
|
- **View** — right-button orbit, wheel zoom, middle-button pan, ViewCube; one-click **Iso / Front / Top / Right** views and an **orthographic ⇄ perspective** toggle
|
||||||
- **Remembers your setup** — window size/position, unit choice and recent files persist across sessions; side panels are resizable
|
- **Remembers your setup** — window size/position, unit choice and recent files persist across sessions; side panels are resizable
|
||||||
@@ -118,6 +119,7 @@ dotnet run --project tools/SmokeTest -- "path\to\model.stp" # import + assembl
|
|||||||
dotnet run --project tools/SmokeTest -- --clip-test # section clipping math
|
dotnet run --project tools/SmokeTest -- --clip-test # section clipping math
|
||||||
dotnet run --project tools/SmokeTest -- --interference-test # interference: intersect / separate / contact
|
dotnet run --project tools/SmokeTest -- --interference-test # interference: intersect / separate / contact
|
||||||
dotnet run --project tools/SmokeTest -- --align-test # 3-point rigid-transform math
|
dotnet run --project tools/SmokeTest -- --align-test # 3-point rigid-transform math
|
||||||
|
dotnet run --project tools/SmokeTest -- --localization-test # language resources and live switching
|
||||||
dotnet run --project tools/SmokeTest -- --export-test in.stp out.stp # STEP export round-trip
|
dotnet run --project tools/SmokeTest -- --export-test in.stp out.stp # STEP export round-trip
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -135,6 +137,24 @@ NuGet dependencies (restored automatically): `CADability`, `HelixToolkit.Wpf`, `
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🌐 Adding a Language
|
||||||
|
|
||||||
|
The application scans `i18n/*.json` beside the executable at startup. Each file name without `.json` is the language ID (for example, `en.json` → `en`, `zh_cn.json` → `zh_cn`). The JSON root has a `key` for the name shown in the language menu and a `data` object that maps resource keys to translated text:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"key": "Français",
|
||||||
|
"data": {
|
||||||
|
"Ui.Language": "Langue",
|
||||||
|
"Ui.Main.MenuImport": "📂 Importer…"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `src/STPViewer/i18n/en.json` as the list of text keys, translate its `data` values, and save the new file as UTF-8 in `i18n/` beside the executable. Restart STPViewer to see the new language in the menu and Quick Bar. No recompilation is needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 📁 Project Structure
|
## 📁 Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -145,6 +165,7 @@ STPViewer/
|
|||||||
├── src/STPViewer/
|
├── src/STPViewer/
|
||||||
│ ├── STPViewer.csproj # net8.0-windows, UseWPF, single-source <Version>
|
│ ├── STPViewer.csproj # net8.0-windows, UseWPF, single-source <Version>
|
||||||
│ ├── MainWindow.xaml / .cs # Layout + mouse-pick forwarding
|
│ ├── MainWindow.xaml / .cs # Layout + mouse-pick forwarding
|
||||||
|
│ ├── i18n/ # Language JSON files copied beside the executable
|
||||||
│ ├── Models/ # FaceInfo, MeasureMode, MeasurementResult, UnitSystem
|
│ ├── Models/ # FaceInfo, MeasureMode, MeasurementResult, UnitSystem
|
||||||
│ ├── Services/ # StepImport, Measurement, Interference, Section, RigidAlign, Settings
|
│ ├── Services/ # StepImport, Measurement, Interference, Section, RigidAlign, Settings
|
||||||
│ └── ViewModels/ # MainViewModel (partial: Drag/Gizmo/Section/Interference/Export), ModelNodeViewModel
|
│ └── ViewModels/ # MainViewModel (partial: Drag/Gizmo/Section/Interference/Export), ModelNodeViewModel
|
||||||
|
|||||||
+234
@@ -0,0 +1,234 @@
|
|||||||
|
# STPViewer
|
||||||
|
|
||||||
|
[](README.md) [](README.zh-CN.md) [](README.zh-TW.md)
|
||||||
|
|
||||||
|
> STP/STEP 3D 查看器(Windows 桌面程序,C# .NET 8 WPF)— 多文件导入、装配树、点/距离/边/面/圆测量。
|
||||||
|
|
||||||
|
   
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📖 目录
|
||||||
|
|
||||||
|
- [✨ 功能特性](#-功能特性)
|
||||||
|
- [💻 系统需求](#-系统需求)
|
||||||
|
- [📥 安装](#-安装)
|
||||||
|
- [🚀 快速开始](#-快速开始)
|
||||||
|
- [📚 使用指南](#-使用指南)
|
||||||
|
- [🔨 从源代码构建](#-从源代码构建)
|
||||||
|
- [📁 项目结构](#-项目结构)
|
||||||
|
- [⚠️ 已知限制](#️-已知限制)
|
||||||
|
- [🤝 贡献](#-贡献)
|
||||||
|
- [📜 版本记录](#-版本记录)
|
||||||
|
- [🙏 致谢](#-致谢)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ 功能特性
|
||||||
|
|
||||||
|
快速打开机械零件 STEP 文件进行查看和测量,无需安装 SolidWorks / Creo 等大型 CAD 软件。
|
||||||
|
|
||||||
|
- **多文件导入** — STEP / STL / DXF,工具栏导入(可多选)、拖放到窗口、命令行 `STPViewer.exe a.stp b.stl`,或最近文件下拉菜单(▾,最近 10 项)
|
||||||
|
- **装配树** — STEP product structure 还原为树(组件→零件);逐节点显示/隐藏、更改颜色(作用于子节点)、缩放至节点;文件层级可移除、开关轮廓边线;**按名称搜索过滤**;右键 **只显示此节点 / 反转显示 / 全部显示 / 计算体积与质心**;提示信息显示外形尺寸 L×W×H
|
||||||
|
- **测量**(工具栏切换模式或快捷键,点击模型):
|
||||||
|
|
||||||
|
| 模式 | 快捷键 | 输出 |
|
||||||
|
|---|---|---|
|
||||||
|
| 📍 点 | P | XYZ 坐标(自动吸附邻近 B-rep 顶点**或圆心**) |
|
||||||
|
| 📏 距离 | D | 两点直线距离 + ΔX/ΔY/ΔZ — 点击两个孔缘自动吸附**圆心**,可直接测量孔中心距 |
|
||||||
|
| 📐 边 | E | 直线长 / 曲线长 / 圆弧长 + 半径 |
|
||||||
|
| ⬛ 面 | F | 面积(网格近似)+ 曲面类型(平面法向量、圆柱半径/轴向) |
|
||||||
|
| ⭕ 圆 | C | 圆心 / 半径 / 直径 / 周长 |
|
||||||
|
| ∠ 角度 | A | 两面(法向量)/ 两条直线边的夹角 + 补角 |
|
||||||
|
| ⇔ 面距 | M | 面到面最短距离(网格近似)+ 最近点对 |
|
||||||
|
| ⚖ 体积 | (树右键) | 网格有符号体积 + 质心标记 + 外形尺寸(封闭实体) |
|
||||||
|
| ⤚ 对齐(两点) | | 点「要移动零件」一点 + 目标点 → 纯平移使两点贴合 |
|
||||||
|
| 🎯 对齐(三点) | | 源文件 3 点 + 目标文件 3 对应点 → 旋转+平移一次贴合 |
|
||||||
|
|
||||||
|
再次按下同一快捷键退出模式;**Esc** 先取消当前选择、再按退出模式。
|
||||||
|
- **旋转** — 树面板选文件 + 工具栏 ↻X/↻Y/↻Z 绕中心 +90°(用于调整朝向,可重复旋转)
|
||||||
|
- **拖动** 🖐 — 手形光标模式,左键按住零件沿屏幕平面拖动、放开定位(右键转视角不受影响)
|
||||||
|
- **操作器** ⊹ — 树面板选文件 → XYZ 三色箭头 + 旋转环(Fusion 360 风格);拖箭头沿该轴移动(与视角无关)、拖环绕轴转。永远浮在最上层、不被实体遮挡
|
||||||
|
- **干涉检查** 🧩 — 2 个以上可见文件**两两配对**检查:相交时显示红色干涉交线 + 相交的三角形对数;无相交时报告最小间隙(gap≈0 即为配合,共面贴合不算干涉)
|
||||||
|
- **剖面** ✂ — X/Y/Z 轴或**在模型上点 3 点定义任意平面**;位置滑块 + 数值输入 + 反向;CPU 网格裁切(背景平行运算),原始几何保留(测量仍精确)
|
||||||
|
- **单位** — mm ⇄ inch 一键切换,已有测量(列表与 3D 标签)实时换算
|
||||||
|
- **语言** — 在 **视图 → 语言** 中选择语言;启动时从 `i18n/` 的 JSON 文件读取可用语言,并记住你的选择
|
||||||
|
- **导出** — 测量结果 CSV(UTF-8 BOM,Excel 中文不乱码)、3D 视图 2× PNG 截图、📤 **STEP 导出**:可见零件以当前对齐位置写成新 STEP 文件(源文件不动)
|
||||||
|
- **视角** — 右键旋转、滚轮缩放、中键平移、ViewCube;一键 **等角/前/上/右** 标准视图与 **正交⇄透视** 切换
|
||||||
|
- **设置保存** — 窗口位置和大小、单位、最近文件自动保存;左右面板可拖动调宽
|
||||||
|
|
||||||
|
测量原则:边长、圆半径、角度取 **B-rep 精确值**;面积为三角网格求和近似(三角化精度根据模型尺寸自适应 0.02–0.5 mm)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 系统需求
|
||||||
|
|
||||||
|
| 项目 | 需求 |
|
||||||
|
|------|------|
|
||||||
|
| 操作系统 | Windows 10 / 11(x64) |
|
||||||
|
| 运行环境 | .NET 8 Desktop Runtime(依赖框架版)— Portable 版则免装 |
|
||||||
|
| 构建 SDK | .NET 8 SDK(仅从源代码构建时需要) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📥 安装
|
||||||
|
|
||||||
|
下载发布版直接执行,免安装。
|
||||||
|
|
||||||
|
- **依赖框架版**(文件较小):需 .NET 8 Desktop Runtime,执行 `STPViewer v0.5.0.exe`。
|
||||||
|
- **Portable 版**(self-contained):内置运行环境,免安装 / 无需管理员权限,执行 `STPViewer v0.5.0.exe`。
|
||||||
|
|
||||||
|
或从源代码构建(见下)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/ETWen/STPViewer.git
|
||||||
|
cd STPViewer
|
||||||
|
dotnet build STPViewer.sln
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建并执行
|
||||||
|
dotnet build STPViewer.sln
|
||||||
|
dotnet run --project src/STPViewer
|
||||||
|
|
||||||
|
# 发布免安装文件夹
|
||||||
|
dotnet publish src/STPViewer -c Release -o publish/STPViewer
|
||||||
|
```
|
||||||
|
|
||||||
|
然后导入 `.stp` 文件(工具栏「导入」、拖放或命令行传入文件路径),选一个测量模式,点击模型即可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 使用指南
|
||||||
|
|
||||||
|
1. **导入** 一个或多个 CAD 文件,每个文件成为装配树的一个根节点,视角自动缩放至适合窗口。
|
||||||
|
2. **操作树** — 按名称搜索、显示/隐藏(或右键隔离显示)、换色、缩放至某节点,或移除文件。
|
||||||
|
3. **测量** — 在工具栏选择模式或按快捷键(P/D/E/F/C/A/M),点击模型;点击孔缘自动吸附圆心,点击两次即可测得孔中心距;Esc 取消。结果显示在右侧面板,可逐条删除或全部清除。
|
||||||
|
4. **装配** — 使用对齐(两点/三点)、旋转、拖动、操作器摆位,再用干涉检查对所有可见文件两两检查,验证装配配合。
|
||||||
|
5. **剖面** — 打开 ✂、选轴向或点 3 点定义任意平面、滑动(或输入 %)剖切;测量仍以原始几何精确计算。
|
||||||
|
6. **导出** — 测量结果保存为 CSV、截取 2× PNG,或把对齐后的场景写成新 STEP 文件交接给 CAD。
|
||||||
|
|
||||||
|
无需启动界面的导入流程与几何运算验证:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet run --project tools/SmokeTest -- "path\to\model.stp" # 导入 + 装配树
|
||||||
|
dotnet run --project tools/SmokeTest -- --clip-test # 剖切裁切数学
|
||||||
|
dotnet run --project tools/SmokeTest -- --interference-test # 干涉 相交/分离/贴合
|
||||||
|
dotnet run --project tools/SmokeTest -- --align-test # 三点对齐刚体变换数学
|
||||||
|
dotnet run --project tools/SmokeTest -- --localization-test # 语言资源与实时切换
|
||||||
|
dotnet run --project tools/SmokeTest -- --export-test in.stp out.stp # STEP 导出往返验证
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔨 从源代码构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build STPViewer.sln -c Debug
|
||||||
|
dotnet run --project src/STPViewer
|
||||||
|
dotnet publish src/STPViewer -c Release -o publish/STPViewer
|
||||||
|
```
|
||||||
|
|
||||||
|
NuGet 依赖(自动还原):`CADability`、`HelixToolkit.Wpf`、`CommunityToolkit.Mvvm`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌐 添加语言
|
||||||
|
|
||||||
|
程序启动时扫描可执行文件旁的 `i18n/*.json`。文件名去掉 `.json` 后就是语言 ID,例如 `en.json` 对应 `en`,`zh_cn.json` 对应 `zh_cn`。JSON 顶层的 `key` 是语言菜单中的显示名,`data` 是资源键到译文的映射:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"key": "Français",
|
||||||
|
"data": {
|
||||||
|
"Ui.Language": "Langue",
|
||||||
|
"Ui.Main.MenuImport": "📂 Importer…"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
参考 `src/STPViewer/i18n/en.json` 中的资源键,翻译其 `data` 内容,将新文件以 UTF-8 编码放入可执行文件旁的 `i18n/`。重启 STPViewer 后,新语言便会出现在菜单和快捷栏中,无需重新编译。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
STPViewer/
|
||||||
|
├── ARCHITECTURE.md # 设计、数据流、开发 阶段
|
||||||
|
├── CLAUDE.md # 项目记忆 & 开发规范
|
||||||
|
├── STPViewer.sln
|
||||||
|
├── src/STPViewer/
|
||||||
|
│ ├── STPViewer.csproj # net8.0-windows、UseWPF、统一的版本号来源 <Version>
|
||||||
|
│ ├── MainWindow.xaml / .cs # 布局 + 鼠标拾取转发
|
||||||
|
│ ├── i18n/ # 复制到可执行文件旁的语言 JSON 文件
|
||||||
|
│ ├── Models/ # FaceInfo、MeasureMode、MeasurementResult、UnitSystem
|
||||||
|
│ ├── Services/ # StepImport、Measurement、Interference、Section、RigidAlign、Settings
|
||||||
|
│ └── ViewModels/ # MainViewModel(partial:Drag/Gizmo/Section/Interference/Export)、ModelNodeViewModel
|
||||||
|
└── tools/SmokeTest/ # 无需 UI 导入 + 几何数学验证
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 已知限制
|
||||||
|
|
||||||
|
- 大型 STEP(数千面)导入需数十秒(CADability 解析成本),导入期间有进度提示、UI 不会卡住。
|
||||||
|
- 轮廓边线超过 30,000 线段的文件会默认关闭边线(WPF `LinesVisual3D` 转动视角时性能限制),可在树面板手动打开。
|
||||||
|
- **IGES 不支持**(CADability 无 IGES reader);STL 无 B-rep,仅支持 点/距离/角度/面距 测量;DXF 为线框查看。
|
||||||
|
- 剖切面无封口(cap),剖开处显示内部背面材质(深灰)。
|
||||||
|
- STEP 导出为「当前位置的 Solid/Shell 扁平列表」,不保留原始装配层级与 product 名称;STL 网格、DXF 线框无 B-rep 不导出。
|
||||||
|
- 面积、面距、体积/质心为三角网格近似值;边长/圆半径/角度为 B-rep 精确值。
|
||||||
|
- 少数 AP242 文件 CADability 支持不完整,导入失败会提示消息(不会崩溃)。
|
||||||
|
- 只读查看器,不写入/修改原始文件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤝 贡献
|
||||||
|
|
||||||
|
1. Fork 并建立 feature 分支:`git checkout -b feature/your-feature`
|
||||||
|
2. 遵循 [Conventional Commits](https://www.conventionalcommits.org/):`feat(scope): summary`
|
||||||
|
3. Push 后开 Pull Request
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📜 版本记录
|
||||||
|
|
||||||
|
### v0.5.0
|
||||||
|
|
||||||
|
- **功能扩展:** 圆心吸附(点击两次测量孔中心距)、测量快捷键(P/D/E/F/C/A/M)+ Esc、标准视图(等角/前/上/右)+ 正交投影、装配树搜索/隔离显示/反转、体积与质心、3 点任意剖切平面 + 位置数值输入、干涉检查支持 3 个以上文件(两两配对)、对齐后场景导出 STEP、面板可调宽、窗口/单位/最近文件自动保存。
|
||||||
|
|
||||||
|
### v0.4.0
|
||||||
|
|
||||||
|
- **性能:** 剖面裁切改为后台并行运算(大文件拖动滑块时不再卡住 UI);零件变换烘焙(拖动/操作器放开)并行化;干涉间隙精修以 AABB 快速排除加速。
|
||||||
|
- **稳定性:** 全域例外处理(记 log 至 `%LOCALAPPDATA%\STPViewer\error.log` + 消息框,不会崩溃);背景运算期间停用会改动几何的指令;导入防重入。
|
||||||
|
|
||||||
|
### v0.3.2
|
||||||
|
|
||||||
|
- **性能:** 大组件测量模式下转动视角不再卡顿。测量模式改为渲染合并网格(每个文件 1 个 model),并用命中三角形的顶点 index 反查回是哪个面,不再挂数万个逐面 model;逐面渲染只保留给剖面模式。
|
||||||
|
- 相机交互暂停事件额外监听 `HelixViewport3D.CameraChanged`,避免相机实例被换掉时失去订阅。
|
||||||
|
|
||||||
|
### v0.3.1
|
||||||
|
|
||||||
|
- 操作器 always-on-top 叠图层(永远浮在零件上、不被实体遮挡)。
|
||||||
|
|
||||||
|
### v0.3.0
|
||||||
|
|
||||||
|
- 旋转对齐:轴向旋转(↻X/↻Y/↻Z)、三点对齐,以及统一的 `TransformRoot` 刚体变换路径。
|
||||||
|
|
||||||
|
### v0.2.x
|
||||||
|
|
||||||
|
- 拖动模式、两点对齐、干涉检查、剖面、角度/面距测量、装配树、STL/DXF 支持、mm ⇄ inch。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🙏 致谢
|
||||||
|
|
||||||
|
- [CADability](https://github.com/SOFAgh/CADability) — 纯 C# CAD kernel:STEP 导入、B-rep 几何、面三角化
|
||||||
|
- [HelixToolkit.Wpf](https://github.com/helix-toolkit/helix-toolkit) — 3D viewport、相机操作、HitTest
|
||||||
|
- [CommunityToolkit.Mvvm](https://github.com/CommunityToolkit/dotnet) — MVVM
|
||||||
|
|
||||||
|
详细设计与开发 阶段 见 [ARCHITECTURE.md](ARCHITECTURE.md)。
|
||||||
+22
-1
@@ -1,6 +1,6 @@
|
|||||||
# STPViewer
|
# STPViewer
|
||||||
|
|
||||||
[](README.md) [](README.zh-TW.md)
|
[](README.md) [](README.zh-CN.md) [](README.zh-TW.md)
|
||||||
|
|
||||||
> STP/STEP 3D 檢視器(Windows 桌面程式,C# .NET 8 WPF)— 多檔匯入、裝配樹、點/距離/邊/面/圓量測。
|
> STP/STEP 3D 檢視器(Windows 桌面程式,C# .NET 8 WPF)— 多檔匯入、裝配樹、點/距離/邊/面/圓量測。
|
||||||
|
|
||||||
@@ -52,6 +52,7 @@
|
|||||||
- **干涉檢查** 🧩 — 2 個以上可見檔案**兩兩配對**檢查:相交時顯示紅色干涉交線 + 相交三角形對數;無相交時回報最小間隙 gap(gap≈0 即為配合 match,共面貼合不算干涉)
|
- **干涉檢查** 🧩 — 2 個以上可見檔案**兩兩配對**檢查:相交時顯示紅色干涉交線 + 相交三角形對數;無相交時回報最小間隙 gap(gap≈0 即為配合 match,共面貼合不算干涉)
|
||||||
- **剖面** ✂ — X/Y/Z 軸或**在模型上點 3 點定義任意平面**;位置滑桿 + 數值輸入 + 反向;CPU 網格裁切(背景平行運算),原始幾何保留(量測仍精確)
|
- **剖面** ✂ — X/Y/Z 軸或**在模型上點 3 點定義任意平面**;位置滑桿 + 數值輸入 + 反向;CPU 網格裁切(背景平行運算),原始幾何保留(量測仍精確)
|
||||||
- **單位** — mm ⇄ inch 一鍵切換,既有量測(清單與 3D 標籤)即時換算
|
- **單位** — mm ⇄ inch 一鍵切換,既有量測(清單與 3D 標籤)即時換算
|
||||||
|
- **語言** — 從 **視圖 → 語言** 選擇語言;啟動時從 `i18n/` 的 JSON 檔案讀取可用語言,並記住選擇
|
||||||
- **匯出** — 量測結果 CSV(UTF-8 BOM,Excel 中文不亂碼)、3D 視圖 2× PNG 截圖、📤 **STEP 匯出**:可見零件以目前對齊位置寫成新 STEP 檔(原始檔不動)
|
- **匯出** — 量測結果 CSV(UTF-8 BOM,Excel 中文不亂碼)、3D 視圖 2× PNG 截圖、📤 **STEP 匯出**:可見零件以目前對齊位置寫成新 STEP 檔(原始檔不動)
|
||||||
- **視角** — 右鍵旋轉、滾輪縮放、中鍵平移、ViewCube;一鍵 **等角/前/上/右** 標準視圖與 **正交⇄透視** 切換
|
- **視角** — 右鍵旋轉、滾輪縮放、中鍵平移、ViewCube;一鍵 **等角/前/上/右** 標準視圖與 **正交⇄透視** 切換
|
||||||
- **記住設定** — 視窗位置大小、單位、最近檔案自動保存;左右面板可拖曳調寬
|
- **記住設定** — 視窗位置大小、單位、最近檔案自動保存;左右面板可拖曳調寬
|
||||||
@@ -118,6 +119,7 @@ dotnet run --project tools/SmokeTest -- "path\to\model.stp" # 匯入 + 裝配
|
|||||||
dotnet run --project tools/SmokeTest -- --clip-test # 剖切裁切數學
|
dotnet run --project tools/SmokeTest -- --clip-test # 剖切裁切數學
|
||||||
dotnet run --project tools/SmokeTest -- --interference-test # 干涉 相交/分離/貼合
|
dotnet run --project tools/SmokeTest -- --interference-test # 干涉 相交/分離/貼合
|
||||||
dotnet run --project tools/SmokeTest -- --align-test # 三點對齊剛體變換數學
|
dotnet run --project tools/SmokeTest -- --align-test # 三點對齊剛體變換數學
|
||||||
|
dotnet run --project tools/SmokeTest -- --localization-test # 語言資源與即時切換
|
||||||
dotnet run --project tools/SmokeTest -- --export-test in.stp out.stp # STEP 匯出往返驗證
|
dotnet run --project tools/SmokeTest -- --export-test in.stp out.stp # STEP 匯出往返驗證
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -135,6 +137,24 @@ NuGet 相依(自動還原):`CADability`、`HelixToolkit.Wpf`、`CommunityT
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🌐 新增語言
|
||||||
|
|
||||||
|
程式啟動時掃描執行檔旁的 `i18n/*.json`。檔名去掉 `.json` 後就是語言 ID,例如 `en.json` 對應 `en`,`zh_tw.json` 對應 `zh_tw`。JSON 頂層的 `key` 是語言選單中的顯示名稱,`data` 是資源鍵與譯文的對應:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"key": "Français",
|
||||||
|
"data": {
|
||||||
|
"Ui.Language": "Langue",
|
||||||
|
"Ui.Main.MenuImport": "📂 Importer…"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
參考 `src/STPViewer/i18n/en.json` 的資源鍵,翻譯其 `data` 內容,將新檔案以 UTF-8 編碼放入執行檔旁的 `i18n/`。重啟 STPViewer 後,新語言便會出現在選單與快速列中,不需重新編譯。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 📁 專案結構
|
## 📁 專案結構
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -145,6 +165,7 @@ STPViewer/
|
|||||||
├── src/STPViewer/
|
├── src/STPViewer/
|
||||||
│ ├── STPViewer.csproj # net8.0-windows、UseWPF、單一版號來源 <Version>
|
│ ├── STPViewer.csproj # net8.0-windows、UseWPF、單一版號來源 <Version>
|
||||||
│ ├── MainWindow.xaml / .cs # 版面 + 滑鼠拾取轉發
|
│ ├── MainWindow.xaml / .cs # 版面 + 滑鼠拾取轉發
|
||||||
|
│ ├── i18n/ # 複製到執行檔旁的語言 JSON 檔
|
||||||
│ ├── Models/ # FaceInfo、MeasureMode、MeasurementResult、UnitSystem
|
│ ├── Models/ # FaceInfo、MeasureMode、MeasurementResult、UnitSystem
|
||||||
│ ├── Services/ # StepImport、Measurement、Interference、Section、RigidAlign、Settings
|
│ ├── Services/ # StepImport、Measurement、Interference、Section、RigidAlign、Settings
|
||||||
│ └── ViewModels/ # MainViewModel(partial:Drag/Gizmo/Section/Interference/Export)、ModelNodeViewModel
|
│ └── ViewModels/ # MainViewModel(partial:Drag/Gizmo/Section/Interference/Export)、ModelNodeViewModel
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# v0.1.0 — First release: STEP viewer with measurement & assembly checks
|
||||||
|
|
||||||
|
## ✨ New features
|
||||||
|
|
||||||
|
**Open your CAD files**
|
||||||
|
* Import STEP, STL and DXF — several files at once. STEP assemblies show up as a tree where you can hide/show, recolor or zoom to any part.
|
||||||
|
|
||||||
|
**Measure**
|
||||||
|
* Point, distance, edge length, face, circle (radius/diameter), angle and face-to-face distance. Values come from the exact CAD geometry wherever it's available.
|
||||||
|
|
||||||
|
**Check how parts fit**
|
||||||
|
* Two-point align (move), three-point align (rotate + move), rotate 90° about an axis, and an interference check that reports overlaps or the minimum gap between parts.
|
||||||
|
|
||||||
|
**More tools**
|
||||||
|
* Section view, mm ⇄ inch switch, export measurements to CSV, and save a high-resolution screenshot.
|
||||||
|
|
||||||
|
## 📦 Downloads
|
||||||
|
|
||||||
|
Source only — no prebuilt binaries were published for this version. For a ready-to-run build, grab the [latest release](https://github.com/ETWen/STPViewer/releases/latest).
|
||||||
|
|
||||||
|
## 🔗 Links
|
||||||
|
|
||||||
|
* [Architecture](https://github.com/ETWen/STPViewer/blob/main/ARCHITECTURE.md)
|
||||||
|
* [README](https://github.com/ETWen/STPViewer/blob/main/README.md)
|
||||||
|
|
||||||
|
**Full changelog:** [commit history](https://github.com/ETWen/STPViewer/commits/v0.1.0)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# v0.2.0 — Drag parts into place
|
||||||
|
|
||||||
|
## ✨ New features
|
||||||
|
|
||||||
|
**Drag mode**
|
||||||
|
* Press and hold a part with the left mouse button and drag it where you want; release to drop it. Smooth even on very large assemblies, and measurement accuracy is unaffected.
|
||||||
|
* Right-drag rotate, wheel zoom and Shift+drag pan still work as before.
|
||||||
|
|
||||||
|
## 📦 Downloads
|
||||||
|
|
||||||
|
Source only — no prebuilt binaries were published for this version. For a ready-to-run build, grab the [latest release](https://github.com/ETWen/STPViewer/releases/latest).
|
||||||
|
|
||||||
|
## 🔗 Links
|
||||||
|
|
||||||
|
* [Architecture](https://github.com/ETWen/STPViewer/blob/main/ARCHITECTURE.md)
|
||||||
|
* [README](https://github.com/ETWen/STPViewer/blob/main/README.md)
|
||||||
|
|
||||||
|
**Full changelog:** [v0.1.0...v0.2.0](https://github.com/ETWen/STPViewer/compare/v0.1.0...v0.2.0)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# v0.2.1 — Fix crash after dragging a part
|
||||||
|
|
||||||
|
## 🐛 Bug fixes
|
||||||
|
|
||||||
|
**No more crash after using drag mode**
|
||||||
|
* Clicking anywhere in the view after dragging a part could crash the app. Fixed — you can drag, then measure or select as usual.
|
||||||
|
|
||||||
|
## 📦 Downloads
|
||||||
|
|
||||||
|
Source only — no prebuilt binaries were published for this version. For a ready-to-run build, grab the [latest release](https://github.com/ETWen/STPViewer/releases/latest).
|
||||||
|
|
||||||
|
## 🔗 Links
|
||||||
|
|
||||||
|
* [Architecture](https://github.com/ETWen/STPViewer/blob/main/ARCHITECTURE.md)
|
||||||
|
* [README](https://github.com/ETWen/STPViewer/blob/main/README.md)
|
||||||
|
|
||||||
|
**Full changelog:** [v0.2.0...v0.2.1](https://github.com/ETWen/STPViewer/compare/v0.2.0...v0.2.1)
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# v0.3.0 — 3-axis gizmo for precise move & rotate
|
||||||
|
|
||||||
|
## ✨ New features
|
||||||
|
|
||||||
|
**Gizmo manipulator**
|
||||||
|
* Select a file in the tree and a gizmo appears at its center: drag the X/Y/Z arrows to move exactly along one axis, or the rotation rings to spin it by any angle — from any viewing angle.
|
||||||
|
* Selecting another file moves the gizmo to it.
|
||||||
|
|
||||||
|
## ⚠️ Known limitation
|
||||||
|
|
||||||
|
* The gizmo can be hidden inside solid parts. (Fixed in v0.3.1 — the gizmo now always stays on top.)
|
||||||
|
|
||||||
|
## 📦 Downloads
|
||||||
|
|
||||||
|
Source only — no prebuilt binaries were published for this version. For a ready-to-run build, grab the [latest release](https://github.com/ETWen/STPViewer/releases/latest).
|
||||||
|
|
||||||
|
## 🔗 Links
|
||||||
|
|
||||||
|
* [Architecture](https://github.com/ETWen/STPViewer/blob/main/ARCHITECTURE.md)
|
||||||
|
* [README](https://github.com/ETWen/STPViewer/blob/main/README.md)
|
||||||
|
|
||||||
|
**Full changelog:** [v0.2.1...v0.3.0](https://github.com/ETWen/STPViewer/compare/v0.2.1...v0.3.0)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# v0.4.0 — Faster and more stable
|
||||||
|
|
||||||
|
## ✨ Improvements
|
||||||
|
|
||||||
|
**Section slider stays responsive on big files**
|
||||||
|
* Dragging the section slider on large assemblies no longer freezes the screen — the cut is computed in the background and shown as soon as it's ready.
|
||||||
|
|
||||||
|
**Faster placement after moving parts**
|
||||||
|
* After dragging or using the gizmo, the part settles into its new position much faster. The interference check's gap calculation is faster too.
|
||||||
|
|
||||||
|
## 🐛 Bug fixes
|
||||||
|
|
||||||
|
**Errors no longer close the app**
|
||||||
|
* If something goes wrong, you now get a message and the app keeps running instead of shutting down. Details are logged to `%LOCALAPPDATA%\STPViewer\error.log`.
|
||||||
|
* Import, rotate and interference buttons are disabled while a long task is running, so they can't be triggered twice by accident.
|
||||||
|
|
||||||
|
## 📦 Downloads
|
||||||
|
|
||||||
|
Source only — no prebuilt binaries were published for this version. For a ready-to-run build, grab the [latest release](https://github.com/ETWen/STPViewer/releases/latest).
|
||||||
|
|
||||||
|
## 🔗 Links
|
||||||
|
|
||||||
|
* [Architecture](https://github.com/ETWen/STPViewer/blob/main/ARCHITECTURE.md)
|
||||||
|
* [README](https://github.com/ETWen/STPViewer/blob/main/README.md)
|
||||||
|
|
||||||
|
**Full changelog:** [v0.3.2...v0.4.0](https://github.com/ETWen/STPViewer/compare/v0.3.2...v0.4.0)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# v0.6.0 — Export to STL
|
||||||
|
|
||||||
|
## ✨ New features
|
||||||
|
|
||||||
|
**STL export with a settings dialog**
|
||||||
|
* Save the parts on screen — including any alignment or rotation you applied — as an STL mesh for 3D printing or other software.
|
||||||
|
* Before exporting you choose: one merged file or one file per input, Binary or ASCII, mm / inch / custom scale, and mesh quality (current or fine).
|
||||||
|
* The dialog shows the triangle count and estimated file size live, so you know what you'll get before you hit export.
|
||||||
|
|
||||||
|
## 📦 Downloads
|
||||||
|
|
||||||
|
Source only — no prebuilt binaries were published for this version. For a ready-to-run build, grab the [latest release](https://github.com/ETWen/STPViewer/releases/latest).
|
||||||
|
|
||||||
|
## 🔗 Links
|
||||||
|
|
||||||
|
* [Architecture](https://github.com/ETWen/STPViewer/blob/main/ARCHITECTURE.md)
|
||||||
|
* [README](https://github.com/ETWen/STPViewer/blob/main/README.md)
|
||||||
|
|
||||||
|
**Full changelog:** [v0.5.0...v0.6.0](https://github.com/ETWen/STPViewer/compare/v0.5.0...v0.6.0)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# v0.7.0 — Menu bar & customizable quick toolbar
|
||||||
|
|
||||||
|
## ✨ New features
|
||||||
|
|
||||||
|
**Menu bar**
|
||||||
|
* Every feature is now in a menu, grouped by File / View / Measure / Transform / Tools — no more hunting through one long row of buttons.
|
||||||
|
|
||||||
|
**Quick toolbar you can customize**
|
||||||
|
* The toolbar now holds only the buttons you use most. Pick which ones under **Quick Bar → Customize**; your choice is remembered next time.
|
||||||
|
|
||||||
|
**Tidier section controls**
|
||||||
|
* The section axis/position bar only appears while section view is on, so it no longer takes up space.
|
||||||
|
|
||||||
|
**About dialog**
|
||||||
|
* **Help → About** shows the version and a plain-language history of what each release brought.
|
||||||
|
|
||||||
|
## 📦 Downloads
|
||||||
|
|
||||||
|
Source only — no prebuilt binaries were published for this version. For a ready-to-run build, grab the [latest release](https://github.com/ETWen/STPViewer/releases/latest).
|
||||||
|
|
||||||
|
## 🔗 Links
|
||||||
|
|
||||||
|
* [Architecture](https://github.com/ETWen/STPViewer/blob/main/ARCHITECTURE.md)
|
||||||
|
* [README](https://github.com/ETWen/STPViewer/blob/main/README.md)
|
||||||
|
|
||||||
|
**Full changelog:** [v0.6.0...v0.7.0](https://github.com/ETWen/STPViewer/compare/v0.6.0...v0.7.0)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# v0.7.1 — Large STL files open in a blink
|
||||||
|
|
||||||
|
## ✨ New features
|
||||||
|
|
||||||
|
**Large STL files open in a blink**
|
||||||
|
* Opening big STL meshes (tens of MB, millions of triangles) is now near-instant instead of a multi-minute wait. A 75 MB / 1.5-million-triangle STL that used to take ages now shows in **under a second**.
|
||||||
|
* STL is now parsed directly instead of going through the CAD kernel's importer, which used to build a full B-rep face per triangle (100k triangles: **~41 s → ~11 ms**). Both binary and ASCII STL are detected and read automatically.
|
||||||
|
|
||||||
|
**Customizable menu bar & quick toolbar**
|
||||||
|
* A full menu bar groups every feature into categorized dropdowns, and a quick toolbar lets you tick just the buttons you use most — your selection is remembered between sessions.
|
||||||
|
|
||||||
|
**About dialog with a changelog**
|
||||||
|
* Help → About now shows a per-version, plain-language changelog so you can see what each release brought.
|
||||||
|
|
||||||
|
## 🐛 Bug fixes
|
||||||
|
|
||||||
|
**STL geometry no longer leaks into STEP export**
|
||||||
|
* STL is pure mesh (no B-rep), so STL parts are now correctly excluded from STEP export — earlier versions wrongly mixed STL shells into the exported STEP. Rigid transforms and measurement on STL parts are unaffected.
|
||||||
|
|
||||||
|
## 📦 Downloads
|
||||||
|
|
||||||
|
| Build | Needs .NET Runtime? | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| **STPViewer_v0.7.1** (Standard) | ✅ Requires .NET 8 Desktop Runtime | Smaller download |
|
||||||
|
| **STPViewer_v0.7.1_portable** (Portable) | ❌ Runtime included | Unzip and run anywhere, no admin |
|
||||||
|
|
||||||
|
Run `STPViewer v0.7.1.exe`.
|
||||||
|
|
||||||
|
## 🔗 Links
|
||||||
|
|
||||||
|
* [Architecture](https://github.com/ETWen/STPViewer/blob/main/ARCHITECTURE.md)
|
||||||
|
* [README](https://github.com/ETWen/STPViewer/blob/main/README.md)
|
||||||
|
|
||||||
|
**Full changelog:** [v0.6.0...v0.7.1](https://github.com/ETWen/STPViewer/compare/v0.6.0...v0.7.1)
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<Window x:Class="STPViewer.AboutDialog"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:loc="clr-namespace:STPViewer.Localization"
|
||||||
|
Title="{loc:Loc Ui.About.Title}"
|
||||||
|
Width="860" Height="600"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
ResizeMode="CanResize" MinWidth="700" MinHeight="420"
|
||||||
|
ShowInTaskbar="False">
|
||||||
|
<Window.Resources>
|
||||||
|
<Style x:Key="Card" TargetType="Border">
|
||||||
|
<Setter Property="Background" Value="#F0F4F8" />
|
||||||
|
<Setter Property="CornerRadius" Value="6" />
|
||||||
|
<Setter Property="Padding" Value="14,10" />
|
||||||
|
<Setter Property="Margin" Value="0,0,0,10" />
|
||||||
|
</Style>
|
||||||
|
<Style x:Key="CardTitle" TargetType="TextBlock">
|
||||||
|
<Setter Property="FontWeight" Value="Bold" />
|
||||||
|
<Setter Property="FontSize" Value="13" />
|
||||||
|
<Setter Property="Margin" Value="0,0,0,6" />
|
||||||
|
</Style>
|
||||||
|
<Style x:Key="DimText" TargetType="TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="#667788" />
|
||||||
|
<Setter Property="FontSize" Value="12" />
|
||||||
|
</Style>
|
||||||
|
</Window.Resources>
|
||||||
|
|
||||||
|
<Grid Margin="14">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="300" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Application, developer, and technology -->
|
||||||
|
<StackPanel Grid.Column="0" Grid.Row="0" Margin="0,0,12,0">
|
||||||
|
<Border Style="{StaticResource Card}">
|
||||||
|
<StackPanel HorizontalAlignment="Center">
|
||||||
|
<TextBlock Text="STPViewer" FontSize="22" FontWeight="Bold"
|
||||||
|
Foreground="#1565C0" HorizontalAlignment="Center" />
|
||||||
|
<TextBlock x:Name="VersionText" Text="v0.0.0" Style="{StaticResource DimText}"
|
||||||
|
HorizontalAlignment="Center" Margin="0,2,0,8" />
|
||||||
|
<TextBlock Text="{loc:Loc Ui.About.Description}" TextAlignment="Center"
|
||||||
|
TextWrapping="Wrap" Width="260" FontSize="12" LineHeight="18" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Style="{StaticResource Card}">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="{loc:Loc Ui.About.Developer}" Style="{StaticResource CardTitle}" />
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="60" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition /><RowDefinition /><RowDefinition />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="0" Text="{loc:Loc Ui.About.Name}" Style="{StaticResource DimText}" />
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="1" Text="ET Wen" FontSize="12" />
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="0" Text="{loc:Loc Ui.About.Email}" Style="{StaticResource DimText}" />
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="1" Text="eric441151893@gmail.com" FontSize="12" />
|
||||||
|
<TextBlock Grid.Row="2" Grid.Column="0" Text="GitHub" Style="{StaticResource DimText}" />
|
||||||
|
<TextBlock Grid.Row="2" Grid.Column="1" Text="github.com/ETWen" FontSize="12" />
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Style="{StaticResource Card}">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="{loc:Loc Ui.About.TechnologyTitle}" Style="{StaticResource CardTitle}" />
|
||||||
|
<TextBlock Text="{loc:Loc Ui.About.Technology}" Style="{StaticResource DimText}"
|
||||||
|
TextWrapping="Wrap" Width="260" LineHeight="18" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Release history -->
|
||||||
|
<Border Grid.Column="1" Grid.Row="0" Background="#FAFBFC" CornerRadius="6" Padding="4">
|
||||||
|
<DockPanel>
|
||||||
|
<TextBlock DockPanel.Dock="Top" Text="{loc:Loc Ui.About.Changelog}" FontWeight="Bold" FontSize="14"
|
||||||
|
Margin="12,8,0,4" />
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="12,0,12,8">
|
||||||
|
<ItemsControl x:Name="ChangelogList">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<StackPanel Margin="0,8,0,4">
|
||||||
|
<TextBlock FontWeight="Bold" Foreground="#1565C0" FontSize="13" TextWrapping="Wrap">
|
||||||
|
<Run Text="●" /> <Run Text="{Binding Header, Mode=OneWay}" />
|
||||||
|
</TextBlock>
|
||||||
|
<ItemsControl ItemsSource="{Binding Changes}" Margin="16,3,0,0">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<TextBlock Text="{Binding}" TextWrapping="Wrap" FontSize="12"
|
||||||
|
LineHeight="17" Margin="0,1,0,1" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Button Grid.Column="1" Grid.Row="1" Content="{loc:Loc Ui.About.Close}" Width="90" Margin="0,10,0,0"
|
||||||
|
HorizontalAlignment="Right" IsCancel="True" IsDefault="True" />
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Windows;
|
||||||
|
using STPViewer.Services;
|
||||||
|
|
||||||
|
namespace STPViewer;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The About window shows application information on the left and release history on the right.
|
||||||
|
/// Add each new release at the start of Changelog.
|
||||||
|
/// </summary>
|
||||||
|
public partial class AboutDialog : Window
|
||||||
|
{
|
||||||
|
public AboutDialog()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
Version v = Assembly.GetExecutingAssembly().GetName().Version!;
|
||||||
|
VersionText.Text = $"v{v.Major}.{v.Minor}.{v.Build}";
|
||||||
|
ChangelogList.ItemsSource = Changelog.Select(e => new
|
||||||
|
{
|
||||||
|
Header = $"v{e.Version} · {LocalizationService.Get(e.Title)} ({e.Date:yyyy-MM-dd})",
|
||||||
|
Changes = e.Changes.Select(LocalizationService.Get).ToArray(),
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ChangelogEntry(string Version, DateOnly Date, string Title, string[] Changes);
|
||||||
|
|
||||||
|
// Release history shown to users, newest first.
|
||||||
|
private static readonly ChangelogEntry[] Changelog =
|
||||||
|
{
|
||||||
|
new("0.7.1", new DateOnly(2026, 7, 12), "Ui.About.Release071.Title",
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
"Ui.About.Release071.Change1",
|
||||||
|
"Ui.About.Release071.Change2",
|
||||||
|
}),
|
||||||
|
new("0.7.0", new DateOnly(2026, 7, 4), "Ui.About.Release070.Title",
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
"Ui.About.Release070.Change1",
|
||||||
|
"Ui.About.Release070.Change2",
|
||||||
|
"Ui.About.Release070.Change3",
|
||||||
|
"Ui.About.Release070.Change4",
|
||||||
|
}),
|
||||||
|
new("0.6.0", new DateOnly(2026, 7, 4), "Ui.About.Release060.Title",
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
"Ui.About.Release060.Change1",
|
||||||
|
"Ui.About.Release060.Change2",
|
||||||
|
"Ui.About.Release060.Change3",
|
||||||
|
}),
|
||||||
|
new("0.5.0", new DateOnly(2026, 7, 3), "Ui.About.Release050.Title",
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
"Ui.About.Release050.Change1",
|
||||||
|
"Ui.About.Release050.Change2",
|
||||||
|
"Ui.About.Release050.Change3",
|
||||||
|
"Ui.About.Release050.Change4",
|
||||||
|
"Ui.About.Release050.Change5",
|
||||||
|
"Ui.About.Release050.Change6",
|
||||||
|
"Ui.About.Release050.Change7",
|
||||||
|
}),
|
||||||
|
new("0.4.0", new DateOnly(2026, 7, 2), "Ui.About.Release040.Title",
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
"Ui.About.Release040.Change1",
|
||||||
|
"Ui.About.Release040.Change2",
|
||||||
|
"Ui.About.Release040.Change3",
|
||||||
|
}),
|
||||||
|
new("0.3.2", new DateOnly(2026, 6, 18), "Ui.About.Release032.Title",
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
"Ui.About.Release032.Change1",
|
||||||
|
}),
|
||||||
|
new("0.3.1", new DateOnly(2026, 6, 13), "Ui.About.Release031.Title",
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
"Ui.About.Release031.Change1",
|
||||||
|
}),
|
||||||
|
new("0.3.0", new DateOnly(2026, 6, 13), "Ui.About.Release030.Title",
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
"Ui.About.Release030.Change1",
|
||||||
|
}),
|
||||||
|
new("0.2.0", new DateOnly(2026, 6, 13), "Ui.About.Release020.Title",
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
"Ui.About.Release020.Change1",
|
||||||
|
"Ui.About.Release020.Change2",
|
||||||
|
}),
|
||||||
|
new("0.1.0", new DateOnly(2026, 6, 13), "Ui.About.Release010.Title",
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
"Ui.About.Release010.Change1",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
+10
-10
@@ -3,12 +3,13 @@ using System.IO;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
using STPViewer.Services;
|
||||||
|
|
||||||
namespace STPViewer;
|
namespace STPViewer;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 全域例外處理:UI 執行緒未攔截例外 → 記 log + 訊息框後盡量存活(不閃退);
|
/// Log unhandled UI and background exceptions to
|
||||||
/// 背景 Task / 非 UI 執行緒例外 → 記 log。log 位於 %LOCALAPPDATA%\STPViewer\error.log。
|
/// %LOCALAPPDATA%\STPViewer\error.log and show a message for UI errors.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class App : Application
|
public partial class App : Application
|
||||||
{
|
{
|
||||||
@@ -18,28 +19,27 @@ public partial class App : Application
|
|||||||
|
|
||||||
protected override void OnStartup(StartupEventArgs e)
|
protected override void OnStartup(StartupEventArgs e)
|
||||||
{
|
{
|
||||||
|
LocalizationService.Instance.SetLanguage(SettingsService.Load().Language);
|
||||||
base.OnStartup(e);
|
base.OnStartup(e);
|
||||||
|
|
||||||
// UI 執行緒(含 async void 回拋)的未攔截例外:記 log、提示、標記 Handled 讓程式存活。
|
// Report UI exceptions and keep the window open.
|
||||||
// 若後續狀態已壞使用者可自行重啟;至少量測結果 / 已載模型不會直接蒸發。
|
|
||||||
DispatcherUnhandledException += (_, args) =>
|
DispatcherUnhandledException += (_, args) =>
|
||||||
{
|
{
|
||||||
LogException("DispatcherUnhandledException", args.Exception);
|
LogException("DispatcherUnhandledException", args.Exception);
|
||||||
MessageBox.Show(
|
MessageBox.Show(
|
||||||
$"發生未預期的錯誤,操作已中止(程式將嘗試繼續運作)。\n\n{args.Exception.Message}\n\n" +
|
LocalizationService.Format("App.UnexpectedError", args.Exception.Message, LogPath),
|
||||||
$"詳細記錄:{LogPath}",
|
LocalizationService.Get("App.ErrorTitle"), MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
"STPViewer 錯誤", MessageBoxButton.OK, MessageBoxImage.Warning);
|
|
||||||
args.Handled = true;
|
args.Handled = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 沒被 await 的背景 Task 例外(finalizer 階段才浮出):記 log 並標記已觀察,避免行程終止
|
// Log unobserved task exceptions.
|
||||||
TaskScheduler.UnobservedTaskException += (_, args) =>
|
TaskScheduler.UnobservedTaskException += (_, args) =>
|
||||||
{
|
{
|
||||||
LogException("UnobservedTaskException", args.Exception);
|
LogException("UnobservedTaskException", args.Exception);
|
||||||
args.SetObserved();
|
args.SetObserved();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 其他非 UI 執行緒的致命例外:無法阻止終止,但至少留下記錄
|
// Log fatal exceptions on non-UI threads.
|
||||||
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
|
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
|
||||||
LogException("AppDomainUnhandledException", args.ExceptionObject as Exception);
|
LogException("AppDomainUnhandledException", args.ExceptionObject as Exception);
|
||||||
}
|
}
|
||||||
@@ -55,6 +55,6 @@ public partial class App : Application
|
|||||||
sb.AppendLine(new string('-', 80));
|
sb.AppendLine(new string('-', 80));
|
||||||
File.AppendAllText(LogPath, sb.ToString(), Encoding.UTF8);
|
File.AppendAllText(LogPath, sb.ToString(), Encoding.UTF8);
|
||||||
}
|
}
|
||||||
catch { /* logging 本身失敗不能再拋 */ }
|
catch { /* Logging must not throw from an exception handler. */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ using STPViewer.Models;
|
|||||||
namespace STPViewer.Converters;
|
namespace STPViewer.Converters;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// MeasureMode ↔ ToggleButton.IsChecked。
|
/// Convert MeasureMode to ToggleButton.IsChecked using the mode name
|
||||||
/// ConverterParameter 為模式名稱字串;取消勾選時回到 None。
|
/// in ConverterParameter. Unchecking returns to None.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class EnumToBoolConverter : IValueConverter
|
public class EnumToBoolConverter : IValueConverter
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Windows.Data;
|
||||||
|
|
||||||
|
namespace STPViewer.Converters;
|
||||||
|
|
||||||
|
/// <summary>Show file names in the recent-files menu while retaining full paths as tooltips.</summary>
|
||||||
|
public class FileNameConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||||
|
value is string s ? System.IO.Path.GetFileName(s) : "";
|
||||||
|
|
||||||
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||||
|
throw new NotSupportedException();
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using System.Windows.Data;
|
||||||
|
using System.Windows.Markup;
|
||||||
|
using STPViewer.Services;
|
||||||
|
|
||||||
|
namespace STPViewer.Localization;
|
||||||
|
|
||||||
|
[MarkupExtensionReturnType(typeof(string))]
|
||||||
|
public class LocExtension : MarkupExtension
|
||||||
|
{
|
||||||
|
public string Key { get; set; }
|
||||||
|
|
||||||
|
public LocExtension(string key) => Key = key;
|
||||||
|
|
||||||
|
public override object ProvideValue(IServiceProvider serviceProvider) =>
|
||||||
|
new Binding($"[{Key}]")
|
||||||
|
{
|
||||||
|
Source = LocalizationService.Instance,
|
||||||
|
Mode = BindingMode.OneWay,
|
||||||
|
}.ProvideValue(serviceProvider);
|
||||||
|
}
|
||||||
+273
-112
@@ -4,7 +4,8 @@
|
|||||||
xmlns:h="http://helix-toolkit.org/wpf"
|
xmlns:h="http://helix-toolkit.org/wpf"
|
||||||
xmlns:conv="clr-namespace:STPViewer.Converters"
|
xmlns:conv="clr-namespace:STPViewer.Converters"
|
||||||
xmlns:vm="clr-namespace:STPViewer.ViewModels"
|
xmlns:vm="clr-namespace:STPViewer.ViewModels"
|
||||||
Title="STPViewer — STP/STEP 3D 檢視器"
|
xmlns:loc="clr-namespace:STPViewer.Localization"
|
||||||
|
Title="{loc:Loc Ui.Main.WindowTitle}"
|
||||||
Height="820" Width="1320"
|
Height="820" Width="1320"
|
||||||
WindowStartupLocation="CenterScreen"
|
WindowStartupLocation="CenterScreen"
|
||||||
AllowDrop="True" Drop="Window_Drop" DragOver="Window_DragOver"
|
AllowDrop="True" Drop="Window_Drop" DragOver="Window_DragOver"
|
||||||
@@ -12,113 +13,274 @@
|
|||||||
|
|
||||||
<Window.Resources>
|
<Window.Resources>
|
||||||
<conv:EnumToBoolConverter x:Key="ModeConv" />
|
<conv:EnumToBoolConverter x:Key="ModeConv" />
|
||||||
|
<conv:FileNameConverter x:Key="FileNameConv" />
|
||||||
<BooleanToVisibilityConverter x:Key="BoolToVis" />
|
<BooleanToVisibilityConverter x:Key="BoolToVis" />
|
||||||
<Style x:Key="ModeButton" TargetType="ToggleButton">
|
<Style x:Key="ModeButton" TargetType="ToggleButton">
|
||||||
<Setter Property="Padding" Value="8,4" />
|
<Setter Property="Padding" Value="8,4" />
|
||||||
<Setter Property="Margin" Value="2,0" />
|
<Setter Property="Margin" Value="2,0" />
|
||||||
<Setter Property="FontSize" Value="13" />
|
<Setter Property="FontSize" Value="13" />
|
||||||
</Style>
|
</Style>
|
||||||
|
<Style x:Key="QuickButton" TargetType="Button">
|
||||||
|
<Setter Property="Padding" Value="8,4" />
|
||||||
|
<Setter Property="Margin" Value="2,0" />
|
||||||
|
<Setter Property="FontSize" Value="13" />
|
||||||
|
</Style>
|
||||||
</Window.Resources>
|
</Window.Resources>
|
||||||
|
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
<!-- ─── 工具列 ─── -->
|
<!-- Menu bar -->
|
||||||
|
<Menu DockPanel.Dock="Top">
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.FileF}">
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.MenuImport}" Command="{Binding ImportCommand}"
|
||||||
|
ToolTip="{loc:Loc Ui.Main.ImportSTPSTEPSTLDXFFiles}" />
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.RecentFiles}" ItemsSource="{Binding RecentFiles}">
|
||||||
|
<MenuItem.ItemContainerStyle>
|
||||||
|
<Style TargetType="MenuItem">
|
||||||
|
<Setter Property="Header" Value="{Binding Converter={StaticResource FileNameConv}}" />
|
||||||
|
<Setter Property="ToolTip" Value="{Binding}" />
|
||||||
|
<Setter Property="Command"
|
||||||
|
Value="{Binding DataContext.ImportRecentCommand, RelativeSource={RelativeSource AncestorType=Menu}}" />
|
||||||
|
<Setter Property="CommandParameter" Value="{Binding}" />
|
||||||
|
</Style>
|
||||||
|
</MenuItem.ItemContainerStyle>
|
||||||
|
</MenuItem>
|
||||||
|
<Separator />
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.ExportMeasurementsCSV}" Command="{Binding ExportCsvCommand}" />
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.SaveViewPNG}" Command="{Binding SaveScreenshotCommand}"
|
||||||
|
ToolTip="{loc:Loc Ui.Main.SavePNGScreenshot3DView2}" />
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.ExportSTEP}" Command="{Binding ExportStepFileCommand}"
|
||||||
|
ToolTip="{loc:Loc Ui.Main.ExportStepTooltip}" />
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.ExportSTL}" Command="{Binding ExportStlFileCommand}"
|
||||||
|
ToolTip="{loc:Loc Ui.Main.ChooseScopeFormatUnitsMeshQuality}" />
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.ViewV}">
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.ZoomFit}" Command="{Binding ZoomAllCommand}" />
|
||||||
|
<Separator />
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.IsometricView}" Command="{Binding SetStandardViewCommand}" CommandParameter="Iso" />
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.FrontView}" Command="{Binding SetStandardViewCommand}" CommandParameter="Front"
|
||||||
|
ToolTip="{loc:Loc Ui.Main.LookAlongYZUp}" />
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.TopView}" Command="{Binding SetStandardViewCommand}" CommandParameter="Top"
|
||||||
|
ToolTip="{loc:Loc Ui.Main.LookDownFromZ}" />
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.RightView}" Command="{Binding SetStandardViewCommand}" CommandParameter="Right"
|
||||||
|
ToolTip="{loc:Loc Ui.Main.LookAlongXZUp}" />
|
||||||
|
<Separator />
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.OrthographicProjection}" IsCheckable="True" IsChecked="{Binding OrthographicView}"
|
||||||
|
ToolTip="{loc:Loc Ui.Main.ParallelProjectionAvoidsPerspectiveDistortionUncheck}" />
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.DisplayInchesMm}" IsCheckable="True" IsChecked="{Binding UseInch}"
|
||||||
|
ToolTip="{loc:Loc Ui.Main.ExistingMeasurementsUpdateImmediatelyValuesStored}" />
|
||||||
|
<Separator />
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Language}" ItemsSource="{Binding Languages}">
|
||||||
|
<MenuItem.ItemContainerStyle>
|
||||||
|
<Style TargetType="MenuItem">
|
||||||
|
<Setter Property="Header" Value="{Binding Key}" />
|
||||||
|
<Setter Property="IsCheckable" Value="True" />
|
||||||
|
<Setter Property="IsChecked" Value="{Binding IsSelected, Mode=OneWay}" />
|
||||||
|
<Setter Property="Command"
|
||||||
|
Value="{Binding DataContext.ChangeLanguageCommand, RelativeSource={RelativeSource AncestorType=Menu}}" />
|
||||||
|
<Setter Property="CommandParameter" Value="{Binding Id}" />
|
||||||
|
</Style>
|
||||||
|
</MenuItem.ItemContainerStyle>
|
||||||
|
</MenuItem>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.MeasureM}">
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.PointCoordinates}" IsCheckable="True" InputGestureText="P"
|
||||||
|
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Point}"
|
||||||
|
ToolTip="{loc:Loc Ui.Main.MeasurePointCoordinatesSnapsVerticesCircle}" />
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.TwoPointDistance}" IsCheckable="True" InputGestureText="D"
|
||||||
|
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Distance}"
|
||||||
|
ToolTip="{loc:Loc Ui.Main.DistanceXYZCircularEdgesSnapCenters}" />
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.EdgeLength}" IsCheckable="True" InputGestureText="E"
|
||||||
|
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Edge}"
|
||||||
|
ToolTip="{loc:Loc Ui.Main.MeasureStraightCurvedCircularEdgeLengths}" />
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.Face}" IsCheckable="True" InputGestureText="F"
|
||||||
|
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Face}"
|
||||||
|
ToolTip="{loc:Loc Ui.Main.AreaSurfaceTypeNormalVector}" />
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.Circle}" IsCheckable="True" InputGestureText="C"
|
||||||
|
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Circle}"
|
||||||
|
ToolTip="{loc:Loc Ui.Main.CenterRadiusDiameterCircumference}" />
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.Angle}" IsCheckable="True" InputGestureText="A"
|
||||||
|
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Angle}"
|
||||||
|
ToolTip="{loc:Loc Ui.Main.AngleBetweenTwoFacesStraightEdges}" />
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.FaceDistance}" IsCheckable="True" InputGestureText="M"
|
||||||
|
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=FaceDistance}"
|
||||||
|
ToolTip="{loc:Loc Ui.Main.ShortestDistanceBetweenFacesMeshApproximation}" />
|
||||||
|
<Separator />
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.ClearAllMeasurements}" Command="{Binding ClearMeasurementsCommand}"
|
||||||
|
InputGestureText="Esc" ToolTip="{loc:Loc Ui.Main.EscCancelsCurrentMeasurementExitsMode}" />
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.TransformT}">
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.TwoPointAlignmentTranslate}" IsCheckable="True"
|
||||||
|
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Align}"
|
||||||
|
ToolTip="{loc:Loc Ui.Main.PickPointPartMoveThenPick}" />
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.ThreePointAlignmentRotateTranslate}" IsCheckable="True"
|
||||||
|
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Align3}"
|
||||||
|
ToolTip="{loc:Loc Ui.Main.PickThreePointsMovingModelThen}" />
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.DragMode}" IsCheckable="True"
|
||||||
|
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Drag}"
|
||||||
|
ToolTip="{loc:Loc Ui.Main.HoldLeftMouseButtonMovePart}" />
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.3AxisGizmo}" IsCheckable="True" IsChecked="{Binding GizmoEnabled}"
|
||||||
|
ToolTip="{loc:Loc Ui.Main.ShowXYZArrowsRotationRingsSelected}" />
|
||||||
|
<Separator />
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.Rotate90AroundX}" Command="{Binding RotateRootCommand}" CommandParameter="X"
|
||||||
|
ToolTip="{loc:Loc Ui.Main.RotateModelSelectedTreeAroundIts}" />
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.Rotate90AroundY}" Command="{Binding RotateRootCommand}" CommandParameter="Y" />
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.Rotate90AroundZ}" Command="{Binding RotateRootCommand}" CommandParameter="Z" />
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.ToolsL}">
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.InterferenceCheck}" Command="{Binding CheckInterferenceCommand}"
|
||||||
|
ToolTip="{loc:Loc Ui.Main.CheckVisibleModelPairsIntersectionsReport}" />
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.SectionView}" IsCheckable="True" IsChecked="{Binding SectionEnabled}"
|
||||||
|
ToolTip="{loc:Loc Ui.Main.ShowSectionControlsAxisPositionDirection}" />
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.QuickBarQ}">
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.CustomizeButtons}" ItemsSource="{Binding QuickBar.Items}"
|
||||||
|
ToolTip="{loc:Loc Ui.Main.CheckButtonsShowQuickBarSaved}">
|
||||||
|
<MenuItem.ItemContainerStyle>
|
||||||
|
<Style TargetType="MenuItem">
|
||||||
|
<Setter Property="Header" Value="{Binding Label}" />
|
||||||
|
<Setter Property="IsCheckable" Value="True" />
|
||||||
|
<Setter Property="IsChecked" Value="{Binding IsChecked}" />
|
||||||
|
<Setter Property="StaysOpenOnClick" Value="True" />
|
||||||
|
</Style>
|
||||||
|
</MenuItem.ItemContainerStyle>
|
||||||
|
</MenuItem>
|
||||||
|
<Separator />
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.RestoreDefaultButtons}" Command="{Binding QuickBar.ResetDefaultsCommand}" />
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.HelpH}">
|
||||||
|
<MenuItem Header="{loc:Loc Ui.Main.IAboutSTPViewer}" Click="About_Click"
|
||||||
|
ToolTip="{loc:Loc Ui.Main.ApplicationInformationReleaseHistory}" />
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
<!-- Customizable Quick Bar -->
|
||||||
<ToolBarTray DockPanel.Dock="Top">
|
<ToolBarTray DockPanel.Dock="Top">
|
||||||
<ToolBar>
|
<ToolBar>
|
||||||
<Button Command="{Binding ImportCommand}" Padding="8,4" FontSize="13"
|
<Button Style="{StaticResource QuickButton}" Command="{Binding ImportCommand}"
|
||||||
ToolTip="匯入 STP/STEP/STL/DXF(可複選,也可直接拖放到視窗)">📂 匯入</Button>
|
Visibility="{Binding QuickBar[Import].IsChecked, Converter={StaticResource BoolToVis}}"
|
||||||
<Button Click="Recent_Click" Padding="4,4" FontSize="13"
|
ToolTip="{loc:Loc Ui.Main.ImportSTPSTEPSTLDXFFiles}" Content="{loc:Loc Ui.Main.ToolbarImport}" />
|
||||||
ToolTip="最近開啟的檔案">▾</Button>
|
<Button Style="{StaticResource QuickButton}" Click="Recent_Click" Padding="4,4"
|
||||||
<Button Command="{Binding ZoomAllCommand}" Padding="8,4" FontSize="13"
|
Visibility="{Binding QuickBar[Recent].IsChecked, Converter={StaticResource BoolToVis}}"
|
||||||
ToolTip="縮放至全部模型">🔍 全覽</Button>
|
ToolTip="{loc:Loc Ui.Main.RecentlyOpenedFiles}">▾</Button>
|
||||||
<Separator />
|
<Button Style="{StaticResource QuickButton}" Command="{Binding ZoomAllCommand}"
|
||||||
<TextBlock Text="視圖:" VerticalAlignment="Center" Margin="4,0,2,0" />
|
Visibility="{Binding QuickBar[ZoomAll].IsChecked, Converter={StaticResource BoolToVis}}"
|
||||||
<Button Command="{Binding SetStandardViewCommand}" CommandParameter="Iso" Padding="6,4" FontSize="13"
|
ToolTip="{loc:Loc Ui.Main.FitAllModelsView}" Content="{loc:Loc Ui.Main.Fit}" />
|
||||||
ToolTip="等角視圖">等角</Button>
|
<Button Style="{StaticResource QuickButton}" Command="{Binding SetStandardViewCommand}" CommandParameter="Iso"
|
||||||
<Button Command="{Binding SetStandardViewCommand}" CommandParameter="Front" Padding="6,4" FontSize="13"
|
Visibility="{Binding QuickBar[ViewIso].IsChecked, Converter={StaticResource BoolToVis}}"
|
||||||
ToolTip="前視圖(-Y 方向看,Z 向上)">前</Button>
|
ToolTip="{loc:Loc Ui.Main.IsometricView}" Content="{loc:Loc Ui.Main.Iso}" />
|
||||||
<Button Command="{Binding SetStandardViewCommand}" CommandParameter="Top" Padding="6,4" FontSize="13"
|
<Button Style="{StaticResource QuickButton}" Command="{Binding SetStandardViewCommand}" CommandParameter="Front"
|
||||||
ToolTip="上視圖(+Z 往下看)">上</Button>
|
Visibility="{Binding QuickBar[ViewFront].IsChecked, Converter={StaticResource BoolToVis}}"
|
||||||
<Button Command="{Binding SetStandardViewCommand}" CommandParameter="Right" Padding="6,4" FontSize="13"
|
ToolTip="{loc:Loc Ui.Main.FrontViewLookAlongYZ}" Content="{loc:Loc Ui.Main.Front}" />
|
||||||
ToolTip="右視圖(+X 方向看,Z 向上)">右</Button>
|
<Button Style="{StaticResource QuickButton}" Command="{Binding SetStandardViewCommand}" CommandParameter="Top"
|
||||||
<ToggleButton IsChecked="{Binding OrthographicView}" Padding="6,4" FontSize="13" Margin="2,0"
|
Visibility="{Binding QuickBar[ViewTop].IsChecked, Converter={StaticResource BoolToVis}}"
|
||||||
ToolTip="正交投影(平行投影,量尺寸不受透視變形;再按切回透視)">正交</ToggleButton>
|
ToolTip="{loc:Loc Ui.Main.TopViewLookDownFromZ}" Content="{loc:Loc Ui.Main.Top}" />
|
||||||
<Separator />
|
<Button Style="{StaticResource QuickButton}" Command="{Binding SetStandardViewCommand}" CommandParameter="Right"
|
||||||
<TextBlock Text="量測:" VerticalAlignment="Center" Margin="4,0,2,0" />
|
Visibility="{Binding QuickBar[ViewRight].IsChecked, Converter={StaticResource BoolToVis}}"
|
||||||
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="量測點座標(吸附頂點/圓心)— 快速鍵 P"
|
ToolTip="{loc:Loc Ui.Main.RightViewLookAlongXZ}" Content="{loc:Loc Ui.Main.Right}" />
|
||||||
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Point}">📍 點</ToggleButton>
|
<ToggleButton Style="{StaticResource ModeButton}" IsChecked="{Binding OrthographicView}"
|
||||||
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="兩點距離 + ΔXYZ(點到圓邊會吸圓心 → 可量孔對孔 pitch)— 快速鍵 D"
|
Visibility="{Binding QuickBar[Ortho].IsChecked, Converter={StaticResource BoolToVis}}"
|
||||||
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Distance}">📏 距離</ToggleButton>
|
ToolTip="{loc:Loc Ui.Main.OrthographicProjectionAvoidsPerspectiveDistortionClick}" Content="{loc:Loc Ui.Main.Ortho}" />
|
||||||
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="邊長(直線/曲線/圓弧)— 快速鍵 E"
|
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="{loc:Loc Ui.Main.PointCoordinatesVertexCircleCenterSnapping}"
|
||||||
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Edge}">📐 邊</ToggleButton>
|
Visibility="{Binding QuickBar[Point].IsChecked, Converter={StaticResource BoolToVis}}"
|
||||||
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="面積 + 曲面類型/法向量 — 快速鍵 F"
|
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Point}" Content="{loc:Loc Ui.Main.Point}" />
|
||||||
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Face}">⬛ 面</ToggleButton>
|
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="{loc:Loc Ui.Main.DistanceXYZCircleCenterSnappingHole}"
|
||||||
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="圓心/半徑/直徑/周長 — 快速鍵 C"
|
Visibility="{Binding QuickBar[Distance].IsChecked, Converter={StaticResource BoolToVis}}"
|
||||||
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Circle}">⭕ 圓</ToggleButton>
|
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Distance}" Content="{loc:Loc Ui.Main.Distance}" />
|
||||||
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="兩面/兩邊夾角(點兩個面或靠近直線邊)— 快速鍵 A"
|
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="{loc:Loc Ui.Main.StraightCurvedCircularEdgeLengthShortcut}"
|
||||||
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Angle}">∠ 角度</ToggleButton>
|
Visibility="{Binding QuickBar[Edge].IsChecked, Converter={StaticResource BoolToVis}}"
|
||||||
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="面到面最短距離(網格近似)— 快速鍵 M"
|
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Edge}" Content="{loc:Loc Ui.Main.Edge}" />
|
||||||
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=FaceDistance}">⇔ 面距</ToggleButton>
|
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="{loc:Loc Ui.Main.AreaSurfaceTypeNormalVectorShortcut}"
|
||||||
<Separator />
|
Visibility="{Binding QuickBar[Face].IsChecked, Converter={StaticResource BoolToVis}}"
|
||||||
|
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Face}" Content="{loc:Loc Ui.Main.Face}" />
|
||||||
|
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="{loc:Loc Ui.Main.CenterRadiusDiameterCircumferenceShortcutC}"
|
||||||
|
Visibility="{Binding QuickBar[Circle].IsChecked, Converter={StaticResource BoolToVis}}"
|
||||||
|
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Circle}" Content="{loc:Loc Ui.Main.Circle}" />
|
||||||
|
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="{loc:Loc Ui.Main.AngleBetweenFacesStraightEdgesShortcut}"
|
||||||
|
Visibility="{Binding QuickBar[Angle].IsChecked, Converter={StaticResource BoolToVis}}"
|
||||||
|
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Angle}" Content="{loc:Loc Ui.Main.Angle}" />
|
||||||
|
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="{loc:Loc Ui.Main.ShortestFaceDistanceMeshApproximationShortcut}"
|
||||||
|
Visibility="{Binding QuickBar[FaceDistance].IsChecked, Converter={StaticResource BoolToVis}}"
|
||||||
|
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=FaceDistance}" Content="{loc:Loc Ui.Main.FaceDistance}" />
|
||||||
<ToggleButton Style="{StaticResource ModeButton}"
|
<ToggleButton Style="{StaticResource ModeButton}"
|
||||||
ToolTip="兩點對齊(平移零件):先點要移動零件上的點,再點目標點"
|
ToolTip="{loc:Loc Ui.Main.TwoPointAlignmentPickPointMoving}"
|
||||||
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Align}">🎯 對齊</ToggleButton>
|
Visibility="{Binding QuickBar[Align].IsChecked, Converter={StaticResource BoolToVis}}"
|
||||||
|
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Align}" Content="{loc:Loc Ui.Main.Align}" />
|
||||||
<ToggleButton Style="{StaticResource ModeButton}"
|
<ToggleButton Style="{StaticResource ModeButton}"
|
||||||
ToolTip="三點對齊(旋轉+平移):先在要移動的檔案點 3 個特徵點,再到目標檔案點 3 個對應點(順序對應)"
|
ToolTip="{loc:Loc Ui.Main.ThreePointAlignmentPickThreePoints}"
|
||||||
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Align3}">🎯 三點</ToggleButton>
|
Visibility="{Binding QuickBar[Align3].IsChecked, Converter={StaticResource BoolToVis}}"
|
||||||
<Separator />
|
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Align3}" Content="{loc:Loc Ui.Main.3Point}" />
|
||||||
<TextBlock Text="旋轉:" VerticalAlignment="Center" Margin="4,0,2,0"
|
<Button Style="{StaticResource QuickButton}" Command="{Binding RotateRootCommand}" CommandParameter="X"
|
||||||
ToolTip="對樹面板選取的檔案,繞其中心旋轉 +90°" />
|
Visibility="{Binding QuickBar[RotateX].IsChecked, Converter={StaticResource BoolToVis}}"
|
||||||
<Button Command="{Binding RotateRootCommand}" CommandParameter="X" Padding="6,4" FontSize="13"
|
ToolTip="{loc:Loc Ui.Main.RotateModelSelectedTree90Around}">↻X</Button>
|
||||||
ToolTip="選取的檔案繞 X 軸 +90°(先在樹面板點選檔案)">↻X</Button>
|
<Button Style="{StaticResource QuickButton}" Command="{Binding RotateRootCommand}" CommandParameter="Y"
|
||||||
<Button Command="{Binding RotateRootCommand}" CommandParameter="Y" Padding="6,4" FontSize="13"
|
Visibility="{Binding QuickBar[RotateY].IsChecked, Converter={StaticResource BoolToVis}}"
|
||||||
ToolTip="選取的檔案繞 Y 軸 +90°">↻Y</Button>
|
ToolTip="{loc:Loc Ui.Main.RotateSelectedModel90AroundY}">↻Y</Button>
|
||||||
<Button Command="{Binding RotateRootCommand}" CommandParameter="Z" Padding="6,4" FontSize="13"
|
<Button Style="{StaticResource QuickButton}" Command="{Binding RotateRootCommand}" CommandParameter="Z"
|
||||||
ToolTip="選取的檔案繞 Z 軸 +90°">↻Z</Button>
|
Visibility="{Binding QuickBar[RotateZ].IsChecked, Converter={StaticResource BoolToVis}}"
|
||||||
|
ToolTip="{loc:Loc Ui.Main.RotateSelectedModel90AroundZ}">↻Z</Button>
|
||||||
<ToggleButton Style="{StaticResource ModeButton}"
|
<ToggleButton Style="{StaticResource ModeButton}"
|
||||||
ToolTip="拖曳模式:左鍵按住零件沿螢幕平面拖動,放開定位(深度方向先轉視角再拖)"
|
ToolTip="{loc:Loc Ui.Main.DragModeHoldLeftMouseButton}"
|
||||||
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Drag}">🖐 拖曳</ToggleButton>
|
Visibility="{Binding QuickBar[Drag].IsChecked, Converter={StaticResource BoolToVis}}"
|
||||||
<ToggleButton IsChecked="{Binding GizmoEnabled}" Padding="8,4" FontSize="13"
|
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Drag}" Content="{loc:Loc Ui.Main.Drag}" />
|
||||||
ToolTip="三軸操作器:對樹面板選取的檔案顯示 XYZ 箭頭(沿軸移動)與旋轉環(繞軸轉任意角度),每次放開即定位">⊹ 操作器</ToggleButton>
|
<ToggleButton Style="{StaticResource ModeButton}" IsChecked="{Binding GizmoEnabled}"
|
||||||
<Button Command="{Binding CheckInterferenceCommand}" Padding="8,4" FontSize="13"
|
Visibility="{Binding QuickBar[Gizmo].IsChecked, Converter={StaticResource BoolToVis}}"
|
||||||
ToolTip="干涉檢查:2 個以上可見檔案兩兩檢查是否相交(match 判定);無相交時回報最小間隙">🧩 干涉</Button>
|
ToolTip="{loc:Loc Ui.Main.3AxisGizmoUseXYZArrows}" Content="{loc:Loc Ui.Main.Gizmo}" />
|
||||||
<Separator />
|
<Button Style="{StaticResource QuickButton}" Command="{Binding CheckInterferenceCommand}"
|
||||||
<Button Command="{Binding ClearMeasurementsCommand}" Padding="8,4" FontSize="13"
|
Visibility="{Binding QuickBar[Interference].IsChecked, Converter={StaticResource BoolToVis}}"
|
||||||
ToolTip="清除全部量測與標記(Esc = 取消進行中量測/退出模式)">🧹 清除</Button>
|
ToolTip="{loc:Loc Ui.Main.InterferenceCheckTestVisibleModelPairs}" Content="{loc:Loc Ui.Main.Interference}" />
|
||||||
<Separator />
|
<ToggleButton Style="{StaticResource ModeButton}" IsChecked="{Binding SectionEnabled}"
|
||||||
<ToggleButton IsChecked="{Binding UseInch}" Padding="8,4" FontSize="13"
|
Visibility="{Binding QuickBar[Section].IsChecked, Converter={StaticResource BoolToVis}}"
|
||||||
ToolTip="切換顯示單位 mm ⇄ inch(既有量測即時換算)">mm ⇄ in</ToggleButton>
|
ToolTip="{loc:Loc Ui.Main.ToggleSectionViewItsControls}" Content="{loc:Loc Ui.Main.ToolbarSection}" />
|
||||||
<Separator />
|
<Button Style="{StaticResource QuickButton}" Command="{Binding ClearMeasurementsCommand}"
|
||||||
<Button Command="{Binding ExportCsvCommand}" Padding="8,4" FontSize="13"
|
Visibility="{Binding QuickBar[Clear].IsChecked, Converter={StaticResource BoolToVis}}"
|
||||||
ToolTip="量測結果匯出 CSV">💾 CSV</Button>
|
ToolTip="{loc:Loc Ui.Main.ClearAllMeasurementsMarkersEscCancels}" Content="{loc:Loc Ui.Main.Clear}" />
|
||||||
<Button Command="{Binding SaveScreenshotCommand}" Padding="8,4" FontSize="13"
|
<ToggleButton Style="{StaticResource ModeButton}" IsChecked="{Binding UseInch}"
|
||||||
ToolTip="3D 視圖截圖存 PNG(2x 解析度)">📷 截圖</Button>
|
Visibility="{Binding QuickBar[Unit].IsChecked, Converter={StaticResource BoolToVis}}"
|
||||||
<Button Command="{Binding ExportStepFileCommand}" Padding="8,4" FontSize="13"
|
ToolTip="{loc:Loc Ui.Main.SwitchDisplayUnitsBetweenMmInches}">mm ⇄ in</ToggleButton>
|
||||||
ToolTip="把可見檔案(目前對齊位置)匯出成新 STEP 檔 — 對齊插合結果可交接 CAD;不動原始檔">📤 STEP</Button>
|
<Button Style="{StaticResource QuickButton}" Command="{Binding ExportCsvCommand}"
|
||||||
</ToolBar>
|
Visibility="{Binding QuickBar[ExportCsv].IsChecked, Converter={StaticResource BoolToVis}}"
|
||||||
<ToolBar>
|
ToolTip="{loc:Loc Ui.Main.ExportMeasurementResultsCSV}">💾 CSV</Button>
|
||||||
<ToggleButton IsChecked="{Binding SectionEnabled}" Padding="8,4" FontSize="13"
|
<Button Style="{StaticResource QuickButton}" Command="{Binding SaveScreenshotCommand}"
|
||||||
ToolTip="開啟/關閉剖面檢視">✂ 剖面</ToggleButton>
|
Visibility="{Binding QuickBar[Screenshot].IsChecked, Converter={StaticResource BoolToVis}}"
|
||||||
<ComboBox SelectedIndex="{Binding SectionAxisIndex}" Width="60" Margin="4,0"
|
ToolTip="{loc:Loc Ui.Main.SavePNGScreenshot3DView2}" Content="{loc:Loc Ui.Main.Capture}" />
|
||||||
VerticalAlignment="Center" ToolTip="剖切軸向(3點 = 在模型上點 3 點定義任意平面)"
|
<Button Style="{StaticResource QuickButton}" Command="{Binding ExportStepFileCommand}"
|
||||||
IsEnabled="{Binding SectionEnabled}">
|
Visibility="{Binding QuickBar[ExportStep].IsChecked, Converter={StaticResource BoolToVis}}"
|
||||||
<ComboBoxItem>X</ComboBoxItem>
|
ToolTip="{loc:Loc Ui.Main.ToolbarExportStepTooltip}">📤 STEP</Button>
|
||||||
<ComboBoxItem>Y</ComboBoxItem>
|
<Button Style="{StaticResource QuickButton}" Command="{Binding ExportStlFileCommand}"
|
||||||
<ComboBoxItem>Z</ComboBoxItem>
|
Visibility="{Binding QuickBar[ExportStl].IsChecked, Converter={StaticResource BoolToVis}}"
|
||||||
<ComboBoxItem>3點</ComboBoxItem>
|
ToolTip="{loc:Loc Ui.Main.ToolbarExportStlTooltip}">📐 STL</Button>
|
||||||
</ComboBox>
|
<ComboBox ItemsSource="{Binding Languages}" DisplayMemberPath="Key"
|
||||||
<Slider Value="{Binding SectionPosition}" Minimum="0" Maximum="100" Width="180"
|
SelectedValue="{Binding SelectedLanguage}" SelectedValuePath="Id"
|
||||||
VerticalAlignment="Center" Margin="4,0" ToolTip="剖面位置(沿法向 %)"
|
Visibility="{Binding QuickBar[Language].IsChecked, Converter={StaticResource BoolToVis}}"
|
||||||
IsEnabled="{Binding SectionEnabled}" />
|
ToolTip="{loc:Loc Ui.Language}" Margin="2,0" />
|
||||||
<TextBox Text="{Binding SectionPosition, StringFormat={}{0:F0}}" Width="34"
|
|
||||||
VerticalAlignment="Center" ToolTip="剖面位置 %(0–100,可直接輸入,離開欄位生效)"
|
|
||||||
IsEnabled="{Binding SectionEnabled}" />
|
|
||||||
<TextBlock Text="%" VerticalAlignment="Center" Margin="1,0,0,0" />
|
|
||||||
<CheckBox IsChecked="{Binding SectionFlip}" Content="反向" VerticalAlignment="Center"
|
|
||||||
Margin="4,0" ToolTip="切換保留側" IsEnabled="{Binding SectionEnabled}" />
|
|
||||||
</ToolBar>
|
</ToolBar>
|
||||||
</ToolBarTray>
|
</ToolBarTray>
|
||||||
|
|
||||||
<!-- ─── 狀態列 ─── -->
|
<!-- Section controls -->
|
||||||
|
<ToolBarTray DockPanel.Dock="Top"
|
||||||
|
Visibility="{Binding SectionEnabled, Converter={StaticResource BoolToVis}}">
|
||||||
|
<ToolBar>
|
||||||
|
<TextBlock Text="{loc:Loc Ui.Main.SectionLabel}" VerticalAlignment="Center" Margin="4,0,2,0" />
|
||||||
|
<ComboBox SelectedIndex="{Binding SectionAxisIndex}" Width="60" Margin="4,0"
|
||||||
|
VerticalAlignment="Center" ToolTip="{loc:Loc Ui.Main.SectionAxis3PointsDefineAn}">
|
||||||
|
<ComboBoxItem>X</ComboBoxItem>
|
||||||
|
<ComboBoxItem>Y</ComboBoxItem>
|
||||||
|
<ComboBoxItem>Z</ComboBoxItem>
|
||||||
|
<ComboBoxItem Content="{loc:Loc Ui.Main.3Points}" />
|
||||||
|
</ComboBox>
|
||||||
|
<Slider Value="{Binding SectionPosition}" Minimum="0" Maximum="100" Width="180"
|
||||||
|
VerticalAlignment="Center" Margin="4,0" ToolTip="{loc:Loc Ui.Main.SectionPositionAlongNormal}" />
|
||||||
|
<TextBox Text="{Binding SectionPosition, StringFormat={}{0:F0}}" Width="34"
|
||||||
|
VerticalAlignment="Center" ToolTip="{loc:Loc Ui.Main.SectionPosition0100EnterValue}" />
|
||||||
|
<TextBlock Text="%" VerticalAlignment="Center" Margin="1,0,0,0" />
|
||||||
|
<CheckBox IsChecked="{Binding SectionFlip}" Content="{loc:Loc Ui.Main.Flip}" VerticalAlignment="Center"
|
||||||
|
Margin="4,0" ToolTip="{loc:Loc Ui.Main.SwitchRetainedSide}" />
|
||||||
|
<Button Style="{StaticResource QuickButton}" Command="{Binding CloseSectionCommand}"
|
||||||
|
ToolTip="{loc:Loc Ui.Main.CloseSectionView}" Content="{loc:Loc Ui.Main.Close}" />
|
||||||
|
</ToolBar>
|
||||||
|
</ToolBarTray>
|
||||||
|
|
||||||
|
<!-- Status bar -->
|
||||||
<StatusBar DockPanel.Dock="Bottom">
|
<StatusBar DockPanel.Dock="Bottom">
|
||||||
<StatusBarItem>
|
<StatusBarItem>
|
||||||
<TextBlock Text="{Binding StatusText}" />
|
<TextBlock Text="{Binding StatusText}" />
|
||||||
@@ -138,12 +300,12 @@
|
|||||||
<ColumnDefinition Width="300" MinWidth="160" />
|
<ColumnDefinition Width="300" MinWidth="160" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<!-- ─── 裝配樹面板 ─── -->
|
<!-- Assembly tree -->
|
||||||
<GroupBox Grid.Column="0" Header="模型 / 裝配樹" 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"
|
||||||
Text="{Binding TreeFilter, UpdateSourceTrigger=PropertyChanged}"
|
Text="{Binding TreeFilter, UpdateSourceTrigger=PropertyChanged}"
|
||||||
ToolTip="輸入名稱過濾裝配樹(只影響樹面板顯示,不影響 3D 可見性;清空 = 全顯)" />
|
ToolTip="{loc:Loc Ui.Main.FilterAssemblyTreeByNameDoes}" />
|
||||||
<TreeView ItemsSource="{Binding Roots}"
|
<TreeView ItemsSource="{Binding Roots}"
|
||||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
||||||
VirtualizingPanel.IsVirtualizing="True"
|
VirtualizingPanel.IsVirtualizing="True"
|
||||||
@@ -164,23 +326,23 @@
|
|||||||
Tag="{Binding DataContext, RelativeSource={RelativeSource AncestorType=Window}}">
|
Tag="{Binding DataContext, RelativeSource={RelativeSource AncestorType=Window}}">
|
||||||
<StackPanel.ContextMenu>
|
<StackPanel.ContextMenu>
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<MenuItem Header="👁 只顯示此節點"
|
<MenuItem Header="{loc:Loc Ui.Main.ShowOnlyThisNode}"
|
||||||
Command="{Binding PlacementTarget.Tag.IsolateNodeCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
Command="{Binding PlacementTarget.Tag.IsolateNodeCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
||||||
CommandParameter="{Binding}" />
|
CommandParameter="{Binding}" />
|
||||||
<MenuItem Header="🔄 反轉顯示"
|
<MenuItem Header="{loc:Loc Ui.Main.InvertVisibility}"
|
||||||
Command="{Binding PlacementTarget.Tag.InvertVisibilityCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
|
Command="{Binding PlacementTarget.Tag.InvertVisibilityCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
|
||||||
<MenuItem Header="☀ 全部顯示"
|
<MenuItem Header="{loc:Loc Ui.Main.ShowAll}"
|
||||||
Command="{Binding PlacementTarget.Tag.ShowAllCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
|
Command="{Binding PlacementTarget.Tag.ShowAllCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
|
||||||
<Separator />
|
<Separator />
|
||||||
<MenuItem Header="⚖ 量體積 / 質心"
|
<MenuItem Header="{loc:Loc Ui.Main.MeasureVolumeCentroid}"
|
||||||
Command="{Binding PlacementTarget.Tag.MeasureVolumeCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
Command="{Binding PlacementTarget.Tag.MeasureVolumeCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
||||||
CommandParameter="{Binding}" />
|
CommandParameter="{Binding}" />
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</StackPanel.ContextMenu>
|
</StackPanel.ContextMenu>
|
||||||
<CheckBox IsChecked="{Binding IsVisible}" VerticalAlignment="Center"
|
<CheckBox IsChecked="{Binding IsVisible}" VerticalAlignment="Center"
|
||||||
ToolTip="顯示 / 隱藏(含子節點)" />
|
ToolTip="{loc:Loc Ui.Main.ShowHideIncludingChildNodes}" />
|
||||||
<Button Width="14" Height="14" Margin="3,0"
|
<Button Width="14" Height="14" Margin="3,0"
|
||||||
VerticalAlignment="Center" ToolTip="點擊換色(含子節點)"
|
VerticalAlignment="Center" ToolTip="{loc:Loc Ui.Main.ClickChangeColorIncludingChildNodes}"
|
||||||
Command="{Binding CycleColorCommand}">
|
Command="{Binding CycleColorCommand}">
|
||||||
<Button.Template>
|
<Button.Template>
|
||||||
<ControlTemplate TargetType="Button">
|
<ControlTemplate TargetType="Button">
|
||||||
@@ -204,16 +366,16 @@
|
|||||||
</Style>
|
</Style>
|
||||||
</TextBlock.Style>
|
</TextBlock.Style>
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
<CheckBox IsChecked="{Binding ShowEdges}" Content="邊" FontSize="10"
|
<CheckBox IsChecked="{Binding ShowEdges}" Content="{loc:Loc Ui.Main.Edges}" FontSize="10"
|
||||||
VerticalAlignment="Center" Margin="4,0,0,0"
|
VerticalAlignment="Center" Margin="4,0,0,0"
|
||||||
Visibility="{Binding IsRoot, Converter={StaticResource BoolToVis}}"
|
Visibility="{Binding IsRoot, Converter={StaticResource BoolToVis}}"
|
||||||
ToolTip="顯示輪廓邊線(邊線量大時轉動視角會變慢)" />
|
ToolTip="{loc:Loc Ui.Main.ShowOutlineEdgesManyEdgesCan}" />
|
||||||
<Button Content="🔍" Width="20" Margin="3,0,0,0" Padding="0"
|
<Button Content="🔍" Width="20" Margin="3,0,0,0" Padding="0"
|
||||||
VerticalAlignment="Center" ToolTip="縮放至此節點"
|
VerticalAlignment="Center" ToolTip="{loc:Loc Ui.Main.ZoomThisNode}"
|
||||||
Command="{Binding DataContext.ZoomNodeCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
Command="{Binding DataContext.ZoomNodeCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||||
CommandParameter="{Binding}" />
|
CommandParameter="{Binding}" />
|
||||||
<Button Content="✕" Width="20" Padding="0" Margin="2,0,0,0"
|
<Button Content="✕" Width="20" Padding="0" Margin="2,0,0,0"
|
||||||
VerticalAlignment="Center" ToolTip="移除此檔案"
|
VerticalAlignment="Center" ToolTip="{loc:Loc Ui.Main.RemoveThisFile}"
|
||||||
Visibility="{Binding IsRoot, Converter={StaticResource BoolToVis}}"
|
Visibility="{Binding IsRoot, Converter={StaticResource BoolToVis}}"
|
||||||
Command="{Binding DataContext.RemoveRootCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
Command="{Binding DataContext.RemoveRootCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||||
CommandParameter="{Binding}" />
|
CommandParameter="{Binding}" />
|
||||||
@@ -226,9 +388,9 @@
|
|||||||
|
|
||||||
<GridSplitter Grid.Column="1" Width="5" HorizontalAlignment="Stretch"
|
<GridSplitter Grid.Column="1" Width="5" HorizontalAlignment="Stretch"
|
||||||
VerticalAlignment="Stretch" Background="#FFD8D8D8"
|
VerticalAlignment="Stretch" Background="#FFD8D8D8"
|
||||||
ToolTip="拖曳調整面板寬度" />
|
ToolTip="{loc:Loc Ui.Main.DragResizePanel}" />
|
||||||
|
|
||||||
<!-- ─── 3D 視圖 ─── -->
|
<!-- 3D viewport -->
|
||||||
<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"
|
||||||
@@ -256,17 +418,16 @@
|
|||||||
<h:DefaultLights />
|
<h:DefaultLights />
|
||||||
</h:HelixViewport3D>
|
</h:HelixViewport3D>
|
||||||
|
|
||||||
<!-- 操作器疊圖層:透明 Viewport3D 疊在主視窗上、永遠最上層。
|
<!-- Transparent gizmo overlay above the main viewport. -->
|
||||||
只在操作器開啟時 IsHitTestVisible(否則空白處滑鼠才會穿透回主視窗) -->
|
|
||||||
<Viewport3D x:Name="gizmoOverlay" Grid.Column="2"
|
<Viewport3D x:Name="gizmoOverlay" Grid.Column="2"
|
||||||
IsHitTestVisible="{Binding GizmoEnabled}" />
|
IsHitTestVisible="{Binding GizmoEnabled}" />
|
||||||
|
|
||||||
<GridSplitter Grid.Column="3" Width="5" HorizontalAlignment="Stretch"
|
<GridSplitter Grid.Column="3" Width="5" HorizontalAlignment="Stretch"
|
||||||
VerticalAlignment="Stretch" Background="#FFD8D8D8"
|
VerticalAlignment="Stretch" Background="#FFD8D8D8"
|
||||||
ToolTip="拖曳調整面板寬度" />
|
ToolTip="{loc:Loc Ui.Main.DragResizePanel}" />
|
||||||
|
|
||||||
<!-- ─── 量測結果面板 ─── -->
|
<!-- Measurement results -->
|
||||||
<GroupBox Grid.Column="4" Header="量測結果" 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">
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
@@ -282,7 +443,7 @@
|
|||||||
TextWrapping="Wrap" />
|
TextWrapping="Wrap" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Button Grid.Column="1" Content="✕" Width="22" Height="22"
|
<Button Grid.Column="1" Content="✕" Width="22" Height="22"
|
||||||
VerticalAlignment="Top" ToolTip="刪除此筆量測"
|
VerticalAlignment="Top" ToolTip="{loc:Loc Ui.Main.DeleteThisMeasurement}"
|
||||||
Command="{Binding DataContext.RemoveMeasurementCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
Command="{Binding DataContext.RemoveMeasurementCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||||
CommandParameter="{Binding}" />
|
CommandParameter="{Binding}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -18,25 +18,39 @@ public partial class MainWindow : Window
|
|||||||
public MainWindow()
|
public MainWindow()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
// 版號單一來源 = csproj <Version>;標題執行時讀,避免寫死
|
// Read the version from the project at runtime.
|
||||||
var v = Assembly.GetExecutingAssembly().GetName().Version!;
|
UpdateTitle();
|
||||||
Title = $"STPViewer V{v.Major}.{v.Minor}.{v.Build} — STP/STEP 3D 檢視器";
|
|
||||||
DataContext = _vm;
|
DataContext = _vm;
|
||||||
_vm.Attach(viewport);
|
_vm.Attach(viewport);
|
||||||
_vm.AttachOverlay(gizmoOverlay);
|
_vm.AttachOverlay(gizmoOverlay);
|
||||||
Loaded += MainWindow_Loaded;
|
Loaded += MainWindow_Loaded;
|
||||||
Closing += MainWindow_Closing;
|
Closing += MainWindow_Closing;
|
||||||
|
|
||||||
// 使用者設定:視窗位置大小(View 職責)+ 單位/MRU(VM 職責)
|
// Restore window settings here and units/recent files in the view model.
|
||||||
_settings = SettingsService.Load();
|
_settings = SettingsService.Load();
|
||||||
ApplyWindowSettings();
|
ApplyWindowSettings();
|
||||||
_vm.LoadSettings(_settings);
|
_vm.LoadSettings(_settings);
|
||||||
|
_vm.InitializeLocalization();
|
||||||
|
LocalizationService.Instance.LanguageChanged += LanguageChanged;
|
||||||
|
Closed += (_, _) =>
|
||||||
|
{
|
||||||
|
LocalizationService.Instance.LanguageChanged -= LanguageChanged;
|
||||||
|
_vm.DisposeLocalization();
|
||||||
|
};
|
||||||
|
|
||||||
// Gizmo 操作器放在疊圖層;manipulator 會把 MouseUp 標 handled,需 handledEventsToo 才收得到
|
// The gizmo overlay needs handled mouse-up events from the manipulator.
|
||||||
gizmoOverlay.AddHandler(MouseLeftButtonUpEvent,
|
gizmoOverlay.AddHandler(MouseLeftButtonUpEvent,
|
||||||
new MouseButtonEventHandler((_, _) => _vm.OnGizmoMouseUp()), handledEventsToo: true);
|
new MouseButtonEventHandler((_, _) => _vm.OnGizmoMouseUp()), handledEventsToo: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void LanguageChanged(object? sender, System.EventArgs e) => UpdateTitle();
|
||||||
|
|
||||||
|
private void UpdateTitle()
|
||||||
|
{
|
||||||
|
var v = Assembly.GetExecutingAssembly().GetName().Version!;
|
||||||
|
Title = LocalizationService.Format("App.WindowTitle", $"{v.Major}.{v.Minor}.{v.Build}");
|
||||||
|
}
|
||||||
|
|
||||||
private void ApplyWindowSettings()
|
private void ApplyWindowSettings()
|
||||||
{
|
{
|
||||||
if (_settings.WindowWidth >= 400 && _settings.WindowHeight >= 300)
|
if (_settings.WindowWidth >= 400 && _settings.WindowHeight >= 300)
|
||||||
@@ -44,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 &&
|
||||||
@@ -73,7 +87,7 @@ public partial class MainWindow : Window
|
|||||||
SettingsService.Save(_settings);
|
SettingsService.Save(_settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>支援命令列帶檔開啟:STPViewer.exe a.stp b.stl …</summary>
|
/// <summary>Open files passed on the command line: 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)
|
||||||
@@ -82,7 +96,7 @@ public partial class MainWindow : Window
|
|||||||
await _vm.ImportFilesAsync(files);
|
await _vm.ImportFilesAsync(files);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Esc / 量測模式快速鍵(焦點在輸入框時不攔截,樹搜尋、剖面數值可正常打字)</summary>
|
/// <summary>Handle Esc and measurement shortcuts without intercepting text inputs.</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;
|
||||||
@@ -97,7 +111,7 @@ public partial class MainWindow : Window
|
|||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>最近檔案下拉(▾):即時從 VM 的 MRU 清單組選單</summary>
|
/// <summary>Build the recent-files menu from the view model's current list.</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
|
||||||
@@ -107,7 +121,7 @@ public partial class MainWindow : Window
|
|||||||
};
|
};
|
||||||
if (_vm.RecentFiles.Count == 0)
|
if (_vm.RecentFiles.Count == 0)
|
||||||
{
|
{
|
||||||
menu.Items.Add(new MenuItem { Header = "(沒有最近開啟的檔案)", IsEnabled = false });
|
menu.Items.Add(new MenuItem { Header = LocalizationService.Get("App.NoRecentFiles"), IsEnabled = false });
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -123,19 +137,22 @@ public partial class MainWindow : Window
|
|||||||
menu.IsOpen = true;
|
menu.IsOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void About_Click(object sender, RoutedEventArgs e) =>
|
||||||
|
new AboutDialog { Owner = this }.ShowDialog();
|
||||||
|
|
||||||
private void Tree_SelectedItemChanged(object sender, RoutedPropertyChangedEventArgs<object> e) =>
|
private void Tree_SelectedItemChanged(object sender, RoutedPropertyChangedEventArgs<object> e) =>
|
||||||
_vm.SelectedNode = e.NewValue as ModelNodeViewModel;
|
_vm.SelectedNode = e.NewValue as ModelNodeViewModel;
|
||||||
|
|
||||||
private void Viewport_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
private void Viewport_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||||
{
|
{
|
||||||
// Shift+左鍵 = 平移(PanGesture2),不觸發量測/拖曳
|
// Shift+left-click pans the view.
|
||||||
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(); // 拖出視窗外也持續收到 Move/Up
|
viewport.CaptureMouse(); // Keep receiving move/up events outside the window.
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -156,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(); // 捕捉意外中斷(Alt+Tab 等)→ 以目前位置定格,不留懸空 Transform
|
_vm.OnDragEnd(); // Commit the current position if capture is interrupted.
|
||||||
|
|
||||||
private void Window_DragOver(object sender, DragEventArgs e)
|
private void Window_DragOver(object sender, DragEventArgs e)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ using STPViewer.ViewModels;
|
|||||||
|
|
||||||
namespace STPViewer.Models;
|
namespace STPViewer.Models;
|
||||||
|
|
||||||
/// <summary>渲染物件(GeometryModel3D)反查 B-rep Face 的對照資料</summary>
|
/// <summary>Maps a rendered GeometryModel3D back to a B-rep face.</summary>
|
||||||
public class FaceInfo
|
public class FaceInfo
|
||||||
{
|
{
|
||||||
/// <summary>B-rep 面;STL 等純網格來源為 null(僅支援點/距離/面距量測)</summary>
|
/// <summary>B-rep face, or null for mesh-only sources such as STL.</summary>
|
||||||
public Face? BrepFace { get; init; }
|
public Face? BrepFace { get; init; }
|
||||||
|
|
||||||
/// <summary>原始(未剖切)三角網格 — 面積計算與剖面還原用;零件平移後會換成新 mesh</summary>
|
/// <summary>Uncut mesh used for area and section restoration; replaced after translation.</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; }
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
|
namespace STPViewer.Models;
|
||||||
|
|
||||||
|
public class LanguageDefinition : ObservableObject
|
||||||
|
{
|
||||||
|
public string Id { get; }
|
||||||
|
public string Key { get; }
|
||||||
|
public IReadOnlyDictionary<string, string> Data { get; }
|
||||||
|
public bool IsSelected { get; private set; }
|
||||||
|
|
||||||
|
internal LanguageDefinition(string id, string key, IReadOnlyDictionary<string, string> data)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
Key = key;
|
||||||
|
Data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void SetSelected(bool selected)
|
||||||
|
{
|
||||||
|
IsSelected = selected;
|
||||||
|
OnPropertyChanged(nameof(IsSelected));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
namespace STPViewer.Models;
|
namespace STPViewer.Models;
|
||||||
|
|
||||||
/// <summary>量測模式(None = 純瀏覽)</summary>
|
/// <summary>Measurement modes; None is the browsing mode.</summary>
|
||||||
public enum MeasureMode
|
public enum MeasureMode
|
||||||
{
|
{
|
||||||
None,
|
None,
|
||||||
@@ -9,11 +9,11 @@ public enum MeasureMode
|
|||||||
Edge,
|
Edge,
|
||||||
Face,
|
Face,
|
||||||
Circle,
|
Circle,
|
||||||
Angle, // 兩面 / 兩邊夾角
|
Angle, // Angle between two faces or edges.
|
||||||
FaceDistance, // 面到面最短距離(網格近似)
|
FaceDistance, // Shortest face-to-face distance (mesh approximation).
|
||||||
Align, // 兩點對齊:平移零件使點 1 貼到點 2
|
Align, // Translate a part so the first point meets the second.
|
||||||
Align3, // 三點對齊:旋轉+平移(來源 3 點 → 目標 3 點)
|
Align3, // Rotate and translate from three source and target points.
|
||||||
Drag, // 拖曳模式:左鍵按住零件沿螢幕平面拖動(放開才烘進 B-rep)
|
Drag, // Move a part in the screen plane; commit on release.
|
||||||
Interference, // 干涉檢查結果(非點擊模式,僅作為結果類型)
|
Interference, // Result type rather than a picking mode.
|
||||||
Volume, // 體積/質心(樹右鍵選單觸發,非點擊模式,僅作為結果類型)
|
Volume, // Volume/centroid result triggered from the tree menu.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,23 +7,23 @@ using HelixToolkit.Wpf;
|
|||||||
namespace STPViewer.Models;
|
namespace STPViewer.Models;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 一筆量測結果。內部以 mm 儲存原始值,Title/Detail 依目前單位即時產生;
|
/// A measurement stored in millimeters. Its title, details, and 3D labels
|
||||||
/// 切換單位時 3D 標籤(DynamicLabels)也同步改字。
|
/// update when the display unit changes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class MeasurementResult : ObservableObject
|
public class MeasurementResult : ObservableObject
|
||||||
{
|
{
|
||||||
public MeasureMode Kind { get; init; }
|
public MeasureMode Kind { get; init; }
|
||||||
|
|
||||||
/// <summary>依單位產生標題(清單粗體列)</summary>
|
/// <summary>Create the list title for a display unit.</summary>
|
||||||
public required Func<UnitSystem, string> TitleFor { get; init; }
|
public required Func<UnitSystem, string> TitleFor { get; init; }
|
||||||
|
|
||||||
/// <summary>依單位產生多行明細</summary>
|
/// <summary>Create the detailed text for a display unit.</summary>
|
||||||
public required Func<UnitSystem, string> DetailFor { get; init; }
|
public required Func<UnitSystem, string> DetailFor { get; init; }
|
||||||
|
|
||||||
/// <summary>視圖中的標記(刪除量測時一併移除)</summary>
|
/// <summary>View markers removed with this measurement.</summary>
|
||||||
public List<Visual3D> Overlays { get; } = new();
|
public List<Visual3D> Overlays { get; } = new();
|
||||||
|
|
||||||
/// <summary>單位相關的 3D 文字標籤(切換單位時更新 Text)</summary>
|
/// <summary>3D labels whose text changes with the display unit.</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;
|
||||||
@@ -35,9 +35,14 @@ public class MeasurementResult : ObservableObject
|
|||||||
{
|
{
|
||||||
if (_unit == unit) return;
|
if (_unit == unit) return;
|
||||||
_unit = unit;
|
_unit = unit;
|
||||||
|
RefreshText();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RefreshText()
|
||||||
|
{
|
||||||
OnPropertyChanged(nameof(Title));
|
OnPropertyChanged(nameof(Title));
|
||||||
OnPropertyChanged(nameof(Detail));
|
OnPropertyChanged(nameof(Detail));
|
||||||
foreach ((BillboardTextVisual3D label, Func<UnitSystem, string> textFor) in DynamicLabels)
|
foreach ((BillboardTextVisual3D label, Func<UnitSystem, string> textFor) in DynamicLabels)
|
||||||
label.Text = textFor(unit);
|
label.Text = textFor(_unit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,28 +8,28 @@ public enum UnitSystem
|
|||||||
Inch,
|
Inch,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>量測值格式化(內部一律 mm,顯示時換算)</summary>
|
/// <summary>Format measurements stored in millimeters for the selected display unit.</summary>
|
||||||
public static class Units
|
public static class Units
|
||||||
{
|
{
|
||||||
private const double MmPerInch = 25.4;
|
private const double MmPerInch = 25.4;
|
||||||
|
|
||||||
/// <summary>長度</summary>
|
/// <summary>Length.</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>面積</summary>
|
/// <summary>Area.</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>體積</summary>
|
/// <summary>Volume.</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>座標分量(無單位字尾)</summary>
|
/// <summary>Coordinate component without a unit suffix.</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>座標點 (x, y, z)</summary>
|
/// <summary>Coordinate point (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)})";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<UseWPF>true</UseWPF>
|
<UseWPF>true</UseWPF>
|
||||||
<Version>0.5.0</Version> <!-- 單一版號來源:標題與 publish-winapp 打包資料夾都讀這個 -->
|
<Version>0.7.1</Version> <!-- Shared by the window title and publish output. -->
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -15,4 +15,8 @@
|
|||||||
<PackageReference Include="HelixToolkit.Wpf" Version="3.1.2" />
|
<PackageReference Include="HelixToolkit.Wpf" Version="3.1.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="i18n\*.json" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ using System.Windows.Media.Media3D;
|
|||||||
namespace STPViewer.Services;
|
namespace STPViewer.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 干涉檢查結果:Intersects=true 時 Segments 為干涉交線;
|
/// Interference result: Segments holds intersection lines, or GapA/GapB the approximate closest points.
|
||||||
/// false 時 GapA/GapB 為最近點對(網格近似)。
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record InterferenceResult(
|
public record InterferenceResult(
|
||||||
bool Intersects,
|
bool Intersects,
|
||||||
@@ -18,10 +17,8 @@ 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
|
||||||
{
|
{
|
||||||
@@ -63,7 +60,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
|
||||||
{
|
{
|
||||||
@@ -100,7 +97,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>均勻網格:cell → 三角形索引</summary>
|
/// <summary>Uniform grid from cells to triangle indices.</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();
|
||||||
@@ -108,7 +105,7 @@ public static class InterferenceService
|
|||||||
|
|
||||||
public TriGrid(Triangle[] tris)
|
public TriGrid(Triangle[] tris)
|
||||||
{
|
{
|
||||||
// cell 大小:整體對角線 / 64(夾限避免極端值)
|
// Size cells from the scene diagonal, with bounds for extreme sizes.
|
||||||
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)
|
||||||
{
|
{
|
||||||
@@ -146,7 +143,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)
|
||||||
{
|
{
|
||||||
@@ -167,12 +164,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; // 共面:不視為穿透(近似)
|
if (dir.LengthSquared < 1e-24) return false; // Coplanar contact.
|
||||||
|
|
||||||
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);
|
||||||
@@ -182,14 +179,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>三角形與平面(頂點符號 d0..d2 已算好)的交線段(兩個交點)</summary>
|
/// <summary>Finds the triangle-plane intersection segment from signed vertex distances.</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)
|
||||||
{
|
{
|
||||||
@@ -208,8 +205,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));
|
||||||
}
|
}
|
||||||
@@ -221,11 +218,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)
|
||||||
{
|
{
|
||||||
// 1) 抽樣頂點對頂點求初值
|
// Sample vertices for an initial closest pair.
|
||||||
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);
|
||||||
@@ -237,14 +234,13 @@ 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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) 以最佳點為中心,精修:點 → 對方所有三角形(AABB 平方距離快速拒絕 + 平行分段,
|
// Refine against opposing triangles, rejecting distant AABBs.
|
||||||
// 初值來自步驟 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>點 p 到三角形集的最近點(帶初值;回傳更新後的最近點與距離)</summary>
|
/// <summary>Refines the closest point from an initial distance.</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();
|
||||||
@@ -256,7 +252,7 @@ public static class InterferenceService
|
|||||||
(i, _, local) =>
|
(i, _, local) =>
|
||||||
{
|
{
|
||||||
Triangle t = tris[i];
|
Triangle t = tris[i];
|
||||||
// AABB 平方距離下界 ≥ 目前最佳 → 不可能更近,略過(絕大多數三角形在此被刷掉)
|
// Reject triangles whose AABB cannot improve the current distance.
|
||||||
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));
|
||||||
@@ -276,7 +272,7 @@ public static class InterferenceService
|
|||||||
return (bestPt, bestD);
|
return (bestPt, bestD);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>點到三角形最近點(Ericson, Real-Time Collision Detection)</summary>
|
/// <summary>Closest point on a triangle, following Ericson's construction.</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;
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using STPViewer.Models;
|
||||||
|
|
||||||
|
namespace STPViewer.Services;
|
||||||
|
|
||||||
|
public class LocalizationService : ObservableObject
|
||||||
|
{
|
||||||
|
public static LocalizationService Instance { get; } = new(Path.Combine(AppContext.BaseDirectory, "i18n"));
|
||||||
|
|
||||||
|
private LanguageDefinition? _selected;
|
||||||
|
private readonly LanguageDefinition? _english;
|
||||||
|
|
||||||
|
public IReadOnlyList<LanguageDefinition> Languages { get; }
|
||||||
|
public string Language => _selected?.Id ?? "";
|
||||||
|
public event EventHandler? LanguageChanged;
|
||||||
|
|
||||||
|
public LocalizationService(string directory)
|
||||||
|
{
|
||||||
|
Languages = LoadLanguages(directory);
|
||||||
|
_english = Languages.FirstOrDefault(language => language.Id == "en");
|
||||||
|
_selected = Languages.FirstOrDefault(language => language.Id == ResolveLanguage(null));
|
||||||
|
foreach (LanguageDefinition language in Languages)
|
||||||
|
language.SetSelected(ReferenceEquals(language, _selected));
|
||||||
|
}
|
||||||
|
|
||||||
|
public string this[string key]
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (_selected?.Data.TryGetValue(key, out string? value) == true) return value;
|
||||||
|
if (_english?.Data.TryGetValue(key, out value) == true) return value;
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Get(string key) => Instance[key];
|
||||||
|
|
||||||
|
public static string Format(string key, params object[] args) => Instance.FormatText(key, args);
|
||||||
|
|
||||||
|
public string FormatText(string key, params object[] args)
|
||||||
|
{
|
||||||
|
string text = this[key];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return string.Format(CultureInfo.CurrentCulture, text, args);
|
||||||
|
}
|
||||||
|
catch (FormatException ex)
|
||||||
|
{
|
||||||
|
Trace.TraceWarning($"Invalid translation format '{key}' in '{Language}': {ex.Message}");
|
||||||
|
if (_english?.Data.TryGetValue(key, out string? fallback) == true && fallback != text)
|
||||||
|
{
|
||||||
|
try { return string.Format(CultureInfo.CurrentCulture, fallback, args); }
|
||||||
|
catch (FormatException) { }
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ResolveLanguage(string? language)
|
||||||
|
{
|
||||||
|
string id = NormalizeId(language ?? CultureInfo.CurrentUICulture.Name);
|
||||||
|
if (Languages.Any(item => item.Id == id)) return id;
|
||||||
|
|
||||||
|
string[] parts = id.Split('_');
|
||||||
|
if (parts[0] == "zh")
|
||||||
|
{
|
||||||
|
bool traditional = parts.Any(part => part is "hant" or "tw" or "hk" or "mo");
|
||||||
|
string[] candidates = traditional ? ["zh_tw", "zh_hant", "zh"] : ["zh_cn", "zh_hans", "zh"];
|
||||||
|
foreach (string candidate in candidates)
|
||||||
|
if (Languages.Any(item => item.Id == candidate)) return candidate;
|
||||||
|
return _english?.Id ?? Languages.FirstOrDefault()?.Id ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
LanguageDefinition? match = Languages.FirstOrDefault(item => item.Id == parts[0]);
|
||||||
|
return match?.Id ?? _english?.Id ?? Languages.FirstOrDefault()?.Id ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetLanguage(string? language)
|
||||||
|
{
|
||||||
|
string id = ResolveLanguage(language);
|
||||||
|
LanguageDefinition? selected = Languages.FirstOrDefault(item => item.Id == id);
|
||||||
|
bool changed = !ReferenceEquals(_selected, selected);
|
||||||
|
_selected = selected;
|
||||||
|
|
||||||
|
foreach (LanguageDefinition item in Languages)
|
||||||
|
item.SetSelected(ReferenceEquals(item, selected));
|
||||||
|
|
||||||
|
if (selected is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
CultureInfo culture = CultureInfo.GetCultureInfo(selected.Id.Replace('_', '-'));
|
||||||
|
CultureInfo.CurrentUICulture = culture;
|
||||||
|
CultureInfo.DefaultThreadCurrentUICulture = culture;
|
||||||
|
}
|
||||||
|
catch (CultureNotFoundException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) return;
|
||||||
|
OnPropertyChanged(nameof(Language));
|
||||||
|
OnPropertyChanged("Item[]");
|
||||||
|
LanguageChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeId(string id) => id.Trim().Replace('-', '_').ToLowerInvariant();
|
||||||
|
|
||||||
|
private static IReadOnlyList<LanguageDefinition> LoadLanguages(string directory)
|
||||||
|
{
|
||||||
|
var languages = new List<LanguageDefinition>();
|
||||||
|
if (!Directory.Exists(directory)) return languages;
|
||||||
|
|
||||||
|
string[] paths;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
paths = Directory.GetFiles(directory, "*.json");
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
Trace.TraceWarning($"Cannot read language directory '{directory}': {ex.Message}");
|
||||||
|
return languages;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (string path in paths.OrderBy(path => path, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
LanguageFile? file = JsonSerializer.Deserialize<LanguageFile>(File.ReadAllText(path));
|
||||||
|
string id = NormalizeId(Path.GetFileNameWithoutExtension(path));
|
||||||
|
if (string.IsNullOrWhiteSpace(file?.Key) || file.Data is null || id.Length == 0 ||
|
||||||
|
file.Data.Any(pair => string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null))
|
||||||
|
throw new JsonException("Expected a display key and a data object containing string translations.");
|
||||||
|
if (languages.Any(language => language.Id == id))
|
||||||
|
throw new JsonException($"Duplicate language identifier '{id}'.");
|
||||||
|
|
||||||
|
languages.Add(new LanguageDefinition(id, file.Key, file.Data));
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
|
||||||
|
{
|
||||||
|
Trace.TraceWarning($"Cannot load language file '{path}': {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return languages;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class LanguageFile
|
||||||
|
{
|
||||||
|
[JsonPropertyName("key")]
|
||||||
|
public string? Key { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("data")]
|
||||||
|
public Dictionary<string, string>? Data { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,16 +6,17 @@ using CADability;
|
|||||||
using CADability.GeoObject;
|
using CADability.GeoObject;
|
||||||
using HelixToolkit.Wpf;
|
using HelixToolkit.Wpf;
|
||||||
using STPViewer.Models;
|
using STPViewer.Models;
|
||||||
|
using static STPViewer.Services.LocalizationService;
|
||||||
|
|
||||||
namespace STPViewer.Services;
|
namespace STPViewer.Services;
|
||||||
|
|
||||||
/// <summary>角度量測的單次拾取:邊方向或面法向量</summary>
|
/// <summary>One direction selected for angle measurement.</summary>
|
||||||
public record DirectionPick(Point3D At, Vector3D Direction, string Desc);
|
public record DirectionPick(Point3D At, Vector3D Direction, string Desc);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 點/距離/邊/面/圓/角度/面距 量測幾何計算 + 視圖 overlay 產生。
|
/// Computes measurements and their viewport overlays.
|
||||||
/// 邊長、圓半徑以 B-rep 精確值為準;面積與面距用三角網格近似。
|
/// B-rep provides exact edge lengths and radii; mesh values are approximate.
|
||||||
/// 所有文字以 Func<UnitSystem,string> 延後產生,支援 mm/inch 即時切換。
|
/// Text is deferred so unit and language changes update existing results.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class MeasurementService
|
public class MeasurementService
|
||||||
{
|
{
|
||||||
@@ -29,13 +30,11 @@ 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>
|
||||||
/// 命中點吸附:點到圓形邊附近(容差內)→ 吸附**圓心**(量兩孔 pitch 的關鍵,v0.5.0);
|
/// Snaps to the nearest vertex or circular edge within tolerance.
|
||||||
/// 否則吸附最近的 B-rep 頂點(容差內);都沒有 → 回傳原始表面點。
|
/// A circular edge snaps to its center for hole pitch measurements.
|
||||||
/// 圓心與頂點都在容差內時,比「命中點到圓形邊」與「命中點到頂點」誰近(圓弧端點常與圓重疊,
|
|
||||||
/// 點在圓邊上時圓心優先勝出才符合直覺)。
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Point3D Snap(FaceInfo fi, Point3D hit, double tolerance)
|
public Point3D Snap(FaceInfo fi, Point3D hit, double tolerance)
|
||||||
{
|
{
|
||||||
@@ -51,9 +50,9 @@ public class MeasurementService
|
|||||||
if (d < bestD) { bestD = d; best = p; }
|
if (d < bestD) { bestD = d; best = p; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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)
|
||||||
@@ -67,19 +66,18 @@ public class MeasurementService
|
|||||||
if (d < bestD) { bestD = d; best = ToP3(el.Center); }
|
if (d < bestD) { bestD = d; best = ToP3(el.Center); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch { /* 邊存取失敗則維持頂點吸附結果 */ }
|
catch { }
|
||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <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)
|
||||||
{
|
{
|
||||||
// 1) 近距離直線邊優先
|
// Prefer a nearby straight edge.
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
foreach (Edge e in fi.BrepFace.AllEdges)
|
foreach (Edge e in fi.BrepFace.AllEdges)
|
||||||
@@ -91,28 +89,28 @@ public class MeasurementService
|
|||||||
{
|
{
|
||||||
Vector3D dir = ToP3(ln.EndPoint) - ToP3(ln.StartPoint);
|
Vector3D dir = ToP3(ln.EndPoint) - ToP3(ln.StartPoint);
|
||||||
dir.Normalize();
|
dir.Normalize();
|
||||||
return new DirectionPick(p, dir, "邊方向");
|
return new DirectionPick(p, dir, "Runtime.DirectionEdge");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch { }
|
catch { }
|
||||||
|
|
||||||
// 2) 面法向量(精確)
|
// Use the B-rep face normal when available.
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
GeoPoint2D uv = fi.BrepFace.Surface.PositionOf(ToGeo(hit));
|
GeoPoint2D uv = fi.BrepFace.Surface.PositionOf(ToGeo(hit));
|
||||||
GeoVector n = fi.BrepFace.Surface.GetNormal(uv).Normalized;
|
GeoVector n = fi.BrepFace.Surface.GetNormal(uv).Normalized;
|
||||||
return new DirectionPick(hit, new Vector3D(n.x, n.y, n.z), "面法向量");
|
return new DirectionPick(hit, new Vector3D(n.x, n.y, n.z), "Runtime.DirectionFace");
|
||||||
}
|
}
|
||||||
catch { }
|
catch { }
|
||||||
}
|
}
|
||||||
Vector3D mn = meshNormal;
|
Vector3D mn = meshNormal;
|
||||||
if (mn.LengthSquared < 1e-12) mn = new Vector3D(0, 0, 1);
|
if (mn.LengthSquared < 1e-12) mn = new Vector3D(0, 0, 1);
|
||||||
mn.Normalize();
|
mn.Normalize();
|
||||||
return new DirectionPick(hit, mn, "面法向量(網格)");
|
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)
|
||||||
{
|
{
|
||||||
@@ -134,8 +132,7 @@ public class MeasurementService
|
|||||||
{
|
{
|
||||||
Kind = MeasureMode.Distance,
|
Kind = MeasureMode.Distance,
|
||||||
TitleFor = u => $"{label} {Units.L(d.Length, u)}",
|
TitleFor = u => $"{label} {Units.L(d.Length, u)}",
|
||||||
DetailFor = u => $"距離 = {Units.L(d.Length, u)}\nΔX = {Units.L(d.X, u)}\nΔY = {Units.L(d.Y, u)}\nΔZ = {Units.L(d.Z, u)}\n" +
|
DetailFor = u => Format("Runtime.DistanceDetail", Units.L(d.Length, u), Units.L(d.X, u), Units.L(d.Y, u), Units.L(d.Z, u), Units.P(p1, u), Units.P(p2, u)),
|
||||||
$"P1 {Units.P(p1, u)}\nP2 {Units.P(p2, u)}",
|
|
||||||
};
|
};
|
||||||
r.Overlays.Add(Sphere(p1, markerR));
|
r.Overlays.Add(Sphere(p1, markerR));
|
||||||
r.Overlays.Add(Sphere(p2, markerR));
|
r.Overlays.Add(Sphere(p2, markerR));
|
||||||
@@ -149,9 +146,9 @@ public class MeasurementService
|
|||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 邊 / 圓 ─────────────────────────────────────────────────
|
// Edge and circle
|
||||||
|
|
||||||
/// <summary>量測命中面上最近的邊。circlesOnly=true 時只找圓形邊。找不到回傳 null。</summary>
|
/// <summary>Measures the nearest edge, or the nearest circular edge when circlesOnly is true.</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;
|
||||||
@@ -193,9 +190,7 @@ public class MeasurementService
|
|||||||
{
|
{
|
||||||
Kind = MeasureMode.Circle,
|
Kind = MeasureMode.Circle,
|
||||||
TitleFor = u => $"{label} ⌀{Units.L(radius * 2, u)}",
|
TitleFor = u => $"{label} ⌀{Units.L(radius * 2, u)}",
|
||||||
DetailFor = u => $"直徑 = {Units.L(radius * 2, u)}\n半徑 = {Units.L(radius, u)}\n" +
|
DetailFor = u => Format("Runtime.CircleDetail", Units.L(radius * 2, u), Units.L(radius, u), Get(full ? "Runtime.Circumference" : "Runtime.ArcLength"), Units.L(curveLen, u), Units.P(center, u)),
|
||||||
$"{(full ? "周長" : "弧長")} = {Units.L(curveLen, u)}\n" +
|
|
||||||
$"圓心 {Units.P(center, u)}",
|
|
||||||
};
|
};
|
||||||
r.Overlays.Add(Polyline(curve, HighlightColor, 3));
|
r.Overlays.Add(Polyline(curve, HighlightColor, 3));
|
||||||
r.Overlays.Add(Sphere(center, markerR));
|
r.Overlays.Add(Sphere(center, markerR));
|
||||||
@@ -219,8 +214,7 @@ public class MeasurementService
|
|||||||
{
|
{
|
||||||
Kind = MeasureMode.Edge,
|
Kind = MeasureMode.Edge,
|
||||||
TitleFor = u => $"{label} L = {Units.L(len, u)}",
|
TitleFor = u => $"{label} L = {Units.L(len, u)}",
|
||||||
DetailFor = u => $"長度 = {Units.L(len, u)}\nΔX = {Units.C(d.X, u)} ΔY = {Units.C(d.Y, u)} ΔZ = {Units.C(d.Z, u)}\n" +
|
DetailFor = u => Format("Runtime.LineDetail", Units.L(len, u), Units.C(d.X, u), Units.C(d.Y, u), Units.C(d.Z, u), Units.P(s, u), Units.P(e, u)),
|
||||||
$"起點 {Units.P(s, u)}\n終點 {Units.P(e, u)}",
|
|
||||||
};
|
};
|
||||||
r.Overlays.Add(new LinesVisual3D
|
r.Overlays.Add(new LinesVisual3D
|
||||||
{
|
{
|
||||||
@@ -241,32 +235,32 @@ public class MeasurementService
|
|||||||
{
|
{
|
||||||
Kind = MeasureMode.Edge,
|
Kind = MeasureMode.Edge,
|
||||||
TitleFor = u => $"{label} L = {Units.L(len, u)}",
|
TitleFor = u => $"{label} L = {Units.L(len, u)}",
|
||||||
DetailFor = u => $"曲線長 = {Units.L(len, u)}\n類型 = {typeName}",
|
DetailFor = u => Format("Runtime.CurveDetail", Units.L(len, u), typeName),
|
||||||
};
|
};
|
||||||
r.Overlays.Add(Polyline(curve, HighlightColor, 3));
|
r.Overlays.Add(Polyline(curve, HighlightColor, 3));
|
||||||
AddLabel(r, ToP3(curve.PointAt(0.5)) + new Vector3D(0, 0, markerR * 2), u => $"L={Units.L(len, u)}");
|
AddLabel(r, ToP3(curve.PointAt(0.5)) + new Vector3D(0, 0, markerR * 2), u => $"L={Units.L(len, u)}");
|
||||||
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
|
||||||
? DescribeSurface(fi.BrepFace.Surface)
|
? DescribeSurface(fi.BrepFace.Surface)
|
||||||
: _ => "類型 = 網格(無 B-rep)";
|
: _ => Get("Runtime.MeshSurface");
|
||||||
|
|
||||||
var r = new MeasurementResult
|
var r = new MeasurementResult
|
||||||
{
|
{
|
||||||
Kind = MeasureMode.Face,
|
Kind = MeasureMode.Face,
|
||||||
TitleFor = u => $"{label} A ≈ {Units.A(area, u)}",
|
TitleFor = u => $"{label} A ≈ {Units.A(area, u)}",
|
||||||
DetailFor = u => $"面積 ≈ {Units.A(area, u)}(網格近似)\n{surfaceDesc(u)}\n三角形數 = {triCount:N0}",
|
DetailFor = u => Format("Runtime.FaceDetail", Units.A(area, u), surfaceDesc(u), triCount),
|
||||||
};
|
};
|
||||||
|
|
||||||
// overlay:外輪廓 highlight + 標籤
|
// Highlight the outer edges and add a label.
|
||||||
if (fi.BrepFace is not null)
|
if (fi.BrepFace is not null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -302,35 +296,33 @@ public class MeasurementService
|
|||||||
case PlaneSurface ps:
|
case PlaneSurface ps:
|
||||||
{
|
{
|
||||||
GeoVector n = ps.Normal.Normalized;
|
GeoVector n = ps.Normal.Normalized;
|
||||||
string desc = $"類型 = 平面\n法向量 ({n.x:F3}, {n.y:F3}, {n.z:F3})";
|
return _ => Format("Runtime.PlaneSurface", n.x, n.y, n.z);
|
||||||
return _ => desc;
|
|
||||||
}
|
}
|
||||||
case CylindricalSurface cs:
|
case CylindricalSurface cs:
|
||||||
{
|
{
|
||||||
GeoVector ax = cs.Axis.Normalized;
|
GeoVector ax = cs.Axis.Normalized;
|
||||||
double radius = cs.RadiusX;
|
double radius = cs.RadiusX;
|
||||||
return u => $"類型 = 圓柱面\n半徑 = {Units.L(radius, u)}\n軸向 ({ax.x:F3}, {ax.y:F3}, {ax.z:F3})";
|
return u => Format("Runtime.CylinderSurface", Units.L(radius, u), ax.x, ax.y, ax.z);
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
{
|
{
|
||||||
string desc = $"類型 = {SurfaceTypeName(surface)}";
|
return _ => Format("Runtime.SurfaceType", SurfaceTypeName(surface));
|
||||||
return _ => desc;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string SurfaceTypeName(ISurface s) => s.GetType().Name switch
|
private static string SurfaceTypeName(ISurface s) => s.GetType().Name switch
|
||||||
{
|
{
|
||||||
"SphericalSurface" => "球面",
|
"SphericalSurface" => Get("Runtime.SphericalSurface"),
|
||||||
"ToroidalSurface" => "環面",
|
"ToroidalSurface" => Get("Runtime.ToroidalSurface"),
|
||||||
"ConicalSurface" => "圓錐面",
|
"ConicalSurface" => Get("Runtime.ConicalSurface"),
|
||||||
"NurbsSurface" => "NURBS 曲面",
|
"NurbsSurface" => Get("Runtime.NurbsSurface"),
|
||||||
"SurfaceOfRevolution" => "旋轉曲面",
|
"SurfaceOfRevolution" => Get("Runtime.RevolutionSurface"),
|
||||||
"SurfaceOfLinearExtrusion" => "拉伸曲面",
|
"SurfaceOfLinearExtrusion" => Get("Runtime.ExtrusionSurface"),
|
||||||
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)
|
||||||
{
|
{
|
||||||
@@ -343,10 +335,7 @@ public class MeasurementService
|
|||||||
{
|
{
|
||||||
Kind = MeasureMode.Angle,
|
Kind = MeasureMode.Angle,
|
||||||
TitleFor = _ => $"{label} ∠ {deg:F2}°",
|
TitleFor = _ => $"{label} ∠ {deg:F2}°",
|
||||||
DetailFor = _ => $"夾角 = {deg:F2}°(補角 {comp:F2}°)\n" +
|
DetailFor = _ => Format("Runtime.AngleDetail", deg, comp, Get(a.Desc), a.Direction.X, a.Direction.Y, a.Direction.Z, Get(b.Desc), b.Direction.X, b.Direction.Y, b.Direction.Z),
|
||||||
$"方向 1:{a.Desc} ({a.Direction.X:F3}, {a.Direction.Y:F3}, {a.Direction.Z:F3})\n" +
|
|
||||||
$"方向 2:{b.Desc} ({b.Direction.X:F3}, {b.Direction.Y:F3}, {b.Direction.Z:F3})\n" +
|
|
||||||
"(兩面夾角以法向量計,互補角請參考補角值)",
|
|
||||||
};
|
};
|
||||||
r.Overlays.Add(Sphere(a.At, markerR));
|
r.Overlays.Add(Sphere(a.At, markerR));
|
||||||
r.Overlays.Add(Sphere(b.At, markerR));
|
r.Overlays.Add(Sphere(b.At, markerR));
|
||||||
@@ -368,7 +357,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)
|
||||||
{
|
{
|
||||||
@@ -378,9 +367,7 @@ public class MeasurementService
|
|||||||
{
|
{
|
||||||
Kind = MeasureMode.FaceDistance,
|
Kind = MeasureMode.FaceDistance,
|
||||||
TitleFor = u => $"{label} min = {Units.L(d.Length, u)}",
|
TitleFor = u => $"{label} min = {Units.L(d.Length, u)}",
|
||||||
DetailFor = u => $"最短距離 ≈ {Units.L(d.Length, u)}(網格近似)\n" +
|
DetailFor = u => Format("Runtime.FaceDistanceDetail", Units.L(d.Length, u), Units.L(d.X, u), Units.L(d.Y, u), Units.L(d.Z, u), Units.P(pa, u), Units.P(pb, u)),
|
||||||
$"ΔX = {Units.L(d.X, u)}\nΔY = {Units.L(d.Y, u)}\nΔZ = {Units.L(d.Z, u)}\n" +
|
|
||||||
$"點 1 {Units.P(pa, u)}\n點 2 {Units.P(pb, u)}",
|
|
||||||
};
|
};
|
||||||
r.Overlays.Add(Sphere(pa, markerR));
|
r.Overlays.Add(Sphere(pa, markerR));
|
||||||
r.Overlays.Add(Sphere(pb, markerR));
|
r.Overlays.Add(Sphere(pb, markerR));
|
||||||
@@ -394,7 +381,7 @@ public class MeasurementService
|
|||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>兩網格最近點對:頂點→三角形雙向(頂點過多時抽樣,結果為近似值)</summary>
|
/// <summary>Approximates the closest points by probing vertices against triangles in both directions.</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;
|
||||||
@@ -427,19 +414,18 @@ public class MeasurementService
|
|||||||
return (pa, pb);
|
return (pa, pb);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 體積 / 質心(封閉網格 signed volume)────────────────────
|
// Volume and centroid
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 網格體積與質心:各三角形與原點構成的四面體有向體積加總(divergence theorem)。
|
/// Computes signed volume and centroid from origin-based tetrahedra.
|
||||||
/// 只對「封閉」網格有意義(開放殼會得到不可靠的值 → IsClosedReliable=false 供呼叫端提示)。
|
/// The result is reliable only for closed meshes.
|
||||||
/// 多個 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; // Σ 6×有向體積
|
double vol6 = 0;
|
||||||
double absVol6 = 0; // Σ |6×有向體積|(封閉性 sanity check 用)
|
double absVol6 = 0;
|
||||||
double cx = 0, cy = 0, cz = 0; // Σ 四面體質心 × 6×有向體積
|
double cx = 0, cy = 0, cz = 0;
|
||||||
foreach (MeshGeometry3D mesh in meshes)
|
foreach (MeshGeometry3D mesh in meshes)
|
||||||
{
|
{
|
||||||
Point3DCollection pos = mesh.Positions;
|
Point3DCollection pos = mesh.Positions;
|
||||||
@@ -453,14 +439,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);
|
||||||
// 四面體(a,b,c,原點)質心 = (a+b+c)/4
|
// Tetrahedron centroid = (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)
|
||||||
@@ -468,7 +454,7 @@ public class MeasurementService
|
|||||||
return (volume, centroid, reliable);
|
return (volume, centroid, reliable);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 點到三角形最近點:共用 InterferenceService.ClosestPointOnTriangle(v0.4.0 去重複)
|
// Shares the triangle closest-point calculation with InterferenceService.
|
||||||
|
|
||||||
// ─── overlay helpers ─────────────────────────────────────────
|
// ─── overlay helpers ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -4,15 +4,14 @@ using System.Windows.Media.Media3D;
|
|||||||
namespace STPViewer.Services;
|
namespace STPViewer.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 三點對齊 / 旋轉的剛體變換數學。
|
/// Rigid transformations for three-point alignment and rotation.
|
||||||
/// 同一個變換需要兩種表示:WPF <see cref="Matrix3D"/>(列向量約定,給網格/邊線)與
|
/// <see cref="ToModOp"/> converts WPF <see cref="Matrix3D"/> to CADability's matrix convention.
|
||||||
/// CADability <see cref="CADability.ModOp"/>(行向量約定,給 B-rep Modify)— 用 <see cref="ToModOp"/> 轉換。
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class RigidAlign
|
public static class RigidAlign
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 來源 3 點 → 目標 3 點 的剛體變換(不縮放):
|
/// Computes a rigid transform from three source points to three target points.
|
||||||
/// p1→q1 精確貼合、p1p2 方向對齊 q1q2、三點平面對齊。共線/退化回傳 false。
|
/// Returns false for collinear or degenerate points.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool TryRigidTransform(
|
public static bool TryRigidTransform(
|
||||||
Point3D p1, Point3D p2, Point3D p3,
|
Point3D p1, Point3D p2, Point3D p3,
|
||||||
@@ -22,7 +21,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ᵀ(行向量約定);WPF Matrix3D 為列向量約定 → 轉置擺放
|
// R = Bq · Bpᵀ, transposed for Matrix3D's convention.
|
||||||
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;
|
||||||
@@ -47,7 +46,7 @@ public static class RigidAlign
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>WPF Matrix3D(列向量約定)→ CADability ModOp(行向量約定,3×4)</summary>
|
/// <summary>Converts a WPF matrix to a CADability ModOp.</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 },
|
||||||
@@ -58,7 +57,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>三點 → 正交座標架(x=1→2 方向、z=平面法向、y=z×x);共線回傳 false</summary>
|
/// <summary>Builds an orthogonal frame from three points; returns false for collinear points.</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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ using System.Windows.Media.Media3D;
|
|||||||
namespace STPViewer.Services;
|
namespace STPViewer.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 剖面:把三角網格 / 線段集用半空間裁切(保留 dot(p - p0, n) ≤ 0 的一側)。
|
/// Clips meshes and line segments to the half-space dot(p - p0, n) ≤ 0.
|
||||||
/// WPF 3D 沒有 GPU clip plane,採 CPU 裁切後置換 Geometry(原始 mesh 保留可還原)。
|
/// The original geometry remains available for restoration.
|
||||||
/// </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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 部分在保留側:Sutherland-Hodgman 裁出 3 或 4 邊形,扇形分割
|
// Clip the polygon and triangulate it as a fan.
|
||||||
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>裁切線段集(成對端點)</summary>
|
/// <summary>Clips paired line segment endpoints.</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();
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ using System.Text.Json;
|
|||||||
|
|
||||||
namespace STPViewer.Services;
|
namespace STPViewer.Services;
|
||||||
|
|
||||||
/// <summary>使用者設定(%LOCALAPPDATA%\STPViewer\settings.json)</summary>
|
/// <summary>User settings stored in %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;
|
||||||
@@ -14,10 +14,14 @@ public class AppSettings
|
|||||||
public double WindowTop { get; set; } = double.NaN;
|
public double WindowTop { get; set; } = double.NaN;
|
||||||
public bool WindowMaximized { get; set; }
|
public bool WindowMaximized { get; set; }
|
||||||
public bool UseInch { get; set; }
|
public bool UseInch { 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>
|
||||||
|
public List<string>? QuickBarKeys { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>設定讀寫:壞檔/缺檔一律回預設值,儲存失敗靜默(設定不值得讓程式閃退)</summary>
|
/// <summary>Loads defaults for missing or invalid settings and ignores save failures.</summary>
|
||||||
public static class SettingsService
|
public static class SettingsService
|
||||||
{
|
{
|
||||||
public const int MaxRecentFiles = 10;
|
public const int MaxRecentFiles = 10;
|
||||||
@@ -33,7 +37,7 @@ public static class SettingsService
|
|||||||
if (File.Exists(SettingsPath))
|
if (File.Exists(SettingsPath))
|
||||||
return JsonSerializer.Deserialize<AppSettings>(File.ReadAllText(SettingsPath)) ?? new AppSettings();
|
return JsonSerializer.Deserialize<AppSettings>(File.ReadAllText(SettingsPath)) ?? new AppSettings();
|
||||||
}
|
}
|
||||||
catch { /* 壞檔回預設 */ }
|
catch { }
|
||||||
return new AppSettings();
|
return new AppSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +49,6 @@ public static class SettingsService
|
|||||||
File.WriteAllText(SettingsPath,
|
File.WriteAllText(SettingsPath,
|
||||||
JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true }));
|
JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true }));
|
||||||
}
|
}
|
||||||
catch { /* 儲存失敗靜默 */ }
|
catch { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,28 +6,31 @@ 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; // CADability.GeoObject.Path(曲線)撞名
|
using IOPath = System.IO.Path;
|
||||||
|
using static STPViewer.Services.LocalizationService;
|
||||||
|
|
||||||
namespace STPViewer.Services;
|
namespace STPViewer.Services;
|
||||||
|
|
||||||
/// <summary>單一 B-rep Face 的三角網格(mesh 已 Freeze,可跨執行緒);STL 來源 BrepFace 為 null</summary>
|
/// <summary>A frozen triangle mesh for one B-rep face; BrepFace is null for STL.</summary>
|
||||||
public record ImportedFace(Face? BrepFace, MeshGeometry3D Mesh);
|
public record ImportedFace(Face? BrepFace, MeshGeometry3D Mesh);
|
||||||
|
|
||||||
/// <summary>匯入後的階層節點:group(裝配)或 leaf(零件幾何)</summary>
|
/// <summary>An imported assembly group or geometry leaf.</summary>
|
||||||
public class ImportedNode
|
public class ImportedNode
|
||||||
{
|
{
|
||||||
public string Name { get; set; } = "";
|
public string Name { get; set; } = "";
|
||||||
|
public string? NameKey { get; set; }
|
||||||
|
public int NameNumber { get; set; }
|
||||||
public List<ImportedNode> Children { get; } = new();
|
public List<ImportedNode> Children { get; } = new();
|
||||||
|
|
||||||
// ── leaf 幾何 ──
|
// Leaf geometry
|
||||||
public List<ImportedFace> Faces { get; } = new();
|
public List<ImportedFace> Faces { get; } = new();
|
||||||
public Point3DCollection? EdgeSegments { get; set; } // 輪廓線(成對端點,已 Freeze)
|
public Point3DCollection? EdgeSegments { get; set; } // Frozen paired endpoints.
|
||||||
public bool HasBrep { get; set; } = true; // STL / 純曲線 = false
|
public bool HasBrep { get; set; } = true; // False for STL and curves.
|
||||||
|
|
||||||
/// <summary>來源 B-rep 物件(Solid/Shell/Face/曲線)— 零件平移時整體 Modify 用</summary>
|
/// <summary>Source B-rep objects used for whole-part transforms.</summary>
|
||||||
public List<CADability.GeoObject.IGeoObject> SourceGeos { get; } = new();
|
public List<CADability.GeoObject.IGeoObject> SourceGeos { get; } = new();
|
||||||
|
|
||||||
// ── 統計(group 節點由 Aggregate 彙總)──
|
// Group totals are calculated by 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; }
|
||||||
@@ -39,29 +42,35 @@ public class ImportedNode
|
|||||||
public record ImportedFileData(string FilePath, ImportedNode Root);
|
public record ImportedFileData(string FilePath, ImportedNode Root);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// CAD 檔匯入(STEP / STL / DXF)。STEP 以 HierarchyToBlocks 還原裝配樹。
|
/// Imports STEP, STL and DXF files on a background thread.
|
||||||
/// 設計為在背景執行緒呼叫;回傳的 Freezable 全部已 Freeze。
|
/// Returned WPF Freezables are frozen.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class StepImportService
|
public class StepImportService
|
||||||
{
|
{
|
||||||
/// <summary>匯入階段回報(耗時分解),UI 可顯示於狀態列;null 則靜默</summary>
|
/// <summary>Import progress for the status bar.</summary>
|
||||||
public Action<string>? Progress { get; set; }
|
public Action<string>? Progress { get; set; }
|
||||||
|
public Action<Func<string>>? ProgressTextFor { get; set; }
|
||||||
|
|
||||||
private long _triTicks, _edgeTicks; // 三角化 / 邊取樣 累計耗時(TimeSpan ticks,避免逐面 ms 截斷歸零)
|
private void ReportProgress(Func<string> textFor)
|
||||||
|
{
|
||||||
|
Progress?.Invoke(textFor());
|
||||||
|
ProgressTextFor?.Invoke(textFor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long _triTicks, _edgeTicks; // Accumulated tessellation and edge sampling ticks.
|
||||||
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>延後的 leaf 幾何工作(三角化+邊取樣):樹走訪時收集,之後平行執行。
|
/// <summary>Geometry work queued per leaf, then run in parallel. Faces within a leaf share edges.</summary>
|
||||||
/// 平行粒度 = 整個 leaf(同 leaf 的面共用 Edge,不可面級平行)。</summary>
|
|
||||||
private readonly List<Action> _leafWork = new();
|
private readonly List<Action> _leafWork = new();
|
||||||
|
|
||||||
/// <summary>平行三角化偶發失敗的面(CADability 跨 leaf 仍有少量共享狀態)→ 平行結束後循序重試</summary>
|
/// <summary>Faces to retry sequentially after parallel tessellation.</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)
|
||||||
{
|
{
|
||||||
if (!File.Exists(filePath))
|
if (!File.Exists(filePath))
|
||||||
throw new FileNotFoundException("找不到檔案", filePath);
|
throw new FileNotFoundException(Get("Runtime.FileNotFound"), filePath);
|
||||||
|
|
||||||
_triTicks = _edgeTicks = 0;
|
_triTicks = _edgeTicks = 0;
|
||||||
_leafWork.Clear();
|
_leafWork.Clear();
|
||||||
@@ -73,21 +82,22 @@ public class StepImportService
|
|||||||
case ".stl": ImportStlFile(filePath, root); break;
|
case ".stl": ImportStlFile(filePath, root); break;
|
||||||
case ".dxf": ImportDxfFile(filePath, root); break;
|
case ".dxf": ImportDxfFile(filePath, root); break;
|
||||||
default:
|
default:
|
||||||
throw new InvalidDataException("不支援的格式(支援 .stp / .step / .stl / .dxf;CADability 無 IGES reader)");
|
throw new InvalidDataException(Get("Runtime.UnsupportedFormat"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_leafWork.Count > 0)
|
if (_leafWork.Count > 0)
|
||||||
{
|
{
|
||||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
Progress?.Invoke($"三角化 {_leafWork.Count} 個零件(平行)…");
|
int leafCount = _leafWork.Count;
|
||||||
|
ReportProgress(() => Format("Runtime.Triangulating", leafCount));
|
||||||
System.Threading.Tasks.Parallel.ForEach(_leafWork, w =>
|
System.Threading.Tasks.Parallel.ForEach(_leafWork, w =>
|
||||||
{
|
{
|
||||||
try { w(); }
|
try { w(); }
|
||||||
catch { /* 個別零件失敗不讓整檔失敗 */ }
|
catch { }
|
||||||
});
|
});
|
||||||
_leafWork.Clear();
|
_leafWork.Clear();
|
||||||
|
|
||||||
// 平行下偶發失敗的面:競爭已結束,循序重試一輪(FinishLeaf 也延到此時才跑)
|
// Retry failed faces sequentially after the parallel pass.
|
||||||
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)
|
||||||
{
|
{
|
||||||
@@ -96,14 +106,16 @@ public class StepImportService
|
|||||||
FinishLeaf(leaf, edgeSet);
|
FinishLeaf(leaf, edgeSet);
|
||||||
}
|
}
|
||||||
_retry.Clear();
|
_retry.Clear();
|
||||||
Progress?.Invoke($"幾何處理 {sw.ElapsedMilliseconds:N0} ms" +
|
long elapsed = sw.ElapsedMilliseconds;
|
||||||
$"(三角化 {TriMs:N0} ms、邊取樣 {EdgeMs:N0} ms{(retried > 0 ? $"、重試補回 {retried} 面" : "")})");
|
int retryCount = retried;
|
||||||
|
ReportProgress(() => Format("Runtime.GeometryProcessed", elapsed, TriMs, EdgeMs,
|
||||||
|
retryCount > 0 ? Format("Runtime.RetriedFaces", retryCount) : ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
Prune(root);
|
Prune(root);
|
||||||
Aggregate(root);
|
Aggregate(root);
|
||||||
if (root.SolidCount == 0 && root.FaceCount == 0 && CountSegments(root) == 0)
|
if (root.SolidCount == 0 && root.FaceCount == 0 && CountSegments(root) == 0)
|
||||||
throw new InvalidDataException("檔案內沒有可顯示的幾何");
|
throw new InvalidDataException(Get("Runtime.NoGeometry"));
|
||||||
return new ImportedFileData(filePath, root);
|
return new ImportedFileData(filePath, root);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,69 +126,65 @@ public class StepImportService
|
|||||||
|
|
||||||
private void ImportStepFile(string path, ImportedNode root)
|
private void ImportStepFile(string path, ImportedNode root)
|
||||||
{
|
{
|
||||||
Progress?.Invoke("解析 STEP(大檔可能需數分鐘)…");
|
ReportProgress(() => Get("Runtime.ParsingStep"));
|
||||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
var importer = new ImportStep { HierarchyToBlocks = true };
|
var importer = new ImportStep { HierarchyToBlocks = true };
|
||||||
GeoObjectList list = importer.Read(path)
|
GeoObjectList list = importer.Read(path)
|
||||||
?? throw new InvalidDataException("CADability 無法解析此 STEP 檔");
|
?? throw new InvalidDataException(Get("Runtime.StepParseFailed"));
|
||||||
Progress?.Invoke($"STEP 解析 {sw.ElapsedMilliseconds:N0} ms");
|
long elapsed = sw.ElapsedMilliseconds;
|
||||||
|
ReportProgress(() => Format("Runtime.StepParsed", elapsed));
|
||||||
BuildChildren(root, EnumerateGeo(list));
|
BuildChildren(root, EnumerateGeo(list));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── STL(純網格,無 B-rep;整個 shell 合併成單一 mesh 避免模型數爆炸)──
|
// STL is read directly as mesh geometry without B-rep objects.
|
||||||
|
|
||||||
private void ImportStlFile(string path, ImportedNode root)
|
private void ImportStlFile(string path, ImportedNode root)
|
||||||
{
|
{
|
||||||
Shell[] shells = new ImportSTL().Read(path)
|
ReportProgress(() => Get("Runtime.ParsingStl"));
|
||||||
?? throw new InvalidDataException("無法解析 STL 檔");
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
int i = 1;
|
List<StlMeshReader.MeshGroup> groups = StlMeshReader.Read(path);
|
||||||
foreach (Shell sh in shells)
|
if (groups.Count == 0)
|
||||||
{
|
throw new InvalidDataException(Get("Runtime.StlNoTriangles"));
|
||||||
var positions = new Point3DCollection();
|
|
||||||
var indices = new Int32Collection();
|
|
||||||
double precision = PrecisionFor(SafeBounds(() => sh.GetBoundingCube()));
|
|
||||||
foreach (Face f in sh.Faces)
|
|
||||||
{
|
|
||||||
GeoPoint[] pts; int[] ind;
|
|
||||||
try { f.GetTriangulation(precision, out pts, out _, out ind, out _); }
|
|
||||||
catch { continue; }
|
|
||||||
if (pts is null || ind is null) continue;
|
|
||||||
int offset = positions.Count;
|
|
||||||
foreach (GeoPoint p in pts) positions.Add(new Point3D(p.x, p.y, p.z));
|
|
||||||
foreach (int k in ind) indices.Add(offset + k);
|
|
||||||
}
|
|
||||||
if (positions.Count == 0) continue;
|
|
||||||
|
|
||||||
var mesh = new MeshGeometry3D { Positions = positions, TriangleIndices = indices };
|
foreach (StlMeshReader.MeshGroup g in groups)
|
||||||
|
{
|
||||||
|
if (g.Positions.Count == 0) continue;
|
||||||
|
g.Positions.Freeze();
|
||||||
|
g.Indices.Freeze();
|
||||||
|
var mesh = new MeshGeometry3D { Positions = g.Positions, TriangleIndices = g.Indices };
|
||||||
mesh.Freeze();
|
mesh.Freeze();
|
||||||
|
int tris = g.Indices.Count / 3;
|
||||||
var leaf = new ImportedNode
|
var leaf = new ImportedNode
|
||||||
{
|
{
|
||||||
Name = shells.Length == 1 ? "網格" : $"網格 {i}",
|
Name = g.Name,
|
||||||
|
NameKey = g.NameKey,
|
||||||
|
NameNumber = g.NameNumber,
|
||||||
HasBrep = false,
|
HasBrep = false,
|
||||||
SolidCount = 1,
|
SolidCount = 1,
|
||||||
FaceCount = sh.Faces.Length,
|
FaceCount = tris,
|
||||||
TriangleCount = indices.Count / 3,
|
TriangleCount = tris,
|
||||||
Bounds = BoundsOfMesh(mesh),
|
Bounds = BoundsOfMesh(mesh),
|
||||||
};
|
};
|
||||||
leaf.SourceGeos.Add(sh);
|
|
||||||
leaf.Faces.Add(new ImportedFace(null, mesh));
|
leaf.Faces.Add(new ImportedFace(null, mesh));
|
||||||
root.Children.Add(leaf);
|
root.Children.Add(leaf);
|
||||||
i++;
|
|
||||||
}
|
}
|
||||||
|
long elapsed = sw.ElapsedMilliseconds;
|
||||||
|
int meshCount = root.Children.Count;
|
||||||
|
ReportProgress(() => Format("Runtime.StlParsed", elapsed, meshCount));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── DXF(多為線架構;Solid/Face 走同一套,曲線取樣成線段)──
|
// DXF
|
||||||
|
|
||||||
private void ImportDxfFile(string path, ImportedNode root)
|
private void ImportDxfFile(string path, ImportedNode root)
|
||||||
{
|
{
|
||||||
var import = new CADability.DXF.Import(path);
|
var import = new CADability.DXF.Import(path);
|
||||||
Project project = import.Project
|
Project project = import.Project
|
||||||
?? throw new InvalidDataException("無法解析 DXF 檔");
|
?? throw new InvalidDataException(Get("Runtime.DxfParseFailed"));
|
||||||
Model model = project.GetActiveModel();
|
Model model = project.GetActiveModel();
|
||||||
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)
|
||||||
{
|
{
|
||||||
@@ -193,7 +201,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>(); // 皆為 ICurve
|
var looseCurves = new List<IGeoObject>();
|
||||||
|
|
||||||
foreach (IGeoObject go in objects)
|
foreach (IGeoObject go in objects)
|
||||||
{
|
{
|
||||||
@@ -223,7 +231,7 @@ public class StepImportService
|
|||||||
|
|
||||||
if (looseFaces.Count > 0)
|
if (looseFaces.Count > 0)
|
||||||
{
|
{
|
||||||
var leaf = new ImportedNode { Name = "面" };
|
var leaf = new ImportedNode { Name = Get("Runtime.FaceName"), NameKey = "Runtime.FaceName" };
|
||||||
leaf.SourceGeos.AddRange(looseFaces);
|
leaf.SourceGeos.AddRange(looseFaces);
|
||||||
_leafWork.Add(() =>
|
_leafWork.Add(() =>
|
||||||
{
|
{
|
||||||
@@ -237,7 +245,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); // 三角化延後,空 leaf 由 Prune 收掉
|
parent.Children.Add(leaf);
|
||||||
}
|
}
|
||||||
if (looseCurves.Count > 0)
|
if (looseCurves.Count > 0)
|
||||||
{
|
{
|
||||||
@@ -247,7 +255,8 @@ public class StepImportService
|
|||||||
segs.Freeze();
|
segs.Freeze();
|
||||||
var leaf = new ImportedNode
|
var leaf = new ImportedNode
|
||||||
{
|
{
|
||||||
Name = "曲線",
|
Name = Get("Runtime.CurveName"),
|
||||||
|
NameKey = "Runtime.CurveName",
|
||||||
HasBrep = false,
|
HasBrep = false,
|
||||||
EdgeSegments = segs,
|
EdgeSegments = segs,
|
||||||
Bounds = BoundsOfPoints(segs),
|
Bounds = BoundsOfPoints(segs),
|
||||||
@@ -260,28 +269,39 @@ public class StepImportService
|
|||||||
|
|
||||||
private ImportedNode? BlockNode(Block block)
|
private ImportedNode? BlockNode(Block block)
|
||||||
{
|
{
|
||||||
// Block 只有單一同名 Solid → 折疊成 leaf(STEP 常見的 product→solid 包裝)
|
// Collapse a block containing one solid into a leaf.
|
||||||
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);
|
||||||
if (!string.IsNullOrWhiteSpace(block.Name)) leaf.Name = block.Name;
|
if (!string.IsNullOrWhiteSpace(block.Name))
|
||||||
|
{
|
||||||
|
leaf.Name = block.Name;
|
||||||
|
leaf.NameKey = null;
|
||||||
|
leaf.NameNumber = 0;
|
||||||
|
}
|
||||||
return leaf;
|
return leaf;
|
||||||
}
|
}
|
||||||
|
|
||||||
var node = new ImportedNode
|
var node = new ImportedNode
|
||||||
{
|
{
|
||||||
Name = string.IsNullOrWhiteSpace(block.Name) ? "組件" : block.Name,
|
Name = string.IsNullOrWhiteSpace(block.Name) ? Get("Runtime.ComponentName") : block.Name,
|
||||||
|
NameKey = string.IsNullOrWhiteSpace(block.Name) ? "Runtime.ComponentName" : null,
|
||||||
};
|
};
|
||||||
BuildChildren(node, ChildrenOf(block));
|
BuildChildren(node, ChildrenOf(block));
|
||||||
if (node.Children.Count == 0) return null;
|
if (node.Children.Count == 0) return null;
|
||||||
|
|
||||||
// 單鏈折疊:group 只有一個子節點且名稱相同/空白 → 去掉中間層
|
// Drop a redundant group when it has one child with the same name.
|
||||||
if (node.Children.Count == 1)
|
if (node.Children.Count == 1)
|
||||||
{
|
{
|
||||||
ImportedNode only = node.Children[0];
|
ImportedNode only = node.Children[0];
|
||||||
if (string.IsNullOrWhiteSpace(only.Name) || only.Name == node.Name)
|
if (string.IsNullOrWhiteSpace(only.Name) || only.Name == node.Name)
|
||||||
{
|
{
|
||||||
only.Name = node.Name;
|
if (node.NameKey is null || only.NameKey is not null || string.IsNullOrWhiteSpace(only.Name))
|
||||||
|
{
|
||||||
|
only.Name = node.Name;
|
||||||
|
only.NameKey = node.NameKey;
|
||||||
|
only.NameNumber = node.NameNumber;
|
||||||
|
}
|
||||||
return only;
|
return only;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -292,7 +312,8 @@ public class StepImportService
|
|||||||
{
|
{
|
||||||
var leaf = new ImportedNode
|
var leaf = new ImportedNode
|
||||||
{
|
{
|
||||||
Name = string.IsNullOrWhiteSpace(solid.NameOrEmpty) ? "Solid" : solid.NameOrEmpty,
|
Name = string.IsNullOrWhiteSpace(solid.NameOrEmpty) ? Get("Runtime.SolidName") : solid.NameOrEmpty,
|
||||||
|
NameKey = string.IsNullOrWhiteSpace(solid.NameOrEmpty) ? "Runtime.SolidName" : null,
|
||||||
SolidCount = 1,
|
SolidCount = 1,
|
||||||
};
|
};
|
||||||
leaf.SourceGeos.Add(solid);
|
leaf.SourceGeos.Add(solid);
|
||||||
@@ -316,7 +337,8 @@ public class StepImportService
|
|||||||
{
|
{
|
||||||
var leaf = new ImportedNode
|
var leaf = new ImportedNode
|
||||||
{
|
{
|
||||||
Name = string.IsNullOrWhiteSpace(shell.NameOrEmpty) ? "Shell" : shell.NameOrEmpty,
|
Name = string.IsNullOrWhiteSpace(shell.NameOrEmpty) ? Get("Runtime.ShellName") : shell.NameOrEmpty,
|
||||||
|
NameKey = string.IsNullOrWhiteSpace(shell.NameOrEmpty) ? "Runtime.ShellName" : null,
|
||||||
};
|
};
|
||||||
leaf.SourceGeos.Add(shell);
|
leaf.SourceGeos.Add(shell);
|
||||||
_leafWork.Add(() =>
|
_leafWork.Add(() =>
|
||||||
@@ -332,7 +354,7 @@ public class StepImportService
|
|||||||
return leaf;
|
return leaf;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 幾何處理 ────────────────────────────────────────────────
|
// Geometry processing
|
||||||
|
|
||||||
private static BoundingCube SafeBounds(Func<BoundingCube> get)
|
private static BoundingCube SafeBounds(Func<BoundingCube> get)
|
||||||
{
|
{
|
||||||
@@ -340,7 +362,7 @@ public class StepImportService
|
|||||||
catch { return new BoundingCube(); }
|
catch { return new BoundingCube(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>三角化精度依物件大小調整:對角線 × 0.0015,限制在 [0.02, 0.5] mm</summary>
|
/// <summary>Tessellation precision: bounding diagonal × 0.0015, clamped to [0.02, 0.5] mm.</summary>
|
||||||
private static double PrecisionFor(BoundingCube bc)
|
private static double PrecisionFor(BoundingCube bc)
|
||||||
{
|
{
|
||||||
double diag;
|
double diag;
|
||||||
@@ -350,7 +372,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 = 三角化失敗(平行下偶發,可循序重試)</returns>
|
/// <returns>False when tessellation fails and the face needs a retry.</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;
|
||||||
@@ -361,7 +383,7 @@ public class StepImportService
|
|||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
return false; // 個別面三角化失敗時跳過,不讓整檔失敗
|
return false;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -385,11 +407,11 @@ public class StepImportService
|
|||||||
{
|
{
|
||||||
foreach (Edge e in face.AllEdges) edgeSet.Add(e);
|
foreach (Edge e in face.AllEdges) edgeSet.Add(e);
|
||||||
}
|
}
|
||||||
catch { /* 邊收集失敗不影響面渲染 */ }
|
catch { }
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>leaf 收尾:邊取樣 + 統計 + 邊界</summary>
|
/// <summary>Completes edge sampling, counts and bounds for one 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();
|
||||||
@@ -426,7 +448,7 @@ public class StepImportService
|
|||||||
return segments;
|
return segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>把曲線取樣成線段對(直線 1 段、曲線 12 段)</summary>
|
/// <summary>Samples curves into paired line endpoints.</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;
|
||||||
@@ -441,7 +463,7 @@ public class StepImportService
|
|||||||
prev = cur;
|
prev = cur;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch { /* 個別曲線取樣失敗跳過 */ }
|
catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Rect3D BoundsOfMesh(MeshGeometry3D mesh) => BoundsOfPoints(mesh.Positions);
|
private static Rect3D BoundsOfMesh(MeshGeometry3D mesh) => BoundsOfPoints(mesh.Positions);
|
||||||
@@ -461,7 +483,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>移除沒有任何幾何的節點(延後三角化全失敗的 leaf、因此變空的 group)</summary>
|
/// <summary>Removes nodes left empty after tessellation.</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--)
|
||||||
@@ -473,7 +495,7 @@ public class StepImportService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>group 節點統計彙總(bottom-up)</summary>
|
/// <summary>Aggregates counts and bounds from children.</summary>
|
||||||
private static void Aggregate(ImportedNode node)
|
private static void Aggregate(ImportedNode node)
|
||||||
{
|
{
|
||||||
if (node.IsLeaf) return;
|
if (node.IsLeaf) return;
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Media3D;
|
||||||
|
using CADability;
|
||||||
|
using CADability.GeoObject;
|
||||||
|
|
||||||
|
namespace STPViewer.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mesh quality: Current uses the displayed mesh; Fine retessellates B-rep geometry.
|
||||||
|
/// </summary>
|
||||||
|
public enum StlMeshQuality { Current, Fine }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes WPF triangle meshes as binary or ASCII STL; scale=1 uses millimeters.
|
||||||
|
/// </summary>
|
||||||
|
public static class StlExportService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Tessellation factor relative to import precision; 0.15 produces a finer mesh.
|
||||||
|
/// </summary>
|
||||||
|
public static double PrecisionFactor(StlMeshQuality q) =>
|
||||||
|
q == StlMeshQuality.Fine ? 0.15 : 1.0;
|
||||||
|
|
||||||
|
/// <summary>Estimated file size: 84 + 50 bytes per binary triangle, about 230 per ASCII triangle.</summary>
|
||||||
|
public static long EstimateBytes(long triangles, bool ascii) =>
|
||||||
|
ascii ? 20 + triangles * 230 : 84 + triangles * 50;
|
||||||
|
|
||||||
|
public static string FormatBytes(long bytes) => bytes switch
|
||||||
|
{
|
||||||
|
>= 1L << 30 => $"{bytes / (double)(1L << 30):F1} GB",
|
||||||
|
>= 1L << 20 => $"{bytes / (double)(1L << 20):F1} MB",
|
||||||
|
>= 1L << 10 => $"{bytes / (double)(1L << 10):F1} KB",
|
||||||
|
_ => $"{bytes} B",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes meshes to one STL file, skipping zero-area triangles. Returns the triangle count.
|
||||||
|
/// </summary>
|
||||||
|
public static int Write(string path, IReadOnlyList<MeshGeometry3D> meshes, bool ascii, double scale) =>
|
||||||
|
ascii ? WriteAscii(path, meshes, scale) : WriteBinary(path, meshes, scale);
|
||||||
|
|
||||||
|
private static int WriteBinary(string path, IReadOnlyList<MeshGeometry3D> meshes, double scale)
|
||||||
|
{
|
||||||
|
using var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, 1 << 16);
|
||||||
|
using var bw = new BinaryWriter(fs);
|
||||||
|
|
||||||
|
// Avoid "solid" in the header; some readers would mistake it for ASCII.
|
||||||
|
var header = new byte[80];
|
||||||
|
Encoding.ASCII.GetBytes("STPViewer binary STL (unit: mm x scale)").CopyTo(header, 0);
|
||||||
|
bw.Write(header);
|
||||||
|
bw.Write(0u); // Backfilled after writing triangles.
|
||||||
|
|
||||||
|
int count = 0;
|
||||||
|
foreach (MeshGeometry3D mesh in meshes)
|
||||||
|
{
|
||||||
|
var pos = mesh.Positions;
|
||||||
|
var idx = mesh.TriangleIndices;
|
||||||
|
for (int i = 0; i + 2 < idx.Count; i += 3)
|
||||||
|
{
|
||||||
|
Point3D a = pos[idx[i]], b = pos[idx[i + 1]], c = pos[idx[i + 2]];
|
||||||
|
if (!TryNormal(a, b, c, out Vector3D n)) continue;
|
||||||
|
bw.Write((float)n.X); bw.Write((float)n.Y); bw.Write((float)n.Z);
|
||||||
|
WriteVertex(bw, a, scale);
|
||||||
|
WriteVertex(bw, b, scale);
|
||||||
|
WriteVertex(bw, c, scale);
|
||||||
|
bw.Write((ushort)0); // attribute byte count
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.Seek(80, SeekOrigin.Begin);
|
||||||
|
bw.Write((uint)count);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteVertex(BinaryWriter bw, Point3D p, double scale)
|
||||||
|
{
|
||||||
|
bw.Write((float)(p.X * scale));
|
||||||
|
bw.Write((float)(p.Y * scale));
|
||||||
|
bw.Write((float)(p.Z * scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int WriteAscii(string path, IReadOnlyList<MeshGeometry3D> meshes, double scale)
|
||||||
|
{
|
||||||
|
var inv = CultureInfo.InvariantCulture;
|
||||||
|
using var sw = new StreamWriter(path, append: false, Encoding.ASCII, 1 << 16);
|
||||||
|
sw.WriteLine("solid STPViewer");
|
||||||
|
|
||||||
|
int count = 0;
|
||||||
|
foreach (MeshGeometry3D mesh in meshes)
|
||||||
|
{
|
||||||
|
var pos = mesh.Positions;
|
||||||
|
var idx = mesh.TriangleIndices;
|
||||||
|
for (int i = 0; i + 2 < idx.Count; i += 3)
|
||||||
|
{
|
||||||
|
Point3D a = pos[idx[i]], b = pos[idx[i + 1]], c = pos[idx[i + 2]];
|
||||||
|
if (!TryNormal(a, b, c, out Vector3D n)) continue;
|
||||||
|
sw.WriteLine(string.Format(inv, " facet normal {0:e6} {1:e6} {2:e6}", n.X, n.Y, n.Z));
|
||||||
|
sw.WriteLine(" outer loop");
|
||||||
|
WriteAsciiVertex(sw, a, scale, inv);
|
||||||
|
WriteAsciiVertex(sw, b, scale, inv);
|
||||||
|
WriteAsciiVertex(sw, c, scale, inv);
|
||||||
|
sw.WriteLine(" endloop");
|
||||||
|
sw.WriteLine(" endfacet");
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sw.WriteLine("endsolid STPViewer");
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteAsciiVertex(StreamWriter sw, Point3D p, double scale, CultureInfo inv) =>
|
||||||
|
sw.WriteLine(string.Format(inv, " vertex {0:e6} {1:e6} {2:e6}",
|
||||||
|
p.X * scale, p.Y * scale, p.Z * scale));
|
||||||
|
|
||||||
|
/// <returns>False for a zero-area triangle.</returns>
|
||||||
|
private static bool TryNormal(Point3D a, Point3D b, Point3D c, out Vector3D n)
|
||||||
|
{
|
||||||
|
n = Vector3D.CrossProduct(b - a, c - a);
|
||||||
|
double len = n.Length;
|
||||||
|
if (len < 1e-12) return false;
|
||||||
|
n /= len;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// B-rep retessellation
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retessellates B-rep geometry into frozen meshes, one per face.
|
||||||
|
/// Call sequentially because CADability tessellation is not thread-safe.
|
||||||
|
/// </summary>
|
||||||
|
public static List<MeshGeometry3D> Tessellate(IEnumerable<IGeoObject> geos, double precisionFactor)
|
||||||
|
{
|
||||||
|
var meshes = new List<MeshGeometry3D>();
|
||||||
|
foreach (IGeoObject g in geos)
|
||||||
|
{
|
||||||
|
switch (g)
|
||||||
|
{
|
||||||
|
case Solid solid:
|
||||||
|
foreach (Shell sh in solid.Shells) TessellateShell(sh, precisionFactor, meshes);
|
||||||
|
break;
|
||||||
|
case Shell shell:
|
||||||
|
TessellateShell(shell, precisionFactor, meshes);
|
||||||
|
break;
|
||||||
|
case Face face:
|
||||||
|
AddFaceMesh(face, PrecisionFor(SafeBounds(face), precisionFactor), meshes);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return meshes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TessellateShell(Shell shell, double factor, List<MeshGeometry3D> meshes)
|
||||||
|
{
|
||||||
|
double precision = PrecisionFor(SafeBounds(shell), factor);
|
||||||
|
foreach (Face f in shell.Faces) AddFaceMesh(f, precision, meshes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clamp the import precision before applying factor so Fine is finer than the cached mesh.
|
||||||
|
/// </summary>
|
||||||
|
private static double PrecisionFor(BoundingCube bc, double factor)
|
||||||
|
{
|
||||||
|
double diag;
|
||||||
|
try { diag = bc.DiagonalLength; }
|
||||||
|
catch { diag = 100; }
|
||||||
|
if (diag <= 0 || double.IsNaN(diag)) diag = 100;
|
||||||
|
double importDefault = Math.Clamp(diag * 0.0015, 0.02, 0.5);
|
||||||
|
return Math.Max(importDefault * factor, 0.005);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BoundingCube SafeBounds(IGeoObject g)
|
||||||
|
{
|
||||||
|
try { return g.GetBoundingCube(); }
|
||||||
|
catch { return new BoundingCube(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddFaceMesh(Face face, double precision, List<MeshGeometry3D> meshes)
|
||||||
|
{
|
||||||
|
GeoPoint[] pts; int[] ind;
|
||||||
|
try { face.GetTriangulation(precision, out pts, out _, out ind, out _); }
|
||||||
|
catch { return; }
|
||||||
|
if (pts is null || ind is null || ind.Length < 3) return;
|
||||||
|
|
||||||
|
var positions = new Point3DCollection(pts.Length);
|
||||||
|
foreach (GeoPoint p in pts) positions.Add(new Point3D(p.x, p.y, p.z));
|
||||||
|
var indices = new Int32Collection(ind.Length);
|
||||||
|
foreach (int i in ind) indices.Add(i);
|
||||||
|
|
||||||
|
var mesh = new MeshGeometry3D { Positions = positions, TriangleIndices = indices };
|
||||||
|
mesh.Freeze();
|
||||||
|
meshes.Add(mesh);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Media3D;
|
||||||
|
|
||||||
|
namespace STPViewer.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads binary and ASCII STL triangles directly into WPF meshes.
|
||||||
|
/// </summary>
|
||||||
|
public static class StlMeshReader
|
||||||
|
{
|
||||||
|
/// <summary>Mesh for one solid section; vertices remain unwelded.</summary>
|
||||||
|
public record MeshGroup(string Name, Point3DCollection Positions, Int32Collection Indices,
|
||||||
|
string? NameKey = null, int NameNumber = 0);
|
||||||
|
|
||||||
|
public static List<MeshGroup> Read(string path)
|
||||||
|
{
|
||||||
|
return IsBinary(path) ? ReadBinary(path) : ReadAscii(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Binary STL has exactly 84 + 50 × triangle count bytes; its header may also start with "solid".
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsBinary(string path)
|
||||||
|
{
|
||||||
|
var fi = new FileInfo(path);
|
||||||
|
if (fi.Length < 84) return false;
|
||||||
|
Span<byte> head = stackalloc byte[84];
|
||||||
|
using (FileStream fs = File.OpenRead(path))
|
||||||
|
{
|
||||||
|
int read = 0;
|
||||||
|
while (read < 84)
|
||||||
|
{
|
||||||
|
int n = fs.Read(head[read..]);
|
||||||
|
if (n <= 0) break;
|
||||||
|
read += n;
|
||||||
|
}
|
||||||
|
if (read < 84) return false;
|
||||||
|
}
|
||||||
|
uint count = BitConverter.ToUInt32(head[80..]);
|
||||||
|
return fi.Length == 84L + 50L * count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<MeshGroup> ReadBinary(string path)
|
||||||
|
{
|
||||||
|
byte[] bytes = File.ReadAllBytes(path);
|
||||||
|
uint count = BitConverter.ToUInt32(bytes, 80);
|
||||||
|
var positions = new Point3DCollection((int)Math.Min(count * 3, int.MaxValue));
|
||||||
|
var indices = new Int32Collection((int)Math.Min(count * 3, int.MaxValue));
|
||||||
|
|
||||||
|
var span = bytes.AsSpan();
|
||||||
|
int off = 84, vi = 0;
|
||||||
|
for (uint t = 0; t < count; t++)
|
||||||
|
{
|
||||||
|
if (off + 50 > bytes.Length) break;
|
||||||
|
off += 12; // WPF computes the normal.
|
||||||
|
for (int v = 0; v < 3; v++)
|
||||||
|
{
|
||||||
|
float x = BitConverter.ToSingle(span[off..]);
|
||||||
|
float y = BitConverter.ToSingle(span[(off + 4)..]);
|
||||||
|
float z = BitConverter.ToSingle(span[(off + 8)..]);
|
||||||
|
positions.Add(new Point3D(x, y, z));
|
||||||
|
indices.Add(vi++);
|
||||||
|
off += 12;
|
||||||
|
}
|
||||||
|
off += 2; // attribute byte count
|
||||||
|
}
|
||||||
|
return new List<MeshGroup> { new(LocalizationService.Get("Runtime.MeshName"), positions, indices, "Runtime.MeshName") };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ASCII STL: read vertex triples and start a new group at each solid section.
|
||||||
|
/// </summary>
|
||||||
|
private static List<MeshGroup> ReadAscii(string path)
|
||||||
|
{
|
||||||
|
var groups = new List<MeshGroup>();
|
||||||
|
Point3DCollection? pos = null;
|
||||||
|
Int32Collection? idx = null;
|
||||||
|
int vi = 0;
|
||||||
|
|
||||||
|
void FlushGroup()
|
||||||
|
{
|
||||||
|
if (pos is { Count: > 0 } && idx is not null)
|
||||||
|
groups.Add(new MeshGroup(groups.Count == 0 ? LocalizationService.Get("Runtime.MeshName") : LocalizationService.Format("Runtime.MeshNameNumbered", groups.Count + 1), pos, idx,
|
||||||
|
groups.Count == 0 ? "Runtime.MeshName" : "Runtime.MeshNameNumbered", groups.Count + 1));
|
||||||
|
pos = null; idx = null; vi = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
char[] sep = { ' ', '\t' };
|
||||||
|
foreach (string raw in File.ReadLines(path))
|
||||||
|
{
|
||||||
|
string line = raw.Trim();
|
||||||
|
if (line.Length == 0) continue;
|
||||||
|
|
||||||
|
if (line.StartsWith("solid", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
FlushGroup();
|
||||||
|
pos = new Point3DCollection();
|
||||||
|
idx = new Int32Collection();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.StartsWith("endsolid", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
FlushGroup();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!line.StartsWith("vertex", StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
|
|
||||||
|
pos ??= new Point3DCollection();
|
||||||
|
idx ??= new Int32Collection();
|
||||||
|
string[] tok = line.Split(sep, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (tok.Length < 4) continue;
|
||||||
|
if (double.TryParse(tok[1], NumberStyles.Float, CultureInfo.InvariantCulture, out double x) &&
|
||||||
|
double.TryParse(tok[2], NumberStyles.Float, CultureInfo.InvariantCulture, out double y) &&
|
||||||
|
double.TryParse(tok[3], NumberStyles.Float, CultureInfo.InvariantCulture, out double z))
|
||||||
|
{
|
||||||
|
pos.Add(new Point3D(x, y, z));
|
||||||
|
idx.Add(vi++);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FlushGroup();
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<Window x:Class="STPViewer.StlExportDialog"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="clr-namespace:STPViewer.ViewModels"
|
||||||
|
xmlns:loc="clr-namespace:STPViewer.Localization"
|
||||||
|
Title="{loc:Loc Ui.StlExport.Title}"
|
||||||
|
Width="520" SizeToContent="Height"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
ResizeMode="NoResize" ShowInTaskbar="False"
|
||||||
|
d:DataContext="{d:DesignInstance Type=vm:StlExportViewModel}"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d">
|
||||||
|
<Window.Resources>
|
||||||
|
<BooleanToVisibilityConverter x:Key="BoolToVis" />
|
||||||
|
</Window.Resources>
|
||||||
|
<Grid Margin="16">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<Grid.Resources>
|
||||||
|
<Style TargetType="TextBlock">
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
<Setter Property="Margin" Value="0,0,12,10" />
|
||||||
|
</Style>
|
||||||
|
<Style TargetType="ComboBox">
|
||||||
|
<Setter Property="Margin" Value="0,0,0,10" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
</Grid.Resources>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="0" Text="{loc:Loc Ui.StlExport.Scope}" />
|
||||||
|
<ComboBox Grid.Row="0" Grid.Column="1" SelectedIndex="{Binding ScopeIndex}"
|
||||||
|
ToolTip="{loc:Loc Ui.StlExport.ScopeTooltip}">
|
||||||
|
<ComboBoxItem Content="{loc:Loc Ui.StlExport.ScopeMerge}" />
|
||||||
|
<ComboBoxItem Content="{loc:Loc Ui.StlExport.ScopePerFile}" />
|
||||||
|
</ComboBox>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="0" Text="{loc:Loc Ui.StlExport.Format}" />
|
||||||
|
<ComboBox Grid.Row="1" Grid.Column="1" SelectedIndex="{Binding FormatIndex}">
|
||||||
|
<ComboBoxItem Content="{loc:Loc Ui.StlExport.FormatBinary}" />
|
||||||
|
<ComboBoxItem Content="{loc:Loc Ui.StlExport.FormatAscii}" />
|
||||||
|
</ComboBox>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="2" Grid.Column="0" Text="{loc:Loc Ui.StlExport.UnitScale}" />
|
||||||
|
<ComboBox Grid.Row="2" Grid.Column="1" SelectedIndex="{Binding UnitIndex}"
|
||||||
|
ToolTip="{loc:Loc Ui.StlExport.UnitTooltip}">
|
||||||
|
<ComboBoxItem Content="{loc:Loc Ui.StlExport.UnitMillimeter}" />
|
||||||
|
<ComboBoxItem Content="{loc:Loc Ui.StlExport.UnitInch}" />
|
||||||
|
<ComboBoxItem Content="{loc:Loc Ui.StlExport.UnitCustom}" />
|
||||||
|
</ComboBox>
|
||||||
|
|
||||||
|
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding CustomScaleText, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Margin="0,0,0,10" Padding="4,2"
|
||||||
|
Visibility="{Binding IsCustomScale, Converter={StaticResource BoolToVis}}"
|
||||||
|
ToolTip="{loc:Loc Ui.StlExport.CustomScaleTooltip}" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="4" Grid.Column="0" Text="{loc:Loc Ui.StlExport.MeshQuality}" />
|
||||||
|
<ComboBox Grid.Row="4" Grid.Column="1" SelectedIndex="{Binding QualityIndex}"
|
||||||
|
IsEnabled="{Binding HasBrep}"
|
||||||
|
ToolTip="{loc:Loc Ui.StlExport.QualityTooltip}">
|
||||||
|
<ComboBoxItem Content="{loc:Loc Ui.StlExport.QualityCurrent}" />
|
||||||
|
<ComboBoxItem Content="{loc:Loc Ui.StlExport.QualityFine}" />
|
||||||
|
</ComboBox>
|
||||||
|
|
||||||
|
<Border Grid.Row="5" Grid.ColumnSpan="2" Background="#F0F4F8" CornerRadius="4"
|
||||||
|
Padding="10,8" Margin="0,2,0,12">
|
||||||
|
<TextBlock Text="{Binding Summary}" TextWrapping="Wrap" Margin="0" FontSize="12" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="6" Grid.ColumnSpan="2" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||||
|
<Button Content="{loc:Loc Ui.StlExport.Cancel}" Width="80" Margin="0,0,8,0" IsCancel="True" />
|
||||||
|
<Button Content="{loc:Loc Ui.StlExport.Export}" Width="96" IsDefault="True"
|
||||||
|
IsEnabled="{Binding CanConfirm}" Click="Confirm_Click" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using STPViewer.ViewModels;
|
||||||
|
|
||||||
|
namespace STPViewer;
|
||||||
|
|
||||||
|
/// <summary>STL export settings dialog; the view model owns the options.</summary>
|
||||||
|
public partial class StlExportDialog : Window
|
||||||
|
{
|
||||||
|
public StlExportViewModel ViewModel { get; }
|
||||||
|
|
||||||
|
public StlExportDialog(StlExportViewModel vm)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
ViewModel = vm;
|
||||||
|
DataContext = vm;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Confirm_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
DialogResult = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,25 +2,26 @@ using System.Windows;
|
|||||||
using System.Windows.Media.Media3D;
|
using System.Windows.Media.Media3D;
|
||||||
using HelixToolkit.Wpf;
|
using HelixToolkit.Wpf;
|
||||||
using STPViewer.Models;
|
using STPViewer.Models;
|
||||||
|
using static STPViewer.Services.LocalizationService;
|
||||||
|
|
||||||
namespace STPViewer.ViewModels;
|
namespace STPViewer.ViewModels;
|
||||||
|
|
||||||
// ─── 拖曳模式(左鍵按住零件沿螢幕平面拖;放開才烘進 B-rep)──────────
|
// Drag mode
|
||||||
public partial class MainViewModel
|
public partial class MainViewModel
|
||||||
{
|
{
|
||||||
// 拖曳模式:暫時 Transform 跟著滑鼠,放開才一次性烘進 B-rep(TranslateRoot)
|
// Preview with a transform, then commit to B-rep on release.
|
||||||
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>左鍵按下:命中零件則開始拖曳(回傳 true 表示要捕捉滑鼠)</summary>
|
/// <summary>Starts dragging a hit part; returns true when the mouse should be captured.</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;
|
||||||
|
|
||||||
// 兩種渲染內容都可能在場:合併網格查 _mergedMap、逐面(剖面時)查 _faceMap
|
// Hits can come from merged meshes or section faces.
|
||||||
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))
|
||||||
@@ -41,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;
|
||||||
|
|
||||||
// 拖曳中隱藏邊線(LinesVisual3D 隨 Transform 變更逐幀重建會卡)
|
// Hide edges while moving to avoid rebuilding them each frame.
|
||||||
_edgesSuspended = true;
|
_edgesSuspended = true;
|
||||||
SetEdgesActive(false);
|
SetEdgesActive(false);
|
||||||
StatusText = $"拖曳「{root.Name}」中…(放開定位)";
|
SetStatus(() => Format("Runtime.Dragging", root.Name));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>滑鼠移動:把 2D 位移投影到「過錨點、面向相機」的平面上 → 暫時 Transform</summary>
|
/// <summary>Projects pointer movement onto the camera-facing plane through the anchor.</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 ||
|
||||||
@@ -62,15 +63,14 @@ public partial class MainViewModel
|
|||||||
_dragTransform.OffsetZ = _dragApplied.Z;
|
_dragTransform.OffsetZ = _dragApplied.Z;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>放開:移除暫時 Transform,一次性烘進 B-rep(量測精度不受拖曳影響)</summary>
|
/// <summary>Commits the temporary transform to B-rep on release.</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;
|
||||||
|
|
||||||
// 清除暫時位移:必須用 Identity,不可用 null —— HelixToolkit GetTransform 對 child.Transform
|
// HelixToolkit hit testing requires Identity rather than a null 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,12 +78,12 @@ public partial class MainViewModel
|
|||||||
_dragTransform = null;
|
_dragTransform = null;
|
||||||
_dragApplied = default;
|
_dragApplied = default;
|
||||||
_edgesSuspended = false;
|
_edgesSuspended = false;
|
||||||
SetEdgesActive(true); // 恢復所有檔案的邊線(被拖檔案的點隨後由 TranslateRoot 更新,期間不會渲染)
|
SetEdgesActive(true);
|
||||||
|
|
||||||
if (delta.LengthSquared > 1e-12)
|
if (delta.LengthSquared > 1e-12)
|
||||||
{
|
{
|
||||||
TranslateRoot(root, delta); // 烘進 B-rep + 網格 + 邊線 + 邊界(量測清空)
|
TranslateRoot(root, delta);
|
||||||
StatusText = $"已拖曳「{root.Name}」 Δ({delta.X:F2}, {delta.Y:F2}, {delta.Z:F2}) mm(可用 🧩干涉 驗證)";
|
SetStatus(() => Format("Runtime.Dragged", root.Name, delta.X, delta.Y, delta.Z));
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows;
|
||||||
using System.Windows.Media;
|
using System.Windows.Media;
|
||||||
using System.Windows.Media.Imaging;
|
using System.Windows.Media.Imaging;
|
||||||
|
using System.Windows.Media.Media3D;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using Microsoft.Win32;
|
using Microsoft.Win32;
|
||||||
using STPViewer.Models;
|
using STPViewer.Models;
|
||||||
|
using STPViewer.Services;
|
||||||
|
using static STPViewer.Services.LocalizationService;
|
||||||
|
|
||||||
namespace STPViewer.ViewModels;
|
namespace STPViewer.ViewModels;
|
||||||
|
|
||||||
// ─── 匯出(量測 CSV / 視圖 PNG 截圖)────────────────────────────────
|
// CSV, STEP, STL, and screenshot export
|
||||||
public partial class MainViewModel
|
public partial class MainViewModel
|
||||||
{
|
{
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -18,32 +24,32 @@ public partial class MainViewModel
|
|||||||
{
|
{
|
||||||
if (Measurements.Count == 0)
|
if (Measurements.Count == 0)
|
||||||
{
|
{
|
||||||
StatusText = "沒有量測結果可匯出";
|
SetStatus(() => Get("Runtime.CsvEmpty"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var dlg = new SaveFileDialog
|
var dlg = new SaveFileDialog
|
||||||
{
|
{
|
||||||
Title = "匯出量測結果",
|
Title = Get("Runtime.CsvTitle"),
|
||||||
Filter = "CSV 檔案 (*.csv)|*.csv",
|
Filter = Get("Runtime.CsvFilter"),
|
||||||
FileName = $"measurements_{DateTime.Now:yyyyMMdd_HHmmss}.csv",
|
FileName = $"measurements_{DateTime.Now:yyyyMMdd_HHmmss}.csv",
|
||||||
};
|
};
|
||||||
if (dlg.ShowDialog() != true) return;
|
if (dlg.ShowDialog() != true) return;
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
sb.AppendLine("標籤,類型,明細");
|
sb.AppendLine(Get("Runtime.CsvHeader"));
|
||||||
foreach (MeasurementResult m in Measurements)
|
foreach (MeasurementResult m in Measurements)
|
||||||
{
|
{
|
||||||
static string Esc(string s) => $"\"{s.Replace("\"", "\"\"")}\"";
|
static string Esc(string s) => $"\"{s.Replace("\"", "\"\"")}\"";
|
||||||
sb.AppendLine($"{Esc(m.Title)},{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:Excel 中文不亂碼
|
File.WriteAllText(dlg.FileName, sb.ToString(), new UTF8Encoding(true)); // BOM for Excel.
|
||||||
StatusText = $"已匯出 {Measurements.Count} 筆量測 → {Path.GetFileName(dlg.FileName)}";
|
int count = Measurements.Count;
|
||||||
|
string fileName = Path.GetFileName(dlg.FileName);
|
||||||
|
SetStatus(() => Format("Runtime.CsvExported", count, fileName));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 把可見檔案(目前位置,含對齊/旋轉/拖曳結果)匯出成「新的」STEP 檔 —
|
/// Exports visible B-rep Solid and Shell objects at their current positions.
|
||||||
/// 對齊插合後的配合結果可交接給 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()
|
||||||
@@ -55,46 +61,175 @@ public partial class MainViewModel
|
|||||||
.ToList();
|
.ToList();
|
||||||
if (geos.Count == 0)
|
if (geos.Count == 0)
|
||||||
{
|
{
|
||||||
StatusText = "沒有可匯出的 B-rep 幾何(STEP 匯出僅含 Solid/Shell;請勾選要匯出的 STEP 檔案)";
|
SetStatus(() => Get("Runtime.StepExportEmpty"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var dlg = new SaveFileDialog
|
var dlg = new SaveFileDialog
|
||||||
{
|
{
|
||||||
Title = "匯出目前場景為 STEP(零件在目前對齊位置)",
|
Title = Get("Runtime.StepExportTitle"),
|
||||||
Filter = "STEP 檔案 (*.stp)|*.stp|STEP 檔案 (*.step)|*.step",
|
Filter = Get("Runtime.StepExportFilter"),
|
||||||
FileName = $"assembly_{DateTime.Now:yyyyMMdd_HHmmss}.stp",
|
FileName = $"assembly_{DateTime.Now:yyyyMMdd_HHmmss}.stp",
|
||||||
};
|
};
|
||||||
if (dlg.ShowDialog() != true) return;
|
if (dlg.ShowDialog() != true) return;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// B-rep 已含目前位置(TransformRoot 對 Solid/Shell 整體 Modify)→ 直接寫出
|
// B-rep objects already contain their transformed positions.
|
||||||
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);
|
||||||
new CADability.ExportStep().WriteToFile(dlg.FileName, project);
|
new CADability.ExportStep().WriteToFile(dlg.FileName, project);
|
||||||
StatusText = $"已匯出 STEP({geos.Count} 個實體,目前位置)→ {Path.GetFileName(dlg.FileName)}";
|
int count = geos.Count;
|
||||||
|
string fileName = Path.GetFileName(dlg.FileName);
|
||||||
|
SetStatus(() => Format("Runtime.StepExported", count, fileName));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
StatusText = $"STEP 匯出失敗:{ex.Message}";
|
string error = ex.Message;
|
||||||
|
SetStatus(() => Format("Runtime.StepExportFailed", error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exports visible meshes as STL using the selected format, scale and quality.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand(CanExecute = nameof(IsIdle))]
|
||||||
|
private async Task ExportStlFile()
|
||||||
|
{
|
||||||
|
// Only visible leaves with meshes can be exported.
|
||||||
|
var groups = Roots.Where(r => r.IsVisible)
|
||||||
|
.Select(r => (Root: r, Leaves: r.Leaves()
|
||||||
|
.Where(l => l.IsVisible && GetMergedMesh(l) is not null).ToList()))
|
||||||
|
.Where(g => g.Leaves.Count > 0)
|
||||||
|
.ToList();
|
||||||
|
if (groups.Count == 0)
|
||||||
|
{
|
||||||
|
SetStatus(() => Get("Runtime.StlExportEmpty"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
long triangles = groups.SelectMany(g => g.Leaves)
|
||||||
|
.Sum(l => GetMergedMesh(l)!.TriangleIndices.Count / 3L);
|
||||||
|
bool hasBrep = groups.SelectMany(g => g.Leaves).Any(l => l.HasBrep && l.SourceGeos.Count > 0);
|
||||||
|
|
||||||
|
// Collect export options.
|
||||||
|
var vm = new StlExportViewModel(groups.Count, triangles, hasBrep);
|
||||||
|
var dialog = new StlExportDialog(vm) { Owner = Application.Current.MainWindow };
|
||||||
|
if (dialog.ShowDialog() != true || vm.Scale is not double scale) return;
|
||||||
|
|
||||||
|
var dlg = new SaveFileDialog
|
||||||
|
{
|
||||||
|
Title = Get(vm.PerFile ? "Runtime.StlExportPerFileTitle" : "Runtime.StlExportMergedTitle"),
|
||||||
|
Filter = Get("Runtime.StlExportFilter"),
|
||||||
|
FileName = vm.PerFile
|
||||||
|
? $"stl_{DateTime.Now:yyyyMMdd_HHmmss}.stl"
|
||||||
|
: $"assembly_{DateTime.Now:yyyyMMdd_HHmmss}.stl",
|
||||||
|
};
|
||||||
|
if (dlg.ShowDialog() != true) return;
|
||||||
|
|
||||||
|
// Snapshot WPF geometry on the UI thread before background export.
|
||||||
|
var snapshot = groups
|
||||||
|
.Select(g => (g.Root.Name, Leaves: g.Leaves
|
||||||
|
.Select(l => (Mesh: GetMergedMesh(l)!, l.HasBrep, l.SourceGeos))
|
||||||
|
.ToList()))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
IsBusy = true;
|
||||||
|
SetStatus(() => Get(vm.Quality == StlMeshQuality.Current
|
||||||
|
? "Runtime.StlExporting" : "Runtime.StlRetriangulating"));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
double factor = StlExportService.PrecisionFactor(vm.Quality);
|
||||||
|
var written = await Task.Run(() =>
|
||||||
|
{
|
||||||
|
var results = new List<(string File, int Triangles)>();
|
||||||
|
if (vm.PerFile)
|
||||||
|
{
|
||||||
|
string dir = Path.GetDirectoryName(dlg.FileName)!;
|
||||||
|
string baseName = Path.GetFileNameWithoutExtension(dlg.FileName);
|
||||||
|
foreach ((string rootName, var leaves) in snapshot)
|
||||||
|
{
|
||||||
|
string file = Path.Combine(dir, $"{baseName}_{SafeFileName(rootName)}.stl");
|
||||||
|
int n = StlExportService.Write(file, CollectMeshes(leaves, vm.Quality, factor), vm.Ascii, scale);
|
||||||
|
results.Add((file, n));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var meshes = CollectMeshes(snapshot.SelectMany(g => g.Leaves).ToList(), vm.Quality, factor);
|
||||||
|
int n = StlExportService.Write(dlg.FileName, meshes, vm.Ascii, scale);
|
||||||
|
results.Add((dlg.FileName, n));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
});
|
||||||
|
|
||||||
|
long total = written.Sum(w => (long)w.Triangles);
|
||||||
|
int fileCount = written.Count;
|
||||||
|
string destination = fileCount == 1 ? Path.GetFileName(written[0].File) : Path.GetDirectoryName(dlg.FileName)!;
|
||||||
|
SetStatus(() => fileCount == 1
|
||||||
|
? Format("Runtime.StlExportedOne", total, destination)
|
||||||
|
: Format("Runtime.StlExportedMany", fileCount, total, destination));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
string error = ex.Message;
|
||||||
|
SetStatus(() => Format("Runtime.StlExportFailed", error));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Current merged mesh for an exportable leaf.</summary>
|
||||||
|
private static MeshGeometry3D? GetMergedMesh(ModelNodeViewModel leaf) =>
|
||||||
|
leaf.MergedContent?.Children.Count > 0
|
||||||
|
? (leaf.MergedContent.Children[0] as GeometryModel3D)?.Geometry as MeshGeometry3D
|
||||||
|
: null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Selects frozen current meshes or sequentially retessellates B-rep geometry.
|
||||||
|
/// </summary>
|
||||||
|
private static List<MeshGeometry3D> CollectMeshes(
|
||||||
|
IReadOnlyList<(MeshGeometry3D Mesh, bool HasBrep, List<CADability.GeoObject.IGeoObject> SourceGeos)> leaves,
|
||||||
|
StlMeshQuality quality, double precisionFactor)
|
||||||
|
{
|
||||||
|
var meshes = new List<MeshGeometry3D>();
|
||||||
|
foreach ((MeshGeometry3D mesh, bool hasBrep, var sourceGeos) in leaves)
|
||||||
|
{
|
||||||
|
if (quality != StlMeshQuality.Current && hasBrep && sourceGeos.Count > 0)
|
||||||
|
{
|
||||||
|
List<MeshGeometry3D> tess = StlExportService.Tessellate(sourceGeos, precisionFactor);
|
||||||
|
if (tess.Count > 0) { meshes.AddRange(tess); continue; }
|
||||||
|
// Fall back to the current mesh if retessellation yields nothing.
|
||||||
|
}
|
||||||
|
meshes.Add(mesh);
|
||||||
|
}
|
||||||
|
return meshes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SafeFileName(string name)
|
||||||
|
{
|
||||||
|
string stem = Path.GetFileNameWithoutExtension(name);
|
||||||
|
if (string.IsNullOrWhiteSpace(stem)) stem = name;
|
||||||
|
foreach (char c in Path.GetInvalidFileNameChars()) stem = stem.Replace(c, '_');
|
||||||
|
return stem;
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void SaveScreenshot()
|
private void SaveScreenshot()
|
||||||
{
|
{
|
||||||
if (_viewport is null || _viewport.ActualWidth < 1) return;
|
if (_viewport is null || _viewport.ActualWidth < 1) return;
|
||||||
var dlg = new SaveFileDialog
|
var dlg = new SaveFileDialog
|
||||||
{
|
{
|
||||||
Title = "儲存視圖截圖",
|
Title = Get("Runtime.ScreenshotTitle"),
|
||||||
Filter = "PNG 圖片 (*.png)|*.png",
|
Filter = Get("Runtime.ScreenshotFilter"),
|
||||||
FileName = $"stpviewer_{DateTime.Now:yyyyMMdd_HHmmss}.png",
|
FileName = $"stpviewer_{DateTime.Now:yyyyMMdd_HHmmss}.png",
|
||||||
};
|
};
|
||||||
if (dlg.ShowDialog() != true) return;
|
if (dlg.ShowDialog() != true) return;
|
||||||
|
|
||||||
const double scale = 2.0; // 2x 解析度
|
const double scale = 2.0;
|
||||||
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);
|
||||||
@@ -103,6 +238,7 @@ public partial class MainViewModel
|
|||||||
encoder.Frames.Add(BitmapFrame.Create(rtb));
|
encoder.Frames.Add(BitmapFrame.Create(rtb));
|
||||||
using FileStream fs = File.Create(dlg.FileName);
|
using FileStream fs = File.Create(dlg.FileName);
|
||||||
encoder.Save(fs);
|
encoder.Save(fs);
|
||||||
StatusText = $"已儲存截圖 → {Path.GetFileName(dlg.FileName)}";
|
string fileName = Path.GetFileName(dlg.FileName);
|
||||||
|
SetStatus(() => Format("Runtime.ScreenshotSaved", fileName));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,42 +4,41 @@ using System.Windows.Media;
|
|||||||
using System.Windows.Media.Media3D;
|
using System.Windows.Media.Media3D;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using HelixToolkit.Wpf;
|
using HelixToolkit.Wpf;
|
||||||
|
using static STPViewer.Services.LocalizationService;
|
||||||
|
|
||||||
namespace STPViewer.ViewModels;
|
namespace STPViewer.ViewModels;
|
||||||
|
|
||||||
// ─── Gizmo 三軸操作器(XYZ 箭頭 + 旋轉環;每次放開滑鼠烘進 B-rep)──────
|
// Gizmo translation and rotation
|
||||||
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; // 操作器綁定的代理 visual(其 Transform = 使用者拖出的變換)
|
private ModelVisual3D? _gizmoProxy;
|
||||||
private ModelNodeViewModel? _gizmoTarget;
|
private ModelNodeViewModel? _gizmoTarget;
|
||||||
private bool _gizmoDragActive; // 拖動中(邊線已暫停)
|
private bool _gizmoDragActive;
|
||||||
private bool _gizmoBaking; // 烘焙中,忽略 Transform 變更回呼
|
private bool _gizmoBaking;
|
||||||
private bool _gizmoBakePending; // 已排程烘焙,防同一次放開重複觸發
|
private bool _gizmoBakePending;
|
||||||
|
|
||||||
// 操作器疊圖層:另一個透明 Viewport3D 疊在主視窗上、相機同步、只放操作器 →
|
// A synchronized overlay viewport keeps the gizmo above scene geometry.
|
||||||
// 操作器不在主場景,永不被實體遮擋(always-on-top);空白處滑鼠穿透回主視窗
|
|
||||||
private System.Windows.Controls.Viewport3D? _overlayViewport;
|
private System.Windows.Controls.Viewport3D? _overlayViewport;
|
||||||
private ProjectionCamera? _overlayCamera; // 型別跟隨主相機(透視/正交切換,v0.5.0)
|
private ProjectionCamera? _overlayCamera;
|
||||||
|
|
||||||
/// <summary>注入操作器疊圖層(透明 Viewport3D,疊在主視窗上、永遠最上層)</summary>
|
/// <summary>Attaches the transparent gizmo overlay viewport.</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 用 DiffuseMaterial)
|
// Manipulator materials need lighting.
|
||||||
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()
|
||||||
{
|
{
|
||||||
@@ -78,7 +77,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()
|
||||||
@@ -90,12 +89,12 @@ public partial class MainViewModel
|
|||||||
: Roots.Count == 1 ? Roots[0] : null;
|
: Roots.Count == 1 ? Roots[0] : null;
|
||||||
if (root is null || root.Bounds.IsEmpty)
|
if (root is null || root.Bounds.IsEmpty)
|
||||||
{
|
{
|
||||||
StatusText = "操作器:請先在樹面板點選要操作的檔案";
|
SetStatus(() => Get("Runtime.GizmoSelectFile"));
|
||||||
GizmoEnabled = false;
|
GizmoEnabled = false;
|
||||||
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);
|
||||||
@@ -112,18 +111,18 @@ 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);
|
||||||
}
|
}
|
||||||
// 平移箭頭(X 紅 / Y 綠 / Z 藍 — 業界慣例)
|
// Translation arrows: X red, Y green, Z blue.
|
||||||
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 });
|
||||||
|
|
||||||
StatusText = $"操作器:拖箭頭沿軸移動「{root.Name}」、拖環繞軸旋轉;放開即定位";
|
SetStatus(() => Format("Runtime.GizmoHint", root.Name));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RemoveGizmo()
|
private void RemoveGizmo()
|
||||||
@@ -143,7 +142,7 @@ public partial class MainViewModel
|
|||||||
_gizmoParts.Clear();
|
_gizmoParts.Clear();
|
||||||
_gizmoProxy = null;
|
_gizmoProxy = null;
|
||||||
|
|
||||||
if (_gizmoTarget is not null) // 清掉殘留的暫時 Transform(用 Identity,不可 null → 否則 FindHits crash)
|
if (_gizmoTarget is not null)
|
||||||
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;
|
||||||
@@ -156,7 +155,7 @@ public partial class MainViewModel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>操作器拖動中:把代理的 Transform 套到目標檔案的所有 BodyVisual(暫時、GPU 端)</summary>
|
/// <summary>Applies the proxy transform to target visuals during dragging.</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;
|
||||||
@@ -165,21 +164,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>滑鼠放開(MainWindow 轉發,handledEventsToo):把累積變換烘進 B-rep 並重置操作器</summary>
|
/// <summary>Commits the accumulated transform and resets the gizmo on mouse release.</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;
|
||||||
|
|
||||||
// 延後到 manipulator 自身的 mouse-up 處理(釋放捕捉等)完成後再烘焙,避免在其事件中改動視覺樹造成 reentrancy
|
// Commit after the manipulator completes its mouse-up handling.
|
||||||
_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);
|
||||||
@@ -191,19 +190,19 @@ public partial class MainViewModel
|
|||||||
if (_gizmoTarget is null) return;
|
if (_gizmoTarget is null) return;
|
||||||
ModelNodeViewModel root = _gizmoTarget;
|
ModelNodeViewModel root = _gizmoTarget;
|
||||||
|
|
||||||
_gizmoBaking = true; // 防 proxy 歸零觸發 GizmoTransformChanged 重入
|
_gizmoBaking = true;
|
||||||
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; // 清暫時位移(用 Identity,不可 null)
|
l.BodyVisual.Transform = Transform3D.Identity;
|
||||||
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); // 烘進 B-rep(量測精度不受影響)
|
TransformRoot(root, Services.RigidAlign.ToModOp(m), m);
|
||||||
|
|
||||||
// 操作器移到變換後的新中心
|
// Move the gizmo to the transformed center.
|
||||||
Rect3D b = root.Bounds;
|
Rect3D b = root.Bounds;
|
||||||
if (!b.IsEmpty)
|
if (!b.IsEmpty)
|
||||||
{
|
{
|
||||||
@@ -211,6 +210,6 @@ public partial class MainViewModel
|
|||||||
foreach (HelixToolkit.Wpf.Manipulator man in _gizmoParts)
|
foreach (HelixToolkit.Wpf.Manipulator man in _gizmoParts)
|
||||||
man.Position = center;
|
man.Position = center;
|
||||||
}
|
}
|
||||||
StatusText = $"已套用操作器變換「{root.Name}」(可用 🧩干涉 驗證)";
|
SetStatus(() => Format("Runtime.GizmoApplied", root.Name));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ using CommunityToolkit.Mvvm.Input;
|
|||||||
using HelixToolkit.Wpf;
|
using HelixToolkit.Wpf;
|
||||||
using STPViewer.Models;
|
using STPViewer.Models;
|
||||||
using STPViewer.Services;
|
using STPViewer.Services;
|
||||||
|
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))]
|
||||||
@@ -22,7 +23,8 @@ public partial class MainViewModel
|
|||||||
var visibleRoots = Roots.Where(r => r.IsVisible).ToList();
|
var visibleRoots = Roots.Where(r => r.IsVisible).ToList();
|
||||||
if (visibleRoots.Count < 2)
|
if (visibleRoots.Count < 2)
|
||||||
{
|
{
|
||||||
StatusText = $"干涉檢查需要至少 2 個可見檔案(目前 {visibleRoots.Count} 個)— 用樹面板勾選";
|
int visibleCount = visibleRoots.Count;
|
||||||
|
SetStatus(() => Format("Runtime.InterferenceNeedTwo", visibleCount));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +35,7 @@ public partial class MainViewModel
|
|||||||
.Select(m => _faceMap[(GeometryModel3D)m].Mesh)
|
.Select(m => _faceMap[(GeometryModel3D)m].Mesh)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// 網格快照(開跑後零件被改走也不會半途換料;v0.4.0 起運算期間指令已鎖,這裡是雙保險)
|
// Snapshot meshes before background calculations.
|
||||||
var meshes = visibleRoots.ToDictionary(r => r, MeshesOf);
|
var meshes = visibleRoots.ToDictionary(r => r, MeshesOf);
|
||||||
|
|
||||||
IsBusy = true;
|
IsBusy = true;
|
||||||
@@ -45,7 +47,8 @@ public partial class MainViewModel
|
|||||||
{
|
{
|
||||||
pairs++;
|
pairs++;
|
||||||
string nameA = visibleRoots[i].Name, nameB = visibleRoots[j].Name;
|
string nameA = visibleRoots[i].Name, nameB = visibleRoots[j].Name;
|
||||||
StatusText = $"干涉檢查中(第 {pairs} 組):{nameA} ⟷ {nameB} …";
|
int currentPair = pairs;
|
||||||
|
SetStatus(() => Format("Runtime.InterferenceChecking", currentPair, nameA, nameB));
|
||||||
var a = meshes[visibleRoots[i]];
|
var a = meshes[visibleRoots[i]];
|
||||||
var b = meshes[visibleRoots[j]];
|
var b = meshes[visibleRoots[j]];
|
||||||
InterferenceResult result = await Task.Run(() => InterferenceService.Check(a, b));
|
InterferenceResult result = await Task.Run(() => InterferenceService.Check(a, b));
|
||||||
@@ -53,11 +56,15 @@ public partial class MainViewModel
|
|||||||
if (result.Intersects) intersecting++;
|
if (result.Intersects) intersecting++;
|
||||||
}
|
}
|
||||||
if (pairs > 1)
|
if (pairs > 1)
|
||||||
StatusText = $"干涉檢查完成:{pairs} 組配對、{intersecting} 組相交";
|
{
|
||||||
|
int checkedPairs = pairs, hits = intersecting;
|
||||||
|
SetStatus(() => Format("Runtime.InterferenceCompleted", checkedPairs, hits));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
StatusText = $"干涉檢查失敗:{ex.Message}";
|
string error = ex.Message;
|
||||||
|
SetStatus(() => Format("Runtime.InterferenceFailed", error));
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -78,9 +85,8 @@ public partial class MainViewModel
|
|||||||
m = new MeasurementResult
|
m = new MeasurementResult
|
||||||
{
|
{
|
||||||
Kind = MeasureMode.Interference,
|
Kind = MeasureMode.Interference,
|
||||||
TitleFor = _ => $"🧩 干涉!{nameA} ⟷ {nameB}",
|
TitleFor = _ => Format("Runtime.InterferenceHitTitle", nameA, nameB),
|
||||||
DetailFor = _ => $"兩零件相交(不 match)\n相交三角形對 = {pairs:N0}\n" +
|
DetailFor = _ => Format("Runtime.InterferenceHitDetail", pairs, segCount),
|
||||||
$"紅色線 = 干涉交線({segCount:N0} 段)\n(共面貼合不會被算為干涉)",
|
|
||||||
};
|
};
|
||||||
var pts = new Point3DCollection(Math.Min(result.Segments.Count, 20000) * 2);
|
var pts = new Point3DCollection(Math.Min(result.Segments.Count, 20000) * 2);
|
||||||
foreach ((Point3D s0, Point3D s1) in result.Segments) { pts.Add(s0); pts.Add(s1); }
|
foreach ((Point3D s0, Point3D s1) in result.Segments) { pts.Add(s0); pts.Add(s1); }
|
||||||
@@ -88,15 +94,17 @@ public partial class MainViewModel
|
|||||||
if (result.Segments.Count > 0)
|
if (result.Segments.Count > 0)
|
||||||
{
|
{
|
||||||
Point3D at = result.Segments[0].A;
|
Point3D at = result.Segments[0].A;
|
||||||
m.Overlays.Add(new BillboardTextVisual3D
|
var label = new BillboardTextVisual3D
|
||||||
{
|
{
|
||||||
Position = at + new Vector3D(0, 0, markerR * 3),
|
Position = at + new Vector3D(0, 0, markerR * 3),
|
||||||
Text = "干涉",
|
Text = Get("Runtime.InterferenceLabel"),
|
||||||
Foreground = Brushes.White,
|
Foreground = Brushes.White,
|
||||||
Background = Brushes.Red,
|
Background = Brushes.Red,
|
||||||
Padding = new Thickness(5, 2, 5, 2),
|
Padding = new Thickness(5, 2, 5, 2),
|
||||||
FontSize = 14,
|
FontSize = 14,
|
||||||
});
|
};
|
||||||
|
m.Overlays.Add(label);
|
||||||
|
m.DynamicLabels.Add((label, _ => Get("Runtime.InterferenceLabel")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -107,9 +115,8 @@ public partial class MainViewModel
|
|||||||
m = new MeasurementResult
|
m = new MeasurementResult
|
||||||
{
|
{
|
||||||
Kind = MeasureMode.Interference,
|
Kind = MeasureMode.Interference,
|
||||||
TitleFor = u => $"🧩 無干涉 gap ≈ {Units.L(gap, u)}",
|
TitleFor = u => Format("Runtime.InterferenceClearTitle", Units.L(gap, u)),
|
||||||
DetailFor = u => $"{nameA} ⟷ {nameB} 無相交\n最小間隙 ≈ {Units.L(gap, u)}(網格近似)\n" +
|
DetailFor = u => Format("Runtime.InterferenceClearDetail", nameA, nameB, Units.L(gap, u), Units.P(ga, u), Units.P(gb, u)),
|
||||||
$"gap ≈ 0 即為貼合(match)\n點 1 {Units.P(ga, u)}\n點 2 {Units.P(gb, u)}",
|
|
||||||
};
|
};
|
||||||
m.Overlays.Add(new SphereVisual3D { Center = ga, Radius = markerR, Fill = Brushes.OrangeRed });
|
m.Overlays.Add(new SphereVisual3D { Center = ga, Radius = markerR, Fill = Brushes.OrangeRed });
|
||||||
m.Overlays.Add(new SphereVisual3D { Center = gb, Radius = markerR, Fill = Brushes.OrangeRed });
|
m.Overlays.Add(new SphereVisual3D { Center = gb, Radius = markerR, Fill = Brushes.OrangeRed });
|
||||||
@@ -122,14 +129,14 @@ public partial class MainViewModel
|
|||||||
var label = new BillboardTextVisual3D
|
var label = new BillboardTextVisual3D
|
||||||
{
|
{
|
||||||
Position = ga + d / 2 + new Vector3D(0, 0, markerR * 2),
|
Position = ga + d / 2 + new Vector3D(0, 0, markerR * 2),
|
||||||
Text = $"gap {Units.L(gap, UnitSystem.Millimeter)}",
|
Text = Format("Runtime.GapLabel", Units.L(gap, UnitSystem.Millimeter)),
|
||||||
Foreground = Brushes.Black,
|
Foreground = Brushes.Black,
|
||||||
Background = new SolidColorBrush(Color.FromArgb(200, 255, 255, 210)),
|
Background = new SolidColorBrush(Color.FromArgb(200, 255, 255, 210)),
|
||||||
Padding = new Thickness(4, 2, 4, 2),
|
Padding = new Thickness(4, 2, 4, 2),
|
||||||
FontSize = 14,
|
FontSize = 14,
|
||||||
};
|
};
|
||||||
m.Overlays.Add(label);
|
m.Overlays.Add(label);
|
||||||
m.DynamicLabels.Add((label, u => $"gap {Units.L(gap, u)}"));
|
m.DynamicLabels.Add((label, u => Format("Runtime.GapLabel", Units.L(gap, u))));
|
||||||
}
|
}
|
||||||
AddMeasurement(m);
|
AddMeasurement(m);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using STPViewer.Models;
|
||||||
|
using STPViewer.Services;
|
||||||
|
|
||||||
|
namespace STPViewer.ViewModels;
|
||||||
|
|
||||||
|
public partial class MainViewModel
|
||||||
|
{
|
||||||
|
public string SelectedLanguage
|
||||||
|
{
|
||||||
|
get => LocalizationService.Instance.Language;
|
||||||
|
set => LocalizationService.Instance.SetLanguage(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<LanguageDefinition> Languages => LocalizationService.Instance.Languages;
|
||||||
|
|
||||||
|
public void InitializeLocalization() =>
|
||||||
|
LocalizationService.Instance.LanguageChanged += OnLanguageChanged;
|
||||||
|
|
||||||
|
public void DisposeLocalization() =>
|
||||||
|
LocalizationService.Instance.LanguageChanged -= OnLanguageChanged;
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ChangeLanguage(string language) => SelectedLanguage = language;
|
||||||
|
|
||||||
|
private void OnLanguageChanged(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(SelectedLanguage));
|
||||||
|
QuickBar.RefreshText();
|
||||||
|
RefreshLocalizedText();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,22 +5,23 @@ using System.Windows.Media;
|
|||||||
using System.Windows.Media.Media3D;
|
using System.Windows.Media.Media3D;
|
||||||
using System.Windows.Threading;
|
using System.Windows.Threading;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using HelixToolkit.Wpf;
|
using HelixToolkit.Wpf;
|
||||||
using STPViewer.Models;
|
using STPViewer.Models;
|
||||||
using STPViewer.Services;
|
using STPViewer.Services;
|
||||||
|
using static STPViewer.Services.LocalizationService;
|
||||||
|
|
||||||
namespace STPViewer.ViewModels;
|
namespace STPViewer.ViewModels;
|
||||||
|
|
||||||
// ─── 剖面(CPU 網格裁切;v0.4.0 起裁切在背景執行緒平行計算,UI 不凍結)──
|
// Section clipping
|
||||||
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; // SectionEnabled 時的剖切平面(合併邊線重建用)
|
private Point3D _sectionPlanePoint;
|
||||||
private Vector3D _sectionNormal;
|
private Vector3D _sectionNormal;
|
||||||
|
|
||||||
// 背景裁切 guard:進行中又有新變更(拉滑桿/換軸/零件平移)→ 完成後用最新參數重跑一輪。
|
// Reapply clipping if parameters change while background work is running.
|
||||||
// 不要改回同步呼叫 ClipMesh —— 大檔(64k 面)整場景裁切會凍結 UI 數秒
|
|
||||||
private bool _sectionApplying;
|
private bool _sectionApplying;
|
||||||
private bool _sectionReapply;
|
private bool _sectionReapply;
|
||||||
|
|
||||||
@@ -31,16 +32,20 @@ public partial class MainViewModel
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private int sectionAxisIndex;
|
private int sectionAxisIndex;
|
||||||
|
|
||||||
/// <summary>剖面位置(沿軸 0~100%)</summary>
|
/// <summary>Section position along the selected axis, from 0 to 100%.</summary>
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private double sectionPosition = 50;
|
private double sectionPosition = 50;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool sectionFlip;
|
private bool sectionFlip;
|
||||||
|
|
||||||
// ── 3點自訂剖切平面(SectionAxisIndex == 3,v0.5.0)──
|
/// <summary>Closes the section parameter bar.</summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void CloseSection() => SectionEnabled = false;
|
||||||
|
|
||||||
|
// Three-point custom cutting plane
|
||||||
private const int CustomAxisIndex = 3;
|
private const int CustomAxisIndex = 3;
|
||||||
private Vector3D? _customNormal; // null = 尚未定義(等使用者點 3 點)
|
private Vector3D? _customNormal;
|
||||||
private readonly List<Point3D> _sectionPicks = new();
|
private readonly List<Point3D> _sectionPicks = new();
|
||||||
private readonly List<Visual3D> _sectionPickOverlays = new();
|
private readonly List<Visual3D> _sectionPickOverlays = new();
|
||||||
|
|
||||||
@@ -55,11 +60,11 @@ public partial class MainViewModel
|
|||||||
if (value == CustomAxisIndex)
|
if (value == CustomAxisIndex)
|
||||||
{
|
{
|
||||||
if (_customNormal is null)
|
if (_customNormal is null)
|
||||||
StatusText = "3點剖面:請在模型上點 3 個點定義剖切平面(Esc 取消)";
|
SetStatus(() => Get("Runtime.SectionPickPrompt"));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// 離開自訂平面 → 丟棄舊平面(再選「3點」即重新定義)
|
// Leaving custom mode discards the old plane.
|
||||||
_customNormal = null;
|
_customNormal = null;
|
||||||
ClearSectionPicks();
|
ClearSectionPicks();
|
||||||
}
|
}
|
||||||
@@ -68,14 +73,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>3點剖面定義模式中的點擊(OnViewportClick 轉入)。回傳 true = 已消費此點擊。</summary>
|
/// <summary>Handles a custom plane pick; returns true when the click is consumed.</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;
|
||||||
@@ -93,13 +98,14 @@ public partial class MainViewModel
|
|||||||
}
|
}
|
||||||
if (hit is null)
|
if (hit is null)
|
||||||
{
|
{
|
||||||
StatusText = $"3點剖面:未命中模型表面(已選 {_sectionPicks.Count}/3 點,Esc 取消)";
|
int selected = _sectionPicks.Count;
|
||||||
|
SetStatus(() => Format("Runtime.SectionNoHit", selected));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (_sectionPicks.Count == 2 &&
|
if (_sectionPicks.Count == 2 &&
|
||||||
Vector3D.CrossProduct(_sectionPicks[1] - _sectionPicks[0], hit.Value - _sectionPicks[0]).LengthSquared < 1e-12)
|
Vector3D.CrossProduct(_sectionPicks[1] - _sectionPicks[0], hit.Value - _sectionPicks[0]).LengthSquared < 1e-12)
|
||||||
{
|
{
|
||||||
StatusText = "3點剖面:三點共線,請改選不共線的第 3 點";
|
SetStatus(() => Get("Runtime.SectionCollinear"));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +117,8 @@ public partial class MainViewModel
|
|||||||
|
|
||||||
if (_sectionPicks.Count < 3)
|
if (_sectionPicks.Count < 3)
|
||||||
{
|
{
|
||||||
StatusText = $"3點剖面:已選 {_sectionPicks.Count}/3 點";
|
int selected = _sectionPicks.Count;
|
||||||
|
SetStatus(() => Format("Runtime.SectionPicked", selected));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,25 +126,25 @@ public partial class MainViewModel
|
|||||||
ClearSectionPicks();
|
ClearSectionPicks();
|
||||||
if (n.LengthSquared < 1e-12)
|
if (n.LengthSquared < 1e-12)
|
||||||
{
|
{
|
||||||
StatusText = "3點剖面:三點退化(共線),請重新點選";
|
SetStatus(() => Get("Runtime.SectionDegenerate"));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
n.Normalize();
|
n.Normalize();
|
||||||
_customNormal = n;
|
_customNormal = n;
|
||||||
StatusText = $"3點剖面:平面已定義(法向 {n.X:F2}, {n.Y:F2}, {n.Z:F2})— 用位置滑桿掃剖面";
|
SetStatus(() => Format("Runtime.SectionDefined", n.X, n.Y, n.Z));
|
||||||
ApplySection();
|
ApplySection();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>取消 3 點平面定義(Esc / 關剖面)。回傳 true = 有取消動作。</summary>
|
/// <summary>Cancels custom plane picking; returns true if it was active.</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; // 回 X 軸
|
SectionAxisIndex = 0;
|
||||||
if (!quiet) StatusText = "已取消 3 點剖面定義";
|
if (!quiet) SetStatus(() => Get("Runtime.SectionCancelled"));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,8 +167,7 @@ public partial class MainViewModel
|
|||||||
|
|
||||||
if (!SectionEnabled)
|
if (!SectionEnabled)
|
||||||
{
|
{
|
||||||
// 還原原始幾何(換回 fi.Mesh 參照,便宜、同步即可)。
|
// Restore original meshes. Background clipping checks SectionEnabled before applying.
|
||||||
// 若背景裁切還在跑,其 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;
|
||||||
@@ -171,8 +177,8 @@ public partial class MainViewModel
|
|||||||
_viewport.Children.Remove(_sectionPlaneVisual);
|
_viewport.Children.Remove(_sectionPlaneVisual);
|
||||||
_sectionPlaneVisual = null;
|
_sectionPlaneVisual = null;
|
||||||
}
|
}
|
||||||
ApplyRenderMode(); // 剖面關閉 → 瀏覽模式可切回合併網格
|
ApplyRenderMode();
|
||||||
StatusText = "剖面已關閉";
|
SetStatus(() => Get("Runtime.SectionClosed"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +196,7 @@ public partial class MainViewModel
|
|||||||
Vector3D axis;
|
Vector3D axis;
|
||||||
if (SectionAxisIndex == CustomAxisIndex)
|
if (SectionAxisIndex == CustomAxisIndex)
|
||||||
{
|
{
|
||||||
if (_customNormal is null) return; // 平面尚未定義(等使用者點 3 點)
|
if (_customNormal is null) return;
|
||||||
axis = _customNormal.Value;
|
axis = _customNormal.Value;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -206,8 +212,7 @@ 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;
|
||||||
// 平面位置:場景 AABB 8 個角投影到法向的 [dmin, dmax],滑桿 t 內插
|
// Interpolate along the projected bounds of the scene AABB.
|
||||||
//(軸向法向 = 與舊版逐軸計算等價;任意法向也成立)
|
|
||||||
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++)
|
||||||
{
|
{
|
||||||
@@ -223,9 +228,7 @@ 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;
|
||||||
|
|
||||||
// UI 執行緒快照(原始 mesh 皆已 Freeze,可跨執行緒),背景平行裁切,回 UI 一次換上。
|
// Clip frozen meshes in the background, then apply on the UI thread.
|
||||||
// 裁切期間若參數又變 / 零件被平移(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)
|
||||||
@@ -243,19 +246,23 @@ 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]; // 已移除檔案的殘留 model 賦值無害(不在視覺樹上)
|
models[i].Geometry = clipped[i];
|
||||||
|
|
||||||
_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);
|
||||||
string axisName = SectionAxisIndex == CustomAxisIndex ? "自訂平面" : $"{"XYZ"[SectionAxisIndex]} 軸";
|
int axisIndex = SectionAxisIndex;
|
||||||
StatusText = $"剖面:{axisName} {SectionPosition:F0}%" + (SectionFlip ? "(反向)" : "");
|
double position = SectionPosition;
|
||||||
|
bool flip = SectionFlip;
|
||||||
|
SetStatus(() => Format("Runtime.SectionStatus",
|
||||||
|
axisIndex == CustomAxisIndex ? Get("Runtime.CustomPlane") : Format("Runtime.Axis", "XYZ"[axisIndex]),
|
||||||
|
position, flip ? Get("Runtime.Reversed") : ""));
|
||||||
} while (_sectionReapply);
|
} while (_sectionReapply);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -274,7 +281,7 @@ public partial class MainViewModel
|
|||||||
0 => (new Vector3D(0, 1, 0), size.Y, size.Z),
|
0 => (new Vector3D(0, 1, 0), size.Y, size.Z),
|
||||||
1 => (new Vector3D(1, 0, 0), size.X, size.Z),
|
1 => (new Vector3D(1, 0, 0), size.X, size.Z),
|
||||||
2 => (new Vector3D(1, 0, 0), size.X, size.Y),
|
2 => (new Vector3D(1, 0, 0), size.X, size.Y),
|
||||||
_ => (AnyPerpendicular(axis), size.Length * 0.8, size.Length * 0.8), // 自訂平面
|
_ => (AnyPerpendicular(axis), size.Length * 0.8, size.Length * 0.8),
|
||||||
};
|
};
|
||||||
lenDir.Normalize();
|
lenDir.Normalize();
|
||||||
_sectionPlaneVisual = new RectangleVisual3D
|
_sectionPlaneVisual = new RectangleVisual3D
|
||||||
@@ -289,7 +296,7 @@ public partial class MainViewModel
|
|||||||
_viewport!.Children.Add(_sectionPlaneVisual);
|
_viewport!.Children.Add(_sectionPlaneVisual);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>取任一與 n 垂直的單位向量(自訂平面的指示矩形方向用)</summary>
|
/// <summary>Returns a unit vector perpendicular to n for the custom plane visual.</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
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ using HelixToolkit.Wpf;
|
|||||||
using Microsoft.Win32;
|
using Microsoft.Win32;
|
||||||
using STPViewer.Models;
|
using STPViewer.Models;
|
||||||
using STPViewer.Services;
|
using STPViewer.Services;
|
||||||
|
using static STPViewer.Services.LocalizationService;
|
||||||
|
|
||||||
namespace STPViewer.ViewModels;
|
namespace STPViewer.ViewModels;
|
||||||
|
|
||||||
@@ -22,34 +23,33 @@ 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>渲染物件 → B-rep Face 反查表(HitTest 用;overlay 不在表內所以自然被忽略)</summary>
|
/// <summary>Maps rendered face models to their B-rep face data for hit testing.</summary>
|
||||||
private readonly Dictionary<Model3D, FaceInfo> _faceMap = new();
|
private readonly Dictionary<Model3D, FaceInfo> _faceMap = new();
|
||||||
|
|
||||||
/// <summary>合併網格 → leaf 反查表(拖曳模式在瀏覽渲染下 hit-test 用)</summary>
|
/// <summary>Maps merged meshes to leaves for drag hit testing.</summary>
|
||||||
private readonly Dictionary<Model3D, ModelNodeViewModel> _mergedMap = new();
|
private readonly Dictionary<Model3D, ModelNodeViewModel> _mergedMap = new();
|
||||||
|
|
||||||
/// <summary>合併網格 → (每面頂點起始邊界, 對應 FaceInfo)。命中合併網格時用三角形頂點 index 二分搜尋反查回是哪個面,
|
/// <summary>Vertex boundaries and faces used to resolve hits on merged meshes.
|
||||||
/// 讓量測模式也能 render 合併網格(62 個 model)而非逐面(數萬個 model)。starts 長度 = faces+1,最後一格為總頂點數。
|
/// The order matches BuildMergedMesh; transforms do not change vertex counts.</summary>
|
||||||
/// 邊界由匯入時依面序串接而成,與 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(); // 三點對齊(前3=來源、後3=目標)
|
private readonly List<(ModelNodeViewModel Root, Point3D P)> _align3 = new();
|
||||||
private readonly List<Visual3D> _pendingOverlays = new();
|
private readonly List<Visual3D> _pendingOverlays = new();
|
||||||
|
|
||||||
/// <summary>樹面板目前選取的節點(MainWindow SelectedItemChanged 轉發;旋轉指令的目標)</summary>
|
/// <summary>Selected tree node used as the target for commands.</summary>
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private ModelNodeViewModel? selectedNode;
|
private ModelNodeViewModel? selectedNode;
|
||||||
|
|
||||||
// 互動中暫停邊線:轉動/縮放/平移時隱藏 LinesVisual3D,停下再顯示 → 大組件流暢
|
// Suspend edge rendering during camera interaction.
|
||||||
private readonly DispatcherTimer _interactionTimer;
|
private readonly DispatcherTimer _interactionTimer;
|
||||||
private bool _edgesSuspended;
|
private bool _edgesSuspended;
|
||||||
|
|
||||||
@@ -57,7 +57,24 @@ public partial class MainViewModel : ObservableObject
|
|||||||
private MeasureMode currentMode = MeasureMode.None;
|
private MeasureMode currentMode = MeasureMode.None;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string statusText = "就緒 — 匯入 STP/STEP/STL/DXF(拖放亦可)。右鍵旋轉、滾輪縮放、中鍵或 Shift+左鍵平移";
|
private string statusText = Get("Runtime.Ready");
|
||||||
|
|
||||||
|
private Func<string> _statusTextFor = () => Get("Runtime.Ready");
|
||||||
|
|
||||||
|
private void SetStatus(Func<string> textFor)
|
||||||
|
{
|
||||||
|
_statusTextFor = textFor;
|
||||||
|
StatusText = textFor();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RefreshLocalizedText()
|
||||||
|
{
|
||||||
|
foreach (MeasurementResult result in Measurements) result.RefreshText();
|
||||||
|
foreach (ModelNodeViewModel root in Roots) root.RefreshLocalizedText();
|
||||||
|
if (!string.IsNullOrWhiteSpace(TreeFilter))
|
||||||
|
foreach (ModelNodeViewModel root in Roots) root.ApplyFilter(TreeFilter);
|
||||||
|
StatusText = _statusTextFor();
|
||||||
|
}
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyCanExecuteChangedFor(nameof(ImportCommand))]
|
[NotifyCanExecuteChangedFor(nameof(ImportCommand))]
|
||||||
@@ -65,12 +82,13 @@ public partial class MainViewModel : ObservableObject
|
|||||||
[NotifyCanExecuteChangedFor(nameof(RotateRootCommand))]
|
[NotifyCanExecuteChangedFor(nameof(RotateRootCommand))]
|
||||||
[NotifyCanExecuteChangedFor(nameof(CheckInterferenceCommand))]
|
[NotifyCanExecuteChangedFor(nameof(CheckInterferenceCommand))]
|
||||||
[NotifyCanExecuteChangedFor(nameof(ExportStepFileCommand))]
|
[NotifyCanExecuteChangedFor(nameof(ExportStepFileCommand))]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(ExportStlFileCommand))]
|
||||||
private bool isBusy;
|
private bool isBusy;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool useInch;
|
private bool useInch;
|
||||||
|
|
||||||
/// <summary>樹面板名稱過濾字串(只影響樹顯示,不影響 3D 可見性)</summary>
|
/// <summary>Tree name filter; does not affect viewport visibility.</summary>
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string? treeFilter;
|
private string? treeFilter;
|
||||||
|
|
||||||
@@ -79,13 +97,16 @@ public partial class MainViewModel : ObservableObject
|
|||||||
foreach (ModelNodeViewModel root in Roots) root.ApplyFilter(value);
|
foreach (ModelNodeViewModel root in Roots) root.ApplyFilter(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>最近開啟檔案(MRU,最多 10 筆;隨 settings.json 保存)</summary>
|
/// <summary>Recently opened files saved in 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();
|
||||||
|
|
||||||
// ─── 使用者設定(視窗由 MainWindow 處理;VM 管單位 + MRU)─────
|
/// <summary>Quick bar button selections.</summary>
|
||||||
|
public QuickBarViewModel QuickBar { get; } = new();
|
||||||
|
|
||||||
|
// User settings
|
||||||
|
|
||||||
public void LoadSettings(AppSettings s)
|
public void LoadSettings(AppSettings s)
|
||||||
{
|
{
|
||||||
@@ -93,12 +114,15 @@ public partial class MainViewModel : ObservableObject
|
|||||||
RecentFiles.Clear();
|
RecentFiles.Clear();
|
||||||
foreach (string f in s.RecentFiles.Where(File.Exists).Take(SettingsService.MaxRecentFiles))
|
foreach (string f in s.RecentFiles.Where(File.Exists).Take(SettingsService.MaxRecentFiles))
|
||||||
RecentFiles.Add(f);
|
RecentFiles.Add(f);
|
||||||
|
QuickBar.Load(s.QuickBarKeys);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SaveSettingsInto(AppSettings s)
|
public void SaveSettingsInto(AppSettings s)
|
||||||
{
|
{
|
||||||
s.UseInch = UseInch;
|
s.UseInch = UseInch;
|
||||||
|
s.Language = SelectedLanguage;
|
||||||
s.RecentFiles = RecentFiles.ToList();
|
s.RecentFiles = RecentFiles.ToList();
|
||||||
|
s.QuickBarKeys = QuickBar.ToKeys();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void TouchRecent(string path)
|
private void TouchRecent(string path)
|
||||||
@@ -115,23 +139,18 @@ 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(); };
|
||||||
|
|
||||||
// 相機停止移動 180ms 後恢復邊線
|
// Restore edges after camera movement stops.
|
||||||
_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>由 MainWindow 注入 viewport(桌面工具,不做過度抽象)</summary>
|
/// <summary>Attaches the viewport from MainWindow.</summary>
|
||||||
public void Attach(HelixViewport3D viewport)
|
public void Attach(HelixViewport3D viewport)
|
||||||
{
|
{
|
||||||
_viewport = viewport;
|
_viewport = viewport;
|
||||||
// 相機一動就暫停邊線/逐面(轉動/縮放/平移皆觸發),停下 180ms 再恢復。
|
// The viewport event also catches camera instance replacements by HelixToolkit.
|
||||||
// 兩個事件都訂閱,互補保底:
|
|
||||||
// 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();
|
||||||
@@ -139,7 +158,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();
|
||||||
@@ -151,7 +170,7 @@ public partial class MainViewModel : ObservableObject
|
|||||||
SetEdgesActive(true);
|
SetEdgesActive(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>只切換 root 合併邊線的 viewport 掛載(不重建 Points)</summary>
|
/// <summary>Shows or hides merged root edges without rebuilding points.</summary>
|
||||||
private void SetEdgesActive(bool active)
|
private void SetEdgesActive(bool active)
|
||||||
{
|
{
|
||||||
if (_viewport is null) return;
|
if (_viewport is null) return;
|
||||||
@@ -160,47 +179,45 @@ public partial class MainViewModel : ObservableObject
|
|||||||
SyncVisual(root.EdgeVisual, active && root.EdgeVisual.Points.Count > 0);
|
SyncVisual(root.EdgeVisual, active && root.EdgeVisual.Points.Count > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>IsBusy 時停用會改動幾何/場景的指令(匯入、旋轉、干涉),避免背景運算期間狀態被改走造成結果錯位</summary>
|
/// <summary>Disables scene-changing commands while background work runs.</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()
|
||||||
{
|
{
|
||||||
var dlg = new OpenFileDialog
|
var dlg = new OpenFileDialog
|
||||||
{
|
{
|
||||||
Title = "匯入 CAD 檔案(可複選)",
|
Title = Get("Runtime.ImportTitle"),
|
||||||
Filter = "CAD 檔案 (*.stp;*.step;*.stl;*.dxf)|*.stp;*.step;*.stl;*.dxf|" +
|
Filter = Get("Runtime.ImportFilter"),
|
||||||
"STEP (*.stp;*.step)|*.stp;*.step|STL (*.stl)|*.stl|DXF (*.dxf)|*.dxf|所有檔案 (*.*)|*.*",
|
|
||||||
Multiselect = true,
|
Multiselect = true,
|
||||||
};
|
};
|
||||||
if (dlg.ShowDialog() == true)
|
if (dlg.ShowDialog() == true)
|
||||||
await ImportFilesAsync(dlg.FileNames);
|
await ImportFilesAsync(dlg.FileNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>MRU 下拉點選(檔案可能已被移走 → ImportFilesAsync 會報匯入失敗)</summary>
|
/// <summary>Imports a recently opened file.</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;
|
||||||
// 拖放/命令列可繞過指令 CanExecute 直接進來 → 匯入或背景運算期間直接擋掉,
|
// Reject overlapping imports because StepImportService has shared state.
|
||||||
// 避免兩批匯入交錯(StepImportService 有共享狀態,非重入安全)
|
if (IsBusy) { SetStatus(() => Get("Runtime.ImportBusy")); return; }
|
||||||
if (IsBusy) { StatusText = "忙碌中(匯入或運算進行中),請稍候再匯入"; return; }
|
|
||||||
int ok = 0, fail = 0;
|
int ok = 0, fail = 0;
|
||||||
IsBusy = true; // 整批匯入期間維持 busy(v0.4.0:不再逐檔開關,杜絕檔案之間的重入空窗)
|
IsBusy = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
foreach (string path in paths)
|
foreach (string path in paths)
|
||||||
{
|
{
|
||||||
string name = Path.GetFileNameWithoutExtension(path);
|
string name = Path.GetFileNameWithoutExtension(path);
|
||||||
StatusText = $"匯入中:{name} …";
|
SetStatus(() => Format("Runtime.Importing", name));
|
||||||
// 匯入階段回報(從背景執行緒來 → 切回 UI 執行緒)
|
// Marshal progress from the import thread to the UI thread.
|
||||||
_importService.Progress = msg =>
|
_importService.ProgressTextFor = textFor =>
|
||||||
System.Windows.Application.Current?.Dispatcher.BeginInvoke(
|
System.Windows.Application.Current?.Dispatcher.BeginInvoke(
|
||||||
() => StatusText = $"匯入 {name}:{msg}");
|
() => SetStatus(() => Format("Runtime.ImportProgress", name, textFor())));
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
ImportedFileData data = await Task.Run(() => _importService.Import(path));
|
ImportedFileData data = await Task.Run(() => _importService.Import(path));
|
||||||
@@ -211,8 +228,9 @@ public partial class MainViewModel : ObservableObject
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
fail++;
|
fail++;
|
||||||
StatusText = $"匯入失敗:{name} — {ex.Message}";
|
string error = ex.Message;
|
||||||
MessageBox.Show($"{path}\n\n{ex.Message}", "匯入失敗",
|
SetStatus(() => Format("Runtime.ImportFailed", name, error));
|
||||||
|
MessageBox.Show($"{path}\n\n{error}", Get("Runtime.ImportFailedTitle"),
|
||||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,7 +243,8 @@ public partial class MainViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
if (SectionEnabled) ApplySection();
|
if (SectionEnabled) ApplySection();
|
||||||
_viewport.ZoomExtents(500);
|
_viewport.ZoomExtents(500);
|
||||||
StatusText = $"匯入完成:成功 {ok} 檔" + (fail > 0 ? $"、失敗 {fail} 檔" : "");
|
int succeeded = ok, failed = fail;
|
||||||
|
SetStatus(() => Format("Runtime.ImportCompleted", succeeded, failed > 0 ? Format("Runtime.ImportFailedCount", failed) : ""));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,13 +257,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);
|
||||||
|
|
||||||
// 邊線總量過大 → 整檔預設關閉(cascade;使用者可再開)
|
// Disable edges by default for very large files.
|
||||||
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);
|
||||||
@@ -252,7 +271,7 @@ public partial class MainViewModel : ObservableObject
|
|||||||
_viewport!.Children.Add(leaf.BodyVisual);
|
_viewport!.Children.Add(leaf.BodyVisual);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 整檔合併成「一條」LinesVisual3D(轉動時只重建 1 條,避免逐零件 N 條造成卡頓)
|
// One merged edge visual per file avoids per-leaf rebuilds.
|
||||||
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
|
||||||
@@ -269,6 +288,8 @@ public partial class MainViewModel : ObservableObject
|
|||||||
var vm = new ModelNodeViewModel(n.Faces.Count > 0 || n.EdgeSegments is not null ? _paletteIndex++ : _paletteIndex)
|
var vm = new ModelNodeViewModel(n.Faces.Count > 0 || n.EdgeSegments is not null ? _paletteIndex++ : _paletteIndex)
|
||||||
{
|
{
|
||||||
Name = overrideName ?? n.Name,
|
Name = overrideName ?? n.Name,
|
||||||
|
NameKey = overrideName is null ? n.NameKey : null,
|
||||||
|
NameNumber = n.NameNumber,
|
||||||
FilePath = isRoot ? filePath : null,
|
FilePath = isRoot ? filePath : null,
|
||||||
SolidCount = n.SolidCount,
|
SolidCount = n.SolidCount,
|
||||||
FaceCount = n.FaceCount,
|
FaceCount = n.FaceCount,
|
||||||
@@ -279,7 +300,7 @@ public partial class MainViewModel : ObservableObject
|
|||||||
|
|
||||||
if (n.Faces.Count > 0)
|
if (n.Faces.Count > 0)
|
||||||
{
|
{
|
||||||
// leaf 共用材質(換色時只改 Brush)
|
// Share material across leaf faces for color changes.
|
||||||
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);
|
||||||
@@ -290,8 +311,7 @@ public partial class MainViewModel : ObservableObject
|
|||||||
backMat.Freeze();
|
backMat.Freeze();
|
||||||
vm.SharedMaterial = diffuse;
|
vm.SharedMaterial = diffuse;
|
||||||
|
|
||||||
// 一個 B-rep Face = 一個 GeometryModel3D,登錄反查表(剖面模式逐面拾取用)。
|
// Keep one model per face for section picking and record merged-mesh face boundaries.
|
||||||
// 同時依面序記錄每面頂點起始邊界 + 對應 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];
|
||||||
@@ -310,11 +330,9 @@ public partial class MainViewModel : ObservableObject
|
|||||||
vertStarts[n.Faces.Count] = vacc;
|
vertStarts[n.Faces.Count] = vacc;
|
||||||
vm.FacesContent = group;
|
vm.FacesContent = group;
|
||||||
|
|
||||||
// 合併網格:整零件所有面併成 1 個 GeometryModel3D(draw call 從每面 1 個降為每檔 1 個)。
|
// BuildMergedMesh concatenates faces in the same order as vertStarts.
|
||||||
// BuildMergedMesh 依 n.Faces 同序串接(baseIdx += positions.Count),與上面 vertStarts 對齊 → 命中頂點 index 可反查回面。
|
|
||||||
var mergedModel = new GeometryModel3D(BuildMergedMesh(n.Faces), matGroup);
|
var mergedModel = new GeometryModel3D(BuildMergedMesh(n.Faces), matGroup);
|
||||||
// 封閉實體背面不可見 → 不設 BackMaterial(WPF 兩面渲染成本砍半);開放殼/STL 仍需背面。
|
// Closed solids render front faces only; open shells and STL render both sides.
|
||||||
// 拾取不受材質影響(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();
|
||||||
@@ -322,14 +340,14 @@ public partial class MainViewModel : ObservableObject
|
|||||||
_mergedMap[mergedModel] = vm;
|
_mergedMap[mergedModel] = vm;
|
||||||
_mergedFaceRanges[mergedModel] = (vertStarts, faceInfos);
|
_mergedFaceRanges[mergedModel] = (vertStarts, faceInfos);
|
||||||
|
|
||||||
// 非剖面一律顯示合併網格(瀏覽/量測都是,量測靠 _mergedFaceRanges 反查面);只有剖面才逐面(裁切後幾何)
|
// Use merged meshes except in section mode, which needs separate clipped faces.
|
||||||
vm.BodyVisual = new ModelVisual3D
|
vm.BodyVisual = new ModelVisual3D
|
||||||
{
|
{
|
||||||
Content = SectionEnabled ? group : vm.MergedContent,
|
Content = SectionEnabled ? group : vm.MergedContent,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 邊線端點僅保存於 leaf(資料);實際 LinesVisual3D 由 root 合併建立(見 BuildRoot)
|
// Leaves hold edge data; roots hold the merged edge visual.
|
||||||
if (n.EdgeSegments is { Count: > 0 })
|
if (n.EdgeSegments is { Count: > 0 })
|
||||||
vm.OriginalEdgePoints = n.EdgeSegments;
|
vm.OriginalEdgePoints = n.EdgeSegments;
|
||||||
|
|
||||||
@@ -348,8 +366,7 @@ public partial class MainViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 重建整檔的合併邊線:收集所有「可見且開邊線」的 leaf 端點為單一 Point3DCollection。
|
/// Rebuilds one edge visual from visible leaves, clipping segments in section mode.
|
||||||
/// 剖面開啟時即時裁切。一檔只有一條 LinesVisual3D → 轉動視角不再逐零件重建。
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void RefreshRootEdges(ModelNodeViewModel root)
|
private void RefreshRootEdges(ModelNodeViewModel root)
|
||||||
{
|
{
|
||||||
@@ -376,9 +393,9 @@ public partial class MainViewModel : ObservableObject
|
|||||||
else if (!shouldShow && contains) _viewport.Children.Remove(visual);
|
else if (!shouldShow && contains) _viewport.Children.Remove(visual);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 合併網格(瀏覽模式降 draw call)────────────────────────
|
// Merged meshes
|
||||||
|
|
||||||
/// <summary>把多個面網格依序併成單一 MeshGeometry3D(索引位移後串接;順序 = 呼叫端給的面序)</summary>
|
/// <summary>Concatenates face meshes in the given order.</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;
|
||||||
@@ -405,7 +422,7 @@ public partial class MainViewModel : ObservableObject
|
|||||||
return mesh;
|
return mesh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>匯入時建合併網格(依 faces 面序串接,與 _mergedFaceRanges 的頂點邊界對齊)</summary>
|
/// <summary>Builds the import mesh in face order for merged hit resolution.</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);
|
||||||
@@ -413,8 +430,7 @@ public partial class MainViewModel : ObservableObject
|
|||||||
return MergeMeshes(meshes);
|
return MergeMeshes(meshes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>重建合併網格(位置變更後,如零件平移)。由 leaf 逐面 fi.Mesh(未剖切原始)依 FacesContent 子序串接,
|
/// <summary>Rebuilds a merged mesh after a transform without changing face vertex boundaries.</summary>
|
||||||
/// 與匯入時 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;
|
||||||
@@ -426,13 +442,11 @@ public partial class MainViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 依目前狀態切換每個 leaf 的渲染內容:
|
/// Uses merged meshes for browsing and measurement; section mode uses separate clipped faces.
|
||||||
/// 非剖面(瀏覽 + 量測皆是)→ 合併網格(每檔 1 個 model,快);量測拾取靠命中合併網格反查面(_mergedFaceRanges)。
|
|
||||||
/// 剖面 → 逐面(要顯示各面裁切後的幾何)。
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void ApplyRenderMode()
|
private void ApplyRenderMode()
|
||||||
{
|
{
|
||||||
// 量測不再需要逐面渲染(拾取打合併網格反查面)→ 只有剖面才逐面。永不在量測模式掛數萬個 GeometryModel3D。
|
// Measurement resolves merged-mesh hits back to their source faces.
|
||||||
bool merged = !SectionEnabled;
|
bool merged = !SectionEnabled;
|
||||||
foreach (ModelNodeViewModel leaf in Roots.SelectMany(r => r.Leaves()))
|
foreach (ModelNodeViewModel leaf in Roots.SelectMany(r => r.Leaves()))
|
||||||
{
|
{
|
||||||
@@ -443,7 +457,7 @@ public partial class MainViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 節點操作 ────────────────────────────────────────────────
|
// Node commands
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void ZoomNode(ModelNodeViewModel node)
|
private void ZoomNode(ModelNodeViewModel node)
|
||||||
@@ -455,14 +469,14 @@ public partial class MainViewModel : ObservableObject
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void ZoomAll() => _viewport?.ZoomExtents(400);
|
private void ZoomAll() => _viewport?.ZoomExtents(400);
|
||||||
|
|
||||||
/// <summary>標準視圖(機構慣例 Z 向上):Iso / Front / Back / Top / Bottom / Left / Right</summary>
|
/// <summary>Standard views with Z up.</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)), // 從 -Y 往 +Y 看
|
"Front" => (new Vector3D(0, 1, 0), new Vector3D(0, 0, 1)),
|
||||||
"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)),
|
||||||
@@ -476,13 +490,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; // 目標點 = Position + LookDirection = 場景中心
|
cam.LookDirection = dir * dist;
|
||||||
cam.UpDirection = up;
|
cam.UpDirection = up;
|
||||||
_viewport.ZoomExtents(400);
|
_viewport.ZoomExtents(400);
|
||||||
StatusText = $"視圖:{name}";
|
SetStatus(() => Format("Runtime.View", Get($"Runtime.View{name}")));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>正交投影切換(平行投影,量尺寸不受透視變形;Helix 保持視角換相機型別)</summary>
|
/// <summary>Toggles orthographic projection while preserving the view angle.</summary>
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool orthographicView;
|
private bool orthographicView;
|
||||||
|
|
||||||
@@ -490,7 +504,7 @@ public partial class MainViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
if (_viewport is null) return;
|
if (_viewport is null) return;
|
||||||
_viewport.Orthographic = value;
|
_viewport.Orthographic = value;
|
||||||
StatusText = value ? "正交投影(平行投影,適合尺寸確認)" : "透視投影";
|
SetStatus(() => Get(value ? "Runtime.Orthographic" : "Runtime.Perspective"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -498,16 +512,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;
|
||||||
|
|
||||||
// 量測 overlay 可能標在此檔模型上 → 一併清除(簡化:全清)
|
// Clear measurements that may be attached to the removed file.
|
||||||
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) // 逐面群組才在 _faceMap(與目前顯示哪種內容無關)
|
if (leaf.FacesContent is not null)
|
||||||
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)
|
||||||
@@ -520,18 +534,19 @@ public partial class MainViewModel : ObservableObject
|
|||||||
if (root.EdgeVisual is not null)
|
if (root.EdgeVisual is not null)
|
||||||
_viewport.Children.Remove(root.EdgeVisual);
|
_viewport.Children.Remove(root.EdgeVisual);
|
||||||
Roots.Remove(root);
|
Roots.Remove(root);
|
||||||
StatusText = $"已移除:{root.Name}";
|
string removedName = root.Name;
|
||||||
|
SetStatus(() => Format("Runtime.Removed", removedName));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 顯示控制 / 體積(樹右鍵選單)────────────────────────────
|
// Visibility and volume
|
||||||
|
|
||||||
/// <summary>只顯示此節點(其餘全部隱藏;「全部顯示」復原)</summary>
|
/// <summary>Shows only this node until ShowAll is called.</summary>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void IsolateNode(ModelNodeViewModel node)
|
private void IsolateNode(ModelNodeViewModel node)
|
||||||
{
|
{
|
||||||
foreach (ModelNodeViewModel r in Roots) SetVisibleRecursive(r, false);
|
foreach (ModelNodeViewModel r in Roots) SetVisibleRecursive(r, false);
|
||||||
SetVisibleRecursive(node, true);
|
SetVisibleRecursive(node, true);
|
||||||
StatusText = $"只顯示「{node.Name}」(樹右鍵 → 全部顯示 復原)";
|
SetStatus(() => Format("Runtime.Isolated", node.Name));
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -539,25 +554,24 @@ public partial class MainViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
foreach (ModelNodeViewModel l in Roots.SelectMany(r => r.Leaves()))
|
foreach (ModelNodeViewModel l in Roots.SelectMany(r => r.Leaves()))
|
||||||
l.IsVisible = !l.IsVisible;
|
l.IsVisible = !l.IsVisible;
|
||||||
StatusText = "已反轉顯示";
|
SetStatus(() => Get("Runtime.VisibilityInverted"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void ShowAll()
|
private void ShowAll()
|
||||||
{
|
{
|
||||||
foreach (ModelNodeViewModel r in Roots) SetVisibleRecursive(r, true);
|
foreach (ModelNodeViewModel r in Roots) SetVisibleRecursive(r, true);
|
||||||
StatusText = "已全部顯示";
|
SetStatus(() => Get("Runtime.AllVisible"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>逐節點顯式遞迴設定可見性。不能只設 root(setter 同值不觸發 cascade,
|
/// <summary>Sets visibility for every node, including mixed child states.</summary>
|
||||||
/// 之前的隔離操作可能留下「父關子開」的混合狀態)</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>體積/質心(網格 signed volume,封閉實體才可靠)→ 加進量測結果 + 質心標記</summary>
|
/// <summary>Adds a volume and centroid measurement for a closed mesh.</summary>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void MeasureVolume(ModelNodeViewModel node)
|
private void MeasureVolume(ModelNodeViewModel node)
|
||||||
{
|
{
|
||||||
@@ -570,14 +584,14 @@ public partial class MainViewModel : ObservableObject
|
|||||||
.ToList();
|
.ToList();
|
||||||
if (meshes.Count == 0)
|
if (meshes.Count == 0)
|
||||||
{
|
{
|
||||||
StatusText = $"「{node.Name}」沒有可計算的面網格(線架構無體積)";
|
SetStatus(() => Format("Runtime.VolumeNoMesh", node.Name));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
(double vol, Point3D centroid, bool reliable) = MeasurementService.MeshVolume(meshes);
|
(double vol, Point3D centroid, bool reliable) = MeasurementService.MeshVolume(meshes);
|
||||||
if (!reliable)
|
if (!reliable)
|
||||||
{
|
{
|
||||||
StatusText = $"「{node.Name}」非封閉網格(開放殼),無法可靠計算體積";
|
SetStatus(() => Format("Runtime.VolumeOpenMesh", node.Name));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,9 +604,7 @@ public partial class MainViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
Kind = MeasureMode.Volume,
|
Kind = MeasureMode.Volume,
|
||||||
TitleFor = u => $"{label} {name} V ≈ {Units.V(vol, u)}",
|
TitleFor = u => $"{label} {name} V ≈ {Units.V(vol, u)}",
|
||||||
DetailFor = u => $"體積 ≈ {Units.V(vol, u)}(網格近似)\n質心 {Units.P(centroid, u)}\n" +
|
DetailFor = u => Format("Runtime.VolumeDetail", Units.V(vol, u), Units.P(centroid, u), Units.C(b.SizeX, u), Units.C(b.SizeY, u), Units.C(b.SizeZ, u), u == UnitSystem.Millimeter ? "mm" : "in"),
|
||||||
$"外形(AABB){Units.C(b.SizeX, u)} × {Units.C(b.SizeY, u)} × {Units.C(b.SizeZ, u)} " +
|
|
||||||
(u == UnitSystem.Millimeter ? "mm" : "in"),
|
|
||||||
};
|
};
|
||||||
m.Overlays.Add(new SphereVisual3D { Center = centroid, Radius = markerR, Fill = Brushes.OrangeRed });
|
m.Overlays.Add(new SphereVisual3D { Center = centroid, Radius = markerR, Fill = Brushes.OrangeRed });
|
||||||
var volLabel = new BillboardTextVisual3D
|
var volLabel = new BillboardTextVisual3D
|
||||||
@@ -609,46 +621,47 @@ public partial class MainViewModel : ObservableObject
|
|||||||
AddMeasurement(m);
|
AddMeasurement(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 量測 ────────────────────────────────────────────────────
|
// Measurement
|
||||||
|
|
||||||
partial void OnCurrentModeChanged(MeasureMode value)
|
partial void OnCurrentModeChanged(MeasureMode value)
|
||||||
{
|
{
|
||||||
CancelPending();
|
CancelPending();
|
||||||
ApplyRenderMode(); // 非剖面一律合併網格(量測拾取靠反查面);不再因切量測模式而掛逐面
|
ApplyRenderMode();
|
||||||
StatusText = value switch
|
string? key = value switch
|
||||||
{
|
{
|
||||||
MeasureMode.None => "瀏覽模式(右鍵旋轉、滾輪縮放、中鍵或 Shift+左鍵拖曳平移)",
|
MeasureMode.None => "Runtime.ModeBrowse",
|
||||||
MeasureMode.Point => "點量測:點擊模型表面(自動吸附鄰近頂點)",
|
MeasureMode.Point => "Runtime.ModePoint",
|
||||||
MeasureMode.Distance => "距離量測:點選第 1 點",
|
MeasureMode.Distance => "Runtime.ModeDistance",
|
||||||
MeasureMode.Edge => "邊量測:點擊靠近要量的邊",
|
MeasureMode.Edge => "Runtime.ModeEdge",
|
||||||
MeasureMode.Face => "面量測:點擊要量的面",
|
MeasureMode.Face => "Runtime.ModeFace",
|
||||||
MeasureMode.Circle => "圓量測:點擊圓孔內壁或圓邊附近",
|
MeasureMode.Circle => "Runtime.ModeCircle",
|
||||||
MeasureMode.Angle => "角度量測:點選第 1 個面(或靠近直線邊)",
|
MeasureMode.Angle => "Runtime.ModeAngle",
|
||||||
MeasureMode.FaceDistance => "面距量測:點選第 1 個面",
|
MeasureMode.FaceDistance => "Runtime.ModeFaceDistance",
|
||||||
MeasureMode.Align => "兩點對齊:先點「要移動零件」上的點,再點目標點(純平移,量測會清空)",
|
MeasureMode.Align => "Runtime.ModeAlign",
|
||||||
MeasureMode.Align3 => "三點對齊:先在「要移動的檔案」點 3 個特徵點,再到目標檔案點 3 個對應點(旋轉+平移)",
|
MeasureMode.Align3 => "Runtime.ModeAlign3",
|
||||||
MeasureMode.Drag => "拖曳模式:左鍵按住零件拖動(沿螢幕平面),放開定位;深度方向先轉視角再拖。右鍵仍可轉視角",
|
MeasureMode.Drag => "Runtime.ModeDrag",
|
||||||
_ => StatusText,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
if (key is not null) SetStatus(() => Get(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnUseInchChanged(bool value)
|
partial void OnUseInchChanged(bool value)
|
||||||
{
|
{
|
||||||
UnitSystem u = CurrentUnit;
|
UnitSystem u = CurrentUnit;
|
||||||
foreach (MeasurementResult m in Measurements) m.SetUnit(u);
|
foreach (MeasurementResult m in Measurements) m.SetUnit(u);
|
||||||
StatusText = value ? "顯示單位:inch" : "顯示單位:mm";
|
SetStatus(() => Get(value ? "Runtime.UnitInch" : "Runtime.UnitMillimeter"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Esc(MainWindow 轉發):先取消進行中的多段量測(保留模式),再按退回瀏覽模式;再退操作器</summary>
|
/// <summary>Cancels a pending pick, then exits measurement mode or closes the gizmo.</summary>
|
||||||
public void OnEscape()
|
public void OnEscape()
|
||||||
{
|
{
|
||||||
if (CancelSectionPickIfActive()) return; // 3點剖面定義中 → 先取消定義
|
if (CancelSectionPickIfActive()) return;
|
||||||
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)
|
||||||
{
|
{
|
||||||
CancelPending();
|
CancelPending();
|
||||||
StatusText = "已取消目前量測起點(再按 Esc 退出量測模式)";
|
SetStatus(() => Get("Runtime.MeasurementStartCancelled"));
|
||||||
}
|
}
|
||||||
else if (CurrentMode != MeasureMode.None)
|
else if (CurrentMode != MeasureMode.None)
|
||||||
{
|
{
|
||||||
@@ -660,7 +673,7 @@ public partial class MainViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>量測模式快速鍵(MainWindow 轉發;無修飾鍵)。同鍵再按 = 退回瀏覽。回傳 false 表示非快速鍵。</summary>
|
/// <summary>Handles measurement hotkeys; repeating a key returns to browse mode.</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
|
||||||
@@ -679,11 +692,11 @@ public partial class MainViewModel : ObservableObject
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>MainWindow 滑鼠左鍵點擊轉發進來</summary>
|
/// <summary>Handles left-clicks forwarded by 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; // 3點剖面定義模式優先吃點擊(v0.5.0)
|
if (HandleSectionPlanePick(position)) return;
|
||||||
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);
|
||||||
@@ -693,13 +706,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;
|
||||||
// 剖面模式:渲染逐面 → 直接查 _faceMap
|
// Section mode renders individual faces.
|
||||||
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;
|
||||||
}
|
}
|
||||||
// 量測/瀏覽模式:渲染合併網格 → 用命中三角形頂點 index 反查回是哪個面
|
// Other modes resolve merged-mesh vertex indices to faces.
|
||||||
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);
|
||||||
@@ -712,7 +725,7 @@ public partial class MainViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
if (fi is null)
|
if (fi is null)
|
||||||
{
|
{
|
||||||
StatusText = "未命中模型表面";
|
SetStatus(() => Get("Runtime.NoSurfaceHit"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -722,17 +735,18 @@ public partial class MainViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
StatusText = $"量測失敗:{ex.Message}";
|
string error = ex.Message;
|
||||||
|
SetStatus(() => Format("Runtime.MeasureFailed", error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>命中合併網格的某頂點 index → 反查回是哪個面(依面序的頂點起始邊界二分搜尋)。</summary>
|
/// <summary>Finds the source face for a merged-mesh vertex by binary search.</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;
|
||||||
// 找最大的 k 使 starts[k] <= vertexIndex(face k 佔頂點 [starts[k], starts[k+1]))
|
// Find the largest face start not exceeding the hit vertex index.
|
||||||
int lo = 0, hi = faces.Length - 1, ans = 0;
|
int lo = 0, hi = faces.Length - 1, ans = 0;
|
||||||
while (lo <= hi)
|
while (lo <= hi)
|
||||||
{
|
{
|
||||||
@@ -749,10 +763,10 @@ 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);
|
||||||
|
|
||||||
// 無 B-rep 來源(STL)只支援部分模式
|
// STL has no B-rep edges or faces for these modes.
|
||||||
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)
|
||||||
{
|
{
|
||||||
StatusText = "此模型無 B-rep(STL/網格),僅支援 點 / 距離 / 角度 / 面距 量測";
|
SetStatus(() => Get("Runtime.MeshMeasurementLimited"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -771,7 +785,7 @@ public partial class MainViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
_pendingPoint = p;
|
_pendingPoint = p;
|
||||||
AddPendingMarker(p, markerR);
|
AddPendingMarker(p, markerR);
|
||||||
StatusText = $"第 1 點 ({p.X:F2}, {p.Y:F2}, {p.Z:F2}) — 點選第 2 點";
|
SetStatus(() => Format("Runtime.DistanceFirstPoint", p.X, p.Y, p.Z));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -785,7 +799,7 @@ public partial class MainViewModel : ObservableObject
|
|||||||
case MeasureMode.Edge:
|
case MeasureMode.Edge:
|
||||||
{
|
{
|
||||||
var result = _measureService.MeasureEdge(fi, hit, NextLabel(MeasureMode.Edge, "E"), markerR, circlesOnly: false);
|
var result = _measureService.MeasureEdge(fi, hit, NextLabel(MeasureMode.Edge, "E"), markerR, circlesOnly: false);
|
||||||
if (result is null) { StatusText = "此面沒有可量測的邊"; UndoLabel(MeasureMode.Edge); }
|
if (result is null) { SetStatus(() => Get("Runtime.NoMeasurableEdge")); UndoLabel(MeasureMode.Edge); }
|
||||||
else AddMeasurement(result);
|
else AddMeasurement(result);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -797,7 +811,7 @@ public partial class MainViewModel : ObservableObject
|
|||||||
case MeasureMode.Circle:
|
case MeasureMode.Circle:
|
||||||
{
|
{
|
||||||
var result = _measureService.MeasureEdge(fi, hit, NextLabel(MeasureMode.Circle, "C"), markerR, circlesOnly: true);
|
var result = _measureService.MeasureEdge(fi, hit, NextLabel(MeasureMode.Circle, "C"), markerR, circlesOnly: true);
|
||||||
if (result is null) { StatusText = "此面沒有圓形邊(請點圓孔內壁或圓邊附近)"; UndoLabel(MeasureMode.Circle); }
|
if (result is null) { SetStatus(() => Get("Runtime.NoCircularEdge")); UndoLabel(MeasureMode.Circle); }
|
||||||
else AddMeasurement(result);
|
else AddMeasurement(result);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -808,7 +822,7 @@ public partial class MainViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
_pendingDirection = pick;
|
_pendingDirection = pick;
|
||||||
AddPendingMarker(pick.At, markerR);
|
AddPendingMarker(pick.At, markerR);
|
||||||
StatusText = $"已選第 1 個方向({pick.Desc})— 點選第 2 個面或邊";
|
SetStatus(() => Format("Runtime.AngleFirstDirection", Get(pick.Desc)));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -825,11 +839,11 @@ public partial class MainViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
_pendingFace = fi;
|
_pendingFace = fi;
|
||||||
AddPendingMarker(hit, markerR);
|
AddPendingMarker(hit, markerR);
|
||||||
StatusText = "已選第 1 個面 — 點選另一個面";
|
SetStatus(() => Get("Runtime.FaceDistanceFirstFace"));
|
||||||
}
|
}
|
||||||
else if (ReferenceEquals(_pendingFace, fi))
|
else if (ReferenceEquals(_pendingFace, fi))
|
||||||
{
|
{
|
||||||
StatusText = "兩次點到同一個面,請點選另一個面";
|
SetStatus(() => Get("Runtime.SameFace"));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -844,16 +858,16 @@ public partial class MainViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
Point3D p = _measureService.Snap(fi, hit, snapTol);
|
Point3D p = _measureService.Snap(fi, hit, snapTol);
|
||||||
ModelNodeViewModel? root = FindRootOf(fi.Owner);
|
ModelNodeViewModel? root = FindRootOf(fi.Owner);
|
||||||
if (root is null) { StatusText = "找不到此零件所屬檔案"; break; }
|
if (root is null) { SetStatus(() => Get("Runtime.RootNotFound")); break; }
|
||||||
if (_pendingAlign is null)
|
if (_pendingAlign is null)
|
||||||
{
|
{
|
||||||
_pendingAlign = (root, p);
|
_pendingAlign = (root, p);
|
||||||
AddPendingMarker(p, markerR);
|
AddPendingMarker(p, markerR);
|
||||||
StatusText = $"將移動「{root.Name}」— 點選目標點(另一個檔案上)";
|
SetStatus(() => Format("Runtime.AlignMoving", root.Name));
|
||||||
}
|
}
|
||||||
else if (ReferenceEquals(_pendingAlign.Value.Root, root))
|
else if (ReferenceEquals(_pendingAlign.Value.Root, root))
|
||||||
{
|
{
|
||||||
StatusText = "目標點要點在另一個檔案上(兩點同屬一個檔案無法對齊)";
|
SetStatus(() => Get("Runtime.AlignOtherFile"));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -861,8 +875,7 @@ public partial class MainViewModel : ObservableObject
|
|||||||
Vector3D offset = p - from;
|
Vector3D offset = p - from;
|
||||||
CancelPending();
|
CancelPending();
|
||||||
TranslateRoot(moveRoot, offset);
|
TranslateRoot(moveRoot, offset);
|
||||||
StatusText = $"已移動「{moveRoot.Name}」 Δ({offset.X:F3}, {offset.Y:F3}, {offset.Z:F3}) mm" +
|
SetStatus(() => Format("Runtime.AlignMoved", moveRoot.Name, offset.X, offset.Y, offset.Z));
|
||||||
"(量測已清空;可用 ⇔面距 / 🧩干涉 驗證配合)";
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -870,43 +883,46 @@ public partial class MainViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
Point3D p = _measureService.Snap(fi, hit, snapTol);
|
Point3D p = _measureService.Snap(fi, hit, snapTol);
|
||||||
ModelNodeViewModel? root = FindRootOf(fi.Owner);
|
ModelNodeViewModel? root = FindRootOf(fi.Owner);
|
||||||
if (root is null) { StatusText = "找不到此零件所屬檔案"; break; }
|
if (root is null) { SetStatus(() => Get("Runtime.RootNotFound")); break; }
|
||||||
|
|
||||||
if (_align3.Count < 3) // 來源 3 點(要移動的檔案)
|
if (_align3.Count < 3)
|
||||||
{
|
{
|
||||||
if (_align3.Count > 0 && !ReferenceEquals(_align3[0].Root, root))
|
if (_align3.Count > 0 && !ReferenceEquals(_align3[0].Root, root))
|
||||||
{
|
{
|
||||||
StatusText = $"來源 3 點要同一個檔案(要移動:{_align3[0].Root.Name})";
|
ModelNodeViewModel sourceRoot = _align3[0].Root;
|
||||||
|
SetStatus(() => Format("Runtime.Align3SameSource", sourceRoot.Name));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (_align3.Count == 2 && RigidAlign.Collinear(_align3[0].P, _align3[1].P, p))
|
if (_align3.Count == 2 && RigidAlign.Collinear(_align3[0].P, _align3[1].P, p))
|
||||||
{
|
{
|
||||||
StatusText = "三點共線,請改選不共線的第 3 點";
|
SetStatus(() => Get("Runtime.Align3SourceCollinear"));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
_align3.Add((root, p));
|
_align3.Add((root, p));
|
||||||
AddPendingMarker(p, markerR);
|
AddPendingMarker(p, markerR);
|
||||||
StatusText = _align3.Count < 3
|
int sourceCount = _align3.Count;
|
||||||
? $"來源點 {_align3.Count}/3 — 繼續點「{root.Name}」上的點"
|
SetStatus(() => sourceCount < 3
|
||||||
: "來源 3 點完成 — 換點「目標檔案」上的對應點 1/3";
|
? Format("Runtime.Align3SourcePoint", sourceCount, root.Name)
|
||||||
|
: Get("Runtime.Align3SourceComplete"));
|
||||||
}
|
}
|
||||||
else // 目標 3 點(另一個檔案,順序對應來源點)
|
else
|
||||||
{
|
{
|
||||||
if (ReferenceEquals(_align3[0].Root, root))
|
if (ReferenceEquals(_align3[0].Root, root))
|
||||||
{
|
{
|
||||||
StatusText = "目標點要點在另一個檔案上";
|
SetStatus(() => Get("Runtime.Align3OtherFile"));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (_align3.Count == 5 && RigidAlign.Collinear(_align3[3].P, _align3[4].P, p))
|
if (_align3.Count == 5 && RigidAlign.Collinear(_align3[3].P, _align3[4].P, p))
|
||||||
{
|
{
|
||||||
StatusText = "目標三點共線,請改選不共線的第 3 點";
|
SetStatus(() => Get("Runtime.Align3TargetCollinear"));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
_align3.Add((root, p));
|
_align3.Add((root, p));
|
||||||
AddPendingMarker(p, markerR);
|
AddPendingMarker(p, markerR);
|
||||||
if (_align3.Count < 6)
|
if (_align3.Count < 6)
|
||||||
{
|
{
|
||||||
StatusText = $"目標點 {_align3.Count - 3}/3 — 繼續點目標檔案上的對應點";
|
int targetCount = _align3.Count - 3;
|
||||||
|
SetStatus(() => Format("Runtime.Align3TargetPoint", targetCount));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -916,11 +932,11 @@ public partial class MainViewModel : ObservableObject
|
|||||||
CancelPending();
|
CancelPending();
|
||||||
if (!RigidAlign.TryRigidTransform(p1, p2, p3, q1, q2, q3, out Matrix3D m))
|
if (!RigidAlign.TryRigidTransform(p1, p2, p3, q1, q2, q3, out Matrix3D m))
|
||||||
{
|
{
|
||||||
StatusText = "三點對齊失敗(點位退化),請重新選點";
|
SetStatus(() => Get("Runtime.Align3Failed"));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
TransformRoot(moveRoot, RigidAlign.ToModOp(m), m);
|
TransformRoot(moveRoot, RigidAlign.ToModOp(m), m);
|
||||||
StatusText = $"已三點對齊「{moveRoot.Name}」(旋轉+平移;點1精確貼合,量測已清空,可用 🧩干涉 驗證)";
|
SetStatus(() => Format("Runtime.Align3Completed", moveRoot.Name));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -955,7 +971,8 @@ public partial class MainViewModel : ObservableObject
|
|||||||
Measurements.Add(result);
|
Measurements.Add(result);
|
||||||
foreach (Visual3D v in result.Overlays)
|
foreach (Visual3D v in result.Overlays)
|
||||||
_viewport!.Children.Add(v);
|
_viewport!.Children.Add(v);
|
||||||
StatusText = $"✓ {result.Title}";
|
Func<UnitSystem, string> titleFor = result.TitleFor;
|
||||||
|
SetStatus(() => $"✓ {titleFor(CurrentUnit)}");
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -975,7 +992,7 @@ public partial class MainViewModel : ObservableObject
|
|||||||
Measurements.Clear();
|
Measurements.Clear();
|
||||||
_counters.Clear();
|
_counters.Clear();
|
||||||
CancelPending();
|
CancelPending();
|
||||||
StatusText = "已清除全部量測";
|
SetStatus(() => Get("Runtime.MeasurementsCleared"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CancelPending()
|
private void CancelPending()
|
||||||
@@ -990,7 +1007,7 @@ public partial class MainViewModel : ObservableObject
|
|||||||
_align3.Clear();
|
_align3.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 零件剛體變換(兩點對齊=平移、旋轉 90°、三點對齊=旋轉+平移)──
|
// Rigid part transforms
|
||||||
|
|
||||||
private void TranslateRoot(ModelNodeViewModel root, Vector3D v)
|
private void TranslateRoot(ModelNodeViewModel root, Vector3D v)
|
||||||
{
|
{
|
||||||
@@ -1001,27 +1018,25 @@ public partial class MainViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 對整個檔案(root)套剛體變換:B-rep 用 ModOp 整體 Modify(量測維持精確)、
|
/// Transforms B-rep objects, meshes, edges and bounds for one file.
|
||||||
/// 渲染網格 / 合併網格 / 邊線端點同步重算、邊界重新計算。
|
/// 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();
|
||||||
|
|
||||||
// B-rep:對 Solid/Shell 整體 Modify(避免共用邊被逐面重複變換)。
|
// Transform whole B-rep objects sequentially to avoid shared-edge changes.
|
||||||
// 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)
|
||||||
{
|
{
|
||||||
try { g.Modify(op); }
|
try { g.Modify(op); }
|
||||||
catch { /* 個別物件變換失敗不中斷 */ }
|
catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 渲染網格:面級平行變換(來源/輸出皆 frozen、各面獨立 → 安全;大檔 64k 面吃滿多核)
|
// Transform independent frozen face meshes in parallel.
|
||||||
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)
|
||||||
@@ -1031,7 +1046,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++) // 視覺樹賦值回 UI 執行緒循序做
|
for (int i = 0; i < faceItems.Count; i++)
|
||||||
{
|
{
|
||||||
faceItems[i].Fi.Mesh = moved[i];
|
faceItems[i].Fi.Mesh = moved[i];
|
||||||
faceItems[i].Gm.Geometry = moved[i];
|
faceItems[i].Gm.Geometry = moved[i];
|
||||||
@@ -1040,15 +1055,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(); // 內含 RefreshRootEdges
|
if (SectionEnabled) ApplySection();
|
||||||
else RefreshRootEdges(root);
|
else RefreshRootEdges(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1059,7 +1074,7 @@ public partial class MainViewModel : ObservableObject
|
|||||||
var mesh = new MeshGeometry3D
|
var mesh = new MeshGeometry3D
|
||||||
{
|
{
|
||||||
Positions = positions,
|
Positions = positions,
|
||||||
TriangleIndices = src.TriangleIndices, // frozen,可共用(法向量缺省由 WPF 自算)
|
TriangleIndices = src.TriangleIndices,
|
||||||
};
|
};
|
||||||
mesh.Freeze();
|
mesh.Freeze();
|
||||||
return mesh;
|
return mesh;
|
||||||
@@ -1073,7 +1088,7 @@ public partial class MainViewModel : ObservableObject
|
|||||||
return pts;
|
return pts;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>旋轉後 AABB 不能只平移 → 由網格/邊線重新計算(bottom-up)</summary>
|
/// <summary>Recomputes bounds from transformed meshes and edges.</summary>
|
||||||
private Rect3D RecomputeBounds(ModelNodeViewModel node)
|
private Rect3D RecomputeBounds(ModelNodeViewModel node)
|
||||||
{
|
{
|
||||||
Rect3D b = Rect3D.Empty;
|
Rect3D b = Rect3D.Empty;
|
||||||
@@ -1095,7 +1110,7 @@ public partial class MainViewModel : ObservableObject
|
|||||||
return b;
|
return b;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 旋轉 90°(樹面板選取的檔案,繞其中心)──────────────────
|
// Rotate selected file
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(IsIdle))]
|
[RelayCommand(CanExecute = nameof(IsIdle))]
|
||||||
private void RotateRoot(string axisName)
|
private void RotateRoot(string axisName)
|
||||||
@@ -1104,7 +1119,7 @@ public partial class MainViewModel : ObservableObject
|
|||||||
: Roots.Count == 1 ? Roots[0] : null;
|
: Roots.Count == 1 ? Roots[0] : null;
|
||||||
if (root is null)
|
if (root is null)
|
||||||
{
|
{
|
||||||
StatusText = "請先在樹面板點選要旋轉的檔案(任一節點即可)";
|
SetStatus(() => Get("Runtime.RotateSelectFile"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Rect3D b = root.Bounds;
|
Rect3D b = root.Bounds;
|
||||||
@@ -1120,7 +1135,7 @@ public partial class MainViewModel : ObservableObject
|
|||||||
var m = Matrix3D.Identity;
|
var m = Matrix3D.Identity;
|
||||||
m.RotateAt(new Quaternion(axis, 90), center);
|
m.RotateAt(new Quaternion(axis, 90), center);
|
||||||
TransformRoot(root, RigidAlign.ToModOp(m), m);
|
TransformRoot(root, RigidAlign.ToModOp(m), m);
|
||||||
StatusText = $"已旋轉「{root.Name}」繞 {axisName} 軸 +90°(再按可繼續轉)";
|
SetStatus(() => Format("Runtime.Rotated", root.Name, axisName));
|
||||||
}
|
}
|
||||||
|
|
||||||
private ModelNodeViewModel? RootContaining(ModelNodeViewModel node) =>
|
private ModelNodeViewModel? RootContaining(ModelNodeViewModel node) =>
|
||||||
@@ -1129,9 +1144,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));
|
||||||
|
|
||||||
// 三點對齊的剛體變換數學在 Services/RigidAlign.cs(SmokeTest --align-test 驗證)
|
// Three-point alignment math is in RigidAlign.
|
||||||
|
|
||||||
// ─── 共用 ────────────────────────────────────────────────────
|
// Shared helpers
|
||||||
|
|
||||||
private Rect3D UnionBounds()
|
private Rect3D UnionBounds()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,34 +7,35 @@ using System.Windows.Media.Media3D;
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using HelixToolkit.Wpf;
|
using HelixToolkit.Wpf;
|
||||||
|
using STPViewer.Services;
|
||||||
|
|
||||||
namespace STPViewer.ViewModels;
|
namespace STPViewer.ViewModels;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 裝配樹節點:root = 匯入檔、group = STEP 裝配(Block)、leaf = 零件幾何。
|
/// Assembly tree node: imported file, STEP group, or geometry leaf.
|
||||||
/// 可見性 / 邊線 / 顏色設定向下層疊(cascade)。
|
/// Visibility, edges, and color cascade to children.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class ModelNodeViewModel : ObservableObject
|
public partial class ModelNodeViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
/// <summary>調色盤(每個 leaf 依序取色,點色塊循環切換)</summary>
|
/// <summary>Palette used when assigning and cycling leaf colors.</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>輪廓邊線開關(邊線量過大的檔案匯入時自動關閉,避免轉動視角卡頓)</summary>
|
/// <summary>Outline visibility.</summary>
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool showEdges = true;
|
private bool showEdges = true;
|
||||||
|
|
||||||
@@ -44,9 +45,17 @@ public partial class ModelNodeViewModel : ObservableObject
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private Color color;
|
private Color color;
|
||||||
|
|
||||||
public string Name { get; init; } = "";
|
public string Name
|
||||||
|
{
|
||||||
|
get => NameKey is null ? _name : NameNumber > 0 && NameKey == "Runtime.MeshNameNumbered"
|
||||||
|
? LocalizationService.Format(NameKey, NameNumber) : LocalizationService.Get(NameKey);
|
||||||
|
init => _name = value;
|
||||||
|
}
|
||||||
|
private string _name = "";
|
||||||
|
public string? NameKey { get; init; }
|
||||||
|
public int NameNumber { get; init; }
|
||||||
|
|
||||||
/// <summary>root(檔案層級)才有值</summary>
|
/// <summary>Set only on imported file roots.</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;
|
||||||
|
|
||||||
@@ -57,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>世界座標邊界(零件平移/旋轉後由 RecomputeBounds 更新;連動 Stats/Tooltip 的外形尺寸)</summary>
|
/// <summary>World-space bounds updated after part transforms.</summary>
|
||||||
public Rect3D Bounds
|
public Rect3D Bounds
|
||||||
{
|
{
|
||||||
get => _bounds;
|
get => _bounds;
|
||||||
@@ -71,31 +80,39 @@ public partial class ModelNodeViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
private Rect3D _bounds = Rect3D.Empty;
|
private Rect3D _bounds = Rect3D.Empty;
|
||||||
|
|
||||||
/// <summary>外形尺寸 L×W×H(AABB,mm)</summary>
|
/// <summary>Axis-aligned size in millimeters.</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>來源 B-rep 物件(零件平移時整體 Modify,避免共用邊被重複位移)</summary>
|
/// <summary>Source B-rep objects transformed as whole parts.</summary>
|
||||||
public List<CADability.GeoObject.IGeoObject> SourceGeos { get; } = new();
|
public List<CADability.GeoObject.IGeoObject> SourceGeos { get; } = new();
|
||||||
|
|
||||||
/// <summary>false = STL/曲線等無 B-rep 來源(邊/面/圓量測不可用)</summary>
|
/// <summary>False for STL or curve-only sources without B-rep geometry.</summary>
|
||||||
public bool HasBrep { get; init; } = true;
|
public bool HasBrep { get; init; } = true;
|
||||||
|
|
||||||
public string Stats =>
|
public string Stats =>
|
||||||
$"{SolidCount} 實體 · {FaceCount} 面 · {TriangleCount:N0} △" + (HasBrep ? "" : " · 無B-rep") +
|
LocalizationService.Format("Runtime.NodeStats", SolidCount, FaceCount, TriangleCount) +
|
||||||
(Bounds.IsEmpty ? "" : $"\n外形 {SizeText}");
|
(HasBrep ? "" : LocalizationService.Get("Runtime.NoBrep")) +
|
||||||
|
(Bounds.IsEmpty ? "" : LocalizationService.Format("Runtime.OutlineSize", SizeText));
|
||||||
|
|
||||||
public string ToolTipText => (FilePath is not null ? FilePath + "\n" : "") + Stats;
|
public string ToolTipText => (FilePath is not null ? FilePath + "\n" : "") + Stats;
|
||||||
|
|
||||||
// ── 樹搜尋/過濾(只影響樹面板顯示,不影響 3D 可見性)──
|
public void RefreshLocalizedText()
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(Name));
|
||||||
|
OnPropertyChanged(nameof(Stats));
|
||||||
|
OnPropertyChanged(nameof(ToolTipText));
|
||||||
|
foreach (ModelNodeViewModel child in Children) child.RefreshLocalizedText();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tree filtering does not change viewport visibility.
|
||||||
|
|
||||||
[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)
|
||||||
{
|
{
|
||||||
@@ -109,17 +126,17 @@ public partial class ModelNodeViewModel : ObservableObject
|
|||||||
return IsFilterVisible;
|
return IsFilterVisible;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 視覺物件 ──
|
// Viewport objects
|
||||||
public ModelVisual3D? BodyVisual { get; set; } // leaf:面網格容器(內容在兩種模式間切換)
|
public ModelVisual3D? BodyVisual { get; set; }
|
||||||
public Model3DGroup? FacesContent { get; set; } // leaf:逐面 GeometryModel3D(量測拾取用)
|
public Model3DGroup? FacesContent { get; set; }
|
||||||
public Model3DGroup? MergedContent { get; set; } // leaf:整零件合併成 1 個 GeometryModel3D(瀏覽用,draw call 大減)
|
public Model3DGroup? MergedContent { get; set; }
|
||||||
public LinesVisual3D? EdgeVisual { get; set; } // root:整檔合併邊線(一檔一條,避免轉動卡頓)
|
public LinesVisual3D? EdgeVisual { get; set; }
|
||||||
public DiffuseMaterial? SharedMaterial { get; set; }
|
public DiffuseMaterial? SharedMaterial { get; set; }
|
||||||
|
|
||||||
/// <summary>原始(未剖切)邊線端點 — 剖面還原用</summary>
|
/// <summary>Unclipped edge endpoints used for section restoration.</summary>
|
||||||
public Point3DCollection? OriginalEdgePoints { get; set; }
|
public Point3DCollection? OriginalEdgePoints { get; set; }
|
||||||
|
|
||||||
/// <summary>leaf 的可見性/邊線狀態變更(MainViewModel 同步 viewport)</summary>
|
/// <summary>Signals the ViewModel to update leaf viewport objects.</summary>
|
||||||
public event Action<ModelNodeViewModel>? VisualStateChanged;
|
public event Action<ModelNodeViewModel>? VisualStateChanged;
|
||||||
|
|
||||||
private int _paletteIndex;
|
private int _paletteIndex;
|
||||||
@@ -130,7 +147,7 @@ public partial class ModelNodeViewModel : ObservableObject
|
|||||||
color = Palette[_paletteIndex];
|
color = Palette[_paletteIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>所有 leaf 後代(含自身若為 leaf)</summary>
|
/// <summary>All descendant leaves, including this node when it is a leaf.</summary>
|
||||||
public IEnumerable<ModelNodeViewModel> Leaves()
|
public IEnumerable<ModelNodeViewModel> Leaves()
|
||||||
{
|
{
|
||||||
if (IsLeafNode) { yield return this; yield break; }
|
if (IsLeafNode) { yield return this; yield break; }
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using STPViewer.Services;
|
||||||
|
|
||||||
|
namespace STPViewer.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>Visibility toggle for one quick bar button.</summary>
|
||||||
|
public partial class QuickBarItemViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
public string Key { get; }
|
||||||
|
public string Label => LocalizationService.Get(_labelKey);
|
||||||
|
private readonly string _labelKey;
|
||||||
|
public bool Default { get; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isChecked;
|
||||||
|
|
||||||
|
public QuickBarItemViewModel(string key, string label, bool @default)
|
||||||
|
{
|
||||||
|
Key = key;
|
||||||
|
_labelKey = label;
|
||||||
|
Default = @default;
|
||||||
|
isChecked = @default;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RefreshText() => OnPropertyChanged(nameof(Label));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Quick bar button registry and saved visibility selections.
|
||||||
|
/// </summary>
|
||||||
|
public partial class QuickBarViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
/// <summary>Button order, label key, and default visibility.</summary>
|
||||||
|
private static readonly (string Key, string Label, bool Default)[] Registry =
|
||||||
|
{
|
||||||
|
("Import", "QuickBar.Import", true),
|
||||||
|
("Recent", "QuickBar.Recent", true),
|
||||||
|
("ZoomAll", "QuickBar.ZoomAll", true),
|
||||||
|
("ViewIso", "QuickBar.ViewIso", true),
|
||||||
|
("ViewFront", "QuickBar.ViewFront", false),
|
||||||
|
("ViewTop", "QuickBar.ViewTop", false),
|
||||||
|
("ViewRight", "QuickBar.ViewRight", false),
|
||||||
|
("Ortho", "QuickBar.Ortho", true),
|
||||||
|
("Point", "QuickBar.Point", true),
|
||||||
|
("Distance", "QuickBar.Distance", true),
|
||||||
|
("Edge", "QuickBar.Edge", false),
|
||||||
|
("Face", "QuickBar.Face", false),
|
||||||
|
("Circle", "QuickBar.Circle", true),
|
||||||
|
("Angle", "QuickBar.Angle", true),
|
||||||
|
("FaceDistance", "QuickBar.FaceDistance", false),
|
||||||
|
("Align", "QuickBar.Align", false),
|
||||||
|
("Align3", "QuickBar.Align3", false),
|
||||||
|
("RotateX", "QuickBar.RotateX", false),
|
||||||
|
("RotateY", "QuickBar.RotateY", false),
|
||||||
|
("RotateZ", "QuickBar.RotateZ", false),
|
||||||
|
("Drag", "QuickBar.Drag", false),
|
||||||
|
("Gizmo", "QuickBar.Gizmo", false),
|
||||||
|
("Interference", "QuickBar.Interference", true),
|
||||||
|
("Section", "QuickBar.Section", true),
|
||||||
|
("Clear", "QuickBar.Clear", true),
|
||||||
|
("Unit", "QuickBar.Unit", true),
|
||||||
|
("ExportCsv", "QuickBar.ExportCsv", false),
|
||||||
|
("Screenshot", "QuickBar.Screenshot", false),
|
||||||
|
("ExportStep", "QuickBar.ExportStep", false),
|
||||||
|
("ExportStl", "QuickBar.ExportStl", false),
|
||||||
|
("Language", "QuickBar.Language", true),
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly Dictionary<string, QuickBarItemViewModel> _byKey;
|
||||||
|
|
||||||
|
/// <summary>All buttons in quick bar order.</summary>
|
||||||
|
public IReadOnlyList<QuickBarItemViewModel> Items { get; }
|
||||||
|
|
||||||
|
public QuickBarViewModel()
|
||||||
|
{
|
||||||
|
Items = Registry.Select(r => new QuickBarItemViewModel(r.Key, r.Label, r.Default)).ToList();
|
||||||
|
_byKey = Items.ToDictionary(i => i.Key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Indexer used by XAML visibility bindings.</summary>
|
||||||
|
public QuickBarItemViewModel this[string key] => _byKey[key];
|
||||||
|
|
||||||
|
/// <summary>Loads saved visibility keys; null retains defaults.</summary>
|
||||||
|
public void Load(List<string>? visibleKeys)
|
||||||
|
{
|
||||||
|
if (visibleKeys is null) return;
|
||||||
|
var set = new HashSet<string>(visibleKeys, StringComparer.Ordinal);
|
||||||
|
foreach (QuickBarItemViewModel item in Items)
|
||||||
|
item.IsChecked = set.Contains(item.Key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Selected keys saved in settings.json.</summary>
|
||||||
|
public List<string> ToKeys() =>
|
||||||
|
Items.Where(i => i.IsChecked).Select(i => i.Key).ToList();
|
||||||
|
|
||||||
|
public void RefreshText()
|
||||||
|
{
|
||||||
|
foreach (QuickBarItemViewModel item in Items)
|
||||||
|
item.RefreshText();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ResetDefaults()
|
||||||
|
{
|
||||||
|
foreach (QuickBarItemViewModel item in Items)
|
||||||
|
item.IsChecked = item.Default;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using STPViewer.Services;
|
||||||
|
|
||||||
|
namespace STPViewer.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Options and live size estimate for the STL export dialog.
|
||||||
|
/// </summary>
|
||||||
|
public partial class StlExportViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
/// <summary>Number of visible files available for export.</summary>
|
||||||
|
public int FileCount { get; }
|
||||||
|
|
||||||
|
/// <summary>Triangle count of the current display meshes.</summary>
|
||||||
|
public long TriangleCount { get; }
|
||||||
|
|
||||||
|
/// <summary>Whether B-rep geometry is available for retessellation.</summary>
|
||||||
|
public bool HasBrep { get; }
|
||||||
|
|
||||||
|
public StlExportViewModel(int fileCount, long triangleCount, bool hasBrep)
|
||||||
|
{
|
||||||
|
FileCount = fileCount;
|
||||||
|
TriangleCount = triangleCount;
|
||||||
|
HasBrep = hasBrep;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>0 = merged STL; 1 = one STL per visible file.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(Summary))]
|
||||||
|
private int scopeIndex;
|
||||||
|
|
||||||
|
/// <summary>0 = binary; 1 = ASCII.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(Summary))]
|
||||||
|
private int formatIndex;
|
||||||
|
|
||||||
|
/// <summary>0 = millimeters; 1 = inches; 2 = custom scale.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(Summary))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsCustomScale))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(CanConfirm))]
|
||||||
|
private int unitIndex;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(Summary))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(CanConfirm))]
|
||||||
|
private string customScaleText = "1.0";
|
||||||
|
|
||||||
|
/// <summary>0 = current mesh; 1 = finer B-rep tessellation.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(Summary))]
|
||||||
|
private int qualityIndex;
|
||||||
|
|
||||||
|
public bool IsCustomScale => UnitIndex == 2;
|
||||||
|
|
||||||
|
public bool Ascii => FormatIndex == 1;
|
||||||
|
public bool PerFile => ScopeIndex == 1;
|
||||||
|
public StlMeshQuality Quality => (StlMeshQuality)QualityIndex;
|
||||||
|
|
||||||
|
/// <summary>Coordinate scale, or null for an invalid custom value.</summary>
|
||||||
|
public double? Scale => UnitIndex switch
|
||||||
|
{
|
||||||
|
0 => 1.0,
|
||||||
|
1 => 1.0 / 25.4,
|
||||||
|
_ => double.TryParse(CustomScaleText, NumberStyles.Float, CultureInfo.InvariantCulture, out double s)
|
||||||
|
&& s > 0 && double.IsFinite(s) ? s : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
public bool CanConfirm => Scale is not null;
|
||||||
|
|
||||||
|
public string Summary
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
long est = StlExportService.EstimateBytes(TriangleCount, Ascii);
|
||||||
|
string size = StlExportService.FormatBytes(est);
|
||||||
|
string basis = QualityIndex == 0
|
||||||
|
? LocalizationService.Format("Ui.StlExport.EstimatedSize", size)
|
||||||
|
: LocalizationService.Format("Ui.StlExport.EstimatedSizeFine", size);
|
||||||
|
return LocalizationService.Format("Ui.StlExport.Summary", FileCount, TriangleCount.ToString("N0"), basis);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
{
|
||||||
|
"key": "English",
|
||||||
|
"data": {
|
||||||
|
"App.ErrorTitle": "STPViewer Error",
|
||||||
|
"App.NoRecentFiles": "(No recent files)",
|
||||||
|
"App.UnexpectedError": "An unexpected error interrupted the operation. The application will try to continue.\n\n{0}\n\nDetails: {1}",
|
||||||
|
"App.WindowTitle": "STPViewer V{0} — STP/STEP 3D Viewer",
|
||||||
|
"QuickBar.Align": "🎯 Two-point alignment",
|
||||||
|
"QuickBar.Align3": "🎯 Three-point alignment",
|
||||||
|
"QuickBar.Angle": "∠ Angle",
|
||||||
|
"QuickBar.Circle": "⭕ Circle",
|
||||||
|
"QuickBar.Clear": "🧹 Clear measurements",
|
||||||
|
"QuickBar.Distance": "📏 Distance",
|
||||||
|
"QuickBar.Drag": "🖐 Drag",
|
||||||
|
"QuickBar.Edge": "📐 Edge",
|
||||||
|
"QuickBar.ExportCsv": "💾 Export CSV",
|
||||||
|
"QuickBar.ExportStep": "📤 Export STEP",
|
||||||
|
"QuickBar.ExportStl": "📐 Export STL",
|
||||||
|
"QuickBar.Face": "⬛ Face",
|
||||||
|
"QuickBar.FaceDistance": "⇔ Face distance",
|
||||||
|
"QuickBar.Gizmo": "⊹ Gizmo",
|
||||||
|
"QuickBar.Import": "📂 Import",
|
||||||
|
"QuickBar.Interference": "🧩 Interference",
|
||||||
|
"QuickBar.Language": "Language",
|
||||||
|
"QuickBar.Ortho": "Orthographic projection",
|
||||||
|
"QuickBar.Point": "📍 Point",
|
||||||
|
"QuickBar.Recent": "▾ Recent files",
|
||||||
|
"QuickBar.RotateX": "Rotate ↻X",
|
||||||
|
"QuickBar.RotateY": "Rotate ↻Y",
|
||||||
|
"QuickBar.RotateZ": "Rotate ↻Z",
|
||||||
|
"QuickBar.Screenshot": "📷 Screenshot",
|
||||||
|
"QuickBar.Section": "✂ Section",
|
||||||
|
"QuickBar.Unit": "mm ⇄ in",
|
||||||
|
"QuickBar.ViewFront": "View: Front",
|
||||||
|
"QuickBar.ViewIso": "View: Isometric",
|
||||||
|
"QuickBar.ViewRight": "View: Right",
|
||||||
|
"QuickBar.ViewTop": "View: Top",
|
||||||
|
"QuickBar.ZoomAll": "🔍 Zoom all",
|
||||||
|
"Runtime.Align3Completed": "Aligned “{0}” using three points (rotation + translation; point 1 matched exactly, measurements cleared)",
|
||||||
|
"Runtime.Align3Failed": "Three-point alignment failed (degenerate points); select new points",
|
||||||
|
"Runtime.Align3OtherFile": "Target points must be on another file",
|
||||||
|
"Runtime.Align3SameSource": "All three source points must be on one file (moving: {0})",
|
||||||
|
"Runtime.Align3SourceCollinear": "The three points are collinear; choose a different third point",
|
||||||
|
"Runtime.Align3SourceComplete": "Three source points selected — select matching point 1/3 on the target file",
|
||||||
|
"Runtime.Align3SourcePoint": "Source point {0}/3 — keep selecting points on “{1}”",
|
||||||
|
"Runtime.Align3TargetCollinear": "Target points are collinear; choose a different third point",
|
||||||
|
"Runtime.Align3TargetPoint": "Target point {0}/3 — select the next matching point",
|
||||||
|
"Runtime.AlignMoved": "Moved “{0}” Δ({1:F3}, {2:F3}, {3:F3}) mm (measurements cleared; use face distance or interference to check fit)",
|
||||||
|
"Runtime.AlignMoving": "Moving “{0}” — select a target point on another file",
|
||||||
|
"Runtime.AlignOtherFile": "The target point must be on another file",
|
||||||
|
"Runtime.AllVisible": "All shown",
|
||||||
|
"Runtime.AngleDetail": "Angle = {0:F2}° (supplement {1:F2}°)\nDirection 1: {2} ({3:F3}, {4:F3}, {5:F3})\nDirection 2: {6} ({7:F3}, {8:F3}, {9:F3})\n(Face angles use normals; see supplement for the complementary angle)",
|
||||||
|
"Runtime.AngleFirstDirection": "First direction selected ({0}) — select another face or edge",
|
||||||
|
"Runtime.ArcLength": "Arc length",
|
||||||
|
"Runtime.Axis": "{0} axis",
|
||||||
|
"Runtime.CircleDetail": "Diameter = {0}\nRadius = {1}\n{2} = {3}\nCenter {4}",
|
||||||
|
"Runtime.Circumference": "Circumference",
|
||||||
|
"Runtime.ComponentName": "Component",
|
||||||
|
"Runtime.ConicalSurface": "Conical surface",
|
||||||
|
"Runtime.CsvEmpty": "No measurements to export",
|
||||||
|
"Runtime.CsvExported": "Exported {0} measurements → {1}",
|
||||||
|
"Runtime.CsvFilter": "CSV files (*.csv)|*.csv",
|
||||||
|
"Runtime.CsvHeader": "Label,Type,Details",
|
||||||
|
"Runtime.CsvTitle": "Export measurements",
|
||||||
|
"Runtime.CurveDetail": "Curve length = {0}\nType = {1}",
|
||||||
|
"Runtime.CurveName": "Curve",
|
||||||
|
"Runtime.CustomPlane": "custom plane",
|
||||||
|
"Runtime.CylinderSurface": "Type = cylindrical surface\nRadius = {0}\nAxis ({1:F3}, {2:F3}, {3:F3})",
|
||||||
|
"Runtime.DirectionEdge": "Edge direction",
|
||||||
|
"Runtime.DirectionFace": "Face normal",
|
||||||
|
"Runtime.DirectionMeshFace": "Face normal (mesh)",
|
||||||
|
"Runtime.DistanceDetail": "Distance = {0}\nΔX = {1}\nΔY = {2}\nΔZ = {3}\nP1 {4}\nP2 {5}",
|
||||||
|
"Runtime.DistanceFirstPoint": "First point ({0:F2}, {1:F2}, {2:F2}) — select the second point",
|
||||||
|
"Runtime.Dragged": "Dragged “{0}” Δ({1:F2}, {2:F2}, {3:F2}) mm (use interference check to verify)",
|
||||||
|
"Runtime.Dragging": "Dragging “{0}”… (release to place)",
|
||||||
|
"Runtime.DxfParseFailed": "Could not parse DXF file",
|
||||||
|
"Runtime.ExtrusionSurface": "Extruded surface",
|
||||||
|
"Runtime.FaceDetail": "Area ≈ {0} (mesh approximation)\n{1}\nTriangles = {2:N0}",
|
||||||
|
"Runtime.FaceDistanceDetail": "Minimum distance ≈ {0} (mesh approximation)\nΔX = {1}\nΔY = {2}\nΔZ = {3}\nPoint 1 {4}\nPoint 2 {5}",
|
||||||
|
"Runtime.FaceDistanceFirstFace": "First face selected — select another face",
|
||||||
|
"Runtime.FaceName": "Face",
|
||||||
|
"Runtime.FileNotFound": "File not found",
|
||||||
|
"Runtime.GapLabel": "gap {0}",
|
||||||
|
"Runtime.GeometryProcessed": "Geometry processed in {0:N0} ms (triangulation {1:N0} ms, edge sampling {2:N0} ms{3})",
|
||||||
|
"Runtime.GizmoApplied": "Applied gizmo transform to “{0}” (use interference check to verify)",
|
||||||
|
"Runtime.GizmoHint": "Gizmo: drag an arrow to move “{0}” along an axis or a ring to rotate; release to place",
|
||||||
|
"Runtime.GizmoSelectFile": "Gizmo: select a file in the tree first",
|
||||||
|
"Runtime.ImportBusy": "Busy importing or calculating; please wait",
|
||||||
|
"Runtime.ImportCompleted": "Import complete: {0} succeeded{1}",
|
||||||
|
"Runtime.ImportFailed": "Import failed: {0} — {1}",
|
||||||
|
"Runtime.ImportFailedCount": ", {0} failed",
|
||||||
|
"Runtime.ImportFailedTitle": "Import failed",
|
||||||
|
"Runtime.ImportFilter": "CAD files (*.stp;*.step;*.stl;*.dxf)|*.stp;*.step;*.stl;*.dxf|STEP (*.stp;*.step)|*.stp;*.step|STL (*.stl)|*.stl|DXF (*.dxf)|*.dxf|All files (*.*)|*.*",
|
||||||
|
"Runtime.ImportProgress": "Importing {0}: {1}",
|
||||||
|
"Runtime.ImportTitle": "Import CAD files (multiple selection)",
|
||||||
|
"Runtime.Importing": "Importing: {0}…",
|
||||||
|
"Runtime.InterferenceChecking": "Checking interference (pair {0}): {1} ⟷ {2}…",
|
||||||
|
"Runtime.InterferenceClearDetail": "{0} ⟷ {1} do not intersect\nMinimum gap ≈ {2} (mesh approximation)\nGap ≈ 0 means contact (match)\nPoint 1 {3}\nPoint 2 {4}",
|
||||||
|
"Runtime.InterferenceClearTitle": "🧩 No interference gap ≈ {0}",
|
||||||
|
"Runtime.InterferenceCompleted": "Interference check complete: {0} pairs, {1} intersecting",
|
||||||
|
"Runtime.InterferenceFailed": "Interference check failed: {0}",
|
||||||
|
"Runtime.InterferenceHitDetail": "Parts intersect (no match)\nIntersecting triangle pairs = {0:N0}\nRed lines = intersection curves ({1:N0} segments)\n(Coplanar contact is not counted as interference)",
|
||||||
|
"Runtime.InterferenceHitTitle": "🧩 Interference! {0} ⟷ {1}",
|
||||||
|
"Runtime.InterferenceLabel": "Interference",
|
||||||
|
"Runtime.InterferenceNeedTwo": "Interference check needs at least two visible files ({0} now); select them in the tree",
|
||||||
|
"Runtime.Isolated": "Showing only “{0}” (tree context menu → Show all to restore)",
|
||||||
|
"Runtime.KindAlign": "Align",
|
||||||
|
"Runtime.KindAlign3": "Three-point align",
|
||||||
|
"Runtime.KindAngle": "Angle",
|
||||||
|
"Runtime.KindCircle": "Circle",
|
||||||
|
"Runtime.KindDistance": "Distance",
|
||||||
|
"Runtime.KindDrag": "Drag",
|
||||||
|
"Runtime.KindEdge": "Edge",
|
||||||
|
"Runtime.KindFace": "Face",
|
||||||
|
"Runtime.KindFaceDistance": "Face distance",
|
||||||
|
"Runtime.KindInterference": "Interference",
|
||||||
|
"Runtime.KindNone": "None",
|
||||||
|
"Runtime.KindPoint": "Point",
|
||||||
|
"Runtime.KindVolume": "Volume",
|
||||||
|
"Runtime.LineDetail": "Length = {0}\nΔX = {1} ΔY = {2} ΔZ = {3}\nStart {4}\nEnd {5}",
|
||||||
|
"Runtime.MeasureFailed": "Measurement failed: {0}",
|
||||||
|
"Runtime.MeasurementStartCancelled": "Current measurement start cancelled (press Esc again to leave measurement mode)",
|
||||||
|
"Runtime.MeasurementsCleared": "All measurements cleared",
|
||||||
|
"Runtime.MeshMeasurementLimited": "This model has no B-rep (STL/mesh); only point, distance, angle and face distance measurements are available",
|
||||||
|
"Runtime.MeshName": "Mesh",
|
||||||
|
"Runtime.MeshNameNumbered": "Mesh {0}",
|
||||||
|
"Runtime.MeshSurface": "Type = mesh (no B-rep)",
|
||||||
|
"Runtime.ModeAlign": "Two-point align: select a point on the part to move, then a target point (translation only; measurements cleared)",
|
||||||
|
"Runtime.ModeAlign3": "Three-point align: select three feature points on the file to move, then three matching points on the target file",
|
||||||
|
"Runtime.ModeAngle": "Angle measurement: select the first face (or near a straight edge)",
|
||||||
|
"Runtime.ModeBrowse": "Browse mode (right drag to orbit, wheel to zoom, middle drag or Shift+left drag to pan)",
|
||||||
|
"Runtime.ModeCircle": "Circle measurement: click near a circular edge or hole wall",
|
||||||
|
"Runtime.ModeDistance": "Distance measurement: select the first point",
|
||||||
|
"Runtime.ModeDrag": "Drag mode: hold left mouse button to drag a part in the screen plane, release to place it; rotate the view to change depth",
|
||||||
|
"Runtime.ModeEdge": "Edge measurement: click near an edge",
|
||||||
|
"Runtime.ModeFace": "Face measurement: click a face",
|
||||||
|
"Runtime.ModeFaceDistance": "Face distance measurement: select the first face",
|
||||||
|
"Runtime.ModePoint": "Point measurement: click a model surface (snaps to nearby vertices)",
|
||||||
|
"Runtime.NoBrep": " · No B-rep",
|
||||||
|
"Runtime.NoCircularEdge": "This face has no circular edge (click near a hole wall or circular edge)",
|
||||||
|
"Runtime.NoGeometry": "File contains no displayable geometry",
|
||||||
|
"Runtime.NoMeasurableEdge": "This face has no measurable edge",
|
||||||
|
"Runtime.NoSurfaceHit": "No model surface hit",
|
||||||
|
"Runtime.NodeStats": "{0} solids · {1} faces · {2:N0} △",
|
||||||
|
"Runtime.NurbsSurface": "NURBS surface",
|
||||||
|
"Runtime.Orthographic": "Orthographic projection (parallel view for checking dimensions)",
|
||||||
|
"Runtime.OutlineSize": "\nBounds {0}",
|
||||||
|
"Runtime.ParsingStep": "Parsing STEP (large files may take several minutes)…",
|
||||||
|
"Runtime.ParsingStl": "Parsing STL triangles…",
|
||||||
|
"Runtime.Perspective": "Perspective projection",
|
||||||
|
"Runtime.PlaneSurface": "Type = plane\nNormal ({0:F3}, {1:F3}, {2:F3})",
|
||||||
|
"Runtime.Ready": "Ready — import STP/STEP/STL/DXF (or drag and drop). Right drag to orbit, wheel to zoom, middle drag or Shift+left drag to pan",
|
||||||
|
"Runtime.Removed": "Removed: {0}",
|
||||||
|
"Runtime.RetriedFaces": ", recovered {0} faces",
|
||||||
|
"Runtime.Reversed": " (reversed)",
|
||||||
|
"Runtime.RevolutionSurface": "Surface of revolution",
|
||||||
|
"Runtime.RootNotFound": "Could not find this part's file",
|
||||||
|
"Runtime.RotateSelectFile": "Select a file to rotate in the tree first",
|
||||||
|
"Runtime.Rotated": "Rotated “{0}” +90° around {1} axis (click again to continue)",
|
||||||
|
"Runtime.SameFace": "The same face was selected twice; select another face",
|
||||||
|
"Runtime.ScreenshotFilter": "PNG images (*.png)|*.png",
|
||||||
|
"Runtime.ScreenshotSaved": "Screenshot saved → {0}",
|
||||||
|
"Runtime.ScreenshotTitle": "Save view screenshot",
|
||||||
|
"Runtime.SectionCancelled": "3-point section definition cancelled",
|
||||||
|
"Runtime.SectionClosed": "Section closed",
|
||||||
|
"Runtime.SectionCollinear": "3-point section: points are collinear; choose a different third point",
|
||||||
|
"Runtime.SectionDefined": "3-point section: plane defined (normal {0:F2}, {1:F2}, {2:F2}) — move the position slider to scan",
|
||||||
|
"Runtime.SectionDegenerate": "3-point section: points are degenerate; select again",
|
||||||
|
"Runtime.SectionNoHit": "3-point section: no model surface hit ({0}/3 points selected, Esc to cancel)",
|
||||||
|
"Runtime.SectionPickPrompt": "3-point section: click three points on the model to define the cutting plane (Esc to cancel)",
|
||||||
|
"Runtime.SectionPicked": "3-point section: {0}/3 points selected",
|
||||||
|
"Runtime.SectionStatus": "Section: {0} {1:F0}%{2}",
|
||||||
|
"Runtime.ShellName": "Shell",
|
||||||
|
"Runtime.SolidName": "Solid",
|
||||||
|
"Runtime.SphericalSurface": "Spherical surface",
|
||||||
|
"Runtime.StepExportEmpty": "No B-rep geometry to export (STEP supports Solid/Shell; select a STEP file to export)",
|
||||||
|
"Runtime.StepExportFailed": "STEP export failed: {0}",
|
||||||
|
"Runtime.StepExportFilter": "STEP files (*.stp)|*.stp|STEP files (*.step)|*.step",
|
||||||
|
"Runtime.StepExportTitle": "Export current scene as STEP (parts at current aligned positions)",
|
||||||
|
"Runtime.StepExported": "Exported STEP ({0} solids at current positions) → {1}",
|
||||||
|
"Runtime.StepParseFailed": "CADability could not parse this STEP file",
|
||||||
|
"Runtime.StepParsed": "STEP parsed in {0:N0} ms",
|
||||||
|
"Runtime.StlExportEmpty": "No meshes to export (select at least one file with a solid mesh)",
|
||||||
|
"Runtime.StlExportFailed": "STL export failed: {0}",
|
||||||
|
"Runtime.StlExportFilter": "STL files (*.stl)|*.stl",
|
||||||
|
"Runtime.StlExportMergedTitle": "Export STL (merged into one file)",
|
||||||
|
"Runtime.StlExportPerFileTitle": "Export STL (one per file; use this as the base filename)",
|
||||||
|
"Runtime.StlExportedMany": "Exported {0} STL files ({1:N0} triangles total) → {2}",
|
||||||
|
"Runtime.StlExportedOne": "Exported STL ({0:N0} triangles) → {1}",
|
||||||
|
"Runtime.StlExporting": "Exporting STL…",
|
||||||
|
"Runtime.StlNoTriangles": "STL file contains no triangles",
|
||||||
|
"Runtime.StlParsed": "STL parsed in {0:N0} ms ({1} meshes)",
|
||||||
|
"Runtime.StlRetriangulating": "Retriangulating and exporting STL… (large assemblies take time)",
|
||||||
|
"Runtime.SurfaceType": "Type = {0}",
|
||||||
|
"Runtime.ToroidalSurface": "Toroidal surface",
|
||||||
|
"Runtime.Triangulating": "Triangulating {0} parts in parallel…",
|
||||||
|
"Runtime.UnitInch": "Display unit: inch",
|
||||||
|
"Runtime.UnitMillimeter": "Display unit: mm",
|
||||||
|
"Runtime.UnsupportedFormat": "Unsupported format (.stp / .step / .stl / .dxf supported; CADability has no IGES reader)",
|
||||||
|
"Runtime.View": "View: {0}",
|
||||||
|
"Runtime.ViewBack": "Back",
|
||||||
|
"Runtime.ViewBottom": "Bottom",
|
||||||
|
"Runtime.ViewFront": "Front",
|
||||||
|
"Runtime.ViewIso": "Isometric",
|
||||||
|
"Runtime.ViewLeft": "Left",
|
||||||
|
"Runtime.ViewRight": "Right",
|
||||||
|
"Runtime.ViewTop": "Top",
|
||||||
|
"Runtime.VisibilityInverted": "Visibility inverted",
|
||||||
|
"Runtime.VolumeDetail": "Volume ≈ {0} (mesh approximation)\nCentroid {1}\nBounds (AABB) {2} × {3} × {4} {5}",
|
||||||
|
"Runtime.VolumeNoMesh": "“{0}” has no face mesh for volume calculation (wireframe has no volume)",
|
||||||
|
"Runtime.VolumeOpenMesh": "“{0}” is an open mesh; volume cannot be calculated reliably",
|
||||||
|
"Ui.About.Changelog": "📋 Release History",
|
||||||
|
"Ui.About.Close": "Close",
|
||||||
|
"Ui.About.Description": "STP / STEP 3D Viewer. Open, measure, align, check interference, section, and export without starting a full CAD application.",
|
||||||
|
"Ui.About.Developer": "👤 Developer",
|
||||||
|
"Ui.About.Email": "Email",
|
||||||
|
"Ui.About.Name": "Name",
|
||||||
|
"Ui.About.Release010.Change1": "• STEP, STL, and DXF import with an assembly tree; point, distance, edge, face, circle, angle, and face-distance measurement; alignment, interference checking, sections, units, CSV, and screenshots.",
|
||||||
|
"Ui.About.Release010.Title": "First Release",
|
||||||
|
"Ui.About.Release020.Change1": "• Hold the left mouse button on a part to drag it into position.",
|
||||||
|
"Ui.About.Release020.Change2": "• Version 0.2.1 fixed a crash after clicking a dragged part.",
|
||||||
|
"Ui.About.Release020.Title": "Drag Mode",
|
||||||
|
"Ui.About.Release030.Change1": "• Select a model and use XYZ arrows to move it along an axis or rings to rotate it by any angle.",
|
||||||
|
"Ui.About.Release030.Title": "3-Axis Gizmo",
|
||||||
|
"Ui.About.Release031.Change1": "• The 3-axis gizmo stays above the model and remains accessible from any angle.",
|
||||||
|
"Ui.About.Release031.Title": "Gizmo Always Visible",
|
||||||
|
"Ui.About.Release032.Change1": "• Rotating a large assembly remains smooth while measuring.",
|
||||||
|
"Ui.About.Release032.Title": "Smooth Measurement of Large Assemblies",
|
||||||
|
"Ui.About.Release040.Change1": "• Moving the section slider on a large file no longer freezes the window.",
|
||||||
|
"Ui.About.Release040.Change2": "• Placement after dragging or using the gizmo is much faster.",
|
||||||
|
"Ui.About.Release040.Change3": "• Errors now show a message and let the application keep running.",
|
||||||
|
"Ui.About.Release040.Title": "Faster and More Reliable",
|
||||||
|
"Ui.About.Release050.Change1": "• Distance measurement snaps to the center of a circular edge, making hole pitch easy to measure.",
|
||||||
|
"Ui.About.Release050.Change2": "• Measurement shortcuts: P point, D distance, E edge, F face, C circle, A angle, M face distance; Esc cancels.",
|
||||||
|
"Ui.About.Release050.Change3": "• Search part names in the model tree; right-click to isolate a node or measure volume and centroid.",
|
||||||
|
"Ui.About.Release050.Change4": "• Standard views and orthographic projection make dimensions easier to inspect without perspective distortion.",
|
||||||
|
"Ui.About.Release050.Change5": "• A three-point section mode cuts along any plane defined by three points on the model.",
|
||||||
|
"Ui.About.Release050.Change6": "• Export aligned parts to a new STEP file for use in CAD.",
|
||||||
|
"Ui.About.Release050.Change7": "• Window size, unit preferences, and the ten most recently opened files are remembered.",
|
||||||
|
"Ui.About.Release050.Title": "Major Improvements to Measurement, Views, and the Model Tree",
|
||||||
|
"Ui.About.Release060.Change1": "• Export visible parts, including their aligned and rotated positions, to STL for 3D printing or other software.",
|
||||||
|
"Ui.About.Release060.Change2": "• Choose one merged file or one file per model, Binary or ASCII, mm or inches, and mesh quality before exporting.",
|
||||||
|
"Ui.About.Release060.Change3": "• The settings dialog shows triangle count and estimated file size immediately.",
|
||||||
|
"Ui.About.Release060.Title": "Export STL",
|
||||||
|
"Ui.About.Release070.Change1": "• A new menu bar groups all features under File, View, Measure, Transform, and Tools.",
|
||||||
|
"Ui.About.Release070.Change2": "• Choose your favorite Quick Bar buttons from Quick Bar → Customize Buttons; your selection is saved.",
|
||||||
|
"Ui.About.Release070.Change3": "• Section controls appear only when section view is active.",
|
||||||
|
"Ui.About.Release070.Change4": "• A new About window shows the release history.",
|
||||||
|
"Ui.About.Release070.Title": "Redesigned Interface with Menus and a Customizable Quick Bar",
|
||||||
|
"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.Title": "Open Large STL Files in Seconds",
|
||||||
|
"Ui.About.Technology": ".NET 8 · WPF · C#; CADability (STEP / B-rep geometry); HelixToolkit (3D display and camera)",
|
||||||
|
"Ui.About.TechnologyTitle": "🔧 Technology",
|
||||||
|
"Ui.About.Title": "About STPViewer",
|
||||||
|
"Ui.Language": "Language",
|
||||||
|
"Ui.Main.3AxisGizmo": "⊹ 3-Axis Gizmo",
|
||||||
|
"Ui.Main.3AxisGizmoUseXYZArrows": "3-axis gizmo: use XYZ arrows to move and rings to rotate the selected model; release to place it",
|
||||||
|
"Ui.Main.3Point": "🎯 3-Point",
|
||||||
|
"Ui.Main.3Points": "3 Points",
|
||||||
|
"Ui.Main.Align": "🎯 Align",
|
||||||
|
"Ui.Main.Angle": "∠ Angle",
|
||||||
|
"Ui.Main.AngleBetweenFacesStraightEdgesShortcut": "Angle between faces or straight edges — shortcut A",
|
||||||
|
"Ui.Main.AngleBetweenTwoFacesStraightEdges": "Angle between two faces or straight edges",
|
||||||
|
"Ui.Main.ApplicationInformationReleaseHistory": "Application information and release history",
|
||||||
|
"Ui.Main.AreaSurfaceTypeNormalVector": "Area, surface type, and normal vector",
|
||||||
|
"Ui.Main.AreaSurfaceTypeNormalVectorShortcut": "Area, surface type, and normal vector — shortcut F",
|
||||||
|
"Ui.Main.Capture": "📷 Capture",
|
||||||
|
"Ui.Main.CenterRadiusDiameterCircumference": "Center, radius, diameter, and circumference",
|
||||||
|
"Ui.Main.CenterRadiusDiameterCircumferenceShortcutC": "Center, radius, diameter, and circumference — shortcut C",
|
||||||
|
"Ui.Main.CheckButtonsShowQuickBarSaved": "Check the buttons to show on the Quick Bar (saved in settings)",
|
||||||
|
"Ui.Main.CheckVisibleModelPairsIntersectionsReport": "Check visible model pairs for intersections; report the smallest gap when none intersect",
|
||||||
|
"Ui.Main.ChooseScopeFormatUnitsMeshQuality": "Choose scope, format, units, and mesh quality before exporting",
|
||||||
|
"Ui.Main.Circle": "⭕ Circle",
|
||||||
|
"Ui.Main.Clear": "🧹 Clear",
|
||||||
|
"Ui.Main.ClearAllMeasurements": "🧹 Clear All Measurements",
|
||||||
|
"Ui.Main.ClearAllMeasurementsMarkersEscCancels": "Clear all measurements and markers (Esc cancels the current measurement or mode)",
|
||||||
|
"Ui.Main.ClickChangeColorIncludingChildNodes": "Click to change color (including child nodes)",
|
||||||
|
"Ui.Main.Close": "✕ Close",
|
||||||
|
"Ui.Main.CloseSectionView": "Close section view",
|
||||||
|
"Ui.Main.CustomizeButtons": "Customize Buttons",
|
||||||
|
"Ui.Main.DeleteThisMeasurement": "Delete this measurement",
|
||||||
|
"Ui.Main.DisplayInchesMm": "Display in Inches (mm ⇄ in)",
|
||||||
|
"Ui.Main.Distance": "📏 Distance",
|
||||||
|
"Ui.Main.DistanceXYZCircleCenterSnappingHole": "Distance and ΔXYZ with circle-center snapping for hole pitch — shortcut D",
|
||||||
|
"Ui.Main.DistanceXYZCircularEdgesSnapCenters": "Distance and ΔXYZ; circular edges snap to centers for hole pitch measurement",
|
||||||
|
"Ui.Main.Drag": "🖐 Drag",
|
||||||
|
"Ui.Main.DragMode": "🖐 Drag Mode",
|
||||||
|
"Ui.Main.DragModeHoldLeftMouseButton": "Drag mode: hold the left mouse button to move a part in the screen plane; rotate the view to move in depth",
|
||||||
|
"Ui.Main.DragResizePanel": "Drag to resize the panel",
|
||||||
|
"Ui.Main.Edge": "📐 Edge",
|
||||||
|
"Ui.Main.EdgeLength": "📐 Edge Length",
|
||||||
|
"Ui.Main.Edges": "Edges",
|
||||||
|
"Ui.Main.EscCancelsCurrentMeasurementExitsMode": "Esc cancels the current measurement or exits the mode",
|
||||||
|
"Ui.Main.ExistingMeasurementsUpdateImmediatelyValuesStored": "Existing measurements update immediately; values are stored internally in mm",
|
||||||
|
"Ui.Main.ExportMeasurementResultsCSV": "Export measurement results as CSV",
|
||||||
|
"Ui.Main.ExportMeasurementsCSV": "💾 Export Measurements as CSV…",
|
||||||
|
"Ui.Main.ExportSTEP": "📤 Export STEP…",
|
||||||
|
"Ui.Main.ExportSTL": "📐 Export STL…",
|
||||||
|
"Ui.Main.ExportStepTooltip": "Export visible models at their current positions to a new STEP file; source files stay unchanged",
|
||||||
|
"Ui.Main.Face": "⬛ Face",
|
||||||
|
"Ui.Main.FaceDistance": "⇔ Face Distance",
|
||||||
|
"Ui.Main.FileF": "File (_F)",
|
||||||
|
"Ui.Main.FilterAssemblyTreeByNameDoes": "Filter assembly tree by name (does not change 3D visibility; clear to show all)",
|
||||||
|
"Ui.Main.Fit": "🔍 Fit",
|
||||||
|
"Ui.Main.FitAllModelsView": "Fit all models in view",
|
||||||
|
"Ui.Main.Flip": "Flip",
|
||||||
|
"Ui.Main.Front": "Front",
|
||||||
|
"Ui.Main.FrontView": "Front View",
|
||||||
|
"Ui.Main.FrontViewLookAlongYZ": "Front view (look along −Y, Z up)",
|
||||||
|
"Ui.Main.Gizmo": "⊹ Gizmo",
|
||||||
|
"Ui.Main.HelpH": "Help (_H)",
|
||||||
|
"Ui.Main.HoldLeftMouseButtonMovePart": "Hold the left mouse button to move a part in the screen plane; release to place it",
|
||||||
|
"Ui.Main.IAboutSTPViewer": "ℹ About STPViewer…",
|
||||||
|
"Ui.Main.ImportSTPSTEPSTLDXFFiles": "Import STP/STEP/STL/DXF files (select multiple or drag files into the window)",
|
||||||
|
"Ui.Main.Interference": "🧩 Interference",
|
||||||
|
"Ui.Main.InterferenceCheck": "🧩 Interference Check",
|
||||||
|
"Ui.Main.InterferenceCheckTestVisibleModelPairs": "Interference check: test visible model pairs for intersections or contact; report the smallest gap",
|
||||||
|
"Ui.Main.InvertVisibility": "🔄 Invert Visibility",
|
||||||
|
"Ui.Main.Iso": "Iso",
|
||||||
|
"Ui.Main.IsometricView": "Isometric View",
|
||||||
|
"Ui.Main.LookAlongXZUp": "Look along +X, with Z up",
|
||||||
|
"Ui.Main.LookAlongYZUp": "Look along −Y, with Z up",
|
||||||
|
"Ui.Main.LookDownFromZ": "Look down from +Z",
|
||||||
|
"Ui.Main.MeasureM": "Measure (_M)",
|
||||||
|
"Ui.Main.MeasurePointCoordinatesSnapsVerticesCircle": "Measure point coordinates (snaps to vertices and circle centers)",
|
||||||
|
"Ui.Main.MeasureStraightCurvedCircularEdgeLengths": "Measure straight, curved, and circular edge lengths",
|
||||||
|
"Ui.Main.MeasureVolumeCentroid": "⚖ Measure Volume / Centroid",
|
||||||
|
"Ui.Main.Measurements": "Measurements",
|
||||||
|
"Ui.Main.MenuImport": "📂 Import…",
|
||||||
|
"Ui.Main.ModelsAssemblyTree": "Models / Assembly Tree",
|
||||||
|
"Ui.Main.Ortho": "Ortho",
|
||||||
|
"Ui.Main.OrthographicProjection": "Orthographic Projection",
|
||||||
|
"Ui.Main.OrthographicProjectionAvoidsPerspectiveDistortionClick": "Orthographic projection avoids perspective distortion; click again for perspective",
|
||||||
|
"Ui.Main.ParallelProjectionAvoidsPerspectiveDistortionUncheck": "Parallel projection avoids perspective distortion; uncheck for perspective view",
|
||||||
|
"Ui.Main.PickPointPartMoveThenPick": "Pick a point on the part to move, then pick the target point",
|
||||||
|
"Ui.Main.PickThreePointsMovingModelThen": "Pick three points on the moving model, then three matching points on the target in the same order",
|
||||||
|
"Ui.Main.Point": "📍 Point",
|
||||||
|
"Ui.Main.PointCoordinates": "📍 Point Coordinates",
|
||||||
|
"Ui.Main.PointCoordinatesVertexCircleCenterSnapping": "Point coordinates with vertex/circle-center snapping — shortcut P",
|
||||||
|
"Ui.Main.QuickBarQ": "Quick Bar (_Q)",
|
||||||
|
"Ui.Main.RecentFiles": "Recent Files",
|
||||||
|
"Ui.Main.RecentlyOpenedFiles": "Recently opened files",
|
||||||
|
"Ui.Main.RemoveThisFile": "Remove this file",
|
||||||
|
"Ui.Main.RestoreDefaultButtons": "Restore Default Buttons",
|
||||||
|
"Ui.Main.Right": "Right",
|
||||||
|
"Ui.Main.RightView": "Right View",
|
||||||
|
"Ui.Main.RightViewLookAlongXZ": "Right view (look along +X, Z up)",
|
||||||
|
"Ui.Main.Rotate90AroundX": "↻ Rotate +90° Around X",
|
||||||
|
"Ui.Main.Rotate90AroundY": "↻ Rotate +90° Around Y",
|
||||||
|
"Ui.Main.Rotate90AroundZ": "↻ Rotate +90° Around Z",
|
||||||
|
"Ui.Main.RotateModelSelectedTree90Around": "Rotate the model selected in the tree +90° around X",
|
||||||
|
"Ui.Main.RotateModelSelectedTreeAroundIts": "Rotate the model selected in the tree around its center",
|
||||||
|
"Ui.Main.RotateSelectedModel90AroundY": "Rotate the selected model +90° around Y",
|
||||||
|
"Ui.Main.RotateSelectedModel90AroundZ": "Rotate the selected model +90° around Z",
|
||||||
|
"Ui.Main.SavePNGScreenshot3DView2": "Save a PNG screenshot of the 3D view at 2× resolution",
|
||||||
|
"Ui.Main.SaveViewPNG": "📷 Save View as PNG…",
|
||||||
|
"Ui.Main.SectionAxis3PointsDefineAn": "Section axis (3 points define an arbitrary plane on the model)",
|
||||||
|
"Ui.Main.SectionLabel": "✂ Section:",
|
||||||
|
"Ui.Main.SectionPosition0100EnterValue": "Section position (0–100%; enter a value and leave the field to apply)",
|
||||||
|
"Ui.Main.SectionPositionAlongNormal": "Section position (% along normal)",
|
||||||
|
"Ui.Main.SectionView": "✂ Section View",
|
||||||
|
"Ui.Main.ShortestDistanceBetweenFacesMeshApproximation": "Shortest distance between faces (mesh approximation)",
|
||||||
|
"Ui.Main.ShortestFaceDistanceMeshApproximationShortcut": "Shortest face distance (mesh approximation) — shortcut M",
|
||||||
|
"Ui.Main.ShowAll": "☀ Show All",
|
||||||
|
"Ui.Main.ShowHideIncludingChildNodes": "Show / hide (including child nodes)",
|
||||||
|
"Ui.Main.ShowOnlyThisNode": "👁 Show Only This Node",
|
||||||
|
"Ui.Main.ShowOutlineEdgesManyEdgesCan": "Show outline edges (many edges can slow view rotation)",
|
||||||
|
"Ui.Main.ShowSectionControlsAxisPositionDirection": "Show section controls for axis, position, and direction",
|
||||||
|
"Ui.Main.ShowXYZArrowsRotationRingsSelected": "Show XYZ arrows and rotation rings for the selected model; release to place it",
|
||||||
|
"Ui.Main.StraightCurvedCircularEdgeLengthShortcut": "Straight, curved, or circular edge length — shortcut E",
|
||||||
|
"Ui.Main.SwitchDisplayUnitsBetweenMmInches": "Switch display units between mm and inches (existing measurements update)",
|
||||||
|
"Ui.Main.SwitchRetainedSide": "Switch the retained side",
|
||||||
|
"Ui.Main.ThreePointAlignmentPickThreePoints": "Three-point alignment: pick three points on the moving model, then matching points on the target",
|
||||||
|
"Ui.Main.ThreePointAlignmentRotateTranslate": "🎯 Three-Point Alignment (Rotate + Translate)",
|
||||||
|
"Ui.Main.ToggleSectionViewItsControls": "Toggle section view and its controls",
|
||||||
|
"Ui.Main.ToolbarExportStepTooltip": "Export visible models at their current positions to STEP for CAD; source files stay unchanged",
|
||||||
|
"Ui.Main.ToolbarExportStlTooltip": "Export visible models at their current positions to STL; choose scope, format, units, and quality first",
|
||||||
|
"Ui.Main.ToolbarImport": "📂 Import",
|
||||||
|
"Ui.Main.ToolbarSection": "✂ Section",
|
||||||
|
"Ui.Main.ToolsL": "Tools (_L)",
|
||||||
|
"Ui.Main.Top": "Top",
|
||||||
|
"Ui.Main.TopView": "Top View",
|
||||||
|
"Ui.Main.TopViewLookDownFromZ": "Top view (look down from +Z)",
|
||||||
|
"Ui.Main.TransformT": "Transform (_T)",
|
||||||
|
"Ui.Main.TwoPointAlignmentPickPointMoving": "Two-point alignment: pick a point on the moving part, then the target point",
|
||||||
|
"Ui.Main.TwoPointAlignmentTranslate": "🎯 Two-Point Alignment (Translate)",
|
||||||
|
"Ui.Main.TwoPointDistance": "📏 Two-Point Distance",
|
||||||
|
"Ui.Main.ViewV": "View (_V)",
|
||||||
|
"Ui.Main.WindowTitle": "STPViewer — STP/STEP 3D Viewer",
|
||||||
|
"Ui.Main.ZoomFit": "🔍 Zoom to Fit",
|
||||||
|
"Ui.Main.ZoomThisNode": "Zoom to this node",
|
||||||
|
"Ui.StlExport.Cancel": "Cancel",
|
||||||
|
"Ui.StlExport.CustomScaleTooltip": "Multiply coordinates by this factor (for example, 0.001 converts mm to m)",
|
||||||
|
"Ui.StlExport.EstimatedSize": "Estimated {0}",
|
||||||
|
"Ui.StlExport.EstimatedSizeFine": "Current mesh estimate {0} (actual size depends on re-triangulation)",
|
||||||
|
"Ui.StlExport.Export": "Export…",
|
||||||
|
"Ui.StlExport.Format": "Format",
|
||||||
|
"Ui.StlExport.FormatAscii": "ASCII (readable text; about 4× larger)",
|
||||||
|
"Ui.StlExport.FormatBinary": "Binary (recommended; smaller file)",
|
||||||
|
"Ui.StlExport.MeshQuality": "Mesh quality",
|
||||||
|
"Ui.StlExport.QualityCurrent": "Current display mesh (fast; matches the view)",
|
||||||
|
"Ui.StlExport.QualityFine": "Fine — re-triangulate for smoother surfaces (slower; larger file)",
|
||||||
|
"Ui.StlExport.QualityTooltip": "Fine quality re-triangulates STEP B-rep geometry; STL/DXF keeps the current mesh. Large assemblies take time",
|
||||||
|
"Ui.StlExport.Scope": "Export scope",
|
||||||
|
"Ui.StlExport.ScopeMerge": "All visible models → one STL",
|
||||||
|
"Ui.StlExport.ScopePerFile": "Each visible model → separate STL",
|
||||||
|
"Ui.StlExport.ScopeTooltip": "STL has no assembly structure; merge puts all visible parts in one file",
|
||||||
|
"Ui.StlExport.Summary": "{0} visible models · {1} triangles · {2}",
|
||||||
|
"Ui.StlExport.Title": "Export STL — Settings",
|
||||||
|
"Ui.StlExport.UnitCustom": "Custom scale factor…",
|
||||||
|
"Ui.StlExport.UnitInch": "inch (coordinates ÷ 25.4)",
|
||||||
|
"Ui.StlExport.UnitMillimeter": "mm (1:1; default)",
|
||||||
|
"Ui.StlExport.UnitScale": "Unit scale",
|
||||||
|
"Ui.StlExport.UnitTooltip": "STL files have no units; internal geometry is in mm, and inches divide coordinates by 25.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
{
|
||||||
|
"key": "简体中文",
|
||||||
|
"data": {
|
||||||
|
"App.ErrorTitle": "STPViewer 错误",
|
||||||
|
"App.NoRecentFiles": "(没有最近打开的文件)",
|
||||||
|
"App.UnexpectedError": "发生意外错误,操作已中止(程序将尝试继续运行)。\n\n{0}\n\n详细记录:{1}",
|
||||||
|
"App.WindowTitle": "STPViewer V{0} — STP/STEP 3D 查看器",
|
||||||
|
"QuickBar.Align": "🎯 两点对齐",
|
||||||
|
"QuickBar.Align3": "🎯 三点对齐",
|
||||||
|
"QuickBar.Angle": "∠ 角度",
|
||||||
|
"QuickBar.Circle": "⭕ 圆",
|
||||||
|
"QuickBar.Clear": "🧹 清除测量",
|
||||||
|
"QuickBar.Distance": "📏 距离",
|
||||||
|
"QuickBar.Drag": "🖐 拖动",
|
||||||
|
"QuickBar.Edge": "📐 边",
|
||||||
|
"QuickBar.ExportCsv": "💾 导出 CSV",
|
||||||
|
"QuickBar.ExportStep": "📤 导出 STEP",
|
||||||
|
"QuickBar.ExportStl": "📐 导出 STL",
|
||||||
|
"QuickBar.Face": "⬛ 面",
|
||||||
|
"QuickBar.FaceDistance": "⇔ 面距",
|
||||||
|
"QuickBar.Gizmo": "⊹ 操作器",
|
||||||
|
"QuickBar.Import": "📂 导入",
|
||||||
|
"QuickBar.Interference": "🧩 干涉",
|
||||||
|
"QuickBar.Language": "语言",
|
||||||
|
"QuickBar.Ortho": "正交投影",
|
||||||
|
"QuickBar.Point": "📍 点",
|
||||||
|
"QuickBar.Recent": "▾ 最近文件",
|
||||||
|
"QuickBar.RotateX": "旋转 ↻X",
|
||||||
|
"QuickBar.RotateY": "旋转 ↻Y",
|
||||||
|
"QuickBar.RotateZ": "旋转 ↻Z",
|
||||||
|
"QuickBar.Screenshot": "📷 截图",
|
||||||
|
"QuickBar.Section": "✂ 剖面",
|
||||||
|
"QuickBar.Unit": "mm ⇄ in",
|
||||||
|
"QuickBar.ViewFront": "视图:前",
|
||||||
|
"QuickBar.ViewIso": "视图:等轴测",
|
||||||
|
"QuickBar.ViewRight": "视图:右",
|
||||||
|
"QuickBar.ViewTop": "视图:上",
|
||||||
|
"QuickBar.ZoomAll": "🔍 显示全部",
|
||||||
|
"Runtime.Align3Completed": "已三点对齐“{0}”(旋转+平移;点 1 精确贴合,测量已清空)",
|
||||||
|
"Runtime.Align3Failed": "三点对齐失败(点位退化),请重新选点",
|
||||||
|
"Runtime.Align3OtherFile": "目标点必须位于另一个文件上",
|
||||||
|
"Runtime.Align3SameSource": "3 个来源点必须位于同一个文件(要移动:{0})",
|
||||||
|
"Runtime.Align3SourceCollinear": "三点共线,请重新选择不共线的第 3 点",
|
||||||
|
"Runtime.Align3SourceComplete": "已选完 3 个来源点 — 请在目标文件上选择对应点 1/3",
|
||||||
|
"Runtime.Align3SourcePoint": "来源点 {0}/3 — 继续选择“{1}”上的点",
|
||||||
|
"Runtime.Align3TargetCollinear": "目标三点共线,请重新选择不共线的第 3 点",
|
||||||
|
"Runtime.Align3TargetPoint": "目标点 {0}/3 — 继续选择目标文件上的对应点",
|
||||||
|
"Runtime.AlignMoved": "已移动“{0}” Δ({1:F3}, {2:F3}, {3:F3}) mm(测量已清空;可用面距或干涉验证配合)",
|
||||||
|
"Runtime.AlignMoving": "将移动“{0}” — 请在另一个文件上选择目标点",
|
||||||
|
"Runtime.AlignOtherFile": "目标点必须位于另一个文件上",
|
||||||
|
"Runtime.AllVisible": "已全部显示",
|
||||||
|
"Runtime.AngleDetail": "夹角 = {0:F2}°(补角 {1:F2}°)\n方向 1:{2} ({3:F3}, {4:F3}, {5:F3})\n方向 2:{6} ({7:F3}, {8:F3}, {9:F3})\n(两面夹角按法向量计算,互补角请参考补角值)",
|
||||||
|
"Runtime.AngleFirstDirection": "已选择第 1 个方向({0})— 选择第 2 个面或边",
|
||||||
|
"Runtime.ArcLength": "弧长",
|
||||||
|
"Runtime.Axis": "{0} 轴",
|
||||||
|
"Runtime.CircleDetail": "直径 = {0}\n半径 = {1}\n{2} = {3}\n圆心 {4}",
|
||||||
|
"Runtime.Circumference": "周长",
|
||||||
|
"Runtime.ComponentName": "组件",
|
||||||
|
"Runtime.ConicalSurface": "圆锥面",
|
||||||
|
"Runtime.CsvEmpty": "没有可导出的测量结果",
|
||||||
|
"Runtime.CsvExported": "已导出 {0} 条测量结果 → {1}",
|
||||||
|
"Runtime.CsvFilter": "CSV 文件 (*.csv)|*.csv",
|
||||||
|
"Runtime.CsvHeader": "标签,类型,明细",
|
||||||
|
"Runtime.CsvTitle": "导出测量结果",
|
||||||
|
"Runtime.CurveDetail": "曲线长度 = {0}\n类型 = {1}",
|
||||||
|
"Runtime.CurveName": "曲线",
|
||||||
|
"Runtime.CustomPlane": "自定义平面",
|
||||||
|
"Runtime.CylinderSurface": "类型 = 圆柱面\n半径 = {0}\n轴向 ({1:F3}, {2:F3}, {3:F3})",
|
||||||
|
"Runtime.DirectionEdge": "边方向",
|
||||||
|
"Runtime.DirectionFace": "面法向量",
|
||||||
|
"Runtime.DirectionMeshFace": "面法向量(网格)",
|
||||||
|
"Runtime.DistanceDetail": "距离 = {0}\nΔX = {1}\nΔY = {2}\nΔZ = {3}\nP1 {4}\nP2 {5}",
|
||||||
|
"Runtime.DistanceFirstPoint": "第 1 点 ({0:F2}, {1:F2}, {2:F2}) — 选择第 2 点",
|
||||||
|
"Runtime.Dragged": "已拖动“{0}” Δ({1:F2}, {2:F2}, {3:F2}) mm(可用干涉检查验证)",
|
||||||
|
"Runtime.Dragging": "正在拖动“{0}”…(松开定位)",
|
||||||
|
"Runtime.DxfParseFailed": "无法解析 DXF 文件",
|
||||||
|
"Runtime.ExtrusionSurface": "拉伸曲面",
|
||||||
|
"Runtime.FaceDetail": "面积 ≈ {0}(网格近似)\n{1}\n三角形数 = {2:N0}",
|
||||||
|
"Runtime.FaceDistanceDetail": "最短距离 ≈ {0}(网格近似)\nΔX = {1}\nΔY = {2}\nΔZ = {3}\n点 1 {4}\n点 2 {5}",
|
||||||
|
"Runtime.FaceDistanceFirstFace": "已选择第 1 个面 — 请选择另一个面",
|
||||||
|
"Runtime.FaceName": "面",
|
||||||
|
"Runtime.FileNotFound": "找不到文件",
|
||||||
|
"Runtime.GapLabel": "间隙 {0}",
|
||||||
|
"Runtime.GeometryProcessed": "几何处理 {0:N0} ms(三角化 {1:N0} ms、边采样 {2:N0} ms{3})",
|
||||||
|
"Runtime.GizmoApplied": "已对“{0}”应用操作器变换(可用干涉检查验证)",
|
||||||
|
"Runtime.GizmoHint": "操作器:拖动箭头沿轴移动“{0}”,拖动圆环绕轴旋转;松开即可定位",
|
||||||
|
"Runtime.GizmoSelectFile": "操作器:请先在树面板选择要操作的文件",
|
||||||
|
"Runtime.ImportBusy": "正在导入或计算,请稍后再导入",
|
||||||
|
"Runtime.ImportCompleted": "导入完成:成功 {0} 个文件{1}",
|
||||||
|
"Runtime.ImportFailed": "导入失败:{0} — {1}",
|
||||||
|
"Runtime.ImportFailedCount": "、失败 {0} 个文件",
|
||||||
|
"Runtime.ImportFailedTitle": "导入失败",
|
||||||
|
"Runtime.ImportFilter": "CAD 文件 (*.stp;*.step;*.stl;*.dxf)|*.stp;*.step;*.stl;*.dxf|STEP (*.stp;*.step)|*.stp;*.step|STL (*.stl)|*.stl|DXF (*.dxf)|*.dxf|所有文件 (*.*)|*.*",
|
||||||
|
"Runtime.ImportProgress": "导入 {0}:{1}",
|
||||||
|
"Runtime.ImportTitle": "导入 CAD 文件(可多选)",
|
||||||
|
"Runtime.Importing": "正在导入:{0} …",
|
||||||
|
"Runtime.InterferenceChecking": "正在检查干涉(第 {0} 组):{1} ⟷ {2} …",
|
||||||
|
"Runtime.InterferenceClearDetail": "{0} ⟷ {1} 无相交\n最小间隙 ≈ {2}(网格近似)\n间隙 ≈ 0 表示贴合\n点 1 {3}\n点 2 {4}",
|
||||||
|
"Runtime.InterferenceClearTitle": "🧩 无干涉 间隙 ≈ {0}",
|
||||||
|
"Runtime.InterferenceCompleted": "干涉检查完成:{0} 组配对、{1} 组相交",
|
||||||
|
"Runtime.InterferenceFailed": "干涉检查失败:{0}",
|
||||||
|
"Runtime.InterferenceHitDetail": "零件相交(非贴合)\n相交三角形对 = {0:N0}\n红色线 = 干涉交线({1:N0} 段)\n(共面贴合不计为干涉)",
|
||||||
|
"Runtime.InterferenceHitTitle": "🧩 发生干涉!{0} ⟷ {1}",
|
||||||
|
"Runtime.InterferenceLabel": "干涉",
|
||||||
|
"Runtime.InterferenceNeedTwo": "干涉检查至少需要 2 个可见文件(当前 {0} 个),请在树面板勾选",
|
||||||
|
"Runtime.Isolated": "只显示“{0}”(树右键 → 全部显示 可恢复)",
|
||||||
|
"Runtime.KindAlign": "对齐",
|
||||||
|
"Runtime.KindAlign3": "三点对齐",
|
||||||
|
"Runtime.KindAngle": "角度",
|
||||||
|
"Runtime.KindCircle": "圆",
|
||||||
|
"Runtime.KindDistance": "距离",
|
||||||
|
"Runtime.KindDrag": "拖动",
|
||||||
|
"Runtime.KindEdge": "边",
|
||||||
|
"Runtime.KindFace": "面",
|
||||||
|
"Runtime.KindFaceDistance": "面距",
|
||||||
|
"Runtime.KindInterference": "干涉",
|
||||||
|
"Runtime.KindNone": "无",
|
||||||
|
"Runtime.KindPoint": "点",
|
||||||
|
"Runtime.KindVolume": "体积",
|
||||||
|
"Runtime.LineDetail": "长度 = {0}\nΔX = {1} ΔY = {2} ΔZ = {3}\n起点 {4}\n终点 {5}",
|
||||||
|
"Runtime.MeasureFailed": "测量失败:{0}",
|
||||||
|
"Runtime.MeasurementStartCancelled": "已取消当前测量起点(再次按 Esc 退出测量模式)",
|
||||||
|
"Runtime.MeasurementsCleared": "已清除全部测量",
|
||||||
|
"Runtime.MeshMeasurementLimited": "此模型无 B-rep(STL/网格),仅支持点、距离、角度和面距测量",
|
||||||
|
"Runtime.MeshName": "网格",
|
||||||
|
"Runtime.MeshNameNumbered": "网格 {0}",
|
||||||
|
"Runtime.MeshSurface": "类型 = 网格(无 B-rep)",
|
||||||
|
"Runtime.ModeAlign": "两点对齐:先点要移动零件上的点,再点目标点(仅平移,测量将清空)",
|
||||||
|
"Runtime.ModeAlign3": "三点对齐:先在要移动的文件中选 3 个特征点,再在目标文件中选 3 个对应点(旋转+平移)",
|
||||||
|
"Runtime.ModeAngle": "角度测量:选择第 1 个面(或直线边附近)",
|
||||||
|
"Runtime.ModeBrowse": "浏览模式(右键旋转、滚轮缩放、中键或 Shift+左键拖动平移)",
|
||||||
|
"Runtime.ModeCircle": "圆测量:点击圆孔内壁或圆边附近",
|
||||||
|
"Runtime.ModeDistance": "距离测量:选择第 1 点",
|
||||||
|
"Runtime.ModeDrag": "拖动模式:按住左键沿屏幕平面移动零件,松开定位;需改变深度时请先旋转视角",
|
||||||
|
"Runtime.ModeEdge": "边测量:点击要测量的边附近",
|
||||||
|
"Runtime.ModeFace": "面测量:点击要测量的面",
|
||||||
|
"Runtime.ModeFaceDistance": "面距测量:选择第 1 个面",
|
||||||
|
"Runtime.ModePoint": "点测量:点击模型表面(自动吸附邻近顶点)",
|
||||||
|
"Runtime.NoBrep": " · 无 B-rep",
|
||||||
|
"Runtime.NoCircularEdge": "此面没有圆形边(请点击圆孔内壁或圆边附近)",
|
||||||
|
"Runtime.NoGeometry": "文件中没有可显示的几何",
|
||||||
|
"Runtime.NoMeasurableEdge": "此面没有可测量的边",
|
||||||
|
"Runtime.NoSurfaceHit": "未命中模型表面",
|
||||||
|
"Runtime.NodeStats": "{0} 实体 · {1} 面 · {2:N0} △",
|
||||||
|
"Runtime.NurbsSurface": "NURBS 曲面",
|
||||||
|
"Runtime.Orthographic": "正交投影(平行投影,适合尺寸确认)",
|
||||||
|
"Runtime.OutlineSize": "\n外形 {0}",
|
||||||
|
"Runtime.ParsingStep": "解析 STEP(大文件可能需要数分钟)…",
|
||||||
|
"Runtime.ParsingStl": "解析 STL(三角形直读)…",
|
||||||
|
"Runtime.Perspective": "透视投影",
|
||||||
|
"Runtime.PlaneSurface": "类型 = 平面\n法向量 ({0:F3}, {1:F3}, {2:F3})",
|
||||||
|
"Runtime.Ready": "就绪 — 导入 STP/STEP/STL/DXF(也可拖放)。右键旋转、滚轮缩放、中键或 Shift+左键平移",
|
||||||
|
"Runtime.Removed": "已移除:{0}",
|
||||||
|
"Runtime.RetriedFaces": "、重试补回 {0} 面",
|
||||||
|
"Runtime.Reversed": "(反向)",
|
||||||
|
"Runtime.RevolutionSurface": "旋转曲面",
|
||||||
|
"Runtime.RootNotFound": "找不到此零件所属文件",
|
||||||
|
"Runtime.RotateSelectFile": "请先在树面板选择要旋转的文件",
|
||||||
|
"Runtime.Rotated": "已将“{0}”绕 {1} 轴旋转 +90°(再次点击可继续旋转)",
|
||||||
|
"Runtime.SameFace": "两次选中了同一个面,请选择另一个面",
|
||||||
|
"Runtime.ScreenshotFilter": "PNG 图片 (*.png)|*.png",
|
||||||
|
"Runtime.ScreenshotSaved": "已保存截图 → {0}",
|
||||||
|
"Runtime.ScreenshotTitle": "保存视图截图",
|
||||||
|
"Runtime.SectionCancelled": "已取消三点剖面定义",
|
||||||
|
"Runtime.SectionClosed": "剖面已关闭",
|
||||||
|
"Runtime.SectionCollinear": "三点剖面:三点共线,请选择不共线的第 3 点",
|
||||||
|
"Runtime.SectionDefined": "三点剖面:平面已定义(法向 {0:F2}, {1:F2}, {2:F2})— 用位置滑块扫描剖面",
|
||||||
|
"Runtime.SectionDegenerate": "三点剖面:三点退化(共线),请重新选择",
|
||||||
|
"Runtime.SectionNoHit": "三点剖面:未命中模型表面(已选 {0}/3 点,Esc 取消)",
|
||||||
|
"Runtime.SectionPickPrompt": "三点剖面:请在模型上选择 3 个点定义剖切平面(Esc 取消)",
|
||||||
|
"Runtime.SectionPicked": "三点剖面:已选 {0}/3 点",
|
||||||
|
"Runtime.SectionStatus": "剖面:{0} {1:F0}%{2}",
|
||||||
|
"Runtime.ShellName": "壳体",
|
||||||
|
"Runtime.SolidName": "实体",
|
||||||
|
"Runtime.SphericalSurface": "球面",
|
||||||
|
"Runtime.StepExportEmpty": "没有可导出的 B-rep 几何(STEP 仅包含 Solid/Shell;请勾选要导出的 STEP 文件)",
|
||||||
|
"Runtime.StepExportFailed": "STEP 导出失败:{0}",
|
||||||
|
"Runtime.StepExportFilter": "STEP 文件 (*.stp)|*.stp|STEP 文件 (*.step)|*.step",
|
||||||
|
"Runtime.StepExportTitle": "将当前场景导出为 STEP(零件处于当前对齐位置)",
|
||||||
|
"Runtime.StepExported": "已导出 STEP({0} 个实体,当前位置)→ {1}",
|
||||||
|
"Runtime.StepParseFailed": "CADability 无法解析此 STEP 文件",
|
||||||
|
"Runtime.StepParsed": "STEP 解析 {0:N0} ms",
|
||||||
|
"Runtime.StlExportEmpty": "没有可导出的网格(请勾选至少一个含实体网格的文件)",
|
||||||
|
"Runtime.StlExportFailed": "STL 导出失败:{0}",
|
||||||
|
"Runtime.StlExportFilter": "STL 文件 (*.stl)|*.stl",
|
||||||
|
"Runtime.StlExportMergedTitle": "导出 STL(合并为单个文件)",
|
||||||
|
"Runtime.StlExportPerFileTitle": "导出 STL(每个文件分别导出,以此作为基础文件名)",
|
||||||
|
"Runtime.StlExportedMany": "已导出 {0} 个 STL(共 {1:N0} 个三角形)→ {2}",
|
||||||
|
"Runtime.StlExportedOne": "已导出 STL({0:N0} 个三角形)→ {1}",
|
||||||
|
"Runtime.StlExporting": "正在导出 STL…",
|
||||||
|
"Runtime.StlNoTriangles": "STL 文件中没有三角形",
|
||||||
|
"Runtime.StlParsed": "STL 解析 {0:N0} ms({1} 个网格)",
|
||||||
|
"Runtime.StlRetriangulating": "正在重新三角化并导出 STL…(大型装配需要时间)",
|
||||||
|
"Runtime.SurfaceType": "类型 = {0}",
|
||||||
|
"Runtime.ToroidalSurface": "环面",
|
||||||
|
"Runtime.Triangulating": "并行三角化 {0} 个零件…",
|
||||||
|
"Runtime.UnitInch": "显示单位:inch",
|
||||||
|
"Runtime.UnitMillimeter": "显示单位:mm",
|
||||||
|
"Runtime.UnsupportedFormat": "不支持的格式(支持 .stp / .step / .stl / .dxf;CADability 无 IGES reader)",
|
||||||
|
"Runtime.View": "视图:{0}",
|
||||||
|
"Runtime.ViewBack": "后",
|
||||||
|
"Runtime.ViewBottom": "下",
|
||||||
|
"Runtime.ViewFront": "前",
|
||||||
|
"Runtime.ViewIso": "等角",
|
||||||
|
"Runtime.ViewLeft": "左",
|
||||||
|
"Runtime.ViewRight": "右",
|
||||||
|
"Runtime.ViewTop": "上",
|
||||||
|
"Runtime.VisibilityInverted": "已反转显示",
|
||||||
|
"Runtime.VolumeDetail": "体积 ≈ {0}(网格近似)\n质心 {1}\n外形(AABB){2} × {3} × {4} {5}",
|
||||||
|
"Runtime.VolumeNoMesh": "“{0}”没有可计算的面网格(线框无体积)",
|
||||||
|
"Runtime.VolumeOpenMesh": "“{0}”不是封闭网格,无法可靠计算体积",
|
||||||
|
"Ui.About.Changelog": "📋 版本纪录",
|
||||||
|
"Ui.About.Close": "关闭",
|
||||||
|
"Ui.About.Description": "STP / STEP 3D 查看器。免开重量级 CAD,快速打开文件、测量、对齐插合、干涉检查、剖面、导出。",
|
||||||
|
"Ui.About.Developer": "👤 开发者",
|
||||||
|
"Ui.About.Email": "邮箱",
|
||||||
|
"Ui.About.Name": "姓名",
|
||||||
|
"Ui.About.Release010.Change1": "• STEP/STL/DXF 导入与装配树、点/距离/边/面/圆/角度/面距测量、两点与三点对齐、干涉检查、剖面、mm ⇄ inch、CSV 与截图导出。",
|
||||||
|
"Ui.About.Release010.Title": "首次发布",
|
||||||
|
"Ui.About.Release020.Change1": "• 添加拖曳模式:左键按住零件直接拖到想要的位置,放开就定位。",
|
||||||
|
"Ui.About.Release020.Change2": "• 0.2.1 修正拖曳过的零件再点击会当掉的问题。",
|
||||||
|
"Ui.About.Release020.Title": "拖曳模式",
|
||||||
|
"Ui.About.Release030.Change1": "• 添加操作器:选取文件后出现 XYZ 三色箭头与旋转环,箭头沿轴精确移动、旋转环绕轴转任意角度。",
|
||||||
|
"Ui.About.Release030.Title": "三轴操作器",
|
||||||
|
"Ui.About.Release031.Change1": "• 三轴操作器改成永远浮在画面最上层,不会被零件挡住,任何角度都抓得到。",
|
||||||
|
"Ui.About.Release031.Title": "操作器永远可见",
|
||||||
|
"Ui.About.Release032.Change1": "• 修正大型组件(数万个面)进入测量模式后,转动视角严重卡顿的问题 — 现在测量跟浏览一样顺。",
|
||||||
|
"Ui.About.Release032.Title": "大组件测量不再卡",
|
||||||
|
"Ui.About.Release040.Change1": "• 大文件拉剖面滑杆不再卡住画面(改到背景计算)。",
|
||||||
|
"Ui.About.Release040.Change2": "• 拖曳/操作器放开零件后的定位计算大幅加速。",
|
||||||
|
"Ui.About.Release040.Change3": "• 程序遇到错误会提示消息并继续运作,不再整个闪退。",
|
||||||
|
"Ui.About.Release040.Title": "更快、更稳",
|
||||||
|
"Ui.About.Release050.Change1": "• 量距离时点到圆孔边缘会自动吸到圆心 — 量两孔中心距(pitch)超方便。",
|
||||||
|
"Ui.About.Release050.Change2": "• 测量快速键:P 点、D 距离、E 边、F 面、C 圆、A 角度、M 面距;Esc 取消。",
|
||||||
|
"Ui.About.Release050.Change3": "• 树状面板可搜索零件名称;右键可「只显示此节点」隔离观察,也可以量体积与质心。",
|
||||||
|
"Ui.About.Release050.Change4": "• 添加标准视图(等角/前/上/右)与正交投影,量尺寸不受透视变形。",
|
||||||
|
"Ui.About.Release050.Change5": "• 剖面添加「3点」模式:在模型上点 3 个点,就能沿任意斜面剖开。",
|
||||||
|
"Ui.About.Release050.Change6": "• 添加导出 STEP:把对齐好的零件位置存成新 STEP 文件,交接给正式 CAD。",
|
||||||
|
"Ui.About.Release050.Change7": "• 会记住窗口大小、单位设置与最近打开的 10 个文件。",
|
||||||
|
"Ui.About.Release050.Title": "测量、视图、树状面板大升级",
|
||||||
|
"Ui.About.Release060.Change1": "• 添加导出 STL:把画面上的零件(含对齐、旋转后的位置)存成 STL 网格文件,可拿去 3D 打印或给其他软件用。",
|
||||||
|
"Ui.About.Release060.Change2": "• 导出前会先跳出设置窗口:全部合并成一个文件或每个文件各存一个、Binary 或文本格式、mm 或 inch、网格精细度 — 确认后才导出。",
|
||||||
|
"Ui.About.Release060.Change3": "• 设置窗口会即时显示三角形数量与预估文件大小。",
|
||||||
|
"Ui.About.Release060.Title": "导出 STL",
|
||||||
|
"Ui.About.Release070.Change1": "• 上方添加菜单栏,全部功能依「文件/视图/测量/变换/工具」分类,不用再在一长排按钮里找。",
|
||||||
|
"Ui.About.Release070.Change2": "• 原本的工具栏变成「快捷栏」:只放常用按钮。想放哪些自己决定 — 到「快捷栏 → 自定义按钮」勾选,下次打开会记住。",
|
||||||
|
"Ui.About.Release070.Change3": "• 剖面的轴向/位置控制列改成只在打开剖面时出现,平常不占空间。",
|
||||||
|
"Ui.About.Release070.Change4": "• 添加「关于」窗口(就是这个画面),可以看版本纪录。",
|
||||||
|
"Ui.About.Release070.Title": "界面大改版 — 菜单栏+可自定义快捷栏",
|
||||||
|
"Ui.About.Release071.Change1": "• 打开大型 STL 网格文件(数十 MB、上百万三角形)现在几乎瞬间完成,不用再等好几分钟。",
|
||||||
|
"Ui.About.Release071.Change2": "• 例如一个 75 MB、150 万个三角形的 STL,以前要等到天荒地老,现在不到 1 秒就显示出来。",
|
||||||
|
"Ui.About.Release071.Title": "大型 STL 秒开",
|
||||||
|
"Ui.About.Technology": ".NET 8 · WPF · C#;CADability(STEP / B-rep 几何内核);HelixToolkit(3D 显示与相机)",
|
||||||
|
"Ui.About.TechnologyTitle": "🔧 技术栈",
|
||||||
|
"Ui.About.Title": "关于 STPViewer",
|
||||||
|
"Ui.Language": "语言",
|
||||||
|
"Ui.Main.3AxisGizmo": "⊹ 三轴操作器",
|
||||||
|
"Ui.Main.3AxisGizmoUseXYZArrows": "三轴操作器:对树面板选取的文件显示 XYZ 箭头(沿轴移动)与旋转环(绕轴转任意角度),每次放开即定位",
|
||||||
|
"Ui.Main.3Point": "🎯 三点",
|
||||||
|
"Ui.Main.3Points": "3点",
|
||||||
|
"Ui.Main.Align": "🎯 对齐",
|
||||||
|
"Ui.Main.Angle": "∠ 角度",
|
||||||
|
"Ui.Main.AngleBetweenFacesStraightEdgesShortcut": "两面/两边夹角(点两个面或靠近直线边)— 快速键 A",
|
||||||
|
"Ui.Main.AngleBetweenTwoFacesStraightEdges": "两面/两边夹角(点两个面或靠近直线边)",
|
||||||
|
"Ui.Main.ApplicationInformationReleaseHistory": "程序信息与版本纪录",
|
||||||
|
"Ui.Main.AreaSurfaceTypeNormalVector": "面积 + 曲面类型/法矢量",
|
||||||
|
"Ui.Main.AreaSurfaceTypeNormalVectorShortcut": "面积 + 曲面类型/法矢量 — 快速键 F",
|
||||||
|
"Ui.Main.Capture": "📷 截图",
|
||||||
|
"Ui.Main.CenterRadiusDiameterCircumference": "圆心/半径/直径/周长",
|
||||||
|
"Ui.Main.CenterRadiusDiameterCircumferenceShortcutC": "圆心/半径/直径/周长 — 快速键 C",
|
||||||
|
"Ui.Main.CheckButtonsShowQuickBarSaved": "勾选要显示在快捷栏的常用按钮(随设置保存)",
|
||||||
|
"Ui.Main.CheckVisibleModelPairsIntersectionsReport": "2 个以上可见文件两两检查是否相交;无相交时回报最小间隙",
|
||||||
|
"Ui.Main.ChooseScopeFormatUnitsMeshQuality": "先设置范围/格式/单位/精度,确认再导出",
|
||||||
|
"Ui.Main.Circle": "⭕ 圆",
|
||||||
|
"Ui.Main.Clear": "🧹 清除",
|
||||||
|
"Ui.Main.ClearAllMeasurements": "🧹 清除全部测量",
|
||||||
|
"Ui.Main.ClearAllMeasurementsMarkersEscCancels": "清除全部测量与标记(Esc = 取消进行中测量/退出模式)",
|
||||||
|
"Ui.Main.ClickChangeColorIncludingChildNodes": "点击换色(含子节点)",
|
||||||
|
"Ui.Main.Close": "✕ 关闭",
|
||||||
|
"Ui.Main.CloseSectionView": "关闭剖面查看",
|
||||||
|
"Ui.Main.CustomizeButtons": "自定义按钮",
|
||||||
|
"Ui.Main.DeleteThisMeasurement": "删除此笔测量",
|
||||||
|
"Ui.Main.DisplayInchesMm": "单位显示 inch(mm ⇄ in)",
|
||||||
|
"Ui.Main.Distance": "📏 距离",
|
||||||
|
"Ui.Main.DistanceXYZCircleCenterSnappingHole": "两点距离 + ΔXYZ(点到圆边会吸圆心 → 可量孔对孔 pitch)— 快速键 D",
|
||||||
|
"Ui.Main.DistanceXYZCircularEdgesSnapCenters": "两点距离 + ΔXYZ(点到圆边会吸圆心 → 可量孔对孔 pitch)",
|
||||||
|
"Ui.Main.Drag": "🖐 拖曳",
|
||||||
|
"Ui.Main.DragMode": "🖐 拖曳模式",
|
||||||
|
"Ui.Main.DragModeHoldLeftMouseButton": "拖曳模式:左键按住零件沿屏幕平面拖动,放开定位(深度方向先转视角再拖)",
|
||||||
|
"Ui.Main.DragResizePanel": "拖曳调整面板宽度",
|
||||||
|
"Ui.Main.Edge": "📐 边",
|
||||||
|
"Ui.Main.EdgeLength": "📐 边长",
|
||||||
|
"Ui.Main.Edges": "边",
|
||||||
|
"Ui.Main.EscCancelsCurrentMeasurementExitsMode": "Esc = 取消进行中测量/退出模式",
|
||||||
|
"Ui.Main.ExistingMeasurementsUpdateImmediatelyValuesStored": "既有测量即时换算;内部一律存 mm",
|
||||||
|
"Ui.Main.ExportMeasurementResultsCSV": "测量结果导出 CSV",
|
||||||
|
"Ui.Main.ExportMeasurementsCSV": "💾 导出测量 CSV…",
|
||||||
|
"Ui.Main.ExportSTEP": "📤 导出 STEP…",
|
||||||
|
"Ui.Main.ExportSTL": "📐 导出 STL…",
|
||||||
|
"Ui.Main.ExportStepTooltip": "把可见文件(当前对齐位置)导出成新 STEP 文件;不动源文件",
|
||||||
|
"Ui.Main.Face": "⬛ 面",
|
||||||
|
"Ui.Main.FaceDistance": "⇔ 面距",
|
||||||
|
"Ui.Main.FileF": "文件(_F)",
|
||||||
|
"Ui.Main.FilterAssemblyTreeByNameDoes": "输入名称过滤装配树(只影响树面板显示,不影响 3D 可见性;清空 = 全显)",
|
||||||
|
"Ui.Main.Fit": "🔍 全览",
|
||||||
|
"Ui.Main.FitAllModelsView": "缩放至全部模型",
|
||||||
|
"Ui.Main.Flip": "反向",
|
||||||
|
"Ui.Main.Front": "前",
|
||||||
|
"Ui.Main.FrontView": "前视图",
|
||||||
|
"Ui.Main.FrontViewLookAlongYZ": "前视图(-Y 方向看,Z 向上)",
|
||||||
|
"Ui.Main.Gizmo": "⊹ 操作器",
|
||||||
|
"Ui.Main.HelpH": "说明(_H)",
|
||||||
|
"Ui.Main.HoldLeftMouseButtonMovePart": "左键按住零件沿屏幕平面拖动,放开定位",
|
||||||
|
"Ui.Main.IAboutSTPViewer": "ℹ 关于 STPViewer…",
|
||||||
|
"Ui.Main.ImportSTPSTEPSTLDXFFiles": "导入 STP/STEP/STL/DXF(可多选,也可直接拖放到窗口)",
|
||||||
|
"Ui.Main.Interference": "🧩 干涉",
|
||||||
|
"Ui.Main.InterferenceCheck": "🧩 干涉检查",
|
||||||
|
"Ui.Main.InterferenceCheckTestVisibleModelPairs": "干涉检查:2 个以上可见文件两两检查是否相交(match 判定);无相交时回报最小间隙",
|
||||||
|
"Ui.Main.InvertVisibility": "🔄 反转显示",
|
||||||
|
"Ui.Main.Iso": "等角",
|
||||||
|
"Ui.Main.IsometricView": "等角视图",
|
||||||
|
"Ui.Main.LookAlongXZUp": "+X 方向看,Z 向上",
|
||||||
|
"Ui.Main.LookAlongYZUp": "-Y 方向看,Z 向上",
|
||||||
|
"Ui.Main.LookDownFromZ": "+Z 往下看",
|
||||||
|
"Ui.Main.MeasureM": "测量(_M)",
|
||||||
|
"Ui.Main.MeasurePointCoordinatesSnapsVerticesCircle": "测量点坐标(吸附顶点/圆心)",
|
||||||
|
"Ui.Main.MeasureStraightCurvedCircularEdgeLengths": "边长(直线/曲线/圆弧)",
|
||||||
|
"Ui.Main.MeasureVolumeCentroid": "⚖ 量体积 / 质心",
|
||||||
|
"Ui.Main.Measurements": "测量结果",
|
||||||
|
"Ui.Main.MenuImport": "📂 导入…",
|
||||||
|
"Ui.Main.ModelsAssemblyTree": "模型 / 装配树",
|
||||||
|
"Ui.Main.Ortho": "正交",
|
||||||
|
"Ui.Main.OrthographicProjection": "正交投影",
|
||||||
|
"Ui.Main.OrthographicProjectionAvoidsPerspectiveDistortionClick": "正交投影(平行投影,量尺寸不受透视变形;再按切回透视)",
|
||||||
|
"Ui.Main.ParallelProjectionAvoidsPerspectiveDistortionUncheck": "平行投影,量尺寸不受透视变形;取消勾选切回透视",
|
||||||
|
"Ui.Main.PickPointPartMoveThenPick": "先点要移动零件上的点,再点目标点",
|
||||||
|
"Ui.Main.PickThreePointsMovingModelThen": "先在要移动的文件点 3 个特征点,再到目标文件点 3 个对应点(顺序对应)",
|
||||||
|
"Ui.Main.Point": "📍 点",
|
||||||
|
"Ui.Main.PointCoordinates": "📍 点坐标",
|
||||||
|
"Ui.Main.PointCoordinatesVertexCircleCenterSnapping": "测量点坐标(吸附顶点/圆心)— 快速键 P",
|
||||||
|
"Ui.Main.QuickBarQ": "快捷栏(_Q)",
|
||||||
|
"Ui.Main.RecentFiles": "最近文件",
|
||||||
|
"Ui.Main.RecentlyOpenedFiles": "最近打开的文件",
|
||||||
|
"Ui.Main.RemoveThisFile": "移除此文件",
|
||||||
|
"Ui.Main.RestoreDefaultButtons": "恢复缺省按钮",
|
||||||
|
"Ui.Main.Right": "右",
|
||||||
|
"Ui.Main.RightView": "右视图",
|
||||||
|
"Ui.Main.RightViewLookAlongXZ": "右视图(+X 方向看,Z 向上)",
|
||||||
|
"Ui.Main.Rotate90AroundX": "↻ 绕 X 轴 +90°",
|
||||||
|
"Ui.Main.Rotate90AroundY": "↻ 绕 Y 轴 +90°",
|
||||||
|
"Ui.Main.Rotate90AroundZ": "↻ 绕 Z 轴 +90°",
|
||||||
|
"Ui.Main.RotateModelSelectedTree90Around": "选取的文件绕 X 轴 +90°(先在树面板点击文件)",
|
||||||
|
"Ui.Main.RotateModelSelectedTreeAroundIts": "对树面板选取的文件,绕其中心旋转(先在树面板点击文件)",
|
||||||
|
"Ui.Main.RotateSelectedModel90AroundY": "选取的文件绕 Y 轴 +90°",
|
||||||
|
"Ui.Main.RotateSelectedModel90AroundZ": "选取的文件绕 Z 轴 +90°",
|
||||||
|
"Ui.Main.SavePNGScreenshot3DView2": "3D 视图截图存 PNG(2x 分辨率)",
|
||||||
|
"Ui.Main.SaveViewPNG": "📷 视图截图 PNG…",
|
||||||
|
"Ui.Main.SectionAxis3PointsDefineAn": "剖切轴向(3点 = 在模型上点 3 点定义任意平面)",
|
||||||
|
"Ui.Main.SectionLabel": "✂ 剖面:",
|
||||||
|
"Ui.Main.SectionPosition0100EnterValue": "剖面位置 %(0–100,可直接输入,离开字段生效)",
|
||||||
|
"Ui.Main.SectionPositionAlongNormal": "剖面位置(沿法向 %)",
|
||||||
|
"Ui.Main.SectionView": "✂ 剖面查看",
|
||||||
|
"Ui.Main.ShortestDistanceBetweenFacesMeshApproximation": "面到面最短距离(网格近似)",
|
||||||
|
"Ui.Main.ShortestFaceDistanceMeshApproximationShortcut": "面到面最短距离(网格近似)— 快速键 M",
|
||||||
|
"Ui.Main.ShowAll": "☀ 全部显示",
|
||||||
|
"Ui.Main.ShowHideIncludingChildNodes": "显示 / 隐藏(含子节点)",
|
||||||
|
"Ui.Main.ShowOnlyThisNode": "👁 只显示此节点",
|
||||||
|
"Ui.Main.ShowOutlineEdgesManyEdgesCan": "显示轮廓边线(边线量大时转动视角会变慢)",
|
||||||
|
"Ui.Main.ShowSectionControlsAxisPositionDirection": "打开后下方出现剖面参数表(轴向/位置/反向)",
|
||||||
|
"Ui.Main.ShowXYZArrowsRotationRingsSelected": "对树面板选取的文件显示 XYZ 箭头与旋转环,每次放开即定位",
|
||||||
|
"Ui.Main.StraightCurvedCircularEdgeLengthShortcut": "边长(直线/曲线/圆弧)— 快速键 E",
|
||||||
|
"Ui.Main.SwitchDisplayUnitsBetweenMmInches": "切换显示单位 mm ⇄ inch(既有测量即时换算)",
|
||||||
|
"Ui.Main.SwitchRetainedSide": "切换保留侧",
|
||||||
|
"Ui.Main.ThreePointAlignmentPickThreePoints": "三点对齐(旋转+平移):先在要移动的文件点 3 个特征点,再到目标文件点 3 个对应点(顺序对应)",
|
||||||
|
"Ui.Main.ThreePointAlignmentRotateTranslate": "🎯 三点对齐(旋转+平移)",
|
||||||
|
"Ui.Main.ToggleSectionViewItsControls": "打开/关闭剖面查看(打开后下方出现参数表)",
|
||||||
|
"Ui.Main.ToolbarExportStepTooltip": "把可见文件(当前对齐位置)导出成新 STEP 文件 — 对齐插合结果可交接 CAD;不动源文件",
|
||||||
|
"Ui.Main.ToolbarExportStlTooltip": "把可见文件(当前位置)导出成 STL 网格文件 — 先设置范围/格式/单位/精度,确认再导出",
|
||||||
|
"Ui.Main.ToolbarImport": "📂 导入",
|
||||||
|
"Ui.Main.ToolbarSection": "✂ 剖面",
|
||||||
|
"Ui.Main.ToolsL": "工具(_L)",
|
||||||
|
"Ui.Main.Top": "上",
|
||||||
|
"Ui.Main.TopView": "上视图",
|
||||||
|
"Ui.Main.TopViewLookDownFromZ": "上视图(+Z 往下看)",
|
||||||
|
"Ui.Main.TransformT": "变换(_T)",
|
||||||
|
"Ui.Main.TwoPointAlignmentPickPointMoving": "两点对齐(平移零件):先点要移动零件上的点,再点目标点",
|
||||||
|
"Ui.Main.TwoPointAlignmentTranslate": "🎯 两点对齐(平移)",
|
||||||
|
"Ui.Main.TwoPointDistance": "📏 两点距离",
|
||||||
|
"Ui.Main.ViewV": "视图(_V)",
|
||||||
|
"Ui.Main.WindowTitle": "STPViewer — STP/STEP 3D 查看器",
|
||||||
|
"Ui.Main.ZoomFit": "🔍 缩放至全部",
|
||||||
|
"Ui.Main.ZoomThisNode": "缩放至此节点",
|
||||||
|
"Ui.StlExport.Cancel": "取消",
|
||||||
|
"Ui.StlExport.CustomScaleTooltip": "坐标乘上此系数(例:0.001 = mm→m)",
|
||||||
|
"Ui.StlExport.EstimatedSize": "预估 {0}",
|
||||||
|
"Ui.StlExport.EstimatedSizeFine": "以当前网格预估 {0}(实际依重新三角化结果)",
|
||||||
|
"Ui.StlExport.Export": "导出…",
|
||||||
|
"Ui.StlExport.Format": "格式",
|
||||||
|
"Ui.StlExport.FormatAscii": "ASCII(文本可读,文件约 4 倍大)",
|
||||||
|
"Ui.StlExport.FormatBinary": "Binary(建议,文件小)",
|
||||||
|
"Ui.StlExport.MeshQuality": "网格精度",
|
||||||
|
"Ui.StlExport.QualityCurrent": "当前显示网格(快,与画面一致)",
|
||||||
|
"Ui.StlExport.QualityFine": "精细 — 重新三角化,曲面更平滑(慢、文件大)",
|
||||||
|
"Ui.StlExport.QualityTooltip": "精细会由 B-rep 重新三角化(仅 STEP 来源;STL/DXF 维持现有网格),大组件需要时间",
|
||||||
|
"Ui.StlExport.Scope": "导出范围",
|
||||||
|
"Ui.StlExport.ScopeMerge": "全部可见文件 → 合并成单一 STL",
|
||||||
|
"Ui.StlExport.ScopePerFile": "每个可见文件 → 各一个 STL",
|
||||||
|
"Ui.StlExport.ScopeTooltip": "STL 无装配结构;合并 = 所有可见零件进同一个文件",
|
||||||
|
"Ui.StlExport.Summary": "{0} 个可见文件 · {1} 三角形 · {2}",
|
||||||
|
"Ui.StlExport.Title": "导出 STL — 参数设置",
|
||||||
|
"Ui.StlExport.UnitCustom": "自定义缩放系数…",
|
||||||
|
"Ui.StlExport.UnitInch": "inch(坐标 ÷ 25.4)",
|
||||||
|
"Ui.StlExport.UnitMillimeter": "mm(1:1,缺省)",
|
||||||
|
"Ui.StlExport.UnitScale": "单位缩放",
|
||||||
|
"Ui.StlExport.UnitTooltip": "STL 文件本身无单位;内部几何为 mm,选 inch 会把坐标 ÷25.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
{
|
||||||
|
"key": "繁體中文",
|
||||||
|
"data": {
|
||||||
|
"App.ErrorTitle": "STPViewer 錯誤",
|
||||||
|
"App.NoRecentFiles": "(沒有最近開啟的檔案)",
|
||||||
|
"App.UnexpectedError": "發生未預期的錯誤,操作已中止(程式將嘗試繼續運作)。\n\n{0}\n\n詳細記錄:{1}",
|
||||||
|
"App.WindowTitle": "STPViewer V{0} — STP/STEP 3D 檢視器",
|
||||||
|
"QuickBar.Align": "🎯 兩點對齊",
|
||||||
|
"QuickBar.Align3": "🎯 三點對齊",
|
||||||
|
"QuickBar.Angle": "∠ 角度",
|
||||||
|
"QuickBar.Circle": "⭕ 圓",
|
||||||
|
"QuickBar.Clear": "🧹 清除量測",
|
||||||
|
"QuickBar.Distance": "📏 距離",
|
||||||
|
"QuickBar.Drag": "🖐 拖曳",
|
||||||
|
"QuickBar.Edge": "📐 邊",
|
||||||
|
"QuickBar.ExportCsv": "💾 匯出 CSV",
|
||||||
|
"QuickBar.ExportStep": "📤 匯出 STEP",
|
||||||
|
"QuickBar.ExportStl": "📐 匯出 STL",
|
||||||
|
"QuickBar.Face": "⬛ 面",
|
||||||
|
"QuickBar.FaceDistance": "⇔ 面距",
|
||||||
|
"QuickBar.Gizmo": "⊹ 操作器",
|
||||||
|
"QuickBar.Import": "📂 匯入",
|
||||||
|
"QuickBar.Interference": "🧩 干涉",
|
||||||
|
"QuickBar.Language": "語言",
|
||||||
|
"QuickBar.Ortho": "正交投影",
|
||||||
|
"QuickBar.Point": "📍 點",
|
||||||
|
"QuickBar.Recent": "▾ 最近檔案",
|
||||||
|
"QuickBar.RotateX": "旋轉 ↻X",
|
||||||
|
"QuickBar.RotateY": "旋轉 ↻Y",
|
||||||
|
"QuickBar.RotateZ": "旋轉 ↻Z",
|
||||||
|
"QuickBar.Screenshot": "📷 截圖",
|
||||||
|
"QuickBar.Section": "✂ 剖面",
|
||||||
|
"QuickBar.Unit": "mm ⇄ in",
|
||||||
|
"QuickBar.ViewFront": "視圖:前",
|
||||||
|
"QuickBar.ViewIso": "視圖:等角",
|
||||||
|
"QuickBar.ViewRight": "視圖:右",
|
||||||
|
"QuickBar.ViewTop": "視圖:上",
|
||||||
|
"QuickBar.ZoomAll": "🔍 全覽",
|
||||||
|
"Runtime.Align3Completed": "已三點對齊「{0}」(旋轉+平移;點1精確貼合,量測已清空,可用 🧩干涉 驗證)",
|
||||||
|
"Runtime.Align3Failed": "三點對齊失敗(點位退化),請重新選點",
|
||||||
|
"Runtime.Align3OtherFile": "目標點要點在另一個檔案上",
|
||||||
|
"Runtime.Align3SameSource": "來源 3 點要同一個檔案(要移動:{0})",
|
||||||
|
"Runtime.Align3SourceCollinear": "三點共線,請改選不共線的第 3 點",
|
||||||
|
"Runtime.Align3SourceComplete": "來源 3 點完成 — 換點「目標檔案」上的對應點 1/3",
|
||||||
|
"Runtime.Align3SourcePoint": "來源點 {0}/3 — 繼續點「{1}」上的點",
|
||||||
|
"Runtime.Align3TargetCollinear": "目標三點共線,請改選不共線的第 3 點",
|
||||||
|
"Runtime.Align3TargetPoint": "目標點 {0}/3 — 繼續點目標檔案上的對應點",
|
||||||
|
"Runtime.AlignMoved": "已移動「{0}」 Δ({1:F3}, {2:F3}, {3:F3}) mm(量測已清空;可用 ⇔面距 / 🧩干涉 驗證配合)",
|
||||||
|
"Runtime.AlignMoving": "將移動「{0}」— 點選目標點(另一個檔案上)",
|
||||||
|
"Runtime.AlignOtherFile": "目標點要點在另一個檔案上(兩點同屬一個檔案無法對齊)",
|
||||||
|
"Runtime.AllVisible": "已全部顯示",
|
||||||
|
"Runtime.AngleDetail": "夾角 = {0:F2}°(補角 {1:F2}°)\n方向 1:{2} ({3:F3}, {4:F3}, {5:F3})\n方向 2:{6} ({7:F3}, {8:F3}, {9:F3})\n(兩面夾角以法向量計,互補角請參考補角值)",
|
||||||
|
"Runtime.AngleFirstDirection": "已選第 1 個方向({0})— 點選第 2 個面或邊",
|
||||||
|
"Runtime.ArcLength": "弧長",
|
||||||
|
"Runtime.Axis": "{0} 軸",
|
||||||
|
"Runtime.CircleDetail": "直徑 = {0}\n半徑 = {1}\n{2} = {3}\n圓心 {4}",
|
||||||
|
"Runtime.Circumference": "周長",
|
||||||
|
"Runtime.ComponentName": "組件",
|
||||||
|
"Runtime.ConicalSurface": "圓錐面",
|
||||||
|
"Runtime.CsvEmpty": "沒有量測結果可匯出",
|
||||||
|
"Runtime.CsvExported": "已匯出 {0} 筆量測 → {1}",
|
||||||
|
"Runtime.CsvFilter": "CSV 檔案 (*.csv)|*.csv",
|
||||||
|
"Runtime.CsvHeader": "標籤,類型,明細",
|
||||||
|
"Runtime.CsvTitle": "匯出量測結果",
|
||||||
|
"Runtime.CurveDetail": "曲線長 = {0}\n類型 = {1}",
|
||||||
|
"Runtime.CurveName": "曲線",
|
||||||
|
"Runtime.CustomPlane": "自訂平面",
|
||||||
|
"Runtime.CylinderSurface": "類型 = 圓柱面\n半徑 = {0}\n軸向 ({1:F3}, {2:F3}, {3:F3})",
|
||||||
|
"Runtime.DirectionEdge": "邊方向",
|
||||||
|
"Runtime.DirectionFace": "面法向量",
|
||||||
|
"Runtime.DirectionMeshFace": "面法向量(網格)",
|
||||||
|
"Runtime.DistanceDetail": "距離 = {0}\nΔX = {1}\nΔY = {2}\nΔZ = {3}\nP1 {4}\nP2 {5}",
|
||||||
|
"Runtime.DistanceFirstPoint": "第 1 點 ({0:F2}, {1:F2}, {2:F2}) — 點選第 2 點",
|
||||||
|
"Runtime.Dragged": "已拖曳「{0}」 Δ({1:F2}, {2:F2}, {3:F2}) mm(可用 🧩干涉 驗證)",
|
||||||
|
"Runtime.Dragging": "拖曳「{0}」中…(放開定位)",
|
||||||
|
"Runtime.DxfParseFailed": "無法解析 DXF 檔",
|
||||||
|
"Runtime.ExtrusionSurface": "拉伸曲面",
|
||||||
|
"Runtime.FaceDetail": "面積 ≈ {0}(網格近似)\n{1}\n三角形數 = {2:N0}",
|
||||||
|
"Runtime.FaceDistanceDetail": "最短距離 ≈ {0}(網格近似)\nΔX = {1}\nΔY = {2}\nΔZ = {3}\n點 1 {4}\n點 2 {5}",
|
||||||
|
"Runtime.FaceDistanceFirstFace": "已選第 1 個面 — 點選另一個面",
|
||||||
|
"Runtime.FaceName": "面",
|
||||||
|
"Runtime.FileNotFound": "找不到檔案",
|
||||||
|
"Runtime.GapLabel": "gap {0}",
|
||||||
|
"Runtime.GeometryProcessed": "幾何處理 {0:N0} ms(三角化 {1:N0} ms、邊取樣 {2:N0} ms{3})",
|
||||||
|
"Runtime.GizmoApplied": "已套用操作器變換「{0}」(可用 🧩干涉 驗證)",
|
||||||
|
"Runtime.GizmoHint": "操作器:拖箭頭沿軸移動「{0}」、拖環繞軸旋轉;放開即定位",
|
||||||
|
"Runtime.GizmoSelectFile": "操作器:請先在樹面板點選要操作的檔案",
|
||||||
|
"Runtime.ImportBusy": "忙碌中(匯入或運算進行中),請稍候再匯入",
|
||||||
|
"Runtime.ImportCompleted": "匯入完成:成功 {0} 檔{1}",
|
||||||
|
"Runtime.ImportFailed": "匯入失敗:{0} — {1}",
|
||||||
|
"Runtime.ImportFailedCount": "、失敗 {0} 檔",
|
||||||
|
"Runtime.ImportFailedTitle": "匯入失敗",
|
||||||
|
"Runtime.ImportFilter": "CAD 檔案 (*.stp;*.step;*.stl;*.dxf)|*.stp;*.step;*.stl;*.dxf|STEP (*.stp;*.step)|*.stp;*.step|STL (*.stl)|*.stl|DXF (*.dxf)|*.dxf|所有檔案 (*.*)|*.*",
|
||||||
|
"Runtime.ImportProgress": "匯入 {0}:{1}",
|
||||||
|
"Runtime.ImportTitle": "匯入 CAD 檔案(可複選)",
|
||||||
|
"Runtime.Importing": "匯入中:{0} …",
|
||||||
|
"Runtime.InterferenceChecking": "干涉檢查中(第 {0} 組):{1} ⟷ {2} …",
|
||||||
|
"Runtime.InterferenceClearDetail": "{0} ⟷ {1} 無相交\n最小間隙 ≈ {2}(網格近似)\ngap ≈ 0 即為貼合(match)\n點 1 {3}\n點 2 {4}",
|
||||||
|
"Runtime.InterferenceClearTitle": "🧩 無干涉 gap ≈ {0}",
|
||||||
|
"Runtime.InterferenceCompleted": "干涉檢查完成:{0} 組配對、{1} 組相交",
|
||||||
|
"Runtime.InterferenceFailed": "干涉檢查失敗:{0}",
|
||||||
|
"Runtime.InterferenceHitDetail": "兩零件相交(不 match)\n相交三角形對 = {0:N0}\n紅色線 = 干涉交線({1:N0} 段)\n(共面貼合不會被算為干涉)",
|
||||||
|
"Runtime.InterferenceHitTitle": "🧩 干涉!{0} ⟷ {1}",
|
||||||
|
"Runtime.InterferenceLabel": "干涉",
|
||||||
|
"Runtime.InterferenceNeedTwo": "干涉檢查需要至少 2 個可見檔案(目前 {0} 個)— 用樹面板勾選",
|
||||||
|
"Runtime.Isolated": "只顯示「{0}」(樹右鍵 → 全部顯示 復原)",
|
||||||
|
"Runtime.KindAlign": "對齊",
|
||||||
|
"Runtime.KindAlign3": "三點對齊",
|
||||||
|
"Runtime.KindAngle": "角度",
|
||||||
|
"Runtime.KindCircle": "圓",
|
||||||
|
"Runtime.KindDistance": "距離",
|
||||||
|
"Runtime.KindDrag": "拖曳",
|
||||||
|
"Runtime.KindEdge": "邊",
|
||||||
|
"Runtime.KindFace": "面",
|
||||||
|
"Runtime.KindFaceDistance": "面距",
|
||||||
|
"Runtime.KindInterference": "干涉",
|
||||||
|
"Runtime.KindNone": "無",
|
||||||
|
"Runtime.KindPoint": "點",
|
||||||
|
"Runtime.KindVolume": "體積",
|
||||||
|
"Runtime.LineDetail": "長度 = {0}\nΔX = {1} ΔY = {2} ΔZ = {3}\n起點 {4}\n終點 {5}",
|
||||||
|
"Runtime.MeasureFailed": "量測失敗:{0}",
|
||||||
|
"Runtime.MeasurementStartCancelled": "已取消目前量測起點(再按 Esc 退出量測模式)",
|
||||||
|
"Runtime.MeasurementsCleared": "已清除全部量測",
|
||||||
|
"Runtime.MeshMeasurementLimited": "此模型無 B-rep(STL/網格),僅支援 點 / 距離 / 角度 / 面距 量測",
|
||||||
|
"Runtime.MeshName": "網格",
|
||||||
|
"Runtime.MeshNameNumbered": "網格 {0}",
|
||||||
|
"Runtime.MeshSurface": "類型 = 網格(無 B-rep)",
|
||||||
|
"Runtime.ModeAlign": "兩點對齊:先點「要移動零件」上的點,再點目標點(純平移,量測會清空)",
|
||||||
|
"Runtime.ModeAlign3": "三點對齊:先在「要移動的檔案」點 3 個特徵點,再到目標檔案點 3 個對應點(旋轉+平移)",
|
||||||
|
"Runtime.ModeAngle": "角度量測:點選第 1 個面(或靠近直線邊)",
|
||||||
|
"Runtime.ModeBrowse": "瀏覽模式(右鍵旋轉、滾輪縮放、中鍵或 Shift+左鍵拖曳平移)",
|
||||||
|
"Runtime.ModeCircle": "圓量測:點擊圓孔內壁或圓邊附近",
|
||||||
|
"Runtime.ModeDistance": "距離量測:點選第 1 點",
|
||||||
|
"Runtime.ModeDrag": "拖曳模式:左鍵按住零件拖動(沿螢幕平面),放開定位;深度方向先轉視角再拖。右鍵仍可轉視角",
|
||||||
|
"Runtime.ModeEdge": "邊量測:點擊靠近要量的邊",
|
||||||
|
"Runtime.ModeFace": "面量測:點擊要量的面",
|
||||||
|
"Runtime.ModeFaceDistance": "面距量測:點選第 1 個面",
|
||||||
|
"Runtime.ModePoint": "點量測:點擊模型表面(自動吸附鄰近頂點)",
|
||||||
|
"Runtime.NoBrep": " · 無 B-rep",
|
||||||
|
"Runtime.NoCircularEdge": "此面沒有圓形邊(請點圓孔內壁或圓邊附近)",
|
||||||
|
"Runtime.NoGeometry": "檔案內沒有可顯示的幾何",
|
||||||
|
"Runtime.NoMeasurableEdge": "此面沒有可量測的邊",
|
||||||
|
"Runtime.NoSurfaceHit": "未命中模型表面",
|
||||||
|
"Runtime.NodeStats": "{0} 實體 · {1} 面 · {2:N0} △",
|
||||||
|
"Runtime.NurbsSurface": "NURBS 曲面",
|
||||||
|
"Runtime.Orthographic": "正交投影(平行投影,適合尺寸確認)",
|
||||||
|
"Runtime.OutlineSize": "\n外形 {0}",
|
||||||
|
"Runtime.ParsingStep": "解析 STEP(大檔可能需數分鐘)…",
|
||||||
|
"Runtime.ParsingStl": "解析 STL(直接讀三角形)…",
|
||||||
|
"Runtime.Perspective": "透視投影",
|
||||||
|
"Runtime.PlaneSurface": "類型 = 平面\n法向量 ({0:F3}, {1:F3}, {2:F3})",
|
||||||
|
"Runtime.Ready": "就緒 — 匯入 STP/STEP/STL/DXF(拖放亦可)。右鍵旋轉、滾輪縮放、中鍵或 Shift+左鍵平移",
|
||||||
|
"Runtime.Removed": "已移除:{0}",
|
||||||
|
"Runtime.RetriedFaces": "、重試補回 {0} 面",
|
||||||
|
"Runtime.Reversed": "(反向)",
|
||||||
|
"Runtime.RevolutionSurface": "旋轉曲面",
|
||||||
|
"Runtime.RootNotFound": "找不到此零件所屬檔案",
|
||||||
|
"Runtime.RotateSelectFile": "請先在樹面板點選要旋轉的檔案(任一節點即可)",
|
||||||
|
"Runtime.Rotated": "已旋轉「{0}」繞 {1} 軸 +90°(再按可繼續轉)",
|
||||||
|
"Runtime.SameFace": "兩次點到同一個面,請點選另一個面",
|
||||||
|
"Runtime.ScreenshotFilter": "PNG 圖片 (*.png)|*.png",
|
||||||
|
"Runtime.ScreenshotSaved": "已儲存截圖 → {0}",
|
||||||
|
"Runtime.ScreenshotTitle": "儲存視圖截圖",
|
||||||
|
"Runtime.SectionCancelled": "已取消 3 點剖面定義",
|
||||||
|
"Runtime.SectionClosed": "剖面已關閉",
|
||||||
|
"Runtime.SectionCollinear": "3點剖面:三點共線,請改選不共線的第 3 點",
|
||||||
|
"Runtime.SectionDefined": "3點剖面:平面已定義(法向 {0:F2}, {1:F2}, {2:F2})— 用位置滑桿掃剖面",
|
||||||
|
"Runtime.SectionDegenerate": "3點剖面:三點退化(共線),請重新點選",
|
||||||
|
"Runtime.SectionNoHit": "3點剖面:未命中模型表面(已選 {0}/3 點,Esc 取消)",
|
||||||
|
"Runtime.SectionPickPrompt": "3點剖面:請在模型上點 3 個點定義剖切平面(Esc 取消)",
|
||||||
|
"Runtime.SectionPicked": "3點剖面:已選 {0}/3 點",
|
||||||
|
"Runtime.SectionStatus": "剖面:{0} {1:F0}%{2}",
|
||||||
|
"Runtime.ShellName": "殼體",
|
||||||
|
"Runtime.SolidName": "實體",
|
||||||
|
"Runtime.SphericalSurface": "球面",
|
||||||
|
"Runtime.StepExportEmpty": "沒有可匯出的 B-rep 幾何(STEP 匯出僅含 Solid/Shell;請勾選要匯出的 STEP 檔案)",
|
||||||
|
"Runtime.StepExportFailed": "STEP 匯出失敗:{0}",
|
||||||
|
"Runtime.StepExportFilter": "STEP 檔案 (*.stp)|*.stp|STEP 檔案 (*.step)|*.step",
|
||||||
|
"Runtime.StepExportTitle": "匯出目前場景為 STEP(零件在目前對齊位置)",
|
||||||
|
"Runtime.StepExported": "已匯出 STEP({0} 個實體,目前位置)→ {1}",
|
||||||
|
"Runtime.StepParseFailed": "CADability 無法解析此 STEP 檔",
|
||||||
|
"Runtime.StepParsed": "STEP 解析 {0:N0} ms",
|
||||||
|
"Runtime.StlExportEmpty": "沒有可匯出的網格(請勾選至少一個含實體網格的檔案)",
|
||||||
|
"Runtime.StlExportFailed": "STL 匯出失敗:{0}",
|
||||||
|
"Runtime.StlExportFilter": "STL 檔案 (*.stl)|*.stl",
|
||||||
|
"Runtime.StlExportMergedTitle": "匯出 STL(合併單一檔)",
|
||||||
|
"Runtime.StlExportPerFileTitle": "匯出 STL(每個檔案各一個,以此為基底檔名)",
|
||||||
|
"Runtime.StlExportedMany": "已匯出 {0} 個 STL(共 {1:N0} 三角形)→ {2}",
|
||||||
|
"Runtime.StlExportedOne": "已匯出 STL({0:N0} 三角形)→ {1}",
|
||||||
|
"Runtime.StlExporting": "匯出 STL 中…",
|
||||||
|
"Runtime.StlNoTriangles": "STL 檔內沒有三角形",
|
||||||
|
"Runtime.StlParsed": "STL 解析 {0:N0} ms({1} 個網格)",
|
||||||
|
"Runtime.StlRetriangulating": "重新三角化 + 匯出 STL 中…(大組件需要時間)",
|
||||||
|
"Runtime.SurfaceType": "類型 = {0}",
|
||||||
|
"Runtime.ToroidalSurface": "環面",
|
||||||
|
"Runtime.Triangulating": "三角化 {0} 個零件(平行)…",
|
||||||
|
"Runtime.UnitInch": "顯示單位:inch",
|
||||||
|
"Runtime.UnitMillimeter": "顯示單位:mm",
|
||||||
|
"Runtime.UnsupportedFormat": "不支援的格式(支援 .stp / .step / .stl / .dxf;CADability 無 IGES reader)",
|
||||||
|
"Runtime.View": "視圖:{0}",
|
||||||
|
"Runtime.ViewBack": "後",
|
||||||
|
"Runtime.ViewBottom": "下",
|
||||||
|
"Runtime.ViewFront": "前",
|
||||||
|
"Runtime.ViewIso": "等角",
|
||||||
|
"Runtime.ViewLeft": "左",
|
||||||
|
"Runtime.ViewRight": "右",
|
||||||
|
"Runtime.ViewTop": "上",
|
||||||
|
"Runtime.VisibilityInverted": "已反轉顯示",
|
||||||
|
"Runtime.VolumeDetail": "體積 ≈ {0}(網格近似)\n質心 {1}\n外形(AABB){2} × {3} × {4} {5}",
|
||||||
|
"Runtime.VolumeNoMesh": "「{0}」沒有可計算的面網格(線架構無體積)",
|
||||||
|
"Runtime.VolumeOpenMesh": "「{0}」非封閉網格(開放殼),無法可靠計算體積",
|
||||||
|
"Ui.About.Changelog": "📋 版本紀錄",
|
||||||
|
"Ui.About.Close": "關閉",
|
||||||
|
"Ui.About.Description": "STP / STEP 3D 檢視器。免開重量級 CAD,快速開檔、量測、對齊插合、干涉檢查、剖面、匯出。",
|
||||||
|
"Ui.About.Developer": "👤 開發者",
|
||||||
|
"Ui.About.Email": "電子郵件",
|
||||||
|
"Ui.About.Name": "姓名",
|
||||||
|
"Ui.About.Release010.Change1": "• STEP/STL/DXF 匯入與裝配樹、點/距離/邊/面/圓/角度/面距量測、兩點與三點對齊、干涉檢查、剖面、mm ⇄ inch、CSV 與截圖匯出。",
|
||||||
|
"Ui.About.Release010.Title": "首次釋出",
|
||||||
|
"Ui.About.Release020.Change1": "• 新增拖曳模式:左鍵按住零件直接拖到想要的位置,放開就定位。",
|
||||||
|
"Ui.About.Release020.Change2": "• 0.2.1 修正拖曳過的零件再點擊會當掉的問題。",
|
||||||
|
"Ui.About.Release020.Title": "拖曳模式",
|
||||||
|
"Ui.About.Release030.Change1": "• 新增操作器:選取檔案後出現 XYZ 三色箭頭與旋轉環,箭頭沿軸精確移動、旋轉環繞軸轉任意角度。",
|
||||||
|
"Ui.About.Release030.Title": "三軸操作器",
|
||||||
|
"Ui.About.Release031.Change1": "• 三軸操作器改成永遠浮在畫面最上層,不會被零件擋住,任何角度都抓得到。",
|
||||||
|
"Ui.About.Release031.Title": "操作器永遠可見",
|
||||||
|
"Ui.About.Release032.Change1": "• 修正大型組件(數萬個面)進入量測模式後,轉動視角嚴重卡頓的問題 — 現在量測跟瀏覽一樣順。",
|
||||||
|
"Ui.About.Release032.Title": "大組件量測不再卡",
|
||||||
|
"Ui.About.Release040.Change1": "• 大檔案拉剖面滑桿不再卡住畫面(改到背景計算)。",
|
||||||
|
"Ui.About.Release040.Change2": "• 拖曳/操作器放開零件後的定位計算大幅加速。",
|
||||||
|
"Ui.About.Release040.Change3": "• 程式遇到錯誤會提示訊息並繼續運作,不再整個閃退。",
|
||||||
|
"Ui.About.Release040.Title": "更快、更穩",
|
||||||
|
"Ui.About.Release050.Change1": "• 量距離時點到圓孔邊緣會自動吸到圓心 — 量兩孔中心距(pitch)超方便。",
|
||||||
|
"Ui.About.Release050.Change2": "• 量測快速鍵:P 點、D 距離、E 邊、F 面、C 圓、A 角度、M 面距;Esc 取消。",
|
||||||
|
"Ui.About.Release050.Change3": "• 樹狀面板可搜尋零件名稱;右鍵可「只顯示此節點」隔離觀察,也可以量體積與質心。",
|
||||||
|
"Ui.About.Release050.Change4": "• 新增標準視圖(等角/前/上/右)與正交投影,量尺寸不受透視變形。",
|
||||||
|
"Ui.About.Release050.Change5": "• 剖面新增「3點」模式:在模型上點 3 個點,就能沿任意斜面剖開。",
|
||||||
|
"Ui.About.Release050.Change6": "• 新增匯出 STEP:把對齊好的零件位置存成新 STEP 檔,交接給正式 CAD。",
|
||||||
|
"Ui.About.Release050.Change7": "• 會記住視窗大小、單位設定與最近開啟的 10 個檔案。",
|
||||||
|
"Ui.About.Release050.Title": "量測、視圖、樹狀面板大升級",
|
||||||
|
"Ui.About.Release060.Change1": "• 新增匯出 STL:把畫面上的零件(含對齊、旋轉後的位置)存成 STL 網格檔,可拿去 3D 列印或給其他軟體用。",
|
||||||
|
"Ui.About.Release060.Change2": "• 匯出前會先跳出設定視窗:全部合併成一個檔或每個檔案各存一個、Binary 或文字格式、mm 或 inch、網格精細度 — 確認後才匯出。",
|
||||||
|
"Ui.About.Release060.Change3": "• 設定視窗會即時顯示三角形數量與預估檔案大小。",
|
||||||
|
"Ui.About.Release060.Title": "匯出 STL",
|
||||||
|
"Ui.About.Release070.Change1": "• 上方新增選單列,全部功能依「檔案/視圖/量測/變換/工具」分類,不用再在一長排按鈕裡找。",
|
||||||
|
"Ui.About.Release070.Change2": "• 原本的工具列變成「快速列」:只放常用按鈕。想放哪些自己決定 — 到「快速列 → 自訂按鈕」勾選,下次開啟會記住。",
|
||||||
|
"Ui.About.Release070.Change3": "• 剖面的軸向/位置控制列改成只在開啟剖面時出現,平常不佔空間。",
|
||||||
|
"Ui.About.Release070.Change4": "• 新增「關於」視窗(就是這個畫面),可以看版本紀錄。",
|
||||||
|
"Ui.About.Release070.Title": "介面大整理 — 選單列+可自訂快速列",
|
||||||
|
"Ui.About.Release071.Change1": "• 開啟大型 STL 網格檔(數十 MB、上百萬三角形)現在幾乎瞬間完成,不用再等好幾分鐘。",
|
||||||
|
"Ui.About.Release071.Change2": "• 例如一個 75 MB、150 萬個三角形的 STL,以前要等到天荒地老,現在不到 1 秒就顯示出來。",
|
||||||
|
"Ui.About.Release071.Title": "大型 STL 秒開",
|
||||||
|
"Ui.About.Technology": ".NET 8 · WPF · C#;CADability(STEP / B-rep 幾何核心);HelixToolkit(3D 顯示與相機)",
|
||||||
|
"Ui.About.TechnologyTitle": "🔧 技術棧",
|
||||||
|
"Ui.About.Title": "關於 STPViewer",
|
||||||
|
"Ui.Language": "語言",
|
||||||
|
"Ui.Main.3AxisGizmo": "⊹ 三軸操作器",
|
||||||
|
"Ui.Main.3AxisGizmoUseXYZArrows": "三軸操作器:對樹面板選取的檔案顯示 XYZ 箭頭(沿軸移動)與旋轉環(繞軸轉任意角度),每次放開即定位",
|
||||||
|
"Ui.Main.3Point": "🎯 三點",
|
||||||
|
"Ui.Main.3Points": "3點",
|
||||||
|
"Ui.Main.Align": "🎯 對齊",
|
||||||
|
"Ui.Main.Angle": "∠ 角度",
|
||||||
|
"Ui.Main.AngleBetweenFacesStraightEdgesShortcut": "兩面/兩邊夾角(點兩個面或靠近直線邊)— 快速鍵 A",
|
||||||
|
"Ui.Main.AngleBetweenTwoFacesStraightEdges": "兩面/兩邊夾角(點兩個面或靠近直線邊)",
|
||||||
|
"Ui.Main.ApplicationInformationReleaseHistory": "程式資訊與版本紀錄",
|
||||||
|
"Ui.Main.AreaSurfaceTypeNormalVector": "面積 + 曲面類型/法向量",
|
||||||
|
"Ui.Main.AreaSurfaceTypeNormalVectorShortcut": "面積 + 曲面類型/法向量 — 快速鍵 F",
|
||||||
|
"Ui.Main.Capture": "📷 截圖",
|
||||||
|
"Ui.Main.CenterRadiusDiameterCircumference": "圓心/半徑/直徑/周長",
|
||||||
|
"Ui.Main.CenterRadiusDiameterCircumferenceShortcutC": "圓心/半徑/直徑/周長 — 快速鍵 C",
|
||||||
|
"Ui.Main.CheckButtonsShowQuickBarSaved": "勾選要顯示在快速列的常用按鈕(隨設定保存)",
|
||||||
|
"Ui.Main.CheckVisibleModelPairsIntersectionsReport": "2 個以上可見檔案兩兩檢查是否相交;無相交時回報最小間隙",
|
||||||
|
"Ui.Main.ChooseScopeFormatUnitsMeshQuality": "先設定範圍/格式/單位/精度,確認再匯出",
|
||||||
|
"Ui.Main.Circle": "⭕ 圓",
|
||||||
|
"Ui.Main.Clear": "🧹 清除",
|
||||||
|
"Ui.Main.ClearAllMeasurements": "🧹 清除全部量測",
|
||||||
|
"Ui.Main.ClearAllMeasurementsMarkersEscCancels": "清除全部量測與標記(Esc = 取消進行中量測/退出模式)",
|
||||||
|
"Ui.Main.ClickChangeColorIncludingChildNodes": "點擊換色(含子節點)",
|
||||||
|
"Ui.Main.Close": "✕ 關閉",
|
||||||
|
"Ui.Main.CloseSectionView": "關閉剖面檢視",
|
||||||
|
"Ui.Main.CustomizeButtons": "自訂按鈕",
|
||||||
|
"Ui.Main.DeleteThisMeasurement": "刪除此筆量測",
|
||||||
|
"Ui.Main.DisplayInchesMm": "單位顯示 inch(mm ⇄ in)",
|
||||||
|
"Ui.Main.Distance": "📏 距離",
|
||||||
|
"Ui.Main.DistanceXYZCircleCenterSnappingHole": "兩點距離 + ΔXYZ(點到圓邊會吸圓心 → 可量孔對孔 pitch)— 快速鍵 D",
|
||||||
|
"Ui.Main.DistanceXYZCircularEdgesSnapCenters": "兩點距離 + ΔXYZ(點到圓邊會吸圓心 → 可量孔對孔 pitch)",
|
||||||
|
"Ui.Main.Drag": "🖐 拖曳",
|
||||||
|
"Ui.Main.DragMode": "🖐 拖曳模式",
|
||||||
|
"Ui.Main.DragModeHoldLeftMouseButton": "拖曳模式:左鍵按住零件沿螢幕平面拖動,放開定位(深度方向先轉視角再拖)",
|
||||||
|
"Ui.Main.DragResizePanel": "拖曳調整面板寬度",
|
||||||
|
"Ui.Main.Edge": "📐 邊",
|
||||||
|
"Ui.Main.EdgeLength": "📐 邊長",
|
||||||
|
"Ui.Main.Edges": "邊",
|
||||||
|
"Ui.Main.EscCancelsCurrentMeasurementExitsMode": "Esc = 取消進行中量測/退出模式",
|
||||||
|
"Ui.Main.ExistingMeasurementsUpdateImmediatelyValuesStored": "既有量測即時換算;內部一律存 mm",
|
||||||
|
"Ui.Main.ExportMeasurementResultsCSV": "量測結果匯出 CSV",
|
||||||
|
"Ui.Main.ExportMeasurementsCSV": "💾 匯出量測 CSV…",
|
||||||
|
"Ui.Main.ExportSTEP": "📤 匯出 STEP…",
|
||||||
|
"Ui.Main.ExportSTL": "📐 匯出 STL…",
|
||||||
|
"Ui.Main.ExportStepTooltip": "把可見檔案(目前對齊位置)匯出成新 STEP 檔;不動原始檔",
|
||||||
|
"Ui.Main.Face": "⬛ 面",
|
||||||
|
"Ui.Main.FaceDistance": "⇔ 面距",
|
||||||
|
"Ui.Main.FileF": "檔案(_F)",
|
||||||
|
"Ui.Main.FilterAssemblyTreeByNameDoes": "輸入名稱過濾裝配樹(只影響樹面板顯示,不影響 3D 可見性;清空 = 全顯)",
|
||||||
|
"Ui.Main.Fit": "🔍 全覽",
|
||||||
|
"Ui.Main.FitAllModelsView": "縮放至全部模型",
|
||||||
|
"Ui.Main.Flip": "反向",
|
||||||
|
"Ui.Main.Front": "前",
|
||||||
|
"Ui.Main.FrontView": "前視圖",
|
||||||
|
"Ui.Main.FrontViewLookAlongYZ": "前視圖(-Y 方向看,Z 向上)",
|
||||||
|
"Ui.Main.Gizmo": "⊹ 操作器",
|
||||||
|
"Ui.Main.HelpH": "說明(_H)",
|
||||||
|
"Ui.Main.HoldLeftMouseButtonMovePart": "左鍵按住零件沿螢幕平面拖動,放開定位",
|
||||||
|
"Ui.Main.IAboutSTPViewer": "ℹ 關於 STPViewer…",
|
||||||
|
"Ui.Main.ImportSTPSTEPSTLDXFFiles": "匯入 STP/STEP/STL/DXF(可複選,也可直接拖放到視窗)",
|
||||||
|
"Ui.Main.Interference": "🧩 干涉",
|
||||||
|
"Ui.Main.InterferenceCheck": "🧩 干涉檢查",
|
||||||
|
"Ui.Main.InterferenceCheckTestVisibleModelPairs": "干涉檢查:2 個以上可見檔案兩兩檢查是否相交(match 判定);無相交時回報最小間隙",
|
||||||
|
"Ui.Main.InvertVisibility": "🔄 反轉顯示",
|
||||||
|
"Ui.Main.Iso": "等角",
|
||||||
|
"Ui.Main.IsometricView": "等角視圖",
|
||||||
|
"Ui.Main.LookAlongXZUp": "+X 方向看,Z 向上",
|
||||||
|
"Ui.Main.LookAlongYZUp": "-Y 方向看,Z 向上",
|
||||||
|
"Ui.Main.LookDownFromZ": "+Z 往下看",
|
||||||
|
"Ui.Main.MeasureM": "量測(_M)",
|
||||||
|
"Ui.Main.MeasurePointCoordinatesSnapsVerticesCircle": "量測點座標(吸附頂點/圓心)",
|
||||||
|
"Ui.Main.MeasureStraightCurvedCircularEdgeLengths": "邊長(直線/曲線/圓弧)",
|
||||||
|
"Ui.Main.MeasureVolumeCentroid": "⚖ 量體積 / 質心",
|
||||||
|
"Ui.Main.Measurements": "量測結果",
|
||||||
|
"Ui.Main.MenuImport": "📂 匯入…",
|
||||||
|
"Ui.Main.ModelsAssemblyTree": "模型 / 裝配樹",
|
||||||
|
"Ui.Main.Ortho": "正交",
|
||||||
|
"Ui.Main.OrthographicProjection": "正交投影",
|
||||||
|
"Ui.Main.OrthographicProjectionAvoidsPerspectiveDistortionClick": "正交投影(平行投影,量尺寸不受透視變形;再按切回透視)",
|
||||||
|
"Ui.Main.ParallelProjectionAvoidsPerspectiveDistortionUncheck": "平行投影,量尺寸不受透視變形;取消勾選切回透視",
|
||||||
|
"Ui.Main.PickPointPartMoveThenPick": "先點要移動零件上的點,再點目標點",
|
||||||
|
"Ui.Main.PickThreePointsMovingModelThen": "先在要移動的檔案點 3 個特徵點,再到目標檔案點 3 個對應點(順序對應)",
|
||||||
|
"Ui.Main.Point": "📍 點",
|
||||||
|
"Ui.Main.PointCoordinates": "📍 點座標",
|
||||||
|
"Ui.Main.PointCoordinatesVertexCircleCenterSnapping": "量測點座標(吸附頂點/圓心)— 快速鍵 P",
|
||||||
|
"Ui.Main.QuickBarQ": "快速列(_Q)",
|
||||||
|
"Ui.Main.RecentFiles": "最近檔案",
|
||||||
|
"Ui.Main.RecentlyOpenedFiles": "最近開啟的檔案",
|
||||||
|
"Ui.Main.RemoveThisFile": "移除此檔案",
|
||||||
|
"Ui.Main.RestoreDefaultButtons": "恢復預設按鈕",
|
||||||
|
"Ui.Main.Right": "右",
|
||||||
|
"Ui.Main.RightView": "右視圖",
|
||||||
|
"Ui.Main.RightViewLookAlongXZ": "右視圖(+X 方向看,Z 向上)",
|
||||||
|
"Ui.Main.Rotate90AroundX": "↻ 繞 X 軸 +90°",
|
||||||
|
"Ui.Main.Rotate90AroundY": "↻ 繞 Y 軸 +90°",
|
||||||
|
"Ui.Main.Rotate90AroundZ": "↻ 繞 Z 軸 +90°",
|
||||||
|
"Ui.Main.RotateModelSelectedTree90Around": "選取的檔案繞 X 軸 +90°(先在樹面板點選檔案)",
|
||||||
|
"Ui.Main.RotateModelSelectedTreeAroundIts": "對樹面板選取的檔案,繞其中心旋轉(先在樹面板點選檔案)",
|
||||||
|
"Ui.Main.RotateSelectedModel90AroundY": "選取的檔案繞 Y 軸 +90°",
|
||||||
|
"Ui.Main.RotateSelectedModel90AroundZ": "選取的檔案繞 Z 軸 +90°",
|
||||||
|
"Ui.Main.SavePNGScreenshot3DView2": "3D 視圖截圖存 PNG(2x 解析度)",
|
||||||
|
"Ui.Main.SaveViewPNG": "📷 視圖截圖 PNG…",
|
||||||
|
"Ui.Main.SectionAxis3PointsDefineAn": "剖切軸向(3點 = 在模型上點 3 點定義任意平面)",
|
||||||
|
"Ui.Main.SectionLabel": "✂ 剖面:",
|
||||||
|
"Ui.Main.SectionPosition0100EnterValue": "剖面位置 %(0–100,可直接輸入,離開欄位生效)",
|
||||||
|
"Ui.Main.SectionPositionAlongNormal": "剖面位置(沿法向 %)",
|
||||||
|
"Ui.Main.SectionView": "✂ 剖面檢視",
|
||||||
|
"Ui.Main.ShortestDistanceBetweenFacesMeshApproximation": "面到面最短距離(網格近似)",
|
||||||
|
"Ui.Main.ShortestFaceDistanceMeshApproximationShortcut": "面到面最短距離(網格近似)— 快速鍵 M",
|
||||||
|
"Ui.Main.ShowAll": "☀ 全部顯示",
|
||||||
|
"Ui.Main.ShowHideIncludingChildNodes": "顯示 / 隱藏(含子節點)",
|
||||||
|
"Ui.Main.ShowOnlyThisNode": "👁 只顯示此節點",
|
||||||
|
"Ui.Main.ShowOutlineEdgesManyEdgesCan": "顯示輪廓邊線(邊線量大時轉動視角會變慢)",
|
||||||
|
"Ui.Main.ShowSectionControlsAxisPositionDirection": "開啟後下方出現剖面參數列(軸向/位置/反向)",
|
||||||
|
"Ui.Main.ShowXYZArrowsRotationRingsSelected": "對樹面板選取的檔案顯示 XYZ 箭頭與旋轉環,每次放開即定位",
|
||||||
|
"Ui.Main.StraightCurvedCircularEdgeLengthShortcut": "邊長(直線/曲線/圓弧)— 快速鍵 E",
|
||||||
|
"Ui.Main.SwitchDisplayUnitsBetweenMmInches": "切換顯示單位 mm ⇄ inch(既有量測即時換算)",
|
||||||
|
"Ui.Main.SwitchRetainedSide": "切換保留側",
|
||||||
|
"Ui.Main.ThreePointAlignmentPickThreePoints": "三點對齊(旋轉+平移):先在要移動的檔案點 3 個特徵點,再到目標檔案點 3 個對應點(順序對應)",
|
||||||
|
"Ui.Main.ThreePointAlignmentRotateTranslate": "🎯 三點對齊(旋轉+平移)",
|
||||||
|
"Ui.Main.ToggleSectionViewItsControls": "開啟/關閉剖面檢視(開啟後下方出現參數列)",
|
||||||
|
"Ui.Main.ToolbarExportStepTooltip": "把可見檔案(目前對齊位置)匯出成新 STEP 檔 — 對齊插合結果可交接 CAD;不動原始檔",
|
||||||
|
"Ui.Main.ToolbarExportStlTooltip": "把可見檔案(目前位置)匯出成 STL 網格檔 — 先設定範圍/格式/單位/精度,確認再匯出",
|
||||||
|
"Ui.Main.ToolbarImport": "📂 匯入",
|
||||||
|
"Ui.Main.ToolbarSection": "✂ 剖面",
|
||||||
|
"Ui.Main.ToolsL": "工具(_L)",
|
||||||
|
"Ui.Main.Top": "上",
|
||||||
|
"Ui.Main.TopView": "上視圖",
|
||||||
|
"Ui.Main.TopViewLookDownFromZ": "上視圖(+Z 往下看)",
|
||||||
|
"Ui.Main.TransformT": "變換(_T)",
|
||||||
|
"Ui.Main.TwoPointAlignmentPickPointMoving": "兩點對齊(平移零件):先點要移動零件上的點,再點目標點",
|
||||||
|
"Ui.Main.TwoPointAlignmentTranslate": "🎯 兩點對齊(平移)",
|
||||||
|
"Ui.Main.TwoPointDistance": "📏 兩點距離",
|
||||||
|
"Ui.Main.ViewV": "視圖(_V)",
|
||||||
|
"Ui.Main.WindowTitle": "STPViewer — STP/STEP 3D 檢視器",
|
||||||
|
"Ui.Main.ZoomFit": "🔍 縮放至全部",
|
||||||
|
"Ui.Main.ZoomThisNode": "縮放至此節點",
|
||||||
|
"Ui.StlExport.Cancel": "取消",
|
||||||
|
"Ui.StlExport.CustomScaleTooltip": "座標乘上此係數(例:0.001 = mm→m)",
|
||||||
|
"Ui.StlExport.EstimatedSize": "預估 {0}",
|
||||||
|
"Ui.StlExport.EstimatedSizeFine": "以目前網格預估 {0}(實際依重新三角化結果)",
|
||||||
|
"Ui.StlExport.Export": "匯出…",
|
||||||
|
"Ui.StlExport.Format": "格式",
|
||||||
|
"Ui.StlExport.FormatAscii": "ASCII(文字可讀,檔案約 4 倍大)",
|
||||||
|
"Ui.StlExport.FormatBinary": "Binary(建議,檔案小)",
|
||||||
|
"Ui.StlExport.MeshQuality": "網格精度",
|
||||||
|
"Ui.StlExport.QualityCurrent": "目前顯示網格(快,與畫面一致)",
|
||||||
|
"Ui.StlExport.QualityFine": "精細 — 重新三角化,曲面更平滑(慢、檔案大)",
|
||||||
|
"Ui.StlExport.QualityTooltip": "精細會由 B-rep 重新三角化(僅 STEP 來源;STL/DXF 維持現有網格),大組件需要時間",
|
||||||
|
"Ui.StlExport.Scope": "匯出範圍",
|
||||||
|
"Ui.StlExport.ScopeMerge": "全部可見檔案 → 合併成單一 STL",
|
||||||
|
"Ui.StlExport.ScopePerFile": "每個可見檔案 → 各一個 STL",
|
||||||
|
"Ui.StlExport.ScopeTooltip": "STL 無裝配結構;合併 = 所有可見零件進同一檔",
|
||||||
|
"Ui.StlExport.Summary": "{0} 個可見檔案 · {1} 三角形 · {2}",
|
||||||
|
"Ui.StlExport.Title": "匯出 STL — 參數設定",
|
||||||
|
"Ui.StlExport.UnitCustom": "自訂縮放係數…",
|
||||||
|
"Ui.StlExport.UnitInch": "inch(座標 ÷ 25.4)",
|
||||||
|
"Ui.StlExport.UnitMillimeter": "mm(1:1,預設)",
|
||||||
|
"Ui.StlExport.UnitScale": "單位縮放",
|
||||||
|
"Ui.StlExport.UnitTooltip": "STL 檔本身無單位;內部幾何為 mm,選 inch 會把座標 ÷25.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
// 三點對齊剛體變換數學驗證:
|
// 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;
|
||||||
|
|
||||||
@@ -11,29 +10,29 @@ internal static class AlignTest
|
|||||||
{
|
{
|
||||||
int failures = 0;
|
int failures = 0;
|
||||||
|
|
||||||
// ── (1) 已知剛體變換:繞 Z 軸 90° + 平移 (5,-3,2),三點求解後應還原同一變換 ──
|
// Recover a known 90-degree Z rotation followed by translation (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); // 不共線、不在同平面軸上
|
var p3 = new Point3D(0, 7, 3); // Not collinear with the first two points.
|
||||||
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("三點求解成功", 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);
|
||||||
|
|
||||||
// 剛體變換唯一性:任意第 4 點也應該跟著正確變換
|
// An independent fourth point checks the full rigid transform.
|
||||||
var r = new Point3D(-4, 12, 8);
|
var r = new Point3D(-4, 12, 8);
|
||||||
failures += CheckPoint("任意第4點", m.Transform(r), truth.Transform(r));
|
failures += CheckPoint("fourth point", m.Transform(r), truth.Transform(r));
|
||||||
|
|
||||||
// ── (2) ToModOp:CADability ModOp 與 WPF Matrix3D 對同一點要算出同樣結果 ──
|
// CADability ModOp and WPF Matrix3D must transform points identically.
|
||||||
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) })
|
||||||
{
|
{
|
||||||
@@ -42,28 +41,28 @@ internal static class AlignTest
|
|||||||
bool same = Dist(new Point3D(gp.x, gp.y, gp.z), wpf) < 1e-9;
|
bool same = Dist(new Point3D(gp.x, gp.y, gp.z), wpf) < 1e-9;
|
||||||
if (!same)
|
if (!same)
|
||||||
{
|
{
|
||||||
System.Console.WriteLine($" FAIL: ModOp/Matrix3D 不一致 @ ({p.X},{p.Y},{p.Z})");
|
System.Console.WriteLine($" FAIL: ModOp/Matrix3D mismatch at ({p.X},{p.Y},{p.Z})");
|
||||||
failures++;
|
failures++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
System.Console.WriteLine($" {(failures == 0 ? "PASS" : "----")}: ModOp ↔ Matrix3D 一致(5 個測試點)");
|
System.Console.WriteLine($" {(failures == 0 ? "PASS" : "----")}: ModOp/Matrix3D agree at five points");
|
||||||
|
|
||||||
// ── (3) 共線拒絕 ──
|
// Collinear source points must be rejected.
|
||||||
bool collinearRejected = !RigidAlign.TryRigidTransform(
|
bool collinearRejected = !RigidAlign.TryRigidTransform(
|
||||||
new Point3D(0, 0, 0), new Point3D(1, 0, 0), new Point3D(2, 0, 0), // 共線
|
new Point3D(0, 0, 0), new Point3D(1, 0, 0), new Point3D(2, 0, 0), // Collinear.
|
||||||
q1, q2, q3, out _);
|
q1, q2, q3, out _);
|
||||||
failures += Check("來源共線 → 拒絕", collinearRejected, true);
|
failures += Check("collinear source rejected", collinearRejected, true);
|
||||||
failures += Check("Collinear 判定", RigidAlign.Collinear(
|
failures += Check("collinearity detection", RigidAlign.Collinear(
|
||||||
new Point3D(0, 0, 0), new Point3D(1, 1, 1), new Point3D(3, 3, 3)), true);
|
new Point3D(0, 0, 0), new Point3D(1, 1, 1), new Point3D(3, 3, 3)), true);
|
||||||
|
|
||||||
System.Console.WriteLine(failures == 0 ? "AlignTest: 全部通過" : $"AlignTest: {failures} 項失敗");
|
System.Console.WriteLine(failures == 0 ? "AlignTest: all passed" : $"AlignTest: {failures} failed");
|
||||||
return failures;
|
return failures;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int Check(string name, bool actual, bool expected)
|
private static int Check(string name, bool actual, bool expected)
|
||||||
{
|
{
|
||||||
bool ok = actual == expected;
|
bool ok = actual == expected;
|
||||||
System.Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name}({actual})");
|
System.Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name} ({actual})");
|
||||||
return ok ? 0 : 1;
|
return ok ? 0 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +70,7 @@ internal static class AlignTest
|
|||||||
{
|
{
|
||||||
double d = Dist(actual, expected);
|
double d = Dist(actual, expected);
|
||||||
bool ok = d < 1e-9;
|
bool ok = d < 1e-9;
|
||||||
System.Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name} 誤差 {d:E2}");
|
System.Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name} error {d:E2}");
|
||||||
return ok ? 0 : 1;
|
return ok ? 0 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-15
@@ -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;
|
||||||
|
|
||||||
// 單一三角形 (0,0,0)(10,0,0)(0,10,0),面積 50
|
// A single triangle with area 50.
|
||||||
var mesh = new MeshGeometry3D
|
var mesh = new MeshGeometry3D
|
||||||
{
|
{
|
||||||
Positions = new Point3DCollection
|
Positions = new Point3DCollection
|
||||||
@@ -22,38 +22,38 @@ internal static class ClipTest
|
|||||||
};
|
};
|
||||||
mesh.Freeze();
|
mesh.Freeze();
|
||||||
|
|
||||||
// case 1: 平面 x=20,全保留
|
// A plane at x=20 retains the full triangle.
|
||||||
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("全保留", Area(m1), 50.0);
|
failures += Check("fully retained", Area(m1), 50.0);
|
||||||
|
|
||||||
// case 2: 平面 x=-1,全裁掉
|
// A plane at x=-1 removes the full triangle.
|
||||||
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("全裁掉", Area(m2), 0.0);
|
failures += Check("fully removed", Area(m2), 0.0);
|
||||||
|
|
||||||
// case 3: 平面 x=5 → 保留 x≤5:梯形面積 = 50 - 右側小三角形(底5高5)/2 = 50 - 12.5 = 37.5
|
// At x=5, the retained trapezoid has area 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("半裁(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: 頂點 x={p.X} 超出保留側"); failures++; }
|
if (p.X > 5 + 1e-9) { Console.WriteLine($" FAIL: vertex x={p.X} is outside retained side"); failures++; }
|
||||||
|
|
||||||
// case 4: 反向(保留 x≥5)→ 12.5
|
// Reverse the plane normal to retain the smaller triangle.
|
||||||
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("半裁(x≥5)", Area(m4), 12.5);
|
failures += Check("half clipped (x>=5)", Area(m4), 12.5);
|
||||||
|
|
||||||
// case 5: 線段裁切
|
// Clip a line segment.
|
||||||
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;
|
||||||
failures += Check("線段裁切長度", segLen, 4.0);
|
failures += Check("clipped segment length", segLen, 4.0);
|
||||||
|
|
||||||
Console.WriteLine(failures == 0 ? "ClipTest: 全部通過" : $"ClipTest: {failures} 項失敗");
|
Console.WriteLine(failures == 0 ? "ClipTest: all passed" : $"ClipTest: {failures} failed");
|
||||||
return failures;
|
return failures;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int Check(string name, double actual, double expected)
|
private static int Check(string name, double actual, double expected)
|
||||||
{
|
{
|
||||||
bool ok = Math.Abs(actual - expected) < 1e-9;
|
bool ok = Math.Abs(actual - expected) < 1e-9;
|
||||||
Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name} = {actual}(期望 {expected})");
|
Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name} = {actual} (expected {expected})");
|
||||||
return ok ? 0 : 1;
|
return ok ? 0 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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; // CADability.GeoObject.Path(曲線)撞名
|
using IOPath = System.IO.Path; // Avoid the CADability.GeoObject.Path name collision.
|
||||||
|
|
||||||
namespace SmokeTest;
|
namespace SmokeTest;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// STEP 匯出往返驗證(v0.5.0 匯出 STEP 功能的無 UI 驗證):
|
/// Headless STEP export round trip: import, export through the same CADability path,
|
||||||
/// 匯入來源 STEP → 走與 MainViewModel.ExportStepFile 相同路徑寫出新檔 → 重新匯入比對實體數。
|
/// then import the result and compare solid counts.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ExportTest
|
public static class ExportTest
|
||||||
{
|
{
|
||||||
@@ -20,26 +20,26 @@ public static class ExportTest
|
|||||||
Console.WriteLine($"=== ExportTest: {IOPath.GetFileName(sourcePath)} → {IOPath.GetFileName(outPath)} ===");
|
Console.WriteLine($"=== ExportTest: {IOPath.GetFileName(sourcePath)} → {IOPath.GetFileName(outPath)} ===");
|
||||||
ImportedFileData data = new StepImportService().Import(sourcePath);
|
ImportedFileData data = new StepImportService().Import(sourcePath);
|
||||||
var geos = Collect(data.Root).Where(g => g is Solid or Shell).ToList();
|
var geos = Collect(data.Root).Where(g => g is Solid or Shell).ToList();
|
||||||
Console.WriteLine($" 來源 Solid/Shell 物件 = {geos.Count}(Solids = {data.Root.SolidCount})");
|
Console.WriteLine($" Source Solid/Shell objects = {geos.Count} (solids = {data.Root.SolidCount})");
|
||||||
|
|
||||||
Project project = Project.CreateSimpleProject();
|
Project project = Project.CreateSimpleProject();
|
||||||
Model model = project.GetActiveModel();
|
Model model = project.GetActiveModel();
|
||||||
foreach (IGeoObject g in geos) model.Add(g);
|
foreach (IGeoObject g in geos) model.Add(g);
|
||||||
new ExportStep().WriteToFile(outPath, project);
|
new ExportStep().WriteToFile(outPath, project);
|
||||||
var fi = new FileInfo(outPath);
|
var fi = new FileInfo(outPath);
|
||||||
Console.WriteLine($" 已寫出({fi.Length:N0} bytes)");
|
Console.WriteLine($" Written ({fi.Length:N0} bytes)");
|
||||||
if (fi.Length < 100)
|
if (fi.Length < 100)
|
||||||
{
|
{
|
||||||
Console.WriteLine("ExportTest: FAIL(輸出檔近乎空白)");
|
Console.WriteLine("ExportTest: FAIL (output is nearly empty)");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
ImportedFileData back = new StepImportService().Import(outPath);
|
ImportedFileData back = new StepImportService().Import(outPath);
|
||||||
Console.WriteLine($" 回讀 Solids = {back.Root.SolidCount}, Faces = {back.Root.FaceCount}");
|
Console.WriteLine($" Reimported solids = {back.Root.SolidCount}, faces = {back.Root.FaceCount}");
|
||||||
bool ok = back.Root.SolidCount == data.Root.SolidCount && back.Root.FaceCount > 0;
|
bool ok = back.Root.SolidCount == data.Root.SolidCount && back.Root.FaceCount > 0;
|
||||||
Console.WriteLine(ok
|
Console.WriteLine(ok
|
||||||
? "ExportTest: PASS(實體數一致)"
|
? "ExportTest: PASS (solid counts match)"
|
||||||
: $"ExportTest: FAIL(來源 Solids {data.Root.SolidCount} ↔ 回讀 {back.Root.SolidCount})");
|
: $"ExportTest: FAIL (source solids {data.Root.SolidCount} vs reimported {back.Root.SolidCount})");
|
||||||
return ok ? 0 : 1;
|
return ok ? 0 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,41 +11,41 @@ internal static class InterferenceTest
|
|||||||
{
|
{
|
||||||
int failures = 0;
|
int failures = 0;
|
||||||
|
|
||||||
// 兩個互穿的四面體(B 比 A 平移 +5,邊長 10 → 必相交)
|
// Two tetrahedra overlap when B is offset by 5 along X.
|
||||||
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("互穿 → 相交", r1.Intersects, true);
|
failures += Check("overlap intersects", r1.Intersects, true);
|
||||||
failures += Check("互穿 → 有交線段", r1.Segments.Count > 0, true);
|
failures += Check("overlap has intersection segments", r1.Segments.Count > 0, true);
|
||||||
|
|
||||||
// 分離(B 平移 +30,gap 應 ≈ 30 - 10 = 20)
|
// B at X=30 leaves a gap of about 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("分離 → 不相交", r2.Intersects, false);
|
failures += Check("separated meshes do not intersect", r2.Intersects, false);
|
||||||
bool gapOk = Math.Abs(r2.GapDistance - 20.0) < 0.01;
|
bool gapOk = Math.Abs(r2.GapDistance - 20.0) < 0.01;
|
||||||
Console.WriteLine($" {(gapOk ? "PASS" : "FAIL")}: 分離 gap = {r2.GapDistance:F4}(期望 ≈ 20)");
|
Console.WriteLine($" {(gapOk ? "PASS" : "FAIL")}: separated gap = {r2.GapDistance:F4} (expected ~20)");
|
||||||
if (!gapOk) failures++;
|
if (!gapOk) failures++;
|
||||||
|
|
||||||
// 貼合(B 平移 +10,頂點剛好接觸 → 不算穿透、gap ≈ 0)
|
// B at X=10 touches a vertex without penetrating A.
|
||||||
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("貼合 → 不算穿透", r3.Intersects, false);
|
failures += Check("touching is not penetration", r3.Intersects, false);
|
||||||
bool touchOk = r3.GapDistance < 0.01;
|
bool touchOk = r3.GapDistance < 0.01;
|
||||||
Console.WriteLine($" {(touchOk ? "PASS" : "FAIL")}: 貼合 gap = {r3.GapDistance:F6}(期望 ≈ 0)");
|
Console.WriteLine($" {(touchOk ? "PASS" : "FAIL")}: touching gap = {r3.GapDistance:F6} (expected ~0)");
|
||||||
if (!touchOk) failures++;
|
if (!touchOk) failures++;
|
||||||
|
|
||||||
Console.WriteLine(failures == 0 ? "InterferenceTest: 全部通過" : $"InterferenceTest: {failures} 項失敗");
|
Console.WriteLine(failures == 0 ? "InterferenceTest: all passed" : $"InterferenceTest: {failures} failed");
|
||||||
return failures;
|
return failures;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int Check(string name, bool actual, bool expected)
|
private static int Check(string name, bool actual, bool expected)
|
||||||
{
|
{
|
||||||
bool ok = actual == expected;
|
bool ok = actual == expected;
|
||||||
Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name}({actual})");
|
Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name} ({actual})");
|
||||||
return ok ? 0 : 1;
|
return ok ? 0 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>邊長 10 的四面體,offset 平移</summary>
|
/// <summary>A tetrahedron with side length 10, translated by 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;
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Windows.Media.Media3D;
|
||||||
|
using CADability.GeoObject;
|
||||||
|
using STPViewer.Models;
|
||||||
|
using STPViewer.Services;
|
||||||
|
using STPViewer.ViewModels;
|
||||||
|
using IOPath = System.IO.Path;
|
||||||
|
|
||||||
|
namespace SmokeTest;
|
||||||
|
|
||||||
|
internal static class LocalizationTest
|
||||||
|
{
|
||||||
|
private static readonly Regex FormatIndex = new(@"(?<!\{)\{(\d+)(?:[^{}]*)\}(?!\})", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
public static int Run()
|
||||||
|
{
|
||||||
|
int failures = 0;
|
||||||
|
Exception? error = null;
|
||||||
|
var thread = new Thread(() =>
|
||||||
|
{
|
||||||
|
try { failures = RunCore(); }
|
||||||
|
catch (Exception ex) { error = ex; }
|
||||||
|
});
|
||||||
|
thread.SetApartmentState(ApartmentState.STA);
|
||||||
|
thread.Start();
|
||||||
|
thread.Join();
|
||||||
|
if (error is not null)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"LocalizationTest: {error}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int RunCore()
|
||||||
|
{
|
||||||
|
int failures = CheckTemporaryPacks() + CheckBundledPacks() + CheckViewModel() + CheckSettings();
|
||||||
|
Console.WriteLine(failures == 0 ? "LocalizationTest: all passed" : $"LocalizationTest: {failures} failed");
|
||||||
|
return failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CheckTemporaryPacks()
|
||||||
|
{
|
||||||
|
int failures = 0;
|
||||||
|
string directory = IOPath.Combine(IOPath.GetTempPath(), "stpviewer_i18n_" + Guid.NewGuid().ToString("N"));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
failures += Check("missing directory loads without languages", new LocalizationService(directory).Languages.Count == 0);
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
failures += Check("empty directory loads without languages", new LocalizationService(directory).Languages.Count == 0);
|
||||||
|
WritePack(directory, "en", "English", new()
|
||||||
|
{
|
||||||
|
["Ui.Sample"] = "View", ["Ui.Fallback"] = "Fallback {0}",
|
||||||
|
["Ui.BadBrace"] = "English {0}", ["Ui.BadIndex"] = "English {0}",
|
||||||
|
["Ui.BothBad"] = "English {"
|
||||||
|
});
|
||||||
|
WritePack(directory, "zh_cn", "Simplified", new()
|
||||||
|
{
|
||||||
|
["Ui.Sample"] = "CN_VIEW", ["Ui.BadBrace"] = "Chinese {",
|
||||||
|
["Ui.BadIndex"] = "Chinese {1}", ["Ui.BothBad"] = "Chinese {"
|
||||||
|
});
|
||||||
|
WritePack(directory, "zh_tw", "Traditional", new() { ["Ui.Sample"] = "TW_VIEW" });
|
||||||
|
WritePack(directory, "pirate_dialect", "Pirate", new() { ["Ui.Sample"] = "ARR_VIEW" });
|
||||||
|
File.WriteAllText(IOPath.Combine(directory, "broken.json"), "{invalid json");
|
||||||
|
File.WriteAllText(IOPath.Combine(directory, "no_key.json"), "{\"data\":{\"Ui.Sample\":\"unused\"}}");
|
||||||
|
File.WriteAllText(IOPath.Combine(directory, "no_data.json"), "{\"key\":\"Missing data\"}");
|
||||||
|
File.WriteAllText(IOPath.Combine(directory, "non_string.json"), "{\"key\":\"Invalid\",\"data\":{\"Ui.Sample\":42}}");
|
||||||
|
|
||||||
|
CultureInfo oldUi = CultureInfo.CurrentUICulture;
|
||||||
|
LocalizationService service;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("en-US");
|
||||||
|
service = new LocalizationService(directory);
|
||||||
|
}
|
||||||
|
finally { CultureInfo.CurrentUICulture = oldUi; }
|
||||||
|
|
||||||
|
failures += Check("valid packs isolated from malformed files", service.Languages.Count == 4 &&
|
||||||
|
service.Languages.Select(l => l.Id).Order().SequenceEqual(new[] { "en", "pirate_dialect", "zh_cn", "zh_tw" }));
|
||||||
|
failures += Check("display names and data loaded", service.Languages.Any(l =>
|
||||||
|
l.Id == "pirate_dialect" && l.Key == "Pirate" && l.Data["Ui.Sample"] == "ARR_VIEW"));
|
||||||
|
failures += Check("English selected from system culture", service.Language == "en");
|
||||||
|
failures += Check("legacy Chinese IDs resolved", service.ResolveLanguage("zh-CN") == "zh_cn" &&
|
||||||
|
service.ResolveLanguage("zh-TW") == "zh_tw");
|
||||||
|
failures += Check("Chinese script and region aliases resolved", service.ResolveLanguage("zh-Hans") == "zh_cn" &&
|
||||||
|
service.ResolveLanguage("zh-SG") == "zh_cn" && service.ResolveLanguage("zh-Hant") == "zh_tw" &&
|
||||||
|
service.ResolveLanguage("zh-HK") == "zh_tw" && service.ResolveLanguage("zh-MO") == "zh_tw");
|
||||||
|
failures += Check("custom language ID resolved", service.ResolveLanguage("pirate-dialect") == "pirate_dialect");
|
||||||
|
failures += Check("unknown language falls back", service.ResolveLanguage("unknown") == "en");
|
||||||
|
failures += Check("invalid custom ID falls back", service.ResolveLanguage("pirate_other") == "en");
|
||||||
|
|
||||||
|
CultureInfo beforeNull = CultureInfo.CurrentUICulture;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("zh-HK");
|
||||||
|
failures += Check("null language uses system UI culture", service.ResolveLanguage(null) == "zh_tw");
|
||||||
|
}
|
||||||
|
finally { CultureInfo.CurrentUICulture = beforeNull; }
|
||||||
|
|
||||||
|
LanguageDefinition chinese = service.Languages.Single(l => l.Id == "zh_cn");
|
||||||
|
bool selectedNotified = false, indexerNotified = false;
|
||||||
|
chinese.PropertyChanged += (_, e) => selectedNotified |= e.PropertyName == nameof(LanguageDefinition.IsSelected);
|
||||||
|
((INotifyPropertyChanged)service).PropertyChanged += (_, e) => indexerNotified |= e.PropertyName == "Item[]";
|
||||||
|
service.SetLanguage("zh-CN");
|
||||||
|
failures += Check("selection and indexer notify", service.Language == "zh_cn" &&
|
||||||
|
chinese.IsSelected && selectedNotified && indexerNotified);
|
||||||
|
failures += Check("selected data and fallback", service["Ui.Sample"] == "CN_VIEW" &&
|
||||||
|
service["Ui.Fallback"] == "Fallback {0}" && service["Ui.Missing"] == "Ui.Missing");
|
||||||
|
failures += Check("malformed brace uses English format", service.FormatText("Ui.BadBrace", "value") == "English value");
|
||||||
|
failures += Check("missing format argument uses English format", service.FormatText("Ui.BadIndex", "value") == "English value");
|
||||||
|
failures += Check("invalid English format returns key", service.FormatText("Ui.BothBad", "value") == "Ui.BothBad");
|
||||||
|
service.SetLanguage("pirate_dialect");
|
||||||
|
failures += Check("custom language selected", service.Language == "pirate_dialect" &&
|
||||||
|
service["Ui.Sample"] == "ARR_VIEW");
|
||||||
|
|
||||||
|
WritePack(directory, "new_language", "New language", new() { ["Ui.Sample"] = "NEW_VIEW" });
|
||||||
|
WritePack(directory, "en", "English", new() { ["Ui.Sample"] = "Updated view", ["Ui.Fallback"] = "Fallback {0}" });
|
||||||
|
failures += Check("existing instance does not hot reload", service.Languages.Count == 4 &&
|
||||||
|
service.Languages.Single(l => l.Id == "en").Data["Ui.Sample"] == "View");
|
||||||
|
var reloaded = new LocalizationService(directory);
|
||||||
|
failures += Check("new instance rescans files and values", reloaded.Languages.Count == 5 &&
|
||||||
|
reloaded.Languages.Single(l => l.Id == "en").Data["Ui.Sample"] == "Updated view");
|
||||||
|
|
||||||
|
File.Delete(IOPath.Combine(directory, "zh_tw.json"));
|
||||||
|
var withoutTraditional = new LocalizationService(directory);
|
||||||
|
failures += Check("missing language not listed", withoutTraditional.Languages.All(l => l.Id != "zh_tw"));
|
||||||
|
failures += Check("missing legacy ID falls back", withoutTraditional.ResolveLanguage("zh-TW") == "en");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true);
|
||||||
|
}
|
||||||
|
return failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WritePack(string directory, string id, string name, Dictionary<string, string> data) =>
|
||||||
|
File.WriteAllText(IOPath.Combine(directory, id + ".json"), JsonSerializer.Serialize(new { key = name, data }));
|
||||||
|
|
||||||
|
private static int CheckBundledPacks()
|
||||||
|
{
|
||||||
|
int failures = 0;
|
||||||
|
LocalizationService service = LocalizationService.Instance;
|
||||||
|
failures += Check("bundled languages available", new[] { "en", "zh_cn", "zh_tw" }
|
||||||
|
.All(id => service.Languages.Any(l => l.Id == id)));
|
||||||
|
LanguageDefinition? english = service.Languages.FirstOrDefault(l => l.Id == "en");
|
||||||
|
if (english is null) return failures;
|
||||||
|
failures += Check("bundled English data nonempty", english.Data.Count > 0 &&
|
||||||
|
english.Data.Values.All(v => !string.IsNullOrWhiteSpace(v)));
|
||||||
|
int formatFailures = 0;
|
||||||
|
foreach (LanguageDefinition language in service.Languages)
|
||||||
|
foreach ((string key, string value) in language.Data)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value)) formatFailures++;
|
||||||
|
int[] indices = Indices(value);
|
||||||
|
if (english.Data.TryGetValue(key, out string? fallback) && !Indices(fallback).SequenceEqual(indices))
|
||||||
|
{
|
||||||
|
Console.WriteLine($" FAIL: {language.Id} format arguments differ for {key}");
|
||||||
|
formatFailures++;
|
||||||
|
}
|
||||||
|
object[] args = Enumerable.Range(0, indices.DefaultIfEmpty(-1).Max() + 1)
|
||||||
|
.Select(i => (object)i).ToArray();
|
||||||
|
try { _ = string.Format(CultureInfo.InvariantCulture, value, args); }
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
Console.WriteLine($" FAIL: {language.Id} format syntax for {key}");
|
||||||
|
formatFailures++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
failures += Check("bundled data and format strings valid", formatFailures == 0);
|
||||||
|
failures += Check("missing key returns key", service["__missing_localization_key__"] == "__missing_localization_key__");
|
||||||
|
return failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CheckViewModel()
|
||||||
|
{
|
||||||
|
int failures = 0;
|
||||||
|
LocalizationService service = LocalizationService.Instance;
|
||||||
|
string originalLanguage = service.Language;
|
||||||
|
CultureInfo numericCulture = CultureInfo.CurrentCulture;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
service.SetLanguage("en");
|
||||||
|
var vm = new MainViewModel();
|
||||||
|
vm.InitializeLocalization();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string oldLabel = vm.QuickBar["Import"].Label;
|
||||||
|
bool labelChanged = false;
|
||||||
|
vm.QuickBar["Import"].PropertyChanged += (_, e) =>
|
||||||
|
labelChanged |= e.PropertyName == nameof(QuickBarItemViewModel.Label);
|
||||||
|
|
||||||
|
MeasurementResult result = new MeasurementService().MeasureDistance(
|
||||||
|
new Point3D(0, 0, 0), new Point3D(25.4, 0, 0), "D1", 0.2);
|
||||||
|
vm.Measurements.Add(result);
|
||||||
|
vm.UseInch = true;
|
||||||
|
string oneInch = 1d.ToString("F4", numericCulture) + " in";
|
||||||
|
string beforeTitle = result.Title, beforeDetail = result.Detail;
|
||||||
|
string beforeLabel = result.DynamicLabels.Single().Label.Text;
|
||||||
|
var (distanceLabel, distanceTextFor) = result.DynamicLabels.Single();
|
||||||
|
int labelRefreshes = 0;
|
||||||
|
result.DynamicLabels[0] = (distanceLabel, u =>
|
||||||
|
{
|
||||||
|
labelRefreshes++;
|
||||||
|
return distanceTextFor(u);
|
||||||
|
});
|
||||||
|
bool titleChanged = false, detailChanged = false;
|
||||||
|
result.PropertyChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
titleChanged |= e.PropertyName == nameof(MeasurementResult.Title);
|
||||||
|
detailChanged |= e.PropertyName == nameof(MeasurementResult.Detail);
|
||||||
|
};
|
||||||
|
|
||||||
|
vm.ChangeLanguageCommand.Execute("zh_tw");
|
||||||
|
failures += Check("VM selects language", service.Language == "zh_tw" &&
|
||||||
|
service.Languages.Single(l => l.Id == "zh_tw").IsSelected);
|
||||||
|
failures += Check("existing distance detail translated", beforeDetail != result.Detail &&
|
||||||
|
titleChanged && detailChanged && result.Detail.Contains(oneInch, StringComparison.Ordinal));
|
||||||
|
failures += Check("distance and number culture preserved", vm.UseInch &&
|
||||||
|
CultureInfo.CurrentCulture.Equals(numericCulture) && beforeTitle == result.Title &&
|
||||||
|
result.Title.Contains(oneInch, StringComparison.Ordinal));
|
||||||
|
failures += Check("3D distance label refreshed", labelRefreshes == 1 &&
|
||||||
|
beforeLabel == oneInch && result.DynamicLabels.Single().Label.Text == beforeLabel);
|
||||||
|
failures += Check("quick bar label refreshed", labelChanged && vm.QuickBar["Import"].Label != oldLabel);
|
||||||
|
|
||||||
|
LanguageDefinition selected = service.Languages.Single(l => l.Id == "zh_tw");
|
||||||
|
bool reselected = false;
|
||||||
|
selected.PropertyChanged += (_, e) => reselected |= e.PropertyName == nameof(LanguageDefinition.IsSelected);
|
||||||
|
vm.ChangeLanguageCommand.Execute("zh_tw");
|
||||||
|
failures += Check("reselecting current language restores check", reselected && selected.IsSelected);
|
||||||
|
}
|
||||||
|
finally { vm.DisposeLocalization(); }
|
||||||
|
failures += CheckImportedNames(service);
|
||||||
|
}
|
||||||
|
finally { service.SetLanguage(originalLanguage); }
|
||||||
|
return failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CheckImportedNames(LocalizationService service)
|
||||||
|
{
|
||||||
|
int failures = 0;
|
||||||
|
MethodInfo blockNode = typeof(StepImportService).GetMethod("BlockNode", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||||
|
var importer = new StepImportService();
|
||||||
|
service.SetLanguage("en");
|
||||||
|
|
||||||
|
ImportedNode Import(Block block) => (ImportedNode)blockNode.Invoke(importer, [block])!;
|
||||||
|
var namedSolidBlock = Block.Construct();
|
||||||
|
namedSolidBlock.Name = "Solid";
|
||||||
|
namedSolidBlock.Add(Solid.Construct());
|
||||||
|
ImportedNode namedSolid = Import(namedSolidBlock);
|
||||||
|
var namedShellBlock = Block.Construct();
|
||||||
|
namedShellBlock.Name = "Shell";
|
||||||
|
namedShellBlock.Add(Shell.Construct());
|
||||||
|
ImportedNode namedShell = Import(namedShellBlock);
|
||||||
|
var defaultSolidBlock = Block.Construct();
|
||||||
|
defaultSolidBlock.Add(Solid.Construct());
|
||||||
|
ImportedNode defaultSolid = Import(defaultSolidBlock);
|
||||||
|
var defaultShellBlock = Block.Construct();
|
||||||
|
defaultShellBlock.Add(Shell.Construct());
|
||||||
|
ImportedNode defaultShellLeaf = Import(defaultShellBlock).Children.Single();
|
||||||
|
var collisionBlock = Block.Construct();
|
||||||
|
var realComponentShell = Shell.Construct();
|
||||||
|
realComponentShell.Name = "Component";
|
||||||
|
collisionBlock.Add(realComponentShell);
|
||||||
|
ImportedNode collision = Import(collisionBlock);
|
||||||
|
|
||||||
|
ModelNodeViewModel ViewModel(ImportedNode node) => new(0)
|
||||||
|
{
|
||||||
|
Name = node.Name,
|
||||||
|
NameKey = node.NameKey,
|
||||||
|
NameNumber = node.NameNumber
|
||||||
|
};
|
||||||
|
var namedSolidVm = ViewModel(namedSolid);
|
||||||
|
var namedShellVm = ViewModel(namedShell);
|
||||||
|
var defaultSolidVm = ViewModel(defaultSolid);
|
||||||
|
var defaultShellVm = ViewModel(defaultShellLeaf);
|
||||||
|
var collisionVm = ViewModel(collision);
|
||||||
|
service.SetLanguage("zh_cn");
|
||||||
|
failures += Check("explicit solid name retained", namedSolid.NameKey is null && namedSolidVm.Name == "Solid");
|
||||||
|
failures += Check("real block name retained", namedShell.NameKey is null && namedShellVm.Name == "Shell");
|
||||||
|
failures += Check("default solid name translated", defaultSolid.NameKey == "Runtime.SolidName" &&
|
||||||
|
defaultSolidVm.Name == service["Runtime.SolidName"]);
|
||||||
|
failures += Check("default shell name translated", defaultShellLeaf.NameKey == "Runtime.ShellName" &&
|
||||||
|
defaultShellVm.Name == service["Runtime.ShellName"]);
|
||||||
|
failures += Check("real child name wins collision", collision.NameKey is null && collisionVm.Name == "Component");
|
||||||
|
return failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CheckSettings()
|
||||||
|
{
|
||||||
|
var settings = new AppSettings
|
||||||
|
{
|
||||||
|
Language = "zh-CN", WindowWidth = 1200, WindowHeight = 800, WindowLeft = 100, WindowTop = 100
|
||||||
|
};
|
||||||
|
AppSettings? restored = JsonSerializer.Deserialize<AppSettings>(JsonSerializer.Serialize(settings));
|
||||||
|
return Check("legacy ID round trips through settings JSON", restored?.Language == "zh-CN" &&
|
||||||
|
LocalizationService.Instance.ResolveLanguage(restored.Language) == "zh_cn");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int[] Indices(string format) => FormatIndex.Matches(format)
|
||||||
|
.Select(m => int.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture))
|
||||||
|
.Distinct().Order().ToArray();
|
||||||
|
|
||||||
|
private static int Check(string name, bool ok)
|
||||||
|
{
|
||||||
|
Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name}");
|
||||||
|
return ok ? 0 : 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// 測試輔助:用 CADability 內嵌的 netDxf 產生標準 DXF 測試檔
|
// Generate a small standard DXF test file with the bundled netDxf library.
|
||||||
using netDxf;
|
using netDxf;
|
||||||
using netDxf.Entities;
|
using netDxf.Entities;
|
||||||
|
|
||||||
@@ -12,6 +12,6 @@ internal static class MakeDxf
|
|||||||
doc.Entities.Add(new Line(new Vector3(0, 0, 0), new Vector3(100, 50, 0)));
|
doc.Entities.Add(new Line(new Vector3(0, 0, 0), new Vector3(100, 50, 0)));
|
||||||
doc.Entities.Add(new Circle(new Vector3(50, 25, 0), 10));
|
doc.Entities.Add(new Circle(new Vector3(50, 25, 0), 10));
|
||||||
doc.Save(path);
|
doc.Save(path);
|
||||||
Console.WriteLine($"已產生 {path}");
|
Console.WriteLine($"Generated {path}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace SmokeTest;
|
||||||
|
|
||||||
|
/// <summary>Generate a binary STL grid with two triangles per cell and noncoplanar vertices.</summary>
|
||||||
|
internal static class MakeStl
|
||||||
|
{
|
||||||
|
public static void Run(string path, int triCount)
|
||||||
|
{
|
||||||
|
// Two triangles per cell; use a square grid large enough for the requested count.
|
||||||
|
int cells = System.Math.Max(1, triCount / 2);
|
||||||
|
int grid = (int)System.Math.Ceiling(System.Math.Sqrt(cells));
|
||||||
|
int tris = grid * grid * 2;
|
||||||
|
|
||||||
|
using var fs = File.Create(path);
|
||||||
|
using var bw = new BinaryWriter(fs);
|
||||||
|
bw.Write(new byte[80]); // header
|
||||||
|
bw.Write((uint)tris); // triangle count
|
||||||
|
|
||||||
|
static void WriteTri(BinaryWriter w, float ax, float ay, float az,
|
||||||
|
float bx, float by, float bz, float cx, float cy, float cz)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < 3; i++) w.Write(0f); // normal (0,0,0)
|
||||||
|
w.Write(ax); w.Write(ay); w.Write(az);
|
||||||
|
w.Write(bx); w.Write(by); w.Write(bz);
|
||||||
|
w.Write(cx); w.Write(cy); w.Write(cz);
|
||||||
|
w.Write((ushort)0); // attribute byte count
|
||||||
|
}
|
||||||
|
|
||||||
|
float Z(int x, int y) => (float)(System.Math.Sin(x * 0.1) * System.Math.Cos(y * 0.1) * 5.0);
|
||||||
|
for (int y = 0; y < grid; y++)
|
||||||
|
for (int x = 0; x < grid; x++)
|
||||||
|
{
|
||||||
|
float x0 = x, x1 = x + 1, y0 = y, y1 = y + 1;
|
||||||
|
WriteTri(bw, x0, y0, Z(x, y), x1, y0, Z(x + 1, y), x1, y1, Z(x + 1, y + 1));
|
||||||
|
WriteTri(bw, x0, y0, Z(x, y), x1, y1, Z(x + 1, y + 1), x0, y1, Z(x, y + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"Wrote {tris:N0} triangles to {path} ({new FileInfo(path).Length:N0} bytes)");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// 無 UI smoke test:驗證 CAD 檔解析 → 三角化 → 裝配樹 整條匯入管線
|
// Headless smoke tests for CAD import, triangulation, and the assembly tree.
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using STPViewer.Services;
|
using STPViewer.Services;
|
||||||
@@ -7,7 +7,7 @@ Console.OutputEncoding = System.Text.Encoding.UTF8;
|
|||||||
|
|
||||||
if (args.Length == 0)
|
if (args.Length == 0)
|
||||||
{
|
{
|
||||||
Console.WriteLine("用法: SmokeTest [--tree] <file.stp|.stl|.dxf> [...]");
|
Console.WriteLine("Usage: SmokeTest [--tree] <file.stp|.stl|.dxf> [...]");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,6 +23,12 @@ if (args[0] == "--make-dxf")
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (args[0] == "--make-stl")
|
||||||
|
{
|
||||||
|
SmokeTest.MakeStl.Run(args[1], args.Length > 2 ? int.Parse(args[2]) : 1_500_000);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
if (args[0] == "--clip-test")
|
if (args[0] == "--clip-test")
|
||||||
return SmokeTest.ClipTest.Run() == 0 ? 0 : 2;
|
return SmokeTest.ClipTest.Run() == 0 ? 0 : 2;
|
||||||
|
|
||||||
@@ -32,13 +38,19 @@ if (args[0] == "--interference-test")
|
|||||||
if (args[0] == "--align-test")
|
if (args[0] == "--align-test")
|
||||||
return SmokeTest.AlignTest.Run() == 0 ? 0 : 2;
|
return SmokeTest.AlignTest.Run() == 0 ? 0 : 2;
|
||||||
|
|
||||||
|
if (args[0] == "--localization-test")
|
||||||
|
return SmokeTest.LocalizationTest.Run() == 0 ? 0 : 2;
|
||||||
|
|
||||||
if (args[0] == "--bbox")
|
if (args[0] == "--bbox")
|
||||||
return SmokeTest.BBoxDump.Run(args[1]);
|
return SmokeTest.BBoxDump.Run(args[1]);
|
||||||
|
|
||||||
if (args[0] == "--export-test")
|
if (args[0] == "--export-test")
|
||||||
return SmokeTest.ExportTest.Run(args[1], args[2]) == 0 ? 0 : 2;
|
return SmokeTest.ExportTest.Run(args[1], args[2]) == 0 ? 0 : 2;
|
||||||
|
|
||||||
var service = new StepImportService { Progress = s => Console.WriteLine($" [階段] {s}") };
|
if (args[0] == "--stl-export-test")
|
||||||
|
return SmokeTest.StlExportTest.Run(args.Length > 1 ? args[1] : null) == 0 ? 0 : 2;
|
||||||
|
|
||||||
|
var service = new StepImportService { Progress = s => Console.WriteLine($" [stage] {s}") };
|
||||||
int failed = 0;
|
int failed = 0;
|
||||||
|
|
||||||
foreach (string path in args)
|
foreach (string path in args)
|
||||||
@@ -55,7 +67,7 @@ foreach (string path in args)
|
|||||||
Console.WriteLine($" Triangles : {r.TriangleCount:N0}");
|
Console.WriteLine($" Triangles : {r.TriangleCount:N0}");
|
||||||
Console.WriteLine($" Bounds : {r.Bounds.SizeX:F2} x {r.Bounds.SizeY:F2} x {r.Bounds.SizeZ:F2} mm");
|
Console.WriteLine($" Bounds : {r.Bounds.SizeX:F2} x {r.Bounds.SizeY:F2} x {r.Bounds.SizeZ:F2} mm");
|
||||||
Console.WriteLine($" Time : {sw.ElapsedMilliseconds} ms");
|
Console.WriteLine($" Time : {sw.ElapsedMilliseconds} ms");
|
||||||
Console.WriteLine(" --- 裝配樹 ---");
|
Console.WriteLine(" --- assembly tree ---");
|
||||||
PrintNode(r, 1);
|
PrintNode(r, 1);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -72,6 +84,6 @@ static void PrintNode(ImportedNode n, int indent)
|
|||||||
{
|
{
|
||||||
string seg = n.EdgeSegments is null ? "" : $", edgeSegs={n.EdgeSegments.Count / 2:N0}";
|
string seg = n.EdgeSegments is null ? "" : $", edgeSegs={n.EdgeSegments.Count / 2:N0}";
|
||||||
Console.WriteLine($"{new string(' ', indent * 2)}{n.Name} " +
|
Console.WriteLine($"{new string(' ', indent * 2)}{n.Name} " +
|
||||||
$"[solids={n.SolidCount}, faces={n.FaceCount}, tris={n.TriangleCount:N0}{seg}{(n.HasBrep ? "" : ", 無B-rep")}]");
|
$"[solids={n.SolidCount}, faces={n.FaceCount}, tris={n.TriangleCount:N0}{seg}{(n.HasBrep ? "" : ", no B-rep")}]");
|
||||||
foreach (ImportedNode c in n.Children) PrintNode(c, indent + 1);
|
foreach (ImportedNode c in n.Children) PrintNode(c, indent + 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Media3D;
|
||||||
|
using STPViewer.Services;
|
||||||
|
using IOPath = System.IO.Path; // Avoid the CADability.GeoObject.Path name collision.
|
||||||
|
|
||||||
|
namespace SmokeTest;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Headless STL export checks:
|
||||||
|
/// binary round trip, ASCII facet structure, and degenerate triangle filtering.
|
||||||
|
/// </summary>
|
||||||
|
public static class StlExportTest
|
||||||
|
{
|
||||||
|
public static int Run(string? stepPath = null)
|
||||||
|
{
|
||||||
|
Console.WriteLine("=== StlExportTest ===");
|
||||||
|
int failed = 0;
|
||||||
|
string dir = IOPath.Combine(IOPath.GetTempPath(), "stpviewer_stl_test");
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
|
||||||
|
MeshGeometry3D cube = BuildCube(10); // 10x10x10 mm, 12 triangles.
|
||||||
|
|
||||||
|
// Binary triangle count and scaled coordinate range.
|
||||||
|
{
|
||||||
|
string f = IOPath.Combine(dir, "cube_bin.stl");
|
||||||
|
double scale = 1.0 / 25.4; // mm to inches.
|
||||||
|
int written = StlExportService.Write(f, new[] { cube }, ascii: false, scale);
|
||||||
|
(uint count, float min, float max) = ReadBinary(f);
|
||||||
|
bool ok = written == 12 && count == 12
|
||||||
|
&& Math.Abs(min - 0f) < 1e-5 && Math.Abs(max - 10f / 25.4f) < 1e-4;
|
||||||
|
Report(ref failed, ok, $"binary round trip: 12 triangles, inch range [{min:F4}, {max:F4}]");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ASCII facet count and structure.
|
||||||
|
{
|
||||||
|
string f = IOPath.Combine(dir, "cube_ascii.stl");
|
||||||
|
int written = StlExportService.Write(f, new[] { cube }, ascii: true, scale: 1.0);
|
||||||
|
string text = File.ReadAllText(f);
|
||||||
|
int facets = CountOccurrences(text, "facet normal");
|
||||||
|
bool ok = written == 12 && facets == 12
|
||||||
|
&& text.StartsWith("solid ", StringComparison.Ordinal)
|
||||||
|
&& text.Contains("endsolid");
|
||||||
|
Report(ref failed, ok, $"ASCII structure valid, facets = {facets}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A zero-area triangle must be filtered from the export.
|
||||||
|
{
|
||||||
|
var degenerate = new MeshGeometry3D();
|
||||||
|
degenerate.Positions.Add(new Point3D(0, 0, 0));
|
||||||
|
degenerate.Positions.Add(new Point3D(1, 1, 1));
|
||||||
|
degenerate.Positions.Add(new Point3D(2, 2, 2)); // Collinear: zero area.
|
||||||
|
degenerate.TriangleIndices.Add(0);
|
||||||
|
degenerate.TriangleIndices.Add(1);
|
||||||
|
degenerate.TriangleIndices.Add(2);
|
||||||
|
degenerate.Freeze();
|
||||||
|
|
||||||
|
string f = IOPath.Combine(dir, "cube_degen.stl");
|
||||||
|
int written = StlExportService.Write(f, new[] { cube, degenerate }, ascii: false, scale: 1.0);
|
||||||
|
(uint count, _, _) = ReadBinary(f);
|
||||||
|
bool ok = written == 12 && count == 12;
|
||||||
|
Report(ref failed, ok, $"degenerate triangle filtered ({written} written)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional real STEP input: retessellate B-rep geometry at fine precision.
|
||||||
|
if (stepPath is not null)
|
||||||
|
{
|
||||||
|
var data = new StepImportService().Import(stepPath);
|
||||||
|
var geos = Collect(data.Root).ToList();
|
||||||
|
int importTris = data.Root.TriangleCount;
|
||||||
|
Console.WriteLine($" Source {IOPath.GetFileName(stepPath)}: {geos.Count} B-rep objects, {importTris:N0} imported triangles");
|
||||||
|
|
||||||
|
var meshes = StlExportService.Tessellate(
|
||||||
|
geos, StlExportService.PrecisionFactor(StlMeshQuality.Fine));
|
||||||
|
string f = IOPath.Combine(dir, "step_fine.stl");
|
||||||
|
int written = StlExportService.Write(f, meshes, ascii: false, scale: 1.0);
|
||||||
|
(uint count, _, _) = ReadBinary(f);
|
||||||
|
Report(ref failed, meshes.Count > 0 && written > 0 && count == written,
|
||||||
|
$"fine retessellation: {meshes.Count} faces, {written:N0} triangles");
|
||||||
|
// CADability's triangle count is not monotonic in precision; 0.15x is measurably finer.
|
||||||
|
Report(ref failed, written > importTris,
|
||||||
|
$"fine ({written:N0}) > imported default ({importTris:N0})");
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine(failed == 0 ? "StlExportTest: PASS" : $"StlExportTest: FAIL ({failed} checks)");
|
||||||
|
return failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<CADability.GeoObject.IGeoObject> Collect(ImportedNode n)
|
||||||
|
{
|
||||||
|
foreach (CADability.GeoObject.IGeoObject g in n.SourceGeos) yield return g;
|
||||||
|
foreach (ImportedNode c in n.Children)
|
||||||
|
foreach (CADability.GeoObject.IGeoObject g in Collect(c))
|
||||||
|
yield return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Report(ref int failed, bool ok, string what)
|
||||||
|
{
|
||||||
|
Console.WriteLine($" [{(ok ? "PASS" : "FAIL")}] {what}");
|
||||||
|
if (!ok) failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Axis-aligned cube from 0 to size, with 12 triangles.</summary>
|
||||||
|
private static MeshGeometry3D BuildCube(double s)
|
||||||
|
{
|
||||||
|
var m = new MeshGeometry3D();
|
||||||
|
Point3D[] v =
|
||||||
|
{
|
||||||
|
new(0, 0, 0), new(s, 0, 0), new(s, s, 0), new(0, s, 0),
|
||||||
|
new(0, 0, s), new(s, 0, s), new(s, s, s), new(0, s, s),
|
||||||
|
};
|
||||||
|
foreach (Point3D p in v) m.Positions.Add(p);
|
||||||
|
int[] quads = { 0,3,2,1, 4,5,6,7, 0,1,5,4, 1,2,6,5, 2,3,7,6, 3,0,4,7 };
|
||||||
|
for (int q = 0; q < quads.Length; q += 4)
|
||||||
|
{
|
||||||
|
m.TriangleIndices.Add(quads[q]); m.TriangleIndices.Add(quads[q + 1]); m.TriangleIndices.Add(quads[q + 2]);
|
||||||
|
m.TriangleIndices.Add(quads[q]); m.TriangleIndices.Add(quads[q + 2]); m.TriangleIndices.Add(quads[q + 3]);
|
||||||
|
}
|
||||||
|
m.Freeze();
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (uint Count, float Min, float Max) ReadBinary(string path)
|
||||||
|
{
|
||||||
|
using var br = new BinaryReader(File.OpenRead(path));
|
||||||
|
byte[] header = br.ReadBytes(80);
|
||||||
|
if (Encoding.ASCII.GetString(header, 0, 5) == "solid")
|
||||||
|
throw new InvalidDataException("Binary STL header must not begin with solid");
|
||||||
|
uint count = br.ReadUInt32();
|
||||||
|
float min = float.MaxValue, max = float.MinValue;
|
||||||
|
for (uint t = 0; t < count; t++)
|
||||||
|
{
|
||||||
|
br.ReadBytes(12); // normal
|
||||||
|
for (int k = 0; k < 9; k++)
|
||||||
|
{
|
||||||
|
float f = br.ReadSingle();
|
||||||
|
min = Math.Min(min, f);
|
||||||
|
max = Math.Max(max, f);
|
||||||
|
}
|
||||||
|
br.ReadUInt16(); // attribute
|
||||||
|
}
|
||||||
|
return (count, min, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CountOccurrences(string text, string token)
|
||||||
|
{
|
||||||
|
int n = 0;
|
||||||
|
for (int i = text.IndexOf(token, StringComparison.Ordinal); i >= 0;
|
||||||
|
i = text.IndexOf(token, i + token.Length, StringComparison.Ordinal)) n++;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// 實驗工具:印出 STEP 匯入後的物件階層(HierarchyToBlocks=true)
|
// Print the STEP object hierarchy after import with HierarchyToBlocks enabled.
|
||||||
using CADability;
|
using CADability;
|
||||||
using CADability.GeoObject;
|
using CADability.GeoObject;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user