feat(export): Add STL export with parameter dialog (v0.6.0)
- StlExportService: binary/ASCII STL writer (degenerate triangle filtering, per-triangle normals) + B-rep re-tessellation - StlExportDialog + StlExportViewModel: scope (merged/per-file), format, unit scale (mm/inch/custom), mesh quality (current/fine), live triangle count + size estimate; confirm before export - Background export with UI-thread mesh snapshot (GeometryModel3D is DispatcherObject; only frozen MeshGeometry3D crosses threads) - Fine quality = 0.15x import precision: CADability GetTriangulation returns cached mesh for coarser requests (no coarse option) and triangle count is non-monotonic in precision (0.4x < 1x) - SmokeTest --stl-export-test: binary round-trip incl. inch scaling, ASCII structure, degenerate filtering, real-file fine re-tessellation - Bump version to 0.6.0; update ARCHITECTURE/CLAUDE docs (Phase 15) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+32
-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,16 @@ STPViewer/
|
|||||||
│ ├── MeasurementResult.cs # 量測結果(雙單位 lambda、3D 標籤同步)
|
│ ├── MeasurementResult.cs # 量測結果(雙單位 lambda、3D 標籤同步)
|
||||||
│ └── UnitSystem.cs # mm/inch + Units 格式化
|
│ └── UnitSystem.cs # mm/inch + Units 格式化
|
||||||
│
|
│
|
||||||
|
├── StlExportDialog.xaml / .cs # STL 匯出參數對話框(範圍/格式/單位/精度 + 預估,確認再匯出)
|
||||||
|
│
|
||||||
├── 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 +124,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 +195,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 +440,29 @@ 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 警告
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 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)。
|
||||||
|
|
||||||
## 技術棧
|
## 技術棧
|
||||||
@@ -51,6 +51,12 @@ dotnet publish src/STPViewer -c Release -o publish/STPViewer
|
|||||||
單位/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 角投影到法向」內插,
|
||||||
軸向與任意法向共用同一段程式,不要改回逐軸特化
|
軸向與任意法向共用同一段程式,不要改回逐軸特化
|
||||||
@@ -111,7 +117,8 @@ dotnet publish src/STPViewer -c Release -o publish/STPViewer
|
|||||||
- 干涉檢查需剛好 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 精細重算)
|
||||||
- **絕不要用 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/
|
||||||
|
|||||||
@@ -94,6 +94,8 @@
|
|||||||
ToolTip="3D 視圖截圖存 PNG(2x 解析度)">📷 截圖</Button>
|
ToolTip="3D 視圖截圖存 PNG(2x 解析度)">📷 截圖</Button>
|
||||||
<Button Command="{Binding ExportStepFileCommand}" Padding="8,4" FontSize="13"
|
<Button Command="{Binding ExportStepFileCommand}" Padding="8,4" FontSize="13"
|
||||||
ToolTip="把可見檔案(目前對齊位置)匯出成新 STEP 檔 — 對齊插合結果可交接 CAD;不動原始檔">📤 STEP</Button>
|
ToolTip="把可見檔案(目前對齊位置)匯出成新 STEP 檔 — 對齊插合結果可交接 CAD;不動原始檔">📤 STEP</Button>
|
||||||
|
<Button Command="{Binding ExportStlFileCommand}" Padding="8,4" FontSize="13"
|
||||||
|
ToolTip="把可見檔案(目前位置)匯出成 STL 網格檔 — 先設定範圍/格式/單位/精度,確認再匯出">📐 STL</Button>
|
||||||
</ToolBar>
|
</ToolBar>
|
||||||
<ToolBar>
|
<ToolBar>
|
||||||
<ToggleButton IsChecked="{Binding SectionEnabled}" Padding="8,4" FontSize="13"
|
<ToggleButton IsChecked="{Binding SectionEnabled}" Padding="8,4" FontSize="13"
|
||||||
|
|||||||
@@ -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.6.0</Version> <!-- 單一版號來源:標題與 publish-winapp 打包資料夾都讀這個 -->
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Media3D;
|
||||||
|
using CADability;
|
||||||
|
using CADability.GeoObject;
|
||||||
|
|
||||||
|
namespace STPViewer.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 網格精度:Current = 目前顯示網格;Fine = 由 B-rep 以較細精度重新三角化(無 B-rep 的來源維持現有網格)。
|
||||||
|
/// 不提供「較粗」:CADability 的 GetTriangulation 對「比快取粗」的精度要求會直接回傳既有較細快取,
|
||||||
|
/// 匯入時已三角化過 → 要求粗化實際上拿到同一份網格(實測驗證),故誠實不提供。
|
||||||
|
/// </summary>
|
||||||
|
public enum StlMeshQuality { Current, Fine }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// STL 匯出:WPF 三角網格 → binary / ASCII STL。
|
||||||
|
/// STL 本身無單位,約定輸出 mm(scale=1);inch 交接用 scale = 1/25.4。
|
||||||
|
/// </summary>
|
||||||
|
public static class StlExportService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 三角化精度係數(相對匯入預設):Fine 較細(三角形多、曲面平滑)。
|
||||||
|
/// 實測 CADability 三角形數對精度非單調(0.4× 重算反而比 1× 少 — 重算網格較有效率),
|
||||||
|
/// 要明顯更細需 ≤0.15×(test.stp:1× = 30,784 → 0.1× = 42,220 三角形)。
|
||||||
|
/// </summary>
|
||||||
|
public static double PrecisionFactor(StlMeshQuality q) =>
|
||||||
|
q == StlMeshQuality.Fine ? 0.15 : 1.0;
|
||||||
|
|
||||||
|
/// <summary>預估檔案大小:binary = 84 + 50×n;ASCII 每三角形約 230 bytes</summary>
|
||||||
|
public static long EstimateBytes(long triangles, bool ascii) =>
|
||||||
|
ascii ? 20 + triangles * 230 : 84 + triangles * 50;
|
||||||
|
|
||||||
|
public static string FormatBytes(long bytes) => bytes switch
|
||||||
|
{
|
||||||
|
>= 1L << 30 => $"{bytes / (double)(1L << 30):F1} GB",
|
||||||
|
>= 1L << 20 => $"{bytes / (double)(1L << 20):F1} MB",
|
||||||
|
>= 1L << 10 => $"{bytes / (double)(1L << 10):F1} KB",
|
||||||
|
_ => $"{bytes} B",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 把多個網格寫成一個 STL 檔。退化(零面積)三角形濾除。
|
||||||
|
/// 回傳實際寫出的三角形數。
|
||||||
|
/// </summary>
|
||||||
|
public static int Write(string path, IReadOnlyList<MeshGeometry3D> meshes, bool ascii, double scale) =>
|
||||||
|
ascii ? WriteAscii(path, meshes, scale) : WriteBinary(path, meshes, scale);
|
||||||
|
|
||||||
|
private static int WriteBinary(string path, IReadOnlyList<MeshGeometry3D> meshes, double scale)
|
||||||
|
{
|
||||||
|
using var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, 1 << 16);
|
||||||
|
using var bw = new BinaryWriter(fs);
|
||||||
|
|
||||||
|
// 80-byte header(不可以 "solid" 開頭,部分讀取器會誤判為 ASCII)
|
||||||
|
var header = new byte[80];
|
||||||
|
Encoding.ASCII.GetBytes("STPViewer binary STL (unit: mm x scale)").CopyTo(header, 0);
|
||||||
|
bw.Write(header);
|
||||||
|
bw.Write(0u); // 三角形數 placeholder,寫完回頭補
|
||||||
|
|
||||||
|
int count = 0;
|
||||||
|
foreach (MeshGeometry3D mesh in meshes)
|
||||||
|
{
|
||||||
|
var pos = mesh.Positions;
|
||||||
|
var idx = mesh.TriangleIndices;
|
||||||
|
for (int i = 0; i + 2 < idx.Count; i += 3)
|
||||||
|
{
|
||||||
|
Point3D a = pos[idx[i]], b = pos[idx[i + 1]], c = pos[idx[i + 2]];
|
||||||
|
if (!TryNormal(a, b, c, out Vector3D n)) continue; // 退化三角形
|
||||||
|
bw.Write((float)n.X); bw.Write((float)n.Y); bw.Write((float)n.Z);
|
||||||
|
WriteVertex(bw, a, scale);
|
||||||
|
WriteVertex(bw, b, scale);
|
||||||
|
WriteVertex(bw, c, scale);
|
||||||
|
bw.Write((ushort)0); // attribute byte count
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.Seek(80, SeekOrigin.Begin);
|
||||||
|
bw.Write((uint)count);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteVertex(BinaryWriter bw, Point3D p, double scale)
|
||||||
|
{
|
||||||
|
bw.Write((float)(p.X * scale));
|
||||||
|
bw.Write((float)(p.Y * scale));
|
||||||
|
bw.Write((float)(p.Z * scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int WriteAscii(string path, IReadOnlyList<MeshGeometry3D> meshes, double scale)
|
||||||
|
{
|
||||||
|
var inv = CultureInfo.InvariantCulture;
|
||||||
|
using var sw = new StreamWriter(path, append: false, Encoding.ASCII, 1 << 16);
|
||||||
|
sw.WriteLine("solid STPViewer");
|
||||||
|
|
||||||
|
int count = 0;
|
||||||
|
foreach (MeshGeometry3D mesh in meshes)
|
||||||
|
{
|
||||||
|
var pos = mesh.Positions;
|
||||||
|
var idx = mesh.TriangleIndices;
|
||||||
|
for (int i = 0; i + 2 < idx.Count; i += 3)
|
||||||
|
{
|
||||||
|
Point3D a = pos[idx[i]], b = pos[idx[i + 1]], c = pos[idx[i + 2]];
|
||||||
|
if (!TryNormal(a, b, c, out Vector3D n)) continue;
|
||||||
|
sw.WriteLine(string.Format(inv, " facet normal {0:e6} {1:e6} {2:e6}", n.X, n.Y, n.Z));
|
||||||
|
sw.WriteLine(" outer loop");
|
||||||
|
WriteAsciiVertex(sw, a, scale, inv);
|
||||||
|
WriteAsciiVertex(sw, b, scale, inv);
|
||||||
|
WriteAsciiVertex(sw, c, scale, inv);
|
||||||
|
sw.WriteLine(" endloop");
|
||||||
|
sw.WriteLine(" endfacet");
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sw.WriteLine("endsolid STPViewer");
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteAsciiVertex(StreamWriter sw, Point3D p, double scale, CultureInfo inv) =>
|
||||||
|
sw.WriteLine(string.Format(inv, " vertex {0:e6} {1:e6} {2:e6}",
|
||||||
|
p.X * scale, p.Y * scale, p.Z * scale));
|
||||||
|
|
||||||
|
/// <returns>false = 退化三角形(面積 ≈ 0)</returns>
|
||||||
|
private static bool TryNormal(Point3D a, Point3D b, Point3D c, out Vector3D n)
|
||||||
|
{
|
||||||
|
n = Vector3D.CrossProduct(b - a, c - a);
|
||||||
|
double len = n.Length;
|
||||||
|
if (len < 1e-12) return false;
|
||||||
|
n /= len;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── B-rep 重新三角化(精細/較粗匯出用)────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 由 B-rep 幾何以指定精度係數重新三角化,回傳每面一個 frozen mesh。
|
||||||
|
/// CADability 非執行緒安全 → 呼叫端維持循序(可在單一背景執行緒)。
|
||||||
|
/// 個別面三角化失敗時跳過(與匯入一致的容錯策略)。
|
||||||
|
/// </summary>
|
||||||
|
public static List<MeshGeometry3D> Tessellate(IEnumerable<IGeoObject> geos, double precisionFactor)
|
||||||
|
{
|
||||||
|
var meshes = new List<MeshGeometry3D>();
|
||||||
|
foreach (IGeoObject g in geos)
|
||||||
|
{
|
||||||
|
switch (g)
|
||||||
|
{
|
||||||
|
case Solid solid:
|
||||||
|
foreach (Shell sh in solid.Shells) TessellateShell(sh, precisionFactor, meshes);
|
||||||
|
break;
|
||||||
|
case Shell shell:
|
||||||
|
TessellateShell(shell, precisionFactor, meshes);
|
||||||
|
break;
|
||||||
|
case Face face:
|
||||||
|
AddFaceMesh(face, PrecisionFor(SafeBounds(face), precisionFactor), meshes);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return meshes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TessellateShell(Shell shell, double factor, List<MeshGeometry3D> meshes)
|
||||||
|
{
|
||||||
|
double precision = PrecisionFor(SafeBounds(shell), factor);
|
||||||
|
foreach (Face f in shell.Faces) AddFaceMesh(f, precision, meshes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 匯入預設精度(與 StepImportService.PrecisionFor 同款 clamp)再乘 factor —
|
||||||
|
/// 先 clamp 再乘,保證 factor<1 時嚴格比匯入快取細(否則大殼在匯入被 0.5 上限截住,
|
||||||
|
/// 直接乘在 clamp 前會算出比快取粗的值 → CADability 回傳快取、精細無效)。
|
||||||
|
/// </summary>
|
||||||
|
private static double PrecisionFor(BoundingCube bc, double factor)
|
||||||
|
{
|
||||||
|
double diag;
|
||||||
|
try { diag = bc.DiagonalLength; }
|
||||||
|
catch { diag = 100; }
|
||||||
|
if (diag <= 0 || double.IsNaN(diag)) diag = 100;
|
||||||
|
double importDefault = Math.Clamp(diag * 0.0015, 0.02, 0.5);
|
||||||
|
return Math.Max(importDefault * factor, 0.005);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BoundingCube SafeBounds(IGeoObject g)
|
||||||
|
{
|
||||||
|
try { return g.GetBoundingCube(); }
|
||||||
|
catch { return new BoundingCube(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddFaceMesh(Face face, double precision, List<MeshGeometry3D> meshes)
|
||||||
|
{
|
||||||
|
GeoPoint[] pts; int[] ind;
|
||||||
|
try { face.GetTriangulation(precision, out pts, out _, out ind, out _); }
|
||||||
|
catch { return; }
|
||||||
|
if (pts is null || ind is null || ind.Length < 3) return;
|
||||||
|
|
||||||
|
var positions = new Point3DCollection(pts.Length);
|
||||||
|
foreach (GeoPoint p in pts) positions.Add(new Point3D(p.x, p.y, p.z));
|
||||||
|
var indices = new Int32Collection(ind.Length);
|
||||||
|
foreach (int i in ind) indices.Add(i);
|
||||||
|
|
||||||
|
var mesh = new MeshGeometry3D { Positions = positions, TriangleIndices = indices };
|
||||||
|
mesh.Freeze();
|
||||||
|
meshes.Add(mesh);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<Window x:Class="STPViewer.StlExportDialog"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="clr-namespace:STPViewer.ViewModels"
|
||||||
|
Title="匯出 STL — 參數設定"
|
||||||
|
Width="440" SizeToContent="Height"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
ResizeMode="NoResize" ShowInTaskbar="False"
|
||||||
|
d:DataContext="{d:DesignInstance Type=vm:StlExportViewModel}"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d">
|
||||||
|
<Window.Resources>
|
||||||
|
<BooleanToVisibilityConverter x:Key="BoolToVis" />
|
||||||
|
</Window.Resources>
|
||||||
|
<Grid Margin="16">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<Grid.Resources>
|
||||||
|
<Style TargetType="TextBlock">
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
<Setter Property="Margin" Value="0,0,12,10" />
|
||||||
|
</Style>
|
||||||
|
<Style TargetType="ComboBox">
|
||||||
|
<Setter Property="Margin" Value="0,0,0,10" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
</Grid.Resources>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="0" Text="匯出範圍" />
|
||||||
|
<ComboBox Grid.Row="0" Grid.Column="1" SelectedIndex="{Binding ScopeIndex}"
|
||||||
|
ToolTip="STL 無裝配結構;合併 = 所有可見零件進同一檔">
|
||||||
|
<ComboBoxItem Content="全部可見檔案 → 合併成單一 STL" />
|
||||||
|
<ComboBoxItem Content="每個可見檔案 → 各一個 STL" />
|
||||||
|
</ComboBox>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="0" Text="格式" />
|
||||||
|
<ComboBox Grid.Row="1" Grid.Column="1" SelectedIndex="{Binding FormatIndex}">
|
||||||
|
<ComboBoxItem Content="Binary(建議,檔案小)" />
|
||||||
|
<ComboBoxItem Content="ASCII(文字可讀,檔案約 4 倍大)" />
|
||||||
|
</ComboBox>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="2" Grid.Column="0" Text="單位縮放" />
|
||||||
|
<ComboBox Grid.Row="2" Grid.Column="1" SelectedIndex="{Binding UnitIndex}"
|
||||||
|
ToolTip="STL 檔本身無單位;內部幾何為 mm,選 inch 會把座標 ÷25.4">
|
||||||
|
<ComboBoxItem Content="mm(1:1,預設)" />
|
||||||
|
<ComboBoxItem Content="inch(座標 ÷ 25.4)" />
|
||||||
|
<ComboBoxItem Content="自訂縮放係數…" />
|
||||||
|
</ComboBox>
|
||||||
|
|
||||||
|
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding CustomScaleText, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Margin="0,0,0,10" Padding="4,2"
|
||||||
|
Visibility="{Binding IsCustomScale, Converter={StaticResource BoolToVis}}"
|
||||||
|
ToolTip="座標乘上此係數(例:0.001 = mm→m)" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="4" Grid.Column="0" Text="網格精度" />
|
||||||
|
<ComboBox Grid.Row="4" Grid.Column="1" SelectedIndex="{Binding QualityIndex}"
|
||||||
|
IsEnabled="{Binding HasBrep}"
|
||||||
|
ToolTip="精細會由 B-rep 重新三角化(僅 STEP 來源;STL/DXF 維持現有網格),大組件需要時間">
|
||||||
|
<ComboBoxItem Content="目前顯示網格(快,與畫面一致)" />
|
||||||
|
<ComboBoxItem Content="精細 — 重新三角化,曲面更平滑(慢、檔案大)" />
|
||||||
|
</ComboBox>
|
||||||
|
|
||||||
|
<Border Grid.Row="5" Grid.ColumnSpan="2" Background="#F0F4F8" CornerRadius="4"
|
||||||
|
Padding="10,8" Margin="0,2,0,12">
|
||||||
|
<TextBlock Text="{Binding Summary}" TextWrapping="Wrap" Margin="0" FontSize="12" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="6" Grid.ColumnSpan="2" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||||
|
<Button Content="取消" Width="80" Margin="0,0,8,0" IsCancel="True" />
|
||||||
|
<Button Content="匯出…" Width="96" IsDefault="True"
|
||||||
|
IsEnabled="{Binding CanConfirm}" Click="Confirm_Click" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using STPViewer.ViewModels;
|
||||||
|
|
||||||
|
namespace STPViewer;
|
||||||
|
|
||||||
|
/// <summary>STL 匯出參數對話框 — 選項邏輯在 StlExportViewModel,這裡只做確認/取消</summary>
|
||||||
|
public partial class StlExportDialog : Window
|
||||||
|
{
|
||||||
|
public StlExportViewModel ViewModel { get; }
|
||||||
|
|
||||||
|
public StlExportDialog(StlExportViewModel vm)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
ViewModel = vm;
|
||||||
|
DataContext = vm;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Confirm_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
DialogResult = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,21 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows;
|
||||||
using System.Windows.Media;
|
using System.Windows.Media;
|
||||||
using System.Windows.Media.Imaging;
|
using System.Windows.Media.Imaging;
|
||||||
|
using System.Windows.Media.Media3D;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using Microsoft.Win32;
|
using Microsoft.Win32;
|
||||||
using STPViewer.Models;
|
using STPViewer.Models;
|
||||||
|
using STPViewer.Services;
|
||||||
|
|
||||||
namespace STPViewer.ViewModels;
|
namespace STPViewer.ViewModels;
|
||||||
|
|
||||||
// ─── 匯出(量測 CSV / 視圖 PNG 截圖)────────────────────────────────
|
// ─── 匯出(量測 CSV / 視圖 PNG 截圖 / STEP / STL)────────────────────
|
||||||
public partial class MainViewModel
|
public partial class MainViewModel
|
||||||
{
|
{
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -82,6 +87,134 @@ public partial class MainViewModel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 匯出可見檔案(目前位置)為 STL 網格檔 — 先開參數對話框(範圍/格式/單位/精度 + 三角形數與大小預估)
|
||||||
|
/// 確認後才選路徑寫檔。精細/較粗 = 由 B-rep 重新三角化(背景執行緒);STL/DXF 來源一律用現有網格。
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand(CanExecute = nameof(IsIdle))]
|
||||||
|
private async Task ExportStlFile()
|
||||||
|
{
|
||||||
|
// 可匯出 = 可見 leaf 且有網格(DXF 純線架構無網格 → 排除)
|
||||||
|
var groups = Roots.Where(r => r.IsVisible)
|
||||||
|
.Select(r => (Root: r, Leaves: r.Leaves()
|
||||||
|
.Where(l => l.IsVisible && GetMergedMesh(l) is not null).ToList()))
|
||||||
|
.Where(g => g.Leaves.Count > 0)
|
||||||
|
.ToList();
|
||||||
|
if (groups.Count == 0)
|
||||||
|
{
|
||||||
|
StatusText = "沒有可匯出的網格(請勾選至少一個含實體網格的檔案)";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
long triangles = groups.SelectMany(g => g.Leaves)
|
||||||
|
.Sum(l => GetMergedMesh(l)!.TriangleIndices.Count / 3L);
|
||||||
|
bool hasBrep = groups.SelectMany(g => g.Leaves).Any(l => l.HasBrep && l.SourceGeos.Count > 0);
|
||||||
|
|
||||||
|
// 參數對話框(確認再匯出)
|
||||||
|
var vm = new StlExportViewModel(groups.Count, triangles, hasBrep);
|
||||||
|
var dialog = new StlExportDialog(vm) { Owner = Application.Current.MainWindow };
|
||||||
|
if (dialog.ShowDialog() != true || vm.Scale is not double scale) return;
|
||||||
|
|
||||||
|
var dlg = new SaveFileDialog
|
||||||
|
{
|
||||||
|
Title = vm.PerFile ? "匯出 STL(每個檔案各一個,以此為基底檔名)" : "匯出 STL(合併單一檔)",
|
||||||
|
Filter = "STL 檔案 (*.stl)|*.stl",
|
||||||
|
FileName = vm.PerFile
|
||||||
|
? $"stl_{DateTime.Now:yyyyMMdd_HHmmss}.stl"
|
||||||
|
: $"assembly_{DateTime.Now:yyyyMMdd_HHmmss}.stl",
|
||||||
|
};
|
||||||
|
if (dlg.ShowDialog() != true) return;
|
||||||
|
|
||||||
|
// UI 執行緒先快照:GeometryModel3D/Model3DGroup 是 DispatcherObject 不可跨執行緒,
|
||||||
|
// 背景只能碰 frozen MeshGeometry3D 與 CADability B-rep(SourceGeos)
|
||||||
|
var snapshot = groups
|
||||||
|
.Select(g => (g.Root.Name, Leaves: g.Leaves
|
||||||
|
.Select(l => (Mesh: GetMergedMesh(l)!, l.HasBrep, l.SourceGeos))
|
||||||
|
.ToList()))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
IsBusy = true;
|
||||||
|
StatusText = vm.Quality == StlMeshQuality.Current
|
||||||
|
? "匯出 STL 中…"
|
||||||
|
: "重新三角化 + 匯出 STL 中…(大組件需要時間)";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
double factor = StlExportService.PrecisionFactor(vm.Quality);
|
||||||
|
var written = await Task.Run(() =>
|
||||||
|
{
|
||||||
|
var results = new List<(string File, int Triangles)>();
|
||||||
|
if (vm.PerFile)
|
||||||
|
{
|
||||||
|
string dir = Path.GetDirectoryName(dlg.FileName)!;
|
||||||
|
string baseName = Path.GetFileNameWithoutExtension(dlg.FileName);
|
||||||
|
foreach ((string rootName, var leaves) in snapshot)
|
||||||
|
{
|
||||||
|
string file = Path.Combine(dir, $"{baseName}_{SafeFileName(rootName)}.stl");
|
||||||
|
int n = StlExportService.Write(file, CollectMeshes(leaves, vm.Quality, factor), vm.Ascii, scale);
|
||||||
|
results.Add((file, n));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var meshes = CollectMeshes(snapshot.SelectMany(g => g.Leaves).ToList(), vm.Quality, factor);
|
||||||
|
int n = StlExportService.Write(dlg.FileName, meshes, vm.Ascii, scale);
|
||||||
|
results.Add((dlg.FileName, n));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
});
|
||||||
|
|
||||||
|
long total = written.Sum(w => (long)w.Triangles);
|
||||||
|
StatusText = written.Count == 1
|
||||||
|
? $"已匯出 STL({total:N0} 三角形)→ {Path.GetFileName(written[0].File)}"
|
||||||
|
: $"已匯出 {written.Count} 個 STL(共 {total:N0} 三角形)→ {Path.GetDirectoryName(dlg.FileName)}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusText = $"STL 匯出失敗:{ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>leaf 的合併網格(frozen、未剖切、目前位置)— STL 匯出的「目前顯示網格」來源</summary>
|
||||||
|
private static MeshGeometry3D? GetMergedMesh(ModelNodeViewModel leaf) =>
|
||||||
|
leaf.MergedContent?.Children.Count > 0
|
||||||
|
? (leaf.MergedContent.Children[0] as GeometryModel3D)?.Geometry as MeshGeometry3D
|
||||||
|
: null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 收集要寫進 STL 的網格(背景執行緒;輸入是 UI 快照):
|
||||||
|
/// 精細且有 B-rep → 重新三角化(循序,CADability 非執行緒安全);
|
||||||
|
/// 其餘(目前網格 / STL 來源)→ 快照的合併網格(frozen,可安全跨執行緒讀取)。
|
||||||
|
/// </summary>
|
||||||
|
private static List<MeshGeometry3D> CollectMeshes(
|
||||||
|
IReadOnlyList<(MeshGeometry3D Mesh, bool HasBrep, List<CADability.GeoObject.IGeoObject> SourceGeos)> leaves,
|
||||||
|
StlMeshQuality quality, double precisionFactor)
|
||||||
|
{
|
||||||
|
var meshes = new List<MeshGeometry3D>();
|
||||||
|
foreach ((MeshGeometry3D mesh, bool hasBrep, var sourceGeos) in leaves)
|
||||||
|
{
|
||||||
|
if (quality != StlMeshQuality.Current && hasBrep && sourceGeos.Count > 0)
|
||||||
|
{
|
||||||
|
List<MeshGeometry3D> tess = StlExportService.Tessellate(sourceGeos, precisionFactor);
|
||||||
|
if (tess.Count > 0) { meshes.AddRange(tess); continue; }
|
||||||
|
// 重新三角化全數失敗 → 退回現有網格,不讓零件憑空消失
|
||||||
|
}
|
||||||
|
meshes.Add(mesh);
|
||||||
|
}
|
||||||
|
return meshes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SafeFileName(string name)
|
||||||
|
{
|
||||||
|
string stem = Path.GetFileNameWithoutExtension(name);
|
||||||
|
if (string.IsNullOrWhiteSpace(stem)) stem = name;
|
||||||
|
foreach (char c in Path.GetInvalidFileNameChars()) stem = stem.Replace(c, '_');
|
||||||
|
return stem;
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void SaveScreenshot()
|
private void SaveScreenshot()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ public partial class MainViewModel : ObservableObject
|
|||||||
[NotifyCanExecuteChangedFor(nameof(RotateRootCommand))]
|
[NotifyCanExecuteChangedFor(nameof(RotateRootCommand))]
|
||||||
[NotifyCanExecuteChangedFor(nameof(CheckInterferenceCommand))]
|
[NotifyCanExecuteChangedFor(nameof(CheckInterferenceCommand))]
|
||||||
[NotifyCanExecuteChangedFor(nameof(ExportStepFileCommand))]
|
[NotifyCanExecuteChangedFor(nameof(ExportStepFileCommand))]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(ExportStlFileCommand))]
|
||||||
private bool isBusy;
|
private bool isBusy;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using STPViewer.Services;
|
||||||
|
|
||||||
|
namespace STPViewer.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// STL 匯出參數對話框的 ViewModel:範圍 / 格式 / 單位縮放 / 網格精度 + 即時摘要(三角形數、預估大小)。
|
||||||
|
/// 對話框按「匯出」後由 MainViewModel.Export 讀取選項執行。
|
||||||
|
/// </summary>
|
||||||
|
public partial class StlExportViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
/// <summary>可見(可匯出)檔案數</summary>
|
||||||
|
public int FileCount { get; }
|
||||||
|
|
||||||
|
/// <summary>目前顯示網格的三角形總數(精細/較粗會重新三角化,實際數量以結果為準)</summary>
|
||||||
|
public long TriangleCount { get; }
|
||||||
|
|
||||||
|
/// <summary>是否有 B-rep 來源(全 STL/DXF 時精度選項無意義 → 停用)</summary>
|
||||||
|
public bool HasBrep { get; }
|
||||||
|
|
||||||
|
public StlExportViewModel(int fileCount, long triangleCount, bool hasBrep)
|
||||||
|
{
|
||||||
|
FileCount = fileCount;
|
||||||
|
TriangleCount = triangleCount;
|
||||||
|
HasBrep = hasBrep;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>0 = 全部可見合併成單一 STL;1 = 每個可見檔案各一個 STL</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(Summary))]
|
||||||
|
private int scopeIndex;
|
||||||
|
|
||||||
|
/// <summary>0 = Binary(建議,檔案小);1 = ASCII(文字可讀,檔案大)</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(Summary))]
|
||||||
|
private int formatIndex;
|
||||||
|
|
||||||
|
/// <summary>0 = mm(1:1);1 = inch(÷25.4);2 = 自訂縮放</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(Summary))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsCustomScale))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(CanConfirm))]
|
||||||
|
private int unitIndex;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(Summary))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(CanConfirm))]
|
||||||
|
private string customScaleText = "1.0";
|
||||||
|
|
||||||
|
/// <summary>0 = 目前顯示網格;1 = 精細(B-rep 重新三角化)</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(Summary))]
|
||||||
|
private int qualityIndex;
|
||||||
|
|
||||||
|
public bool IsCustomScale => UnitIndex == 2;
|
||||||
|
|
||||||
|
public bool Ascii => FormatIndex == 1;
|
||||||
|
public bool PerFile => ScopeIndex == 1;
|
||||||
|
public StlMeshQuality Quality => (StlMeshQuality)QualityIndex;
|
||||||
|
|
||||||
|
/// <summary>座標縮放;自訂值無效時回 null(擋確認)</summary>
|
||||||
|
public double? Scale => UnitIndex switch
|
||||||
|
{
|
||||||
|
0 => 1.0,
|
||||||
|
1 => 1.0 / 25.4,
|
||||||
|
_ => double.TryParse(CustomScaleText, NumberStyles.Float, CultureInfo.InvariantCulture, out double s)
|
||||||
|
&& s > 0 && double.IsFinite(s) ? s : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
public bool CanConfirm => Scale is not null;
|
||||||
|
|
||||||
|
public string Summary
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
long est = StlExportService.EstimateBytes(TriangleCount, Ascii);
|
||||||
|
string size = StlExportService.FormatBytes(est);
|
||||||
|
string basis = QualityIndex == 0
|
||||||
|
? $"預估 {size}"
|
||||||
|
: $"以目前網格預估 {size}(實際依重新三角化結果)";
|
||||||
|
return $"{FileCount} 個可見檔案 · {TriangleCount:N0} 三角形 · {basis}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,9 @@ if (args[0] == "--bbox")
|
|||||||
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;
|
||||||
|
|
||||||
|
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($" [階段] {s}") };
|
var service = new StepImportService { Progress = s => Console.WriteLine($" [階段] {s}") };
|
||||||
int failed = 0;
|
int failed = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Media3D;
|
||||||
|
using STPViewer.Services;
|
||||||
|
using IOPath = System.IO.Path; // CADability.GeoObject.Path(曲線)撞名
|
||||||
|
|
||||||
|
namespace SmokeTest;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// STL 匯出驗證(無 UI):
|
||||||
|
/// 1) binary — 已知立方體網格寫出 → 解析 header/三角形數/座標範圍比對(含縮放)
|
||||||
|
/// 2) ASCII — facet 數與 solid/endsolid 結構
|
||||||
|
/// 3) 退化三角形(零面積)自動濾除
|
||||||
|
/// </summary>
|
||||||
|
public static class StlExportTest
|
||||||
|
{
|
||||||
|
public static int Run(string? stepPath = null)
|
||||||
|
{
|
||||||
|
Console.WriteLine("=== StlExportTest ===");
|
||||||
|
int failed = 0;
|
||||||
|
string dir = IOPath.Combine(IOPath.GetTempPath(), "stpviewer_stl_test");
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
|
||||||
|
MeshGeometry3D cube = BuildCube(10); // 10×10×10 mm、12 三角形
|
||||||
|
|
||||||
|
// 1) binary:三角形數 + 縮放後座標範圍
|
||||||
|
{
|
||||||
|
string f = IOPath.Combine(dir, "cube_bin.stl");
|
||||||
|
double scale = 1.0 / 25.4; // mm → inch
|
||||||
|
int written = StlExportService.Write(f, new[] { cube }, ascii: false, scale);
|
||||||
|
(uint count, float min, float max) = ReadBinary(f);
|
||||||
|
bool ok = written == 12 && count == 12
|
||||||
|
&& Math.Abs(min - 0f) < 1e-5 && Math.Abs(max - 10f / 25.4f) < 1e-4;
|
||||||
|
Report(ref failed, ok, $"binary 寫出/回讀 12 三角形、inch 縮放範圍 [{min:F4}, {max:F4}]");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) ASCII:facet 數 + 結構
|
||||||
|
{
|
||||||
|
string f = IOPath.Combine(dir, "cube_ascii.stl");
|
||||||
|
int written = StlExportService.Write(f, new[] { cube }, ascii: true, scale: 1.0);
|
||||||
|
string text = File.ReadAllText(f);
|
||||||
|
int facets = CountOccurrences(text, "facet normal");
|
||||||
|
bool ok = written == 12 && facets == 12
|
||||||
|
&& text.StartsWith("solid ", StringComparison.Ordinal)
|
||||||
|
&& text.Contains("endsolid");
|
||||||
|
Report(ref failed, ok, $"ASCII 結構完整、facet = {facets}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 退化三角形濾除:加一個零面積三角形 → 仍是 12
|
||||||
|
{
|
||||||
|
var degenerate = new MeshGeometry3D();
|
||||||
|
degenerate.Positions.Add(new Point3D(0, 0, 0));
|
||||||
|
degenerate.Positions.Add(new Point3D(1, 1, 1));
|
||||||
|
degenerate.Positions.Add(new Point3D(2, 2, 2)); // 共線 → 面積 0
|
||||||
|
degenerate.TriangleIndices.Add(0);
|
||||||
|
degenerate.TriangleIndices.Add(1);
|
||||||
|
degenerate.TriangleIndices.Add(2);
|
||||||
|
degenerate.Freeze();
|
||||||
|
|
||||||
|
string f = IOPath.Combine(dir, "cube_degen.stl");
|
||||||
|
int written = StlExportService.Write(f, new[] { cube, degenerate }, ascii: false, scale: 1.0);
|
||||||
|
(uint count, _, _) = ReadBinary(f);
|
||||||
|
bool ok = written == 12 && count == 12;
|
||||||
|
Report(ref failed, ok, $"退化三角形濾除(寫出 {written})");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4)(可選)真實 STEP:B-rep 精細重新三角化 → 寫 binary STL 驗證
|
||||||
|
//(不測「較粗」:CADability GetTriangulation 對比快取粗的精度回傳既有快取,粗化無效 → 功能已移除)
|
||||||
|
if (stepPath is not null)
|
||||||
|
{
|
||||||
|
var data = new StepImportService().Import(stepPath);
|
||||||
|
var geos = Collect(data.Root).ToList();
|
||||||
|
int importTris = data.Root.TriangleCount;
|
||||||
|
Console.WriteLine($" 來源 {IOPath.GetFileName(stepPath)}:{geos.Count} 個 B-rep 物件、匯入網格 {importTris:N0} 三角形");
|
||||||
|
|
||||||
|
var meshes = StlExportService.Tessellate(
|
||||||
|
geos, StlExportService.PrecisionFactor(StlMeshQuality.Fine));
|
||||||
|
string f = IOPath.Combine(dir, "step_fine.stl");
|
||||||
|
int written = StlExportService.Write(f, meshes, ascii: false, scale: 1.0);
|
||||||
|
(uint count, _, _) = ReadBinary(f);
|
||||||
|
Report(ref failed, meshes.Count > 0 && written > 0 && count == written,
|
||||||
|
$"精細重新三角化 {meshes.Count} 面 → {written:N0} 三角形");
|
||||||
|
// CADability 三角形數對精度非單調(0.4× 反而比 1× 少),Fine=0.15× 實測明顯更細
|
||||||
|
Report(ref failed, written > importTris,
|
||||||
|
$"精細({written:N0})> 匯入預設({importTris:N0})");
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine(failed == 0 ? "StlExportTest: PASS" : $"StlExportTest: FAIL({failed} 項)");
|
||||||
|
return failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<CADability.GeoObject.IGeoObject> Collect(ImportedNode n)
|
||||||
|
{
|
||||||
|
foreach (CADability.GeoObject.IGeoObject g in n.SourceGeos) yield return g;
|
||||||
|
foreach (ImportedNode c in n.Children)
|
||||||
|
foreach (CADability.GeoObject.IGeoObject g in Collect(c))
|
||||||
|
yield return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Report(ref int failed, bool ok, string what)
|
||||||
|
{
|
||||||
|
Console.WriteLine($" [{(ok ? "PASS" : "FAIL")}] {what}");
|
||||||
|
if (!ok) failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>0..size 的軸對齊立方體,12 個三角形(法向不講究,STL 寫出時自算)</summary>
|
||||||
|
private static MeshGeometry3D BuildCube(double s)
|
||||||
|
{
|
||||||
|
var m = new MeshGeometry3D();
|
||||||
|
Point3D[] v =
|
||||||
|
{
|
||||||
|
new(0, 0, 0), new(s, 0, 0), new(s, s, 0), new(0, s, 0),
|
||||||
|
new(0, 0, s), new(s, 0, s), new(s, s, s), new(0, s, s),
|
||||||
|
};
|
||||||
|
foreach (Point3D p in v) m.Positions.Add(p);
|
||||||
|
int[] quads = { 0,3,2,1, 4,5,6,7, 0,1,5,4, 1,2,6,5, 2,3,7,6, 3,0,4,7 };
|
||||||
|
for (int q = 0; q < quads.Length; q += 4)
|
||||||
|
{
|
||||||
|
m.TriangleIndices.Add(quads[q]); m.TriangleIndices.Add(quads[q + 1]); m.TriangleIndices.Add(quads[q + 2]);
|
||||||
|
m.TriangleIndices.Add(quads[q]); m.TriangleIndices.Add(quads[q + 2]); m.TriangleIndices.Add(quads[q + 3]);
|
||||||
|
}
|
||||||
|
m.Freeze();
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (uint Count, float Min, float Max) ReadBinary(string path)
|
||||||
|
{
|
||||||
|
using var br = new BinaryReader(File.OpenRead(path));
|
||||||
|
byte[] header = br.ReadBytes(80);
|
||||||
|
if (Encoding.ASCII.GetString(header, 0, 5) == "solid")
|
||||||
|
throw new InvalidDataException("binary STL header 不可以 solid 開頭");
|
||||||
|
uint count = br.ReadUInt32();
|
||||||
|
float min = float.MaxValue, max = float.MinValue;
|
||||||
|
for (uint t = 0; t < count; t++)
|
||||||
|
{
|
||||||
|
br.ReadBytes(12); // normal
|
||||||
|
for (int k = 0; k < 9; k++)
|
||||||
|
{
|
||||||
|
float f = br.ReadSingle();
|
||||||
|
min = Math.Min(min, f);
|
||||||
|
max = Math.Max(max, f);
|
||||||
|
}
|
||||||
|
br.ReadUInt16(); // attribute
|
||||||
|
}
|
||||||
|
return (count, min, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CountOccurrences(string text, string token)
|
||||||
|
{
|
||||||
|
int n = 0;
|
||||||
|
for (int i = text.IndexOf(token, StringComparison.Ordinal); i >= 0;
|
||||||
|
i = text.IndexOf(token, i + token.Length, StringComparison.Ordinal)) n++;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user