From 060ca4ecd0275289d85deb5dac6b2e61f9eef9fe Mon Sep 17 00:00:00 2001 From: ETWen Date: Tue, 14 Jul 2026 14:20:36 +0800 Subject: [PATCH] feat(perf): direct STL parsing for near-instant large-file load (v0.7.1) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XgHf5MW39U3Xo8y2uA229K --- CLAUDE.md | 276 ++++++++++---------- src/STPViewer/AboutDialog.xaml.cs | 6 + src/STPViewer/STPViewer.csproj | 36 +-- src/STPViewer/Services/StepImportService.cs | 48 ++-- src/STPViewer/Services/StlMeshReader.cs | 130 +++++++++ tools/SmokeTest/MakeStl.cs | 41 +++ tools/SmokeTest/Program.cs | 166 ++++++------ 7 files changed, 445 insertions(+), 258 deletions(-) create mode 100644 src/STPViewer/Services/StlMeshReader.cs create mode 100644 tools/SmokeTest/MakeStl.cs diff --git a/CLAUDE.md b/CLAUDE.md index c6df7e9..0ab651a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,134 +1,142 @@ -# STPViewer — Claude 專案記憶 - -## 專案簡介 - -CAD 3D 檢視器(Windows 桌面 WPF, .NET 8):STEP/STL/DXF 匯入、STEP 裝配樹(搜尋/隔離顯示)、 -點/距離/邊/面/圓/角度/面距/體積質心量測(吸附含圓心)、兩點對齊(平移)、三點對齊(旋轉+平移)、 -軸向旋轉 90°、拖曳模式、干涉檢查(≥2 檔兩兩配對)、剖面(X/Y/Z + 3點任意平面)、 -標準視圖+正交投影、量測快速鍵+Esc、mm⇄inch、CSV/截圖/STEP/STL 匯出(STL 有參數對話框)、設定與 MRU 保存。 -詳細設計見 [ARCHITECTURE.md](ARCHITECTURE.md)。 - -## 技術棧 - -- .NET 8 WPF(`net8.0-windows`)、MVVM(CommunityToolkit.Mvvm) -- **CADability**(純 C# CAD kernel):STEP 匯入、B-rep 幾何、Face 三角化 -- **HelixToolkit.Wpf**:3D viewport、相機、HitTest - -## 常用指令 - -```bash -dotnet build STPViewer.sln -dotnet run --project src/STPViewer -dotnet publish src/STPViewer -c Release -o publish/STPViewer -``` - -測試模型放 `For_AI/`(gitignored,**客戶料號不入 git**):`For_AI/test.stp`(Amphenol RA PHD 座端,小檔)與 -`For_AI/Amphenol PHD to PHD Cable 10201248 1.stp`(39MB 大組件,效能測試用)。`*.stp`/`*.step` 已全域 gitignore - -## 開發慣例 - -- MVVM:邏輯寫 ViewModel/Service,code-behind 只做純 View 事件(滑鼠拾取轉發) -- `MainViewModel` 是 **partial class 分檔**(v0.4.0):主檔(匯入/樹/量測/變換/邊線)+ - `.Drag` / `.Gizmo` / `.Section` / `.Interference` / `.Export`。加新功能先看職責放哪個檔,勿全塞回主檔 -- 全域例外處理在 `App.xaml.cs`:UI 執行緒未攔截例外 → 記 log(`%LOCALAPPDATA%\STPViewer\error.log`)+ - 訊息框 + `Handled=true` 存活;背景 Task 例外記 log。個別功能仍應自行 try/catch 給出有意義的狀態列訊息 -- 一個 B-rep Face = 一個 `GeometryModel3D`,用 `Dictionary` (`_faceMap`) 反查(剖面模式逐面拾取靠這個,**不可移除逐面結構**) -- 渲染雙模式(降 draw call):每個 leaf 同時持有 `FacesContent`(逐面,剖面用)與 `MergedContent`(整零件合併成 1 個 `GeometryModel3D`)。 - `ApplyRenderMode()` 依狀態切 `BodyVisual.Content`:**非剖面(瀏覽+量測皆是)→ 合併網格**;**剖面 → 逐面**(要顯示各面裁切後幾何)。 - 合併網格只代表「未剖切、目前位置」幾何;平移後呼叫 `RebuildMerged(leaf)` 同步。剖面/干涉/`RebuildMerged` 一律走 `FacesContent`,與目前顯示哪種內容無關 -- ⚡ **量測拾取打合併網格、用三角形頂點 index 反查面**(`_mergedFaceRanges`:合併 Model → (每面頂點起始邊界, FaceInfo[]))。 - `BuildMergedMesh` 依面序串接(`baseIdx += positions.Count`,無焊接共用頂點),故命中 `RayHit.VertexIndex1` 可二分搜尋(`ResolveMergedFace`)回是哪個面。 - **這是 v0.3.2 修大檔量測卡頓的關鍵**:量測模式不再掛數萬個逐面 `GeometryModel3D`(64k 面實測 = 64k draw call,轉動/停下重繪爆量)→ 改成每檔 1 個 model,量測模式 orbit 與瀏覽同樣順。 - 邊界由面頂點數決定、平移不改 → 永久有效,`RebuildMerged` 不需重算。**不要因為「量測要逐面」而把渲染改回逐面** -- 合併網格的 `BackMaterial`:封閉實體(`SolidCount≥1` 且 `HasBrep`)**不設**(WPF 兩面渲染成本砍半);開放殼/STL 才設。 - **拾取不受材質影響**(WPF 3D hit-test 純幾何、不剔背面),故打合併網格仍命中孔內壁等背向面。逐面 `FacesContent` 一律保留 BackMaterial(剖切要看內部) -- 量測值以 B-rep 為準(圓半徑、邊長、角度),面積/面距/體積/質心用網格近似 -- `Snap` 吸附順序(v0.5.0):頂點與「圓形邊」都在容差內比誰離命中點近,圓邊勝出時回傳**圓心** - (量孔對孔 pitch 靠這個)。不要把圓心吸附拿掉或改成永遠優先頂點 -- 鍵盤快速鍵在 `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 管)+ - 單位/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 角投影到法向」內插, - 軸向與任意法向共用同一段程式,不要改回逐軸特化 -- 量測文字一律 `Func` 延後產生(mm⇄inch 即時切換);內部數值永遠存 mm -- 裝配樹節點(`ModelNodeViewModel`)的可見性/邊線/顏色向下 cascade -- 剖面只換 `GeometryModel3D.Geometry`(`FaceInfo.Mesh` 保留原始 frozen mesh 供還原與量測) -- ⚡ 剖面裁切是**背景平行**(v0.4.0):`ApplySection` 在 UI 快照 (model, frozen mesh) → `Task.Run`+`Parallel.For` - 裁切 → 回 UI 一次換上;`_sectionApplying`/`_sectionReapply` guard 保證裁切中的新變更(滑桿/換軸/`TransformRoot`) - 完成後用最新參數重跑。**不要改回同步呼叫 ClipMesh**(64k 面整場景裁切會凍結 UI 數秒); - await 之後一定要檢查 `SectionEnabled`(期間可能被關掉,還原分支已處理) -- 剛體變換(兩點對齊/旋轉 90°/三點對齊)統一走 `TransformRoot(root, ModOp, Matrix3D)`:B-rep 用 ModOp 對 Solid/Shell 整體 `Modify` - (勿逐面位移,會重複位移共用邊),網格/合併網格/邊線/邊界同步重算;變換後量測已失效要 `ClearMeasurements()`。 - **op 與 m 必須是同一個變換** — 數學在 `Services/RigidAlign.cs`:WPF `Matrix3D` 是「列向量」約定、CADability `ModOp` 是「行向量」約定, - `ToModOp` 負責轉置轉換,改動務必跑 `SmokeTest --align-test` 驗證兩種表示一致,否則 B-rep 與顯示網格會悄悄分家。 - `TransformRoot` 的網格變換是**面級平行**(v0.4.0;來源/輸出 mesh 皆 frozen 所以安全); - B-rep `Modify` 與視覺樹賦值必須維持循序(CADability 非執行緒安全 / WPF 執行緒親和) -- IsBusy 期間匯入/旋轉 90°/干涉檢查指令停用(`CanExecute = nameof(IsIdle)` + isBusy 的 - `NotifyCanExecuteChangedFor`);`ImportFilesAsync` 整批維持 busy 且開頭擋重入 - (拖放/命令列會繞過 CanExecute)。新增「會改動幾何的指令」時記得比照辦理 -- 拖曳模式(`MeasureMode.Drag`):拖曳中只掛**暫時 `TranslateTransform3D`**(GPU 免費),放開才一次性 `TranslateRoot` 烘進 B-rep — - **不要改成拖曳中逐幀 TransformRoot**(大檔每幀重建網格會卡死)。2D→3D 用 Helix `UnProject`(過錨點、法向=相機 LookDirection 的平面)。 - 合併網格的 hit-test 走 `_mergedMap`(合併 Model → leaf);拖曳中邊線用 `_edgesSuspended` 暫停 -- ⚠️ **`Visual3D.Transform` 永遠不要設成 `null`,清除要用 `Transform3D.Identity`**。HelixToolkit `Viewport3DHelper.GetTransform` - 對 `child.Transform` 沒做 null 檢查(`GeneralTransform3DGroup.Children.Add(null)` → 拋「無法新增空值到集合中」), - 之後任何 `FindHits` 都會 crash。v0.2.1 修的就是拖曳放開時把 BodyVisual.Transform 設 null(拖過一次後再點擊就炸)。 - Gizmo 的暫時 Transform 清除同理一律用 Identity -- Gizmo 操作器:Helix `TranslateManipulator`/`RotateManipulator` ×6 `Bind` 到代理 `ModelVisual3D`, - 代理 Transform 變更(`DependencyPropertyDescriptor.AddValueChanged`)即時套到目標 BodyVisual(暫時); - **放開滑鼠才 `TransformRoot` 烘焙** — MouseUp 被 manipulator 標 handled,MainWindow 用 `AddHandler(..., handledEventsToo: true)` 才收得到。 - 烘焙用 `Dispatcher.BeginInvoke` 延後到 manipulator 自身事件處理完,避免 reentrancy;`_gizmoBaking` 旗標防 Transform 歸零的回呼重入 -- Gizmo always-on-top(v0.3.1):操作器放在獨立透明 `Viewport3D`(`gizmoOverlay`)疊在主視窗上,**不在主場景所以永不被實體遮擋**。 - `_overlayCamera` 在主 `Camera.Changed` 時同步主相機(Position/方向/FOV/near-far);raw Viewport3D 空白處不吃滑鼠 → 穿透回主視窗(orbit/量測正常); - `IsHitTestVisible` 綁 `GizmoEnabled`。Manipulator 是 `UIElement3D`、用 `GetViewport3D()` 抓所在層相機,故在疊圖層用同步相機運作。 - 放開事件靠 overlay 的 `AddHandler(MouseLeftButtonUp, handledEventsToo:true)`(manipulator 會標 handled),`_gizmoBakePending` 防同次重複烘焙 -- 干涉/面距/對齊等運算在背景執行緒;`Freeze()` 幾何後才跨執行緒 -- 匯入在背景執行緒;`Freeze()` 幾何後才跨執行緒 -- 發新版:升 csproj `` 之外,記得在 `AboutDialog.xaml.cs` 的 `Changelog` 陣列**頂端**加一筆 - (口吻寫給一般使用者:「你會感覺到什麼」,不是技術 changelog)。 - ⚠️ WPF **頂層 MenuItem 直接掛 Click 不會觸發**(實測滑鼠/鍵盤都沒反應),要開視窗的入口一律放子選單(如 說明 → 關於) -- Commit 格式:Conventional Commits(`feat:` / `fix:` / `docs:` …) - -## 注意事項 / 已知限制 - -- `Path` 在 service 會與 `CADability.GeoObject.Path` 撞名 → 用 `IOPath` alias -- CADability 解析大 STEP 慢(39MB/64k 面實測:解析約 276 秒 + 幾何處理),不要改成同步呼叫。 - 解析(`ImportStep.Read`)單執行緒無解;三角化/邊取樣已按 **leaf 平行化**(`_leafWork` 收集 → `Parallel.ForEach`)。 - **平行粒度只能到 leaf**:同 leaf 的面共用 Edge 物件,面級平行會 race。空 leaf 由 `Prune` 收掉(延後三角化可能全失敗)。 - 平行下 `GetTriangulation` 偶發失敗(實測 64k 面丟 ~8 面,跨 leaf 仍有共享狀態)→ 失敗面收進 `_retry`,平行結束後**循序重試**補回, - 該 leaf 的 `FinishLeaf` 也延到重試後才跑。**不要移除重試機制**,也不要把平行度開到面級 -- `StepImportService.Progress` 回報匯入階段(解析/三角化耗時),UI 已接狀態列;訊息來自背景執行緒,要 `Dispatcher.BeginInvoke` -- `LinesVisual3D` 轉動視角逐幀重建,>30k 線段會卡 → 邊線自動關閉邏輯不要移除 -- 邊線採「**一檔一條合併 `LinesVisual3D`**」(掛在 root `EdgeVisual`,由 `RefreshRootEdges` 收集各 leaf `OriginalEdgePoints` 重建)。 - **不要改回逐 leaf 一條** — 裝配樹零件多時,N 條線每幀重建會嚴重卡頓(實測主因)。leaf 只保留邊線「資料」,渲染統一在 root; - 可見性/ShowEdges/剖面/平移變更時呼叫 `RefreshRootEdges(root)` 重組合併線 -- 互動中暫停邊線:`Attach` 掛 **`HelixViewport3D.CameraChanged`(控制項層級 routed event)+ `Camera.Changed`(保底)** → `OnCameraMoved` 隱藏邊線、`_interactionTimer`(180ms) 停下後 `ResumeEdges` 顯示; - `_edgesSuspended` 為真時 `RefreshRootEdges` 不把線掛回。轉動/縮放/平移時不付邊線重建成本。 - **只訂 `Camera.Changed` 不夠**:`Attach` 在建構式呼叫,相機實例若被 Helix 換掉訂閱會孤兒化 → 暫停永不觸發;故加訂控制項層級事件保底(重複觸發 `OnCameraMoved` 無害,有 guard) -- CADability `ImportStep` 對少數 AP242 檔案支援不完整;匯入失敗要 catch 顯示訊息,不可閃退 -- IGES 無 reader;STL 無 B-rep(FaceInfo.BrepFace == null 的分支要保留) -- 不寫入原始檔(唯讀工具);WPF 限 Windows,不要嘗試移植 vbox/Linux -- 干涉檢查需剛好 2 個可見檔案(樹面板勾選);共面貼合(無穿透)不算干涉、gap≈0 視為配合(match) -- SmokeTest 工具:`--tree`(裝配樹)、`--clip-test`(剖切數學)、`--interference-test`(干涉相交/分離/貼合)、 - `--align-test`(三點對齊剛體變換 + ModOp↔Matrix3D 一致性)、`--make-dxf`(產測試檔)、 - `--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/ - -- `For_AI/`:AI 協作素材(截圖、草稿)+ 測試模型 `*.stp`(客戶料號),整夾 gitignored。 -- 本專案無 runtime secret(離線桌面工具,無 DB/ApiKey),故未保留 `secret/` 資料夾。 +# STPViewer — Claude 專案記憶 + +## 專案簡介 + +CAD 3D 檢視器(Windows 桌面 WPF, .NET 8):STEP/STL/DXF 匯入、STEP 裝配樹(搜尋/隔離顯示)、 +點/距離/邊/面/圓/角度/面距/體積質心量測(吸附含圓心)、兩點對齊(平移)、三點對齊(旋轉+平移)、 +軸向旋轉 90°、拖曳模式、干涉檢查(≥2 檔兩兩配對)、剖面(X/Y/Z + 3點任意平面)、 +標準視圖+正交投影、量測快速鍵+Esc、mm⇄inch、CSV/截圖/STEP/STL 匯出(STL 有參數對話框)、設定與 MRU 保存。 +詳細設計見 [ARCHITECTURE.md](ARCHITECTURE.md)。 + +## 技術棧 + +- .NET 8 WPF(`net8.0-windows`)、MVVM(CommunityToolkit.Mvvm) +- **CADability**(純 C# CAD kernel):STEP 匯入、B-rep 幾何、Face 三角化 +- **HelixToolkit.Wpf**:3D viewport、相機、HitTest + +## 常用指令 + +```bash +dotnet build STPViewer.sln +dotnet run --project src/STPViewer +dotnet publish src/STPViewer -c Release -o publish/STPViewer +``` + +測試模型放 `For_AI/`(gitignored,**客戶料號不入 git**):`For_AI/test.stp`(Amphenol RA PHD 座端,小檔)與 +`For_AI/Amphenol PHD to PHD Cable 10201248 1.stp`(39MB 大組件,效能測試用)。`*.stp`/`*.step` 已全域 gitignore + +## 開發慣例 + +- MVVM:邏輯寫 ViewModel/Service,code-behind 只做純 View 事件(滑鼠拾取轉發) +- `MainViewModel` 是 **partial class 分檔**(v0.4.0):主檔(匯入/樹/量測/變換/邊線)+ + `.Drag` / `.Gizmo` / `.Section` / `.Interference` / `.Export`。加新功能先看職責放哪個檔,勿全塞回主檔 +- 全域例外處理在 `App.xaml.cs`:UI 執行緒未攔截例外 → 記 log(`%LOCALAPPDATA%\STPViewer\error.log`)+ + 訊息框 + `Handled=true` 存活;背景 Task 例外記 log。個別功能仍應自行 try/catch 給出有意義的狀態列訊息 +- 一個 B-rep Face = 一個 `GeometryModel3D`,用 `Dictionary` (`_faceMap`) 反查(剖面模式逐面拾取靠這個,**不可移除逐面結構**) +- 渲染雙模式(降 draw call):每個 leaf 同時持有 `FacesContent`(逐面,剖面用)與 `MergedContent`(整零件合併成 1 個 `GeometryModel3D`)。 + `ApplyRenderMode()` 依狀態切 `BodyVisual.Content`:**非剖面(瀏覽+量測皆是)→ 合併網格**;**剖面 → 逐面**(要顯示各面裁切後幾何)。 + 合併網格只代表「未剖切、目前位置」幾何;平移後呼叫 `RebuildMerged(leaf)` 同步。剖面/干涉/`RebuildMerged` 一律走 `FacesContent`,與目前顯示哪種內容無關 +- ⚡ **量測拾取打合併網格、用三角形頂點 index 反查面**(`_mergedFaceRanges`:合併 Model → (每面頂點起始邊界, FaceInfo[]))。 + `BuildMergedMesh` 依面序串接(`baseIdx += positions.Count`,無焊接共用頂點),故命中 `RayHit.VertexIndex1` 可二分搜尋(`ResolveMergedFace`)回是哪個面。 + **這是 v0.3.2 修大檔量測卡頓的關鍵**:量測模式不再掛數萬個逐面 `GeometryModel3D`(64k 面實測 = 64k draw call,轉動/停下重繪爆量)→ 改成每檔 1 個 model,量測模式 orbit 與瀏覽同樣順。 + 邊界由面頂點數決定、平移不改 → 永久有效,`RebuildMerged` 不需重算。**不要因為「量測要逐面」而把渲染改回逐面** +- 合併網格的 `BackMaterial`:封閉實體(`SolidCount≥1` 且 `HasBrep`)**不設**(WPF 兩面渲染成本砍半);開放殼/STL 才設。 + **拾取不受材質影響**(WPF 3D hit-test 純幾何、不剔背面),故打合併網格仍命中孔內壁等背向面。逐面 `FacesContent` 一律保留 BackMaterial(剖切要看內部) +- 量測值以 B-rep 為準(圓半徑、邊長、角度),面積/面距/體積/質心用網格近似 +- `Snap` 吸附順序(v0.5.0):頂點與「圓形邊」都在容差內比誰離命中點近,圓邊勝出時回傳**圓心** + (量孔對孔 pitch 靠這個)。不要把圓心吸附拿掉或改成永遠優先頂點 +- 鍵盤快速鍵在 `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 管)+ + 單位/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 角投影到法向」內插, + 軸向與任意法向共用同一段程式,不要改回逐軸特化 +- 量測文字一律 `Func` 延後產生(mm⇄inch 即時切換);內部數值永遠存 mm +- 裝配樹節點(`ModelNodeViewModel`)的可見性/邊線/顏色向下 cascade +- 剖面只換 `GeometryModel3D.Geometry`(`FaceInfo.Mesh` 保留原始 frozen mesh 供還原與量測) +- ⚡ 剖面裁切是**背景平行**(v0.4.0):`ApplySection` 在 UI 快照 (model, frozen mesh) → `Task.Run`+`Parallel.For` + 裁切 → 回 UI 一次換上;`_sectionApplying`/`_sectionReapply` guard 保證裁切中的新變更(滑桿/換軸/`TransformRoot`) + 完成後用最新參數重跑。**不要改回同步呼叫 ClipMesh**(64k 面整場景裁切會凍結 UI 數秒); + await 之後一定要檢查 `SectionEnabled`(期間可能被關掉,還原分支已處理) +- 剛體變換(兩點對齊/旋轉 90°/三點對齊)統一走 `TransformRoot(root, ModOp, Matrix3D)`:B-rep 用 ModOp 對 Solid/Shell 整體 `Modify` + (勿逐面位移,會重複位移共用邊),網格/合併網格/邊線/邊界同步重算;變換後量測已失效要 `ClearMeasurements()`。 + **op 與 m 必須是同一個變換** — 數學在 `Services/RigidAlign.cs`:WPF `Matrix3D` 是「列向量」約定、CADability `ModOp` 是「行向量」約定, + `ToModOp` 負責轉置轉換,改動務必跑 `SmokeTest --align-test` 驗證兩種表示一致,否則 B-rep 與顯示網格會悄悄分家。 + `TransformRoot` 的網格變換是**面級平行**(v0.4.0;來源/輸出 mesh 皆 frozen 所以安全); + B-rep `Modify` 與視覺樹賦值必須維持循序(CADability 非執行緒安全 / WPF 執行緒親和) +- IsBusy 期間匯入/旋轉 90°/干涉檢查指令停用(`CanExecute = nameof(IsIdle)` + isBusy 的 + `NotifyCanExecuteChangedFor`);`ImportFilesAsync` 整批維持 busy 且開頭擋重入 + (拖放/命令列會繞過 CanExecute)。新增「會改動幾何的指令」時記得比照辦理 +- 拖曳模式(`MeasureMode.Drag`):拖曳中只掛**暫時 `TranslateTransform3D`**(GPU 免費),放開才一次性 `TranslateRoot` 烘進 B-rep — + **不要改成拖曳中逐幀 TransformRoot**(大檔每幀重建網格會卡死)。2D→3D 用 Helix `UnProject`(過錨點、法向=相機 LookDirection 的平面)。 + 合併網格的 hit-test 走 `_mergedMap`(合併 Model → leaf);拖曳中邊線用 `_edgesSuspended` 暫停 +- ⚠️ **`Visual3D.Transform` 永遠不要設成 `null`,清除要用 `Transform3D.Identity`**。HelixToolkit `Viewport3DHelper.GetTransform` + 對 `child.Transform` 沒做 null 檢查(`GeneralTransform3DGroup.Children.Add(null)` → 拋「無法新增空值到集合中」), + 之後任何 `FindHits` 都會 crash。v0.2.1 修的就是拖曳放開時把 BodyVisual.Transform 設 null(拖過一次後再點擊就炸)。 + Gizmo 的暫時 Transform 清除同理一律用 Identity +- Gizmo 操作器:Helix `TranslateManipulator`/`RotateManipulator` ×6 `Bind` 到代理 `ModelVisual3D`, + 代理 Transform 變更(`DependencyPropertyDescriptor.AddValueChanged`)即時套到目標 BodyVisual(暫時); + **放開滑鼠才 `TransformRoot` 烘焙** — MouseUp 被 manipulator 標 handled,MainWindow 用 `AddHandler(..., handledEventsToo: true)` 才收得到。 + 烘焙用 `Dispatcher.BeginInvoke` 延後到 manipulator 自身事件處理完,避免 reentrancy;`_gizmoBaking` 旗標防 Transform 歸零的回呼重入 +- Gizmo always-on-top(v0.3.1):操作器放在獨立透明 `Viewport3D`(`gizmoOverlay`)疊在主視窗上,**不在主場景所以永不被實體遮擋**。 + `_overlayCamera` 在主 `Camera.Changed` 時同步主相機(Position/方向/FOV/near-far);raw Viewport3D 空白處不吃滑鼠 → 穿透回主視窗(orbit/量測正常); + `IsHitTestVisible` 綁 `GizmoEnabled`。Manipulator 是 `UIElement3D`、用 `GetViewport3D()` 抓所在層相機,故在疊圖層用同步相機運作。 + 放開事件靠 overlay 的 `AddHandler(MouseLeftButtonUp, handledEventsToo:true)`(manipulator 會標 handled),`_gizmoBakePending` 防同次重複烘焙 +- 干涉/面距/對齊等運算在背景執行緒;`Freeze()` 幾何後才跨執行緒 +- 匯入在背景執行緒;`Freeze()` 幾何後才跨執行緒 +- 發新版:升 csproj `` 之外,記得在 `AboutDialog.xaml.cs` 的 `Changelog` 陣列**頂端**加一筆 + (口吻寫給一般使用者:「你會感覺到什麼」,不是技術 changelog)。 + ⚠️ WPF **頂層 MenuItem 直接掛 Click 不會觸發**(實測滑鼠/鍵盤都沒反應),要開視窗的入口一律放子選單(如 說明 → 關於) +- Commit 格式:Conventional Commits(`feat:` / `fix:` / `docs:` …) + +## 注意事項 / 已知限制 + +- `Path` 在 service 會與 `CADability.GeoObject.Path` 撞名 → 用 `IOPath` alias +- CADability 解析大 STEP 慢(39MB/64k 面實測:解析約 276 秒 + 幾何處理),不要改成同步呼叫。 + 解析(`ImportStep.Read`)單執行緒無解;三角化/邊取樣已按 **leaf 平行化**(`_leafWork` 收集 → `Parallel.ForEach`)。 + **平行粒度只能到 leaf**:同 leaf 的面共用 Edge 物件,面級平行會 race。空 leaf 由 `Prune` 收掉(延後三角化可能全失敗)。 + 平行下 `GetTriangulation` 偶發失敗(實測 64k 面丟 ~8 面,跨 leaf 仍有共享狀態)→ 失敗面收進 `_retry`,平行結束後**循序重試**補回, + 該 leaf 的 `FinishLeaf` 也延到重試後才跑。**不要移除重試機制**,也不要把平行度開到面級 +- `StepImportService.Progress` 回報匯入階段(解析/三角化耗時),UI 已接狀態列;訊息來自背景執行緒,要 `Dispatcher.BeginInvoke` +- `LinesVisual3D` 轉動視角逐幀重建,>30k 線段會卡 → 邊線自動關閉邏輯不要移除 +- 邊線採「**一檔一條合併 `LinesVisual3D`**」(掛在 root `EdgeVisual`,由 `RefreshRootEdges` 收集各 leaf `OriginalEdgePoints` 重建)。 + **不要改回逐 leaf 一條** — 裝配樹零件多時,N 條線每幀重建會嚴重卡頓(實測主因)。leaf 只保留邊線「資料」,渲染統一在 root; + 可見性/ShowEdges/剖面/平移變更時呼叫 `RefreshRootEdges(root)` 重組合併線 +- 互動中暫停邊線:`Attach` 掛 **`HelixViewport3D.CameraChanged`(控制項層級 routed event)+ `Camera.Changed`(保底)** → `OnCameraMoved` 隱藏邊線、`_interactionTimer`(180ms) 停下後 `ResumeEdges` 顯示; + `_edgesSuspended` 為真時 `RefreshRootEdges` 不把線掛回。轉動/縮放/平移時不付邊線重建成本。 + **只訂 `Camera.Changed` 不夠**:`Attach` 在建構式呼叫,相機實例若被 Helix 換掉訂閱會孤兒化 → 暫停永不觸發;故加訂控制項層級事件保底(重複觸發 `OnCameraMoved` 無害,有 guard) +- 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 的分支要保留) +- 不寫入原始檔(唯讀工具);WPF 限 Windows,不要嘗試移植 vbox/Linux +- 干涉檢查需剛好 2 個可見檔案(樹面板勾選);共面貼合(無穿透)不算干涉、gap≈0 視為配合(match) +- SmokeTest 工具:`--tree`(裝配樹)、`--clip-test`(剖切數學)、`--interference-test`(干涉相交/分離/貼合)、 + `--align-test`(三點對齊剛體變換 + ModOp↔Matrix3D 一致性)、`--make-dxf`(產測試檔)、 + `--export-test `(STEP 匯出往返:寫出→回讀比對實體數)、 + `--stl-export-test [file.stp]`(STL binary/ASCII 往返 + 退化濾除;給 stp 加測 B-rep 精細重算)、 + `--make-stl [triCount]`(產測試用 binary STL,預設 150 萬三角形,量 STL 匯入效能用) +- **絕不要用 PowerShell regex/Set-Content 改 .cs 檔** — Windows PowerShell 5.1 預設編碼會把 UTF-8 中文弄成亂碼(已踩過,靠反編譯 DLL 救回)。文字取代一律用 Edit 工具 + +## For_AI/ + +- `For_AI/`:AI 協作素材(截圖、草稿)+ 測試模型 `*.stp`(客戶料號),整夾 gitignored。 +- 本專案無 runtime secret(離線桌面工具,無 DB/ApiKey),故未保留 `secret/` 資料夾。 diff --git a/src/STPViewer/AboutDialog.xaml.cs b/src/STPViewer/AboutDialog.xaml.cs index 22cc298..2ef61ea 100644 --- a/src/STPViewer/AboutDialog.xaml.cs +++ b/src/STPViewer/AboutDialog.xaml.cs @@ -28,6 +28,12 @@ public partial class AboutDialog : Window // ── 版本紀錄(新版加最上面;口吻:一般使用者看得懂,講「你會感覺到什麼」而非實作細節)── private static readonly ChangelogEntry[] Changelog = { + new("0.7.1", new DateOnly(2026, 7, 12), "大型 STL 秒開", + new[] + { + "• 開啟大型 STL 網格檔(數十 MB、上百萬三角形)現在幾乎瞬間完成,不用再等好幾分鐘。", + "• 例如一個 75 MB、150 萬個三角形的 STL,以前要等到天荒地老,現在不到 1 秒就顯示出來。", + }), new("0.7.0", new DateOnly(2026, 7, 4), "介面大整理 — 選單列+可自訂快速列", new[] { diff --git a/src/STPViewer/STPViewer.csproj b/src/STPViewer/STPViewer.csproj index 6125ed3..48f1b7e 100644 --- a/src/STPViewer/STPViewer.csproj +++ b/src/STPViewer/STPViewer.csproj @@ -1,18 +1,18 @@ - - - - WinExe - net8.0-windows - enable - enable - true - 0.7.0 - - - - - - - - - + + + + WinExe + net8.0-windows + enable + enable + true + 0.7.1 + + + + + + + + + diff --git a/src/STPViewer/Services/StepImportService.cs b/src/STPViewer/Services/StepImportService.cs index cd02986..24cc893 100644 --- a/src/STPViewer/Services/StepImportService.cs +++ b/src/STPViewer/Services/StepImportService.cs @@ -123,46 +123,42 @@ public class StepImportService BuildChildren(root, EnumerateGeo(list)); } - // ─── STL(純網格,無 B-rep;整個 shell 合併成單一 mesh 避免模型數爆炸)── + // ─── STL(純網格,無 B-rep)── + // 直接解析三角形(StlMeshReader),**不經過 CADability**。CADability 的 ImportSTL 會為每個 + // 三角形建一個 B-rep Face(含 Edge/Vertex 拓樸),百萬三角形需數十分鐘且吃光記憶體 + // (實測 100k 三角形:CADability ~41 s,直接解析 ~11 ms)。STL 本就無 B-rep,讀三角形即可。 + // ⚠️ 因此 STL leaf 沒有 SourceGeos(CADability B-rep)→ 不進 STEP 匯出(本就非 STEP B-rep 幾何); + // 剛體變換仍走網格層(TransformRoot 的 SourceGeos 迴圈空轉、mesh 照樣變換),量測走網格近似不受影響。 private void ImportStlFile(string path, ImportedNode root) { - Shell[] shells = new ImportSTL().Read(path) - ?? throw new InvalidDataException("無法解析 STL 檔"); - int i = 1; - foreach (Shell sh in shells) - { - 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; + Progress?.Invoke("解析 STL(直接讀三角形)…"); + var sw = System.Diagnostics.Stopwatch.StartNew(); + List groups = StlMeshReader.Read(path); + if (groups.Count == 0) + throw new InvalidDataException("STL 檔內沒有三角形"); - 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(); + int tris = g.Indices.Count / 3; var leaf = new ImportedNode { - Name = shells.Length == 1 ? "網格" : $"網格 {i}", + Name = g.Name, HasBrep = false, SolidCount = 1, - FaceCount = sh.Faces.Length, - TriangleCount = indices.Count / 3, + FaceCount = tris, // STL 無 B-rep 面 → 以三角形數代表 + TriangleCount = tris, Bounds = BoundsOfMesh(mesh), }; - leaf.SourceGeos.Add(sh); leaf.Faces.Add(new ImportedFace(null, mesh)); root.Children.Add(leaf); - i++; } + Progress?.Invoke($"STL 解析 {sw.ElapsedMilliseconds:N0} ms({root.Children.Count} 個網格)"); } // ─── DXF(多為線架構;Solid/Face 走同一套,曲線取樣成線段)── diff --git a/src/STPViewer/Services/StlMeshReader.cs b/src/STPViewer/Services/StlMeshReader.cs new file mode 100644 index 0000000..1a1ad72 --- /dev/null +++ b/src/STPViewer/Services/StlMeshReader.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Windows.Media; +using System.Windows.Media.Media3D; + +namespace STPViewer.Services; + +/// +/// 直接解析 STL 三角網格(binary / ASCII)→ WPF mesh,**不經過 CADability**。 +/// CADability 的 ImportSTL 會為每個三角形建一個 B-rep Face(含 Edge/Vertex 拓樸), +/// 大檔(百萬三角形)需數十分鐘且吃光記憶體;STL 本就無 B-rep,直接讀三角形即可。 +/// 實測 100k 三角形:CADability 約 41,000 ms,直接解析約 11 ms。 +/// +public static class StlMeshReader +{ + /// 一個 solid 區塊的網格(頂點未焊接:每三角形 3 個獨立頂點,與 STL 原生一致) + public record MeshGroup(string Name, Point3DCollection Positions, Int32Collection Indices); + + public static List Read(string path) + { + return IsBinary(path) ? ReadBinary(path) : ReadAscii(path); + } + + /// + /// binary STL 判定:檔案大小必須剛好等於 84 + 50×count(count = offset 80 的 uint32)。 + /// 這是最可靠的判別法 — binary 的 80-byte header 常也以 "solid" 開頭,不能只看開頭字串。 + /// + private static bool IsBinary(string path) + { + var fi = new FileInfo(path); + if (fi.Length < 84) return false; // 太小,當 ASCII 讓後續拋有意義的錯 + Span 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 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; // 跳過 normal(渲染用不到;WPF 自算法向) + 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 { new("網格", positions, indices) }; + } + + /// + /// ASCII STL:逐行掃 "vertex x y z",每 3 個湊成一個三角形;"solid <name>" 起新群組。 + /// ASCII 大檔罕見(同樣三角數約為 binary 的 5 倍體積),故以正確性為主。 + /// + private static List ReadAscii(string path) + { + var groups = new List(); + 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 ? "網格" : $"網格 {groups.Count + 1}", pos, idx)); + 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; + } +} diff --git a/tools/SmokeTest/MakeStl.cs b/tools/SmokeTest/MakeStl.cs new file mode 100644 index 0000000..c9f75b4 --- /dev/null +++ b/tools/SmokeTest/MakeStl.cs @@ -0,0 +1,41 @@ +using System.IO; + +namespace SmokeTest; + +/// 產生測試用 binary STL:一片 gridN×gridN 的網格,每格 2 個三角形(波浪起伏,避免全共面)。 +internal static class MakeStl +{ + public static void Run(string path, int triCount) + { + // 每格 2 三角,格數 = triCount/2;邊長 grid = ceil(sqrt(cells)) + 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($"寫出 {tris:N0} 三角形 → {path} ({new FileInfo(path).Length:N0} bytes)"); + } +} diff --git a/tools/SmokeTest/Program.cs b/tools/SmokeTest/Program.cs index b848c50..f8583a9 100644 --- a/tools/SmokeTest/Program.cs +++ b/tools/SmokeTest/Program.cs @@ -1,80 +1,86 @@ -// 無 UI smoke test:驗證 CAD 檔解析 → 三角化 → 裝配樹 整條匯入管線 -using System.Diagnostics; -using System.IO; -using STPViewer.Services; - -Console.OutputEncoding = System.Text.Encoding.UTF8; - -if (args.Length == 0) -{ - Console.WriteLine("用法: SmokeTest [--tree] [...]"); - return 1; -} - -if (args[0] == "--tree") -{ - foreach (string p in args.Skip(1)) SmokeTest.TreeDump.Run(p); - return 0; -} - -if (args[0] == "--make-dxf") -{ - SmokeTest.MakeDxf.Run(args[1]); - return 0; -} - -if (args[0] == "--clip-test") - return SmokeTest.ClipTest.Run() == 0 ? 0 : 2; - -if (args[0] == "--interference-test") - return SmokeTest.InterferenceTest.Run() == 0 ? 0 : 2; - -if (args[0] == "--align-test") - return SmokeTest.AlignTest.Run() == 0 ? 0 : 2; - -if (args[0] == "--bbox") - return SmokeTest.BBoxDump.Run(args[1]); - -if (args[0] == "--export-test") - 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}") }; -int failed = 0; - -foreach (string path in args) -{ - Console.WriteLine($"=== {Path.GetFileName(path)} ==="); - var sw = Stopwatch.StartNew(); - try - { - ImportedFileData data = service.Import(path); - sw.Stop(); - ImportedNode r = data.Root; - Console.WriteLine($" Solids : {r.SolidCount}"); - Console.WriteLine($" Faces : {r.FaceCount}"); - 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($" Time : {sw.ElapsedMilliseconds} ms"); - Console.WriteLine(" --- 裝配樹 ---"); - PrintNode(r, 1); - } - catch (Exception ex) - { - sw.Stop(); - failed++; - Console.WriteLine($" FAILED ({sw.ElapsedMilliseconds} ms): {ex.GetType().Name}: {ex.Message}"); - } -} - -return failed == 0 ? 0 : 2; - -static void PrintNode(ImportedNode n, int indent) -{ - string seg = n.EdgeSegments is null ? "" : $", edgeSegs={n.EdgeSegments.Count / 2:N0}"; - Console.WriteLine($"{new string(' ', indent * 2)}{n.Name} " + - $"[solids={n.SolidCount}, faces={n.FaceCount}, tris={n.TriangleCount:N0}{seg}{(n.HasBrep ? "" : ", 無B-rep")}]"); - foreach (ImportedNode c in n.Children) PrintNode(c, indent + 1); -} +// 無 UI smoke test:驗證 CAD 檔解析 → 三角化 → 裝配樹 整條匯入管線 +using System.Diagnostics; +using System.IO; +using STPViewer.Services; + +Console.OutputEncoding = System.Text.Encoding.UTF8; + +if (args.Length == 0) +{ + Console.WriteLine("用法: SmokeTest [--tree] [...]"); + return 1; +} + +if (args[0] == "--tree") +{ + foreach (string p in args.Skip(1)) SmokeTest.TreeDump.Run(p); + return 0; +} + +if (args[0] == "--make-dxf") +{ + SmokeTest.MakeDxf.Run(args[1]); + 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") + return SmokeTest.ClipTest.Run() == 0 ? 0 : 2; + +if (args[0] == "--interference-test") + return SmokeTest.InterferenceTest.Run() == 0 ? 0 : 2; + +if (args[0] == "--align-test") + return SmokeTest.AlignTest.Run() == 0 ? 0 : 2; + +if (args[0] == "--bbox") + return SmokeTest.BBoxDump.Run(args[1]); + +if (args[0] == "--export-test") + 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}") }; +int failed = 0; + +foreach (string path in args) +{ + Console.WriteLine($"=== {Path.GetFileName(path)} ==="); + var sw = Stopwatch.StartNew(); + try + { + ImportedFileData data = service.Import(path); + sw.Stop(); + ImportedNode r = data.Root; + Console.WriteLine($" Solids : {r.SolidCount}"); + Console.WriteLine($" Faces : {r.FaceCount}"); + 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($" Time : {sw.ElapsedMilliseconds} ms"); + Console.WriteLine(" --- 裝配樹 ---"); + PrintNode(r, 1); + } + catch (Exception ex) + { + sw.Stop(); + failed++; + Console.WriteLine($" FAILED ({sw.ElapsedMilliseconds} ms): {ex.GetType().Name}: {ex.Message}"); + } +} + +return failed == 0 ? 0 : 2; + +static void PrintNode(ImportedNode n, int indent) +{ + string seg = n.EdgeSegments is null ? "" : $", edgeSegs={n.EdgeSegments.Count / 2:N0}"; + Console.WriteLine($"{new string(' ', indent * 2)}{n.Name} " + + $"[solids={n.SolidCount}, faces={n.FaceCount}, tris={n.TriangleCount:N0}{seg}{(n.HasBrep ? "" : ", 無B-rep")}]"); + foreach (ImportedNode c in n.Children) PrintNode(c, indent + 1); +}