From 96e15013c39cc2881068cd5af77a87930427e988 Mon Sep 17 00:00:00 2001 From: ETWen Date: Thu, 2 Jul 2026 14:39:08 +0800 Subject: [PATCH] perf: Background parallel section clip, parallel transform bake, faster gap refine (v0.4.0) - ApplySection: snapshot on UI thread, clip whole scene with Task.Run + Parallel.For, swap once; _sectionApplying/_sectionReapply guard reruns with latest params when settings change mid-clip - TransformRoot: per-face parallel mesh transform (frozen in/out); B-rep Modify and visual-tree assignment stay sequential - InterferenceService.ApproxMinDistance: AABB squared-distance early reject + Parallel.For refine with thread-local best - Stability: global exception handlers in App.xaml.cs (dispatcher/task/ appdomain -> %LOCALAPPDATA%/STPViewer/error.log); IsBusy CanExecute guards on Import/Rotate/Interference; whole-batch busy + reentrancy guard in ImportFilesAsync - Refactor: split MainViewModel into partial classes (Drag/Gizmo/ Section/Interference/Export); dedupe merged-mesh build and ClosestPointOnTriangle - Bump version to 0.4.0 Co-Authored-By: Claude Fable 5 --- src/STPViewer/App.xaml.cs | 55 +- src/STPViewer/STPViewer.csproj | 2 +- src/STPViewer/Services/InterferenceService.cs | 46 +- src/STPViewer/Services/MeasurementService.cs | 36 +- .../ViewModels/MainViewModel.Drag.cs | 90 +++ .../ViewModels/MainViewModel.Export.cs | 65 ++ .../ViewModels/MainViewModel.Gizmo.cs | 191 +++++ .../ViewModels/MainViewModel.Interference.cs | 125 ++++ .../ViewModels/MainViewModel.Section.cs | 171 +++++ src/STPViewer/ViewModels/MainViewModel.cs | 685 ++---------------- 10 files changed, 806 insertions(+), 660 deletions(-) create mode 100644 src/STPViewer/ViewModels/MainViewModel.Drag.cs create mode 100644 src/STPViewer/ViewModels/MainViewModel.Export.cs create mode 100644 src/STPViewer/ViewModels/MainViewModel.Gizmo.cs create mode 100644 src/STPViewer/ViewModels/MainViewModel.Interference.cs create mode 100644 src/STPViewer/ViewModels/MainViewModel.Section.cs diff --git a/src/STPViewer/App.xaml.cs b/src/STPViewer/App.xaml.cs index 66a8db6..0f48b50 100644 --- a/src/STPViewer/App.xaml.cs +++ b/src/STPViewer/App.xaml.cs @@ -1,13 +1,60 @@ -using System.Configuration; -using System.Data; +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; using System.Windows; namespace STPViewer; /// -/// Interaction logic for App.xaml +/// 全域例外處理:UI 執行緒未攔截例外 → 記 log + 訊息框後盡量存活(不閃退); +/// 背景 Task / 非 UI 執行緒例外 → 記 log。log 位於 %LOCALAPPDATA%\STPViewer\error.log。 /// 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 本身失敗不能再拋 */ } + } +} diff --git a/src/STPViewer/STPViewer.csproj b/src/STPViewer/STPViewer.csproj index 34bbae7..258685a 100644 --- a/src/STPViewer/STPViewer.csproj +++ b/src/STPViewer/STPViewer.csproj @@ -6,7 +6,7 @@ enable enable true - 0.3.2 + 0.4.0 diff --git a/src/STPViewer/Services/InterferenceService.cs b/src/STPViewer/Services/InterferenceService.cs index f80363b..2979d9d 100644 --- a/src/STPViewer/Services/InterferenceService.cs +++ b/src/STPViewer/Services/InterferenceService.cs @@ -237,21 +237,43 @@ public static class InterferenceService if (d < best) { best = d; pa = trisA[i].A; pb = trisB[j].A; } } - // 2) 以最佳點為中心,精修:點 → 對方所有三角形 - Point3D RefineAgainst(Point3D p, Triangle[] tris, ref double bestD, Point3D current) - { - Point3D bestPt = current; - foreach (Triangle t in tris) + // 2) 以最佳點為中心,精修:點 → 對方所有三角形(AABB 平方距離快速拒絕 + 平行分段, + // 初值來自步驟 1 已相當接近 → 絕大多數三角形被 AABB 直接刷掉) + (pb, best) = ClosestOnTriangles(pa, trisB, pb, best); + (pa, best) = ClosestOnTriangles(pb, trisA, pa, best); + return (pa, pb, best); + } + + /// 點 p 到三角形集的最近點(帶初值;回傳更新後的最近點與距離) + private static (Point3D pt, double d) ClosestOnTriangles(Point3D p, Triangle[] tris, Point3D seedPt, double seedD) + { + object gate = new(); + 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); double d = (q - p).Length; - if (d < bestD) { bestD = d; bestPt = q; } - } - return bestPt; - } - pb = RefineAgainst(pa, trisB, ref best, pb); - pa = RefineAgainst(pb, trisA, ref best, pa); - return (pa, pb, best); + return d < local.D ? (d, q, true) : local; + }, + local => + { + if (!local.Found) return; + lock (gate) + if (local.D < bestD) { bestD = local.D; bestPt = local.Pt; } + }); + + return (bestPt, bestD); } /// 點到三角形最近點(Ericson, Real-Time Collision Detection) diff --git a/src/STPViewer/Services/MeasurementService.cs b/src/STPViewer/Services/MeasurementService.cs index 7b5499e..c280218 100644 --- a/src/STPViewer/Services/MeasurementService.cs +++ b/src/STPViewer/Services/MeasurementService.cs @@ -390,7 +390,7 @@ public class MeasurementService Point3D v = vp[i]; 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; if (d < best) { @@ -406,39 +406,7 @@ public class MeasurementService return (pa, pb); } - /// 點到三角形最近點(Ericson, Real-Time Collision Detection) - 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); - } + // 點到三角形最近點:共用 InterferenceService.ClosestPointOnTriangle(v0.4.0 去重複) // ─── overlay helpers ───────────────────────────────────────── diff --git a/src/STPViewer/ViewModels/MainViewModel.Drag.cs b/src/STPViewer/ViewModels/MainViewModel.Drag.cs new file mode 100644 index 0000000..baf2a4f --- /dev/null +++ b/src/STPViewer/ViewModels/MainViewModel.Drag.cs @@ -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; + + /// 左鍵按下:命中零件則開始拖曳(回傳 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; + + // 清除暫時位移:必須用 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; + } +} diff --git a/src/STPViewer/ViewModels/MainViewModel.Export.cs b/src/STPViewer/ViewModels/MainViewModel.Export.cs new file mode 100644 index 0000000..9a70c7c --- /dev/null +++ b/src/STPViewer/ViewModels/MainViewModel.Export.cs @@ -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)}"; + } +} diff --git a/src/STPViewer/ViewModels/MainViewModel.Gizmo.cs b/src/STPViewer/ViewModels/MainViewModel.Gizmo.cs new file mode 100644 index 0000000..bb79902 --- /dev/null +++ b/src/STPViewer/ViewModels/MainViewModel.Gizmo.cs @@ -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 _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; + + /// 注入操作器疊圖層(透明 Viewport3D,疊在主視窗上、永遠最上層) + 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(); + } + + /// 疊圖層相機跟隨主相機(每次主相機變更時呼叫,讓操作器疊在正確螢幕位置) + 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); + } + } + + /// 操作器拖動中:把代理的 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 || _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}」(可用 🧩干涉 驗證)"; + } +} diff --git a/src/STPViewer/ViewModels/MainViewModel.Interference.cs b/src/STPViewer/ViewModels/MainViewModel.Interference.cs new file mode 100644 index 0000000..2e6700e --- /dev/null +++ b/src/STPViewer/ViewModels/MainViewModel.Interference.cs @@ -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 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); + } +} diff --git a/src/STPViewer/ViewModels/MainViewModel.Section.cs b/src/STPViewer/ViewModels/MainViewModel.Section.cs new file mode 100644 index 0000000..8cadeb9 --- /dev/null +++ b/src/STPViewer/ViewModels/MainViewModel.Section.cs @@ -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; + + /// 0=X 1=Y 2=Z + [ObservableProperty] + private int sectionAxisIndex; + + /// 剖面位置(沿軸 0~100%) + [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(_faceMap.Count); + var sources = new List(_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); + } +} diff --git a/src/STPViewer/ViewModels/MainViewModel.cs b/src/STPViewer/ViewModels/MainViewModel.cs index 1357ae9..e06dd14 100644 --- a/src/STPViewer/ViewModels/MainViewModel.cs +++ b/src/STPViewer/ViewModels/MainViewModel.cs @@ -3,11 +3,9 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; using System.Linq; -using System.Text; using System.Threading.Tasks; using System.Windows; using System.Windows.Media; -using System.Windows.Media.Imaging; using System.Windows.Media.Media3D; using System.Windows.Threading; using CommunityToolkit.Mvvm.ComponentModel; @@ -35,12 +33,6 @@ public partial class MainViewModel : ObservableObject /// 邊界由匯入時依面序串接而成,與 BuildMergedMesh 串接順序一致;平移不改各面頂點數,故邊界永久有效。 private readonly Dictionary _mergedFaceRanges = 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(); @@ -57,12 +49,6 @@ public partial class MainViewModel : ObservableObject [ObservableProperty] private ModelNodeViewModel? selectedNode; - // 剖面 - private readonly DispatcherTimer _sectionTimer; - private RectangleVisual3D? _sectionPlaneVisual; - private Point3D _sectionPlanePoint; // SectionEnabled 時的剖切平面(合併邊線重建用) - private Vector3D _sectionNormal; - // 互動中暫停邊線:轉動/縮放/平移時隱藏 LinesVisual3D,停下再顯示 → 大組件流暢 private readonly DispatcherTimer _interactionTimer; private bool _edgesSuspended; @@ -74,25 +60,14 @@ public partial class MainViewModel : ObservableObject private string statusText = "就緒 — 匯入 STP/STEP/STL/DXF(拖放亦可)。右鍵旋轉、滾輪縮放、中鍵或 Shift+左鍵平移"; [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(ImportCommand))] + [NotifyCanExecuteChangedFor(nameof(RotateRootCommand))] + [NotifyCanExecuteChangedFor(nameof(CheckInterferenceCommand))] private bool isBusy; [ObservableProperty] private bool useInch; - [ObservableProperty] - private bool sectionEnabled; - - /// 0=X 1=Y 2=Z - [ObservableProperty] - private int sectionAxisIndex; - - /// 剖面位置(沿軸 0~100%) - [ObservableProperty] - private double sectionPosition = 50; - - [ObservableProperty] - private bool sectionFlip; - public ObservableCollection Roots { get; } = new(); public ObservableCollection Measurements { get; } = new(); @@ -123,30 +98,6 @@ public partial class MainViewModel : ObservableObject viewport.Camera.Changed += (_, _) => OnCameraMoved(); } - /// 注入操作器疊圖層(透明 Viewport3D,疊在主視窗上、永遠最上層) - 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(); - } - - /// 疊圖層相機跟隨主相機(每次主相機變更時呼叫,讓操作器疊在正確螢幕位置) - 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() { SyncOverlayCamera(); // 操作器疊圖層跟著主相機 @@ -170,9 +121,12 @@ public partial class MainViewModel : ObservableObject SyncVisual(root.EdgeVisual, active && root.EdgeVisual.Points.Count > 0); } + /// IsBusy 時停用會改動幾何/場景的指令(匯入、旋轉、干涉),避免背景運算期間狀態被改走造成結果錯位 + private bool IsIdle() => !IsBusy; + // ─── 匯入 ──────────────────────────────────────────────────── - [RelayCommand] + [RelayCommand(CanExecute = nameof(IsIdle))] private async Task ImportAsync() { var dlg = new OpenFileDialog @@ -189,34 +143,40 @@ public partial class MainViewModel : ObservableObject public async Task ImportFilesAsync(IEnumerable paths) { if (_viewport is null) return; + // 拖放/命令列可繞過指令 CanExecute 直接進來 → 匯入或背景運算期間直接擋掉, + // 避免兩批匯入交錯(StepImportService 有共享狀態,非重入安全) + if (IsBusy) { StatusText = "忙碌中(匯入或運算進行中),請稍候再匯入"; return; } int ok = 0, fail = 0; - foreach (string path in paths) + IsBusy = true; // 整批匯入期間維持 busy(v0.4.0:不再逐檔開關,杜絕檔案之間的重入空窗) + try { - string name = Path.GetFileNameWithoutExtension(path); - IsBusy = true; - StatusText = $"匯入中:{name} …"; - // 匯入階段回報(從背景執行緒來 → 切回 UI 執行緒) - _importService.Progress = msg => - System.Windows.Application.Current?.Dispatcher.BeginInvoke( - () => StatusText = $"匯入 {name}:{msg}"); - try + foreach (string path in paths) { - ImportedFileData data = await Task.Run(() => _importService.Import(path)); - BuildRoot(data); - ok++; - } - catch (Exception ex) - { - fail++; - StatusText = $"匯入失敗:{name} — {ex.Message}"; - MessageBox.Show($"{path}\n\n{ex.Message}", "匯入失敗", - MessageBoxButton.OK, MessageBoxImage.Warning); - } - finally - { - IsBusy = false; + string name = Path.GetFileNameWithoutExtension(path); + StatusText = $"匯入中:{name} …"; + // 匯入階段回報(從背景執行緒來 → 切回 UI 執行緒) + _importService.Progress = msg => + System.Windows.Application.Current?.Dispatcher.BeginInvoke( + () => StatusText = $"匯入 {name}:{msg}"); + try + { + ImportedFileData data = await Task.Run(() => _importService.Import(path)); + BuildRoot(data); + ok++; + } + catch (Exception ex) + { + fail++; + StatusText = $"匯入失敗:{name} — {ex.Message}"; + MessageBox.Show($"{path}\n\n{ex.Message}", "匯入失敗", + MessageBoxButton.OK, MessageBoxImage.Warning); + } } } + finally + { + IsBusy = false; + } if (ok > 0) { if (SectionEnabled) ApplySection(); @@ -373,20 +333,19 @@ public partial class MainViewModel : ObservableObject // ─── 合併網格(瀏覽模式降 draw call)──────────────────────── - /// 把一個零件的所有面網格併成單一 MeshGeometry3D(索引位移後串接) - private static MeshGeometry3D BuildMergedMesh(IReadOnlyList faces) + /// 把多個面網格依序併成單一 MeshGeometry3D(索引位移後串接;順序 = 呼叫端給的面序) + private static MeshGeometry3D MergeMeshes(IReadOnlyList meshes) { 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 nrm = new Vector3DCollection(nv); var idx = new Int32Collection(nt); int baseIdx = 0; 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); if (m.Normals.Count == m.Positions.Count) foreach (Vector3D v in m.Normals) nrm.Add(v); @@ -401,33 +360,24 @@ public partial class MainViewModel : ObservableObject return mesh; } - /// 重建合併網格(位置變更後,如零件平移)。由 leaf 逐面 fi.Mesh(未剖切原始)串接。 + /// 匯入時建合併網格(依 faces 面序串接,與 _mergedFaceRanges 的頂點邊界對齊) + private static MeshGeometry3D BuildMergedMesh(IReadOnlyList faces) + { + var meshes = new List(faces.Count); + foreach (ImportedFace f in faces) meshes.Add(f.Mesh); + return MergeMeshes(meshes); + } + + /// 重建合併網格(位置變更後,如零件平移)。由 leaf 逐面 fi.Mesh(未剖切原始)依 FacesContent 子序串接, + /// 與匯入時 BuildMergedMesh 的面序一致 → _mergedFaceRanges 頂點邊界維持有效。 private void RebuildMerged(ModelNodeViewModel leaf) { if (leaf.MergedContent is null || leaf.FacesContent is null) return; - int nv = 0, nt = 0; + var meshes = new List(leaf.FacesContent.Children.Count); foreach (Model3D mm in leaf.FacesContent.Children) if (mm is GeometryModel3D g && _faceMap.TryGetValue(g, out FaceInfo? fi)) - { nv += fi.Mesh.Positions.Count; nt += fi.Mesh.TriangleIndices.Count; } - - 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; + meshes.Add(fi.Mesh); + ((GeometryModel3D)leaf.MergedContent.Children[0]).Geometry = MergeMeshes(meshes); } /// @@ -849,27 +799,37 @@ public partial class MainViewModel : ObservableObject if (_viewport is null) return; ClearMeasurements(); // 量測標記位置已失效 - foreach (ModelNodeViewModel leaf in root.Leaves()) - { - // B-rep:對 Solid/Shell 整體 Modify(避免共用邊被逐面重複變換) + var leaves = root.Leaves().ToList(); + + // B-rep:對 Solid/Shell 整體 Modify(避免共用邊被逐面重複變換)。 + // CADability 物件非執行緒安全 → 維持循序 + foreach (ModelNodeViewModel leaf in leaves) foreach (CADability.GeoObject.IGeoObject g in leaf.SourceGeos) { try { g.Modify(op); } catch { /* 個別物件變換失敗不中斷 */ } } - // 渲染網格(逐面,與目前顯示哪種內容無關) + // 渲染網格:面級平行變換(來源/輸出皆 frozen、各面獨立 → 安全;大檔 64k 面吃滿多核) + var faceItems = new List<(GeometryModel3D Gm, FaceInfo Fi)>(); + foreach (ModelNodeViewModel leaf in leaves) if (leaf.FacesContent is not null) - { foreach (Model3D mm in leaf.FacesContent.Children) - { - if (mm is not GeometryModel3D gm || !_faceMap.TryGetValue(gm, out FaceInfo? fi)) continue; - MeshGeometry3D moved = TransformMesh(fi.Mesh, m); - fi.Mesh = moved; - gm.Geometry = moved; - } + 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 執行緒循序做 + { + faceItems[i].Fi.Mesh = moved[i]; + faceItems[i].Gm.Geometry = moved[i]; + } + + foreach (ModelNodeViewModel leaf in leaves) + { + if (leaf.FacesContent is not null) RebuildMerged(leaf); // 合併網格同步 - } // 邊線端點(資料);實際合併線於迴圈後重建 if (leaf.OriginalEdgePoints is not null) @@ -926,7 +886,7 @@ public partial class MainViewModel : ObservableObject // ─── 旋轉 90°(樹面板選取的檔案,繞其中心)────────────────── - [RelayCommand] + [RelayCommand(CanExecute = nameof(IsIdle))] private void RotateRoot(string axisName) { ModelNodeViewModel? root = SelectedNode is not null ? RootContaining(SelectedNode) @@ -960,499 +920,6 @@ 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; - - // 清除暫時位移:必須用 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 _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); - } - } - - /// 操作器拖動中:把代理的 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 || _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 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()