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 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 14:39:08 +08:00
co-authored by Claude Fable 5
parent d5a0f5ef57
commit 96e15013c3
10 changed files with 806 additions and 660 deletions
+51 -4
View File
@@ -1,13 +1,60 @@
using System.Configuration; using System;
using System.Data; using System.IO;
using System.Text;
using System.Threading.Tasks;
using System.Windows; using System.Windows;
namespace STPViewer; namespace STPViewer;
/// <summary> /// <summary>
/// Interaction logic for App.xaml /// 全域例外處理:UI 執行緒未攔截例外 → 記 log + 訊息框後盡量存活(不閃退);
/// 背景 Task / 非 UI 執行緒例外 → 記 log。log 位於 %LOCALAPPDATA%\STPViewer\error.log。
/// </summary> /// </summary>
public partial class App : Application public partial class App : Application
{ {
} private static readonly string LogPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"STPViewer", "error.log");
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
// UI 執行緒(含 async void 回拋)的未攔截例外:記 log、提示、標記 Handled 讓程式存活。
// 若後續狀態已壞使用者可自行重啟;至少量測結果 / 已載模型不會直接蒸發。
DispatcherUnhandledException += (_, args) =>
{
LogException("DispatcherUnhandledException", args.Exception);
MessageBox.Show(
$"發生未預期的錯誤,操作已中止(程式將嘗試繼續運作)。\n\n{args.Exception.Message}\n\n" +
$"詳細記錄:{LogPath}",
"STPViewer 錯誤", MessageBoxButton.OK, MessageBoxImage.Warning);
args.Handled = true;
};
// 沒被 await 的背景 Task 例外(finalizer 階段才浮出):記 log 並標記已觀察,避免行程終止
TaskScheduler.UnobservedTaskException += (_, args) =>
{
LogException("UnobservedTaskException", args.Exception);
args.SetObserved();
};
// 其他非 UI 執行緒的致命例外:無法阻止終止,但至少留下記錄
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
LogException("AppDomainUnhandledException", args.ExceptionObject as Exception);
}
private static void LogException(string source, Exception? ex)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(LogPath)!);
var sb = new StringBuilder();
sb.AppendLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {source}");
sb.AppendLine(ex?.ToString() ?? "(null exception)");
sb.AppendLine(new string('-', 80));
File.AppendAllText(LogPath, sb.ToString(), Encoding.UTF8);
}
catch { /* logging 本身失敗不能再拋 */ }
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF> <UseWPF>true</UseWPF>
<Version>0.3.2</Version> <!-- 單一版號來源:標題與 publish-winapp 打包資料夾都讀這個 --> <Version>0.4.0</Version> <!-- 單一版號來源:標題與 publish-winapp 打包資料夾都讀這個 -->
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
+34 -12
View File
@@ -237,21 +237,43 @@ public static class InterferenceService
if (d < best) { best = d; pa = trisA[i].A; pb = trisB[j].A; } if (d < best) { best = d; pa = trisA[i].A; pb = trisB[j].A; }
} }
// 2) 以最佳點為中心,精修:點 → 對方所有三角形 // 2) 以最佳點為中心,精修:點 → 對方所有三角形(AABB 平方距離快速拒絕 + 平行分段,
Point3D RefineAgainst(Point3D p, Triangle[] tris, ref double bestD, Point3D current) // 初值來自步驟 1 已相當接近 → 絕大多數三角形被 AABB 直接刷掉)
{ (pb, best) = ClosestOnTriangles(pa, trisB, pb, best);
Point3D bestPt = current; (pa, best) = ClosestOnTriangles(pb, trisA, pa, best);
foreach (Triangle t in tris) return (pa, pb, best);
}
/// <summary>點 p 到三角形集的最近點(帶初值;回傳更新後的最近點與距離)</summary>
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); Point3D q = ClosestPointOnTriangle(p, t.A, t.B, t.C);
double d = (q - p).Length; double d = (q - p).Length;
if (d < bestD) { bestD = d; bestPt = q; } return d < local.D ? (d, q, true) : local;
} },
return bestPt; local =>
} {
pb = RefineAgainst(pa, trisB, ref best, pb); if (!local.Found) return;
pa = RefineAgainst(pb, trisA, ref best, pa); lock (gate)
return (pa, pb, best); if (local.D < bestD) { bestD = local.D; bestPt = local.Pt; }
});
return (bestPt, bestD);
} }
/// <summary>點到三角形最近點(Ericson, Real-Time Collision Detection</summary> /// <summary>點到三角形最近點(Ericson, Real-Time Collision Detection</summary>
+2 -34
View File
@@ -390,7 +390,7 @@ public class MeasurementService
Point3D v = vp[i]; Point3D v = vp[i];
for (int t = 0; t + 2 < ti.Count; t += 3) for (int t = 0; t + 2 < ti.Count; t += 3)
{ {
Point3D q = ClosestPointOnTriangle(v, tp[ti[t]], tp[ti[t + 1]], tp[ti[t + 2]]); Point3D q = InterferenceService.ClosestPointOnTriangle(v, tp[ti[t]], tp[ti[t + 1]], tp[ti[t + 2]]);
double d = (q - v).Length; double d = (q - v).Length;
if (d < best) if (d < best)
{ {
@@ -406,39 +406,7 @@ public class MeasurementService
return (pa, pb); return (pa, pb);
} }
/// <summary>點到三角形最近點(Ericson, Real-Time Collision Detection</summary> // 點到三角形最近點:共用 InterferenceService.ClosestPointOnTrianglev0.4.0 去重複)
private static Point3D ClosestPointOnTriangle(Point3D p, Point3D a, Point3D b, Point3D c)
{
Vector3D ab = b - a, ac = c - a, ap = p - a;
double d1 = Vector3D.DotProduct(ab, ap);
double d2 = Vector3D.DotProduct(ac, ap);
if (d1 <= 0 && d2 <= 0) return a;
Vector3D bp = p - b;
double d3 = Vector3D.DotProduct(ab, bp);
double d4 = Vector3D.DotProduct(ac, bp);
if (d3 >= 0 && d4 <= d3) return b;
double vc = d1 * d4 - d3 * d2;
if (vc <= 0 && d1 >= 0 && d3 <= 0)
return a + ab * (d1 / (d1 - d3));
Vector3D cp = p - c;
double d5 = Vector3D.DotProduct(ab, cp);
double d6 = Vector3D.DotProduct(ac, cp);
if (d6 >= 0 && d5 <= d6) return c;
double vb = d5 * d2 - d1 * d6;
if (vb <= 0 && d2 >= 0 && d6 <= 0)
return a + ac * (d2 / (d2 - d6));
double va = d3 * d6 - d5 * d4;
if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0)
return b + (c - b) * ((d4 - d3) / ((d4 - d3) + (d5 - d6)));
double denom = 1 / (va + vb + vc);
return a + ab * (vb * denom) + ac * (vc * denom);
}
// ─── overlay helpers ───────────────────────────────────────── // ─── overlay helpers ─────────────────────────────────────────
@@ -0,0 +1,90 @@
using System.Windows;
using System.Windows.Media.Media3D;
using HelixToolkit.Wpf;
using STPViewer.Models;
namespace STPViewer.ViewModels;
// ─── 拖曳模式(左鍵按住零件沿螢幕平面拖;放開才烘進 B-rep)──────────
public partial class MainViewModel
{
// 拖曳模式:暫時 Transform 跟著滑鼠,放開才一次性烘進 B-repTranslateRoot
private ModelNodeViewModel? _dragRoot;
private Point3D _dragAnchor; // 命中點(世界座標),拖曳平面的錨點
private Vector3D _dragApplied; // 目前累計位移
private TranslateTransform3D? _dragTransform;
/// <summary>左鍵按下:命中零件則開始拖曳(回傳 true 表示要捕捉滑鼠)</summary>
public bool OnDragStart(Point position)
{
if (_viewport is null || IsBusy || CurrentMode != MeasureMode.Drag || _dragRoot is not null)
return false;
// 兩種渲染內容都可能在場:合併網格查 _mergedMap、逐面(剖面時)查 _faceMap
ModelNodeViewModel? leaf = null;
Point3D anchor = default;
foreach (var h in _viewport.Viewport.FindHits(position))
{
if (h.Model is null) continue;
if (_faceMap.TryGetValue(h.Model, out FaceInfo? fi)) { leaf = fi.Owner; anchor = h.Position; break; }
if (_mergedMap.TryGetValue(h.Model, out ModelNodeViewModel? ml)) { leaf = ml; anchor = h.Position; break; }
}
if (leaf is null) return false;
ModelNodeViewModel? root = FindRootOf(leaf) ?? RootContaining(leaf);
if (root is null) return false;
_dragRoot = root;
_dragAnchor = anchor;
_dragApplied = default;
_dragTransform = new TranslateTransform3D();
foreach (ModelNodeViewModel l in root.Leaves())
if (l.BodyVisual is not null)
l.BodyVisual.Transform = _dragTransform;
// 拖曳中隱藏邊線(LinesVisual3D 隨 Transform 變更逐幀重建會卡)
_edgesSuspended = true;
SetEdgesActive(false);
StatusText = $"拖曳「{root.Name}」中…(放開定位)";
return true;
}
/// <summary>滑鼠移動:把 2D 位移投影到「過錨點、面向相機」的平面上 → 暫時 Transform</summary>
public void OnDragMove(Point position)
{
if (_viewport?.Camera is not System.Windows.Media.Media3D.ProjectionCamera cam ||
_dragRoot is null || _dragTransform is null) return;
Point3D? p = _viewport.Viewport.UnProject(position, _dragAnchor, cam.LookDirection);
if (p is null) return;
_dragApplied = p.Value - _dragAnchor;
_dragTransform.OffsetX = _dragApplied.X;
_dragTransform.OffsetY = _dragApplied.Y;
_dragTransform.OffsetZ = _dragApplied.Z;
}
/// <summary>放開:移除暫時 Transform,一次性烘進 B-rep(量測精度不受拖曳影響)</summary>
public bool OnDragEnd()
{
if (_dragRoot is null) return false;
ModelNodeViewModel root = _dragRoot;
Vector3D delta = _dragApplied;
// 清除暫時位移:必須用 Identity,不可用 null —— HelixToolkit GetTransform 對 child.Transform
// 不做 null 檢查(Children.Add(null) 會拋「無法新增空值到集合中」),下次 FindHits 即 crash
foreach (ModelNodeViewModel l in root.Leaves())
if (l.BodyVisual is not null)
l.BodyVisual.Transform = Transform3D.Identity;
_dragRoot = null;
_dragTransform = null;
_dragApplied = default;
_edgesSuspended = false;
SetEdgesActive(true); // 恢復所有檔案的邊線(被拖檔案的點隨後由 TranslateRoot 更新,期間不會渲染)
if (delta.LengthSquared > 1e-12)
{
TranslateRoot(root, delta); // 烘進 B-rep + 網格 + 邊線 + 邊界(量測清空)
StatusText = $"已拖曳「{root.Name}」 Δ({delta.X:F2}, {delta.Y:F2}, {delta.Z:F2}) mm(可用 🧩干涉 驗證)";
}
return true;
}
}
@@ -0,0 +1,65 @@
using System;
using System.IO;
using System.Text;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Win32;
using STPViewer.Models;
namespace STPViewer.ViewModels;
// ─── 匯出(量測 CSV / 視圖 PNG 截圖)────────────────────────────────
public partial class MainViewModel
{
[RelayCommand]
private void ExportCsv()
{
if (Measurements.Count == 0)
{
StatusText = "沒有量測結果可匯出";
return;
}
var dlg = new SaveFileDialog
{
Title = "匯出量測結果",
Filter = "CSV 檔案 (*.csv)|*.csv",
FileName = $"measurements_{DateTime.Now:yyyyMMdd_HHmmss}.csv",
};
if (dlg.ShowDialog() != true) return;
var sb = new StringBuilder();
sb.AppendLine("標籤,類型,明細");
foreach (MeasurementResult m in Measurements)
{
static string Esc(string s) => $"\"{s.Replace("\"", "\"\"")}\"";
sb.AppendLine($"{Esc(m.Title)},{m.Kind},{Esc(m.Detail.Replace("\n", " | "))}");
}
File.WriteAllText(dlg.FileName, sb.ToString(), new UTF8Encoding(true)); // BOMExcel 中文不亂碼
StatusText = $"已匯出 {Measurements.Count} 筆量測 → {Path.GetFileName(dlg.FileName)}";
}
[RelayCommand]
private void SaveScreenshot()
{
if (_viewport is null || _viewport.ActualWidth < 1) return;
var dlg = new SaveFileDialog
{
Title = "儲存視圖截圖",
Filter = "PNG 圖片 (*.png)|*.png",
FileName = $"stpviewer_{DateTime.Now:yyyyMMdd_HHmmss}.png",
};
if (dlg.ShowDialog() != true) return;
const double scale = 2.0; // 2x 解析度
var rtb = new RenderTargetBitmap(
(int)(_viewport.ActualWidth * scale), (int)(_viewport.ActualHeight * scale),
96 * scale, 96 * scale, PixelFormats.Pbgra32);
rtb.Render(_viewport);
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(rtb));
using FileStream fs = File.Create(dlg.FileName);
encoder.Save(fs);
StatusText = $"已儲存截圖 → {Path.GetFileName(dlg.FileName)}";
}
}
@@ -0,0 +1,191 @@
using System;
using System.Collections.Generic;
using System.Windows.Media;
using System.Windows.Media.Media3D;
using CommunityToolkit.Mvvm.ComponentModel;
using HelixToolkit.Wpf;
namespace STPViewer.ViewModels;
// ─── Gizmo 三軸操作器(XYZ 箭頭 + 旋轉環;每次放開滑鼠烘進 B-rep)──────
public partial class MainViewModel
{
[ObservableProperty]
private bool gizmoEnabled;
private readonly List<HelixToolkit.Wpf.Manipulator> _gizmoParts = new();
private ModelVisual3D? _gizmoProxy; // 操作器綁定的代理 visual(其 Transform = 使用者拖出的變換)
private ModelNodeViewModel? _gizmoTarget;
private bool _gizmoDragActive; // 拖動中(邊線已暫停)
private bool _gizmoBaking; // 烘焙中,忽略 Transform 變更回呼
private bool _gizmoBakePending; // 已排程烘焙,防同一次放開重複觸發
// 操作器疊圖層:另一個透明 Viewport3D 疊在主視窗上、相機同步、只放操作器 →
// 操作器不在主場景,永不被實體遮擋(always-on-top);空白處滑鼠穿透回主視窗
private System.Windows.Controls.Viewport3D? _overlayViewport;
private PerspectiveCamera? _overlayCamera;
/// <summary>注入操作器疊圖層(透明 Viewport3D,疊在主視窗上、永遠最上層)</summary>
public void AttachOverlay(System.Windows.Controls.Viewport3D overlay)
{
_overlayViewport = overlay;
_overlayCamera = new PerspectiveCamera();
overlay.Camera = _overlayCamera;
// 操作器材質需打光(manipulator 用 DiffuseMaterial
overlay.Children.Add(new ModelVisual3D { Content = new AmbientLight(Color.FromRgb(0x80, 0x80, 0x80)) });
overlay.Children.Add(new ModelVisual3D { Content = new DirectionalLight(Colors.White, new Vector3D(-1, -1, -3)) });
SyncOverlayCamera();
}
/// <summary>疊圖層相機跟隨主相機(每次主相機變更時呼叫,讓操作器疊在正確螢幕位置)</summary>
private void SyncOverlayCamera()
{
if (_overlayCamera is null || _viewport?.Camera is not ProjectionCamera src) return;
_overlayCamera.Position = src.Position;
_overlayCamera.LookDirection = src.LookDirection;
_overlayCamera.UpDirection = src.UpDirection;
_overlayCamera.NearPlaneDistance = src.NearPlaneDistance;
_overlayCamera.FarPlaneDistance = src.FarPlaneDistance;
if (src is PerspectiveCamera p) _overlayCamera.FieldOfView = p.FieldOfView;
}
partial void OnGizmoEnabledChanged(bool value) => UpdateGizmo();
partial void OnSelectedNodeChanged(ModelNodeViewModel? value)
{
if (GizmoEnabled) UpdateGizmo(); // 換選取 → 操作器跟著換目標
}
private void UpdateGizmo()
{
RemoveGizmo();
if (!GizmoEnabled || _viewport is null || _overlayViewport is null) return;
ModelNodeViewModel? root = SelectedNode is not null ? RootContaining(SelectedNode)
: Roots.Count == 1 ? Roots[0] : null;
if (root is null || root.Bounds.IsEmpty)
{
StatusText = "操作器:請先在樹面板點選要操作的檔案";
GizmoEnabled = false;
return;
}
_gizmoTarget = root;
SyncOverlayCamera(); // 操作器出現前先對齊相機
Rect3D b = root.Bounds;
var center = new Point3D(b.X + b.SizeX / 2, b.Y + b.SizeY / 2, b.Z + b.SizeZ / 2);
double diag = new Vector3D(b.SizeX, b.SizeY, b.SizeZ).Length;
_gizmoProxy = new ModelVisual3D();
_overlayViewport.Children.Add(_gizmoProxy);
System.ComponentModel.DependencyPropertyDescriptor
.FromProperty(Visual3D.TransformProperty, typeof(Visual3D))
.AddValueChanged(_gizmoProxy, GizmoTransformChanged);
void AddPart(HelixToolkit.Wpf.Manipulator man)
{
man.Position = center;
man.Bind(_gizmoProxy);
_gizmoParts.Add(man);
_overlayViewport!.Children.Add(man); // 放疊圖層 → 永不被實體遮擋
}
// 平移箭頭(X 紅 / Y 綠 / Z 藍 — 業界慣例)
AddPart(new TranslateManipulator { Direction = new Vector3D(1, 0, 0), Color = Colors.Red, Length = diag * 0.22, Diameter = diag * 0.016 });
AddPart(new TranslateManipulator { Direction = new Vector3D(0, 1, 0), Color = Colors.Green, Length = diag * 0.22, Diameter = diag * 0.016 });
AddPart(new TranslateManipulator { Direction = new Vector3D(0, 0, 1), Color = Colors.Blue, Length = diag * 0.22, Diameter = diag * 0.016 });
// 旋轉環
AddPart(new RotateManipulator { Axis = new Vector3D(1, 0, 0), Color = Colors.Red, Diameter = diag * 0.34, InnerDiameter = diag * 0.30, Length = diag * 0.012 });
AddPart(new RotateManipulator { Axis = new Vector3D(0, 1, 0), Color = Colors.Green, Diameter = diag * 0.34, InnerDiameter = diag * 0.30, Length = diag * 0.012 });
AddPart(new RotateManipulator { Axis = new Vector3D(0, 0, 1), Color = Colors.Blue, Diameter = diag * 0.34, InnerDiameter = diag * 0.30, Length = diag * 0.012 });
StatusText = $"操作器:拖箭頭沿軸移動「{root.Name}」、拖環繞軸旋轉;放開即定位";
}
private void RemoveGizmo()
{
if (_gizmoProxy is not null)
{
System.ComponentModel.DependencyPropertyDescriptor
.FromProperty(Visual3D.TransformProperty, typeof(Visual3D))
.RemoveValueChanged(_gizmoProxy, GizmoTransformChanged);
_overlayViewport?.Children.Remove(_gizmoProxy);
}
foreach (HelixToolkit.Wpf.Manipulator man in _gizmoParts)
{
man.UnBind();
_overlayViewport?.Children.Remove(man);
}
_gizmoParts.Clear();
_gizmoProxy = null;
if (_gizmoTarget is not null) // 清掉殘留的暫時 Transform(用 Identity,不可 null → 否則 FindHits crash
foreach (ModelNodeViewModel l in _gizmoTarget.Leaves())
if (l.BodyVisual is not null)
l.BodyVisual.Transform = Transform3D.Identity;
_gizmoTarget = null;
if (_gizmoDragActive)
{
_gizmoDragActive = false;
_edgesSuspended = false;
SetEdgesActive(true);
}
}
/// <summary>操作器拖動中:把代理的 Transform 套到目標檔案的所有 BodyVisual(暫時、GPU 端)</summary>
private void GizmoTransformChanged(object? sender, EventArgs e)
{
if (_gizmoBaking || _gizmoTarget is null || _gizmoProxy is null) return;
Transform3D t = _gizmoProxy.Transform;
if (!_gizmoDragActive && t is not null && !t.Value.IsIdentity)
{
_gizmoDragActive = true;
_edgesSuspended = true;
SetEdgesActive(false); // 邊線逐幀重建會卡,拖動中暫停
}
foreach (ModelNodeViewModel l in _gizmoTarget.Leaves())
if (l.BodyVisual is not null)
l.BodyVisual.Transform = t;
}
/// <summary>滑鼠放開(MainWindow 轉發,handledEventsToo):把累積變換烘進 B-rep 並重置操作器</summary>
public void OnGizmoMouseUp()
{
if (_gizmoTarget is null || _gizmoProxy is null || _gizmoBakePending) return;
Matrix3D m = _gizmoProxy.Transform?.Value ?? Matrix3D.Identity;
if (m.IsIdentity) return; // 只是點一下、沒拖操作器 → 不烘焙
// 延後到 manipulator 自身的 mouse-up 處理(釋放捕捉等)完成後再烘焙,避免在其事件中改動視覺樹造成 reentrancy
_gizmoBakePending = true;
_viewport?.Dispatcher.BeginInvoke(new Action(() => BakeGizmo(m)),
System.Windows.Threading.DispatcherPriority.Background);
}
private void BakeGizmo(Matrix3D m)
{
_gizmoBakePending = false;
if (_gizmoTarget is null) return;
ModelNodeViewModel root = _gizmoTarget;
_gizmoBaking = true; // 防 proxy 歸零觸發 GizmoTransformChanged 重入
foreach (ModelNodeViewModel l in root.Leaves())
if (l.BodyVisual is not null)
l.BodyVisual.Transform = Transform3D.Identity; // 清暫時位移(用 Identity,不可 null
if (_gizmoProxy is not null) _gizmoProxy.Transform = Transform3D.Identity; // 綁定 → 操作器歸零
_gizmoBaking = false;
_gizmoDragActive = false;
_edgesSuspended = false;
SetEdgesActive(true);
TransformRoot(root, Services.RigidAlign.ToModOp(m), m); // 烘進 B-rep(量測精度不受影響)
// 操作器移到變換後的新中心
Rect3D b = root.Bounds;
if (!b.IsEmpty)
{
var center = new Point3D(b.X + b.SizeX / 2, b.Y + b.SizeY / 2, b.Z + b.SizeZ / 2);
foreach (HelixToolkit.Wpf.Manipulator man in _gizmoParts)
man.Position = center;
}
StatusText = $"已套用操作器變換「{root.Name}」(可用 🧩干涉 驗證)";
}
}
@@ -0,0 +1,125 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Media3D;
using CommunityToolkit.Mvvm.Input;
using HelixToolkit.Wpf;
using STPViewer.Models;
using STPViewer.Services;
namespace STPViewer.ViewModels;
// ─── 干涉檢查 ────────────────────────────────────────────────────────
public partial class MainViewModel
{
[RelayCommand(CanExecute = nameof(IsIdle))]
private async Task CheckInterferenceAsync()
{
if (_viewport is null) return;
var visibleRoots = Roots.Where(r => r.IsVisible).ToList();
if (visibleRoots.Count != 2)
{
StatusText = $"干涉檢查需要剛好 2 個可見檔案(目前 {visibleRoots.Count} 個)— 用樹面板勾選";
return;
}
List<MeshGeometry3D> MeshesOf(ModelNodeViewModel root) =>
root.Leaves().Where(l => l.IsVisible && l.FacesContent is not null)
.SelectMany(l => l.FacesContent!.Children)
.Where(m => m is GeometryModel3D gm && _faceMap.ContainsKey(gm))
.Select(m => _faceMap[(GeometryModel3D)m].Mesh)
.ToList();
var a = MeshesOf(visibleRoots[0]);
var b = MeshesOf(visibleRoots[1]);
string nameA = visibleRoots[0].Name, nameB = visibleRoots[1].Name;
IsBusy = true;
StatusText = $"干涉檢查中:{nameA} ⟷ {nameB} …";
try
{
InterferenceResult result = await Task.Run(() => InterferenceService.Check(a, b));
AddInterferenceResult(result, nameA, nameB);
}
catch (Exception ex)
{
StatusText = $"干涉檢查失敗:{ex.Message}";
}
finally
{
IsBusy = false;
}
}
private void AddInterferenceResult(InterferenceResult result, string nameA, string nameB)
{
double diag = SceneDiagonal();
double markerR = Math.Clamp(diag * 0.004, 0.01, 5.0);
MeasurementResult m;
if (result.Intersects)
{
int pairs = result.PairCount;
int segCount = result.Segments.Count;
m = new MeasurementResult
{
Kind = MeasureMode.Interference,
TitleFor = _ => $"🧩 干涉!{nameA} ⟷ {nameB}",
DetailFor = _ => $"兩零件相交(不 match\n相交三角形對 = {pairs:N0}\n" +
$"紅色線 = 干涉交線({segCount:N0} 段)\n(共面貼合不會被算為干涉)",
};
var pts = new Point3DCollection(Math.Min(result.Segments.Count, 20000) * 2);
foreach ((Point3D s0, Point3D s1) in result.Segments) { pts.Add(s0); pts.Add(s1); }
m.Overlays.Add(new LinesVisual3D { Points = pts, Color = Colors.Red, Thickness = 3 });
if (result.Segments.Count > 0)
{
Point3D at = result.Segments[0].A;
m.Overlays.Add(new BillboardTextVisual3D
{
Position = at + new Vector3D(0, 0, markerR * 3),
Text = "干涉",
Foreground = Brushes.White,
Background = Brushes.Red,
Padding = new Thickness(5, 2, 5, 2),
FontSize = 14,
});
}
}
else
{
Point3D ga = result.GapA, gb = result.GapB;
Vector3D d = gb - ga;
double gap = result.GapDistance;
m = new MeasurementResult
{
Kind = MeasureMode.Interference,
TitleFor = u => $"🧩 無干涉 gap ≈ {Units.L(gap, u)}",
DetailFor = u => $"{nameA} ⟷ {nameB} 無相交\n最小間隙 ≈ {Units.L(gap, u)}(網格近似)\n" +
$"gap ≈ 0 即為貼合(match\n點 1 {Units.P(ga, u)}\n點 2 {Units.P(gb, u)}",
};
m.Overlays.Add(new SphereVisual3D { Center = ga, Radius = markerR, Fill = Brushes.OrangeRed });
m.Overlays.Add(new SphereVisual3D { Center = gb, Radius = markerR, Fill = Brushes.OrangeRed });
m.Overlays.Add(new LinesVisual3D
{
Points = new Point3DCollection { ga, gb },
Color = Colors.OrangeRed,
Thickness = 2,
});
var label = new BillboardTextVisual3D
{
Position = ga + d / 2 + new Vector3D(0, 0, markerR * 2),
Text = $"gap {Units.L(gap, UnitSystem.Millimeter)}",
Foreground = Brushes.Black,
Background = new SolidColorBrush(Color.FromArgb(200, 255, 255, 210)),
Padding = new Thickness(4, 2, 4, 2),
FontSize = 14,
};
m.Overlays.Add(label);
m.DynamicLabels.Add((label, u => $"gap {Units.L(gap, u)}"));
}
AddMeasurement(m);
}
}
@@ -0,0 +1,171 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Windows.Media;
using System.Windows.Media.Media3D;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using HelixToolkit.Wpf;
using STPViewer.Models;
using STPViewer.Services;
namespace STPViewer.ViewModels;
// ─── 剖面(CPU 網格裁切;v0.4.0 起裁切在背景執行緒平行計算,UI 不凍結)──
public partial class MainViewModel
{
private readonly DispatcherTimer _sectionTimer;
private RectangleVisual3D? _sectionPlaneVisual;
private Point3D _sectionPlanePoint; // SectionEnabled 時的剖切平面(合併邊線重建用)
private Vector3D _sectionNormal;
// 背景裁切 guard:進行中又有新變更(拉滑桿/換軸/零件平移)→ 完成後用最新參數重跑一輪。
// 不要改回同步呼叫 ClipMesh —— 大檔(64k 面)整場景裁切會凍結 UI 數秒
private bool _sectionApplying;
private bool _sectionReapply;
[ObservableProperty]
private bool sectionEnabled;
/// <summary>0=X 1=Y 2=Z</summary>
[ObservableProperty]
private int sectionAxisIndex;
/// <summary>剖面位置(沿軸 0~100%</summary>
[ObservableProperty]
private double sectionPosition = 50;
[ObservableProperty]
private bool sectionFlip;
partial void OnSectionEnabledChanged(bool value) => ScheduleSection();
partial void OnSectionAxisIndexChanged(int value) => ScheduleSection();
partial void OnSectionPositionChanged(double value) => ScheduleSection();
partial void OnSectionFlipChanged(bool value) => ScheduleSection();
private void ScheduleSection()
{
_sectionTimer.Stop();
_sectionTimer.Start();
}
private async void ApplySection()
{
if (_viewport is null) return;
if (!SectionEnabled)
{
// 還原原始幾何(換回 fi.Mesh 參照,便宜、同步即可)。
// 若背景裁切還在跑,其 await 之後會看到 SectionEnabled=false 而放棄套用
foreach ((Model3D model, FaceInfo fi) in _faceMap)
if (model is GeometryModel3D gm && !ReferenceEquals(gm.Geometry, fi.Mesh))
gm.Geometry = fi.Mesh;
foreach (ModelNodeViewModel root in Roots) RefreshRootEdges(root);
if (_sectionPlaneVisual is not null)
{
_viewport.Children.Remove(_sectionPlaneVisual);
_sectionPlaneVisual = null;
}
ApplyRenderMode(); // 剖面關閉 → 瀏覽模式可切回合併網格
StatusText = "剖面已關閉";
return;
}
if (_sectionApplying) { _sectionReapply = true; return; }
_sectionApplying = true;
try
{
do
{
_sectionReapply = false;
Rect3D bounds = UnionBounds();
if (bounds.IsEmpty) return;
Vector3D axis = SectionAxisIndex switch
{
0 => new Vector3D(1, 0, 0),
1 => new Vector3D(0, 1, 0),
_ => new Vector3D(0, 0, 1),
};
double t = SectionPosition / 100.0;
Point3D min = bounds.Location;
var size = new Vector3D(bounds.SizeX, bounds.SizeY, bounds.SizeZ);
double planeCoord = SectionAxisIndex switch
{
0 => min.X + size.X * t,
1 => min.Y + size.Y * t,
_ => min.Z + size.Z * t,
};
Point3D center = min + size / 2;
Point3D planePoint = SectionAxisIndex switch
{
0 => new Point3D(planeCoord, center.Y, center.Z),
1 => new Point3D(center.X, planeCoord, center.Z),
_ => new Point3D(center.X, center.Y, planeCoord),
};
Vector3D normal = SectionFlip ? -axis : axis;
// UI 執行緒快照(原始 mesh 皆已 Freeze,可跨執行緒),背景平行裁切,回 UI 一次換上。
// 裁切期間若參數又變 / 零件被平移(TransformRoot 會呼叫 ApplySection)→
// _sectionReapply 讓迴圈用最新的 fi.Mesh 與平面重跑,最終狀態必為最新
var models = new List<GeometryModel3D>(_faceMap.Count);
var sources = new List<MeshGeometry3D>(_faceMap.Count);
foreach ((Model3D model, FaceInfo fi) in _faceMap)
if (model is GeometryModel3D gm)
{
models.Add(gm);
sources.Add(fi.Mesh);
}
MeshGeometry3D[] clipped = await Task.Run(() =>
{
var result = new MeshGeometry3D[sources.Count];
Parallel.For(0, sources.Count, i =>
result[i] = SectionService.ClipMesh(sources[i], planePoint, normal));
return result;
});
if (!SectionEnabled) return; // 裁切期間被關掉 → 還原分支已處理,丟棄結果
for (int i = 0; i < models.Count; i++)
models[i].Geometry = clipped[i]; // 已移除檔案的殘留 model 賦值無害(不在視覺樹上)
_sectionPlanePoint = planePoint;
_sectionNormal = normal;
foreach (ModelNodeViewModel root in Roots) RefreshRootEdges(root);
ApplyRenderMode(); // 剖面開啟 → 改用逐面(裁切後幾何)
UpdateSectionPlaneVisual(planePoint, axis, size);
StatusText = $"剖面:{"XYZ"[SectionAxisIndex]} 軸 {SectionPosition:F0}%" + (SectionFlip ? "(反向)" : "");
} while (_sectionReapply);
}
finally
{
_sectionApplying = false;
}
}
private void UpdateSectionPlaneVisual(Point3D planePoint, Vector3D axis, Vector3D size)
{
if (_sectionPlaneVisual is not null)
_viewport!.Children.Remove(_sectionPlaneVisual);
(Vector3D lenDir, double len, double wid) = SectionAxisIndex switch
{
0 => (new Vector3D(0, 1, 0), size.Y, size.Z),
1 => (new Vector3D(1, 0, 0), size.X, size.Z),
_ => (new Vector3D(1, 0, 0), size.X, size.Y),
};
_sectionPlaneVisual = new RectangleVisual3D
{
Origin = planePoint,
Normal = axis,
LengthDirection = lenDir,
Length = len * 1.05,
Width = wid * 1.05,
Fill = new SolidColorBrush(Color.FromArgb(40, 30, 120, 255)),
};
_viewport!.Children.Add(_sectionPlaneVisual);
}
}
+76 -609
View File
@@ -3,11 +3,9 @@ using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Windows; using System.Windows;
using System.Windows.Media; using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Media.Media3D; using System.Windows.Media.Media3D;
using System.Windows.Threading; using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
@@ -35,12 +33,6 @@ public partial class MainViewModel : ObservableObject
/// 邊界由匯入時依面序串接而成,與 BuildMergedMesh 串接順序一致;平移不改各面頂點數,故邊界永久有效。</summary> /// 邊界由匯入時依面序串接而成,與 BuildMergedMesh 串接順序一致;平移不改各面頂點數,故邊界永久有效。</summary>
private readonly Dictionary<Model3D, (int[] Starts, FaceInfo[] Faces)> _mergedFaceRanges = new(); private readonly Dictionary<Model3D, (int[] Starts, FaceInfo[] Faces)> _mergedFaceRanges = new();
// 拖曳模式:暫時 Transform 跟著滑鼠,放開才一次性烘進 B-repTranslateRoot
private ModelNodeViewModel? _dragRoot;
private Point3D _dragAnchor; // 命中點(世界座標),拖曳平面的錨點
private Vector3D _dragApplied; // 目前累計位移
private TranslateTransform3D? _dragTransform;
private HelixViewport3D? _viewport; private HelixViewport3D? _viewport;
private int _paletteIndex; private int _paletteIndex;
private readonly Dictionary<MeasureMode, int> _counters = new(); private readonly Dictionary<MeasureMode, int> _counters = new();
@@ -57,12 +49,6 @@ public partial class MainViewModel : ObservableObject
[ObservableProperty] [ObservableProperty]
private ModelNodeViewModel? selectedNode; private ModelNodeViewModel? selectedNode;
// 剖面
private readonly DispatcherTimer _sectionTimer;
private RectangleVisual3D? _sectionPlaneVisual;
private Point3D _sectionPlanePoint; // SectionEnabled 時的剖切平面(合併邊線重建用)
private Vector3D _sectionNormal;
// 互動中暫停邊線:轉動/縮放/平移時隱藏 LinesVisual3D,停下再顯示 → 大組件流暢 // 互動中暫停邊線:轉動/縮放/平移時隱藏 LinesVisual3D,停下再顯示 → 大組件流暢
private readonly DispatcherTimer _interactionTimer; private readonly DispatcherTimer _interactionTimer;
private bool _edgesSuspended; private bool _edgesSuspended;
@@ -74,25 +60,14 @@ public partial class MainViewModel : ObservableObject
private string statusText = "就緒 — 匯入 STP/STEP/STL/DXF(拖放亦可)。右鍵旋轉、滾輪縮放、中鍵或 Shift+左鍵平移"; private string statusText = "就緒 — 匯入 STP/STEP/STL/DXF(拖放亦可)。右鍵旋轉、滾輪縮放、中鍵或 Shift+左鍵平移";
[ObservableProperty] [ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ImportCommand))]
[NotifyCanExecuteChangedFor(nameof(RotateRootCommand))]
[NotifyCanExecuteChangedFor(nameof(CheckInterferenceCommand))]
private bool isBusy; private bool isBusy;
[ObservableProperty] [ObservableProperty]
private bool useInch; private bool useInch;
[ObservableProperty]
private bool sectionEnabled;
/// <summary>0=X 1=Y 2=Z</summary>
[ObservableProperty]
private int sectionAxisIndex;
/// <summary>剖面位置(沿軸 0~100%</summary>
[ObservableProperty]
private double sectionPosition = 50;
[ObservableProperty]
private bool sectionFlip;
public ObservableCollection<ModelNodeViewModel> Roots { get; } = new(); public ObservableCollection<ModelNodeViewModel> Roots { get; } = new();
public ObservableCollection<MeasurementResult> Measurements { get; } = new(); public ObservableCollection<MeasurementResult> Measurements { get; } = new();
@@ -123,30 +98,6 @@ public partial class MainViewModel : ObservableObject
viewport.Camera.Changed += (_, _) => OnCameraMoved(); viewport.Camera.Changed += (_, _) => OnCameraMoved();
} }
/// <summary>注入操作器疊圖層(透明 Viewport3D,疊在主視窗上、永遠最上層)</summary>
public void AttachOverlay(System.Windows.Controls.Viewport3D overlay)
{
_overlayViewport = overlay;
_overlayCamera = new PerspectiveCamera();
overlay.Camera = _overlayCamera;
// 操作器材質需打光(manipulator 用 DiffuseMaterial
overlay.Children.Add(new ModelVisual3D { Content = new AmbientLight(Color.FromRgb(0x80, 0x80, 0x80)) });
overlay.Children.Add(new ModelVisual3D { Content = new DirectionalLight(Colors.White, new Vector3D(-1, -1, -3)) });
SyncOverlayCamera();
}
/// <summary>疊圖層相機跟隨主相機(每次主相機變更時呼叫,讓操作器疊在正確螢幕位置)</summary>
private void SyncOverlayCamera()
{
if (_overlayCamera is null || _viewport?.Camera is not ProjectionCamera src) return;
_overlayCamera.Position = src.Position;
_overlayCamera.LookDirection = src.LookDirection;
_overlayCamera.UpDirection = src.UpDirection;
_overlayCamera.NearPlaneDistance = src.NearPlaneDistance;
_overlayCamera.FarPlaneDistance = src.FarPlaneDistance;
if (src is PerspectiveCamera p) _overlayCamera.FieldOfView = p.FieldOfView;
}
private void OnCameraMoved() private void OnCameraMoved()
{ {
SyncOverlayCamera(); // 操作器疊圖層跟著主相機 SyncOverlayCamera(); // 操作器疊圖層跟著主相機
@@ -170,9 +121,12 @@ public partial class MainViewModel : ObservableObject
SyncVisual(root.EdgeVisual, active && root.EdgeVisual.Points.Count > 0); SyncVisual(root.EdgeVisual, active && root.EdgeVisual.Points.Count > 0);
} }
/// <summary>IsBusy 時停用會改動幾何/場景的指令(匯入、旋轉、干涉),避免背景運算期間狀態被改走造成結果錯位</summary>
private bool IsIdle() => !IsBusy;
// ─── 匯入 ──────────────────────────────────────────────────── // ─── 匯入 ────────────────────────────────────────────────────
[RelayCommand] [RelayCommand(CanExecute = nameof(IsIdle))]
private async Task ImportAsync() private async Task ImportAsync()
{ {
var dlg = new OpenFileDialog var dlg = new OpenFileDialog
@@ -189,34 +143,40 @@ public partial class MainViewModel : ObservableObject
public async Task ImportFilesAsync(IEnumerable<string> paths) public async Task ImportFilesAsync(IEnumerable<string> paths)
{ {
if (_viewport is null) return; if (_viewport is null) return;
// 拖放/命令列可繞過指令 CanExecute 直接進來 → 匯入或背景運算期間直接擋掉,
// 避免兩批匯入交錯(StepImportService 有共享狀態,非重入安全)
if (IsBusy) { StatusText = "忙碌中(匯入或運算進行中),請稍候再匯入"; return; }
int ok = 0, fail = 0; int ok = 0, fail = 0;
foreach (string path in paths) IsBusy = true; // 整批匯入期間維持 busy(v0.4.0:不再逐檔開關,杜絕檔案之間的重入空窗)
try
{ {
string name = Path.GetFileNameWithoutExtension(path); foreach (string path in paths)
IsBusy = true;
StatusText = $"匯入中:{name} …";
// 匯入階段回報(從背景執行緒來 → 切回 UI 執行緒)
_importService.Progress = msg =>
System.Windows.Application.Current?.Dispatcher.BeginInvoke(
() => StatusText = $"匯入 {name}{msg}");
try
{ {
ImportedFileData data = await Task.Run(() => _importService.Import(path)); string name = Path.GetFileNameWithoutExtension(path);
BuildRoot(data); StatusText = $"匯入中:{name} …";
ok++; // 匯入階段回報(從背景執行緒來 → 切回 UI 執行緒)
} _importService.Progress = msg =>
catch (Exception ex) System.Windows.Application.Current?.Dispatcher.BeginInvoke(
{ () => StatusText = $"匯入 {name}{msg}");
fail++; try
StatusText = $"匯入失敗:{name} — {ex.Message}"; {
MessageBox.Show($"{path}\n\n{ex.Message}", "匯入失敗", ImportedFileData data = await Task.Run(() => _importService.Import(path));
MessageBoxButton.OK, MessageBoxImage.Warning); BuildRoot(data);
} ok++;
finally }
{ catch (Exception ex)
IsBusy = false; {
fail++;
StatusText = $"匯入失敗:{name} — {ex.Message}";
MessageBox.Show($"{path}\n\n{ex.Message}", "匯入失敗",
MessageBoxButton.OK, MessageBoxImage.Warning);
}
} }
} }
finally
{
IsBusy = false;
}
if (ok > 0) if (ok > 0)
{ {
if (SectionEnabled) ApplySection(); if (SectionEnabled) ApplySection();
@@ -373,20 +333,19 @@ public partial class MainViewModel : ObservableObject
// ─── 合併網格(瀏覽模式降 draw call)──────────────────────── // ─── 合併網格(瀏覽模式降 draw call)────────────────────────
/// <summary>把一個零件的所有面網格併成單一 MeshGeometry3D(索引位移後串接)</summary> /// <summary>把多個面網格依序併成單一 MeshGeometry3D(索引位移後串接;順序 = 呼叫端給的面序</summary>
private static MeshGeometry3D BuildMergedMesh(IReadOnlyList<ImportedFace> faces) private static MeshGeometry3D MergeMeshes(IReadOnlyList<MeshGeometry3D> meshes)
{ {
int nv = 0, nt = 0; int nv = 0, nt = 0;
foreach (ImportedFace f in faces) { nv += f.Mesh.Positions.Count; nt += f.Mesh.TriangleIndices.Count; } foreach (MeshGeometry3D m in meshes) { nv += m.Positions.Count; nt += m.TriangleIndices.Count; }
var pos = new Point3DCollection(nv); var pos = new Point3DCollection(nv);
var nrm = new Vector3DCollection(nv); var nrm = new Vector3DCollection(nv);
var idx = new Int32Collection(nt); var idx = new Int32Collection(nt);
int baseIdx = 0; int baseIdx = 0;
bool hasNormals = true; bool hasNormals = true;
foreach (ImportedFace f in faces) foreach (MeshGeometry3D m in meshes)
{ {
MeshGeometry3D m = f.Mesh;
foreach (Point3D p in m.Positions) pos.Add(p); foreach (Point3D p in m.Positions) pos.Add(p);
if (m.Normals.Count == m.Positions.Count) if (m.Normals.Count == m.Positions.Count)
foreach (Vector3D v in m.Normals) nrm.Add(v); foreach (Vector3D v in m.Normals) nrm.Add(v);
@@ -401,33 +360,24 @@ public partial class MainViewModel : ObservableObject
return mesh; return mesh;
} }
/// <summary>建合併網格(位置變更後,如零件平移)。由 leaf 逐面 fi.Mesh(未剖切原始)串接。</summary> /// <summary>匯入時建合併網格(依 faces 面序串接,與 _mergedFaceRanges 的頂點邊界對齊)</summary>
private static MeshGeometry3D BuildMergedMesh(IReadOnlyList<ImportedFace> faces)
{
var meshes = new List<MeshGeometry3D>(faces.Count);
foreach (ImportedFace f in faces) meshes.Add(f.Mesh);
return MergeMeshes(meshes);
}
/// <summary>重建合併網格(位置變更後,如零件平移)。由 leaf 逐面 fi.Mesh(未剖切原始)依 FacesContent 子序串接,
/// 與匯入時 BuildMergedMesh 的面序一致 → _mergedFaceRanges 頂點邊界維持有效。</summary>
private void RebuildMerged(ModelNodeViewModel leaf) private void RebuildMerged(ModelNodeViewModel leaf)
{ {
if (leaf.MergedContent is null || leaf.FacesContent is null) return; if (leaf.MergedContent is null || leaf.FacesContent is null) return;
int nv = 0, nt = 0; var meshes = new List<MeshGeometry3D>(leaf.FacesContent.Children.Count);
foreach (Model3D mm in leaf.FacesContent.Children) foreach (Model3D mm in leaf.FacesContent.Children)
if (mm is GeometryModel3D g && _faceMap.TryGetValue(g, out FaceInfo? fi)) if (mm is GeometryModel3D g && _faceMap.TryGetValue(g, out FaceInfo? fi))
{ nv += fi.Mesh.Positions.Count; nt += fi.Mesh.TriangleIndices.Count; } meshes.Add(fi.Mesh);
((GeometryModel3D)leaf.MergedContent.Children[0]).Geometry = MergeMeshes(meshes);
var pos = new Point3DCollection(nv);
var nrm = new Vector3DCollection(nv);
var idx = new Int32Collection(nt);
int baseIdx = 0; bool hasNormals = true;
foreach (Model3D mm in leaf.FacesContent.Children)
{
if (mm is not GeometryModel3D g || !_faceMap.TryGetValue(g, out FaceInfo? fi)) continue;
MeshGeometry3D m = fi.Mesh;
foreach (Point3D p in m.Positions) pos.Add(p);
if (m.Normals.Count == m.Positions.Count) foreach (Vector3D v in m.Normals) nrm.Add(v);
else hasNormals = false;
foreach (int ti in m.TriangleIndices) idx.Add(ti + baseIdx);
baseIdx += m.Positions.Count;
}
var mesh = new MeshGeometry3D { Positions = pos, TriangleIndices = idx };
if (hasNormals && nrm.Count == pos.Count) mesh.Normals = nrm;
mesh.Freeze();
((GeometryModel3D)leaf.MergedContent.Children[0]).Geometry = mesh;
} }
/// <summary> /// <summary>
@@ -849,27 +799,37 @@ public partial class MainViewModel : ObservableObject
if (_viewport is null) return; if (_viewport is null) return;
ClearMeasurements(); // 量測標記位置已失效 ClearMeasurements(); // 量測標記位置已失效
foreach (ModelNodeViewModel leaf in root.Leaves()) var leaves = root.Leaves().ToList();
{
// B-rep:對 Solid/Shell 整體 Modify(避免共用邊被逐面重複變換) // B-rep:對 Solid/Shell 整體 Modify(避免共用邊被逐面重複變換)
// CADability 物件非執行緒安全 → 維持循序
foreach (ModelNodeViewModel leaf in leaves)
foreach (CADability.GeoObject.IGeoObject g in leaf.SourceGeos) foreach (CADability.GeoObject.IGeoObject g in leaf.SourceGeos)
{ {
try { g.Modify(op); } try { g.Modify(op); }
catch { /* 個別物件變換失敗不中斷 */ } catch { /* 個別物件變換失敗不中斷 */ }
} }
// 渲染網格(逐面,與目前顯示哪種內容無關 // 渲染網格:面級平行變換(來源/輸出皆 frozen、各面獨立 → 安全;大檔 64k 面吃滿多核
var faceItems = new List<(GeometryModel3D Gm, FaceInfo Fi)>();
foreach (ModelNodeViewModel leaf in leaves)
if (leaf.FacesContent is not null) if (leaf.FacesContent is not null)
{
foreach (Model3D mm in leaf.FacesContent.Children) foreach (Model3D mm in leaf.FacesContent.Children)
{ if (mm is GeometryModel3D gm && _faceMap.TryGetValue(gm, out FaceInfo? fi))
if (mm is not GeometryModel3D gm || !_faceMap.TryGetValue(gm, out FaceInfo? fi)) continue; faceItems.Add((gm, fi));
MeshGeometry3D moved = TransformMesh(fi.Mesh, m);
fi.Mesh = moved; var moved = new MeshGeometry3D[faceItems.Count];
gm.Geometry = moved; 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); // 合併網格同步 RebuildMerged(leaf); // 合併網格同步
}
// 邊線端點(資料);實際合併線於迴圈後重建 // 邊線端點(資料);實際合併線於迴圈後重建
if (leaf.OriginalEdgePoints is not null) if (leaf.OriginalEdgePoints is not null)
@@ -926,7 +886,7 @@ public partial class MainViewModel : ObservableObject
// ─── 旋轉 90°(樹面板選取的檔案,繞其中心)────────────────── // ─── 旋轉 90°(樹面板選取的檔案,繞其中心)──────────────────
[RelayCommand] [RelayCommand(CanExecute = nameof(IsIdle))]
private void RotateRoot(string axisName) private void RotateRoot(string axisName)
{ {
ModelNodeViewModel? root = SelectedNode is not null ? RootContaining(SelectedNode) ModelNodeViewModel? root = SelectedNode is not null ? RootContaining(SelectedNode)
@@ -960,499 +920,6 @@ public partial class MainViewModel : ObservableObject
// 三點對齊的剛體變換數學在 Services/RigidAlign.csSmokeTest --align-test 驗證) // 三點對齊的剛體變換數學在 Services/RigidAlign.csSmokeTest --align-test 驗證)
// ─── 拖曳模式(左鍵按住零件沿螢幕平面拖;放開才烘進 B-rep)──
/// <summary>左鍵按下:命中零件則開始拖曳(回傳 true 表示要捕捉滑鼠)</summary>
public bool OnDragStart(Point position)
{
if (_viewport is null || IsBusy || CurrentMode != MeasureMode.Drag || _dragRoot is not null)
return false;
// 兩種渲染內容都可能在場:合併網格查 _mergedMap、逐面(剖面時)查 _faceMap
ModelNodeViewModel? leaf = null;
Point3D anchor = default;
foreach (var h in _viewport.Viewport.FindHits(position))
{
if (h.Model is null) continue;
if (_faceMap.TryGetValue(h.Model, out FaceInfo? fi)) { leaf = fi.Owner; anchor = h.Position; break; }
if (_mergedMap.TryGetValue(h.Model, out ModelNodeViewModel? ml)) { leaf = ml; anchor = h.Position; break; }
}
if (leaf is null) return false;
ModelNodeViewModel? root = FindRootOf(leaf) ?? RootContaining(leaf);
if (root is null) return false;
_dragRoot = root;
_dragAnchor = anchor;
_dragApplied = default;
_dragTransform = new TranslateTransform3D();
foreach (ModelNodeViewModel l in root.Leaves())
if (l.BodyVisual is not null)
l.BodyVisual.Transform = _dragTransform;
// 拖曳中隱藏邊線(LinesVisual3D 隨 Transform 變更逐幀重建會卡)
_edgesSuspended = true;
SetEdgesActive(false);
StatusText = $"拖曳「{root.Name}」中…(放開定位)";
return true;
}
/// <summary>滑鼠移動:把 2D 位移投影到「過錨點、面向相機」的平面上 → 暫時 Transform</summary>
public void OnDragMove(Point position)
{
if (_viewport?.Camera is not System.Windows.Media.Media3D.ProjectionCamera cam ||
_dragRoot is null || _dragTransform is null) return;
Point3D? p = _viewport.Viewport.UnProject(position, _dragAnchor, cam.LookDirection);
if (p is null) return;
_dragApplied = p.Value - _dragAnchor;
_dragTransform.OffsetX = _dragApplied.X;
_dragTransform.OffsetY = _dragApplied.Y;
_dragTransform.OffsetZ = _dragApplied.Z;
}
/// <summary>放開:移除暫時 Transform,一次性烘進 B-rep(量測精度不受拖曳影響)</summary>
public bool OnDragEnd()
{
if (_dragRoot is null) return false;
ModelNodeViewModel root = _dragRoot;
Vector3D delta = _dragApplied;
// 清除暫時位移:必須用 Identity,不可用 null —— HelixToolkit GetTransform 對 child.Transform
// 不做 null 檢查(Children.Add(null) 會拋「無法新增空值到集合中」),下次 FindHits 即 crash
foreach (ModelNodeViewModel l in root.Leaves())
if (l.BodyVisual is not null)
l.BodyVisual.Transform = Transform3D.Identity;
_dragRoot = null;
_dragTransform = null;
_dragApplied = default;
_edgesSuspended = false;
SetEdgesActive(true); // 恢復所有檔案的邊線(被拖檔案的點隨後由 TranslateRoot 更新,期間不會渲染)
if (delta.LengthSquared > 1e-12)
{
TranslateRoot(root, delta); // 烘進 B-rep + 網格 + 邊線 + 邊界(量測清空)
StatusText = $"已拖曳「{root.Name}」 Δ({delta.X:F2}, {delta.Y:F2}, {delta.Z:F2}) mm(可用 🧩干涉 驗證)";
}
return true;
}
// ─── Gizmo 三軸操作器(XYZ 箭頭 + 旋轉環;每次放開滑鼠烘進 B-rep)──
[ObservableProperty]
private bool gizmoEnabled;
private readonly List<HelixToolkit.Wpf.Manipulator> _gizmoParts = new();
private ModelVisual3D? _gizmoProxy; // 操作器綁定的代理 visual(其 Transform = 使用者拖出的變換)
private ModelNodeViewModel? _gizmoTarget;
private bool _gizmoDragActive; // 拖動中(邊線已暫停)
private bool _gizmoBaking; // 烘焙中,忽略 Transform 變更回呼
private bool _gizmoBakePending; // 已排程烘焙,防同一次放開重複觸發
// 操作器疊圖層:另一個透明 Viewport3D 疊在主視窗上、相機同步、只放操作器 →
// 操作器不在主場景,永不被實體遮擋(always-on-top);空白處滑鼠穿透回主視窗
private System.Windows.Controls.Viewport3D? _overlayViewport;
private PerspectiveCamera? _overlayCamera;
partial void OnGizmoEnabledChanged(bool value) => UpdateGizmo();
partial void OnSelectedNodeChanged(ModelNodeViewModel? value)
{
if (GizmoEnabled) UpdateGizmo(); // 換選取 → 操作器跟著換目標
}
private void UpdateGizmo()
{
RemoveGizmo();
if (!GizmoEnabled || _viewport is null || _overlayViewport is null) return;
ModelNodeViewModel? root = SelectedNode is not null ? RootContaining(SelectedNode)
: Roots.Count == 1 ? Roots[0] : null;
if (root is null || root.Bounds.IsEmpty)
{
StatusText = "操作器:請先在樹面板點選要操作的檔案";
GizmoEnabled = false;
return;
}
_gizmoTarget = root;
SyncOverlayCamera(); // 操作器出現前先對齊相機
Rect3D b = root.Bounds;
var center = new Point3D(b.X + b.SizeX / 2, b.Y + b.SizeY / 2, b.Z + b.SizeZ / 2);
double diag = new Vector3D(b.SizeX, b.SizeY, b.SizeZ).Length;
_gizmoProxy = new ModelVisual3D();
_overlayViewport.Children.Add(_gizmoProxy);
System.ComponentModel.DependencyPropertyDescriptor
.FromProperty(Visual3D.TransformProperty, typeof(Visual3D))
.AddValueChanged(_gizmoProxy, GizmoTransformChanged);
void AddPart(HelixToolkit.Wpf.Manipulator man)
{
man.Position = center;
man.Bind(_gizmoProxy);
_gizmoParts.Add(man);
_overlayViewport!.Children.Add(man); // 放疊圖層 → 永不被實體遮擋
}
// 平移箭頭(X 紅 / Y 綠 / Z 藍 — 業界慣例)
AddPart(new TranslateManipulator { Direction = new Vector3D(1, 0, 0), Color = Colors.Red, Length = diag * 0.22, Diameter = diag * 0.016 });
AddPart(new TranslateManipulator { Direction = new Vector3D(0, 1, 0), Color = Colors.Green, Length = diag * 0.22, Diameter = diag * 0.016 });
AddPart(new TranslateManipulator { Direction = new Vector3D(0, 0, 1), Color = Colors.Blue, Length = diag * 0.22, Diameter = diag * 0.016 });
// 旋轉環
AddPart(new RotateManipulator { Axis = new Vector3D(1, 0, 0), Color = Colors.Red, Diameter = diag * 0.34, InnerDiameter = diag * 0.30, Length = diag * 0.012 });
AddPart(new RotateManipulator { Axis = new Vector3D(0, 1, 0), Color = Colors.Green, Diameter = diag * 0.34, InnerDiameter = diag * 0.30, Length = diag * 0.012 });
AddPart(new RotateManipulator { Axis = new Vector3D(0, 0, 1), Color = Colors.Blue, Diameter = diag * 0.34, InnerDiameter = diag * 0.30, Length = diag * 0.012 });
StatusText = $"操作器:拖箭頭沿軸移動「{root.Name}」、拖環繞軸旋轉;放開即定位";
}
private void RemoveGizmo()
{
if (_gizmoProxy is not null)
{
System.ComponentModel.DependencyPropertyDescriptor
.FromProperty(Visual3D.TransformProperty, typeof(Visual3D))
.RemoveValueChanged(_gizmoProxy, GizmoTransformChanged);
_overlayViewport?.Children.Remove(_gizmoProxy);
}
foreach (HelixToolkit.Wpf.Manipulator man in _gizmoParts)
{
man.UnBind();
_overlayViewport?.Children.Remove(man);
}
_gizmoParts.Clear();
_gizmoProxy = null;
if (_gizmoTarget is not null) // 清掉殘留的暫時 Transform(用 Identity,不可 null → 否則 FindHits crash
foreach (ModelNodeViewModel l in _gizmoTarget.Leaves())
if (l.BodyVisual is not null)
l.BodyVisual.Transform = Transform3D.Identity;
_gizmoTarget = null;
if (_gizmoDragActive)
{
_gizmoDragActive = false;
_edgesSuspended = false;
SetEdgesActive(true);
}
}
/// <summary>操作器拖動中:把代理的 Transform 套到目標檔案的所有 BodyVisual(暫時、GPU 端)</summary>
private void GizmoTransformChanged(object? sender, EventArgs e)
{
if (_gizmoBaking || _gizmoTarget is null || _gizmoProxy is null) return;
Transform3D t = _gizmoProxy.Transform;
if (!_gizmoDragActive && t is not null && !t.Value.IsIdentity)
{
_gizmoDragActive = true;
_edgesSuspended = true;
SetEdgesActive(false); // 邊線逐幀重建會卡,拖動中暫停
}
foreach (ModelNodeViewModel l in _gizmoTarget.Leaves())
if (l.BodyVisual is not null)
l.BodyVisual.Transform = t;
}
/// <summary>滑鼠放開(MainWindow 轉發,handledEventsToo):把累積變換烘進 B-rep 並重置操作器</summary>
public void OnGizmoMouseUp()
{
if (_gizmoTarget is null || _gizmoProxy is null || _gizmoBakePending) return;
Matrix3D m = _gizmoProxy.Transform?.Value ?? Matrix3D.Identity;
if (m.IsIdentity) return; // 只是點一下、沒拖操作器 → 不烘焙
// 延後到 manipulator 自身的 mouse-up 處理(釋放捕捉等)完成後再烘焙,避免在其事件中改動視覺樹造成 reentrancy
_gizmoBakePending = true;
_viewport?.Dispatcher.BeginInvoke(new Action(() => BakeGizmo(m)),
System.Windows.Threading.DispatcherPriority.Background);
}
private void BakeGizmo(Matrix3D m)
{
_gizmoBakePending = false;
if (_gizmoTarget is null) return;
ModelNodeViewModel root = _gizmoTarget;
_gizmoBaking = true; // 防 proxy 歸零觸發 GizmoTransformChanged 重入
foreach (ModelNodeViewModel l in root.Leaves())
if (l.BodyVisual is not null)
l.BodyVisual.Transform = Transform3D.Identity; // 清暫時位移(用 Identity,不可 null
if (_gizmoProxy is not null) _gizmoProxy.Transform = Transform3D.Identity; // 綁定 → 操作器歸零
_gizmoBaking = false;
_gizmoDragActive = false;
_edgesSuspended = false;
SetEdgesActive(true);
TransformRoot(root, RigidAlign.ToModOp(m), m); // 烘進 B-rep(量測精度不受影響)
// 操作器移到變換後的新中心
Rect3D b = root.Bounds;
if (!b.IsEmpty)
{
var center = new Point3D(b.X + b.SizeX / 2, b.Y + b.SizeY / 2, b.Z + b.SizeZ / 2);
foreach (HelixToolkit.Wpf.Manipulator man in _gizmoParts)
man.Position = center;
}
StatusText = $"已套用操作器變換「{root.Name}」(可用 🧩干涉 驗證)";
}
// ─── 干涉檢查 ────────────────────────────────────────────────
[RelayCommand]
private async Task CheckInterferenceAsync()
{
if (_viewport is null) return;
var visibleRoots = Roots.Where(r => r.IsVisible).ToList();
if (visibleRoots.Count != 2)
{
StatusText = $"干涉檢查需要剛好 2 個可見檔案(目前 {visibleRoots.Count} 個)— 用樹面板勾選";
return;
}
List<MeshGeometry3D> MeshesOf(ModelNodeViewModel root) =>
root.Leaves().Where(l => l.IsVisible && l.FacesContent is not null)
.SelectMany(l => l.FacesContent!.Children)
.Where(m => m is GeometryModel3D gm && _faceMap.ContainsKey(gm))
.Select(m => _faceMap[(GeometryModel3D)m].Mesh)
.ToList();
var a = MeshesOf(visibleRoots[0]);
var b = MeshesOf(visibleRoots[1]);
string nameA = visibleRoots[0].Name, nameB = visibleRoots[1].Name;
IsBusy = true;
StatusText = $"干涉檢查中:{nameA} ⟷ {nameB} …";
try
{
InterferenceResult result = await Task.Run(() => InterferenceService.Check(a, b));
AddInterferenceResult(result, nameA, nameB);
}
catch (Exception ex)
{
StatusText = $"干涉檢查失敗:{ex.Message}";
}
finally
{
IsBusy = false;
}
}
private void AddInterferenceResult(InterferenceResult result, string nameA, string nameB)
{
double diag = SceneDiagonal();
double markerR = Math.Clamp(diag * 0.004, 0.01, 5.0);
MeasurementResult m;
if (result.Intersects)
{
int pairs = result.PairCount;
int segCount = result.Segments.Count;
m = new MeasurementResult
{
Kind = MeasureMode.Interference,
TitleFor = _ => $"🧩 干涉!{nameA} ⟷ {nameB}",
DetailFor = _ => $"兩零件相交(不 match\n相交三角形對 = {pairs:N0}\n" +
$"紅色線 = 干涉交線({segCount:N0} 段)\n(共面貼合不會被算為干涉)",
};
var pts = new Point3DCollection(Math.Min(result.Segments.Count, 20000) * 2);
foreach ((Point3D s0, Point3D s1) in result.Segments) { pts.Add(s0); pts.Add(s1); }
m.Overlays.Add(new LinesVisual3D { Points = pts, Color = Colors.Red, Thickness = 3 });
if (result.Segments.Count > 0)
{
Point3D at = result.Segments[0].A;
m.Overlays.Add(new BillboardTextVisual3D
{
Position = at + new Vector3D(0, 0, markerR * 3),
Text = "干涉",
Foreground = Brushes.White,
Background = Brushes.Red,
Padding = new Thickness(5, 2, 5, 2),
FontSize = 14,
});
}
}
else
{
Point3D ga = result.GapA, gb = result.GapB;
Vector3D d = gb - ga;
double gap = result.GapDistance;
m = new MeasurementResult
{
Kind = MeasureMode.Interference,
TitleFor = u => $"🧩 無干涉 gap ≈ {Units.L(gap, u)}",
DetailFor = u => $"{nameA} ⟷ {nameB} 無相交\n最小間隙 ≈ {Units.L(gap, u)}(網格近似)\n" +
$"gap ≈ 0 即為貼合(match\n點 1 {Units.P(ga, u)}\n點 2 {Units.P(gb, u)}",
};
m.Overlays.Add(new SphereVisual3D { Center = ga, Radius = markerR, Fill = Brushes.OrangeRed });
m.Overlays.Add(new SphereVisual3D { Center = gb, Radius = markerR, Fill = Brushes.OrangeRed });
m.Overlays.Add(new LinesVisual3D
{
Points = new Point3DCollection { ga, gb },
Color = Colors.OrangeRed,
Thickness = 2,
});
var label = new BillboardTextVisual3D
{
Position = ga + d / 2 + new Vector3D(0, 0, markerR * 2),
Text = $"gap {Units.L(gap, UnitSystem.Millimeter)}",
Foreground = Brushes.Black,
Background = new SolidColorBrush(Color.FromArgb(200, 255, 255, 210)),
Padding = new Thickness(4, 2, 4, 2),
FontSize = 14,
};
m.Overlays.Add(label);
m.DynamicLabels.Add((label, u => $"gap {Units.L(gap, u)}"));
}
AddMeasurement(m);
}
// ─── 剖面 ────────────────────────────────────────────────────
partial void OnSectionEnabledChanged(bool value) => ScheduleSection();
partial void OnSectionAxisIndexChanged(int value) => ScheduleSection();
partial void OnSectionPositionChanged(double value) => ScheduleSection();
partial void OnSectionFlipChanged(bool value) => ScheduleSection();
private void ScheduleSection()
{
_sectionTimer.Stop();
_sectionTimer.Start();
}
private void ApplySection()
{
if (_viewport is null) return;
if (!SectionEnabled)
{
// 還原原始幾何
foreach ((Model3D model, FaceInfo fi) in _faceMap)
if (model is GeometryModel3D gm && !ReferenceEquals(gm.Geometry, fi.Mesh))
gm.Geometry = fi.Mesh;
foreach (ModelNodeViewModel root in Roots) RefreshRootEdges(root);
if (_sectionPlaneVisual is not null)
{
_viewport.Children.Remove(_sectionPlaneVisual);
_sectionPlaneVisual = null;
}
ApplyRenderMode(); // 剖面關閉 → 瀏覽模式可切回合併網格
StatusText = "剖面已關閉";
return;
}
Rect3D bounds = UnionBounds();
if (bounds.IsEmpty) return;
Vector3D axis = SectionAxisIndex switch
{
0 => new Vector3D(1, 0, 0),
1 => new Vector3D(0, 1, 0),
_ => new Vector3D(0, 0, 1),
};
double t = SectionPosition / 100.0;
Point3D min = bounds.Location;
var size = new Vector3D(bounds.SizeX, bounds.SizeY, bounds.SizeZ);
double planeCoord = SectionAxisIndex switch
{
0 => min.X + size.X * t,
1 => min.Y + size.Y * t,
_ => min.Z + size.Z * t,
};
Point3D center = min + size / 2;
Point3D planePoint = SectionAxisIndex switch
{
0 => new Point3D(planeCoord, center.Y, center.Z),
1 => new Point3D(center.X, planeCoord, center.Z),
_ => new Point3D(center.X, center.Y, planeCoord),
};
Vector3D normal = SectionFlip ? -axis : axis;
foreach ((Model3D model, FaceInfo fi) in _faceMap)
if (model is GeometryModel3D gm)
gm.Geometry = SectionService.ClipMesh(fi.Mesh, planePoint, normal);
_sectionPlanePoint = planePoint;
_sectionNormal = normal;
foreach (ModelNodeViewModel root in Roots) RefreshRootEdges(root);
ApplyRenderMode(); // 剖面開啟 → 改用逐面(裁切後幾何)
UpdateSectionPlaneVisual(planePoint, axis, size);
StatusText = $"剖面:{"XYZ"[SectionAxisIndex]} 軸 {SectionPosition:F0}%" + (SectionFlip ? "(反向)" : "");
}
private void UpdateSectionPlaneVisual(Point3D planePoint, Vector3D axis, Vector3D size)
{
if (_sectionPlaneVisual is not null)
_viewport!.Children.Remove(_sectionPlaneVisual);
(Vector3D lenDir, double len, double wid) = SectionAxisIndex switch
{
0 => (new Vector3D(0, 1, 0), size.Y, size.Z),
1 => (new Vector3D(1, 0, 0), size.X, size.Z),
_ => (new Vector3D(1, 0, 0), size.X, size.Y),
};
_sectionPlaneVisual = new RectangleVisual3D
{
Origin = planePoint,
Normal = axis,
LengthDirection = lenDir,
Length = len * 1.05,
Width = wid * 1.05,
Fill = new SolidColorBrush(Color.FromArgb(40, 30, 120, 255)),
};
_viewport!.Children.Add(_sectionPlaneVisual);
}
// ─── 匯出 ────────────────────────────────────────────────────
[RelayCommand]
private void ExportCsv()
{
if (Measurements.Count == 0)
{
StatusText = "沒有量測結果可匯出";
return;
}
var dlg = new SaveFileDialog
{
Title = "匯出量測結果",
Filter = "CSV 檔案 (*.csv)|*.csv",
FileName = $"measurements_{DateTime.Now:yyyyMMdd_HHmmss}.csv",
};
if (dlg.ShowDialog() != true) return;
var sb = new StringBuilder();
sb.AppendLine("標籤,類型,明細");
foreach (MeasurementResult m in Measurements)
{
static string Esc(string s) => $"\"{s.Replace("\"", "\"\"")}\"";
sb.AppendLine($"{Esc(m.Title)},{m.Kind},{Esc(m.Detail.Replace("\n", " | "))}");
}
File.WriteAllText(dlg.FileName, sb.ToString(), new UTF8Encoding(true)); // BOMExcel 中文不亂碼
StatusText = $"已匯出 {Measurements.Count} 筆量測 → {Path.GetFileName(dlg.FileName)}";
}
[RelayCommand]
private void SaveScreenshot()
{
if (_viewport is null || _viewport.ActualWidth < 1) return;
var dlg = new SaveFileDialog
{
Title = "儲存視圖截圖",
Filter = "PNG 圖片 (*.png)|*.png",
FileName = $"stpviewer_{DateTime.Now:yyyyMMdd_HHmmss}.png",
};
if (dlg.ShowDialog() != true) return;
const double scale = 2.0; // 2x 解析度
var rtb = new RenderTargetBitmap(
(int)(_viewport.ActualWidth * scale), (int)(_viewport.ActualHeight * scale),
96 * scale, 96 * scale, PixelFormats.Pbgra32);
rtb.Render(_viewport);
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(rtb));
using FileStream fs = File.Create(dlg.FileName);
encoder.Save(fs);
StatusText = $"已儲存截圖 → {Path.GetFileName(dlg.FileName)}";
}
// ─── 共用 ──────────────────────────────────────────────────── // ─── 共用 ────────────────────────────────────────────────────
private Rect3D UnionBounds() private Rect3D UnionBounds()