Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e355cb4363 | ||
|
|
96e15013c3 | ||
|
|
d5a0f5ef57 |
+70
-4
@@ -94,7 +94,7 @@ STPViewer/
|
|||||||
└── src/
|
└── src/
|
||||||
└── STPViewer/
|
└── STPViewer/
|
||||||
├── STPViewer.csproj # net8.0-windows, UseWPF
|
├── STPViewer.csproj # net8.0-windows, UseWPF
|
||||||
├── App.xaml / App.xaml.cs
|
├── App.xaml / App.xaml.cs # 全域例外處理(log + 訊息框存活;%LOCALAPPDATA%\STPViewer\error.log)
|
||||||
├── MainWindow.xaml / .cs # 版面 + 滑鼠拾取事件
|
├── MainWindow.xaml / .cs # 版面 + 滑鼠拾取事件
|
||||||
│
|
│
|
||||||
├── Models/
|
├── Models/
|
||||||
@@ -110,8 +110,13 @@ STPViewer/
|
|||||||
│ ├── RigidAlign.cs # 三點對齊/旋轉的剛體變換數學(Matrix3D列向量 ↔ ModOp行向量 轉換)
|
│ ├── RigidAlign.cs # 三點對齊/旋轉的剛體變換數學(Matrix3D列向量 ↔ ModOp行向量 轉換)
|
||||||
│ └── SectionService.cs # 剖面:網格/線段半空間裁切
|
│ └── SectionService.cs # 剖面:網格/線段半空間裁切
|
||||||
│
|
│
|
||||||
└── ViewModels/
|
└── ViewModels/ # MainViewModel 為 partial class,依職責分檔
|
||||||
├── MainViewModel.cs # 樹集合、量測集合、剖面、單位、匯出
|
├── MainViewModel.cs # 核心:匯入、裝配樹、量測、剛體變換、邊線、單位
|
||||||
|
├── MainViewModel.Drag.cs # 拖曳模式
|
||||||
|
├── MainViewModel.Gizmo.cs # 三軸操作器 + 疊圖層相機
|
||||||
|
├── MainViewModel.Section.cs # 剖面(v0.4.0 起背景平行裁切)
|
||||||
|
├── MainViewModel.Interference.cs # 干涉檢查指令
|
||||||
|
├── MainViewModel.Export.cs # CSV / 截圖匯出
|
||||||
└── ModelNodeViewModel.cs # 裝配樹節點(可見性/顏色 cascade)
|
└── ModelNodeViewModel.cs # 裝配樹節點(可見性/顏色 cascade)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -357,9 +362,70 @@ WPF retained-mode 每幀重走 visual tree → frame rate 崩。瀏覽模式因
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Development Phases — 第五輪(效能 + 穩定性 v0.4.0,全部完成)
|
||||||
|
|
||||||
|
### Phase 13 — 效能 + 穩定性(工作量:M)
|
||||||
|
|
||||||
|
**效能:**
|
||||||
|
- [x] 剖面裁切背景化 + 平行化 — `ApplySection` 改 async:UI 執行緒快照 (model, frozen mesh)、
|
||||||
|
`Task.Run` + `Parallel.For` 全場景裁切、回 UI 一次換上。`_sectionApplying`/`_sectionReapply` guard:
|
||||||
|
裁切期間的新變更(拉滑桿/換軸/零件平移)→ 完成後用最新參數重跑一輪,最終狀態必為最新。
|
||||||
|
大檔(64k 面)拉剖面滑桿不再凍結 UI
|
||||||
|
- [x] `TransformRoot` 網格變換面級平行化 — 來源/輸出 mesh 皆 frozen、各面獨立所以安全;
|
||||||
|
B-rep `Modify` 與視覺樹賦值維持循序(CADability 非執行緒安全 / WPF 執行緒親和)。
|
||||||
|
大組件拖曳/操作器放開的烘焙時間大減
|
||||||
|
- [x] 干涉間隙精修加速 — `ApproxMinDistance` 精修改為 AABB 平方距離下界快速拒絕 + `Parallel.For`
|
||||||
|
分段掃描(thread-local best,lock 只在合併時),大檔全三角形掃描成本大減
|
||||||
|
- [x] 合併網格邏輯去重複 — `BuildMergedMesh`/`RebuildMerged` 共用 `MergeMeshes`(面序不變,
|
||||||
|
`_mergedFaceRanges` 頂點邊界維持有效);`MeasurementService.ClosestPointOnTriangle`
|
||||||
|
改用 `InterferenceService` 的同一實作
|
||||||
|
|
||||||
|
**穩定性:**
|
||||||
|
- [x] 全域例外處理(App.xaml.cs)— `DispatcherUnhandledException`(記 log + 訊息框 + Handled 讓程式存活)、
|
||||||
|
`TaskScheduler.UnobservedTaskException`(SetObserved)、`AppDomain.UnhandledException`(致命前留記錄);
|
||||||
|
log 位於 `%LOCALAPPDATA%\STPViewer\error.log`
|
||||||
|
- [x] `IsBusy` 指令防護 — 匯入 / 旋轉 90° / 干涉檢查在背景運算期間停用(`CanExecute`),
|
||||||
|
避免干涉檢查中零件被轉走造成結果錯位;`ImportFilesAsync` 整批維持 busy、
|
||||||
|
重入(拖放/命令列)直接擋掉(StepImportService 有共享狀態、非重入安全)
|
||||||
|
- [x] `MainViewModel`(原 1,476 行)拆 partial class — Drag / Gizmo / Section / Interference / Export 各自成檔
|
||||||
|
|
||||||
|
**驗收:** ClipTest 5 項、AlignTest 8 項、InterferenceTest 3 情境全過;test.stp 匯入 smoke test 通過;建置 0 警告
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Future Extensions(下一輪)
|
## Future Extensions(下一輪)
|
||||||
|
|
||||||
- 剖切面封口(cap)填實(目前剖開處可見內部背面材質)
|
依價值/成本排序(v0.4.0 盤點):
|
||||||
|
|
||||||
|
### 低成本高價值(建議下一輪優先)
|
||||||
|
|
||||||
|
- **圓心吸附** — 距離模式點到圓邊時吸附到圓心 → 直接量兩孔 pitch(心到心距離),
|
||||||
|
連接器 pin pitch 確認的殺手級量測;實作只需在 `MeasurementService.Snap` 加掃該面的圓形 Edge
|
||||||
|
- **Esc 取消** — 兩段式量測(距離/角度/三點對齊)按 Esc 清除 pending、再按退回瀏覽模式;
|
||||||
|
順手加量測模式快速鍵(P 點 / D 距離 / E 邊 / F 面 / C 圓…)
|
||||||
|
- **GridSplitter** — 樹面板 / 量測面板寬度可調(現為固定 280 / 300 px,深層裝配樹名稱會被截斷)
|
||||||
|
- **標準視圖 + 正交投影** — 前/上/右/等角視圖按鈕 + `OrthographicCamera` 切換(Helix 原生支援);
|
||||||
|
工程師確認尺寸時正交投影是剛需,透視會騙眼睛
|
||||||
|
- **節點外形尺寸** — 樹節點顯示該零件/組件 L×W×H(`Bounds` 已有,加進 Stats/tooltip 或做成一種量測)
|
||||||
|
- **隔離顯示(Isolate)** — 樹節點右鍵「只顯示此節點」/「反轉顯示」,大組件找零件比逐個勾 checkbox 快
|
||||||
|
- **記住設定 + 最近檔案** — 視窗大小、mm/inch、邊線開關存 user settings;匯入按鈕加 MRU 下拉
|
||||||
|
|
||||||
|
### 中等成本
|
||||||
|
|
||||||
|
- **體積/質心** — 封閉實體用網格 signed volume(各三角形對原點的四面體有向體積加總)近似,
|
||||||
|
對 match 驗證與料號確認有用
|
||||||
|
- **樹搜尋/過濾** — 裝配樹上百零件時依名稱過濾
|
||||||
|
- **干涉支援 >2 個可見檔** — 跳選擇配對對話框或兩兩檢查(現硬性要求剛好 2 個)
|
||||||
|
- **對齊結果匯出 STEP** — 把目前場景(對齊後位置)輸出成新 STEP 檔,把配合結果交接出去;
|
||||||
|
需先驗證 CADability 有無 STEP writer;寫「新檔」不違反唯讀原則
|
||||||
|
- **任意剖面** — 三點定義剖切平面 + 位置數值輸入框(`ClipMesh` 本就吃任意 plane point/normal,純 UI 工作)
|
||||||
|
- **大檔重開快取** — 匯入成功後把三角網格 + 裝配樹序列化成 sidecar 快取檔(以來源檔 hash 驗證),
|
||||||
|
重開同檔免等 CADability 解析(39MB 實測 276 秒)。取捨:B-rep 無法快取 → 量測退化成網格模式
|
||||||
|
或背景補載;是否值得取決於「重複開同一大檔」的頻率
|
||||||
|
|
||||||
|
### 既有清單(維持)
|
||||||
|
|
||||||
|
- 剖切面封口(cap)填實(目前剖開處可見內部背面材質;需對裁切輪廓三角化補面,難度最高,建議最後做)
|
||||||
- 樹節點三態 checkbox(部分子節點隱藏時顯示中間態)
|
- 樹節點三態 checkbox(部分子節點隱藏時顯示中間態)
|
||||||
- IGES 支援(需引入其他幾何核心或自寫 reader)
|
- IGES 支援(需引入其他幾何核心或自寫 reader)
|
||||||
- 量測結果匯出含截圖的 PDF 報告
|
- 量測結果匯出含截圖的 PDF 報告
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ dotnet publish src/STPViewer -c Release -o publish/STPViewer
|
|||||||
## 開發慣例
|
## 開發慣例
|
||||||
|
|
||||||
- MVVM:邏輯寫 ViewModel/Service,code-behind 只做純 View 事件(滑鼠拾取轉發)
|
- 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`) 反查(剖面模式逐面拾取靠這個,**不可移除逐面結構**)
|
- 一個 B-rep Face = 一個 `GeometryModel3D`,用 `Dictionary<Model3D, FaceInfo>` (`_faceMap`) 反查(剖面模式逐面拾取靠這個,**不可移除逐面結構**)
|
||||||
- 渲染雙模式(降 draw call):每個 leaf 同時持有 `FacesContent`(逐面,剖面用)與 `MergedContent`(整零件合併成 1 個 `GeometryModel3D`)。
|
- 渲染雙模式(降 draw call):每個 leaf 同時持有 `FacesContent`(逐面,剖面用)與 `MergedContent`(整零件合併成 1 個 `GeometryModel3D`)。
|
||||||
`ApplyRenderMode()` 依狀態切 `BodyVisual.Content`:**非剖面(瀏覽+量測皆是)→ 合併網格**;**剖面 → 逐面**(要顯示各面裁切後幾何)。
|
`ApplyRenderMode()` 依狀態切 `BodyVisual.Content`:**非剖面(瀏覽+量測皆是)→ 合併網格**;**剖面 → 逐面**(要顯示各面裁切後幾何)。
|
||||||
@@ -41,10 +45,19 @@ dotnet publish src/STPViewer -c Release -o publish/STPViewer
|
|||||||
- 量測文字一律 `Func<UnitSystem,string>` 延後產生(mm⇄inch 即時切換);內部數值永遠存 mm
|
- 量測文字一律 `Func<UnitSystem,string>` 延後產生(mm⇄inch 即時切換);內部數值永遠存 mm
|
||||||
- 裝配樹節點(`ModelNodeViewModel`)的可見性/邊線/顏色向下 cascade
|
- 裝配樹節點(`ModelNodeViewModel`)的可見性/邊線/顏色向下 cascade
|
||||||
- 剖面只換 `GeometryModel3D.Geometry`(`FaceInfo.Mesh` 保留原始 frozen mesh 供還原與量測)
|
- 剖面只換 `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`
|
- 剛體變換(兩點對齊/旋轉 90°/三點對齊)統一走 `TransformRoot(root, ModOp, Matrix3D)`:B-rep 用 ModOp 對 Solid/Shell 整體 `Modify`
|
||||||
(勿逐面位移,會重複位移共用邊),網格/合併網格/邊線/邊界同步重算;變換後量測已失效要 `ClearMeasurements()`。
|
(勿逐面位移,會重複位移共用邊),網格/合併網格/邊線/邊界同步重算;變換後量測已失效要 `ClearMeasurements()`。
|
||||||
**op 與 m 必須是同一個變換** — 數學在 `Services/RigidAlign.cs`:WPF `Matrix3D` 是「列向量」約定、CADability `ModOp` 是「行向量」約定,
|
**op 與 m 必須是同一個變換** — 數學在 `Services/RigidAlign.cs`:WPF `Matrix3D` 是「列向量」約定、CADability `ModOp` 是「行向量」約定,
|
||||||
`ToModOp` 負責轉置轉換,改動務必跑 `SmokeTest --align-test` 驗證兩種表示一致,否則 B-rep 與顯示網格會悄悄分家
|
`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 —
|
- 拖曳模式(`MeasureMode.Drag`):拖曳中只掛**暫時 `TranslateTransform3D`**(GPU 免費),放開才一次性 `TranslateRoot` 烘進 B-rep —
|
||||||
**不要改成拖曳中逐幀 TransformRoot**(大檔每幀重建網格會卡死)。2D→3D 用 Helix `UnProject`(過錨點、法向=相機 LookDirection 的平面)。
|
**不要改成拖曳中逐幀 TransformRoot**(大檔每幀重建網格會卡死)。2D→3D 用 Helix `UnProject`(過錨點、法向=相機 LookDirection 的平面)。
|
||||||
合併網格的 hit-test 走 `_mergedMap`(合併 Model → leaf);拖曳中邊線用 `_edgesSuspended` 暫停
|
合併網格的 hit-test 走 `_mergedMap`(合併 Model → leaf);拖曳中邊線用 `_edgesSuspended` 暫停
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# v0.3.2 — Measuring large assemblies no longer lags when you orbit
|
||||||
|
|
||||||
|
## 🐛 Bug fixes
|
||||||
|
|
||||||
|
**Smooth orbit while measuring large models**
|
||||||
|
* Fixed: with a large assembly loaded, switching to any measurement tool
|
||||||
|
(Distance, Edge, Face, Circle, Angle, Face-distance) and then rotating the
|
||||||
|
view used to lag badly — it hitched every time the view came to rest. Plain
|
||||||
|
browsing stayed smooth; only measuring was affected.
|
||||||
|
* Measuring now rotates just as smoothly as browsing, at any model size.
|
||||||
|
* Picking accuracy is unchanged — circles/holes, faces, edges, angles and
|
||||||
|
face-distance all read exactly as before, including inner hole walls.
|
||||||
|
* Section-mode measurement is unaffected.
|
||||||
|
|
||||||
|
## 📦 Downloads
|
||||||
|
|
||||||
|
Two builds are produced:
|
||||||
|
|
||||||
|
| Build | Needs .NET 8 Desktop Runtime? | Notes |
|
||||||
|
|-------|-------------------------------|-------|
|
||||||
|
| **Standard** (`STPViewer_v0.3.2`) | ✅ Yes | Smaller; for machines that already have the runtime |
|
||||||
|
| **Portable** (`STPViewer_v0.3.2_portable`) | ❌ No | Runtime bundled — unzip and run, no install / admin needed |
|
||||||
|
|
||||||
|
Run `STPViewer v0.3.2.exe`.
|
||||||
|
|
||||||
|
**Full changelog:** [v0.3.1...v0.3.2](https://github.com/ETWen/STPViewer/compare/v0.3.1...v0.3.2)
|
||||||
@@ -1,13 +1,60 @@
|
|||||||
using System.Configuration;
|
using System;
|
||||||
using System.Data;
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
|
||||||
namespace STPViewer;
|
namespace STPViewer;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Interaction logic for App.xaml
|
/// 全域例外處理:UI 執行緒未攔截例外 → 記 log + 訊息框後盡量存活(不閃退);
|
||||||
|
/// 背景 Task / 非 UI 執行緒例外 → 記 log。log 位於 %LOCALAPPDATA%\STPViewer\error.log。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class App : Application
|
public partial class App : Application
|
||||||
{
|
{
|
||||||
|
private static readonly string LogPath = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"STPViewer", "error.log");
|
||||||
|
|
||||||
|
protected override void OnStartup(StartupEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnStartup(e);
|
||||||
|
|
||||||
|
// UI 執行緒(含 async void 回拋)的未攔截例外:記 log、提示、標記 Handled 讓程式存活。
|
||||||
|
// 若後續狀態已壞使用者可自行重啟;至少量測結果 / 已載模型不會直接蒸發。
|
||||||
|
DispatcherUnhandledException += (_, args) =>
|
||||||
|
{
|
||||||
|
LogException("DispatcherUnhandledException", args.Exception);
|
||||||
|
MessageBox.Show(
|
||||||
|
$"發生未預期的錯誤,操作已中止(程式將嘗試繼續運作)。\n\n{args.Exception.Message}\n\n" +
|
||||||
|
$"詳細記錄:{LogPath}",
|
||||||
|
"STPViewer 錯誤", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
args.Handled = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 沒被 await 的背景 Task 例外(finalizer 階段才浮出):記 log 並標記已觀察,避免行程終止
|
||||||
|
TaskScheduler.UnobservedTaskException += (_, args) =>
|
||||||
|
{
|
||||||
|
LogException("UnobservedTaskException", args.Exception);
|
||||||
|
args.SetObserved();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 其他非 UI 執行緒的致命例外:無法阻止終止,但至少留下記錄
|
||||||
|
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
|
||||||
|
LogException("AppDomainUnhandledException", args.ExceptionObject as Exception);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void LogException(string source, Exception? ex)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(LogPath)!);
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {source}");
|
||||||
|
sb.AppendLine(ex?.ToString() ?? "(null exception)");
|
||||||
|
sb.AppendLine(new string('-', 80));
|
||||||
|
File.AppendAllText(LogPath, sb.ToString(), Encoding.UTF8);
|
||||||
|
}
|
||||||
|
catch { /* logging 本身失敗不能再拋 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<UseWPF>true</UseWPF>
|
<UseWPF>true</UseWPF>
|
||||||
<Version>0.3.2</Version> <!-- 單一版號來源:標題與 publish-winapp 打包資料夾都讀這個 -->
|
<Version>0.4.0</Version> <!-- 單一版號來源:標題與 publish-winapp 打包資料夾都讀這個 -->
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -237,21 +237,43 @@ public static class InterferenceService
|
|||||||
if (d < best) { best = d; pa = trisA[i].A; pb = trisB[j].A; }
|
if (d < best) { best = d; pa = trisA[i].A; pb = trisB[j].A; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) 以最佳點為中心,精修:點 → 對方所有三角形
|
// 2) 以最佳點為中心,精修:點 → 對方所有三角形(AABB 平方距離快速拒絕 + 平行分段,
|
||||||
Point3D RefineAgainst(Point3D p, Triangle[] tris, ref double bestD, Point3D current)
|
// 初值來自步驟 1 已相當接近 → 絕大多數三角形被 AABB 直接刷掉)
|
||||||
|
(pb, best) = ClosestOnTriangles(pa, trisB, pb, best);
|
||||||
|
(pa, best) = ClosestOnTriangles(pb, trisA, pa, best);
|
||||||
|
return (pa, pb, best);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>點 p 到三角形集的最近點(帶初值;回傳更新後的最近點與距離)</summary>
|
||||||
|
private static (Point3D pt, double d) ClosestOnTriangles(Point3D p, Triangle[] tris, Point3D seedPt, double seedD)
|
||||||
{
|
{
|
||||||
Point3D bestPt = current;
|
object gate = new();
|
||||||
foreach (Triangle t in tris)
|
Point3D bestPt = seedPt;
|
||||||
|
double bestD = seedD;
|
||||||
|
|
||||||
|
System.Threading.Tasks.Parallel.For(0, tris.Length,
|
||||||
|
() => (D: seedD, Pt: seedPt, Found: false),
|
||||||
|
(i, _, local) =>
|
||||||
{
|
{
|
||||||
|
Triangle t = tris[i];
|
||||||
|
// AABB 平方距離下界 ≥ 目前最佳 → 不可能更近,略過(絕大多數三角形在此被刷掉)
|
||||||
|
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));
|
||||||
|
if (dx * dx + dy * dy + dz * dz >= local.D * local.D) return local;
|
||||||
|
|
||||||
Point3D q = ClosestPointOnTriangle(p, t.A, t.B, t.C);
|
Point3D q = ClosestPointOnTriangle(p, t.A, t.B, t.C);
|
||||||
double d = (q - p).Length;
|
double d = (q - p).Length;
|
||||||
if (d < bestD) { bestD = d; bestPt = q; }
|
return d < local.D ? (d, q, true) : local;
|
||||||
}
|
},
|
||||||
return bestPt;
|
local =>
|
||||||
}
|
{
|
||||||
pb = RefineAgainst(pa, trisB, ref best, pb);
|
if (!local.Found) return;
|
||||||
pa = RefineAgainst(pb, trisA, ref best, pa);
|
lock (gate)
|
||||||
return (pa, pb, best);
|
if (local.D < bestD) { bestD = local.D; bestPt = local.Pt; }
|
||||||
|
});
|
||||||
|
|
||||||
|
return (bestPt, bestD);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>點到三角形最近點(Ericson, Real-Time Collision Detection)</summary>
|
/// <summary>點到三角形最近點(Ericson, Real-Time Collision Detection)</summary>
|
||||||
|
|||||||
@@ -390,7 +390,7 @@ public class MeasurementService
|
|||||||
Point3D v = vp[i];
|
Point3D v = vp[i];
|
||||||
for (int t = 0; t + 2 < ti.Count; t += 3)
|
for (int t = 0; t + 2 < ti.Count; t += 3)
|
||||||
{
|
{
|
||||||
Point3D q = ClosestPointOnTriangle(v, tp[ti[t]], tp[ti[t + 1]], tp[ti[t + 2]]);
|
Point3D q = InterferenceService.ClosestPointOnTriangle(v, tp[ti[t]], tp[ti[t + 1]], tp[ti[t + 2]]);
|
||||||
double d = (q - v).Length;
|
double d = (q - v).Length;
|
||||||
if (d < best)
|
if (d < best)
|
||||||
{
|
{
|
||||||
@@ -406,39 +406,7 @@ public class MeasurementService
|
|||||||
return (pa, pb);
|
return (pa, pb);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>點到三角形最近點(Ericson, Real-Time Collision Detection)</summary>
|
// 點到三角形最近點:共用 InterferenceService.ClosestPointOnTriangle(v0.4.0 去重複)
|
||||||
private static Point3D ClosestPointOnTriangle(Point3D p, Point3D a, Point3D b, Point3D c)
|
|
||||||
{
|
|
||||||
Vector3D ab = b - a, ac = c - a, ap = p - a;
|
|
||||||
double d1 = Vector3D.DotProduct(ab, ap);
|
|
||||||
double d2 = Vector3D.DotProduct(ac, ap);
|
|
||||||
if (d1 <= 0 && d2 <= 0) return a;
|
|
||||||
|
|
||||||
Vector3D bp = p - b;
|
|
||||||
double d3 = Vector3D.DotProduct(ab, bp);
|
|
||||||
double d4 = Vector3D.DotProduct(ac, bp);
|
|
||||||
if (d3 >= 0 && d4 <= d3) return b;
|
|
||||||
|
|
||||||
double vc = d1 * d4 - d3 * d2;
|
|
||||||
if (vc <= 0 && d1 >= 0 && d3 <= 0)
|
|
||||||
return a + ab * (d1 / (d1 - d3));
|
|
||||||
|
|
||||||
Vector3D cp = p - c;
|
|
||||||
double d5 = Vector3D.DotProduct(ab, cp);
|
|
||||||
double d6 = Vector3D.DotProduct(ac, cp);
|
|
||||||
if (d6 >= 0 && d5 <= d6) return c;
|
|
||||||
|
|
||||||
double vb = d5 * d2 - d1 * d6;
|
|
||||||
if (vb <= 0 && d2 >= 0 && d6 <= 0)
|
|
||||||
return a + ac * (d2 / (d2 - d6));
|
|
||||||
|
|
||||||
double va = d3 * d6 - d5 * d4;
|
|
||||||
if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0)
|
|
||||||
return b + (c - b) * ((d4 - d3) / ((d4 - d3) + (d5 - d6)));
|
|
||||||
|
|
||||||
double denom = 1 / (va + vb + vc);
|
|
||||||
return a + ab * (vb * denom) + ac * (vc * denom);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── overlay helpers ─────────────────────────────────────────
|
// ─── overlay helpers ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Media.Media3D;
|
||||||
|
using HelixToolkit.Wpf;
|
||||||
|
using STPViewer.Models;
|
||||||
|
|
||||||
|
namespace STPViewer.ViewModels;
|
||||||
|
|
||||||
|
// ─── 拖曳模式(左鍵按住零件沿螢幕平面拖;放開才烘進 B-rep)──────────
|
||||||
|
public partial class MainViewModel
|
||||||
|
{
|
||||||
|
// 拖曳模式:暫時 Transform 跟著滑鼠,放開才一次性烘進 B-rep(TranslateRoot)
|
||||||
|
private ModelNodeViewModel? _dragRoot;
|
||||||
|
private Point3D _dragAnchor; // 命中點(世界座標),拖曳平面的錨點
|
||||||
|
private Vector3D _dragApplied; // 目前累計位移
|
||||||
|
private TranslateTransform3D? _dragTransform;
|
||||||
|
|
||||||
|
/// <summary>左鍵按下:命中零件則開始拖曳(回傳 true 表示要捕捉滑鼠)</summary>
|
||||||
|
public bool OnDragStart(Point position)
|
||||||
|
{
|
||||||
|
if (_viewport is null || IsBusy || CurrentMode != MeasureMode.Drag || _dragRoot is not null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// 兩種渲染內容都可能在場:合併網格查 _mergedMap、逐面(剖面時)查 _faceMap
|
||||||
|
ModelNodeViewModel? leaf = null;
|
||||||
|
Point3D anchor = default;
|
||||||
|
foreach (var h in _viewport.Viewport.FindHits(position))
|
||||||
|
{
|
||||||
|
if (h.Model is null) continue;
|
||||||
|
if (_faceMap.TryGetValue(h.Model, out FaceInfo? fi)) { leaf = fi.Owner; anchor = h.Position; break; }
|
||||||
|
if (_mergedMap.TryGetValue(h.Model, out ModelNodeViewModel? ml)) { leaf = ml; anchor = h.Position; break; }
|
||||||
|
}
|
||||||
|
if (leaf is null) return false;
|
||||||
|
ModelNodeViewModel? root = FindRootOf(leaf) ?? RootContaining(leaf);
|
||||||
|
if (root is null) return false;
|
||||||
|
|
||||||
|
_dragRoot = root;
|
||||||
|
_dragAnchor = anchor;
|
||||||
|
_dragApplied = default;
|
||||||
|
_dragTransform = new TranslateTransform3D();
|
||||||
|
foreach (ModelNodeViewModel l in root.Leaves())
|
||||||
|
if (l.BodyVisual is not null)
|
||||||
|
l.BodyVisual.Transform = _dragTransform;
|
||||||
|
|
||||||
|
// 拖曳中隱藏邊線(LinesVisual3D 隨 Transform 變更逐幀重建會卡)
|
||||||
|
_edgesSuspended = true;
|
||||||
|
SetEdgesActive(false);
|
||||||
|
StatusText = $"拖曳「{root.Name}」中…(放開定位)";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>滑鼠移動:把 2D 位移投影到「過錨點、面向相機」的平面上 → 暫時 Transform</summary>
|
||||||
|
public void OnDragMove(Point position)
|
||||||
|
{
|
||||||
|
if (_viewport?.Camera is not System.Windows.Media.Media3D.ProjectionCamera cam ||
|
||||||
|
_dragRoot is null || _dragTransform is null) return;
|
||||||
|
|
||||||
|
Point3D? p = _viewport.Viewport.UnProject(position, _dragAnchor, cam.LookDirection);
|
||||||
|
if (p is null) return;
|
||||||
|
_dragApplied = p.Value - _dragAnchor;
|
||||||
|
_dragTransform.OffsetX = _dragApplied.X;
|
||||||
|
_dragTransform.OffsetY = _dragApplied.Y;
|
||||||
|
_dragTransform.OffsetZ = _dragApplied.Z;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>放開:移除暫時 Transform,一次性烘進 B-rep(量測精度不受拖曳影響)</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
|
||||||
|
foreach (ModelNodeViewModel l in root.Leaves())
|
||||||
|
if (l.BodyVisual is not null)
|
||||||
|
l.BodyVisual.Transform = Transform3D.Identity;
|
||||||
|
_dragRoot = null;
|
||||||
|
_dragTransform = null;
|
||||||
|
_dragApplied = default;
|
||||||
|
_edgesSuspended = false;
|
||||||
|
SetEdgesActive(true); // 恢復所有檔案的邊線(被拖檔案的點隨後由 TranslateRoot 更新,期間不會渲染)
|
||||||
|
|
||||||
|
if (delta.LengthSquared > 1e-12)
|
||||||
|
{
|
||||||
|
TranslateRoot(root, delta); // 烘進 B-rep + 網格 + 邊線 + 邊界(量測清空)
|
||||||
|
StatusText = $"已拖曳「{root.Name}」 Δ({delta.X:F2}, {delta.Y:F2}, {delta.Z:F2}) mm(可用 🧩干涉 驗證)";
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
using STPViewer.Models;
|
||||||
|
|
||||||
|
namespace STPViewer.ViewModels;
|
||||||
|
|
||||||
|
// ─── 匯出(量測 CSV / 視圖 PNG 截圖)────────────────────────────────
|
||||||
|
public partial class MainViewModel
|
||||||
|
{
|
||||||
|
[RelayCommand]
|
||||||
|
private void ExportCsv()
|
||||||
|
{
|
||||||
|
if (Measurements.Count == 0)
|
||||||
|
{
|
||||||
|
StatusText = "沒有量測結果可匯出";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var dlg = new SaveFileDialog
|
||||||
|
{
|
||||||
|
Title = "匯出量測結果",
|
||||||
|
Filter = "CSV 檔案 (*.csv)|*.csv",
|
||||||
|
FileName = $"measurements_{DateTime.Now:yyyyMMdd_HHmmss}.csv",
|
||||||
|
};
|
||||||
|
if (dlg.ShowDialog() != true) return;
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine("標籤,類型,明細");
|
||||||
|
foreach (MeasurementResult m in Measurements)
|
||||||
|
{
|
||||||
|
static string Esc(string s) => $"\"{s.Replace("\"", "\"\"")}\"";
|
||||||
|
sb.AppendLine($"{Esc(m.Title)},{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)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void SaveScreenshot()
|
||||||
|
{
|
||||||
|
if (_viewport is null || _viewport.ActualWidth < 1) return;
|
||||||
|
var dlg = new SaveFileDialog
|
||||||
|
{
|
||||||
|
Title = "儲存視圖截圖",
|
||||||
|
Filter = "PNG 圖片 (*.png)|*.png",
|
||||||
|
FileName = $"stpviewer_{DateTime.Now:yyyyMMdd_HHmmss}.png",
|
||||||
|
};
|
||||||
|
if (dlg.ShowDialog() != true) return;
|
||||||
|
|
||||||
|
const double scale = 2.0; // 2x 解析度
|
||||||
|
var rtb = new RenderTargetBitmap(
|
||||||
|
(int)(_viewport.ActualWidth * scale), (int)(_viewport.ActualHeight * scale),
|
||||||
|
96 * scale, 96 * scale, PixelFormats.Pbgra32);
|
||||||
|
rtb.Render(_viewport);
|
||||||
|
var encoder = new PngBitmapEncoder();
|
||||||
|
encoder.Frames.Add(BitmapFrame.Create(rtb));
|
||||||
|
using FileStream fs = File.Create(dlg.FileName);
|
||||||
|
encoder.Save(fs);
|
||||||
|
StatusText = $"已儲存截圖 → {Path.GetFileName(dlg.FileName)}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Media3D;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using HelixToolkit.Wpf;
|
||||||
|
|
||||||
|
namespace STPViewer.ViewModels;
|
||||||
|
|
||||||
|
// ─── Gizmo 三軸操作器(XYZ 箭頭 + 旋轉環;每次放開滑鼠烘進 B-rep)──────
|
||||||
|
public partial class MainViewModel
|
||||||
|
{
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool gizmoEnabled;
|
||||||
|
|
||||||
|
private readonly List<HelixToolkit.Wpf.Manipulator> _gizmoParts = new();
|
||||||
|
private ModelVisual3D? _gizmoProxy; // 操作器綁定的代理 visual(其 Transform = 使用者拖出的變換)
|
||||||
|
private ModelNodeViewModel? _gizmoTarget;
|
||||||
|
private bool _gizmoDragActive; // 拖動中(邊線已暫停)
|
||||||
|
private bool _gizmoBaking; // 烘焙中,忽略 Transform 變更回呼
|
||||||
|
private bool _gizmoBakePending; // 已排程烘焙,防同一次放開重複觸發
|
||||||
|
|
||||||
|
// 操作器疊圖層:另一個透明 Viewport3D 疊在主視窗上、相機同步、只放操作器 →
|
||||||
|
// 操作器不在主場景,永不被實體遮擋(always-on-top);空白處滑鼠穿透回主視窗
|
||||||
|
private System.Windows.Controls.Viewport3D? _overlayViewport;
|
||||||
|
private PerspectiveCamera? _overlayCamera;
|
||||||
|
|
||||||
|
/// <summary>注入操作器疊圖層(透明 Viewport3D,疊在主視窗上、永遠最上層)</summary>
|
||||||
|
public void AttachOverlay(System.Windows.Controls.Viewport3D overlay)
|
||||||
|
{
|
||||||
|
_overlayViewport = overlay;
|
||||||
|
_overlayCamera = new PerspectiveCamera();
|
||||||
|
overlay.Camera = _overlayCamera;
|
||||||
|
// 操作器材質需打光(manipulator 用 DiffuseMaterial)
|
||||||
|
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>疊圖層相機跟隨主相機(每次主相機變更時呼叫,讓操作器疊在正確螢幕位置)</summary>
|
||||||
|
private void SyncOverlayCamera()
|
||||||
|
{
|
||||||
|
if (_overlayCamera is null || _viewport?.Camera is not ProjectionCamera src) return;
|
||||||
|
_overlayCamera.Position = src.Position;
|
||||||
|
_overlayCamera.LookDirection = src.LookDirection;
|
||||||
|
_overlayCamera.UpDirection = src.UpDirection;
|
||||||
|
_overlayCamera.NearPlaneDistance = src.NearPlaneDistance;
|
||||||
|
_overlayCamera.FarPlaneDistance = src.FarPlaneDistance;
|
||||||
|
if (src is PerspectiveCamera p) _overlayCamera.FieldOfView = p.FieldOfView;
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnGizmoEnabledChanged(bool value) => UpdateGizmo();
|
||||||
|
|
||||||
|
partial void OnSelectedNodeChanged(ModelNodeViewModel? value)
|
||||||
|
{
|
||||||
|
if (GizmoEnabled) UpdateGizmo(); // 換選取 → 操作器跟著換目標
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateGizmo()
|
||||||
|
{
|
||||||
|
RemoveGizmo();
|
||||||
|
if (!GizmoEnabled || _viewport is null || _overlayViewport is null) return;
|
||||||
|
|
||||||
|
ModelNodeViewModel? root = SelectedNode is not null ? RootContaining(SelectedNode)
|
||||||
|
: Roots.Count == 1 ? Roots[0] : null;
|
||||||
|
if (root is null || root.Bounds.IsEmpty)
|
||||||
|
{
|
||||||
|
StatusText = "操作器:請先在樹面板點選要操作的檔案";
|
||||||
|
GizmoEnabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_gizmoTarget = root;
|
||||||
|
SyncOverlayCamera(); // 操作器出現前先對齊相機
|
||||||
|
|
||||||
|
Rect3D b = root.Bounds;
|
||||||
|
var center = new Point3D(b.X + b.SizeX / 2, b.Y + b.SizeY / 2, b.Z + b.SizeZ / 2);
|
||||||
|
double diag = new Vector3D(b.SizeX, b.SizeY, b.SizeZ).Length;
|
||||||
|
|
||||||
|
_gizmoProxy = new ModelVisual3D();
|
||||||
|
_overlayViewport.Children.Add(_gizmoProxy);
|
||||||
|
System.ComponentModel.DependencyPropertyDescriptor
|
||||||
|
.FromProperty(Visual3D.TransformProperty, typeof(Visual3D))
|
||||||
|
.AddValueChanged(_gizmoProxy, GizmoTransformChanged);
|
||||||
|
|
||||||
|
void AddPart(HelixToolkit.Wpf.Manipulator man)
|
||||||
|
{
|
||||||
|
man.Position = center;
|
||||||
|
man.Bind(_gizmoProxy);
|
||||||
|
_gizmoParts.Add(man);
|
||||||
|
_overlayViewport!.Children.Add(man); // 放疊圖層 → 永不被實體遮擋
|
||||||
|
}
|
||||||
|
// 平移箭頭(X 紅 / Y 綠 / Z 藍 — 業界慣例)
|
||||||
|
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 });
|
||||||
|
// 旋轉環
|
||||||
|
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}」、拖環繞軸旋轉;放開即定位";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveGizmo()
|
||||||
|
{
|
||||||
|
if (_gizmoProxy is not null)
|
||||||
|
{
|
||||||
|
System.ComponentModel.DependencyPropertyDescriptor
|
||||||
|
.FromProperty(Visual3D.TransformProperty, typeof(Visual3D))
|
||||||
|
.RemoveValueChanged(_gizmoProxy, GizmoTransformChanged);
|
||||||
|
_overlayViewport?.Children.Remove(_gizmoProxy);
|
||||||
|
}
|
||||||
|
foreach (HelixToolkit.Wpf.Manipulator man in _gizmoParts)
|
||||||
|
{
|
||||||
|
man.UnBind();
|
||||||
|
_overlayViewport?.Children.Remove(man);
|
||||||
|
}
|
||||||
|
_gizmoParts.Clear();
|
||||||
|
_gizmoProxy = null;
|
||||||
|
|
||||||
|
if (_gizmoTarget is not null) // 清掉殘留的暫時 Transform(用 Identity,不可 null → 否則 FindHits crash)
|
||||||
|
foreach (ModelNodeViewModel l in _gizmoTarget.Leaves())
|
||||||
|
if (l.BodyVisual is not null)
|
||||||
|
l.BodyVisual.Transform = Transform3D.Identity;
|
||||||
|
_gizmoTarget = null;
|
||||||
|
if (_gizmoDragActive)
|
||||||
|
{
|
||||||
|
_gizmoDragActive = false;
|
||||||
|
_edgesSuspended = false;
|
||||||
|
SetEdgesActive(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>操作器拖動中:把代理的 Transform 套到目標檔案的所有 BodyVisual(暫時、GPU 端)</summary>
|
||||||
|
private void GizmoTransformChanged(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (_gizmoBaking || _gizmoTarget is null || _gizmoProxy is null) return;
|
||||||
|
Transform3D t = _gizmoProxy.Transform;
|
||||||
|
if (!_gizmoDragActive && t is not null && !t.Value.IsIdentity)
|
||||||
|
{
|
||||||
|
_gizmoDragActive = true;
|
||||||
|
_edgesSuspended = true;
|
||||||
|
SetEdgesActive(false); // 邊線逐幀重建會卡,拖動中暫停
|
||||||
|
}
|
||||||
|
foreach (ModelNodeViewModel l in _gizmoTarget.Leaves())
|
||||||
|
if (l.BodyVisual is not null)
|
||||||
|
l.BodyVisual.Transform = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>滑鼠放開(MainWindow 轉發,handledEventsToo):把累積變換烘進 B-rep 並重置操作器</summary>
|
||||||
|
public void OnGizmoMouseUp()
|
||||||
|
{
|
||||||
|
if (_gizmoTarget is null || _gizmoProxy is null || _gizmoBakePending) return;
|
||||||
|
Matrix3D m = _gizmoProxy.Transform?.Value ?? Matrix3D.Identity;
|
||||||
|
if (m.IsIdentity) return; // 只是點一下、沒拖操作器 → 不烘焙
|
||||||
|
|
||||||
|
// 延後到 manipulator 自身的 mouse-up 處理(釋放捕捉等)完成後再烘焙,避免在其事件中改動視覺樹造成 reentrancy
|
||||||
|
_gizmoBakePending = true;
|
||||||
|
_viewport?.Dispatcher.BeginInvoke(new Action(() => BakeGizmo(m)),
|
||||||
|
System.Windows.Threading.DispatcherPriority.Background);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BakeGizmo(Matrix3D m)
|
||||||
|
{
|
||||||
|
_gizmoBakePending = false;
|
||||||
|
if (_gizmoTarget is null) return;
|
||||||
|
ModelNodeViewModel root = _gizmoTarget;
|
||||||
|
|
||||||
|
_gizmoBaking = true; // 防 proxy 歸零觸發 GizmoTransformChanged 重入
|
||||||
|
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; // 綁定 → 操作器歸零
|
||||||
|
_gizmoBaking = false;
|
||||||
|
_gizmoDragActive = false;
|
||||||
|
_edgesSuspended = false;
|
||||||
|
SetEdgesActive(true);
|
||||||
|
|
||||||
|
TransformRoot(root, Services.RigidAlign.ToModOp(m), m); // 烘進 B-rep(量測精度不受影響)
|
||||||
|
|
||||||
|
// 操作器移到變換後的新中心
|
||||||
|
Rect3D b = root.Bounds;
|
||||||
|
if (!b.IsEmpty)
|
||||||
|
{
|
||||||
|
var center = new Point3D(b.X + b.SizeX / 2, b.Y + b.SizeY / 2, b.Z + b.SizeZ / 2);
|
||||||
|
foreach (HelixToolkit.Wpf.Manipulator man in _gizmoParts)
|
||||||
|
man.Position = center;
|
||||||
|
}
|
||||||
|
StatusText = $"已套用操作器變換「{root.Name}」(可用 🧩干涉 驗證)";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Media3D;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using HelixToolkit.Wpf;
|
||||||
|
using STPViewer.Models;
|
||||||
|
using STPViewer.Services;
|
||||||
|
|
||||||
|
namespace STPViewer.ViewModels;
|
||||||
|
|
||||||
|
// ─── 干涉檢查 ────────────────────────────────────────────────────────
|
||||||
|
public partial class MainViewModel
|
||||||
|
{
|
||||||
|
[RelayCommand(CanExecute = nameof(IsIdle))]
|
||||||
|
private async Task CheckInterferenceAsync()
|
||||||
|
{
|
||||||
|
if (_viewport is null) return;
|
||||||
|
var visibleRoots = Roots.Where(r => r.IsVisible).ToList();
|
||||||
|
if (visibleRoots.Count != 2)
|
||||||
|
{
|
||||||
|
StatusText = $"干涉檢查需要剛好 2 個可見檔案(目前 {visibleRoots.Count} 個)— 用樹面板勾選";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<MeshGeometry3D> MeshesOf(ModelNodeViewModel root) =>
|
||||||
|
root.Leaves().Where(l => l.IsVisible && l.FacesContent is not null)
|
||||||
|
.SelectMany(l => l.FacesContent!.Children)
|
||||||
|
.Where(m => m is GeometryModel3D gm && _faceMap.ContainsKey(gm))
|
||||||
|
.Select(m => _faceMap[(GeometryModel3D)m].Mesh)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var a = MeshesOf(visibleRoots[0]);
|
||||||
|
var b = MeshesOf(visibleRoots[1]);
|
||||||
|
string nameA = visibleRoots[0].Name, nameB = visibleRoots[1].Name;
|
||||||
|
|
||||||
|
IsBusy = true;
|
||||||
|
StatusText = $"干涉檢查中:{nameA} ⟷ {nameB} …";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
InterferenceResult result = await Task.Run(() => InterferenceService.Check(a, b));
|
||||||
|
AddInterferenceResult(result, nameA, nameB);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusText = $"干涉檢查失敗:{ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddInterferenceResult(InterferenceResult result, string nameA, string nameB)
|
||||||
|
{
|
||||||
|
double diag = SceneDiagonal();
|
||||||
|
double markerR = Math.Clamp(diag * 0.004, 0.01, 5.0);
|
||||||
|
MeasurementResult m;
|
||||||
|
|
||||||
|
if (result.Intersects)
|
||||||
|
{
|
||||||
|
int pairs = result.PairCount;
|
||||||
|
int segCount = result.Segments.Count;
|
||||||
|
m = new MeasurementResult
|
||||||
|
{
|
||||||
|
Kind = MeasureMode.Interference,
|
||||||
|
TitleFor = _ => $"🧩 干涉!{nameA} ⟷ {nameB}",
|
||||||
|
DetailFor = _ => $"兩零件相交(不 match)\n相交三角形對 = {pairs:N0}\n" +
|
||||||
|
$"紅色線 = 干涉交線({segCount:N0} 段)\n(共面貼合不會被算為干涉)",
|
||||||
|
};
|
||||||
|
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); }
|
||||||
|
m.Overlays.Add(new LinesVisual3D { Points = pts, Color = Colors.Red, Thickness = 3 });
|
||||||
|
if (result.Segments.Count > 0)
|
||||||
|
{
|
||||||
|
Point3D at = result.Segments[0].A;
|
||||||
|
m.Overlays.Add(new BillboardTextVisual3D
|
||||||
|
{
|
||||||
|
Position = at + new Vector3D(0, 0, markerR * 3),
|
||||||
|
Text = "干涉",
|
||||||
|
Foreground = Brushes.White,
|
||||||
|
Background = Brushes.Red,
|
||||||
|
Padding = new Thickness(5, 2, 5, 2),
|
||||||
|
FontSize = 14,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Point3D ga = result.GapA, gb = result.GapB;
|
||||||
|
Vector3D d = gb - ga;
|
||||||
|
double gap = result.GapDistance;
|
||||||
|
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)}",
|
||||||
|
};
|
||||||
|
m.Overlays.Add(new SphereVisual3D { Center = ga, Radius = markerR, Fill = Brushes.OrangeRed });
|
||||||
|
m.Overlays.Add(new SphereVisual3D { Center = gb, Radius = markerR, Fill = Brushes.OrangeRed });
|
||||||
|
m.Overlays.Add(new LinesVisual3D
|
||||||
|
{
|
||||||
|
Points = new Point3DCollection { ga, gb },
|
||||||
|
Color = Colors.OrangeRed,
|
||||||
|
Thickness = 2,
|
||||||
|
});
|
||||||
|
var label = new BillboardTextVisual3D
|
||||||
|
{
|
||||||
|
Position = ga + d / 2 + new Vector3D(0, 0, markerR * 2),
|
||||||
|
Text = $"gap {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)}"));
|
||||||
|
}
|
||||||
|
AddMeasurement(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Media3D;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using HelixToolkit.Wpf;
|
||||||
|
using STPViewer.Models;
|
||||||
|
using STPViewer.Services;
|
||||||
|
|
||||||
|
namespace STPViewer.ViewModels;
|
||||||
|
|
||||||
|
// ─── 剖面(CPU 網格裁切;v0.4.0 起裁切在背景執行緒平行計算,UI 不凍結)──
|
||||||
|
public partial class MainViewModel
|
||||||
|
{
|
||||||
|
private readonly DispatcherTimer _sectionTimer;
|
||||||
|
private RectangleVisual3D? _sectionPlaneVisual;
|
||||||
|
private Point3D _sectionPlanePoint; // SectionEnabled 時的剖切平面(合併邊線重建用)
|
||||||
|
private Vector3D _sectionNormal;
|
||||||
|
|
||||||
|
// 背景裁切 guard:進行中又有新變更(拉滑桿/換軸/零件平移)→ 完成後用最新參數重跑一輪。
|
||||||
|
// 不要改回同步呼叫 ClipMesh —— 大檔(64k 面)整場景裁切會凍結 UI 數秒
|
||||||
|
private bool _sectionApplying;
|
||||||
|
private bool _sectionReapply;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool sectionEnabled;
|
||||||
|
|
||||||
|
/// <summary>0=X 1=Y 2=Z</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private int sectionAxisIndex;
|
||||||
|
|
||||||
|
/// <summary>剖面位置(沿軸 0~100%)</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private double sectionPosition = 50;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool sectionFlip;
|
||||||
|
|
||||||
|
partial void OnSectionEnabledChanged(bool value) => ScheduleSection();
|
||||||
|
partial void OnSectionAxisIndexChanged(int value) => ScheduleSection();
|
||||||
|
partial void OnSectionPositionChanged(double value) => ScheduleSection();
|
||||||
|
partial void OnSectionFlipChanged(bool value) => ScheduleSection();
|
||||||
|
|
||||||
|
private void ScheduleSection()
|
||||||
|
{
|
||||||
|
_sectionTimer.Stop();
|
||||||
|
_sectionTimer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void ApplySection()
|
||||||
|
{
|
||||||
|
if (_viewport is null) return;
|
||||||
|
|
||||||
|
if (!SectionEnabled)
|
||||||
|
{
|
||||||
|
// 還原原始幾何(換回 fi.Mesh 參照,便宜、同步即可)。
|
||||||
|
// 若背景裁切還在跑,其 await 之後會看到 SectionEnabled=false 而放棄套用
|
||||||
|
foreach ((Model3D model, FaceInfo fi) in _faceMap)
|
||||||
|
if (model is GeometryModel3D gm && !ReferenceEquals(gm.Geometry, fi.Mesh))
|
||||||
|
gm.Geometry = fi.Mesh;
|
||||||
|
foreach (ModelNodeViewModel root in Roots) RefreshRootEdges(root);
|
||||||
|
if (_sectionPlaneVisual is not null)
|
||||||
|
{
|
||||||
|
_viewport.Children.Remove(_sectionPlaneVisual);
|
||||||
|
_sectionPlaneVisual = null;
|
||||||
|
}
|
||||||
|
ApplyRenderMode(); // 剖面關閉 → 瀏覽模式可切回合併網格
|
||||||
|
StatusText = "剖面已關閉";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_sectionApplying) { _sectionReapply = true; return; }
|
||||||
|
_sectionApplying = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
do
|
||||||
|
{
|
||||||
|
_sectionReapply = false;
|
||||||
|
|
||||||
|
Rect3D bounds = UnionBounds();
|
||||||
|
if (bounds.IsEmpty) return;
|
||||||
|
|
||||||
|
Vector3D axis = SectionAxisIndex switch
|
||||||
|
{
|
||||||
|
0 => new Vector3D(1, 0, 0),
|
||||||
|
1 => new Vector3D(0, 1, 0),
|
||||||
|
_ => new Vector3D(0, 0, 1),
|
||||||
|
};
|
||||||
|
double t = SectionPosition / 100.0;
|
||||||
|
Point3D min = bounds.Location;
|
||||||
|
var size = new Vector3D(bounds.SizeX, bounds.SizeY, bounds.SizeZ);
|
||||||
|
double planeCoord = SectionAxisIndex switch
|
||||||
|
{
|
||||||
|
0 => min.X + size.X * t,
|
||||||
|
1 => min.Y + size.Y * t,
|
||||||
|
_ => min.Z + size.Z * t,
|
||||||
|
};
|
||||||
|
Point3D center = min + size / 2;
|
||||||
|
Point3D planePoint = SectionAxisIndex switch
|
||||||
|
{
|
||||||
|
0 => new Point3D(planeCoord, center.Y, center.Z),
|
||||||
|
1 => new Point3D(center.X, planeCoord, center.Z),
|
||||||
|
_ => new Point3D(center.X, center.Y, planeCoord),
|
||||||
|
};
|
||||||
|
Vector3D normal = SectionFlip ? -axis : axis;
|
||||||
|
|
||||||
|
// UI 執行緒快照(原始 mesh 皆已 Freeze,可跨執行緒),背景平行裁切,回 UI 一次換上。
|
||||||
|
// 裁切期間若參數又變 / 零件被平移(TransformRoot 會呼叫 ApplySection)→
|
||||||
|
// _sectionReapply 讓迴圈用最新的 fi.Mesh 與平面重跑,最終狀態必為最新
|
||||||
|
var models = new List<GeometryModel3D>(_faceMap.Count);
|
||||||
|
var sources = new List<MeshGeometry3D>(_faceMap.Count);
|
||||||
|
foreach ((Model3D model, FaceInfo fi) in _faceMap)
|
||||||
|
if (model is GeometryModel3D gm)
|
||||||
|
{
|
||||||
|
models.Add(gm);
|
||||||
|
sources.Add(fi.Mesh);
|
||||||
|
}
|
||||||
|
|
||||||
|
MeshGeometry3D[] clipped = await Task.Run(() =>
|
||||||
|
{
|
||||||
|
var result = new MeshGeometry3D[sources.Count];
|
||||||
|
Parallel.For(0, sources.Count, i =>
|
||||||
|
result[i] = SectionService.ClipMesh(sources[i], planePoint, normal));
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!SectionEnabled) return; // 裁切期間被關掉 → 還原分支已處理,丟棄結果
|
||||||
|
|
||||||
|
for (int i = 0; i < models.Count; i++)
|
||||||
|
models[i].Geometry = clipped[i]; // 已移除檔案的殘留 model 賦值無害(不在視覺樹上)
|
||||||
|
|
||||||
|
_sectionPlanePoint = planePoint;
|
||||||
|
_sectionNormal = normal;
|
||||||
|
foreach (ModelNodeViewModel root in Roots) RefreshRootEdges(root);
|
||||||
|
|
||||||
|
ApplyRenderMode(); // 剖面開啟 → 改用逐面(裁切後幾何)
|
||||||
|
UpdateSectionPlaneVisual(planePoint, axis, size);
|
||||||
|
StatusText = $"剖面:{"XYZ"[SectionAxisIndex]} 軸 {SectionPosition:F0}%" + (SectionFlip ? "(反向)" : "");
|
||||||
|
} while (_sectionReapply);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_sectionApplying = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateSectionPlaneVisual(Point3D planePoint, Vector3D axis, Vector3D size)
|
||||||
|
{
|
||||||
|
if (_sectionPlaneVisual is not null)
|
||||||
|
_viewport!.Children.Remove(_sectionPlaneVisual);
|
||||||
|
|
||||||
|
(Vector3D lenDir, double len, double wid) = SectionAxisIndex switch
|
||||||
|
{
|
||||||
|
0 => (new Vector3D(0, 1, 0), size.Y, size.Z),
|
||||||
|
1 => (new Vector3D(1, 0, 0), size.X, size.Z),
|
||||||
|
_ => (new Vector3D(1, 0, 0), size.X, size.Y),
|
||||||
|
};
|
||||||
|
_sectionPlaneVisual = new RectangleVisual3D
|
||||||
|
{
|
||||||
|
Origin = planePoint,
|
||||||
|
Normal = axis,
|
||||||
|
LengthDirection = lenDir,
|
||||||
|
Length = len * 1.05,
|
||||||
|
Width = wid * 1.05,
|
||||||
|
Fill = new SolidColorBrush(Color.FromArgb(40, 30, 120, 255)),
|
||||||
|
};
|
||||||
|
_viewport!.Children.Add(_sectionPlaneVisual);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,11 +3,9 @@ using System.Collections.Generic;
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Media;
|
using System.Windows.Media;
|
||||||
using System.Windows.Media.Imaging;
|
|
||||||
using System.Windows.Media.Media3D;
|
using System.Windows.Media.Media3D;
|
||||||
using System.Windows.Threading;
|
using System.Windows.Threading;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
@@ -35,12 +33,6 @@ public partial class MainViewModel : ObservableObject
|
|||||||
/// 邊界由匯入時依面序串接而成,與 BuildMergedMesh 串接順序一致;平移不改各面頂點數,故邊界永久有效。</summary>
|
/// 邊界由匯入時依面序串接而成,與 BuildMergedMesh 串接順序一致;平移不改各面頂點數,故邊界永久有效。</summary>
|
||||||
private readonly Dictionary<Model3D, (int[] Starts, FaceInfo[] Faces)> _mergedFaceRanges = new();
|
private readonly Dictionary<Model3D, (int[] Starts, FaceInfo[] Faces)> _mergedFaceRanges = new();
|
||||||
|
|
||||||
// 拖曳模式:暫時 Transform 跟著滑鼠,放開才一次性烘進 B-rep(TranslateRoot)
|
|
||||||
private ModelNodeViewModel? _dragRoot;
|
|
||||||
private Point3D _dragAnchor; // 命中點(世界座標),拖曳平面的錨點
|
|
||||||
private Vector3D _dragApplied; // 目前累計位移
|
|
||||||
private TranslateTransform3D? _dragTransform;
|
|
||||||
|
|
||||||
private HelixViewport3D? _viewport;
|
private HelixViewport3D? _viewport;
|
||||||
private int _paletteIndex;
|
private int _paletteIndex;
|
||||||
private readonly Dictionary<MeasureMode, int> _counters = new();
|
private readonly Dictionary<MeasureMode, int> _counters = new();
|
||||||
@@ -57,12 +49,6 @@ public partial class MainViewModel : ObservableObject
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private ModelNodeViewModel? selectedNode;
|
private ModelNodeViewModel? selectedNode;
|
||||||
|
|
||||||
// 剖面
|
|
||||||
private readonly DispatcherTimer _sectionTimer;
|
|
||||||
private RectangleVisual3D? _sectionPlaneVisual;
|
|
||||||
private Point3D _sectionPlanePoint; // SectionEnabled 時的剖切平面(合併邊線重建用)
|
|
||||||
private Vector3D _sectionNormal;
|
|
||||||
|
|
||||||
// 互動中暫停邊線:轉動/縮放/平移時隱藏 LinesVisual3D,停下再顯示 → 大組件流暢
|
// 互動中暫停邊線:轉動/縮放/平移時隱藏 LinesVisual3D,停下再顯示 → 大組件流暢
|
||||||
private readonly DispatcherTimer _interactionTimer;
|
private readonly DispatcherTimer _interactionTimer;
|
||||||
private bool _edgesSuspended;
|
private bool _edgesSuspended;
|
||||||
@@ -74,25 +60,14 @@ public partial class MainViewModel : ObservableObject
|
|||||||
private string statusText = "就緒 — 匯入 STP/STEP/STL/DXF(拖放亦可)。右鍵旋轉、滾輪縮放、中鍵或 Shift+左鍵平移";
|
private string statusText = "就緒 — 匯入 STP/STEP/STL/DXF(拖放亦可)。右鍵旋轉、滾輪縮放、中鍵或 Shift+左鍵平移";
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(ImportCommand))]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(RotateRootCommand))]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(CheckInterferenceCommand))]
|
||||||
private bool isBusy;
|
private bool isBusy;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool useInch;
|
private bool useInch;
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private bool sectionEnabled;
|
|
||||||
|
|
||||||
/// <summary>0=X 1=Y 2=Z</summary>
|
|
||||||
[ObservableProperty]
|
|
||||||
private int sectionAxisIndex;
|
|
||||||
|
|
||||||
/// <summary>剖面位置(沿軸 0~100%)</summary>
|
|
||||||
[ObservableProperty]
|
|
||||||
private double sectionPosition = 50;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private bool sectionFlip;
|
|
||||||
|
|
||||||
public ObservableCollection<ModelNodeViewModel> Roots { get; } = new();
|
public ObservableCollection<ModelNodeViewModel> Roots { get; } = new();
|
||||||
public ObservableCollection<MeasurementResult> Measurements { get; } = new();
|
public ObservableCollection<MeasurementResult> Measurements { get; } = new();
|
||||||
|
|
||||||
@@ -123,30 +98,6 @@ public partial class MainViewModel : ObservableObject
|
|||||||
viewport.Camera.Changed += (_, _) => OnCameraMoved();
|
viewport.Camera.Changed += (_, _) => OnCameraMoved();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>注入操作器疊圖層(透明 Viewport3D,疊在主視窗上、永遠最上層)</summary>
|
|
||||||
public void AttachOverlay(System.Windows.Controls.Viewport3D overlay)
|
|
||||||
{
|
|
||||||
_overlayViewport = overlay;
|
|
||||||
_overlayCamera = new PerspectiveCamera();
|
|
||||||
overlay.Camera = _overlayCamera;
|
|
||||||
// 操作器材質需打光(manipulator 用 DiffuseMaterial)
|
|
||||||
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>疊圖層相機跟隨主相機(每次主相機變更時呼叫,讓操作器疊在正確螢幕位置)</summary>
|
|
||||||
private void SyncOverlayCamera()
|
|
||||||
{
|
|
||||||
if (_overlayCamera is null || _viewport?.Camera is not ProjectionCamera src) return;
|
|
||||||
_overlayCamera.Position = src.Position;
|
|
||||||
_overlayCamera.LookDirection = src.LookDirection;
|
|
||||||
_overlayCamera.UpDirection = src.UpDirection;
|
|
||||||
_overlayCamera.NearPlaneDistance = src.NearPlaneDistance;
|
|
||||||
_overlayCamera.FarPlaneDistance = src.FarPlaneDistance;
|
|
||||||
if (src is PerspectiveCamera p) _overlayCamera.FieldOfView = p.FieldOfView;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnCameraMoved()
|
private void OnCameraMoved()
|
||||||
{
|
{
|
||||||
SyncOverlayCamera(); // 操作器疊圖層跟著主相機
|
SyncOverlayCamera(); // 操作器疊圖層跟著主相機
|
||||||
@@ -170,9 +121,12 @@ public partial class MainViewModel : ObservableObject
|
|||||||
SyncVisual(root.EdgeVisual, active && root.EdgeVisual.Points.Count > 0);
|
SyncVisual(root.EdgeVisual, active && root.EdgeVisual.Points.Count > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>IsBusy 時停用會改動幾何/場景的指令(匯入、旋轉、干涉),避免背景運算期間狀態被改走造成結果錯位</summary>
|
||||||
|
private bool IsIdle() => !IsBusy;
|
||||||
|
|
||||||
// ─── 匯入 ────────────────────────────────────────────────────
|
// ─── 匯入 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand(CanExecute = nameof(IsIdle))]
|
||||||
private async Task ImportAsync()
|
private async Task ImportAsync()
|
||||||
{
|
{
|
||||||
var dlg = new OpenFileDialog
|
var dlg = new OpenFileDialog
|
||||||
@@ -189,11 +143,16 @@ public partial class MainViewModel : ObservableObject
|
|||||||
public async Task ImportFilesAsync(IEnumerable<string> paths)
|
public async Task ImportFilesAsync(IEnumerable<string> paths)
|
||||||
{
|
{
|
||||||
if (_viewport is null) return;
|
if (_viewport is null) return;
|
||||||
|
// 拖放/命令列可繞過指令 CanExecute 直接進來 → 匯入或背景運算期間直接擋掉,
|
||||||
|
// 避免兩批匯入交錯(StepImportService 有共享狀態,非重入安全)
|
||||||
|
if (IsBusy) { StatusText = "忙碌中(匯入或運算進行中),請稍候再匯入"; return; }
|
||||||
int ok = 0, fail = 0;
|
int ok = 0, fail = 0;
|
||||||
|
IsBusy = true; // 整批匯入期間維持 busy(v0.4.0:不再逐檔開關,杜絕檔案之間的重入空窗)
|
||||||
|
try
|
||||||
|
{
|
||||||
foreach (string path in paths)
|
foreach (string path in paths)
|
||||||
{
|
{
|
||||||
string name = Path.GetFileNameWithoutExtension(path);
|
string name = Path.GetFileNameWithoutExtension(path);
|
||||||
IsBusy = true;
|
|
||||||
StatusText = $"匯入中:{name} …";
|
StatusText = $"匯入中:{name} …";
|
||||||
// 匯入階段回報(從背景執行緒來 → 切回 UI 執行緒)
|
// 匯入階段回報(從背景執行緒來 → 切回 UI 執行緒)
|
||||||
_importService.Progress = msg =>
|
_importService.Progress = msg =>
|
||||||
@@ -212,11 +171,12 @@ public partial class MainViewModel : ObservableObject
|
|||||||
MessageBox.Show($"{path}\n\n{ex.Message}", "匯入失敗",
|
MessageBox.Show($"{path}\n\n{ex.Message}", "匯入失敗",
|
||||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
IsBusy = false;
|
IsBusy = false;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (ok > 0)
|
if (ok > 0)
|
||||||
{
|
{
|
||||||
if (SectionEnabled) ApplySection();
|
if (SectionEnabled) ApplySection();
|
||||||
@@ -373,20 +333,19 @@ public partial class MainViewModel : ObservableObject
|
|||||||
|
|
||||||
// ─── 合併網格(瀏覽模式降 draw call)────────────────────────
|
// ─── 合併網格(瀏覽模式降 draw call)────────────────────────
|
||||||
|
|
||||||
/// <summary>把一個零件的所有面網格併成單一 MeshGeometry3D(索引位移後串接)</summary>
|
/// <summary>把多個面網格依序併成單一 MeshGeometry3D(索引位移後串接;順序 = 呼叫端給的面序)</summary>
|
||||||
private static MeshGeometry3D BuildMergedMesh(IReadOnlyList<ImportedFace> faces)
|
private static MeshGeometry3D MergeMeshes(IReadOnlyList<MeshGeometry3D> meshes)
|
||||||
{
|
{
|
||||||
int nv = 0, nt = 0;
|
int nv = 0, nt = 0;
|
||||||
foreach (ImportedFace f in faces) { nv += f.Mesh.Positions.Count; nt += f.Mesh.TriangleIndices.Count; }
|
foreach (MeshGeometry3D m in meshes) { nv += m.Positions.Count; nt += m.TriangleIndices.Count; }
|
||||||
|
|
||||||
var pos = new Point3DCollection(nv);
|
var pos = new Point3DCollection(nv);
|
||||||
var nrm = new Vector3DCollection(nv);
|
var nrm = new Vector3DCollection(nv);
|
||||||
var idx = new Int32Collection(nt);
|
var idx = new Int32Collection(nt);
|
||||||
int baseIdx = 0;
|
int baseIdx = 0;
|
||||||
bool hasNormals = true;
|
bool hasNormals = true;
|
||||||
foreach (ImportedFace f in faces)
|
foreach (MeshGeometry3D m in meshes)
|
||||||
{
|
{
|
||||||
MeshGeometry3D m = f.Mesh;
|
|
||||||
foreach (Point3D p in m.Positions) pos.Add(p);
|
foreach (Point3D p in m.Positions) pos.Add(p);
|
||||||
if (m.Normals.Count == m.Positions.Count)
|
if (m.Normals.Count == m.Positions.Count)
|
||||||
foreach (Vector3D v in m.Normals) nrm.Add(v);
|
foreach (Vector3D v in m.Normals) nrm.Add(v);
|
||||||
@@ -401,33 +360,24 @@ public partial class MainViewModel : ObservableObject
|
|||||||
return mesh;
|
return mesh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>重建合併網格(位置變更後,如零件平移)。由 leaf 逐面 fi.Mesh(未剖切原始)串接。</summary>
|
/// <summary>匯入時建合併網格(依 faces 面序串接,與 _mergedFaceRanges 的頂點邊界對齊)</summary>
|
||||||
|
private static MeshGeometry3D BuildMergedMesh(IReadOnlyList<ImportedFace> faces)
|
||||||
|
{
|
||||||
|
var meshes = new List<MeshGeometry3D>(faces.Count);
|
||||||
|
foreach (ImportedFace f in faces) meshes.Add(f.Mesh);
|
||||||
|
return MergeMeshes(meshes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>重建合併網格(位置變更後,如零件平移)。由 leaf 逐面 fi.Mesh(未剖切原始)依 FacesContent 子序串接,
|
||||||
|
/// 與匯入時 BuildMergedMesh 的面序一致 → _mergedFaceRanges 頂點邊界維持有效。</summary>
|
||||||
private void RebuildMerged(ModelNodeViewModel leaf)
|
private void RebuildMerged(ModelNodeViewModel leaf)
|
||||||
{
|
{
|
||||||
if (leaf.MergedContent is null || leaf.FacesContent is null) return;
|
if (leaf.MergedContent is null || leaf.FacesContent is null) return;
|
||||||
int nv = 0, nt = 0;
|
var meshes = new List<MeshGeometry3D>(leaf.FacesContent.Children.Count);
|
||||||
foreach (Model3D mm in leaf.FacesContent.Children)
|
foreach (Model3D mm in leaf.FacesContent.Children)
|
||||||
if (mm is GeometryModel3D g && _faceMap.TryGetValue(g, out FaceInfo? fi))
|
if (mm is GeometryModel3D g && _faceMap.TryGetValue(g, out FaceInfo? fi))
|
||||||
{ nv += fi.Mesh.Positions.Count; nt += fi.Mesh.TriangleIndices.Count; }
|
meshes.Add(fi.Mesh);
|
||||||
|
((GeometryModel3D)leaf.MergedContent.Children[0]).Geometry = MergeMeshes(meshes);
|
||||||
var pos = new Point3DCollection(nv);
|
|
||||||
var nrm = new Vector3DCollection(nv);
|
|
||||||
var idx = new Int32Collection(nt);
|
|
||||||
int baseIdx = 0; bool hasNormals = true;
|
|
||||||
foreach (Model3D mm in leaf.FacesContent.Children)
|
|
||||||
{
|
|
||||||
if (mm is not GeometryModel3D g || !_faceMap.TryGetValue(g, out FaceInfo? fi)) continue;
|
|
||||||
MeshGeometry3D m = fi.Mesh;
|
|
||||||
foreach (Point3D p in m.Positions) pos.Add(p);
|
|
||||||
if (m.Normals.Count == m.Positions.Count) foreach (Vector3D v in m.Normals) nrm.Add(v);
|
|
||||||
else hasNormals = false;
|
|
||||||
foreach (int ti in m.TriangleIndices) idx.Add(ti + baseIdx);
|
|
||||||
baseIdx += m.Positions.Count;
|
|
||||||
}
|
|
||||||
var mesh = new MeshGeometry3D { Positions = pos, TriangleIndices = idx };
|
|
||||||
if (hasNormals && nrm.Count == pos.Count) mesh.Normals = nrm;
|
|
||||||
mesh.Freeze();
|
|
||||||
((GeometryModel3D)leaf.MergedContent.Children[0]).Geometry = mesh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -849,27 +799,37 @@ public partial class MainViewModel : ObservableObject
|
|||||||
if (_viewport is null) return;
|
if (_viewport is null) return;
|
||||||
ClearMeasurements(); // 量測標記位置已失效
|
ClearMeasurements(); // 量測標記位置已失效
|
||||||
|
|
||||||
foreach (ModelNodeViewModel leaf in root.Leaves())
|
var leaves = root.Leaves().ToList();
|
||||||
{
|
|
||||||
// B-rep:對 Solid/Shell 整體 Modify(避免共用邊被逐面重複變換)
|
// B-rep:對 Solid/Shell 整體 Modify(避免共用邊被逐面重複變換)。
|
||||||
|
// CADability 物件非執行緒安全 → 維持循序
|
||||||
|
foreach (ModelNodeViewModel leaf in leaves)
|
||||||
foreach (CADability.GeoObject.IGeoObject g in leaf.SourceGeos)
|
foreach (CADability.GeoObject.IGeoObject g in leaf.SourceGeos)
|
||||||
{
|
{
|
||||||
try { g.Modify(op); }
|
try { g.Modify(op); }
|
||||||
catch { /* 個別物件變換失敗不中斷 */ }
|
catch { /* 個別物件變換失敗不中斷 */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 渲染網格(逐面,與目前顯示哪種內容無關)
|
// 渲染網格:面級平行變換(來源/輸出皆 frozen、各面獨立 → 安全;大檔 64k 面吃滿多核)
|
||||||
|
var faceItems = new List<(GeometryModel3D Gm, FaceInfo Fi)>();
|
||||||
|
foreach (ModelNodeViewModel leaf in leaves)
|
||||||
if (leaf.FacesContent is not null)
|
if (leaf.FacesContent is not null)
|
||||||
{
|
|
||||||
foreach (Model3D mm in leaf.FacesContent.Children)
|
foreach (Model3D mm in leaf.FacesContent.Children)
|
||||||
|
if (mm is GeometryModel3D gm && _faceMap.TryGetValue(gm, out FaceInfo? fi))
|
||||||
|
faceItems.Add((gm, fi));
|
||||||
|
|
||||||
|
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 執行緒循序做
|
||||||
{
|
{
|
||||||
if (mm is not GeometryModel3D gm || !_faceMap.TryGetValue(gm, out FaceInfo? fi)) continue;
|
faceItems[i].Fi.Mesh = moved[i];
|
||||||
MeshGeometry3D moved = TransformMesh(fi.Mesh, m);
|
faceItems[i].Gm.Geometry = moved[i];
|
||||||
fi.Mesh = moved;
|
|
||||||
gm.Geometry = moved;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (ModelNodeViewModel leaf in leaves)
|
||||||
|
{
|
||||||
|
if (leaf.FacesContent is not null)
|
||||||
RebuildMerged(leaf); // 合併網格同步
|
RebuildMerged(leaf); // 合併網格同步
|
||||||
}
|
|
||||||
|
|
||||||
// 邊線端點(資料);實際合併線於迴圈後重建
|
// 邊線端點(資料);實際合併線於迴圈後重建
|
||||||
if (leaf.OriginalEdgePoints is not null)
|
if (leaf.OriginalEdgePoints is not null)
|
||||||
@@ -926,7 +886,7 @@ public partial class MainViewModel : ObservableObject
|
|||||||
|
|
||||||
// ─── 旋轉 90°(樹面板選取的檔案,繞其中心)──────────────────
|
// ─── 旋轉 90°(樹面板選取的檔案,繞其中心)──────────────────
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand(CanExecute = nameof(IsIdle))]
|
||||||
private void RotateRoot(string axisName)
|
private void RotateRoot(string axisName)
|
||||||
{
|
{
|
||||||
ModelNodeViewModel? root = SelectedNode is not null ? RootContaining(SelectedNode)
|
ModelNodeViewModel? root = SelectedNode is not null ? RootContaining(SelectedNode)
|
||||||
@@ -960,499 +920,6 @@ public partial class MainViewModel : ObservableObject
|
|||||||
|
|
||||||
// 三點對齊的剛體變換數學在 Services/RigidAlign.cs(SmokeTest --align-test 驗證)
|
// 三點對齊的剛體變換數學在 Services/RigidAlign.cs(SmokeTest --align-test 驗證)
|
||||||
|
|
||||||
// ─── 拖曳模式(左鍵按住零件沿螢幕平面拖;放開才烘進 B-rep)──
|
|
||||||
|
|
||||||
/// <summary>左鍵按下:命中零件則開始拖曳(回傳 true 表示要捕捉滑鼠)</summary>
|
|
||||||
public bool OnDragStart(Point position)
|
|
||||||
{
|
|
||||||
if (_viewport is null || IsBusy || CurrentMode != MeasureMode.Drag || _dragRoot is not null)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
// 兩種渲染內容都可能在場:合併網格查 _mergedMap、逐面(剖面時)查 _faceMap
|
|
||||||
ModelNodeViewModel? leaf = null;
|
|
||||||
Point3D anchor = default;
|
|
||||||
foreach (var h in _viewport.Viewport.FindHits(position))
|
|
||||||
{
|
|
||||||
if (h.Model is null) continue;
|
|
||||||
if (_faceMap.TryGetValue(h.Model, out FaceInfo? fi)) { leaf = fi.Owner; anchor = h.Position; break; }
|
|
||||||
if (_mergedMap.TryGetValue(h.Model, out ModelNodeViewModel? ml)) { leaf = ml; anchor = h.Position; break; }
|
|
||||||
}
|
|
||||||
if (leaf is null) return false;
|
|
||||||
ModelNodeViewModel? root = FindRootOf(leaf) ?? RootContaining(leaf);
|
|
||||||
if (root is null) return false;
|
|
||||||
|
|
||||||
_dragRoot = root;
|
|
||||||
_dragAnchor = anchor;
|
|
||||||
_dragApplied = default;
|
|
||||||
_dragTransform = new TranslateTransform3D();
|
|
||||||
foreach (ModelNodeViewModel l in root.Leaves())
|
|
||||||
if (l.BodyVisual is not null)
|
|
||||||
l.BodyVisual.Transform = _dragTransform;
|
|
||||||
|
|
||||||
// 拖曳中隱藏邊線(LinesVisual3D 隨 Transform 變更逐幀重建會卡)
|
|
||||||
_edgesSuspended = true;
|
|
||||||
SetEdgesActive(false);
|
|
||||||
StatusText = $"拖曳「{root.Name}」中…(放開定位)";
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>滑鼠移動:把 2D 位移投影到「過錨點、面向相機」的平面上 → 暫時 Transform</summary>
|
|
||||||
public void OnDragMove(Point position)
|
|
||||||
{
|
|
||||||
if (_viewport?.Camera is not System.Windows.Media.Media3D.ProjectionCamera cam ||
|
|
||||||
_dragRoot is null || _dragTransform is null) return;
|
|
||||||
|
|
||||||
Point3D? p = _viewport.Viewport.UnProject(position, _dragAnchor, cam.LookDirection);
|
|
||||||
if (p is null) return;
|
|
||||||
_dragApplied = p.Value - _dragAnchor;
|
|
||||||
_dragTransform.OffsetX = _dragApplied.X;
|
|
||||||
_dragTransform.OffsetY = _dragApplied.Y;
|
|
||||||
_dragTransform.OffsetZ = _dragApplied.Z;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>放開:移除暫時 Transform,一次性烘進 B-rep(量測精度不受拖曳影響)</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
|
|
||||||
foreach (ModelNodeViewModel l in root.Leaves())
|
|
||||||
if (l.BodyVisual is not null)
|
|
||||||
l.BodyVisual.Transform = Transform3D.Identity;
|
|
||||||
_dragRoot = null;
|
|
||||||
_dragTransform = null;
|
|
||||||
_dragApplied = default;
|
|
||||||
_edgesSuspended = false;
|
|
||||||
SetEdgesActive(true); // 恢復所有檔案的邊線(被拖檔案的點隨後由 TranslateRoot 更新,期間不會渲染)
|
|
||||||
|
|
||||||
if (delta.LengthSquared > 1e-12)
|
|
||||||
{
|
|
||||||
TranslateRoot(root, delta); // 烘進 B-rep + 網格 + 邊線 + 邊界(量測清空)
|
|
||||||
StatusText = $"已拖曳「{root.Name}」 Δ({delta.X:F2}, {delta.Y:F2}, {delta.Z:F2}) mm(可用 🧩干涉 驗證)";
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Gizmo 三軸操作器(XYZ 箭頭 + 旋轉環;每次放開滑鼠烘進 B-rep)──
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private bool gizmoEnabled;
|
|
||||||
|
|
||||||
private readonly List<HelixToolkit.Wpf.Manipulator> _gizmoParts = new();
|
|
||||||
private ModelVisual3D? _gizmoProxy; // 操作器綁定的代理 visual(其 Transform = 使用者拖出的變換)
|
|
||||||
private ModelNodeViewModel? _gizmoTarget;
|
|
||||||
private bool _gizmoDragActive; // 拖動中(邊線已暫停)
|
|
||||||
private bool _gizmoBaking; // 烘焙中,忽略 Transform 變更回呼
|
|
||||||
private bool _gizmoBakePending; // 已排程烘焙,防同一次放開重複觸發
|
|
||||||
|
|
||||||
// 操作器疊圖層:另一個透明 Viewport3D 疊在主視窗上、相機同步、只放操作器 →
|
|
||||||
// 操作器不在主場景,永不被實體遮擋(always-on-top);空白處滑鼠穿透回主視窗
|
|
||||||
private System.Windows.Controls.Viewport3D? _overlayViewport;
|
|
||||||
private PerspectiveCamera? _overlayCamera;
|
|
||||||
|
|
||||||
partial void OnGizmoEnabledChanged(bool value) => UpdateGizmo();
|
|
||||||
|
|
||||||
partial void OnSelectedNodeChanged(ModelNodeViewModel? value)
|
|
||||||
{
|
|
||||||
if (GizmoEnabled) UpdateGizmo(); // 換選取 → 操作器跟著換目標
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateGizmo()
|
|
||||||
{
|
|
||||||
RemoveGizmo();
|
|
||||||
if (!GizmoEnabled || _viewport is null || _overlayViewport is null) return;
|
|
||||||
|
|
||||||
ModelNodeViewModel? root = SelectedNode is not null ? RootContaining(SelectedNode)
|
|
||||||
: Roots.Count == 1 ? Roots[0] : null;
|
|
||||||
if (root is null || root.Bounds.IsEmpty)
|
|
||||||
{
|
|
||||||
StatusText = "操作器:請先在樹面板點選要操作的檔案";
|
|
||||||
GizmoEnabled = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_gizmoTarget = root;
|
|
||||||
SyncOverlayCamera(); // 操作器出現前先對齊相機
|
|
||||||
|
|
||||||
Rect3D b = root.Bounds;
|
|
||||||
var center = new Point3D(b.X + b.SizeX / 2, b.Y + b.SizeY / 2, b.Z + b.SizeZ / 2);
|
|
||||||
double diag = new Vector3D(b.SizeX, b.SizeY, b.SizeZ).Length;
|
|
||||||
|
|
||||||
_gizmoProxy = new ModelVisual3D();
|
|
||||||
_overlayViewport.Children.Add(_gizmoProxy);
|
|
||||||
System.ComponentModel.DependencyPropertyDescriptor
|
|
||||||
.FromProperty(Visual3D.TransformProperty, typeof(Visual3D))
|
|
||||||
.AddValueChanged(_gizmoProxy, GizmoTransformChanged);
|
|
||||||
|
|
||||||
void AddPart(HelixToolkit.Wpf.Manipulator man)
|
|
||||||
{
|
|
||||||
man.Position = center;
|
|
||||||
man.Bind(_gizmoProxy);
|
|
||||||
_gizmoParts.Add(man);
|
|
||||||
_overlayViewport!.Children.Add(man); // 放疊圖層 → 永不被實體遮擋
|
|
||||||
}
|
|
||||||
// 平移箭頭(X 紅 / Y 綠 / Z 藍 — 業界慣例)
|
|
||||||
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 });
|
|
||||||
// 旋轉環
|
|
||||||
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}」、拖環繞軸旋轉;放開即定位";
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RemoveGizmo()
|
|
||||||
{
|
|
||||||
if (_gizmoProxy is not null)
|
|
||||||
{
|
|
||||||
System.ComponentModel.DependencyPropertyDescriptor
|
|
||||||
.FromProperty(Visual3D.TransformProperty, typeof(Visual3D))
|
|
||||||
.RemoveValueChanged(_gizmoProxy, GizmoTransformChanged);
|
|
||||||
_overlayViewport?.Children.Remove(_gizmoProxy);
|
|
||||||
}
|
|
||||||
foreach (HelixToolkit.Wpf.Manipulator man in _gizmoParts)
|
|
||||||
{
|
|
||||||
man.UnBind();
|
|
||||||
_overlayViewport?.Children.Remove(man);
|
|
||||||
}
|
|
||||||
_gizmoParts.Clear();
|
|
||||||
_gizmoProxy = null;
|
|
||||||
|
|
||||||
if (_gizmoTarget is not null) // 清掉殘留的暫時 Transform(用 Identity,不可 null → 否則 FindHits crash)
|
|
||||||
foreach (ModelNodeViewModel l in _gizmoTarget.Leaves())
|
|
||||||
if (l.BodyVisual is not null)
|
|
||||||
l.BodyVisual.Transform = Transform3D.Identity;
|
|
||||||
_gizmoTarget = null;
|
|
||||||
if (_gizmoDragActive)
|
|
||||||
{
|
|
||||||
_gizmoDragActive = false;
|
|
||||||
_edgesSuspended = false;
|
|
||||||
SetEdgesActive(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>操作器拖動中:把代理的 Transform 套到目標檔案的所有 BodyVisual(暫時、GPU 端)</summary>
|
|
||||||
private void GizmoTransformChanged(object? sender, EventArgs e)
|
|
||||||
{
|
|
||||||
if (_gizmoBaking || _gizmoTarget is null || _gizmoProxy is null) return;
|
|
||||||
Transform3D t = _gizmoProxy.Transform;
|
|
||||||
if (!_gizmoDragActive && t is not null && !t.Value.IsIdentity)
|
|
||||||
{
|
|
||||||
_gizmoDragActive = true;
|
|
||||||
_edgesSuspended = true;
|
|
||||||
SetEdgesActive(false); // 邊線逐幀重建會卡,拖動中暫停
|
|
||||||
}
|
|
||||||
foreach (ModelNodeViewModel l in _gizmoTarget.Leaves())
|
|
||||||
if (l.BodyVisual is not null)
|
|
||||||
l.BodyVisual.Transform = t;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>滑鼠放開(MainWindow 轉發,handledEventsToo):把累積變換烘進 B-rep 並重置操作器</summary>
|
|
||||||
public void OnGizmoMouseUp()
|
|
||||||
{
|
|
||||||
if (_gizmoTarget is null || _gizmoProxy is null || _gizmoBakePending) return;
|
|
||||||
Matrix3D m = _gizmoProxy.Transform?.Value ?? Matrix3D.Identity;
|
|
||||||
if (m.IsIdentity) return; // 只是點一下、沒拖操作器 → 不烘焙
|
|
||||||
|
|
||||||
// 延後到 manipulator 自身的 mouse-up 處理(釋放捕捉等)完成後再烘焙,避免在其事件中改動視覺樹造成 reentrancy
|
|
||||||
_gizmoBakePending = true;
|
|
||||||
_viewport?.Dispatcher.BeginInvoke(new Action(() => BakeGizmo(m)),
|
|
||||||
System.Windows.Threading.DispatcherPriority.Background);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void BakeGizmo(Matrix3D m)
|
|
||||||
{
|
|
||||||
_gizmoBakePending = false;
|
|
||||||
if (_gizmoTarget is null) return;
|
|
||||||
ModelNodeViewModel root = _gizmoTarget;
|
|
||||||
|
|
||||||
_gizmoBaking = true; // 防 proxy 歸零觸發 GizmoTransformChanged 重入
|
|
||||||
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; // 綁定 → 操作器歸零
|
|
||||||
_gizmoBaking = false;
|
|
||||||
_gizmoDragActive = false;
|
|
||||||
_edgesSuspended = false;
|
|
||||||
SetEdgesActive(true);
|
|
||||||
|
|
||||||
TransformRoot(root, RigidAlign.ToModOp(m), m); // 烘進 B-rep(量測精度不受影響)
|
|
||||||
|
|
||||||
// 操作器移到變換後的新中心
|
|
||||||
Rect3D b = root.Bounds;
|
|
||||||
if (!b.IsEmpty)
|
|
||||||
{
|
|
||||||
var center = new Point3D(b.X + b.SizeX / 2, b.Y + b.SizeY / 2, b.Z + b.SizeZ / 2);
|
|
||||||
foreach (HelixToolkit.Wpf.Manipulator man in _gizmoParts)
|
|
||||||
man.Position = center;
|
|
||||||
}
|
|
||||||
StatusText = $"已套用操作器變換「{root.Name}」(可用 🧩干涉 驗證)";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 干涉檢查 ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task CheckInterferenceAsync()
|
|
||||||
{
|
|
||||||
if (_viewport is null) return;
|
|
||||||
var visibleRoots = Roots.Where(r => r.IsVisible).ToList();
|
|
||||||
if (visibleRoots.Count != 2)
|
|
||||||
{
|
|
||||||
StatusText = $"干涉檢查需要剛好 2 個可見檔案(目前 {visibleRoots.Count} 個)— 用樹面板勾選";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<MeshGeometry3D> MeshesOf(ModelNodeViewModel root) =>
|
|
||||||
root.Leaves().Where(l => l.IsVisible && l.FacesContent is not null)
|
|
||||||
.SelectMany(l => l.FacesContent!.Children)
|
|
||||||
.Where(m => m is GeometryModel3D gm && _faceMap.ContainsKey(gm))
|
|
||||||
.Select(m => _faceMap[(GeometryModel3D)m].Mesh)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var a = MeshesOf(visibleRoots[0]);
|
|
||||||
var b = MeshesOf(visibleRoots[1]);
|
|
||||||
string nameA = visibleRoots[0].Name, nameB = visibleRoots[1].Name;
|
|
||||||
|
|
||||||
IsBusy = true;
|
|
||||||
StatusText = $"干涉檢查中:{nameA} ⟷ {nameB} …";
|
|
||||||
try
|
|
||||||
{
|
|
||||||
InterferenceResult result = await Task.Run(() => InterferenceService.Check(a, b));
|
|
||||||
AddInterferenceResult(result, nameA, nameB);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
StatusText = $"干涉檢查失敗:{ex.Message}";
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
IsBusy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void AddInterferenceResult(InterferenceResult result, string nameA, string nameB)
|
|
||||||
{
|
|
||||||
double diag = SceneDiagonal();
|
|
||||||
double markerR = Math.Clamp(diag * 0.004, 0.01, 5.0);
|
|
||||||
MeasurementResult m;
|
|
||||||
|
|
||||||
if (result.Intersects)
|
|
||||||
{
|
|
||||||
int pairs = result.PairCount;
|
|
||||||
int segCount = result.Segments.Count;
|
|
||||||
m = new MeasurementResult
|
|
||||||
{
|
|
||||||
Kind = MeasureMode.Interference,
|
|
||||||
TitleFor = _ => $"🧩 干涉!{nameA} ⟷ {nameB}",
|
|
||||||
DetailFor = _ => $"兩零件相交(不 match)\n相交三角形對 = {pairs:N0}\n" +
|
|
||||||
$"紅色線 = 干涉交線({segCount:N0} 段)\n(共面貼合不會被算為干涉)",
|
|
||||||
};
|
|
||||||
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); }
|
|
||||||
m.Overlays.Add(new LinesVisual3D { Points = pts, Color = Colors.Red, Thickness = 3 });
|
|
||||||
if (result.Segments.Count > 0)
|
|
||||||
{
|
|
||||||
Point3D at = result.Segments[0].A;
|
|
||||||
m.Overlays.Add(new BillboardTextVisual3D
|
|
||||||
{
|
|
||||||
Position = at + new Vector3D(0, 0, markerR * 3),
|
|
||||||
Text = "干涉",
|
|
||||||
Foreground = Brushes.White,
|
|
||||||
Background = Brushes.Red,
|
|
||||||
Padding = new Thickness(5, 2, 5, 2),
|
|
||||||
FontSize = 14,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Point3D ga = result.GapA, gb = result.GapB;
|
|
||||||
Vector3D d = gb - ga;
|
|
||||||
double gap = result.GapDistance;
|
|
||||||
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)}",
|
|
||||||
};
|
|
||||||
m.Overlays.Add(new SphereVisual3D { Center = ga, Radius = markerR, Fill = Brushes.OrangeRed });
|
|
||||||
m.Overlays.Add(new SphereVisual3D { Center = gb, Radius = markerR, Fill = Brushes.OrangeRed });
|
|
||||||
m.Overlays.Add(new LinesVisual3D
|
|
||||||
{
|
|
||||||
Points = new Point3DCollection { ga, gb },
|
|
||||||
Color = Colors.OrangeRed,
|
|
||||||
Thickness = 2,
|
|
||||||
});
|
|
||||||
var label = new BillboardTextVisual3D
|
|
||||||
{
|
|
||||||
Position = ga + d / 2 + new Vector3D(0, 0, markerR * 2),
|
|
||||||
Text = $"gap {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)}"));
|
|
||||||
}
|
|
||||||
AddMeasurement(m);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 剖面 ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
partial void OnSectionEnabledChanged(bool value) => ScheduleSection();
|
|
||||||
partial void OnSectionAxisIndexChanged(int value) => ScheduleSection();
|
|
||||||
partial void OnSectionPositionChanged(double value) => ScheduleSection();
|
|
||||||
partial void OnSectionFlipChanged(bool value) => ScheduleSection();
|
|
||||||
|
|
||||||
private void ScheduleSection()
|
|
||||||
{
|
|
||||||
_sectionTimer.Stop();
|
|
||||||
_sectionTimer.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ApplySection()
|
|
||||||
{
|
|
||||||
if (_viewport is null) return;
|
|
||||||
|
|
||||||
if (!SectionEnabled)
|
|
||||||
{
|
|
||||||
// 還原原始幾何
|
|
||||||
foreach ((Model3D model, FaceInfo fi) in _faceMap)
|
|
||||||
if (model is GeometryModel3D gm && !ReferenceEquals(gm.Geometry, fi.Mesh))
|
|
||||||
gm.Geometry = fi.Mesh;
|
|
||||||
foreach (ModelNodeViewModel root in Roots) RefreshRootEdges(root);
|
|
||||||
if (_sectionPlaneVisual is not null)
|
|
||||||
{
|
|
||||||
_viewport.Children.Remove(_sectionPlaneVisual);
|
|
||||||
_sectionPlaneVisual = null;
|
|
||||||
}
|
|
||||||
ApplyRenderMode(); // 剖面關閉 → 瀏覽模式可切回合併網格
|
|
||||||
StatusText = "剖面已關閉";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Rect3D bounds = UnionBounds();
|
|
||||||
if (bounds.IsEmpty) return;
|
|
||||||
|
|
||||||
Vector3D axis = SectionAxisIndex switch
|
|
||||||
{
|
|
||||||
0 => new Vector3D(1, 0, 0),
|
|
||||||
1 => new Vector3D(0, 1, 0),
|
|
||||||
_ => new Vector3D(0, 0, 1),
|
|
||||||
};
|
|
||||||
double t = SectionPosition / 100.0;
|
|
||||||
Point3D min = bounds.Location;
|
|
||||||
var size = new Vector3D(bounds.SizeX, bounds.SizeY, bounds.SizeZ);
|
|
||||||
double planeCoord = SectionAxisIndex switch
|
|
||||||
{
|
|
||||||
0 => min.X + size.X * t,
|
|
||||||
1 => min.Y + size.Y * t,
|
|
||||||
_ => min.Z + size.Z * t,
|
|
||||||
};
|
|
||||||
Point3D center = min + size / 2;
|
|
||||||
Point3D planePoint = SectionAxisIndex switch
|
|
||||||
{
|
|
||||||
0 => new Point3D(planeCoord, center.Y, center.Z),
|
|
||||||
1 => new Point3D(center.X, planeCoord, center.Z),
|
|
||||||
_ => new Point3D(center.X, center.Y, planeCoord),
|
|
||||||
};
|
|
||||||
Vector3D normal = SectionFlip ? -axis : axis;
|
|
||||||
|
|
||||||
foreach ((Model3D model, FaceInfo fi) in _faceMap)
|
|
||||||
if (model is GeometryModel3D gm)
|
|
||||||
gm.Geometry = SectionService.ClipMesh(fi.Mesh, planePoint, normal);
|
|
||||||
|
|
||||||
_sectionPlanePoint = planePoint;
|
|
||||||
_sectionNormal = normal;
|
|
||||||
foreach (ModelNodeViewModel root in Roots) RefreshRootEdges(root);
|
|
||||||
|
|
||||||
ApplyRenderMode(); // 剖面開啟 → 改用逐面(裁切後幾何)
|
|
||||||
UpdateSectionPlaneVisual(planePoint, axis, size);
|
|
||||||
StatusText = $"剖面:{"XYZ"[SectionAxisIndex]} 軸 {SectionPosition:F0}%" + (SectionFlip ? "(反向)" : "");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateSectionPlaneVisual(Point3D planePoint, Vector3D axis, Vector3D size)
|
|
||||||
{
|
|
||||||
if (_sectionPlaneVisual is not null)
|
|
||||||
_viewport!.Children.Remove(_sectionPlaneVisual);
|
|
||||||
|
|
||||||
(Vector3D lenDir, double len, double wid) = SectionAxisIndex switch
|
|
||||||
{
|
|
||||||
0 => (new Vector3D(0, 1, 0), size.Y, size.Z),
|
|
||||||
1 => (new Vector3D(1, 0, 0), size.X, size.Z),
|
|
||||||
_ => (new Vector3D(1, 0, 0), size.X, size.Y),
|
|
||||||
};
|
|
||||||
_sectionPlaneVisual = new RectangleVisual3D
|
|
||||||
{
|
|
||||||
Origin = planePoint,
|
|
||||||
Normal = axis,
|
|
||||||
LengthDirection = lenDir,
|
|
||||||
Length = len * 1.05,
|
|
||||||
Width = wid * 1.05,
|
|
||||||
Fill = new SolidColorBrush(Color.FromArgb(40, 30, 120, 255)),
|
|
||||||
};
|
|
||||||
_viewport!.Children.Add(_sectionPlaneVisual);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 匯出 ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void ExportCsv()
|
|
||||||
{
|
|
||||||
if (Measurements.Count == 0)
|
|
||||||
{
|
|
||||||
StatusText = "沒有量測結果可匯出";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var dlg = new SaveFileDialog
|
|
||||||
{
|
|
||||||
Title = "匯出量測結果",
|
|
||||||
Filter = "CSV 檔案 (*.csv)|*.csv",
|
|
||||||
FileName = $"measurements_{DateTime.Now:yyyyMMdd_HHmmss}.csv",
|
|
||||||
};
|
|
||||||
if (dlg.ShowDialog() != true) return;
|
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
sb.AppendLine("標籤,類型,明細");
|
|
||||||
foreach (MeasurementResult m in Measurements)
|
|
||||||
{
|
|
||||||
static string Esc(string s) => $"\"{s.Replace("\"", "\"\"")}\"";
|
|
||||||
sb.AppendLine($"{Esc(m.Title)},{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)}";
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void SaveScreenshot()
|
|
||||||
{
|
|
||||||
if (_viewport is null || _viewport.ActualWidth < 1) return;
|
|
||||||
var dlg = new SaveFileDialog
|
|
||||||
{
|
|
||||||
Title = "儲存視圖截圖",
|
|
||||||
Filter = "PNG 圖片 (*.png)|*.png",
|
|
||||||
FileName = $"stpviewer_{DateTime.Now:yyyyMMdd_HHmmss}.png",
|
|
||||||
};
|
|
||||||
if (dlg.ShowDialog() != true) return;
|
|
||||||
|
|
||||||
const double scale = 2.0; // 2x 解析度
|
|
||||||
var rtb = new RenderTargetBitmap(
|
|
||||||
(int)(_viewport.ActualWidth * scale), (int)(_viewport.ActualHeight * scale),
|
|
||||||
96 * scale, 96 * scale, PixelFormats.Pbgra32);
|
|
||||||
rtb.Render(_viewport);
|
|
||||||
var encoder = new PngBitmapEncoder();
|
|
||||||
encoder.Frames.Add(BitmapFrame.Create(rtb));
|
|
||||||
using FileStream fs = File.Create(dlg.FileName);
|
|
||||||
encoder.Save(fs);
|
|
||||||
StatusText = $"已儲存截圖 → {Path.GetFileName(dlg.FileName)}";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 共用 ────────────────────────────────────────────────────
|
// ─── 共用 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
private Rect3D UnionBounds()
|
private Rect3D UnionBounds()
|
||||||
|
|||||||
Reference in New Issue
Block a user