feat: v0.5.0 feature round - measurement, view, tree, section, export upgrades

Measurement / interaction:
- Circle-center snap in Snap(): click near a circular edge to snap to its
  center (hole-to-hole pitch measuring); nearest of vertex vs circle wins
- Esc cancels pending multi-step measurement, then exits mode, then gizmo;
  hotkeys P/D/E/F/C/A/M toggle measure modes (ignored while typing)
- Volume/centroid via mesh signed volume (open-shell detection rejects
  unreliable results); tree context menu adds it as a measurement

View / UI:
- Standard views (Iso/Front/Top/Right, Z-up) + orthographic toggle;
  gizmo overlay camera now switches type to match main camera
- GridSplitters for tree/measurement panels; node tooltip shows AABB LxWxH
- Tree name filter (matches + ancestors + subtree, auto-expand);
  context menu: isolate node / invert visibility / show all
- Settings persistence (window bounds, unit, 10-file MRU) in
  %LOCALAPPDATA%/STPViewer/settings.json; recent-files dropdown

Files / section:
- Interference accepts >=2 visible files, checks all pairs
- Export STEP: visible Solids/Shells at current aligned positions via
  CADability ExportStep; SmokeTest --export-test round-trip verifies
- Custom section plane by picking 3 points (axis combo 3-pt); plane
  position generalized to AABB-corner projection; numeric position input
