Compare commits
3
Commits
6d48d7f852
...
2caa95a69d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2caa95a69d | ||
|
|
fb46da29cf | ||
|
|
c65fc4e67f |
+2
-2
@@ -6,9 +6,9 @@ Publish/ # publish-winapp output on case-sensitive systems
|
||||
*.user
|
||||
.vs/
|
||||
|
||||
# Local development assets
|
||||
# AI 協作素材不入 git
|
||||
For_AI/
|
||||
|
||||
# CAD samples are stored locally in For_AI/
|
||||
# 客戶 CAD 料號檔不入 git(避免外流);測試模型放 For_AI/
|
||||
*.stp
|
||||
*.step
|
||||
|
||||
+31
-1
@@ -100,8 +100,12 @@ STPViewer/
|
||||
├── STPViewer.csproj # net8.0-windows, UseWPF
|
||||
├── App.xaml / App.xaml.cs # 全域例外處理(log + 訊息框存活;%LOCALAPPDATA%\STPViewer\error.log)
|
||||
├── MainWindow.xaml / .cs # 版面 + 滑鼠拾取事件
|
||||
├── i18n/ # 語言包 en / zh_tw / zh_cn.json(輸出到 exe 旁;啟動時掃描,免重編)
|
||||
├── Localization/
|
||||
│ └── LocExtension.cs # XAML {loc:Loc Key} → 綁定 LocalizationService[Key](切語言即時更新)
|
||||
│
|
||||
├── Models/
|
||||
│ ├── LanguageDefinition.cs # 一個語言包(Id = 檔名、Key = 顯示名、Data = 翻譯表)
|
||||
│ ├── FaceInfo.cs # GeometryModel3D ↔ B-rep Face 對照(STL 為 null)
|
||||
│ ├── MeasureMode.cs # enum: None/Point/Distance/Edge/Face/Circle/Angle/FaceDistance/Align/Align3/Drag/Interference/Volume
|
||||
│ ├── MeasurementResult.cs # 量測結果(雙單位 lambda、3D 標籤同步)
|
||||
@@ -112,6 +116,8 @@ STPViewer/
|
||||
│
|
||||
├── Services/
|
||||
│ ├── StepImportService.cs # STEP/STL/DXF 讀檔 + 三角化 + 裝配樹
|
||||
│ ├── StlMeshReader.cs # STL 直接解析(binary/ASCII,不經 CADability;v0.7.1)
|
||||
│ ├── LocalizationService.cs # 語言包載入、目前語言、[key] 查詢(缺字回退英文)、LanguageChanged
|
||||
│ ├── MeasurementService.cs # 點/線/面/圓/角度/面距/體積質心 幾何計算(吸附含圓心)
|
||||
│ ├── InterferenceService.cs # 干涉檢查:三角形-三角形相交(區間法)+均勻網格加速;無干涉時近似最小間隙
|
||||
│ ├── RigidAlign.cs # 三點對齊/旋轉的剛體變換數學(Matrix3D列向量 ↔ ModOp行向量 轉換)
|
||||
@@ -126,6 +132,8 @@ STPViewer/
|
||||
├── MainViewModel.Section.cs # 剖面(v0.4.0 起背景平行裁切)
|
||||
├── MainViewModel.Interference.cs # 干涉檢查指令
|
||||
├── MainViewModel.Export.cs # CSV / 截圖 / STEP / STL 匯出
|
||||
├── MainViewModel.Localization.cs # 語言切換指令 + 切換後刷新量測/樹/狀態列文字
|
||||
├── QuickBarViewModel.cs # 快速列按鈕註冊表(key/文字 key/預設值)
|
||||
├── StlExportViewModel.cs # STL 匯出對話框 VM(選項 + 即時摘要)
|
||||
└── ModelNodeViewModel.cs # 裝配樹節點(可見性/顏色 cascade)
|
||||
```
|
||||
@@ -201,7 +209,8 @@ class MeasurementResult
|
||||
| 快速鍵 | P 點 / D 距離 / E 邊 / F 面 / C 圓 / A 角度 / M 面距;同鍵再按=退出;Esc=取消進行中量測→退出模式 | |
|
||||
| 樹操作 | 搜尋框過濾節點名稱;右鍵:只顯示此節點 / 反轉顯示 / 全部顯示 / 量體積質心 | tooltip 含外形尺寸 L×W×H |
|
||||
| 體積/質心 | 樹節點右鍵「⚖ 量體積/質心」 | signed volume(封閉實體)+ 質心標記 + AABB 外形尺寸 |
|
||||
| 設定/MRU | 自動記住視窗位置大小、單位;「▾」最近 10 個檔案 | %LOCALAPPDATA%\STPViewer\settings.json |
|
||||
| 語言 | 視圖 → 語言 / 快速列下拉:English、繁體中文、简体中文(v0.8.0) | 即時切換(含既有量測文字)並記住;`i18n/*.json` 加檔即新增語言,缺字回退英文 |
|
||||
| 設定/MRU | 自動記住視窗位置大小、單位、語言;「▾」最近 10 個檔案 | %LOCALAPPDATA%\STPViewer\settings.json |
|
||||
|
||||
支援格式:`.stp` / `.step`(B-rep + 裝配樹)、`.stl`(純網格,僅點/距離/角度/面距量測)、
|
||||
`.dxf`(線架構檢視)。**IGES 不支援**(CADability 無 IGES reader)。
|
||||
@@ -489,6 +498,27 @@ WPF retained-mode 每幀重走 visual tree → frame rate 崩。瀏覽模式因
|
||||
|
||||
---
|
||||
|
||||
## Development Phases — 第九輪(多語系 v0.8.0)
|
||||
|
||||
### Phase 17 — 執行期語言包 + 即時切換(工作量:L;外部貢獻 PR #1 by @CarmJos)
|
||||
|
||||
**問題:** 介面字串全部寫死繁中,非中文使用者無法使用;新增語言要改程式重編。
|
||||
|
||||
- [x] **語言包**:`i18n/*.json`(`key` = 選單顯示名、`data` = key → 譯文)複製到 exe 旁;
|
||||
啟動時 `LocalizationService` 掃描,檔名即語言 Id(`zh_cn.json` → `zh_cn`);壞檔/重複 Id 略過記 Trace
|
||||
- [x] **查詢與回退**:`LocalizationService[key]` → 目前語言 → 英文 → key 本身;`Format` 格式錯誤時回退英文格式
|
||||
- [x] **XAML**:`{loc:Loc Key}`(`LocExtension` = 綁 `LocalizationService.Instance[Key]`,`Item[]` 變更即時刷新)
|
||||
- [x] **程式內文字**:狀態列 `SetStatus(() => Get/Format(...))`、量測/樹節點文字延後產生,
|
||||
`LanguageChanged` → `RefreshLocalizedText()` 重算(與 mm⇄inch 同一套延後產生模式)
|
||||
- [x] **初始語言**:settings.json `Language`,null → 依系統 UI 語系(zh-Hant/TW/HK/MO → zh_tw,其他 zh → zh_cn)
|
||||
- [x] 關於視窗版本紀錄改為 key(`Ui.About.ReleaseXYZ.Title/ChangeN`),三語各一份
|
||||
- [x] SmokeTest `--localization-test`:語系解析(zh-Hans/Hant/TW…)、缺字/格式錯回退英文、壞檔容錯、
|
||||
內建語言包格式參數一致、VM 切語言後既有量測/3D 標籤/快速列即時更新
|
||||
|
||||
**驗收:** 三語切換即時生效(含已存在的量測結果與狀態列)、重開保留;刪掉某語言檔中的 key 時顯示英文不崩潰
|
||||
|
||||
---
|
||||
|
||||
## Future Extensions(下一輪)
|
||||
|
||||
- **大檔重開快取** — 匯入成功後把三角網格 + 裝配樹序列化成 sidecar 快取檔(以來源檔 hash 驗證),
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
CAD 3D 檢視器(Windows 桌面 WPF, .NET 8):STEP/STL/DXF 匯入、STEP 裝配樹(搜尋/隔離顯示)、
|
||||
點/距離/邊/面/圓/角度/面距/體積質心量測(吸附含圓心)、兩點對齊(平移)、三點對齊(旋轉+平移)、
|
||||
軸向旋轉 90°、拖曳模式、干涉檢查(≥2 檔兩兩配對)、剖面(X/Y/Z + 3點任意平面)、
|
||||
標準視圖+正交投影、量測快速鍵+Esc、mm⇄inch、CSV/截圖/STEP/STL 匯出(STL 有參數對話框)、設定與 MRU 保存。
|
||||
標準視圖+正交投影、量測快速鍵+Esc、mm⇄inch、多語系(英/繁/簡,執行期語言包)、CSV/截圖/STEP/STL 匯出(STL 有參數對話框)、設定與 MRU 保存。
|
||||
詳細設計見 [ARCHITECTURE.md](ARCHITECTURE.md)。
|
||||
|
||||
## 技術棧
|
||||
@@ -49,8 +49,8 @@ dotnet publish src/STPViewer -c Release -o publish/STPViewer
|
||||
(樹搜尋框、剖面數值框要能打字),新增輸入控件不用再各自處理
|
||||
- UI 三層(v0.7.0):**選單列**(全部功能,分類下拉)+ **快速列**(`QuickBarViewModel` 註冊表,
|
||||
使用者勾選常用按鈕,XAML 索引子綁定 `QuickBar[Key].IsChecked` 控 Visibility,按鈕本體維持靜態綁定)+
|
||||
**剖面參數列**(只在 SectionEnabled 顯示)。新增功能要三處都接:選單分類、快速列註冊表
|
||||
(含預設值)+ 對應 XAML 按鈕;勾選清單存 settings.json `QuickBarKeys`(null = 預設)
|
||||
**剖面參數列**(只在 SectionEnabled 顯示)。新增功能要四處都接:選單分類、快速列註冊表
|
||||
(含預設值)+ 對應 XAML 按鈕、**三個語言包的文字 key**;勾選清單存 settings.json `QuickBarKeys`(null = 預設)
|
||||
- 使用者設定 `SettingsService`(%LOCALAPPDATA%\STPViewer\settings.json):視窗(MainWindow 管)+
|
||||
單位/MRU(VM `LoadSettings`/`SaveSettingsInto`);壞檔回預設、儲存失敗靜默
|
||||
- 匯出 STEP 走 `CADability.ExportStep.WriteToFile(file, Project.CreateSimpleProject()+Model.Add)`;
|
||||
@@ -65,6 +65,13 @@ dotnet publish src/STPViewer -c Release -o publish/STPViewer
|
||||
OnViewportClick 最前面攔點擊);換軸/關剖面/Esc 會重置。平面位置一律用「AABB 8 角投影到法向」內插,
|
||||
軸向與任意法向共用同一段程式,不要改回逐軸特化
|
||||
- 量測文字一律 `Func<UnitSystem,string>` 延後產生(mm⇄inch 即時切換);內部數值永遠存 mm
|
||||
- 🌐 **多語系(v0.8.0,PR #1 by @CarmJos)**:介面文字**一律走 key,不可寫死字串**。語言包 `src/STPViewer/i18n/{en,zh_tw,zh_cn}.json`
|
||||
(`key` = 選單顯示名、`data` = key → 譯文;建置時複製到 exe 旁,啟動掃描,加檔即新增語言免重編)。
|
||||
XAML 用 `{loc:Loc Ui.Xxx}`;程式用 `LocalizationService.Get(key)` / `Format(key, args)`;
|
||||
狀態列用 `SetStatus(() => Get(...))`(延後產生,切語言時 `RefreshLocalizedText()` 重算 — 直接設 `StatusText` 切語言不會更新)。
|
||||
新增文字 key **三個 json 都要加**(`en.json` 是 key 清單正本;缺字執行期回退英文、再缺顯示 key 本身)。
|
||||
key 命名:`Ui.Main.*`(主視窗)/ `Ui.StlExport.*` / `Ui.About.*` / `QuickBar.*` / `Runtime.*`(狀態列訊息)/ `App.*`。
|
||||
選用語言存 settings.json `Language`(null = 依系統 UI 語系)。**程式碼註解維持中文**(設計理由與「不要改回 X」都寫在註解,勿精簡掉)
|
||||
- 裝配樹節點(`ModelNodeViewModel`)的可見性/邊線/顏色向下 cascade
|
||||
- 剖面只換 `GeometryModel3D.Geometry`(`FaceInfo.Mesh` 保留原始 frozen mesh 供還原與量測)
|
||||
- ⚡ 剖面裁切是**背景平行**(v0.4.0):`ApplySection` 在 UI 快照 (model, frozen mesh) → `Task.Run`+`Parallel.For`
|
||||
@@ -98,7 +105,8 @@ dotnet publish src/STPViewer -c Release -o publish/STPViewer
|
||||
- 干涉/面距/對齊等運算在背景執行緒;`Freeze()` 幾何後才跨執行緒
|
||||
- 匯入在背景執行緒;`Freeze()` 幾何後才跨執行緒
|
||||
- 發新版:升 csproj `<Version>` 之外,記得在 `AboutDialog.xaml.cs` 的 `Changelog` 陣列**頂端**加一筆
|
||||
(口吻寫給一般使用者:「你會感覺到什麼」,不是技術 changelog)。
|
||||
(口吻寫給一般使用者:「你會感覺到什麼」,不是技術 changelog)。v0.8.0 起內容是 key
|
||||
(`Ui.About.ReleaseXYZ.Title` / `.ChangeN`),**三個語言包都要寫**。
|
||||
⚠️ WPF **頂層 MenuItem 直接掛 Click 不會觸發**(實測滑鼠/鍵盤都沒反應),要開視窗的入口一律放子選單(如 說明 → 關於)
|
||||
- Commit 格式:Conventional Commits(`feat:` / `fix:` / `docs:` …)
|
||||
|
||||
@@ -133,7 +141,9 @@ dotnet publish src/STPViewer -c Release -o publish/STPViewer
|
||||
`--align-test`(三點對齊剛體變換 + ModOp↔Matrix3D 一致性)、`--make-dxf`(產測試檔)、
|
||||
`--export-test <in.stp> <out.stp>`(STEP 匯出往返:寫出→回讀比對實體數)、
|
||||
`--stl-export-test [file.stp]`(STL binary/ASCII 往返 + 退化濾除;給 stp 加測 B-rep 精細重算)、
|
||||
`--make-stl <out.stl> [triCount]`(產測試用 binary STL,預設 150 萬三角形,量 STL 匯入效能用)
|
||||
`--make-stl <out.stl> [triCount]`(產測試用 binary STL,預設 150 萬三角形,量 STL 匯入效能用)、
|
||||
`--localization-test`(語言包載入/回退/壞檔容錯 + VM 即時切換;改 i18n 或加文字要跑)
|
||||
- Linux 也能編 Windows 版:`~/.dotnet/dotnet build/publish ... -p:EnableWindowsTargeting=true`(SmokeTest 是 net8.0-windows,只能在 Windows 跑)
|
||||
- **絕不要用 PowerShell regex/Set-Content 改 .cs 檔** — Windows PowerShell 5.1 預設編碼會把 UTF-8 中文弄成亂碼(已踩過,靠反編譯 DLL 救回)。文字取代一律用 Edit 工具
|
||||
|
||||
## For_AI/
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# v0.8.0 — Switch languages: English, 繁體中文, 简体中文
|
||||
|
||||
## ✨ New features
|
||||
|
||||
**Language switching**
|
||||
* Pick **English**, **繁體中文** or **简体中文** from **View → Language** or the language box on the quick toolbar. The whole interface switches instantly — menus, dialogs, status messages, and even measurements already on screen — and your choice is remembered next time.
|
||||
* On first launch STPViewer follows your Windows display language (Traditional Chinese regions → 繁體中文, other Chinese → 简体中文, everything else → English).
|
||||
|
||||
**Add your own language — no update needed**
|
||||
* Languages are plain JSON files in the `i18n` folder next to `STPViewer.exe`. Copy `en.json`, translate it, save it under a new name, and restart — the new language appears in the menu. Anything not yet translated falls back to English. See [Adding a Language](https://github.com/ETWen/STPViewer#-adding-a-language) in the README.
|
||||
|
||||
**Simplified Chinese README**
|
||||
* The README is now also available in [简体中文](https://github.com/ETWen/STPViewer/blob/main/README.zh-CN.md).
|
||||
|
||||
## 🙏 Thanks
|
||||
|
||||
* Multi-language support was contributed by **@CarmJos** in [#1](https://github.com/ETWen/STPViewer/pull/1) — thank you!
|
||||
|
||||
## 📦 Downloads
|
||||
|
||||
| Build | Needs .NET Runtime? | Notes |
|
||||
|---|---|---|
|
||||
| **STPViewer_v0.8.0** (Standard) | ✅ Requires .NET 8 Desktop Runtime | Smaller download |
|
||||
| **STPViewer_v0.8.0_portable** (Portable) | ❌ Runtime included | Unzip and run anywhere, no admin |
|
||||
|
||||
Run `STPViewer v0.8.0.exe`. Keep the `i18n` folder next to it — that's where the languages live.
|
||||
|
||||
## 🔗 Links
|
||||
|
||||
* [Architecture](https://github.com/ETWen/STPViewer/blob/main/ARCHITECTURE.md)
|
||||
* [README](https://github.com/ETWen/STPViewer/blob/main/README.md)
|
||||
|
||||
**Full changelog:** [v0.7.1...v0.8.0](https://github.com/ETWen/STPViewer/compare/v0.7.1...v0.8.0)
|
||||
@@ -35,7 +35,7 @@
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Application, developer, and technology -->
|
||||
<!-- ─── 左:程式 / 開發者 / 技術棧 ─── -->
|
||||
<StackPanel Grid.Column="0" Grid.Row="0" Margin="0,0,12,0">
|
||||
<Border Style="{StaticResource Card}">
|
||||
<StackPanel HorizontalAlignment="Center">
|
||||
@@ -78,7 +78,7 @@
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Release history -->
|
||||
<!-- ─── 右:Change log ─── -->
|
||||
<Border Grid.Column="1" Grid.Row="0" Background="#FAFBFC" CornerRadius="6" Padding="4">
|
||||
<DockPanel>
|
||||
<TextBlock DockPanel.Dock="Top" Text="{loc:Loc Ui.About.Changelog}" FontWeight="Bold" FontSize="14"
|
||||
|
||||
@@ -7,8 +7,8 @@ using STPViewer.Services;
|
||||
namespace STPViewer;
|
||||
|
||||
/// <summary>
|
||||
/// The About window shows application information on the left and release history on the right.
|
||||
/// Add each new release at the start of Changelog.
|
||||
/// 關於視窗:左 = 程式資訊 / 開發者 / 技術棧,右 = 版本紀錄(寫給一般使用者看,非技術 changelog)。
|
||||
/// 樣式參考 ETTerms 的 AboutView。發新版時在 Changelog 陣列最上面加一筆。
|
||||
/// </summary>
|
||||
public partial class AboutDialog : Window
|
||||
{
|
||||
@@ -26,9 +26,16 @@ public partial class AboutDialog : Window
|
||||
|
||||
private record ChangelogEntry(string Version, DateOnly Date, string Title, string[] Changes);
|
||||
|
||||
// Release history shown to users, newest first.
|
||||
// ── 版本紀錄(新版加最上面;口吻:一般使用者看得懂,講「你會感覺到什麼」而非實作細節)──
|
||||
private static readonly ChangelogEntry[] Changelog =
|
||||
{
|
||||
new("0.8.0", new DateOnly(2026, 10, 20), "Ui.About.Release080.Title",
|
||||
new[]
|
||||
{
|
||||
"Ui.About.Release080.Change1",
|
||||
"Ui.About.Release080.Change2",
|
||||
"Ui.About.Release080.Change3",
|
||||
}),
|
||||
new("0.7.1", new DateOnly(2026, 7, 12), "Ui.About.Release071.Title",
|
||||
new[]
|
||||
{
|
||||
|
||||
@@ -8,8 +8,8 @@ using STPViewer.Services;
|
||||
namespace STPViewer;
|
||||
|
||||
/// <summary>
|
||||
/// Log unhandled UI and background exceptions to
|
||||
/// %LOCALAPPDATA%\STPViewer\error.log and show a message for UI errors.
|
||||
/// 全域例外處理:UI 執行緒未攔截例外 → 記 log + 訊息框後盡量存活(不閃退);
|
||||
/// 背景 Task / 非 UI 執行緒例外 → 記 log。log 位於 %LOCALAPPDATA%\STPViewer\error.log。
|
||||
/// </summary>
|
||||
public partial class App : Application
|
||||
{
|
||||
@@ -22,7 +22,8 @@ public partial class App : Application
|
||||
LocalizationService.Instance.SetLanguage(SettingsService.Load().Language);
|
||||
base.OnStartup(e);
|
||||
|
||||
// Report UI exceptions and keep the window open.
|
||||
// UI 執行緒(含 async void 回拋)的未攔截例外:記 log、提示、標記 Handled 讓程式存活。
|
||||
// 若後續狀態已壞使用者可自行重啟;至少量測結果 / 已載模型不會直接蒸發。
|
||||
DispatcherUnhandledException += (_, args) =>
|
||||
{
|
||||
LogException("DispatcherUnhandledException", args.Exception);
|
||||
@@ -32,14 +33,14 @@ public partial class App : Application
|
||||
args.Handled = true;
|
||||
};
|
||||
|
||||
// Log unobserved task exceptions.
|
||||
// 沒被 await 的背景 Task 例外(finalizer 階段才浮出):記 log 並標記已觀察,避免行程終止
|
||||
TaskScheduler.UnobservedTaskException += (_, args) =>
|
||||
{
|
||||
LogException("UnobservedTaskException", args.Exception);
|
||||
args.SetObserved();
|
||||
};
|
||||
|
||||
// Log fatal exceptions on non-UI threads.
|
||||
// 其他非 UI 執行緒的致命例外:無法阻止終止,但至少留下記錄
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
|
||||
LogException("AppDomainUnhandledException", args.ExceptionObject as Exception);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ using STPViewer.Models;
|
||||
namespace STPViewer.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Convert MeasureMode to ToggleButton.IsChecked using the mode name
|
||||
/// in ConverterParameter. Unchecking returns to None.
|
||||
/// MeasureMode ↔ ToggleButton.IsChecked。
|
||||
/// ConverterParameter 為模式名稱字串;取消勾選時回到 None。
|
||||
/// </summary>
|
||||
public class EnumToBoolConverter : IValueConverter
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ using System.Windows.Data;
|
||||
|
||||
namespace STPViewer.Converters;
|
||||
|
||||
/// <summary>Show file names in the recent-files menu while retaining full paths as tooltips.</summary>
|
||||
/// <summary>完整路徑 → 檔名(選單「最近檔案」顯示用;tooltip 仍給完整路徑)</summary>
|
||||
public class FileNameConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</Window.Resources>
|
||||
|
||||
<DockPanel>
|
||||
<!-- Menu bar -->
|
||||
<!-- ─── 選單列(全部功能,分類下拉)─── -->
|
||||
<Menu DockPanel.Dock="Top">
|
||||
<MenuItem Header="{loc:Loc Ui.Main.FileF}">
|
||||
<MenuItem Header="{loc:Loc Ui.Main.MenuImport}" Command="{Binding ImportCommand}"
|
||||
@@ -153,7 +153,7 @@
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<!-- Customizable Quick Bar -->
|
||||
<!-- ─── 快速工具列(可自訂:選單「快速列 → 自訂按鈕」勾選)─── -->
|
||||
<ToolBarTray DockPanel.Dock="Top">
|
||||
<ToolBar>
|
||||
<Button Style="{StaticResource QuickButton}" Command="{Binding ImportCommand}"
|
||||
@@ -256,7 +256,7 @@
|
||||
</ToolBar>
|
||||
</ToolBarTray>
|
||||
|
||||
<!-- Section controls -->
|
||||
<!-- ─── 剖面參數列(只在剖面開啟時出現)─── -->
|
||||
<ToolBarTray DockPanel.Dock="Top"
|
||||
Visibility="{Binding SectionEnabled, Converter={StaticResource BoolToVis}}">
|
||||
<ToolBar>
|
||||
@@ -280,7 +280,7 @@
|
||||
</ToolBar>
|
||||
</ToolBarTray>
|
||||
|
||||
<!-- Status bar -->
|
||||
<!-- ─── 狀態列 ─── -->
|
||||
<StatusBar DockPanel.Dock="Bottom">
|
||||
<StatusBarItem>
|
||||
<TextBlock Text="{Binding StatusText}" />
|
||||
@@ -300,7 +300,7 @@
|
||||
<ColumnDefinition Width="300" MinWidth="160" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Assembly tree -->
|
||||
<!-- ─── 裝配樹面板 ─── -->
|
||||
<GroupBox Grid.Column="0" Header="{loc:Loc Ui.Main.ModelsAssemblyTree}" Margin="4">
|
||||
<DockPanel>
|
||||
<TextBox DockPanel.Dock="Top" Margin="0,2,0,4"
|
||||
@@ -390,7 +390,7 @@
|
||||
VerticalAlignment="Stretch" Background="#FFD8D8D8"
|
||||
ToolTip="{loc:Loc Ui.Main.DragResizePanel}" />
|
||||
|
||||
<!-- 3D viewport -->
|
||||
<!-- ─── 3D 視圖 ─── -->
|
||||
<h:HelixViewport3D x:Name="viewport" Grid.Column="2"
|
||||
ShowViewCube="True" ShowCoordinateSystem="True"
|
||||
ShowFrameRate="False" ZoomExtentsWhenLoaded="True"
|
||||
@@ -418,7 +418,8 @@
|
||||
<h:DefaultLights />
|
||||
</h:HelixViewport3D>
|
||||
|
||||
<!-- Transparent gizmo overlay above the main viewport. -->
|
||||
<!-- 操作器疊圖層:透明 Viewport3D 疊在主視窗上、永遠最上層。
|
||||
只在操作器開啟時 IsHitTestVisible(否則空白處滑鼠才會穿透回主視窗) -->
|
||||
<Viewport3D x:Name="gizmoOverlay" Grid.Column="2"
|
||||
IsHitTestVisible="{Binding GizmoEnabled}" />
|
||||
|
||||
@@ -426,7 +427,7 @@
|
||||
VerticalAlignment="Stretch" Background="#FFD8D8D8"
|
||||
ToolTip="{loc:Loc Ui.Main.DragResizePanel}" />
|
||||
|
||||
<!-- Measurement results -->
|
||||
<!-- ─── 量測結果面板 ─── -->
|
||||
<GroupBox Grid.Column="4" Header="{loc:Loc Ui.Main.Measurements}" Margin="4">
|
||||
<ListBox ItemsSource="{Binding Measurements}" HorizontalContentAlignment="Stretch"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
|
||||
|
||||
@@ -18,7 +18,7 @@ public partial class MainWindow : Window
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
// Read the version from the project at runtime.
|
||||
// 版號單一來源 = csproj <Version>;標題執行時讀,避免寫死
|
||||
UpdateTitle();
|
||||
DataContext = _vm;
|
||||
_vm.Attach(viewport);
|
||||
@@ -26,7 +26,7 @@ public partial class MainWindow : Window
|
||||
Loaded += MainWindow_Loaded;
|
||||
Closing += MainWindow_Closing;
|
||||
|
||||
// Restore window settings here and units/recent files in the view model.
|
||||
// 使用者設定:視窗位置大小(View 職責)+ 單位/MRU(VM 職責)
|
||||
_settings = SettingsService.Load();
|
||||
ApplyWindowSettings();
|
||||
_vm.LoadSettings(_settings);
|
||||
@@ -38,7 +38,7 @@ public partial class MainWindow : Window
|
||||
_vm.DisposeLocalization();
|
||||
};
|
||||
|
||||
// The gizmo overlay needs handled mouse-up events from the manipulator.
|
||||
// Gizmo 操作器放在疊圖層;manipulator 會把 MouseUp 標 handled,需 handledEventsToo 才收得到
|
||||
gizmoOverlay.AddHandler(MouseLeftButtonUpEvent,
|
||||
new MouseButtonEventHandler((_, _) => _vm.OnGizmoMouseUp()), handledEventsToo: true);
|
||||
}
|
||||
@@ -58,7 +58,7 @@ public partial class MainWindow : Window
|
||||
Width = _settings.WindowWidth;
|
||||
Height = _settings.WindowHeight;
|
||||
}
|
||||
// Restore the position only when it is within the current virtual desktop.
|
||||
// 位置要落在目前虛擬桌面內才還原(避免拔掉外接螢幕後視窗開在螢幕外)
|
||||
if (!double.IsNaN(_settings.WindowLeft) && !double.IsNaN(_settings.WindowTop) &&
|
||||
_settings.WindowLeft >= SystemParameters.VirtualScreenLeft - 50 &&
|
||||
_settings.WindowTop >= SystemParameters.VirtualScreenTop - 50 &&
|
||||
@@ -87,7 +87,7 @@ public partial class MainWindow : Window
|
||||
SettingsService.Save(_settings);
|
||||
}
|
||||
|
||||
/// <summary>Open files passed on the command line: STPViewer.exe a.stp b.stl …</summary>
|
||||
/// <summary>支援命令列帶檔開啟:STPViewer.exe a.stp b.stl …</summary>
|
||||
private async void MainWindow_Loaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var files = System.Environment.GetCommandLineArgs().Skip(1)
|
||||
@@ -96,7 +96,7 @@ public partial class MainWindow : Window
|
||||
await _vm.ImportFilesAsync(files);
|
||||
}
|
||||
|
||||
/// <summary>Handle Esc and measurement shortcuts without intercepting text inputs.</summary>
|
||||
/// <summary>Esc / 量測模式快速鍵(焦點在輸入框時不攔截,樹搜尋、剖面數值可正常打字)</summary>
|
||||
private void Window_PreviewKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (Keyboard.FocusedElement is TextBoxBase or ComboBox) return;
|
||||
@@ -111,7 +111,7 @@ public partial class MainWindow : Window
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
/// <summary>Build the recent-files menu from the view model's current list.</summary>
|
||||
/// <summary>最近檔案下拉(▾):即時從 VM 的 MRU 清單組選單</summary>
|
||||
private void Recent_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var menu = new ContextMenu
|
||||
@@ -145,14 +145,14 @@ public partial class MainWindow : Window
|
||||
|
||||
private void Viewport_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
// Shift+left-click pans the view.
|
||||
// Shift+左鍵 = 平移(PanGesture2),不觸發量測/拖曳
|
||||
if (Keyboard.Modifiers.HasFlag(ModifierKeys.Shift)) return;
|
||||
|
||||
if (_vm.CurrentMode == STPViewer.Models.MeasureMode.Drag)
|
||||
{
|
||||
if (_vm.OnDragStart(e.GetPosition(viewport.Viewport)))
|
||||
{
|
||||
viewport.CaptureMouse(); // Keep receiving move/up events outside the window.
|
||||
viewport.CaptureMouse(); // 拖出視窗外也持續收到 Move/Up
|
||||
e.Handled = true;
|
||||
}
|
||||
return;
|
||||
@@ -173,7 +173,7 @@ public partial class MainWindow : Window
|
||||
}
|
||||
|
||||
private void Viewport_LostMouseCapture(object sender, MouseEventArgs e) =>
|
||||
_vm.OnDragEnd(); // Commit the current position if capture is interrupted.
|
||||
_vm.OnDragEnd(); // 捕捉意外中斷(Alt+Tab 等)→ 以目前位置定格,不留懸空 Transform
|
||||
|
||||
private void Window_DragOver(object sender, DragEventArgs e)
|
||||
{
|
||||
|
||||
@@ -4,13 +4,13 @@ using STPViewer.ViewModels;
|
||||
|
||||
namespace STPViewer.Models;
|
||||
|
||||
/// <summary>Maps a rendered GeometryModel3D back to a B-rep face.</summary>
|
||||
/// <summary>渲染物件(GeometryModel3D)反查 B-rep Face 的對照資料</summary>
|
||||
public class FaceInfo
|
||||
{
|
||||
/// <summary>B-rep face, or null for mesh-only sources such as STL.</summary>
|
||||
/// <summary>B-rep 面;STL 等純網格來源為 null(僅支援點/距離/面距量測)</summary>
|
||||
public Face? BrepFace { get; init; }
|
||||
|
||||
/// <summary>Uncut mesh used for area and section restoration; replaced after translation.</summary>
|
||||
/// <summary>原始(未剖切)三角網格 — 面積計算與剖面還原用;零件平移後會換成新 mesh</summary>
|
||||
public required MeshGeometry3D Mesh { get; set; }
|
||||
|
||||
public required ModelNodeViewModel Owner { get; init; }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace STPViewer.Models;
|
||||
|
||||
/// <summary>Measurement modes; None is the browsing mode.</summary>
|
||||
/// <summary>量測模式(None = 純瀏覽)</summary>
|
||||
public enum MeasureMode
|
||||
{
|
||||
None,
|
||||
@@ -9,11 +9,11 @@ public enum MeasureMode
|
||||
Edge,
|
||||
Face,
|
||||
Circle,
|
||||
Angle, // Angle between two faces or edges.
|
||||
FaceDistance, // Shortest face-to-face distance (mesh approximation).
|
||||
Align, // Translate a part so the first point meets the second.
|
||||
Align3, // Rotate and translate from three source and target points.
|
||||
Drag, // Move a part in the screen plane; commit on release.
|
||||
Interference, // Result type rather than a picking mode.
|
||||
Volume, // Volume/centroid result triggered from the tree menu.
|
||||
Angle, // 兩面 / 兩邊夾角
|
||||
FaceDistance, // 面到面最短距離(網格近似)
|
||||
Align, // 兩點對齊:平移零件使點 1 貼到點 2
|
||||
Align3, // 三點對齊:旋轉+平移(來源 3 點 → 目標 3 點)
|
||||
Drag, // 拖曳模式:左鍵按住零件沿螢幕平面拖動(放開才烘進 B-rep)
|
||||
Interference, // 干涉檢查結果(非點擊模式,僅作為結果類型)
|
||||
Volume, // 體積/質心(樹右鍵選單觸發,非點擊模式,僅作為結果類型)
|
||||
}
|
||||
|
||||
@@ -7,23 +7,23 @@ using HelixToolkit.Wpf;
|
||||
namespace STPViewer.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A measurement stored in millimeters. Its title, details, and 3D labels
|
||||
/// update when the display unit changes.
|
||||
/// 一筆量測結果。內部以 mm 儲存原始值,Title/Detail 依目前單位即時產生;
|
||||
/// 切換單位時 3D 標籤(DynamicLabels)也同步改字。
|
||||
/// </summary>
|
||||
public class MeasurementResult : ObservableObject
|
||||
{
|
||||
public MeasureMode Kind { get; init; }
|
||||
|
||||
/// <summary>Create the list title for a display unit.</summary>
|
||||
/// <summary>依單位產生標題(清單粗體列)</summary>
|
||||
public required Func<UnitSystem, string> TitleFor { get; init; }
|
||||
|
||||
/// <summary>Create the detailed text for a display unit.</summary>
|
||||
/// <summary>依單位產生多行明細</summary>
|
||||
public required Func<UnitSystem, string> DetailFor { get; init; }
|
||||
|
||||
/// <summary>View markers removed with this measurement.</summary>
|
||||
/// <summary>視圖中的標記(刪除量測時一併移除)</summary>
|
||||
public List<Visual3D> Overlays { get; } = new();
|
||||
|
||||
/// <summary>3D labels whose text changes with the display unit.</summary>
|
||||
/// <summary>單位相關的 3D 文字標籤(切換單位時更新 Text)</summary>
|
||||
public List<(BillboardTextVisual3D Label, Func<UnitSystem, string> TextFor)> DynamicLabels { get; } = new();
|
||||
|
||||
private UnitSystem _unit = UnitSystem.Millimeter;
|
||||
|
||||
@@ -8,28 +8,28 @@ public enum UnitSystem
|
||||
Inch,
|
||||
}
|
||||
|
||||
/// <summary>Format measurements stored in millimeters for the selected display unit.</summary>
|
||||
/// <summary>量測值格式化(內部一律 mm,顯示時換算)</summary>
|
||||
public static class Units
|
||||
{
|
||||
private const double MmPerInch = 25.4;
|
||||
|
||||
/// <summary>Length.</summary>
|
||||
/// <summary>長度</summary>
|
||||
public static string L(double mm, UnitSystem u) =>
|
||||
u == UnitSystem.Millimeter ? $"{mm:F3} mm" : $"{mm / MmPerInch:F4} in";
|
||||
|
||||
/// <summary>Area.</summary>
|
||||
/// <summary>面積</summary>
|
||||
public static string A(double mm2, UnitSystem u) =>
|
||||
u == UnitSystem.Millimeter ? $"{mm2:N3} mm²" : $"{mm2 / (MmPerInch * MmPerInch):N4} in²";
|
||||
|
||||
/// <summary>Volume.</summary>
|
||||
/// <summary>體積</summary>
|
||||
public static string V(double mm3, UnitSystem u) =>
|
||||
u == UnitSystem.Millimeter ? $"{mm3:N2} mm³" : $"{mm3 / (MmPerInch * MmPerInch * MmPerInch):N4} in³";
|
||||
|
||||
/// <summary>Coordinate component without a unit suffix.</summary>
|
||||
/// <summary>座標分量(無單位字尾)</summary>
|
||||
public static string C(double mm, UnitSystem u) =>
|
||||
u == UnitSystem.Millimeter ? $"{mm:F3}" : $"{mm / MmPerInch:F4}";
|
||||
|
||||
/// <summary>Coordinate point (x, y, z).</summary>
|
||||
/// <summary>座標點 (x, y, z)</summary>
|
||||
public static string P(Point3D p, UnitSystem u) =>
|
||||
$"({C(p.X, u)}, {C(p.Y, u)}, {C(p.Z, u)})";
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWPF>true</UseWPF>
|
||||
<Version>0.7.1</Version> <!-- Shared by the window title and publish output. -->
|
||||
<Version>0.8.0</Version> <!-- Shared by the window title and publish output. -->
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -6,7 +6,8 @@ using System.Windows.Media.Media3D;
|
||||
namespace STPViewer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Interference result: Segments holds intersection lines, or GapA/GapB the approximate closest points.
|
||||
/// 干涉檢查結果:Intersects=true 時 Segments 為干涉交線;
|
||||
/// false 時 GapA/GapB 為最近點對(網格近似)。
|
||||
/// </summary>
|
||||
public record InterferenceResult(
|
||||
bool Intersects,
|
||||
@@ -17,8 +18,10 @@ public record InterferenceResult(
|
||||
double GapDistance);
|
||||
|
||||
/// <summary>
|
||||
/// Checks intersections between two triangle mesh sets using a uniform spatial grid.
|
||||
/// Coplanar contact without penetration is excluded.
|
||||
/// 兩組三角網格的干涉檢查:
|
||||
/// 三角形-三角形相交(區間法,回傳交線段)+ 均勻網格空間加速。
|
||||
/// 共面接觸(無穿透)不會被判為干涉(近似限制)。
|
||||
/// 設計為在背景執行緒呼叫。
|
||||
/// </summary>
|
||||
public static class InterferenceService
|
||||
{
|
||||
@@ -60,7 +63,7 @@ public static class InterferenceService
|
||||
return new InterferenceResult(false, 0, segments, ga, gb, d);
|
||||
}
|
||||
|
||||
// Spatial data
|
||||
// ─── 資料結構 ────────────────────────────────────────────────
|
||||
|
||||
private readonly struct Triangle
|
||||
{
|
||||
@@ -97,7 +100,7 @@ public static class InterferenceService
|
||||
a.Min.Y <= b.Max.Y && a.Max.Y >= b.Min.Y &&
|
||||
a.Min.Z <= b.Max.Z && a.Max.Z >= b.Min.Z;
|
||||
|
||||
/// <summary>Uniform grid from cells to triangle indices.</summary>
|
||||
/// <summary>均勻網格:cell → 三角形索引</summary>
|
||||
private sealed class TriGrid
|
||||
{
|
||||
private readonly Dictionary<(int, int, int), List<int>> _cells = new();
|
||||
@@ -105,7 +108,7 @@ public static class InterferenceService
|
||||
|
||||
public TriGrid(Triangle[] tris)
|
||||
{
|
||||
// Size cells from the scene diagonal, with bounds for extreme sizes.
|
||||
// cell 大小:整體對角線 / 64(夾限避免極端值)
|
||||
Point3D min = tris[0].Min, max = tris[0].Max;
|
||||
foreach (Triangle t in tris)
|
||||
{
|
||||
@@ -143,7 +146,7 @@ public static class InterferenceService
|
||||
}
|
||||
}
|
||||
|
||||
// Triangle intersection
|
||||
// ─── 三角形相交(區間法,回傳交線段)────────────────────────
|
||||
|
||||
private static bool TriTriIntersect(in Triangle ta, in Triangle tb, out Point3D s0, out Point3D s1)
|
||||
{
|
||||
@@ -164,12 +167,12 @@ public static class InterferenceService
|
||||
if ((db0 > 0 && db1 > 0 && db2 > 0) || (db0 < 0 && db1 < 0 && db2 < 0)) return false;
|
||||
|
||||
Vector3D dir = Vector3D.CrossProduct(n1, n2);
|
||||
if (dir.LengthSquared < 1e-24) return false; // Coplanar contact.
|
||||
if (dir.LengthSquared < 1e-24) return false; // 共面:不視為穿透(近似)
|
||||
|
||||
if (!PlaneCrossSegment(ta, da0, da1, da2, out Point3D p0, out Point3D p1)) return false;
|
||||
if (!PlaneCrossSegment(tb, db0, db1, db2, out Point3D q0, out Point3D q1)) return false;
|
||||
|
||||
// Project onto the intersection direction and overlap the intervals.
|
||||
// 投影到交線方向取重疊區間
|
||||
double tp0 = Vector3D.DotProduct(dir, (Vector3D)p0);
|
||||
double tp1 = Vector3D.DotProduct(dir, (Vector3D)p1);
|
||||
double tq0 = Vector3D.DotProduct(dir, (Vector3D)q0);
|
||||
@@ -179,14 +182,14 @@ public static class InterferenceService
|
||||
|
||||
double lo = Math.Max(tp0, tq0);
|
||||
double hi = Math.Min(tp1, tq1);
|
||||
if (lo >= hi) return false;
|
||||
if (lo >= hi) return false; // 無重疊(或僅點接觸)
|
||||
|
||||
s0 = LerpOnSegment(p0, p1, tp0, tp1, lo);
|
||||
s1 = LerpOnSegment(p0, p1, tp0, tp1, hi);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Finds the triangle-plane intersection segment from signed vertex distances.</summary>
|
||||
/// <summary>三角形與平面(頂點符號 d0..d2 已算好)的交線段(兩個交點)</summary>
|
||||
private static bool PlaneCrossSegment(in Triangle t, double d0, double d1, double d2,
|
||||
out Point3D p0, out Point3D p1)
|
||||
{
|
||||
@@ -205,8 +208,8 @@ public static class InterferenceService
|
||||
Span<Point3D> pts, ref int n)
|
||||
{
|
||||
if (n >= 3) return;
|
||||
if (da == 0 && db == 0) return;
|
||||
if (da == 0) { pts[n++] = a; return; }
|
||||
if (da == 0 && db == 0) return; // 邊在平面上 → 共面情形,略過
|
||||
if (da == 0) { pts[n++] = a; return; } // 頂點剛好在平面上
|
||||
if (da * db < 0)
|
||||
pts[n++] = a + (b - a) * (da / (da - db));
|
||||
}
|
||||
@@ -218,11 +221,11 @@ public static class InterferenceService
|
||||
return p0 + (p1 - p0) * f;
|
||||
}
|
||||
|
||||
// Approximate gap when meshes do not intersect
|
||||
// ─── 無干涉時的近似最小間隙 ─────────────────────────────────
|
||||
|
||||
private static (Point3D a, Point3D b, double d) ApproxMinDistance(Triangle[] trisA, Triangle[] trisB)
|
||||
{
|
||||
// Sample vertices for an initial closest pair.
|
||||
// 1) 抽樣頂點對頂點求初值
|
||||
double best = double.MaxValue;
|
||||
Point3D pa = default, pb = default;
|
||||
int strideA = Math.Max(1, trisA.Length / 2000);
|
||||
@@ -234,13 +237,14 @@ public static class InterferenceService
|
||||
if (d < best) { best = d; pa = trisA[i].A; pb = trisB[j].A; }
|
||||
}
|
||||
|
||||
// Refine against opposing triangles, rejecting distant AABBs.
|
||||
// 2) 以最佳點為中心,精修:點 → 對方所有三角形(AABB 平方距離快速拒絕 + 平行分段,
|
||||
// 初值來自步驟 1 已相當接近 → 絕大多數三角形被 AABB 直接刷掉)
|
||||
(pb, best) = ClosestOnTriangles(pa, trisB, pb, best);
|
||||
(pa, best) = ClosestOnTriangles(pb, trisA, pa, best);
|
||||
return (pa, pb, best);
|
||||
}
|
||||
|
||||
/// <summary>Refines the closest point from an initial distance.</summary>
|
||||
/// <summary>點 p 到三角形集的最近點(帶初值;回傳更新後的最近點與距離)</summary>
|
||||
private static (Point3D pt, double d) ClosestOnTriangles(Point3D p, Triangle[] tris, Point3D seedPt, double seedD)
|
||||
{
|
||||
object gate = new();
|
||||
@@ -252,7 +256,7 @@ public static class InterferenceService
|
||||
(i, _, local) =>
|
||||
{
|
||||
Triangle t = tris[i];
|
||||
// Reject triangles whose AABB cannot improve the current distance.
|
||||
// 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));
|
||||
@@ -272,7 +276,7 @@ public static class InterferenceService
|
||||
return (bestPt, bestD);
|
||||
}
|
||||
|
||||
/// <summary>Closest point on a triangle, following Ericson's construction.</summary>
|
||||
/// <summary>點到三角形最近點(Ericson, Real-Time Collision Detection)</summary>
|
||||
internal static Point3D ClosestPointOnTriangle(Point3D p, Point3D a, Point3D b, Point3D c)
|
||||
{
|
||||
Vector3D ab = b - a, ac = c - a, ap = p - a;
|
||||
|
||||
@@ -10,13 +10,13 @@ using static STPViewer.Services.LocalizationService;
|
||||
|
||||
namespace STPViewer.Services;
|
||||
|
||||
/// <summary>One direction selected for angle measurement.</summary>
|
||||
/// <summary>角度量測的單次拾取:邊方向或面法向量</summary>
|
||||
public record DirectionPick(Point3D At, Vector3D Direction, string Desc);
|
||||
|
||||
/// <summary>
|
||||
/// Computes measurements and their viewport overlays.
|
||||
/// B-rep provides exact edge lengths and radii; mesh values are approximate.
|
||||
/// Text is deferred so unit and language changes update existing results.
|
||||
/// 點/距離/邊/面/圓/角度/面距 量測幾何計算 + 視圖 overlay 產生。
|
||||
/// 邊長、圓半徑以 B-rep 精確值為準;面積與面距用三角網格近似。
|
||||
/// 所有文字以 Func<UnitSystem,string> 延後產生,支援 mm/inch 即時切換。
|
||||
/// </summary>
|
||||
public class MeasurementService
|
||||
{
|
||||
@@ -30,11 +30,13 @@ public class MeasurementService
|
||||
private static GeoPoint ToGeo(Point3D p) => new(p.X, p.Y, p.Z);
|
||||
private static Point3D ToP3(GeoPoint p) => new(p.x, p.y, p.z);
|
||||
|
||||
// Picking
|
||||
// ─── 拾取輔助 ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Snaps to the nearest vertex or circular edge within tolerance.
|
||||
/// A circular edge snaps to its center for hole pitch measurements.
|
||||
/// 命中點吸附:點到圓形邊附近(容差內)→ 吸附**圓心**(量兩孔 pitch 的關鍵,v0.5.0);
|
||||
/// 否則吸附最近的 B-rep 頂點(容差內);都沒有 → 回傳原始表面點。
|
||||
/// 圓心與頂點都在容差內時,比「命中點到圓形邊」與「命中點到頂點」誰近(圓弧端點常與圓重疊,
|
||||
/// 點在圓邊上時圓心優先勝出才符合直覺)。
|
||||
/// </summary>
|
||||
public Point3D Snap(FaceInfo fi, Point3D hit, double tolerance)
|
||||
{
|
||||
@@ -52,7 +54,7 @@ public class MeasurementService
|
||||
}
|
||||
catch { }
|
||||
|
||||
// Compare distance to the circle edge before selecting its center.
|
||||
// 圓形邊:以「命中點到邊曲線」的距離參與比較,勝出時吸附到圓心
|
||||
try
|
||||
{
|
||||
foreach (Edge e in fi.BrepFace.AllEdges)
|
||||
@@ -71,13 +73,14 @@ public class MeasurementService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uses a nearby straight edge for direction, otherwise a face normal.
|
||||
/// 角度量測拾取:命中點附近有直線邊(容差內)→ 邊方向;
|
||||
/// 否則取面法向量(B-rep 精確;無 B-rep 時用網格法向量)。
|
||||
/// </summary>
|
||||
public DirectionPick PickDirection(FaceInfo fi, Point3D hit, Vector3D meshNormal, double tolerance)
|
||||
{
|
||||
if (fi.BrepFace is not null)
|
||||
{
|
||||
// Prefer a nearby straight edge.
|
||||
// 1) 近距離直線邊優先
|
||||
try
|
||||
{
|
||||
foreach (Edge e in fi.BrepFace.AllEdges)
|
||||
@@ -95,7 +98,7 @@ public class MeasurementService
|
||||
}
|
||||
catch { }
|
||||
|
||||
// Use the B-rep face normal when available.
|
||||
// 2) 面法向量(精確)
|
||||
try
|
||||
{
|
||||
GeoPoint2D uv = fi.BrepFace.Surface.PositionOf(ToGeo(hit));
|
||||
@@ -110,7 +113,7 @@ public class MeasurementService
|
||||
return new DirectionPick(hit, mn, "Runtime.DirectionMeshFace");
|
||||
}
|
||||
|
||||
// Point and distance
|
||||
// ─── 點 / 距離 ───────────────────────────────────────────────
|
||||
|
||||
public MeasurementResult MeasurePoint(Point3D p, string label, double markerR)
|
||||
{
|
||||
@@ -146,9 +149,9 @@ public class MeasurementService
|
||||
return r;
|
||||
}
|
||||
|
||||
// Edge and circle
|
||||
// ─── 邊 / 圓 ─────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Measures the nearest edge, or the nearest circular edge when circlesOnly is true.</summary>
|
||||
/// <summary>量測命中面上最近的邊。circlesOnly=true 時只找圓形邊。找不到回傳 null。</summary>
|
||||
public MeasurementResult? MeasureEdge(FaceInfo fi, Point3D hit, string label, double markerR, bool circlesOnly)
|
||||
{
|
||||
if (fi.BrepFace is null) return null;
|
||||
@@ -242,11 +245,11 @@ public class MeasurementService
|
||||
return r;
|
||||
}
|
||||
|
||||
// Face
|
||||
// ─── 面 ──────────────────────────────────────────────────────
|
||||
|
||||
public MeasurementResult MeasureFace(FaceInfo fi, Point3D hit, string label, double markerR)
|
||||
{
|
||||
// Area is estimated from the triangle mesh.
|
||||
// 面積:三角網格加總(近似值,三角化精度內)
|
||||
double area = MeshArea(fi.Mesh);
|
||||
int triCount = fi.Mesh.TriangleIndices.Count / 3;
|
||||
Func<UnitSystem, string> surfaceDesc = fi.BrepFace is not null
|
||||
@@ -260,7 +263,7 @@ public class MeasurementService
|
||||
DetailFor = u => Format("Runtime.FaceDetail", Units.A(area, u), surfaceDesc(u), triCount),
|
||||
};
|
||||
|
||||
// Highlight the outer edges and add a label.
|
||||
// overlay:外輪廓 highlight + 標籤
|
||||
if (fi.BrepFace is not null)
|
||||
{
|
||||
try
|
||||
@@ -322,7 +325,7 @@ public class MeasurementService
|
||||
var other => other,
|
||||
};
|
||||
|
||||
// Angle
|
||||
// ─── 角度(兩面 / 兩邊夾角)─────────────────────────────────
|
||||
|
||||
public MeasurementResult MeasureAngle(DirectionPick a, DirectionPick b, string label, double markerR, double arrowLen)
|
||||
{
|
||||
@@ -357,7 +360,7 @@ public class MeasurementService
|
||||
Thickness = 2,
|
||||
};
|
||||
|
||||
// Face distance
|
||||
// ─── 面到面最短距離(網格近似)──────────────────────────────
|
||||
|
||||
public MeasurementResult MeasureFaceDistance(FaceInfo a, FaceInfo b, string label, double markerR)
|
||||
{
|
||||
@@ -381,7 +384,7 @@ public class MeasurementService
|
||||
return r;
|
||||
}
|
||||
|
||||
/// <summary>Approximates the closest points by probing vertices against triangles in both directions.</summary>
|
||||
/// <summary>兩網格最近點對:頂點→三角形雙向(頂點過多時抽樣,結果為近似值)</summary>
|
||||
private static (Point3D a, Point3D b) MeshMinDistance(MeshGeometry3D ma, MeshGeometry3D mb)
|
||||
{
|
||||
double best = double.MaxValue;
|
||||
@@ -414,18 +417,19 @@ public class MeasurementService
|
||||
return (pa, pb);
|
||||
}
|
||||
|
||||
// Volume and centroid
|
||||
// ─── 體積 / 質心(封閉網格 signed volume)────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Computes signed volume and centroid from origin-based tetrahedra.
|
||||
/// The result is reliable only for closed meshes.
|
||||
/// 網格體積與質心:各三角形與原點構成的四面體有向體積加總(divergence theorem)。
|
||||
/// 只對「封閉」網格有意義(開放殼會得到不可靠的值 → IsClosedReliable=false 供呼叫端提示)。
|
||||
/// 多個 mesh(多實體)直接加總。
|
||||
/// </summary>
|
||||
public static (double VolumeMm3, Point3D Centroid, bool Reliable) MeshVolume(
|
||||
System.Collections.Generic.IEnumerable<MeshGeometry3D> meshes)
|
||||
{
|
||||
double vol6 = 0;
|
||||
double absVol6 = 0;
|
||||
double cx = 0, cy = 0, cz = 0;
|
||||
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;
|
||||
@@ -439,14 +443,14 @@ public class MeasurementService
|
||||
+ a.Z * (b.X * c.Y - b.Y * c.X);
|
||||
vol6 += v;
|
||||
absVol6 += Math.Abs(v);
|
||||
// Tetrahedron centroid = (a + b + c) / 4.
|
||||
// 四面體(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;
|
||||
// Large signed cancellation suggests an open mesh or inconsistent normals.
|
||||
// 有向體積相對正負抵銷比例過高 → 可能是開放殼/法向不一致,數值不可靠
|
||||
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)
|
||||
@@ -454,7 +458,7 @@ public class MeasurementService
|
||||
return (volume, centroid, reliable);
|
||||
}
|
||||
|
||||
// Shares the triangle closest-point calculation with InterferenceService.
|
||||
// 點到三角形最近點:共用 InterferenceService.ClosestPointOnTriangle(v0.4.0 去重複)
|
||||
|
||||
// ─── overlay helpers ─────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -4,14 +4,15 @@ using System.Windows.Media.Media3D;
|
||||
namespace STPViewer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Rigid transformations for three-point alignment and rotation.
|
||||
/// <see cref="ToModOp"/> converts WPF <see cref="Matrix3D"/> to CADability's matrix convention.
|
||||
/// 三點對齊 / 旋轉的剛體變換數學。
|
||||
/// 同一個變換需要兩種表示:WPF <see cref="Matrix3D"/>(列向量約定,給網格/邊線)與
|
||||
/// CADability <see cref="CADability.ModOp"/>(行向量約定,給 B-rep Modify)— 用 <see cref="ToModOp"/> 轉換。
|
||||
/// </summary>
|
||||
public static class RigidAlign
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes a rigid transform from three source points to three target points.
|
||||
/// Returns false for collinear or degenerate points.
|
||||
/// 來源 3 點 → 目標 3 點 的剛體變換(不縮放):
|
||||
/// p1→q1 精確貼合、p1p2 方向對齊 q1q2、三點平面對齊。共線/退化回傳 false。
|
||||
/// </summary>
|
||||
public static bool TryRigidTransform(
|
||||
Point3D p1, Point3D p2, Point3D p3,
|
||||
@@ -21,7 +22,7 @@ public static class RigidAlign
|
||||
if (!TryFrame(p1, p2, p3, out Vector3D xp, out Vector3D yp, out Vector3D zp)) return false;
|
||||
if (!TryFrame(q1, q2, q3, out Vector3D xq, out Vector3D yq, out Vector3D zq)) return false;
|
||||
|
||||
// R = Bq · Bpᵀ, transposed for Matrix3D's convention.
|
||||
// R = Bq · Bpᵀ(行向量約定);WPF Matrix3D 為列向量約定 → 轉置擺放
|
||||
double R(int r, int c)
|
||||
{
|
||||
double xpc = c == 0 ? xp.X : c == 1 ? xp.Y : xp.Z;
|
||||
@@ -46,7 +47,7 @@ public static class RigidAlign
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Converts a WPF matrix to a CADability ModOp.</summary>
|
||||
/// <summary>WPF Matrix3D(列向量約定)→ CADability ModOp(行向量約定,3×4)</summary>
|
||||
public static CADability.ModOp ToModOp(Matrix3D m) => new(new double[3, 4]
|
||||
{
|
||||
{ m.M11, m.M21, m.M31, m.OffsetX },
|
||||
@@ -57,7 +58,7 @@ public static class RigidAlign
|
||||
public static bool Collinear(Point3D a, Point3D b, Point3D c) =>
|
||||
Vector3D.CrossProduct(b - a, c - a).LengthSquared < 1e-12;
|
||||
|
||||
/// <summary>Builds an orthogonal frame from three points; returns false for collinear points.</summary>
|
||||
/// <summary>三點 → 正交座標架(x=1→2 方向、z=平面法向、y=z×x);共線回傳 false</summary>
|
||||
private static bool TryFrame(Point3D a, Point3D b, Point3D c,
|
||||
out Vector3D x, out Vector3D y, out Vector3D z)
|
||||
{
|
||||
|
||||
@@ -4,8 +4,8 @@ using System.Windows.Media.Media3D;
|
||||
namespace STPViewer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Clips meshes and line segments to the half-space dot(p - p0, n) ≤ 0.
|
||||
/// The original geometry remains available for restoration.
|
||||
/// 剖面:把三角網格 / 線段集用半空間裁切(保留 dot(p - p0, n) ≤ 0 的一側)。
|
||||
/// WPF 3D 沒有 GPU clip plane,採 CPU 裁切後置換 Geometry(原始 mesh 保留可還原)。
|
||||
/// </summary>
|
||||
public static class SectionService
|
||||
{
|
||||
@@ -23,7 +23,7 @@ public static class SectionService
|
||||
Point3DCollection pos = src.Positions;
|
||||
Int32Collection idx = src.TriangleIndices;
|
||||
|
||||
// Cache signed distances for each vertex.
|
||||
// 頂點距離快取
|
||||
var dist = new double[pos.Count];
|
||||
for (int i = 0; i < pos.Count; i++)
|
||||
dist[i] = Vector3D.DotProduct(pos[i] - planePoint, normal);
|
||||
@@ -47,7 +47,7 @@ public static class SectionService
|
||||
continue;
|
||||
}
|
||||
|
||||
// Clip the polygon and triangulate it as a fan.
|
||||
// 部分在保留側:Sutherland-Hodgman 裁出 3 或 4 邊形,扇形分割
|
||||
int n = 0;
|
||||
for (int k = 0; k < 3; k++)
|
||||
{
|
||||
@@ -78,7 +78,7 @@ public static class SectionService
|
||||
idx.Add(i); idx.Add(i + 1); idx.Add(i + 2);
|
||||
}
|
||||
|
||||
/// <summary>Clips paired line segment endpoints.</summary>
|
||||
/// <summary>裁切線段集(成對端點)</summary>
|
||||
public static Point3DCollection ClipSegments(Point3DCollection segments, Point3D planePoint, Vector3D normal)
|
||||
{
|
||||
var result = new Point3DCollection();
|
||||
|
||||
@@ -5,7 +5,7 @@ using System.Text.Json;
|
||||
|
||||
namespace STPViewer.Services;
|
||||
|
||||
/// <summary>User settings stored in %LOCALAPPDATA%\STPViewer\settings.json.</summary>
|
||||
/// <summary>使用者設定(%LOCALAPPDATA%\STPViewer\settings.json)</summary>
|
||||
public class AppSettings
|
||||
{
|
||||
public double WindowWidth { get; set; } = double.NaN;
|
||||
@@ -17,11 +17,11 @@ public class AppSettings
|
||||
public string? Language { get; set; }
|
||||
public List<string> RecentFiles { get; set; } = new();
|
||||
|
||||
/// <summary>Visible quick bar keys; null uses registry defaults.</summary>
|
||||
/// <summary>快速工具列顯示的按鈕 key 清單;null = 用預設(QuickBarViewModel.Registry)</summary>
|
||||
public List<string>? QuickBarKeys { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Loads defaults for missing or invalid settings and ignores save failures.</summary>
|
||||
/// <summary>設定讀寫:壞檔/缺檔一律回預設值,儲存失敗靜默(設定不值得讓程式閃退)</summary>
|
||||
public static class SettingsService
|
||||
{
|
||||
public const int MaxRecentFiles = 10;
|
||||
|
||||
@@ -6,15 +6,15 @@ using System.Windows.Media;
|
||||
using System.Windows.Media.Media3D;
|
||||
using CADability;
|
||||
using CADability.GeoObject;
|
||||
using IOPath = System.IO.Path;
|
||||
using IOPath = System.IO.Path; // CADability.GeoObject.Path(曲線)撞名
|
||||
using static STPViewer.Services.LocalizationService;
|
||||
|
||||
namespace STPViewer.Services;
|
||||
|
||||
/// <summary>A frozen triangle mesh for one B-rep face; BrepFace is null for STL.</summary>
|
||||
/// <summary>單一 B-rep Face 的三角網格(mesh 已 Freeze,可跨執行緒);STL 來源 BrepFace 為 null</summary>
|
||||
public record ImportedFace(Face? BrepFace, MeshGeometry3D Mesh);
|
||||
|
||||
/// <summary>An imported assembly group or geometry leaf.</summary>
|
||||
/// <summary>匯入後的階層節點:group(裝配)或 leaf(零件幾何)</summary>
|
||||
public class ImportedNode
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
@@ -22,15 +22,15 @@ public class ImportedNode
|
||||
public int NameNumber { get; set; }
|
||||
public List<ImportedNode> Children { get; } = new();
|
||||
|
||||
// Leaf geometry
|
||||
// ── leaf 幾何 ──
|
||||
public List<ImportedFace> Faces { get; } = new();
|
||||
public Point3DCollection? EdgeSegments { get; set; } // Frozen paired endpoints.
|
||||
public bool HasBrep { get; set; } = true; // False for STL and curves.
|
||||
public Point3DCollection? EdgeSegments { get; set; } // 輪廓線(成對端點,已 Freeze)
|
||||
public bool HasBrep { get; set; } = true; // STL / 純曲線 = false
|
||||
|
||||
/// <summary>Source B-rep objects used for whole-part transforms.</summary>
|
||||
/// <summary>來源 B-rep 物件(Solid/Shell/Face/曲線)— 零件平移時整體 Modify 用</summary>
|
||||
public List<CADability.GeoObject.IGeoObject> SourceGeos { get; } = new();
|
||||
|
||||
// Group totals are calculated by Aggregate.
|
||||
// ── 統計(group 節點由 Aggregate 彙總)──
|
||||
public int SolidCount { get; set; }
|
||||
public int FaceCount { get; set; }
|
||||
public int TriangleCount { get; set; }
|
||||
@@ -42,12 +42,12 @@ public class ImportedNode
|
||||
public record ImportedFileData(string FilePath, ImportedNode Root);
|
||||
|
||||
/// <summary>
|
||||
/// Imports STEP, STL and DXF files on a background thread.
|
||||
/// Returned WPF Freezables are frozen.
|
||||
/// CAD 檔匯入(STEP / STL / DXF)。STEP 以 HierarchyToBlocks 還原裝配樹。
|
||||
/// 設計為在背景執行緒呼叫;回傳的 Freezable 全部已 Freeze。
|
||||
/// </summary>
|
||||
public class StepImportService
|
||||
{
|
||||
/// <summary>Import progress for the status bar.</summary>
|
||||
/// <summary>匯入階段回報(耗時分解),UI 可顯示於狀態列;null 則靜默</summary>
|
||||
public Action<string>? Progress { get; set; }
|
||||
public Action<Func<string>>? ProgressTextFor { get; set; }
|
||||
|
||||
@@ -57,14 +57,15 @@ public class StepImportService
|
||||
ProgressTextFor?.Invoke(textFor);
|
||||
}
|
||||
|
||||
private long _triTicks, _edgeTicks; // Accumulated tessellation and edge sampling ticks.
|
||||
private long _triTicks, _edgeTicks; // 三角化 / 邊取樣 累計耗時(TimeSpan ticks,避免逐面 ms 截斷歸零)
|
||||
private double TriMs => new TimeSpan(_triTicks).TotalMilliseconds;
|
||||
private double EdgeMs => new TimeSpan(_edgeTicks).TotalMilliseconds;
|
||||
|
||||
/// <summary>Geometry work queued per leaf, then run in parallel. Faces within a leaf share edges.</summary>
|
||||
/// <summary>延後的 leaf 幾何工作(三角化+邊取樣):樹走訪時收集,之後平行執行。
|
||||
/// 平行粒度 = 整個 leaf(同 leaf 的面共用 Edge,不可面級平行)。</summary>
|
||||
private readonly List<Action> _leafWork = new();
|
||||
|
||||
/// <summary>Faces to retry sequentially after parallel tessellation.</summary>
|
||||
/// <summary>平行三角化偶發失敗的面(CADability 跨 leaf 仍有少量共享狀態)→ 平行結束後循序重試</summary>
|
||||
private readonly System.Collections.Concurrent.ConcurrentBag<(ImportedNode Leaf, HashSet<Edge> EdgeSet, List<(Face F, double Prec)> Failed)> _retry = new();
|
||||
|
||||
public ImportedFileData Import(string filePath)
|
||||
@@ -97,7 +98,7 @@ public class StepImportService
|
||||
});
|
||||
_leafWork.Clear();
|
||||
|
||||
// Retry failed faces sequentially after the parallel pass.
|
||||
// 平行下偶發失敗的面:競爭已結束,循序重試一輪(FinishLeaf 也延到此時才跑)
|
||||
int retried = 0;
|
||||
foreach ((ImportedNode leaf, HashSet<Edge> edgeSet, var failed) in _retry)
|
||||
{
|
||||
@@ -136,7 +137,12 @@ public class StepImportService
|
||||
BuildChildren(root, EnumerateGeo(list));
|
||||
}
|
||||
|
||||
// STL is read directly as mesh geometry without B-rep objects.
|
||||
// ─── STL(純網格,無 B-rep)──
|
||||
// 直接解析三角形(StlMeshReader),**不經過 CADability**。CADability 的 ImportSTL 會為每個
|
||||
// 三角形建一個 B-rep Face(含 Edge/Vertex 拓樸),百萬三角形需數十分鐘且吃光記憶體
|
||||
// (實測 100k 三角形:CADability ~41 s,直接解析 ~11 ms)。STL 本就無 B-rep,讀三角形即可。
|
||||
// ⚠️ 因此 STL leaf 沒有 SourceGeos(CADability B-rep)→ 不進 STEP 匯出(本就非 STEP B-rep 幾何);
|
||||
// 剛體變換仍走網格層(TransformRoot 的 SourceGeos 迴圈空轉、mesh 照樣變換),量測走網格近似不受影響。
|
||||
|
||||
private void ImportStlFile(string path, ImportedNode root)
|
||||
{
|
||||
@@ -161,7 +167,7 @@ public class StepImportService
|
||||
NameNumber = g.NameNumber,
|
||||
HasBrep = false,
|
||||
SolidCount = 1,
|
||||
FaceCount = tris,
|
||||
FaceCount = tris, // STL 無 B-rep 面 → 以三角形數代表
|
||||
TriangleCount = tris,
|
||||
Bounds = BoundsOfMesh(mesh),
|
||||
};
|
||||
@@ -173,7 +179,7 @@ public class StepImportService
|
||||
ReportProgress(() => Format("Runtime.StlParsed", elapsed, meshCount));
|
||||
}
|
||||
|
||||
// DXF
|
||||
// ─── DXF(多為線架構;Solid/Face 走同一套,曲線取樣成線段)──
|
||||
|
||||
private void ImportDxfFile(string path, ImportedNode root)
|
||||
{
|
||||
@@ -184,7 +190,7 @@ public class StepImportService
|
||||
BuildChildren(root, EnumerateGeo(model.AllObjects));
|
||||
}
|
||||
|
||||
// Assembly traversal
|
||||
// ─── 階層走訪 ────────────────────────────────────────────────
|
||||
|
||||
private static IEnumerable<IGeoObject> EnumerateGeo(System.Collections.IEnumerable list)
|
||||
{
|
||||
@@ -201,7 +207,7 @@ public class StepImportService
|
||||
private void BuildChildren(ImportedNode parent, IEnumerable<IGeoObject> objects)
|
||||
{
|
||||
var looseFaces = new List<Face>();
|
||||
var looseCurves = new List<IGeoObject>();
|
||||
var looseCurves = new List<IGeoObject>(); // 皆為 ICurve
|
||||
|
||||
foreach (IGeoObject go in objects)
|
||||
{
|
||||
@@ -245,7 +251,7 @@ public class StepImportService
|
||||
if (failed.Count > 0) _retry.Add((leaf, edgeSet, failed));
|
||||
else FinishLeaf(leaf, edgeSet);
|
||||
});
|
||||
parent.Children.Add(leaf);
|
||||
parent.Children.Add(leaf); // 三角化延後,空 leaf 由 Prune 收掉
|
||||
}
|
||||
if (looseCurves.Count > 0)
|
||||
{
|
||||
@@ -269,7 +275,7 @@ public class StepImportService
|
||||
|
||||
private ImportedNode? BlockNode(Block block)
|
||||
{
|
||||
// Collapse a block containing one solid into a leaf.
|
||||
// Block 只有單一同名 Solid → 折疊成 leaf(STEP 常見的 product→solid 包裝)
|
||||
if (block.NumChildren == 1 && block.Child(0) is Solid s)
|
||||
{
|
||||
ImportedNode leaf = SolidNode(s);
|
||||
@@ -290,7 +296,7 @@ public class StepImportService
|
||||
BuildChildren(node, ChildrenOf(block));
|
||||
if (node.Children.Count == 0) return null;
|
||||
|
||||
// Drop a redundant group when it has one child with the same name.
|
||||
// 單鏈折疊:group 只有一個子節點且名稱相同/空白 → 去掉中間層
|
||||
if (node.Children.Count == 1)
|
||||
{
|
||||
ImportedNode only = node.Children[0];
|
||||
@@ -354,7 +360,7 @@ public class StepImportService
|
||||
return leaf;
|
||||
}
|
||||
|
||||
// Geometry processing
|
||||
// ─── 幾何處理 ────────────────────────────────────────────────
|
||||
|
||||
private static BoundingCube SafeBounds(Func<BoundingCube> get)
|
||||
{
|
||||
@@ -362,7 +368,7 @@ public class StepImportService
|
||||
catch { return new BoundingCube(); }
|
||||
}
|
||||
|
||||
/// <summary>Tessellation precision: bounding diagonal × 0.0015, clamped to [0.02, 0.5] mm.</summary>
|
||||
/// <summary>三角化精度依物件大小調整:對角線 × 0.0015,限制在 [0.02, 0.5] mm</summary>
|
||||
private static double PrecisionFor(BoundingCube bc)
|
||||
{
|
||||
double diag;
|
||||
@@ -372,7 +378,7 @@ public class StepImportService
|
||||
return Math.Clamp(diag * 0.0015, 0.02, 0.5);
|
||||
}
|
||||
|
||||
/// <returns>False when tessellation fails and the face needs a retry.</returns>
|
||||
/// <returns>false = 三角化失敗(平行下偶發,可循序重試)</returns>
|
||||
private bool AddFace(Face face, double precision, ImportedNode leaf, HashSet<Edge> edgeSet)
|
||||
{
|
||||
GeoPoint[] points; int[] indices;
|
||||
@@ -383,7 +389,7 @@ public class StepImportService
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
return false; // 個別面三角化失敗時跳過,不讓整檔失敗
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -411,7 +417,7 @@ public class StepImportService
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Completes edge sampling, counts and bounds for one leaf.</summary>
|
||||
/// <summary>leaf 收尾:邊取樣 + 統計 + 邊界</summary>
|
||||
private void FinishLeaf(ImportedNode leaf, HashSet<Edge> edgeSet)
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
@@ -448,7 +454,7 @@ public class StepImportService
|
||||
return segments;
|
||||
}
|
||||
|
||||
/// <summary>Samples curves into paired line endpoints.</summary>
|
||||
/// <summary>把曲線取樣成線段對(直線 1 段、曲線 12 段)</summary>
|
||||
private static void AppendCurveSegments(Point3DCollection segments, ICurve curve)
|
||||
{
|
||||
int n = curve is Line ? 1 : 12;
|
||||
@@ -483,7 +489,7 @@ public class StepImportService
|
||||
Math.Max(maxX - minX, 1e-6), Math.Max(maxY - minY, 1e-6), Math.Max(maxZ - minZ, 1e-6));
|
||||
}
|
||||
|
||||
/// <summary>Removes nodes left empty after tessellation.</summary>
|
||||
/// <summary>移除沒有任何幾何的節點(延後三角化全失敗的 leaf、因此變空的 group)</summary>
|
||||
private static void Prune(ImportedNode node)
|
||||
{
|
||||
for (int i = node.Children.Count - 1; i >= 0; i--)
|
||||
@@ -495,7 +501,7 @@ public class StepImportService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Aggregates counts and bounds from children.</summary>
|
||||
/// <summary>group 節點統計彙總(bottom-up)</summary>
|
||||
private static void Aggregate(ImportedNode node)
|
||||
{
|
||||
if (node.IsLeaf) return;
|
||||
|
||||
@@ -11,22 +11,27 @@ using CADability.GeoObject;
|
||||
namespace STPViewer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Mesh quality: Current uses the displayed mesh; Fine retessellates B-rep geometry.
|
||||
/// 網格精度:Current = 目前顯示網格;Fine = 由 B-rep 以較細精度重新三角化(無 B-rep 的來源維持現有網格)。
|
||||
/// 不提供「較粗」:CADability 的 GetTriangulation 對「比快取粗」的精度要求會直接回傳既有較細快取,
|
||||
/// 匯入時已三角化過 → 要求粗化實際上拿到同一份網格(實測驗證),故誠實不提供。
|
||||
/// </summary>
|
||||
public enum StlMeshQuality { Current, Fine }
|
||||
|
||||
/// <summary>
|
||||
/// Writes WPF triangle meshes as binary or ASCII STL; scale=1 uses millimeters.
|
||||
/// STL 匯出:WPF 三角網格 → binary / ASCII STL。
|
||||
/// STL 本身無單位,約定輸出 mm(scale=1);inch 交接用 scale = 1/25.4。
|
||||
/// </summary>
|
||||
public static class StlExportService
|
||||
{
|
||||
/// <summary>
|
||||
/// Tessellation factor relative to import precision; 0.15 produces a finer mesh.
|
||||
/// 三角化精度係數(相對匯入預設):Fine 較細(三角形多、曲面平滑)。
|
||||
/// 實測 CADability 三角形數對精度非單調(0.4× 重算反而比 1× 少 — 重算網格較有效率),
|
||||
/// 要明顯更細需 ≤0.15×(test.stp:1× = 30,784 → 0.1× = 42,220 三角形)。
|
||||
/// </summary>
|
||||
public static double PrecisionFactor(StlMeshQuality q) =>
|
||||
q == StlMeshQuality.Fine ? 0.15 : 1.0;
|
||||
|
||||
/// <summary>Estimated file size: 84 + 50 bytes per binary triangle, about 230 per ASCII triangle.</summary>
|
||||
/// <summary>預估檔案大小:binary = 84 + 50×n;ASCII 每三角形約 230 bytes</summary>
|
||||
public static long EstimateBytes(long triangles, bool ascii) =>
|
||||
ascii ? 20 + triangles * 230 : 84 + triangles * 50;
|
||||
|
||||
@@ -39,7 +44,8 @@ public static class StlExportService
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Writes meshes to one STL file, skipping zero-area triangles. Returns the triangle count.
|
||||
/// 把多個網格寫成一個 STL 檔。退化(零面積)三角形濾除。
|
||||
/// 回傳實際寫出的三角形數。
|
||||
/// </summary>
|
||||
public static int Write(string path, IReadOnlyList<MeshGeometry3D> meshes, bool ascii, double scale) =>
|
||||
ascii ? WriteAscii(path, meshes, scale) : WriteBinary(path, meshes, scale);
|
||||
@@ -49,11 +55,11 @@ public static class StlExportService
|
||||
using var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, 1 << 16);
|
||||
using var bw = new BinaryWriter(fs);
|
||||
|
||||
// Avoid "solid" in the header; some readers would mistake it for ASCII.
|
||||
// 80-byte header(不可以 "solid" 開頭,部分讀取器會誤判為 ASCII)
|
||||
var header = new byte[80];
|
||||
Encoding.ASCII.GetBytes("STPViewer binary STL (unit: mm x scale)").CopyTo(header, 0);
|
||||
bw.Write(header);
|
||||
bw.Write(0u); // Backfilled after writing triangles.
|
||||
bw.Write(0u); // 三角形數 placeholder,寫完回頭補
|
||||
|
||||
int count = 0;
|
||||
foreach (MeshGeometry3D mesh in meshes)
|
||||
@@ -119,7 +125,7 @@ public static class StlExportService
|
||||
sw.WriteLine(string.Format(inv, " vertex {0:e6} {1:e6} {2:e6}",
|
||||
p.X * scale, p.Y * scale, p.Z * scale));
|
||||
|
||||
/// <returns>False for a zero-area triangle.</returns>
|
||||
/// <returns>false = 退化三角形(面積 ≈ 0)</returns>
|
||||
private static bool TryNormal(Point3D a, Point3D b, Point3D c, out Vector3D n)
|
||||
{
|
||||
n = Vector3D.CrossProduct(b - a, c - a);
|
||||
@@ -129,11 +135,12 @@ public static class StlExportService
|
||||
return true;
|
||||
}
|
||||
|
||||
// B-rep retessellation
|
||||
// ─── B-rep 重新三角化(精細/較粗匯出用)────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Retessellates B-rep geometry into frozen meshes, one per face.
|
||||
/// Call sequentially because CADability tessellation is not thread-safe.
|
||||
/// 由 B-rep 幾何以指定精度係數重新三角化,回傳每面一個 frozen mesh。
|
||||
/// CADability 非執行緒安全 → 呼叫端維持循序(可在單一背景執行緒)。
|
||||
/// 個別面三角化失敗時跳過(與匯入一致的容錯策略)。
|
||||
/// </summary>
|
||||
public static List<MeshGeometry3D> Tessellate(IEnumerable<IGeoObject> geos, double precisionFactor)
|
||||
{
|
||||
@@ -163,7 +170,9 @@ public static class StlExportService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clamp the import precision before applying factor so Fine is finer than the cached mesh.
|
||||
/// 匯入預設精度(與 StepImportService.PrecisionFor 同款 clamp)再乘 factor —
|
||||
/// 先 clamp 再乘,保證 factor<1 時嚴格比匯入快取細(否則大殼在匯入被 0.5 上限截住,
|
||||
/// 直接乘在 clamp 前會算出比快取粗的值 → CADability 回傳快取、精細無效)。
|
||||
/// </summary>
|
||||
private static double PrecisionFor(BoundingCube bc, double factor)
|
||||
{
|
||||
|
||||
@@ -8,11 +8,14 @@ using System.Windows.Media.Media3D;
|
||||
namespace STPViewer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reads binary and ASCII STL triangles directly into WPF meshes.
|
||||
/// 直接解析 STL 三角網格(binary / ASCII)→ WPF mesh,**不經過 CADability**。
|
||||
/// CADability 的 ImportSTL 會為每個三角形建一個 B-rep Face(含 Edge/Vertex 拓樸),
|
||||
/// 大檔(百萬三角形)需數十分鐘且吃光記憶體;STL 本就無 B-rep,直接讀三角形即可。
|
||||
/// 實測 100k 三角形:CADability 約 41,000 ms,直接解析約 11 ms。
|
||||
/// </summary>
|
||||
public static class StlMeshReader
|
||||
{
|
||||
/// <summary>Mesh for one solid section; vertices remain unwelded.</summary>
|
||||
/// <summary>一個 solid 區塊的網格(頂點未焊接:每三角形 3 個獨立頂點,與 STL 原生一致)</summary>
|
||||
public record MeshGroup(string Name, Point3DCollection Positions, Int32Collection Indices,
|
||||
string? NameKey = null, int NameNumber = 0);
|
||||
|
||||
@@ -22,12 +25,13 @@ public static class StlMeshReader
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binary STL has exactly 84 + 50 × triangle count bytes; its header may also start with "solid".
|
||||
/// binary STL 判定:檔案大小必須剛好等於 84 + 50×count(count = offset 80 的 uint32)。
|
||||
/// 這是最可靠的判別法 — binary 的 80-byte header 常也以 "solid" 開頭,不能只看開頭字串。
|
||||
/// </summary>
|
||||
private static bool IsBinary(string path)
|
||||
{
|
||||
var fi = new FileInfo(path);
|
||||
if (fi.Length < 84) return false;
|
||||
if (fi.Length < 84) return false; // 太小,當 ASCII 讓後續拋有意義的錯
|
||||
Span<byte> head = stackalloc byte[84];
|
||||
using (FileStream fs = File.OpenRead(path))
|
||||
{
|
||||
@@ -55,8 +59,8 @@ public static class StlMeshReader
|
||||
int off = 84, vi = 0;
|
||||
for (uint t = 0; t < count; t++)
|
||||
{
|
||||
if (off + 50 > bytes.Length) break;
|
||||
off += 12; // WPF computes the normal.
|
||||
if (off + 50 > bytes.Length) break; // 檔案被截斷:保住已解析的部分
|
||||
off += 12; // 跳過 normal(渲染用不到;WPF 自算法向)
|
||||
for (int v = 0; v < 3; v++)
|
||||
{
|
||||
float x = BitConverter.ToSingle(span[off..]);
|
||||
@@ -72,7 +76,8 @@ public static class StlMeshReader
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ASCII STL: read vertex triples and start a new group at each solid section.
|
||||
/// ASCII STL:逐行掃 "vertex x y z",每 3 個湊成一個三角形;"solid <name>" 起新群組。
|
||||
/// ASCII 大檔罕見(同樣三角數約為 binary 的 5 倍體積),故以正確性為主。
|
||||
/// </summary>
|
||||
private static List<MeshGroup> ReadAscii(string path)
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@ using STPViewer.ViewModels;
|
||||
|
||||
namespace STPViewer;
|
||||
|
||||
/// <summary>STL export settings dialog; the view model owns the options.</summary>
|
||||
/// <summary>STL 匯出參數對話框 — 選項邏輯在 StlExportViewModel,這裡只做確認/取消</summary>
|
||||
public partial class StlExportDialog : Window
|
||||
{
|
||||
public StlExportViewModel ViewModel { get; }
|
||||
|
||||
@@ -6,22 +6,22 @@ using static STPViewer.Services.LocalizationService;
|
||||
|
||||
namespace STPViewer.ViewModels;
|
||||
|
||||
// Drag mode
|
||||
// ─── 拖曳模式(左鍵按住零件沿螢幕平面拖;放開才烘進 B-rep)──────────
|
||||
public partial class MainViewModel
|
||||
{
|
||||
// Preview with a transform, then commit to B-rep on release.
|
||||
// 拖曳模式:暫時 Transform 跟著滑鼠,放開才一次性烘進 B-rep(TranslateRoot)
|
||||
private ModelNodeViewModel? _dragRoot;
|
||||
private Point3D _dragAnchor;
|
||||
private Vector3D _dragApplied;
|
||||
private Point3D _dragAnchor; // 命中點(世界座標),拖曳平面的錨點
|
||||
private Vector3D _dragApplied; // 目前累計位移
|
||||
private TranslateTransform3D? _dragTransform;
|
||||
|
||||
/// <summary>Starts dragging a hit part; returns true when the mouse should be captured.</summary>
|
||||
/// <summary>左鍵按下:命中零件則開始拖曳(回傳 true 表示要捕捉滑鼠)</summary>
|
||||
public bool OnDragStart(Point position)
|
||||
{
|
||||
if (_viewport is null || IsBusy || CurrentMode != MeasureMode.Drag || _dragRoot is not null)
|
||||
return false;
|
||||
|
||||
// Hits can come from merged meshes or section faces.
|
||||
// 兩種渲染內容都可能在場:合併網格查 _mergedMap、逐面(剖面時)查 _faceMap
|
||||
ModelNodeViewModel? leaf = null;
|
||||
Point3D anchor = default;
|
||||
foreach (var h in _viewport.Viewport.FindHits(position))
|
||||
@@ -42,14 +42,14 @@ public partial class MainViewModel
|
||||
if (l.BodyVisual is not null)
|
||||
l.BodyVisual.Transform = _dragTransform;
|
||||
|
||||
// Hide edges while moving to avoid rebuilding them each frame.
|
||||
// 拖曳中隱藏邊線(LinesVisual3D 隨 Transform 變更逐幀重建會卡)
|
||||
_edgesSuspended = true;
|
||||
SetEdgesActive(false);
|
||||
SetStatus(() => Format("Runtime.Dragging", root.Name));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Projects pointer movement onto the camera-facing plane through the anchor.</summary>
|
||||
/// <summary>滑鼠移動:把 2D 位移投影到「過錨點、面向相機」的平面上 → 暫時 Transform</summary>
|
||||
public void OnDragMove(Point position)
|
||||
{
|
||||
if (_viewport?.Camera is not System.Windows.Media.Media3D.ProjectionCamera cam ||
|
||||
@@ -63,14 +63,15 @@ public partial class MainViewModel
|
||||
_dragTransform.OffsetZ = _dragApplied.Z;
|
||||
}
|
||||
|
||||
/// <summary>Commits the temporary transform to B-rep on release.</summary>
|
||||
/// <summary>放開:移除暫時 Transform,一次性烘進 B-rep(量測精度不受拖曳影響)</summary>
|
||||
public bool OnDragEnd()
|
||||
{
|
||||
if (_dragRoot is null) return false;
|
||||
ModelNodeViewModel root = _dragRoot;
|
||||
Vector3D delta = _dragApplied;
|
||||
|
||||
// HelixToolkit hit testing requires Identity rather than a null transform.
|
||||
// 清除暫時位移:必須用 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;
|
||||
@@ -78,11 +79,11 @@ public partial class MainViewModel
|
||||
_dragTransform = null;
|
||||
_dragApplied = default;
|
||||
_edgesSuspended = false;
|
||||
SetEdgesActive(true);
|
||||
SetEdgesActive(true); // 恢復所有檔案的邊線(被拖檔案的點隨後由 TranslateRoot 更新,期間不會渲染)
|
||||
|
||||
if (delta.LengthSquared > 1e-12)
|
||||
{
|
||||
TranslateRoot(root, delta);
|
||||
TranslateRoot(root, delta); // 烘進 B-rep + 網格 + 邊線 + 邊界(量測清空)
|
||||
SetStatus(() => Format("Runtime.Dragged", root.Name, delta.X, delta.Y, delta.Z));
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -16,7 +16,7 @@ using static STPViewer.Services.LocalizationService;
|
||||
|
||||
namespace STPViewer.ViewModels;
|
||||
|
||||
// CSV, STEP, STL, and screenshot export
|
||||
// ─── 匯出(量測 CSV / 視圖 PNG 截圖 / STEP / STL)────────────────────
|
||||
public partial class MainViewModel
|
||||
{
|
||||
[RelayCommand]
|
||||
@@ -42,14 +42,16 @@ public partial class MainViewModel
|
||||
static string Esc(string s) => $"\"{s.Replace("\"", "\"\"")}\"";
|
||||
sb.AppendLine($"{Esc(m.Title)},{Esc(Get($"Runtime.Kind{m.Kind}"))},{Esc(m.Detail.Replace("\n", " | "))}");
|
||||
}
|
||||
File.WriteAllText(dlg.FileName, sb.ToString(), new UTF8Encoding(true)); // BOM for Excel.
|
||||
File.WriteAllText(dlg.FileName, sb.ToString(), new UTF8Encoding(true)); // BOM:Excel 中文不亂碼
|
||||
int count = Measurements.Count;
|
||||
string fileName = Path.GetFileName(dlg.FileName);
|
||||
SetStatus(() => Format("Runtime.CsvExported", count, fileName));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports visible B-rep Solid and Shell objects at their current positions.
|
||||
/// 把可見檔案(目前位置,含對齊/旋轉/拖曳結果)匯出成「新的」STEP 檔 —
|
||||
/// 對齊插合後的配合結果可交接給 CAD;不動任何原始檔(唯讀原則不變)。
|
||||
/// 只含 B-rep Solid/Shell(STL 網格、DXF 線架構無法進 STEP B-rep)。
|
||||
/// </summary>
|
||||
[RelayCommand(CanExecute = nameof(IsIdle))]
|
||||
private void ExportStepFile()
|
||||
@@ -75,7 +77,7 @@ public partial class MainViewModel
|
||||
|
||||
try
|
||||
{
|
||||
// B-rep objects already contain their transformed positions.
|
||||
// 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);
|
||||
@@ -92,12 +94,13 @@ public partial class MainViewModel
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports visible meshes as STL using the selected format, scale and quality.
|
||||
/// 匯出可見檔案(目前位置)為 STL 網格檔 — 先開參數對話框(範圍/格式/單位/精度 + 三角形數與大小預估)
|
||||
/// 確認後才選路徑寫檔。精細/較粗 = 由 B-rep 重新三角化(背景執行緒);STL/DXF 來源一律用現有網格。
|
||||
/// </summary>
|
||||
[RelayCommand(CanExecute = nameof(IsIdle))]
|
||||
private async Task ExportStlFile()
|
||||
{
|
||||
// Only visible leaves with meshes can be exported.
|
||||
// 可匯出 = 可見 leaf 且有網格(DXF 純線架構無網格 → 排除)
|
||||
var groups = Roots.Where(r => r.IsVisible)
|
||||
.Select(r => (Root: r, Leaves: r.Leaves()
|
||||
.Where(l => l.IsVisible && GetMergedMesh(l) is not null).ToList()))
|
||||
@@ -113,7 +116,7 @@ public partial class MainViewModel
|
||||
.Sum(l => GetMergedMesh(l)!.TriangleIndices.Count / 3L);
|
||||
bool hasBrep = groups.SelectMany(g => g.Leaves).Any(l => l.HasBrep && l.SourceGeos.Count > 0);
|
||||
|
||||
// Collect export options.
|
||||
// 參數對話框(確認再匯出)
|
||||
var vm = new StlExportViewModel(groups.Count, triangles, hasBrep);
|
||||
var dialog = new StlExportDialog(vm) { Owner = Application.Current.MainWindow };
|
||||
if (dialog.ShowDialog() != true || vm.Scale is not double scale) return;
|
||||
@@ -128,7 +131,8 @@ public partial class MainViewModel
|
||||
};
|
||||
if (dlg.ShowDialog() != true) return;
|
||||
|
||||
// Snapshot WPF geometry on the UI thread before background export.
|
||||
// UI 執行緒先快照:GeometryModel3D/Model3DGroup 是 DispatcherObject 不可跨執行緒,
|
||||
// 背景只能碰 frozen MeshGeometry3D 與 CADability B-rep(SourceGeos)
|
||||
var snapshot = groups
|
||||
.Select(g => (g.Root.Name, Leaves: g.Leaves
|
||||
.Select(l => (Mesh: GetMergedMesh(l)!, l.HasBrep, l.SourceGeos))
|
||||
@@ -182,14 +186,16 @@ public partial class MainViewModel
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Current merged mesh for an exportable leaf.</summary>
|
||||
/// <summary>leaf 的合併網格(frozen、未剖切、目前位置)— STL 匯出的「目前顯示網格」來源</summary>
|
||||
private static MeshGeometry3D? GetMergedMesh(ModelNodeViewModel leaf) =>
|
||||
leaf.MergedContent?.Children.Count > 0
|
||||
? (leaf.MergedContent.Children[0] as GeometryModel3D)?.Geometry as MeshGeometry3D
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// Selects frozen current meshes or sequentially retessellates B-rep geometry.
|
||||
/// 收集要寫進 STL 的網格(背景執行緒;輸入是 UI 快照):
|
||||
/// 精細且有 B-rep → 重新三角化(循序,CADability 非執行緒安全);
|
||||
/// 其餘(目前網格 / STL 來源)→ 快照的合併網格(frozen,可安全跨執行緒讀取)。
|
||||
/// </summary>
|
||||
private static List<MeshGeometry3D> CollectMeshes(
|
||||
IReadOnlyList<(MeshGeometry3D Mesh, bool HasBrep, List<CADability.GeoObject.IGeoObject> SourceGeos)> leaves,
|
||||
@@ -202,7 +208,7 @@ public partial class MainViewModel
|
||||
{
|
||||
List<MeshGeometry3D> tess = StlExportService.Tessellate(sourceGeos, precisionFactor);
|
||||
if (tess.Count > 0) { meshes.AddRange(tess); continue; }
|
||||
// Fall back to the current mesh if retessellation yields nothing.
|
||||
// 重新三角化全數失敗 → 退回現有網格,不讓零件憑空消失
|
||||
}
|
||||
meshes.Add(mesh);
|
||||
}
|
||||
@@ -229,7 +235,7 @@ public partial class MainViewModel
|
||||
};
|
||||
if (dlg.ShowDialog() != true) return;
|
||||
|
||||
const double scale = 2.0;
|
||||
const double scale = 2.0; // 2x 解析度
|
||||
var rtb = new RenderTargetBitmap(
|
||||
(int)(_viewport.ActualWidth * scale), (int)(_viewport.ActualHeight * scale),
|
||||
96 * scale, 96 * scale, PixelFormats.Pbgra32);
|
||||
|
||||
@@ -8,37 +8,39 @@ using static STPViewer.Services.LocalizationService;
|
||||
|
||||
namespace STPViewer.ViewModels;
|
||||
|
||||
// Gizmo translation and rotation
|
||||
// ─── Gizmo 三軸操作器(XYZ 箭頭 + 旋轉環;每次放開滑鼠烘進 B-rep)──────
|
||||
public partial class MainViewModel
|
||||
{
|
||||
[ObservableProperty]
|
||||
private bool gizmoEnabled;
|
||||
|
||||
private readonly List<HelixToolkit.Wpf.Manipulator> _gizmoParts = new();
|
||||
private ModelVisual3D? _gizmoProxy;
|
||||
private ModelVisual3D? _gizmoProxy; // 操作器綁定的代理 visual(其 Transform = 使用者拖出的變換)
|
||||
private ModelNodeViewModel? _gizmoTarget;
|
||||
private bool _gizmoDragActive;
|
||||
private bool _gizmoBaking;
|
||||
private bool _gizmoBakePending;
|
||||
private bool _gizmoDragActive; // 拖動中(邊線已暫停)
|
||||
private bool _gizmoBaking; // 烘焙中,忽略 Transform 變更回呼
|
||||
private bool _gizmoBakePending; // 已排程烘焙,防同一次放開重複觸發
|
||||
|
||||
// A synchronized overlay viewport keeps the gizmo above scene geometry.
|
||||
// 操作器疊圖層:另一個透明 Viewport3D 疊在主視窗上、相機同步、只放操作器 →
|
||||
// 操作器不在主場景,永不被實體遮擋(always-on-top);空白處滑鼠穿透回主視窗
|
||||
private System.Windows.Controls.Viewport3D? _overlayViewport;
|
||||
private ProjectionCamera? _overlayCamera;
|
||||
private ProjectionCamera? _overlayCamera; // 型別跟隨主相機(透視/正交切換,v0.5.0)
|
||||
|
||||
/// <summary>Attaches the transparent gizmo overlay viewport.</summary>
|
||||
/// <summary>注入操作器疊圖層(透明 Viewport3D,疊在主視窗上、永遠最上層)</summary>
|
||||
public void AttachOverlay(System.Windows.Controls.Viewport3D overlay)
|
||||
{
|
||||
_overlayViewport = overlay;
|
||||
_overlayCamera = new PerspectiveCamera();
|
||||
overlay.Camera = _overlayCamera;
|
||||
// Manipulator materials need lighting.
|
||||
// 操作器材質需打光(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>
|
||||
/// Keeps the overlay camera in sync with the main camera and projection mode.
|
||||
/// 疊圖層相機跟隨主相機(每次主相機變更時呼叫,讓操作器疊在正確螢幕位置)。
|
||||
/// 主相機可能在透視/正交間切換(Orthographic 屬性)→ 疊圖層相機「型別」也要跟著換,投影才一致。
|
||||
/// </summary>
|
||||
private void SyncOverlayCamera()
|
||||
{
|
||||
@@ -77,7 +79,7 @@ public partial class MainViewModel
|
||||
|
||||
partial void OnSelectedNodeChanged(ModelNodeViewModel? value)
|
||||
{
|
||||
if (GizmoEnabled) UpdateGizmo();
|
||||
if (GizmoEnabled) UpdateGizmo(); // 換選取 → 操作器跟著換目標
|
||||
}
|
||||
|
||||
private void UpdateGizmo()
|
||||
@@ -94,7 +96,7 @@ public partial class MainViewModel
|
||||
return;
|
||||
}
|
||||
_gizmoTarget = root;
|
||||
SyncOverlayCamera();
|
||||
SyncOverlayCamera(); // 操作器出現前先對齊相機
|
||||
|
||||
Rect3D b = root.Bounds;
|
||||
var center = new Point3D(b.X + b.SizeX / 2, b.Y + b.SizeY / 2, b.Z + b.SizeZ / 2);
|
||||
@@ -111,13 +113,13 @@ public partial class MainViewModel
|
||||
man.Position = center;
|
||||
man.Bind(_gizmoProxy);
|
||||
_gizmoParts.Add(man);
|
||||
_overlayViewport!.Children.Add(man);
|
||||
_overlayViewport!.Children.Add(man); // 放疊圖層 → 永不被實體遮擋
|
||||
}
|
||||
// Translation arrows: X red, Y green, Z blue.
|
||||
// 平移箭頭(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 });
|
||||
// Rotation rings
|
||||
// 旋轉環
|
||||
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 });
|
||||
@@ -142,7 +144,7 @@ public partial class MainViewModel
|
||||
_gizmoParts.Clear();
|
||||
_gizmoProxy = null;
|
||||
|
||||
if (_gizmoTarget is not 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;
|
||||
@@ -155,7 +157,7 @@ public partial class MainViewModel
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Applies the proxy transform to target visuals during dragging.</summary>
|
||||
/// <summary>操作器拖動中:把代理的 Transform 套到目標檔案的所有 BodyVisual(暫時、GPU 端)</summary>
|
||||
private void GizmoTransformChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (_gizmoBaking || _gizmoTarget is null || _gizmoProxy is null) return;
|
||||
@@ -164,21 +166,21 @@ public partial class MainViewModel
|
||||
{
|
||||
_gizmoDragActive = true;
|
||||
_edgesSuspended = true;
|
||||
SetEdgesActive(false);
|
||||
SetEdgesActive(false); // 邊線逐幀重建會卡,拖動中暫停
|
||||
}
|
||||
foreach (ModelNodeViewModel l in _gizmoTarget.Leaves())
|
||||
if (l.BodyVisual is not null)
|
||||
l.BodyVisual.Transform = t;
|
||||
}
|
||||
|
||||
/// <summary>Commits the accumulated transform and resets the gizmo on mouse release.</summary>
|
||||
/// <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;
|
||||
if (m.IsIdentity) return; // 只是點一下、沒拖操作器 → 不烘焙
|
||||
|
||||
// Commit after the manipulator completes its mouse-up handling.
|
||||
// 延後到 manipulator 自身的 mouse-up 處理(釋放捕捉等)完成後再烘焙,避免在其事件中改動視覺樹造成 reentrancy
|
||||
_gizmoBakePending = true;
|
||||
_viewport?.Dispatcher.BeginInvoke(new Action(() => BakeGizmo(m)),
|
||||
System.Windows.Threading.DispatcherPriority.Background);
|
||||
@@ -190,19 +192,19 @@ public partial class MainViewModel
|
||||
if (_gizmoTarget is null) return;
|
||||
ModelNodeViewModel root = _gizmoTarget;
|
||||
|
||||
_gizmoBaking = true;
|
||||
_gizmoBaking = true; // 防 proxy 歸零觸發 GizmoTransformChanged 重入
|
||||
foreach (ModelNodeViewModel l in root.Leaves())
|
||||
if (l.BodyVisual is not null)
|
||||
l.BodyVisual.Transform = Transform3D.Identity;
|
||||
if (_gizmoProxy is not null) _gizmoProxy.Transform = Transform3D.Identity;
|
||||
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);
|
||||
TransformRoot(root, Services.RigidAlign.ToModOp(m), m); // 烘進 B-rep(量測精度不受影響)
|
||||
|
||||
// Move the gizmo to the transformed center.
|
||||
// 操作器移到變換後的新中心
|
||||
Rect3D b = root.Bounds;
|
||||
if (!b.IsEmpty)
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@ using static STPViewer.Services.LocalizationService;
|
||||
|
||||
namespace STPViewer.ViewModels;
|
||||
|
||||
// Interference checking
|
||||
// ─── 干涉檢查 ────────────────────────────────────────────────────────
|
||||
public partial class MainViewModel
|
||||
{
|
||||
[RelayCommand(CanExecute = nameof(IsIdle))]
|
||||
@@ -35,7 +35,7 @@ public partial class MainViewModel
|
||||
.Select(m => _faceMap[(GeometryModel3D)m].Mesh)
|
||||
.ToList();
|
||||
|
||||
// Snapshot meshes before background calculations.
|
||||
// 網格快照(開跑後零件被改走也不會半途換料;v0.4.0 起運算期間指令已鎖,這裡是雙保險)
|
||||
var meshes = visibleRoots.ToDictionary(r => r, MeshesOf);
|
||||
|
||||
IsBusy = true;
|
||||
|
||||
@@ -13,15 +13,16 @@ using static STPViewer.Services.LocalizationService;
|
||||
|
||||
namespace STPViewer.ViewModels;
|
||||
|
||||
// Section clipping
|
||||
// ─── 剖面(CPU 網格裁切;v0.4.0 起裁切在背景執行緒平行計算,UI 不凍結)──
|
||||
public partial class MainViewModel
|
||||
{
|
||||
private readonly DispatcherTimer _sectionTimer;
|
||||
private RectangleVisual3D? _sectionPlaneVisual;
|
||||
private Point3D _sectionPlanePoint;
|
||||
private Point3D _sectionPlanePoint; // SectionEnabled 時的剖切平面(合併邊線重建用)
|
||||
private Vector3D _sectionNormal;
|
||||
|
||||
// Reapply clipping if parameters change while background work is running.
|
||||
// 背景裁切 guard:進行中又有新變更(拉滑桿/換軸/零件平移)→ 完成後用最新參數重跑一輪。
|
||||
// 不要改回同步呼叫 ClipMesh —— 大檔(64k 面)整場景裁切會凍結 UI 數秒
|
||||
private bool _sectionApplying;
|
||||
private bool _sectionReapply;
|
||||
|
||||
@@ -32,20 +33,20 @@ public partial class MainViewModel
|
||||
[ObservableProperty]
|
||||
private int sectionAxisIndex;
|
||||
|
||||
/// <summary>Section position along the selected axis, from 0 to 100%.</summary>
|
||||
/// <summary>剖面位置(沿軸 0~100%)</summary>
|
||||
[ObservableProperty]
|
||||
private double sectionPosition = 50;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool sectionFlip;
|
||||
|
||||
/// <summary>Closes the section parameter bar.</summary>
|
||||
/// <summary>剖面參數列的「✕ 關閉」按鈕(參數列只在剖面開啟時顯示)</summary>
|
||||
[RelayCommand]
|
||||
private void CloseSection() => SectionEnabled = false;
|
||||
|
||||
// Three-point custom cutting plane
|
||||
// ── 3點自訂剖切平面(SectionAxisIndex == 3,v0.5.0)──
|
||||
private const int CustomAxisIndex = 3;
|
||||
private Vector3D? _customNormal;
|
||||
private Vector3D? _customNormal; // null = 尚未定義(等使用者點 3 點)
|
||||
private readonly List<Point3D> _sectionPicks = new();
|
||||
private readonly List<Visual3D> _sectionPickOverlays = new();
|
||||
|
||||
@@ -64,7 +65,7 @@ public partial class MainViewModel
|
||||
}
|
||||
else
|
||||
{
|
||||
// Leaving custom mode discards the old plane.
|
||||
// 離開自訂平面 → 丟棄舊平面(再選「3點」即重新定義)
|
||||
_customNormal = null;
|
||||
ClearSectionPicks();
|
||||
}
|
||||
@@ -73,14 +74,14 @@ public partial class MainViewModel
|
||||
|
||||
partial void OnSectionPositionChanged(double value)
|
||||
{
|
||||
if (value < 0) { SectionPosition = 0; return; }
|
||||
if (value < 0) { SectionPosition = 0; return; } // 位置數值輸入框防呆
|
||||
if (value > 100) { SectionPosition = 100; return; }
|
||||
ScheduleSection();
|
||||
}
|
||||
|
||||
partial void OnSectionFlipChanged(bool value) => ScheduleSection();
|
||||
|
||||
/// <summary>Handles a custom plane pick; returns true when the click is consumed.</summary>
|
||||
/// <summary>3點剖面定義模式中的點擊(OnViewportClick 轉入)。回傳 true = 已消費此點擊。</summary>
|
||||
public bool HandleSectionPlanePick(System.Windows.Point position)
|
||||
{
|
||||
if (!SectionEnabled || SectionAxisIndex != CustomAxisIndex || _customNormal is not null) return false;
|
||||
@@ -136,14 +137,14 @@ public partial class MainViewModel
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Cancels custom plane picking; returns true if it was active.</summary>
|
||||
/// <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;
|
||||
SectionAxisIndex = 0; // 回 X 軸
|
||||
if (!quiet) SetStatus(() => Get("Runtime.SectionCancelled"));
|
||||
return true;
|
||||
}
|
||||
@@ -167,7 +168,8 @@ public partial class MainViewModel
|
||||
|
||||
if (!SectionEnabled)
|
||||
{
|
||||
// Restore original meshes. Background clipping checks SectionEnabled before applying.
|
||||
// 還原原始幾何(換回 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;
|
||||
@@ -177,7 +179,7 @@ public partial class MainViewModel
|
||||
_viewport.Children.Remove(_sectionPlaneVisual);
|
||||
_sectionPlaneVisual = null;
|
||||
}
|
||||
ApplyRenderMode();
|
||||
ApplyRenderMode(); // 剖面關閉 → 瀏覽模式可切回合併網格
|
||||
SetStatus(() => Get("Runtime.SectionClosed"));
|
||||
return;
|
||||
}
|
||||
@@ -196,7 +198,7 @@ public partial class MainViewModel
|
||||
Vector3D axis;
|
||||
if (SectionAxisIndex == CustomAxisIndex)
|
||||
{
|
||||
if (_customNormal is null) return;
|
||||
if (_customNormal is null) return; // 平面尚未定義(等使用者點 3 點)
|
||||
axis = _customNormal.Value;
|
||||
}
|
||||
else
|
||||
@@ -212,7 +214,8 @@ public partial class MainViewModel
|
||||
Point3D min = bounds.Location;
|
||||
var size = new Vector3D(bounds.SizeX, bounds.SizeY, bounds.SizeZ);
|
||||
Point3D center = min + size / 2;
|
||||
// Interpolate along the projected bounds of the scene AABB.
|
||||
// 平面位置:場景 AABB 8 個角投影到法向的 [dmin, dmax],滑桿 t 內插
|
||||
//(軸向法向 = 與舊版逐軸計算等價;任意法向也成立)
|
||||
double dmin = double.MaxValue, dmax = double.MinValue;
|
||||
for (int ci = 0; ci < 8; ci++)
|
||||
{
|
||||
@@ -228,7 +231,9 @@ public partial class MainViewModel
|
||||
Point3D planePoint = center + axis * (dStar - Vector3D.DotProduct((Vector3D)center, axis));
|
||||
Vector3D normal = SectionFlip ? -axis : axis;
|
||||
|
||||
// Clip frozen meshes in the background, then apply on the UI thread.
|
||||
// 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)
|
||||
@@ -246,16 +251,16 @@ public partial class MainViewModel
|
||||
return result;
|
||||
});
|
||||
|
||||
if (!SectionEnabled) return;
|
||||
if (!SectionEnabled) return; // 裁切期間被關掉 → 還原分支已處理,丟棄結果
|
||||
|
||||
for (int i = 0; i < models.Count; i++)
|
||||
models[i].Geometry = clipped[i];
|
||||
models[i].Geometry = clipped[i]; // 已移除檔案的殘留 model 賦值無害(不在視覺樹上)
|
||||
|
||||
_sectionPlanePoint = planePoint;
|
||||
_sectionNormal = normal;
|
||||
foreach (ModelNodeViewModel root in Roots) RefreshRootEdges(root);
|
||||
|
||||
ApplyRenderMode();
|
||||
ApplyRenderMode(); // 剖面開啟 → 改用逐面(裁切後幾何)
|
||||
UpdateSectionPlaneVisual(planePoint, axis, size);
|
||||
int axisIndex = SectionAxisIndex;
|
||||
double position = SectionPosition;
|
||||
@@ -296,7 +301,7 @@ public partial class MainViewModel
|
||||
_viewport!.Children.Add(_sectionPlaneVisual);
|
||||
}
|
||||
|
||||
/// <summary>Returns a unit vector perpendicular to n for the custom plane visual.</summary>
|
||||
/// <summary>取任一與 n 垂直的單位向量(自訂平面的指示矩形方向用)</summary>
|
||||
private static Vector3D AnyPerpendicular(Vector3D n)
|
||||
{
|
||||
Vector3D v = Math.Abs(n.Z) < 0.9
|
||||
|
||||
@@ -23,33 +23,34 @@ public partial class MainViewModel : ObservableObject
|
||||
private readonly StepImportService _importService = new();
|
||||
private readonly MeasurementService _measureService = new();
|
||||
|
||||
/// <summary>Maps rendered face models to their B-rep face data for hit testing.</summary>
|
||||
/// <summary>渲染物件 → B-rep Face 反查表(HitTest 用;overlay 不在表內所以自然被忽略)</summary>
|
||||
private readonly Dictionary<Model3D, FaceInfo> _faceMap = new();
|
||||
|
||||
/// <summary>Maps merged meshes to leaves for drag hit testing.</summary>
|
||||
/// <summary>合併網格 → leaf 反查表(拖曳模式在瀏覽渲染下 hit-test 用)</summary>
|
||||
private readonly Dictionary<Model3D, ModelNodeViewModel> _mergedMap = new();
|
||||
|
||||
/// <summary>Vertex boundaries and faces used to resolve hits on merged meshes.
|
||||
/// The order matches BuildMergedMesh; transforms do not change vertex counts.</summary>
|
||||
/// <summary>合併網格 → (每面頂點起始邊界, 對應 FaceInfo)。命中合併網格時用三角形頂點 index 二分搜尋反查回是哪個面,
|
||||
/// 讓量測模式也能 render 合併網格(62 個 model)而非逐面(數萬個 model)。starts 長度 = faces+1,最後一格為總頂點數。
|
||||
/// 邊界由匯入時依面序串接而成,與 BuildMergedMesh 串接順序一致;平移不改各面頂點數,故邊界永久有效。</summary>
|
||||
private readonly Dictionary<Model3D, (int[] Starts, FaceInfo[] Faces)> _mergedFaceRanges = new();
|
||||
|
||||
private HelixViewport3D? _viewport;
|
||||
private int _paletteIndex;
|
||||
private readonly Dictionary<MeasureMode, int> _counters = new();
|
||||
|
||||
// First picks for multi-step measurements.
|
||||
private Point3D? _pendingPoint;
|
||||
private DirectionPick? _pendingDirection;
|
||||
private FaceInfo? _pendingFace;
|
||||
private (ModelNodeViewModel Root, Point3D Point)? _pendingAlign;
|
||||
private readonly List<(ModelNodeViewModel Root, Point3D P)> _align3 = new();
|
||||
// 兩段式量測的第一次拾取暫存
|
||||
private Point3D? _pendingPoint; // 距離
|
||||
private DirectionPick? _pendingDirection; // 角度
|
||||
private FaceInfo? _pendingFace; // 面距
|
||||
private (ModelNodeViewModel Root, Point3D Point)? _pendingAlign; // 兩點對齊
|
||||
private readonly List<(ModelNodeViewModel Root, Point3D P)> _align3 = new(); // 三點對齊(前3=來源、後3=目標)
|
||||
private readonly List<Visual3D> _pendingOverlays = new();
|
||||
|
||||
/// <summary>Selected tree node used as the target for commands.</summary>
|
||||
/// <summary>樹面板目前選取的節點(MainWindow SelectedItemChanged 轉發;旋轉指令的目標)</summary>
|
||||
[ObservableProperty]
|
||||
private ModelNodeViewModel? selectedNode;
|
||||
|
||||
// Suspend edge rendering during camera interaction.
|
||||
// 互動中暫停邊線:轉動/縮放/平移時隱藏 LinesVisual3D,停下再顯示 → 大組件流暢
|
||||
private readonly DispatcherTimer _interactionTimer;
|
||||
private bool _edgesSuspended;
|
||||
|
||||
@@ -88,7 +89,7 @@ public partial class MainViewModel : ObservableObject
|
||||
[ObservableProperty]
|
||||
private bool useInch;
|
||||
|
||||
/// <summary>Tree name filter; does not affect viewport visibility.</summary>
|
||||
/// <summary>樹面板名稱過濾字串(只影響樹顯示,不影響 3D 可見性)</summary>
|
||||
[ObservableProperty]
|
||||
private string? treeFilter;
|
||||
|
||||
@@ -97,16 +98,16 @@ public partial class MainViewModel : ObservableObject
|
||||
foreach (ModelNodeViewModel root in Roots) root.ApplyFilter(value);
|
||||
}
|
||||
|
||||
/// <summary>Recently opened files saved in settings.json.</summary>
|
||||
/// <summary>最近開啟檔案(MRU,最多 10 筆;隨 settings.json 保存)</summary>
|
||||
public ObservableCollection<string> RecentFiles { get; } = new();
|
||||
|
||||
public ObservableCollection<ModelNodeViewModel> Roots { get; } = new();
|
||||
public ObservableCollection<MeasurementResult> Measurements { get; } = new();
|
||||
|
||||
/// <summary>Quick bar button selections.</summary>
|
||||
/// <summary>快速工具列自訂(選單列有全部功能,快速列只放勾選的常用按鈕)</summary>
|
||||
public QuickBarViewModel QuickBar { get; } = new();
|
||||
|
||||
// User settings
|
||||
// ─── 使用者設定(視窗由 MainWindow 處理;VM 管單位 + MRU)─────
|
||||
|
||||
public void LoadSettings(AppSettings s)
|
||||
{
|
||||
@@ -139,18 +140,23 @@ public partial class MainViewModel : ObservableObject
|
||||
_sectionTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(100) };
|
||||
_sectionTimer.Tick += (_, _) => { _sectionTimer.Stop(); ApplySection(); };
|
||||
|
||||
// Restore edges after camera movement stops.
|
||||
// 相機停止移動 180ms 後恢復邊線
|
||||
_interactionTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(180) };
|
||||
_interactionTimer.Tick += (_, _) => { _interactionTimer.Stop(); ResumeEdges(); };
|
||||
}
|
||||
|
||||
private UnitSystem CurrentUnit => UseInch ? UnitSystem.Inch : UnitSystem.Millimeter;
|
||||
|
||||
/// <summary>Attaches the viewport from MainWindow.</summary>
|
||||
/// <summary>由 MainWindow 注入 viewport(桌面工具,不做過度抽象)</summary>
|
||||
public void Attach(HelixViewport3D viewport)
|
||||
{
|
||||
_viewport = viewport;
|
||||
// The viewport event also catches camera instance replacements by HelixToolkit.
|
||||
// 相機一動就暫停邊線/逐面(轉動/縮放/平移皆觸發),停下 180ms 再恢復。
|
||||
// 兩個事件都訂閱,互補保底:
|
||||
// 1) viewport.CameraChanged(控制項層級 routed event)— 相機實例若被 Helix 內部換掉也接得住;
|
||||
// 2) Camera.Changed(Freezable,相機 Position/方向一被改就觸發)— 確保每次移動都收到。
|
||||
// 舊寫法只訂 Camera.Changed 且包在 null 檢查裡,萬一相機實例被換就永久失聯 → 暫停機制全失效。
|
||||
// 重複觸發 OnCameraMoved 無害(idempotent,內部有 _edgesSuspended guard)。
|
||||
viewport.CameraChanged += (_, _) => OnCameraMoved();
|
||||
if (viewport.Camera is not null)
|
||||
viewport.Camera.Changed += (_, _) => OnCameraMoved();
|
||||
@@ -158,7 +164,7 @@ public partial class MainViewModel : ObservableObject
|
||||
|
||||
private void OnCameraMoved()
|
||||
{
|
||||
SyncOverlayCamera();
|
||||
SyncOverlayCamera(); // 操作器疊圖層跟著主相機
|
||||
if (!_edgesSuspended) { _edgesSuspended = true; SetEdgesActive(false); }
|
||||
_interactionTimer.Stop();
|
||||
_interactionTimer.Start();
|
||||
@@ -170,7 +176,7 @@ public partial class MainViewModel : ObservableObject
|
||||
SetEdgesActive(true);
|
||||
}
|
||||
|
||||
/// <summary>Shows or hides merged root edges without rebuilding points.</summary>
|
||||
/// <summary>只切換 root 合併邊線的 viewport 掛載(不重建 Points)</summary>
|
||||
private void SetEdgesActive(bool active)
|
||||
{
|
||||
if (_viewport is null) return;
|
||||
@@ -179,10 +185,10 @@ public partial class MainViewModel : ObservableObject
|
||||
SyncVisual(root.EdgeVisual, active && root.EdgeVisual.Points.Count > 0);
|
||||
}
|
||||
|
||||
/// <summary>Disables scene-changing commands while background work runs.</summary>
|
||||
/// <summary>IsBusy 時停用會改動幾何/場景的指令(匯入、旋轉、干涉),避免背景運算期間狀態被改走造成結果錯位</summary>
|
||||
private bool IsIdle() => !IsBusy;
|
||||
|
||||
// Import
|
||||
// ─── 匯入 ────────────────────────────────────────────────────
|
||||
|
||||
[RelayCommand(CanExecute = nameof(IsIdle))]
|
||||
private async Task ImportAsync()
|
||||
@@ -197,24 +203,25 @@ public partial class MainViewModel : ObservableObject
|
||||
await ImportFilesAsync(dlg.FileNames);
|
||||
}
|
||||
|
||||
/// <summary>Imports a recently opened file.</summary>
|
||||
/// <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;
|
||||
// Reject overlapping imports because StepImportService has shared state.
|
||||
// 拖放/命令列可繞過指令 CanExecute 直接進來 → 匯入或背景運算期間直接擋掉,
|
||||
// 避免兩批匯入交錯(StepImportService 有共享狀態,非重入安全)
|
||||
if (IsBusy) { SetStatus(() => Get("Runtime.ImportBusy")); return; }
|
||||
int ok = 0, fail = 0;
|
||||
IsBusy = true;
|
||||
IsBusy = true; // 整批匯入期間維持 busy(v0.4.0:不再逐檔開關,杜絕檔案之間的重入空窗)
|
||||
try
|
||||
{
|
||||
foreach (string path in paths)
|
||||
{
|
||||
string name = Path.GetFileNameWithoutExtension(path);
|
||||
SetStatus(() => Format("Runtime.Importing", name));
|
||||
// Marshal progress from the import thread to the UI thread.
|
||||
// 匯入階段回報(從背景執行緒來 → 切回 UI 執行緒)
|
||||
_importService.ProgressTextFor = textFor =>
|
||||
System.Windows.Application.Current?.Dispatcher.BeginInvoke(
|
||||
() => SetStatus(() => Format("Runtime.ImportProgress", name, textFor())));
|
||||
@@ -257,13 +264,13 @@ public partial class MainViewModel : ObservableObject
|
||||
|
||||
ModelNodeViewModel root = BuildNode(data.Root, isRoot: true, data.FilePath, name);
|
||||
|
||||
// Disable edges by default for very large files.
|
||||
// 邊線總量過大 → 整檔預設關閉(cascade;使用者可再開)
|
||||
int totalSegments = root.Leaves().Sum(l => (l.OriginalEdgePoints?.Count ?? 0) / 2);
|
||||
if (totalSegments > 30_000)
|
||||
root.ShowEdges = false;
|
||||
|
||||
Roots.Add(root);
|
||||
if (!string.IsNullOrWhiteSpace(TreeFilter)) root.ApplyFilter(TreeFilter);
|
||||
if (!string.IsNullOrWhiteSpace(TreeFilter)) root.ApplyFilter(TreeFilter); // 新檔套用目前過濾
|
||||
foreach (ModelNodeViewModel leaf in root.Leaves())
|
||||
{
|
||||
leaf.VisualStateChanged += _ => SyncLeafVisuals(root);
|
||||
@@ -271,7 +278,7 @@ public partial class MainViewModel : ObservableObject
|
||||
_viewport!.Children.Add(leaf.BodyVisual);
|
||||
}
|
||||
|
||||
// One merged edge visual per file avoids per-leaf rebuilds.
|
||||
// 整檔合併成「一條」LinesVisual3D(轉動時只重建 1 條,避免逐零件 N 條造成卡頓)
|
||||
if (root.Leaves().Any(l => l.OriginalEdgePoints is { Count: > 0 }))
|
||||
{
|
||||
root.EdgeVisual = new LinesVisual3D
|
||||
@@ -300,7 +307,7 @@ public partial class MainViewModel : ObservableObject
|
||||
|
||||
if (n.Faces.Count > 0)
|
||||
{
|
||||
// Share material across leaf faces for color changes.
|
||||
// leaf 共用材質(換色時只改 Brush)
|
||||
var diffuseBrush = new SolidColorBrush(vm.Color);
|
||||
diffuseBrush.Freeze();
|
||||
var diffuse = new DiffuseMaterial(diffuseBrush);
|
||||
@@ -311,7 +318,8 @@ public partial class MainViewModel : ObservableObject
|
||||
backMat.Freeze();
|
||||
vm.SharedMaterial = diffuse;
|
||||
|
||||
// Keep one model per face for section picking and record merged-mesh face boundaries.
|
||||
// 一個 B-rep Face = 一個 GeometryModel3D,登錄反查表(剖面模式逐面拾取用)。
|
||||
// 同時依面序記錄每面頂點起始邊界 + 對應 FaceInfo,供「命中合併網格反查面」(量測模式拾取用)。
|
||||
var group = new Model3DGroup();
|
||||
var faceInfos = new FaceInfo[n.Faces.Count];
|
||||
var vertStarts = new int[n.Faces.Count + 1];
|
||||
@@ -330,9 +338,11 @@ public partial class MainViewModel : ObservableObject
|
||||
vertStarts[n.Faces.Count] = vacc;
|
||||
vm.FacesContent = group;
|
||||
|
||||
// BuildMergedMesh concatenates faces in the same order as vertStarts.
|
||||
// 合併網格:整零件所有面併成 1 個 GeometryModel3D(draw call 從每面 1 個降為每檔 1 個)。
|
||||
// BuildMergedMesh 依 n.Faces 同序串接(baseIdx += positions.Count),與上面 vertStarts 對齊 → 命中頂點 index 可反查回面。
|
||||
var mergedModel = new GeometryModel3D(BuildMergedMesh(n.Faces), matGroup);
|
||||
// Closed solids render front faces only; open shells and STL render both sides.
|
||||
// 封閉實體背面不可見 → 不設 BackMaterial(WPF 兩面渲染成本砍半);開放殼/STL 仍需背面。
|
||||
// 拾取不受材質影響(WPF 3D hit-test 純幾何、不剔背面),故孔內壁仍可命中。
|
||||
if (n.SolidCount == 0 || !n.HasBrep)
|
||||
mergedModel.BackMaterial = backMat;
|
||||
vm.MergedContent = new Model3DGroup();
|
||||
@@ -340,14 +350,14 @@ public partial class MainViewModel : ObservableObject
|
||||
_mergedMap[mergedModel] = vm;
|
||||
_mergedFaceRanges[mergedModel] = (vertStarts, faceInfos);
|
||||
|
||||
// Use merged meshes except in section mode, which needs separate clipped faces.
|
||||
// 非剖面一律顯示合併網格(瀏覽/量測都是,量測靠 _mergedFaceRanges 反查面);只有剖面才逐面(裁切後幾何)
|
||||
vm.BodyVisual = new ModelVisual3D
|
||||
{
|
||||
Content = SectionEnabled ? group : vm.MergedContent,
|
||||
};
|
||||
}
|
||||
|
||||
// Leaves hold edge data; roots hold the merged edge visual.
|
||||
// 邊線端點僅保存於 leaf(資料);實際 LinesVisual3D 由 root 合併建立(見 BuildRoot)
|
||||
if (n.EdgeSegments is { Count: > 0 })
|
||||
vm.OriginalEdgePoints = n.EdgeSegments;
|
||||
|
||||
@@ -366,7 +376,8 @@ public partial class MainViewModel : ObservableObject
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds one edge visual from visible leaves, clipping segments in section mode.
|
||||
/// 重建整檔的合併邊線:收集所有「可見且開邊線」的 leaf 端點為單一 Point3DCollection。
|
||||
/// 剖面開啟時即時裁切。一檔只有一條 LinesVisual3D → 轉動視角不再逐零件重建。
|
||||
/// </summary>
|
||||
private void RefreshRootEdges(ModelNodeViewModel root)
|
||||
{
|
||||
@@ -393,9 +404,9 @@ public partial class MainViewModel : ObservableObject
|
||||
else if (!shouldShow && contains) _viewport.Children.Remove(visual);
|
||||
}
|
||||
|
||||
// Merged meshes
|
||||
// ─── 合併網格(瀏覽模式降 draw call)────────────────────────
|
||||
|
||||
/// <summary>Concatenates face meshes in the given order.</summary>
|
||||
/// <summary>把多個面網格依序併成單一 MeshGeometry3D(索引位移後串接;順序 = 呼叫端給的面序)</summary>
|
||||
private static MeshGeometry3D MergeMeshes(IReadOnlyList<MeshGeometry3D> meshes)
|
||||
{
|
||||
int nv = 0, nt = 0;
|
||||
@@ -422,7 +433,7 @@ public partial class MainViewModel : ObservableObject
|
||||
return mesh;
|
||||
}
|
||||
|
||||
/// <summary>Builds the import mesh in face order for merged hit resolution.</summary>
|
||||
/// <summary>匯入時建合併網格(依 faces 面序串接,與 _mergedFaceRanges 的頂點邊界對齊)</summary>
|
||||
private static MeshGeometry3D BuildMergedMesh(IReadOnlyList<ImportedFace> faces)
|
||||
{
|
||||
var meshes = new List<MeshGeometry3D>(faces.Count);
|
||||
@@ -430,7 +441,8 @@ public partial class MainViewModel : ObservableObject
|
||||
return MergeMeshes(meshes);
|
||||
}
|
||||
|
||||
/// <summary>Rebuilds a merged mesh after a transform without changing face vertex boundaries.</summary>
|
||||
/// <summary>重建合併網格(位置變更後,如零件平移)。由 leaf 逐面 fi.Mesh(未剖切原始)依 FacesContent 子序串接,
|
||||
/// 與匯入時 BuildMergedMesh 的面序一致 → _mergedFaceRanges 頂點邊界維持有效。</summary>
|
||||
private void RebuildMerged(ModelNodeViewModel leaf)
|
||||
{
|
||||
if (leaf.MergedContent is null || leaf.FacesContent is null) return;
|
||||
@@ -442,11 +454,13 @@ public partial class MainViewModel : ObservableObject
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uses merged meshes for browsing and measurement; section mode uses separate clipped faces.
|
||||
/// 依目前狀態切換每個 leaf 的渲染內容:
|
||||
/// 非剖面(瀏覽 + 量測皆是)→ 合併網格(每檔 1 個 model,快);量測拾取靠命中合併網格反查面(_mergedFaceRanges)。
|
||||
/// 剖面 → 逐面(要顯示各面裁切後的幾何)。
|
||||
/// </summary>
|
||||
private void ApplyRenderMode()
|
||||
{
|
||||
// Measurement resolves merged-mesh hits back to their source faces.
|
||||
// 量測不再需要逐面渲染(拾取打合併網格反查面)→ 只有剖面才逐面。永不在量測模式掛數萬個 GeometryModel3D。
|
||||
bool merged = !SectionEnabled;
|
||||
foreach (ModelNodeViewModel leaf in Roots.SelectMany(r => r.Leaves()))
|
||||
{
|
||||
@@ -457,7 +471,7 @@ public partial class MainViewModel : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
// Node commands
|
||||
// ─── 節點操作 ────────────────────────────────────────────────
|
||||
|
||||
[RelayCommand]
|
||||
private void ZoomNode(ModelNodeViewModel node)
|
||||
@@ -469,14 +483,14 @@ public partial class MainViewModel : ObservableObject
|
||||
[RelayCommand]
|
||||
private void ZoomAll() => _viewport?.ZoomExtents(400);
|
||||
|
||||
/// <summary>Standard views with Z up.</summary>
|
||||
/// <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)),
|
||||
"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)),
|
||||
@@ -490,13 +504,13 @@ public partial class MainViewModel : ObservableObject
|
||||
: 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;
|
||||
cam.LookDirection = dir * dist; // 目標點 = Position + LookDirection = 場景中心
|
||||
cam.UpDirection = up;
|
||||
_viewport.ZoomExtents(400);
|
||||
SetStatus(() => Format("Runtime.View", Get($"Runtime.View{name}")));
|
||||
}
|
||||
|
||||
/// <summary>Toggles orthographic projection while preserving the view angle.</summary>
|
||||
/// <summary>正交投影切換(平行投影,量尺寸不受透視變形;Helix 保持視角換相機型別)</summary>
|
||||
[ObservableProperty]
|
||||
private bool orthographicView;
|
||||
|
||||
@@ -512,16 +526,16 @@ public partial class MainViewModel : ObservableObject
|
||||
{
|
||||
if (_viewport is null || !root.IsRoot) return;
|
||||
|
||||
if (ReferenceEquals(root, _gizmoTarget)) GizmoEnabled = false;
|
||||
if (ReferenceEquals(root, _gizmoTarget)) GizmoEnabled = false; // 目標被移除 → 收掉操作器
|
||||
|
||||
// Clear measurements that may be attached to the removed file.
|
||||
// 量測 overlay 可能標在此檔模型上 → 一併清除(簡化:全清)
|
||||
if (Measurements.Count > 0) ClearMeasurements();
|
||||
|
||||
foreach (ModelNodeViewModel leaf in root.Leaves())
|
||||
{
|
||||
if (leaf.BodyVisual is not null)
|
||||
_viewport.Children.Remove(leaf.BodyVisual);
|
||||
if (leaf.FacesContent is not null)
|
||||
if (leaf.FacesContent is not null) // 逐面群組才在 _faceMap(與目前顯示哪種內容無關)
|
||||
foreach (Model3D m in leaf.FacesContent.Children)
|
||||
_faceMap.Remove(m);
|
||||
if (leaf.MergedContent is not null)
|
||||
@@ -538,9 +552,9 @@ public partial class MainViewModel : ObservableObject
|
||||
SetStatus(() => Format("Runtime.Removed", removedName));
|
||||
}
|
||||
|
||||
// Visibility and volume
|
||||
// ─── 顯示控制 / 體積(樹右鍵選單)────────────────────────────
|
||||
|
||||
/// <summary>Shows only this node until ShowAll is called.</summary>
|
||||
/// <summary>只顯示此節點(其餘全部隱藏;「全部顯示」復原)</summary>
|
||||
[RelayCommand]
|
||||
private void IsolateNode(ModelNodeViewModel node)
|
||||
{
|
||||
@@ -564,14 +578,15 @@ public partial class MainViewModel : ObservableObject
|
||||
SetStatus(() => Get("Runtime.AllVisible"));
|
||||
}
|
||||
|
||||
/// <summary>Sets visibility for every node, including mixed child states.</summary>
|
||||
/// <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>Adds a volume and centroid measurement for a closed mesh.</summary>
|
||||
/// <summary>體積/質心(網格 signed volume,封閉實體才可靠)→ 加進量測結果 + 質心標記</summary>
|
||||
[RelayCommand]
|
||||
private void MeasureVolume(ModelNodeViewModel node)
|
||||
{
|
||||
@@ -621,12 +636,12 @@ public partial class MainViewModel : ObservableObject
|
||||
AddMeasurement(m);
|
||||
}
|
||||
|
||||
// Measurement
|
||||
// ─── 量測 ────────────────────────────────────────────────────
|
||||
|
||||
partial void OnCurrentModeChanged(MeasureMode value)
|
||||
{
|
||||
CancelPending();
|
||||
ApplyRenderMode();
|
||||
ApplyRenderMode(); // 非剖面一律合併網格(量測拾取靠反查面);不再因切量測模式而掛逐面
|
||||
string? key = value switch
|
||||
{
|
||||
MeasureMode.None => "Runtime.ModeBrowse",
|
||||
@@ -652,10 +667,10 @@ public partial class MainViewModel : ObservableObject
|
||||
SetStatus(() => Get(value ? "Runtime.UnitInch" : "Runtime.UnitMillimeter"));
|
||||
}
|
||||
|
||||
/// <summary>Cancels a pending pick, then exits measurement mode or closes the gizmo.</summary>
|
||||
/// <summary>Esc(MainWindow 轉發):先取消進行中的多段量測(保留模式),再按退回瀏覽模式;再退操作器</summary>
|
||||
public void OnEscape()
|
||||
{
|
||||
if (CancelSectionPickIfActive()) return;
|
||||
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)
|
||||
@@ -673,7 +688,7 @@ public partial class MainViewModel : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Handles measurement hotkeys; repeating a key returns to browse mode.</summary>
|
||||
/// <summary>量測模式快速鍵(MainWindow 轉發;無修飾鍵)。同鍵再按 = 退回瀏覽。回傳 false 表示非快速鍵。</summary>
|
||||
public bool OnHotKey(System.Windows.Input.Key key)
|
||||
{
|
||||
MeasureMode? m = key switch
|
||||
@@ -692,11 +707,11 @@ public partial class MainViewModel : ObservableObject
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Handles left-clicks forwarded by MainWindow.</summary>
|
||||
/// <summary>MainWindow 滑鼠左鍵點擊轉發進來</summary>
|
||||
public void OnViewportClick(Point position)
|
||||
{
|
||||
if (_viewport is null || IsBusy) return;
|
||||
if (HandleSectionPlanePick(position)) return;
|
||||
if (HandleSectionPlanePick(position)) return; // 3點剖面定義模式優先吃點擊(v0.5.0)
|
||||
if (CurrentMode is MeasureMode.None or MeasureMode.Drag) return;
|
||||
|
||||
var hits = _viewport.Viewport.FindHits(position);
|
||||
@@ -706,13 +721,13 @@ public partial class MainViewModel : ObservableObject
|
||||
foreach (var h in hits)
|
||||
{
|
||||
if (h.Model is null) continue;
|
||||
// Section mode renders individual faces.
|
||||
// 剖面模式:渲染逐面 → 直接查 _faceMap
|
||||
if (_faceMap.TryGetValue(h.Model, out FaceInfo? f))
|
||||
{
|
||||
fi = f; hitPoint = h.Position; hitNormal = h.Normal;
|
||||
break;
|
||||
}
|
||||
// Other modes resolve merged-mesh vertex indices to faces.
|
||||
// 量測/瀏覽模式:渲染合併網格 → 用命中三角形頂點 index 反查回是哪個面
|
||||
if (h.RayHit is not null && _mergedFaceRanges.TryGetValue(h.Model, out var range))
|
||||
{
|
||||
FaceInfo? mf = ResolveMergedFace(range, h.RayHit.VertexIndex1);
|
||||
@@ -740,13 +755,13 @@ public partial class MainViewModel : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Finds the source face for a merged-mesh vertex by binary search.</summary>
|
||||
/// <summary>命中合併網格的某頂點 index → 反查回是哪個面(依面序的頂點起始邊界二分搜尋)。</summary>
|
||||
private static FaceInfo? ResolveMergedFace((int[] Starts, FaceInfo[] Faces) range, int vertexIndex)
|
||||
{
|
||||
int[] starts = range.Starts;
|
||||
FaceInfo[] faces = range.Faces;
|
||||
if (vertexIndex < 0 || faces.Length == 0 || vertexIndex >= starts[faces.Length]) return null;
|
||||
// Find the largest face start not exceeding the hit vertex index.
|
||||
// 找最大的 k 使 starts[k] <= vertexIndex(face k 佔頂點 [starts[k], starts[k+1]))
|
||||
int lo = 0, hi = faces.Length - 1, ans = 0;
|
||||
while (lo <= hi)
|
||||
{
|
||||
@@ -763,7 +778,7 @@ public partial class MainViewModel : ObservableObject
|
||||
double snapTol = diag * 0.02;
|
||||
double markerR = Math.Clamp(diag * 0.004, 0.01, 5.0);
|
||||
|
||||
// STL has no B-rep edges or faces for these modes.
|
||||
// 無 B-rep 來源(STL)只支援部分模式
|
||||
if (fi.BrepFace is null && CurrentMode is MeasureMode.Edge or MeasureMode.Circle or MeasureMode.Face)
|
||||
{
|
||||
SetStatus(() => Get("Runtime.MeshMeasurementLimited"));
|
||||
@@ -885,7 +900,7 @@ public partial class MainViewModel : ObservableObject
|
||||
ModelNodeViewModel? root = FindRootOf(fi.Owner);
|
||||
if (root is null) { SetStatus(() => Get("Runtime.RootNotFound")); break; }
|
||||
|
||||
if (_align3.Count < 3)
|
||||
if (_align3.Count < 3) // 來源 3 點(要移動的檔案)
|
||||
{
|
||||
if (_align3.Count > 0 && !ReferenceEquals(_align3[0].Root, root))
|
||||
{
|
||||
@@ -905,7 +920,7 @@ public partial class MainViewModel : ObservableObject
|
||||
? Format("Runtime.Align3SourcePoint", sourceCount, root.Name)
|
||||
: Get("Runtime.Align3SourceComplete"));
|
||||
}
|
||||
else
|
||||
else // 目標 3 點(另一個檔案,順序對應來源點)
|
||||
{
|
||||
if (ReferenceEquals(_align3[0].Root, root))
|
||||
{
|
||||
@@ -1007,7 +1022,7 @@ public partial class MainViewModel : ObservableObject
|
||||
_align3.Clear();
|
||||
}
|
||||
|
||||
// Rigid part transforms
|
||||
// ─── 零件剛體變換(兩點對齊=平移、旋轉 90°、三點對齊=旋轉+平移)──
|
||||
|
||||
private void TranslateRoot(ModelNodeViewModel root, Vector3D v)
|
||||
{
|
||||
@@ -1018,17 +1033,19 @@ public partial class MainViewModel : ObservableObject
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transforms B-rep objects, meshes, edges and bounds for one file.
|
||||
/// op and m must represent the same transform in CADability and WPF conventions.
|
||||
/// 對整個檔案(root)套剛體變換:B-rep 用 ModOp 整體 Modify(量測維持精確)、
|
||||
/// 渲染網格 / 合併網格 / 邊線端點同步重算、邊界重新計算。
|
||||
/// op 與 m 必須是同一個變換(CADability 與 WPF 兩種表示)。
|
||||
/// </summary>
|
||||
private void TransformRoot(ModelNodeViewModel root, CADability.ModOp op, Matrix3D m)
|
||||
{
|
||||
if (_viewport is null) return;
|
||||
ClearMeasurements();
|
||||
ClearMeasurements(); // 量測標記位置已失效
|
||||
|
||||
var leaves = root.Leaves().ToList();
|
||||
|
||||
// Transform whole B-rep objects sequentially to avoid shared-edge changes.
|
||||
// B-rep:對 Solid/Shell 整體 Modify(避免共用邊被逐面重複變換)。
|
||||
// CADability 物件非執行緒安全 → 維持循序
|
||||
foreach (ModelNodeViewModel leaf in leaves)
|
||||
foreach (CADability.GeoObject.IGeoObject g in leaf.SourceGeos)
|
||||
{
|
||||
@@ -1036,7 +1053,7 @@ public partial class MainViewModel : ObservableObject
|
||||
catch { }
|
||||
}
|
||||
|
||||
// Transform independent frozen face meshes in parallel.
|
||||
// 渲染網格:面級平行變換(來源/輸出皆 frozen、各面獨立 → 安全;大檔 64k 面吃滿多核)
|
||||
var faceItems = new List<(GeometryModel3D Gm, FaceInfo Fi)>();
|
||||
foreach (ModelNodeViewModel leaf in leaves)
|
||||
if (leaf.FacesContent is not null)
|
||||
@@ -1046,7 +1063,7 @@ public partial class MainViewModel : ObservableObject
|
||||
|
||||
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++)
|
||||
for (int i = 0; i < faceItems.Count; i++) // 視覺樹賦值回 UI 執行緒循序做
|
||||
{
|
||||
faceItems[i].Fi.Mesh = moved[i];
|
||||
faceItems[i].Gm.Geometry = moved[i];
|
||||
@@ -1055,15 +1072,15 @@ public partial class MainViewModel : ObservableObject
|
||||
foreach (ModelNodeViewModel leaf in leaves)
|
||||
{
|
||||
if (leaf.FacesContent is not null)
|
||||
RebuildMerged(leaf);
|
||||
RebuildMerged(leaf); // 合併網格同步
|
||||
|
||||
// Update edge data before rebuilding the merged visual.
|
||||
// 邊線端點(資料);實際合併線於迴圈後重建
|
||||
if (leaf.OriginalEdgePoints is not null)
|
||||
leaf.OriginalEdgePoints = TransformPoints(leaf.OriginalEdgePoints, m);
|
||||
}
|
||||
|
||||
RecomputeBounds(root);
|
||||
if (SectionEnabled) ApplySection();
|
||||
if (SectionEnabled) ApplySection(); // 內含 RefreshRootEdges
|
||||
else RefreshRootEdges(root);
|
||||
}
|
||||
|
||||
@@ -1074,7 +1091,7 @@ public partial class MainViewModel : ObservableObject
|
||||
var mesh = new MeshGeometry3D
|
||||
{
|
||||
Positions = positions,
|
||||
TriangleIndices = src.TriangleIndices,
|
||||
TriangleIndices = src.TriangleIndices, // frozen,可共用(法向量缺省由 WPF 自算)
|
||||
};
|
||||
mesh.Freeze();
|
||||
return mesh;
|
||||
@@ -1088,7 +1105,7 @@ public partial class MainViewModel : ObservableObject
|
||||
return pts;
|
||||
}
|
||||
|
||||
/// <summary>Recomputes bounds from transformed meshes and edges.</summary>
|
||||
/// <summary>旋轉後 AABB 不能只平移 → 由網格/邊線重新計算(bottom-up)</summary>
|
||||
private Rect3D RecomputeBounds(ModelNodeViewModel node)
|
||||
{
|
||||
Rect3D b = Rect3D.Empty;
|
||||
@@ -1110,7 +1127,7 @@ public partial class MainViewModel : ObservableObject
|
||||
return b;
|
||||
}
|
||||
|
||||
// Rotate selected file
|
||||
// ─── 旋轉 90°(樹面板選取的檔案,繞其中心)──────────────────
|
||||
|
||||
[RelayCommand(CanExecute = nameof(IsIdle))]
|
||||
private void RotateRoot(string axisName)
|
||||
@@ -1144,9 +1161,9 @@ public partial class MainViewModel : ObservableObject
|
||||
private static bool ContainsNode(ModelNodeViewModel parent, ModelNodeViewModel target) =>
|
||||
parent.Children.Any(c => ReferenceEquals(c, target) || ContainsNode(c, target));
|
||||
|
||||
// Three-point alignment math is in RigidAlign.
|
||||
// 三點對齊的剛體變換數學在 Services/RigidAlign.cs(SmokeTest --align-test 驗證)
|
||||
|
||||
// Shared helpers
|
||||
// ─── 共用 ────────────────────────────────────────────────────
|
||||
|
||||
private Rect3D UnionBounds()
|
||||
{
|
||||
|
||||
@@ -12,30 +12,30 @@ using STPViewer.Services;
|
||||
namespace STPViewer.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Assembly tree node: imported file, STEP group, or geometry leaf.
|
||||
/// Visibility, edges, and color cascade to children.
|
||||
/// 裝配樹節點:root = 匯入檔、group = STEP 裝配(Block)、leaf = 零件幾何。
|
||||
/// 可見性 / 邊線 / 顏色設定向下層疊(cascade)。
|
||||
/// </summary>
|
||||
public partial class ModelNodeViewModel : ObservableObject
|
||||
{
|
||||
/// <summary>Palette used when assigning and cycling leaf colors.</summary>
|
||||
/// <summary>調色盤(每個 leaf 依序取色,點色塊循環切換)</summary>
|
||||
public static readonly Color[] Palette =
|
||||
{
|
||||
Color.FromRgb(0x90, 0xA4, 0xAE),
|
||||
Color.FromRgb(0x4F, 0xC3, 0xF7),
|
||||
Color.FromRgb(0xFF, 0xB7, 0x4D),
|
||||
Color.FromRgb(0x81, 0xC7, 0x84),
|
||||
Color.FromRgb(0xBA, 0x68, 0xC8),
|
||||
Color.FromRgb(0xE5, 0x73, 0x73),
|
||||
Color.FromRgb(0xFF, 0xD5, 0x4F),
|
||||
Color.FromRgb(0x4D, 0xB6, 0xAC),
|
||||
Color.FromRgb(0xA1, 0x88, 0x7F),
|
||||
Color.FromRgb(0x79, 0x86, 0xCB),
|
||||
Color.FromRgb(0x90, 0xA4, 0xAE), // 藍灰(金屬感)
|
||||
Color.FromRgb(0x4F, 0xC3, 0xF7), // 淺藍
|
||||
Color.FromRgb(0xFF, 0xB7, 0x4D), // 橙
|
||||
Color.FromRgb(0x81, 0xC7, 0x84), // 綠
|
||||
Color.FromRgb(0xBA, 0x68, 0xC8), // 紫
|
||||
Color.FromRgb(0xE5, 0x73, 0x73), // 紅
|
||||
Color.FromRgb(0xFF, 0xD5, 0x4F), // 黃
|
||||
Color.FromRgb(0x4D, 0xB6, 0xAC), // 藍綠
|
||||
Color.FromRgb(0xA1, 0x88, 0x7F), // 棕
|
||||
Color.FromRgb(0x79, 0x86, 0xCB), // 靛藍
|
||||
};
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isVisible = true;
|
||||
|
||||
/// <summary>Outline visibility.</summary>
|
||||
/// <summary>輪廓邊線開關(邊線量過大的檔案匯入時自動關閉,避免轉動視角卡頓)</summary>
|
||||
[ObservableProperty]
|
||||
private bool showEdges = true;
|
||||
|
||||
@@ -55,7 +55,7 @@ public partial class ModelNodeViewModel : ObservableObject
|
||||
public string? NameKey { get; init; }
|
||||
public int NameNumber { get; init; }
|
||||
|
||||
/// <summary>Set only on imported file roots.</summary>
|
||||
/// <summary>root(檔案層級)才有值</summary>
|
||||
public string? FilePath { get; init; }
|
||||
public bool IsRoot => FilePath is not null;
|
||||
|
||||
@@ -66,7 +66,7 @@ public partial class ModelNodeViewModel : ObservableObject
|
||||
public int FaceCount { get; init; }
|
||||
public int TriangleCount { get; init; }
|
||||
|
||||
/// <summary>World-space bounds updated after part transforms.</summary>
|
||||
/// <summary>世界座標邊界(零件平移/旋轉後由 RecomputeBounds 更新;連動 Stats/Tooltip 的外形尺寸)</summary>
|
||||
public Rect3D Bounds
|
||||
{
|
||||
get => _bounds;
|
||||
@@ -80,15 +80,15 @@ public partial class ModelNodeViewModel : ObservableObject
|
||||
}
|
||||
private Rect3D _bounds = Rect3D.Empty;
|
||||
|
||||
/// <summary>Axis-aligned size in millimeters.</summary>
|
||||
/// <summary>外形尺寸 L×W×H(AABB,mm)</summary>
|
||||
public string SizeText => Bounds.IsEmpty
|
||||
? ""
|
||||
: $"{Bounds.SizeX:F1} × {Bounds.SizeY:F1} × {Bounds.SizeZ:F1} mm";
|
||||
|
||||
/// <summary>Source B-rep objects transformed as whole parts.</summary>
|
||||
/// <summary>來源 B-rep 物件(零件平移時整體 Modify,避免共用邊被重複位移)</summary>
|
||||
public List<CADability.GeoObject.IGeoObject> SourceGeos { get; } = new();
|
||||
|
||||
/// <summary>False for STL or curve-only sources without B-rep geometry.</summary>
|
||||
/// <summary>false = STL/曲線等無 B-rep 來源(邊/面/圓量測不可用)</summary>
|
||||
public bool HasBrep { get; init; } = true;
|
||||
|
||||
public string Stats =>
|
||||
@@ -106,13 +106,14 @@ public partial class ModelNodeViewModel : ObservableObject
|
||||
foreach (ModelNodeViewModel child in Children) child.RefreshLocalizedText();
|
||||
}
|
||||
|
||||
// Tree filtering does not change viewport visibility.
|
||||
// ── 樹搜尋/過濾(只影響樹面板顯示,不影響 3D 可見性)──
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isFilterVisible = true;
|
||||
|
||||
/// <summary>
|
||||
/// Shows matching nodes, their ancestors and descendants; expands matching branches.
|
||||
/// 套用名稱過濾:符合者 + 其祖先 + 其子樹 顯示。空字串 = 全顯。
|
||||
/// 過濾中有符合的子孫時自動展開。回傳此節點是否顯示。
|
||||
/// </summary>
|
||||
public bool ApplyFilter(string? filter, bool ancestorMatched = false)
|
||||
{
|
||||
@@ -126,17 +127,17 @@ public partial class ModelNodeViewModel : ObservableObject
|
||||
return IsFilterVisible;
|
||||
}
|
||||
|
||||
// Viewport objects
|
||||
public ModelVisual3D? BodyVisual { get; set; }
|
||||
public Model3DGroup? FacesContent { get; set; }
|
||||
public Model3DGroup? MergedContent { get; set; }
|
||||
public LinesVisual3D? EdgeVisual { get; set; }
|
||||
// ── 視覺物件 ──
|
||||
public ModelVisual3D? BodyVisual { get; set; } // leaf:面網格容器(內容在兩種模式間切換)
|
||||
public Model3DGroup? FacesContent { get; set; } // leaf:逐面 GeometryModel3D(剖面用;量測打合併網格)
|
||||
public Model3DGroup? MergedContent { get; set; } // leaf:整零件合併成 1 個 GeometryModel3D(瀏覽+量測,draw call 大減)
|
||||
public LinesVisual3D? EdgeVisual { get; set; } // root:整檔合併邊線(一檔一條,避免轉動卡頓)
|
||||
public DiffuseMaterial? SharedMaterial { get; set; }
|
||||
|
||||
/// <summary>Unclipped edge endpoints used for section restoration.</summary>
|
||||
/// <summary>原始(未剖切)邊線端點 — 剖面還原用</summary>
|
||||
public Point3DCollection? OriginalEdgePoints { get; set; }
|
||||
|
||||
/// <summary>Signals the ViewModel to update leaf viewport objects.</summary>
|
||||
/// <summary>leaf 的可見性/邊線狀態變更(MainViewModel 同步 viewport)</summary>
|
||||
public event Action<ModelNodeViewModel>? VisualStateChanged;
|
||||
|
||||
private int _paletteIndex;
|
||||
@@ -147,7 +148,7 @@ public partial class ModelNodeViewModel : ObservableObject
|
||||
color = Palette[_paletteIndex];
|
||||
}
|
||||
|
||||
/// <summary>All descendant leaves, including this node when it is a leaf.</summary>
|
||||
/// <summary>所有 leaf 後代(含自身若為 leaf)</summary>
|
||||
public IEnumerable<ModelNodeViewModel> Leaves()
|
||||
{
|
||||
if (IsLeafNode) { yield return this; yield break; }
|
||||
|
||||
@@ -7,7 +7,7 @@ using STPViewer.Services;
|
||||
|
||||
namespace STPViewer.ViewModels;
|
||||
|
||||
/// <summary>Visibility toggle for one quick bar button.</summary>
|
||||
/// <summary>快速列單一按鈕的顯示開關(選單「快速列 → 自訂按鈕」的 checkbox 項)</summary>
|
||||
public partial class QuickBarItemViewModel : ObservableObject
|
||||
{
|
||||
public string Key { get; }
|
||||
@@ -30,11 +30,13 @@ public partial class QuickBarItemViewModel : ObservableObject
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Quick bar button registry and saved visibility selections.
|
||||
/// 快速工具列自訂:所有功能都在選單列,快速列只放使用者勾選的常用按鈕。
|
||||
/// XAML 以索引子綁定:Visibility="{Binding QuickBar[Point].IsChecked, Converter=…}"。
|
||||
/// 勾選狀態隨 settings.json(QuickBarKeys)保存;null = 用預設。
|
||||
/// </summary>
|
||||
public partial class QuickBarViewModel : ObservableObject
|
||||
{
|
||||
/// <summary>Button order, label key, and default visibility.</summary>
|
||||
/// <summary>註冊表 = 快速列順序 + 選單顯示名 + 是否預設顯示(一般 user 常用)</summary>
|
||||
private static readonly (string Key, string Label, bool Default)[] Registry =
|
||||
{
|
||||
("Import", "QuickBar.Import", true),
|
||||
@@ -72,7 +74,7 @@ public partial class QuickBarViewModel : ObservableObject
|
||||
|
||||
private readonly Dictionary<string, QuickBarItemViewModel> _byKey;
|
||||
|
||||
/// <summary>All buttons in quick bar order.</summary>
|
||||
/// <summary>依快速列順序的全部項目(選單自訂清單用)</summary>
|
||||
public IReadOnlyList<QuickBarItemViewModel> Items { get; }
|
||||
|
||||
public QuickBarViewModel()
|
||||
@@ -81,10 +83,10 @@ public partial class QuickBarViewModel : ObservableObject
|
||||
_byKey = Items.ToDictionary(i => i.Key);
|
||||
}
|
||||
|
||||
/// <summary>Indexer used by XAML visibility bindings.</summary>
|
||||
/// <summary>XAML 索引子綁定入口:QuickBar[Point].IsChecked</summary>
|
||||
public QuickBarItemViewModel this[string key] => _byKey[key];
|
||||
|
||||
/// <summary>Loads saved visibility keys; null retains defaults.</summary>
|
||||
/// <summary>套用保存的勾選清單;null = 維持預設(含未知 key 容錯:新版本新增按鈕用預設值)</summary>
|
||||
public void Load(List<string>? visibleKeys)
|
||||
{
|
||||
if (visibleKeys is null) return;
|
||||
@@ -93,7 +95,7 @@ public partial class QuickBarViewModel : ObservableObject
|
||||
item.IsChecked = set.Contains(item.Key);
|
||||
}
|
||||
|
||||
/// <summary>Selected keys saved in settings.json.</summary>
|
||||
/// <summary>目前勾選清單(存 settings.json)</summary>
|
||||
public List<string> ToKeys() =>
|
||||
Items.Where(i => i.IsChecked).Select(i => i.Key).ToList();
|
||||
|
||||
|
||||
@@ -5,17 +5,18 @@ using STPViewer.Services;
|
||||
namespace STPViewer.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Options and live size estimate for the STL export dialog.
|
||||
/// STL 匯出參數對話框的 ViewModel:範圍 / 格式 / 單位縮放 / 網格精度 + 即時摘要(三角形數、預估大小)。
|
||||
/// 對話框按「匯出」後由 MainViewModel.Export 讀取選項執行。
|
||||
/// </summary>
|
||||
public partial class StlExportViewModel : ObservableObject
|
||||
{
|
||||
/// <summary>Number of visible files available for export.</summary>
|
||||
/// <summary>可見(可匯出)檔案數</summary>
|
||||
public int FileCount { get; }
|
||||
|
||||
/// <summary>Triangle count of the current display meshes.</summary>
|
||||
/// <summary>目前顯示網格的三角形總數(精細/較粗會重新三角化,實際數量以結果為準)</summary>
|
||||
public long TriangleCount { get; }
|
||||
|
||||
/// <summary>Whether B-rep geometry is available for retessellation.</summary>
|
||||
/// <summary>是否有 B-rep 來源(全 STL/DXF 時精度選項無意義 → 停用)</summary>
|
||||
public bool HasBrep { get; }
|
||||
|
||||
public StlExportViewModel(int fileCount, long triangleCount, bool hasBrep)
|
||||
@@ -25,17 +26,17 @@ public partial class StlExportViewModel : ObservableObject
|
||||
HasBrep = hasBrep;
|
||||
}
|
||||
|
||||
/// <summary>0 = merged STL; 1 = one STL per visible file.</summary>
|
||||
/// <summary>0 = 全部可見合併成單一 STL;1 = 每個可見檔案各一個 STL</summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(Summary))]
|
||||
private int scopeIndex;
|
||||
|
||||
/// <summary>0 = binary; 1 = ASCII.</summary>
|
||||
/// <summary>0 = Binary(建議,檔案小);1 = ASCII(文字可讀,檔案大)</summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(Summary))]
|
||||
private int formatIndex;
|
||||
|
||||
/// <summary>0 = millimeters; 1 = inches; 2 = custom scale.</summary>
|
||||
/// <summary>0 = mm(1:1);1 = inch(÷25.4);2 = 自訂縮放</summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(Summary))]
|
||||
[NotifyPropertyChangedFor(nameof(IsCustomScale))]
|
||||
@@ -47,7 +48,7 @@ public partial class StlExportViewModel : ObservableObject
|
||||
[NotifyPropertyChangedFor(nameof(CanConfirm))]
|
||||
private string customScaleText = "1.0";
|
||||
|
||||
/// <summary>0 = current mesh; 1 = finer B-rep tessellation.</summary>
|
||||
/// <summary>0 = 目前顯示網格;1 = 精細(B-rep 重新三角化)</summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(Summary))]
|
||||
private int qualityIndex;
|
||||
@@ -58,7 +59,7 @@ public partial class StlExportViewModel : ObservableObject
|
||||
public bool PerFile => ScopeIndex == 1;
|
||||
public StlMeshQuality Quality => (StlMeshQuality)QualityIndex;
|
||||
|
||||
/// <summary>Coordinate scale, or null for an invalid custom value.</summary>
|
||||
/// <summary>座標縮放;自訂值無效時回 null(擋確認)</summary>
|
||||
public double? Scale => UnitIndex switch
|
||||
{
|
||||
0 => 1.0,
|
||||
|
||||
@@ -250,6 +250,10 @@
|
||||
"Ui.About.Release071.Change1": "• Large STL mesh files with millions of triangles now open almost instantly instead of taking several minutes.",
|
||||
"Ui.About.Release071.Change2": "• For example, a 75 MB STL with 1.5 million triangles now appears in under a second.",
|
||||
"Ui.About.Release071.Title": "Open Large STL Files in Seconds",
|
||||
"Ui.About.Release080.Change1": "• Choose English, 繁體中文 or 简体中文 from View → Language or the quick toolbar. The whole interface switches instantly — including measurements already on screen — and your choice is remembered.",
|
||||
"Ui.About.Release080.Change2": "• Languages are plain files in the i18n folder next to the program: drop in a new one and it shows up in the menu, no update needed. Anything not yet translated falls back to English.",
|
||||
"Ui.About.Release080.Change3": "• Thanks to @CarmJos for contributing this feature.",
|
||||
"Ui.About.Release080.Title": "Switch Languages: English, Traditional & Simplified Chinese",
|
||||
"Ui.About.Technology": ".NET 8 · WPF · C#; CADability (STEP / B-rep geometry); HelixToolkit (3D display and camera)",
|
||||
"Ui.About.TechnologyTitle": "🔧 Technology",
|
||||
"Ui.About.Title": "About STPViewer",
|
||||
|
||||
@@ -250,6 +250,10 @@
|
||||
"Ui.About.Release071.Change1": "• 打开大型 STL 网格文件(数十 MB、上百万三角形)现在几乎瞬间完成,不用再等好几分钟。",
|
||||
"Ui.About.Release071.Change2": "• 例如一个 75 MB、150 万个三角形的 STL,以前要等到天荒地老,现在不到 1 秒就显示出来。",
|
||||
"Ui.About.Release071.Title": "大型 STL 秒开",
|
||||
"Ui.About.Release080.Change1": "• 在「视图 → 语言」或快速栏即可切换 English、繁體中文、简体中文,整个界面立即更换(连画面上已经测量好的结果也会跟着换),下次打开会记住。",
|
||||
"Ui.About.Release080.Change2": "• 语言是程序旁 i18n 文件夹里的普通文件:放一个新的进去,菜单就多一种语言,无需更新程序。尚未翻译的文字会先以英文显示。",
|
||||
"Ui.About.Release080.Change3": "• 感谢 @CarmJos 贡献这项功能。",
|
||||
"Ui.About.Release080.Title": "语言切换:英文/繁体中文/简体中文",
|
||||
"Ui.About.Technology": ".NET 8 · WPF · C#;CADability(STEP / B-rep 几何内核);HelixToolkit(3D 显示与相机)",
|
||||
"Ui.About.TechnologyTitle": "🔧 技术栈",
|
||||
"Ui.About.Title": "关于 STPViewer",
|
||||
|
||||
@@ -250,6 +250,10 @@
|
||||
"Ui.About.Release071.Change1": "• 開啟大型 STL 網格檔(數十 MB、上百萬三角形)現在幾乎瞬間完成,不用再等好幾分鐘。",
|
||||
"Ui.About.Release071.Change2": "• 例如一個 75 MB、150 萬個三角形的 STL,以前要等到天荒地老,現在不到 1 秒就顯示出來。",
|
||||
"Ui.About.Release071.Title": "大型 STL 秒開",
|
||||
"Ui.About.Release080.Change1": "• 到「視圖 → 語言」或快速列就能切換 English、繁體中文、简体中文,整個介面立刻換掉(連畫面上已經量好的結果也會跟著換),下次開啟會記住。",
|
||||
"Ui.About.Release080.Change2": "• 語言是程式旁 i18n 資料夾裡的一般檔案:放一個新的進去,選單就多一種語言,不用更新程式。還沒翻譯到的文字會先用英文顯示。",
|
||||
"Ui.About.Release080.Change3": "• 感謝 @CarmJos 貢獻這項功能。",
|
||||
"Ui.About.Release080.Title": "語言切換:英文/繁體中文/簡體中文",
|
||||
"Ui.About.Technology": ".NET 8 · WPF · C#;CADability(STEP / B-rep 幾何核心);HelixToolkit(3D 顯示與相機)",
|
||||
"Ui.About.TechnologyTitle": "🔧 技術棧",
|
||||
"Ui.About.Title": "關於 STPViewer",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Three-point alignment checks: known transform, Matrix3D/ModOp agreement, and collinear rejection.
|
||||
// 三點對齊剛體變換數學驗證:
|
||||
// (1) 已知變換還原 (2) Matrix3D ↔ ModOp 兩種表示一致 (3) 共線拒絕
|
||||
using System.Windows.Media.Media3D;
|
||||
using STPViewer.Services;
|
||||
|
||||
@@ -10,29 +11,29 @@ internal static class AlignTest
|
||||
{
|
||||
int failures = 0;
|
||||
|
||||
// Recover a known 90-degree Z rotation followed by translation (5,-3,2).
|
||||
// ── (1) 已知剛體變換:繞 Z 軸 90° + 平移 (5,-3,2),三點求解後應還原同一變換 ──
|
||||
var truth = Matrix3D.Identity;
|
||||
truth.RotateAt(new Quaternion(new Vector3D(0, 0, 1), 90), new Point3D(2, 1, 0));
|
||||
truth.Translate(new Vector3D(5, -3, 2));
|
||||
|
||||
var p1 = new Point3D(0, 0, 0);
|
||||
var p2 = new Point3D(10, 0, 0);
|
||||
var p3 = new Point3D(0, 7, 3); // Not collinear with the first two points.
|
||||
var p3 = new Point3D(0, 7, 3); // 不共線、不在同平面軸上
|
||||
Point3D q1 = truth.Transform(p1), q2 = truth.Transform(p2), q3 = truth.Transform(p3);
|
||||
|
||||
bool ok = RigidAlign.TryRigidTransform(p1, p2, p3, q1, q2, q3, out Matrix3D m);
|
||||
failures += Check("three-point transform solved", ok, true);
|
||||
|
||||
// Corresponding points must coincide.
|
||||
// 三個對應點要精確貼合
|
||||
failures += CheckPoint("p1→q1", m.Transform(p1), q1);
|
||||
failures += CheckPoint("p2→q2", m.Transform(p2), q2);
|
||||
failures += CheckPoint("p3→q3", m.Transform(p3), q3);
|
||||
|
||||
// An independent fourth point checks the full rigid transform.
|
||||
// 剛體變換唯一性:任意第 4 點也應該跟著正確變換
|
||||
var r = new Point3D(-4, 12, 8);
|
||||
failures += CheckPoint("fourth point", m.Transform(r), truth.Transform(r));
|
||||
|
||||
// CADability ModOp and WPF Matrix3D must transform points identically.
|
||||
// ── (2) ToModOp:CADability ModOp 與 WPF Matrix3D 對同一點要算出同樣結果 ──
|
||||
CADability.ModOp op = RigidAlign.ToModOp(m);
|
||||
foreach (Point3D p in new[] { p1, p2, p3, r, new Point3D(1.5, -2.25, 9) })
|
||||
{
|
||||
@@ -47,7 +48,7 @@ internal static class AlignTest
|
||||
}
|
||||
System.Console.WriteLine($" {(failures == 0 ? "PASS" : "----")}: ModOp/Matrix3D agree at five points");
|
||||
|
||||
// Collinear source points must be rejected.
|
||||
// ── (3) 共線拒絕 ──
|
||||
bool collinearRejected = !RigidAlign.TryRigidTransform(
|
||||
new Point3D(0, 0, 0), new Point3D(1, 0, 0), new Point3D(2, 0, 0), // Collinear.
|
||||
q1, q2, q3, out _);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Section clipping checks the retained side and triangle area.
|
||||
// 剖面裁切數學驗證:對單位幾何裁切,檢查保留側與面積守恆
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Media3D;
|
||||
using STPViewer.Services;
|
||||
@@ -11,7 +11,7 @@ internal static class ClipTest
|
||||
{
|
||||
int failures = 0;
|
||||
|
||||
// A single triangle with area 50.
|
||||
// 單一三角形 (0,0,0)(10,0,0)(0,10,0),面積 50
|
||||
var mesh = new MeshGeometry3D
|
||||
{
|
||||
Positions = new Point3DCollection
|
||||
@@ -22,25 +22,25 @@ internal static class ClipTest
|
||||
};
|
||||
mesh.Freeze();
|
||||
|
||||
// A plane at x=20 retains the full triangle.
|
||||
// case 1: 平面 x=20,全保留
|
||||
var m1 = SectionService.ClipMesh(mesh, new Point3D(20, 0, 0), new Vector3D(1, 0, 0));
|
||||
failures += Check("fully retained", Area(m1), 50.0);
|
||||
|
||||
// A plane at x=-1 removes the full triangle.
|
||||
// case 2: 平面 x=-1,全裁掉
|
||||
var m2 = SectionService.ClipMesh(mesh, new Point3D(-1, 0, 0), new Vector3D(1, 0, 0));
|
||||
failures += Check("fully removed", Area(m2), 0.0);
|
||||
|
||||
// At x=5, the retained trapezoid has area 50 - 12.5 = 37.5.
|
||||
// case 3: 平面 x=5 → 保留 x≤5:梯形面積 = 50 - 右側小三角形(底5高5)/2 = 50 - 12.5 = 37.5
|
||||
var m3 = SectionService.ClipMesh(mesh, new Point3D(5, 0, 0), new Vector3D(1, 0, 0));
|
||||
failures += Check("half clipped (x<=5)", Area(m3), 37.5);
|
||||
foreach (Point3D p in m3.Positions)
|
||||
if (p.X > 5 + 1e-9) { Console.WriteLine($" FAIL: vertex x={p.X} is outside retained side"); failures++; }
|
||||
|
||||
// Reverse the plane normal to retain the smaller triangle.
|
||||
// case 4: 反向(保留 x≥5)→ 12.5
|
||||
var m4 = SectionService.ClipMesh(mesh, new Point3D(5, 0, 0), new Vector3D(-1, 0, 0));
|
||||
failures += Check("half clipped (x>=5)", Area(m4), 12.5);
|
||||
|
||||
// Clip a line segment.
|
||||
// case 5: 線段裁切
|
||||
var segs = new Point3DCollection { new(0, 0, 0), new(10, 0, 0) };
|
||||
Point3DCollection s1 = SectionService.ClipSegments(segs, new Point3D(4, 0, 0), new Vector3D(1, 0, 0));
|
||||
double segLen = s1.Count == 2 ? (s1[1] - s1[0]).Length : -1;
|
||||
|
||||
@@ -5,13 +5,13 @@ using System.Linq;
|
||||
using CADability;
|
||||
using CADability.GeoObject;
|
||||
using STPViewer.Services;
|
||||
using IOPath = System.IO.Path; // Avoid the CADability.GeoObject.Path name collision.
|
||||
using IOPath = System.IO.Path; // CADability.GeoObject.Path(曲線)撞名
|
||||
|
||||
namespace SmokeTest;
|
||||
|
||||
/// <summary>
|
||||
/// Headless STEP export round trip: import, export through the same CADability path,
|
||||
/// then import the result and compare solid counts.
|
||||
/// STEP 匯出往返驗證(v0.5.0 匯出 STEP 功能的無 UI 驗證):
|
||||
/// 匯入來源 STEP → 走與 MainViewModel.ExportStepFile 相同路徑寫出新檔 → 重新匯入比對實體數。
|
||||
/// </summary>
|
||||
public static class ExportTest
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Interference checks for intersecting, separated, and touching meshes.
|
||||
// 干涉檢查數學驗證:相交 / 分離 / 貼合三種情境
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Media3D;
|
||||
using STPViewer.Services;
|
||||
@@ -11,14 +11,14 @@ internal static class InterferenceTest
|
||||
{
|
||||
int failures = 0;
|
||||
|
||||
// Two tetrahedra overlap when B is offset by 5 along X.
|
||||
// 兩個互穿的四面體(B 比 A 平移 +5,邊長 10 → 必相交)
|
||||
MeshGeometry3D a = Tetra(new Vector3D(0, 0, 0));
|
||||
MeshGeometry3D b = Tetra(new Vector3D(5, 0, 0));
|
||||
InterferenceResult r1 = InterferenceService.Check(new[] { a }, new[] { b });
|
||||
failures += Check("overlap intersects", r1.Intersects, true);
|
||||
failures += Check("overlap has intersection segments", r1.Segments.Count > 0, true);
|
||||
|
||||
// B at X=30 leaves a gap of about 20.
|
||||
// 分離(B 平移 +30,gap 應 ≈ 30 - 10 = 20)
|
||||
MeshGeometry3D c = Tetra(new Vector3D(30, 0, 0));
|
||||
InterferenceResult r2 = InterferenceService.Check(new[] { a }, new[] { c });
|
||||
failures += Check("separated meshes do not intersect", r2.Intersects, false);
|
||||
@@ -26,7 +26,7 @@ internal static class InterferenceTest
|
||||
Console.WriteLine($" {(gapOk ? "PASS" : "FAIL")}: separated gap = {r2.GapDistance:F4} (expected ~20)");
|
||||
if (!gapOk) failures++;
|
||||
|
||||
// B at X=10 touches a vertex without penetrating A.
|
||||
// 貼合(B 平移 +10,頂點剛好接觸 → 不算穿透、gap ≈ 0)
|
||||
MeshGeometry3D d = Tetra(new Vector3D(10, 0, 0));
|
||||
InterferenceResult r3 = InterferenceService.Check(new[] { a }, new[] { d });
|
||||
failures += Check("touching is not penetration", r3.Intersects, false);
|
||||
@@ -45,7 +45,7 @@ internal static class InterferenceTest
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
|
||||
/// <summary>A tetrahedron with side length 10, translated by offset.</summary>
|
||||
/// <summary>邊長 10 的四面體,offset 平移</summary>
|
||||
private static MeshGeometry3D Tetra(Vector3D offset)
|
||||
{
|
||||
Point3D P(double x, double y, double z) => new Point3D(x, y, z) + offset;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Generate a small standard DXF test file with the bundled netDxf library.
|
||||
// 測試輔助:用 CADability 內嵌的 netDxf 產生標準 DXF 測試檔
|
||||
using netDxf;
|
||||
using netDxf.Entities;
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@ using System.IO;
|
||||
|
||||
namespace SmokeTest;
|
||||
|
||||
/// <summary>Generate a binary STL grid with two triangles per cell and noncoplanar vertices.</summary>
|
||||
/// <summary>產生測試用 binary STL:一片 gridN×gridN 的網格,每格 2 個三角形(波浪起伏,避免全共面)。</summary>
|
||||
internal static class MakeStl
|
||||
{
|
||||
public static void Run(string path, int triCount)
|
||||
{
|
||||
// Two triangles per cell; use a square grid large enough for the requested count.
|
||||
// 每格 2 三角,格數 = triCount/2;邊長 grid = ceil(sqrt(cells))
|
||||
int cells = System.Math.Max(1, triCount / 2);
|
||||
int grid = (int)System.Math.Ceiling(System.Math.Sqrt(cells));
|
||||
int tris = grid * grid * 2;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Headless smoke tests for CAD import, triangulation, and the assembly tree.
|
||||
// 無 UI smoke test:驗證 CAD 檔解析 → 三角化 → 裝配樹 整條匯入管線
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using STPViewer.Services;
|
||||
|
||||
@@ -5,13 +5,15 @@ using System.Text;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Media3D;
|
||||
using STPViewer.Services;
|
||||
using IOPath = System.IO.Path; // Avoid the CADability.GeoObject.Path name collision.
|
||||
using IOPath = System.IO.Path; // CADability.GeoObject.Path(曲線)撞名
|
||||
|
||||
namespace SmokeTest;
|
||||
|
||||
/// <summary>
|
||||
/// Headless STL export checks:
|
||||
/// binary round trip, ASCII facet structure, and degenerate triangle filtering.
|
||||
/// STL 匯出驗證(無 UI):
|
||||
/// 1) binary — 已知立方體網格寫出 → 解析 header/三角形數/座標範圍比對(含縮放)
|
||||
/// 2) ASCII — facet 數與 solid/endsolid 結構
|
||||
/// 3) 退化三角形(零面積)自動濾除
|
||||
/// </summary>
|
||||
public static class StlExportTest
|
||||
{
|
||||
@@ -22,9 +24,9 @@ public static class StlExportTest
|
||||
string dir = IOPath.Combine(IOPath.GetTempPath(), "stpviewer_stl_test");
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
MeshGeometry3D cube = BuildCube(10); // 10x10x10 mm, 12 triangles.
|
||||
MeshGeometry3D cube = BuildCube(10); // 10×10×10 mm、12 三角形
|
||||
|
||||
// Binary triangle count and scaled coordinate range.
|
||||
// 1) binary:三角形數 + 縮放後座標範圍
|
||||
{
|
||||
string f = IOPath.Combine(dir, "cube_bin.stl");
|
||||
double scale = 1.0 / 25.4; // mm to inches.
|
||||
@@ -35,7 +37,7 @@ public static class StlExportTest
|
||||
Report(ref failed, ok, $"binary round trip: 12 triangles, inch range [{min:F4}, {max:F4}]");
|
||||
}
|
||||
|
||||
// ASCII facet count and structure.
|
||||
// 2) ASCII:facet 數 + 結構
|
||||
{
|
||||
string f = IOPath.Combine(dir, "cube_ascii.stl");
|
||||
int written = StlExportService.Write(f, new[] { cube }, ascii: true, scale: 1.0);
|
||||
@@ -47,12 +49,12 @@ public static class StlExportTest
|
||||
Report(ref failed, ok, $"ASCII structure valid, facets = {facets}");
|
||||
}
|
||||
|
||||
// A zero-area triangle must be filtered from the export.
|
||||
// 3) 退化三角形濾除:加一個零面積三角形 → 仍是 12
|
||||
{
|
||||
var degenerate = new MeshGeometry3D();
|
||||
degenerate.Positions.Add(new Point3D(0, 0, 0));
|
||||
degenerate.Positions.Add(new Point3D(1, 1, 1));
|
||||
degenerate.Positions.Add(new Point3D(2, 2, 2)); // Collinear: zero area.
|
||||
degenerate.Positions.Add(new Point3D(2, 2, 2)); // 共線 → 面積 0
|
||||
degenerate.TriangleIndices.Add(0);
|
||||
degenerate.TriangleIndices.Add(1);
|
||||
degenerate.TriangleIndices.Add(2);
|
||||
@@ -65,7 +67,8 @@ public static class StlExportTest
|
||||
Report(ref failed, ok, $"degenerate triangle filtered ({written} written)");
|
||||
}
|
||||
|
||||
// Optional real STEP input: retessellate B-rep geometry at fine precision.
|
||||
// 4)(可選)真實 STEP:B-rep 精細重新三角化 → 寫 binary STL 驗證
|
||||
//(不測「較粗」:CADability GetTriangulation 對比快取粗的精度回傳既有快取,粗化無效 → 功能已移除)
|
||||
if (stepPath is not null)
|
||||
{
|
||||
var data = new StepImportService().Import(stepPath);
|
||||
@@ -80,7 +83,7 @@ public static class StlExportTest
|
||||
(uint count, _, _) = ReadBinary(f);
|
||||
Report(ref failed, meshes.Count > 0 && written > 0 && count == written,
|
||||
$"fine retessellation: {meshes.Count} faces, {written:N0} triangles");
|
||||
// CADability's triangle count is not monotonic in precision; 0.15x is measurably finer.
|
||||
// CADability 三角形數對精度非單調(0.4× 反而比 1× 少),Fine=0.15× 實測明顯更細
|
||||
Report(ref failed, written > importTris,
|
||||
$"fine ({written:N0}) > imported default ({importTris:N0})");
|
||||
}
|
||||
@@ -103,7 +106,7 @@ public static class StlExportTest
|
||||
if (!ok) failed++;
|
||||
}
|
||||
|
||||
/// <summary>Axis-aligned cube from 0 to size, with 12 triangles.</summary>
|
||||
/// <summary>0..size 的軸對齊立方體,12 個三角形(法向不講究,STL 寫出時自算)</summary>
|
||||
private static MeshGeometry3D BuildCube(double s)
|
||||
{
|
||||
var m = new MeshGeometry3D();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Print the STEP object hierarchy after import with HierarchyToBlocks enabled.
|
||||
// 實驗工具:印出 STEP 匯入後的物件階層(HierarchyToBlocks=true)
|
||||
using CADability;
|
||||
using CADability.GeoObject;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user