4 Commits
Author SHA1 Message Date
CarmandGitHub 6d48d7f852 feat(i18n): Add runtime language packs and language switching (#1) 2026-10-20 09:46:47 +08:00
etwenandClaude Opus 5.5 39ed6d7bba docs: add release notes for v0.1.0–v0.4.0, v0.6.0 and v0.7.0
Backfill notes for versions that had a tag but no release page, so
every version up to v0.7.1 has a GitHub/Gitea release. Tag v0.7.0 at
45b27d5 (was missing).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:30:50 +08:00
etwenandClaude Opus 4.8 89ed1270a8 docs: add v0.7.1 release notes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XgHf5MW39U3Xo8y2uA229K
2026-07-14 14:20:36 +08:00
etwenandClaude Opus 4.8 060ca4ecd0 feat(perf): direct STL parsing for near-instant large-file load (v0.7.1)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XgHf5MW39U3Xo8y2uA229K
2026-07-14 14:20:36 +08:00
61 changed files with 3714 additions and 1188 deletions
+3 -3
View File
@@ -2,13 +2,13 @@
bin/
obj/
publish/
Publish/ # publish-winapp 打包產物(大小寫明確,Linux/CI 也忽略)
Publish/ # publish-winapp output on case-sensitive systems
*.user
.vs/
# AI 協作素材不入 git
# Local development assets
For_AI/
# 客戶 CAD 料號檔不入 git(避免外流);測試模型放 For_AI/
# CAD samples are stored locally in For_AI/
*.stp
*.step
+142 -134
View File
@@ -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<Model3D, FaceInfo>` (`_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<UnitSystem,string>` 延後產生(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 `<Version>` 之外,記得在 `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 <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 工具
## 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<Model3D, FaceInfo>` (`_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<UnitSystem,string>` 延後產生(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 `<Version>` 之外,記得在 `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 <in.stp> <out.stp>`(STEP 匯出往返:寫出→回讀比對實體數)、
`--stl-export-test [file.stp]`(STL binary/ASCII 往返 + 退化濾除;給 stp 加測 B-rep 精細重算)、
`--make-stl <out.stl> [triCount]`(產測試用 binary STL,預設 150 萬三角形,量 STL 匯入效能用)
- **絕不要用 PowerShell regex/Set-Content 改 .cs 檔** — Windows PowerShell 5.1 預設編碼會把 UTF-8 中文弄成亂碼(已踩過,靠反編譯 DLL 救回)。文字取代一律用 Edit 工具
## For_AI/
- `For_AI/`:AI 協作素材(截圖、草稿)+ 測試模型 `*.stp`(客戶料號),整夾 gitignored。
- 本專案無 runtime secret(離線桌面工具,無 DB/ApiKey),故未保留 `secret/` 資料夾。
+22 -1
View File
@@ -1,6 +1,6 @@
# STPViewer
[![English](https://img.shields.io/badge/English-2ea043.svg)](README.md) [![繁體中文](https://img.shields.io/badge/%E7%B9%81%E9%AB%94%E4%B8%AD%E6%96%87-lightgrey.svg)](README.zh-TW.md)
[![English](https://img.shields.io/badge/English-2ea043.svg)](README.md) [![简体中文](https://img.shields.io/badge/%E7%AE%80%E4%BD%93%E4%B8%AD%E6%96%87-lightgrey.svg)](README.zh-CN.md) [![繁體中文](https://img.shields.io/badge/%E7%B9%81%E9%AB%94%E4%B8%AD%E6%96%87-lightgrey.svg)](README.zh-TW.md)
> A Windows desktop 3D viewer for STP/STEP CAD files — multi-file import, assembly tree, and point / distance / edge / face / circle measurement. Built with C# .NET 8 WPF.
@@ -52,6 +52,7 @@ Open mechanical STEP parts for quick review and measurement without a heavyweigh
- **Interference check** 🧩 — with 2+ visible files, checks **every pair**: red intersection curves + intersecting triangle-pair count, or the minimum gap (gap ≈ 0 means a fit/match; coplanar contact is not interference)
- **Section plane** ✂ — X/Y/Z axis **or an arbitrary plane picked from 3 points on the model**; position slider + numeric input + flip; CPU mesh clipping (parallel, in the background), original geometry preserved (measurement stays exact)
- **Units** — one-click mm ⇄ inch; existing measurements (list + 3D labels) convert live
- **Language** — choose a language from **View → Language**; available languages come from the `i18n/` JSON files at startup, and your choice is remembered
- **Export** — measurement results to CSV (UTF-8 BOM, no mojibake in Excel), a 2× PNG screenshot, and 📤 **STEP export**: writes all visible parts at their current aligned positions into a new STEP file (originals untouched)
- **View** — right-button orbit, wheel zoom, middle-button pan, ViewCube; one-click **Iso / Front / Top / Right** views and an **orthographic ⇄ perspective** toggle
- **Remembers your setup** — window size/position, unit choice and recent files persist across sessions; side panels are resizable
@@ -118,6 +119,7 @@ dotnet run --project tools/SmokeTest -- "path\to\model.stp" # import + assembl
dotnet run --project tools/SmokeTest -- --clip-test # section clipping math
dotnet run --project tools/SmokeTest -- --interference-test # interference: intersect / separate / contact
dotnet run --project tools/SmokeTest -- --align-test # 3-point rigid-transform math
dotnet run --project tools/SmokeTest -- --localization-test # language resources and live switching
dotnet run --project tools/SmokeTest -- --export-test in.stp out.stp # STEP export round-trip
```
@@ -135,6 +137,24 @@ NuGet dependencies (restored automatically): `CADability`, `HelixToolkit.Wpf`, `
---
## 🌐 Adding a Language
The application scans `i18n/*.json` beside the executable at startup. Each file name without `.json` is the language ID (for example, `en.json` → `en`, `zh_cn.json` → `zh_cn`). The JSON root has a `key` for the name shown in the language menu and a `data` object that maps resource keys to translated text:
```json
{
"key": "Français",
"data": {
"Ui.Language": "Langue",
"Ui.Main.MenuImport": "📂 Importer…"
}
}
```
Use `src/STPViewer/i18n/en.json` as the list of text keys, translate its `data` values, and save the new file as UTF-8 in `i18n/` beside the executable. Restart STPViewer to see the new language in the menu and Quick Bar. No recompilation is needed.
---
## 📁 Project Structure
```
@@ -145,6 +165,7 @@ STPViewer/
├── src/STPViewer/
│ ├── STPViewer.csproj # net8.0-windows, UseWPF, single-source <Version>
│ ├── MainWindow.xaml / .cs # Layout + mouse-pick forwarding
│ ├── i18n/ # Language JSON files copied beside the executable
│ ├── Models/ # FaceInfo, MeasureMode, MeasurementResult, UnitSystem
│ ├── Services/ # StepImport, Measurement, Interference, Section, RigidAlign, Settings
│ └── ViewModels/ # MainViewModel (partial: Drag/Gizmo/Section/Interference/Export), ModelNodeViewModel
+234
View File
@@ -0,0 +1,234 @@
# STPViewer
[![English](https://img.shields.io/badge/English-lightgrey.svg)](README.md) [![简体中文](https://img.shields.io/badge/%E7%AE%80%E4%BD%93%E4%B8%AD%E6%96%87-2ea043.svg)](README.zh-CN.md) [![繁體中文](https://img.shields.io/badge/%E7%B9%81%E9%AB%94%E4%B8%AD%E6%96%87-lightgrey.svg)](README.zh-TW.md)
> STP/STEP 3D 查看器(Windows 桌面程序,C# .NET 8 WPF)— 多文件导入、装配树、点/距离/边/面/圆测量。
![version](https://img.shields.io/badge/version-0.5.0-blue.svg) ![platform](https://img.shields.io/badge/platform-Windows-0078D6.svg) ![.NET](https://img.shields.io/badge/.NET-8.0-512BD4.svg) ![UI](https://img.shields.io/badge/UI-WPF-blueviolet.svg)
---
## 📖 目录
- [✨ 功能特性](#-功能特性)
- [💻 系统需求](#-系统需求)
- [📥 安装](#-安装)
- [🚀 快速开始](#-快速开始)
- [📚 使用指南](#-使用指南)
- [🔨 从源代码构建](#-从源代码构建)
- [📁 项目结构](#-项目结构)
- [⚠️ 已知限制](#️-已知限制)
- [🤝 贡献](#-贡献)
- [📜 版本记录](#-版本记录)
- [🙏 致谢](#-致谢)
---
## ✨ 功能特性
快速打开机械零件 STEP 文件进行查看和测量,无需安装 SolidWorks / Creo 等大型 CAD 软件。
- **多文件导入** — STEP / STL / DXF,工具栏导入(可多选)、拖放到窗口、命令行 `STPViewer.exe a.stp b.stl`,或最近文件下拉菜单(▾,最近 10 项)
- **装配树** — STEP product structure 还原为树(组件→零件);逐节点显示/隐藏、更改颜色(作用于子节点)、缩放至节点;文件层级可移除、开关轮廓边线;**按名称搜索过滤**;右键 **只显示此节点 / 反转显示 / 全部显示 / 计算体积与质心**;提示信息显示外形尺寸 L×W×H
- **测量**(工具栏切换模式或快捷键,点击模型):
| 模式 | 快捷键 | 输出 |
|---|---|---|
| 📍 点 | P | XYZ 坐标(自动吸附邻近 B-rep 顶点**或圆心**) |
| 📏 距离 | D | 两点直线距离 + ΔX/ΔY/ΔZ — 点击两个孔缘自动吸附**圆心**,可直接测量孔中心距 |
| 📐 边 | E | 直线长 / 曲线长 / 圆弧长 + 半径 |
| ⬛ 面 | F | 面积(网格近似)+ 曲面类型(平面法向量、圆柱半径/轴向) |
| ⭕ 圆 | C | 圆心 / 半径 / 直径 / 周长 |
| ∠ 角度 | A | 两面(法向量)/ 两条直线边的夹角 + 补角 |
| ⇔ 面距 | M | 面到面最短距离(网格近似)+ 最近点对 |
| ⚖ 体积 | (树右键) | 网格有符号体积 + 质心标记 + 外形尺寸(封闭实体) |
| ⤚ 对齐(两点) | | 点「要移动零件」一点 + 目标点 → 纯平移使两点贴合 |
| 🎯 对齐(三点) | | 源文件 3 点 + 目标文件 3 对应点 → 旋转+平移一次贴合 |
再次按下同一快捷键退出模式;**Esc** 先取消当前选择、再按退出模式。
- **旋转** — 树面板选文件 + 工具栏 ↻X/↻Y/↻Z 绕中心 +90°(用于调整朝向,可重复旋转)
- **拖动** 🖐 — 手形光标模式,左键按住零件沿屏幕平面拖动、放开定位(右键转视角不受影响)
- **操作器** ⊹ — 树面板选文件 → XYZ 三色箭头 + 旋转环(Fusion 360 风格);拖箭头沿该轴移动(与视角无关)、拖环绕轴转。永远浮在最上层、不被实体遮挡
- **干涉检查** 🧩 — 2 个以上可见文件**两两配对**检查:相交时显示红色干涉交线 + 相交的三角形对数;无相交时报告最小间隙(gap≈0 即为配合,共面贴合不算干涉)
- **剖面** ✂ — X/Y/Z 轴或**在模型上点 3 点定义任意平面**;位置滑块 + 数值输入 + 反向;CPU 网格裁切(背景平行运算),原始几何保留(测量仍精确)
- **单位** — mm ⇄ inch 一键切换,已有测量(列表与 3D 标签)实时换算
- **语言** — 在 **视图 → 语言** 中选择语言;启动时从 `i18n/` 的 JSON 文件读取可用语言,并记住你的选择
- **导出** — 测量结果 CSV(UTF-8 BOM,Excel 中文不乱码)、3D 视图 2× PNG 截图、📤 **STEP 导出**:可见零件以当前对齐位置写成新 STEP 文件(源文件不动)
- **视角** — 右键旋转、滚轮缩放、中键平移、ViewCube;一键 **等角/前/上/右** 标准视图与 **正交⇄透视** 切换
- **设置保存** — 窗口位置和大小、单位、最近文件自动保存;左右面板可拖动调宽
测量原则:边长、圆半径、角度取 **B-rep 精确值**;面积为三角网格求和近似(三角化精度根据模型尺寸自适应 0.02–0.5 mm)。
---
## 💻 系统需求
| 项目 | 需求 |
|------|------|
| 操作系统 | Windows 10 / 11(x64) |
| 运行环境 | .NET 8 Desktop Runtime(依赖框架版)— Portable 版则免装 |
| 构建 SDK | .NET 8 SDK(仅从源代码构建时需要) |
---
## 📥 安装
下载发布版直接执行,免安装。
- **依赖框架版**(文件较小):需 .NET 8 Desktop Runtime,执行 `STPViewer v0.5.0.exe`。
- **Portable 版**(self-contained):内置运行环境,免安装 / 无需管理员权限,执行 `STPViewer v0.5.0.exe`。
或从源代码构建(见下)。
```bash
git clone https://github.com/ETWen/STPViewer.git
cd STPViewer
dotnet build STPViewer.sln
```
---
## 🚀 快速开始
```bash
# 构建并执行
dotnet build STPViewer.sln
dotnet run --project src/STPViewer
# 发布免安装文件夹
dotnet publish src/STPViewer -c Release -o publish/STPViewer
```
然后导入 `.stp` 文件(工具栏「导入」、拖放或命令行传入文件路径),选一个测量模式,点击模型即可。
---
## 📚 使用指南
1. **导入** 一个或多个 CAD 文件,每个文件成为装配树的一个根节点,视角自动缩放至适合窗口。
2. **操作树** — 按名称搜索、显示/隐藏(或右键隔离显示)、换色、缩放至某节点,或移除文件。
3. **测量** — 在工具栏选择模式或按快捷键(P/D/E/F/C/A/M),点击模型;点击孔缘自动吸附圆心,点击两次即可测得孔中心距;Esc 取消。结果显示在右侧面板,可逐条删除或全部清除。
4. **装配** — 使用对齐(两点/三点)、旋转、拖动、操作器摆位,再用干涉检查对所有可见文件两两检查,验证装配配合。
5. **剖面** — 打开 ✂、选轴向或点 3 点定义任意平面、滑动(或输入 %)剖切;测量仍以原始几何精确计算。
6. **导出** — 测量结果保存为 CSV、截取 2× PNG,或把对齐后的场景写成新 STEP 文件交接给 CAD。
无需启动界面的导入流程与几何运算验证:
```bash
dotnet run --project tools/SmokeTest -- "path\to\model.stp" # 导入 + 装配树
dotnet run --project tools/SmokeTest -- --clip-test # 剖切裁切数学
dotnet run --project tools/SmokeTest -- --interference-test # 干涉 相交/分离/贴合
dotnet run --project tools/SmokeTest -- --align-test # 三点对齐刚体变换数学
dotnet run --project tools/SmokeTest -- --localization-test # 语言资源与实时切换
dotnet run --project tools/SmokeTest -- --export-test in.stp out.stp # STEP 导出往返验证
```
---
## 🔨 从源代码构建
```bash
dotnet build STPViewer.sln -c Debug
dotnet run --project src/STPViewer
dotnet publish src/STPViewer -c Release -o publish/STPViewer
```
NuGet 依赖(自动还原):`CADability`、`HelixToolkit.Wpf`、`CommunityToolkit.Mvvm`。
---
## 🌐 添加语言
程序启动时扫描可执行文件旁的 `i18n/*.json`。文件名去掉 `.json` 后就是语言 ID,例如 `en.json` 对应 `en`,`zh_cn.json` 对应 `zh_cn`。JSON 顶层的 `key` 是语言菜单中的显示名,`data` 是资源键到译文的映射:
```json
{
"key": "Français",
"data": {
"Ui.Language": "Langue",
"Ui.Main.MenuImport": "📂 Importer…"
}
}
```
参考 `src/STPViewer/i18n/en.json` 中的资源键,翻译其 `data` 内容,将新文件以 UTF-8 编码放入可执行文件旁的 `i18n/`。重启 STPViewer 后,新语言便会出现在菜单和快捷栏中,无需重新编译。
---
## 📁 项目结构
```
STPViewer/
├── ARCHITECTURE.md # 设计、数据流、开发 阶段
├── CLAUDE.md # 项目记忆 & 开发规范
├── STPViewer.sln
├── src/STPViewer/
│ ├── STPViewer.csproj # net8.0-windows、UseWPF、统一的版本号来源 <Version>
│ ├── MainWindow.xaml / .cs # 布局 + 鼠标拾取转发
│ ├── i18n/ # 复制到可执行文件旁的语言 JSON 文件
│ ├── Models/ # FaceInfo、MeasureMode、MeasurementResult、UnitSystem
│ ├── Services/ # StepImport、Measurement、Interference、Section、RigidAlign、Settings
│ └── ViewModels/ # MainViewModel(partial:Drag/Gizmo/Section/Interference/Export)、ModelNodeViewModel
└── tools/SmokeTest/ # 无需 UI 导入 + 几何数学验证
```
---
## ⚠️ 已知限制
- 大型 STEP(数千面)导入需数十秒(CADability 解析成本),导入期间有进度提示、UI 不会卡住。
- 轮廓边线超过 30,000 线段的文件会默认关闭边线(WPF `LinesVisual3D` 转动视角时性能限制),可在树面板手动打开。
- **IGES 不支持**(CADability 无 IGES reader);STL 无 B-rep,仅支持 点/距离/角度/面距 测量;DXF 为线框查看。
- 剖切面无封口(cap),剖开处显示内部背面材质(深灰)。
- STEP 导出为「当前位置的 Solid/Shell 扁平列表」,不保留原始装配层级与 product 名称;STL 网格、DXF 线框无 B-rep 不导出。
- 面积、面距、体积/质心为三角网格近似值;边长/圆半径/角度为 B-rep 精确值。
- 少数 AP242 文件 CADability 支持不完整,导入失败会提示消息(不会崩溃)。
- 只读查看器,不写入/修改原始文件。
---
## 🤝 贡献
1. Fork 并建立 feature 分支:`git checkout -b feature/your-feature`
2. 遵循 [Conventional Commits](https://www.conventionalcommits.org/):`feat(scope): summary`
3. Push 后开 Pull Request
---
## 📜 版本记录
### v0.5.0
- **功能扩展:** 圆心吸附(点击两次测量孔中心距)、测量快捷键(P/D/E/F/C/A/M)+ Esc、标准视图(等角/前/上/右)+ 正交投影、装配树搜索/隔离显示/反转、体积与质心、3 点任意剖切平面 + 位置数值输入、干涉检查支持 3 个以上文件(两两配对)、对齐后场景导出 STEP、面板可调宽、窗口/单位/最近文件自动保存。
### v0.4.0
- **性能:** 剖面裁切改为后台并行运算(大文件拖动滑块时不再卡住 UI);零件变换烘焙(拖动/操作器放开)并行化;干涉间隙精修以 AABB 快速排除加速。
- **稳定性:** 全域例外处理(记 log 至 `%LOCALAPPDATA%\STPViewer\error.log` + 消息框,不会崩溃);背景运算期间停用会改动几何的指令;导入防重入。
### v0.3.2
- **性能:** 大组件测量模式下转动视角不再卡顿。测量模式改为渲染合并网格(每个文件 1 个 model),并用命中三角形的顶点 index 反查回是哪个面,不再挂数万个逐面 model;逐面渲染只保留给剖面模式。
- 相机交互暂停事件额外监听 `HelixViewport3D.CameraChanged`,避免相机实例被换掉时失去订阅。
### v0.3.1
- 操作器 always-on-top 叠图层(永远浮在零件上、不被实体遮挡)。
### v0.3.0
- 旋转对齐:轴向旋转(↻X/↻Y/↻Z)、三点对齐,以及统一的 `TransformRoot` 刚体变换路径。
### v0.2.x
- 拖动模式、两点对齐、干涉检查、剖面、角度/面距测量、装配树、STL/DXF 支持、mm ⇄ inch。
---
## 🙏 致谢
- [CADability](https://github.com/SOFAgh/CADability) — 纯 C# CAD kernel:STEP 导入、B-rep 几何、面三角化
- [HelixToolkit.Wpf](https://github.com/helix-toolkit/helix-toolkit) — 3D viewport、相机操作、HitTest
- [CommunityToolkit.Mvvm](https://github.com/CommunityToolkit/dotnet) — MVVM
详细设计与开发 阶段 见 [ARCHITECTURE.md](ARCHITECTURE.md)。
+22 -1
View File
@@ -1,6 +1,6 @@
# STPViewer
[![English](https://img.shields.io/badge/English-lightgrey.svg)](README.md) [![繁體中文](https://img.shields.io/badge/%E7%B9%81%E9%AB%94%E4%B8%AD%E6%96%87-2ea043.svg)](README.zh-TW.md)
[![English](https://img.shields.io/badge/English-lightgrey.svg)](README.md) [![简体中文](https://img.shields.io/badge/%E7%AE%80%E4%BD%93%E4%B8%AD%E6%96%87-lightgrey.svg)](README.zh-CN.md) [![繁體中文](https://img.shields.io/badge/%E7%B9%81%E9%AB%94%E4%B8%AD%E6%96%87-2ea043.svg)](README.zh-TW.md)
> STP/STEP 3D 檢視器(Windows 桌面程式,C# .NET 8 WPF)— 多檔匯入、裝配樹、點/距離/邊/面/圓量測。
@@ -52,6 +52,7 @@
- **干涉檢查** 🧩 — 2 個以上可見檔案**兩兩配對**檢查:相交時顯示紅色干涉交線 + 相交三角形對數;無相交時回報最小間隙 gap(gap≈0 即為配合 match,共面貼合不算干涉)
- **剖面** ✂ — X/Y/Z 軸或**在模型上點 3 點定義任意平面**;位置滑桿 + 數值輸入 + 反向;CPU 網格裁切(背景平行運算),原始幾何保留(量測仍精確)
- **單位** — mm ⇄ inch 一鍵切換,既有量測(清單與 3D 標籤)即時換算
- **語言** — 從 **視圖 → 語言** 選擇語言;啟動時從 `i18n/` 的 JSON 檔案讀取可用語言,並記住選擇
- **匯出** — 量測結果 CSV(UTF-8 BOM,Excel 中文不亂碼)、3D 視圖 2× PNG 截圖、📤 **STEP 匯出**:可見零件以目前對齊位置寫成新 STEP 檔(原始檔不動)
- **視角** — 右鍵旋轉、滾輪縮放、中鍵平移、ViewCube;一鍵 **等角/前/上/右** 標準視圖與 **正交⇄透視** 切換
- **記住設定** — 視窗位置大小、單位、最近檔案自動保存;左右面板可拖曳調寬
@@ -118,6 +119,7 @@ dotnet run --project tools/SmokeTest -- "path\to\model.stp" # 匯入 + 裝配
dotnet run --project tools/SmokeTest -- --clip-test # 剖切裁切數學
dotnet run --project tools/SmokeTest -- --interference-test # 干涉 相交/分離/貼合
dotnet run --project tools/SmokeTest -- --align-test # 三點對齊剛體變換數學
dotnet run --project tools/SmokeTest -- --localization-test # 語言資源與即時切換
dotnet run --project tools/SmokeTest -- --export-test in.stp out.stp # STEP 匯出往返驗證
```
@@ -135,6 +137,24 @@ NuGet 相依(自動還原):`CADability`、`HelixToolkit.Wpf`、`CommunityT
---
## 🌐 新增語言
程式啟動時掃描執行檔旁的 `i18n/*.json`。檔名去掉 `.json` 後就是語言 ID,例如 `en.json` 對應 `en`,`zh_tw.json` 對應 `zh_tw`。JSON 頂層的 `key` 是語言選單中的顯示名稱,`data` 是資源鍵與譯文的對應:
```json
{
"key": "Français",
"data": {
"Ui.Language": "Langue",
"Ui.Main.MenuImport": "📂 Importer…"
}
}
```
參考 `src/STPViewer/i18n/en.json` 的資源鍵,翻譯其 `data` 內容,將新檔案以 UTF-8 編碼放入執行檔旁的 `i18n/`。重啟 STPViewer 後,新語言便會出現在選單與快速列中,不需重新編譯。
---
## 📁 專案結構
```
@@ -145,6 +165,7 @@ STPViewer/
├── src/STPViewer/
│ ├── STPViewer.csproj # net8.0-windows、UseWPF、單一版號來源 <Version>
│ ├── MainWindow.xaml / .cs # 版面 + 滑鼠拾取轉發
│ ├── i18n/ # 複製到執行檔旁的語言 JSON 檔
│ ├── Models/ # FaceInfo、MeasureMode、MeasurementResult、UnitSystem
│ ├── Services/ # StepImport、Measurement、Interference、Section、RigidAlign、Settings
│ └── ViewModels/ # MainViewModel(partial:Drag/Gizmo/Section/Interference/Export)、ModelNodeViewModel
+26
View File
@@ -0,0 +1,26 @@
# v0.1.0 — First release: STEP viewer with measurement & assembly checks
## ✨ New features
**Open your CAD files**
* Import STEP, STL and DXF — several files at once. STEP assemblies show up as a tree where you can hide/show, recolor or zoom to any part.
**Measure**
* Point, distance, edge length, face, circle (radius/diameter), angle and face-to-face distance. Values come from the exact CAD geometry wherever it's available.
**Check how parts fit**
* Two-point align (move), three-point align (rotate + move), rotate 90° about an axis, and an interference check that reports overlaps or the minimum gap between parts.
**More tools**
* Section view, mm ⇄ inch switch, export measurements to CSV, and save a high-resolution screenshot.
## 📦 Downloads
Source only — no prebuilt binaries were published for this version. For a ready-to-run build, grab the [latest release](https://github.com/ETWen/STPViewer/releases/latest).
## 🔗 Links
* [Architecture](https://github.com/ETWen/STPViewer/blob/main/ARCHITECTURE.md)
* [README](https://github.com/ETWen/STPViewer/blob/main/README.md)
**Full changelog:** [commit history](https://github.com/ETWen/STPViewer/commits/v0.1.0)
+18
View File
@@ -0,0 +1,18 @@
# v0.2.0 — Drag parts into place
## ✨ New features
**Drag mode**
* Press and hold a part with the left mouse button and drag it where you want; release to drop it. Smooth even on very large assemblies, and measurement accuracy is unaffected.
* Right-drag rotate, wheel zoom and Shift+drag pan still work as before.
## 📦 Downloads
Source only — no prebuilt binaries were published for this version. For a ready-to-run build, grab the [latest release](https://github.com/ETWen/STPViewer/releases/latest).
## 🔗 Links
* [Architecture](https://github.com/ETWen/STPViewer/blob/main/ARCHITECTURE.md)
* [README](https://github.com/ETWen/STPViewer/blob/main/README.md)
**Full changelog:** [v0.1.0...v0.2.0](https://github.com/ETWen/STPViewer/compare/v0.1.0...v0.2.0)
+17
View File
@@ -0,0 +1,17 @@
# v0.2.1 — Fix crash after dragging a part
## 🐛 Bug fixes
**No more crash after using drag mode**
* Clicking anywhere in the view after dragging a part could crash the app. Fixed — you can drag, then measure or select as usual.
## 📦 Downloads
Source only — no prebuilt binaries were published for this version. For a ready-to-run build, grab the [latest release](https://github.com/ETWen/STPViewer/releases/latest).
## 🔗 Links
* [Architecture](https://github.com/ETWen/STPViewer/blob/main/ARCHITECTURE.md)
* [README](https://github.com/ETWen/STPViewer/blob/main/README.md)
**Full changelog:** [v0.2.0...v0.2.1](https://github.com/ETWen/STPViewer/compare/v0.2.0...v0.2.1)
+22
View File
@@ -0,0 +1,22 @@
# v0.3.0 — 3-axis gizmo for precise move & rotate
## ✨ New features
**Gizmo manipulator**
* Select a file in the tree and a gizmo appears at its center: drag the X/Y/Z arrows to move exactly along one axis, or the rotation rings to spin it by any angle — from any viewing angle.
* Selecting another file moves the gizmo to it.
## ⚠️ Known limitation
* The gizmo can be hidden inside solid parts. (Fixed in v0.3.1 — the gizmo now always stays on top.)
## 📦 Downloads
Source only — no prebuilt binaries were published for this version. For a ready-to-run build, grab the [latest release](https://github.com/ETWen/STPViewer/releases/latest).
## 🔗 Links
* [Architecture](https://github.com/ETWen/STPViewer/blob/main/ARCHITECTURE.md)
* [README](https://github.com/ETWen/STPViewer/blob/main/README.md)
**Full changelog:** [v0.2.1...v0.3.0](https://github.com/ETWen/STPViewer/compare/v0.2.1...v0.3.0)
+26
View File
@@ -0,0 +1,26 @@
# v0.4.0 — Faster and more stable
## ✨ Improvements
**Section slider stays responsive on big files**
* Dragging the section slider on large assemblies no longer freezes the screen — the cut is computed in the background and shown as soon as it's ready.
**Faster placement after moving parts**
* After dragging or using the gizmo, the part settles into its new position much faster. The interference check's gap calculation is faster too.
## 🐛 Bug fixes
**Errors no longer close the app**
* If something goes wrong, you now get a message and the app keeps running instead of shutting down. Details are logged to `%LOCALAPPDATA%\STPViewer\error.log`.
* Import, rotate and interference buttons are disabled while a long task is running, so they can't be triggered twice by accident.
## 📦 Downloads
Source only — no prebuilt binaries were published for this version. For a ready-to-run build, grab the [latest release](https://github.com/ETWen/STPViewer/releases/latest).
## 🔗 Links
* [Architecture](https://github.com/ETWen/STPViewer/blob/main/ARCHITECTURE.md)
* [README](https://github.com/ETWen/STPViewer/blob/main/README.md)
**Full changelog:** [v0.3.2...v0.4.0](https://github.com/ETWen/STPViewer/compare/v0.3.2...v0.4.0)
+19
View File
@@ -0,0 +1,19 @@
# v0.6.0 — Export to STL
## ✨ New features
**STL export with a settings dialog**
* Save the parts on screen — including any alignment or rotation you applied — as an STL mesh for 3D printing or other software.
* Before exporting you choose: one merged file or one file per input, Binary or ASCII, mm / inch / custom scale, and mesh quality (current or fine).
* The dialog shows the triangle count and estimated file size live, so you know what you'll get before you hit export.
## 📦 Downloads
Source only — no prebuilt binaries were published for this version. For a ready-to-run build, grab the [latest release](https://github.com/ETWen/STPViewer/releases/latest).
## 🔗 Links
* [Architecture](https://github.com/ETWen/STPViewer/blob/main/ARCHITECTURE.md)
* [README](https://github.com/ETWen/STPViewer/blob/main/README.md)
**Full changelog:** [v0.5.0...v0.6.0](https://github.com/ETWen/STPViewer/compare/v0.5.0...v0.6.0)
+26
View File
@@ -0,0 +1,26 @@
# v0.7.0 — Menu bar & customizable quick toolbar
## ✨ New features
**Menu bar**
* Every feature is now in a menu, grouped by File / View / Measure / Transform / Tools — no more hunting through one long row of buttons.
**Quick toolbar you can customize**
* The toolbar now holds only the buttons you use most. Pick which ones under **Quick Bar → Customize**; your choice is remembered next time.
**Tidier section controls**
* The section axis/position bar only appears while section view is on, so it no longer takes up space.
**About dialog**
* **Help → About** shows the version and a plain-language history of what each release brought.
## 📦 Downloads
Source only — no prebuilt binaries were published for this version. For a ready-to-run build, grab the [latest release](https://github.com/ETWen/STPViewer/releases/latest).
## 🔗 Links
* [Architecture](https://github.com/ETWen/STPViewer/blob/main/ARCHITECTURE.md)
* [README](https://github.com/ETWen/STPViewer/blob/main/README.md)
**Full changelog:** [v0.6.0...v0.7.0](https://github.com/ETWen/STPViewer/compare/v0.6.0...v0.7.0)
+34
View File
@@ -0,0 +1,34 @@
# v0.7.1 — Large STL files open in a blink
## ✨ New features
**Large STL files open in a blink**
* Opening big STL meshes (tens of MB, millions of triangles) is now near-instant instead of a multi-minute wait. A 75 MB / 1.5-million-triangle STL that used to take ages now shows in **under a second**.
* STL is now parsed directly instead of going through the CAD kernel's importer, which used to build a full B-rep face per triangle (100k triangles: **~41 s → ~11 ms**). Both binary and ASCII STL are detected and read automatically.
**Customizable menu bar & quick toolbar**
* A full menu bar groups every feature into categorized dropdowns, and a quick toolbar lets you tick just the buttons you use most — your selection is remembered between sessions.
**About dialog with a changelog**
* Help → About now shows a per-version, plain-language changelog so you can see what each release brought.
## 🐛 Bug fixes
**STL geometry no longer leaks into STEP export**
* STL is pure mesh (no B-rep), so STL parts are now correctly excluded from STEP export — earlier versions wrongly mixed STL shells into the exported STEP. Rigid transforms and measurement on STL parts are unaffected.
## 📦 Downloads
| Build | Needs .NET Runtime? | Notes |
|---|---|---|
| **STPViewer_v0.7.1** (Standard) | ✅ Requires .NET 8 Desktop Runtime | Smaller download |
| **STPViewer_v0.7.1_portable** (Portable) | ❌ Runtime included | Unzip and run anywhere, no admin |
Run `STPViewer v0.7.1.exe`.
## 🔗 Links
* [Architecture](https://github.com/ETWen/STPViewer/blob/main/ARCHITECTURE.md)
* [README](https://github.com/ETWen/STPViewer/blob/main/README.md)
**Full changelog:** [v0.6.0...v0.7.1](https://github.com/ETWen/STPViewer/compare/v0.6.0...v0.7.1)
+15 -20
View File
@@ -1,7 +1,8 @@
<Window x:Class="STPViewer.AboutDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="關於 STPViewer"
xmlns:loc="clr-namespace:STPViewer.Localization"
Title="{loc:Loc Ui.About.Title}"
Width="860" Height="600"
WindowStartupLocation="CenterOwner"
ResizeMode="CanResize" MinWidth="700" MinHeight="420"
@@ -34,7 +35,7 @@
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- ─── 左:程式 / 開發者 / 技術棧 ─── -->
<!-- Application, developer, and technology -->
<StackPanel Grid.Column="0" Grid.Row="0" Margin="0,0,12,0">
<Border Style="{StaticResource Card}">
<StackPanel HorizontalAlignment="Center">
@@ -42,17 +43,14 @@
Foreground="#1565C0" HorizontalAlignment="Center" />
<TextBlock x:Name="VersionText" Text="v0.0.0" Style="{StaticResource DimText}"
HorizontalAlignment="Center" Margin="0,2,0,8" />
<TextBlock TextAlignment="Center" FontSize="12" LineHeight="18">
STP / STEP 3D 檢視器<LineBreak />
免開重量級 CAD,快速開檔、量測、<LineBreak />
對齊插合、干涉檢查、剖面、匯出
</TextBlock>
<TextBlock Text="{loc:Loc Ui.About.Description}" TextAlignment="Center"
TextWrapping="Wrap" Width="260" FontSize="12" LineHeight="18" />
</StackPanel>
</Border>
<Border Style="{StaticResource Card}">
<StackPanel>
<TextBlock Text="👤 開發者" Style="{StaticResource CardTitle}" />
<TextBlock Text="{loc:Loc Ui.About.Developer}" Style="{StaticResource CardTitle}" />
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="60" />
@@ -61,9 +59,9 @@
<Grid.RowDefinitions>
<RowDefinition /><RowDefinition /><RowDefinition />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Name" Style="{StaticResource DimText}" />
<TextBlock Grid.Row="0" Grid.Column="0" Text="{loc:Loc Ui.About.Name}" Style="{StaticResource DimText}" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="ET Wen" FontSize="12" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Email" Style="{StaticResource DimText}" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="{loc:Loc Ui.About.Email}" Style="{StaticResource DimText}" />
<TextBlock Grid.Row="1" Grid.Column="1" Text="eric441151893@gmail.com" FontSize="12" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="GitHub" Style="{StaticResource DimText}" />
<TextBlock Grid.Row="2" Grid.Column="1" Text="github.com/ETWen" FontSize="12" />
@@ -73,27 +71,24 @@
<Border Style="{StaticResource Card}">
<StackPanel>
<TextBlock Text="🔧 技術棧" Style="{StaticResource CardTitle}" />
<TextBlock Style="{StaticResource DimText}" LineHeight="18">
.NET 8 · WPF · C#<LineBreak />
CADability(STEP / B-rep 幾何核心)<LineBreak />
HelixToolkit(3D 顯示與相機)
</TextBlock>
<TextBlock Text="{loc:Loc Ui.About.TechnologyTitle}" Style="{StaticResource CardTitle}" />
<TextBlock Text="{loc:Loc Ui.About.Technology}" Style="{StaticResource DimText}"
TextWrapping="Wrap" Width="260" LineHeight="18" />
</StackPanel>
</Border>
</StackPanel>
<!-- ─── 右:Change log ─── -->
<!-- Release history -->
<Border Grid.Column="1" Grid.Row="0" Background="#FAFBFC" CornerRadius="6" Padding="4">
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="📋 版本紀錄" FontWeight="Bold" FontSize="14"
<TextBlock DockPanel.Dock="Top" Text="{loc:Loc Ui.About.Changelog}" FontWeight="Bold" FontSize="14"
Margin="12,8,0,4" />
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="12,0,12,8">
<ItemsControl x:Name="ChangelogList">
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Margin="0,8,0,4">
<TextBlock FontWeight="Bold" Foreground="#1565C0" FontSize="13">
<TextBlock FontWeight="Bold" Foreground="#1565C0" FontSize="13" TextWrapping="Wrap">
<Run Text="●" /> <Run Text="{Binding Header, Mode=OneWay}" />
</TextBlock>
<ItemsControl ItemsSource="{Binding Changes}" Margin="16,3,0,0">
@@ -112,7 +107,7 @@
</DockPanel>
</Border>
<Button Grid.Column="1" Grid.Row="1" Content="關閉" Width="90" Margin="0,10,0,0"
<Button Grid.Column="1" Grid.Row="1" Content="{loc:Loc Ui.About.Close}" Width="90" Margin="0,10,0,0"
HorizontalAlignment="Right" IsCancel="True" IsDefault="True" />
</Grid>
</Window>
+44 -37
View File
@@ -2,12 +2,13 @@ using System;
using System.Linq;
using System.Reflection;
using System.Windows;
using STPViewer.Services;
namespace STPViewer;
/// <summary>
/// 關於視窗:左 = 程式資訊 / 開發者 / 技術棧,右 = 版本紀錄(寫給一般使用者看,非技術 changelog)。
/// 樣式參考 ETTerms 的 AboutView。發新版時在 Changelog 陣列最上面加一筆。
/// The About window shows application information on the left and release history on the right.
/// Add each new release at the start of Changelog.
/// </summary>
public partial class AboutDialog : Window
{
@@ -18,74 +19,80 @@ public partial class AboutDialog : Window
VersionText.Text = $"v{v.Major}.{v.Minor}.{v.Build}";
ChangelogList.ItemsSource = Changelog.Select(e => new
{
Header = $"v{e.Version} · {e.Title} ({e.Date:yyyy-MM-dd})",
e.Changes,
Header = $"v{e.Version} · {LocalizationService.Get(e.Title)} ({e.Date:yyyy-MM-dd})",
Changes = e.Changes.Select(LocalizationService.Get).ToArray(),
}).ToList();
}
private record ChangelogEntry(string Version, DateOnly Date, string Title, string[] Changes);
// ── 版本紀錄(新版加最上面;口吻:一般使用者看得懂,講「你會感覺到什麼」而非實作細節)──
// Release history shown to users, newest first.
private static readonly ChangelogEntry[] Changelog =
{
new("0.7.0", new DateOnly(2026, 7, 4), "介面大整理 — 選單列+可自訂快速列",
new("0.7.1", new DateOnly(2026, 7, 12), "Ui.About.Release071.Title",
new[]
{
"• 上方新增選單列,全部功能依「檔案/視圖/量測/變換/工具」分類,不用再在一長排按鈕裡找。",
"• 原本的工具列變成「快速列」:只放常用按鈕。想放哪些自己決定 — 到「快速列 → 自訂按鈕」勾選,下次開啟會記住。",
"• 剖面的軸向/位置控制列改成只在開啟剖面時出現,平常不佔空間。",
"• 新增「關於」視窗(就是這個畫面),可以看版本紀錄。",
"Ui.About.Release071.Change1",
"Ui.About.Release071.Change2",
}),
new("0.6.0", new DateOnly(2026, 7, 4), "匯出 STL",
new("0.7.0", new DateOnly(2026, 7, 4), "Ui.About.Release070.Title",
new[]
{
"• 新增匯出 STL:把畫面上的零件(含對齊、旋轉後的位置)存成 STL 網格檔,可拿去 3D 列印或給其他軟體用。",
"• 匯出前會先跳出設定視窗:全部合併成一個檔或每個檔案各存一個、Binary 或文字格式、mm 或 inch、網格精細度 — 確認後才匯出。",
"• 設定視窗會即時顯示三角形數量與預估檔案大小。",
"Ui.About.Release070.Change1",
"Ui.About.Release070.Change2",
"Ui.About.Release070.Change3",
"Ui.About.Release070.Change4",
}),
new("0.5.0", new DateOnly(2026, 7, 3), "量測、視圖、樹狀面板大升級",
new("0.6.0", new DateOnly(2026, 7, 4), "Ui.About.Release060.Title",
new[]
{
"• 量距離時點到圓孔邊緣會自動吸到圓心 — 量兩孔中心距(pitch)超方便。",
"• 量測快速鍵:P 點、D 距離、E 邊、F 面、C 圓、A 角度、M 面距;Esc 取消。",
"• 樹狀面板可搜尋零件名稱;右鍵可「只顯示此節點」隔離觀察,也可以量體積與質心。",
"• 新增標準視圖(等角/前/上/右)與正交投影,量尺寸不受透視變形。",
"• 剖面新增「3點」模式:在模型上點 3 個點,就能沿任意斜面剖開。",
"• 新增匯出 STEP:把對齊好的零件位置存成新 STEP 檔,交接給正式 CAD。",
"• 會記住視窗大小、單位設定與最近開啟的 10 個檔案。",
"Ui.About.Release060.Change1",
"Ui.About.Release060.Change2",
"Ui.About.Release060.Change3",
}),
new("0.4.0", new DateOnly(2026, 7, 2), "更快、更穩",
new("0.5.0", new DateOnly(2026, 7, 3), "Ui.About.Release050.Title",
new[]
{
"• 大檔案拉剖面滑桿不再卡住畫面(改到背景計算)。",
"• 拖曳/操作器放開零件後的定位計算大幅加速。",
"• 程式遇到錯誤會提示訊息並繼續運作,不再整個閃退。",
"Ui.About.Release050.Change1",
"Ui.About.Release050.Change2",
"Ui.About.Release050.Change3",
"Ui.About.Release050.Change4",
"Ui.About.Release050.Change5",
"Ui.About.Release050.Change6",
"Ui.About.Release050.Change7",
}),
new("0.3.2", new DateOnly(2026, 6, 18), "大組件量測不再卡",
new("0.4.0", new DateOnly(2026, 7, 2), "Ui.About.Release040.Title",
new[]
{
"• 修正大型組件(數萬個面)進入量測模式後,轉動視角嚴重卡頓的問題 — 現在量測跟瀏覽一樣順。",
"Ui.About.Release040.Change1",
"Ui.About.Release040.Change2",
"Ui.About.Release040.Change3",
}),
new("0.3.1", new DateOnly(2026, 6, 13), "操作器永遠可見",
new("0.3.2", new DateOnly(2026, 6, 18), "Ui.About.Release032.Title",
new[]
{
"• 三軸操作器改成永遠浮在畫面最上層,不會被零件擋住,任何角度都抓得到。",
"Ui.About.Release032.Change1",
}),
new("0.3.0", new DateOnly(2026, 6, 13), "三軸操作器",
new("0.3.1", new DateOnly(2026, 6, 13), "Ui.About.Release031.Title",
new[]
{
"• 新增操作器:選取檔案後出現 XYZ 三色箭頭與旋轉環,箭頭沿軸精確移動、旋轉環繞軸轉任意角度。",
"Ui.About.Release031.Change1",
}),
new("0.2.0", new DateOnly(2026, 6, 13), "拖曳模式",
new("0.3.0", new DateOnly(2026, 6, 13), "Ui.About.Release030.Title",
new[]
{
"• 新增拖曳模式:左鍵按住零件直接拖到想要的位置,放開就定位。",
"• 0.2.1 修正拖曳過的零件再點擊會當掉的問題。",
"Ui.About.Release030.Change1",
}),
new("0.1.0", new DateOnly(2026, 6, 13), "首次釋出",
new("0.2.0", new DateOnly(2026, 6, 13), "Ui.About.Release020.Title",
new[]
{
"• STEP/STL/DXF 匯入與裝配樹、點/距離/邊/面/圓/角度/面距量測、兩點與三點對齊、干涉檢查、剖面、mm ⇄ inch、CSV 與截圖匯出。",
"Ui.About.Release020.Change1",
"Ui.About.Release020.Change2",
}),
new("0.1.0", new DateOnly(2026, 6, 13), "Ui.About.Release010.Title",
new[]
{
"Ui.About.Release010.Change1",
}),
};
}
+10 -10
View File
@@ -3,12 +3,13 @@ using System.IO;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using STPViewer.Services;
namespace STPViewer;
/// <summary>
/// 全域例外處理:UI 執行緒未攔截例外 → 記 log + 訊息框後盡量存活(不閃退);
/// 背景 Task / 非 UI 執行緒例外 → 記 log。log 位於 %LOCALAPPDATA%\STPViewer\error.log。
/// Log unhandled UI and background exceptions to
/// %LOCALAPPDATA%\STPViewer\error.log and show a message for UI errors.
/// </summary>
public partial class App : Application
{
@@ -18,28 +19,27 @@ public partial class App : Application
protected override void OnStartup(StartupEventArgs e)
{
LocalizationService.Instance.SetLanguage(SettingsService.Load().Language);
base.OnStartup(e);
// UI 執行緒(含 async void 回拋)的未攔截例外:記 log、提示、標記 Handled 讓程式存活。
// 若後續狀態已壞使用者可自行重啟;至少量測結果 / 已載模型不會直接蒸發。
// Report UI exceptions and keep the window open.
DispatcherUnhandledException += (_, args) =>
{
LogException("DispatcherUnhandledException", args.Exception);
MessageBox.Show(
$"發生未預期的錯誤,操作已中止(程式將嘗試繼續運作)。\n\n{args.Exception.Message}\n\n" +
$"詳細記錄:{LogPath}",
"STPViewer 錯誤", MessageBoxButton.OK, MessageBoxImage.Warning);
LocalizationService.Format("App.UnexpectedError", args.Exception.Message, LogPath),
LocalizationService.Get("App.ErrorTitle"), MessageBoxButton.OK, MessageBoxImage.Warning);
args.Handled = true;
};
// 沒被 await 的背景 Task 例外(finalizer 階段才浮出):記 log 並標記已觀察,避免行程終止
// Log unobserved task exceptions.
TaskScheduler.UnobservedTaskException += (_, args) =>
{
LogException("UnobservedTaskException", args.Exception);
args.SetObserved();
};
// 其他非 UI 執行緒的致命例外:無法阻止終止,但至少留下記錄
// Log fatal exceptions on non-UI threads.
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
LogException("AppDomainUnhandledException", args.ExceptionObject as Exception);
}
@@ -55,6 +55,6 @@ public partial class App : Application
sb.AppendLine(new string('-', 80));
File.AppendAllText(LogPath, sb.ToString(), Encoding.UTF8);
}
catch { /* logging 本身失敗不能再拋 */ }
catch { /* Logging must not throw from an exception handler. */ }
}
}
@@ -6,8 +6,8 @@ using STPViewer.Models;
namespace STPViewer.Converters;
/// <summary>
/// MeasureMode ↔ ToggleButton.IsChecked。
/// ConverterParameter 為模式名稱字串;取消勾選時回到 None。
/// Convert MeasureMode to ToggleButton.IsChecked using the mode name
/// in ConverterParameter. Unchecking returns to None.
/// </summary>
public class EnumToBoolConverter : IValueConverter
{
@@ -4,7 +4,7 @@ using System.Windows.Data;
namespace STPViewer.Converters;
/// <summary>完整路徑 → 檔名(選單「最近檔案」顯示用;tooltip 仍給完整路徑)</summary>
/// <summary>Show file names in the recent-files menu while retaining full paths as tooltips.</summary>
public class FileNameConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
@@ -0,0 +1,20 @@
using System.Windows.Data;
using System.Windows.Markup;
using STPViewer.Services;
namespace STPViewer.Localization;
[MarkupExtensionReturnType(typeof(string))]
public class LocExtension : MarkupExtension
{
public string Key { get; set; }
public LocExtension(string key) => Key = key;
public override object ProvideValue(IServiceProvider serviceProvider) =>
new Binding($"[{Key}]")
{
Source = LocalizationService.Instance,
Mode = BindingMode.OneWay,
}.ProvideValue(serviceProvider);
}
+157 -140
View File
@@ -4,7 +4,8 @@
xmlns:h="http://helix-toolkit.org/wpf"
xmlns:conv="clr-namespace:STPViewer.Converters"
xmlns:vm="clr-namespace:STPViewer.ViewModels"
Title="STPViewer — STP/STEP 3D 檢視器"
xmlns:loc="clr-namespace:STPViewer.Localization"
Title="{loc:Loc Ui.Main.WindowTitle}"
Height="820" Width="1320"
WindowStartupLocation="CenterScreen"
AllowDrop="True" Drop="Window_Drop" DragOver="Window_DragOver"
@@ -27,12 +28,12 @@
</Window.Resources>
<DockPanel>
<!-- ─── 選單列(全部功能,分類下拉)─── -->
<!-- Menu bar -->
<Menu DockPanel.Dock="Top">
<MenuItem Header="檔案(_F)">
<MenuItem Header="📂 匯入…" Command="{Binding ImportCommand}"
ToolTip="匯入 STP/STEP/STL/DXF(可複選,也可直接拖放到視窗)" />
<MenuItem Header="最近檔案" ItemsSource="{Binding RecentFiles}">
<MenuItem Header="{loc:Loc Ui.Main.FileF}">
<MenuItem Header="{loc:Loc Ui.Main.MenuImport}" Command="{Binding ImportCommand}"
ToolTip="{loc:Loc Ui.Main.ImportSTPSTEPSTLDXFFiles}" />
<MenuItem Header="{loc:Loc Ui.Main.RecentFiles}" ItemsSource="{Binding RecentFiles}">
<MenuItem.ItemContainerStyle>
<Style TargetType="MenuItem">
<Setter Property="Header" Value="{Binding Converter={StaticResource FileNameConv}}" />
@@ -44,83 +45,96 @@
</MenuItem.ItemContainerStyle>
</MenuItem>
<Separator />
<MenuItem Header="💾 匯出量測 CSV…" Command="{Binding ExportCsvCommand}" />
<MenuItem Header="📷 視圖截圖 PNG…" Command="{Binding SaveScreenshotCommand}"
ToolTip="3D 視圖截圖存 PNG(2x 解析度)" />
<MenuItem Header="📤 匯出 STEP…" Command="{Binding ExportStepFileCommand}"
ToolTip="把可見檔案(目前對齊位置)匯出成新 STEP 檔;不動原始檔" />
<MenuItem Header="📐 匯出 STL…" Command="{Binding ExportStlFileCommand}"
ToolTip="先設定範圍/格式/單位/精度,確認再匯出" />
<MenuItem Header="{loc:Loc Ui.Main.ExportMeasurementsCSV}" Command="{Binding ExportCsvCommand}" />
<MenuItem Header="{loc:Loc Ui.Main.SaveViewPNG}" Command="{Binding SaveScreenshotCommand}"
ToolTip="{loc:Loc Ui.Main.SavePNGScreenshot3DView2}" />
<MenuItem Header="{loc:Loc Ui.Main.ExportSTEP}" Command="{Binding ExportStepFileCommand}"
ToolTip="{loc:Loc Ui.Main.ExportStepTooltip}" />
<MenuItem Header="{loc:Loc Ui.Main.ExportSTL}" Command="{Binding ExportStlFileCommand}"
ToolTip="{loc:Loc Ui.Main.ChooseScopeFormatUnitsMeshQuality}" />
</MenuItem>
<MenuItem Header="視圖(_V)">
<MenuItem Header="🔍 縮放至全部" Command="{Binding ZoomAllCommand}" />
<MenuItem Header="{loc:Loc Ui.Main.ViewV}">
<MenuItem Header="{loc:Loc Ui.Main.ZoomFit}" Command="{Binding ZoomAllCommand}" />
<Separator />
<MenuItem Header="等角視圖" Command="{Binding SetStandardViewCommand}" CommandParameter="Iso" />
<MenuItem Header="前視圖" Command="{Binding SetStandardViewCommand}" CommandParameter="Front"
ToolTip="-Y 方向看,Z 向上" />
<MenuItem Header="上視圖" Command="{Binding SetStandardViewCommand}" CommandParameter="Top"
ToolTip="+Z 往下看" />
<MenuItem Header="右視圖" Command="{Binding SetStandardViewCommand}" CommandParameter="Right"
ToolTip="+X 方向看,Z 向上" />
<MenuItem Header="{loc:Loc Ui.Main.IsometricView}" Command="{Binding SetStandardViewCommand}" CommandParameter="Iso" />
<MenuItem Header="{loc:Loc Ui.Main.FrontView}" Command="{Binding SetStandardViewCommand}" CommandParameter="Front"
ToolTip="{loc:Loc Ui.Main.LookAlongYZUp}" />
<MenuItem Header="{loc:Loc Ui.Main.TopView}" Command="{Binding SetStandardViewCommand}" CommandParameter="Top"
ToolTip="{loc:Loc Ui.Main.LookDownFromZ}" />
<MenuItem Header="{loc:Loc Ui.Main.RightView}" Command="{Binding SetStandardViewCommand}" CommandParameter="Right"
ToolTip="{loc:Loc Ui.Main.LookAlongXZUp}" />
<Separator />
<MenuItem Header="正交投影" IsCheckable="True" IsChecked="{Binding OrthographicView}"
ToolTip="平行投影,量尺寸不受透視變形;取消勾選切回透視" />
<MenuItem Header="單位顯示 inch(mm ⇄ in)" IsCheckable="True" IsChecked="{Binding UseInch}"
ToolTip="既有量測即時換算;內部一律存 mm" />
<MenuItem Header="{loc:Loc Ui.Main.OrthographicProjection}" IsCheckable="True" IsChecked="{Binding OrthographicView}"
ToolTip="{loc:Loc Ui.Main.ParallelProjectionAvoidsPerspectiveDistortionUncheck}" />
<MenuItem Header="{loc:Loc Ui.Main.DisplayInchesMm}" IsCheckable="True" IsChecked="{Binding UseInch}"
ToolTip="{loc:Loc Ui.Main.ExistingMeasurementsUpdateImmediatelyValuesStored}" />
<Separator />
<MenuItem Header="{loc:Loc Ui.Language}" ItemsSource="{Binding Languages}">
<MenuItem.ItemContainerStyle>
<Style TargetType="MenuItem">
<Setter Property="Header" Value="{Binding Key}" />
<Setter Property="IsCheckable" Value="True" />
<Setter Property="IsChecked" Value="{Binding IsSelected, Mode=OneWay}" />
<Setter Property="Command"
Value="{Binding DataContext.ChangeLanguageCommand, RelativeSource={RelativeSource AncestorType=Menu}}" />
<Setter Property="CommandParameter" Value="{Binding Id}" />
</Style>
</MenuItem.ItemContainerStyle>
</MenuItem>
</MenuItem>
<MenuItem Header="量測(_M)">
<MenuItem Header="📍 點座標" IsCheckable="True" InputGestureText="P"
<MenuItem Header="{loc:Loc Ui.Main.MeasureM}">
<MenuItem Header="{loc:Loc Ui.Main.PointCoordinates}" IsCheckable="True" InputGestureText="P"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Point}"
ToolTip="量測點座標(吸附頂點/圓心)" />
<MenuItem Header="📏 兩點距離" IsCheckable="True" InputGestureText="D"
ToolTip="{loc:Loc Ui.Main.MeasurePointCoordinatesSnapsVerticesCircle}" />
<MenuItem Header="{loc:Loc Ui.Main.TwoPointDistance}" IsCheckable="True" InputGestureText="D"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Distance}"
ToolTip="兩點距離 + ΔXYZ(點到圓邊會吸圓心 → 可量孔對孔 pitch)" />
<MenuItem Header="📐 邊長" IsCheckable="True" InputGestureText="E"
ToolTip="{loc:Loc Ui.Main.DistanceXYZCircularEdgesSnapCenters}" />
<MenuItem Header="{loc:Loc Ui.Main.EdgeLength}" IsCheckable="True" InputGestureText="E"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Edge}"
ToolTip="邊長(直線/曲線/圓弧)" />
<MenuItem Header="⬛ 面" IsCheckable="True" InputGestureText="F"
ToolTip="{loc:Loc Ui.Main.MeasureStraightCurvedCircularEdgeLengths}" />
<MenuItem Header="{loc:Loc Ui.Main.Face}" IsCheckable="True" InputGestureText="F"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Face}"
ToolTip="面積 + 曲面類型/法向量" />
<MenuItem Header="⭕ 圓" IsCheckable="True" InputGestureText="C"
ToolTip="{loc:Loc Ui.Main.AreaSurfaceTypeNormalVector}" />
<MenuItem Header="{loc:Loc Ui.Main.Circle}" IsCheckable="True" InputGestureText="C"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Circle}"
ToolTip="圓心/半徑/直徑/周長" />
<MenuItem Header="∠ 角度" IsCheckable="True" InputGestureText="A"
ToolTip="{loc:Loc Ui.Main.CenterRadiusDiameterCircumference}" />
<MenuItem Header="{loc:Loc Ui.Main.Angle}" IsCheckable="True" InputGestureText="A"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Angle}"
ToolTip="兩面/兩邊夾角(點兩個面或靠近直線邊)" />
<MenuItem Header="⇔ 面距" IsCheckable="True" InputGestureText="M"
ToolTip="{loc:Loc Ui.Main.AngleBetweenTwoFacesStraightEdges}" />
<MenuItem Header="{loc:Loc Ui.Main.FaceDistance}" IsCheckable="True" InputGestureText="M"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=FaceDistance}"
ToolTip="面到面最短距離(網格近似)" />
ToolTip="{loc:Loc Ui.Main.ShortestDistanceBetweenFacesMeshApproximation}" />
<Separator />
<MenuItem Header="🧹 清除全部量測" Command="{Binding ClearMeasurementsCommand}"
InputGestureText="Esc" ToolTip="Esc = 取消進行中量測/退出模式" />
<MenuItem Header="{loc:Loc Ui.Main.ClearAllMeasurements}" Command="{Binding ClearMeasurementsCommand}"
InputGestureText="Esc" ToolTip="{loc:Loc Ui.Main.EscCancelsCurrentMeasurementExitsMode}" />
</MenuItem>
<MenuItem Header="變換(_T)">
<MenuItem Header="🎯 兩點對齊(平移)" IsCheckable="True"
<MenuItem Header="{loc:Loc Ui.Main.TransformT}">
<MenuItem Header="{loc:Loc Ui.Main.TwoPointAlignmentTranslate}" IsCheckable="True"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Align}"
ToolTip="先點要移動零件上的點,再點目標點" />
<MenuItem Header="🎯 三點對齊(旋轉+平移)" IsCheckable="True"
ToolTip="{loc:Loc Ui.Main.PickPointPartMoveThenPick}" />
<MenuItem Header="{loc:Loc Ui.Main.ThreePointAlignmentRotateTranslate}" IsCheckable="True"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Align3}"
ToolTip="先在要移動的檔案點 3 個特徵點,再到目標檔案點 3 個對應點(順序對應)" />
<MenuItem Header="🖐 拖曳模式" IsCheckable="True"
ToolTip="{loc:Loc Ui.Main.PickThreePointsMovingModelThen}" />
<MenuItem Header="{loc:Loc Ui.Main.DragMode}" IsCheckable="True"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Drag}"
ToolTip="左鍵按住零件沿螢幕平面拖動,放開定位" />
<MenuItem Header="⊹ 三軸操作器" IsCheckable="True" IsChecked="{Binding GizmoEnabled}"
ToolTip="對樹面板選取的檔案顯示 XYZ 箭頭與旋轉環,每次放開即定位" />
ToolTip="{loc:Loc Ui.Main.HoldLeftMouseButtonMovePart}" />
<MenuItem Header="{loc:Loc Ui.Main.3AxisGizmo}" IsCheckable="True" IsChecked="{Binding GizmoEnabled}"
ToolTip="{loc:Loc Ui.Main.ShowXYZArrowsRotationRingsSelected}" />
<Separator />
<MenuItem Header="↻ 繞 X 軸 +90°" Command="{Binding RotateRootCommand}" CommandParameter="X"
ToolTip="對樹面板選取的檔案,繞其中心旋轉(先在樹面板點選檔案)" />
<MenuItem Header="↻ 繞 Y 軸 +90°" Command="{Binding RotateRootCommand}" CommandParameter="Y" />
<MenuItem Header="↻ 繞 Z 軸 +90°" Command="{Binding RotateRootCommand}" CommandParameter="Z" />
<MenuItem Header="{loc:Loc Ui.Main.Rotate90AroundX}" Command="{Binding RotateRootCommand}" CommandParameter="X"
ToolTip="{loc:Loc Ui.Main.RotateModelSelectedTreeAroundIts}" />
<MenuItem Header="{loc:Loc Ui.Main.Rotate90AroundY}" Command="{Binding RotateRootCommand}" CommandParameter="Y" />
<MenuItem Header="{loc:Loc Ui.Main.Rotate90AroundZ}" Command="{Binding RotateRootCommand}" CommandParameter="Z" />
</MenuItem>
<MenuItem Header="工具(_L)">
<MenuItem Header="🧩 干涉檢查" Command="{Binding CheckInterferenceCommand}"
ToolTip="2 個以上可見檔案兩兩檢查是否相交;無相交時回報最小間隙" />
<MenuItem Header="✂ 剖面檢視" IsCheckable="True" IsChecked="{Binding SectionEnabled}"
ToolTip="開啟後下方出現剖面參數列(軸向/位置/反向)" />
<MenuItem Header="{loc:Loc Ui.Main.ToolsL}">
<MenuItem Header="{loc:Loc Ui.Main.InterferenceCheck}" Command="{Binding CheckInterferenceCommand}"
ToolTip="{loc:Loc Ui.Main.CheckVisibleModelPairsIntersectionsReport}" />
<MenuItem Header="{loc:Loc Ui.Main.SectionView}" IsCheckable="True" IsChecked="{Binding SectionEnabled}"
ToolTip="{loc:Loc Ui.Main.ShowSectionControlsAxisPositionDirection}" />
</MenuItem>
<MenuItem Header="快速列(_Q)">
<MenuItem Header="自訂按鈕" ItemsSource="{Binding QuickBar.Items}"
ToolTip="勾選要顯示在快速列的常用按鈕(隨設定保存)">
<MenuItem Header="{loc:Loc Ui.Main.QuickBarQ}">
<MenuItem Header="{loc:Loc Ui.Main.CustomizeButtons}" ItemsSource="{Binding QuickBar.Items}"
ToolTip="{loc:Loc Ui.Main.CheckButtonsShowQuickBarSaved}">
<MenuItem.ItemContainerStyle>
<Style TargetType="MenuItem">
<Setter Property="Header" Value="{Binding Label}" />
@@ -131,138 +145,142 @@
</MenuItem.ItemContainerStyle>
</MenuItem>
<Separator />
<MenuItem Header="恢復預設按鈕" Command="{Binding QuickBar.ResetDefaultsCommand}" />
<MenuItem Header="{loc:Loc Ui.Main.RestoreDefaultButtons}" Command="{Binding QuickBar.ResetDefaultsCommand}" />
</MenuItem>
<MenuItem Header="說明(_H)">
<MenuItem Header="ℹ 關於 STPViewer…" Click="About_Click"
ToolTip="程式資訊與版本紀錄" />
<MenuItem Header="{loc:Loc Ui.Main.HelpH}">
<MenuItem Header="{loc:Loc Ui.Main.IAboutSTPViewer}" Click="About_Click"
ToolTip="{loc:Loc Ui.Main.ApplicationInformationReleaseHistory}" />
</MenuItem>
</Menu>
<!-- ─── 快速工具列(可自訂:選單「快速列 → 自訂按鈕」勾選)─── -->
<!-- Customizable Quick Bar -->
<ToolBarTray DockPanel.Dock="Top">
<ToolBar>
<Button Style="{StaticResource QuickButton}" Command="{Binding ImportCommand}"
Visibility="{Binding QuickBar[Import].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="匯入 STP/STEP/STL/DXF(可複選,也可直接拖放到視窗)">📂 匯入</Button>
ToolTip="{loc:Loc Ui.Main.ImportSTPSTEPSTLDXFFiles}" Content="{loc:Loc Ui.Main.ToolbarImport}" />
<Button Style="{StaticResource QuickButton}" Click="Recent_Click" Padding="4,4"
Visibility="{Binding QuickBar[Recent].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="最近開啟的檔案">▾</Button>
ToolTip="{loc:Loc Ui.Main.RecentlyOpenedFiles}">▾</Button>
<Button Style="{StaticResource QuickButton}" Command="{Binding ZoomAllCommand}"
Visibility="{Binding QuickBar[ZoomAll].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="縮放至全部模型">🔍 全覽</Button>
ToolTip="{loc:Loc Ui.Main.FitAllModelsView}" Content="{loc:Loc Ui.Main.Fit}" />
<Button Style="{StaticResource QuickButton}" Command="{Binding SetStandardViewCommand}" CommandParameter="Iso"
Visibility="{Binding QuickBar[ViewIso].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="等角視圖">等角</Button>
ToolTip="{loc:Loc Ui.Main.IsometricView}" Content="{loc:Loc Ui.Main.Iso}" />
<Button Style="{StaticResource QuickButton}" Command="{Binding SetStandardViewCommand}" CommandParameter="Front"
Visibility="{Binding QuickBar[ViewFront].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="前視圖(-Y 方向看,Z 向上)">前</Button>
ToolTip="{loc:Loc Ui.Main.FrontViewLookAlongYZ}" Content="{loc:Loc Ui.Main.Front}" />
<Button Style="{StaticResource QuickButton}" Command="{Binding SetStandardViewCommand}" CommandParameter="Top"
Visibility="{Binding QuickBar[ViewTop].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="上視圖(+Z 往下看)">上</Button>
ToolTip="{loc:Loc Ui.Main.TopViewLookDownFromZ}" Content="{loc:Loc Ui.Main.Top}" />
<Button Style="{StaticResource QuickButton}" Command="{Binding SetStandardViewCommand}" CommandParameter="Right"
Visibility="{Binding QuickBar[ViewRight].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="右視圖(+X 方向看,Z 向上)">右</Button>
ToolTip="{loc:Loc Ui.Main.RightViewLookAlongXZ}" Content="{loc:Loc Ui.Main.Right}" />
<ToggleButton Style="{StaticResource ModeButton}" IsChecked="{Binding OrthographicView}"
Visibility="{Binding QuickBar[Ortho].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="正交投影(平行投影,量尺寸不受透視變形;再按切回透視)">正交</ToggleButton>
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="量測點座標(吸附頂點/圓心)— 快速鍵 P"
ToolTip="{loc:Loc Ui.Main.OrthographicProjectionAvoidsPerspectiveDistortionClick}" Content="{loc:Loc Ui.Main.Ortho}" />
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="{loc:Loc Ui.Main.PointCoordinatesVertexCircleCenterSnapping}"
Visibility="{Binding QuickBar[Point].IsChecked, Converter={StaticResource BoolToVis}}"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Point}">📍 點</ToggleButton>
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="兩點距離 + ΔXYZ(點到圓邊會吸圓心 → 可量孔對孔 pitch)— 快速鍵 D"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Point}" Content="{loc:Loc Ui.Main.Point}" />
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="{loc:Loc Ui.Main.DistanceXYZCircleCenterSnappingHole}"
Visibility="{Binding QuickBar[Distance].IsChecked, Converter={StaticResource BoolToVis}}"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Distance}">📏 距離</ToggleButton>
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="邊長(直線/曲線/圓弧)— 快速鍵 E"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Distance}" Content="{loc:Loc Ui.Main.Distance}" />
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="{loc:Loc Ui.Main.StraightCurvedCircularEdgeLengthShortcut}"
Visibility="{Binding QuickBar[Edge].IsChecked, Converter={StaticResource BoolToVis}}"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Edge}">📐 邊</ToggleButton>
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="面積 + 曲面類型/法向量 — 快速鍵 F"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Edge}" Content="{loc:Loc Ui.Main.Edge}" />
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="{loc:Loc Ui.Main.AreaSurfaceTypeNormalVectorShortcut}"
Visibility="{Binding QuickBar[Face].IsChecked, Converter={StaticResource BoolToVis}}"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Face}">⬛ 面</ToggleButton>
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="圓心/半徑/直徑/周長 — 快速鍵 C"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Face}" Content="{loc:Loc Ui.Main.Face}" />
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="{loc:Loc Ui.Main.CenterRadiusDiameterCircumferenceShortcutC}"
Visibility="{Binding QuickBar[Circle].IsChecked, Converter={StaticResource BoolToVis}}"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Circle}">⭕ 圓</ToggleButton>
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="兩面/兩邊夾角(點兩個面或靠近直線邊)— 快速鍵 A"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Circle}" Content="{loc:Loc Ui.Main.Circle}" />
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="{loc:Loc Ui.Main.AngleBetweenFacesStraightEdgesShortcut}"
Visibility="{Binding QuickBar[Angle].IsChecked, Converter={StaticResource BoolToVis}}"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Angle}">∠ 角度</ToggleButton>
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="面到面最短距離(網格近似)— 快速鍵 M"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Angle}" Content="{loc:Loc Ui.Main.Angle}" />
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="{loc:Loc Ui.Main.ShortestFaceDistanceMeshApproximationShortcut}"
Visibility="{Binding QuickBar[FaceDistance].IsChecked, Converter={StaticResource BoolToVis}}"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=FaceDistance}">⇔ 面距</ToggleButton>
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=FaceDistance}" Content="{loc:Loc Ui.Main.FaceDistance}" />
<ToggleButton Style="{StaticResource ModeButton}"
ToolTip="兩點對齊(平移零件):先點要移動零件上的點,再點目標點"
ToolTip="{loc:Loc Ui.Main.TwoPointAlignmentPickPointMoving}"
Visibility="{Binding QuickBar[Align].IsChecked, Converter={StaticResource BoolToVis}}"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Align}">🎯 對齊</ToggleButton>
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Align}" Content="{loc:Loc Ui.Main.Align}" />
<ToggleButton Style="{StaticResource ModeButton}"
ToolTip="三點對齊(旋轉+平移):先在要移動的檔案點 3 個特徵點,再到目標檔案點 3 個對應點(順序對應)"
ToolTip="{loc:Loc Ui.Main.ThreePointAlignmentPickThreePoints}"
Visibility="{Binding QuickBar[Align3].IsChecked, Converter={StaticResource BoolToVis}}"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Align3}">🎯 三點</ToggleButton>
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Align3}" Content="{loc:Loc Ui.Main.3Point}" />
<Button Style="{StaticResource QuickButton}" Command="{Binding RotateRootCommand}" CommandParameter="X"
Visibility="{Binding QuickBar[RotateX].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="選取的檔案繞 X 軸 +90°(先在樹面板點選檔案)">↻X</Button>
ToolTip="{loc:Loc Ui.Main.RotateModelSelectedTree90Around}">↻X</Button>
<Button Style="{StaticResource QuickButton}" Command="{Binding RotateRootCommand}" CommandParameter="Y"
Visibility="{Binding QuickBar[RotateY].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="選取的檔案繞 Y 軸 +90°">↻Y</Button>
ToolTip="{loc:Loc Ui.Main.RotateSelectedModel90AroundY}">↻Y</Button>
<Button Style="{StaticResource QuickButton}" Command="{Binding RotateRootCommand}" CommandParameter="Z"
Visibility="{Binding QuickBar[RotateZ].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="選取的檔案繞 Z 軸 +90°">↻Z</Button>
ToolTip="{loc:Loc Ui.Main.RotateSelectedModel90AroundZ}">↻Z</Button>
<ToggleButton Style="{StaticResource ModeButton}"
ToolTip="拖曳模式:左鍵按住零件沿螢幕平面拖動,放開定位(深度方向先轉視角再拖)"
ToolTip="{loc:Loc Ui.Main.DragModeHoldLeftMouseButton}"
Visibility="{Binding QuickBar[Drag].IsChecked, Converter={StaticResource BoolToVis}}"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Drag}">🖐 拖曳</ToggleButton>
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Drag}" Content="{loc:Loc Ui.Main.Drag}" />
<ToggleButton Style="{StaticResource ModeButton}" IsChecked="{Binding GizmoEnabled}"
Visibility="{Binding QuickBar[Gizmo].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="三軸操作器:對樹面板選取的檔案顯示 XYZ 箭頭(沿軸移動)與旋轉環(繞軸轉任意角度),每次放開即定位">⊹ 操作器</ToggleButton>
ToolTip="{loc:Loc Ui.Main.3AxisGizmoUseXYZArrows}" Content="{loc:Loc Ui.Main.Gizmo}" />
<Button Style="{StaticResource QuickButton}" Command="{Binding CheckInterferenceCommand}"
Visibility="{Binding QuickBar[Interference].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="干涉檢查:2 個以上可見檔案兩兩檢查是否相交(match 判定);無相交時回報最小間隙">🧩 干涉</Button>
ToolTip="{loc:Loc Ui.Main.InterferenceCheckTestVisibleModelPairs}" Content="{loc:Loc Ui.Main.Interference}" />
<ToggleButton Style="{StaticResource ModeButton}" IsChecked="{Binding SectionEnabled}"
Visibility="{Binding QuickBar[Section].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="開啟/關閉剖面檢視(開啟後下方出現參數列)">✂ 剖面</ToggleButton>
ToolTip="{loc:Loc Ui.Main.ToggleSectionViewItsControls}" Content="{loc:Loc Ui.Main.ToolbarSection}" />
<Button Style="{StaticResource QuickButton}" Command="{Binding ClearMeasurementsCommand}"
Visibility="{Binding QuickBar[Clear].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="清除全部量測與標記(Esc = 取消進行中量測/退出模式)">🧹 清除</Button>
ToolTip="{loc:Loc Ui.Main.ClearAllMeasurementsMarkersEscCancels}" Content="{loc:Loc Ui.Main.Clear}" />
<ToggleButton Style="{StaticResource ModeButton}" IsChecked="{Binding UseInch}"
Visibility="{Binding QuickBar[Unit].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="切換顯示單位 mm ⇄ inch(既有量測即時換算)">mm ⇄ in</ToggleButton>
ToolTip="{loc:Loc Ui.Main.SwitchDisplayUnitsBetweenMmInches}">mm ⇄ in</ToggleButton>
<Button Style="{StaticResource QuickButton}" Command="{Binding ExportCsvCommand}"
Visibility="{Binding QuickBar[ExportCsv].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="量測結果匯出 CSV">💾 CSV</Button>
ToolTip="{loc:Loc Ui.Main.ExportMeasurementResultsCSV}">💾 CSV</Button>
<Button Style="{StaticResource QuickButton}" Command="{Binding SaveScreenshotCommand}"
Visibility="{Binding QuickBar[Screenshot].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="3D 視圖截圖存 PNG(2x 解析度)">📷 截圖</Button>
ToolTip="{loc:Loc Ui.Main.SavePNGScreenshot3DView2}" Content="{loc:Loc Ui.Main.Capture}" />
<Button Style="{StaticResource QuickButton}" Command="{Binding ExportStepFileCommand}"
Visibility="{Binding QuickBar[ExportStep].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="把可見檔案(目前對齊位置)匯出成新 STEP 檔 — 對齊插合結果可交接 CAD;不動原始檔">📤 STEP</Button>
ToolTip="{loc:Loc Ui.Main.ToolbarExportStepTooltip}">📤 STEP</Button>
<Button Style="{StaticResource QuickButton}" Command="{Binding ExportStlFileCommand}"
Visibility="{Binding QuickBar[ExportStl].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="把可見檔案(目前位置)匯出成 STL 網格檔 — 先設定範圍/格式/單位/精度,確認再匯出">📐 STL</Button>
ToolTip="{loc:Loc Ui.Main.ToolbarExportStlTooltip}">📐 STL</Button>
<ComboBox ItemsSource="{Binding Languages}" DisplayMemberPath="Key"
SelectedValue="{Binding SelectedLanguage}" SelectedValuePath="Id"
Visibility="{Binding QuickBar[Language].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="{loc:Loc Ui.Language}" Margin="2,0" />
</ToolBar>
</ToolBarTray>
<!-- ─── 剖面參數列(只在剖面開啟時出現)─── -->
<!-- Section controls -->
<ToolBarTray DockPanel.Dock="Top"
Visibility="{Binding SectionEnabled, Converter={StaticResource BoolToVis}}">
<ToolBar>
<TextBlock Text="✂ 剖面:" VerticalAlignment="Center" Margin="4,0,2,0" />
<TextBlock Text="{loc:Loc Ui.Main.SectionLabel}" VerticalAlignment="Center" Margin="4,0,2,0" />
<ComboBox SelectedIndex="{Binding SectionAxisIndex}" Width="60" Margin="4,0"
VerticalAlignment="Center" ToolTip="剖切軸向(3點 = 在模型上點 3 點定義任意平面)">
VerticalAlignment="Center" ToolTip="{loc:Loc Ui.Main.SectionAxis3PointsDefineAn}">
<ComboBoxItem>X</ComboBoxItem>
<ComboBoxItem>Y</ComboBoxItem>
<ComboBoxItem>Z</ComboBoxItem>
<ComboBoxItem>3點</ComboBoxItem>
<ComboBoxItem Content="{loc:Loc Ui.Main.3Points}" />
</ComboBox>
<Slider Value="{Binding SectionPosition}" Minimum="0" Maximum="100" Width="180"
VerticalAlignment="Center" Margin="4,0" ToolTip="剖面位置(沿法向 %)" />
VerticalAlignment="Center" Margin="4,0" ToolTip="{loc:Loc Ui.Main.SectionPositionAlongNormal}" />
<TextBox Text="{Binding SectionPosition, StringFormat={}{0:F0}}" Width="34"
VerticalAlignment="Center" ToolTip="剖面位置 %(0–100,可直接輸入,離開欄位生效)" />
VerticalAlignment="Center" ToolTip="{loc:Loc Ui.Main.SectionPosition0100EnterValue}" />
<TextBlock Text="%" VerticalAlignment="Center" Margin="1,0,0,0" />
<CheckBox IsChecked="{Binding SectionFlip}" Content="反向" VerticalAlignment="Center"
Margin="4,0" ToolTip="切換保留側" />
<CheckBox IsChecked="{Binding SectionFlip}" Content="{loc:Loc Ui.Main.Flip}" VerticalAlignment="Center"
Margin="4,0" ToolTip="{loc:Loc Ui.Main.SwitchRetainedSide}" />
<Button Style="{StaticResource QuickButton}" Command="{Binding CloseSectionCommand}"
ToolTip="關閉剖面檢視">✕ 關閉</Button>
ToolTip="{loc:Loc Ui.Main.CloseSectionView}" Content="{loc:Loc Ui.Main.Close}" />
</ToolBar>
</ToolBarTray>
<!-- ─── 狀態列 ─── -->
<!-- Status bar -->
<StatusBar DockPanel.Dock="Bottom">
<StatusBarItem>
<TextBlock Text="{Binding StatusText}" />
@@ -282,12 +300,12 @@
<ColumnDefinition Width="300" MinWidth="160" />
</Grid.ColumnDefinitions>
<!-- ─── 裝配樹面板 ─── -->
<GroupBox Grid.Column="0" Header="模型 / 裝配樹" Margin="4">
<!-- Assembly tree -->
<GroupBox Grid.Column="0" Header="{loc:Loc Ui.Main.ModelsAssemblyTree}" Margin="4">
<DockPanel>
<TextBox DockPanel.Dock="Top" Margin="0,2,0,4"
Text="{Binding TreeFilter, UpdateSourceTrigger=PropertyChanged}"
ToolTip="輸入名稱過濾裝配樹(只影響樹面板顯示,不影響 3D 可見性;清空 = 全顯)" />
ToolTip="{loc:Loc Ui.Main.FilterAssemblyTreeByNameDoes}" />
<TreeView ItemsSource="{Binding Roots}"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
VirtualizingPanel.IsVirtualizing="True"
@@ -308,23 +326,23 @@
Tag="{Binding DataContext, RelativeSource={RelativeSource AncestorType=Window}}">
<StackPanel.ContextMenu>
<ContextMenu>
<MenuItem Header="👁 只顯示此節點"
<MenuItem Header="{loc:Loc Ui.Main.ShowOnlyThisNode}"
Command="{Binding PlacementTarget.Tag.IsolateNodeCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
CommandParameter="{Binding}" />
<MenuItem Header="🔄 反轉顯示"
<MenuItem Header="{loc:Loc Ui.Main.InvertVisibility}"
Command="{Binding PlacementTarget.Tag.InvertVisibilityCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
<MenuItem Header="☀ 全部顯示"
<MenuItem Header="{loc:Loc Ui.Main.ShowAll}"
Command="{Binding PlacementTarget.Tag.ShowAllCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
<Separator />
<MenuItem Header="⚖ 量體積 / 質心"
<MenuItem Header="{loc:Loc Ui.Main.MeasureVolumeCentroid}"
Command="{Binding PlacementTarget.Tag.MeasureVolumeCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
CommandParameter="{Binding}" />
</ContextMenu>
</StackPanel.ContextMenu>
<CheckBox IsChecked="{Binding IsVisible}" VerticalAlignment="Center"
ToolTip="顯示 / 隱藏(含子節點)" />
ToolTip="{loc:Loc Ui.Main.ShowHideIncludingChildNodes}" />
<Button Width="14" Height="14" Margin="3,0"
VerticalAlignment="Center" ToolTip="點擊換色(含子節點)"
VerticalAlignment="Center" ToolTip="{loc:Loc Ui.Main.ClickChangeColorIncludingChildNodes}"
Command="{Binding CycleColorCommand}">
<Button.Template>
<ControlTemplate TargetType="Button">
@@ -348,16 +366,16 @@
</Style>
</TextBlock.Style>
</TextBlock>
<CheckBox IsChecked="{Binding ShowEdges}" Content="邊" FontSize="10"
<CheckBox IsChecked="{Binding ShowEdges}" Content="{loc:Loc Ui.Main.Edges}" FontSize="10"
VerticalAlignment="Center" Margin="4,0,0,0"
Visibility="{Binding IsRoot, Converter={StaticResource BoolToVis}}"
ToolTip="顯示輪廓邊線(邊線量大時轉動視角會變慢)" />
ToolTip="{loc:Loc Ui.Main.ShowOutlineEdgesManyEdgesCan}" />
<Button Content="🔍" Width="20" Margin="3,0,0,0" Padding="0"
VerticalAlignment="Center" ToolTip="縮放至此節點"
VerticalAlignment="Center" ToolTip="{loc:Loc Ui.Main.ZoomThisNode}"
Command="{Binding DataContext.ZoomNodeCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}" />
<Button Content="✕" Width="20" Padding="0" Margin="2,0,0,0"
VerticalAlignment="Center" ToolTip="移除此檔案"
VerticalAlignment="Center" ToolTip="{loc:Loc Ui.Main.RemoveThisFile}"
Visibility="{Binding IsRoot, Converter={StaticResource BoolToVis}}"
Command="{Binding DataContext.RemoveRootCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}" />
@@ -370,9 +388,9 @@
<GridSplitter Grid.Column="1" Width="5" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" Background="#FFD8D8D8"
ToolTip="拖曳調整面板寬度" />
ToolTip="{loc:Loc Ui.Main.DragResizePanel}" />
<!-- ─── 3D 視圖 ─── -->
<!-- 3D viewport -->
<h:HelixViewport3D x:Name="viewport" Grid.Column="2"
ShowViewCube="True" ShowCoordinateSystem="True"
ShowFrameRate="False" ZoomExtentsWhenLoaded="True"
@@ -400,17 +418,16 @@
<h:DefaultLights />
</h:HelixViewport3D>
<!-- 操作器疊圖層:透明 Viewport3D 疊在主視窗上、永遠最上層。
只在操作器開啟時 IsHitTestVisible(否則空白處滑鼠才會穿透回主視窗) -->
<!-- Transparent gizmo overlay above the main viewport. -->
<Viewport3D x:Name="gizmoOverlay" Grid.Column="2"
IsHitTestVisible="{Binding GizmoEnabled}" />
<GridSplitter Grid.Column="3" Width="5" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" Background="#FFD8D8D8"
ToolTip="拖曳調整面板寬度" />
ToolTip="{loc:Loc Ui.Main.DragResizePanel}" />
<!-- ─── 量測結果面板 ─── -->
<GroupBox Grid.Column="4" Header="量測結果" Margin="4">
<!-- Measurement results -->
<GroupBox Grid.Column="4" Header="{loc:Loc Ui.Main.Measurements}" Margin="4">
<ListBox ItemsSource="{Binding Measurements}" HorizontalContentAlignment="Stretch"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListBox.ItemTemplate>
@@ -426,7 +443,7 @@
TextWrapping="Wrap" />
</StackPanel>
<Button Grid.Column="1" Content="✕" Width="22" Height="22"
VerticalAlignment="Top" ToolTip="刪除此筆量測"
VerticalAlignment="Top" ToolTip="{loc:Loc Ui.Main.DeleteThisMeasurement}"
Command="{Binding DataContext.RemoveMeasurementCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}" />
</Grid>
+27 -13
View File
@@ -18,25 +18,39 @@ public partial class MainWindow : Window
public MainWindow()
{
InitializeComponent();
// 版號單一來源 = csproj <Version>;標題執行時讀,避免寫死
var v = Assembly.GetExecutingAssembly().GetName().Version!;
Title = $"STPViewer V{v.Major}.{v.Minor}.{v.Build} — STP/STEP 3D 檢視器";
// Read the version from the project at runtime.
UpdateTitle();
DataContext = _vm;
_vm.Attach(viewport);
_vm.AttachOverlay(gizmoOverlay);
Loaded += MainWindow_Loaded;
Closing += MainWindow_Closing;
// 使用者設定:視窗位置大小(View 職責)+ 單位/MRU(VM 職責)
// Restore window settings here and units/recent files in the view model.
_settings = SettingsService.Load();
ApplyWindowSettings();
_vm.LoadSettings(_settings);
_vm.InitializeLocalization();
LocalizationService.Instance.LanguageChanged += LanguageChanged;
Closed += (_, _) =>
{
LocalizationService.Instance.LanguageChanged -= LanguageChanged;
_vm.DisposeLocalization();
};
// Gizmo 操作器放在疊圖層;manipulator 會把 MouseUp 標 handled,需 handledEventsToo 才收得到
// The gizmo overlay needs handled mouse-up events from the manipulator.
gizmoOverlay.AddHandler(MouseLeftButtonUpEvent,
new MouseButtonEventHandler((_, _) => _vm.OnGizmoMouseUp()), handledEventsToo: true);
}
private void LanguageChanged(object? sender, System.EventArgs e) => UpdateTitle();
private void UpdateTitle()
{
var v = Assembly.GetExecutingAssembly().GetName().Version!;
Title = LocalizationService.Format("App.WindowTitle", $"{v.Major}.{v.Minor}.{v.Build}");
}
private void ApplyWindowSettings()
{
if (_settings.WindowWidth >= 400 && _settings.WindowHeight >= 300)
@@ -44,7 +58,7 @@ public partial class MainWindow : Window
Width = _settings.WindowWidth;
Height = _settings.WindowHeight;
}
// 位置要落在目前虛擬桌面內才還原(避免拔掉外接螢幕後視窗開在螢幕外)
// Restore the position only when it is within the current virtual desktop.
if (!double.IsNaN(_settings.WindowLeft) && !double.IsNaN(_settings.WindowTop) &&
_settings.WindowLeft >= SystemParameters.VirtualScreenLeft - 50 &&
_settings.WindowTop >= SystemParameters.VirtualScreenTop - 50 &&
@@ -73,7 +87,7 @@ public partial class MainWindow : Window
SettingsService.Save(_settings);
}
/// <summary>支援命令列帶檔開啟:STPViewer.exe a.stp b.stl …</summary>
/// <summary>Open files passed on the command line: STPViewer.exe a.stp b.stl …</summary>
private async void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
var files = System.Environment.GetCommandLineArgs().Skip(1)
@@ -82,7 +96,7 @@ public partial class MainWindow : Window
await _vm.ImportFilesAsync(files);
}
/// <summary>Esc / 量測模式快速鍵(焦點在輸入框時不攔截,樹搜尋、剖面數值可正常打字)</summary>
/// <summary>Handle Esc and measurement shortcuts without intercepting text inputs.</summary>
private void Window_PreviewKeyDown(object sender, KeyEventArgs e)
{
if (Keyboard.FocusedElement is TextBoxBase or ComboBox) return;
@@ -97,7 +111,7 @@ public partial class MainWindow : Window
e.Handled = true;
}
/// <summary>最近檔案下拉(▾):即時從 VM 的 MRU 清單組選單</summary>
/// <summary>Build the recent-files menu from the view model's current list.</summary>
private void Recent_Click(object sender, RoutedEventArgs e)
{
var menu = new ContextMenu
@@ -107,7 +121,7 @@ public partial class MainWindow : Window
};
if (_vm.RecentFiles.Count == 0)
{
menu.Items.Add(new MenuItem { Header = "(沒有最近開啟的檔案)", IsEnabled = false });
menu.Items.Add(new MenuItem { Header = LocalizationService.Get("App.NoRecentFiles"), IsEnabled = false });
}
else
{
@@ -131,14 +145,14 @@ public partial class MainWindow : Window
private void Viewport_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
// Shift+左鍵 = 平移(PanGesture2),不觸發量測/拖曳
// Shift+left-click pans the view.
if (Keyboard.Modifiers.HasFlag(ModifierKeys.Shift)) return;
if (_vm.CurrentMode == STPViewer.Models.MeasureMode.Drag)
{
if (_vm.OnDragStart(e.GetPosition(viewport.Viewport)))
{
viewport.CaptureMouse(); // 拖出視窗外也持續收到 Move/Up
viewport.CaptureMouse(); // Keep receiving move/up events outside the window.
e.Handled = true;
}
return;
@@ -159,7 +173,7 @@ public partial class MainWindow : Window
}
private void Viewport_LostMouseCapture(object sender, MouseEventArgs e) =>
_vm.OnDragEnd(); // 捕捉意外中斷(Alt+Tab 等)→ 以目前位置定格,不留懸空 Transform
_vm.OnDragEnd(); // Commit the current position if capture is interrupted.
private void Window_DragOver(object sender, DragEventArgs e)
{
+3 -3
View File
@@ -4,13 +4,13 @@ using STPViewer.ViewModels;
namespace STPViewer.Models;
/// <summary>渲染物件(GeometryModel3D)反查 B-rep Face 的對照資料</summary>
/// <summary>Maps a rendered GeometryModel3D back to a B-rep face.</summary>
public class FaceInfo
{
/// <summary>B-rep 面;STL 等純網格來源為 null(僅支援點/距離/面距量測)</summary>
/// <summary>B-rep face, or null for mesh-only sources such as STL.</summary>
public Face? BrepFace { get; init; }
/// <summary>原始(未剖切)三角網格 — 面積計算與剖面還原用;零件平移後會換成新 mesh</summary>
/// <summary>Uncut mesh used for area and section restoration; replaced after translation.</summary>
public required MeshGeometry3D Mesh { get; set; }
public required ModelNodeViewModel Owner { get; init; }
@@ -0,0 +1,24 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace STPViewer.Models;
public class LanguageDefinition : ObservableObject
{
public string Id { get; }
public string Key { get; }
public IReadOnlyDictionary<string, string> Data { get; }
public bool IsSelected { get; private set; }
internal LanguageDefinition(string id, string key, IReadOnlyDictionary<string, string> data)
{
Id = id;
Key = key;
Data = data;
}
internal void SetSelected(bool selected)
{
IsSelected = selected;
OnPropertyChanged(nameof(IsSelected));
}
}
+8 -8
View File
@@ -1,6 +1,6 @@
namespace STPViewer.Models;
/// <summary>量測模式(None = 純瀏覽)</summary>
/// <summary>Measurement modes; None is the browsing mode.</summary>
public enum MeasureMode
{
None,
@@ -9,11 +9,11 @@ public enum MeasureMode
Edge,
Face,
Circle,
Angle, // 兩面 / 兩邊夾角
FaceDistance, // 面到面最短距離(網格近似)
Align, // 兩點對齊:平移零件使點 1 貼到點 2
Align3, // 三點對齊:旋轉+平移(來源 3 點 → 目標 3 點)
Drag, // 拖曳模式:左鍵按住零件沿螢幕平面拖動(放開才烘進 B-rep)
Interference, // 干涉檢查結果(非點擊模式,僅作為結果類型)
Volume, // 體積/質心(樹右鍵選單觸發,非點擊模式,僅作為結果類型)
Angle, // Angle between two faces or edges.
FaceDistance, // Shortest face-to-face distance (mesh approximation).
Align, // Translate a part so the first point meets the second.
Align3, // Rotate and translate from three source and target points.
Drag, // Move a part in the screen plane; commit on release.
Interference, // Result type rather than a picking mode.
Volume, // Volume/centroid result triggered from the tree menu.
}
+12 -7
View File
@@ -7,23 +7,23 @@ using HelixToolkit.Wpf;
namespace STPViewer.Models;
/// <summary>
/// 一筆量測結果。內部以 mm 儲存原始值,Title/Detail 依目前單位即時產生;
/// 切換單位時 3D 標籤(DynamicLabels)也同步改字。
/// A measurement stored in millimeters. Its title, details, and 3D labels
/// update when the display unit changes.
/// </summary>
public class MeasurementResult : ObservableObject
{
public MeasureMode Kind { get; init; }
/// <summary>依單位產生標題(清單粗體列)</summary>
/// <summary>Create the list title for a display unit.</summary>
public required Func<UnitSystem, string> TitleFor { get; init; }
/// <summary>依單位產生多行明細</summary>
/// <summary>Create the detailed text for a display unit.</summary>
public required Func<UnitSystem, string> DetailFor { get; init; }
/// <summary>視圖中的標記(刪除量測時一併移除)</summary>
/// <summary>View markers removed with this measurement.</summary>
public List<Visual3D> Overlays { get; } = new();
/// <summary>單位相關的 3D 文字標籤(切換單位時更新 Text)</summary>
/// <summary>3D labels whose text changes with the display unit.</summary>
public List<(BillboardTextVisual3D Label, Func<UnitSystem, string> TextFor)> DynamicLabels { get; } = new();
private UnitSystem _unit = UnitSystem.Millimeter;
@@ -35,9 +35,14 @@ public class MeasurementResult : ObservableObject
{
if (_unit == unit) return;
_unit = unit;
RefreshText();
}
public void RefreshText()
{
OnPropertyChanged(nameof(Title));
OnPropertyChanged(nameof(Detail));
foreach ((BillboardTextVisual3D label, Func<UnitSystem, string> textFor) in DynamicLabels)
label.Text = textFor(unit);
label.Text = textFor(_unit);
}
}
+6 -6
View File
@@ -8,28 +8,28 @@ public enum UnitSystem
Inch,
}
/// <summary>量測值格式化(內部一律 mm,顯示時換算)</summary>
/// <summary>Format measurements stored in millimeters for the selected display unit.</summary>
public static class Units
{
private const double MmPerInch = 25.4;
/// <summary>長度</summary>
/// <summary>Length.</summary>
public static string L(double mm, UnitSystem u) =>
u == UnitSystem.Millimeter ? $"{mm:F3} mm" : $"{mm / MmPerInch:F4} in";
/// <summary>面積</summary>
/// <summary>Area.</summary>
public static string A(double mm2, UnitSystem u) =>
u == UnitSystem.Millimeter ? $"{mm2:N3} mm²" : $"{mm2 / (MmPerInch * MmPerInch):N4} in²";
/// <summary>體積</summary>
/// <summary>Volume.</summary>
public static string V(double mm3, UnitSystem u) =>
u == UnitSystem.Millimeter ? $"{mm3:N2} mm³" : $"{mm3 / (MmPerInch * MmPerInch * MmPerInch):N4} in³";
/// <summary>座標分量(無單位字尾)</summary>
/// <summary>Coordinate component without a unit suffix.</summary>
public static string C(double mm, UnitSystem u) =>
u == UnitSystem.Millimeter ? $"{mm:F3}" : $"{mm / MmPerInch:F4}";
/// <summary>座標點 (x, y, z)</summary>
/// <summary>Coordinate point (x, y, z).</summary>
public static string P(Point3D p, UnitSystem u) =>
$"({C(p.X, u)}, {C(p.Y, u)}, {C(p.Z, u)})";
}
+22 -18
View File
@@ -1,18 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<Version>0.7.0</Version> <!-- 單一版號來源:標題與 publish-winapp 打包資料夾都讀這個 -->
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CADability" Version="1.0.33" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="HelixToolkit.Wpf" Version="3.1.2" />
</ItemGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<Version>0.7.1</Version> <!-- Shared by the window title and publish output. -->
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CADability" Version="1.0.33" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="HelixToolkit.Wpf" Version="3.1.2" />
</ItemGroup>
<ItemGroup>
<None Update="i18n\*.json" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+19 -23
View File
@@ -6,8 +6,7 @@ using System.Windows.Media.Media3D;
namespace STPViewer.Services;
/// <summary>
/// 干涉檢查結果:Intersects=true 時 Segments 為干涉交線;
/// false 時 GapA/GapB 為最近點對(網格近似)。
/// Interference result: Segments holds intersection lines, or GapA/GapB the approximate closest points.
/// </summary>
public record InterferenceResult(
bool Intersects,
@@ -18,10 +17,8 @@ public record InterferenceResult(
double GapDistance);
/// <summary>
/// 兩組三角網格的干涉檢查:
/// 三角形-三角形相交(區間法,回傳交線段)+ 均勻網格空間加速。
/// 共面接觸(無穿透)不會被判為干涉(近似限制)。
/// 設計為在背景執行緒呼叫。
/// Checks intersections between two triangle mesh sets using a uniform spatial grid.
/// Coplanar contact without penetration is excluded.
/// </summary>
public static class InterferenceService
{
@@ -63,7 +60,7 @@ public static class InterferenceService
return new InterferenceResult(false, 0, segments, ga, gb, d);
}
// ─── 資料結構 ────────────────────────────────────────────────
// Spatial data
private readonly struct Triangle
{
@@ -100,7 +97,7 @@ public static class InterferenceService
a.Min.Y <= b.Max.Y && a.Max.Y >= b.Min.Y &&
a.Min.Z <= b.Max.Z && a.Max.Z >= b.Min.Z;
/// <summary>均勻網格:cell → 三角形索引</summary>
/// <summary>Uniform grid from cells to triangle indices.</summary>
private sealed class TriGrid
{
private readonly Dictionary<(int, int, int), List<int>> _cells = new();
@@ -108,7 +105,7 @@ public static class InterferenceService
public TriGrid(Triangle[] tris)
{
// cell 大小:整體對角線 / 64(夾限避免極端值)
// Size cells from the scene diagonal, with bounds for extreme sizes.
Point3D min = tris[0].Min, max = tris[0].Max;
foreach (Triangle t in tris)
{
@@ -146,7 +143,7 @@ public static class InterferenceService
}
}
// ─── 三角形相交(區間法,回傳交線段)────────────────────────
// Triangle intersection
private static bool TriTriIntersect(in Triangle ta, in Triangle tb, out Point3D s0, out Point3D s1)
{
@@ -167,12 +164,12 @@ public static class InterferenceService
if ((db0 > 0 && db1 > 0 && db2 > 0) || (db0 < 0 && db1 < 0 && db2 < 0)) return false;
Vector3D dir = Vector3D.CrossProduct(n1, n2);
if (dir.LengthSquared < 1e-24) return false; // 共面:不視為穿透(近似)
if (dir.LengthSquared < 1e-24) return false; // Coplanar contact.
if (!PlaneCrossSegment(ta, da0, da1, da2, out Point3D p0, out Point3D p1)) return false;
if (!PlaneCrossSegment(tb, db0, db1, db2, out Point3D q0, out Point3D q1)) return false;
// 投影到交線方向取重疊區間
// Project onto the intersection direction and overlap the intervals.
double tp0 = Vector3D.DotProduct(dir, (Vector3D)p0);
double tp1 = Vector3D.DotProduct(dir, (Vector3D)p1);
double tq0 = Vector3D.DotProduct(dir, (Vector3D)q0);
@@ -182,14 +179,14 @@ public static class InterferenceService
double lo = Math.Max(tp0, tq0);
double hi = Math.Min(tp1, tq1);
if (lo >= hi) return false; // 無重疊(或僅點接觸)
if (lo >= hi) return false;
s0 = LerpOnSegment(p0, p1, tp0, tp1, lo);
s1 = LerpOnSegment(p0, p1, tp0, tp1, hi);
return true;
}
/// <summary>三角形與平面(頂點符號 d0..d2 已算好)的交線段(兩個交點)</summary>
/// <summary>Finds the triangle-plane intersection segment from signed vertex distances.</summary>
private static bool PlaneCrossSegment(in Triangle t, double d0, double d1, double d2,
out Point3D p0, out Point3D p1)
{
@@ -208,8 +205,8 @@ public static class InterferenceService
Span<Point3D> pts, ref int n)
{
if (n >= 3) return;
if (da == 0 && db == 0) return; // 邊在平面上 → 共面情形,略過
if (da == 0) { pts[n++] = a; return; } // 頂點剛好在平面上
if (da == 0 && db == 0) return;
if (da == 0) { pts[n++] = a; return; }
if (da * db < 0)
pts[n++] = a + (b - a) * (da / (da - db));
}
@@ -221,11 +218,11 @@ public static class InterferenceService
return p0 + (p1 - p0) * f;
}
// ─── 無干涉時的近似最小間隙 ─────────────────────────────────
// Approximate gap when meshes do not intersect
private static (Point3D a, Point3D b, double d) ApproxMinDistance(Triangle[] trisA, Triangle[] trisB)
{
// 1) 抽樣頂點對頂點求初值
// Sample vertices for an initial closest pair.
double best = double.MaxValue;
Point3D pa = default, pb = default;
int strideA = Math.Max(1, trisA.Length / 2000);
@@ -237,14 +234,13 @@ public static class InterferenceService
if (d < best) { best = d; pa = trisA[i].A; pb = trisB[j].A; }
}
// 2) 以最佳點為中心,精修:點 → 對方所有三角形(AABB 平方距離快速拒絕 + 平行分段,
// 初值來自步驟 1 已相當接近 → 絕大多數三角形被 AABB 直接刷掉)
// Refine against opposing triangles, rejecting distant AABBs.
(pb, best) = ClosestOnTriangles(pa, trisB, pb, best);
(pa, best) = ClosestOnTriangles(pb, trisA, pa, best);
return (pa, pb, best);
}
/// <summary>點 p 到三角形集的最近點(帶初值;回傳更新後的最近點與距離)</summary>
/// <summary>Refines the closest point from an initial distance.</summary>
private static (Point3D pt, double d) ClosestOnTriangles(Point3D p, Triangle[] tris, Point3D seedPt, double seedD)
{
object gate = new();
@@ -256,7 +252,7 @@ public static class InterferenceService
(i, _, local) =>
{
Triangle t = tris[i];
// AABB 平方距離下界 ≥ 目前最佳 → 不可能更近,略過(絕大多數三角形在此被刷掉)
// Reject triangles whose AABB cannot improve the current distance.
double dx = Math.Max(0, Math.Max(t.Min.X - p.X, p.X - t.Max.X));
double dy = Math.Max(0, Math.Max(t.Min.Y - p.Y, p.Y - t.Max.Y));
double dz = Math.Max(0, Math.Max(t.Min.Z - p.Z, p.Z - t.Max.Z));
@@ -276,7 +272,7 @@ public static class InterferenceService
return (bestPt, bestD);
}
/// <summary>點到三角形最近點(Ericson, Real-Time Collision Detection)</summary>
/// <summary>Closest point on a triangle, following Ericson's construction.</summary>
internal static Point3D ClosestPointOnTriangle(Point3D p, Point3D a, Point3D b, Point3D c)
{
Vector3D ab = b - a, ac = c - a, ap = p - a;
@@ -0,0 +1,158 @@
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
using CommunityToolkit.Mvvm.ComponentModel;
using STPViewer.Models;
namespace STPViewer.Services;
public class LocalizationService : ObservableObject
{
public static LocalizationService Instance { get; } = new(Path.Combine(AppContext.BaseDirectory, "i18n"));
private LanguageDefinition? _selected;
private readonly LanguageDefinition? _english;
public IReadOnlyList<LanguageDefinition> Languages { get; }
public string Language => _selected?.Id ?? "";
public event EventHandler? LanguageChanged;
public LocalizationService(string directory)
{
Languages = LoadLanguages(directory);
_english = Languages.FirstOrDefault(language => language.Id == "en");
_selected = Languages.FirstOrDefault(language => language.Id == ResolveLanguage(null));
foreach (LanguageDefinition language in Languages)
language.SetSelected(ReferenceEquals(language, _selected));
}
public string this[string key]
{
get
{
if (_selected?.Data.TryGetValue(key, out string? value) == true) return value;
if (_english?.Data.TryGetValue(key, out value) == true) return value;
return key;
}
}
public static string Get(string key) => Instance[key];
public static string Format(string key, params object[] args) => Instance.FormatText(key, args);
public string FormatText(string key, params object[] args)
{
string text = this[key];
try
{
return string.Format(CultureInfo.CurrentCulture, text, args);
}
catch (FormatException ex)
{
Trace.TraceWarning($"Invalid translation format '{key}' in '{Language}': {ex.Message}");
if (_english?.Data.TryGetValue(key, out string? fallback) == true && fallback != text)
{
try { return string.Format(CultureInfo.CurrentCulture, fallback, args); }
catch (FormatException) { }
}
return key;
}
}
public string ResolveLanguage(string? language)
{
string id = NormalizeId(language ?? CultureInfo.CurrentUICulture.Name);
if (Languages.Any(item => item.Id == id)) return id;
string[] parts = id.Split('_');
if (parts[0] == "zh")
{
bool traditional = parts.Any(part => part is "hant" or "tw" or "hk" or "mo");
string[] candidates = traditional ? ["zh_tw", "zh_hant", "zh"] : ["zh_cn", "zh_hans", "zh"];
foreach (string candidate in candidates)
if (Languages.Any(item => item.Id == candidate)) return candidate;
return _english?.Id ?? Languages.FirstOrDefault()?.Id ?? "";
}
LanguageDefinition? match = Languages.FirstOrDefault(item => item.Id == parts[0]);
return match?.Id ?? _english?.Id ?? Languages.FirstOrDefault()?.Id ?? "";
}
public void SetLanguage(string? language)
{
string id = ResolveLanguage(language);
LanguageDefinition? selected = Languages.FirstOrDefault(item => item.Id == id);
bool changed = !ReferenceEquals(_selected, selected);
_selected = selected;
foreach (LanguageDefinition item in Languages)
item.SetSelected(ReferenceEquals(item, selected));
if (selected is not null)
{
try
{
CultureInfo culture = CultureInfo.GetCultureInfo(selected.Id.Replace('_', '-'));
CultureInfo.CurrentUICulture = culture;
CultureInfo.DefaultThreadCurrentUICulture = culture;
}
catch (CultureNotFoundException) { }
}
if (!changed) return;
OnPropertyChanged(nameof(Language));
OnPropertyChanged("Item[]");
LanguageChanged?.Invoke(this, EventArgs.Empty);
}
private static string NormalizeId(string id) => id.Trim().Replace('-', '_').ToLowerInvariant();
private static IReadOnlyList<LanguageDefinition> LoadLanguages(string directory)
{
var languages = new List<LanguageDefinition>();
if (!Directory.Exists(directory)) return languages;
string[] paths;
try
{
paths = Directory.GetFiles(directory, "*.json");
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Trace.TraceWarning($"Cannot read language directory '{directory}': {ex.Message}");
return languages;
}
foreach (string path in paths.OrderBy(path => path, StringComparer.Ordinal))
{
try
{
LanguageFile? file = JsonSerializer.Deserialize<LanguageFile>(File.ReadAllText(path));
string id = NormalizeId(Path.GetFileNameWithoutExtension(path));
if (string.IsNullOrWhiteSpace(file?.Key) || file.Data is null || id.Length == 0 ||
file.Data.Any(pair => string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null))
throw new JsonException("Expected a display key and a data object containing string translations.");
if (languages.Any(language => language.Id == id))
throw new JsonException($"Duplicate language identifier '{id}'.");
languages.Add(new LanguageDefinition(id, file.Key, file.Data));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
{
Trace.TraceWarning($"Cannot load language file '{path}': {ex.Message}");
}
}
return languages;
}
private sealed class LanguageFile
{
[JsonPropertyName("key")]
public string? Key { get; set; }
[JsonPropertyName("data")]
public Dictionary<string, string>? Data { get; set; }
}
}
+52 -66
View File
@@ -6,16 +6,17 @@ using CADability;
using CADability.GeoObject;
using HelixToolkit.Wpf;
using STPViewer.Models;
using static STPViewer.Services.LocalizationService;
namespace STPViewer.Services;
/// <summary>角度量測的單次拾取:邊方向或面法向量</summary>
/// <summary>One direction selected for angle measurement.</summary>
public record DirectionPick(Point3D At, Vector3D Direction, string Desc);
/// <summary>
/// 點/距離/邊/面/圓/角度/面距 量測幾何計算 + 視圖 overlay 產生。
/// 邊長、圓半徑以 B-rep 精確值為準;面積與面距用三角網格近似。
/// 所有文字以 Func&lt;UnitSystem,string&gt; 延後產生,支援 mm/inch 即時切換。
/// Computes measurements and their viewport overlays.
/// B-rep provides exact edge lengths and radii; mesh values are approximate.
/// Text is deferred so unit and language changes update existing results.
/// </summary>
public class MeasurementService
{
@@ -29,13 +30,11 @@ public class MeasurementService
private static GeoPoint ToGeo(Point3D p) => new(p.X, p.Y, p.Z);
private static Point3D ToP3(GeoPoint p) => new(p.x, p.y, p.z);
// ─── 拾取輔助 ────────────────────────────────────────────────
// Picking
/// <summary>
/// 命中點吸附:點到圓形邊附近(容差內)→ 吸附**圓心**(量兩孔 pitch 的關鍵,v0.5.0);
/// 否則吸附最近的 B-rep 頂點(容差內);都沒有 → 回傳原始表面點。
/// 圓心與頂點都在容差內時,比「命中點到圓形邊」與「命中點到頂點」誰近(圓弧端點常與圓重疊,
/// 點在圓邊上時圓心優先勝出才符合直覺)。
/// Snaps to the nearest vertex or circular edge within tolerance.
/// A circular edge snaps to its center for hole pitch measurements.
/// </summary>
public Point3D Snap(FaceInfo fi, Point3D hit, double tolerance)
{
@@ -51,9 +50,9 @@ public class MeasurementService
if (d < bestD) { bestD = d; best = p; }
}
}
catch { /* 頂點存取失敗則不吸附 */ }
catch { }
// 圓形邊:以「命中點到邊曲線」的距離參與比較,勝出時吸附到圓心
// Compare distance to the circle edge before selecting its center.
try
{
foreach (Edge e in fi.BrepFace.AllEdges)
@@ -67,19 +66,18 @@ public class MeasurementService
if (d < bestD) { bestD = d; best = ToP3(el.Center); }
}
}
catch { /* 邊存取失敗則維持頂點吸附結果 */ }
catch { }
return best;
}
/// <summary>
/// 角度量測拾取:命中點附近有直線邊(容差內)→ 邊方向;
/// 否則取面法向量(B-rep 精確;無 B-rep 時用網格法向量)。
/// Uses a nearby straight edge for direction, otherwise a face normal.
/// </summary>
public DirectionPick PickDirection(FaceInfo fi, Point3D hit, Vector3D meshNormal, double tolerance)
{
if (fi.BrepFace is not null)
{
// 1) 近距離直線邊優先
// Prefer a nearby straight edge.
try
{
foreach (Edge e in fi.BrepFace.AllEdges)
@@ -91,28 +89,28 @@ public class MeasurementService
{
Vector3D dir = ToP3(ln.EndPoint) - ToP3(ln.StartPoint);
dir.Normalize();
return new DirectionPick(p, dir, "邊方向");
return new DirectionPick(p, dir, "Runtime.DirectionEdge");
}
}
}
catch { }
// 2) 面法向量(精確)
// Use the B-rep face normal when available.
try
{
GeoPoint2D uv = fi.BrepFace.Surface.PositionOf(ToGeo(hit));
GeoVector n = fi.BrepFace.Surface.GetNormal(uv).Normalized;
return new DirectionPick(hit, new Vector3D(n.x, n.y, n.z), "面法向量");
return new DirectionPick(hit, new Vector3D(n.x, n.y, n.z), "Runtime.DirectionFace");
}
catch { }
}
Vector3D mn = meshNormal;
if (mn.LengthSquared < 1e-12) mn = new Vector3D(0, 0, 1);
mn.Normalize();
return new DirectionPick(hit, mn, "面法向量(網格)");
return new DirectionPick(hit, mn, "Runtime.DirectionMeshFace");
}
// ─── 點 / 距離 ───────────────────────────────────────────────
// Point and distance
public MeasurementResult MeasurePoint(Point3D p, string label, double markerR)
{
@@ -134,8 +132,7 @@ public class MeasurementService
{
Kind = MeasureMode.Distance,
TitleFor = u => $"{label} {Units.L(d.Length, u)}",
DetailFor = u => $"距離 = {Units.L(d.Length, u)}\nΔX = {Units.L(d.X, u)}\nΔY = {Units.L(d.Y, u)}\nΔZ = {Units.L(d.Z, u)}\n" +
$"P1 {Units.P(p1, u)}\nP2 {Units.P(p2, u)}",
DetailFor = u => Format("Runtime.DistanceDetail", Units.L(d.Length, u), Units.L(d.X, u), Units.L(d.Y, u), Units.L(d.Z, u), Units.P(p1, u), Units.P(p2, u)),
};
r.Overlays.Add(Sphere(p1, markerR));
r.Overlays.Add(Sphere(p2, markerR));
@@ -149,9 +146,9 @@ public class MeasurementService
return r;
}
// ─── 邊 / 圓 ─────────────────────────────────────────────────
// Edge and circle
/// <summary>量測命中面上最近的邊。circlesOnly=true 時只找圓形邊。找不到回傳 null。</summary>
/// <summary>Measures the nearest edge, or the nearest circular edge when circlesOnly is true.</summary>
public MeasurementResult? MeasureEdge(FaceInfo fi, Point3D hit, string label, double markerR, bool circlesOnly)
{
if (fi.BrepFace is null) return null;
@@ -193,9 +190,7 @@ public class MeasurementService
{
Kind = MeasureMode.Circle,
TitleFor = u => $"{label} ⌀{Units.L(radius * 2, u)}",
DetailFor = u => $"直徑 = {Units.L(radius * 2, u)}\n半徑 = {Units.L(radius, u)}\n" +
$"{(full ? "周長" : "弧長")} = {Units.L(curveLen, u)}\n" +
$"圓心 {Units.P(center, u)}",
DetailFor = u => Format("Runtime.CircleDetail", Units.L(radius * 2, u), Units.L(radius, u), Get(full ? "Runtime.Circumference" : "Runtime.ArcLength"), Units.L(curveLen, u), Units.P(center, u)),
};
r.Overlays.Add(Polyline(curve, HighlightColor, 3));
r.Overlays.Add(Sphere(center, markerR));
@@ -219,8 +214,7 @@ public class MeasurementService
{
Kind = MeasureMode.Edge,
TitleFor = u => $"{label} L = {Units.L(len, u)}",
DetailFor = u => $"長度 = {Units.L(len, u)}\nΔX = {Units.C(d.X, u)} ΔY = {Units.C(d.Y, u)} ΔZ = {Units.C(d.Z, u)}\n" +
$"起點 {Units.P(s, u)}\n終點 {Units.P(e, u)}",
DetailFor = u => Format("Runtime.LineDetail", Units.L(len, u), Units.C(d.X, u), Units.C(d.Y, u), Units.C(d.Z, u), Units.P(s, u), Units.P(e, u)),
};
r.Overlays.Add(new LinesVisual3D
{
@@ -241,32 +235,32 @@ public class MeasurementService
{
Kind = MeasureMode.Edge,
TitleFor = u => $"{label} L = {Units.L(len, u)}",
DetailFor = u => $"曲線長 = {Units.L(len, u)}\n類型 = {typeName}",
DetailFor = u => Format("Runtime.CurveDetail", Units.L(len, u), typeName),
};
r.Overlays.Add(Polyline(curve, HighlightColor, 3));
AddLabel(r, ToP3(curve.PointAt(0.5)) + new Vector3D(0, 0, markerR * 2), u => $"L={Units.L(len, u)}");
return r;
}
// ─── 面 ──────────────────────────────────────────────────────
// Face
public MeasurementResult MeasureFace(FaceInfo fi, Point3D hit, string label, double markerR)
{
// 面積:三角網格加總(近似值,三角化精度內)
// Area is estimated from the triangle mesh.
double area = MeshArea(fi.Mesh);
int triCount = fi.Mesh.TriangleIndices.Count / 3;
Func<UnitSystem, string> surfaceDesc = fi.BrepFace is not null
? DescribeSurface(fi.BrepFace.Surface)
: _ => "類型 = 網格(無 B-rep)";
: _ => Get("Runtime.MeshSurface");
var r = new MeasurementResult
{
Kind = MeasureMode.Face,
TitleFor = u => $"{label} A ≈ {Units.A(area, u)}",
DetailFor = u => $"面積 ≈ {Units.A(area, u)}(網格近似)\n{surfaceDesc(u)}\n三角形數 = {triCount:N0}",
DetailFor = u => Format("Runtime.FaceDetail", Units.A(area, u), surfaceDesc(u), triCount),
};
// overlay:外輪廓 highlight + 標籤
// Highlight the outer edges and add a label.
if (fi.BrepFace is not null)
{
try
@@ -302,35 +296,33 @@ public class MeasurementService
case PlaneSurface ps:
{
GeoVector n = ps.Normal.Normalized;
string desc = $"類型 = 平面\n法向量 ({n.x:F3}, {n.y:F3}, {n.z:F3})";
return _ => desc;
return _ => Format("Runtime.PlaneSurface", n.x, n.y, n.z);
}
case CylindricalSurface cs:
{
GeoVector ax = cs.Axis.Normalized;
double radius = cs.RadiusX;
return u => $"類型 = 圓柱面\n半徑 = {Units.L(radius, u)}\n軸向 ({ax.x:F3}, {ax.y:F3}, {ax.z:F3})";
return u => Format("Runtime.CylinderSurface", Units.L(radius, u), ax.x, ax.y, ax.z);
}
default:
{
string desc = $"類型 = {SurfaceTypeName(surface)}";
return _ => desc;
return _ => Format("Runtime.SurfaceType", SurfaceTypeName(surface));
}
}
}
private static string SurfaceTypeName(ISurface s) => s.GetType().Name switch
{
"SphericalSurface" => "球面",
"ToroidalSurface" => "環面",
"ConicalSurface" => "圓錐面",
"NurbsSurface" => "NURBS 曲面",
"SurfaceOfRevolution" => "旋轉曲面",
"SurfaceOfLinearExtrusion" => "拉伸曲面",
"SphericalSurface" => Get("Runtime.SphericalSurface"),
"ToroidalSurface" => Get("Runtime.ToroidalSurface"),
"ConicalSurface" => Get("Runtime.ConicalSurface"),
"NurbsSurface" => Get("Runtime.NurbsSurface"),
"SurfaceOfRevolution" => Get("Runtime.RevolutionSurface"),
"SurfaceOfLinearExtrusion" => Get("Runtime.ExtrusionSurface"),
var other => other,
};
// ─── 角度(兩面 / 兩邊夾角)─────────────────────────────────
// Angle
public MeasurementResult MeasureAngle(DirectionPick a, DirectionPick b, string label, double markerR, double arrowLen)
{
@@ -343,10 +335,7 @@ public class MeasurementService
{
Kind = MeasureMode.Angle,
TitleFor = _ => $"{label} ∠ {deg:F2}°",
DetailFor = _ => $"夾角 = {deg:F2}°(補角 {comp:F2}°)\n" +
$"方向 1:{a.Desc} ({a.Direction.X:F3}, {a.Direction.Y:F3}, {a.Direction.Z:F3})\n" +
$"方向 2:{b.Desc} ({b.Direction.X:F3}, {b.Direction.Y:F3}, {b.Direction.Z:F3})\n" +
"(兩面夾角以法向量計,互補角請參考補角值)",
DetailFor = _ => Format("Runtime.AngleDetail", deg, comp, Get(a.Desc), a.Direction.X, a.Direction.Y, a.Direction.Z, Get(b.Desc), b.Direction.X, b.Direction.Y, b.Direction.Z),
};
r.Overlays.Add(Sphere(a.At, markerR));
r.Overlays.Add(Sphere(b.At, markerR));
@@ -368,7 +357,7 @@ public class MeasurementService
Thickness = 2,
};
// ─── 面到面最短距離(網格近似)──────────────────────────────
// Face distance
public MeasurementResult MeasureFaceDistance(FaceInfo a, FaceInfo b, string label, double markerR)
{
@@ -378,9 +367,7 @@ public class MeasurementService
{
Kind = MeasureMode.FaceDistance,
TitleFor = u => $"{label} min = {Units.L(d.Length, u)}",
DetailFor = u => $"最短距離 ≈ {Units.L(d.Length, u)}(網格近似)\n" +
$"ΔX = {Units.L(d.X, u)}\nΔY = {Units.L(d.Y, u)}\nΔZ = {Units.L(d.Z, u)}\n" +
$"點 1 {Units.P(pa, u)}\n點 2 {Units.P(pb, u)}",
DetailFor = u => Format("Runtime.FaceDistanceDetail", Units.L(d.Length, u), Units.L(d.X, u), Units.L(d.Y, u), Units.L(d.Z, u), Units.P(pa, u), Units.P(pb, u)),
};
r.Overlays.Add(Sphere(pa, markerR));
r.Overlays.Add(Sphere(pb, markerR));
@@ -394,7 +381,7 @@ public class MeasurementService
return r;
}
/// <summary>兩網格最近點對:頂點→三角形雙向(頂點過多時抽樣,結果為近似值)</summary>
/// <summary>Approximates the closest points by probing vertices against triangles in both directions.</summary>
private static (Point3D a, Point3D b) MeshMinDistance(MeshGeometry3D ma, MeshGeometry3D mb)
{
double best = double.MaxValue;
@@ -427,19 +414,18 @@ public class MeasurementService
return (pa, pb);
}
// ─── 體積 / 質心(封閉網格 signed volume)────────────────────
// Volume and centroid
/// <summary>
/// 網格體積與質心:各三角形與原點構成的四面體有向體積加總(divergence theorem)。
/// 只對「封閉」網格有意義(開放殼會得到不可靠的值 → IsClosedReliable=false 供呼叫端提示)。
/// 多個 mesh(多實體)直接加總。
/// Computes signed volume and centroid from origin-based tetrahedra.
/// The result is reliable only for closed meshes.
/// </summary>
public static (double VolumeMm3, Point3D Centroid, bool Reliable) MeshVolume(
System.Collections.Generic.IEnumerable<MeshGeometry3D> meshes)
{
double vol6 = 0; // Σ 6×有向體積
double absVol6 = 0; // Σ |6×有向體積|(封閉性 sanity check 用)
double cx = 0, cy = 0, cz = 0; // Σ 四面體質心 × 6×有向體積
double vol6 = 0;
double absVol6 = 0;
double cx = 0, cy = 0, cz = 0;
foreach (MeshGeometry3D mesh in meshes)
{
Point3DCollection pos = mesh.Positions;
@@ -453,14 +439,14 @@ public class MeasurementService
+ a.Z * (b.X * c.Y - b.Y * c.X);
vol6 += v;
absVol6 += Math.Abs(v);
// 四面體(a,b,c,原點)質心 = (a+b+c)/4
// Tetrahedron centroid = (a + b + c) / 4.
cx += (a.X + b.X + c.X) / 4 * v;
cy += (a.Y + b.Y + c.Y) / 4 * v;
cz += (a.Z + b.Z + c.Z) / 4 * v;
}
}
double volume = Math.Abs(vol6) / 6;
// 有向體積相對正負抵銷比例過高 → 可能是開放殼/法向不一致,數值不可靠
// Large signed cancellation suggests an open mesh or inconsistent normals.
bool reliable = absVol6 > 0 && Math.Abs(vol6) / absVol6 > 0.01 && volume > 1e-9;
Point3D centroid = Math.Abs(vol6) > 1e-12
? new Point3D(cx / vol6, cy / vol6, cz / vol6)
@@ -468,7 +454,7 @@ public class MeasurementService
return (volume, centroid, reliable);
}
// 點到三角形最近點:共用 InterferenceService.ClosestPointOnTriangle(v0.4.0 去重複)
// Shares the triangle closest-point calculation with InterferenceService.
// ─── overlay helpers ─────────────────────────────────────────
+7 -8
View File
@@ -4,15 +4,14 @@ using System.Windows.Media.Media3D;
namespace STPViewer.Services;
/// <summary>
/// 三點對齊 / 旋轉的剛體變換數學。
/// 同一個變換需要兩種表示:WPF <see cref="Matrix3D"/>(列向量約定,給網格/邊線)與
/// CADability <see cref="CADability.ModOp"/>(行向量約定,給 B-rep Modify)— 用 <see cref="ToModOp"/> 轉換。
/// Rigid transformations for three-point alignment and rotation.
/// <see cref="ToModOp"/> converts WPF <see cref="Matrix3D"/> to CADability's matrix convention.
/// </summary>
public static class RigidAlign
{
/// <summary>
/// 來源 3 點 → 目標 3 點 的剛體變換(不縮放):
/// p1→q1 精確貼合、p1p2 方向對齊 q1q2、三點平面對齊。共線/退化回傳 false。
/// Computes a rigid transform from three source points to three target points.
/// Returns false for collinear or degenerate points.
/// </summary>
public static bool TryRigidTransform(
Point3D p1, Point3D p2, Point3D p3,
@@ -22,7 +21,7 @@ public static class RigidAlign
if (!TryFrame(p1, p2, p3, out Vector3D xp, out Vector3D yp, out Vector3D zp)) return false;
if (!TryFrame(q1, q2, q3, out Vector3D xq, out Vector3D yq, out Vector3D zq)) return false;
// R = Bq · Bpᵀ(行向量約定);WPF Matrix3D 為列向量約定 → 轉置擺放
// R = Bq · Bpᵀ, transposed for Matrix3D's convention.
double R(int r, int c)
{
double xpc = c == 0 ? xp.X : c == 1 ? xp.Y : xp.Z;
@@ -47,7 +46,7 @@ public static class RigidAlign
return true;
}
/// <summary>WPF Matrix3D(列向量約定)→ CADability ModOp(行向量約定,3×4)</summary>
/// <summary>Converts a WPF matrix to a CADability ModOp.</summary>
public static CADability.ModOp ToModOp(Matrix3D m) => new(new double[3, 4]
{
{ m.M11, m.M21, m.M31, m.OffsetX },
@@ -58,7 +57,7 @@ public static class RigidAlign
public static bool Collinear(Point3D a, Point3D b, Point3D c) =>
Vector3D.CrossProduct(b - a, c - a).LengthSquared < 1e-12;
/// <summary>三點 → 正交座標架(x=1→2 方向、z=平面法向、y=z×x);共線回傳 false</summary>
/// <summary>Builds an orthogonal frame from three points; returns false for collinear points.</summary>
private static bool TryFrame(Point3D a, Point3D b, Point3D c,
out Vector3D x, out Vector3D y, out Vector3D z)
{
+5 -5
View File
@@ -4,8 +4,8 @@ using System.Windows.Media.Media3D;
namespace STPViewer.Services;
/// <summary>
/// 剖面:把三角網格 / 線段集用半空間裁切(保留 dot(p - p0, n) ≤ 0 的一側)。
/// WPF 3D 沒有 GPU clip plane,採 CPU 裁切後置換 Geometry(原始 mesh 保留可還原)。
/// Clips meshes and line segments to the half-space dot(p - p0, n) ≤ 0.
/// The original geometry remains available for restoration.
/// </summary>
public static class SectionService
{
@@ -23,7 +23,7 @@ public static class SectionService
Point3DCollection pos = src.Positions;
Int32Collection idx = src.TriangleIndices;
// 頂點距離快取
// Cache signed distances for each vertex.
var dist = new double[pos.Count];
for (int i = 0; i < pos.Count; i++)
dist[i] = Vector3D.DotProduct(pos[i] - planePoint, normal);
@@ -47,7 +47,7 @@ public static class SectionService
continue;
}
// 部分在保留側:Sutherland-Hodgman 裁出 3 或 4 邊形,扇形分割
// Clip the polygon and triangulate it as a fan.
int n = 0;
for (int k = 0; k < 3; k++)
{
@@ -78,7 +78,7 @@ public static class SectionService
idx.Add(i); idx.Add(i + 1); idx.Add(i + 2);
}
/// <summary>裁切線段集(成對端點)</summary>
/// <summary>Clips paired line segment endpoints.</summary>
public static Point3DCollection ClipSegments(Point3DCollection segments, Point3D planePoint, Vector3D normal)
{
var result = new Point3DCollection();
+6 -5
View File
@@ -5,7 +5,7 @@ using System.Text.Json;
namespace STPViewer.Services;
/// <summary>使用者設定(%LOCALAPPDATA%\STPViewer\settings.json)</summary>
/// <summary>User settings stored in %LOCALAPPDATA%\STPViewer\settings.json.</summary>
public class AppSettings
{
public double WindowWidth { get; set; } = double.NaN;
@@ -14,13 +14,14 @@ public class AppSettings
public double WindowTop { get; set; } = double.NaN;
public bool WindowMaximized { get; set; }
public bool UseInch { get; set; }
public string? Language { get; set; }
public List<string> RecentFiles { get; set; } = new();
/// <summary>快速工具列顯示的按鈕 key 清單;null = 用預設(QuickBarViewModel.Registry)</summary>
/// <summary>Visible quick bar keys; null uses registry defaults.</summary>
public List<string>? QuickBarKeys { get; set; }
}
/// <summary>設定讀寫:壞檔/缺檔一律回預設值,儲存失敗靜默(設定不值得讓程式閃退)</summary>
/// <summary>Loads defaults for missing or invalid settings and ignores save failures.</summary>
public static class SettingsService
{
public const int MaxRecentFiles = 10;
@@ -36,7 +37,7 @@ public static class SettingsService
if (File.Exists(SettingsPath))
return JsonSerializer.Deserialize<AppSettings>(File.ReadAllText(SettingsPath)) ?? new AppSettings();
}
catch { /* 壞檔回預設 */ }
catch { }
return new AppSettings();
}
@@ -48,6 +49,6 @@ public static class SettingsService
File.WriteAllText(SettingsPath,
JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true }));
}
catch { /* 儲存失敗靜默 */ }
catch { }
}
}
+98 -76
View File
@@ -6,28 +6,31 @@ using System.Windows.Media;
using System.Windows.Media.Media3D;
using CADability;
using CADability.GeoObject;
using IOPath = System.IO.Path; // CADability.GeoObject.Path(曲線)撞名
using IOPath = System.IO.Path;
using static STPViewer.Services.LocalizationService;
namespace STPViewer.Services;
/// <summary>單一 B-rep Face 的三角網格(mesh 已 Freeze,可跨執行緒);STL 來源 BrepFace 為 null</summary>
/// <summary>A frozen triangle mesh for one B-rep face; BrepFace is null for STL.</summary>
public record ImportedFace(Face? BrepFace, MeshGeometry3D Mesh);
/// <summary>匯入後的階層節點:group(裝配)或 leaf(零件幾何)</summary>
/// <summary>An imported assembly group or geometry leaf.</summary>
public class ImportedNode
{
public string Name { get; set; } = "";
public string? NameKey { get; set; }
public int NameNumber { get; set; }
public List<ImportedNode> Children { get; } = new();
// ── leaf 幾何 ──
// Leaf geometry
public List<ImportedFace> Faces { get; } = new();
public Point3DCollection? EdgeSegments { get; set; } // 輪廓線(成對端點,已 Freeze)
public bool HasBrep { get; set; } = true; // STL / 純曲線 = false
public Point3DCollection? EdgeSegments { get; set; } // Frozen paired endpoints.
public bool HasBrep { get; set; } = true; // False for STL and curves.
/// <summary>來源 B-rep 物件(Solid/Shell/Face/曲線)— 零件平移時整體 Modify 用</summary>
/// <summary>Source B-rep objects used for whole-part transforms.</summary>
public List<CADability.GeoObject.IGeoObject> SourceGeos { get; } = new();
// ── 統計(group 節點由 Aggregate 彙總)──
// Group totals are calculated by Aggregate.
public int SolidCount { get; set; }
public int FaceCount { get; set; }
public int TriangleCount { get; set; }
@@ -39,29 +42,35 @@ public class ImportedNode
public record ImportedFileData(string FilePath, ImportedNode Root);
/// <summary>
/// CAD 檔匯入(STEP / STL / DXF)。STEP 以 HierarchyToBlocks 還原裝配樹。
/// 設計為在背景執行緒呼叫;回傳的 Freezable 全部已 Freeze。
/// Imports STEP, STL and DXF files on a background thread.
/// Returned WPF Freezables are frozen.
/// </summary>
public class StepImportService
{
/// <summary>匯入階段回報(耗時分解),UI 可顯示於狀態列;null 則靜默</summary>
/// <summary>Import progress for the status bar.</summary>
public Action<string>? Progress { get; set; }
public Action<Func<string>>? ProgressTextFor { get; set; }
private long _triTicks, _edgeTicks; // 三角化 / 邊取樣 累計耗時(TimeSpan ticks,避免逐面 ms 截斷歸零)
private void ReportProgress(Func<string> textFor)
{
Progress?.Invoke(textFor());
ProgressTextFor?.Invoke(textFor);
}
private long _triTicks, _edgeTicks; // Accumulated tessellation and edge sampling ticks.
private double TriMs => new TimeSpan(_triTicks).TotalMilliseconds;
private double EdgeMs => new TimeSpan(_edgeTicks).TotalMilliseconds;
/// <summary>延後的 leaf 幾何工作(三角化+邊取樣):樹走訪時收集,之後平行執行。
/// 平行粒度 = 整個 leaf(同 leaf 的面共用 Edge,不可面級平行)。</summary>
/// <summary>Geometry work queued per leaf, then run in parallel. Faces within a leaf share edges.</summary>
private readonly List<Action> _leafWork = new();
/// <summary>平行三角化偶發失敗的面(CADability 跨 leaf 仍有少量共享狀態)→ 平行結束後循序重試</summary>
/// <summary>Faces to retry sequentially after parallel tessellation.</summary>
private readonly System.Collections.Concurrent.ConcurrentBag<(ImportedNode Leaf, HashSet<Edge> EdgeSet, List<(Face F, double Prec)> Failed)> _retry = new();
public ImportedFileData Import(string filePath)
{
if (!File.Exists(filePath))
throw new FileNotFoundException("找不到檔案", filePath);
throw new FileNotFoundException(Get("Runtime.FileNotFound"), filePath);
_triTicks = _edgeTicks = 0;
_leafWork.Clear();
@@ -73,21 +82,22 @@ public class StepImportService
case ".stl": ImportStlFile(filePath, root); break;
case ".dxf": ImportDxfFile(filePath, root); break;
default:
throw new InvalidDataException("不支援的格式(支援 .stp / .step / .stl / .dxf;CADability 無 IGES reader)");
throw new InvalidDataException(Get("Runtime.UnsupportedFormat"));
}
if (_leafWork.Count > 0)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
Progress?.Invoke($"三角化 {_leafWork.Count} 個零件(平行)…");
int leafCount = _leafWork.Count;
ReportProgress(() => Format("Runtime.Triangulating", leafCount));
System.Threading.Tasks.Parallel.ForEach(_leafWork, w =>
{
try { w(); }
catch { /* 個別零件失敗不讓整檔失敗 */ }
catch { }
});
_leafWork.Clear();
// 平行下偶發失敗的面:競爭已結束,循序重試一輪(FinishLeaf 也延到此時才跑)
// Retry failed faces sequentially after the parallel pass.
int retried = 0;
foreach ((ImportedNode leaf, HashSet<Edge> edgeSet, var failed) in _retry)
{
@@ -96,14 +106,16 @@ public class StepImportService
FinishLeaf(leaf, edgeSet);
}
_retry.Clear();
Progress?.Invoke($"幾何處理 {sw.ElapsedMilliseconds:N0} ms" +
$"(三角化 {TriMs:N0} ms、邊取樣 {EdgeMs:N0} ms{(retried > 0 ? $"、重試補回 {retried} 面" : "")})");
long elapsed = sw.ElapsedMilliseconds;
int retryCount = retried;
ReportProgress(() => Format("Runtime.GeometryProcessed", elapsed, TriMs, EdgeMs,
retryCount > 0 ? Format("Runtime.RetriedFaces", retryCount) : ""));
}
Prune(root);
Aggregate(root);
if (root.SolidCount == 0 && root.FaceCount == 0 && CountSegments(root) == 0)
throw new InvalidDataException("檔案內沒有可顯示的幾何");
throw new InvalidDataException(Get("Runtime.NoGeometry"));
return new ImportedFileData(filePath, root);
}
@@ -114,69 +126,65 @@ public class StepImportService
private void ImportStepFile(string path, ImportedNode root)
{
Progress?.Invoke("解析 STEP(大檔可能需數分鐘)…");
ReportProgress(() => Get("Runtime.ParsingStep"));
var sw = System.Diagnostics.Stopwatch.StartNew();
var importer = new ImportStep { HierarchyToBlocks = true };
GeoObjectList list = importer.Read(path)
?? throw new InvalidDataException("CADability 無法解析此 STEP 檔");
Progress?.Invoke($"STEP 解析 {sw.ElapsedMilliseconds:N0} ms");
?? throw new InvalidDataException(Get("Runtime.StepParseFailed"));
long elapsed = sw.ElapsedMilliseconds;
ReportProgress(() => Format("Runtime.StepParsed", elapsed));
BuildChildren(root, EnumerateGeo(list));
}
// ─── STL(純網格,無 B-rep;整個 shell 合併成單一 mesh 避免模型數爆炸)──
// STL is read directly as mesh geometry without B-rep objects.
private void ImportStlFile(string path, ImportedNode root)
{
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;
ReportProgress(() => Get("Runtime.ParsingStl"));
var sw = System.Diagnostics.Stopwatch.StartNew();
List<StlMeshReader.MeshGroup> groups = StlMeshReader.Read(path);
if (groups.Count == 0)
throw new InvalidDataException(Get("Runtime.StlNoTriangles"));
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,
NameKey = g.NameKey,
NameNumber = g.NameNumber,
HasBrep = false,
SolidCount = 1,
FaceCount = sh.Faces.Length,
TriangleCount = indices.Count / 3,
FaceCount = tris,
TriangleCount = tris,
Bounds = BoundsOfMesh(mesh),
};
leaf.SourceGeos.Add(sh);
leaf.Faces.Add(new ImportedFace(null, mesh));
root.Children.Add(leaf);
i++;
}
long elapsed = sw.ElapsedMilliseconds;
int meshCount = root.Children.Count;
ReportProgress(() => Format("Runtime.StlParsed", elapsed, meshCount));
}
// ─── DXF(多為線架構;Solid/Face 走同一套,曲線取樣成線段)──
// DXF
private void ImportDxfFile(string path, ImportedNode root)
{
var import = new CADability.DXF.Import(path);
Project project = import.Project
?? throw new InvalidDataException("無法解析 DXF 檔");
?? throw new InvalidDataException(Get("Runtime.DxfParseFailed"));
Model model = project.GetActiveModel();
BuildChildren(root, EnumerateGeo(model.AllObjects));
}
// ─── 階層走訪 ────────────────────────────────────────────────
// Assembly traversal
private static IEnumerable<IGeoObject> EnumerateGeo(System.Collections.IEnumerable list)
{
@@ -193,7 +201,7 @@ public class StepImportService
private void BuildChildren(ImportedNode parent, IEnumerable<IGeoObject> objects)
{
var looseFaces = new List<Face>();
var looseCurves = new List<IGeoObject>(); // 皆為 ICurve
var looseCurves = new List<IGeoObject>();
foreach (IGeoObject go in objects)
{
@@ -223,7 +231,7 @@ public class StepImportService
if (looseFaces.Count > 0)
{
var leaf = new ImportedNode { Name = "面" };
var leaf = new ImportedNode { Name = Get("Runtime.FaceName"), NameKey = "Runtime.FaceName" };
leaf.SourceGeos.AddRange(looseFaces);
_leafWork.Add(() =>
{
@@ -237,7 +245,7 @@ public class StepImportService
if (failed.Count > 0) _retry.Add((leaf, edgeSet, failed));
else FinishLeaf(leaf, edgeSet);
});
parent.Children.Add(leaf); // 三角化延後,空 leaf 由 Prune 收掉
parent.Children.Add(leaf);
}
if (looseCurves.Count > 0)
{
@@ -247,7 +255,8 @@ public class StepImportService
segs.Freeze();
var leaf = new ImportedNode
{
Name = "曲線",
Name = Get("Runtime.CurveName"),
NameKey = "Runtime.CurveName",
HasBrep = false,
EdgeSegments = segs,
Bounds = BoundsOfPoints(segs),
@@ -260,28 +269,39 @@ public class StepImportService
private ImportedNode? BlockNode(Block block)
{
// Block 只有單一同名 Solid → 折疊成 leaf(STEP 常見的 product→solid 包裝)
// Collapse a block containing one solid into a leaf.
if (block.NumChildren == 1 && block.Child(0) is Solid s)
{
ImportedNode leaf = SolidNode(s);
if (!string.IsNullOrWhiteSpace(block.Name)) leaf.Name = block.Name;
if (!string.IsNullOrWhiteSpace(block.Name))
{
leaf.Name = block.Name;
leaf.NameKey = null;
leaf.NameNumber = 0;
}
return leaf;
}
var node = new ImportedNode
{
Name = string.IsNullOrWhiteSpace(block.Name) ? "組件" : block.Name,
Name = string.IsNullOrWhiteSpace(block.Name) ? Get("Runtime.ComponentName") : block.Name,
NameKey = string.IsNullOrWhiteSpace(block.Name) ? "Runtime.ComponentName" : null,
};
BuildChildren(node, ChildrenOf(block));
if (node.Children.Count == 0) return null;
// 單鏈折疊:group 只有一個子節點且名稱相同/空白 → 去掉中間層
// Drop a redundant group when it has one child with the same name.
if (node.Children.Count == 1)
{
ImportedNode only = node.Children[0];
if (string.IsNullOrWhiteSpace(only.Name) || only.Name == node.Name)
{
only.Name = node.Name;
if (node.NameKey is null || only.NameKey is not null || string.IsNullOrWhiteSpace(only.Name))
{
only.Name = node.Name;
only.NameKey = node.NameKey;
only.NameNumber = node.NameNumber;
}
return only;
}
}
@@ -292,7 +312,8 @@ public class StepImportService
{
var leaf = new ImportedNode
{
Name = string.IsNullOrWhiteSpace(solid.NameOrEmpty) ? "Solid" : solid.NameOrEmpty,
Name = string.IsNullOrWhiteSpace(solid.NameOrEmpty) ? Get("Runtime.SolidName") : solid.NameOrEmpty,
NameKey = string.IsNullOrWhiteSpace(solid.NameOrEmpty) ? "Runtime.SolidName" : null,
SolidCount = 1,
};
leaf.SourceGeos.Add(solid);
@@ -316,7 +337,8 @@ public class StepImportService
{
var leaf = new ImportedNode
{
Name = string.IsNullOrWhiteSpace(shell.NameOrEmpty) ? "Shell" : shell.NameOrEmpty,
Name = string.IsNullOrWhiteSpace(shell.NameOrEmpty) ? Get("Runtime.ShellName") : shell.NameOrEmpty,
NameKey = string.IsNullOrWhiteSpace(shell.NameOrEmpty) ? "Runtime.ShellName" : null,
};
leaf.SourceGeos.Add(shell);
_leafWork.Add(() =>
@@ -332,7 +354,7 @@ public class StepImportService
return leaf;
}
// ─── 幾何處理 ────────────────────────────────────────────────
// Geometry processing
private static BoundingCube SafeBounds(Func<BoundingCube> get)
{
@@ -340,7 +362,7 @@ public class StepImportService
catch { return new BoundingCube(); }
}
/// <summary>三角化精度依物件大小調整:對角線 × 0.0015,限制在 [0.02, 0.5] mm</summary>
/// <summary>Tessellation precision: bounding diagonal × 0.0015, clamped to [0.02, 0.5] mm.</summary>
private static double PrecisionFor(BoundingCube bc)
{
double diag;
@@ -350,7 +372,7 @@ public class StepImportService
return Math.Clamp(diag * 0.0015, 0.02, 0.5);
}
/// <returns>false = 三角化失敗(平行下偶發,可循序重試)</returns>
/// <returns>False when tessellation fails and the face needs a retry.</returns>
private bool AddFace(Face face, double precision, ImportedNode leaf, HashSet<Edge> edgeSet)
{
GeoPoint[] points; int[] indices;
@@ -361,7 +383,7 @@ public class StepImportService
}
catch
{
return false; // 個別面三角化失敗時跳過,不讓整檔失敗
return false;
}
finally
{
@@ -385,11 +407,11 @@ public class StepImportService
{
foreach (Edge e in face.AllEdges) edgeSet.Add(e);
}
catch { /* 邊收集失敗不影響面渲染 */ }
catch { }
return true;
}
/// <summary>leaf 收尾:邊取樣 + 統計 + 邊界</summary>
/// <summary>Completes edge sampling, counts and bounds for one leaf.</summary>
private void FinishLeaf(ImportedNode leaf, HashSet<Edge> edgeSet)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
@@ -426,7 +448,7 @@ public class StepImportService
return segments;
}
/// <summary>把曲線取樣成線段對(直線 1 段、曲線 12 段)</summary>
/// <summary>Samples curves into paired line endpoints.</summary>
private static void AppendCurveSegments(Point3DCollection segments, ICurve curve)
{
int n = curve is Line ? 1 : 12;
@@ -441,7 +463,7 @@ public class StepImportService
prev = cur;
}
}
catch { /* 個別曲線取樣失敗跳過 */ }
catch { }
}
private static Rect3D BoundsOfMesh(MeshGeometry3D mesh) => BoundsOfPoints(mesh.Positions);
@@ -461,7 +483,7 @@ public class StepImportService
Math.Max(maxX - minX, 1e-6), Math.Max(maxY - minY, 1e-6), Math.Max(maxZ - minZ, 1e-6));
}
/// <summary>移除沒有任何幾何的節點(延後三角化全失敗的 leaf、因此變空的 group)</summary>
/// <summary>Removes nodes left empty after tessellation.</summary>
private static void Prune(ImportedNode node)
{
for (int i = node.Children.Count - 1; i >= 0; i--)
@@ -473,7 +495,7 @@ public class StepImportService
}
}
/// <summary>group 節點統計彙總(bottom-up)</summary>
/// <summary>Aggregates counts and bounds from children.</summary>
private static void Aggregate(ImportedNode node)
{
if (node.IsLeaf) return;
+13 -22
View File
@@ -11,27 +11,22 @@ using CADability.GeoObject;
namespace STPViewer.Services;
/// <summary>
/// 網格精度:Current = 目前顯示網格;Fine = 由 B-rep 以較細精度重新三角化(無 B-rep 的來源維持現有網格)。
/// 不提供「較粗」:CADability 的 GetTriangulation 對「比快取粗」的精度要求會直接回傳既有較細快取,
/// 匯入時已三角化過 → 要求粗化實際上拿到同一份網格(實測驗證),故誠實不提供。
/// Mesh quality: Current uses the displayed mesh; Fine retessellates B-rep geometry.
/// </summary>
public enum StlMeshQuality { Current, Fine }
/// <summary>
/// STL 匯出:WPF 三角網格 → binary / ASCII STL。
/// STL 本身無單位,約定輸出 mm(scale=1);inch 交接用 scale = 1/25.4。
/// Writes WPF triangle meshes as binary or ASCII STL; scale=1 uses millimeters.
/// </summary>
public static class StlExportService
{
/// <summary>
/// 三角化精度係數(相對匯入預設):Fine 較細(三角形多、曲面平滑)。
/// 實測 CADability 三角形數對精度非單調(0.4× 重算反而比 1× 少 — 重算網格較有效率),
/// 要明顯更細需 ≤0.15×(test.stp:1× = 30,784 → 0.1× = 42,220 三角形)。
/// Tessellation factor relative to import precision; 0.15 produces a finer mesh.
/// </summary>
public static double PrecisionFactor(StlMeshQuality q) =>
q == StlMeshQuality.Fine ? 0.15 : 1.0;
/// <summary>預估檔案大小:binary = 84 + 50×n;ASCII 每三角形約 230 bytes</summary>
/// <summary>Estimated file size: 84 + 50 bytes per binary triangle, about 230 per ASCII triangle.</summary>
public static long EstimateBytes(long triangles, bool ascii) =>
ascii ? 20 + triangles * 230 : 84 + triangles * 50;
@@ -44,8 +39,7 @@ public static class StlExportService
};
/// <summary>
/// 把多個網格寫成一個 STL 檔。退化(零面積)三角形濾除。
/// 回傳實際寫出的三角形數。
/// Writes meshes to one STL file, skipping zero-area triangles. Returns the triangle count.
/// </summary>
public static int Write(string path, IReadOnlyList<MeshGeometry3D> meshes, bool ascii, double scale) =>
ascii ? WriteAscii(path, meshes, scale) : WriteBinary(path, meshes, scale);
@@ -55,11 +49,11 @@ public static class StlExportService
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)
// Avoid "solid" in the header; some readers would mistake it for ASCII.
var header = new byte[80];
Encoding.ASCII.GetBytes("STPViewer binary STL (unit: mm x scale)").CopyTo(header, 0);
bw.Write(header);
bw.Write(0u); // 三角形數 placeholder,寫完回頭補
bw.Write(0u); // Backfilled after writing triangles.
int count = 0;
foreach (MeshGeometry3D mesh in meshes)
@@ -69,7 +63,7 @@ public static class StlExportService
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; // 退化三角形
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);
@@ -125,7 +119,7 @@ public static class StlExportService
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>
/// <returns>False for a zero-area triangle.</returns>
private static bool TryNormal(Point3D a, Point3D b, Point3D c, out Vector3D n)
{
n = Vector3D.CrossProduct(b - a, c - a);
@@ -135,12 +129,11 @@ public static class StlExportService
return true;
}
// ─── B-rep 重新三角化(精細/較粗匯出用)────────────────────────
// B-rep retessellation
/// <summary>
/// 由 B-rep 幾何以指定精度係數重新三角化,回傳每面一個 frozen mesh。
/// CADability 非執行緒安全 → 呼叫端維持循序(可在單一背景執行緒)。
/// 個別面三角化失敗時跳過(與匯入一致的容錯策略)。
/// Retessellates B-rep geometry into frozen meshes, one per face.
/// Call sequentially because CADability tessellation is not thread-safe.
/// </summary>
public static List<MeshGeometry3D> Tessellate(IEnumerable<IGeoObject> geos, double precisionFactor)
{
@@ -170,9 +163,7 @@ public static class StlExportService
}
/// <summary>
/// 匯入預設精度(與 StepImportService.PrecisionFor 同款 clamp)再乘 factor —
/// 先 clamp 再乘,保證 factor&lt;1 時嚴格比匯入快取細(否則大殼在匯入被 0.5 上限截住,
/// 直接乘在 clamp 前會算出比快取粗的值 → CADability 回傳快取、精細無效)。
/// Clamp the import precision before applying factor so Fine is finer than the cached mesh.
/// </summary>
private static double PrecisionFor(BoundingCube bc, double factor)
{
+127
View File
@@ -0,0 +1,127 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Windows.Media;
using System.Windows.Media.Media3D;
namespace STPViewer.Services;
/// <summary>
/// Reads binary and ASCII STL triangles directly into WPF meshes.
/// </summary>
public static class StlMeshReader
{
/// <summary>Mesh for one solid section; vertices remain unwelded.</summary>
public record MeshGroup(string Name, Point3DCollection Positions, Int32Collection Indices,
string? NameKey = null, int NameNumber = 0);
public static List<MeshGroup> Read(string path)
{
return IsBinary(path) ? ReadBinary(path) : ReadAscii(path);
}
/// <summary>
/// Binary STL has exactly 84 + 50 × triangle count bytes; its header may also start with "solid".
/// </summary>
private static bool IsBinary(string path)
{
var fi = new FileInfo(path);
if (fi.Length < 84) return false;
Span<byte> head = stackalloc byte[84];
using (FileStream fs = File.OpenRead(path))
{
int read = 0;
while (read < 84)
{
int n = fs.Read(head[read..]);
if (n <= 0) break;
read += n;
}
if (read < 84) return false;
}
uint count = BitConverter.ToUInt32(head[80..]);
return fi.Length == 84L + 50L * count;
}
private static List<MeshGroup> ReadBinary(string path)
{
byte[] bytes = File.ReadAllBytes(path);
uint count = BitConverter.ToUInt32(bytes, 80);
var positions = new Point3DCollection((int)Math.Min(count * 3, int.MaxValue));
var indices = new Int32Collection((int)Math.Min(count * 3, int.MaxValue));
var span = bytes.AsSpan();
int off = 84, vi = 0;
for (uint t = 0; t < count; t++)
{
if (off + 50 > bytes.Length) break;
off += 12; // WPF computes the normal.
for (int v = 0; v < 3; v++)
{
float x = BitConverter.ToSingle(span[off..]);
float y = BitConverter.ToSingle(span[(off + 4)..]);
float z = BitConverter.ToSingle(span[(off + 8)..]);
positions.Add(new Point3D(x, y, z));
indices.Add(vi++);
off += 12;
}
off += 2; // attribute byte count
}
return new List<MeshGroup> { new(LocalizationService.Get("Runtime.MeshName"), positions, indices, "Runtime.MeshName") };
}
/// <summary>
/// ASCII STL: read vertex triples and start a new group at each solid section.
/// </summary>
private static List<MeshGroup> ReadAscii(string path)
{
var groups = new List<MeshGroup>();
Point3DCollection? pos = null;
Int32Collection? idx = null;
int vi = 0;
void FlushGroup()
{
if (pos is { Count: > 0 } && idx is not null)
groups.Add(new MeshGroup(groups.Count == 0 ? LocalizationService.Get("Runtime.MeshName") : LocalizationService.Format("Runtime.MeshNameNumbered", groups.Count + 1), pos, idx,
groups.Count == 0 ? "Runtime.MeshName" : "Runtime.MeshNameNumbered", groups.Count + 1));
pos = null; idx = null; vi = 0;
}
char[] sep = { ' ', '\t' };
foreach (string raw in File.ReadLines(path))
{
string line = raw.Trim();
if (line.Length == 0) continue;
if (line.StartsWith("solid", StringComparison.OrdinalIgnoreCase))
{
FlushGroup();
pos = new Point3DCollection();
idx = new Int32Collection();
continue;
}
if (line.StartsWith("endsolid", StringComparison.OrdinalIgnoreCase))
{
FlushGroup();
continue;
}
if (!line.StartsWith("vertex", StringComparison.OrdinalIgnoreCase)) continue;
pos ??= new Point3DCollection();
idx ??= new Int32Collection();
string[] tok = line.Split(sep, StringSplitOptions.RemoveEmptyEntries);
if (tok.Length < 4) continue;
if (double.TryParse(tok[1], NumberStyles.Float, CultureInfo.InvariantCulture, out double x) &&
double.TryParse(tok[2], NumberStyles.Float, CultureInfo.InvariantCulture, out double y) &&
double.TryParse(tok[3], NumberStyles.Float, CultureInfo.InvariantCulture, out double z))
{
pos.Add(new Point3D(x, y, z));
idx.Add(vi++);
}
}
FlushGroup();
return groups;
}
}
+22 -21
View File
@@ -2,8 +2,9 @@
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"
xmlns:loc="clr-namespace:STPViewer.Localization"
Title="{loc:Loc Ui.StlExport.Title}"
Width="520" SizeToContent="Height"
WindowStartupLocation="CenterOwner"
ResizeMode="NoResize" ShowInTaskbar="False"
d:DataContext="{d:DesignInstance Type=vm:StlExportViewModel}"
@@ -38,38 +39,38 @@
</Style>
</Grid.Resources>
<TextBlock Grid.Row="0" Grid.Column="0" Text="匯出範圍" />
<TextBlock Grid.Row="0" Grid.Column="0" Text="{loc:Loc Ui.StlExport.Scope}" />
<ComboBox Grid.Row="0" Grid.Column="1" SelectedIndex="{Binding ScopeIndex}"
ToolTip="STL 無裝配結構;合併 = 所有可見零件進同一檔">
<ComboBoxItem Content="全部可見檔案 → 合併成單一 STL" />
<ComboBoxItem Content="每個可見檔案 → 各一個 STL" />
ToolTip="{loc:Loc Ui.StlExport.ScopeTooltip}">
<ComboBoxItem Content="{loc:Loc Ui.StlExport.ScopeMerge}" />
<ComboBoxItem Content="{loc:Loc Ui.StlExport.ScopePerFile}" />
</ComboBox>
<TextBlock Grid.Row="1" Grid.Column="0" Text="格式" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="{loc:Loc Ui.StlExport.Format}" />
<ComboBox Grid.Row="1" Grid.Column="1" SelectedIndex="{Binding FormatIndex}">
<ComboBoxItem Content="Binary(建議,檔案小)" />
<ComboBoxItem Content="ASCII(文字可讀,檔案約 4 倍大)" />
<ComboBoxItem Content="{loc:Loc Ui.StlExport.FormatBinary}" />
<ComboBoxItem Content="{loc:Loc Ui.StlExport.FormatAscii}" />
</ComboBox>
<TextBlock Grid.Row="2" Grid.Column="0" Text="單位縮放" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="{loc:Loc Ui.StlExport.UnitScale}" />
<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="自訂縮放係數…" />
ToolTip="{loc:Loc Ui.StlExport.UnitTooltip}">
<ComboBoxItem Content="{loc:Loc Ui.StlExport.UnitMillimeter}" />
<ComboBoxItem Content="{loc:Loc Ui.StlExport.UnitInch}" />
<ComboBoxItem Content="{loc:Loc Ui.StlExport.UnitCustom}" />
</ComboBox>
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding CustomScaleText, UpdateSourceTrigger=PropertyChanged}"
Margin="0,0,0,10" Padding="4,2"
Visibility="{Binding IsCustomScale, Converter={StaticResource BoolToVis}}"
ToolTip="座標乘上此係數(例:0.001 = mm→m)" />
ToolTip="{loc:Loc Ui.StlExport.CustomScaleTooltip}" />
<TextBlock Grid.Row="4" Grid.Column="0" Text="網格精度" />
<TextBlock Grid.Row="4" Grid.Column="0" Text="{loc:Loc Ui.StlExport.MeshQuality}" />
<ComboBox Grid.Row="4" Grid.Column="1" SelectedIndex="{Binding QualityIndex}"
IsEnabled="{Binding HasBrep}"
ToolTip="精細會由 B-rep 重新三角化(僅 STEP 來源;STL/DXF 維持現有網格),大組件需要時間">
<ComboBoxItem Content="目前顯示網格(快,與畫面一致)" />
<ComboBoxItem Content="精細 — 重新三角化,曲面更平滑(慢、檔案大)" />
ToolTip="{loc:Loc Ui.StlExport.QualityTooltip}">
<ComboBoxItem Content="{loc:Loc Ui.StlExport.QualityCurrent}" />
<ComboBoxItem Content="{loc:Loc Ui.StlExport.QualityFine}" />
</ComboBox>
<Border Grid.Row="5" Grid.ColumnSpan="2" Background="#F0F4F8" CornerRadius="4"
@@ -78,8 +79,8 @@
</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"
<Button Content="{loc:Loc Ui.StlExport.Cancel}" Width="80" Margin="0,0,8,0" IsCancel="True" />
<Button Content="{loc:Loc Ui.StlExport.Export}" Width="96" IsDefault="True"
IsEnabled="{Binding CanConfirm}" Click="Confirm_Click" />
</StackPanel>
</Grid>
+1 -1
View File
@@ -3,7 +3,7 @@ using STPViewer.ViewModels;
namespace STPViewer;
/// <summary>STL 匯出參數對話框 — 選項邏輯在 StlExportViewModel,這裡只做確認/取消</summary>
/// <summary>STL export settings dialog; the view model owns the options.</summary>
public partial class StlExportDialog : Window
{
public StlExportViewModel ViewModel { get; }
+15 -15
View File
@@ -2,25 +2,26 @@ using System.Windows;
using System.Windows.Media.Media3D;
using HelixToolkit.Wpf;
using STPViewer.Models;
using static STPViewer.Services.LocalizationService;
namespace STPViewer.ViewModels;
// ─── 拖曳模式(左鍵按住零件沿螢幕平面拖;放開才烘進 B-rep)──────────
// Drag mode
public partial class MainViewModel
{
// 拖曳模式:暫時 Transform 跟著滑鼠,放開才一次性烘進 B-rep(TranslateRoot)
// Preview with a transform, then commit to B-rep on release.
private ModelNodeViewModel? _dragRoot;
private Point3D _dragAnchor; // 命中點(世界座標),拖曳平面的錨點
private Vector3D _dragApplied; // 目前累計位移
private Point3D _dragAnchor;
private Vector3D _dragApplied;
private TranslateTransform3D? _dragTransform;
/// <summary>左鍵按下:命中零件則開始拖曳(回傳 true 表示要捕捉滑鼠)</summary>
/// <summary>Starts dragging a hit part; returns true when the mouse should be captured.</summary>
public bool OnDragStart(Point position)
{
if (_viewport is null || IsBusy || CurrentMode != MeasureMode.Drag || _dragRoot is not null)
return false;
// 兩種渲染內容都可能在場:合併網格查 _mergedMap、逐面(剖面時)查 _faceMap
// Hits can come from merged meshes or section faces.
ModelNodeViewModel? leaf = null;
Point3D anchor = default;
foreach (var h in _viewport.Viewport.FindHits(position))
@@ -41,14 +42,14 @@ public partial class MainViewModel
if (l.BodyVisual is not null)
l.BodyVisual.Transform = _dragTransform;
// 拖曳中隱藏邊線(LinesVisual3D 隨 Transform 變更逐幀重建會卡)
// Hide edges while moving to avoid rebuilding them each frame.
_edgesSuspended = true;
SetEdgesActive(false);
StatusText = $"拖曳「{root.Name}」中…(放開定位)";
SetStatus(() => Format("Runtime.Dragging", root.Name));
return true;
}
/// <summary>滑鼠移動:把 2D 位移投影到「過錨點、面向相機」的平面上 → 暫時 Transform</summary>
/// <summary>Projects pointer movement onto the camera-facing plane through the anchor.</summary>
public void OnDragMove(Point position)
{
if (_viewport?.Camera is not System.Windows.Media.Media3D.ProjectionCamera cam ||
@@ -62,15 +63,14 @@ public partial class MainViewModel
_dragTransform.OffsetZ = _dragApplied.Z;
}
/// <summary>放開:移除暫時 Transform,一次性烘進 B-rep(量測精度不受拖曳影響)</summary>
/// <summary>Commits the temporary transform to B-rep on release.</summary>
public bool OnDragEnd()
{
if (_dragRoot is null) return false;
ModelNodeViewModel root = _dragRoot;
Vector3D delta = _dragApplied;
// 清除暫時位移:必須用 Identity,不可用 null —— HelixToolkit GetTransform 對 child.Transform
// 不做 null 檢查(Children.Add(null) 會拋「無法新增空值到集合中」),下次 FindHits 即 crash
// HelixToolkit hit testing requires Identity rather than a null transform.
foreach (ModelNodeViewModel l in root.Leaves())
if (l.BodyVisual is not null)
l.BodyVisual.Transform = Transform3D.Identity;
@@ -78,12 +78,12 @@ public partial class MainViewModel
_dragTransform = null;
_dragApplied = default;
_edgesSuspended = false;
SetEdgesActive(true); // 恢復所有檔案的邊線(被拖檔案的點隨後由 TranslateRoot 更新,期間不會渲染)
SetEdgesActive(true);
if (delta.LengthSquared > 1e-12)
{
TranslateRoot(root, delta); // 烘進 B-rep + 網格 + 邊線 + 邊界(量測清空)
StatusText = $"已拖曳「{root.Name}」 Δ({delta.X:F2}, {delta.Y:F2}, {delta.Z:F2}) mm(可用 🧩干涉 驗證)";
TranslateRoot(root, delta);
SetStatus(() => Format("Runtime.Dragged", root.Name, delta.X, delta.Y, delta.Z));
}
return true;
}
@@ -12,10 +12,11 @@ using CommunityToolkit.Mvvm.Input;
using Microsoft.Win32;
using STPViewer.Models;
using STPViewer.Services;
using static STPViewer.Services.LocalizationService;
namespace STPViewer.ViewModels;
// ─── 匯出(量測 CSV / 視圖 PNG 截圖 / STEP / STL)────────────────────
// CSV, STEP, STL, and screenshot export
public partial class MainViewModel
{
[RelayCommand]
@@ -23,32 +24,32 @@ public partial class MainViewModel
{
if (Measurements.Count == 0)
{
StatusText = "沒有量測結果可匯出";
SetStatus(() => Get("Runtime.CsvEmpty"));
return;
}
var dlg = new SaveFileDialog
{
Title = "匯出量測結果",
Filter = "CSV 檔案 (*.csv)|*.csv",
Title = Get("Runtime.CsvTitle"),
Filter = Get("Runtime.CsvFilter"),
FileName = $"measurements_{DateTime.Now:yyyyMMdd_HHmmss}.csv",
};
if (dlg.ShowDialog() != true) return;
var sb = new StringBuilder();
sb.AppendLine("標籤,類型,明細");
sb.AppendLine(Get("Runtime.CsvHeader"));
foreach (MeasurementResult m in Measurements)
{
static string Esc(string s) => $"\"{s.Replace("\"", "\"\"")}\"";
sb.AppendLine($"{Esc(m.Title)},{m.Kind},{Esc(m.Detail.Replace("\n", " | "))}");
sb.AppendLine($"{Esc(m.Title)},{Esc(Get($"Runtime.Kind{m.Kind}"))},{Esc(m.Detail.Replace("\n", " | "))}");
}
File.WriteAllText(dlg.FileName, sb.ToString(), new UTF8Encoding(true)); // BOM:Excel 中文不亂碼
StatusText = $"已匯出 {Measurements.Count} 筆量測 → {Path.GetFileName(dlg.FileName)}";
File.WriteAllText(dlg.FileName, sb.ToString(), new UTF8Encoding(true)); // BOM for Excel.
int count = Measurements.Count;
string fileName = Path.GetFileName(dlg.FileName);
SetStatus(() => Format("Runtime.CsvExported", count, fileName));
}
/// <summary>
/// 把可見檔案(目前位置,含對齊/旋轉/拖曳結果)匯出成「新的」STEP 檔 —
/// 對齊插合後的配合結果可交接給 CAD;不動任何原始檔(唯讀原則不變)。
/// 只含 B-rep Solid/Shell(STL 網格、DXF 線架構無法進 STEP B-rep)。
/// Exports visible B-rep Solid and Shell objects at their current positions.
/// </summary>
[RelayCommand(CanExecute = nameof(IsIdle))]
private void ExportStepFile()
@@ -60,41 +61,43 @@ public partial class MainViewModel
.ToList();
if (geos.Count == 0)
{
StatusText = "沒有可匯出的 B-rep 幾何(STEP 匯出僅含 Solid/Shell;請勾選要匯出的 STEP 檔案)";
SetStatus(() => Get("Runtime.StepExportEmpty"));
return;
}
var dlg = new SaveFileDialog
{
Title = "匯出目前場景為 STEP(零件在目前對齊位置)",
Filter = "STEP 檔案 (*.stp)|*.stp|STEP 檔案 (*.step)|*.step",
Title = Get("Runtime.StepExportTitle"),
Filter = Get("Runtime.StepExportFilter"),
FileName = $"assembly_{DateTime.Now:yyyyMMdd_HHmmss}.stp",
};
if (dlg.ShowDialog() != true) return;
try
{
// B-rep 已含目前位置(TransformRoot 對 Solid/Shell 整體 Modify)→ 直接寫出
// B-rep objects already contain their transformed positions.
CADability.Project project = CADability.Project.CreateSimpleProject();
CADability.Model model = project.GetActiveModel();
foreach (CADability.GeoObject.IGeoObject g in geos) model.Add(g);
new CADability.ExportStep().WriteToFile(dlg.FileName, project);
StatusText = $"已匯出 STEP({geos.Count} 個實體,目前位置)→ {Path.GetFileName(dlg.FileName)}";
int count = geos.Count;
string fileName = Path.GetFileName(dlg.FileName);
SetStatus(() => Format("Runtime.StepExported", count, fileName));
}
catch (Exception ex)
{
StatusText = $"STEP 匯出失敗:{ex.Message}";
string error = ex.Message;
SetStatus(() => Format("Runtime.StepExportFailed", error));
}
}
/// <summary>
/// 匯出可見檔案(目前位置)為 STL 網格檔 — 先開參數對話框(範圍/格式/單位/精度 + 三角形數與大小預估)
/// 確認後才選路徑寫檔。精細/較粗 = 由 B-rep 重新三角化(背景執行緒);STL/DXF 來源一律用現有網格。
/// Exports visible meshes as STL using the selected format, scale and quality.
/// </summary>
[RelayCommand(CanExecute = nameof(IsIdle))]
private async Task ExportStlFile()
{
// 可匯出 = 可見 leaf 且有網格(DXF 純線架構無網格 → 排除)
// Only visible leaves with meshes can be exported.
var groups = Roots.Where(r => r.IsVisible)
.Select(r => (Root: r, Leaves: r.Leaves()
.Where(l => l.IsVisible && GetMergedMesh(l) is not null).ToList()))
@@ -102,7 +105,7 @@ public partial class MainViewModel
.ToList();
if (groups.Count == 0)
{
StatusText = "沒有可匯出的網格(請勾選至少一個含實體網格的檔案)";
SetStatus(() => Get("Runtime.StlExportEmpty"));
return;
}
@@ -110,23 +113,22 @@ public partial class MainViewModel
.Sum(l => GetMergedMesh(l)!.TriangleIndices.Count / 3L);
bool hasBrep = groups.SelectMany(g => g.Leaves).Any(l => l.HasBrep && l.SourceGeos.Count > 0);
// 參數對話框(確認再匯出)
// Collect export options.
var vm = new StlExportViewModel(groups.Count, triangles, hasBrep);
var dialog = new StlExportDialog(vm) { Owner = Application.Current.MainWindow };
if (dialog.ShowDialog() != true || vm.Scale is not double scale) return;
var dlg = new SaveFileDialog
{
Title = vm.PerFile ? "匯出 STL(每個檔案各一個,以此為基底檔名)" : "匯出 STL(合併單一檔)",
Filter = "STL 檔案 (*.stl)|*.stl",
Title = Get(vm.PerFile ? "Runtime.StlExportPerFileTitle" : "Runtime.StlExportMergedTitle"),
Filter = Get("Runtime.StlExportFilter"),
FileName = vm.PerFile
? $"stl_{DateTime.Now:yyyyMMdd_HHmmss}.stl"
: $"assembly_{DateTime.Now:yyyyMMdd_HHmmss}.stl",
};
if (dlg.ShowDialog() != true) return;
// UI 執行緒先快照:GeometryModel3D/Model3DGroup 是 DispatcherObject 不可跨執行緒,
// 背景只能碰 frozen MeshGeometry3D 與 CADability B-rep(SourceGeos)
// Snapshot WPF geometry on the UI thread before background export.
var snapshot = groups
.Select(g => (g.Root.Name, Leaves: g.Leaves
.Select(l => (Mesh: GetMergedMesh(l)!, l.HasBrep, l.SourceGeos))
@@ -134,9 +136,8 @@ public partial class MainViewModel
.ToList();
IsBusy = true;
StatusText = vm.Quality == StlMeshQuality.Current
? "匯出 STL 中…"
: "重新三角化 + 匯出 STL 中…(大組件需要時間)";
SetStatus(() => Get(vm.Quality == StlMeshQuality.Current
? "Runtime.StlExporting" : "Runtime.StlRetriangulating"));
try
{
double factor = StlExportService.PrecisionFactor(vm.Quality);
@@ -164,13 +165,16 @@ public partial class MainViewModel
});
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)}";
int fileCount = written.Count;
string destination = fileCount == 1 ? Path.GetFileName(written[0].File) : Path.GetDirectoryName(dlg.FileName)!;
SetStatus(() => fileCount == 1
? Format("Runtime.StlExportedOne", total, destination)
: Format("Runtime.StlExportedMany", fileCount, total, destination));
}
catch (Exception ex)
{
StatusText = $"STL 匯出失敗:{ex.Message}";
string error = ex.Message;
SetStatus(() => Format("Runtime.StlExportFailed", error));
}
finally
{
@@ -178,16 +182,14 @@ public partial class MainViewModel
}
}
/// <summary>leaf 的合併網格(frozen、未剖切、目前位置)— STL 匯出的「目前顯示網格」來源</summary>
/// <summary>Current merged mesh for an exportable leaf.</summary>
private static MeshGeometry3D? GetMergedMesh(ModelNodeViewModel leaf) =>
leaf.MergedContent?.Children.Count > 0
? (leaf.MergedContent.Children[0] as GeometryModel3D)?.Geometry as MeshGeometry3D
: null;
/// <summary>
/// 收集要寫進 STL 的網格(背景執行緒;輸入是 UI 快照):
/// 精細且有 B-rep → 重新三角化(循序,CADability 非執行緒安全);
/// 其餘(目前網格 / STL 來源)→ 快照的合併網格(frozen,可安全跨執行緒讀取)。
/// Selects frozen current meshes or sequentially retessellates B-rep geometry.
/// </summary>
private static List<MeshGeometry3D> CollectMeshes(
IReadOnlyList<(MeshGeometry3D Mesh, bool HasBrep, List<CADability.GeoObject.IGeoObject> SourceGeos)> leaves,
@@ -200,7 +202,7 @@ public partial class MainViewModel
{
List<MeshGeometry3D> tess = StlExportService.Tessellate(sourceGeos, precisionFactor);
if (tess.Count > 0) { meshes.AddRange(tess); continue; }
// 重新三角化全數失敗 → 退回現有網格,不讓零件憑空消失
// Fall back to the current mesh if retessellation yields nothing.
}
meshes.Add(mesh);
}
@@ -221,13 +223,13 @@ public partial class MainViewModel
if (_viewport is null || _viewport.ActualWidth < 1) return;
var dlg = new SaveFileDialog
{
Title = "儲存視圖截圖",
Filter = "PNG 圖片 (*.png)|*.png",
Title = Get("Runtime.ScreenshotTitle"),
Filter = Get("Runtime.ScreenshotFilter"),
FileName = $"stpviewer_{DateTime.Now:yyyyMMdd_HHmmss}.png",
};
if (dlg.ShowDialog() != true) return;
const double scale = 2.0; // 2x 解析度
const double scale = 2.0;
var rtb = new RenderTargetBitmap(
(int)(_viewport.ActualWidth * scale), (int)(_viewport.ActualHeight * scale),
96 * scale, 96 * scale, PixelFormats.Pbgra32);
@@ -236,6 +238,7 @@ public partial class MainViewModel
encoder.Frames.Add(BitmapFrame.Create(rtb));
using FileStream fs = File.Create(dlg.FileName);
encoder.Save(fs);
StatusText = $"已儲存截圖 → {Path.GetFileName(dlg.FileName)}";
string fileName = Path.GetFileName(dlg.FileName);
SetStatus(() => Format("Runtime.ScreenshotSaved", fileName));
}
}
+30 -31
View File
@@ -4,42 +4,41 @@ using System.Windows.Media;
using System.Windows.Media.Media3D;
using CommunityToolkit.Mvvm.ComponentModel;
using HelixToolkit.Wpf;
using static STPViewer.Services.LocalizationService;
namespace STPViewer.ViewModels;
// ─── Gizmo 三軸操作器(XYZ 箭頭 + 旋轉環;每次放開滑鼠烘進 B-rep)──────
// Gizmo translation and rotation
public partial class MainViewModel
{
[ObservableProperty]
private bool gizmoEnabled;
private readonly List<HelixToolkit.Wpf.Manipulator> _gizmoParts = new();
private ModelVisual3D? _gizmoProxy; // 操作器綁定的代理 visual(其 Transform = 使用者拖出的變換)
private ModelVisual3D? _gizmoProxy;
private ModelNodeViewModel? _gizmoTarget;
private bool _gizmoDragActive; // 拖動中(邊線已暫停)
private bool _gizmoBaking; // 烘焙中,忽略 Transform 變更回呼
private bool _gizmoBakePending; // 已排程烘焙,防同一次放開重複觸發
private bool _gizmoDragActive;
private bool _gizmoBaking;
private bool _gizmoBakePending;
// 操作器疊圖層:另一個透明 Viewport3D 疊在主視窗上、相機同步、只放操作器 →
// 操作器不在主場景,永不被實體遮擋(always-on-top);空白處滑鼠穿透回主視窗
// A synchronized overlay viewport keeps the gizmo above scene geometry.
private System.Windows.Controls.Viewport3D? _overlayViewport;
private ProjectionCamera? _overlayCamera; // 型別跟隨主相機(透視/正交切換,v0.5.0)
private ProjectionCamera? _overlayCamera;
/// <summary>注入操作器疊圖層(透明 Viewport3D,疊在主視窗上、永遠最上層)</summary>
/// <summary>Attaches the transparent gizmo overlay viewport.</summary>
public void AttachOverlay(System.Windows.Controls.Viewport3D overlay)
{
_overlayViewport = overlay;
_overlayCamera = new PerspectiveCamera();
overlay.Camera = _overlayCamera;
// 操作器材質需打光(manipulator 用 DiffuseMaterial)
// Manipulator materials need lighting.
overlay.Children.Add(new ModelVisual3D { Content = new AmbientLight(Color.FromRgb(0x80, 0x80, 0x80)) });
overlay.Children.Add(new ModelVisual3D { Content = new DirectionalLight(Colors.White, new Vector3D(-1, -1, -3)) });
SyncOverlayCamera();
}
/// <summary>
/// 疊圖層相機跟隨主相機(每次主相機變更時呼叫,讓操作器疊在正確螢幕位置)。
/// 主相機可能在透視/正交間切換(Orthographic 屬性)→ 疊圖層相機「型別」也要跟著換,投影才一致。
/// Keeps the overlay camera in sync with the main camera and projection mode.
/// </summary>
private void SyncOverlayCamera()
{
@@ -78,7 +77,7 @@ public partial class MainViewModel
partial void OnSelectedNodeChanged(ModelNodeViewModel? value)
{
if (GizmoEnabled) UpdateGizmo(); // 換選取 → 操作器跟著換目標
if (GizmoEnabled) UpdateGizmo();
}
private void UpdateGizmo()
@@ -90,12 +89,12 @@ public partial class MainViewModel
: Roots.Count == 1 ? Roots[0] : null;
if (root is null || root.Bounds.IsEmpty)
{
StatusText = "操作器:請先在樹面板點選要操作的檔案";
SetStatus(() => Get("Runtime.GizmoSelectFile"));
GizmoEnabled = false;
return;
}
_gizmoTarget = root;
SyncOverlayCamera(); // 操作器出現前先對齊相機
SyncOverlayCamera();
Rect3D b = root.Bounds;
var center = new Point3D(b.X + b.SizeX / 2, b.Y + b.SizeY / 2, b.Z + b.SizeZ / 2);
@@ -112,18 +111,18 @@ public partial class MainViewModel
man.Position = center;
man.Bind(_gizmoProxy);
_gizmoParts.Add(man);
_overlayViewport!.Children.Add(man); // 放疊圖層 → 永不被實體遮擋
_overlayViewport!.Children.Add(man);
}
// 平移箭頭(X 紅 / Y 綠 / Z 藍 — 業界慣例)
// Translation arrows: X red, Y green, Z blue.
AddPart(new TranslateManipulator { Direction = new Vector3D(1, 0, 0), Color = Colors.Red, Length = diag * 0.22, Diameter = diag * 0.016 });
AddPart(new TranslateManipulator { Direction = new Vector3D(0, 1, 0), Color = Colors.Green, Length = diag * 0.22, Diameter = diag * 0.016 });
AddPart(new TranslateManipulator { Direction = new Vector3D(0, 0, 1), Color = Colors.Blue, Length = diag * 0.22, Diameter = diag * 0.016 });
// 旋轉環
// Rotation rings
AddPart(new RotateManipulator { Axis = new Vector3D(1, 0, 0), Color = Colors.Red, Diameter = diag * 0.34, InnerDiameter = diag * 0.30, Length = diag * 0.012 });
AddPart(new RotateManipulator { Axis = new Vector3D(0, 1, 0), Color = Colors.Green, Diameter = diag * 0.34, InnerDiameter = diag * 0.30, Length = diag * 0.012 });
AddPart(new RotateManipulator { Axis = new Vector3D(0, 0, 1), Color = Colors.Blue, Diameter = diag * 0.34, InnerDiameter = diag * 0.30, Length = diag * 0.012 });
StatusText = $"操作器:拖箭頭沿軸移動「{root.Name}」、拖環繞軸旋轉;放開即定位";
SetStatus(() => Format("Runtime.GizmoHint", root.Name));
}
private void RemoveGizmo()
@@ -143,7 +142,7 @@ public partial class MainViewModel
_gizmoParts.Clear();
_gizmoProxy = null;
if (_gizmoTarget is not null) // 清掉殘留的暫時 Transform(用 Identity,不可 null → 否則 FindHits crash)
if (_gizmoTarget is not null)
foreach (ModelNodeViewModel l in _gizmoTarget.Leaves())
if (l.BodyVisual is not null)
l.BodyVisual.Transform = Transform3D.Identity;
@@ -156,7 +155,7 @@ public partial class MainViewModel
}
}
/// <summary>操作器拖動中:把代理的 Transform 套到目標檔案的所有 BodyVisual(暫時、GPU 端)</summary>
/// <summary>Applies the proxy transform to target visuals during dragging.</summary>
private void GizmoTransformChanged(object? sender, EventArgs e)
{
if (_gizmoBaking || _gizmoTarget is null || _gizmoProxy is null) return;
@@ -165,21 +164,21 @@ public partial class MainViewModel
{
_gizmoDragActive = true;
_edgesSuspended = true;
SetEdgesActive(false); // 邊線逐幀重建會卡,拖動中暫停
SetEdgesActive(false);
}
foreach (ModelNodeViewModel l in _gizmoTarget.Leaves())
if (l.BodyVisual is not null)
l.BodyVisual.Transform = t;
}
/// <summary>滑鼠放開(MainWindow 轉發,handledEventsToo):把累積變換烘進 B-rep 並重置操作器</summary>
/// <summary>Commits the accumulated transform and resets the gizmo on mouse release.</summary>
public void OnGizmoMouseUp()
{
if (_gizmoTarget is null || _gizmoProxy is null || _gizmoBakePending) return;
Matrix3D m = _gizmoProxy.Transform?.Value ?? Matrix3D.Identity;
if (m.IsIdentity) return; // 只是點一下、沒拖操作器 → 不烘焙
if (m.IsIdentity) return;
// 延後到 manipulator 自身的 mouse-up 處理(釋放捕捉等)完成後再烘焙,避免在其事件中改動視覺樹造成 reentrancy
// Commit after the manipulator completes its mouse-up handling.
_gizmoBakePending = true;
_viewport?.Dispatcher.BeginInvoke(new Action(() => BakeGizmo(m)),
System.Windows.Threading.DispatcherPriority.Background);
@@ -191,19 +190,19 @@ public partial class MainViewModel
if (_gizmoTarget is null) return;
ModelNodeViewModel root = _gizmoTarget;
_gizmoBaking = true; // 防 proxy 歸零觸發 GizmoTransformChanged 重入
_gizmoBaking = true;
foreach (ModelNodeViewModel l in root.Leaves())
if (l.BodyVisual is not null)
l.BodyVisual.Transform = Transform3D.Identity; // 清暫時位移(用 Identity,不可 null)
if (_gizmoProxy is not null) _gizmoProxy.Transform = Transform3D.Identity; // 綁定 → 操作器歸零
l.BodyVisual.Transform = Transform3D.Identity;
if (_gizmoProxy is not null) _gizmoProxy.Transform = Transform3D.Identity;
_gizmoBaking = false;
_gizmoDragActive = false;
_edgesSuspended = false;
SetEdgesActive(true);
TransformRoot(root, Services.RigidAlign.ToModOp(m), m); // 烘進 B-rep(量測精度不受影響)
TransformRoot(root, Services.RigidAlign.ToModOp(m), m);
// 操作器移到變換後的新中心
// Move the gizmo to the transformed center.
Rect3D b = root.Bounds;
if (!b.IsEmpty)
{
@@ -211,6 +210,6 @@ public partial class MainViewModel
foreach (HelixToolkit.Wpf.Manipulator man in _gizmoParts)
man.Position = center;
}
StatusText = $"已套用操作器變換「{root.Name}」(可用 🧩干涉 驗證)";
SetStatus(() => Format("Runtime.GizmoApplied", root.Name));
}
}
@@ -9,10 +9,11 @@ using CommunityToolkit.Mvvm.Input;
using HelixToolkit.Wpf;
using STPViewer.Models;
using STPViewer.Services;
using static STPViewer.Services.LocalizationService;
namespace STPViewer.ViewModels;
// ─── 干涉檢查 ────────────────────────────────────────────────────────
// Interference checking
public partial class MainViewModel
{
[RelayCommand(CanExecute = nameof(IsIdle))]
@@ -22,7 +23,8 @@ public partial class MainViewModel
var visibleRoots = Roots.Where(r => r.IsVisible).ToList();
if (visibleRoots.Count < 2)
{
StatusText = $"干涉檢查需要至少 2 個可見檔案(目前 {visibleRoots.Count} 個)— 用樹面板勾選";
int visibleCount = visibleRoots.Count;
SetStatus(() => Format("Runtime.InterferenceNeedTwo", visibleCount));
return;
}
@@ -33,7 +35,7 @@ public partial class MainViewModel
.Select(m => _faceMap[(GeometryModel3D)m].Mesh)
.ToList();
// 網格快照(開跑後零件被改走也不會半途換料;v0.4.0 起運算期間指令已鎖,這裡是雙保險)
// Snapshot meshes before background calculations.
var meshes = visibleRoots.ToDictionary(r => r, MeshesOf);
IsBusy = true;
@@ -45,7 +47,8 @@ public partial class MainViewModel
{
pairs++;
string nameA = visibleRoots[i].Name, nameB = visibleRoots[j].Name;
StatusText = $"干涉檢查中(第 {pairs} 組):{nameA} ⟷ {nameB} …";
int currentPair = pairs;
SetStatus(() => Format("Runtime.InterferenceChecking", currentPair, nameA, nameB));
var a = meshes[visibleRoots[i]];
var b = meshes[visibleRoots[j]];
InterferenceResult result = await Task.Run(() => InterferenceService.Check(a, b));
@@ -53,11 +56,15 @@ public partial class MainViewModel
if (result.Intersects) intersecting++;
}
if (pairs > 1)
StatusText = $"干涉檢查完成:{pairs} 組配對、{intersecting} 組相交";
{
int checkedPairs = pairs, hits = intersecting;
SetStatus(() => Format("Runtime.InterferenceCompleted", checkedPairs, hits));
}
}
catch (Exception ex)
{
StatusText = $"干涉檢查失敗:{ex.Message}";
string error = ex.Message;
SetStatus(() => Format("Runtime.InterferenceFailed", error));
}
finally
{
@@ -78,9 +85,8 @@ public partial class MainViewModel
m = new MeasurementResult
{
Kind = MeasureMode.Interference,
TitleFor = _ => $"🧩 干涉!{nameA} ⟷ {nameB}",
DetailFor = _ => $"兩零件相交(不 match)\n相交三角形對 = {pairs:N0}\n" +
$"紅色線 = 干涉交線({segCount:N0} 段)\n(共面貼合不會被算為干涉)",
TitleFor = _ => Format("Runtime.InterferenceHitTitle", nameA, nameB),
DetailFor = _ => Format("Runtime.InterferenceHitDetail", pairs, segCount),
};
var pts = new Point3DCollection(Math.Min(result.Segments.Count, 20000) * 2);
foreach ((Point3D s0, Point3D s1) in result.Segments) { pts.Add(s0); pts.Add(s1); }
@@ -88,15 +94,17 @@ public partial class MainViewModel
if (result.Segments.Count > 0)
{
Point3D at = result.Segments[0].A;
m.Overlays.Add(new BillboardTextVisual3D
var label = new BillboardTextVisual3D
{
Position = at + new Vector3D(0, 0, markerR * 3),
Text = "干涉",
Text = Get("Runtime.InterferenceLabel"),
Foreground = Brushes.White,
Background = Brushes.Red,
Padding = new Thickness(5, 2, 5, 2),
FontSize = 14,
});
};
m.Overlays.Add(label);
m.DynamicLabels.Add((label, _ => Get("Runtime.InterferenceLabel")));
}
}
else
@@ -107,9 +115,8 @@ public partial class MainViewModel
m = new MeasurementResult
{
Kind = MeasureMode.Interference,
TitleFor = u => $"🧩 無干涉 gap ≈ {Units.L(gap, u)}",
DetailFor = u => $"{nameA} ⟷ {nameB} 無相交\n最小間隙 ≈ {Units.L(gap, u)}(網格近似)\n" +
$"gap ≈ 0 即為貼合(match)\n點 1 {Units.P(ga, u)}\n點 2 {Units.P(gb, u)}",
TitleFor = u => Format("Runtime.InterferenceClearTitle", Units.L(gap, u)),
DetailFor = u => Format("Runtime.InterferenceClearDetail", nameA, nameB, Units.L(gap, u), Units.P(ga, u), Units.P(gb, u)),
};
m.Overlays.Add(new SphereVisual3D { Center = ga, Radius = markerR, Fill = Brushes.OrangeRed });
m.Overlays.Add(new SphereVisual3D { Center = gb, Radius = markerR, Fill = Brushes.OrangeRed });
@@ -122,14 +129,14 @@ public partial class MainViewModel
var label = new BillboardTextVisual3D
{
Position = ga + d / 2 + new Vector3D(0, 0, markerR * 2),
Text = $"gap {Units.L(gap, UnitSystem.Millimeter)}",
Text = Format("Runtime.GapLabel", Units.L(gap, UnitSystem.Millimeter)),
Foreground = Brushes.Black,
Background = new SolidColorBrush(Color.FromArgb(200, 255, 255, 210)),
Padding = new Thickness(4, 2, 4, 2),
FontSize = 14,
};
m.Overlays.Add(label);
m.DynamicLabels.Add((label, u => $"gap {Units.L(gap, u)}"));
m.DynamicLabels.Add((label, u => Format("Runtime.GapLabel", Units.L(gap, u))));
}
AddMeasurement(m);
}
@@ -0,0 +1,32 @@
using CommunityToolkit.Mvvm.Input;
using STPViewer.Models;
using STPViewer.Services;
namespace STPViewer.ViewModels;
public partial class MainViewModel
{
public string SelectedLanguage
{
get => LocalizationService.Instance.Language;
set => LocalizationService.Instance.SetLanguage(value);
}
public IReadOnlyList<LanguageDefinition> Languages => LocalizationService.Instance.Languages;
public void InitializeLocalization() =>
LocalizationService.Instance.LanguageChanged += OnLanguageChanged;
public void DisposeLocalization() =>
LocalizationService.Instance.LanguageChanged -= OnLanguageChanged;
[RelayCommand]
private void ChangeLanguage(string language) => SelectedLanguage = language;
private void OnLanguageChanged(object? sender, EventArgs e)
{
OnPropertyChanged(nameof(SelectedLanguage));
QuickBar.RefreshText();
RefreshLocalizedText();
}
}
@@ -9,19 +9,19 @@ using CommunityToolkit.Mvvm.Input;
using HelixToolkit.Wpf;
using STPViewer.Models;
using STPViewer.Services;
using static STPViewer.Services.LocalizationService;
namespace STPViewer.ViewModels;
// ─── 剖面(CPU 網格裁切;v0.4.0 起裁切在背景執行緒平行計算,UI 不凍結)──
// Section clipping
public partial class MainViewModel
{
private readonly DispatcherTimer _sectionTimer;
private RectangleVisual3D? _sectionPlaneVisual;
private Point3D _sectionPlanePoint; // SectionEnabled 時的剖切平面(合併邊線重建用)
private Point3D _sectionPlanePoint;
private Vector3D _sectionNormal;
// 背景裁切 guard:進行中又有新變更(拉滑桿/換軸/零件平移)→ 完成後用最新參數重跑一輪。
// 不要改回同步呼叫 ClipMesh —— 大檔(64k 面)整場景裁切會凍結 UI 數秒
// Reapply clipping if parameters change while background work is running.
private bool _sectionApplying;
private bool _sectionReapply;
@@ -32,20 +32,20 @@ public partial class MainViewModel
[ObservableProperty]
private int sectionAxisIndex;
/// <summary>剖面位置(沿軸 0~100%)</summary>
/// <summary>Section position along the selected axis, from 0 to 100%.</summary>
[ObservableProperty]
private double sectionPosition = 50;
[ObservableProperty]
private bool sectionFlip;
/// <summary>剖面參數列的「✕ 關閉」按鈕(參數列只在剖面開啟時顯示)</summary>
/// <summary>Closes the section parameter bar.</summary>
[RelayCommand]
private void CloseSection() => SectionEnabled = false;
// ── 3點自訂剖切平面(SectionAxisIndex == 3,v0.5.0)──
// Three-point custom cutting plane
private const int CustomAxisIndex = 3;
private Vector3D? _customNormal; // null = 尚未定義(等使用者點 3 點)
private Vector3D? _customNormal;
private readonly List<Point3D> _sectionPicks = new();
private readonly List<Visual3D> _sectionPickOverlays = new();
@@ -60,11 +60,11 @@ public partial class MainViewModel
if (value == CustomAxisIndex)
{
if (_customNormal is null)
StatusText = "3點剖面:請在模型上點 3 個點定義剖切平面(Esc 取消)";
SetStatus(() => Get("Runtime.SectionPickPrompt"));
}
else
{
// 離開自訂平面 → 丟棄舊平面(再選「3點」即重新定義)
// Leaving custom mode discards the old plane.
_customNormal = null;
ClearSectionPicks();
}
@@ -73,14 +73,14 @@ public partial class MainViewModel
partial void OnSectionPositionChanged(double value)
{
if (value < 0) { SectionPosition = 0; return; } // 位置數值輸入框防呆
if (value < 0) { SectionPosition = 0; return; }
if (value > 100) { SectionPosition = 100; return; }
ScheduleSection();
}
partial void OnSectionFlipChanged(bool value) => ScheduleSection();
/// <summary>3點剖面定義模式中的點擊(OnViewportClick 轉入)。回傳 true = 已消費此點擊。</summary>
/// <summary>Handles a custom plane pick; returns true when the click is consumed.</summary>
public bool HandleSectionPlanePick(System.Windows.Point position)
{
if (!SectionEnabled || SectionAxisIndex != CustomAxisIndex || _customNormal is not null) return false;
@@ -98,13 +98,14 @@ public partial class MainViewModel
}
if (hit is null)
{
StatusText = $"3點剖面:未命中模型表面(已選 {_sectionPicks.Count}/3 點,Esc 取消)";
int selected = _sectionPicks.Count;
SetStatus(() => Format("Runtime.SectionNoHit", selected));
return true;
}
if (_sectionPicks.Count == 2 &&
Vector3D.CrossProduct(_sectionPicks[1] - _sectionPicks[0], hit.Value - _sectionPicks[0]).LengthSquared < 1e-12)
{
StatusText = "3點剖面:三點共線,請改選不共線的第 3 點";
SetStatus(() => Get("Runtime.SectionCollinear"));
return true;
}
@@ -116,7 +117,8 @@ public partial class MainViewModel
if (_sectionPicks.Count < 3)
{
StatusText = $"3點剖面:已選 {_sectionPicks.Count}/3 點";
int selected = _sectionPicks.Count;
SetStatus(() => Format("Runtime.SectionPicked", selected));
return true;
}
@@ -124,25 +126,25 @@ public partial class MainViewModel
ClearSectionPicks();
if (n.LengthSquared < 1e-12)
{
StatusText = "3點剖面:三點退化(共線),請重新點選";
SetStatus(() => Get("Runtime.SectionDegenerate"));
return true;
}
n.Normalize();
_customNormal = n;
StatusText = $"3點剖面:平面已定義(法向 {n.X:F2}, {n.Y:F2}, {n.Z:F2})— 用位置滑桿掃剖面";
SetStatus(() => Format("Runtime.SectionDefined", n.X, n.Y, n.Z));
ApplySection();
return true;
}
/// <summary>取消 3 點平面定義(Esc / 關剖面)。回傳 true = 有取消動作。</summary>
/// <summary>Cancels custom plane picking; returns true if it was active.</summary>
private bool CancelSectionPickIfActive(bool quiet = false)
{
bool picking = SectionEnabled && SectionAxisIndex == CustomAxisIndex && _customNormal is null;
if (_sectionPicks.Count == 0 && !picking) return false;
ClearSectionPicks();
if (SectionAxisIndex == CustomAxisIndex && _customNormal is null)
SectionAxisIndex = 0; // 回 X 軸
if (!quiet) StatusText = "已取消 3 點剖面定義";
SectionAxisIndex = 0;
if (!quiet) SetStatus(() => Get("Runtime.SectionCancelled"));
return true;
}
@@ -165,8 +167,7 @@ public partial class MainViewModel
if (!SectionEnabled)
{
// 還原原始幾何(換回 fi.Mesh 參照,便宜、同步即可)。
// 若背景裁切還在跑,其 await 之後會看到 SectionEnabled=false 而放棄套用
// Restore original meshes. Background clipping checks SectionEnabled before applying.
foreach ((Model3D model, FaceInfo fi) in _faceMap)
if (model is GeometryModel3D gm && !ReferenceEquals(gm.Geometry, fi.Mesh))
gm.Geometry = fi.Mesh;
@@ -176,8 +177,8 @@ public partial class MainViewModel
_viewport.Children.Remove(_sectionPlaneVisual);
_sectionPlaneVisual = null;
}
ApplyRenderMode(); // 剖面關閉 → 瀏覽模式可切回合併網格
StatusText = "剖面已關閉";
ApplyRenderMode();
SetStatus(() => Get("Runtime.SectionClosed"));
return;
}
@@ -195,7 +196,7 @@ public partial class MainViewModel
Vector3D axis;
if (SectionAxisIndex == CustomAxisIndex)
{
if (_customNormal is null) return; // 平面尚未定義(等使用者點 3 點)
if (_customNormal is null) return;
axis = _customNormal.Value;
}
else
@@ -211,8 +212,7 @@ public partial class MainViewModel
Point3D min = bounds.Location;
var size = new Vector3D(bounds.SizeX, bounds.SizeY, bounds.SizeZ);
Point3D center = min + size / 2;
// 平面位置:場景 AABB 8 個角投影到法向的 [dmin, dmax],滑桿 t 內插
//(軸向法向 = 與舊版逐軸計算等價;任意法向也成立)
// Interpolate along the projected bounds of the scene AABB.
double dmin = double.MaxValue, dmax = double.MinValue;
for (int ci = 0; ci < 8; ci++)
{
@@ -228,9 +228,7 @@ public partial class MainViewModel
Point3D planePoint = center + axis * (dStar - Vector3D.DotProduct((Vector3D)center, axis));
Vector3D normal = SectionFlip ? -axis : axis;
// UI 執行緒快照(原始 mesh 皆已 Freeze,可跨執行緒),背景平行裁切,回 UI 一次換上。
// 裁切期間若參數又變 / 零件被平移(TransformRoot 會呼叫 ApplySection)→
// _sectionReapply 讓迴圈用最新的 fi.Mesh 與平面重跑,最終狀態必為最新
// Clip frozen meshes in the background, then apply on the UI thread.
var models = new List<GeometryModel3D>(_faceMap.Count);
var sources = new List<MeshGeometry3D>(_faceMap.Count);
foreach ((Model3D model, FaceInfo fi) in _faceMap)
@@ -248,19 +246,23 @@ public partial class MainViewModel
return result;
});
if (!SectionEnabled) return; // 裁切期間被關掉 → 還原分支已處理,丟棄結果
if (!SectionEnabled) return;
for (int i = 0; i < models.Count; i++)
models[i].Geometry = clipped[i]; // 已移除檔案的殘留 model 賦值無害(不在視覺樹上)
models[i].Geometry = clipped[i];
_sectionPlanePoint = planePoint;
_sectionNormal = normal;
foreach (ModelNodeViewModel root in Roots) RefreshRootEdges(root);
ApplyRenderMode(); // 剖面開啟 → 改用逐面(裁切後幾何)
ApplyRenderMode();
UpdateSectionPlaneVisual(planePoint, axis, size);
string axisName = SectionAxisIndex == CustomAxisIndex ? "自訂平面" : $"{"XYZ"[SectionAxisIndex]} 軸";
StatusText = $"剖面:{axisName} {SectionPosition:F0}%" + (SectionFlip ? "(反向)" : "");
int axisIndex = SectionAxisIndex;
double position = SectionPosition;
bool flip = SectionFlip;
SetStatus(() => Format("Runtime.SectionStatus",
axisIndex == CustomAxisIndex ? Get("Runtime.CustomPlane") : Format("Runtime.Axis", "XYZ"[axisIndex]),
position, flip ? Get("Runtime.Reversed") : ""));
} while (_sectionReapply);
}
finally
@@ -279,7 +281,7 @@ public partial class MainViewModel
0 => (new Vector3D(0, 1, 0), size.Y, size.Z),
1 => (new Vector3D(1, 0, 0), size.X, size.Z),
2 => (new Vector3D(1, 0, 0), size.X, size.Y),
_ => (AnyPerpendicular(axis), size.Length * 0.8, size.Length * 0.8), // 自訂平面
_ => (AnyPerpendicular(axis), size.Length * 0.8, size.Length * 0.8),
};
lenDir.Normalize();
_sectionPlaneVisual = new RectangleVisual3D
@@ -294,7 +296,7 @@ public partial class MainViewModel
_viewport!.Children.Add(_sectionPlaneVisual);
}
/// <summary>取任一與 n 垂直的單位向量(自訂平面的指示矩形方向用)</summary>
/// <summary>Returns a unit vector perpendicular to n for the custom plane visual.</summary>
private static Vector3D AnyPerpendicular(Vector3D n)
{
Vector3D v = Math.Abs(n.Z) < 0.9
+177 -168
View File
@@ -14,6 +14,7 @@ using HelixToolkit.Wpf;
using Microsoft.Win32;
using STPViewer.Models;
using STPViewer.Services;
using static STPViewer.Services.LocalizationService;
namespace STPViewer.ViewModels;
@@ -22,34 +23,33 @@ public partial class MainViewModel : ObservableObject
private readonly StepImportService _importService = new();
private readonly MeasurementService _measureService = new();
/// <summary>渲染物件 → B-rep Face 反查表(HitTest 用;overlay 不在表內所以自然被忽略)</summary>
/// <summary>Maps rendered face models to their B-rep face data for hit testing.</summary>
private readonly Dictionary<Model3D, FaceInfo> _faceMap = new();
/// <summary>合併網格 → leaf 反查表(拖曳模式在瀏覽渲染下 hit-test 用)</summary>
/// <summary>Maps merged meshes to leaves for drag hit testing.</summary>
private readonly Dictionary<Model3D, ModelNodeViewModel> _mergedMap = new();
/// <summary>合併網格 → (每面頂點起始邊界, 對應 FaceInfo)。命中合併網格時用三角形頂點 index 二分搜尋反查回是哪個面,
/// 讓量測模式也能 render 合併網格(62 個 model)而非逐面(數萬個 model)。starts 長度 = faces+1,最後一格為總頂點數。
/// 邊界由匯入時依面序串接而成,與 BuildMergedMesh 串接順序一致;平移不改各面頂點數,故邊界永久有效。</summary>
/// <summary>Vertex boundaries and faces used to resolve hits on merged meshes.
/// The order matches BuildMergedMesh; transforms do not change vertex counts.</summary>
private readonly Dictionary<Model3D, (int[] Starts, FaceInfo[] Faces)> _mergedFaceRanges = new();
private HelixViewport3D? _viewport;
private int _paletteIndex;
private readonly Dictionary<MeasureMode, int> _counters = new();
// 兩段式量測的第一次拾取暫存
private Point3D? _pendingPoint; // 距離
private DirectionPick? _pendingDirection; // 角度
private FaceInfo? _pendingFace; // 面距
private (ModelNodeViewModel Root, Point3D Point)? _pendingAlign; // 兩點對齊
private readonly List<(ModelNodeViewModel Root, Point3D P)> _align3 = new(); // 三點對齊(前3=來源、後3=目標)
// First picks for multi-step measurements.
private Point3D? _pendingPoint;
private DirectionPick? _pendingDirection;
private FaceInfo? _pendingFace;
private (ModelNodeViewModel Root, Point3D Point)? _pendingAlign;
private readonly List<(ModelNodeViewModel Root, Point3D P)> _align3 = new();
private readonly List<Visual3D> _pendingOverlays = new();
/// <summary>樹面板目前選取的節點(MainWindow SelectedItemChanged 轉發;旋轉指令的目標)</summary>
/// <summary>Selected tree node used as the target for commands.</summary>
[ObservableProperty]
private ModelNodeViewModel? selectedNode;
// 互動中暫停邊線:轉動/縮放/平移時隱藏 LinesVisual3D,停下再顯示 → 大組件流暢
// Suspend edge rendering during camera interaction.
private readonly DispatcherTimer _interactionTimer;
private bool _edgesSuspended;
@@ -57,7 +57,24 @@ public partial class MainViewModel : ObservableObject
private MeasureMode currentMode = MeasureMode.None;
[ObservableProperty]
private string statusText = "就緒 — 匯入 STP/STEP/STL/DXF(拖放亦可)。右鍵旋轉、滾輪縮放、中鍵或 Shift+左鍵平移";
private string statusText = Get("Runtime.Ready");
private Func<string> _statusTextFor = () => Get("Runtime.Ready");
private void SetStatus(Func<string> textFor)
{
_statusTextFor = textFor;
StatusText = textFor();
}
public void RefreshLocalizedText()
{
foreach (MeasurementResult result in Measurements) result.RefreshText();
foreach (ModelNodeViewModel root in Roots) root.RefreshLocalizedText();
if (!string.IsNullOrWhiteSpace(TreeFilter))
foreach (ModelNodeViewModel root in Roots) root.ApplyFilter(TreeFilter);
StatusText = _statusTextFor();
}
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ImportCommand))]
@@ -71,7 +88,7 @@ public partial class MainViewModel : ObservableObject
[ObservableProperty]
private bool useInch;
/// <summary>樹面板名稱過濾字串(只影響樹顯示,不影響 3D 可見性)</summary>
/// <summary>Tree name filter; does not affect viewport visibility.</summary>
[ObservableProperty]
private string? treeFilter;
@@ -80,16 +97,16 @@ public partial class MainViewModel : ObservableObject
foreach (ModelNodeViewModel root in Roots) root.ApplyFilter(value);
}
/// <summary>最近開啟檔案(MRU,最多 10 筆;隨 settings.json 保存)</summary>
/// <summary>Recently opened files saved in settings.json.</summary>
public ObservableCollection<string> RecentFiles { get; } = new();
public ObservableCollection<ModelNodeViewModel> Roots { get; } = new();
public ObservableCollection<MeasurementResult> Measurements { get; } = new();
/// <summary>快速工具列自訂(選單列有全部功能,快速列只放勾選的常用按鈕)</summary>
/// <summary>Quick bar button selections.</summary>
public QuickBarViewModel QuickBar { get; } = new();
// ─── 使用者設定(視窗由 MainWindow 處理;VM 管單位 + MRU)─────
// User settings
public void LoadSettings(AppSettings s)
{
@@ -103,6 +120,7 @@ public partial class MainViewModel : ObservableObject
public void SaveSettingsInto(AppSettings s)
{
s.UseInch = UseInch;
s.Language = SelectedLanguage;
s.RecentFiles = RecentFiles.ToList();
s.QuickBarKeys = QuickBar.ToKeys();
}
@@ -121,23 +139,18 @@ public partial class MainViewModel : ObservableObject
_sectionTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(100) };
_sectionTimer.Tick += (_, _) => { _sectionTimer.Stop(); ApplySection(); };
// 相機停止移動 180ms 後恢復邊線
// Restore edges after camera movement stops.
_interactionTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(180) };
_interactionTimer.Tick += (_, _) => { _interactionTimer.Stop(); ResumeEdges(); };
}
private UnitSystem CurrentUnit => UseInch ? UnitSystem.Inch : UnitSystem.Millimeter;
/// <summary>由 MainWindow 注入 viewport(桌面工具,不做過度抽象)</summary>
/// <summary>Attaches the viewport from MainWindow.</summary>
public void Attach(HelixViewport3D viewport)
{
_viewport = viewport;
// 相機一動就暫停邊線/逐面(轉動/縮放/平移皆觸發),停下 180ms 再恢復。
// 兩個事件都訂閱,互補保底:
// 1) viewport.CameraChanged(控制項層級 routed event)— 相機實例若被 Helix 內部換掉也接得住;
// 2) Camera.Changed(Freezable,相機 Position/方向一被改就觸發)— 確保每次移動都收到。
// 舊寫法只訂 Camera.Changed 且包在 null 檢查裡,萬一相機實例被換就永久失聯 → 暫停機制全失效。
// 重複觸發 OnCameraMoved 無害(idempotent,內部有 _edgesSuspended guard)。
// The viewport event also catches camera instance replacements by HelixToolkit.
viewport.CameraChanged += (_, _) => OnCameraMoved();
if (viewport.Camera is not null)
viewport.Camera.Changed += (_, _) => OnCameraMoved();
@@ -145,7 +158,7 @@ public partial class MainViewModel : ObservableObject
private void OnCameraMoved()
{
SyncOverlayCamera(); // 操作器疊圖層跟著主相機
SyncOverlayCamera();
if (!_edgesSuspended) { _edgesSuspended = true; SetEdgesActive(false); }
_interactionTimer.Stop();
_interactionTimer.Start();
@@ -157,7 +170,7 @@ public partial class MainViewModel : ObservableObject
SetEdgesActive(true);
}
/// <summary>只切換 root 合併邊線的 viewport 掛載(不重建 Points)</summary>
/// <summary>Shows or hides merged root edges without rebuilding points.</summary>
private void SetEdgesActive(bool active)
{
if (_viewport is null) return;
@@ -166,47 +179,45 @@ public partial class MainViewModel : ObservableObject
SyncVisual(root.EdgeVisual, active && root.EdgeVisual.Points.Count > 0);
}
/// <summary>IsBusy 時停用會改動幾何/場景的指令(匯入、旋轉、干涉),避免背景運算期間狀態被改走造成結果錯位</summary>
/// <summary>Disables scene-changing commands while background work runs.</summary>
private bool IsIdle() => !IsBusy;
// ─── 匯入 ────────────────────────────────────────────────────
// Import
[RelayCommand(CanExecute = nameof(IsIdle))]
private async Task ImportAsync()
{
var dlg = new OpenFileDialog
{
Title = "匯入 CAD 檔案(可複選)",
Filter = "CAD 檔案 (*.stp;*.step;*.stl;*.dxf)|*.stp;*.step;*.stl;*.dxf|" +
"STEP (*.stp;*.step)|*.stp;*.step|STL (*.stl)|*.stl|DXF (*.dxf)|*.dxf|所有檔案 (*.*)|*.*",
Title = Get("Runtime.ImportTitle"),
Filter = Get("Runtime.ImportFilter"),
Multiselect = true,
};
if (dlg.ShowDialog() == true)
await ImportFilesAsync(dlg.FileNames);
}
/// <summary>MRU 下拉點選(檔案可能已被移走 → ImportFilesAsync 會報匯入失敗)</summary>
/// <summary>Imports a recently opened file.</summary>
[RelayCommand(CanExecute = nameof(IsIdle))]
private Task ImportRecentAsync(string path) => ImportFilesAsync(new[] { path });
public async Task ImportFilesAsync(IEnumerable<string> paths)
{
if (_viewport is null) return;
// 拖放/命令列可繞過指令 CanExecute 直接進來 → 匯入或背景運算期間直接擋掉,
// 避免兩批匯入交錯(StepImportService 有共享狀態,非重入安全)
if (IsBusy) { StatusText = "忙碌中(匯入或運算進行中),請稍候再匯入"; return; }
// Reject overlapping imports because StepImportService has shared state.
if (IsBusy) { SetStatus(() => Get("Runtime.ImportBusy")); return; }
int ok = 0, fail = 0;
IsBusy = true; // 整批匯入期間維持 busy(v0.4.0:不再逐檔開關,杜絕檔案之間的重入空窗)
IsBusy = true;
try
{
foreach (string path in paths)
{
string name = Path.GetFileNameWithoutExtension(path);
StatusText = $"匯入中:{name} …";
// 匯入階段回報(從背景執行緒來 → 切回 UI 執行緒)
_importService.Progress = msg =>
SetStatus(() => Format("Runtime.Importing", name));
// Marshal progress from the import thread to the UI thread.
_importService.ProgressTextFor = textFor =>
System.Windows.Application.Current?.Dispatcher.BeginInvoke(
() => StatusText = $"匯入 {name}:{msg}");
() => SetStatus(() => Format("Runtime.ImportProgress", name, textFor())));
try
{
ImportedFileData data = await Task.Run(() => _importService.Import(path));
@@ -217,8 +228,9 @@ public partial class MainViewModel : ObservableObject
catch (Exception ex)
{
fail++;
StatusText = $"匯入失敗:{name} — {ex.Message}";
MessageBox.Show($"{path}\n\n{ex.Message}", "匯入失敗",
string error = ex.Message;
SetStatus(() => Format("Runtime.ImportFailed", name, error));
MessageBox.Show($"{path}\n\n{error}", Get("Runtime.ImportFailedTitle"),
MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
@@ -231,7 +243,8 @@ public partial class MainViewModel : ObservableObject
{
if (SectionEnabled) ApplySection();
_viewport.ZoomExtents(500);
StatusText = $"匯入完成:成功 {ok} 檔" + (fail > 0 ? $"、失敗 {fail} 檔" : "");
int succeeded = ok, failed = fail;
SetStatus(() => Format("Runtime.ImportCompleted", succeeded, failed > 0 ? Format("Runtime.ImportFailedCount", failed) : ""));
}
}
@@ -244,13 +257,13 @@ public partial class MainViewModel : ObservableObject
ModelNodeViewModel root = BuildNode(data.Root, isRoot: true, data.FilePath, name);
// 邊線總量過大 → 整檔預設關閉(cascade;使用者可再開)
// Disable edges by default for very large files.
int totalSegments = root.Leaves().Sum(l => (l.OriginalEdgePoints?.Count ?? 0) / 2);
if (totalSegments > 30_000)
root.ShowEdges = false;
Roots.Add(root);
if (!string.IsNullOrWhiteSpace(TreeFilter)) root.ApplyFilter(TreeFilter); // 新檔套用目前過濾
if (!string.IsNullOrWhiteSpace(TreeFilter)) root.ApplyFilter(TreeFilter);
foreach (ModelNodeViewModel leaf in root.Leaves())
{
leaf.VisualStateChanged += _ => SyncLeafVisuals(root);
@@ -258,7 +271,7 @@ public partial class MainViewModel : ObservableObject
_viewport!.Children.Add(leaf.BodyVisual);
}
// 整檔合併成「一條」LinesVisual3D(轉動時只重建 1 條,避免逐零件 N 條造成卡頓)
// One merged edge visual per file avoids per-leaf rebuilds.
if (root.Leaves().Any(l => l.OriginalEdgePoints is { Count: > 0 }))
{
root.EdgeVisual = new LinesVisual3D
@@ -275,6 +288,8 @@ public partial class MainViewModel : ObservableObject
var vm = new ModelNodeViewModel(n.Faces.Count > 0 || n.EdgeSegments is not null ? _paletteIndex++ : _paletteIndex)
{
Name = overrideName ?? n.Name,
NameKey = overrideName is null ? n.NameKey : null,
NameNumber = n.NameNumber,
FilePath = isRoot ? filePath : null,
SolidCount = n.SolidCount,
FaceCount = n.FaceCount,
@@ -285,7 +300,7 @@ public partial class MainViewModel : ObservableObject
if (n.Faces.Count > 0)
{
// leaf 共用材質(換色時只改 Brush)
// Share material across leaf faces for color changes.
var diffuseBrush = new SolidColorBrush(vm.Color);
diffuseBrush.Freeze();
var diffuse = new DiffuseMaterial(diffuseBrush);
@@ -296,8 +311,7 @@ public partial class MainViewModel : ObservableObject
backMat.Freeze();
vm.SharedMaterial = diffuse;
// 一個 B-rep Face = 一個 GeometryModel3D,登錄反查表(剖面模式逐面拾取用)。
// 同時依面序記錄每面頂點起始邊界 + 對應 FaceInfo,供「命中合併網格反查面」(量測模式拾取用)。
// Keep one model per face for section picking and record merged-mesh face boundaries.
var group = new Model3DGroup();
var faceInfos = new FaceInfo[n.Faces.Count];
var vertStarts = new int[n.Faces.Count + 1];
@@ -316,11 +330,9 @@ public partial class MainViewModel : ObservableObject
vertStarts[n.Faces.Count] = vacc;
vm.FacesContent = group;
// 合併網格:整零件所有面併成 1 個 GeometryModel3D(draw call 從每面 1 個降為每檔 1 個)。
// BuildMergedMesh 依 n.Faces 同序串接(baseIdx += positions.Count),與上面 vertStarts 對齊 → 命中頂點 index 可反查回面。
// BuildMergedMesh concatenates faces in the same order as vertStarts.
var mergedModel = new GeometryModel3D(BuildMergedMesh(n.Faces), matGroup);
// 封閉實體背面不可見 → 不設 BackMaterial(WPF 兩面渲染成本砍半);開放殼/STL 仍需背面。
// 拾取不受材質影響(WPF 3D hit-test 純幾何、不剔背面),故孔內壁仍可命中。
// Closed solids render front faces only; open shells and STL render both sides.
if (n.SolidCount == 0 || !n.HasBrep)
mergedModel.BackMaterial = backMat;
vm.MergedContent = new Model3DGroup();
@@ -328,14 +340,14 @@ public partial class MainViewModel : ObservableObject
_mergedMap[mergedModel] = vm;
_mergedFaceRanges[mergedModel] = (vertStarts, faceInfos);
// 非剖面一律顯示合併網格(瀏覽/量測都是,量測靠 _mergedFaceRanges 反查面);只有剖面才逐面(裁切後幾何)
// Use merged meshes except in section mode, which needs separate clipped faces.
vm.BodyVisual = new ModelVisual3D
{
Content = SectionEnabled ? group : vm.MergedContent,
};
}
// 邊線端點僅保存於 leaf(資料);實際 LinesVisual3D 由 root 合併建立(見 BuildRoot)
// Leaves hold edge data; roots hold the merged edge visual.
if (n.EdgeSegments is { Count: > 0 })
vm.OriginalEdgePoints = n.EdgeSegments;
@@ -354,8 +366,7 @@ public partial class MainViewModel : ObservableObject
}
/// <summary>
/// 重建整檔的合併邊線:收集所有「可見且開邊線」的 leaf 端點為單一 Point3DCollection。
/// 剖面開啟時即時裁切。一檔只有一條 LinesVisual3D → 轉動視角不再逐零件重建。
/// Rebuilds one edge visual from visible leaves, clipping segments in section mode.
/// </summary>
private void RefreshRootEdges(ModelNodeViewModel root)
{
@@ -382,9 +393,9 @@ public partial class MainViewModel : ObservableObject
else if (!shouldShow && contains) _viewport.Children.Remove(visual);
}
// ─── 合併網格(瀏覽模式降 draw call)────────────────────────
// Merged meshes
/// <summary>把多個面網格依序併成單一 MeshGeometry3D(索引位移後串接;順序 = 呼叫端給的面序)</summary>
/// <summary>Concatenates face meshes in the given order.</summary>
private static MeshGeometry3D MergeMeshes(IReadOnlyList<MeshGeometry3D> meshes)
{
int nv = 0, nt = 0;
@@ -411,7 +422,7 @@ public partial class MainViewModel : ObservableObject
return mesh;
}
/// <summary>匯入時建合併網格(依 faces 面序串接,與 _mergedFaceRanges 的頂點邊界對齊)</summary>
/// <summary>Builds the import mesh in face order for merged hit resolution.</summary>
private static MeshGeometry3D BuildMergedMesh(IReadOnlyList<ImportedFace> faces)
{
var meshes = new List<MeshGeometry3D>(faces.Count);
@@ -419,8 +430,7 @@ public partial class MainViewModel : ObservableObject
return MergeMeshes(meshes);
}
/// <summary>重建合併網格(位置變更後,如零件平移)。由 leaf 逐面 fi.Mesh(未剖切原始)依 FacesContent 子序串接,
/// 與匯入時 BuildMergedMesh 的面序一致 → _mergedFaceRanges 頂點邊界維持有效。</summary>
/// <summary>Rebuilds a merged mesh after a transform without changing face vertex boundaries.</summary>
private void RebuildMerged(ModelNodeViewModel leaf)
{
if (leaf.MergedContent is null || leaf.FacesContent is null) return;
@@ -432,13 +442,11 @@ public partial class MainViewModel : ObservableObject
}
/// <summary>
/// 依目前狀態切換每個 leaf 的渲染內容:
/// 非剖面(瀏覽 + 量測皆是)→ 合併網格(每檔 1 個 model,快);量測拾取靠命中合併網格反查面(_mergedFaceRanges)。
/// 剖面 → 逐面(要顯示各面裁切後的幾何)。
/// Uses merged meshes for browsing and measurement; section mode uses separate clipped faces.
/// </summary>
private void ApplyRenderMode()
{
// 量測不再需要逐面渲染(拾取打合併網格反查面)→ 只有剖面才逐面。永不在量測模式掛數萬個 GeometryModel3D。
// Measurement resolves merged-mesh hits back to their source faces.
bool merged = !SectionEnabled;
foreach (ModelNodeViewModel leaf in Roots.SelectMany(r => r.Leaves()))
{
@@ -449,7 +457,7 @@ public partial class MainViewModel : ObservableObject
}
}
// ─── 節點操作 ────────────────────────────────────────────────
// Node commands
[RelayCommand]
private void ZoomNode(ModelNodeViewModel node)
@@ -461,14 +469,14 @@ public partial class MainViewModel : ObservableObject
[RelayCommand]
private void ZoomAll() => _viewport?.ZoomExtents(400);
/// <summary>標準視圖(機構慣例 Z 向上):Iso / Front / Back / Top / Bottom / Left / Right</summary>
/// <summary>Standard views with Z up.</summary>
[RelayCommand]
private void SetStandardView(string name)
{
if (_viewport?.Camera is not ProjectionCamera cam) return;
(Vector3D dir, Vector3D up) = name switch
{
"Front" => (new Vector3D(0, 1, 0), new Vector3D(0, 0, 1)), // 從 -Y 往 +Y 看
"Front" => (new Vector3D(0, 1, 0), new Vector3D(0, 0, 1)),
"Back" => (new Vector3D(0, -1, 0), new Vector3D(0, 0, 1)),
"Top" => (new Vector3D(0, 0, -1), new Vector3D(0, 1, 0)),
"Bottom" => (new Vector3D(0, 0, 1), new Vector3D(0, -1, 0)),
@@ -482,13 +490,13 @@ public partial class MainViewModel : ObservableObject
: new Point3D(b.X + b.SizeX / 2, b.Y + b.SizeY / 2, b.Z + b.SizeZ / 2);
double dist = Math.Max(SceneDiagonal() * 1.8, 10);
cam.Position = center - dir * dist;
cam.LookDirection = dir * dist; // 目標點 = Position + LookDirection = 場景中心
cam.LookDirection = dir * dist;
cam.UpDirection = up;
_viewport.ZoomExtents(400);
StatusText = $"視圖:{name}";
SetStatus(() => Format("Runtime.View", Get($"Runtime.View{name}")));
}
/// <summary>正交投影切換(平行投影,量尺寸不受透視變形;Helix 保持視角換相機型別)</summary>
/// <summary>Toggles orthographic projection while preserving the view angle.</summary>
[ObservableProperty]
private bool orthographicView;
@@ -496,7 +504,7 @@ public partial class MainViewModel : ObservableObject
{
if (_viewport is null) return;
_viewport.Orthographic = value;
StatusText = value ? "正交投影(平行投影,適合尺寸確認)" : "透視投影";
SetStatus(() => Get(value ? "Runtime.Orthographic" : "Runtime.Perspective"));
}
[RelayCommand]
@@ -504,16 +512,16 @@ public partial class MainViewModel : ObservableObject
{
if (_viewport is null || !root.IsRoot) return;
if (ReferenceEquals(root, _gizmoTarget)) GizmoEnabled = false; // 目標被移除 → 收掉操作器
if (ReferenceEquals(root, _gizmoTarget)) GizmoEnabled = false;
// 量測 overlay 可能標在此檔模型上 → 一併清除(簡化:全清)
// Clear measurements that may be attached to the removed file.
if (Measurements.Count > 0) ClearMeasurements();
foreach (ModelNodeViewModel leaf in root.Leaves())
{
if (leaf.BodyVisual is not null)
_viewport.Children.Remove(leaf.BodyVisual);
if (leaf.FacesContent is not null) // 逐面群組才在 _faceMap(與目前顯示哪種內容無關)
if (leaf.FacesContent is not null)
foreach (Model3D m in leaf.FacesContent.Children)
_faceMap.Remove(m);
if (leaf.MergedContent is not null)
@@ -526,18 +534,19 @@ public partial class MainViewModel : ObservableObject
if (root.EdgeVisual is not null)
_viewport.Children.Remove(root.EdgeVisual);
Roots.Remove(root);
StatusText = $"已移除:{root.Name}";
string removedName = root.Name;
SetStatus(() => Format("Runtime.Removed", removedName));
}
// ─── 顯示控制 / 體積(樹右鍵選單)────────────────────────────
// Visibility and volume
/// <summary>只顯示此節點(其餘全部隱藏;「全部顯示」復原)</summary>
/// <summary>Shows only this node until ShowAll is called.</summary>
[RelayCommand]
private void IsolateNode(ModelNodeViewModel node)
{
foreach (ModelNodeViewModel r in Roots) SetVisibleRecursive(r, false);
SetVisibleRecursive(node, true);
StatusText = $"只顯示「{node.Name}」(樹右鍵 → 全部顯示 復原)";
SetStatus(() => Format("Runtime.Isolated", node.Name));
}
[RelayCommand]
@@ -545,25 +554,24 @@ public partial class MainViewModel : ObservableObject
{
foreach (ModelNodeViewModel l in Roots.SelectMany(r => r.Leaves()))
l.IsVisible = !l.IsVisible;
StatusText = "已反轉顯示";
SetStatus(() => Get("Runtime.VisibilityInverted"));
}
[RelayCommand]
private void ShowAll()
{
foreach (ModelNodeViewModel r in Roots) SetVisibleRecursive(r, true);
StatusText = "已全部顯示";
SetStatus(() => Get("Runtime.AllVisible"));
}
/// <summary>逐節點顯式遞迴設定可見性。不能只設 root(setter 同值不觸發 cascade,
/// 之前的隔離操作可能留下「父關子開」的混合狀態)</summary>
/// <summary>Sets visibility for every node, including mixed child states.</summary>
private static void SetVisibleRecursive(ModelNodeViewModel node, bool visible)
{
node.IsVisible = visible;
foreach (ModelNodeViewModel c in node.Children) SetVisibleRecursive(c, visible);
}
/// <summary>體積/質心(網格 signed volume,封閉實體才可靠)→ 加進量測結果 + 質心標記</summary>
/// <summary>Adds a volume and centroid measurement for a closed mesh.</summary>
[RelayCommand]
private void MeasureVolume(ModelNodeViewModel node)
{
@@ -576,14 +584,14 @@ public partial class MainViewModel : ObservableObject
.ToList();
if (meshes.Count == 0)
{
StatusText = $"「{node.Name}」沒有可計算的面網格(線架構無體積)";
SetStatus(() => Format("Runtime.VolumeNoMesh", node.Name));
return;
}
(double vol, Point3D centroid, bool reliable) = MeasurementService.MeshVolume(meshes);
if (!reliable)
{
StatusText = $"「{node.Name}」非封閉網格(開放殼),無法可靠計算體積";
SetStatus(() => Format("Runtime.VolumeOpenMesh", node.Name));
return;
}
@@ -596,9 +604,7 @@ public partial class MainViewModel : ObservableObject
{
Kind = MeasureMode.Volume,
TitleFor = u => $"{label} {name} V ≈ {Units.V(vol, u)}",
DetailFor = u => $"體積 ≈ {Units.V(vol, u)}(網格近似)\n質心 {Units.P(centroid, u)}\n" +
$"外形(AABB){Units.C(b.SizeX, u)} × {Units.C(b.SizeY, u)} × {Units.C(b.SizeZ, u)} " +
(u == UnitSystem.Millimeter ? "mm" : "in"),
DetailFor = u => Format("Runtime.VolumeDetail", Units.V(vol, u), Units.P(centroid, u), Units.C(b.SizeX, u), Units.C(b.SizeY, u), Units.C(b.SizeZ, u), u == UnitSystem.Millimeter ? "mm" : "in"),
};
m.Overlays.Add(new SphereVisual3D { Center = centroid, Radius = markerR, Fill = Brushes.OrangeRed });
var volLabel = new BillboardTextVisual3D
@@ -615,46 +621,47 @@ public partial class MainViewModel : ObservableObject
AddMeasurement(m);
}
// ─── 量測 ────────────────────────────────────────────────────
// Measurement
partial void OnCurrentModeChanged(MeasureMode value)
{
CancelPending();
ApplyRenderMode(); // 非剖面一律合併網格(量測拾取靠反查面);不再因切量測模式而掛逐面
StatusText = value switch
ApplyRenderMode();
string? key = value switch
{
MeasureMode.None => "瀏覽模式(右鍵旋轉、滾輪縮放、中鍵或 Shift+左鍵拖曳平移)",
MeasureMode.Point => "點量測:點擊模型表面(自動吸附鄰近頂點)",
MeasureMode.Distance => "距離量測:點選第 1 點",
MeasureMode.Edge => "邊量測:點擊靠近要量的邊",
MeasureMode.Face => "面量測:點擊要量的面",
MeasureMode.Circle => "圓量測:點擊圓孔內壁或圓邊附近",
MeasureMode.Angle => "角度量測:點選第 1 個面(或靠近直線邊)",
MeasureMode.FaceDistance => "面距量測:點選第 1 個面",
MeasureMode.Align => "兩點對齊:先點「要移動零件」上的點,再點目標點(純平移,量測會清空)",
MeasureMode.Align3 => "三點對齊:先在「要移動的檔案」點 3 個特徵點,再到目標檔案點 3 個對應點(旋轉+平移)",
MeasureMode.Drag => "拖曳模式:左鍵按住零件拖動(沿螢幕平面),放開定位;深度方向先轉視角再拖。右鍵仍可轉視角",
_ => StatusText,
MeasureMode.None => "Runtime.ModeBrowse",
MeasureMode.Point => "Runtime.ModePoint",
MeasureMode.Distance => "Runtime.ModeDistance",
MeasureMode.Edge => "Runtime.ModeEdge",
MeasureMode.Face => "Runtime.ModeFace",
MeasureMode.Circle => "Runtime.ModeCircle",
MeasureMode.Angle => "Runtime.ModeAngle",
MeasureMode.FaceDistance => "Runtime.ModeFaceDistance",
MeasureMode.Align => "Runtime.ModeAlign",
MeasureMode.Align3 => "Runtime.ModeAlign3",
MeasureMode.Drag => "Runtime.ModeDrag",
_ => null,
};
if (key is not null) SetStatus(() => Get(key));
}
partial void OnUseInchChanged(bool value)
{
UnitSystem u = CurrentUnit;
foreach (MeasurementResult m in Measurements) m.SetUnit(u);
StatusText = value ? "顯示單位:inch" : "顯示單位:mm";
SetStatus(() => Get(value ? "Runtime.UnitInch" : "Runtime.UnitMillimeter"));
}
/// <summary>Esc(MainWindow 轉發):先取消進行中的多段量測(保留模式),再按退回瀏覽模式;再退操作器</summary>
/// <summary>Cancels a pending pick, then exits measurement mode or closes the gizmo.</summary>
public void OnEscape()
{
if (CancelSectionPickIfActive()) return; // 3點剖面定義中 → 先取消定義
if (CancelSectionPickIfActive()) return;
bool hadPending = _pendingPoint is not null || _pendingDirection is not null ||
_pendingFace is not null || _pendingAlign is not null || _align3.Count > 0;
if (hadPending)
{
CancelPending();
StatusText = "已取消目前量測起點(再按 Esc 退出量測模式)";
SetStatus(() => Get("Runtime.MeasurementStartCancelled"));
}
else if (CurrentMode != MeasureMode.None)
{
@@ -666,7 +673,7 @@ public partial class MainViewModel : ObservableObject
}
}
/// <summary>量測模式快速鍵(MainWindow 轉發;無修飾鍵)。同鍵再按 = 退回瀏覽。回傳 false 表示非快速鍵。</summary>
/// <summary>Handles measurement hotkeys; repeating a key returns to browse mode.</summary>
public bool OnHotKey(System.Windows.Input.Key key)
{
MeasureMode? m = key switch
@@ -685,11 +692,11 @@ public partial class MainViewModel : ObservableObject
return true;
}
/// <summary>MainWindow 滑鼠左鍵點擊轉發進來</summary>
/// <summary>Handles left-clicks forwarded by MainWindow.</summary>
public void OnViewportClick(Point position)
{
if (_viewport is null || IsBusy) return;
if (HandleSectionPlanePick(position)) return; // 3點剖面定義模式優先吃點擊(v0.5.0)
if (HandleSectionPlanePick(position)) return;
if (CurrentMode is MeasureMode.None or MeasureMode.Drag) return;
var hits = _viewport.Viewport.FindHits(position);
@@ -699,13 +706,13 @@ public partial class MainViewModel : ObservableObject
foreach (var h in hits)
{
if (h.Model is null) continue;
// 剖面模式:渲染逐面 → 直接查 _faceMap
// Section mode renders individual faces.
if (_faceMap.TryGetValue(h.Model, out FaceInfo? f))
{
fi = f; hitPoint = h.Position; hitNormal = h.Normal;
break;
}
// 量測/瀏覽模式:渲染合併網格 → 用命中三角形頂點 index 反查回是哪個面
// Other modes resolve merged-mesh vertex indices to faces.
if (h.RayHit is not null && _mergedFaceRanges.TryGetValue(h.Model, out var range))
{
FaceInfo? mf = ResolveMergedFace(range, h.RayHit.VertexIndex1);
@@ -718,7 +725,7 @@ public partial class MainViewModel : ObservableObject
}
if (fi is null)
{
StatusText = "未命中模型表面";
SetStatus(() => Get("Runtime.NoSurfaceHit"));
return;
}
@@ -728,17 +735,18 @@ public partial class MainViewModel : ObservableObject
}
catch (Exception ex)
{
StatusText = $"量測失敗:{ex.Message}";
string error = ex.Message;
SetStatus(() => Format("Runtime.MeasureFailed", error));
}
}
/// <summary>命中合併網格的某頂點 index → 反查回是哪個面(依面序的頂點起始邊界二分搜尋)。</summary>
/// <summary>Finds the source face for a merged-mesh vertex by binary search.</summary>
private static FaceInfo? ResolveMergedFace((int[] Starts, FaceInfo[] Faces) range, int vertexIndex)
{
int[] starts = range.Starts;
FaceInfo[] faces = range.Faces;
if (vertexIndex < 0 || faces.Length == 0 || vertexIndex >= starts[faces.Length]) return null;
// 找最大的 k 使 starts[k] <= vertexIndex(face k 佔頂點 [starts[k], starts[k+1]))
// Find the largest face start not exceeding the hit vertex index.
int lo = 0, hi = faces.Length - 1, ans = 0;
while (lo <= hi)
{
@@ -755,10 +763,10 @@ public partial class MainViewModel : ObservableObject
double snapTol = diag * 0.02;
double markerR = Math.Clamp(diag * 0.004, 0.01, 5.0);
// 無 B-rep 來源(STL)只支援部分模式
// STL has no B-rep edges or faces for these modes.
if (fi.BrepFace is null && CurrentMode is MeasureMode.Edge or MeasureMode.Circle or MeasureMode.Face)
{
StatusText = "此模型無 B-rep(STL/網格),僅支援 點 / 距離 / 角度 / 面距 量測";
SetStatus(() => Get("Runtime.MeshMeasurementLimited"));
return;
}
@@ -777,7 +785,7 @@ public partial class MainViewModel : ObservableObject
{
_pendingPoint = p;
AddPendingMarker(p, markerR);
StatusText = $"第 1 點 ({p.X:F2}, {p.Y:F2}, {p.Z:F2}) — 點選第 2 點";
SetStatus(() => Format("Runtime.DistanceFirstPoint", p.X, p.Y, p.Z));
}
else
{
@@ -791,7 +799,7 @@ public partial class MainViewModel : ObservableObject
case MeasureMode.Edge:
{
var result = _measureService.MeasureEdge(fi, hit, NextLabel(MeasureMode.Edge, "E"), markerR, circlesOnly: false);
if (result is null) { StatusText = "此面沒有可量測的邊"; UndoLabel(MeasureMode.Edge); }
if (result is null) { SetStatus(() => Get("Runtime.NoMeasurableEdge")); UndoLabel(MeasureMode.Edge); }
else AddMeasurement(result);
break;
}
@@ -803,7 +811,7 @@ public partial class MainViewModel : ObservableObject
case MeasureMode.Circle:
{
var result = _measureService.MeasureEdge(fi, hit, NextLabel(MeasureMode.Circle, "C"), markerR, circlesOnly: true);
if (result is null) { StatusText = "此面沒有圓形邊(請點圓孔內壁或圓邊附近)"; UndoLabel(MeasureMode.Circle); }
if (result is null) { SetStatus(() => Get("Runtime.NoCircularEdge")); UndoLabel(MeasureMode.Circle); }
else AddMeasurement(result);
break;
}
@@ -814,7 +822,7 @@ public partial class MainViewModel : ObservableObject
{
_pendingDirection = pick;
AddPendingMarker(pick.At, markerR);
StatusText = $"已選第 1 個方向({pick.Desc})— 點選第 2 個面或邊";
SetStatus(() => Format("Runtime.AngleFirstDirection", Get(pick.Desc)));
}
else
{
@@ -831,11 +839,11 @@ public partial class MainViewModel : ObservableObject
{
_pendingFace = fi;
AddPendingMarker(hit, markerR);
StatusText = "已選第 1 個面 — 點選另一個面";
SetStatus(() => Get("Runtime.FaceDistanceFirstFace"));
}
else if (ReferenceEquals(_pendingFace, fi))
{
StatusText = "兩次點到同一個面,請點選另一個面";
SetStatus(() => Get("Runtime.SameFace"));
}
else
{
@@ -850,16 +858,16 @@ public partial class MainViewModel : ObservableObject
{
Point3D p = _measureService.Snap(fi, hit, snapTol);
ModelNodeViewModel? root = FindRootOf(fi.Owner);
if (root is null) { StatusText = "找不到此零件所屬檔案"; break; }
if (root is null) { SetStatus(() => Get("Runtime.RootNotFound")); break; }
if (_pendingAlign is null)
{
_pendingAlign = (root, p);
AddPendingMarker(p, markerR);
StatusText = $"將移動「{root.Name}」— 點選目標點(另一個檔案上)";
SetStatus(() => Format("Runtime.AlignMoving", root.Name));
}
else if (ReferenceEquals(_pendingAlign.Value.Root, root))
{
StatusText = "目標點要點在另一個檔案上(兩點同屬一個檔案無法對齊)";
SetStatus(() => Get("Runtime.AlignOtherFile"));
}
else
{
@@ -867,8 +875,7 @@ public partial class MainViewModel : ObservableObject
Vector3D offset = p - from;
CancelPending();
TranslateRoot(moveRoot, offset);
StatusText = $"已移動「{moveRoot.Name}」 Δ({offset.X:F3}, {offset.Y:F3}, {offset.Z:F3}) mm" +
"(量測已清空;可用 ⇔面距 / 🧩干涉 驗證配合)";
SetStatus(() => Format("Runtime.AlignMoved", moveRoot.Name, offset.X, offset.Y, offset.Z));
}
break;
}
@@ -876,43 +883,46 @@ public partial class MainViewModel : ObservableObject
{
Point3D p = _measureService.Snap(fi, hit, snapTol);
ModelNodeViewModel? root = FindRootOf(fi.Owner);
if (root is null) { StatusText = "找不到此零件所屬檔案"; break; }
if (root is null) { SetStatus(() => Get("Runtime.RootNotFound")); break; }
if (_align3.Count < 3) // 來源 3 點(要移動的檔案)
if (_align3.Count < 3)
{
if (_align3.Count > 0 && !ReferenceEquals(_align3[0].Root, root))
{
StatusText = $"來源 3 點要同一個檔案(要移動:{_align3[0].Root.Name})";
ModelNodeViewModel sourceRoot = _align3[0].Root;
SetStatus(() => Format("Runtime.Align3SameSource", sourceRoot.Name));
break;
}
if (_align3.Count == 2 && RigidAlign.Collinear(_align3[0].P, _align3[1].P, p))
{
StatusText = "三點共線,請改選不共線的第 3 點";
SetStatus(() => Get("Runtime.Align3SourceCollinear"));
break;
}
_align3.Add((root, p));
AddPendingMarker(p, markerR);
StatusText = _align3.Count < 3
? $"來源點 {_align3.Count}/3 — 繼續點「{root.Name}」上的點"
: "來源 3 點完成 — 換點「目標檔案」上的對應點 1/3";
int sourceCount = _align3.Count;
SetStatus(() => sourceCount < 3
? Format("Runtime.Align3SourcePoint", sourceCount, root.Name)
: Get("Runtime.Align3SourceComplete"));
}
else // 目標 3 點(另一個檔案,順序對應來源點)
else
{
if (ReferenceEquals(_align3[0].Root, root))
{
StatusText = "目標點要點在另一個檔案上";
SetStatus(() => Get("Runtime.Align3OtherFile"));
break;
}
if (_align3.Count == 5 && RigidAlign.Collinear(_align3[3].P, _align3[4].P, p))
{
StatusText = "目標三點共線,請改選不共線的第 3 點";
SetStatus(() => Get("Runtime.Align3TargetCollinear"));
break;
}
_align3.Add((root, p));
AddPendingMarker(p, markerR);
if (_align3.Count < 6)
{
StatusText = $"目標點 {_align3.Count - 3}/3 — 繼續點目標檔案上的對應點";
int targetCount = _align3.Count - 3;
SetStatus(() => Format("Runtime.Align3TargetPoint", targetCount));
break;
}
@@ -922,11 +932,11 @@ public partial class MainViewModel : ObservableObject
CancelPending();
if (!RigidAlign.TryRigidTransform(p1, p2, p3, q1, q2, q3, out Matrix3D m))
{
StatusText = "三點對齊失敗(點位退化),請重新選點";
SetStatus(() => Get("Runtime.Align3Failed"));
break;
}
TransformRoot(moveRoot, RigidAlign.ToModOp(m), m);
StatusText = $"已三點對齊「{moveRoot.Name}」(旋轉+平移;點1精確貼合,量測已清空,可用 🧩干涉 驗證)";
SetStatus(() => Format("Runtime.Align3Completed", moveRoot.Name));
}
break;
}
@@ -961,7 +971,8 @@ public partial class MainViewModel : ObservableObject
Measurements.Add(result);
foreach (Visual3D v in result.Overlays)
_viewport!.Children.Add(v);
StatusText = $"✓ {result.Title}";
Func<UnitSystem, string> titleFor = result.TitleFor;
SetStatus(() => $"✓ {titleFor(CurrentUnit)}");
}
[RelayCommand]
@@ -981,7 +992,7 @@ public partial class MainViewModel : ObservableObject
Measurements.Clear();
_counters.Clear();
CancelPending();
StatusText = "已清除全部量測";
SetStatus(() => Get("Runtime.MeasurementsCleared"));
}
private void CancelPending()
@@ -996,7 +1007,7 @@ public partial class MainViewModel : ObservableObject
_align3.Clear();
}
// ─── 零件剛體變換(兩點對齊=平移、旋轉 90°、三點對齊=旋轉+平移)──
// Rigid part transforms
private void TranslateRoot(ModelNodeViewModel root, Vector3D v)
{
@@ -1007,27 +1018,25 @@ public partial class MainViewModel : ObservableObject
}
/// <summary>
/// 對整個檔案(root)套剛體變換:B-rep 用 ModOp 整體 Modify(量測維持精確)、
/// 渲染網格 / 合併網格 / 邊線端點同步重算、邊界重新計算。
/// op 與 m 必須是同一個變換(CADability 與 WPF 兩種表示)。
/// Transforms B-rep objects, meshes, edges and bounds for one file.
/// op and m must represent the same transform in CADability and WPF conventions.
/// </summary>
private void TransformRoot(ModelNodeViewModel root, CADability.ModOp op, Matrix3D m)
{
if (_viewport is null) return;
ClearMeasurements(); // 量測標記位置已失效
ClearMeasurements();
var leaves = root.Leaves().ToList();
// B-rep:對 Solid/Shell 整體 Modify(避免共用邊被逐面重複變換)。
// CADability 物件非執行緒安全 → 維持循序
// Transform whole B-rep objects sequentially to avoid shared-edge changes.
foreach (ModelNodeViewModel leaf in leaves)
foreach (CADability.GeoObject.IGeoObject g in leaf.SourceGeos)
{
try { g.Modify(op); }
catch { /* 個別物件變換失敗不中斷 */ }
catch { }
}
// 渲染網格:面級平行變換(來源/輸出皆 frozen、各面獨立 → 安全;大檔 64k 面吃滿多核)
// Transform independent frozen face meshes in parallel.
var faceItems = new List<(GeometryModel3D Gm, FaceInfo Fi)>();
foreach (ModelNodeViewModel leaf in leaves)
if (leaf.FacesContent is not null)
@@ -1037,7 +1046,7 @@ public partial class MainViewModel : ObservableObject
var moved = new MeshGeometry3D[faceItems.Count];
Parallel.For(0, faceItems.Count, i => moved[i] = TransformMesh(faceItems[i].Fi.Mesh, m));
for (int i = 0; i < faceItems.Count; i++) // 視覺樹賦值回 UI 執行緒循序做
for (int i = 0; i < faceItems.Count; i++)
{
faceItems[i].Fi.Mesh = moved[i];
faceItems[i].Gm.Geometry = moved[i];
@@ -1046,15 +1055,15 @@ public partial class MainViewModel : ObservableObject
foreach (ModelNodeViewModel leaf in leaves)
{
if (leaf.FacesContent is not null)
RebuildMerged(leaf); // 合併網格同步
RebuildMerged(leaf);
// 邊線端點(資料);實際合併線於迴圈後重建
// Update edge data before rebuilding the merged visual.
if (leaf.OriginalEdgePoints is not null)
leaf.OriginalEdgePoints = TransformPoints(leaf.OriginalEdgePoints, m);
}
RecomputeBounds(root);
if (SectionEnabled) ApplySection(); // 內含 RefreshRootEdges
if (SectionEnabled) ApplySection();
else RefreshRootEdges(root);
}
@@ -1065,7 +1074,7 @@ public partial class MainViewModel : ObservableObject
var mesh = new MeshGeometry3D
{
Positions = positions,
TriangleIndices = src.TriangleIndices, // frozen,可共用(法向量缺省由 WPF 自算)
TriangleIndices = src.TriangleIndices,
};
mesh.Freeze();
return mesh;
@@ -1079,7 +1088,7 @@ public partial class MainViewModel : ObservableObject
return pts;
}
/// <summary>旋轉後 AABB 不能只平移 → 由網格/邊線重新計算(bottom-up)</summary>
/// <summary>Recomputes bounds from transformed meshes and edges.</summary>
private Rect3D RecomputeBounds(ModelNodeViewModel node)
{
Rect3D b = Rect3D.Empty;
@@ -1101,7 +1110,7 @@ public partial class MainViewModel : ObservableObject
return b;
}
// ─── 旋轉 90°(樹面板選取的檔案,繞其中心)──────────────────
// Rotate selected file
[RelayCommand(CanExecute = nameof(IsIdle))]
private void RotateRoot(string axisName)
@@ -1110,7 +1119,7 @@ public partial class MainViewModel : ObservableObject
: Roots.Count == 1 ? Roots[0] : null;
if (root is null)
{
StatusText = "請先在樹面板點選要旋轉的檔案(任一節點即可)";
SetStatus(() => Get("Runtime.RotateSelectFile"));
return;
}
Rect3D b = root.Bounds;
@@ -1126,7 +1135,7 @@ public partial class MainViewModel : ObservableObject
var m = Matrix3D.Identity;
m.RotateAt(new Quaternion(axis, 90), center);
TransformRoot(root, RigidAlign.ToModOp(m), m);
StatusText = $"已旋轉「{root.Name}」繞 {axisName} 軸 +90°(再按可繼續轉)";
SetStatus(() => Format("Runtime.Rotated", root.Name, axisName));
}
private ModelNodeViewModel? RootContaining(ModelNodeViewModel node) =>
@@ -1135,9 +1144,9 @@ public partial class MainViewModel : ObservableObject
private static bool ContainsNode(ModelNodeViewModel parent, ModelNodeViewModel target) =>
parent.Children.Any(c => ReferenceEquals(c, target) || ContainsNode(c, target));
// 三點對齊的剛體變換數學在 Services/RigidAlign.cs(SmokeTest --align-test 驗證)
// Three-point alignment math is in RigidAlign.
// ─── 共用 ────────────────────────────────────────────────────
// Shared helpers
private Rect3D UnionBounds()
{
+50 -33
View File
@@ -7,34 +7,35 @@ using System.Windows.Media.Media3D;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using HelixToolkit.Wpf;
using STPViewer.Services;
namespace STPViewer.ViewModels;
/// <summary>
/// 裝配樹節點:root = 匯入檔、group = STEP 裝配(Block)、leaf = 零件幾何。
/// 可見性 / 邊線 / 顏色設定向下層疊(cascade)。
/// Assembly tree node: imported file, STEP group, or geometry leaf.
/// Visibility, edges, and color cascade to children.
/// </summary>
public partial class ModelNodeViewModel : ObservableObject
{
/// <summary>調色盤(每個 leaf 依序取色,點色塊循環切換)</summary>
/// <summary>Palette used when assigning and cycling leaf colors.</summary>
public static readonly Color[] Palette =
{
Color.FromRgb(0x90, 0xA4, 0xAE), // 藍灰(金屬感)
Color.FromRgb(0x4F, 0xC3, 0xF7), // 淺藍
Color.FromRgb(0xFF, 0xB7, 0x4D), // 橙
Color.FromRgb(0x81, 0xC7, 0x84), // 綠
Color.FromRgb(0xBA, 0x68, 0xC8), // 紫
Color.FromRgb(0xE5, 0x73, 0x73), // 紅
Color.FromRgb(0xFF, 0xD5, 0x4F), // 黃
Color.FromRgb(0x4D, 0xB6, 0xAC), // 藍綠
Color.FromRgb(0xA1, 0x88, 0x7F), // 棕
Color.FromRgb(0x79, 0x86, 0xCB), // 靛藍
Color.FromRgb(0x90, 0xA4, 0xAE),
Color.FromRgb(0x4F, 0xC3, 0xF7),
Color.FromRgb(0xFF, 0xB7, 0x4D),
Color.FromRgb(0x81, 0xC7, 0x84),
Color.FromRgb(0xBA, 0x68, 0xC8),
Color.FromRgb(0xE5, 0x73, 0x73),
Color.FromRgb(0xFF, 0xD5, 0x4F),
Color.FromRgb(0x4D, 0xB6, 0xAC),
Color.FromRgb(0xA1, 0x88, 0x7F),
Color.FromRgb(0x79, 0x86, 0xCB),
};
[ObservableProperty]
private bool isVisible = true;
/// <summary>輪廓邊線開關(邊線量過大的檔案匯入時自動關閉,避免轉動視角卡頓)</summary>
/// <summary>Outline visibility.</summary>
[ObservableProperty]
private bool showEdges = true;
@@ -44,9 +45,17 @@ public partial class ModelNodeViewModel : ObservableObject
[ObservableProperty]
private Color color;
public string Name { get; init; } = "";
public string Name
{
get => NameKey is null ? _name : NameNumber > 0 && NameKey == "Runtime.MeshNameNumbered"
? LocalizationService.Format(NameKey, NameNumber) : LocalizationService.Get(NameKey);
init => _name = value;
}
private string _name = "";
public string? NameKey { get; init; }
public int NameNumber { get; init; }
/// <summary>root(檔案層級)才有值</summary>
/// <summary>Set only on imported file roots.</summary>
public string? FilePath { get; init; }
public bool IsRoot => FilePath is not null;
@@ -57,7 +66,7 @@ public partial class ModelNodeViewModel : ObservableObject
public int FaceCount { get; init; }
public int TriangleCount { get; init; }
/// <summary>世界座標邊界(零件平移/旋轉後由 RecomputeBounds 更新;連動 Stats/Tooltip 的外形尺寸)</summary>
/// <summary>World-space bounds updated after part transforms.</summary>
public Rect3D Bounds
{
get => _bounds;
@@ -71,31 +80,39 @@ public partial class ModelNodeViewModel : ObservableObject
}
private Rect3D _bounds = Rect3D.Empty;
/// <summary>外形尺寸 L×W×H(AABB,mm)</summary>
/// <summary>Axis-aligned size in millimeters.</summary>
public string SizeText => Bounds.IsEmpty
? ""
: $"{Bounds.SizeX:F1} × {Bounds.SizeY:F1} × {Bounds.SizeZ:F1} mm";
/// <summary>來源 B-rep 物件(零件平移時整體 Modify,避免共用邊被重複位移)</summary>
/// <summary>Source B-rep objects transformed as whole parts.</summary>
public List<CADability.GeoObject.IGeoObject> SourceGeos { get; } = new();
/// <summary>false = STL/曲線等無 B-rep 來源(邊/面/圓量測不可用)</summary>
/// <summary>False for STL or curve-only sources without B-rep geometry.</summary>
public bool HasBrep { get; init; } = true;
public string Stats =>
$"{SolidCount} 實體 · {FaceCount} 面 · {TriangleCount:N0} △" + (HasBrep ? "" : " · 無B-rep") +
(Bounds.IsEmpty ? "" : $"\n外形 {SizeText}");
LocalizationService.Format("Runtime.NodeStats", SolidCount, FaceCount, TriangleCount) +
(HasBrep ? "" : LocalizationService.Get("Runtime.NoBrep")) +
(Bounds.IsEmpty ? "" : LocalizationService.Format("Runtime.OutlineSize", SizeText));
public string ToolTipText => (FilePath is not null ? FilePath + "\n" : "") + Stats;
// ── 樹搜尋/過濾(只影響樹面板顯示,不影響 3D 可見性)──
public void RefreshLocalizedText()
{
OnPropertyChanged(nameof(Name));
OnPropertyChanged(nameof(Stats));
OnPropertyChanged(nameof(ToolTipText));
foreach (ModelNodeViewModel child in Children) child.RefreshLocalizedText();
}
// Tree filtering does not change viewport visibility.
[ObservableProperty]
private bool isFilterVisible = true;
/// <summary>
/// 套用名稱過濾:符合者 + 其祖先 + 其子樹 顯示。空字串 = 全顯。
/// 過濾中有符合的子孫時自動展開。回傳此節點是否顯示。
/// Shows matching nodes, their ancestors and descendants; expands matching branches.
/// </summary>
public bool ApplyFilter(string? filter, bool ancestorMatched = false)
{
@@ -109,17 +126,17 @@ public partial class ModelNodeViewModel : ObservableObject
return IsFilterVisible;
}
// ── 視覺物件 ──
public ModelVisual3D? BodyVisual { get; set; } // leaf:面網格容器(內容在兩種模式間切換)
public Model3DGroup? FacesContent { get; set; } // leaf:逐面 GeometryModel3D(量測拾取用)
public Model3DGroup? MergedContent { get; set; } // leaf:整零件合併成 1 個 GeometryModel3D(瀏覽用,draw call 大減)
public LinesVisual3D? EdgeVisual { get; set; } // root:整檔合併邊線(一檔一條,避免轉動卡頓)
// Viewport objects
public ModelVisual3D? BodyVisual { get; set; }
public Model3DGroup? FacesContent { get; set; }
public Model3DGroup? MergedContent { get; set; }
public LinesVisual3D? EdgeVisual { get; set; }
public DiffuseMaterial? SharedMaterial { get; set; }
/// <summary>原始(未剖切)邊線端點 — 剖面還原用</summary>
/// <summary>Unclipped edge endpoints used for section restoration.</summary>
public Point3DCollection? OriginalEdgePoints { get; set; }
/// <summary>leaf 的可見性/邊線狀態變更(MainViewModel 同步 viewport)</summary>
/// <summary>Signals the ViewModel to update leaf viewport objects.</summary>
public event Action<ModelNodeViewModel>? VisualStateChanged;
private int _paletteIndex;
@@ -130,7 +147,7 @@ public partial class ModelNodeViewModel : ObservableObject
color = Palette[_paletteIndex];
}
/// <summary>所有 leaf 後代(含自身若為 leaf)</summary>
/// <summary>All descendant leaves, including this node when it is a leaf.</summary>
public IEnumerable<ModelNodeViewModel> Leaves()
{
if (IsLeafNode) { yield return this; yield break; }
+50 -41
View File
@@ -3,14 +3,16 @@ using System.Collections.Generic;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using STPViewer.Services;
namespace STPViewer.ViewModels;
/// <summary>快速列單一按鈕的顯示開關(選單「快速列 → 自訂按鈕」的 checkbox 項)</summary>
/// <summary>Visibility toggle for one quick bar button.</summary>
public partial class QuickBarItemViewModel : ObservableObject
{
public string Key { get; }
public string Label { get; }
public string Label => LocalizationService.Get(_labelKey);
private readonly string _labelKey;
public bool Default { get; }
[ObservableProperty]
@@ -19,57 +21,58 @@ public partial class QuickBarItemViewModel : ObservableObject
public QuickBarItemViewModel(string key, string label, bool @default)
{
Key = key;
Label = label;
_labelKey = label;
Default = @default;
isChecked = @default;
}
public void RefreshText() => OnPropertyChanged(nameof(Label));
}
/// <summary>
/// 快速工具列自訂:所有功能都在選單列,快速列只放使用者勾選的常用按鈕。
/// XAML 以索引子綁定:Visibility="{Binding QuickBar[Point].IsChecked, Converter=…}"。
/// 勾選狀態隨 settings.json(QuickBarKeys)保存;null = 用預設。
/// Quick bar button registry and saved visibility selections.
/// </summary>
public partial class QuickBarViewModel : ObservableObject
{
/// <summary>註冊表 = 快速列順序 + 選單顯示名 + 是否預設顯示(一般 user 常用)</summary>
/// <summary>Button order, label key, and default visibility.</summary>
private static readonly (string Key, string Label, bool Default)[] Registry =
{
("Import", "📂 匯入", true),
("Recent", "▾ 最近檔案", true),
("ZoomAll", "🔍 全覽", true),
("ViewIso", "視圖:等角", true),
("ViewFront", "視圖:前", false),
("ViewTop", "視圖:上", false),
("ViewRight", "視圖:右", false),
("Ortho", "正交投影", true),
("Point", "📍 點", true),
("Distance", "📏 距離", true),
("Edge", "📐 邊", false),
("Face", "⬛ 面", false),
("Circle", "⭕ 圓", true),
("Angle", "∠ 角度", true),
("FaceDistance", "⇔ 面距", false),
("Align", "🎯 兩點對齊", false),
("Align3", "🎯 三點對齊", false),
("RotateX", "旋轉 ↻X", false),
("RotateY", "旋轉 ↻Y", false),
("RotateZ", "旋轉 ↻Z", false),
("Drag", "🖐 拖曳", false),
("Gizmo", "⊹ 操作器", false),
("Interference", "🧩 干涉", true),
("Section", "✂ 剖面", true),
("Clear", "🧹 清除量測", true),
("Unit", "mm ⇄ in", true),
("ExportCsv", "💾 匯出 CSV", false),
("Screenshot", "📷 截圖", false),
("ExportStep", "📤 匯出 STEP", false),
("ExportStl", "📐 匯出 STL", false),
("Import", "QuickBar.Import", true),
("Recent", "QuickBar.Recent", true),
("ZoomAll", "QuickBar.ZoomAll", true),
("ViewIso", "QuickBar.ViewIso", true),
("ViewFront", "QuickBar.ViewFront", false),
("ViewTop", "QuickBar.ViewTop", false),
("ViewRight", "QuickBar.ViewRight", false),
("Ortho", "QuickBar.Ortho", true),
("Point", "QuickBar.Point", true),
("Distance", "QuickBar.Distance", true),
("Edge", "QuickBar.Edge", false),
("Face", "QuickBar.Face", false),
("Circle", "QuickBar.Circle", true),
("Angle", "QuickBar.Angle", true),
("FaceDistance", "QuickBar.FaceDistance", false),
("Align", "QuickBar.Align", false),
("Align3", "QuickBar.Align3", false),
("RotateX", "QuickBar.RotateX", false),
("RotateY", "QuickBar.RotateY", false),
("RotateZ", "QuickBar.RotateZ", false),
("Drag", "QuickBar.Drag", false),
("Gizmo", "QuickBar.Gizmo", false),
("Interference", "QuickBar.Interference", true),
("Section", "QuickBar.Section", true),
("Clear", "QuickBar.Clear", true),
("Unit", "QuickBar.Unit", true),
("ExportCsv", "QuickBar.ExportCsv", false),
("Screenshot", "QuickBar.Screenshot", false),
("ExportStep", "QuickBar.ExportStep", false),
("ExportStl", "QuickBar.ExportStl", false),
("Language", "QuickBar.Language", true),
};
private readonly Dictionary<string, QuickBarItemViewModel> _byKey;
/// <summary>依快速列順序的全部項目(選單自訂清單用)</summary>
/// <summary>All buttons in quick bar order.</summary>
public IReadOnlyList<QuickBarItemViewModel> Items { get; }
public QuickBarViewModel()
@@ -78,10 +81,10 @@ public partial class QuickBarViewModel : ObservableObject
_byKey = Items.ToDictionary(i => i.Key);
}
/// <summary>XAML 索引子綁定入口:QuickBar[Point].IsChecked</summary>
/// <summary>Indexer used by XAML visibility bindings.</summary>
public QuickBarItemViewModel this[string key] => _byKey[key];
/// <summary>套用保存的勾選清單;null = 維持預設(含未知 key 容錯:新版本新增按鈕用預設值)</summary>
/// <summary>Loads saved visibility keys; null retains defaults.</summary>
public void Load(List<string>? visibleKeys)
{
if (visibleKeys is null) return;
@@ -90,10 +93,16 @@ public partial class QuickBarViewModel : ObservableObject
item.IsChecked = set.Contains(item.Key);
}
/// <summary>目前勾選清單(存 settings.json)</summary>
/// <summary>Selected keys saved in settings.json.</summary>
public List<string> ToKeys() =>
Items.Where(i => i.IsChecked).Select(i => i.Key).ToList();
public void RefreshText()
{
foreach (QuickBarItemViewModel item in Items)
item.RefreshText();
}
[RelayCommand]
private void ResetDefaults()
{
+12 -13
View File
@@ -5,18 +5,17 @@ using STPViewer.Services;
namespace STPViewer.ViewModels;
/// <summary>
/// STL 匯出參數對話框的 ViewModel:範圍 / 格式 / 單位縮放 / 網格精度 + 即時摘要(三角形數、預估大小)。
/// 對話框按「匯出」後由 MainViewModel.Export 讀取選項執行。
/// Options and live size estimate for the STL export dialog.
/// </summary>
public partial class StlExportViewModel : ObservableObject
{
/// <summary>可見(可匯出)檔案數</summary>
/// <summary>Number of visible files available for export.</summary>
public int FileCount { get; }
/// <summary>目前顯示網格的三角形總數(精細/較粗會重新三角化,實際數量以結果為準)</summary>
/// <summary>Triangle count of the current display meshes.</summary>
public long TriangleCount { get; }
/// <summary>是否有 B-rep 來源(全 STL/DXF 時精度選項無意義 → 停用)</summary>
/// <summary>Whether B-rep geometry is available for retessellation.</summary>
public bool HasBrep { get; }
public StlExportViewModel(int fileCount, long triangleCount, bool hasBrep)
@@ -26,17 +25,17 @@ public partial class StlExportViewModel : ObservableObject
HasBrep = hasBrep;
}
/// <summary>0 = 全部可見合併成單一 STL;1 = 每個可見檔案各一個 STL</summary>
/// <summary>0 = merged STL; 1 = one STL per visible file.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Summary))]
private int scopeIndex;
/// <summary>0 = Binary(建議,檔案小);1 = ASCII(文字可讀,檔案大)</summary>
/// <summary>0 = binary; 1 = ASCII.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Summary))]
private int formatIndex;
/// <summary>0 = mm(1:1);1 = inch(÷25.4);2 = 自訂縮放</summary>
/// <summary>0 = millimeters; 1 = inches; 2 = custom scale.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Summary))]
[NotifyPropertyChangedFor(nameof(IsCustomScale))]
@@ -48,7 +47,7 @@ public partial class StlExportViewModel : ObservableObject
[NotifyPropertyChangedFor(nameof(CanConfirm))]
private string customScaleText = "1.0";
/// <summary>0 = 目前顯示網格;1 = 精細(B-rep 重新三角化)</summary>
/// <summary>0 = current mesh; 1 = finer B-rep tessellation.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Summary))]
private int qualityIndex;
@@ -59,7 +58,7 @@ public partial class StlExportViewModel : ObservableObject
public bool PerFile => ScopeIndex == 1;
public StlMeshQuality Quality => (StlMeshQuality)QualityIndex;
/// <summary>座標縮放;自訂值無效時回 null(擋確認)</summary>
/// <summary>Coordinate scale, or null for an invalid custom value.</summary>
public double? Scale => UnitIndex switch
{
0 => 1.0,
@@ -77,9 +76,9 @@ public partial class StlExportViewModel : ObservableObject
long est = StlExportService.EstimateBytes(TriangleCount, Ascii);
string size = StlExportService.FormatBytes(est);
string basis = QualityIndex == 0
? $"預估 {size}"
: $"以目前網格預估 {size}(實際依重新三角化結果)";
return $"{FileCount} 個可見檔案 · {TriangleCount:N0} 三角形 · {basis}";
? LocalizationService.Format("Ui.StlExport.EstimatedSize", size)
: LocalizationService.Format("Ui.StlExport.EstimatedSizeFine", size);
return LocalizationService.Format("Ui.StlExport.Summary", FileCount, TriangleCount.ToString("N0"), basis);
}
}
}
+417
View File
@@ -0,0 +1,417 @@
{
"key": "English",
"data": {
"App.ErrorTitle": "STPViewer Error",
"App.NoRecentFiles": "(No recent files)",
"App.UnexpectedError": "An unexpected error interrupted the operation. The application will try to continue.\n\n{0}\n\nDetails: {1}",
"App.WindowTitle": "STPViewer V{0} — STP/STEP 3D Viewer",
"QuickBar.Align": "🎯 Two-point alignment",
"QuickBar.Align3": "🎯 Three-point alignment",
"QuickBar.Angle": "∠ Angle",
"QuickBar.Circle": "⭕ Circle",
"QuickBar.Clear": "🧹 Clear measurements",
"QuickBar.Distance": "📏 Distance",
"QuickBar.Drag": "🖐 Drag",
"QuickBar.Edge": "📐 Edge",
"QuickBar.ExportCsv": "💾 Export CSV",
"QuickBar.ExportStep": "📤 Export STEP",
"QuickBar.ExportStl": "📐 Export STL",
"QuickBar.Face": "⬛ Face",
"QuickBar.FaceDistance": "⇔ Face distance",
"QuickBar.Gizmo": "⊹ Gizmo",
"QuickBar.Import": "📂 Import",
"QuickBar.Interference": "🧩 Interference",
"QuickBar.Language": "Language",
"QuickBar.Ortho": "Orthographic projection",
"QuickBar.Point": "📍 Point",
"QuickBar.Recent": "▾ Recent files",
"QuickBar.RotateX": "Rotate ↻X",
"QuickBar.RotateY": "Rotate ↻Y",
"QuickBar.RotateZ": "Rotate ↻Z",
"QuickBar.Screenshot": "📷 Screenshot",
"QuickBar.Section": "✂ Section",
"QuickBar.Unit": "mm ⇄ in",
"QuickBar.ViewFront": "View: Front",
"QuickBar.ViewIso": "View: Isometric",
"QuickBar.ViewRight": "View: Right",
"QuickBar.ViewTop": "View: Top",
"QuickBar.ZoomAll": "🔍 Zoom all",
"Runtime.Align3Completed": "Aligned “{0}” using three points (rotation + translation; point 1 matched exactly, measurements cleared)",
"Runtime.Align3Failed": "Three-point alignment failed (degenerate points); select new points",
"Runtime.Align3OtherFile": "Target points must be on another file",
"Runtime.Align3SameSource": "All three source points must be on one file (moving: {0})",
"Runtime.Align3SourceCollinear": "The three points are collinear; choose a different third point",
"Runtime.Align3SourceComplete": "Three source points selected — select matching point 1/3 on the target file",
"Runtime.Align3SourcePoint": "Source point {0}/3 — keep selecting points on “{1}”",
"Runtime.Align3TargetCollinear": "Target points are collinear; choose a different third point",
"Runtime.Align3TargetPoint": "Target point {0}/3 — select the next matching point",
"Runtime.AlignMoved": "Moved “{0}” Δ({1:F3}, {2:F3}, {3:F3}) mm (measurements cleared; use face distance or interference to check fit)",
"Runtime.AlignMoving": "Moving “{0}” — select a target point on another file",
"Runtime.AlignOtherFile": "The target point must be on another file",
"Runtime.AllVisible": "All shown",
"Runtime.AngleDetail": "Angle = {0:F2}° (supplement {1:F2}°)\nDirection 1: {2} ({3:F3}, {4:F3}, {5:F3})\nDirection 2: {6} ({7:F3}, {8:F3}, {9:F3})\n(Face angles use normals; see supplement for the complementary angle)",
"Runtime.AngleFirstDirection": "First direction selected ({0}) — select another face or edge",
"Runtime.ArcLength": "Arc length",
"Runtime.Axis": "{0} axis",
"Runtime.CircleDetail": "Diameter = {0}\nRadius = {1}\n{2} = {3}\nCenter {4}",
"Runtime.Circumference": "Circumference",
"Runtime.ComponentName": "Component",
"Runtime.ConicalSurface": "Conical surface",
"Runtime.CsvEmpty": "No measurements to export",
"Runtime.CsvExported": "Exported {0} measurements → {1}",
"Runtime.CsvFilter": "CSV files (*.csv)|*.csv",
"Runtime.CsvHeader": "Label,Type,Details",
"Runtime.CsvTitle": "Export measurements",
"Runtime.CurveDetail": "Curve length = {0}\nType = {1}",
"Runtime.CurveName": "Curve",
"Runtime.CustomPlane": "custom plane",
"Runtime.CylinderSurface": "Type = cylindrical surface\nRadius = {0}\nAxis ({1:F3}, {2:F3}, {3:F3})",
"Runtime.DirectionEdge": "Edge direction",
"Runtime.DirectionFace": "Face normal",
"Runtime.DirectionMeshFace": "Face normal (mesh)",
"Runtime.DistanceDetail": "Distance = {0}\nΔX = {1}\nΔY = {2}\nΔZ = {3}\nP1 {4}\nP2 {5}",
"Runtime.DistanceFirstPoint": "First point ({0:F2}, {1:F2}, {2:F2}) — select the second point",
"Runtime.Dragged": "Dragged “{0}” Δ({1:F2}, {2:F2}, {3:F2}) mm (use interference check to verify)",
"Runtime.Dragging": "Dragging “{0}”… (release to place)",
"Runtime.DxfParseFailed": "Could not parse DXF file",
"Runtime.ExtrusionSurface": "Extruded surface",
"Runtime.FaceDetail": "Area ≈ {0} (mesh approximation)\n{1}\nTriangles = {2:N0}",
"Runtime.FaceDistanceDetail": "Minimum distance ≈ {0} (mesh approximation)\nΔX = {1}\nΔY = {2}\nΔZ = {3}\nPoint 1 {4}\nPoint 2 {5}",
"Runtime.FaceDistanceFirstFace": "First face selected — select another face",
"Runtime.FaceName": "Face",
"Runtime.FileNotFound": "File not found",
"Runtime.GapLabel": "gap {0}",
"Runtime.GeometryProcessed": "Geometry processed in {0:N0} ms (triangulation {1:N0} ms, edge sampling {2:N0} ms{3})",
"Runtime.GizmoApplied": "Applied gizmo transform to “{0}” (use interference check to verify)",
"Runtime.GizmoHint": "Gizmo: drag an arrow to move “{0}” along an axis or a ring to rotate; release to place",
"Runtime.GizmoSelectFile": "Gizmo: select a file in the tree first",
"Runtime.ImportBusy": "Busy importing or calculating; please wait",
"Runtime.ImportCompleted": "Import complete: {0} succeeded{1}",
"Runtime.ImportFailed": "Import failed: {0} — {1}",
"Runtime.ImportFailedCount": ", {0} failed",
"Runtime.ImportFailedTitle": "Import failed",
"Runtime.ImportFilter": "CAD files (*.stp;*.step;*.stl;*.dxf)|*.stp;*.step;*.stl;*.dxf|STEP (*.stp;*.step)|*.stp;*.step|STL (*.stl)|*.stl|DXF (*.dxf)|*.dxf|All files (*.*)|*.*",
"Runtime.ImportProgress": "Importing {0}: {1}",
"Runtime.ImportTitle": "Import CAD files (multiple selection)",
"Runtime.Importing": "Importing: {0}…",
"Runtime.InterferenceChecking": "Checking interference (pair {0}): {1} ⟷ {2}…",
"Runtime.InterferenceClearDetail": "{0} ⟷ {1} do not intersect\nMinimum gap ≈ {2} (mesh approximation)\nGap ≈ 0 means contact (match)\nPoint 1 {3}\nPoint 2 {4}",
"Runtime.InterferenceClearTitle": "🧩 No interference gap ≈ {0}",
"Runtime.InterferenceCompleted": "Interference check complete: {0} pairs, {1} intersecting",
"Runtime.InterferenceFailed": "Interference check failed: {0}",
"Runtime.InterferenceHitDetail": "Parts intersect (no match)\nIntersecting triangle pairs = {0:N0}\nRed lines = intersection curves ({1:N0} segments)\n(Coplanar contact is not counted as interference)",
"Runtime.InterferenceHitTitle": "🧩 Interference! {0} ⟷ {1}",
"Runtime.InterferenceLabel": "Interference",
"Runtime.InterferenceNeedTwo": "Interference check needs at least two visible files ({0} now); select them in the tree",
"Runtime.Isolated": "Showing only “{0}” (tree context menu → Show all to restore)",
"Runtime.KindAlign": "Align",
"Runtime.KindAlign3": "Three-point align",
"Runtime.KindAngle": "Angle",
"Runtime.KindCircle": "Circle",
"Runtime.KindDistance": "Distance",
"Runtime.KindDrag": "Drag",
"Runtime.KindEdge": "Edge",
"Runtime.KindFace": "Face",
"Runtime.KindFaceDistance": "Face distance",
"Runtime.KindInterference": "Interference",
"Runtime.KindNone": "None",
"Runtime.KindPoint": "Point",
"Runtime.KindVolume": "Volume",
"Runtime.LineDetail": "Length = {0}\nΔX = {1} ΔY = {2} ΔZ = {3}\nStart {4}\nEnd {5}",
"Runtime.MeasureFailed": "Measurement failed: {0}",
"Runtime.MeasurementStartCancelled": "Current measurement start cancelled (press Esc again to leave measurement mode)",
"Runtime.MeasurementsCleared": "All measurements cleared",
"Runtime.MeshMeasurementLimited": "This model has no B-rep (STL/mesh); only point, distance, angle and face distance measurements are available",
"Runtime.MeshName": "Mesh",
"Runtime.MeshNameNumbered": "Mesh {0}",
"Runtime.MeshSurface": "Type = mesh (no B-rep)",
"Runtime.ModeAlign": "Two-point align: select a point on the part to move, then a target point (translation only; measurements cleared)",
"Runtime.ModeAlign3": "Three-point align: select three feature points on the file to move, then three matching points on the target file",
"Runtime.ModeAngle": "Angle measurement: select the first face (or near a straight edge)",
"Runtime.ModeBrowse": "Browse mode (right drag to orbit, wheel to zoom, middle drag or Shift+left drag to pan)",
"Runtime.ModeCircle": "Circle measurement: click near a circular edge or hole wall",
"Runtime.ModeDistance": "Distance measurement: select the first point",
"Runtime.ModeDrag": "Drag mode: hold left mouse button to drag a part in the screen plane, release to place it; rotate the view to change depth",
"Runtime.ModeEdge": "Edge measurement: click near an edge",
"Runtime.ModeFace": "Face measurement: click a face",
"Runtime.ModeFaceDistance": "Face distance measurement: select the first face",
"Runtime.ModePoint": "Point measurement: click a model surface (snaps to nearby vertices)",
"Runtime.NoBrep": " · No B-rep",
"Runtime.NoCircularEdge": "This face has no circular edge (click near a hole wall or circular edge)",
"Runtime.NoGeometry": "File contains no displayable geometry",
"Runtime.NoMeasurableEdge": "This face has no measurable edge",
"Runtime.NoSurfaceHit": "No model surface hit",
"Runtime.NodeStats": "{0} solids · {1} faces · {2:N0} △",
"Runtime.NurbsSurface": "NURBS surface",
"Runtime.Orthographic": "Orthographic projection (parallel view for checking dimensions)",
"Runtime.OutlineSize": "\nBounds {0}",
"Runtime.ParsingStep": "Parsing STEP (large files may take several minutes)…",
"Runtime.ParsingStl": "Parsing STL triangles…",
"Runtime.Perspective": "Perspective projection",
"Runtime.PlaneSurface": "Type = plane\nNormal ({0:F3}, {1:F3}, {2:F3})",
"Runtime.Ready": "Ready — import STP/STEP/STL/DXF (or drag and drop). Right drag to orbit, wheel to zoom, middle drag or Shift+left drag to pan",
"Runtime.Removed": "Removed: {0}",
"Runtime.RetriedFaces": ", recovered {0} faces",
"Runtime.Reversed": " (reversed)",
"Runtime.RevolutionSurface": "Surface of revolution",
"Runtime.RootNotFound": "Could not find this part's file",
"Runtime.RotateSelectFile": "Select a file to rotate in the tree first",
"Runtime.Rotated": "Rotated “{0}” +90° around {1} axis (click again to continue)",
"Runtime.SameFace": "The same face was selected twice; select another face",
"Runtime.ScreenshotFilter": "PNG images (*.png)|*.png",
"Runtime.ScreenshotSaved": "Screenshot saved → {0}",
"Runtime.ScreenshotTitle": "Save view screenshot",
"Runtime.SectionCancelled": "3-point section definition cancelled",
"Runtime.SectionClosed": "Section closed",
"Runtime.SectionCollinear": "3-point section: points are collinear; choose a different third point",
"Runtime.SectionDefined": "3-point section: plane defined (normal {0:F2}, {1:F2}, {2:F2}) — move the position slider to scan",
"Runtime.SectionDegenerate": "3-point section: points are degenerate; select again",
"Runtime.SectionNoHit": "3-point section: no model surface hit ({0}/3 points selected, Esc to cancel)",
"Runtime.SectionPickPrompt": "3-point section: click three points on the model to define the cutting plane (Esc to cancel)",
"Runtime.SectionPicked": "3-point section: {0}/3 points selected",
"Runtime.SectionStatus": "Section: {0} {1:F0}%{2}",
"Runtime.ShellName": "Shell",
"Runtime.SolidName": "Solid",
"Runtime.SphericalSurface": "Spherical surface",
"Runtime.StepExportEmpty": "No B-rep geometry to export (STEP supports Solid/Shell; select a STEP file to export)",
"Runtime.StepExportFailed": "STEP export failed: {0}",
"Runtime.StepExportFilter": "STEP files (*.stp)|*.stp|STEP files (*.step)|*.step",
"Runtime.StepExportTitle": "Export current scene as STEP (parts at current aligned positions)",
"Runtime.StepExported": "Exported STEP ({0} solids at current positions) → {1}",
"Runtime.StepParseFailed": "CADability could not parse this STEP file",
"Runtime.StepParsed": "STEP parsed in {0:N0} ms",
"Runtime.StlExportEmpty": "No meshes to export (select at least one file with a solid mesh)",
"Runtime.StlExportFailed": "STL export failed: {0}",
"Runtime.StlExportFilter": "STL files (*.stl)|*.stl",
"Runtime.StlExportMergedTitle": "Export STL (merged into one file)",
"Runtime.StlExportPerFileTitle": "Export STL (one per file; use this as the base filename)",
"Runtime.StlExportedMany": "Exported {0} STL files ({1:N0} triangles total) → {2}",
"Runtime.StlExportedOne": "Exported STL ({0:N0} triangles) → {1}",
"Runtime.StlExporting": "Exporting STL…",
"Runtime.StlNoTriangles": "STL file contains no triangles",
"Runtime.StlParsed": "STL parsed in {0:N0} ms ({1} meshes)",
"Runtime.StlRetriangulating": "Retriangulating and exporting STL… (large assemblies take time)",
"Runtime.SurfaceType": "Type = {0}",
"Runtime.ToroidalSurface": "Toroidal surface",
"Runtime.Triangulating": "Triangulating {0} parts in parallel…",
"Runtime.UnitInch": "Display unit: inch",
"Runtime.UnitMillimeter": "Display unit: mm",
"Runtime.UnsupportedFormat": "Unsupported format (.stp / .step / .stl / .dxf supported; CADability has no IGES reader)",
"Runtime.View": "View: {0}",
"Runtime.ViewBack": "Back",
"Runtime.ViewBottom": "Bottom",
"Runtime.ViewFront": "Front",
"Runtime.ViewIso": "Isometric",
"Runtime.ViewLeft": "Left",
"Runtime.ViewRight": "Right",
"Runtime.ViewTop": "Top",
"Runtime.VisibilityInverted": "Visibility inverted",
"Runtime.VolumeDetail": "Volume ≈ {0} (mesh approximation)\nCentroid {1}\nBounds (AABB) {2} × {3} × {4} {5}",
"Runtime.VolumeNoMesh": "“{0}” has no face mesh for volume calculation (wireframe has no volume)",
"Runtime.VolumeOpenMesh": "“{0}” is an open mesh; volume cannot be calculated reliably",
"Ui.About.Changelog": "📋 Release History",
"Ui.About.Close": "Close",
"Ui.About.Description": "STP / STEP 3D Viewer. Open, measure, align, check interference, section, and export without starting a full CAD application.",
"Ui.About.Developer": "👤 Developer",
"Ui.About.Email": "Email",
"Ui.About.Name": "Name",
"Ui.About.Release010.Change1": "• STEP, STL, and DXF import with an assembly tree; point, distance, edge, face, circle, angle, and face-distance measurement; alignment, interference checking, sections, units, CSV, and screenshots.",
"Ui.About.Release010.Title": "First Release",
"Ui.About.Release020.Change1": "• Hold the left mouse button on a part to drag it into position.",
"Ui.About.Release020.Change2": "• Version 0.2.1 fixed a crash after clicking a dragged part.",
"Ui.About.Release020.Title": "Drag Mode",
"Ui.About.Release030.Change1": "• Select a model and use XYZ arrows to move it along an axis or rings to rotate it by any angle.",
"Ui.About.Release030.Title": "3-Axis Gizmo",
"Ui.About.Release031.Change1": "• The 3-axis gizmo stays above the model and remains accessible from any angle.",
"Ui.About.Release031.Title": "Gizmo Always Visible",
"Ui.About.Release032.Change1": "• Rotating a large assembly remains smooth while measuring.",
"Ui.About.Release032.Title": "Smooth Measurement of Large Assemblies",
"Ui.About.Release040.Change1": "• Moving the section slider on a large file no longer freezes the window.",
"Ui.About.Release040.Change2": "• Placement after dragging or using the gizmo is much faster.",
"Ui.About.Release040.Change3": "• Errors now show a message and let the application keep running.",
"Ui.About.Release040.Title": "Faster and More Reliable",
"Ui.About.Release050.Change1": "• Distance measurement snaps to the center of a circular edge, making hole pitch easy to measure.",
"Ui.About.Release050.Change2": "• Measurement shortcuts: P point, D distance, E edge, F face, C circle, A angle, M face distance; Esc cancels.",
"Ui.About.Release050.Change3": "• Search part names in the model tree; right-click to isolate a node or measure volume and centroid.",
"Ui.About.Release050.Change4": "• Standard views and orthographic projection make dimensions easier to inspect without perspective distortion.",
"Ui.About.Release050.Change5": "• A three-point section mode cuts along any plane defined by three points on the model.",
"Ui.About.Release050.Change6": "• Export aligned parts to a new STEP file for use in CAD.",
"Ui.About.Release050.Change7": "• Window size, unit preferences, and the ten most recently opened files are remembered.",
"Ui.About.Release050.Title": "Major Improvements to Measurement, Views, and the Model Tree",
"Ui.About.Release060.Change1": "• Export visible parts, including their aligned and rotated positions, to STL for 3D printing or other software.",
"Ui.About.Release060.Change2": "• Choose one merged file or one file per model, Binary or ASCII, mm or inches, and mesh quality before exporting.",
"Ui.About.Release060.Change3": "• The settings dialog shows triangle count and estimated file size immediately.",
"Ui.About.Release060.Title": "Export STL",
"Ui.About.Release070.Change1": "• A new menu bar groups all features under File, View, Measure, Transform, and Tools.",
"Ui.About.Release070.Change2": "• Choose your favorite Quick Bar buttons from Quick Bar → Customize Buttons; your selection is saved.",
"Ui.About.Release070.Change3": "• Section controls appear only when section view is active.",
"Ui.About.Release070.Change4": "• A new About window shows the release history.",
"Ui.About.Release070.Title": "Redesigned Interface with Menus and a Customizable Quick Bar",
"Ui.About.Release071.Change1": "• Large STL mesh files with millions of triangles now open almost instantly instead of taking several minutes.",
"Ui.About.Release071.Change2": "• For example, a 75 MB STL with 1.5 million triangles now appears in under a second.",
"Ui.About.Release071.Title": "Open Large STL Files in Seconds",
"Ui.About.Technology": ".NET 8 · WPF · C#; CADability (STEP / B-rep geometry); HelixToolkit (3D display and camera)",
"Ui.About.TechnologyTitle": "🔧 Technology",
"Ui.About.Title": "About STPViewer",
"Ui.Language": "Language",
"Ui.Main.3AxisGizmo": "⊹ 3-Axis Gizmo",
"Ui.Main.3AxisGizmoUseXYZArrows": "3-axis gizmo: use XYZ arrows to move and rings to rotate the selected model; release to place it",
"Ui.Main.3Point": "🎯 3-Point",
"Ui.Main.3Points": "3 Points",
"Ui.Main.Align": "🎯 Align",
"Ui.Main.Angle": "∠ Angle",
"Ui.Main.AngleBetweenFacesStraightEdgesShortcut": "Angle between faces or straight edges — shortcut A",
"Ui.Main.AngleBetweenTwoFacesStraightEdges": "Angle between two faces or straight edges",
"Ui.Main.ApplicationInformationReleaseHistory": "Application information and release history",
"Ui.Main.AreaSurfaceTypeNormalVector": "Area, surface type, and normal vector",
"Ui.Main.AreaSurfaceTypeNormalVectorShortcut": "Area, surface type, and normal vector — shortcut F",
"Ui.Main.Capture": "📷 Capture",
"Ui.Main.CenterRadiusDiameterCircumference": "Center, radius, diameter, and circumference",
"Ui.Main.CenterRadiusDiameterCircumferenceShortcutC": "Center, radius, diameter, and circumference — shortcut C",
"Ui.Main.CheckButtonsShowQuickBarSaved": "Check the buttons to show on the Quick Bar (saved in settings)",
"Ui.Main.CheckVisibleModelPairsIntersectionsReport": "Check visible model pairs for intersections; report the smallest gap when none intersect",
"Ui.Main.ChooseScopeFormatUnitsMeshQuality": "Choose scope, format, units, and mesh quality before exporting",
"Ui.Main.Circle": "⭕ Circle",
"Ui.Main.Clear": "🧹 Clear",
"Ui.Main.ClearAllMeasurements": "🧹 Clear All Measurements",
"Ui.Main.ClearAllMeasurementsMarkersEscCancels": "Clear all measurements and markers (Esc cancels the current measurement or mode)",
"Ui.Main.ClickChangeColorIncludingChildNodes": "Click to change color (including child nodes)",
"Ui.Main.Close": "✕ Close",
"Ui.Main.CloseSectionView": "Close section view",
"Ui.Main.CustomizeButtons": "Customize Buttons",
"Ui.Main.DeleteThisMeasurement": "Delete this measurement",
"Ui.Main.DisplayInchesMm": "Display in Inches (mm ⇄ in)",
"Ui.Main.Distance": "📏 Distance",
"Ui.Main.DistanceXYZCircleCenterSnappingHole": "Distance and ΔXYZ with circle-center snapping for hole pitch — shortcut D",
"Ui.Main.DistanceXYZCircularEdgesSnapCenters": "Distance and ΔXYZ; circular edges snap to centers for hole pitch measurement",
"Ui.Main.Drag": "🖐 Drag",
"Ui.Main.DragMode": "🖐 Drag Mode",
"Ui.Main.DragModeHoldLeftMouseButton": "Drag mode: hold the left mouse button to move a part in the screen plane; rotate the view to move in depth",
"Ui.Main.DragResizePanel": "Drag to resize the panel",
"Ui.Main.Edge": "📐 Edge",
"Ui.Main.EdgeLength": "📐 Edge Length",
"Ui.Main.Edges": "Edges",
"Ui.Main.EscCancelsCurrentMeasurementExitsMode": "Esc cancels the current measurement or exits the mode",
"Ui.Main.ExistingMeasurementsUpdateImmediatelyValuesStored": "Existing measurements update immediately; values are stored internally in mm",
"Ui.Main.ExportMeasurementResultsCSV": "Export measurement results as CSV",
"Ui.Main.ExportMeasurementsCSV": "💾 Export Measurements as CSV…",
"Ui.Main.ExportSTEP": "📤 Export STEP…",
"Ui.Main.ExportSTL": "📐 Export STL…",
"Ui.Main.ExportStepTooltip": "Export visible models at their current positions to a new STEP file; source files stay unchanged",
"Ui.Main.Face": "⬛ Face",
"Ui.Main.FaceDistance": "⇔ Face Distance",
"Ui.Main.FileF": "File (_F)",
"Ui.Main.FilterAssemblyTreeByNameDoes": "Filter assembly tree by name (does not change 3D visibility; clear to show all)",
"Ui.Main.Fit": "🔍 Fit",
"Ui.Main.FitAllModelsView": "Fit all models in view",
"Ui.Main.Flip": "Flip",
"Ui.Main.Front": "Front",
"Ui.Main.FrontView": "Front View",
"Ui.Main.FrontViewLookAlongYZ": "Front view (look along −Y, Z up)",
"Ui.Main.Gizmo": "⊹ Gizmo",
"Ui.Main.HelpH": "Help (_H)",
"Ui.Main.HoldLeftMouseButtonMovePart": "Hold the left mouse button to move a part in the screen plane; release to place it",
"Ui.Main.IAboutSTPViewer": "ℹ About STPViewer…",
"Ui.Main.ImportSTPSTEPSTLDXFFiles": "Import STP/STEP/STL/DXF files (select multiple or drag files into the window)",
"Ui.Main.Interference": "🧩 Interference",
"Ui.Main.InterferenceCheck": "🧩 Interference Check",
"Ui.Main.InterferenceCheckTestVisibleModelPairs": "Interference check: test visible model pairs for intersections or contact; report the smallest gap",
"Ui.Main.InvertVisibility": "🔄 Invert Visibility",
"Ui.Main.Iso": "Iso",
"Ui.Main.IsometricView": "Isometric View",
"Ui.Main.LookAlongXZUp": "Look along +X, with Z up",
"Ui.Main.LookAlongYZUp": "Look along −Y, with Z up",
"Ui.Main.LookDownFromZ": "Look down from +Z",
"Ui.Main.MeasureM": "Measure (_M)",
"Ui.Main.MeasurePointCoordinatesSnapsVerticesCircle": "Measure point coordinates (snaps to vertices and circle centers)",
"Ui.Main.MeasureStraightCurvedCircularEdgeLengths": "Measure straight, curved, and circular edge lengths",
"Ui.Main.MeasureVolumeCentroid": "⚖ Measure Volume / Centroid",
"Ui.Main.Measurements": "Measurements",
"Ui.Main.MenuImport": "📂 Import…",
"Ui.Main.ModelsAssemblyTree": "Models / Assembly Tree",
"Ui.Main.Ortho": "Ortho",
"Ui.Main.OrthographicProjection": "Orthographic Projection",
"Ui.Main.OrthographicProjectionAvoidsPerspectiveDistortionClick": "Orthographic projection avoids perspective distortion; click again for perspective",
"Ui.Main.ParallelProjectionAvoidsPerspectiveDistortionUncheck": "Parallel projection avoids perspective distortion; uncheck for perspective view",
"Ui.Main.PickPointPartMoveThenPick": "Pick a point on the part to move, then pick the target point",
"Ui.Main.PickThreePointsMovingModelThen": "Pick three points on the moving model, then three matching points on the target in the same order",
"Ui.Main.Point": "📍 Point",
"Ui.Main.PointCoordinates": "📍 Point Coordinates",
"Ui.Main.PointCoordinatesVertexCircleCenterSnapping": "Point coordinates with vertex/circle-center snapping — shortcut P",
"Ui.Main.QuickBarQ": "Quick Bar (_Q)",
"Ui.Main.RecentFiles": "Recent Files",
"Ui.Main.RecentlyOpenedFiles": "Recently opened files",
"Ui.Main.RemoveThisFile": "Remove this file",
"Ui.Main.RestoreDefaultButtons": "Restore Default Buttons",
"Ui.Main.Right": "Right",
"Ui.Main.RightView": "Right View",
"Ui.Main.RightViewLookAlongXZ": "Right view (look along +X, Z up)",
"Ui.Main.Rotate90AroundX": "↻ Rotate +90° Around X",
"Ui.Main.Rotate90AroundY": "↻ Rotate +90° Around Y",
"Ui.Main.Rotate90AroundZ": "↻ Rotate +90° Around Z",
"Ui.Main.RotateModelSelectedTree90Around": "Rotate the model selected in the tree +90° around X",
"Ui.Main.RotateModelSelectedTreeAroundIts": "Rotate the model selected in the tree around its center",
"Ui.Main.RotateSelectedModel90AroundY": "Rotate the selected model +90° around Y",
"Ui.Main.RotateSelectedModel90AroundZ": "Rotate the selected model +90° around Z",
"Ui.Main.SavePNGScreenshot3DView2": "Save a PNG screenshot of the 3D view at 2× resolution",
"Ui.Main.SaveViewPNG": "📷 Save View as PNG…",
"Ui.Main.SectionAxis3PointsDefineAn": "Section axis (3 points define an arbitrary plane on the model)",
"Ui.Main.SectionLabel": "✂ Section:",
"Ui.Main.SectionPosition0100EnterValue": "Section position (0–100%; enter a value and leave the field to apply)",
"Ui.Main.SectionPositionAlongNormal": "Section position (% along normal)",
"Ui.Main.SectionView": "✂ Section View",
"Ui.Main.ShortestDistanceBetweenFacesMeshApproximation": "Shortest distance between faces (mesh approximation)",
"Ui.Main.ShortestFaceDistanceMeshApproximationShortcut": "Shortest face distance (mesh approximation) — shortcut M",
"Ui.Main.ShowAll": "☀ Show All",
"Ui.Main.ShowHideIncludingChildNodes": "Show / hide (including child nodes)",
"Ui.Main.ShowOnlyThisNode": "👁 Show Only This Node",
"Ui.Main.ShowOutlineEdgesManyEdgesCan": "Show outline edges (many edges can slow view rotation)",
"Ui.Main.ShowSectionControlsAxisPositionDirection": "Show section controls for axis, position, and direction",
"Ui.Main.ShowXYZArrowsRotationRingsSelected": "Show XYZ arrows and rotation rings for the selected model; release to place it",
"Ui.Main.StraightCurvedCircularEdgeLengthShortcut": "Straight, curved, or circular edge length — shortcut E",
"Ui.Main.SwitchDisplayUnitsBetweenMmInches": "Switch display units between mm and inches (existing measurements update)",
"Ui.Main.SwitchRetainedSide": "Switch the retained side",
"Ui.Main.ThreePointAlignmentPickThreePoints": "Three-point alignment: pick three points on the moving model, then matching points on the target",
"Ui.Main.ThreePointAlignmentRotateTranslate": "🎯 Three-Point Alignment (Rotate + Translate)",
"Ui.Main.ToggleSectionViewItsControls": "Toggle section view and its controls",
"Ui.Main.ToolbarExportStepTooltip": "Export visible models at their current positions to STEP for CAD; source files stay unchanged",
"Ui.Main.ToolbarExportStlTooltip": "Export visible models at their current positions to STL; choose scope, format, units, and quality first",
"Ui.Main.ToolbarImport": "📂 Import",
"Ui.Main.ToolbarSection": "✂ Section",
"Ui.Main.ToolsL": "Tools (_L)",
"Ui.Main.Top": "Top",
"Ui.Main.TopView": "Top View",
"Ui.Main.TopViewLookDownFromZ": "Top view (look down from +Z)",
"Ui.Main.TransformT": "Transform (_T)",
"Ui.Main.TwoPointAlignmentPickPointMoving": "Two-point alignment: pick a point on the moving part, then the target point",
"Ui.Main.TwoPointAlignmentTranslate": "🎯 Two-Point Alignment (Translate)",
"Ui.Main.TwoPointDistance": "📏 Two-Point Distance",
"Ui.Main.ViewV": "View (_V)",
"Ui.Main.WindowTitle": "STPViewer — STP/STEP 3D Viewer",
"Ui.Main.ZoomFit": "🔍 Zoom to Fit",
"Ui.Main.ZoomThisNode": "Zoom to this node",
"Ui.StlExport.Cancel": "Cancel",
"Ui.StlExport.CustomScaleTooltip": "Multiply coordinates by this factor (for example, 0.001 converts mm to m)",
"Ui.StlExport.EstimatedSize": "Estimated {0}",
"Ui.StlExport.EstimatedSizeFine": "Current mesh estimate {0} (actual size depends on re-triangulation)",
"Ui.StlExport.Export": "Export…",
"Ui.StlExport.Format": "Format",
"Ui.StlExport.FormatAscii": "ASCII (readable text; about 4× larger)",
"Ui.StlExport.FormatBinary": "Binary (recommended; smaller file)",
"Ui.StlExport.MeshQuality": "Mesh quality",
"Ui.StlExport.QualityCurrent": "Current display mesh (fast; matches the view)",
"Ui.StlExport.QualityFine": "Fine — re-triangulate for smoother surfaces (slower; larger file)",
"Ui.StlExport.QualityTooltip": "Fine quality re-triangulates STEP B-rep geometry; STL/DXF keeps the current mesh. Large assemblies take time",
"Ui.StlExport.Scope": "Export scope",
"Ui.StlExport.ScopeMerge": "All visible models → one STL",
"Ui.StlExport.ScopePerFile": "Each visible model → separate STL",
"Ui.StlExport.ScopeTooltip": "STL has no assembly structure; merge puts all visible parts in one file",
"Ui.StlExport.Summary": "{0} visible models · {1} triangles · {2}",
"Ui.StlExport.Title": "Export STL — Settings",
"Ui.StlExport.UnitCustom": "Custom scale factor…",
"Ui.StlExport.UnitInch": "inch (coordinates ÷ 25.4)",
"Ui.StlExport.UnitMillimeter": "mm (1:1; default)",
"Ui.StlExport.UnitScale": "Unit scale",
"Ui.StlExport.UnitTooltip": "STL files have no units; internal geometry is in mm, and inches divide coordinates by 25.4"
}
}
+417
View File
@@ -0,0 +1,417 @@
{
"key": "简体中文",
"data": {
"App.ErrorTitle": "STPViewer 错误",
"App.NoRecentFiles": "(没有最近打开的文件)",
"App.UnexpectedError": "发生意外错误,操作已中止(程序将尝试继续运行)。\n\n{0}\n\n详细记录:{1}",
"App.WindowTitle": "STPViewer V{0} — STP/STEP 3D 查看器",
"QuickBar.Align": "🎯 两点对齐",
"QuickBar.Align3": "🎯 三点对齐",
"QuickBar.Angle": "∠ 角度",
"QuickBar.Circle": "⭕ 圆",
"QuickBar.Clear": "🧹 清除测量",
"QuickBar.Distance": "📏 距离",
"QuickBar.Drag": "🖐 拖动",
"QuickBar.Edge": "📐 边",
"QuickBar.ExportCsv": "💾 导出 CSV",
"QuickBar.ExportStep": "📤 导出 STEP",
"QuickBar.ExportStl": "📐 导出 STL",
"QuickBar.Face": "⬛ 面",
"QuickBar.FaceDistance": "⇔ 面距",
"QuickBar.Gizmo": "⊹ 操作器",
"QuickBar.Import": "📂 导入",
"QuickBar.Interference": "🧩 干涉",
"QuickBar.Language": "语言",
"QuickBar.Ortho": "正交投影",
"QuickBar.Point": "📍 点",
"QuickBar.Recent": "▾ 最近文件",
"QuickBar.RotateX": "旋转 ↻X",
"QuickBar.RotateY": "旋转 ↻Y",
"QuickBar.RotateZ": "旋转 ↻Z",
"QuickBar.Screenshot": "📷 截图",
"QuickBar.Section": "✂ 剖面",
"QuickBar.Unit": "mm ⇄ in",
"QuickBar.ViewFront": "视图:前",
"QuickBar.ViewIso": "视图:等轴测",
"QuickBar.ViewRight": "视图:右",
"QuickBar.ViewTop": "视图:上",
"QuickBar.ZoomAll": "🔍 显示全部",
"Runtime.Align3Completed": "已三点对齐“{0}”(旋转+平移;点 1 精确贴合,测量已清空)",
"Runtime.Align3Failed": "三点对齐失败(点位退化),请重新选点",
"Runtime.Align3OtherFile": "目标点必须位于另一个文件上",
"Runtime.Align3SameSource": "3 个来源点必须位于同一个文件(要移动:{0})",
"Runtime.Align3SourceCollinear": "三点共线,请重新选择不共线的第 3 点",
"Runtime.Align3SourceComplete": "已选完 3 个来源点 — 请在目标文件上选择对应点 1/3",
"Runtime.Align3SourcePoint": "来源点 {0}/3 — 继续选择“{1}”上的点",
"Runtime.Align3TargetCollinear": "目标三点共线,请重新选择不共线的第 3 点",
"Runtime.Align3TargetPoint": "目标点 {0}/3 — 继续选择目标文件上的对应点",
"Runtime.AlignMoved": "已移动“{0}” Δ({1:F3}, {2:F3}, {3:F3}) mm(测量已清空;可用面距或干涉验证配合)",
"Runtime.AlignMoving": "将移动“{0}” — 请在另一个文件上选择目标点",
"Runtime.AlignOtherFile": "目标点必须位于另一个文件上",
"Runtime.AllVisible": "已全部显示",
"Runtime.AngleDetail": "夹角 = {0:F2}°(补角 {1:F2}°)\n方向 1:{2} ({3:F3}, {4:F3}, {5:F3})\n方向 2:{6} ({7:F3}, {8:F3}, {9:F3})\n(两面夹角按法向量计算,互补角请参考补角值)",
"Runtime.AngleFirstDirection": "已选择第 1 个方向({0})— 选择第 2 个面或边",
"Runtime.ArcLength": "弧长",
"Runtime.Axis": "{0} 轴",
"Runtime.CircleDetail": "直径 = {0}\n半径 = {1}\n{2} = {3}\n圆心 {4}",
"Runtime.Circumference": "周长",
"Runtime.ComponentName": "组件",
"Runtime.ConicalSurface": "圆锥面",
"Runtime.CsvEmpty": "没有可导出的测量结果",
"Runtime.CsvExported": "已导出 {0} 条测量结果 → {1}",
"Runtime.CsvFilter": "CSV 文件 (*.csv)|*.csv",
"Runtime.CsvHeader": "标签,类型,明细",
"Runtime.CsvTitle": "导出测量结果",
"Runtime.CurveDetail": "曲线长度 = {0}\n类型 = {1}",
"Runtime.CurveName": "曲线",
"Runtime.CustomPlane": "自定义平面",
"Runtime.CylinderSurface": "类型 = 圆柱面\n半径 = {0}\n轴向 ({1:F3}, {2:F3}, {3:F3})",
"Runtime.DirectionEdge": "边方向",
"Runtime.DirectionFace": "面法向量",
"Runtime.DirectionMeshFace": "面法向量(网格)",
"Runtime.DistanceDetail": "距离 = {0}\nΔX = {1}\nΔY = {2}\nΔZ = {3}\nP1 {4}\nP2 {5}",
"Runtime.DistanceFirstPoint": "第 1 点 ({0:F2}, {1:F2}, {2:F2}) — 选择第 2 点",
"Runtime.Dragged": "已拖动“{0}” Δ({1:F2}, {2:F2}, {3:F2}) mm(可用干涉检查验证)",
"Runtime.Dragging": "正在拖动“{0}”…(松开定位)",
"Runtime.DxfParseFailed": "无法解析 DXF 文件",
"Runtime.ExtrusionSurface": "拉伸曲面",
"Runtime.FaceDetail": "面积 ≈ {0}(网格近似)\n{1}\n三角形数 = {2:N0}",
"Runtime.FaceDistanceDetail": "最短距离 ≈ {0}(网格近似)\nΔX = {1}\nΔY = {2}\nΔZ = {3}\n点 1 {4}\n点 2 {5}",
"Runtime.FaceDistanceFirstFace": "已选择第 1 个面 — 请选择另一个面",
"Runtime.FaceName": "面",
"Runtime.FileNotFound": "找不到文件",
"Runtime.GapLabel": "间隙 {0}",
"Runtime.GeometryProcessed": "几何处理 {0:N0} ms(三角化 {1:N0} ms、边采样 {2:N0} ms{3})",
"Runtime.GizmoApplied": "已对“{0}”应用操作器变换(可用干涉检查验证)",
"Runtime.GizmoHint": "操作器:拖动箭头沿轴移动“{0}”,拖动圆环绕轴旋转;松开即可定位",
"Runtime.GizmoSelectFile": "操作器:请先在树面板选择要操作的文件",
"Runtime.ImportBusy": "正在导入或计算,请稍后再导入",
"Runtime.ImportCompleted": "导入完成:成功 {0} 个文件{1}",
"Runtime.ImportFailed": "导入失败:{0} — {1}",
"Runtime.ImportFailedCount": "、失败 {0} 个文件",
"Runtime.ImportFailedTitle": "导入失败",
"Runtime.ImportFilter": "CAD 文件 (*.stp;*.step;*.stl;*.dxf)|*.stp;*.step;*.stl;*.dxf|STEP (*.stp;*.step)|*.stp;*.step|STL (*.stl)|*.stl|DXF (*.dxf)|*.dxf|所有文件 (*.*)|*.*",
"Runtime.ImportProgress": "导入 {0}:{1}",
"Runtime.ImportTitle": "导入 CAD 文件(可多选)",
"Runtime.Importing": "正在导入:{0} …",
"Runtime.InterferenceChecking": "正在检查干涉(第 {0} 组):{1} ⟷ {2} …",
"Runtime.InterferenceClearDetail": "{0} ⟷ {1} 无相交\n最小间隙 ≈ {2}(网格近似)\n间隙 ≈ 0 表示贴合\n点 1 {3}\n点 2 {4}",
"Runtime.InterferenceClearTitle": "🧩 无干涉 间隙 ≈ {0}",
"Runtime.InterferenceCompleted": "干涉检查完成:{0} 组配对、{1} 组相交",
"Runtime.InterferenceFailed": "干涉检查失败:{0}",
"Runtime.InterferenceHitDetail": "零件相交(非贴合)\n相交三角形对 = {0:N0}\n红色线 = 干涉交线({1:N0} 段)\n(共面贴合不计为干涉)",
"Runtime.InterferenceHitTitle": "🧩 发生干涉!{0} ⟷ {1}",
"Runtime.InterferenceLabel": "干涉",
"Runtime.InterferenceNeedTwo": "干涉检查至少需要 2 个可见文件(当前 {0} 个),请在树面板勾选",
"Runtime.Isolated": "只显示“{0}”(树右键 → 全部显示 可恢复)",
"Runtime.KindAlign": "对齐",
"Runtime.KindAlign3": "三点对齐",
"Runtime.KindAngle": "角度",
"Runtime.KindCircle": "圆",
"Runtime.KindDistance": "距离",
"Runtime.KindDrag": "拖动",
"Runtime.KindEdge": "边",
"Runtime.KindFace": "面",
"Runtime.KindFaceDistance": "面距",
"Runtime.KindInterference": "干涉",
"Runtime.KindNone": "无",
"Runtime.KindPoint": "点",
"Runtime.KindVolume": "体积",
"Runtime.LineDetail": "长度 = {0}\nΔX = {1} ΔY = {2} ΔZ = {3}\n起点 {4}\n终点 {5}",
"Runtime.MeasureFailed": "测量失败:{0}",
"Runtime.MeasurementStartCancelled": "已取消当前测量起点(再次按 Esc 退出测量模式)",
"Runtime.MeasurementsCleared": "已清除全部测量",
"Runtime.MeshMeasurementLimited": "此模型无 B-rep(STL/网格),仅支持点、距离、角度和面距测量",
"Runtime.MeshName": "网格",
"Runtime.MeshNameNumbered": "网格 {0}",
"Runtime.MeshSurface": "类型 = 网格(无 B-rep)",
"Runtime.ModeAlign": "两点对齐:先点要移动零件上的点,再点目标点(仅平移,测量将清空)",
"Runtime.ModeAlign3": "三点对齐:先在要移动的文件中选 3 个特征点,再在目标文件中选 3 个对应点(旋转+平移)",
"Runtime.ModeAngle": "角度测量:选择第 1 个面(或直线边附近)",
"Runtime.ModeBrowse": "浏览模式(右键旋转、滚轮缩放、中键或 Shift+左键拖动平移)",
"Runtime.ModeCircle": "圆测量:点击圆孔内壁或圆边附近",
"Runtime.ModeDistance": "距离测量:选择第 1 点",
"Runtime.ModeDrag": "拖动模式:按住左键沿屏幕平面移动零件,松开定位;需改变深度时请先旋转视角",
"Runtime.ModeEdge": "边测量:点击要测量的边附近",
"Runtime.ModeFace": "面测量:点击要测量的面",
"Runtime.ModeFaceDistance": "面距测量:选择第 1 个面",
"Runtime.ModePoint": "点测量:点击模型表面(自动吸附邻近顶点)",
"Runtime.NoBrep": " · 无 B-rep",
"Runtime.NoCircularEdge": "此面没有圆形边(请点击圆孔内壁或圆边附近)",
"Runtime.NoGeometry": "文件中没有可显示的几何",
"Runtime.NoMeasurableEdge": "此面没有可测量的边",
"Runtime.NoSurfaceHit": "未命中模型表面",
"Runtime.NodeStats": "{0} 实体 · {1} 面 · {2:N0} △",
"Runtime.NurbsSurface": "NURBS 曲面",
"Runtime.Orthographic": "正交投影(平行投影,适合尺寸确认)",
"Runtime.OutlineSize": "\n外形 {0}",
"Runtime.ParsingStep": "解析 STEP(大文件可能需要数分钟)…",
"Runtime.ParsingStl": "解析 STL(三角形直读)…",
"Runtime.Perspective": "透视投影",
"Runtime.PlaneSurface": "类型 = 平面\n法向量 ({0:F3}, {1:F3}, {2:F3})",
"Runtime.Ready": "就绪 — 导入 STP/STEP/STL/DXF(也可拖放)。右键旋转、滚轮缩放、中键或 Shift+左键平移",
"Runtime.Removed": "已移除:{0}",
"Runtime.RetriedFaces": "、重试补回 {0} 面",
"Runtime.Reversed": "(反向)",
"Runtime.RevolutionSurface": "旋转曲面",
"Runtime.RootNotFound": "找不到此零件所属文件",
"Runtime.RotateSelectFile": "请先在树面板选择要旋转的文件",
"Runtime.Rotated": "已将“{0}”绕 {1} 轴旋转 +90°(再次点击可继续旋转)",
"Runtime.SameFace": "两次选中了同一个面,请选择另一个面",
"Runtime.ScreenshotFilter": "PNG 图片 (*.png)|*.png",
"Runtime.ScreenshotSaved": "已保存截图 → {0}",
"Runtime.ScreenshotTitle": "保存视图截图",
"Runtime.SectionCancelled": "已取消三点剖面定义",
"Runtime.SectionClosed": "剖面已关闭",
"Runtime.SectionCollinear": "三点剖面:三点共线,请选择不共线的第 3 点",
"Runtime.SectionDefined": "三点剖面:平面已定义(法向 {0:F2}, {1:F2}, {2:F2})— 用位置滑块扫描剖面",
"Runtime.SectionDegenerate": "三点剖面:三点退化(共线),请重新选择",
"Runtime.SectionNoHit": "三点剖面:未命中模型表面(已选 {0}/3 点,Esc 取消)",
"Runtime.SectionPickPrompt": "三点剖面:请在模型上选择 3 个点定义剖切平面(Esc 取消)",
"Runtime.SectionPicked": "三点剖面:已选 {0}/3 点",
"Runtime.SectionStatus": "剖面:{0} {1:F0}%{2}",
"Runtime.ShellName": "壳体",
"Runtime.SolidName": "实体",
"Runtime.SphericalSurface": "球面",
"Runtime.StepExportEmpty": "没有可导出的 B-rep 几何(STEP 仅包含 Solid/Shell;请勾选要导出的 STEP 文件)",
"Runtime.StepExportFailed": "STEP 导出失败:{0}",
"Runtime.StepExportFilter": "STEP 文件 (*.stp)|*.stp|STEP 文件 (*.step)|*.step",
"Runtime.StepExportTitle": "将当前场景导出为 STEP(零件处于当前对齐位置)",
"Runtime.StepExported": "已导出 STEP({0} 个实体,当前位置)→ {1}",
"Runtime.StepParseFailed": "CADability 无法解析此 STEP 文件",
"Runtime.StepParsed": "STEP 解析 {0:N0} ms",
"Runtime.StlExportEmpty": "没有可导出的网格(请勾选至少一个含实体网格的文件)",
"Runtime.StlExportFailed": "STL 导出失败:{0}",
"Runtime.StlExportFilter": "STL 文件 (*.stl)|*.stl",
"Runtime.StlExportMergedTitle": "导出 STL(合并为单个文件)",
"Runtime.StlExportPerFileTitle": "导出 STL(每个文件分别导出,以此作为基础文件名)",
"Runtime.StlExportedMany": "已导出 {0} 个 STL(共 {1:N0} 个三角形)→ {2}",
"Runtime.StlExportedOne": "已导出 STL({0:N0} 个三角形)→ {1}",
"Runtime.StlExporting": "正在导出 STL…",
"Runtime.StlNoTriangles": "STL 文件中没有三角形",
"Runtime.StlParsed": "STL 解析 {0:N0} ms({1} 个网格)",
"Runtime.StlRetriangulating": "正在重新三角化并导出 STL…(大型装配需要时间)",
"Runtime.SurfaceType": "类型 = {0}",
"Runtime.ToroidalSurface": "环面",
"Runtime.Triangulating": "并行三角化 {0} 个零件…",
"Runtime.UnitInch": "显示单位:inch",
"Runtime.UnitMillimeter": "显示单位:mm",
"Runtime.UnsupportedFormat": "不支持的格式(支持 .stp / .step / .stl / .dxf;CADability 无 IGES reader)",
"Runtime.View": "视图:{0}",
"Runtime.ViewBack": "后",
"Runtime.ViewBottom": "下",
"Runtime.ViewFront": "前",
"Runtime.ViewIso": "等角",
"Runtime.ViewLeft": "左",
"Runtime.ViewRight": "右",
"Runtime.ViewTop": "上",
"Runtime.VisibilityInverted": "已反转显示",
"Runtime.VolumeDetail": "体积 ≈ {0}(网格近似)\n质心 {1}\n外形(AABB){2} × {3} × {4} {5}",
"Runtime.VolumeNoMesh": "“{0}”没有可计算的面网格(线框无体积)",
"Runtime.VolumeOpenMesh": "“{0}”不是封闭网格,无法可靠计算体积",
"Ui.About.Changelog": "📋 版本纪录",
"Ui.About.Close": "关闭",
"Ui.About.Description": "STP / STEP 3D 查看器。免开重量级 CAD,快速打开文件、测量、对齐插合、干涉检查、剖面、导出。",
"Ui.About.Developer": "👤 开发者",
"Ui.About.Email": "邮箱",
"Ui.About.Name": "姓名",
"Ui.About.Release010.Change1": "• STEP/STL/DXF 导入与装配树、点/距离/边/面/圆/角度/面距测量、两点与三点对齐、干涉检查、剖面、mm ⇄ inch、CSV 与截图导出。",
"Ui.About.Release010.Title": "首次发布",
"Ui.About.Release020.Change1": "• 添加拖曳模式:左键按住零件直接拖到想要的位置,放开就定位。",
"Ui.About.Release020.Change2": "• 0.2.1 修正拖曳过的零件再点击会当掉的问题。",
"Ui.About.Release020.Title": "拖曳模式",
"Ui.About.Release030.Change1": "• 添加操作器:选取文件后出现 XYZ 三色箭头与旋转环,箭头沿轴精确移动、旋转环绕轴转任意角度。",
"Ui.About.Release030.Title": "三轴操作器",
"Ui.About.Release031.Change1": "• 三轴操作器改成永远浮在画面最上层,不会被零件挡住,任何角度都抓得到。",
"Ui.About.Release031.Title": "操作器永远可见",
"Ui.About.Release032.Change1": "• 修正大型组件(数万个面)进入测量模式后,转动视角严重卡顿的问题 — 现在测量跟浏览一样顺。",
"Ui.About.Release032.Title": "大组件测量不再卡",
"Ui.About.Release040.Change1": "• 大文件拉剖面滑杆不再卡住画面(改到背景计算)。",
"Ui.About.Release040.Change2": "• 拖曳/操作器放开零件后的定位计算大幅加速。",
"Ui.About.Release040.Change3": "• 程序遇到错误会提示消息并继续运作,不再整个闪退。",
"Ui.About.Release040.Title": "更快、更稳",
"Ui.About.Release050.Change1": "• 量距离时点到圆孔边缘会自动吸到圆心 — 量两孔中心距(pitch)超方便。",
"Ui.About.Release050.Change2": "• 测量快速键:P 点、D 距离、E 边、F 面、C 圆、A 角度、M 面距;Esc 取消。",
"Ui.About.Release050.Change3": "• 树状面板可搜索零件名称;右键可「只显示此节点」隔离观察,也可以量体积与质心。",
"Ui.About.Release050.Change4": "• 添加标准视图(等角/前/上/右)与正交投影,量尺寸不受透视变形。",
"Ui.About.Release050.Change5": "• 剖面添加「3点」模式:在模型上点 3 个点,就能沿任意斜面剖开。",
"Ui.About.Release050.Change6": "• 添加导出 STEP:把对齐好的零件位置存成新 STEP 文件,交接给正式 CAD。",
"Ui.About.Release050.Change7": "• 会记住窗口大小、单位设置与最近打开的 10 个文件。",
"Ui.About.Release050.Title": "测量、视图、树状面板大升级",
"Ui.About.Release060.Change1": "• 添加导出 STL:把画面上的零件(含对齐、旋转后的位置)存成 STL 网格文件,可拿去 3D 打印或给其他软件用。",
"Ui.About.Release060.Change2": "• 导出前会先跳出设置窗口:全部合并成一个文件或每个文件各存一个、Binary 或文本格式、mm 或 inch、网格精细度 — 确认后才导出。",
"Ui.About.Release060.Change3": "• 设置窗口会即时显示三角形数量与预估文件大小。",
"Ui.About.Release060.Title": "导出 STL",
"Ui.About.Release070.Change1": "• 上方添加菜单栏,全部功能依「文件/视图/测量/变换/工具」分类,不用再在一长排按钮里找。",
"Ui.About.Release070.Change2": "• 原本的工具栏变成「快捷栏」:只放常用按钮。想放哪些自己决定 — 到「快捷栏 → 自定义按钮」勾选,下次打开会记住。",
"Ui.About.Release070.Change3": "• 剖面的轴向/位置控制列改成只在打开剖面时出现,平常不占空间。",
"Ui.About.Release070.Change4": "• 添加「关于」窗口(就是这个画面),可以看版本纪录。",
"Ui.About.Release070.Title": "界面大改版 — 菜单栏+可自定义快捷栏",
"Ui.About.Release071.Change1": "• 打开大型 STL 网格文件(数十 MB、上百万三角形)现在几乎瞬间完成,不用再等好几分钟。",
"Ui.About.Release071.Change2": "• 例如一个 75 MB、150 万个三角形的 STL,以前要等到天荒地老,现在不到 1 秒就显示出来。",
"Ui.About.Release071.Title": "大型 STL 秒开",
"Ui.About.Technology": ".NET 8 · WPF · C#;CADability(STEP / B-rep 几何内核);HelixToolkit(3D 显示与相机)",
"Ui.About.TechnologyTitle": "🔧 技术栈",
"Ui.About.Title": "关于 STPViewer",
"Ui.Language": "语言",
"Ui.Main.3AxisGizmo": "⊹ 三轴操作器",
"Ui.Main.3AxisGizmoUseXYZArrows": "三轴操作器:对树面板选取的文件显示 XYZ 箭头(沿轴移动)与旋转环(绕轴转任意角度),每次放开即定位",
"Ui.Main.3Point": "🎯 三点",
"Ui.Main.3Points": "3点",
"Ui.Main.Align": "🎯 对齐",
"Ui.Main.Angle": "∠ 角度",
"Ui.Main.AngleBetweenFacesStraightEdgesShortcut": "两面/两边夹角(点两个面或靠近直线边)— 快速键 A",
"Ui.Main.AngleBetweenTwoFacesStraightEdges": "两面/两边夹角(点两个面或靠近直线边)",
"Ui.Main.ApplicationInformationReleaseHistory": "程序信息与版本纪录",
"Ui.Main.AreaSurfaceTypeNormalVector": "面积 + 曲面类型/法矢量",
"Ui.Main.AreaSurfaceTypeNormalVectorShortcut": "面积 + 曲面类型/法矢量 — 快速键 F",
"Ui.Main.Capture": "📷 截图",
"Ui.Main.CenterRadiusDiameterCircumference": "圆心/半径/直径/周长",
"Ui.Main.CenterRadiusDiameterCircumferenceShortcutC": "圆心/半径/直径/周长 — 快速键 C",
"Ui.Main.CheckButtonsShowQuickBarSaved": "勾选要显示在快捷栏的常用按钮(随设置保存)",
"Ui.Main.CheckVisibleModelPairsIntersectionsReport": "2 个以上可见文件两两检查是否相交;无相交时回报最小间隙",
"Ui.Main.ChooseScopeFormatUnitsMeshQuality": "先设置范围/格式/单位/精度,确认再导出",
"Ui.Main.Circle": "⭕ 圆",
"Ui.Main.Clear": "🧹 清除",
"Ui.Main.ClearAllMeasurements": "🧹 清除全部测量",
"Ui.Main.ClearAllMeasurementsMarkersEscCancels": "清除全部测量与标记(Esc = 取消进行中测量/退出模式)",
"Ui.Main.ClickChangeColorIncludingChildNodes": "点击换色(含子节点)",
"Ui.Main.Close": "✕ 关闭",
"Ui.Main.CloseSectionView": "关闭剖面查看",
"Ui.Main.CustomizeButtons": "自定义按钮",
"Ui.Main.DeleteThisMeasurement": "删除此笔测量",
"Ui.Main.DisplayInchesMm": "单位显示 inch(mm ⇄ in)",
"Ui.Main.Distance": "📏 距离",
"Ui.Main.DistanceXYZCircleCenterSnappingHole": "两点距离 + ΔXYZ(点到圆边会吸圆心 → 可量孔对孔 pitch)— 快速键 D",
"Ui.Main.DistanceXYZCircularEdgesSnapCenters": "两点距离 + ΔXYZ(点到圆边会吸圆心 → 可量孔对孔 pitch)",
"Ui.Main.Drag": "🖐 拖曳",
"Ui.Main.DragMode": "🖐 拖曳模式",
"Ui.Main.DragModeHoldLeftMouseButton": "拖曳模式:左键按住零件沿屏幕平面拖动,放开定位(深度方向先转视角再拖)",
"Ui.Main.DragResizePanel": "拖曳调整面板宽度",
"Ui.Main.Edge": "📐 边",
"Ui.Main.EdgeLength": "📐 边长",
"Ui.Main.Edges": "边",
"Ui.Main.EscCancelsCurrentMeasurementExitsMode": "Esc = 取消进行中测量/退出模式",
"Ui.Main.ExistingMeasurementsUpdateImmediatelyValuesStored": "既有测量即时换算;内部一律存 mm",
"Ui.Main.ExportMeasurementResultsCSV": "测量结果导出 CSV",
"Ui.Main.ExportMeasurementsCSV": "💾 导出测量 CSV…",
"Ui.Main.ExportSTEP": "📤 导出 STEP…",
"Ui.Main.ExportSTL": "📐 导出 STL…",
"Ui.Main.ExportStepTooltip": "把可见文件(当前对齐位置)导出成新 STEP 文件;不动源文件",
"Ui.Main.Face": "⬛ 面",
"Ui.Main.FaceDistance": "⇔ 面距",
"Ui.Main.FileF": "文件(_F)",
"Ui.Main.FilterAssemblyTreeByNameDoes": "输入名称过滤装配树(只影响树面板显示,不影响 3D 可见性;清空 = 全显)",
"Ui.Main.Fit": "🔍 全览",
"Ui.Main.FitAllModelsView": "缩放至全部模型",
"Ui.Main.Flip": "反向",
"Ui.Main.Front": "前",
"Ui.Main.FrontView": "前视图",
"Ui.Main.FrontViewLookAlongYZ": "前视图(-Y 方向看,Z 向上)",
"Ui.Main.Gizmo": "⊹ 操作器",
"Ui.Main.HelpH": "说明(_H)",
"Ui.Main.HoldLeftMouseButtonMovePart": "左键按住零件沿屏幕平面拖动,放开定位",
"Ui.Main.IAboutSTPViewer": "ℹ 关于 STPViewer…",
"Ui.Main.ImportSTPSTEPSTLDXFFiles": "导入 STP/STEP/STL/DXF(可多选,也可直接拖放到窗口)",
"Ui.Main.Interference": "🧩 干涉",
"Ui.Main.InterferenceCheck": "🧩 干涉检查",
"Ui.Main.InterferenceCheckTestVisibleModelPairs": "干涉检查:2 个以上可见文件两两检查是否相交(match 判定);无相交时回报最小间隙",
"Ui.Main.InvertVisibility": "🔄 反转显示",
"Ui.Main.Iso": "等角",
"Ui.Main.IsometricView": "等角视图",
"Ui.Main.LookAlongXZUp": "+X 方向看,Z 向上",
"Ui.Main.LookAlongYZUp": "-Y 方向看,Z 向上",
"Ui.Main.LookDownFromZ": "+Z 往下看",
"Ui.Main.MeasureM": "测量(_M)",
"Ui.Main.MeasurePointCoordinatesSnapsVerticesCircle": "测量点坐标(吸附顶点/圆心)",
"Ui.Main.MeasureStraightCurvedCircularEdgeLengths": "边长(直线/曲线/圆弧)",
"Ui.Main.MeasureVolumeCentroid": "⚖ 量体积 / 质心",
"Ui.Main.Measurements": "测量结果",
"Ui.Main.MenuImport": "📂 导入…",
"Ui.Main.ModelsAssemblyTree": "模型 / 装配树",
"Ui.Main.Ortho": "正交",
"Ui.Main.OrthographicProjection": "正交投影",
"Ui.Main.OrthographicProjectionAvoidsPerspectiveDistortionClick": "正交投影(平行投影,量尺寸不受透视变形;再按切回透视)",
"Ui.Main.ParallelProjectionAvoidsPerspectiveDistortionUncheck": "平行投影,量尺寸不受透视变形;取消勾选切回透视",
"Ui.Main.PickPointPartMoveThenPick": "先点要移动零件上的点,再点目标点",
"Ui.Main.PickThreePointsMovingModelThen": "先在要移动的文件点 3 个特征点,再到目标文件点 3 个对应点(顺序对应)",
"Ui.Main.Point": "📍 点",
"Ui.Main.PointCoordinates": "📍 点坐标",
"Ui.Main.PointCoordinatesVertexCircleCenterSnapping": "测量点坐标(吸附顶点/圆心)— 快速键 P",
"Ui.Main.QuickBarQ": "快捷栏(_Q)",
"Ui.Main.RecentFiles": "最近文件",
"Ui.Main.RecentlyOpenedFiles": "最近打开的文件",
"Ui.Main.RemoveThisFile": "移除此文件",
"Ui.Main.RestoreDefaultButtons": "恢复缺省按钮",
"Ui.Main.Right": "右",
"Ui.Main.RightView": "右视图",
"Ui.Main.RightViewLookAlongXZ": "右视图(+X 方向看,Z 向上)",
"Ui.Main.Rotate90AroundX": "↻ 绕 X 轴 +90°",
"Ui.Main.Rotate90AroundY": "↻ 绕 Y 轴 +90°",
"Ui.Main.Rotate90AroundZ": "↻ 绕 Z 轴 +90°",
"Ui.Main.RotateModelSelectedTree90Around": "选取的文件绕 X 轴 +90°(先在树面板点击文件)",
"Ui.Main.RotateModelSelectedTreeAroundIts": "对树面板选取的文件,绕其中心旋转(先在树面板点击文件)",
"Ui.Main.RotateSelectedModel90AroundY": "选取的文件绕 Y 轴 +90°",
"Ui.Main.RotateSelectedModel90AroundZ": "选取的文件绕 Z 轴 +90°",
"Ui.Main.SavePNGScreenshot3DView2": "3D 视图截图存 PNG(2x 分辨率)",
"Ui.Main.SaveViewPNG": "📷 视图截图 PNG…",
"Ui.Main.SectionAxis3PointsDefineAn": "剖切轴向(3点 = 在模型上点 3 点定义任意平面)",
"Ui.Main.SectionLabel": "✂ 剖面:",
"Ui.Main.SectionPosition0100EnterValue": "剖面位置 %(0–100,可直接输入,离开字段生效)",
"Ui.Main.SectionPositionAlongNormal": "剖面位置(沿法向 %)",
"Ui.Main.SectionView": "✂ 剖面查看",
"Ui.Main.ShortestDistanceBetweenFacesMeshApproximation": "面到面最短距离(网格近似)",
"Ui.Main.ShortestFaceDistanceMeshApproximationShortcut": "面到面最短距离(网格近似)— 快速键 M",
"Ui.Main.ShowAll": "☀ 全部显示",
"Ui.Main.ShowHideIncludingChildNodes": "显示 / 隐藏(含子节点)",
"Ui.Main.ShowOnlyThisNode": "👁 只显示此节点",
"Ui.Main.ShowOutlineEdgesManyEdgesCan": "显示轮廓边线(边线量大时转动视角会变慢)",
"Ui.Main.ShowSectionControlsAxisPositionDirection": "打开后下方出现剖面参数表(轴向/位置/反向)",
"Ui.Main.ShowXYZArrowsRotationRingsSelected": "对树面板选取的文件显示 XYZ 箭头与旋转环,每次放开即定位",
"Ui.Main.StraightCurvedCircularEdgeLengthShortcut": "边长(直线/曲线/圆弧)— 快速键 E",
"Ui.Main.SwitchDisplayUnitsBetweenMmInches": "切换显示单位 mm ⇄ inch(既有测量即时换算)",
"Ui.Main.SwitchRetainedSide": "切换保留侧",
"Ui.Main.ThreePointAlignmentPickThreePoints": "三点对齐(旋转+平移):先在要移动的文件点 3 个特征点,再到目标文件点 3 个对应点(顺序对应)",
"Ui.Main.ThreePointAlignmentRotateTranslate": "🎯 三点对齐(旋转+平移)",
"Ui.Main.ToggleSectionViewItsControls": "打开/关闭剖面查看(打开后下方出现参数表)",
"Ui.Main.ToolbarExportStepTooltip": "把可见文件(当前对齐位置)导出成新 STEP 文件 — 对齐插合结果可交接 CAD;不动源文件",
"Ui.Main.ToolbarExportStlTooltip": "把可见文件(当前位置)导出成 STL 网格文件 — 先设置范围/格式/单位/精度,确认再导出",
"Ui.Main.ToolbarImport": "📂 导入",
"Ui.Main.ToolbarSection": "✂ 剖面",
"Ui.Main.ToolsL": "工具(_L)",
"Ui.Main.Top": "上",
"Ui.Main.TopView": "上视图",
"Ui.Main.TopViewLookDownFromZ": "上视图(+Z 往下看)",
"Ui.Main.TransformT": "变换(_T)",
"Ui.Main.TwoPointAlignmentPickPointMoving": "两点对齐(平移零件):先点要移动零件上的点,再点目标点",
"Ui.Main.TwoPointAlignmentTranslate": "🎯 两点对齐(平移)",
"Ui.Main.TwoPointDistance": "📏 两点距离",
"Ui.Main.ViewV": "视图(_V)",
"Ui.Main.WindowTitle": "STPViewer — STP/STEP 3D 查看器",
"Ui.Main.ZoomFit": "🔍 缩放至全部",
"Ui.Main.ZoomThisNode": "缩放至此节点",
"Ui.StlExport.Cancel": "取消",
"Ui.StlExport.CustomScaleTooltip": "坐标乘上此系数(例:0.001 = mm→m)",
"Ui.StlExport.EstimatedSize": "预估 {0}",
"Ui.StlExport.EstimatedSizeFine": "以当前网格预估 {0}(实际依重新三角化结果)",
"Ui.StlExport.Export": "导出…",
"Ui.StlExport.Format": "格式",
"Ui.StlExport.FormatAscii": "ASCII(文本可读,文件约 4 倍大)",
"Ui.StlExport.FormatBinary": "Binary(建议,文件小)",
"Ui.StlExport.MeshQuality": "网格精度",
"Ui.StlExport.QualityCurrent": "当前显示网格(快,与画面一致)",
"Ui.StlExport.QualityFine": "精细 — 重新三角化,曲面更平滑(慢、文件大)",
"Ui.StlExport.QualityTooltip": "精细会由 B-rep 重新三角化(仅 STEP 来源;STL/DXF 维持现有网格),大组件需要时间",
"Ui.StlExport.Scope": "导出范围",
"Ui.StlExport.ScopeMerge": "全部可见文件 → 合并成单一 STL",
"Ui.StlExport.ScopePerFile": "每个可见文件 → 各一个 STL",
"Ui.StlExport.ScopeTooltip": "STL 无装配结构;合并 = 所有可见零件进同一个文件",
"Ui.StlExport.Summary": "{0} 个可见文件 · {1} 三角形 · {2}",
"Ui.StlExport.Title": "导出 STL — 参数设置",
"Ui.StlExport.UnitCustom": "自定义缩放系数…",
"Ui.StlExport.UnitInch": "inch(坐标 ÷ 25.4)",
"Ui.StlExport.UnitMillimeter": "mm(1:1,缺省)",
"Ui.StlExport.UnitScale": "单位缩放",
"Ui.StlExport.UnitTooltip": "STL 文件本身无单位;内部几何为 mm,选 inch 会把坐标 ÷25.4"
}
}
+417
View File
@@ -0,0 +1,417 @@
{
"key": "繁體中文",
"data": {
"App.ErrorTitle": "STPViewer 錯誤",
"App.NoRecentFiles": "(沒有最近開啟的檔案)",
"App.UnexpectedError": "發生未預期的錯誤,操作已中止(程式將嘗試繼續運作)。\n\n{0}\n\n詳細記錄:{1}",
"App.WindowTitle": "STPViewer V{0} — STP/STEP 3D 檢視器",
"QuickBar.Align": "🎯 兩點對齊",
"QuickBar.Align3": "🎯 三點對齊",
"QuickBar.Angle": "∠ 角度",
"QuickBar.Circle": "⭕ 圓",
"QuickBar.Clear": "🧹 清除量測",
"QuickBar.Distance": "📏 距離",
"QuickBar.Drag": "🖐 拖曳",
"QuickBar.Edge": "📐 邊",
"QuickBar.ExportCsv": "💾 匯出 CSV",
"QuickBar.ExportStep": "📤 匯出 STEP",
"QuickBar.ExportStl": "📐 匯出 STL",
"QuickBar.Face": "⬛ 面",
"QuickBar.FaceDistance": "⇔ 面距",
"QuickBar.Gizmo": "⊹ 操作器",
"QuickBar.Import": "📂 匯入",
"QuickBar.Interference": "🧩 干涉",
"QuickBar.Language": "語言",
"QuickBar.Ortho": "正交投影",
"QuickBar.Point": "📍 點",
"QuickBar.Recent": "▾ 最近檔案",
"QuickBar.RotateX": "旋轉 ↻X",
"QuickBar.RotateY": "旋轉 ↻Y",
"QuickBar.RotateZ": "旋轉 ↻Z",
"QuickBar.Screenshot": "📷 截圖",
"QuickBar.Section": "✂ 剖面",
"QuickBar.Unit": "mm ⇄ in",
"QuickBar.ViewFront": "視圖:前",
"QuickBar.ViewIso": "視圖:等角",
"QuickBar.ViewRight": "視圖:右",
"QuickBar.ViewTop": "視圖:上",
"QuickBar.ZoomAll": "🔍 全覽",
"Runtime.Align3Completed": "已三點對齊「{0}」(旋轉+平移;點1精確貼合,量測已清空,可用 🧩干涉 驗證)",
"Runtime.Align3Failed": "三點對齊失敗(點位退化),請重新選點",
"Runtime.Align3OtherFile": "目標點要點在另一個檔案上",
"Runtime.Align3SameSource": "來源 3 點要同一個檔案(要移動:{0})",
"Runtime.Align3SourceCollinear": "三點共線,請改選不共線的第 3 點",
"Runtime.Align3SourceComplete": "來源 3 點完成 — 換點「目標檔案」上的對應點 1/3",
"Runtime.Align3SourcePoint": "來源點 {0}/3 — 繼續點「{1}」上的點",
"Runtime.Align3TargetCollinear": "目標三點共線,請改選不共線的第 3 點",
"Runtime.Align3TargetPoint": "目標點 {0}/3 — 繼續點目標檔案上的對應點",
"Runtime.AlignMoved": "已移動「{0}」 Δ({1:F3}, {2:F3}, {3:F3}) mm(量測已清空;可用 ⇔面距 / 🧩干涉 驗證配合)",
"Runtime.AlignMoving": "將移動「{0}」— 點選目標點(另一個檔案上)",
"Runtime.AlignOtherFile": "目標點要點在另一個檔案上(兩點同屬一個檔案無法對齊)",
"Runtime.AllVisible": "已全部顯示",
"Runtime.AngleDetail": "夾角 = {0:F2}°(補角 {1:F2}°)\n方向 1:{2} ({3:F3}, {4:F3}, {5:F3})\n方向 2:{6} ({7:F3}, {8:F3}, {9:F3})\n(兩面夾角以法向量計,互補角請參考補角值)",
"Runtime.AngleFirstDirection": "已選第 1 個方向({0})— 點選第 2 個面或邊",
"Runtime.ArcLength": "弧長",
"Runtime.Axis": "{0} 軸",
"Runtime.CircleDetail": "直徑 = {0}\n半徑 = {1}\n{2} = {3}\n圓心 {4}",
"Runtime.Circumference": "周長",
"Runtime.ComponentName": "組件",
"Runtime.ConicalSurface": "圓錐面",
"Runtime.CsvEmpty": "沒有量測結果可匯出",
"Runtime.CsvExported": "已匯出 {0} 筆量測 → {1}",
"Runtime.CsvFilter": "CSV 檔案 (*.csv)|*.csv",
"Runtime.CsvHeader": "標籤,類型,明細",
"Runtime.CsvTitle": "匯出量測結果",
"Runtime.CurveDetail": "曲線長 = {0}\n類型 = {1}",
"Runtime.CurveName": "曲線",
"Runtime.CustomPlane": "自訂平面",
"Runtime.CylinderSurface": "類型 = 圓柱面\n半徑 = {0}\n軸向 ({1:F3}, {2:F3}, {3:F3})",
"Runtime.DirectionEdge": "邊方向",
"Runtime.DirectionFace": "面法向量",
"Runtime.DirectionMeshFace": "面法向量(網格)",
"Runtime.DistanceDetail": "距離 = {0}\nΔX = {1}\nΔY = {2}\nΔZ = {3}\nP1 {4}\nP2 {5}",
"Runtime.DistanceFirstPoint": "第 1 點 ({0:F2}, {1:F2}, {2:F2}) — 點選第 2 點",
"Runtime.Dragged": "已拖曳「{0}」 Δ({1:F2}, {2:F2}, {3:F2}) mm(可用 🧩干涉 驗證)",
"Runtime.Dragging": "拖曳「{0}」中…(放開定位)",
"Runtime.DxfParseFailed": "無法解析 DXF 檔",
"Runtime.ExtrusionSurface": "拉伸曲面",
"Runtime.FaceDetail": "面積 ≈ {0}(網格近似)\n{1}\n三角形數 = {2:N0}",
"Runtime.FaceDistanceDetail": "最短距離 ≈ {0}(網格近似)\nΔX = {1}\nΔY = {2}\nΔZ = {3}\n點 1 {4}\n點 2 {5}",
"Runtime.FaceDistanceFirstFace": "已選第 1 個面 — 點選另一個面",
"Runtime.FaceName": "面",
"Runtime.FileNotFound": "找不到檔案",
"Runtime.GapLabel": "gap {0}",
"Runtime.GeometryProcessed": "幾何處理 {0:N0} ms(三角化 {1:N0} ms、邊取樣 {2:N0} ms{3})",
"Runtime.GizmoApplied": "已套用操作器變換「{0}」(可用 🧩干涉 驗證)",
"Runtime.GizmoHint": "操作器:拖箭頭沿軸移動「{0}」、拖環繞軸旋轉;放開即定位",
"Runtime.GizmoSelectFile": "操作器:請先在樹面板點選要操作的檔案",
"Runtime.ImportBusy": "忙碌中(匯入或運算進行中),請稍候再匯入",
"Runtime.ImportCompleted": "匯入完成:成功 {0} 檔{1}",
"Runtime.ImportFailed": "匯入失敗:{0} — {1}",
"Runtime.ImportFailedCount": "、失敗 {0} 檔",
"Runtime.ImportFailedTitle": "匯入失敗",
"Runtime.ImportFilter": "CAD 檔案 (*.stp;*.step;*.stl;*.dxf)|*.stp;*.step;*.stl;*.dxf|STEP (*.stp;*.step)|*.stp;*.step|STL (*.stl)|*.stl|DXF (*.dxf)|*.dxf|所有檔案 (*.*)|*.*",
"Runtime.ImportProgress": "匯入 {0}:{1}",
"Runtime.ImportTitle": "匯入 CAD 檔案(可複選)",
"Runtime.Importing": "匯入中:{0} …",
"Runtime.InterferenceChecking": "干涉檢查中(第 {0} 組):{1} ⟷ {2} …",
"Runtime.InterferenceClearDetail": "{0} ⟷ {1} 無相交\n最小間隙 ≈ {2}(網格近似)\ngap ≈ 0 即為貼合(match)\n點 1 {3}\n點 2 {4}",
"Runtime.InterferenceClearTitle": "🧩 無干涉 gap ≈ {0}",
"Runtime.InterferenceCompleted": "干涉檢查完成:{0} 組配對、{1} 組相交",
"Runtime.InterferenceFailed": "干涉檢查失敗:{0}",
"Runtime.InterferenceHitDetail": "兩零件相交(不 match)\n相交三角形對 = {0:N0}\n紅色線 = 干涉交線({1:N0} 段)\n(共面貼合不會被算為干涉)",
"Runtime.InterferenceHitTitle": "🧩 干涉!{0} ⟷ {1}",
"Runtime.InterferenceLabel": "干涉",
"Runtime.InterferenceNeedTwo": "干涉檢查需要至少 2 個可見檔案(目前 {0} 個)— 用樹面板勾選",
"Runtime.Isolated": "只顯示「{0}」(樹右鍵 → 全部顯示 復原)",
"Runtime.KindAlign": "對齊",
"Runtime.KindAlign3": "三點對齊",
"Runtime.KindAngle": "角度",
"Runtime.KindCircle": "圓",
"Runtime.KindDistance": "距離",
"Runtime.KindDrag": "拖曳",
"Runtime.KindEdge": "邊",
"Runtime.KindFace": "面",
"Runtime.KindFaceDistance": "面距",
"Runtime.KindInterference": "干涉",
"Runtime.KindNone": "無",
"Runtime.KindPoint": "點",
"Runtime.KindVolume": "體積",
"Runtime.LineDetail": "長度 = {0}\nΔX = {1} ΔY = {2} ΔZ = {3}\n起點 {4}\n終點 {5}",
"Runtime.MeasureFailed": "量測失敗:{0}",
"Runtime.MeasurementStartCancelled": "已取消目前量測起點(再按 Esc 退出量測模式)",
"Runtime.MeasurementsCleared": "已清除全部量測",
"Runtime.MeshMeasurementLimited": "此模型無 B-rep(STL/網格),僅支援 點 / 距離 / 角度 / 面距 量測",
"Runtime.MeshName": "網格",
"Runtime.MeshNameNumbered": "網格 {0}",
"Runtime.MeshSurface": "類型 = 網格(無 B-rep)",
"Runtime.ModeAlign": "兩點對齊:先點「要移動零件」上的點,再點目標點(純平移,量測會清空)",
"Runtime.ModeAlign3": "三點對齊:先在「要移動的檔案」點 3 個特徵點,再到目標檔案點 3 個對應點(旋轉+平移)",
"Runtime.ModeAngle": "角度量測:點選第 1 個面(或靠近直線邊)",
"Runtime.ModeBrowse": "瀏覽模式(右鍵旋轉、滾輪縮放、中鍵或 Shift+左鍵拖曳平移)",
"Runtime.ModeCircle": "圓量測:點擊圓孔內壁或圓邊附近",
"Runtime.ModeDistance": "距離量測:點選第 1 點",
"Runtime.ModeDrag": "拖曳模式:左鍵按住零件拖動(沿螢幕平面),放開定位;深度方向先轉視角再拖。右鍵仍可轉視角",
"Runtime.ModeEdge": "邊量測:點擊靠近要量的邊",
"Runtime.ModeFace": "面量測:點擊要量的面",
"Runtime.ModeFaceDistance": "面距量測:點選第 1 個面",
"Runtime.ModePoint": "點量測:點擊模型表面(自動吸附鄰近頂點)",
"Runtime.NoBrep": " · 無 B-rep",
"Runtime.NoCircularEdge": "此面沒有圓形邊(請點圓孔內壁或圓邊附近)",
"Runtime.NoGeometry": "檔案內沒有可顯示的幾何",
"Runtime.NoMeasurableEdge": "此面沒有可量測的邊",
"Runtime.NoSurfaceHit": "未命中模型表面",
"Runtime.NodeStats": "{0} 實體 · {1} 面 · {2:N0} △",
"Runtime.NurbsSurface": "NURBS 曲面",
"Runtime.Orthographic": "正交投影(平行投影,適合尺寸確認)",
"Runtime.OutlineSize": "\n外形 {0}",
"Runtime.ParsingStep": "解析 STEP(大檔可能需數分鐘)…",
"Runtime.ParsingStl": "解析 STL(直接讀三角形)…",
"Runtime.Perspective": "透視投影",
"Runtime.PlaneSurface": "類型 = 平面\n法向量 ({0:F3}, {1:F3}, {2:F3})",
"Runtime.Ready": "就緒 — 匯入 STP/STEP/STL/DXF(拖放亦可)。右鍵旋轉、滾輪縮放、中鍵或 Shift+左鍵平移",
"Runtime.Removed": "已移除:{0}",
"Runtime.RetriedFaces": "、重試補回 {0} 面",
"Runtime.Reversed": "(反向)",
"Runtime.RevolutionSurface": "旋轉曲面",
"Runtime.RootNotFound": "找不到此零件所屬檔案",
"Runtime.RotateSelectFile": "請先在樹面板點選要旋轉的檔案(任一節點即可)",
"Runtime.Rotated": "已旋轉「{0}」繞 {1} 軸 +90°(再按可繼續轉)",
"Runtime.SameFace": "兩次點到同一個面,請點選另一個面",
"Runtime.ScreenshotFilter": "PNG 圖片 (*.png)|*.png",
"Runtime.ScreenshotSaved": "已儲存截圖 → {0}",
"Runtime.ScreenshotTitle": "儲存視圖截圖",
"Runtime.SectionCancelled": "已取消 3 點剖面定義",
"Runtime.SectionClosed": "剖面已關閉",
"Runtime.SectionCollinear": "3點剖面:三點共線,請改選不共線的第 3 點",
"Runtime.SectionDefined": "3點剖面:平面已定義(法向 {0:F2}, {1:F2}, {2:F2})— 用位置滑桿掃剖面",
"Runtime.SectionDegenerate": "3點剖面:三點退化(共線),請重新點選",
"Runtime.SectionNoHit": "3點剖面:未命中模型表面(已選 {0}/3 點,Esc 取消)",
"Runtime.SectionPickPrompt": "3點剖面:請在模型上點 3 個點定義剖切平面(Esc 取消)",
"Runtime.SectionPicked": "3點剖面:已選 {0}/3 點",
"Runtime.SectionStatus": "剖面:{0} {1:F0}%{2}",
"Runtime.ShellName": "殼體",
"Runtime.SolidName": "實體",
"Runtime.SphericalSurface": "球面",
"Runtime.StepExportEmpty": "沒有可匯出的 B-rep 幾何(STEP 匯出僅含 Solid/Shell;請勾選要匯出的 STEP 檔案)",
"Runtime.StepExportFailed": "STEP 匯出失敗:{0}",
"Runtime.StepExportFilter": "STEP 檔案 (*.stp)|*.stp|STEP 檔案 (*.step)|*.step",
"Runtime.StepExportTitle": "匯出目前場景為 STEP(零件在目前對齊位置)",
"Runtime.StepExported": "已匯出 STEP({0} 個實體,目前位置)→ {1}",
"Runtime.StepParseFailed": "CADability 無法解析此 STEP 檔",
"Runtime.StepParsed": "STEP 解析 {0:N0} ms",
"Runtime.StlExportEmpty": "沒有可匯出的網格(請勾選至少一個含實體網格的檔案)",
"Runtime.StlExportFailed": "STL 匯出失敗:{0}",
"Runtime.StlExportFilter": "STL 檔案 (*.stl)|*.stl",
"Runtime.StlExportMergedTitle": "匯出 STL(合併單一檔)",
"Runtime.StlExportPerFileTitle": "匯出 STL(每個檔案各一個,以此為基底檔名)",
"Runtime.StlExportedMany": "已匯出 {0} 個 STL(共 {1:N0} 三角形)→ {2}",
"Runtime.StlExportedOne": "已匯出 STL({0:N0} 三角形)→ {1}",
"Runtime.StlExporting": "匯出 STL 中…",
"Runtime.StlNoTriangles": "STL 檔內沒有三角形",
"Runtime.StlParsed": "STL 解析 {0:N0} ms({1} 個網格)",
"Runtime.StlRetriangulating": "重新三角化 + 匯出 STL 中…(大組件需要時間)",
"Runtime.SurfaceType": "類型 = {0}",
"Runtime.ToroidalSurface": "環面",
"Runtime.Triangulating": "三角化 {0} 個零件(平行)…",
"Runtime.UnitInch": "顯示單位:inch",
"Runtime.UnitMillimeter": "顯示單位:mm",
"Runtime.UnsupportedFormat": "不支援的格式(支援 .stp / .step / .stl / .dxf;CADability 無 IGES reader)",
"Runtime.View": "視圖:{0}",
"Runtime.ViewBack": "後",
"Runtime.ViewBottom": "下",
"Runtime.ViewFront": "前",
"Runtime.ViewIso": "等角",
"Runtime.ViewLeft": "左",
"Runtime.ViewRight": "右",
"Runtime.ViewTop": "上",
"Runtime.VisibilityInverted": "已反轉顯示",
"Runtime.VolumeDetail": "體積 ≈ {0}(網格近似)\n質心 {1}\n外形(AABB){2} × {3} × {4} {5}",
"Runtime.VolumeNoMesh": "「{0}」沒有可計算的面網格(線架構無體積)",
"Runtime.VolumeOpenMesh": "「{0}」非封閉網格(開放殼),無法可靠計算體積",
"Ui.About.Changelog": "📋 版本紀錄",
"Ui.About.Close": "關閉",
"Ui.About.Description": "STP / STEP 3D 檢視器。免開重量級 CAD,快速開檔、量測、對齊插合、干涉檢查、剖面、匯出。",
"Ui.About.Developer": "👤 開發者",
"Ui.About.Email": "電子郵件",
"Ui.About.Name": "姓名",
"Ui.About.Release010.Change1": "• STEP/STL/DXF 匯入與裝配樹、點/距離/邊/面/圓/角度/面距量測、兩點與三點對齊、干涉檢查、剖面、mm ⇄ inch、CSV 與截圖匯出。",
"Ui.About.Release010.Title": "首次釋出",
"Ui.About.Release020.Change1": "• 新增拖曳模式:左鍵按住零件直接拖到想要的位置,放開就定位。",
"Ui.About.Release020.Change2": "• 0.2.1 修正拖曳過的零件再點擊會當掉的問題。",
"Ui.About.Release020.Title": "拖曳模式",
"Ui.About.Release030.Change1": "• 新增操作器:選取檔案後出現 XYZ 三色箭頭與旋轉環,箭頭沿軸精確移動、旋轉環繞軸轉任意角度。",
"Ui.About.Release030.Title": "三軸操作器",
"Ui.About.Release031.Change1": "• 三軸操作器改成永遠浮在畫面最上層,不會被零件擋住,任何角度都抓得到。",
"Ui.About.Release031.Title": "操作器永遠可見",
"Ui.About.Release032.Change1": "• 修正大型組件(數萬個面)進入量測模式後,轉動視角嚴重卡頓的問題 — 現在量測跟瀏覽一樣順。",
"Ui.About.Release032.Title": "大組件量測不再卡",
"Ui.About.Release040.Change1": "• 大檔案拉剖面滑桿不再卡住畫面(改到背景計算)。",
"Ui.About.Release040.Change2": "• 拖曳/操作器放開零件後的定位計算大幅加速。",
"Ui.About.Release040.Change3": "• 程式遇到錯誤會提示訊息並繼續運作,不再整個閃退。",
"Ui.About.Release040.Title": "更快、更穩",
"Ui.About.Release050.Change1": "• 量距離時點到圓孔邊緣會自動吸到圓心 — 量兩孔中心距(pitch)超方便。",
"Ui.About.Release050.Change2": "• 量測快速鍵:P 點、D 距離、E 邊、F 面、C 圓、A 角度、M 面距;Esc 取消。",
"Ui.About.Release050.Change3": "• 樹狀面板可搜尋零件名稱;右鍵可「只顯示此節點」隔離觀察,也可以量體積與質心。",
"Ui.About.Release050.Change4": "• 新增標準視圖(等角/前/上/右)與正交投影,量尺寸不受透視變形。",
"Ui.About.Release050.Change5": "• 剖面新增「3點」模式:在模型上點 3 個點,就能沿任意斜面剖開。",
"Ui.About.Release050.Change6": "• 新增匯出 STEP:把對齊好的零件位置存成新 STEP 檔,交接給正式 CAD。",
"Ui.About.Release050.Change7": "• 會記住視窗大小、單位設定與最近開啟的 10 個檔案。",
"Ui.About.Release050.Title": "量測、視圖、樹狀面板大升級",
"Ui.About.Release060.Change1": "• 新增匯出 STL:把畫面上的零件(含對齊、旋轉後的位置)存成 STL 網格檔,可拿去 3D 列印或給其他軟體用。",
"Ui.About.Release060.Change2": "• 匯出前會先跳出設定視窗:全部合併成一個檔或每個檔案各存一個、Binary 或文字格式、mm 或 inch、網格精細度 — 確認後才匯出。",
"Ui.About.Release060.Change3": "• 設定視窗會即時顯示三角形數量與預估檔案大小。",
"Ui.About.Release060.Title": "匯出 STL",
"Ui.About.Release070.Change1": "• 上方新增選單列,全部功能依「檔案/視圖/量測/變換/工具」分類,不用再在一長排按鈕裡找。",
"Ui.About.Release070.Change2": "• 原本的工具列變成「快速列」:只放常用按鈕。想放哪些自己決定 — 到「快速列 → 自訂按鈕」勾選,下次開啟會記住。",
"Ui.About.Release070.Change3": "• 剖面的軸向/位置控制列改成只在開啟剖面時出現,平常不佔空間。",
"Ui.About.Release070.Change4": "• 新增「關於」視窗(就是這個畫面),可以看版本紀錄。",
"Ui.About.Release070.Title": "介面大整理 — 選單列+可自訂快速列",
"Ui.About.Release071.Change1": "• 開啟大型 STL 網格檔(數十 MB、上百萬三角形)現在幾乎瞬間完成,不用再等好幾分鐘。",
"Ui.About.Release071.Change2": "• 例如一個 75 MB、150 萬個三角形的 STL,以前要等到天荒地老,現在不到 1 秒就顯示出來。",
"Ui.About.Release071.Title": "大型 STL 秒開",
"Ui.About.Technology": ".NET 8 · WPF · C#;CADability(STEP / B-rep 幾何核心);HelixToolkit(3D 顯示與相機)",
"Ui.About.TechnologyTitle": "🔧 技術棧",
"Ui.About.Title": "關於 STPViewer",
"Ui.Language": "語言",
"Ui.Main.3AxisGizmo": "⊹ 三軸操作器",
"Ui.Main.3AxisGizmoUseXYZArrows": "三軸操作器:對樹面板選取的檔案顯示 XYZ 箭頭(沿軸移動)與旋轉環(繞軸轉任意角度),每次放開即定位",
"Ui.Main.3Point": "🎯 三點",
"Ui.Main.3Points": "3點",
"Ui.Main.Align": "🎯 對齊",
"Ui.Main.Angle": "∠ 角度",
"Ui.Main.AngleBetweenFacesStraightEdgesShortcut": "兩面/兩邊夾角(點兩個面或靠近直線邊)— 快速鍵 A",
"Ui.Main.AngleBetweenTwoFacesStraightEdges": "兩面/兩邊夾角(點兩個面或靠近直線邊)",
"Ui.Main.ApplicationInformationReleaseHistory": "程式資訊與版本紀錄",
"Ui.Main.AreaSurfaceTypeNormalVector": "面積 + 曲面類型/法向量",
"Ui.Main.AreaSurfaceTypeNormalVectorShortcut": "面積 + 曲面類型/法向量 — 快速鍵 F",
"Ui.Main.Capture": "📷 截圖",
"Ui.Main.CenterRadiusDiameterCircumference": "圓心/半徑/直徑/周長",
"Ui.Main.CenterRadiusDiameterCircumferenceShortcutC": "圓心/半徑/直徑/周長 — 快速鍵 C",
"Ui.Main.CheckButtonsShowQuickBarSaved": "勾選要顯示在快速列的常用按鈕(隨設定保存)",
"Ui.Main.CheckVisibleModelPairsIntersectionsReport": "2 個以上可見檔案兩兩檢查是否相交;無相交時回報最小間隙",
"Ui.Main.ChooseScopeFormatUnitsMeshQuality": "先設定範圍/格式/單位/精度,確認再匯出",
"Ui.Main.Circle": "⭕ 圓",
"Ui.Main.Clear": "🧹 清除",
"Ui.Main.ClearAllMeasurements": "🧹 清除全部量測",
"Ui.Main.ClearAllMeasurementsMarkersEscCancels": "清除全部量測與標記(Esc = 取消進行中量測/退出模式)",
"Ui.Main.ClickChangeColorIncludingChildNodes": "點擊換色(含子節點)",
"Ui.Main.Close": "✕ 關閉",
"Ui.Main.CloseSectionView": "關閉剖面檢視",
"Ui.Main.CustomizeButtons": "自訂按鈕",
"Ui.Main.DeleteThisMeasurement": "刪除此筆量測",
"Ui.Main.DisplayInchesMm": "單位顯示 inch(mm ⇄ in)",
"Ui.Main.Distance": "📏 距離",
"Ui.Main.DistanceXYZCircleCenterSnappingHole": "兩點距離 + ΔXYZ(點到圓邊會吸圓心 → 可量孔對孔 pitch)— 快速鍵 D",
"Ui.Main.DistanceXYZCircularEdgesSnapCenters": "兩點距離 + ΔXYZ(點到圓邊會吸圓心 → 可量孔對孔 pitch)",
"Ui.Main.Drag": "🖐 拖曳",
"Ui.Main.DragMode": "🖐 拖曳模式",
"Ui.Main.DragModeHoldLeftMouseButton": "拖曳模式:左鍵按住零件沿螢幕平面拖動,放開定位(深度方向先轉視角再拖)",
"Ui.Main.DragResizePanel": "拖曳調整面板寬度",
"Ui.Main.Edge": "📐 邊",
"Ui.Main.EdgeLength": "📐 邊長",
"Ui.Main.Edges": "邊",
"Ui.Main.EscCancelsCurrentMeasurementExitsMode": "Esc = 取消進行中量測/退出模式",
"Ui.Main.ExistingMeasurementsUpdateImmediatelyValuesStored": "既有量測即時換算;內部一律存 mm",
"Ui.Main.ExportMeasurementResultsCSV": "量測結果匯出 CSV",
"Ui.Main.ExportMeasurementsCSV": "💾 匯出量測 CSV…",
"Ui.Main.ExportSTEP": "📤 匯出 STEP…",
"Ui.Main.ExportSTL": "📐 匯出 STL…",
"Ui.Main.ExportStepTooltip": "把可見檔案(目前對齊位置)匯出成新 STEP 檔;不動原始檔",
"Ui.Main.Face": "⬛ 面",
"Ui.Main.FaceDistance": "⇔ 面距",
"Ui.Main.FileF": "檔案(_F)",
"Ui.Main.FilterAssemblyTreeByNameDoes": "輸入名稱過濾裝配樹(只影響樹面板顯示,不影響 3D 可見性;清空 = 全顯)",
"Ui.Main.Fit": "🔍 全覽",
"Ui.Main.FitAllModelsView": "縮放至全部模型",
"Ui.Main.Flip": "反向",
"Ui.Main.Front": "前",
"Ui.Main.FrontView": "前視圖",
"Ui.Main.FrontViewLookAlongYZ": "前視圖(-Y 方向看,Z 向上)",
"Ui.Main.Gizmo": "⊹ 操作器",
"Ui.Main.HelpH": "說明(_H)",
"Ui.Main.HoldLeftMouseButtonMovePart": "左鍵按住零件沿螢幕平面拖動,放開定位",
"Ui.Main.IAboutSTPViewer": "ℹ 關於 STPViewer…",
"Ui.Main.ImportSTPSTEPSTLDXFFiles": "匯入 STP/STEP/STL/DXF(可複選,也可直接拖放到視窗)",
"Ui.Main.Interference": "🧩 干涉",
"Ui.Main.InterferenceCheck": "🧩 干涉檢查",
"Ui.Main.InterferenceCheckTestVisibleModelPairs": "干涉檢查:2 個以上可見檔案兩兩檢查是否相交(match 判定);無相交時回報最小間隙",
"Ui.Main.InvertVisibility": "🔄 反轉顯示",
"Ui.Main.Iso": "等角",
"Ui.Main.IsometricView": "等角視圖",
"Ui.Main.LookAlongXZUp": "+X 方向看,Z 向上",
"Ui.Main.LookAlongYZUp": "-Y 方向看,Z 向上",
"Ui.Main.LookDownFromZ": "+Z 往下看",
"Ui.Main.MeasureM": "量測(_M)",
"Ui.Main.MeasurePointCoordinatesSnapsVerticesCircle": "量測點座標(吸附頂點/圓心)",
"Ui.Main.MeasureStraightCurvedCircularEdgeLengths": "邊長(直線/曲線/圓弧)",
"Ui.Main.MeasureVolumeCentroid": "⚖ 量體積 / 質心",
"Ui.Main.Measurements": "量測結果",
"Ui.Main.MenuImport": "📂 匯入…",
"Ui.Main.ModelsAssemblyTree": "模型 / 裝配樹",
"Ui.Main.Ortho": "正交",
"Ui.Main.OrthographicProjection": "正交投影",
"Ui.Main.OrthographicProjectionAvoidsPerspectiveDistortionClick": "正交投影(平行投影,量尺寸不受透視變形;再按切回透視)",
"Ui.Main.ParallelProjectionAvoidsPerspectiveDistortionUncheck": "平行投影,量尺寸不受透視變形;取消勾選切回透視",
"Ui.Main.PickPointPartMoveThenPick": "先點要移動零件上的點,再點目標點",
"Ui.Main.PickThreePointsMovingModelThen": "先在要移動的檔案點 3 個特徵點,再到目標檔案點 3 個對應點(順序對應)",
"Ui.Main.Point": "📍 點",
"Ui.Main.PointCoordinates": "📍 點座標",
"Ui.Main.PointCoordinatesVertexCircleCenterSnapping": "量測點座標(吸附頂點/圓心)— 快速鍵 P",
"Ui.Main.QuickBarQ": "快速列(_Q)",
"Ui.Main.RecentFiles": "最近檔案",
"Ui.Main.RecentlyOpenedFiles": "最近開啟的檔案",
"Ui.Main.RemoveThisFile": "移除此檔案",
"Ui.Main.RestoreDefaultButtons": "恢復預設按鈕",
"Ui.Main.Right": "右",
"Ui.Main.RightView": "右視圖",
"Ui.Main.RightViewLookAlongXZ": "右視圖(+X 方向看,Z 向上)",
"Ui.Main.Rotate90AroundX": "↻ 繞 X 軸 +90°",
"Ui.Main.Rotate90AroundY": "↻ 繞 Y 軸 +90°",
"Ui.Main.Rotate90AroundZ": "↻ 繞 Z 軸 +90°",
"Ui.Main.RotateModelSelectedTree90Around": "選取的檔案繞 X 軸 +90°(先在樹面板點選檔案)",
"Ui.Main.RotateModelSelectedTreeAroundIts": "對樹面板選取的檔案,繞其中心旋轉(先在樹面板點選檔案)",
"Ui.Main.RotateSelectedModel90AroundY": "選取的檔案繞 Y 軸 +90°",
"Ui.Main.RotateSelectedModel90AroundZ": "選取的檔案繞 Z 軸 +90°",
"Ui.Main.SavePNGScreenshot3DView2": "3D 視圖截圖存 PNG(2x 解析度)",
"Ui.Main.SaveViewPNG": "📷 視圖截圖 PNG…",
"Ui.Main.SectionAxis3PointsDefineAn": "剖切軸向(3點 = 在模型上點 3 點定義任意平面)",
"Ui.Main.SectionLabel": "✂ 剖面:",
"Ui.Main.SectionPosition0100EnterValue": "剖面位置 %(0–100,可直接輸入,離開欄位生效)",
"Ui.Main.SectionPositionAlongNormal": "剖面位置(沿法向 %)",
"Ui.Main.SectionView": "✂ 剖面檢視",
"Ui.Main.ShortestDistanceBetweenFacesMeshApproximation": "面到面最短距離(網格近似)",
"Ui.Main.ShortestFaceDistanceMeshApproximationShortcut": "面到面最短距離(網格近似)— 快速鍵 M",
"Ui.Main.ShowAll": "☀ 全部顯示",
"Ui.Main.ShowHideIncludingChildNodes": "顯示 / 隱藏(含子節點)",
"Ui.Main.ShowOnlyThisNode": "👁 只顯示此節點",
"Ui.Main.ShowOutlineEdgesManyEdgesCan": "顯示輪廓邊線(邊線量大時轉動視角會變慢)",
"Ui.Main.ShowSectionControlsAxisPositionDirection": "開啟後下方出現剖面參數列(軸向/位置/反向)",
"Ui.Main.ShowXYZArrowsRotationRingsSelected": "對樹面板選取的檔案顯示 XYZ 箭頭與旋轉環,每次放開即定位",
"Ui.Main.StraightCurvedCircularEdgeLengthShortcut": "邊長(直線/曲線/圓弧)— 快速鍵 E",
"Ui.Main.SwitchDisplayUnitsBetweenMmInches": "切換顯示單位 mm ⇄ inch(既有量測即時換算)",
"Ui.Main.SwitchRetainedSide": "切換保留側",
"Ui.Main.ThreePointAlignmentPickThreePoints": "三點對齊(旋轉+平移):先在要移動的檔案點 3 個特徵點,再到目標檔案點 3 個對應點(順序對應)",
"Ui.Main.ThreePointAlignmentRotateTranslate": "🎯 三點對齊(旋轉+平移)",
"Ui.Main.ToggleSectionViewItsControls": "開啟/關閉剖面檢視(開啟後下方出現參數列)",
"Ui.Main.ToolbarExportStepTooltip": "把可見檔案(目前對齊位置)匯出成新 STEP 檔 — 對齊插合結果可交接 CAD;不動原始檔",
"Ui.Main.ToolbarExportStlTooltip": "把可見檔案(目前位置)匯出成 STL 網格檔 — 先設定範圍/格式/單位/精度,確認再匯出",
"Ui.Main.ToolbarImport": "📂 匯入",
"Ui.Main.ToolbarSection": "✂ 剖面",
"Ui.Main.ToolsL": "工具(_L)",
"Ui.Main.Top": "上",
"Ui.Main.TopView": "上視圖",
"Ui.Main.TopViewLookDownFromZ": "上視圖(+Z 往下看)",
"Ui.Main.TransformT": "變換(_T)",
"Ui.Main.TwoPointAlignmentPickPointMoving": "兩點對齊(平移零件):先點要移動零件上的點,再點目標點",
"Ui.Main.TwoPointAlignmentTranslate": "🎯 兩點對齊(平移)",
"Ui.Main.TwoPointDistance": "📏 兩點距離",
"Ui.Main.ViewV": "視圖(_V)",
"Ui.Main.WindowTitle": "STPViewer — STP/STEP 3D 檢視器",
"Ui.Main.ZoomFit": "🔍 縮放至全部",
"Ui.Main.ZoomThisNode": "縮放至此節點",
"Ui.StlExport.Cancel": "取消",
"Ui.StlExport.CustomScaleTooltip": "座標乘上此係數(例:0.001 = mm→m)",
"Ui.StlExport.EstimatedSize": "預估 {0}",
"Ui.StlExport.EstimatedSizeFine": "以目前網格預估 {0}(實際依重新三角化結果)",
"Ui.StlExport.Export": "匯出…",
"Ui.StlExport.Format": "格式",
"Ui.StlExport.FormatAscii": "ASCII(文字可讀,檔案約 4 倍大)",
"Ui.StlExport.FormatBinary": "Binary(建議,檔案小)",
"Ui.StlExport.MeshQuality": "網格精度",
"Ui.StlExport.QualityCurrent": "目前顯示網格(快,與畫面一致)",
"Ui.StlExport.QualityFine": "精細 — 重新三角化,曲面更平滑(慢、檔案大)",
"Ui.StlExport.QualityTooltip": "精細會由 B-rep 重新三角化(僅 STEP 來源;STL/DXF 維持現有網格),大組件需要時間",
"Ui.StlExport.Scope": "匯出範圍",
"Ui.StlExport.ScopeMerge": "全部可見檔案 → 合併成單一 STL",
"Ui.StlExport.ScopePerFile": "每個可見檔案 → 各一個 STL",
"Ui.StlExport.ScopeTooltip": "STL 無裝配結構;合併 = 所有可見零件進同一檔",
"Ui.StlExport.Summary": "{0} 個可見檔案 · {1} 三角形 · {2}",
"Ui.StlExport.Title": "匯出 STL — 參數設定",
"Ui.StlExport.UnitCustom": "自訂縮放係數…",
"Ui.StlExport.UnitInch": "inch(座標 ÷ 25.4)",
"Ui.StlExport.UnitMillimeter": "mm(1:1,預設)",
"Ui.StlExport.UnitScale": "單位縮放",
"Ui.StlExport.UnitTooltip": "STL 檔本身無單位;內部幾何為 mm,選 inch 會把座標 ÷25.4"
}
}
+17 -18
View File
@@ -1,5 +1,4 @@
// 三點對齊剛體變換數學驗證:
// (1) 已知變換還原 (2) Matrix3D ↔ ModOp 兩種表示一致 (3) 共線拒絕
// Three-point alignment checks: known transform, Matrix3D/ModOp agreement, and collinear rejection.
using System.Windows.Media.Media3D;
using STPViewer.Services;
@@ -11,29 +10,29 @@ internal static class AlignTest
{
int failures = 0;
// ── (1) 已知剛體變換:繞 Z 軸 90° + 平移 (5,-3,2),三點求解後應還原同一變換 ──
// Recover a known 90-degree Z rotation followed by translation (5,-3,2).
var truth = Matrix3D.Identity;
truth.RotateAt(new Quaternion(new Vector3D(0, 0, 1), 90), new Point3D(2, 1, 0));
truth.Translate(new Vector3D(5, -3, 2));
var p1 = new Point3D(0, 0, 0);
var p2 = new Point3D(10, 0, 0);
var p3 = new Point3D(0, 7, 3); // 不共線、不在同平面軸上
var p3 = new Point3D(0, 7, 3); // Not collinear with the first two points.
Point3D q1 = truth.Transform(p1), q2 = truth.Transform(p2), q3 = truth.Transform(p3);
bool ok = RigidAlign.TryRigidTransform(p1, p2, p3, q1, q2, q3, out Matrix3D m);
failures += Check("三點求解成功", ok, true);
failures += Check("three-point transform solved", ok, true);
// 三個對應點要精確貼合
// Corresponding points must coincide.
failures += CheckPoint("p1→q1", m.Transform(p1), q1);
failures += CheckPoint("p2→q2", m.Transform(p2), q2);
failures += CheckPoint("p3→q3", m.Transform(p3), q3);
// 剛體變換唯一性:任意第 4 點也應該跟著正確變換
// An independent fourth point checks the full rigid transform.
var r = new Point3D(-4, 12, 8);
failures += CheckPoint("任意第4點", m.Transform(r), truth.Transform(r));
failures += CheckPoint("fourth point", m.Transform(r), truth.Transform(r));
// ── (2) ToModOp:CADability ModOp 與 WPF Matrix3D 對同一點要算出同樣結果 ──
// CADability ModOp and WPF Matrix3D must transform points identically.
CADability.ModOp op = RigidAlign.ToModOp(m);
foreach (Point3D p in new[] { p1, p2, p3, r, new Point3D(1.5, -2.25, 9) })
{
@@ -42,28 +41,28 @@ internal static class AlignTest
bool same = Dist(new Point3D(gp.x, gp.y, gp.z), wpf) < 1e-9;
if (!same)
{
System.Console.WriteLine($" FAIL: ModOp/Matrix3D 不一致 @ ({p.X},{p.Y},{p.Z})");
System.Console.WriteLine($" FAIL: ModOp/Matrix3D mismatch at ({p.X},{p.Y},{p.Z})");
failures++;
}
}
System.Console.WriteLine($" {(failures == 0 ? "PASS" : "----")}: ModOp ↔ Matrix3D 一致(5 個測試點)");
System.Console.WriteLine($" {(failures == 0 ? "PASS" : "----")}: ModOp/Matrix3D agree at five points");
// ── (3) 共線拒絕 ──
// Collinear source points must be rejected.
bool collinearRejected = !RigidAlign.TryRigidTransform(
new Point3D(0, 0, 0), new Point3D(1, 0, 0), new Point3D(2, 0, 0), // 共線
new Point3D(0, 0, 0), new Point3D(1, 0, 0), new Point3D(2, 0, 0), // Collinear.
q1, q2, q3, out _);
failures += Check("來源共線 → 拒絕", collinearRejected, true);
failures += Check("Collinear 判定", RigidAlign.Collinear(
failures += Check("collinear source rejected", collinearRejected, true);
failures += Check("collinearity detection", RigidAlign.Collinear(
new Point3D(0, 0, 0), new Point3D(1, 1, 1), new Point3D(3, 3, 3)), true);
System.Console.WriteLine(failures == 0 ? "AlignTest: 全部通過" : $"AlignTest: {failures} 項失敗");
System.Console.WriteLine(failures == 0 ? "AlignTest: all passed" : $"AlignTest: {failures} failed");
return failures;
}
private static int Check(string name, bool actual, bool expected)
{
bool ok = actual == expected;
System.Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name}({actual})");
System.Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name} ({actual})");
return ok ? 0 : 1;
}
@@ -71,7 +70,7 @@ internal static class AlignTest
{
double d = Dist(actual, expected);
bool ok = d < 1e-9;
System.Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name} 誤差 {d:E2}");
System.Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name} error {d:E2}");
return ok ? 0 : 1;
}
+15 -15
View File
@@ -1,4 +1,4 @@
// 剖面裁切數學驗證:對單位幾何裁切,檢查保留側與面積守恆
// Section clipping checks the retained side and triangle area.
using System.Windows.Media;
using System.Windows.Media.Media3D;
using STPViewer.Services;
@@ -11,7 +11,7 @@ internal static class ClipTest
{
int failures = 0;
// 單一三角形 (0,0,0)(10,0,0)(0,10,0),面積 50
// A single triangle with area 50.
var mesh = new MeshGeometry3D
{
Positions = new Point3DCollection
@@ -22,38 +22,38 @@ internal static class ClipTest
};
mesh.Freeze();
// case 1: 平面 x=20,全保留
// A plane at x=20 retains the full triangle.
var m1 = SectionService.ClipMesh(mesh, new Point3D(20, 0, 0), new Vector3D(1, 0, 0));
failures += Check("全保留", Area(m1), 50.0);
failures += Check("fully retained", Area(m1), 50.0);
// case 2: 平面 x=-1,全裁掉
// A plane at x=-1 removes the full triangle.
var m2 = SectionService.ClipMesh(mesh, new Point3D(-1, 0, 0), new Vector3D(1, 0, 0));
failures += Check("全裁掉", Area(m2), 0.0);
failures += Check("fully removed", Area(m2), 0.0);
// case 3: 平面 x=5 → 保留 x≤5:梯形面積 = 50 - 右側小三角形(底5高5)/2 = 50 - 12.5 = 37.5
// At x=5, the retained trapezoid has area 50 - 12.5 = 37.5.
var m3 = SectionService.ClipMesh(mesh, new Point3D(5, 0, 0), new Vector3D(1, 0, 0));
failures += Check("半裁(x≤5)", Area(m3), 37.5);
failures += Check("half clipped (x<=5)", Area(m3), 37.5);
foreach (Point3D p in m3.Positions)
if (p.X > 5 + 1e-9) { Console.WriteLine($" FAIL: 頂點 x={p.X} 超出保留側"); failures++; }
if (p.X > 5 + 1e-9) { Console.WriteLine($" FAIL: vertex x={p.X} is outside retained side"); failures++; }
// case 4: 反向(保留 x≥5)→ 12.5
// Reverse the plane normal to retain the smaller triangle.
var m4 = SectionService.ClipMesh(mesh, new Point3D(5, 0, 0), new Vector3D(-1, 0, 0));
failures += Check("半裁(x≥5)", Area(m4), 12.5);
failures += Check("half clipped (x>=5)", Area(m4), 12.5);
// case 5: 線段裁切
// Clip a line segment.
var segs = new Point3DCollection { new(0, 0, 0), new(10, 0, 0) };
Point3DCollection s1 = SectionService.ClipSegments(segs, new Point3D(4, 0, 0), new Vector3D(1, 0, 0));
double segLen = s1.Count == 2 ? (s1[1] - s1[0]).Length : -1;
failures += Check("線段裁切長度", segLen, 4.0);
failures += Check("clipped segment length", segLen, 4.0);
Console.WriteLine(failures == 0 ? "ClipTest: 全部通過" : $"ClipTest: {failures} 項失敗");
Console.WriteLine(failures == 0 ? "ClipTest: all passed" : $"ClipTest: {failures} failed");
return failures;
}
private static int Check(string name, double actual, double expected)
{
bool ok = Math.Abs(actual - expected) < 1e-9;
Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name} = {actual}(期望 {expected})");
Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name} = {actual} (expected {expected})");
return ok ? 0 : 1;
}
+9 -9
View File
@@ -5,13 +5,13 @@ using System.Linq;
using CADability;
using CADability.GeoObject;
using STPViewer.Services;
using IOPath = System.IO.Path; // CADability.GeoObject.Path(曲線)撞名
using IOPath = System.IO.Path; // Avoid the CADability.GeoObject.Path name collision.
namespace SmokeTest;
/// <summary>
/// STEP 匯出往返驗證(v0.5.0 匯出 STEP 功能的無 UI 驗證):
/// 匯入來源 STEP → 走與 MainViewModel.ExportStepFile 相同路徑寫出新檔 → 重新匯入比對實體數。
/// Headless STEP export round trip: import, export through the same CADability path,
/// then import the result and compare solid counts.
/// </summary>
public static class ExportTest
{
@@ -20,26 +20,26 @@ public static class ExportTest
Console.WriteLine($"=== ExportTest: {IOPath.GetFileName(sourcePath)} → {IOPath.GetFileName(outPath)} ===");
ImportedFileData data = new StepImportService().Import(sourcePath);
var geos = Collect(data.Root).Where(g => g is Solid or Shell).ToList();
Console.WriteLine($" 來源 Solid/Shell 物件 = {geos.Count}(Solids = {data.Root.SolidCount})");
Console.WriteLine($" Source Solid/Shell objects = {geos.Count} (solids = {data.Root.SolidCount})");
Project project = Project.CreateSimpleProject();
Model model = project.GetActiveModel();
foreach (IGeoObject g in geos) model.Add(g);
new ExportStep().WriteToFile(outPath, project);
var fi = new FileInfo(outPath);
Console.WriteLine($" 已寫出({fi.Length:N0} bytes)");
Console.WriteLine($" Written ({fi.Length:N0} bytes)");
if (fi.Length < 100)
{
Console.WriteLine("ExportTest: FAIL(輸出檔近乎空白)");
Console.WriteLine("ExportTest: FAIL (output is nearly empty)");
return 1;
}
ImportedFileData back = new StepImportService().Import(outPath);
Console.WriteLine($" 回讀 Solids = {back.Root.SolidCount}, Faces = {back.Root.FaceCount}");
Console.WriteLine($" Reimported solids = {back.Root.SolidCount}, faces = {back.Root.FaceCount}");
bool ok = back.Root.SolidCount == data.Root.SolidCount && back.Root.FaceCount > 0;
Console.WriteLine(ok
? "ExportTest: PASS(實體數一致)"
: $"ExportTest: FAIL(來源 Solids {data.Root.SolidCount} ↔ 回讀 {back.Root.SolidCount})");
? "ExportTest: PASS (solid counts match)"
: $"ExportTest: FAIL (source solids {data.Root.SolidCount} vs reimported {back.Root.SolidCount})");
return ok ? 0 : 1;
}
+13 -13
View File
@@ -1,4 +1,4 @@
// 干涉檢查數學驗證:相交 / 分離 / 貼合三種情境
// Interference checks for intersecting, separated, and touching meshes.
using System.Windows.Media;
using System.Windows.Media.Media3D;
using STPViewer.Services;
@@ -11,41 +11,41 @@ internal static class InterferenceTest
{
int failures = 0;
// 兩個互穿的四面體(B 比 A 平移 +5,邊長 10 → 必相交)
// Two tetrahedra overlap when B is offset by 5 along X.
MeshGeometry3D a = Tetra(new Vector3D(0, 0, 0));
MeshGeometry3D b = Tetra(new Vector3D(5, 0, 0));
InterferenceResult r1 = InterferenceService.Check(new[] { a }, new[] { b });
failures += Check("互穿 → 相交", r1.Intersects, true);
failures += Check("互穿 → 有交線段", r1.Segments.Count > 0, true);
failures += Check("overlap intersects", r1.Intersects, true);
failures += Check("overlap has intersection segments", r1.Segments.Count > 0, true);
// 分離(B 平移 +30,gap 應 ≈ 30 - 10 = 20)
// B at X=30 leaves a gap of about 20.
MeshGeometry3D c = Tetra(new Vector3D(30, 0, 0));
InterferenceResult r2 = InterferenceService.Check(new[] { a }, new[] { c });
failures += Check("分離 → 不相交", r2.Intersects, false);
failures += Check("separated meshes do not intersect", r2.Intersects, false);
bool gapOk = Math.Abs(r2.GapDistance - 20.0) < 0.01;
Console.WriteLine($" {(gapOk ? "PASS" : "FAIL")}: 分離 gap = {r2.GapDistance:F4}(期望 ≈ 20)");
Console.WriteLine($" {(gapOk ? "PASS" : "FAIL")}: separated gap = {r2.GapDistance:F4} (expected ~20)");
if (!gapOk) failures++;
// 貼合(B 平移 +10,頂點剛好接觸 → 不算穿透、gap ≈ 0)
// B at X=10 touches a vertex without penetrating A.
MeshGeometry3D d = Tetra(new Vector3D(10, 0, 0));
InterferenceResult r3 = InterferenceService.Check(new[] { a }, new[] { d });
failures += Check("貼合 → 不算穿透", r3.Intersects, false);
failures += Check("touching is not penetration", r3.Intersects, false);
bool touchOk = r3.GapDistance < 0.01;
Console.WriteLine($" {(touchOk ? "PASS" : "FAIL")}: 貼合 gap = {r3.GapDistance:F6}(期望 ≈ 0)");
Console.WriteLine($" {(touchOk ? "PASS" : "FAIL")}: touching gap = {r3.GapDistance:F6} (expected ~0)");
if (!touchOk) failures++;
Console.WriteLine(failures == 0 ? "InterferenceTest: 全部通過" : $"InterferenceTest: {failures} 項失敗");
Console.WriteLine(failures == 0 ? "InterferenceTest: all passed" : $"InterferenceTest: {failures} failed");
return failures;
}
private static int Check(string name, bool actual, bool expected)
{
bool ok = actual == expected;
Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name}({actual})");
Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name} ({actual})");
return ok ? 0 : 1;
}
/// <summary>邊長 10 的四面體,offset 平移</summary>
/// <summary>A tetrahedron with side length 10, translated by offset.</summary>
private static MeshGeometry3D Tetra(Vector3D offset)
{
Point3D P(double x, double y, double z) => new Point3D(x, y, z) + offset;
+313
View File
@@ -0,0 +1,313 @@
using System.ComponentModel;
using System.Globalization;
using System.IO;
using System.Reflection;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Windows.Media.Media3D;
using CADability.GeoObject;
using STPViewer.Models;
using STPViewer.Services;
using STPViewer.ViewModels;
using IOPath = System.IO.Path;
namespace SmokeTest;
internal static class LocalizationTest
{
private static readonly Regex FormatIndex = new(@"(?<!\{)\{(\d+)(?:[^{}]*)\}(?!\})", RegexOptions.Compiled);
public static int Run()
{
int failures = 0;
Exception? error = null;
var thread = new Thread(() =>
{
try { failures = RunCore(); }
catch (Exception ex) { error = ex; }
});
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
thread.Join();
if (error is not null)
{
Console.WriteLine($"LocalizationTest: {error}");
return 1;
}
return failures;
}
private static int RunCore()
{
int failures = CheckTemporaryPacks() + CheckBundledPacks() + CheckViewModel() + CheckSettings();
Console.WriteLine(failures == 0 ? "LocalizationTest: all passed" : $"LocalizationTest: {failures} failed");
return failures;
}
private static int CheckTemporaryPacks()
{
int failures = 0;
string directory = IOPath.Combine(IOPath.GetTempPath(), "stpviewer_i18n_" + Guid.NewGuid().ToString("N"));
try
{
failures += Check("missing directory loads without languages", new LocalizationService(directory).Languages.Count == 0);
Directory.CreateDirectory(directory);
failures += Check("empty directory loads without languages", new LocalizationService(directory).Languages.Count == 0);
WritePack(directory, "en", "English", new()
{
["Ui.Sample"] = "View", ["Ui.Fallback"] = "Fallback {0}",
["Ui.BadBrace"] = "English {0}", ["Ui.BadIndex"] = "English {0}",
["Ui.BothBad"] = "English {"
});
WritePack(directory, "zh_cn", "Simplified", new()
{
["Ui.Sample"] = "CN_VIEW", ["Ui.BadBrace"] = "Chinese {",
["Ui.BadIndex"] = "Chinese {1}", ["Ui.BothBad"] = "Chinese {"
});
WritePack(directory, "zh_tw", "Traditional", new() { ["Ui.Sample"] = "TW_VIEW" });
WritePack(directory, "pirate_dialect", "Pirate", new() { ["Ui.Sample"] = "ARR_VIEW" });
File.WriteAllText(IOPath.Combine(directory, "broken.json"), "{invalid json");
File.WriteAllText(IOPath.Combine(directory, "no_key.json"), "{\"data\":{\"Ui.Sample\":\"unused\"}}");
File.WriteAllText(IOPath.Combine(directory, "no_data.json"), "{\"key\":\"Missing data\"}");
File.WriteAllText(IOPath.Combine(directory, "non_string.json"), "{\"key\":\"Invalid\",\"data\":{\"Ui.Sample\":42}}");
CultureInfo oldUi = CultureInfo.CurrentUICulture;
LocalizationService service;
try
{
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("en-US");
service = new LocalizationService(directory);
}
finally { CultureInfo.CurrentUICulture = oldUi; }
failures += Check("valid packs isolated from malformed files", service.Languages.Count == 4 &&
service.Languages.Select(l => l.Id).Order().SequenceEqual(new[] { "en", "pirate_dialect", "zh_cn", "zh_tw" }));
failures += Check("display names and data loaded", service.Languages.Any(l =>
l.Id == "pirate_dialect" && l.Key == "Pirate" && l.Data["Ui.Sample"] == "ARR_VIEW"));
failures += Check("English selected from system culture", service.Language == "en");
failures += Check("legacy Chinese IDs resolved", service.ResolveLanguage("zh-CN") == "zh_cn" &&
service.ResolveLanguage("zh-TW") == "zh_tw");
failures += Check("Chinese script and region aliases resolved", service.ResolveLanguage("zh-Hans") == "zh_cn" &&
service.ResolveLanguage("zh-SG") == "zh_cn" && service.ResolveLanguage("zh-Hant") == "zh_tw" &&
service.ResolveLanguage("zh-HK") == "zh_tw" && service.ResolveLanguage("zh-MO") == "zh_tw");
failures += Check("custom language ID resolved", service.ResolveLanguage("pirate-dialect") == "pirate_dialect");
failures += Check("unknown language falls back", service.ResolveLanguage("unknown") == "en");
failures += Check("invalid custom ID falls back", service.ResolveLanguage("pirate_other") == "en");
CultureInfo beforeNull = CultureInfo.CurrentUICulture;
try
{
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("zh-HK");
failures += Check("null language uses system UI culture", service.ResolveLanguage(null) == "zh_tw");
}
finally { CultureInfo.CurrentUICulture = beforeNull; }
LanguageDefinition chinese = service.Languages.Single(l => l.Id == "zh_cn");
bool selectedNotified = false, indexerNotified = false;
chinese.PropertyChanged += (_, e) => selectedNotified |= e.PropertyName == nameof(LanguageDefinition.IsSelected);
((INotifyPropertyChanged)service).PropertyChanged += (_, e) => indexerNotified |= e.PropertyName == "Item[]";
service.SetLanguage("zh-CN");
failures += Check("selection and indexer notify", service.Language == "zh_cn" &&
chinese.IsSelected && selectedNotified && indexerNotified);
failures += Check("selected data and fallback", service["Ui.Sample"] == "CN_VIEW" &&
service["Ui.Fallback"] == "Fallback {0}" && service["Ui.Missing"] == "Ui.Missing");
failures += Check("malformed brace uses English format", service.FormatText("Ui.BadBrace", "value") == "English value");
failures += Check("missing format argument uses English format", service.FormatText("Ui.BadIndex", "value") == "English value");
failures += Check("invalid English format returns key", service.FormatText("Ui.BothBad", "value") == "Ui.BothBad");
service.SetLanguage("pirate_dialect");
failures += Check("custom language selected", service.Language == "pirate_dialect" &&
service["Ui.Sample"] == "ARR_VIEW");
WritePack(directory, "new_language", "New language", new() { ["Ui.Sample"] = "NEW_VIEW" });
WritePack(directory, "en", "English", new() { ["Ui.Sample"] = "Updated view", ["Ui.Fallback"] = "Fallback {0}" });
failures += Check("existing instance does not hot reload", service.Languages.Count == 4 &&
service.Languages.Single(l => l.Id == "en").Data["Ui.Sample"] == "View");
var reloaded = new LocalizationService(directory);
failures += Check("new instance rescans files and values", reloaded.Languages.Count == 5 &&
reloaded.Languages.Single(l => l.Id == "en").Data["Ui.Sample"] == "Updated view");
File.Delete(IOPath.Combine(directory, "zh_tw.json"));
var withoutTraditional = new LocalizationService(directory);
failures += Check("missing language not listed", withoutTraditional.Languages.All(l => l.Id != "zh_tw"));
failures += Check("missing legacy ID falls back", withoutTraditional.ResolveLanguage("zh-TW") == "en");
}
finally
{
if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true);
}
return failures;
}
private static void WritePack(string directory, string id, string name, Dictionary<string, string> data) =>
File.WriteAllText(IOPath.Combine(directory, id + ".json"), JsonSerializer.Serialize(new { key = name, data }));
private static int CheckBundledPacks()
{
int failures = 0;
LocalizationService service = LocalizationService.Instance;
failures += Check("bundled languages available", new[] { "en", "zh_cn", "zh_tw" }
.All(id => service.Languages.Any(l => l.Id == id)));
LanguageDefinition? english = service.Languages.FirstOrDefault(l => l.Id == "en");
if (english is null) return failures;
failures += Check("bundled English data nonempty", english.Data.Count > 0 &&
english.Data.Values.All(v => !string.IsNullOrWhiteSpace(v)));
int formatFailures = 0;
foreach (LanguageDefinition language in service.Languages)
foreach ((string key, string value) in language.Data)
{
if (string.IsNullOrWhiteSpace(value)) formatFailures++;
int[] indices = Indices(value);
if (english.Data.TryGetValue(key, out string? fallback) && !Indices(fallback).SequenceEqual(indices))
{
Console.WriteLine($" FAIL: {language.Id} format arguments differ for {key}");
formatFailures++;
}
object[] args = Enumerable.Range(0, indices.DefaultIfEmpty(-1).Max() + 1)
.Select(i => (object)i).ToArray();
try { _ = string.Format(CultureInfo.InvariantCulture, value, args); }
catch (FormatException)
{
Console.WriteLine($" FAIL: {language.Id} format syntax for {key}");
formatFailures++;
}
}
failures += Check("bundled data and format strings valid", formatFailures == 0);
failures += Check("missing key returns key", service["__missing_localization_key__"] == "__missing_localization_key__");
return failures;
}
private static int CheckViewModel()
{
int failures = 0;
LocalizationService service = LocalizationService.Instance;
string originalLanguage = service.Language;
CultureInfo numericCulture = CultureInfo.CurrentCulture;
try
{
service.SetLanguage("en");
var vm = new MainViewModel();
vm.InitializeLocalization();
try
{
string oldLabel = vm.QuickBar["Import"].Label;
bool labelChanged = false;
vm.QuickBar["Import"].PropertyChanged += (_, e) =>
labelChanged |= e.PropertyName == nameof(QuickBarItemViewModel.Label);
MeasurementResult result = new MeasurementService().MeasureDistance(
new Point3D(0, 0, 0), new Point3D(25.4, 0, 0), "D1", 0.2);
vm.Measurements.Add(result);
vm.UseInch = true;
string oneInch = 1d.ToString("F4", numericCulture) + " in";
string beforeTitle = result.Title, beforeDetail = result.Detail;
string beforeLabel = result.DynamicLabels.Single().Label.Text;
var (distanceLabel, distanceTextFor) = result.DynamicLabels.Single();
int labelRefreshes = 0;
result.DynamicLabels[0] = (distanceLabel, u =>
{
labelRefreshes++;
return distanceTextFor(u);
});
bool titleChanged = false, detailChanged = false;
result.PropertyChanged += (_, e) =>
{
titleChanged |= e.PropertyName == nameof(MeasurementResult.Title);
detailChanged |= e.PropertyName == nameof(MeasurementResult.Detail);
};
vm.ChangeLanguageCommand.Execute("zh_tw");
failures += Check("VM selects language", service.Language == "zh_tw" &&
service.Languages.Single(l => l.Id == "zh_tw").IsSelected);
failures += Check("existing distance detail translated", beforeDetail != result.Detail &&
titleChanged && detailChanged && result.Detail.Contains(oneInch, StringComparison.Ordinal));
failures += Check("distance and number culture preserved", vm.UseInch &&
CultureInfo.CurrentCulture.Equals(numericCulture) && beforeTitle == result.Title &&
result.Title.Contains(oneInch, StringComparison.Ordinal));
failures += Check("3D distance label refreshed", labelRefreshes == 1 &&
beforeLabel == oneInch && result.DynamicLabels.Single().Label.Text == beforeLabel);
failures += Check("quick bar label refreshed", labelChanged && vm.QuickBar["Import"].Label != oldLabel);
LanguageDefinition selected = service.Languages.Single(l => l.Id == "zh_tw");
bool reselected = false;
selected.PropertyChanged += (_, e) => reselected |= e.PropertyName == nameof(LanguageDefinition.IsSelected);
vm.ChangeLanguageCommand.Execute("zh_tw");
failures += Check("reselecting current language restores check", reselected && selected.IsSelected);
}
finally { vm.DisposeLocalization(); }
failures += CheckImportedNames(service);
}
finally { service.SetLanguage(originalLanguage); }
return failures;
}
private static int CheckImportedNames(LocalizationService service)
{
int failures = 0;
MethodInfo blockNode = typeof(StepImportService).GetMethod("BlockNode", BindingFlags.NonPublic | BindingFlags.Instance)!;
var importer = new StepImportService();
service.SetLanguage("en");
ImportedNode Import(Block block) => (ImportedNode)blockNode.Invoke(importer, [block])!;
var namedSolidBlock = Block.Construct();
namedSolidBlock.Name = "Solid";
namedSolidBlock.Add(Solid.Construct());
ImportedNode namedSolid = Import(namedSolidBlock);
var namedShellBlock = Block.Construct();
namedShellBlock.Name = "Shell";
namedShellBlock.Add(Shell.Construct());
ImportedNode namedShell = Import(namedShellBlock);
var defaultSolidBlock = Block.Construct();
defaultSolidBlock.Add(Solid.Construct());
ImportedNode defaultSolid = Import(defaultSolidBlock);
var defaultShellBlock = Block.Construct();
defaultShellBlock.Add(Shell.Construct());
ImportedNode defaultShellLeaf = Import(defaultShellBlock).Children.Single();
var collisionBlock = Block.Construct();
var realComponentShell = Shell.Construct();
realComponentShell.Name = "Component";
collisionBlock.Add(realComponentShell);
ImportedNode collision = Import(collisionBlock);
ModelNodeViewModel ViewModel(ImportedNode node) => new(0)
{
Name = node.Name,
NameKey = node.NameKey,
NameNumber = node.NameNumber
};
var namedSolidVm = ViewModel(namedSolid);
var namedShellVm = ViewModel(namedShell);
var defaultSolidVm = ViewModel(defaultSolid);
var defaultShellVm = ViewModel(defaultShellLeaf);
var collisionVm = ViewModel(collision);
service.SetLanguage("zh_cn");
failures += Check("explicit solid name retained", namedSolid.NameKey is null && namedSolidVm.Name == "Solid");
failures += Check("real block name retained", namedShell.NameKey is null && namedShellVm.Name == "Shell");
failures += Check("default solid name translated", defaultSolid.NameKey == "Runtime.SolidName" &&
defaultSolidVm.Name == service["Runtime.SolidName"]);
failures += Check("default shell name translated", defaultShellLeaf.NameKey == "Runtime.ShellName" &&
defaultShellVm.Name == service["Runtime.ShellName"]);
failures += Check("real child name wins collision", collision.NameKey is null && collisionVm.Name == "Component");
return failures;
}
private static int CheckSettings()
{
var settings = new AppSettings
{
Language = "zh-CN", WindowWidth = 1200, WindowHeight = 800, WindowLeft = 100, WindowTop = 100
};
AppSettings? restored = JsonSerializer.Deserialize<AppSettings>(JsonSerializer.Serialize(settings));
return Check("legacy ID round trips through settings JSON", restored?.Language == "zh-CN" &&
LocalizationService.Instance.ResolveLanguage(restored.Language) == "zh_cn");
}
private static int[] Indices(string format) => FormatIndex.Matches(format)
.Select(m => int.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture))
.Distinct().Order().ToArray();
private static int Check(string name, bool ok)
{
Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name}");
return ok ? 0 : 1;
}
}
+2 -2
View File
@@ -1,4 +1,4 @@
// 測試輔助:用 CADability 內嵌的 netDxf 產生標準 DXF 測試檔
// Generate a small standard DXF test file with the bundled netDxf library.
using netDxf;
using netDxf.Entities;
@@ -12,6 +12,6 @@ internal static class MakeDxf
doc.Entities.Add(new Line(new Vector3(0, 0, 0), new Vector3(100, 50, 0)));
doc.Entities.Add(new Circle(new Vector3(50, 25, 0), 10));
doc.Save(path);
Console.WriteLine($"已產生 {path}");
Console.WriteLine($"Generated {path}");
}
}
+41
View File
@@ -0,0 +1,41 @@
using System.IO;
namespace SmokeTest;
/// <summary>Generate a binary STL grid with two triangles per cell and noncoplanar vertices.</summary>
internal static class MakeStl
{
public static void Run(string path, int triCount)
{
// Two triangles per cell; use a square grid large enough for the requested count.
int cells = System.Math.Max(1, triCount / 2);
int grid = (int)System.Math.Ceiling(System.Math.Sqrt(cells));
int tris = grid * grid * 2;
using var fs = File.Create(path);
using var bw = new BinaryWriter(fs);
bw.Write(new byte[80]); // header
bw.Write((uint)tris); // triangle count
static void WriteTri(BinaryWriter w, float ax, float ay, float az,
float bx, float by, float bz, float cx, float cy, float cz)
{
for (int i = 0; i < 3; i++) w.Write(0f); // normal (0,0,0)
w.Write(ax); w.Write(ay); w.Write(az);
w.Write(bx); w.Write(by); w.Write(bz);
w.Write(cx); w.Write(cy); w.Write(cz);
w.Write((ushort)0); // attribute byte count
}
float Z(int x, int y) => (float)(System.Math.Sin(x * 0.1) * System.Math.Cos(y * 0.1) * 5.0);
for (int y = 0; y < grid; y++)
for (int x = 0; x < grid; x++)
{
float x0 = x, x1 = x + 1, y0 = y, y1 = y + 1;
WriteTri(bw, x0, y0, Z(x, y), x1, y0, Z(x + 1, y), x1, y1, Z(x + 1, y + 1));
WriteTri(bw, x0, y0, Z(x, y), x1, y1, Z(x + 1, y + 1), x0, y1, Z(x, y + 1));
}
Console.WriteLine($"Wrote {tris:N0} triangles to {path} ({new FileInfo(path).Length:N0} bytes)");
}
}
+88 -79
View File
@@ -1,80 +1,89 @@
// 無 UI smoke test:驗證 CAD 檔解析 → 三角化 → 裝配樹 整條匯入管線
using System.Diagnostics;
using System.IO;
using STPViewer.Services;
// Headless smoke tests for CAD import, triangulation, and the assembly tree.
using System.Diagnostics;
using System.IO;
using STPViewer.Services;
Console.OutputEncoding = System.Text.Encoding.UTF8;
if (args.Length == 0)
{
Console.WriteLine("Usage: SmokeTest [--tree] <file.stp|.stl|.dxf> [...]");
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] == "--localization-test")
return SmokeTest.LocalizationTest.Run() == 0 ? 0 : 2;
Console.OutputEncoding = System.Text.Encoding.UTF8;
if (args.Length == 0)
{
Console.WriteLine("用法: SmokeTest [--tree] <file.stp|.stl|.dxf> [...]");
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);
}
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($" [stage] {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(" --- assembly tree ---");
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 ? "" : ", no B-rep")}]");
foreach (ImportedNode c in n.Children) PrintNode(c, indent + 1);
}
+20 -23
View File
@@ -5,15 +5,13 @@ using System.Text;
using System.Windows.Media;
using System.Windows.Media.Media3D;
using STPViewer.Services;
using IOPath = System.IO.Path; // CADability.GeoObject.Path(曲線)撞名
using IOPath = System.IO.Path; // Avoid the CADability.GeoObject.Path name collision.
namespace SmokeTest;
/// <summary>
/// STL 匯出驗證(無 UI):
/// 1) binary — 已知立方體網格寫出 → 解析 header/三角形數/座標範圍比對(含縮放)
/// 2) ASCII — facet 數與 solid/endsolid 結構
/// 3) 退化三角形(零面積)自動濾除
/// Headless STL export checks:
/// binary round trip, ASCII facet structure, and degenerate triangle filtering.
/// </summary>
public static class StlExportTest
{
@@ -24,20 +22,20 @@ public static class StlExportTest
string dir = IOPath.Combine(IOPath.GetTempPath(), "stpviewer_stl_test");
Directory.CreateDirectory(dir);
MeshGeometry3D cube = BuildCube(10); // 10×10×10 mm、12 三角形
MeshGeometry3D cube = BuildCube(10); // 10x10x10 mm, 12 triangles.
// 1) binary:三角形數 + 縮放後座標範圍
// Binary triangle count and scaled coordinate range.
{
string f = IOPath.Combine(dir, "cube_bin.stl");
double scale = 1.0 / 25.4; // mm → inch
double scale = 1.0 / 25.4; // mm to inches.
int written = StlExportService.Write(f, new[] { cube }, ascii: false, scale);
(uint count, float min, float max) = ReadBinary(f);
bool ok = written == 12 && count == 12
&& Math.Abs(min - 0f) < 1e-5 && Math.Abs(max - 10f / 25.4f) < 1e-4;
Report(ref failed, ok, $"binary 寫出/回讀 12 三角形、inch 縮放範圍 [{min:F4}, {max:F4}]");
Report(ref failed, ok, $"binary round trip: 12 triangles, inch range [{min:F4}, {max:F4}]");
}
// 2) ASCII:facet 數 + 結構
// ASCII facet count and structure.
{
string f = IOPath.Combine(dir, "cube_ascii.stl");
int written = StlExportService.Write(f, new[] { cube }, ascii: true, scale: 1.0);
@@ -46,15 +44,15 @@ public static class StlExportTest
bool ok = written == 12 && facets == 12
&& text.StartsWith("solid ", StringComparison.Ordinal)
&& text.Contains("endsolid");
Report(ref failed, ok, $"ASCII 結構完整、facet = {facets}");
Report(ref failed, ok, $"ASCII structure valid, facets = {facets}");
}
// 3) 退化三角形濾除:加一個零面積三角形 → 仍是 12
// A zero-area triangle must be filtered from the export.
{
var degenerate = new MeshGeometry3D();
degenerate.Positions.Add(new Point3D(0, 0, 0));
degenerate.Positions.Add(new Point3D(1, 1, 1));
degenerate.Positions.Add(new Point3D(2, 2, 2)); // 共線 → 面積 0
degenerate.Positions.Add(new Point3D(2, 2, 2)); // Collinear: zero area.
degenerate.TriangleIndices.Add(0);
degenerate.TriangleIndices.Add(1);
degenerate.TriangleIndices.Add(2);
@@ -64,17 +62,16 @@ public static class StlExportTest
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})");
Report(ref failed, ok, $"degenerate triangle filtered ({written} written)");
}
// 4)(可選)真實 STEP:B-rep 精細重新三角化 → 寫 binary STL 驗證
//(不測「較粗」:CADability GetTriangulation 對比快取粗的精度回傳既有快取,粗化無效 → 功能已移除)
// Optional real STEP input: retessellate B-rep geometry at fine precision.
if (stepPath is not null)
{
var data = new StepImportService().Import(stepPath);
var geos = Collect(data.Root).ToList();
int importTris = data.Root.TriangleCount;
Console.WriteLine($" 來源 {IOPath.GetFileName(stepPath)}:{geos.Count} 個 B-rep 物件、匯入網格 {importTris:N0} 三角形");
Console.WriteLine($" Source {IOPath.GetFileName(stepPath)}: {geos.Count} B-rep objects, {importTris:N0} imported triangles");
var meshes = StlExportService.Tessellate(
geos, StlExportService.PrecisionFactor(StlMeshQuality.Fine));
@@ -82,13 +79,13 @@ public static class StlExportTest
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× 實測明顯更細
$"fine retessellation: {meshes.Count} faces, {written:N0} triangles");
// CADability's triangle count is not monotonic in precision; 0.15x is measurably finer.
Report(ref failed, written > importTris,
$"精細({written:N0})> 匯入預設({importTris:N0})");
$"fine ({written:N0}) > imported default ({importTris:N0})");
}
Console.WriteLine(failed == 0 ? "StlExportTest: PASS" : $"StlExportTest: FAIL({failed} 項)");
Console.WriteLine(failed == 0 ? "StlExportTest: PASS" : $"StlExportTest: FAIL ({failed} checks)");
return failed;
}
@@ -106,7 +103,7 @@ public static class StlExportTest
if (!ok) failed++;
}
/// <summary>0..size 的軸對齊立方體,12 個三角形(法向不講究,STL 寫出時自算)</summary>
/// <summary>Axis-aligned cube from 0 to size, with 12 triangles.</summary>
private static MeshGeometry3D BuildCube(double s)
{
var m = new MeshGeometry3D();
@@ -131,7 +128,7 @@ public static class StlExportTest
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 開頭");
throw new InvalidDataException("Binary STL header must not begin with solid");
uint count = br.ReadUInt32();
float min = float.MaxValue, max = float.MinValue;
for (uint t = 0; t < count; t++)
+1 -1
View File
@@ -1,4 +1,4 @@
// 實驗工具:印出 STEP 匯入後的物件階層(HierarchyToBlocks=true)
// Print the STEP object hierarchy after import with HierarchyToBlocks enabled.
using CADability;
using CADability.GeoObject;