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()