From f5ccc13e9ccf0b1f1c71e7b7b7a84071cd81b68b Mon Sep 17 00:00:00 2001 From: ETWen Date: Sat, 4 Jul 2026 08:44:34 +0800 Subject: [PATCH] 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 --- ARCHITECTURE.md | 36 ++- CLAUDE.md | 11 +- src/STPViewer/MainWindow.xaml | 2 + src/STPViewer/STPViewer.csproj | 2 +- src/STPViewer/Services/StlExportService.cs | 209 ++++++++++++++++++ src/STPViewer/StlExportDialog.xaml | 86 +++++++ src/STPViewer/StlExportDialog.xaml.cs | 22 ++ .../ViewModels/MainViewModel.Export.cs | 135 ++++++++++- src/STPViewer/ViewModels/MainViewModel.cs | 1 + .../ViewModels/StlExportViewModel.cs | 85 +++++++ tools/SmokeTest/Program.cs | 3 + tools/SmokeTest/StlExportTest.cs | 158 +++++++++++++ 12 files changed, 742 insertions(+), 8 deletions(-) create mode 100644 src/STPViewer/Services/StlExportService.cs create mode 100644 src/STPViewer/StlExportDialog.xaml create mode 100644 src/STPViewer/StlExportDialog.xaml.cs create mode 100644 src/STPViewer/ViewModels/StlExportViewModel.cs create mode 100644 tools/SmokeTest/StlExportTest.cs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 310c5cd..53f438d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -19,7 +19,8 @@ STEP(`.stp` / `.step`)檔案做確認與量測,不需要安裝 SolidWorks 快速鍵 P/D/E/F/C/A/M + Esc;單位 mm ⇄ inch 即時切換 - **剖面**:X/Y/Z 軸向 + **3點任意平面**,位置滑桿/數值輸入 + 反向,CPU 網格裁切(量測不受影響) - **視圖**:標準視圖(等角/前/上/右)+ 正交⇄透視投影 -- **匯出**:量測結果 CSV、3D 視圖 PNG 截圖(2x)、**目前對齊位置寫成新 STEP 檔** +- **匯出**:量測結果 CSV、3D 視圖 PNG 截圖(2x)、**目前對齊位置寫成新 STEP 檔**、 + **STL 網格檔**(參數對話框:合併/每檔、binary/ASCII、mm/inch/自訂縮放、精度,確認再匯出) - 單機離線執行,無網路相依;視窗/單位/最近檔案自動保存(settings.json) 使用者:單一桌面使用者(無登入 / 角色系統)。 @@ -106,13 +107,16 @@ STPViewer/ │ ├── MeasurementResult.cs # 量測結果(雙單位 lambda、3D 標籤同步) │ └── UnitSystem.cs # mm/inch + Units 格式化 │ + ├── StlExportDialog.xaml / .cs # STL 匯出參數對話框(範圍/格式/單位/精度 + 預估,確認再匯出) + │ ├── Services/ │ ├── StepImportService.cs # STEP/STL/DXF 讀檔 + 三角化 + 裝配樹 │ ├── MeasurementService.cs # 點/線/面/圓/角度/面距/體積質心 幾何計算(吸附含圓心) │ ├── InterferenceService.cs # 干涉檢查:三角形-三角形相交(區間法)+均勻網格加速;無干涉時近似最小間隙 │ ├── RigidAlign.cs # 三點對齊/旋轉的剛體變換數學(Matrix3D列向量 ↔ ModOp行向量 轉換) │ ├── 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,依職責分檔 ├── MainViewModel.cs # 核心:匯入、裝配樹、量測、剛體變換、邊線、單位 @@ -120,7 +124,8 @@ STPViewer/ ├── MainViewModel.Gizmo.cs # 三軸操作器 + 疊圖層相機 ├── MainViewModel.Section.cs # 剖面(v0.4.0 起背景平行裁切) ├── MainViewModel.Interference.cs # 干涉檢查指令 - ├── MainViewModel.Export.cs # CSV / 截圖匯出 + ├── MainViewModel.Export.cs # CSV / 截圖 / STEP / STL 匯出 + ├── StlExportViewModel.cs # STL 匯出對話框 VM(選項 + 即時摘要) └── ModelNodeViewModel.cs # 裝配樹節點(可見性/顏色 cascade) ``` @@ -190,7 +195,7 @@ class MeasurementResult | 干涉 | 工具列「🧩 干涉」(≥2 個可見檔案,兩兩配對檢查) | 每組配對:相交→紅色交線+相交三角形對數;無相交→最小間隙 gap(≈0 即配合 match) | | 剖面 | 工具列「✂ 剖面」+ 軸向(X/Y/Z/**3點任意平面**)/位置(滑桿+數值輸入)/反向 | CPU 裁切渲染網格(原始幾何保留,量測仍精確);3點=在模型上點 3 點定義平面 | | 單位 | 工具列 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、工具列 等角/前/上/右、正交⇄透視 | | | 快速鍵 | P 點 / D 距離 / E 邊 / F 面 / C 圓 / A 角度 / M 面距;同鍵再按=退出;Esc=取消進行中量測→退出模式 | | | 樹操作 | 搜尋框過濾節點名稱;右鍵:只顯示此節點 / 反轉顯示 / 全部顯示 / 量體積質心 | 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(下一輪) - **大檔重開快取** — 匯入成功後把三角網格 + 裝配樹序列化成 sidecar 快取檔(以來源檔 hash 驗證), diff --git a/CLAUDE.md b/CLAUDE.md index 6541780..b7b93be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ CAD 3D 檢視器(Windows 桌面 WPF, .NET 8):STEP/STL/DXF 匯入、STEP 裝配樹(搜尋/隔離顯示)、 點/距離/邊/面/圓/角度/面距/體積質心量測(吸附含圓心)、兩點對齊(平移)、三點對齊(旋轉+平移)、 軸向旋轉 90°、拖曳模式、干涉檢查(≥2 檔兩兩配對)、剖面(X/Y/Z + 3點任意平面)、 -標準視圖+正交投影、量測快速鍵+Esc、mm⇄inch、CSV/截圖/STEP 匯出、設定與 MRU 保存。 +標準視圖+正交投影、量測快速鍵+Esc、mm⇄inch、CSV/截圖/STEP/STL 匯出(STL 有參數對話框)、設定與 MRU 保存。 詳細設計見 [ARCHITECTURE.md](ARCHITECTURE.md)。 ## 技術棧 @@ -51,6 +51,12 @@ dotnet publish src/STPViewer -c Release -o publish/STPViewer 單位/MRU(VM `LoadSettings`/`SaveSettingsInto`);壞檔回預設、儲存失敗靜默 - 匯出 STEP 走 `CADability.ExportStep.WriteToFile(file, Project.CreateSimpleProject()+Model.Add)`; 只收 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` 在 OnViewportClick 最前面攔點擊);換軸/關剖面/Esc 會重置。平面位置一律用「AABB 8 角投影到法向」內插, 軸向與任意法向共用同一段程式,不要改回逐軸特化 @@ -111,7 +117,8 @@ dotnet publish src/STPViewer -c Release -o publish/STPViewer - 干涉檢查需剛好 2 個可見檔案(樹面板勾選);共面貼合(無穿透)不算干涉、gap≈0 視為配合(match) - SmokeTest 工具:`--tree`(裝配樹)、`--clip-test`(剖切數學)、`--interference-test`(干涉相交/分離/貼合)、 `--align-test`(三點對齊剛體變換 + ModOp↔Matrix3D 一致性)、`--make-dxf`(產測試檔)、 - `--export-test `(STEP 匯出往返:寫出→回讀比對實體數) + `--export-test `(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 工具 ## For_AI/ diff --git a/src/STPViewer/MainWindow.xaml b/src/STPViewer/MainWindow.xaml index aa8172e..adfc8db 100644 --- a/src/STPViewer/MainWindow.xaml +++ b/src/STPViewer/MainWindow.xaml @@ -94,6 +94,8 @@ ToolTip="3D 視圖截圖存 PNG(2x 解析度)">📷 截圖 + enable enable true - 0.5.0 + 0.6.0 diff --git a/src/STPViewer/Services/StlExportService.cs b/src/STPViewer/Services/StlExportService.cs new file mode 100644 index 0000000..d01f809 --- /dev/null +++ b/src/STPViewer/Services/StlExportService.cs @@ -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; + +/// +/// 網格精度:Current = 目前顯示網格;Fine = 由 B-rep 以較細精度重新三角化(無 B-rep 的來源維持現有網格)。 +/// 不提供「較粗」:CADability 的 GetTriangulation 對「比快取粗」的精度要求會直接回傳既有較細快取, +/// 匯入時已三角化過 → 要求粗化實際上拿到同一份網格(實測驗證),故誠實不提供。 +/// +public enum StlMeshQuality { Current, Fine } + +/// +/// STL 匯出:WPF 三角網格 → binary / ASCII STL。 +/// STL 本身無單位,約定輸出 mm(scale=1);inch 交接用 scale = 1/25.4。 +/// +public static class StlExportService +{ + /// + /// 三角化精度係數(相對匯入預設):Fine 較細(三角形多、曲面平滑)。 + /// 實測 CADability 三角形數對精度非單調(0.4× 重算反而比 1× 少 — 重算網格較有效率), + /// 要明顯更細需 ≤0.15×(test.stp:1× = 30,784 → 0.1× = 42,220 三角形)。 + /// + public static double PrecisionFactor(StlMeshQuality q) => + q == StlMeshQuality.Fine ? 0.15 : 1.0; + + /// 預估檔案大小:binary = 84 + 50×n;ASCII 每三角形約 230 bytes + 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", + }; + + /// + /// 把多個網格寫成一個 STL 檔。退化(零面積)三角形濾除。 + /// 回傳實際寫出的三角形數。 + /// + public static int Write(string path, IReadOnlyList meshes, bool ascii, double scale) => + ascii ? WriteAscii(path, meshes, scale) : WriteBinary(path, meshes, scale); + + private static int WriteBinary(string path, IReadOnlyList 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 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)); + + /// false = 退化三角形(面積 ≈ 0) + 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 重新三角化(精細/較粗匯出用)──────────────────────── + + /// + /// 由 B-rep 幾何以指定精度係數重新三角化,回傳每面一個 frozen mesh。 + /// CADability 非執行緒安全 → 呼叫端維持循序(可在單一背景執行緒)。 + /// 個別面三角化失敗時跳過(與匯入一致的容錯策略)。 + /// + public static List Tessellate(IEnumerable geos, double precisionFactor) + { + var meshes = new List(); + 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 meshes) + { + double precision = PrecisionFor(SafeBounds(shell), factor); + foreach (Face f in shell.Faces) AddFaceMesh(f, precision, meshes); + } + + /// + /// 匯入預設精度(與 StepImportService.PrecisionFor 同款 clamp)再乘 factor — + /// 先 clamp 再乘,保證 factor<1 時嚴格比匯入快取細(否則大殼在匯入被 0.5 上限截住, + /// 直接乘在 clamp 前會算出比快取粗的值 → CADability 回傳快取、精細無效)。 + /// + 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 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); + } +} diff --git a/src/STPViewer/StlExportDialog.xaml b/src/STPViewer/StlExportDialog.xaml new file mode 100644 index 0000000..5daaf18 --- /dev/null +++ b/src/STPViewer/StlExportDialog.xaml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +