From dd15a83986ebef80c184aebaae0168626c9c3966 Mon Sep 17 00:00:00 2001 From: ETWen Date: Sat, 13 Jun 2026 09:41:45 +0800 Subject: [PATCH] feat(ui): Add drag mode for direct part manipulation (v0.2.0) - New Drag mode (hand cursor): left-press a part and drag to move its whole file along the screen plane; release to commit - During drag only a temporary TranslateTransform3D is applied (GPU-side, smooth even on 430k-triangle assemblies); on release the offset is baked into B-rep + meshes + edges via TranslateRoot, so measurement accuracy is unaffected - Merged-mesh hit-testing via _mergedMap (browse rendering active in drag mode); edges suspended while dragging - Mouse capture keeps drag alive outside the window; lost capture commits at current position - Right-button rotate / wheel zoom / Shift+left pan unchanged - Rename small test model to test.stp; docs updated (ARCHITECTURE Phase 9, CLAUDE.md conventions, README) Co-Authored-By: Claude Opus 4.8 --- ARCHITECTURE.md | 13 +++- CLAUDE.md | 7 +- README.md | 1 + src/STPViewer/MainWindow.xaml | 18 ++++- src/STPViewer/MainWindow.xaml.cs | 27 ++++++- src/STPViewer/Models/MeasureMode.cs | 1 + src/STPViewer/ViewModels/MainViewModel.cs | 95 ++++++++++++++++++++++- 7 files changed, 153 insertions(+), 9 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 64ab66e..25be026 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -102,7 +102,7 @@ STPViewer/ │ ├── Models/ │ ├── FaceInfo.cs # GeometryModel3D ↔ B-rep Face 對照(STL 為 null) - │ ├── MeasureMode.cs # enum: None/Point/Distance/Edge/Face/Circle/Angle/FaceDistance/Align/Align3/Interference + │ ├── MeasureMode.cs # enum: None/Point/Distance/Edge/Face/Circle/Angle/FaceDistance/Align/Align3/Drag/Interference │ ├── MeasurementResult.cs # 量測結果(雙單位 lambda、3D 標籤同步) │ └── UnitSystem.cs # mm/inch + Units 格式化 │ @@ -147,7 +147,7 @@ class FaceInfo } enum MeasureMode { None, Point, Distance, Edge, Face, Circle, - Angle, FaceDistance, Align, Interference } + Angle, FaceDistance, Align, Align3, Drag, Interference } class MeasurementResult { @@ -178,6 +178,7 @@ class MeasurementResult | 對齊 | 模式「對齊」+ 點「要移動零件」上一點、再點目標點 | 純平移該檔案使點1貼到點2(B-rep 用 `ModOp` 整體位移,量測清空) | | 三點對齊 | 模式「三點」+ 來源檔 3 點、目標檔 3 對應點 | 旋轉+平移剛體變換(點1精確貼合、1→2 方向對齊、三點平面對齊;`RigidAlign`) | | 旋轉 | 樹面板選檔案 + 工具列 ↻X/↻Y/↻Z | 繞檔案中心 +90°(連按累加;方向不合時先轉正再對齊) | +| 拖曳 | 模式「🖐 拖曳」(手形游標)+ 左鍵按住零件拖 | 沿螢幕平面移動該檔案,放開烘進 B-rep(拖曳中僅暫時 Transform,不卡) | | 干涉 | 工具列「🧩 干涉」(需剛好 2 個可見檔案) | 相交→紅色交線+相交三角形對數;無相交→最小間隙 gap(≈0 即配合 match) | | 剖面 | 工具列「✂ 剖面」+ 軸向/位置/反向 | CPU 裁切渲染網格(原始幾何保留,量測仍精確) | | 單位 | 工具列 mm ⇄ in | 既有量測(清單+3D 標籤)即時換算 | @@ -314,6 +315,14 @@ NuGet 相依(自動還原):`CADability`、`HelixToolkit.Wpf`、`CommunityT **驗收:** AlignTest 8 項全過;公母連接器可旋轉擺正後三點對齊插合,再用干涉檢查驗證配合 +### Phase 9 — 拖曳模式(工作量:M) +- [x] 🖐 拖曳模式(`MeasureMode.Drag`)— 手形游標,左鍵按住零件沿螢幕平面拖動(Helix `UnProject` 投影到過錨點、面向相機的平面) +- [x] 拖曳中僅掛暫時 `TranslateTransform3D`(GPU 端,大組件不卡),放開一次性 `TranslateRoot` 烘進 B-rep(量測精度不受影響) +- [x] `_mergedMap`(合併網格 → leaf 反查)支援瀏覽渲染下的 hit-test;拖曳中邊線暫停 +- [x] 滑鼠捕捉(拖出視窗持續)、意外中斷以當前位置定格 + +**驗收:** 大組件手形拖曳流暢;放開後干涉/量測結果與拖曳位置一致;右鍵視角操作不受影響 + --- ## Future Extensions(下一輪) diff --git a/CLAUDE.md b/CLAUDE.md index e129826..8a181a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ CAD 3D 檢視器(Windows 桌面 WPF, .NET 8):STEP/STL/DXF 匯入、STEP 裝配樹、 點/距離/邊/面/圓/角度/面距量測、兩點對齊(平移)、三點對齊(旋轉+平移)、軸向旋轉 90°、 -干涉檢查、剖面、mm⇄inch、CSV/截圖匯出。 +拖曳模式(手形游標直接拖零件)、干涉檢查、剖面、mm⇄inch、CSV/截圖匯出。 詳細設計見 [ARCHITECTURE.md](ARCHITECTURE.md)。 ## 技術棧 @@ -21,7 +21,7 @@ dotnet run --project src/STPViewer dotnet publish src/STPViewer -c Release -o publish/STPViewer ``` -測試模型:根目錄 `Amphenol RA PHD GPD20-50075_RevB_DC (for Cable).stp` +測試模型:根目錄 `test.stp`(Amphenol RA PHD 座端,小檔)與 `Amphenol PHD to PHD Cable 10201248 1.stp`(39MB 大組件,效能測試用) ## 開發慣例 @@ -39,6 +39,9 @@ dotnet publish src/STPViewer -c Release -o publish/STPViewer (勿逐面位移,會重複位移共用邊),網格/合併網格/邊線/邊界同步重算;變換後量測已失效要 `ClearMeasurements()`。 **op 與 m 必須是同一個變換** — 數學在 `Services/RigidAlign.cs`:WPF `Matrix3D` 是「列向量」約定、CADability `ModOp` 是「行向量」約定, `ToModOp` 負責轉置轉換,改動務必跑 `SmokeTest --align-test` 驗證兩種表示一致,否則 B-rep 與顯示網格會悄悄分家 +- 拖曳模式(`MeasureMode.Drag`):拖曳中只掛**暫時 `TranslateTransform3D`**(GPU 免費),放開才一次性 `TranslateRoot` 烘進 B-rep — + **不要改成拖曳中逐幀 TransformRoot**(大檔每幀重建網格會卡死)。2D→3D 用 Helix `UnProject`(過錨點、法向=相機 LookDirection 的平面)。 + 合併網格的 hit-test 走 `_mergedMap`(合併 Model → leaf);拖曳中邊線用 `_edgesSuspended` 暫停 - 干涉/面距/對齊等運算在背景執行緒;`Freeze()` 幾何後才跨執行緒 - 匯入在背景執行緒;`Freeze()` 幾何後才跨執行緒 - Commit 格式:Conventional Commits(`feat:` / `fix:` / `docs:` …) diff --git a/README.md b/README.md index 742b22e..25a8224 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ STP/STEP 3D 檢視器(Windows 桌面程式,C# .NET 8 WPF)— 多檔匯入 | ⤚ 對齊 | 點「要移動零件」一點 + 目標點 → 純平移該檔案使兩點貼合(B-rep 整體位移) | | 🎯 三點 | 來源檔 3 特徵點 + 目標檔 3 對應點 → 旋轉+平移一次貼合(方向不同也能對) | - **旋轉**:樹面板選檔案 + 工具列 ↻X/↻Y/↻Z 繞中心 +90°(擺正方向用,連按累加) +- **拖曳** 🖐:手形游標模式,左鍵按住零件沿螢幕平面拖動、放開定位(粗定位用;精確貼合用對齊);右鍵轉視角不受影響 - **干涉檢查** 🧩:勾選剛好 2 個可見檔案 → 相交時顯示紅色干涉交線 + 相交三角形對數;無相交時回報最小間隙 gap(gap≈0 即為配合 match,共面貼合不算干涉) - **剖面**:✂ 開關 + X/Y/Z 軸 + 位置滑桿 + 反向;CPU 網格裁切,原始幾何保留(量測不受影響) - **單位**:mm ⇄ inch 一鍵切換,既有量測(清單與 3D 標籤)即時換算 diff --git a/src/STPViewer/MainWindow.xaml b/src/STPViewer/MainWindow.xaml index 41592d4..fe07ec1 100644 --- a/src/STPViewer/MainWindow.xaml +++ b/src/STPViewer/MainWindow.xaml @@ -59,6 +59,9 @@ ToolTip="選取的檔案繞 Y 軸 +90°">↻Y + 🖐 拖曳 @@ -178,7 +181,20 @@ ShowViewCube="True" ShowCoordinateSystem="True" ShowFrameRate="False" ZoomExtentsWhenLoaded="True" PanGesture="MiddleClick" PanGesture2="Shift+LeftClick" - MouseLeftButtonDown="Viewport_MouseLeftButtonDown"> + MouseLeftButtonDown="Viewport_MouseLeftButtonDown" + MouseMove="Viewport_MouseMove" + MouseLeftButtonUp="Viewport_MouseLeftButtonUp" + LostMouseCapture="Viewport_LostMouseCapture"> + + + diff --git a/src/STPViewer/MainWindow.xaml.cs b/src/STPViewer/MainWindow.xaml.cs index 43baf70..7e4a851 100644 --- a/src/STPViewer/MainWindow.xaml.cs +++ b/src/STPViewer/MainWindow.xaml.cs @@ -32,11 +32,36 @@ public partial class MainWindow : Window private void Viewport_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) { - // Shift+左鍵 = 平移(PanGesture2),不觸發量測 + // Shift+左鍵 = 平移(PanGesture2),不觸發量測/拖曳 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 + e.Handled = true; + } + return; + } _vm.OnViewportClick(e.GetPosition(viewport.Viewport)); } + private void Viewport_MouseMove(object sender, MouseEventArgs e) + { + if (e.LeftButton == MouseButtonState.Pressed) + _vm.OnDragMove(e.GetPosition(viewport.Viewport)); + } + + private void Viewport_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + if (_vm.OnDragEnd()) + viewport.ReleaseMouseCapture(); + } + + private void Viewport_LostMouseCapture(object sender, MouseEventArgs e) => + _vm.OnDragEnd(); // 捕捉意外中斷(Alt+Tab 等)→ 以目前位置定格,不留懸空 Transform + private void Window_DragOver(object sender, DragEventArgs e) { e.Effects = HasSupportedFiles(e) ? DragDropEffects.Copy : DragDropEffects.None; diff --git a/src/STPViewer/Models/MeasureMode.cs b/src/STPViewer/Models/MeasureMode.cs index d9b8d23..1884c25 100644 --- a/src/STPViewer/Models/MeasureMode.cs +++ b/src/STPViewer/Models/MeasureMode.cs @@ -13,5 +13,6 @@ public enum MeasureMode FaceDistance, // 面到面最短距離(網格近似) Align, // 兩點對齊:平移零件使點 1 貼到點 2 Align3, // 三點對齊:旋轉+平移(來源 3 點 → 目標 3 點) + Drag, // 拖曳模式:左鍵按住零件沿螢幕平面拖動(放開才烘進 B-rep) Interference, // 干涉檢查結果(非點擊模式,僅作為結果類型) } diff --git a/src/STPViewer/ViewModels/MainViewModel.cs b/src/STPViewer/ViewModels/MainViewModel.cs index 2e33cff..43398d1 100644 --- a/src/STPViewer/ViewModels/MainViewModel.cs +++ b/src/STPViewer/ViewModels/MainViewModel.cs @@ -27,6 +27,15 @@ public partial class MainViewModel : ObservableObject /// 渲染物件 → B-rep Face 反查表(HitTest 用;overlay 不在表內所以自然被忽略) private readonly Dictionary _faceMap = new(); + /// 合併網格 → leaf 反查表(拖曳模式在瀏覽渲染下 hit-test 用) + private readonly Dictionary _mergedMap = new(); + + // 拖曳模式:暫時 Transform 跟著滑鼠,放開才一次性烘進 B-rep(TranslateRoot) + private ModelNodeViewModel? _dragRoot; + private Point3D _dragAnchor; // 命中點(世界座標),拖曳平面的錨點 + private Vector3D _dragApplied; // 目前累計位移 + private TranslateTransform3D? _dragTransform; + private HelixViewport3D? _viewport; private int _paletteIndex; private readonly Dictionary _counters = new(); @@ -257,11 +266,13 @@ public partial class MainViewModel : ObservableObject mergedModel.BackMaterial = backMat; vm.MergedContent = new Model3DGroup(); vm.MergedContent.Children.Add(mergedModel); + _mergedMap[mergedModel] = vm; // 預設瀏覽模式 → 顯示合併網格(量測模式時由 ApplyRenderMode 換回逐面) vm.BodyVisual = new ModelVisual3D { - Content = (CurrentMode == MeasureMode.None && !SectionEnabled) ? vm.MergedContent : group, + Content = (CurrentMode is MeasureMode.None or MeasureMode.Drag && !SectionEnabled) + ? vm.MergedContent : group, }; } @@ -377,7 +388,7 @@ public partial class MainViewModel : ObservableObject /// private void ApplyRenderMode() { - bool browse = CurrentMode == MeasureMode.None && !SectionEnabled; + bool browse = CurrentMode is MeasureMode.None or MeasureMode.Drag && !SectionEnabled; foreach (ModelNodeViewModel leaf in Roots.SelectMany(r => r.Leaves())) { if (leaf.BodyVisual is null) continue; @@ -414,6 +425,9 @@ public partial class MainViewModel : ObservableObject if (leaf.FacesContent is not null) // 逐面群組才在 _faceMap(與目前顯示哪種內容無關) foreach (Model3D m in leaf.FacesContent.Children) _faceMap.Remove(m); + if (leaf.MergedContent is not null) + foreach (Model3D m in leaf.MergedContent.Children) + _mergedMap.Remove(m); } if (root.EdgeVisual is not null) _viewport.Children.Remove(root.EdgeVisual); @@ -439,6 +453,7 @@ public partial class MainViewModel : ObservableObject MeasureMode.FaceDistance => "面距量測:點選第 1 個面", MeasureMode.Align => "兩點對齊:先點「要移動零件」上的點,再點目標點(純平移,量測會清空)", MeasureMode.Align3 => "三點對齊:先在「要移動的檔案」點 3 個特徵點,再到目標檔案點 3 個對應點(旋轉+平移)", + MeasureMode.Drag => "拖曳模式:左鍵按住零件拖動(沿螢幕平面),放開定位;深度方向先轉視角再拖。右鍵仍可轉視角", _ => StatusText, }; } @@ -453,7 +468,7 @@ public partial class MainViewModel : ObservableObject /// MainWindow 滑鼠左鍵點擊轉發進來 public void OnViewportClick(Point position) { - if (_viewport is null || IsBusy || CurrentMode == MeasureMode.None) return; + if (_viewport is null || IsBusy || CurrentMode is MeasureMode.None or MeasureMode.Drag) return; var hits = _viewport.Viewport.FindHits(position); FaceInfo? fi = null; @@ -863,6 +878,80 @@ public partial class MainViewModel : ObservableObject // 三點對齊的剛體變換數學在 Services/RigidAlign.cs(SmokeTest --align-test 驗證) + // ─── 拖曳模式(左鍵按住零件沿螢幕平面拖;放開才烘進 B-rep)── + + /// 左鍵按下:命中零件則開始拖曳(回傳 true 表示要捕捉滑鼠) + 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; + } + + /// 滑鼠移動:把 2D 位移投影到「過錨點、面向相機」的平面上 → 暫時 Transform + 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; + } + + /// 放開:移除暫時 Transform,一次性烘進 B-rep(量測精度不受拖曳影響) + public bool OnDragEnd() + { + if (_dragRoot is null) return false; + ModelNodeViewModel root = _dragRoot; + Vector3D delta = _dragApplied; + + foreach (ModelNodeViewModel l in root.Leaves()) + if (l.BodyVisual is not null) + l.BodyVisual.Transform = null; + _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; + } + // ─── 干涉檢查 ──────────────────────────────────────────────── [RelayCommand]