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:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Interaction logic for App.xaml
|
||||
/// 全域例外處理:UI 執行緒未攔截例外 → 記 log + 訊息框後盡量存活(不閃退);
|
||||
/// 背景 Task / 非 UI 執行緒例外 → 記 log。log 位於 %LOCALAPPDATA%\STPViewer\error.log。
|
||||
/// </summary>
|
||||
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 本身失敗不能再拋 */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWPF>true</UseWPF>
|
||||
<Version>0.3.2</Version> <!-- 單一版號來源:標題與 publish-winapp 打包資料夾都讀這個 -->
|
||||
<Version>0.4.0</Version> <!-- 單一版號來源:標題與 publish-winapp 打包資料夾都讀這個 -->
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <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);
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>點到三角形最近點(Ericson, Real-Time Collision Detection)</summary>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>點到三角形最近點(Ericson, Real-Time Collision Detection)</summary>
|
||||
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 ─────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <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)); // 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)}";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 串接順序一致;平移不改各面頂點數,故邊界永久有效。</summary>
|
||||
private readonly Dictionary<Model3D, (int[] Starts, FaceInfo[] Faces)> _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<MeasureMode, int> _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;
|
||||
|
||||
/// <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<MeasurementResult> Measurements { get; } = new();
|
||||
|
||||
@@ -123,30 +98,6 @@ public partial class MainViewModel : ObservableObject
|
||||
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()
|
||||
{
|
||||
SyncOverlayCamera(); // 操作器疊圖層跟著主相機
|
||||
@@ -170,9 +121,12 @@ public partial class MainViewModel : ObservableObject
|
||||
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()
|
||||
{
|
||||
var dlg = new OpenFileDialog
|
||||
@@ -189,34 +143,40 @@ public partial class MainViewModel : ObservableObject
|
||||
public async Task ImportFilesAsync(IEnumerable<string> 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)────────────────────────
|
||||
|
||||
/// <summary>把一個零件的所有面網格併成單一 MeshGeometry3D(索引位移後串接)</summary>
|
||||
private static MeshGeometry3D BuildMergedMesh(IReadOnlyList<ImportedFace> faces)
|
||||
/// <summary>把多個面網格依序併成單一 MeshGeometry3D(索引位移後串接;順序 = 呼叫端給的面序)</summary>
|
||||
private static MeshGeometry3D MergeMeshes(IReadOnlyList<MeshGeometry3D> 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;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
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)
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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)──
|
||||
|
||||
/// <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)); // 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()
|
||||
|
||||
Reference in New Issue
Block a user