diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2f78982..b72ec7a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -179,6 +179,7 @@ class MeasurementResult | 三點對齊 | 模式「三點」+ 來源檔 3 點、目標檔 3 對應點 | 旋轉+平移剛體變換(點1精確貼合、1→2 方向對齊、三點平面對齊;`RigidAlign`) | | 旋轉 | 樹面板選檔案 + 工具列 ↻X/↻Y/↻Z | 繞檔案中心 +90°(連按累加;方向不合時先轉正再對齊) | | 拖曳 | 模式「🖐 拖曳」(手形游標)+ 左鍵按住零件拖 | 沿螢幕平面移動該檔案,放開烘進 B-rep(拖曳中僅暫時 Transform,不卡) | +| 操作器 | 「⊹ 操作器」+ 樹面板選檔案 | XYZ 箭頭沿軸移動、旋轉環繞軸轉任意角度(與視角無關);每次放開烘進 B-rep | | 干涉 | 工具列「🧩 干涉」(需剛好 2 個可見檔案) | 相交→紅色交線+相交三角形對數;無相交→最小間隙 gap(≈0 即配合 match) | | 剖面 | 工具列「✂ 剖面」+ 軸向/位置/反向 | CPU 裁切渲染網格(原始幾何保留,量測仍精確) | | 單位 | 工具列 mm ⇄ in | 既有量測(清單+3D 標籤)即時換算 | @@ -326,6 +327,14 @@ NuGet 相依(自動還原):`CADability`、`HelixToolkit.Wpf`、`CommunityT > v0.2.1 修正:拖曳放開時清除暫時位移誤用 `Transform = null`,導致下次 `FindHits` 命中該檔案時 > HelixToolkit `GetTransform` 對 null transform `Add` 而 crash。改用 `Transform3D.Identity`。 +### Phase 10 — Gizmo 三軸操作器(工作量:M) +- [x] 「⊹ 操作器」toggle — 對樹面板選取的檔案顯示 Helix `TranslateManipulator` ×3(XYZ 紅綠藍箭頭)+ `RotateManipulator` ×3(旋轉環),尺寸隨檔案 Bounds 自適應 +- [x] 操作器綁定代理 `ModelVisual3D`,拖動中代理 Transform 即時套到目標所有 BodyVisual(暫時、GPU 端);邊線暫停 +- [x] 放開滑鼠(`handledEventsToo` 捕捉)→ `Dispatcher.BeginInvoke` 延後 `TransformRoot` 烘進 B-rep、操作器歸零並移到新中心 +- [x] 換樹選取自動換目標;目標被移除自動收掉;暫時 Transform 清除用 `Identity`(非 null) + +**驗收:** 拖 X 箭頭零件僅沿世界 X 移動(與視角無關);拖環任意角度旋轉;放開後量測/干涉與顯示位置一致 + --- ## Future Extensions(下一輪) diff --git a/CLAUDE.md b/CLAUDE.md index 2081cc6..e28dbba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,12 @@ dotnet publish src/STPViewer -c Release -o publish/STPViewer 合併網格的 hit-test 走 `_mergedMap`(合併 Model → leaf);拖曳中邊線用 `_edgesSuspended` 暫停 - ⚠️ **`Visual3D.Transform` 永遠不要設成 `null`,清除要用 `Transform3D.Identity`**。HelixToolkit `Viewport3DHelper.GetTransform` 對 `child.Transform` 沒做 null 檢查(`GeneralTransform3DGroup.Children.Add(null)` → 拋「無法新增空值到集合中」), - 之後任何 `FindHits` 都會 crash。v0.2.1 修的就是拖曳放開時把 BodyVisual.Transform 設 null(拖過一次後再點擊就炸) + 之後任何 `FindHits` 都會 crash。v0.2.1 修的就是拖曳放開時把 BodyVisual.Transform 設 null(拖過一次後再點擊就炸)。 + Gizmo 的暫時 Transform 清除同理一律用 Identity +- Gizmo 操作器:Helix `TranslateManipulator`/`RotateManipulator` ×6 `Bind` 到代理 `ModelVisual3D`, + 代理 Transform 變更(`DependencyPropertyDescriptor.AddValueChanged`)即時套到目標 BodyVisual(暫時); + **放開滑鼠才 `TransformRoot` 烘焙** — MouseUp 被 manipulator 標 handled,MainWindow 用 `AddHandler(..., handledEventsToo: true)` 才收得到。 + 烘焙用 `Dispatcher.BeginInvoke` 延後到 manipulator 自身事件處理完,避免 reentrancy;`_gizmoBaking` 旗標防 Transform 歸零的回呼重入 - 干涉/面距/對齊等運算在背景執行緒;`Freeze()` 幾何後才跨執行緒 - 匯入在背景執行緒;`Freeze()` 幾何後才跨執行緒 - Commit 格式:Conventional Commits(`feat:` / `fix:` / `docs:` …) diff --git a/README.md b/README.md index 25a8224..0f66ca3 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ STP/STEP 3D 檢視器(Windows 桌面程式,C# .NET 8 WPF)— 多檔匯入 | 🎯 三點 | 來源檔 3 特徵點 + 目標檔 3 對應點 → 旋轉+平移一次貼合(方向不同也能對) | - **旋轉**:樹面板選檔案 + 工具列 ↻X/↻Y/↻Z 繞中心 +90°(擺正方向用,連按累加) - **拖曳** 🖐:手形游標模式,左鍵按住零件沿螢幕平面拖動、放開定位(粗定位用;精確貼合用對齊);右鍵轉視角不受影響 +- **操作器** ⊹:樹面板選檔案 → 顯示 XYZ 三色箭頭 + 旋轉環(Fusion 360 風格);拖箭頭沿該軸移動(與視角無關,適合沿插合軸推進)、拖環繞軸轉任意角度,放開即定位 - **干涉檢查** 🧩:勾選剛好 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 fe07ec1..5539527 100644 --- a/src/STPViewer/MainWindow.xaml +++ b/src/STPViewer/MainWindow.xaml @@ -62,6 +62,8 @@ 🖐 拖曳 + ⊹ 操作器 diff --git a/src/STPViewer/MainWindow.xaml.cs b/src/STPViewer/MainWindow.xaml.cs index 7e4a851..d8372f3 100644 --- a/src/STPViewer/MainWindow.xaml.cs +++ b/src/STPViewer/MainWindow.xaml.cs @@ -16,6 +16,10 @@ public partial class MainWindow : Window DataContext = _vm; _vm.Attach(viewport); Loaded += MainWindow_Loaded; + + // Gizmo 操作器的放開事件:manipulator 會把 MouseUp 標成 handled,需 handledEventsToo 才收得到 + viewport.AddHandler(MouseLeftButtonUpEvent, + new MouseButtonEventHandler((_, _) => _vm.OnGizmoMouseUp()), handledEventsToo: true); } /// 支援命令列帶檔開啟:STPViewer.exe a.stp b.stl … diff --git a/src/STPViewer/ViewModels/MainViewModel.cs b/src/STPViewer/ViewModels/MainViewModel.cs index f7bd2d5..2892487 100644 --- a/src/STPViewer/ViewModels/MainViewModel.cs +++ b/src/STPViewer/ViewModels/MainViewModel.cs @@ -415,6 +415,8 @@ public partial class MainViewModel : ObservableObject { if (_viewport is null || !root.IsRoot) return; + if (ReferenceEquals(root, _gizmoTarget)) GizmoEnabled = false; // 目標被移除 → 收掉操作器 + // 量測 overlay 可能標在此檔模型上 → 一併清除(簡化:全清) if (Measurements.Count > 0) ClearMeasurements(); @@ -954,6 +956,154 @@ public partial class MainViewModel : ObservableObject return true; } + // ─── Gizmo 三軸操作器(XYZ 箭頭 + 旋轉環;每次放開滑鼠烘進 B-rep)── + + [ObservableProperty] + private bool gizmoEnabled; + + private readonly List _gizmoParts = new(); + private ModelVisual3D? _gizmoProxy; // 操作器綁定的代理 visual(其 Transform = 使用者拖出的變換) + private ModelNodeViewModel? _gizmoTarget; + private bool _gizmoDragActive; // 拖動中(邊線已暫停) + private bool _gizmoBaking; // 烘焙中,忽略 Transform 變更回呼 + + partial void OnGizmoEnabledChanged(bool value) => UpdateGizmo(); + + partial void OnSelectedNodeChanged(ModelNodeViewModel? value) + { + if (GizmoEnabled) UpdateGizmo(); // 換選取 → 操作器跟著換目標 + } + + private void UpdateGizmo() + { + RemoveGizmo(); + if (!GizmoEnabled || _viewport 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; + + 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(); + _viewport.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); + _viewport!.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); + _viewport?.Children.Remove(_gizmoProxy); + } + foreach (HelixToolkit.Wpf.Manipulator man in _gizmoParts) + { + man.UnBind(); + _viewport?.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); + } + } + + /// 操作器拖動中:把代理的 Transform 套到目標檔案的所有 BodyVisual(暫時、GPU 端) + 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; + } + + /// 滑鼠放開(MainWindow 轉發,handledEventsToo):把累積變換烘進 B-rep 並重置操作器 + public void OnGizmoMouseUp() + { + if (_gizmoTarget is null || _gizmoProxy is null) return; + Matrix3D m = _gizmoProxy.Transform?.Value ?? Matrix3D.Identity; + if (m.IsIdentity) return; // 只是點一下、沒拖操作器 → 不烘焙 + + // 延後到 manipulator 自身的 mouse-up 處理(釋放捕捉等)完成後再烘焙,避免在其事件中改動視覺樹造成 reentrancy + _viewport?.Dispatcher.BeginInvoke(new Action(() => BakeGizmo(m)), + System.Windows.Threading.DispatcherPriority.Background); + } + + private void BakeGizmo(Matrix3D m) + { + 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]