- Bump version to 0.5.0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 08:34:20 +08:00
co-authored by Claude Fable 5
parent e355cb4363
commit c657417d6b
15 changed files with 890 additions and 118 deletions
+76 -22
View File
@@ -7,7 +7,8 @@
Title="STPViewer — STP/STEP 3D 檢視器"
Height="820" Width="1320"
WindowStartupLocation="CenterScreen"
AllowDrop="True" Drop="Window_Drop" DragOver="Window_DragOver">
AllowDrop="True" Drop="Window_Drop" DragOver="Window_DragOver"
PreviewKeyDown="Window_PreviewKeyDown">
<Window.Resources>
<conv:EnumToBoolConverter x:Key="ModeConv" />
@@ -25,23 +26,37 @@
<ToolBar>
<Button Command="{Binding ImportCommand}" Padding="8,4" FontSize="13"
ToolTip="匯入 STP/STEP/STL/DXF(可複選,也可直接拖放到視窗)">📂 匯入</Button>
<Button Click="Recent_Click" Padding="4,4" FontSize="13"
ToolTip="最近開啟的檔案">▾</Button>
<Button Command="{Binding ZoomAllCommand}" Padding="8,4" FontSize="13"
ToolTip="縮放至全部模型">🔍 全覽</Button>
<Separator />
<TextBlock Text="視圖:" VerticalAlignment="Center" Margin="4,0,2,0" />
<Button Command="{Binding SetStandardViewCommand}" CommandParameter="Iso" Padding="6,4" FontSize="13"
ToolTip="等角視圖">等角</Button>
<Button Command="{Binding SetStandardViewCommand}" CommandParameter="Front" Padding="6,4" FontSize="13"
ToolTip="前視圖(-Y 方向看,Z 向上)">前</Button>
<Button Command="{Binding SetStandardViewCommand}" CommandParameter="Top" Padding="6,4" FontSize="13"
ToolTip="上視圖(+Z 往下看)">上</Button>
<Button Command="{Binding SetStandardViewCommand}" CommandParameter="Right" Padding="6,4" FontSize="13"
ToolTip="右視圖(+X 方向看,Z 向上)">右</Button>
<ToggleButton IsChecked="{Binding OrthographicView}" Padding="6,4" FontSize="13" Margin="2,0"
ToolTip="正交投影(平行投影,量尺寸不受透視變形;再按切回透視)">正交</ToggleButton>
<Separator />
<TextBlock Text="量測:" VerticalAlignment="Center" Margin="4,0,2,0" />
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="量測點座標(吸附頂點"
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="量測點座標(吸附頂點/圓心)— 快速鍵 P"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Point}">📍 點</ToggleButton>
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="兩點距離 + ΔXYZ"
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="兩點距離 + ΔXYZ(點到圓邊會吸圓心 → 可量孔對孔 pitch)— 快速鍵 D"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Distance}">📏 距離</ToggleButton>
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="邊長(直線/曲線/圓弧)"
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="邊長(直線/曲線/圓弧)— 快速鍵 E"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Edge}">📐 邊</ToggleButton>
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="面積 + 曲面類型/法向量"
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="面積 + 曲面類型/法向量 — 快速鍵 F"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Face}">⬛ 面</ToggleButton>
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="圓心/半徑/直徑/周長"
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="圓心/半徑/直徑/周長 — 快速鍵 C"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Circle}">⭕ 圓</ToggleButton>
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="兩面/兩邊夾角(點兩個面或靠近直線邊)"
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="兩面/兩邊夾角(點兩個面或靠近直線邊)— 快速鍵 A"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Angle}">∠ 角度</ToggleButton>
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="面到面最短距離(網格近似)"
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="面到面最短距離(網格近似)— 快速鍵 M"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=FaceDistance}">⇔ 面距</ToggleButton>
<Separator />
<ToggleButton Style="{StaticResource ModeButton}"
@@ -65,10 +80,10 @@
<ToggleButton IsChecked="{Binding GizmoEnabled}" Padding="8,4" FontSize="13"
ToolTip="三軸操作器:對樹面板選取的檔案顯示 XYZ 箭頭(沿軸移動)與旋轉環(繞軸轉任意角度),每次放開即定位">⊹ 操作器</ToggleButton>
<Button Command="{Binding CheckInterferenceCommand}" Padding="8,4" FontSize="13"
ToolTip="干涉檢查:檢查 2 個可見檔案是否相交(match 判定);無相交時回報最小間隙">🧩 干涉</Button>
ToolTip="干涉檢查:2 個以上可見檔案兩兩檢查是否相交(match 判定);無相交時回報最小間隙">🧩 干涉</Button>
<Separator />
<Button Command="{Binding ClearMeasurementsCommand}" Padding="8,4" FontSize="13"
ToolTip="清除全部量測與標記">🧹 清除</Button>
ToolTip="清除全部量測與標記(Esc = 取消進行中量測/退出模式)">🧹 清除</Button>
<Separator />
<ToggleButton IsChecked="{Binding UseInch}" Padding="8,4" FontSize="13"
ToolTip="切換顯示單位 mm ⇄ inch(既有量測即時換算)">mm ⇄ in</ToggleButton>
@@ -77,22 +92,27 @@
ToolTip="量測結果匯出 CSV">💾 CSV</Button>
<Button Command="{Binding SaveScreenshotCommand}" Padding="8,4" FontSize="13"
ToolTip="3D 視圖截圖存 PNG2x 解析度)">📷 截圖</Button>
<Button Command="{Binding ExportStepFileCommand}" Padding="8,4" FontSize="13"
ToolTip="把可見檔案(目前對齊位置)匯出成新 STEP 檔 — 對齊插合結果可交接 CAD;不動原始檔">📤 STEP</Button>
</ToolBar>
<ToolBar>
<ToggleButton IsChecked="{Binding SectionEnabled}" Padding="8,4" FontSize="13"
ToolTip="開啟/關閉剖面檢視">✂ 剖面</ToggleButton>
<ComboBox SelectedIndex="{Binding SectionAxisIndex}" Width="52" Margin="4,0"
VerticalAlignment="Center" ToolTip="剖切軸向"
<ComboBox SelectedIndex="{Binding SectionAxisIndex}" Width="60" Margin="4,0"
VerticalAlignment="Center" ToolTip="剖切軸向(3點 = 在模型上點 3 點定義任意平面)"
IsEnabled="{Binding SectionEnabled}">
<ComboBoxItem>X</ComboBoxItem>
<ComboBoxItem>Y</ComboBoxItem>
<ComboBoxItem>Z</ComboBoxItem>
<ComboBoxItem>3點</ComboBoxItem>
</ComboBox>
<Slider Value="{Binding SectionPosition}" Minimum="0" Maximum="100" Width="180"
VerticalAlignment="Center" Margin="4,0" ToolTip="剖面位置(沿 %"
VerticalAlignment="Center" Margin="4,0" ToolTip="剖面位置(沿法向 %"
IsEnabled="{Binding SectionEnabled}" />
<TextBlock Text="{Binding SectionPosition, StringFormat={}{0:F0}%}" Width="36"
VerticalAlignment="Center" />
<TextBox Text="{Binding SectionPosition, StringFormat={}{0:F0}}" Width="34"
VerticalAlignment="Center" ToolTip="剖面位置 %(0–100,可直接輸入,離開欄位生效)"
IsEnabled="{Binding SectionEnabled}" />
<TextBlock Text="%" VerticalAlignment="Center" Margin="1,0,0,0" />
<CheckBox IsChecked="{Binding SectionFlip}" Content="反向" VerticalAlignment="Center"
Margin="4,0" ToolTip="切換保留側" IsEnabled="{Binding SectionEnabled}" />
</ToolBar>
@@ -111,13 +131,19 @@
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="280" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="300" />
<ColumnDefinition Width="280" MinWidth="160" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" MinWidth="300" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="300" MinWidth="160" />
</Grid.ColumnDefinitions>
<!-- ─── 裝配樹面板 ─── -->
<GroupBox Grid.Column="0" Header="模型 / 裝配樹" Margin="4">
<DockPanel>
<TextBox DockPanel.Dock="Top" Margin="0,2,0,4"
Text="{Binding TreeFilter, UpdateSourceTrigger=PropertyChanged}"
ToolTip="輸入名稱過濾裝配樹(只影響樹面板顯示,不影響 3D 可見性;清空 = 全顯)" />
<TreeView ItemsSource="{Binding Roots}"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
VirtualizingPanel.IsVirtualizing="True"
@@ -125,13 +151,32 @@
<TreeView.ItemContainerStyle>
<Style TargetType="TreeViewItem">
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
<Setter Property="Visibility"
Value="{Binding IsFilterVisible, Converter={StaticResource BoolToVis}}" />
</Style>
</TreeView.ItemContainerStyle>
<TreeView.ItemTemplate>
<HierarchicalDataTemplate DataType="{x:Type vm:ModelNodeViewModel}"
ItemsSource="{Binding Children}">
<StackPanel Orientation="Horizontal" Margin="0,1"
ToolTip="{Binding ToolTipText}">
Background="Transparent"
ToolTip="{Binding ToolTipText}"
Tag="{Binding DataContext, RelativeSource={RelativeSource AncestorType=Window}}">
<StackPanel.ContextMenu>
<ContextMenu>
<MenuItem Header="👁 只顯示此節點"
Command="{Binding PlacementTarget.Tag.IsolateNodeCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
CommandParameter="{Binding}" />
<MenuItem Header="🔄 反轉顯示"
Command="{Binding PlacementTarget.Tag.InvertVisibilityCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
<MenuItem Header="☀ 全部顯示"
Command="{Binding PlacementTarget.Tag.ShowAllCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
<Separator />
<MenuItem Header="⚖ 量體積 / 質心"
Command="{Binding PlacementTarget.Tag.MeasureVolumeCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
CommandParameter="{Binding}" />
</ContextMenu>
</StackPanel.ContextMenu>
<CheckBox IsChecked="{Binding IsVisible}" VerticalAlignment="Center"
ToolTip="顯示 / 隱藏(含子節點)" />
<Button Width="14" Height="14" Margin="3,0"
@@ -176,10 +221,15 @@
</HierarchicalDataTemplate>
</TreeView.ItemTemplate>
</TreeView>
</DockPanel>
</GroupBox>
<GridSplitter Grid.Column="1" Width="5" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" Background="#FFD8D8D8"
ToolTip="拖曳調整面板寬度" />
<!-- ─── 3D 視圖 ─── -->
<h:HelixViewport3D x:Name="viewport" Grid.Column="1"
<h:HelixViewport3D x:Name="viewport" Grid.Column="2"
ShowViewCube="True" ShowCoordinateSystem="True"
ShowFrameRate="False" ZoomExtentsWhenLoaded="True"
PanGesture="MiddleClick" PanGesture2="Shift+LeftClick"
@@ -208,11 +258,15 @@
<!-- 操作器疊圖層:透明 Viewport3D 疊在主視窗上、永遠最上層。
只在操作器開啟時 IsHitTestVisible(否則空白處滑鼠才會穿透回主視窗) -->
<Viewport3D x:Name="gizmoOverlay" Grid.Column="1"
<Viewport3D x:Name="gizmoOverlay" Grid.Column="2"
IsHitTestVisible="{Binding GizmoEnabled}" />
<GridSplitter Grid.Column="3" Width="5" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" Background="#FFD8D8D8"
ToolTip="拖曳調整面板寬度" />
<!-- ─── 量測結果面板 ─── -->
<GroupBox Grid.Column="2" Header="量測結果" Margin="4">
<GroupBox Grid.Column="4" Header="量測結果" Margin="4">
<ListBox ItemsSource="{Binding Measurements}" HorizontalContentAlignment="Stretch"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListBox.ItemTemplate>
+87
View File
@@ -1,6 +1,9 @@
using System.ComponentModel;
using System.Linq;
using System.Reflection;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using STPViewer.Services;
using STPViewer.ViewModels;
@@ -10,6 +13,7 @@ namespace STPViewer;
public partial class MainWindow : Window
{
private readonly MainViewModel _vm = new();
private readonly AppSettings _settings;
public MainWindow()
{
@@ -21,12 +25,54 @@ public partial class MainWindow : Window
_vm.Attach(viewport);
_vm.AttachOverlay(gizmoOverlay);
Loaded += MainWindow_Loaded;
Closing += MainWindow_Closing;
// 使用者設定:視窗位置大小(View 職責)+ 單位/MRU(VM 職責)
_settings = SettingsService.Load();
ApplyWindowSettings();
_vm.LoadSettings(_settings);
// Gizmo 操作器放在疊圖層;manipulator 會把 MouseUp 標 handled,需 handledEventsToo 才收得到
gizmoOverlay.AddHandler(MouseLeftButtonUpEvent,
new MouseButtonEventHandler((_, _) => _vm.OnGizmoMouseUp()), handledEventsToo: true);
}
private void ApplyWindowSettings()
{
if (_settings.WindowWidth >= 400 && _settings.WindowHeight >= 300)
{
Width = _settings.WindowWidth;
Height = _settings.WindowHeight;
}
// 位置要落在目前虛擬桌面內才還原(避免拔掉外接螢幕後視窗開在螢幕外)
if (!double.IsNaN(_settings.WindowLeft) && !double.IsNaN(_settings.WindowTop) &&
_settings.WindowLeft >= SystemParameters.VirtualScreenLeft - 50 &&
_settings.WindowTop >= SystemParameters.VirtualScreenTop - 50 &&
_settings.WindowLeft <= SystemParameters.VirtualScreenLeft + SystemParameters.VirtualScreenWidth - 200 &&
_settings.WindowTop <= SystemParameters.VirtualScreenTop + SystemParameters.VirtualScreenHeight - 200)
{
WindowStartupLocation = WindowStartupLocation.Manual;
Left = _settings.WindowLeft;
Top = _settings.WindowTop;
}
if (_settings.WindowMaximized)
WindowState = WindowState.Maximized;
}
private void MainWindow_Closing(object? sender, CancelEventArgs e)
{
Rect r = WindowState == WindowState.Normal
? new Rect(Left, Top, Width, Height)
: RestoreBounds;
_settings.WindowLeft = r.Left;
_settings.WindowTop = r.Top;
_settings.WindowWidth = r.Width;
_settings.WindowHeight = r.Height;
_settings.WindowMaximized = WindowState == WindowState.Maximized;
_vm.SaveSettingsInto(_settings);
SettingsService.Save(_settings);
}
/// <summary>支援命令列帶檔開啟:STPViewer.exe a.stp b.stl …</summary>
private async void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
@@ -36,6 +82,47 @@ public partial class MainWindow : Window
await _vm.ImportFilesAsync(files);
}
/// <summary>Esc / 量測模式快速鍵(焦點在輸入框時不攔截,樹搜尋、剖面數值可正常打字)</summary>
private void Window_PreviewKeyDown(object sender, KeyEventArgs e)
{
if (Keyboard.FocusedElement is TextBoxBase or ComboBox) return;
if (e.Key == Key.Escape)
{
_vm.OnEscape();
e.Handled = true;
return;
}
if (Keyboard.Modifiers != ModifierKeys.None) return;
if (_vm.OnHotKey(e.Key))
e.Handled = true;
}
/// <summary>最近檔案下拉(▾):即時從 VM 的 MRU 清單組選單</summary>
private void Recent_Click(object sender, RoutedEventArgs e)
{
var menu = new ContextMenu
{
PlacementTarget = (UIElement)sender,
Placement = PlacementMode.Bottom,
};
if (_vm.RecentFiles.Count == 0)
{
menu.Items.Add(new MenuItem { Header = "(沒有最近開啟的檔案)", IsEnabled = false });
}
else
{
foreach (string f in _vm.RecentFiles)
menu.Items.Add(new MenuItem
{
Header = System.IO.Path.GetFileName(f),
ToolTip = f,
Command = _vm.ImportRecentCommand,
CommandParameter = f,
});
}
menu.IsOpen = true;
}
private void Tree_SelectedItemChanged(object sender, RoutedPropertyChangedEventArgs<object> e) =>
_vm.SelectedNode = e.NewValue as ModelNodeViewModel;
+1
View File
@@ -15,4 +15,5 @@ public enum MeasureMode
Align3, // 三點對齊:旋轉+平移(來源 3 點 → 目標 3 點)
Drag, // 拖曳模式:左鍵按住零件沿螢幕平面拖動(放開才烘進 B-rep)
Interference, // 干涉檢查結果(非點擊模式,僅作為結果類型)
Volume, // 體積/質心(樹右鍵選單觸發,非點擊模式,僅作為結果類型)
}
+4
View File
@@ -21,6 +21,10 @@ public static class Units
public static string A(double mm2, UnitSystem u) =>
u == UnitSystem.Millimeter ? $"{mm2:N3} mm²" : $"{mm2 / (MmPerInch * MmPerInch):N4} in²";
/// <summary>體積</summary>
public static string V(double mm3, UnitSystem u) =>
u == UnitSystem.Millimeter ? $"{mm3:N2} mm³" : $"{mm3 / (MmPerInch * MmPerInch * MmPerInch):N4} in³";
/// <summary>座標分量(無單位字尾)</summary>
public static string C(double mm, UnitSystem u) =>
u == UnitSystem.Millimeter ? $"{mm:F3}" : $"{mm / MmPerInch:F4}";
+1 -1
View File
@@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<Version>0.4.0</Version> <!-- 單一版號來源:標題與 publish-winapp 打包資料夾都讀這個 -->
<Version>0.5.0</Version> <!-- 單一版號來源:標題與 publish-winapp 打包資料夾都讀這個 -->
</PropertyGroup>
<ItemGroup>
+63 -1
View File
@@ -31,7 +31,12 @@ public class MeasurementService
// ─── 拾取輔助 ────────────────────────────────────────────────
/// <summary>命中點吸附到最近的 B-rep 頂點(容差內),否則回傳原始表面點</summary>
/// <summary>
/// 命中點吸附:點到圓形邊附近(容差內)→ 吸附**圓心**(量兩孔 pitch 的關鍵,v0.5.0);
/// 否則吸附最近的 B-rep 頂點(容差內);都沒有 → 回傳原始表面點。
/// 圓心與頂點都在容差內時,比「命中點到圓形邊」與「命中點到頂點」誰近(圓弧端點常與圓重疊,
/// 點在圓邊上時圓心優先勝出才符合直覺)。
/// </summary>
public Point3D Snap(FaceInfo fi, Point3D hit, double tolerance)
{
if (fi.BrepFace is null) return hit;
@@ -47,6 +52,22 @@ public class MeasurementService
}
}
catch { /* 頂點存取失敗則不吸附 */ }
// 圓形邊:以「命中點到邊曲線」的距離參與比較,勝出時吸附到圓心
try
{
foreach (Edge e in fi.BrepFace.AllEdges)
{
if (e.Curve3D is not Ellipse { IsCircle: true } el) continue;
double pos;
try { pos = el.PositionOf(ToGeo(hit)); } catch { continue; }
if (double.IsNaN(pos)) continue;
Point3D onCurve = ToP3(el.PointAt(Math.Clamp(pos, 0, 1)));
double d = (onCurve - hit).Length;
if (d < bestD) { bestD = d; best = ToP3(el.Center); }
}
}
catch { /* 邊存取失敗則維持頂點吸附結果 */ }
return best;
}
@@ -406,6 +427,47 @@ public class MeasurementService
return (pa, pb);
}
// ─── 體積 / 質心(封閉網格 signed volume)────────────────────
/// <summary>
/// 網格體積與質心:各三角形與原點構成的四面體有向體積加總(divergence theorem)。
/// 只對「封閉」網格有意義(開放殼會得到不可靠的值 → IsClosedReliable=false 供呼叫端提示)。
/// 多個 mesh(多實體)直接加總。
/// </summary>
public static (double VolumeMm3, Point3D Centroid, bool Reliable) MeshVolume(
System.Collections.Generic.IEnumerable<MeshGeometry3D> meshes)
{
double vol6 = 0; // Σ 6×有向體積
double absVol6 = 0; // Σ |6×有向體積|(封閉性 sanity check 用)
double cx = 0, cy = 0, cz = 0; // Σ 四面體質心 × 6×有向體積
foreach (MeshGeometry3D mesh in meshes)
{
Point3DCollection pos = mesh.Positions;
Int32Collection idx = mesh.TriangleIndices;
for (int i = 0; i + 2 < idx.Count; i += 3)
{
Point3D a = pos[idx[i]], b = pos[idx[i + 1]], c = pos[idx[i + 2]];
// 6V = a · (b × c)
double v = a.X * (b.Y * c.Z - b.Z * c.Y)
+ a.Y * (b.Z * c.X - b.X * c.Z)
+ a.Z * (b.X * c.Y - b.Y * c.X);
vol6 += v;
absVol6 += Math.Abs(v);
// 四面體(a,b,c,原點)質心 = (a+b+c)/4
cx += (a.X + b.X + c.X) / 4 * v;
cy += (a.Y + b.Y + c.Y) / 4 * v;
cz += (a.Z + b.Z + c.Z) / 4 * v;
}
}
double volume = Math.Abs(vol6) / 6;
// 有向體積相對正負抵銷比例過高 → 可能是開放殼/法向不一致,數值不可靠
bool reliable = absVol6 > 0 && Math.Abs(vol6) / absVol6 > 0.01 && volume > 1e-9;
Point3D centroid = Math.Abs(vol6) > 1e-12
? new Point3D(cx / vol6, cy / vol6, cz / vol6)
: default;
return (volume, centroid, reliable);
}
// 點到三角形最近點:共用 InterferenceService.ClosestPointOnTrianglev0.4.0 去重複)
// ─── overlay helpers ─────────────────────────────────────────
+50
View File
@@ -0,0 +1,50 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
namespace STPViewer.Services;
/// <summary>使用者設定(%LOCALAPPDATA%\STPViewer\settings.json</summary>
public class AppSettings
{
public double WindowWidth { get; set; } = double.NaN;
public double WindowHeight { get; set; } = double.NaN;
public double WindowLeft { get; set; } = double.NaN;
public double WindowTop { get; set; } = double.NaN;
public bool WindowMaximized { get; set; }
public bool UseInch { get; set; }
public List<string> RecentFiles { get; set; } = new();
}
/// <summary>設定讀寫:壞檔/缺檔一律回預設值,儲存失敗靜默(設定不值得讓程式閃退)</summary>
public static class SettingsService
{
public const int MaxRecentFiles = 10;
private static readonly string SettingsPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"STPViewer", "settings.json");
public static AppSettings Load()
{
try
{
if (File.Exists(SettingsPath))
return JsonSerializer.Deserialize<AppSettings>(File.ReadAllText(SettingsPath)) ?? new AppSettings();
}
catch { /* 壞檔回預設 */ }
return new AppSettings();
}
public static void Save(AppSettings settings)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(SettingsPath)!);
File.WriteAllText(SettingsPath,
JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true }));
}
catch { /* 儲存失敗靜默 */ }
}
}
@@ -1,5 +1,6 @@
using System;
using System.IO;
using System.Linq;
using System.Text;
using System.Windows.Media;
using System.Windows.Media.Imaging;
@@ -39,6 +40,48 @@ public partial class MainViewModel
StatusText = $"已匯出 {Measurements.Count} 筆量測 → {Path.GetFileName(dlg.FileName)}";
}
/// <summary>
/// 把可見檔案(目前位置,含對齊/旋轉/拖曳結果)匯出成「新的」STEP 檔 —
/// 對齊插合後的配合結果可交接給 CAD;不動任何原始檔(唯讀原則不變)。
/// 只含 B-rep Solid/ShellSTL 網格、DXF 線架構無法進 STEP B-rep)。
/// </summary>
[RelayCommand(CanExecute = nameof(IsIdle))]
private void ExportStepFile()
{
var geos = Roots.Where(r => r.IsVisible)
.SelectMany(r => r.Leaves()).Where(l => l.IsVisible)
.SelectMany(l => l.SourceGeos)
.Where(g => g is CADability.GeoObject.Solid or CADability.GeoObject.Shell)
.ToList();
if (geos.Count == 0)
{
StatusText = "沒有可匯出的 B-rep 幾何(STEP 匯出僅含 Solid/Shell;請勾選要匯出的 STEP 檔案)";
return;
}
var dlg = new SaveFileDialog
{
Title = "匯出目前場景為 STEP(零件在目前對齊位置)",
Filter = "STEP 檔案 (*.stp)|*.stp|STEP 檔案 (*.step)|*.step",
FileName = $"assembly_{DateTime.Now:yyyyMMdd_HHmmss}.stp",
};
if (dlg.ShowDialog() != true) return;
try
{
// B-rep 已含目前位置(TransformRoot 對 Solid/Shell 整體 Modify)→ 直接寫出
CADability.Project project = CADability.Project.CreateSimpleProject();
CADability.Model model = project.GetActiveModel();
foreach (CADability.GeoObject.IGeoObject g in geos) model.Add(g);
new CADability.ExportStep().WriteToFile(dlg.FileName, project);
StatusText = $"已匯出 STEP{geos.Count} 個實體,目前位置)→ {Path.GetFileName(dlg.FileName)}";
}
catch (Exception ex)
{
StatusText = $"STEP 匯出失敗:{ex.Message}";
}
}
[RelayCommand]
private void SaveScreenshot()
{
@@ -23,7 +23,7 @@ public partial class MainViewModel
// 操作器疊圖層:另一個透明 Viewport3D 疊在主視窗上、相機同步、只放操作器 →
// 操作器不在主場景,永不被實體遮擋(always-on-top);空白處滑鼠穿透回主視窗
private System.Windows.Controls.Viewport3D? _overlayViewport;
private PerspectiveCamera? _overlayCamera;
private ProjectionCamera? _overlayCamera; // 型別跟隨主相機(透視/正交切換,v0.5.0)
/// <summary>注入操作器疊圖層(透明 Viewport3D,疊在主視窗上、永遠最上層)</summary>
public void AttachOverlay(System.Windows.Controls.Viewport3D overlay)
@@ -37,16 +37,41 @@ public partial class MainViewModel
SyncOverlayCamera();
}
/// <summary>疊圖層相機跟隨主相機(每次主相機變更時呼叫,讓操作器疊在正確螢幕位置)</summary>
/// <summary>
/// 疊圖層相機跟隨主相機(每次主相機變更時呼叫,讓操作器疊在正確螢幕位置)。
/// 主相機可能在透視/正交間切換(Orthographic 屬性)→ 疊圖層相機「型別」也要跟著換,投影才一致。
/// </summary>
private void SyncOverlayCamera()
{
if (_overlayCamera is null || _viewport?.Camera is not ProjectionCamera src) return;
if (_overlayViewport is null || _viewport?.Camera is not ProjectionCamera src) return;
if (src is PerspectiveCamera p)
{
if (_overlayCamera is not PerspectiveCamera op)
{
op = new PerspectiveCamera();
_overlayCamera = op;
_overlayViewport.Camera = op;
}
op.FieldOfView = p.FieldOfView;
}
else if (src is OrthographicCamera o)
{
if (_overlayCamera is not OrthographicCamera oo)
{
oo = new OrthographicCamera();
_overlayCamera = oo;
_overlayViewport.Camera = oo;
}
oo.Width = o.Width;
}
if (_overlayCamera is null) 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();
@@ -20,9 +20,9 @@ public partial class MainViewModel
{
if (_viewport is null) return;
var visibleRoots = Roots.Where(r => r.IsVisible).ToList();
if (visibleRoots.Count != 2)
if (visibleRoots.Count < 2)
{
StatusText = $"干涉檢查需要剛好 2 個可見檔案(目前 {visibleRoots.Count} 個)— 用樹面板勾選";
StatusText = $"干涉檢查需要至少 2 個可見檔案(目前 {visibleRoots.Count} 個)— 用樹面板勾選";
return;
}
@@ -33,16 +33,27 @@ public partial class MainViewModel
.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;
// 網格快照(開跑後零件被改走也不會半途換料;v0.4.0 起運算期間指令已鎖,這裡是雙保險)
var meshes = visibleRoots.ToDictionary(r => r, MeshesOf);
IsBusy = true;
StatusText = $"干涉檢查中:{nameA} ⟷ {nameB} …";
try
{
int pairs = 0, intersecting = 0;
for (int i = 0; i < visibleRoots.Count; i++)
for (int j = i + 1; j < visibleRoots.Count; j++)
{
pairs++;
string nameA = visibleRoots[i].Name, nameB = visibleRoots[j].Name;
StatusText = $"干涉檢查中(第 {pairs} 組):{nameA} ⟷ {nameB} …";
var a = meshes[visibleRoots[i]];
var b = meshes[visibleRoots[j]];
InterferenceResult result = await Task.Run(() => InterferenceService.Check(a, b));
AddInterferenceResult(result, nameA, nameB);
if (result.Intersects) intersecting++;
}
if (pairs > 1)
StatusText = $"干涉檢查完成:{pairs} 組配對、{intersecting} 組相交";
}
catch (Exception ex)
{
+147 -17
View File
@@ -38,11 +38,116 @@ public partial class MainViewModel
[ObservableProperty]
private bool sectionFlip;
partial void OnSectionEnabledChanged(bool value) => ScheduleSection();
partial void OnSectionAxisIndexChanged(int value) => ScheduleSection();
partial void OnSectionPositionChanged(double value) => ScheduleSection();
// ── 3點自訂剖切平面(SectionAxisIndex == 3v0.5.0)──
private const int CustomAxisIndex = 3;
private Vector3D? _customNormal; // null = 尚未定義(等使用者點 3 點)
private readonly List<Point3D> _sectionPicks = new();
private readonly List<Visual3D> _sectionPickOverlays = new();
partial void OnSectionEnabledChanged(bool value)
{
if (!value) CancelSectionPickIfActive(quiet: true);
ScheduleSection();
}
partial void OnSectionAxisIndexChanged(int value)
{
if (value == CustomAxisIndex)
{
if (_customNormal is null)
StatusText = "3點剖面:請在模型上點 3 個點定義剖切平面(Esc 取消)";
}
else
{
// 離開自訂平面 → 丟棄舊平面(再選「3點」即重新定義)
_customNormal = null;
ClearSectionPicks();
}
ScheduleSection();
}
partial void OnSectionPositionChanged(double value)
{
if (value < 0) { SectionPosition = 0; return; } // 位置數值輸入框防呆
if (value > 100) { SectionPosition = 100; return; }
ScheduleSection();
}
partial void OnSectionFlipChanged(bool value) => ScheduleSection();
/// <summary>3點剖面定義模式中的點擊(OnViewportClick 轉入)。回傳 true = 已消費此點擊。</summary>
public bool HandleSectionPlanePick(System.Windows.Point position)
{
if (!SectionEnabled || SectionAxisIndex != CustomAxisIndex || _customNormal is not null) return false;
if (_viewport is null) return true;
Point3D? hit = null;
foreach (var h in _viewport.Viewport.FindHits(position))
{
if (h.Model is null) continue;
if (_faceMap.ContainsKey(h.Model) || _mergedFaceRanges.ContainsKey(h.Model))
{
hit = h.Position;
break;
}
}
if (hit is null)
{
StatusText = $"3點剖面:未命中模型表面(已選 {_sectionPicks.Count}/3 點,Esc 取消)";
return true;
}
if (_sectionPicks.Count == 2 &&
Vector3D.CrossProduct(_sectionPicks[1] - _sectionPicks[0], hit.Value - _sectionPicks[0]).LengthSquared < 1e-12)
{
StatusText = "3點剖面:三點共線,請改選不共線的第 3 點";
return true;
}
_sectionPicks.Add(hit.Value);
double markerR = Math.Clamp(SceneDiagonal() * 0.004, 0.01, 5.0);
var marker = new SphereVisual3D { Center = hit.Value, Radius = markerR, Fill = System.Windows.Media.Brushes.DodgerBlue };
_sectionPickOverlays.Add(marker);
_viewport.Children.Add(marker);
if (_sectionPicks.Count < 3)
{
StatusText = $"3點剖面:已選 {_sectionPicks.Count}/3 點";
return true;
}
Vector3D n = Vector3D.CrossProduct(_sectionPicks[1] - _sectionPicks[0], _sectionPicks[2] - _sectionPicks[0]);
ClearSectionPicks();
if (n.LengthSquared < 1e-12)
{
StatusText = "3點剖面:三點退化(共線),請重新點選";
return true;
}
n.Normalize();
_customNormal = n;
StatusText = $"3點剖面:平面已定義(法向 {n.X:F2}, {n.Y:F2}, {n.Z:F2})— 用位置滑桿掃剖面";
ApplySection();
return true;
}
/// <summary>取消 3 點平面定義(Esc / 關剖面)。回傳 true = 有取消動作。</summary>
private bool CancelSectionPickIfActive(bool quiet = false)
{
bool picking = SectionEnabled && SectionAxisIndex == CustomAxisIndex && _customNormal is null;
if (_sectionPicks.Count == 0 && !picking) return false;
ClearSectionPicks();
if (SectionAxisIndex == CustomAxisIndex && _customNormal is null)
SectionAxisIndex = 0; // 回 X 軸
if (!quiet) StatusText = "已取消 3 點剖面定義";
return true;
}
private void ClearSectionPicks()
{
foreach (Visual3D v in _sectionPickOverlays) _viewport?.Children.Remove(v);
_sectionPickOverlays.Clear();
_sectionPicks.Clear();
}
private void ScheduleSection()
{
_sectionTimer.Stop();
@@ -82,28 +187,40 @@ public partial class MainViewModel
Rect3D bounds = UnionBounds();
if (bounds.IsEmpty) return;
Vector3D axis = SectionAxisIndex switch
Vector3D axis;
if (SectionAxisIndex == CustomAxisIndex)
{
if (_customNormal is null) return; // 平面尚未定義(等使用者點 3 點)
axis = _customNormal.Value;
}
else
{
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
// 平面位置:場景 AABB 8 個角投影到法向的 [dmin, dmax],滑桿 t 內插
//(軸向法向 = 與舊版逐軸計算等價;任意法向也成立)
double dmin = double.MaxValue, dmax = double.MinValue;
for (int ci = 0; ci < 8; ci++)
{
0 => new Point3D(planeCoord, center.Y, center.Z),
1 => new Point3D(center.X, planeCoord, center.Z),
_ => new Point3D(center.X, center.Y, planeCoord),
};
var corner = new Point3D(
(ci & 1) == 0 ? min.X : min.X + size.X,
(ci & 2) == 0 ? min.Y : min.Y + size.Y,
(ci & 4) == 0 ? min.Z : min.Z + size.Z);
double d = Vector3D.DotProduct((Vector3D)corner, axis);
if (d < dmin) dmin = d;
if (d > dmax) dmax = d;
}
double dStar = dmin + (dmax - dmin) * t;
Point3D planePoint = center + axis * (dStar - Vector3D.DotProduct((Vector3D)center, axis));
Vector3D normal = SectionFlip ? -axis : axis;
// UI 執行緒快照(原始 mesh 皆已 Freeze,可跨執行緒),背景平行裁切,回 UI 一次換上。
@@ -137,7 +254,8 @@ public partial class MainViewModel
ApplyRenderMode(); // 剖面開啟 → 改用逐面(裁切後幾何)
UpdateSectionPlaneVisual(planePoint, axis, size);
StatusText = $"剖面:{"XYZ"[SectionAxisIndex]} 軸 {SectionPosition:F0}%" + (SectionFlip ? "(反向)" : "");
string axisName = SectionAxisIndex == CustomAxisIndex ? "自訂平面" : $"{"XYZ"[SectionAxisIndex]} 軸";
StatusText = $"剖面:{axisName} {SectionPosition:F0}%" + (SectionFlip ? "(反向)" : "");
} while (_sectionReapply);
}
finally
@@ -155,8 +273,10 @@ public partial class MainViewModel
{
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),
2 => (new Vector3D(1, 0, 0), size.X, size.Y),
_ => (AnyPerpendicular(axis), size.Length * 0.8, size.Length * 0.8), // 自訂平面
};
lenDir.Normalize();
_sectionPlaneVisual = new RectangleVisual3D
{
Origin = planePoint,
@@ -168,4 +288,14 @@ public partial class MainViewModel
};
_viewport!.Children.Add(_sectionPlaneVisual);
}
/// <summary>取任一與 n 垂直的單位向量(自訂平面的指示矩形方向用)</summary>
private static Vector3D AnyPerpendicular(Vector3D n)
{
Vector3D v = Math.Abs(n.Z) < 0.9
? Vector3D.CrossProduct(n, new Vector3D(0, 0, 1))
: Vector3D.CrossProduct(n, new Vector3D(1, 0, 0));
v.Normalize();
return v;
}
}
+212 -1
View File
@@ -61,16 +61,55 @@ public partial class MainViewModel : ObservableObject
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ImportCommand))]
[NotifyCanExecuteChangedFor(nameof(ImportRecentCommand))]
[NotifyCanExecuteChangedFor(nameof(RotateRootCommand))]
[NotifyCanExecuteChangedFor(nameof(CheckInterferenceCommand))]
[NotifyCanExecuteChangedFor(nameof(ExportStepFileCommand))]
private bool isBusy;
[ObservableProperty]
private bool useInch;
/// <summary>樹面板名稱過濾字串(只影響樹顯示,不影響 3D 可見性)</summary>
[ObservableProperty]
private string? treeFilter;
partial void OnTreeFilterChanged(string? value)
{
foreach (ModelNodeViewModel root in Roots) root.ApplyFilter(value);
}
/// <summary>最近開啟檔案(MRU,最多 10 筆;隨 settings.json 保存)</summary>
public ObservableCollection<string> RecentFiles { get; } = new();
public ObservableCollection<ModelNodeViewModel> Roots { get; } = new();
public ObservableCollection<MeasurementResult> Measurements { get; } = new();
// ─── 使用者設定(視窗由 MainWindow 處理;VM 管單位 + MRU)─────
public void LoadSettings(AppSettings s)
{
UseInch = s.UseInch;
RecentFiles.Clear();
foreach (string f in s.RecentFiles.Where(File.Exists).Take(SettingsService.MaxRecentFiles))
RecentFiles.Add(f);
}
public void SaveSettingsInto(AppSettings s)
{
s.UseInch = UseInch;
s.RecentFiles = RecentFiles.ToList();
}
private void TouchRecent(string path)
{
string full = Path.GetFullPath(path);
RecentFiles.Remove(full);
RecentFiles.Insert(0, full);
while (RecentFiles.Count > SettingsService.MaxRecentFiles)
RecentFiles.RemoveAt(RecentFiles.Count - 1);
}
public MainViewModel()
{
_sectionTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(100) };
@@ -140,6 +179,10 @@ public partial class MainViewModel : ObservableObject
await ImportFilesAsync(dlg.FileNames);
}
/// <summary>MRU 下拉點選(檔案可能已被移走 → ImportFilesAsync 會報匯入失敗)</summary>
[RelayCommand(CanExecute = nameof(IsIdle))]
private Task ImportRecentAsync(string path) => ImportFilesAsync(new[] { path });
public async Task ImportFilesAsync(IEnumerable<string> paths)
{
if (_viewport is null) return;
@@ -162,6 +205,7 @@ public partial class MainViewModel : ObservableObject
{
ImportedFileData data = await Task.Run(() => _importService.Import(path));
BuildRoot(data);
TouchRecent(path);
ok++;
}
catch (Exception ex)
@@ -200,6 +244,7 @@ public partial class MainViewModel : ObservableObject
root.ShowEdges = false;
Roots.Add(root);
if (!string.IsNullOrWhiteSpace(TreeFilter)) root.ApplyFilter(TreeFilter); // 新檔套用目前過濾
foreach (ModelNodeViewModel leaf in root.Leaves())
{
leaf.VisualStateChanged += _ => SyncLeafVisuals(root);
@@ -410,6 +455,44 @@ public partial class MainViewModel : ObservableObject
[RelayCommand]
private void ZoomAll() => _viewport?.ZoomExtents(400);
/// <summary>標準視圖(機構慣例 Z 向上):Iso / Front / Back / Top / Bottom / Left / Right</summary>
[RelayCommand]
private void SetStandardView(string name)
{
if (_viewport?.Camera is not ProjectionCamera cam) return;
(Vector3D dir, Vector3D up) = name switch
{
"Front" => (new Vector3D(0, 1, 0), new Vector3D(0, 0, 1)), // 從 -Y 往 +Y 看
"Back" => (new Vector3D(0, -1, 0), new Vector3D(0, 0, 1)),
"Top" => (new Vector3D(0, 0, -1), new Vector3D(0, 1, 0)),
"Bottom" => (new Vector3D(0, 0, 1), new Vector3D(0, -1, 0)),
"Right" => (new Vector3D(-1, 0, 0), new Vector3D(0, 0, 1)),
"Left" => (new Vector3D(1, 0, 0), new Vector3D(0, 0, 1)),
_ => (new Vector3D(-1, -1, -1), new Vector3D(0, 0, 1)), // Iso
};
dir.Normalize();
Rect3D b = UnionBounds();
Point3D center = b.IsEmpty ? new Point3D()
: new Point3D(b.X + b.SizeX / 2, b.Y + b.SizeY / 2, b.Z + b.SizeZ / 2);
double dist = Math.Max(SceneDiagonal() * 1.8, 10);
cam.Position = center - dir * dist;
cam.LookDirection = dir * dist; // 目標點 = Position + LookDirection = 場景中心
cam.UpDirection = up;
_viewport.ZoomExtents(400);
StatusText = $"視圖:{name}";
}
/// <summary>正交投影切換(平行投影,量尺寸不受透視變形;Helix 保持視角換相機型別)</summary>
[ObservableProperty]
private bool orthographicView;
partial void OnOrthographicViewChanged(bool value)
{
if (_viewport is null) return;
_viewport.Orthographic = value;
StatusText = value ? "正交投影(平行投影,適合尺寸確認)" : "透視投影";
}
[RelayCommand]
private void RemoveRoot(ModelNodeViewModel root)
{
@@ -440,6 +523,92 @@ public partial class MainViewModel : ObservableObject
StatusText = $"已移除:{root.Name}";
}
// ─── 顯示控制 / 體積(樹右鍵選單)────────────────────────────
/// <summary>只顯示此節點(其餘全部隱藏;「全部顯示」復原)</summary>
[RelayCommand]
private void IsolateNode(ModelNodeViewModel node)
{
foreach (ModelNodeViewModel r in Roots) SetVisibleRecursive(r, false);
SetVisibleRecursive(node, true);
StatusText = $"只顯示「{node.Name}」(樹右鍵 → 全部顯示 復原)";
}
[RelayCommand]
private void InvertVisibility()
{
foreach (ModelNodeViewModel l in Roots.SelectMany(r => r.Leaves()))
l.IsVisible = !l.IsVisible;
StatusText = "已反轉顯示";
}
[RelayCommand]
private void ShowAll()
{
foreach (ModelNodeViewModel r in Roots) SetVisibleRecursive(r, true);
StatusText = "已全部顯示";
}
/// <summary>逐節點顯式遞迴設定可見性。不能只設 root(setter 同值不觸發 cascade
/// 之前的隔離操作可能留下「父關子開」的混合狀態)</summary>
private static void SetVisibleRecursive(ModelNodeViewModel node, bool visible)
{
node.IsVisible = visible;
foreach (ModelNodeViewModel c in node.Children) SetVisibleRecursive(c, visible);
}
/// <summary>體積/質心(網格 signed volume,封閉實體才可靠)→ 加進量測結果 + 質心標記</summary>
[RelayCommand]
private void MeasureVolume(ModelNodeViewModel node)
{
var meshes = node.Leaves()
.Where(l => l.FacesContent is not null)
.SelectMany(l => l.FacesContent!.Children)
.OfType<GeometryModel3D>()
.Where(g => _faceMap.ContainsKey(g))
.Select(g => _faceMap[g].Mesh)
.ToList();
if (meshes.Count == 0)
{
StatusText = $"「{node.Name}」沒有可計算的面網格(線架構無體積)";
return;
}
(double vol, Point3D centroid, bool reliable) = MeasurementService.MeshVolume(meshes);
if (!reliable)
{
StatusText = $"「{node.Name}」非封閉網格(開放殼),無法可靠計算體積";
return;
}
double diag = SceneDiagonal();
double markerR = Math.Clamp(diag * 0.004, 0.01, 5.0);
string label = NextLabel(MeasureMode.Volume, "V");
string name = node.Name;
Rect3D b = node.Bounds;
var m = new MeasurementResult
{
Kind = MeasureMode.Volume,
TitleFor = u => $"{label} {name} V ≈ {Units.V(vol, u)}",
DetailFor = u => $"體積 ≈ {Units.V(vol, u)}(網格近似)\n質心 {Units.P(centroid, u)}\n" +
$"外形(AABB{Units.C(b.SizeX, u)} × {Units.C(b.SizeY, u)} × {Units.C(b.SizeZ, u)} " +
(u == UnitSystem.Millimeter ? "mm" : "in"),
};
m.Overlays.Add(new SphereVisual3D { Center = centroid, Radius = markerR, Fill = Brushes.OrangeRed });
var volLabel = new BillboardTextVisual3D
{
Position = centroid + new Vector3D(0, 0, markerR * 2),
Text = $"{label} {Units.V(vol, 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(volLabel);
m.DynamicLabels.Add((volLabel, u => $"{label} {Units.V(vol, u)}"));
AddMeasurement(m);
}
// ─── 量測 ────────────────────────────────────────────────────
partial void OnCurrentModeChanged(MeasureMode value)
@@ -470,10 +639,52 @@ public partial class MainViewModel : ObservableObject
StatusText = value ? "顯示單位:inch" : "顯示單位:mm";
}
/// <summary>EscMainWindow 轉發):先取消進行中的多段量測(保留模式),再按退回瀏覽模式;再退操作器</summary>
public void OnEscape()
{
if (CancelSectionPickIfActive()) return; // 3點剖面定義中 → 先取消定義
bool hadPending = _pendingPoint is not null || _pendingDirection is not null ||
_pendingFace is not null || _pendingAlign is not null || _align3.Count > 0;
if (hadPending)
{
CancelPending();
StatusText = "已取消目前量測起點(再按 Esc 退出量測模式)";
}
else if (CurrentMode != MeasureMode.None)
{
CurrentMode = MeasureMode.None;
}
else if (GizmoEnabled)
{
GizmoEnabled = false;
}
}
/// <summary>量測模式快速鍵(MainWindow 轉發;無修飾鍵)。同鍵再按 = 退回瀏覽。回傳 false 表示非快速鍵。</summary>
public bool OnHotKey(System.Windows.Input.Key key)
{
MeasureMode? m = key switch
{
System.Windows.Input.Key.P => MeasureMode.Point,
System.Windows.Input.Key.D => MeasureMode.Distance,
System.Windows.Input.Key.E => MeasureMode.Edge,
System.Windows.Input.Key.F => MeasureMode.Face,
System.Windows.Input.Key.C => MeasureMode.Circle,
System.Windows.Input.Key.A => MeasureMode.Angle,
System.Windows.Input.Key.M => MeasureMode.FaceDistance,
_ => null,
};
if (m is null) return false;
CurrentMode = CurrentMode == m ? MeasureMode.None : m.Value;
return true;
}
/// <summary>MainWindow 滑鼠左鍵點擊轉發進來</summary>
public void OnViewportClick(Point position)
{
if (_viewport is null || IsBusy || CurrentMode is MeasureMode.None or MeasureMode.Drag) return;
if (_viewport is null || IsBusy) return;
if (HandleSectionPlanePick(position)) return; // 3點剖面定義模式優先吃點擊(v0.5.0)
if (CurrentMode is MeasureMode.None or MeasureMode.Drag) return;
var hits = _viewport.Viewport.FindHits(position);
FaceInfo? fi = null;
+41 -3
View File
@@ -57,8 +57,24 @@ public partial class ModelNodeViewModel : ObservableObject
public int FaceCount { get; init; }
public int TriangleCount { get; init; }
/// <summary>世界座標邊界(零件平移後同步位移</summary>
public Rect3D Bounds { get; set; }
/// <summary>世界座標邊界(零件平移/旋轉後由 RecomputeBounds 更新;連動 Stats/Tooltip 的外形尺寸</summary>
public Rect3D Bounds
{
get => _bounds;
set
{
if (_bounds == value) return;
_bounds = value;
OnPropertyChanged(nameof(Stats));
OnPropertyChanged(nameof(ToolTipText));
}
}
private Rect3D _bounds = Rect3D.Empty;
/// <summary>外形尺寸 L×W×HAABBmm</summary>
public string SizeText => Bounds.IsEmpty
? ""
: $"{Bounds.SizeX:F1} × {Bounds.SizeY:F1} × {Bounds.SizeZ:F1} mm";
/// <summary>來源 B-rep 物件(零件平移時整體 Modify,避免共用邊被重複位移)</summary>
public List<CADability.GeoObject.IGeoObject> SourceGeos { get; } = new();
@@ -67,10 +83,32 @@ public partial class ModelNodeViewModel : ObservableObject
public bool HasBrep { get; init; } = true;
public string Stats =>
$"{SolidCount} 實體 · {FaceCount} 面 · {TriangleCount:N0} △" + (HasBrep ? "" : " · 無B-rep");
$"{SolidCount} 實體 · {FaceCount} 面 · {TriangleCount:N0} △" + (HasBrep ? "" : " · 無B-rep") +
(Bounds.IsEmpty ? "" : $"\n外形 {SizeText}");
public string ToolTipText => (FilePath is not null ? FilePath + "\n" : "") + Stats;
// ── 樹搜尋/過濾(只影響樹面板顯示,不影響 3D 可見性)──
[ObservableProperty]
private bool isFilterVisible = true;
/// <summary>
/// 套用名稱過濾:符合者 + 其祖先 + 其子樹 顯示。空字串 = 全顯。
/// 過濾中有符合的子孫時自動展開。回傳此節點是否顯示。
/// </summary>
public bool ApplyFilter(string? filter, bool ancestorMatched = false)
{
bool empty = string.IsNullOrWhiteSpace(filter);
bool selfMatch = !empty && Name.Contains(filter!, StringComparison.OrdinalIgnoreCase);
bool anyChild = false;
foreach (ModelNodeViewModel c in Children)
anyChild |= c.ApplyFilter(filter, ancestorMatched || selfMatch);
IsFilterVisible = empty || selfMatch || ancestorMatched || anyChild;
if (!empty && anyChild) IsExpanded = true;
return IsFilterVisible;
}
// ── 視覺物件 ──
public ModelVisual3D? BodyVisual { get; set; } // leaf:面網格容器(內容在兩種模式間切換)
public Model3DGroup? FacesContent { get; set; } // leaf:逐面 GeometryModel3D(量測拾取用)
+53
View File
@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using CADability;
using CADability.GeoObject;
using STPViewer.Services;
using IOPath = System.IO.Path; // CADability.GeoObject.Path(曲線)撞名
namespace SmokeTest;
/// <summary>
/// STEP 匯出往返驗證(v0.5.0 匯出 STEP 功能的無 UI 驗證):
/// 匯入來源 STEP → 走與 MainViewModel.ExportStepFile 相同路徑寫出新檔 → 重新匯入比對實體數。
/// </summary>
public static class ExportTest
{
public static int Run(string sourcePath, string outPath)
{
Console.WriteLine($"=== ExportTest: {IOPath.GetFileName(sourcePath)} → {IOPath.GetFileName(outPath)} ===");
ImportedFileData data = new StepImportService().Import(sourcePath);
var geos = Collect(data.Root).Where(g => g is Solid or Shell).ToList();
Console.WriteLine($" 來源 Solid/Shell 物件 = {geos.Count}Solids = {data.Root.SolidCount}");
Project project = Project.CreateSimpleProject();
Model model = project.GetActiveModel();
foreach (IGeoObject g in geos) model.Add(g);
new ExportStep().WriteToFile(outPath, project);
var fi = new FileInfo(outPath);
Console.WriteLine($" 已寫出({fi.Length:N0} bytes");
if (fi.Length < 100)
{
Console.WriteLine("ExportTest: FAIL(輸出檔近乎空白)");
return 1;
}
ImportedFileData back = new StepImportService().Import(outPath);
Console.WriteLine($" 回讀 Solids = {back.Root.SolidCount}, Faces = {back.Root.FaceCount}");
bool ok = back.Root.SolidCount == data.Root.SolidCount && back.Root.FaceCount > 0;
Console.WriteLine(ok
? "ExportTest: PASS(實體數一致)"
: $"ExportTest: FAIL(來源 Solids {data.Root.SolidCount} ↔ 回讀 {back.Root.SolidCount}");
return ok ? 0 : 1;
}
private static IEnumerable<IGeoObject> Collect(ImportedNode n)
{
foreach (IGeoObject g in n.SourceGeos) yield return g;
foreach (ImportedNode c in n.Children)
foreach (IGeoObject g in Collect(c))
yield return g;
}
}
+3
View File
@@ -35,6 +35,9 @@ if (args[0] == "--align-test")
if (args[0] == "--bbox")
return SmokeTest.BBoxDump.Run(args[1]);
if (args[0] == "--export-test")
return SmokeTest.ExportTest.Run(args[1], args[2]) == 0 ? 0 : 2;
var service = new StepImportService { Progress = s => Console.WriteLine($" [階段] {s}") };
int failed = 0;