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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 08:44:34 +08:00
12 changed files with 742 additions and 8 deletions
+32 -4
View File
@@ -19,7 +19,8 @@ STEP`.stp` / `.step`)檔案做確認與量測,不需要安裝 SolidWorks
快速鍵 P/D/E/F/C/A/M + Esc;單位 mm ⇄ inch 即時切換 快速鍵 P/D/E/F/C/A/M + Esc;單位 mm ⇄ inch 即時切換
- **剖面**X/Y/Z 軸向 + **3點任意平面**,位置滑桿/數值輸入 + 反向,CPU 網格裁切(量測不受影響) - **剖面**X/Y/Z 軸向 + **3點任意平面**,位置滑桿/數值輸入 + 反向,CPU 網格裁切(量測不受影響)
- **視圖**:標準視圖(等角/前/上/右)+ 正交⇄透視投影 - **視圖**:標準視圖(等角/前/上/右)+ 正交⇄透視投影
- **匯出**:量測結果 CSV、3D 視圖 PNG 截圖(2x)、**目前對齊位置寫成新 STEP 檔** - **匯出**:量測結果 CSV、3D 視圖 PNG 截圖(2x)、**目前對齊位置寫成新 STEP 檔**
**STL 網格檔**(參數對話框:合併/每檔、binary/ASCII、mm/inch/自訂縮放、精度,確認再匯出)
- 單機離線執行,無網路相依;視窗/單位/最近檔案自動保存(settings.json - 單機離線執行,無網路相依;視窗/單位/最近檔案自動保存(settings.json
使用者:單一桌面使用者(無登入 / 角色系統)。 使用者:單一桌面使用者(無登入 / 角色系統)。
@@ -106,13 +107,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 CSV2x PNG;可見檔案目前位置寫成**新** STEP 檔(對齊結果交接 CAD | | 匯出 | 💾 CSV / 📷 截圖 / 📤 STEP / 📐 STL | UTF-8 BOM CSV2x PNG;可見檔案目前位置寫成**新** STEP 檔(對齊結果交接 CAD);STL 先開參數對話框(範圍/格式/單位/精度 + 三角形數與大小預估)確認再匯出 |
| 視圖 | 滑鼠右鍵旋轉/滾輪縮放/中鍵平移(Helix 預設)、ViewCube、工具列 等角/前/上/右、正交⇄透視 | | | 視圖 | 滑鼠右鍵旋轉/滾輪縮放/中鍵平移(Helix 預設)、ViewCube、工具列 等角/前/上/右、正交⇄透視 | |
| 快速鍵 | P 點 / D 距離 / E 邊 / F 面 / C 圓 / A 角度 / M 面距;同鍵再按=退出;Esc=取消進行中量測→退出模式 | | | 快速鍵 | P 點 / D 距離 / E 邊 / F 面 / C 圓 / A 角度 / M 面距;同鍵再按=退出;Esc=取消進行中量測→退出模式 | |
| 樹操作 | 搜尋框過濾節點名稱;右鍵:只顯示此節點 / 反轉顯示 / 全部顯示 / 量體積質心 | tooltip 含外形尺寸 L×W×H | | 樹操作 | 搜尋框過濾節點名稱;右鍵:只顯示此節點 / 反轉顯示 / 全部顯示 / 量體積質心 | tooltip 含外形尺寸 L×W×H |
@@ -435,6 +440,29 @@ WPF retained-mode 每幀重走 visual tree → frame rate 崩。瀏覽模式因
--- ---
## Development Phases — 第七輪(STL 匯出 v0.6.0,全部完成)
### Phase 15 — STL 匯出(工作量:M)
- [x] `StlExportService` — binary80B header + placeholder 補三角形數)/ ASCII 寫檔,
退化(零面積)三角形濾除、逐三角形自算法向量;`Tessellate` 由 B-rep 重新三角化
- [x] 參數對話框(`StlExportDialog` + `StlExportViewModel`)— 範圍(合併單檔/每檔一個)、
格式(binary/ASCII)、單位縮放(mm / inch ÷25.4 / 自訂)、網格精度(目前網格/精細)、
即時摘要(檔案數/三角形數/預估大小),**確認才選路徑匯出**
- [x] 背景執行緒寫檔 — UI 先快照 frozen mesh`GeometryModel3D` 是 DispatcherObject
不可跨執行緒,v0.6.0 修正),背景只碰 frozen `MeshGeometry3D` + CADability B-rep
- [x] SmokeTest `--stl-export-test [file.stp]`binary 往返(含 inch 縮放)、ASCII 結構、
退化三角形濾除、真檔精細重算三角形數 > 匯入預設
**CADability 三角化兩個實測特性(設計依據):**
1. `GetTriangulation` 對「比快取粗」的精度要求直接回傳既有較細快取 → 「較粗」選項無效,誠實不提供
2. 三角形數對精度**非單調**(0.4× 重算反而比 1× 少 — 重算網格較有效率),
要明顯更細需 ≤0.15×(test.stp1× = 30,784 → 0.15× = 35,228)→ 精細係數定 0.15
**驗收:** StlExportTest 5 項全過;既有 ClipTest/AlignTest/InterferenceTest 回歸全過;建置 0 警告
---
## Future Extensions(下一輪) ## Future Extensions(下一輪)
- **大檔重開快取** — 匯入成功後把三角網格 + 裝配樹序列化成 sidecar 快取檔(以來源檔 hash 驗證), - **大檔重開快取** — 匯入成功後把三角網格 + 裝配樹序列化成 sidecar 快取檔(以來源檔 hash 驗證),
+9 -2
View File
@@ -5,7 +5,7 @@
CAD 3D 檢視器(Windows 桌面 WPF, .NET 8):STEP/STL/DXF 匯入、STEP 裝配樹(搜尋/隔離顯示)、 CAD 3D 檢視器(Windows 桌面 WPF, .NET 8):STEP/STL/DXF 匯入、STEP 裝配樹(搜尋/隔離顯示)、
點/距離/邊/面/圓/角度/面距/體積質心量測(吸附含圓心)、兩點對齊(平移)、三點對齊(旋轉+平移)、 點/距離/邊/面/圓/角度/面距/體積質心量測(吸附含圓心)、兩點對齊(平移)、三點對齊(旋轉+平移)、
軸向旋轉 90°、拖曳模式、干涉檢查(≥2 檔兩兩配對)、剖面(X/Y/Z + 3點任意平面)、 軸向旋轉 90°、拖曳模式、干涉檢查(≥2 檔兩兩配對)、剖面(X/Y/Z + 3點任意平面)、
標準視圖+正交投影、量測快速鍵+Esc、mm⇄inch、CSV/截圖/STEP 匯出、設定與 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
單位/MRUVM `LoadSettings`/`SaveSettingsInto`);壞檔回預設、儲存失敗靜默 單位/MRUVM `LoadSettings`/`SaveSettingsInto`);壞檔回預設、儲存失敗靜默
- 匯出 STEP 走 `CADability.ExportStep.WriteToFile(file, Project.CreateSimpleProject()+Model.Add)` - 匯出 STEP 走 `CADability.ExportStep.WriteToFile(file, Project.CreateSimpleProject()+Model.Add)`
只收 Solid/ShellSTL/DXF 無 B-rep 進不了);改動要跑 `SmokeTest --export-test` 往返驗證 只收 Solid/ShellSTL/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/
+2
View File
@@ -94,6 +94,8 @@
ToolTip="3D 視圖截圖存 PNG2x 解析度)">📷 截圖</Button> ToolTip="3D 視圖截圖存 PNG2x 解析度)">📷 截圖</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"
+1 -1
View File
@@ -6,7 +6,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF> <UseWPF>true</UseWPF>
<Version>0.5.0</Version> <!-- 單一版號來源:標題與 publish-winapp 打包資料夾都讀這個 --> <Version>0.6.0</Version> <!-- 單一版號來源:標題與 publish-winapp 打包資料夾都讀這個 -->
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
+209
View File
@@ -0,0 +1,209 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using System.Windows.Media;
using System.Windows.Media.Media3D;
using CADability;
using CADability.GeoObject;
namespace STPViewer.Services;
/// <summary>
/// 網格精度:Current = 目前顯示網格;Fine = 由 B-rep 以較細精度重新三角化(無 B-rep 的來源維持現有網格)。
/// 不提供「較粗」:CADability 的 GetTriangulation 對「比快取粗」的精度要求會直接回傳既有較細快取,
/// 匯入時已三角化過 → 要求粗化實際上拿到同一份網格(實測驗證),故誠實不提供。
/// </summary>
public enum StlMeshQuality { Current, Fine }
/// <summary>
/// STL 匯出:WPF 三角網格 → binary / ASCII STL。
/// STL 本身無單位,約定輸出 mmscale=1);inch 交接用 scale = 1/25.4。
/// </summary>
public static class StlExportService
{
/// <summary>
/// 三角化精度係數(相對匯入預設):Fine 較細(三角形多、曲面平滑)。
/// 實測 CADability 三角形數對精度非單調(0.4× 重算反而比 1× 少 — 重算網格較有效率),
/// 要明顯更細需 ≤0.15×(test.stp1× = 30,784 → 0.1× = 42,220 三角形)。
/// </summary>
public static double PrecisionFactor(StlMeshQuality q) =>
q == StlMeshQuality.Fine ? 0.15 : 1.0;
/// <summary>預估檔案大小:binary = 84 + 50×nASCII 每三角形約 230 bytes</summary>
public static long EstimateBytes(long triangles, bool ascii) =>
ascii ? 20 + triangles * 230 : 84 + triangles * 50;
public static string FormatBytes(long bytes) => bytes switch
{
>= 1L << 30 => $"{bytes / (double)(1L << 30):F1} GB",
>= 1L << 20 => $"{bytes / (double)(1L << 20):F1} MB",
>= 1L << 10 => $"{bytes / (double)(1L << 10):F1} KB",
_ => $"{bytes} B",
};
/// <summary>
/// 把多個網格寫成一個 STL 檔。退化(零面積)三角形濾除。
/// 回傳實際寫出的三角形數。
/// </summary>
public static int Write(string path, IReadOnlyList<MeshGeometry3D> meshes, bool ascii, double scale) =>
ascii ? WriteAscii(path, meshes, scale) : WriteBinary(path, meshes, scale);
private static int WriteBinary(string path, IReadOnlyList<MeshGeometry3D> meshes, double scale)
{
using var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, 1 << 16);
using var bw = new BinaryWriter(fs);
// 80-byte header(不可以 "solid" 開頭,部分讀取器會誤判為 ASCII)
var header = new byte[80];
Encoding.ASCII.GetBytes("STPViewer binary STL (unit: mm x scale)").CopyTo(header, 0);
bw.Write(header);
bw.Write(0u); // 三角形數 placeholder,寫完回頭補
int count = 0;
foreach (MeshGeometry3D mesh in meshes)
{
var pos = mesh.Positions;
var idx = mesh.TriangleIndices;
for (int i = 0; i + 2 < idx.Count; i += 3)
{
Point3D a = pos[idx[i]], b = pos[idx[i + 1]], c = pos[idx[i + 2]];
if (!TryNormal(a, b, c, out Vector3D n)) continue; // 退化三角形
bw.Write((float)n.X); bw.Write((float)n.Y); bw.Write((float)n.Z);
WriteVertex(bw, a, scale);
WriteVertex(bw, b, scale);
WriteVertex(bw, c, scale);
bw.Write((ushort)0); // attribute byte count
count++;
}
}
fs.Seek(80, SeekOrigin.Begin);
bw.Write((uint)count);
return count;
}
private static void WriteVertex(BinaryWriter bw, Point3D p, double scale)
{
bw.Write((float)(p.X * scale));
bw.Write((float)(p.Y * scale));
bw.Write((float)(p.Z * scale));
}
private static int WriteAscii(string path, IReadOnlyList<MeshGeometry3D> meshes, double scale)
{
var inv = CultureInfo.InvariantCulture;
using var sw = new StreamWriter(path, append: false, Encoding.ASCII, 1 << 16);
sw.WriteLine("solid STPViewer");
int count = 0;
foreach (MeshGeometry3D mesh in meshes)
{
var pos = mesh.Positions;
var idx = mesh.TriangleIndices;
for (int i = 0; i + 2 < idx.Count; i += 3)
{
Point3D a = pos[idx[i]], b = pos[idx[i + 1]], c = pos[idx[i + 2]];
if (!TryNormal(a, b, c, out Vector3D n)) continue;
sw.WriteLine(string.Format(inv, " facet normal {0:e6} {1:e6} {2:e6}", n.X, n.Y, n.Z));
sw.WriteLine(" outer loop");
WriteAsciiVertex(sw, a, scale, inv);
WriteAsciiVertex(sw, b, scale, inv);
WriteAsciiVertex(sw, c, scale, inv);
sw.WriteLine(" endloop");
sw.WriteLine(" endfacet");
count++;
}
}
sw.WriteLine("endsolid STPViewer");
return count;
}
private static void WriteAsciiVertex(StreamWriter sw, Point3D p, double scale, CultureInfo inv) =>
sw.WriteLine(string.Format(inv, " vertex {0:e6} {1:e6} {2:e6}",
p.X * scale, p.Y * scale, p.Z * scale));
/// <returns>false = 退化三角形(面積 ≈ 0</returns>
private static bool TryNormal(Point3D a, Point3D b, Point3D c, out Vector3D n)
{
n = Vector3D.CrossProduct(b - a, c - a);
double len = n.Length;
if (len < 1e-12) return false;
n /= len;
return true;
}
// ─── B-rep 重新三角化(精細/較粗匯出用)────────────────────────
/// <summary>
/// 由 B-rep 幾何以指定精度係數重新三角化,回傳每面一個 frozen mesh。
/// CADability 非執行緒安全 → 呼叫端維持循序(可在單一背景執行緒)。
/// 個別面三角化失敗時跳過(與匯入一致的容錯策略)。
/// </summary>
public static List<MeshGeometry3D> Tessellate(IEnumerable<IGeoObject> geos, double precisionFactor)
{
var meshes = new List<MeshGeometry3D>();
foreach (IGeoObject g in geos)
{
switch (g)
{
case Solid solid:
foreach (Shell sh in solid.Shells) TessellateShell(sh, precisionFactor, meshes);
break;
case Shell shell:
TessellateShell(shell, precisionFactor, meshes);
break;
case Face face:
AddFaceMesh(face, PrecisionFor(SafeBounds(face), precisionFactor), meshes);
break;
}
}
return meshes;
}
private static void TessellateShell(Shell shell, double factor, List<MeshGeometry3D> meshes)
{
double precision = PrecisionFor(SafeBounds(shell), factor);
foreach (Face f in shell.Faces) AddFaceMesh(f, precision, meshes);
}
/// <summary>
/// 匯入預設精度(與 StepImportService.PrecisionFor 同款 clamp)再乘 factor —
/// 先 clamp 再乘,保證 factor&lt;1 時嚴格比匯入快取細(否則大殼在匯入被 0.5 上限截住,
/// 直接乘在 clamp 前會算出比快取粗的值 → CADability 回傳快取、精細無效)。
/// </summary>
private static double PrecisionFor(BoundingCube bc, double factor)
{
double diag;
try { diag = bc.DiagonalLength; }
catch { diag = 100; }
if (diag <= 0 || double.IsNaN(diag)) diag = 100;
double importDefault = Math.Clamp(diag * 0.0015, 0.02, 0.5);
return Math.Max(importDefault * factor, 0.005);
}
private static BoundingCube SafeBounds(IGeoObject g)
{
try { return g.GetBoundingCube(); }
catch { return new BoundingCube(); }
}
private static void AddFaceMesh(Face face, double precision, List<MeshGeometry3D> meshes)
{
GeoPoint[] pts; int[] ind;
try { face.GetTriangulation(precision, out pts, out _, out ind, out _); }
catch { return; }
if (pts is null || ind is null || ind.Length < 3) return;
var positions = new Point3DCollection(pts.Length);
foreach (GeoPoint p in pts) positions.Add(new Point3D(p.x, p.y, p.z));
var indices = new Int32Collection(ind.Length);
foreach (int i in ind) indices.Add(i);
var mesh = new MeshGeometry3D { Positions = positions, TriangleIndices = indices };
mesh.Freeze();
meshes.Add(mesh);
}
}
+86
View File
@@ -0,0 +1,86 @@
<Window x:Class="STPViewer.StlExportDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:STPViewer.ViewModels"
Title="匯出 STL — 參數設定"
Width="440" SizeToContent="Height"
WindowStartupLocation="CenterOwner"
ResizeMode="NoResize" ShowInTaskbar="False"
d:DataContext="{d:DesignInstance Type=vm:StlExportViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Window.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVis" />
</Window.Resources>
<Grid Margin="16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.Resources>
<Style TargetType="TextBlock">
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Margin" Value="0,0,12,10" />
</Style>
<Style TargetType="ComboBox">
<Setter Property="Margin" Value="0,0,0,10" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
</Grid.Resources>
<TextBlock Grid.Row="0" Grid.Column="0" Text="匯出範圍" />
<ComboBox Grid.Row="0" Grid.Column="1" SelectedIndex="{Binding ScopeIndex}"
ToolTip="STL 無裝配結構;合併 = 所有可見零件進同一檔">
<ComboBoxItem Content="全部可見檔案 → 合併成單一 STL" />
<ComboBoxItem Content="每個可見檔案 → 各一個 STL" />
</ComboBox>
<TextBlock Grid.Row="1" Grid.Column="0" Text="格式" />
<ComboBox Grid.Row="1" Grid.Column="1" SelectedIndex="{Binding FormatIndex}">
<ComboBoxItem Content="Binary(建議,檔案小)" />
<ComboBoxItem Content="ASCII(文字可讀,檔案約 4 倍大)" />
</ComboBox>
<TextBlock Grid.Row="2" Grid.Column="0" Text="單位縮放" />
<ComboBox Grid.Row="2" Grid.Column="1" SelectedIndex="{Binding UnitIndex}"
ToolTip="STL 檔本身無單位;內部幾何為 mm,選 inch 會把座標 ÷25.4">
<ComboBoxItem Content="mm1:1,預設)" />
<ComboBoxItem Content="inch(座標 ÷ 25.4" />
<ComboBoxItem Content="自訂縮放係數…" />
</ComboBox>
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding CustomScaleText, UpdateSourceTrigger=PropertyChanged}"
Margin="0,0,0,10" Padding="4,2"
Visibility="{Binding IsCustomScale, Converter={StaticResource BoolToVis}}"
ToolTip="座標乘上此係數(例:0.001 = mm→m" />
<TextBlock Grid.Row="4" Grid.Column="0" Text="網格精度" />
<ComboBox Grid.Row="4" Grid.Column="1" SelectedIndex="{Binding QualityIndex}"
IsEnabled="{Binding HasBrep}"
ToolTip="精細會由 B-rep 重新三角化(僅 STEP 來源;STL/DXF 維持現有網格),大組件需要時間">
<ComboBoxItem Content="目前顯示網格(快,與畫面一致)" />
<ComboBoxItem Content="精細 — 重新三角化,曲面更平滑(慢、檔案大)" />
</ComboBox>
<Border Grid.Row="5" Grid.ColumnSpan="2" Background="#F0F4F8" CornerRadius="4"
Padding="10,8" Margin="0,2,0,12">
<TextBlock Text="{Binding Summary}" TextWrapping="Wrap" Margin="0" FontSize="12" />
</Border>
<StackPanel Grid.Row="6" Grid.ColumnSpan="2" Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="取消" Width="80" Margin="0,0,8,0" IsCancel="True" />
<Button Content="匯出…" Width="96" IsDefault="True"
IsEnabled="{Binding CanConfirm}" Click="Confirm_Click" />
</StackPanel>
</Grid>
</Window>
+22
View File
@@ -0,0 +1,22 @@
using System.Windows;
using STPViewer.ViewModels;
namespace STPViewer;
/// <summary>STL 匯出參數對話框 — 選項邏輯在 StlExportViewModel,這裡只做確認/取消</summary>
public partial class StlExportDialog : Window
{
public StlExportViewModel ViewModel { get; }
public StlExportDialog(StlExportViewModel vm)
{
InitializeComponent();
ViewModel = vm;
DataContext = vm;
}
private void Confirm_Click(object sender, RoutedEventArgs e)
{
DialogResult = true;
}
}
@@ -1,16 +1,21 @@
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Media; using System.Windows.Media;
using System.Windows.Media.Imaging; using System.Windows.Media.Imaging;
using System.Windows.Media.Media3D;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.Win32; using Microsoft.Win32;
using STPViewer.Models; using STPViewer.Models;
using STPViewer.Services;
namespace STPViewer.ViewModels; namespace STPViewer.ViewModels;
// ─── 匯出(量測 CSV / 視圖 PNG 截圖)──────────────────────────────── // ─── 匯出(量測 CSV / 視圖 PNG 截圖 / STEP / STL────────────────────
public partial class MainViewModel public partial class MainViewModel
{ {
[RelayCommand] [RelayCommand]
@@ -82,6 +87,134 @@ public partial class MainViewModel
} }
} }
/// <summary>
/// 匯出可見檔案(目前位置)為 STL 網格檔 — 先開參數對話框(範圍/格式/單位/精度 + 三角形數與大小預估)
/// 確認後才選路徑寫檔。精細/較粗 = 由 B-rep 重新三角化(背景執行緒);STL/DXF 來源一律用現有網格。
/// </summary>
[RelayCommand(CanExecute = nameof(IsIdle))]
private async Task ExportStlFile()
{
// 可匯出 = 可見 leaf 且有網格(DXF 純線架構無網格 → 排除)
var groups = Roots.Where(r => r.IsVisible)
.Select(r => (Root: r, Leaves: r.Leaves()
.Where(l => l.IsVisible && GetMergedMesh(l) is not null).ToList()))
.Where(g => g.Leaves.Count > 0)
.ToList();
if (groups.Count == 0)
{
StatusText = "沒有可匯出的網格(請勾選至少一個含實體網格的檔案)";
return;
}
long triangles = groups.SelectMany(g => g.Leaves)
.Sum(l => GetMergedMesh(l)!.TriangleIndices.Count / 3L);
bool hasBrep = groups.SelectMany(g => g.Leaves).Any(l => l.HasBrep && l.SourceGeos.Count > 0);
// 參數對話框(確認再匯出)
var vm = new StlExportViewModel(groups.Count, triangles, hasBrep);
var dialog = new StlExportDialog(vm) { Owner = Application.Current.MainWindow };
if (dialog.ShowDialog() != true || vm.Scale is not double scale) return;
var dlg = new SaveFileDialog
{
Title = vm.PerFile ? "匯出 STL(每個檔案各一個,以此為基底檔名)" : "匯出 STL(合併單一檔)",
Filter = "STL 檔案 (*.stl)|*.stl",
FileName = vm.PerFile
? $"stl_{DateTime.Now:yyyyMMdd_HHmmss}.stl"
: $"assembly_{DateTime.Now:yyyyMMdd_HHmmss}.stl",
};
if (dlg.ShowDialog() != true) return;
// UI 執行緒先快照:GeometryModel3D/Model3DGroup 是 DispatcherObject 不可跨執行緒,
// 背景只能碰 frozen MeshGeometry3D 與 CADability B-repSourceGeos
var snapshot = groups
.Select(g => (g.Root.Name, Leaves: g.Leaves
.Select(l => (Mesh: GetMergedMesh(l)!, l.HasBrep, l.SourceGeos))
.ToList()))
.ToList();
IsBusy = true;
StatusText = vm.Quality == StlMeshQuality.Current
? "匯出 STL 中…"
: "重新三角化 + 匯出 STL 中…(大組件需要時間)";
try
{
double factor = StlExportService.PrecisionFactor(vm.Quality);
var written = await Task.Run(() =>
{
var results = new List<(string File, int Triangles)>();
if (vm.PerFile)
{
string dir = Path.GetDirectoryName(dlg.FileName)!;
string baseName = Path.GetFileNameWithoutExtension(dlg.FileName);
foreach ((string rootName, var leaves) in snapshot)
{
string file = Path.Combine(dir, $"{baseName}_{SafeFileName(rootName)}.stl");
int n = StlExportService.Write(file, CollectMeshes(leaves, vm.Quality, factor), vm.Ascii, scale);
results.Add((file, n));
}
}
else
{
var meshes = CollectMeshes(snapshot.SelectMany(g => g.Leaves).ToList(), vm.Quality, factor);
int n = StlExportService.Write(dlg.FileName, meshes, vm.Ascii, scale);
results.Add((dlg.FileName, n));
}
return results;
});
long total = written.Sum(w => (long)w.Triangles);
StatusText = written.Count == 1
? $"已匯出 STL{total:N0} 三角形)→ {Path.GetFileName(written[0].File)}"
: $"已匯出 {written.Count} 個 STL(共 {total:N0} 三角形)→ {Path.GetDirectoryName(dlg.FileName)}";
}
catch (Exception ex)
{
StatusText = $"STL 匯出失敗:{ex.Message}";
}
finally
{
IsBusy = false;
}
}
/// <summary>leaf 的合併網格(frozen、未剖切、目前位置)— STL 匯出的「目前顯示網格」來源</summary>
private static MeshGeometry3D? GetMergedMesh(ModelNodeViewModel leaf) =>
leaf.MergedContent?.Children.Count > 0
? (leaf.MergedContent.Children[0] as GeometryModel3D)?.Geometry as MeshGeometry3D
: null;
/// <summary>
/// 收集要寫進 STL 的網格(背景執行緒;輸入是 UI 快照):
/// 精細且有 B-rep → 重新三角化(循序,CADability 非執行緒安全);
/// 其餘(目前網格 / STL 來源)→ 快照的合併網格(frozen,可安全跨執行緒讀取)。
/// </summary>
private static List<MeshGeometry3D> CollectMeshes(
IReadOnlyList<(MeshGeometry3D Mesh, bool HasBrep, List<CADability.GeoObject.IGeoObject> SourceGeos)> leaves,
StlMeshQuality quality, double precisionFactor)
{
var meshes = new List<MeshGeometry3D>();
foreach ((MeshGeometry3D mesh, bool hasBrep, var sourceGeos) in leaves)
{
if (quality != StlMeshQuality.Current && hasBrep && sourceGeos.Count > 0)
{
List<MeshGeometry3D> tess = StlExportService.Tessellate(sourceGeos, precisionFactor);
if (tess.Count > 0) { meshes.AddRange(tess); continue; }
// 重新三角化全數失敗 → 退回現有網格,不讓零件憑空消失
}
meshes.Add(mesh);
}
return meshes;
}
private static string SafeFileName(string name)
{
string stem = Path.GetFileNameWithoutExtension(name);
if (string.IsNullOrWhiteSpace(stem)) stem = name;
foreach (char c in Path.GetInvalidFileNameChars()) stem = stem.Replace(c, '_');
return stem;
}
[RelayCommand] [RelayCommand]
private void SaveScreenshot() private void SaveScreenshot()
{ {
@@ -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 = mm1:1);1 = inch(÷25.4);2 = 自訂縮放</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Summary))]
[NotifyPropertyChangedFor(nameof(IsCustomScale))]
[NotifyPropertyChangedFor(nameof(CanConfirm))]
private int unitIndex;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Summary))]
[NotifyPropertyChangedFor(nameof(CanConfirm))]
private string customScaleText = "1.0";
/// <summary>0 = 目前顯示網格;1 = 精細(B-rep 重新三角化)</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Summary))]
private int qualityIndex;
public bool IsCustomScale => UnitIndex == 2;
public bool Ascii => FormatIndex == 1;
public bool PerFile => ScopeIndex == 1;
public StlMeshQuality Quality => (StlMeshQuality)QualityIndex;
/// <summary>座標縮放;自訂值無效時回 null(擋確認)</summary>
public double? Scale => UnitIndex switch
{
0 => 1.0,
1 => 1.0 / 25.4,
_ => double.TryParse(CustomScaleText, NumberStyles.Float, CultureInfo.InvariantCulture, out double s)
&& s > 0 && double.IsFinite(s) ? s : null,
};
public bool CanConfirm => Scale is not null;
public string Summary
{
get
{
long est = StlExportService.EstimateBytes(TriangleCount, Ascii);
string size = StlExportService.FormatBytes(est);
string basis = QualityIndex == 0
? $"預估 {size}"
: $"以目前網格預估 {size}(實際依重新三角化結果)";
return $"{FileCount} 個可見檔案 · {TriangleCount:N0} 三角形 · {basis}";
}
}
}
+3
View File
@@ -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;
+158
View File
@@ -0,0 +1,158 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Windows.Media;
using System.Windows.Media.Media3D;
using STPViewer.Services;
using IOPath = System.IO.Path; // CADability.GeoObject.Path(曲線)撞名
namespace SmokeTest;
/// <summary>
/// STL 匯出驗證(無 UI):
/// 1) binary — 已知立方體網格寫出 → 解析 header/三角形數/座標範圍比對(含縮放)
/// 2) ASCII — facet 數與 solid/endsolid 結構
/// 3) 退化三角形(零面積)自動濾除
/// </summary>
public static class StlExportTest
{
public static int Run(string? stepPath = null)
{
Console.WriteLine("=== StlExportTest ===");
int failed = 0;
string dir = IOPath.Combine(IOPath.GetTempPath(), "stpviewer_stl_test");
Directory.CreateDirectory(dir);
MeshGeometry3D cube = BuildCube(10); // 10×10×10 mm、12 三角形
// 1) binary:三角形數 + 縮放後座標範圍
{
string f = IOPath.Combine(dir, "cube_bin.stl");
double scale = 1.0 / 25.4; // mm → inch
int written = StlExportService.Write(f, new[] { cube }, ascii: false, scale);
(uint count, float min, float max) = ReadBinary(f);
bool ok = written == 12 && count == 12
&& Math.Abs(min - 0f) < 1e-5 && Math.Abs(max - 10f / 25.4f) < 1e-4;
Report(ref failed, ok, $"binary 寫出/回讀 12 三角形、inch 縮放範圍 [{min:F4}, {max:F4}]");
}
// 2) ASCIIfacet 數 + 結構
{
string f = IOPath.Combine(dir, "cube_ascii.stl");
int written = StlExportService.Write(f, new[] { cube }, ascii: true, scale: 1.0);
string text = File.ReadAllText(f);
int facets = CountOccurrences(text, "facet normal");
bool ok = written == 12 && facets == 12
&& text.StartsWith("solid ", StringComparison.Ordinal)
&& text.Contains("endsolid");
Report(ref failed, ok, $"ASCII 結構完整、facet = {facets}");
}
// 3) 退化三角形濾除:加一個零面積三角形 → 仍是 12
{
var degenerate = new MeshGeometry3D();
degenerate.Positions.Add(new Point3D(0, 0, 0));
degenerate.Positions.Add(new Point3D(1, 1, 1));
degenerate.Positions.Add(new Point3D(2, 2, 2)); // 共線 → 面積 0
degenerate.TriangleIndices.Add(0);
degenerate.TriangleIndices.Add(1);
degenerate.TriangleIndices.Add(2);
degenerate.Freeze();
string f = IOPath.Combine(dir, "cube_degen.stl");
int written = StlExportService.Write(f, new[] { cube, degenerate }, ascii: false, scale: 1.0);
(uint count, _, _) = ReadBinary(f);
bool ok = written == 12 && count == 12;
Report(ref failed, ok, $"退化三角形濾除(寫出 {written}");
}
// 4)(可選)真實 STEP:B-rep 精細重新三角化 → 寫 binary STL 驗證
//(不測「較粗」:CADability GetTriangulation 對比快取粗的精度回傳既有快取,粗化無效 → 功能已移除)
if (stepPath is not null)
{
var data = new StepImportService().Import(stepPath);
var geos = Collect(data.Root).ToList();
int importTris = data.Root.TriangleCount;
Console.WriteLine($" 來源 {IOPath.GetFileName(stepPath)}{geos.Count} 個 B-rep 物件、匯入網格 {importTris:N0} 三角形");
var meshes = StlExportService.Tessellate(
geos, StlExportService.PrecisionFactor(StlMeshQuality.Fine));
string f = IOPath.Combine(dir, "step_fine.stl");
int written = StlExportService.Write(f, meshes, ascii: false, scale: 1.0);
(uint count, _, _) = ReadBinary(f);
Report(ref failed, meshes.Count > 0 && written > 0 && count == written,
$"精細重新三角化 {meshes.Count} 面 → {written:N0} 三角形");
// CADability 三角形數對精度非單調(0.4× 反而比 1× 少),Fine=0.15× 實測明顯更細
Report(ref failed, written > importTris,
$"精細({written:N0}> 匯入預設({importTris:N0}");
}
Console.WriteLine(failed == 0 ? "StlExportTest: PASS" : $"StlExportTest: FAIL{failed} 項)");
return failed;
}
private static IEnumerable<CADability.GeoObject.IGeoObject> Collect(ImportedNode n)
{
foreach (CADability.GeoObject.IGeoObject g in n.SourceGeos) yield return g;
foreach (ImportedNode c in n.Children)
foreach (CADability.GeoObject.IGeoObject g in Collect(c))
yield return g;
}
private static void Report(ref int failed, bool ok, string what)
{
Console.WriteLine($" [{(ok ? "PASS" : "FAIL")}] {what}");
if (!ok) failed++;
}
/// <summary>0..size 的軸對齊立方體,12 個三角形(法向不講究,STL 寫出時自算)</summary>
private static MeshGeometry3D BuildCube(double s)
{
var m = new MeshGeometry3D();
Point3D[] v =
{
new(0, 0, 0), new(s, 0, 0), new(s, s, 0), new(0, s, 0),
new(0, 0, s), new(s, 0, s), new(s, s, s), new(0, s, s),
};
foreach (Point3D p in v) m.Positions.Add(p);
int[] quads = { 0,3,2,1, 4,5,6,7, 0,1,5,4, 1,2,6,5, 2,3,7,6, 3,0,4,7 };
for (int q = 0; q < quads.Length; q += 4)
{
m.TriangleIndices.Add(quads[q]); m.TriangleIndices.Add(quads[q + 1]); m.TriangleIndices.Add(quads[q + 2]);
m.TriangleIndices.Add(quads[q]); m.TriangleIndices.Add(quads[q + 2]); m.TriangleIndices.Add(quads[q + 3]);
}
m.Freeze();
return m;
}
private static (uint Count, float Min, float Max) ReadBinary(string path)
{
using var br = new BinaryReader(File.OpenRead(path));
byte[] header = br.ReadBytes(80);
if (Encoding.ASCII.GetString(header, 0, 5) == "solid")
throw new InvalidDataException("binary STL header 不可以 solid 開頭");
uint count = br.ReadUInt32();
float min = float.MaxValue, max = float.MinValue;
for (uint t = 0; t < count; t++)
{
br.ReadBytes(12); // normal
for (int k = 0; k < 9; k++)
{
float f = br.ReadSingle();
min = Math.Min(min, f);
max = Math.Max(max, f);
}
br.ReadUInt16(); // attribute
}
return (count, min, max);
}
private static int CountOccurrences(string text, string token)
{
int n = 0;
for (int i = text.IndexOf(token, StringComparison.Ordinal); i >= 0;
i = text.IndexOf(token, i + token.Length, StringComparison.Ordinal)) n++;
return n;
}
}