feat: Initial release v0.1.0
STP/STEP 3D viewer (WPF, .NET 8) - CADability + HelixToolkit.Wpf: - Multi-file import (STEP/STL/DXF) with STEP assembly tree (per-node visibility/color cascade, zoom-to) - Measurements: point / distance / edge / face / circle / angle / face-to-face distance (B-rep exact where available) - Assembly verification: two-point align (translate), 3-point align (rotate+translate via RigidAlign), axis rotate 90, interference check (tri-tri intersection + min gap) - Section view (CPU mesh clipping, originals preserved) - mm/inch toggle, CSV export (UTF-8 BOM), 2x PNG screenshot - Performance: per-file merged edge lines, per-leaf merged mesh in browse mode, no BackMaterial on closed solids, edge suspend during camera interaction, parallel leaf triangulation with retry - SmokeTest CLI: import pipeline, --tree, --clip-test, --interference-test, --align-test (all passing) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+15
@@ -0,0 +1,15 @@
|
||||
# .NET
|
||||
bin/
|
||||
obj/
|
||||
publish/
|
||||
*.user
|
||||
.vs/
|
||||
|
||||
# secret/ 全部忽略,但保留 README 與範本
|
||||
secret/*
|
||||
!secret/README.md
|
||||
!secret/*.example
|
||||
!secret/.gitkeep
|
||||
|
||||
# AI 協作素材不入 git
|
||||
For_AI/
|
||||
+326
@@ -0,0 +1,326 @@
|
||||
# STPViewer — Architecture
|
||||
|
||||
> STP/STEP 3D 檢視器:多檔匯入、圖層管理、點/線/面/圓量測(C# .NET 8 WPF 桌面程式)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
STPViewer 是一套 Windows 桌面 3D CAD 檢視工具,給硬體 / SI 工程師快速開啟機構件的
|
||||
STEP(`.stp` / `.step`)檔案做確認與量測,不需要安裝 SolidWorks / Creo 等重量級 CAD。
|
||||
|
||||
核心價值:
|
||||
|
||||
- **多檔匯入**:一次載入多個 CAD 檔(STEP / STL / DXF),每個檔案自成一棵樹
|
||||
- **裝配樹**:STEP product structure 還原成樹狀節點(組件→零件),逐節點 顯示/隱藏、換色、Zoom-to
|
||||
- **量測**:點座標、兩點距離、邊長、面(面積/類型/法向量)、圓(心/半徑/直徑/周長)、
|
||||
兩面/兩邊夾角、面到面最短距離;單位 mm ⇄ inch 即時切換
|
||||
- **剖面**:X/Y/Z 軸向剖切,位置滑桿 + 反向,CPU 網格裁切(量測不受影響)
|
||||
- **匯出**:量測結果 CSV、3D 視圖 PNG 截圖(2x)
|
||||
- 單機離線執行,無網路相依
|
||||
|
||||
使用者:單一桌面使用者(無登入 / 角色系統)。
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology | 說明 |
|
||||
|---|---|---|
|
||||
| Runtime | .NET 8(`net8.0-windows`) | 桌面 WPF |
|
||||
| UI Framework | WPF + MVVM(CommunityToolkit.Mvvm) | |
|
||||
| 3D 渲染 / 拾取 | **HelixToolkit.Wpf** | Viewport3D 封裝、相機操作、HitTest |
|
||||
| STEP 解析 / 幾何核心 | **CADability**(純 C#,netstandard2.0) | STEP B-rep 匯入、Face 三角化、邊/面幾何查詢 |
|
||||
| 量測幾何運算 | CADability(B-rep 精確值)+ 網格近似(面積) | |
|
||||
| 打包 | `dotnet publish -c Release` | 免安裝、單資料夾 |
|
||||
|
||||
> 技術選型備註:OpenCASCADE 的 .NET wrapper(Macad.Occt 等)不在 NuGet 上,
|
||||
> 自建 C++/CLI wrapper 成本過高;CADability 是純 C# 的 CAD kernel(MIT),
|
||||
> 內建 STEP reader 與三角化,與 .NET 8 相容(netstandard2.0),故採用。
|
||||
|
||||
---
|
||||
|
||||
## Architecture Diagram
|
||||
|
||||
```
|
||||
┌────────────────────────── MainWindow (WPF) ──────────────────────────┐
|
||||
│ Toolbar(匯入/量測模式) LayerPanel HelixViewport3D 量測結果面板 │
|
||||
└──────────────┬───────────────┬───────────────┬───────────────────────┘
|
||||
│ ICommand │ binding │ MouseDown(HitTest)
|
||||
▼ ▼ ▼
|
||||
┌──────────────────────── MainViewModel (MVVM) ────────────────────────┐
|
||||
│ Layers: ObservableCollection<LayerItemViewModel> │
|
||||
│ Measurements: ObservableCollection<MeasurementResult> │
|
||||
│ CurrentMode: None/Point/Distance/Edge/Face/Circle │
|
||||
└───────┬──────────────────────────────┬───────────────────────────────┘
|
||||
▼ ▼
|
||||
┌─ StepImportService ─────────┐ ┌─ MeasurementService ───────────────┐
|
||||
│ CADability ImportStep.Read │ │ Hit → GeometryModel3D → FaceInfo │
|
||||
│ Solid→Shell→Face 三角化 │ │ 點: 頂點吸附 / 表面點 │
|
||||
│ Edge 取樣折線(輪廓線) │ │ 邊: 最近 Edge → Line/Ellipse 判型 │
|
||||
│ → MeshGeometry3D + FaceInfo │ │ 面: 面積(網格Σ)+Surface 類型 │
|
||||
└─────────────────────────────┘ │ 圓: 圓形 Edge → 心/半徑/直徑/周長 │
|
||||
└────────────────────────────────────┘
|
||||
```
|
||||
|
||||
資料流:`*.stp → CADability B-rep → 三角網格(渲染) + B-rep 參照(量測) → Helix Viewport`
|
||||
|
||||
每個 Face 對應一個 `GeometryModel3D`,並登錄到 `Dictionary<GeometryModel3D, FaceInfo>`,
|
||||
HitTest 命中後可反查回 B-rep Face / Edge 做精確量測。
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
STPViewer/
|
||||
│
|
||||
├── CLAUDE.md # 專案記憶 & 給 Claude 的指令
|
||||
├── README.md # 快速上手
|
||||
├── ARCHITECTURE.md # 本文件
|
||||
├── .gitignore # 含 secret/ 與 For_AI/ 規則
|
||||
│
|
||||
├── docs/
|
||||
│ ├── decisions/ # 技術決策紀錄(ADR)
|
||||
│ └── runbooks/ # 操作手冊
|
||||
│
|
||||
├── secret/ # 🚫 gitignored(README.md / *.example 除外)
|
||||
│ ├── README.md
|
||||
│ ├── run-STPViewer.ps1.example
|
||||
│ └── run-STPViewer.sh.example
|
||||
│
|
||||
├── For_AI/ # 🚫 gitignored — AI 協作素材
|
||||
│
|
||||
├── STPViewer.sln
|
||||
├── *.stp # 根目錄現有測試模型(Amphenol connector)
|
||||
│
|
||||
└── src/
|
||||
└── STPViewer/
|
||||
├── STPViewer.csproj # net8.0-windows, UseWPF
|
||||
├── App.xaml / App.xaml.cs
|
||||
├── MainWindow.xaml / .cs # 版面 + 滑鼠拾取事件
|
||||
│
|
||||
├── Models/
|
||||
│ ├── FaceInfo.cs # GeometryModel3D ↔ B-rep Face 對照(STL 為 null)
|
||||
│ ├── MeasureMode.cs # enum: None/Point/Distance/Edge/Face/Circle/Angle/FaceDistance/Align/Align3/Interference
|
||||
│ ├── MeasurementResult.cs # 量測結果(雙單位 lambda、3D 標籤同步)
|
||||
│ └── UnitSystem.cs # mm/inch + Units 格式化
|
||||
│
|
||||
├── Services/
|
||||
│ ├── StepImportService.cs # STEP/STL/DXF 讀檔 + 三角化 + 裝配樹
|
||||
│ ├── MeasurementService.cs # 點/線/面/圓/角度/面距 幾何計算
|
||||
│ ├── InterferenceService.cs # 干涉檢查:三角形-三角形相交(區間法)+均勻網格加速;無干涉時近似最小間隙
|
||||
│ ├── RigidAlign.cs # 三點對齊/旋轉的剛體變換數學(Matrix3D列向量 ↔ ModOp行向量 轉換)
|
||||
│ └── SectionService.cs # 剖面:網格/線段半空間裁切
|
||||
│
|
||||
└── ViewModels/
|
||||
├── MainViewModel.cs # 樹集合、量測集合、剖面、單位、匯出
|
||||
└── ModelNodeViewModel.cs # 裝配樹節點(可見性/顏色 cascade)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Models
|
||||
|
||||
桌面程式無資料庫;核心為記憶體內模型:
|
||||
|
||||
```csharp
|
||||
// 一個匯入檔 = 一個圖層
|
||||
class LayerItemViewModel
|
||||
{
|
||||
string Name; // 檔名(不含路徑)
|
||||
string FilePath;
|
||||
bool IsVisible; // 切換 viewport 中的 ModelVisual3D
|
||||
Color Color; // 圖層色(換色重建材質)
|
||||
int SolidCount, FaceCount, TriangleCount;
|
||||
ModelVisual3D BodyVisual; // 面網格
|
||||
ModelVisual3D EdgeVisual; // 輪廓線
|
||||
Rect3D Bounds; // Zoom-to 用
|
||||
}
|
||||
|
||||
// HitTest 反查:渲染物件 → B-rep
|
||||
class FaceInfo
|
||||
{
|
||||
object Face; // CADability Face(量測用 B-rep)
|
||||
LayerItemViewModel Owner;
|
||||
MeshGeometry3D Mesh; // 面積近似 / 頂點吸附
|
||||
}
|
||||
|
||||
enum MeasureMode { None, Point, Distance, Edge, Face, Circle,
|
||||
Angle, FaceDistance, Align, Interference }
|
||||
|
||||
class MeasurementResult
|
||||
{
|
||||
MeasureMode Kind;
|
||||
string Title; // "P1 (12.30, 4.50, 0.00)"
|
||||
string Detail; // 多行明細(Δ、半徑、面積…)
|
||||
List<Visual3D> Overlays; // 視圖中的標記(刪除量測時一併移除)
|
||||
}
|
||||
```
|
||||
|
||||
單位:STEP 內部以 mm 為準(CADability 匯入時依檔內單位換算),UI 顯示 mm。
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
| 功能 | 操作 | 輸出 |
|
||||
|---|---|---|
|
||||
| 匯入 STP | 工具列「匯入」(可複選) / 拖放檔案 | 新圖層 + 自動 ZoomExtents |
|
||||
| 圖層 | 面板勾選顯示、換色、Zoom-to、移除 | 即時反映於 3D 視圖 |
|
||||
| 量測-點 | 模式「點」+ 點擊模型 | 座標(優先吸附頂點/邊端點) |
|
||||
| 量測-距離 | 模式「距離」+ 點兩下 | 直線距離 + ΔX/ΔY/ΔZ + 視圖連線 |
|
||||
| 量測-邊 | 模式「邊」+ 點擊邊附近 | 線段長/曲線長;圓弧附半徑 |
|
||||
| 量測-面 | 模式「面」+ 點擊面 | 面積、曲面類型、平面法向量/圓柱半徑 |
|
||||
| 量測-圓 | 模式「圓」+ 點擊圓孔邊緣 | 圓心、半徑、直徑、周長 + 視圖圓心標記 |
|
||||
| 量測-角度 | 模式「∠」+ 點兩個面(或靠近直線邊) | 夾角 + 補角(面取法向量、邊取方向) |
|
||||
| 量測-面距 | 模式「⇔」+ 點兩個面 | 面到面最短距離(網格近似)+ 最近點對連線 |
|
||||
| 對齊 | 模式「對齊」+ 點「要移動零件」上一點、再點目標點 | 純平移該檔案使點1貼到點2(B-rep 用 `ModOp` 整體位移,量測清空) |
|
||||
| 三點對齊 | 模式「三點」+ 來源檔 3 點、目標檔 3 對應點 | 旋轉+平移剛體變換(點1精確貼合、1→2 方向對齊、三點平面對齊;`RigidAlign`) |
|
||||
| 旋轉 | 樹面板選檔案 + 工具列 ↻X/↻Y/↻Z | 繞檔案中心 +90°(連按累加;方向不合時先轉正再對齊) |
|
||||
| 干涉 | 工具列「🧩 干涉」(需剛好 2 個可見檔案) | 相交→紅色交線+相交三角形對數;無相交→最小間隙 gap(≈0 即配合 match) |
|
||||
| 剖面 | 工具列「✂ 剖面」+ 軸向/位置/反向 | CPU 裁切渲染網格(原始幾何保留,量測仍精確) |
|
||||
| 單位 | 工具列 mm ⇄ in | 既有量測(清單+3D 標籤)即時換算 |
|
||||
| 匯出 | 💾 CSV / 📷 截圖 | UTF-8 BOM CSV;2x PNG |
|
||||
| 視圖 | 滑鼠右鍵旋轉/滾輪縮放/中鍵平移(Helix 預設)、ViewCube | |
|
||||
|
||||
支援格式:`.stp` / `.step`(B-rep + 裝配樹)、`.stl`(純網格,僅點/距離/角度/面距量測)、
|
||||
`.dxf`(線架構檢視)。**IGES 不支援**(CADability 無 IGES reader)。
|
||||
|
||||
---
|
||||
|
||||
## Key Constraints & Business Rules
|
||||
|
||||
1. 每個匯入檔案 = 一個圖層;同檔重複匯入產生新圖層(後綴 `(2)`)
|
||||
2. 量測一律以 **B-rep 幾何** 為準(邊長、圓半徑);僅「面積」用網格加總近似(三角化精度內)
|
||||
3. 隱藏圖層不參與 HitTest(量測不會打到看不見的東西)
|
||||
4. 移除圖層時,其上的量測 overlay 一併清除
|
||||
5. 三角化精度依物件尺寸自適應(對角線 × 0.0015,限 0.02–0.5 mm);大型組件匯入走背景執行緒,UI 不凍結
|
||||
6. 不寫入/修改原始 STP 檔(唯讀檢視器)
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- 純離線桌面工具,無網路、無帳號、無資料庫
|
||||
- 本專案目前無任何 runtime secret;`secret/` 仍依專案慣例建立:
|
||||
- `secret/*` 全部 gitignore,僅 `README.md`、`*.example` 進 git
|
||||
- `run-STPViewer.*.example` 為啟動腳本範本(本專案無 DB/ApiKey,腳本僅做 build+run)
|
||||
- 無 compile-time secret → 不需 `publish-*.example` 腳本
|
||||
- `For_AI/` 收 AI 協作素材(截圖、筆記),整夾 gitignore
|
||||
|
||||
---
|
||||
|
||||
## Build & Setup Steps
|
||||
|
||||
```bash
|
||||
cd E:/10_AI/STPViewer
|
||||
|
||||
# 還原 + 建置
|
||||
dotnet build STPViewer.sln -c Debug
|
||||
|
||||
# 執行
|
||||
dotnet run --project src/STPViewer
|
||||
|
||||
# 發佈(免安裝資料夾)
|
||||
dotnet publish src/STPViewer -c Release -o publish/STPViewer
|
||||
```
|
||||
|
||||
NuGet 相依(自動還原):`CADability`、`HelixToolkit.Wpf`、`CommunityToolkit.Mvvm`
|
||||
|
||||
---
|
||||
|
||||
## Development Phases
|
||||
|
||||
### Phase 1 — 專案骨架 + 3D 視窗(工作量:S)
|
||||
**目標:** 專案能跑起來,出現含 Helix 3D viewport 的主視窗
|
||||
**包含:**
|
||||
- [x] `STPViewer.sln` + `src/STPViewer/STPViewer.csproj`(net8.0-windows、UseWPF、NuGet 三件套)
|
||||
- [x] `MainWindow.xaml`:工具列 / 左側圖層面板 / 中央 `HelixViewport3D`(含 ViewCube、預設光源)/ 右側量測面板 / 底部狀態列
|
||||
- [x] `MainViewModel.cs` 空殼 + DataContext 接線
|
||||
**驗收條件:** `dotnet run` 開出主視窗,3D 區可旋轉縮放(空場景 + 格線)
|
||||
|
||||
### Phase 2 — STEP 匯入與渲染(工作量:L)
|
||||
**目標:** 可開啟單一 STP 並看到實體模型
|
||||
**包含:**
|
||||
- [x] `StepImportService.cs`:CADability `ImportStep` 讀檔 → Solid/Shell/Face 三角化 → `MeshGeometry3D`
|
||||
- [x] Face→`GeometryModel3D` 一對一、建 `FaceInfo` 對照字典
|
||||
- [x] Edge 取樣折線 → `LinesVisual3D` 輪廓線(CAD 外觀)
|
||||
- [x] 匯入走 `Task.Run`,完成後 UI 執行緒組 Visual + `ZoomExtents`
|
||||
- [x] 用根目錄 Amphenol STP 驗證
|
||||
**驗收條件:** 匯入 Amphenol STP 顯示正確 3D 模型(含輪廓線),視角操作流暢
|
||||
|
||||
### Phase 3 — 圖層系統(工作量:M)
|
||||
**目標:** 多檔匯入、各自成層、可管理
|
||||
**包含:**
|
||||
- [x] 「匯入」支援複選 + 檔案拖放
|
||||
- [x] `LayerItemViewModel.cs`:名稱、可見性 checkbox、色塊(調色盤換色)、統計(Solid/Face/三角形數)
|
||||
- [x] 圖層操作:顯示/隱藏(含 HitTest 排除)、Zoom-to、移除(連帶清 overlay)
|
||||
**驗收條件:** 匯入 2+ 個 STP,逐層開關/換色/移除皆即時生效
|
||||
|
||||
### Phase 4 — 量測功能(工作量:L)
|
||||
**目標:** 點 / 距離 / 邊 / 面 / 圓 五種量測可用
|
||||
**包含:**
|
||||
- [x] `MeasurementService.cs` + `MeasureMode` 工具列切換(互斥 toggle)
|
||||
- [x] 點:HitTest 命中點 + 頂點/邊端點吸附;距離:兩點 + ΔXYZ + 視圖連線
|
||||
- [x] 邊:命中面最近 Edge,`Line`→長度、`Ellipse(IsCircle)`→弧長+半徑、其他→曲線長
|
||||
- [x] 面:網格面積加總 + Surface 類型(平面法向量 / 圓柱半徑)
|
||||
- [x] 圓:搜尋最近圓形 Edge → 圓心/半徑/直徑/周長 + 圓心標記
|
||||
- [x] 量測結果面板:清單 + 單筆刪除 + 全部清除(overlay 同步移除)
|
||||
**驗收條件:** 對 Amphenol STP 可量出 pin 孔圓徑、殼體面積、兩點距離,數值合理
|
||||
|
||||
### Phase 5 — 整合收尾(工作量:S)
|
||||
**目標:** 穩定可交付
|
||||
**包含:**
|
||||
- [x] 錯誤處理(壞檔/非 STEP → 訊息列提示不閃退)、匯入進度提示
|
||||
- [x] 狀態列模式提示(「點選第 2 點…」)
|
||||
- [x] `README.md`、`CLAUDE.md` 完稿
|
||||
- [x] `dotnet publish -c Release` 驗證 + smoke test(無 UI 載檔驗證管線)
|
||||
**驗收條件:** Release 發佈資料夾雙擊可用;載入壞檔不閃退
|
||||
|
||||
---
|
||||
|
||||
## Development Phases — 第二輪(Future Extensions 實作,全部完成)
|
||||
|
||||
### Phase 6 — Future Extensions(工作量:L)
|
||||
- [x] 剖面(Section plane)檢視 — `SectionService` CPU 網格/線段裁切 + 軸向/位置/反向控制 + 半透明剖面指示
|
||||
- [x] 角度量測(兩面/兩邊夾角,含補角)、面到面最短距離(頂點→三角形雙向,網格近似)
|
||||
- [x] 量測結果匯出 CSV(UTF-8 BOM)/ 視圖 PNG 截圖(2x)
|
||||
- [x] 裝配樹(STEP `HierarchyToBlocks` product structure)取代「一檔一層」,節點層級 顯示/換色/Zoom
|
||||
- [x] STL / DXF 格式支援(IGES 落空:CADability 無 IGES reader,誠實不支援)
|
||||
- [x] 量測單位切換 mm ⇄ inch(清單與 3D 標籤即時換算,內部一律存 mm)
|
||||
|
||||
**驗收:** ClipTest 裁切數學 5 項全過;STEP/STL/DXF 三格式 smoke test 通過;UI 端到端存活
|
||||
|
||||
---
|
||||
|
||||
## Development Phases — 第三輪(裝配驗證,全部完成)
|
||||
|
||||
### Phase 7 — 配合 / 干涉驗證(工作量:M)
|
||||
- [x] 兩點對齊(`Align`)— 點「要移動零件」一點 + 目標點 → 純平移整個檔案使其貼合;
|
||||
B-rep 用 `CADability.ModOp.Translate` 對 Solid/Shell 整體 `Modify`,網格/邊線/邊界同步重建,量測清空
|
||||
- [x] 干涉檢查(`InterferenceService`)— 三角形-三角形相交(區間法回傳交線段)+ 均勻網格空間加速;
|
||||
相交→紅色交線 overlay;無相交→近似最小間隙 gap(共面貼合不算穿透)
|
||||
- [x] SmokeTest `--interference-test`:相交 / 分離(gap≈20) / 貼合(gap≈0) 三情境數學驗證
|
||||
|
||||
**驗收:** InterferenceTest 3 情境全過;兩件 STEP 勾選後可判定干涉或回報配合間隙
|
||||
|
||||
### Phase 8 — 旋轉對齊(工作量:M)
|
||||
- [x] 軸向旋轉 — 樹面板選檔案 + 工具列 ↻X/↻Y/↻Z,繞檔案 Bounds 中心 +90°(方向不合先轉正)
|
||||
- [x] 三點對齊(`Align3`)— 來源檔 3 特徵點 → 目標檔 3 對應點,解旋轉+平移剛體變換一次貼合
|
||||
- [x] `RigidAlign` 數學服務 — `TryRigidTransform`(座標架法)、`ToModOp`(WPF Matrix3D 列向量 ↔ CADability ModOp 行向量轉置)
|
||||
- [x] 通用 `TransformRoot`(取代平移專用路徑):B-rep ModOp Modify + 網格/合併網格/邊線重算 + `RecomputeBounds`
|
||||
- [x] SmokeTest `--align-test`:已知變換還原(誤差 ~1e-15)、ModOp↔Matrix3D 一致、共線拒絕
|
||||
|
||||
**驗收:** AlignTest 8 項全過;公母連接器可旋轉擺正後三點對齊插合,再用干涉檢查驗證配合
|
||||
|
||||
---
|
||||
|
||||
## Future Extensions(下一輪)
|
||||
|
||||
- 剖切面封口(cap)填實(目前剖開處可見內部背面材質)
|
||||
- 樹節點三態 checkbox(部分子節點隱藏時顯示中間態)
|
||||
- IGES 支援(需引入其他幾何核心或自寫 reader)
|
||||
- 量測結果匯出含截圖的 PDF 報告
|
||||
- 兩邊最短距離、邊到面距離
|
||||
- 視圖狀態(相機、圖層、量測)存檔/還原
|
||||
@@ -0,0 +1,73 @@
|
||||
# STPViewer — Claude 專案記憶
|
||||
|
||||
## 專案簡介
|
||||
|
||||
CAD 3D 檢視器(Windows 桌面 WPF, .NET 8):STEP/STL/DXF 匯入、STEP 裝配樹、
|
||||
點/距離/邊/面/圓/角度/面距量測、兩點對齊(平移)、三點對齊(旋轉+平移)、軸向旋轉 90°、
|
||||
干涉檢查、剖面、mm⇄inch、CSV/截圖匯出。
|
||||
詳細設計見 [ARCHITECTURE.md](ARCHITECTURE.md)。
|
||||
|
||||
## 技術棧
|
||||
|
||||
- .NET 8 WPF(`net8.0-windows`)、MVVM(CommunityToolkit.Mvvm)
|
||||
- **CADability**(純 C# CAD kernel):STEP 匯入、B-rep 幾何、Face 三角化
|
||||
- **HelixToolkit.Wpf**:3D viewport、相機、HitTest
|
||||
|
||||
## 常用指令
|
||||
|
||||
```bash
|
||||
dotnet build STPViewer.sln
|
||||
dotnet run --project src/STPViewer
|
||||
dotnet publish src/STPViewer -c Release -o publish/STPViewer
|
||||
```
|
||||
|
||||
測試模型:根目錄 `Amphenol RA PHD GPD20-50075_RevB_DC (for Cable).stp`
|
||||
|
||||
## 開發慣例
|
||||
|
||||
- MVVM:邏輯寫 ViewModel/Service,code-behind 只做純 View 事件(滑鼠拾取轉發)
|
||||
- 一個 B-rep Face = 一個 `GeometryModel3D`,用 `Dictionary<Model3D, FaceInfo>` 反查(量測拾取靠這個,**不可移除逐面結構**)
|
||||
- 渲染雙模式(降 draw call):每個 leaf 同時持有 `FacesContent`(逐面,量測用)與 `MergedContent`(整零件合併成 1 個 `GeometryModel3D`,瀏覽用)。
|
||||
`ApplyRenderMode()` 依狀態切 `BodyVisual.Content`:**瀏覽(None)且未剖面 → 合併網格**;量測模式或剖面 → 逐面。
|
||||
合併網格只代表「未剖切、目前位置」幾何;平移後呼叫 `RebuildMerged(leaf)` 同步。`_faceMap`/量測/剖面/干涉一律走 `FacesContent`,與目前顯示哪種內容無關
|
||||
- 合併網格的 `BackMaterial`:封閉實體(`SolidCount≥1` 且 `HasBrep`)**不設**(WPF 兩面渲染成本砍半);開放殼/STL 才設。逐面 `FacesContent` 一律保留 BackMaterial(剖切要看內部)
|
||||
- 量測值以 B-rep 為準(圓半徑、邊長、角度),面積/面距用網格近似
|
||||
- 量測文字一律 `Func<UnitSystem,string>` 延後產生(mm⇄inch 即時切換);內部數值永遠存 mm
|
||||
- 裝配樹節點(`ModelNodeViewModel`)的可見性/邊線/顏色向下 cascade
|
||||
- 剖面只換 `GeometryModel3D.Geometry`(`FaceInfo.Mesh` 保留原始 frozen mesh 供還原與量測)
|
||||
- 剛體變換(兩點對齊/旋轉 90°/三點對齊)統一走 `TransformRoot(root, ModOp, Matrix3D)`:B-rep 用 ModOp 對 Solid/Shell 整體 `Modify`
|
||||
(勿逐面位移,會重複位移共用邊),網格/合併網格/邊線/邊界同步重算;變換後量測已失效要 `ClearMeasurements()`。
|
||||
**op 與 m 必須是同一個變換** — 數學在 `Services/RigidAlign.cs`:WPF `Matrix3D` 是「列向量」約定、CADability `ModOp` 是「行向量」約定,
|
||||
`ToModOp` 負責轉置轉換,改動務必跑 `SmokeTest --align-test` 驗證兩種表示一致,否則 B-rep 與顯示網格會悄悄分家
|
||||
- 干涉/面距/對齊等運算在背景執行緒;`Freeze()` 幾何後才跨執行緒
|
||||
- 匯入在背景執行緒;`Freeze()` 幾何後才跨執行緒
|
||||
- Commit 格式:Conventional Commits(`feat:` / `fix:` / `docs:` …)
|
||||
|
||||
## 注意事項 / 已知限制
|
||||
|
||||
- `Path` 在 service 會與 `CADability.GeoObject.Path` 撞名 → 用 `IOPath` alias
|
||||
- CADability 解析大 STEP 慢(39MB/64k 面實測:解析約 276 秒 + 幾何處理),不要改成同步呼叫。
|
||||
解析(`ImportStep.Read`)單執行緒無解;三角化/邊取樣已按 **leaf 平行化**(`_leafWork` 收集 → `Parallel.ForEach`)。
|
||||
**平行粒度只能到 leaf**:同 leaf 的面共用 Edge 物件,面級平行會 race。空 leaf 由 `Prune` 收掉(延後三角化可能全失敗)。
|
||||
平行下 `GetTriangulation` 偶發失敗(實測 64k 面丟 ~8 面,跨 leaf 仍有共享狀態)→ 失敗面收進 `_retry`,平行結束後**循序重試**補回,
|
||||
該 leaf 的 `FinishLeaf` 也延到重試後才跑。**不要移除重試機制**,也不要把平行度開到面級
|
||||
- `StepImportService.Progress` 回報匯入階段(解析/三角化耗時),UI 已接狀態列;訊息來自背景執行緒,要 `Dispatcher.BeginInvoke`
|
||||
- `LinesVisual3D` 轉動視角逐幀重建,>30k 線段會卡 → 邊線自動關閉邏輯不要移除
|
||||
- 邊線採「**一檔一條合併 `LinesVisual3D`**」(掛在 root `EdgeVisual`,由 `RefreshRootEdges` 收集各 leaf `OriginalEdgePoints` 重建)。
|
||||
**不要改回逐 leaf 一條** — 裝配樹零件多時,N 條線每幀重建會嚴重卡頓(實測主因)。leaf 只保留邊線「資料」,渲染統一在 root;
|
||||
可見性/ShowEdges/剖面/平移變更時呼叫 `RefreshRootEdges(root)` 重組合併線
|
||||
- 互動中暫停邊線:`Attach` 掛 `Camera.Changed` → `OnCameraMoved` 隱藏邊線、`_interactionTimer`(180ms) 停下後 `ResumeEdges` 顯示;
|
||||
`_edgesSuspended` 為真時 `RefreshRootEdges` 不把線掛回。轉動/縮放/平移時不付邊線重建成本
|
||||
- CADability `ImportStep` 對少數 AP242 檔案支援不完整;匯入失敗要 catch 顯示訊息,不可閃退
|
||||
- IGES 無 reader;STL 無 B-rep(FaceInfo.BrepFace == null 的分支要保留)
|
||||
- 不寫入原始檔(唯讀工具);WPF 限 Windows,不要嘗試移植 vbox/Linux
|
||||
- 干涉檢查需剛好 2 個可見檔案(樹面板勾選);共面貼合(無穿透)不算干涉、gap≈0 視為配合(match)
|
||||
- SmokeTest 工具:`--tree`(裝配樹)、`--clip-test`(剖切數學)、`--interference-test`(干涉相交/分離/貼合)、
|
||||
`--align-test`(三點對齊剛體變換 + ModOp↔Matrix3D 一致性)、`--make-dxf`(產測試檔)
|
||||
- **絕不要用 PowerShell regex/Set-Content 改 .cs 檔** — Windows PowerShell 5.1 預設編碼會把 UTF-8 中文弄成亂碼(已踩過,靠反編譯 DLL 救回)。文字取代一律用 Edit 工具
|
||||
|
||||
## secret/ 與 For_AI/
|
||||
|
||||
- `secret/`:本機敏感資料集中地,`secret/*` gitignored(`README.md`、`*.example` 除外)。
|
||||
本專案無 runtime secret,僅有啟動腳本範本。
|
||||
- `For_AI/`:AI 協作素材(截圖、草稿),整夾 gitignored。
|
||||
@@ -0,0 +1,69 @@
|
||||
# STPViewer
|
||||
|
||||
STP/STEP 3D 檢視器(Windows 桌面程式,C# .NET 8 WPF)— 多檔匯入、圖層管理、點/距離/邊/面/圓量測。
|
||||
|
||||
  
|
||||
|
||||
## 功能
|
||||
|
||||
- **多檔匯入**:STEP / STL / DXF — 工具列匯入(可複選)、拖放到視窗、或命令列 `STPViewer.exe a.stp b.stl`
|
||||
- **裝配樹**:STEP product structure 還原成樹(組件→零件),逐節點 顯示/隱藏、換色(cascade)、Zoom-to;檔案層級可移除、開關輪廓邊線
|
||||
- **量測**(工具列切換模式,點擊模型):
|
||||
| 模式 | 輸出 |
|
||||
|---|---|
|
||||
| 📍 點 | XYZ 座標(自動吸附鄰近 B-rep 頂點) |
|
||||
| 📏 距離 | 兩點直線距離 + ΔX/ΔY/ΔZ |
|
||||
| 📐 邊 | 直線長 / 曲線長 / 圓弧長+半徑 |
|
||||
| ⬛ 面 | 面積(網格近似)+ 曲面類型(平面法向量、圓柱半徑/軸向) |
|
||||
| ⭕ 圓 | 圓心 / 半徑 / 直徑 / 周長 |
|
||||
| ∠ 角度 | 兩面(法向量)/ 兩直線邊 夾角 + 補角 |
|
||||
| ⇔ 面距 | 面到面最短距離(網格近似)+ 最近點對 |
|
||||
| ⤚ 對齊 | 點「要移動零件」一點 + 目標點 → 純平移該檔案使兩點貼合(B-rep 整體位移) |
|
||||
| 🎯 三點 | 來源檔 3 特徵點 + 目標檔 3 對應點 → 旋轉+平移一次貼合(方向不同也能對) |
|
||||
- **旋轉**:樹面板選檔案 + 工具列 ↻X/↻Y/↻Z 繞中心 +90°(擺正方向用,連按累加)
|
||||
- **干涉檢查** 🧩:勾選剛好 2 個可見檔案 → 相交時顯示紅色干涉交線 + 相交三角形對數;無相交時回報最小間隙 gap(gap≈0 即為配合 match,共面貼合不算干涉)
|
||||
- **剖面**:✂ 開關 + X/Y/Z 軸 + 位置滑桿 + 反向;CPU 網格裁切,原始幾何保留(量測不受影響)
|
||||
- **單位**:mm ⇄ inch 一鍵切換,既有量測(清單與 3D 標籤)即時換算
|
||||
- **匯出**:量測結果 CSV(Excel 中文不亂碼)、3D 視圖 PNG 截圖(2x 解析度)
|
||||
- **視角**:右鍵旋轉、滾輪縮放、中鍵平移、ViewCube
|
||||
|
||||
## 快速開始
|
||||
|
||||
```bash
|
||||
dotnet build STPViewer.sln
|
||||
dotnet run --project src/STPViewer
|
||||
|
||||
# 發佈免安裝資料夾
|
||||
dotnet publish src/STPViewer -c Release -o publish/STPViewer
|
||||
```
|
||||
|
||||
無 UI 匯入管線與幾何數學驗證:
|
||||
|
||||
```bash
|
||||
dotnet run --project tools/SmokeTest -- "path\to\model.stp" # 匯入 + 裝配樹
|
||||
dotnet run --project tools/SmokeTest -- --clip-test # 剖切裁切數學
|
||||
dotnet run --project tools/SmokeTest -- --interference-test # 干涉 相交/分離/貼合
|
||||
dotnet run --project tools/SmokeTest -- --align-test # 三點對齊剛體變換數學
|
||||
```
|
||||
|
||||
## 技術棧
|
||||
|
||||
| 元件 | 用途 |
|
||||
|---|---|
|
||||
| [CADability](https://github.com/SOFAgh/CADability)(純 C#) | STEP 匯入、B-rep 幾何核心、面三角化 |
|
||||
| [HelixToolkit.Wpf](https://github.com/helix-toolkit/helix-toolkit) | 3D viewport、相機操作、HitTest |
|
||||
| CommunityToolkit.Mvvm | MVVM |
|
||||
|
||||
量測原則:邊長、圓半徑等取 **B-rep 精確值**;面積為三角網格加總近似(三角化精度依模型尺寸自適應 0.02–0.5 mm)。
|
||||
|
||||
## 已知限制
|
||||
|
||||
- 大型 STEP(數千面)匯入需數十秒(CADability 解析成本),匯入期間 UI 有進度提示不凍結
|
||||
- 輪廓邊線超過 30,000 線段的檔案預設關閉邊線(WPF LinesVisual3D 轉動視角時效能限制),可在樹面板手動開啟
|
||||
- **IGES 不支援**(CADability 無 IGES reader);STL 無 B-rep,僅支援 點/距離/角度/面距 量測;DXF 為線架構檢視
|
||||
- 剖切面無封口(cap),剖開處顯示內部背面材質(深灰)
|
||||
- 面積與面距為三角網格近似值;邊長/圓半徑/角度為 B-rep 精確值
|
||||
- 少數 AP242 檔案 CADability 支援不完整,匯入失敗會提示訊息(不閃退)
|
||||
- 唯讀檢視器,不寫入/修改原始檔案
|
||||
|
||||
詳細設計與開發 Phase 見 [ARCHITECTURE.md](ARCHITECTURE.md)。
|
||||
@@ -0,0 +1,56 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "STPViewer", "src\STPViewer\STPViewer.csproj", "{95832FFD-8DAB-47B8-98BF-9E523B6D329A}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{07C2787E-EAC7-C090-1BA3-A61EC2A24D84}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SmokeTest", "tools\SmokeTest\SmokeTest.csproj", "{C2A2A7E8-9CB3-43BE-912C-9056AD7F8BFB}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{95832FFD-8DAB-47B8-98BF-9E523B6D329A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{95832FFD-8DAB-47B8-98BF-9E523B6D329A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{95832FFD-8DAB-47B8-98BF-9E523B6D329A}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{95832FFD-8DAB-47B8-98BF-9E523B6D329A}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{95832FFD-8DAB-47B8-98BF-9E523B6D329A}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{95832FFD-8DAB-47B8-98BF-9E523B6D329A}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{95832FFD-8DAB-47B8-98BF-9E523B6D329A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{95832FFD-8DAB-47B8-98BF-9E523B6D329A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{95832FFD-8DAB-47B8-98BF-9E523B6D329A}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{95832FFD-8DAB-47B8-98BF-9E523B6D329A}.Release|x64.Build.0 = Release|Any CPU
|
||||
{95832FFD-8DAB-47B8-98BF-9E523B6D329A}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{95832FFD-8DAB-47B8-98BF-9E523B6D329A}.Release|x86.Build.0 = Release|Any CPU
|
||||
{C2A2A7E8-9CB3-43BE-912C-9056AD7F8BFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C2A2A7E8-9CB3-43BE-912C-9056AD7F8BFB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C2A2A7E8-9CB3-43BE-912C-9056AD7F8BFB}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C2A2A7E8-9CB3-43BE-912C-9056AD7F8BFB}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C2A2A7E8-9CB3-43BE-912C-9056AD7F8BFB}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C2A2A7E8-9CB3-43BE-912C-9056AD7F8BFB}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C2A2A7E8-9CB3-43BE-912C-9056AD7F8BFB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C2A2A7E8-9CB3-43BE-912C-9056AD7F8BFB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C2A2A7E8-9CB3-43BE-912C-9056AD7F8BFB}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C2A2A7E8-9CB3-43BE-912C-9056AD7F8BFB}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C2A2A7E8-9CB3-43BE-912C-9056AD7F8BFB}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C2A2A7E8-9CB3-43BE-912C-9056AD7F8BFB}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{95832FFD-8DAB-47B8-98BF-9E523B6D329A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{C2A2A7E8-9CB3-43BE-912C-9056AD7F8BFB} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -0,0 +1,24 @@
|
||||
# secret/ — 本機敏感資料集中地
|
||||
|
||||
本資料夾除 `README.md` 與 `*.example` 外全部 gitignored(規則見根目錄 `.gitignore`)。
|
||||
|
||||
## 內含物件
|
||||
|
||||
| 檔案 | git | 用途 |
|
||||
|---|---|---|
|
||||
| `README.md` | ✅ committed | 本說明 |
|
||||
| `run-STPViewer.ps1.example` | ✅ committed | Windows 啟動腳本範本 |
|
||||
| `run-STPViewer.sh.example` | ✅ committed | Git Bash 啟動腳本範本 |
|
||||
| `run-STPViewer.ps1` / `.sh` | 🚫 ignored | 從範本複製後的本機實值版 |
|
||||
|
||||
> STPViewer 為離線桌面工具,目前**沒有任何 DB 密碼 / ApiKey**;
|
||||
> 腳本僅做 build + run。未來若加入需要 secret 的功能(雲端授權、回報伺服器等),
|
||||
> 依範本內註解加上 env var 注入。
|
||||
|
||||
## 第一次使用
|
||||
|
||||
```powershell
|
||||
cd secret
|
||||
Copy-Item run-STPViewer.ps1.example run-STPViewer.ps1
|
||||
.\run-STPViewer.ps1
|
||||
```
|
||||
@@ -0,0 +1,31 @@
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
# run-STPViewer.ps1 範本
|
||||
#
|
||||
# 第一次 clone 下來:
|
||||
# cd secret
|
||||
# Copy-Item run-STPViewer.ps1.example run-STPViewer.ps1
|
||||
# .\run-STPViewer.ps1
|
||||
#
|
||||
# 若 PowerShell 擋執行 script,執行一次:
|
||||
# Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
|
||||
#
|
||||
# secret/ 下除 README.md 和 .example 外皆已 gitignore。
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# ─── secret(本專案目前無 DB / ApiKey;未來需要時在此加 env var) ──
|
||||
# $env:SomeService__ApiKey = '__CHANGE_ME__'
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$RepoRoot = Resolve-Path (Join-Path $ScriptDir '..')
|
||||
|
||||
Push-Location $RepoRoot
|
||||
try {
|
||||
dotnet run --project src/STPViewer
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
# run-STPViewer.sh 範本(Git Bash on Windows)
|
||||
#
|
||||
# 第一次 clone 下來:
|
||||
# cd secret
|
||||
# cp run-STPViewer.sh.example run-STPViewer.sh
|
||||
# chmod +x run-STPViewer.sh
|
||||
# ./run-STPViewer.sh
|
||||
#
|
||||
# secret/ 下除 README.md 和 .example 外皆已 gitignore。
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ─── secret(本專案目前無 DB / ApiKey;未來需要時在此 export) ────
|
||||
# export SomeService__ApiKey="__CHANGE_ME__"
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
cd "${REPO_ROOT}"
|
||||
exec dotnet run --project src/STPViewer
|
||||
@@ -0,0 +1,9 @@
|
||||
<Application x:Class="STPViewer.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:STPViewer"
|
||||
StartupUri="MainWindow.xaml">
|
||||
<Application.Resources>
|
||||
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Configuration;
|
||||
using System.Data;
|
||||
using System.Windows;
|
||||
|
||||
namespace STPViewer;
|
||||
|
||||
/// <summary>
|
||||
/// Interaction logic for App.xaml
|
||||
/// </summary>
|
||||
public partial class App : Application
|
||||
{
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Windows;
|
||||
|
||||
[assembly:ThemeInfo(
|
||||
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
|
||||
//(used if a resource is not found in the page,
|
||||
// or application resource dictionaries)
|
||||
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
|
||||
//(used if a resource is not found in the page,
|
||||
// app, or any theme specific resource dictionaries)
|
||||
)]
|
||||
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Windows.Data;
|
||||
using STPViewer.Models;
|
||||
|
||||
namespace STPViewer.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// MeasureMode ↔ ToggleButton.IsChecked。
|
||||
/// ConverterParameter 為模式名稱字串;取消勾選時回到 None。
|
||||
/// </summary>
|
||||
public class EnumToBoolConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
value?.ToString() == parameter as string;
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
value is true && parameter is string s
|
||||
? Enum.Parse<MeasureMode>(s)
|
||||
: MeasureMode.None;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
<Window x:Class="STPViewer.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:h="http://helix-toolkit.org/wpf"
|
||||
xmlns:conv="clr-namespace:STPViewer.Converters"
|
||||
xmlns:vm="clr-namespace:STPViewer.ViewModels"
|
||||
Title="STPViewer — STP/STEP 3D 檢視器"
|
||||
Height="820" Width="1320"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
AllowDrop="True" Drop="Window_Drop" DragOver="Window_DragOver">
|
||||
|
||||
<Window.Resources>
|
||||
<conv:EnumToBoolConverter x:Key="ModeConv" />
|
||||
<BooleanToVisibilityConverter x:Key="BoolToVis" />
|
||||
<Style x:Key="ModeButton" TargetType="ToggleButton">
|
||||
<Setter Property="Padding" Value="8,4" />
|
||||
<Setter Property="Margin" Value="2,0" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
</Style>
|
||||
</Window.Resources>
|
||||
|
||||
<DockPanel>
|
||||
<!-- ─── 工具列 ─── -->
|
||||
<ToolBarTray DockPanel.Dock="Top">
|
||||
<ToolBar>
|
||||
<Button Command="{Binding ImportCommand}" Padding="8,4" FontSize="13"
|
||||
ToolTip="匯入 STP/STEP/STL/DXF(可複選,也可直接拖放到視窗)">📂 匯入</Button>
|
||||
<Button Command="{Binding ZoomAllCommand}" Padding="8,4" FontSize="13"
|
||||
ToolTip="縮放至全部模型">🔍 全覽</Button>
|
||||
<Separator />
|
||||
<TextBlock Text="量測:" VerticalAlignment="Center" Margin="4,0,2,0" />
|
||||
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="量測點座標(吸附頂點)"
|
||||
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Point}">📍 點</ToggleButton>
|
||||
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="兩點距離 + ΔXYZ"
|
||||
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Distance}">📏 距離</ToggleButton>
|
||||
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="邊長(直線/曲線/圓弧)"
|
||||
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Edge}">📐 邊</ToggleButton>
|
||||
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="面積 + 曲面類型/法向量"
|
||||
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Face}">⬛ 面</ToggleButton>
|
||||
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="圓心/半徑/直徑/周長"
|
||||
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Circle}">⭕ 圓</ToggleButton>
|
||||
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="兩面/兩邊夾角(點兩個面或靠近直線邊)"
|
||||
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Angle}">∠ 角度</ToggleButton>
|
||||
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="面到面最短距離(網格近似)"
|
||||
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=FaceDistance}">⇔ 面距</ToggleButton>
|
||||
<Separator />
|
||||
<ToggleButton Style="{StaticResource ModeButton}"
|
||||
ToolTip="兩點對齊(平移零件):先點要移動零件上的點,再點目標點"
|
||||
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Align}">🎯 對齊</ToggleButton>
|
||||
<ToggleButton Style="{StaticResource ModeButton}"
|
||||
ToolTip="三點對齊(旋轉+平移):先在要移動的檔案點 3 個特徵點,再到目標檔案點 3 個對應點(順序對應)"
|
||||
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Align3}">🎯 三點</ToggleButton>
|
||||
<Separator />
|
||||
<TextBlock Text="旋轉:" VerticalAlignment="Center" Margin="4,0,2,0"
|
||||
ToolTip="對樹面板選取的檔案,繞其中心旋轉 +90°" />
|
||||
<Button Command="{Binding RotateRootCommand}" CommandParameter="X" Padding="6,4" FontSize="13"
|
||||
ToolTip="選取的檔案繞 X 軸 +90°(先在樹面板點選檔案)">↻X</Button>
|
||||
<Button Command="{Binding RotateRootCommand}" CommandParameter="Y" Padding="6,4" FontSize="13"
|
||||
ToolTip="選取的檔案繞 Y 軸 +90°">↻Y</Button>
|
||||
<Button Command="{Binding RotateRootCommand}" CommandParameter="Z" Padding="6,4" FontSize="13"
|
||||
ToolTip="選取的檔案繞 Z 軸 +90°">↻Z</Button>
|
||||
<Button Command="{Binding CheckInterferenceCommand}" Padding="8,4" FontSize="13"
|
||||
ToolTip="干涉檢查:檢查 2 個可見檔案是否相交(match 判定);無相交時回報最小間隙">🧩 干涉</Button>
|
||||
<Separator />
|
||||
<Button Command="{Binding ClearMeasurementsCommand}" Padding="8,4" FontSize="13"
|
||||
ToolTip="清除全部量測與標記">🧹 清除</Button>
|
||||
<Separator />
|
||||
<ToggleButton IsChecked="{Binding UseInch}" Padding="8,4" FontSize="13"
|
||||
ToolTip="切換顯示單位 mm ⇄ inch(既有量測即時換算)">mm ⇄ in</ToggleButton>
|
||||
<Separator />
|
||||
<Button Command="{Binding ExportCsvCommand}" Padding="8,4" FontSize="13"
|
||||
ToolTip="量測結果匯出 CSV">💾 CSV</Button>
|
||||
<Button Command="{Binding SaveScreenshotCommand}" Padding="8,4" FontSize="13"
|
||||
ToolTip="3D 視圖截圖存 PNG(2x 解析度)">📷 截圖</Button>
|
||||
</ToolBar>
|
||||
<ToolBar>
|
||||
<ToggleButton IsChecked="{Binding SectionEnabled}" Padding="8,4" FontSize="13"
|
||||
ToolTip="開啟/關閉剖面檢視">✂ 剖面</ToggleButton>
|
||||
<ComboBox SelectedIndex="{Binding SectionAxisIndex}" Width="52" Margin="4,0"
|
||||
VerticalAlignment="Center" ToolTip="剖切軸向"
|
||||
IsEnabled="{Binding SectionEnabled}">
|
||||
<ComboBoxItem>X</ComboBoxItem>
|
||||
<ComboBoxItem>Y</ComboBoxItem>
|
||||
<ComboBoxItem>Z</ComboBoxItem>
|
||||
</ComboBox>
|
||||
<Slider Value="{Binding SectionPosition}" Minimum="0" Maximum="100" Width="180"
|
||||
VerticalAlignment="Center" Margin="4,0" ToolTip="剖面位置(沿軸 %)"
|
||||
IsEnabled="{Binding SectionEnabled}" />
|
||||
<TextBlock Text="{Binding SectionPosition, StringFormat={}{0:F0}%}" Width="36"
|
||||
VerticalAlignment="Center" />
|
||||
<CheckBox IsChecked="{Binding SectionFlip}" Content="反向" VerticalAlignment="Center"
|
||||
Margin="4,0" ToolTip="切換保留側" IsEnabled="{Binding SectionEnabled}" />
|
||||
</ToolBar>
|
||||
</ToolBarTray>
|
||||
|
||||
<!-- ─── 狀態列 ─── -->
|
||||
<StatusBar DockPanel.Dock="Bottom">
|
||||
<StatusBarItem>
|
||||
<TextBlock Text="{Binding StatusText}" />
|
||||
</StatusBarItem>
|
||||
<StatusBarItem HorizontalAlignment="Right">
|
||||
<ProgressBar Width="140" Height="13" IsIndeterminate="True"
|
||||
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVis}}" />
|
||||
</StatusBarItem>
|
||||
</StatusBar>
|
||||
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="280" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="300" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- ─── 裝配樹面板 ─── -->
|
||||
<GroupBox Grid.Column="0" Header="模型 / 裝配樹" Margin="4">
|
||||
<TreeView ItemsSource="{Binding Roots}"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
||||
VirtualizingPanel.IsVirtualizing="True"
|
||||
SelectedItemChanged="Tree_SelectedItemChanged">
|
||||
<TreeView.ItemContainerStyle>
|
||||
<Style TargetType="TreeViewItem">
|
||||
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
|
||||
</Style>
|
||||
</TreeView.ItemContainerStyle>
|
||||
<TreeView.ItemTemplate>
|
||||
<HierarchicalDataTemplate DataType="{x:Type vm:ModelNodeViewModel}"
|
||||
ItemsSource="{Binding Children}">
|
||||
<StackPanel Orientation="Horizontal" Margin="0,1"
|
||||
ToolTip="{Binding ToolTipText}">
|
||||
<CheckBox IsChecked="{Binding IsVisible}" VerticalAlignment="Center"
|
||||
ToolTip="顯示 / 隱藏(含子節點)" />
|
||||
<Button Width="14" Height="14" Margin="3,0"
|
||||
VerticalAlignment="Center" ToolTip="點擊換色(含子節點)"
|
||||
Command="{Binding CycleColorCommand}">
|
||||
<Button.Template>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border BorderBrush="Gray" BorderThickness="1" CornerRadius="2">
|
||||
<Border.Background>
|
||||
<SolidColorBrush Color="{Binding Color}" />
|
||||
</Border.Background>
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Button.Template>
|
||||
</Button>
|
||||
<TextBlock Text="{Binding Name}" VerticalAlignment="Center"
|
||||
MaxWidth="130" TextTrimming="CharacterEllipsis">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsRoot}" Value="True">
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
<CheckBox IsChecked="{Binding ShowEdges}" Content="邊" FontSize="10"
|
||||
VerticalAlignment="Center" Margin="4,0,0,0"
|
||||
Visibility="{Binding IsRoot, Converter={StaticResource BoolToVis}}"
|
||||
ToolTip="顯示輪廓邊線(邊線量大時轉動視角會變慢)" />
|
||||
<Button Content="🔍" Width="20" Margin="3,0,0,0" Padding="0"
|
||||
VerticalAlignment="Center" ToolTip="縮放至此節點"
|
||||
Command="{Binding DataContext.ZoomNodeCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
CommandParameter="{Binding}" />
|
||||
<Button Content="✕" Width="20" Padding="0" Margin="2,0,0,0"
|
||||
VerticalAlignment="Center" ToolTip="移除此檔案"
|
||||
Visibility="{Binding IsRoot, Converter={StaticResource BoolToVis}}"
|
||||
Command="{Binding DataContext.RemoveRootCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
CommandParameter="{Binding}" />
|
||||
</StackPanel>
|
||||
</HierarchicalDataTemplate>
|
||||
</TreeView.ItemTemplate>
|
||||
</TreeView>
|
||||
</GroupBox>
|
||||
|
||||
<!-- ─── 3D 視圖 ─── -->
|
||||
<h:HelixViewport3D x:Name="viewport" Grid.Column="1"
|
||||
ShowViewCube="True" ShowCoordinateSystem="True"
|
||||
ShowFrameRate="False" ZoomExtentsWhenLoaded="True"
|
||||
PanGesture="MiddleClick" PanGesture2="Shift+LeftClick"
|
||||
MouseLeftButtonDown="Viewport_MouseLeftButtonDown">
|
||||
<h:HelixViewport3D.Background>
|
||||
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
|
||||
<GradientStop Color="#FFEAF2F8" Offset="0" />
|
||||
<GradientStop Color="#FFB9C9D4" Offset="1" />
|
||||
</LinearGradientBrush>
|
||||
</h:HelixViewport3D.Background>
|
||||
<h:DefaultLights />
|
||||
</h:HelixViewport3D>
|
||||
|
||||
<!-- ─── 量測結果面板 ─── -->
|
||||
<GroupBox Grid.Column="2" Header="量測結果" Margin="4">
|
||||
<ListBox ItemsSource="{Binding Measurements}" HorizontalContentAlignment="Stretch"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid Margin="0,3">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" TextWrapping="Wrap" />
|
||||
<TextBlock Text="{Binding Detail}" FontSize="11" Foreground="#FF555555"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Content="✕" Width="22" Height="22"
|
||||
VerticalAlignment="Top" ToolTip="刪除此筆量測"
|
||||
Command="{Binding DataContext.RemoveMeasurementCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
CommandParameter="{Binding}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</GroupBox>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using STPViewer.Services;
|
||||
using STPViewer.ViewModels;
|
||||
|
||||
namespace STPViewer;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private readonly MainViewModel _vm = new();
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = _vm;
|
||||
_vm.Attach(viewport);
|
||||
Loaded += MainWindow_Loaded;
|
||||
}
|
||||
|
||||
/// <summary>支援命令列帶檔開啟:STPViewer.exe a.stp b.stl …</summary>
|
||||
private async void MainWindow_Loaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var files = System.Environment.GetCommandLineArgs().Skip(1)
|
||||
.Where(StepImportService.IsSupported).ToArray();
|
||||
if (files.Length > 0)
|
||||
await _vm.ImportFilesAsync(files);
|
||||
}
|
||||
|
||||
private void Tree_SelectedItemChanged(object sender, RoutedPropertyChangedEventArgs<object> e) =>
|
||||
_vm.SelectedNode = e.NewValue as ModelNodeViewModel;
|
||||
|
||||
private void Viewport_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
// Shift+左鍵 = 平移(PanGesture2),不觸發量測
|
||||
if (Keyboard.Modifiers.HasFlag(ModifierKeys.Shift)) return;
|
||||
_vm.OnViewportClick(e.GetPosition(viewport.Viewport));
|
||||
}
|
||||
|
||||
private void Window_DragOver(object sender, DragEventArgs e)
|
||||
{
|
||||
e.Effects = HasSupportedFiles(e) ? DragDropEffects.Copy : DragDropEffects.None;
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private async void Window_Drop(object sender, DragEventArgs e)
|
||||
{
|
||||
if (e.Data.GetData(DataFormats.FileDrop) is not string[] files) return;
|
||||
var supported = files.Where(StepImportService.IsSupported).ToArray();
|
||||
if (supported.Length > 0)
|
||||
await _vm.ImportFilesAsync(supported);
|
||||
}
|
||||
|
||||
private static bool HasSupportedFiles(DragEventArgs e) =>
|
||||
e.Data.GetDataPresent(DataFormats.FileDrop) &&
|
||||
e.Data.GetData(DataFormats.FileDrop) is string[] files &&
|
||||
files.Any(StepImportService.IsSupported);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Windows.Media.Media3D;
|
||||
using CADability.GeoObject;
|
||||
using STPViewer.ViewModels;
|
||||
|
||||
namespace STPViewer.Models;
|
||||
|
||||
/// <summary>渲染物件(GeometryModel3D)反查 B-rep Face 的對照資料</summary>
|
||||
public class FaceInfo
|
||||
{
|
||||
/// <summary>B-rep 面;STL 等純網格來源為 null(僅支援點/距離/面距量測)</summary>
|
||||
public Face? BrepFace { get; init; }
|
||||
|
||||
/// <summary>原始(未剖切)三角網格 — 面積計算與剖面還原用;零件平移後會換成新 mesh</summary>
|
||||
public required MeshGeometry3D Mesh { get; set; }
|
||||
|
||||
public required ModelNodeViewModel Owner { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace STPViewer.Models;
|
||||
|
||||
/// <summary>量測模式(None = 純瀏覽)</summary>
|
||||
public enum MeasureMode
|
||||
{
|
||||
None,
|
||||
Point,
|
||||
Distance,
|
||||
Edge,
|
||||
Face,
|
||||
Circle,
|
||||
Angle, // 兩面 / 兩邊夾角
|
||||
FaceDistance, // 面到面最短距離(網格近似)
|
||||
Align, // 兩點對齊:平移零件使點 1 貼到點 2
|
||||
Align3, // 三點對齊:旋轉+平移(來源 3 點 → 目標 3 點)
|
||||
Interference, // 干涉檢查結果(非點擊模式,僅作為結果類型)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Windows.Media.Media3D;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using HelixToolkit.Wpf;
|
||||
|
||||
namespace STPViewer.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 一筆量測結果。內部以 mm 儲存原始值,Title/Detail 依目前單位即時產生;
|
||||
/// 切換單位時 3D 標籤(DynamicLabels)也同步改字。
|
||||
/// </summary>
|
||||
public class MeasurementResult : ObservableObject
|
||||
{
|
||||
public MeasureMode Kind { get; init; }
|
||||
|
||||
/// <summary>依單位產生標題(清單粗體列)</summary>
|
||||
public required Func<UnitSystem, string> TitleFor { get; init; }
|
||||
|
||||
/// <summary>依單位產生多行明細</summary>
|
||||
public required Func<UnitSystem, string> DetailFor { get; init; }
|
||||
|
||||
/// <summary>視圖中的標記(刪除量測時一併移除)</summary>
|
||||
public List<Visual3D> Overlays { get; } = new();
|
||||
|
||||
/// <summary>單位相關的 3D 文字標籤(切換單位時更新 Text)</summary>
|
||||
public List<(BillboardTextVisual3D Label, Func<UnitSystem, string> TextFor)> DynamicLabels { get; } = new();
|
||||
|
||||
private UnitSystem _unit = UnitSystem.Millimeter;
|
||||
|
||||
public string Title => TitleFor(_unit);
|
||||
public string Detail => DetailFor(_unit);
|
||||
|
||||
public void SetUnit(UnitSystem unit)
|
||||
{
|
||||
if (_unit == unit) return;
|
||||
_unit = unit;
|
||||
OnPropertyChanged(nameof(Title));
|
||||
OnPropertyChanged(nameof(Detail));
|
||||
foreach ((BillboardTextVisual3D label, Func<UnitSystem, string> textFor) in DynamicLabels)
|
||||
label.Text = textFor(unit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Windows.Media.Media3D;
|
||||
|
||||
namespace STPViewer.Models;
|
||||
|
||||
public enum UnitSystem
|
||||
{
|
||||
Millimeter,
|
||||
Inch,
|
||||
}
|
||||
|
||||
/// <summary>量測值格式化(內部一律 mm,顯示時換算)</summary>
|
||||
public static class Units
|
||||
{
|
||||
private const double MmPerInch = 25.4;
|
||||
|
||||
/// <summary>長度</summary>
|
||||
public static string L(double mm, UnitSystem u) =>
|
||||
u == UnitSystem.Millimeter ? $"{mm:F3} mm" : $"{mm / MmPerInch:F4} in";
|
||||
|
||||
/// <summary>面積</summary>
|
||||
public static string A(double mm2, UnitSystem u) =>
|
||||
u == UnitSystem.Millimeter ? $"{mm2:N3} mm²" : $"{mm2 / (MmPerInch * MmPerInch):N4} in²";
|
||||
|
||||
/// <summary>座標分量(無單位字尾)</summary>
|
||||
public static string C(double mm, UnitSystem u) =>
|
||||
u == UnitSystem.Millimeter ? $"{mm:F3}" : $"{mm / MmPerInch:F4}";
|
||||
|
||||
/// <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)})";
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWPF>true</UseWPF>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CADability" Version="1.0.33" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||
<PackageReference Include="HelixToolkit.Wpf" Version="3.1.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,290 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Media3D;
|
||||
|
||||
namespace STPViewer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 干涉檢查結果:Intersects=true 時 Segments 為干涉交線;
|
||||
/// false 時 GapA/GapB 為最近點對(網格近似)。
|
||||
/// </summary>
|
||||
public record InterferenceResult(
|
||||
bool Intersects,
|
||||
int PairCount,
|
||||
List<(Point3D A, Point3D B)> Segments,
|
||||
Point3D GapA,
|
||||
Point3D GapB,
|
||||
double GapDistance);
|
||||
|
||||
/// <summary>
|
||||
/// 兩組三角網格的干涉檢查:
|
||||
/// 三角形-三角形相交(區間法,回傳交線段)+ 均勻網格空間加速。
|
||||
/// 共面接觸(無穿透)不會被判為干涉(近似限制)。
|
||||
/// 設計為在背景執行緒呼叫。
|
||||
/// </summary>
|
||||
public static class InterferenceService
|
||||
{
|
||||
private const int MaxSegments = 20_000;
|
||||
|
||||
public static InterferenceResult Check(
|
||||
IReadOnlyList<MeshGeometry3D> meshesA, IReadOnlyList<MeshGeometry3D> meshesB)
|
||||
{
|
||||
Triangle[] trisA = Flatten(meshesA);
|
||||
Triangle[] trisB = Flatten(meshesB);
|
||||
if (trisA.Length == 0 || trisB.Length == 0)
|
||||
return new InterferenceResult(false, 0, new(), default, default, double.NaN);
|
||||
|
||||
var grid = new TriGrid(trisB);
|
||||
var segments = new List<(Point3D, Point3D)>();
|
||||
int pairCount = 0;
|
||||
var candidates = new HashSet<int>();
|
||||
|
||||
foreach (Triangle ta in trisA)
|
||||
{
|
||||
candidates.Clear();
|
||||
grid.Query(ta.Min, ta.Max, candidates);
|
||||
foreach (int bi in candidates)
|
||||
{
|
||||
Triangle tb = trisB[bi];
|
||||
if (!AabbOverlap(ta, tb)) continue;
|
||||
if (TriTriIntersect(ta, tb, out Point3D s0, out Point3D s1))
|
||||
{
|
||||
pairCount++;
|
||||
if (segments.Count < MaxSegments) segments.Add((s0, s1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pairCount > 0)
|
||||
return new InterferenceResult(true, pairCount, segments, default, default, 0);
|
||||
|
||||
(Point3D ga, Point3D gb, double d) = ApproxMinDistance(trisA, trisB);
|
||||
return new InterferenceResult(false, 0, segments, ga, gb, d);
|
||||
}
|
||||
|
||||
// ─── 資料結構 ────────────────────────────────────────────────
|
||||
|
||||
private readonly struct Triangle
|
||||
{
|
||||
public readonly Point3D A, B, C;
|
||||
public readonly Point3D Min, Max;
|
||||
|
||||
public Triangle(Point3D a, Point3D b, Point3D c)
|
||||
{
|
||||
A = a; B = b; C = c;
|
||||
Min = new Point3D(Math.Min(a.X, Math.Min(b.X, c.X)),
|
||||
Math.Min(a.Y, Math.Min(b.Y, c.Y)),
|
||||
Math.Min(a.Z, Math.Min(b.Z, c.Z)));
|
||||
Max = new Point3D(Math.Max(a.X, Math.Max(b.X, c.X)),
|
||||
Math.Max(a.Y, Math.Max(b.Y, c.Y)),
|
||||
Math.Max(a.Z, Math.Max(b.Z, c.Z)));
|
||||
}
|
||||
}
|
||||
|
||||
private static Triangle[] Flatten(IReadOnlyList<MeshGeometry3D> meshes)
|
||||
{
|
||||
var list = new List<Triangle>();
|
||||
foreach (MeshGeometry3D m in meshes)
|
||||
{
|
||||
Point3DCollection p = m.Positions;
|
||||
Int32Collection idx = m.TriangleIndices;
|
||||
for (int i = 0; i + 2 < idx.Count; i += 3)
|
||||
list.Add(new Triangle(p[idx[i]], p[idx[i + 1]], p[idx[i + 2]]));
|
||||
}
|
||||
return list.ToArray();
|
||||
}
|
||||
|
||||
private static bool AabbOverlap(in Triangle a, in Triangle b) =>
|
||||
a.Min.X <= b.Max.X && a.Max.X >= b.Min.X &&
|
||||
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>均勻網格:cell → 三角形索引</summary>
|
||||
private sealed class TriGrid
|
||||
{
|
||||
private readonly Dictionary<(int, int, int), List<int>> _cells = new();
|
||||
private readonly double _cell;
|
||||
|
||||
public TriGrid(Triangle[] tris)
|
||||
{
|
||||
// cell 大小:整體對角線 / 64(夾限避免極端值)
|
||||
Point3D min = tris[0].Min, max = tris[0].Max;
|
||||
foreach (Triangle t in tris)
|
||||
{
|
||||
min = new Point3D(Math.Min(min.X, t.Min.X), Math.Min(min.Y, t.Min.Y), Math.Min(min.Z, t.Min.Z));
|
||||
max = new Point3D(Math.Max(max.X, t.Max.X), Math.Max(max.Y, t.Max.Y), Math.Max(max.Z, t.Max.Z));
|
||||
}
|
||||
double diag = (max - min).Length;
|
||||
_cell = Math.Clamp(diag / 64, 1e-3, 1e6);
|
||||
|
||||
for (int i = 0; i < tris.Length; i++)
|
||||
ForEachCell(tris[i].Min, tris[i].Max, key =>
|
||||
{
|
||||
if (!_cells.TryGetValue(key, out List<int>? list))
|
||||
_cells[key] = list = new List<int>();
|
||||
list.Add(i);
|
||||
});
|
||||
}
|
||||
|
||||
public void Query(Point3D min, Point3D max, HashSet<int> result) =>
|
||||
ForEachCell(min, max, key =>
|
||||
{
|
||||
if (_cells.TryGetValue(key, out List<int>? list))
|
||||
foreach (int i in list) result.Add(i);
|
||||
});
|
||||
|
||||
private void ForEachCell(Point3D min, Point3D max, Action<(int, int, int)> action)
|
||||
{
|
||||
int x0 = (int)Math.Floor(min.X / _cell), x1 = (int)Math.Floor(max.X / _cell);
|
||||
int y0 = (int)Math.Floor(min.Y / _cell), y1 = (int)Math.Floor(max.Y / _cell);
|
||||
int z0 = (int)Math.Floor(min.Z / _cell), z1 = (int)Math.Floor(max.Z / _cell);
|
||||
for (int x = x0; x <= x1; x++)
|
||||
for (int y = y0; y <= y1; y++)
|
||||
for (int z = z0; z <= z1; z++)
|
||||
action((x, y, z));
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 三角形相交(區間法,回傳交線段)────────────────────────
|
||||
|
||||
private static bool TriTriIntersect(in Triangle ta, in Triangle tb, out Point3D s0, out Point3D s1)
|
||||
{
|
||||
s0 = s1 = default;
|
||||
|
||||
Vector3D n2 = Vector3D.CrossProduct(tb.B - tb.A, tb.C - tb.A);
|
||||
if (n2.LengthSquared < 1e-24) return false;
|
||||
double da0 = Vector3D.DotProduct(n2, ta.A - tb.A);
|
||||
double da1 = Vector3D.DotProduct(n2, ta.B - tb.A);
|
||||
double da2 = Vector3D.DotProduct(n2, ta.C - tb.A);
|
||||
if ((da0 > 0 && da1 > 0 && da2 > 0) || (da0 < 0 && da1 < 0 && da2 < 0)) return false;
|
||||
|
||||
Vector3D n1 = Vector3D.CrossProduct(ta.B - ta.A, ta.C - ta.A);
|
||||
if (n1.LengthSquared < 1e-24) return false;
|
||||
double db0 = Vector3D.DotProduct(n1, tb.A - ta.A);
|
||||
double db1 = Vector3D.DotProduct(n1, tb.B - ta.A);
|
||||
double db2 = Vector3D.DotProduct(n1, tb.C - ta.A);
|
||||
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; // 共面:不視為穿透(近似)
|
||||
|
||||
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;
|
||||
|
||||
// 投影到交線方向取重疊區間
|
||||
double tp0 = Vector3D.DotProduct(dir, (Vector3D)p0);
|
||||
double tp1 = Vector3D.DotProduct(dir, (Vector3D)p1);
|
||||
double tq0 = Vector3D.DotProduct(dir, (Vector3D)q0);
|
||||
double tq1 = Vector3D.DotProduct(dir, (Vector3D)q1);
|
||||
if (tp0 > tp1) { (tp0, tp1) = (tp1, tp0); (p0, p1) = (p1, p0); }
|
||||
if (tq0 > tq1) { (tq0, tq1) = (tq1, tq0); }
|
||||
|
||||
double lo = Math.Max(tp0, tq0);
|
||||
double hi = Math.Min(tp1, tq1);
|
||||
if (lo >= hi) return false; // 無重疊(或僅點接觸)
|
||||
|
||||
s0 = LerpOnSegment(p0, p1, tp0, tp1, lo);
|
||||
s1 = LerpOnSegment(p0, p1, tp0, tp1, hi);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>三角形與平面(頂點符號 d0..d2 已算好)的交線段(兩個交點)</summary>
|
||||
private static bool PlaneCrossSegment(in Triangle t, double d0, double d1, double d2,
|
||||
out Point3D p0, out Point3D p1)
|
||||
{
|
||||
Span<Point3D> pts = stackalloc Point3D[3];
|
||||
int n = 0;
|
||||
AddCrossing(t.A, t.B, d0, d1, pts, ref n);
|
||||
AddCrossing(t.B, t.C, d1, d2, pts, ref n);
|
||||
AddCrossing(t.C, t.A, d2, d0, pts, ref n);
|
||||
if (n < 2) { p0 = p1 = default; return false; }
|
||||
p0 = pts[0];
|
||||
p1 = pts[1];
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void AddCrossing(Point3D a, Point3D b, double da, double db,
|
||||
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 * db < 0)
|
||||
pts[n++] = a + (b - a) * (da / (da - db));
|
||||
}
|
||||
|
||||
private static Point3D LerpOnSegment(Point3D p0, Point3D p1, double t0, double t1, double t)
|
||||
{
|
||||
double span = t1 - t0;
|
||||
double f = Math.Abs(span) < 1e-30 ? 0 : (t - t0) / span;
|
||||
return p0 + (p1 - p0) * f;
|
||||
}
|
||||
|
||||
// ─── 無干涉時的近似最小間隙 ─────────────────────────────────
|
||||
|
||||
private static (Point3D a, Point3D b, double d) ApproxMinDistance(Triangle[] trisA, Triangle[] trisB)
|
||||
{
|
||||
// 1) 抽樣頂點對頂點求初值
|
||||
double best = double.MaxValue;
|
||||
Point3D pa = default, pb = default;
|
||||
int strideA = Math.Max(1, trisA.Length / 2000);
|
||||
int strideB = Math.Max(1, trisB.Length / 2000);
|
||||
for (int i = 0; i < trisA.Length; i += strideA)
|
||||
for (int j = 0; j < trisB.Length; j += strideB)
|
||||
{
|
||||
double d = (trisB[j].A - trisA[i].A).Length;
|
||||
if (d < best) { best = d; pa = trisA[i].A; pb = trisB[j].A; }
|
||||
}
|
||||
|
||||
// 2) 以最佳點為中心,精修:點 → 對方所有三角形
|
||||
Point3D RefineAgainst(Point3D p, Triangle[] tris, ref double bestD, Point3D current)
|
||||
{
|
||||
Point3D bestPt = current;
|
||||
foreach (Triangle t in tris)
|
||||
{
|
||||
Point3D q = ClosestPointOnTriangle(p, t.A, t.B, t.C);
|
||||
double d = (q - p).Length;
|
||||
if (d < bestD) { bestD = d; bestPt = q; }
|
||||
}
|
||||
return bestPt;
|
||||
}
|
||||
pb = RefineAgainst(pa, trisB, ref best, pb);
|
||||
pa = RefineAgainst(pb, trisA, ref best, pa);
|
||||
return (pa, pb, best);
|
||||
}
|
||||
|
||||
/// <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;
|
||||
double d1 = Vector3D.DotProduct(ab, ap);
|
||||
double d2 = Vector3D.DotProduct(ac, ap);
|
||||
if (d1 <= 0 && d2 <= 0) return a;
|
||||
|
||||
Vector3D bp = p - b;
|
||||
double d3 = Vector3D.DotProduct(ab, bp);
|
||||
double d4 = Vector3D.DotProduct(ac, bp);
|
||||
if (d3 >= 0 && d4 <= d3) return b;
|
||||
|
||||
double vc = d1 * d4 - d3 * d2;
|
||||
if (vc <= 0 && d1 >= 0 && d3 <= 0)
|
||||
return a + ab * (d1 / (d1 - d3));
|
||||
|
||||
Vector3D cp = p - c;
|
||||
double d5 = Vector3D.DotProduct(ab, cp);
|
||||
double d6 = Vector3D.DotProduct(ac, cp);
|
||||
if (d6 >= 0 && d5 <= d6) return c;
|
||||
|
||||
double vb = d5 * d2 - d1 * d6;
|
||||
if (vb <= 0 && d2 >= 0 && d6 <= 0)
|
||||
return a + ac * (d2 / (d2 - d6));
|
||||
|
||||
double va = d3 * d6 - d5 * d4;
|
||||
if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0)
|
||||
return b + (c - b) * ((d4 - d3) / ((d4 - d3) + (d5 - d6)));
|
||||
|
||||
double denom = 1 / (va + vb + vc);
|
||||
return a + ab * (vb * denom) + ac * (vc * denom);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
using System;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Media3D;
|
||||
using CADability;
|
||||
using CADability.GeoObject;
|
||||
using HelixToolkit.Wpf;
|
||||
using STPViewer.Models;
|
||||
|
||||
namespace STPViewer.Services;
|
||||
|
||||
/// <summary>角度量測的單次拾取:邊方向或面法向量</summary>
|
||||
public record DirectionPick(Point3D At, Vector3D Direction, string Desc);
|
||||
|
||||
/// <summary>
|
||||
/// 點/距離/邊/面/圓/角度/面距 量測幾何計算 + 視圖 overlay 產生。
|
||||
/// 邊長、圓半徑以 B-rep 精確值為準;面積與面距用三角網格近似。
|
||||
/// 所有文字以 Func<UnitSystem,string> 延後產生,支援 mm/inch 即時切換。
|
||||
/// </summary>
|
||||
public class MeasurementService
|
||||
{
|
||||
private static readonly Brush MarkerBrush = Brushes.OrangeRed;
|
||||
private static readonly Color HighlightColor = Colors.Gold;
|
||||
private static readonly Brush LabelFg = Brushes.Black;
|
||||
private static readonly Brush LabelBg = new SolidColorBrush(Color.FromArgb(200, 255, 255, 210));
|
||||
|
||||
static MeasurementService() => LabelBg.Freeze();
|
||||
|
||||
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);
|
||||
|
||||
// ─── 拾取輔助 ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>命中點吸附到最近的 B-rep 頂點(容差內),否則回傳原始表面點</summary>
|
||||
public Point3D Snap(FaceInfo fi, Point3D hit, double tolerance)
|
||||
{
|
||||
if (fi.BrepFace is null) return hit;
|
||||
Point3D best = hit;
|
||||
double bestD = tolerance;
|
||||
try
|
||||
{
|
||||
foreach (Vertex v in fi.BrepFace.Vertices)
|
||||
{
|
||||
Point3D p = ToP3(v.Position);
|
||||
double d = (p - hit).Length;
|
||||
if (d < bestD) { bestD = d; best = p; }
|
||||
}
|
||||
}
|
||||
catch { /* 頂點存取失敗則不吸附 */ }
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 角度量測拾取:命中點附近有直線邊(容差內)→ 邊方向;
|
||||
/// 否則取面法向量(B-rep 精確;無 B-rep 時用網格法向量)。
|
||||
/// </summary>
|
||||
public DirectionPick PickDirection(FaceInfo fi, Point3D hit, Vector3D meshNormal, double tolerance)
|
||||
{
|
||||
if (fi.BrepFace is not null)
|
||||
{
|
||||
// 1) 近距離直線邊優先
|
||||
try
|
||||
{
|
||||
foreach (Edge e in fi.BrepFace.AllEdges)
|
||||
{
|
||||
if (e.Curve3D is not Line ln) continue;
|
||||
double pos = Math.Clamp(ln.PositionOf(ToGeo(hit)), 0, 1);
|
||||
Point3D p = ToP3(ln.PointAt(pos));
|
||||
if ((p - hit).Length < tolerance)
|
||||
{
|
||||
Vector3D dir = ToP3(ln.EndPoint) - ToP3(ln.StartPoint);
|
||||
dir.Normalize();
|
||||
return new DirectionPick(p, dir, "邊方向");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
// 2) 面法向量(精確)
|
||||
try
|
||||
{
|
||||
GeoPoint2D uv = fi.BrepFace.Surface.PositionOf(ToGeo(hit));
|
||||
GeoVector n = fi.BrepFace.Surface.GetNormal(uv).Normalized;
|
||||
return new DirectionPick(hit, new Vector3D(n.x, n.y, n.z), "面法向量");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
Vector3D mn = meshNormal;
|
||||
if (mn.LengthSquared < 1e-12) mn = new Vector3D(0, 0, 1);
|
||||
mn.Normalize();
|
||||
return new DirectionPick(hit, mn, "面法向量(網格)");
|
||||
}
|
||||
|
||||
// ─── 點 / 距離 ───────────────────────────────────────────────
|
||||
|
||||
public MeasurementResult MeasurePoint(Point3D p, string label, double markerR)
|
||||
{
|
||||
var r = new MeasurementResult
|
||||
{
|
||||
Kind = MeasureMode.Point,
|
||||
TitleFor = u => $"{label} {Units.P(p, u)}",
|
||||
DetailFor = u => $"X = {Units.L(p.X, u)}\nY = {Units.L(p.Y, u)}\nZ = {Units.L(p.Z, u)}",
|
||||
};
|
||||
r.Overlays.Add(Sphere(p, markerR));
|
||||
AddLabel(r, p + new Vector3D(0, 0, markerR * 2), _ => label);
|
||||
return r;
|
||||
}
|
||||
|
||||
public MeasurementResult MeasureDistance(Point3D p1, Point3D p2, string label, double markerR)
|
||||
{
|
||||
Vector3D d = p2 - p1;
|
||||
var r = new MeasurementResult
|
||||
{
|
||||
Kind = MeasureMode.Distance,
|
||||
TitleFor = u => $"{label} {Units.L(d.Length, u)}",
|
||||
DetailFor = u => $"距離 = {Units.L(d.Length, u)}\nΔX = {Units.L(d.X, u)}\nΔY = {Units.L(d.Y, u)}\nΔZ = {Units.L(d.Z, u)}\n" +
|
||||
$"P1 {Units.P(p1, u)}\nP2 {Units.P(p2, u)}",
|
||||
};
|
||||
r.Overlays.Add(Sphere(p1, markerR));
|
||||
r.Overlays.Add(Sphere(p2, markerR));
|
||||
r.Overlays.Add(new LinesVisual3D
|
||||
{
|
||||
Points = new Point3DCollection { p1, p2 },
|
||||
Color = Colors.OrangeRed,
|
||||
Thickness = 2,
|
||||
});
|
||||
AddLabel(r, p1 + d / 2 + new Vector3D(0, 0, markerR * 2), u => Units.L(d.Length, u));
|
||||
return r;
|
||||
}
|
||||
|
||||
// ─── 邊 / 圓 ─────────────────────────────────────────────────
|
||||
|
||||
/// <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;
|
||||
Edge? best = null;
|
||||
double bestD = double.MaxValue;
|
||||
foreach (Edge e in fi.BrepFace.AllEdges)
|
||||
{
|
||||
ICurve? c;
|
||||
try { c = e.Curve3D; } catch { continue; }
|
||||
if (c is null) continue;
|
||||
if (circlesOnly && c is not Ellipse { IsCircle: true }) continue;
|
||||
|
||||
double pos;
|
||||
try { pos = c.PositionOf(ToGeo(hit)); } catch { continue; }
|
||||
if (double.IsNaN(pos)) pos = 0.5;
|
||||
pos = Math.Clamp(pos, 0, 1);
|
||||
Point3D p;
|
||||
try { p = ToP3(c.PointAt(pos)); } catch { continue; }
|
||||
double d = (p - hit).Length;
|
||||
if (d < bestD) { bestD = d; best = e; }
|
||||
}
|
||||
if (best?.Curve3D is not ICurve curve) return null;
|
||||
|
||||
return curve switch
|
||||
{
|
||||
Ellipse el when el.IsCircle => CircleResult(el, curve, label, markerR),
|
||||
Line ln => LineResult(ln, label, markerR),
|
||||
_ => GenericCurveResult(curve, label, markerR),
|
||||
};
|
||||
}
|
||||
|
||||
private MeasurementResult CircleResult(Ellipse el, ICurve curve, string label, double markerR)
|
||||
{
|
||||
Point3D center = ToP3(el.Center);
|
||||
double radius = el.Radius;
|
||||
double curveLen = curve.Length;
|
||||
bool full = !el.IsArc;
|
||||
var r = new MeasurementResult
|
||||
{
|
||||
Kind = MeasureMode.Circle,
|
||||
TitleFor = u => $"{label} ⌀{Units.L(radius * 2, u)}",
|
||||
DetailFor = u => $"直徑 = {Units.L(radius * 2, u)}\n半徑 = {Units.L(radius, u)}\n" +
|
||||
$"{(full ? "周長" : "弧長")} = {Units.L(curveLen, u)}\n" +
|
||||
$"圓心 {Units.P(center, u)}",
|
||||
};
|
||||
r.Overlays.Add(Polyline(curve, HighlightColor, 3));
|
||||
r.Overlays.Add(Sphere(center, markerR));
|
||||
Point3D rim = ToP3(curve.PointAt(0));
|
||||
r.Overlays.Add(new LinesVisual3D
|
||||
{
|
||||
Points = new Point3DCollection { center, rim },
|
||||
Color = Colors.OrangeRed,
|
||||
Thickness = 1.5,
|
||||
});
|
||||
AddLabel(r, center + new Vector3D(0, 0, markerR * 2), u => $"{label} ⌀{Units.L(radius * 2, u)}");
|
||||
return r;
|
||||
}
|
||||
|
||||
private MeasurementResult LineResult(Line ln, string label, double markerR)
|
||||
{
|
||||
Point3D s = ToP3(ln.StartPoint), e = ToP3(ln.EndPoint);
|
||||
Vector3D d = e - s;
|
||||
double len = ln.Length;
|
||||
var r = new MeasurementResult
|
||||
{
|
||||
Kind = MeasureMode.Edge,
|
||||
TitleFor = u => $"{label} L = {Units.L(len, u)}",
|
||||
DetailFor = u => $"長度 = {Units.L(len, u)}\nΔX = {Units.C(d.X, u)} ΔY = {Units.C(d.Y, u)} ΔZ = {Units.C(d.Z, u)}\n" +
|
||||
$"起點 {Units.P(s, u)}\n終點 {Units.P(e, u)}",
|
||||
};
|
||||
r.Overlays.Add(new LinesVisual3D
|
||||
{
|
||||
Points = new Point3DCollection { s, e },
|
||||
Color = HighlightColor,
|
||||
Thickness = 3,
|
||||
});
|
||||
AddLabel(r, s + d / 2 + new Vector3D(0, 0, markerR * 2), u => $"L={Units.L(len, u)}");
|
||||
return r;
|
||||
}
|
||||
|
||||
private MeasurementResult GenericCurveResult(ICurve curve, string label, double markerR)
|
||||
{
|
||||
double len;
|
||||
try { len = curve.Length; } catch { len = double.NaN; }
|
||||
string typeName = curve.GetType().Name;
|
||||
var r = new MeasurementResult
|
||||
{
|
||||
Kind = MeasureMode.Edge,
|
||||
TitleFor = u => $"{label} L = {Units.L(len, u)}",
|
||||
DetailFor = u => $"曲線長 = {Units.L(len, u)}\n類型 = {typeName}",
|
||||
};
|
||||
r.Overlays.Add(Polyline(curve, HighlightColor, 3));
|
||||
AddLabel(r, ToP3(curve.PointAt(0.5)) + new Vector3D(0, 0, markerR * 2), u => $"L={Units.L(len, u)}");
|
||||
return r;
|
||||
}
|
||||
|
||||
// ─── 面 ──────────────────────────────────────────────────────
|
||||
|
||||
public MeasurementResult MeasureFace(FaceInfo fi, Point3D hit, string label, double markerR)
|
||||
{
|
||||
// 面積:三角網格加總(近似值,三角化精度內)
|
||||
double area = MeshArea(fi.Mesh);
|
||||
int triCount = fi.Mesh.TriangleIndices.Count / 3;
|
||||
Func<UnitSystem, string> surfaceDesc = fi.BrepFace is not null
|
||||
? DescribeSurface(fi.BrepFace.Surface)
|
||||
: _ => "類型 = 網格(無 B-rep)";
|
||||
|
||||
var r = new MeasurementResult
|
||||
{
|
||||
Kind = MeasureMode.Face,
|
||||
TitleFor = u => $"{label} A ≈ {Units.A(area, u)}",
|
||||
DetailFor = u => $"面積 ≈ {Units.A(area, u)}(網格近似)\n{surfaceDesc(u)}\n三角形數 = {triCount:N0}",
|
||||
};
|
||||
|
||||
// overlay:外輪廓 highlight + 標籤
|
||||
if (fi.BrepFace is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (Edge e in fi.BrepFace.OutlineEdges)
|
||||
if (e.Curve3D is ICurve c)
|
||||
r.Overlays.Add(Polyline(c, HighlightColor, 3));
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
AddLabel(r, hit + new Vector3D(0, 0, markerR * 2), u => $"{label} A≈{Units.A(area, u)}");
|
||||
return r;
|
||||
}
|
||||
|
||||
private static double MeshArea(MeshGeometry3D mesh)
|
||||
{
|
||||
double area = 0;
|
||||
Point3DCollection pos = mesh.Positions;
|
||||
Int32Collection idx = mesh.TriangleIndices;
|
||||
for (int i = 0; i + 2 < idx.Count; i += 3)
|
||||
{
|
||||
Vector3D a = pos[idx[i + 1]] - pos[idx[i]];
|
||||
Vector3D b = pos[idx[i + 2]] - pos[idx[i]];
|
||||
area += Vector3D.CrossProduct(a, b).Length / 2;
|
||||
}
|
||||
return area;
|
||||
}
|
||||
|
||||
private static Func<UnitSystem, string> DescribeSurface(ISurface surface)
|
||||
{
|
||||
switch (surface)
|
||||
{
|
||||
case PlaneSurface ps:
|
||||
{
|
||||
GeoVector n = ps.Normal.Normalized;
|
||||
string desc = $"類型 = 平面\n法向量 ({n.x:F3}, {n.y:F3}, {n.z:F3})";
|
||||
return _ => desc;
|
||||
}
|
||||
case CylindricalSurface cs:
|
||||
{
|
||||
GeoVector ax = cs.Axis.Normalized;
|
||||
double radius = cs.RadiusX;
|
||||
return u => $"類型 = 圓柱面\n半徑 = {Units.L(radius, u)}\n軸向 ({ax.x:F3}, {ax.y:F3}, {ax.z:F3})";
|
||||
}
|
||||
default:
|
||||
{
|
||||
string desc = $"類型 = {SurfaceTypeName(surface)}";
|
||||
return _ => desc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string SurfaceTypeName(ISurface s) => s.GetType().Name switch
|
||||
{
|
||||
"SphericalSurface" => "球面",
|
||||
"ToroidalSurface" => "環面",
|
||||
"ConicalSurface" => "圓錐面",
|
||||
"NurbsSurface" => "NURBS 曲面",
|
||||
"SurfaceOfRevolution" => "旋轉曲面",
|
||||
"SurfaceOfLinearExtrusion" => "拉伸曲面",
|
||||
var other => other,
|
||||
};
|
||||
|
||||
// ─── 角度(兩面 / 兩邊夾角)─────────────────────────────────
|
||||
|
||||
public MeasurementResult MeasureAngle(DirectionPick a, DirectionPick b, string label, double markerR, double arrowLen)
|
||||
{
|
||||
double cos = Math.Clamp(Vector3D.DotProduct(a.Direction, b.Direction)
|
||||
/ (a.Direction.Length * b.Direction.Length), -1, 1);
|
||||
double deg = Math.Acos(cos) * 180 / Math.PI;
|
||||
double comp = 180 - deg;
|
||||
|
||||
var r = new MeasurementResult
|
||||
{
|
||||
Kind = MeasureMode.Angle,
|
||||
TitleFor = _ => $"{label} ∠ {deg:F2}°",
|
||||
DetailFor = _ => $"夾角 = {deg:F2}°(補角 {comp:F2}°)\n" +
|
||||
$"方向 1:{a.Desc} ({a.Direction.X:F3}, {a.Direction.Y:F3}, {a.Direction.Z:F3})\n" +
|
||||
$"方向 2:{b.Desc} ({b.Direction.X:F3}, {b.Direction.Y:F3}, {b.Direction.Z:F3})\n" +
|
||||
"(兩面夾角以法向量計,互補角請參考補角值)",
|
||||
};
|
||||
r.Overlays.Add(Sphere(a.At, markerR));
|
||||
r.Overlays.Add(Sphere(b.At, markerR));
|
||||
r.Overlays.Add(DirectionLine(a, arrowLen));
|
||||
r.Overlays.Add(DirectionLine(b, arrowLen));
|
||||
Point3D mid = a.At + (b.At - a.At) / 2;
|
||||
AddLabel(r, mid + new Vector3D(0, 0, markerR * 2), _ => $"∠{deg:F2}°");
|
||||
return r;
|
||||
}
|
||||
|
||||
private static LinesVisual3D DirectionLine(DirectionPick p, double len) => new()
|
||||
{
|
||||
Points = new Point3DCollection
|
||||
{
|
||||
p.At - p.Direction * len * 0.2,
|
||||
p.At + p.Direction * len,
|
||||
},
|
||||
Color = Colors.MediumBlue,
|
||||
Thickness = 2,
|
||||
};
|
||||
|
||||
// ─── 面到面最短距離(網格近似)──────────────────────────────
|
||||
|
||||
public MeasurementResult MeasureFaceDistance(FaceInfo a, FaceInfo b, string label, double markerR)
|
||||
{
|
||||
(Point3D pa, Point3D pb) = MeshMinDistance(a.Mesh, b.Mesh);
|
||||
Vector3D d = pb - pa;
|
||||
var r = new MeasurementResult
|
||||
{
|
||||
Kind = MeasureMode.FaceDistance,
|
||||
TitleFor = u => $"{label} min = {Units.L(d.Length, u)}",
|
||||
DetailFor = u => $"最短距離 ≈ {Units.L(d.Length, u)}(網格近似)\n" +
|
||||
$"ΔX = {Units.L(d.X, u)}\nΔY = {Units.L(d.Y, u)}\nΔZ = {Units.L(d.Z, u)}\n" +
|
||||
$"點 1 {Units.P(pa, u)}\n點 2 {Units.P(pb, u)}",
|
||||
};
|
||||
r.Overlays.Add(Sphere(pa, markerR));
|
||||
r.Overlays.Add(Sphere(pb, markerR));
|
||||
r.Overlays.Add(new LinesVisual3D
|
||||
{
|
||||
Points = new Point3DCollection { pa, pb },
|
||||
Color = Colors.OrangeRed,
|
||||
Thickness = 2,
|
||||
});
|
||||
AddLabel(r, pa + d / 2 + new Vector3D(0, 0, markerR * 2), u => $"min {Units.L(d.Length, u)}");
|
||||
return r;
|
||||
}
|
||||
|
||||
/// <summary>兩網格最近點對:頂點→三角形雙向(頂點過多時抽樣,結果為近似值)</summary>
|
||||
private static (Point3D a, Point3D b) MeshMinDistance(MeshGeometry3D ma, MeshGeometry3D mb)
|
||||
{
|
||||
double best = double.MaxValue;
|
||||
Point3D pa = default, pb = default;
|
||||
|
||||
void Probe(MeshGeometry3D verts, MeshGeometry3D tris, bool swap)
|
||||
{
|
||||
Point3DCollection vp = verts.Positions;
|
||||
Point3DCollection tp = tris.Positions;
|
||||
Int32Collection ti = tris.TriangleIndices;
|
||||
int vStride = Math.Max(1, vp.Count / 3000);
|
||||
for (int i = 0; i < vp.Count; i += vStride)
|
||||
{
|
||||
Point3D v = vp[i];
|
||||
for (int t = 0; t + 2 < ti.Count; t += 3)
|
||||
{
|
||||
Point3D q = ClosestPointOnTriangle(v, tp[ti[t]], tp[ti[t + 1]], tp[ti[t + 2]]);
|
||||
double d = (q - v).Length;
|
||||
if (d < best)
|
||||
{
|
||||
best = d;
|
||||
(pa, pb) = swap ? (q, v) : (v, q);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Probe(ma, mb, swap: false);
|
||||
Probe(mb, ma, swap: true);
|
||||
return (pa, pb);
|
||||
}
|
||||
|
||||
/// <summary>點到三角形最近點(Ericson, Real-Time Collision Detection)</summary>
|
||||
private static Point3D ClosestPointOnTriangle(Point3D p, Point3D a, Point3D b, Point3D c)
|
||||
{
|
||||
Vector3D ab = b - a, ac = c - a, ap = p - a;
|
||||
double d1 = Vector3D.DotProduct(ab, ap);
|
||||
double d2 = Vector3D.DotProduct(ac, ap);
|
||||
if (d1 <= 0 && d2 <= 0) return a;
|
||||
|
||||
Vector3D bp = p - b;
|
||||
double d3 = Vector3D.DotProduct(ab, bp);
|
||||
double d4 = Vector3D.DotProduct(ac, bp);
|
||||
if (d3 >= 0 && d4 <= d3) return b;
|
||||
|
||||
double vc = d1 * d4 - d3 * d2;
|
||||
if (vc <= 0 && d1 >= 0 && d3 <= 0)
|
||||
return a + ab * (d1 / (d1 - d3));
|
||||
|
||||
Vector3D cp = p - c;
|
||||
double d5 = Vector3D.DotProduct(ab, cp);
|
||||
double d6 = Vector3D.DotProduct(ac, cp);
|
||||
if (d6 >= 0 && d5 <= d6) return c;
|
||||
|
||||
double vb = d5 * d2 - d1 * d6;
|
||||
if (vb <= 0 && d2 >= 0 && d6 <= 0)
|
||||
return a + ac * (d2 / (d2 - d6));
|
||||
|
||||
double va = d3 * d6 - d5 * d4;
|
||||
if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0)
|
||||
return b + (c - b) * ((d4 - d3) / ((d4 - d3) + (d5 - d6)));
|
||||
|
||||
double denom = 1 / (va + vb + vc);
|
||||
return a + ab * (vb * denom) + ac * (vc * denom);
|
||||
}
|
||||
|
||||
// ─── overlay helpers ─────────────────────────────────────────
|
||||
|
||||
private static SphereVisual3D Sphere(Point3D center, double radius) => new()
|
||||
{
|
||||
Center = center,
|
||||
Radius = radius,
|
||||
Fill = MarkerBrush,
|
||||
};
|
||||
|
||||
private static void AddLabel(MeasurementResult r, Point3D position, Func<UnitSystem, string> textFor)
|
||||
{
|
||||
var label = new BillboardTextVisual3D
|
||||
{
|
||||
Position = position,
|
||||
Text = textFor(UnitSystem.Millimeter),
|
||||
Foreground = LabelFg,
|
||||
Background = LabelBg,
|
||||
Padding = new Thickness(4, 2, 4, 2),
|
||||
FontSize = 14,
|
||||
};
|
||||
r.Overlays.Add(label);
|
||||
r.DynamicLabels.Add((label, textFor));
|
||||
}
|
||||
|
||||
private static LinesVisual3D Polyline(ICurve curve, Color color, double thickness)
|
||||
{
|
||||
int n = curve is Line ? 1 : 64;
|
||||
var pts = new Point3DCollection(n * 2);
|
||||
try
|
||||
{
|
||||
GeoPoint prev = curve.PointAt(0);
|
||||
for (int i = 1; i <= n; i++)
|
||||
{
|
||||
GeoPoint cur = curve.PointAt(i / (double)n);
|
||||
pts.Add(ToP3(prev));
|
||||
pts.Add(ToP3(cur));
|
||||
prev = cur;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return new LinesVisual3D { Points = pts, Color = color, Thickness = thickness };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.Windows.Media.Media3D;
|
||||
|
||||
namespace STPViewer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 三點對齊 / 旋轉的剛體變換數學。
|
||||
/// 同一個變換需要兩種表示:WPF <see cref="Matrix3D"/>(列向量約定,給網格/邊線)與
|
||||
/// CADability <see cref="CADability.ModOp"/>(行向量約定,給 B-rep Modify)— 用 <see cref="ToModOp"/> 轉換。
|
||||
/// </summary>
|
||||
public static class RigidAlign
|
||||
{
|
||||
/// <summary>
|
||||
/// 來源 3 點 → 目標 3 點 的剛體變換(不縮放):
|
||||
/// p1→q1 精確貼合、p1p2 方向對齊 q1q2、三點平面對齊。共線/退化回傳 false。
|
||||
/// </summary>
|
||||
public static bool TryRigidTransform(
|
||||
Point3D p1, Point3D p2, Point3D p3,
|
||||
Point3D q1, Point3D q2, Point3D q3, out Matrix3D m)
|
||||
{
|
||||
m = Matrix3D.Identity;
|
||||
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ᵀ(行向量約定);WPF Matrix3D 為列向量約定 → 轉置擺放
|
||||
double R(int r, int c)
|
||||
{
|
||||
double xpc = c == 0 ? xp.X : c == 1 ? xp.Y : xp.Z;
|
||||
double ypc = c == 0 ? yp.X : c == 1 ? yp.Y : yp.Z;
|
||||
double zpc = c == 0 ? zp.X : c == 1 ? zp.Y : zp.Z;
|
||||
double xqr = r == 0 ? xq.X : r == 1 ? xq.Y : xq.Z;
|
||||
double yqr = r == 0 ? yq.X : r == 1 ? yq.Y : yq.Z;
|
||||
double zqr = r == 0 ? zq.X : r == 1 ? zq.Y : zq.Z;
|
||||
return xqr * xpc + yqr * ypc + zqr * zpc;
|
||||
}
|
||||
|
||||
// t = q1 − R·p1
|
||||
double tx = q1.X - (R(0, 0) * p1.X + R(0, 1) * p1.Y + R(0, 2) * p1.Z);
|
||||
double ty = q1.Y - (R(1, 0) * p1.X + R(1, 1) * p1.Y + R(1, 2) * p1.Z);
|
||||
double tz = q1.Z - (R(2, 0) * p1.X + R(2, 1) * p1.Y + R(2, 2) * p1.Z);
|
||||
|
||||
m = new Matrix3D(
|
||||
R(0, 0), R(1, 0), R(2, 0), 0,
|
||||
R(0, 1), R(1, 1), R(2, 1), 0,
|
||||
R(0, 2), R(1, 2), R(2, 2), 0,
|
||||
tx, ty, tz, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <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 },
|
||||
{ m.M12, m.M22, m.M32, m.OffsetY },
|
||||
{ m.M13, m.M23, m.M33, m.OffsetZ },
|
||||
});
|
||||
|
||||
public static bool Collinear(Point3D a, Point3D b, Point3D c) =>
|
||||
Vector3D.CrossProduct(b - a, c - a).LengthSquared < 1e-12;
|
||||
|
||||
/// <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)
|
||||
{
|
||||
x = b - a;
|
||||
z = Vector3D.CrossProduct(x, c - a);
|
||||
y = default;
|
||||
if (x.LengthSquared < 1e-12 || z.LengthSquared < 1e-12) return false;
|
||||
x.Normalize();
|
||||
z.Normalize();
|
||||
y = Vector3D.CrossProduct(z, x);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Media3D;
|
||||
|
||||
namespace STPViewer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 剖面:把三角網格 / 線段集用半空間裁切(保留 dot(p - p0, n) ≤ 0 的一側)。
|
||||
/// WPF 3D 沒有 GPU clip plane,採 CPU 裁切後置換 Geometry(原始 mesh 保留可還原)。
|
||||
/// </summary>
|
||||
public static class SectionService
|
||||
{
|
||||
private static readonly MeshGeometry3D EmptyMesh = CreateEmpty();
|
||||
|
||||
private static MeshGeometry3D CreateEmpty()
|
||||
{
|
||||
var m = new MeshGeometry3D();
|
||||
m.Freeze();
|
||||
return m;
|
||||
}
|
||||
|
||||
public static MeshGeometry3D ClipMesh(MeshGeometry3D src, Point3D planePoint, Vector3D normal)
|
||||
{
|
||||
Point3DCollection pos = src.Positions;
|
||||
Int32Collection idx = src.TriangleIndices;
|
||||
|
||||
// 頂點距離快取
|
||||
var dist = new double[pos.Count];
|
||||
for (int i = 0; i < pos.Count; i++)
|
||||
dist[i] = Vector3D.DotProduct(pos[i] - planePoint, normal);
|
||||
|
||||
var outPos = new Point3DCollection();
|
||||
var outIdx = new Int32Collection();
|
||||
|
||||
Span<int> vi = stackalloc int[3];
|
||||
Span<Point3D> poly = stackalloc Point3D[4];
|
||||
for (int t = 0; t + 2 < idx.Count; t += 3)
|
||||
{
|
||||
vi[0] = idx[t]; vi[1] = idx[t + 1]; vi[2] = idx[t + 2];
|
||||
int keepCount = 0;
|
||||
for (int k = 0; k < 3; k++)
|
||||
if (dist[vi[k]] <= 0) keepCount++;
|
||||
|
||||
if (keepCount == 0) continue;
|
||||
if (keepCount == 3)
|
||||
{
|
||||
EmitTriangle(outPos, outIdx, pos[vi[0]], pos[vi[1]], pos[vi[2]]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 部分在保留側:Sutherland-Hodgman 裁出 3 或 4 邊形,扇形分割
|
||||
int n = 0;
|
||||
for (int k = 0; k < 3; k++)
|
||||
{
|
||||
int a = vi[k], b = vi[(k + 1) % 3];
|
||||
double da = dist[a], db = dist[b];
|
||||
if (da <= 0) poly[n++] = pos[a];
|
||||
if ((da <= 0) != (db <= 0))
|
||||
{
|
||||
double f = da / (da - db);
|
||||
poly[n++] = pos[a] + (pos[b] - pos[a]) * f;
|
||||
}
|
||||
}
|
||||
if (n >= 3) EmitTriangle(outPos, outIdx, poly[0], poly[1], poly[2]);
|
||||
if (n == 4) EmitTriangle(outPos, outIdx, poly[0], poly[2], poly[3]);
|
||||
}
|
||||
|
||||
if (outIdx.Count == 0) return EmptyMesh;
|
||||
var mesh = new MeshGeometry3D { Positions = outPos, TriangleIndices = outIdx };
|
||||
mesh.Freeze();
|
||||
return mesh;
|
||||
}
|
||||
|
||||
private static void EmitTriangle(Point3DCollection pos, Int32Collection idx,
|
||||
Point3D a, Point3D b, Point3D c)
|
||||
{
|
||||
int i = pos.Count;
|
||||
pos.Add(a); pos.Add(b); pos.Add(c);
|
||||
idx.Add(i); idx.Add(i + 1); idx.Add(i + 2);
|
||||
}
|
||||
|
||||
/// <summary>裁切線段集(成對端點)</summary>
|
||||
public static Point3DCollection ClipSegments(Point3DCollection segments, Point3D planePoint, Vector3D normal)
|
||||
{
|
||||
var result = new Point3DCollection();
|
||||
for (int i = 0; i + 1 < segments.Count; i += 2)
|
||||
{
|
||||
Point3D a = segments[i], b = segments[i + 1];
|
||||
double da = Vector3D.DotProduct(a - planePoint, normal);
|
||||
double db = Vector3D.DotProduct(b - planePoint, normal);
|
||||
if (da <= 0 && db <= 0) { result.Add(a); result.Add(b); }
|
||||
else if (da <= 0 || db <= 0)
|
||||
{
|
||||
double f = da / (da - db);
|
||||
Point3D x = a + (b - a) * f;
|
||||
if (da <= 0) { result.Add(a); result.Add(x); }
|
||||
else { result.Add(x); result.Add(b); }
|
||||
}
|
||||
}
|
||||
result.Freeze();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Media3D;
|
||||
using CADability;
|
||||
using CADability.GeoObject;
|
||||
using IOPath = System.IO.Path; // CADability.GeoObject.Path(曲線)撞名
|
||||
|
||||
namespace STPViewer.Services;
|
||||
|
||||
/// <summary>單一 B-rep Face 的三角網格(mesh 已 Freeze,可跨執行緒);STL 來源 BrepFace 為 null</summary>
|
||||
public record ImportedFace(Face? BrepFace, MeshGeometry3D Mesh);
|
||||
|
||||
/// <summary>匯入後的階層節點:group(裝配)或 leaf(零件幾何)</summary>
|
||||
public class ImportedNode
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public List<ImportedNode> Children { get; } = new();
|
||||
|
||||
// ── leaf 幾何 ──
|
||||
public List<ImportedFace> Faces { get; } = new();
|
||||
public Point3DCollection? EdgeSegments { get; set; } // 輪廓線(成對端點,已 Freeze)
|
||||
public bool HasBrep { get; set; } = true; // STL / 純曲線 = false
|
||||
|
||||
/// <summary>來源 B-rep 物件(Solid/Shell/Face/曲線)— 零件平移時整體 Modify 用</summary>
|
||||
public List<CADability.GeoObject.IGeoObject> SourceGeos { get; } = new();
|
||||
|
||||
// ── 統計(group 節點由 Aggregate 彙總)──
|
||||
public int SolidCount { get; set; }
|
||||
public int FaceCount { get; set; }
|
||||
public int TriangleCount { get; set; }
|
||||
public Rect3D Bounds { get; set; } = Rect3D.Empty;
|
||||
|
||||
public bool IsLeaf => Children.Count == 0;
|
||||
}
|
||||
|
||||
public record ImportedFileData(string FilePath, ImportedNode Root);
|
||||
|
||||
/// <summary>
|
||||
/// CAD 檔匯入(STEP / STL / DXF)。STEP 以 HierarchyToBlocks 還原裝配樹。
|
||||
/// 設計為在背景執行緒呼叫;回傳的 Freezable 全部已 Freeze。
|
||||
/// </summary>
|
||||
public class StepImportService
|
||||
{
|
||||
/// <summary>匯入階段回報(耗時分解),UI 可顯示於狀態列;null 則靜默</summary>
|
||||
public Action<string>? Progress { get; set; }
|
||||
|
||||
private long _triTicks, _edgeTicks; // 三角化 / 邊取樣 累計耗時(TimeSpan ticks,避免逐面 ms 截斷歸零)
|
||||
private double TriMs => new TimeSpan(_triTicks).TotalMilliseconds;
|
||||
private double EdgeMs => new TimeSpan(_edgeTicks).TotalMilliseconds;
|
||||
|
||||
/// <summary>延後的 leaf 幾何工作(三角化+邊取樣):樹走訪時收集,之後平行執行。
|
||||
/// 平行粒度 = 整個 leaf(同 leaf 的面共用 Edge,不可面級平行)。</summary>
|
||||
private readonly List<Action> _leafWork = new();
|
||||
|
||||
/// <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)
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
throw new FileNotFoundException("找不到檔案", filePath);
|
||||
|
||||
_triTicks = _edgeTicks = 0;
|
||||
_leafWork.Clear();
|
||||
_retry.Clear();
|
||||
var root = new ImportedNode { Name = IOPath.GetFileNameWithoutExtension(filePath) };
|
||||
switch (IOPath.GetExtension(filePath).ToLowerInvariant())
|
||||
{
|
||||
case ".stp" or ".step": ImportStepFile(filePath, root); break;
|
||||
case ".stl": ImportStlFile(filePath, root); break;
|
||||
case ".dxf": ImportDxfFile(filePath, root); break;
|
||||
default:
|
||||
throw new InvalidDataException("不支援的格式(支援 .stp / .step / .stl / .dxf;CADability 無 IGES reader)");
|
||||
}
|
||||
|
||||
if (_leafWork.Count > 0)
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
Progress?.Invoke($"三角化 {_leafWork.Count} 個零件(平行)…");
|
||||
System.Threading.Tasks.Parallel.ForEach(_leafWork, w =>
|
||||
{
|
||||
try { w(); }
|
||||
catch { /* 個別零件失敗不讓整檔失敗 */ }
|
||||
});
|
||||
_leafWork.Clear();
|
||||
|
||||
// 平行下偶發失敗的面:競爭已結束,循序重試一輪(FinishLeaf 也延到此時才跑)
|
||||
int retried = 0;
|
||||
foreach ((ImportedNode leaf, HashSet<Edge> edgeSet, var failed) in _retry)
|
||||
{
|
||||
foreach ((Face f, double prec) in failed)
|
||||
if (AddFace(f, prec, leaf, edgeSet)) retried++;
|
||||
FinishLeaf(leaf, edgeSet);
|
||||
}
|
||||
_retry.Clear();
|
||||
Progress?.Invoke($"幾何處理 {sw.ElapsedMilliseconds:N0} ms" +
|
||||
$"(三角化 {TriMs:N0} ms、邊取樣 {EdgeMs:N0} ms{(retried > 0 ? $"、重試補回 {retried} 面" : "")})");
|
||||
}
|
||||
|
||||
Prune(root);
|
||||
Aggregate(root);
|
||||
if (root.SolidCount == 0 && root.FaceCount == 0 && CountSegments(root) == 0)
|
||||
throw new InvalidDataException("檔案內沒有可顯示的幾何");
|
||||
return new ImportedFileData(filePath, root);
|
||||
}
|
||||
|
||||
public static bool IsSupported(string path) =>
|
||||
IOPath.GetExtension(path).ToLowerInvariant() is ".stp" or ".step" or ".stl" or ".dxf";
|
||||
|
||||
// ─── STEP ────────────────────────────────────────────────────
|
||||
|
||||
private void ImportStepFile(string path, ImportedNode root)
|
||||
{
|
||||
Progress?.Invoke("解析 STEP(大檔可能需數分鐘)…");
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var importer = new ImportStep { HierarchyToBlocks = true };
|
||||
GeoObjectList list = importer.Read(path)
|
||||
?? throw new InvalidDataException("CADability 無法解析此 STEP 檔");
|
||||
Progress?.Invoke($"STEP 解析 {sw.ElapsedMilliseconds:N0} ms");
|
||||
BuildChildren(root, EnumerateGeo(list));
|
||||
}
|
||||
|
||||
// ─── STL(純網格,無 B-rep;整個 shell 合併成單一 mesh 避免模型數爆炸)──
|
||||
|
||||
private void ImportStlFile(string path, ImportedNode root)
|
||||
{
|
||||
Shell[] shells = new ImportSTL().Read(path)
|
||||
?? throw new InvalidDataException("無法解析 STL 檔");
|
||||
int i = 1;
|
||||
foreach (Shell sh in shells)
|
||||
{
|
||||
var positions = new Point3DCollection();
|
||||
var indices = new Int32Collection();
|
||||
double precision = PrecisionFor(SafeBounds(() => sh.GetBoundingCube()));
|
||||
foreach (Face f in sh.Faces)
|
||||
{
|
||||
GeoPoint[] pts; int[] ind;
|
||||
try { f.GetTriangulation(precision, out pts, out _, out ind, out _); }
|
||||
catch { continue; }
|
||||
if (pts is null || ind is null) continue;
|
||||
int offset = positions.Count;
|
||||
foreach (GeoPoint p in pts) positions.Add(new Point3D(p.x, p.y, p.z));
|
||||
foreach (int k in ind) indices.Add(offset + k);
|
||||
}
|
||||
if (positions.Count == 0) continue;
|
||||
|
||||
var mesh = new MeshGeometry3D { Positions = positions, TriangleIndices = indices };
|
||||
mesh.Freeze();
|
||||
var leaf = new ImportedNode
|
||||
{
|
||||
Name = shells.Length == 1 ? "網格" : $"網格 {i}",
|
||||
HasBrep = false,
|
||||
SolidCount = 1,
|
||||
FaceCount = sh.Faces.Length,
|
||||
TriangleCount = indices.Count / 3,
|
||||
Bounds = BoundsOfMesh(mesh),
|
||||
};
|
||||
leaf.SourceGeos.Add(sh);
|
||||
leaf.Faces.Add(new ImportedFace(null, mesh));
|
||||
root.Children.Add(leaf);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── DXF(多為線架構;Solid/Face 走同一套,曲線取樣成線段)──
|
||||
|
||||
private void ImportDxfFile(string path, ImportedNode root)
|
||||
{
|
||||
var import = new CADability.DXF.Import(path);
|
||||
Project project = import.Project
|
||||
?? throw new InvalidDataException("無法解析 DXF 檔");
|
||||
Model model = project.GetActiveModel();
|
||||
BuildChildren(root, EnumerateGeo(model.AllObjects));
|
||||
}
|
||||
|
||||
// ─── 階層走訪 ────────────────────────────────────────────────
|
||||
|
||||
private static IEnumerable<IGeoObject> EnumerateGeo(System.Collections.IEnumerable list)
|
||||
{
|
||||
foreach (object o in list)
|
||||
if (o is IGeoObject g) yield return g;
|
||||
}
|
||||
|
||||
private static IEnumerable<IGeoObject> ChildrenOf(IGeoObject go)
|
||||
{
|
||||
for (int i = 0; i < go.NumChildren; i++)
|
||||
if (go.Child(i) is IGeoObject c) yield return c;
|
||||
}
|
||||
|
||||
private void BuildChildren(ImportedNode parent, IEnumerable<IGeoObject> objects)
|
||||
{
|
||||
var looseFaces = new List<Face>();
|
||||
var looseCurves = new List<IGeoObject>(); // 皆為 ICurve
|
||||
|
||||
foreach (IGeoObject go in objects)
|
||||
{
|
||||
switch (go)
|
||||
{
|
||||
case Solid solid:
|
||||
parent.Children.Add(SolidNode(solid));
|
||||
break;
|
||||
case Shell shell:
|
||||
parent.Children.Add(ShellNode(shell));
|
||||
break;
|
||||
case Face face:
|
||||
looseFaces.Add(face);
|
||||
break;
|
||||
case Block block:
|
||||
{
|
||||
ImportedNode? n = BlockNode(block);
|
||||
if (n is not null) parent.Children.Add(n);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
if (go is ICurve) looseCurves.Add(go);
|
||||
else if (go.NumChildren > 0) BuildChildren(parent, ChildrenOf(go));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (looseFaces.Count > 0)
|
||||
{
|
||||
var leaf = new ImportedNode { Name = "面" };
|
||||
leaf.SourceGeos.AddRange(looseFaces);
|
||||
_leafWork.Add(() =>
|
||||
{
|
||||
var edgeSet = new HashSet<Edge>();
|
||||
var failed = new List<(Face, double)>();
|
||||
foreach (Face f in looseFaces)
|
||||
{
|
||||
double prec = PrecisionFor(SafeBounds(() => f.GetBoundingCube()));
|
||||
if (!AddFace(f, prec, leaf, edgeSet)) failed.Add((f, prec));
|
||||
}
|
||||
if (failed.Count > 0) _retry.Add((leaf, edgeSet, failed));
|
||||
else FinishLeaf(leaf, edgeSet);
|
||||
});
|
||||
parent.Children.Add(leaf); // 三角化延後,空 leaf 由 Prune 收掉
|
||||
}
|
||||
if (looseCurves.Count > 0)
|
||||
{
|
||||
Point3DCollection segs = SampleCurves(looseCurves.OfType<ICurve>());
|
||||
if (segs.Count > 0)
|
||||
{
|
||||
segs.Freeze();
|
||||
var leaf = new ImportedNode
|
||||
{
|
||||
Name = "曲線",
|
||||
HasBrep = false,
|
||||
EdgeSegments = segs,
|
||||
Bounds = BoundsOfPoints(segs),
|
||||
};
|
||||
leaf.SourceGeos.AddRange(looseCurves);
|
||||
parent.Children.Add(leaf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ImportedNode? BlockNode(Block block)
|
||||
{
|
||||
// Block 只有單一同名 Solid → 折疊成 leaf(STEP 常見的 product→solid 包裝)
|
||||
if (block.NumChildren == 1 && block.Child(0) is Solid s)
|
||||
{
|
||||
ImportedNode leaf = SolidNode(s);
|
||||
if (!string.IsNullOrWhiteSpace(block.Name)) leaf.Name = block.Name;
|
||||
return leaf;
|
||||
}
|
||||
|
||||
var node = new ImportedNode
|
||||
{
|
||||
Name = string.IsNullOrWhiteSpace(block.Name) ? "組件" : block.Name,
|
||||
};
|
||||
BuildChildren(node, ChildrenOf(block));
|
||||
if (node.Children.Count == 0) return null;
|
||||
|
||||
// 單鏈折疊:group 只有一個子節點且名稱相同/空白 → 去掉中間層
|
||||
if (node.Children.Count == 1)
|
||||
{
|
||||
ImportedNode only = node.Children[0];
|
||||
if (string.IsNullOrWhiteSpace(only.Name) || only.Name == node.Name)
|
||||
{
|
||||
only.Name = node.Name;
|
||||
return only;
|
||||
}
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
private ImportedNode SolidNode(Solid solid)
|
||||
{
|
||||
var leaf = new ImportedNode
|
||||
{
|
||||
Name = string.IsNullOrWhiteSpace(solid.NameOrEmpty) ? "Solid" : solid.NameOrEmpty,
|
||||
SolidCount = 1,
|
||||
};
|
||||
leaf.SourceGeos.Add(solid);
|
||||
_leafWork.Add(() =>
|
||||
{
|
||||
var edgeSet = new HashSet<Edge>();
|
||||
var failed = new List<(Face, double)>();
|
||||
foreach (Shell sh in solid.Shells)
|
||||
{
|
||||
double precision = PrecisionFor(SafeBounds(() => sh.GetBoundingCube()));
|
||||
foreach (Face f in sh.Faces)
|
||||
if (!AddFace(f, precision, leaf, edgeSet)) failed.Add((f, precision));
|
||||
}
|
||||
if (failed.Count > 0) _retry.Add((leaf, edgeSet, failed));
|
||||
else FinishLeaf(leaf, edgeSet);
|
||||
});
|
||||
return leaf;
|
||||
}
|
||||
|
||||
private ImportedNode ShellNode(Shell shell)
|
||||
{
|
||||
var leaf = new ImportedNode
|
||||
{
|
||||
Name = string.IsNullOrWhiteSpace(shell.NameOrEmpty) ? "Shell" : shell.NameOrEmpty,
|
||||
};
|
||||
leaf.SourceGeos.Add(shell);
|
||||
_leafWork.Add(() =>
|
||||
{
|
||||
var edgeSet = new HashSet<Edge>();
|
||||
var failed = new List<(Face, double)>();
|
||||
double precision = PrecisionFor(SafeBounds(() => shell.GetBoundingCube()));
|
||||
foreach (Face f in shell.Faces)
|
||||
if (!AddFace(f, precision, leaf, edgeSet)) failed.Add((f, precision));
|
||||
if (failed.Count > 0) _retry.Add((leaf, edgeSet, failed));
|
||||
else FinishLeaf(leaf, edgeSet);
|
||||
});
|
||||
return leaf;
|
||||
}
|
||||
|
||||
// ─── 幾何處理 ────────────────────────────────────────────────
|
||||
|
||||
private static BoundingCube SafeBounds(Func<BoundingCube> get)
|
||||
{
|
||||
try { return get(); }
|
||||
catch { return new BoundingCube(); }
|
||||
}
|
||||
|
||||
/// <summary>三角化精度依物件大小調整:對角線 × 0.0015,限制在 [0.02, 0.5] mm</summary>
|
||||
private static double PrecisionFor(BoundingCube bc)
|
||||
{
|
||||
double diag;
|
||||
try { diag = bc.DiagonalLength; }
|
||||
catch { diag = 100; }
|
||||
if (double.IsNaN(diag) || double.IsInfinity(diag) || diag <= 0) diag = 100;
|
||||
return Math.Clamp(diag * 0.0015, 0.02, 0.5);
|
||||
}
|
||||
|
||||
/// <returns>false = 三角化失敗(平行下偶發,可循序重試)</returns>
|
||||
private bool AddFace(Face face, double precision, ImportedNode leaf, HashSet<Edge> edgeSet)
|
||||
{
|
||||
GeoPoint[] points; int[] indices;
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
face.GetTriangulation(precision, out points, out _, out indices, out _);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false; // 個別面三角化失敗時跳過,不讓整檔失敗
|
||||
}
|
||||
finally
|
||||
{
|
||||
System.Threading.Interlocked.Add(ref _triTicks, sw.Elapsed.Ticks);
|
||||
}
|
||||
if (points is null || indices is null || indices.Length < 3) return false;
|
||||
|
||||
var positions = new Point3DCollection(points.Length);
|
||||
foreach (GeoPoint p in points)
|
||||
positions.Add(new Point3D(p.x, p.y, p.z));
|
||||
|
||||
var mesh = new MeshGeometry3D
|
||||
{
|
||||
Positions = positions,
|
||||
TriangleIndices = new Int32Collection(indices),
|
||||
};
|
||||
mesh.Freeze();
|
||||
leaf.Faces.Add(new ImportedFace(face, mesh));
|
||||
|
||||
try
|
||||
{
|
||||
foreach (Edge e in face.AllEdges) edgeSet.Add(e);
|
||||
}
|
||||
catch { /* 邊收集失敗不影響面渲染 */ }
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>leaf 收尾:邊取樣 + 統計 + 邊界</summary>
|
||||
private void FinishLeaf(ImportedNode leaf, HashSet<Edge> edgeSet)
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var segments = new Point3DCollection();
|
||||
foreach (Edge e in edgeSet)
|
||||
{
|
||||
ICurve? curve;
|
||||
try { curve = e.Curve3D; }
|
||||
catch { continue; }
|
||||
if (curve is null) continue;
|
||||
AppendCurveSegments(segments, curve);
|
||||
}
|
||||
segments.Freeze();
|
||||
leaf.EdgeSegments = segments;
|
||||
System.Threading.Interlocked.Add(ref _edgeTicks, sw.Elapsed.Ticks);
|
||||
|
||||
leaf.FaceCount = leaf.Faces.Count;
|
||||
int tri = 0;
|
||||
Rect3D bounds = Rect3D.Empty;
|
||||
foreach (ImportedFace f in leaf.Faces)
|
||||
{
|
||||
tri += f.Mesh.TriangleIndices.Count / 3;
|
||||
bounds.Union(BoundsOfMesh(f.Mesh));
|
||||
}
|
||||
leaf.TriangleCount = tri;
|
||||
leaf.Bounds = bounds;
|
||||
}
|
||||
|
||||
private static Point3DCollection SampleCurves(IEnumerable<ICurve> curves)
|
||||
{
|
||||
var segments = new Point3DCollection();
|
||||
foreach (ICurve c in curves)
|
||||
AppendCurveSegments(segments, c);
|
||||
return segments;
|
||||
}
|
||||
|
||||
/// <summary>把曲線取樣成線段對(直線 1 段、曲線 12 段)</summary>
|
||||
private static void AppendCurveSegments(Point3DCollection segments, ICurve curve)
|
||||
{
|
||||
int n = curve is Line ? 1 : 12;
|
||||
try
|
||||
{
|
||||
GeoPoint prev = curve.PointAt(0);
|
||||
for (int i = 1; i <= n; i++)
|
||||
{
|
||||
GeoPoint cur = curve.PointAt(i / (double)n);
|
||||
segments.Add(new Point3D(prev.x, prev.y, prev.z));
|
||||
segments.Add(new Point3D(cur.x, cur.y, cur.z));
|
||||
prev = cur;
|
||||
}
|
||||
}
|
||||
catch { /* 個別曲線取樣失敗跳過 */ }
|
||||
}
|
||||
|
||||
private static Rect3D BoundsOfMesh(MeshGeometry3D mesh) => BoundsOfPoints(mesh.Positions);
|
||||
|
||||
private static Rect3D BoundsOfPoints(Point3DCollection points)
|
||||
{
|
||||
if (points.Count == 0) return Rect3D.Empty;
|
||||
double minX = double.MaxValue, minY = double.MaxValue, minZ = double.MaxValue;
|
||||
double maxX = double.MinValue, maxY = double.MinValue, maxZ = double.MinValue;
|
||||
foreach (Point3D p in points)
|
||||
{
|
||||
if (p.X < minX) minX = p.X; if (p.X > maxX) maxX = p.X;
|
||||
if (p.Y < minY) minY = p.Y; if (p.Y > maxY) maxY = p.Y;
|
||||
if (p.Z < minZ) minZ = p.Z; if (p.Z > maxZ) maxZ = p.Z;
|
||||
}
|
||||
return new Rect3D(minX, minY, minZ,
|
||||
Math.Max(maxX - minX, 1e-6), Math.Max(maxY - minY, 1e-6), Math.Max(maxZ - minZ, 1e-6));
|
||||
}
|
||||
|
||||
/// <summary>移除沒有任何幾何的節點(延後三角化全失敗的 leaf、因此變空的 group)</summary>
|
||||
private static void Prune(ImportedNode node)
|
||||
{
|
||||
for (int i = node.Children.Count - 1; i >= 0; i--)
|
||||
{
|
||||
ImportedNode c = node.Children[i];
|
||||
Prune(c);
|
||||
bool empty = c.Children.Count == 0 && c.Faces.Count == 0 && (c.EdgeSegments?.Count ?? 0) == 0;
|
||||
if (empty) node.Children.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>group 節點統計彙總(bottom-up)</summary>
|
||||
private static void Aggregate(ImportedNode node)
|
||||
{
|
||||
if (node.IsLeaf) return;
|
||||
int solids = node.SolidCount, faces = node.FaceCount, tris = node.TriangleCount;
|
||||
Rect3D bounds = node.Bounds;
|
||||
foreach (ImportedNode c in node.Children)
|
||||
{
|
||||
Aggregate(c);
|
||||
solids += c.SolidCount;
|
||||
faces += c.FaceCount;
|
||||
tris += c.TriangleCount;
|
||||
bounds.Union(c.Bounds);
|
||||
}
|
||||
node.SolidCount = solids;
|
||||
node.FaceCount = faces;
|
||||
node.TriangleCount = tris;
|
||||
node.Bounds = bounds;
|
||||
}
|
||||
|
||||
private static int CountSegments(ImportedNode node)
|
||||
{
|
||||
int n = node.EdgeSegments?.Count ?? 0;
|
||||
foreach (ImportedNode c in node.Children) n += CountSegments(c);
|
||||
return n;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,133 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Media3D;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using HelixToolkit.Wpf;
|
||||
|
||||
namespace STPViewer.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// 裝配樹節點:root = 匯入檔、group = STEP 裝配(Block)、leaf = 零件幾何。
|
||||
/// 可見性 / 邊線 / 顏色設定向下層疊(cascade)。
|
||||
/// </summary>
|
||||
public partial class ModelNodeViewModel : ObservableObject
|
||||
{
|
||||
/// <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), // 靛藍
|
||||
};
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isVisible = true;
|
||||
|
||||
/// <summary>輪廓邊線開關(邊線量過大的檔案匯入時自動關閉,避免轉動視角卡頓)</summary>
|
||||
[ObservableProperty]
|
||||
private bool showEdges = true;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isExpanded = true;
|
||||
|
||||
[ObservableProperty]
|
||||
private Color color;
|
||||
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
/// <summary>root(檔案層級)才有值</summary>
|
||||
public string? FilePath { get; init; }
|
||||
public bool IsRoot => FilePath is not null;
|
||||
|
||||
public ObservableCollection<ModelNodeViewModel> Children { get; } = new();
|
||||
public bool IsLeafNode => Children.Count == 0;
|
||||
|
||||
public int SolidCount { get; init; }
|
||||
public int FaceCount { get; init; }
|
||||
public int TriangleCount { get; init; }
|
||||
|
||||
/// <summary>世界座標邊界(零件平移後同步位移)</summary>
|
||||
public Rect3D Bounds { get; set; }
|
||||
|
||||
/// <summary>來源 B-rep 物件(零件平移時整體 Modify,避免共用邊被重複位移)</summary>
|
||||
public List<CADability.GeoObject.IGeoObject> SourceGeos { get; } = new();
|
||||
|
||||
/// <summary>false = STL/曲線等無 B-rep 來源(邊/面/圓量測不可用)</summary>
|
||||
public bool HasBrep { get; init; } = true;
|
||||
|
||||
public string Stats =>
|
||||
$"{SolidCount} 實體 · {FaceCount} 面 · {TriangleCount:N0} △" + (HasBrep ? "" : " · 無B-rep");
|
||||
|
||||
public string ToolTipText => (FilePath is not null ? FilePath + "\n" : "") + Stats;
|
||||
|
||||
// ── 視覺物件 ──
|
||||
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>原始(未剖切)邊線端點 — 剖面還原用</summary>
|
||||
public Point3DCollection? OriginalEdgePoints { get; set; }
|
||||
|
||||
/// <summary>leaf 的可見性/邊線狀態變更(MainViewModel 同步 viewport)</summary>
|
||||
public event Action<ModelNodeViewModel>? VisualStateChanged;
|
||||
|
||||
private int _paletteIndex;
|
||||
|
||||
public ModelNodeViewModel(int paletteIndex)
|
||||
{
|
||||
_paletteIndex = paletteIndex % Palette.Length;
|
||||
color = Palette[_paletteIndex];
|
||||
}
|
||||
|
||||
/// <summary>所有 leaf 後代(含自身若為 leaf)</summary>
|
||||
public IEnumerable<ModelNodeViewModel> Leaves()
|
||||
{
|
||||
if (IsLeafNode) { yield return this; yield break; }
|
||||
foreach (ModelNodeViewModel c in Children)
|
||||
foreach (ModelNodeViewModel l in c.Leaves())
|
||||
yield return l;
|
||||
}
|
||||
|
||||
partial void OnIsVisibleChanged(bool value)
|
||||
{
|
||||
foreach (ModelNodeViewModel c in Children) c.IsVisible = value;
|
||||
if (IsLeafNode) VisualStateChanged?.Invoke(this);
|
||||
}
|
||||
|
||||
partial void OnShowEdgesChanged(bool value)
|
||||
{
|
||||
foreach (ModelNodeViewModel c in Children) c.ShowEdges = value;
|
||||
if (IsLeafNode) VisualStateChanged?.Invoke(this);
|
||||
}
|
||||
|
||||
partial void OnColorChanged(Color value)
|
||||
{
|
||||
foreach (ModelNodeViewModel c in Children) c.Color = value;
|
||||
if (SharedMaterial is not null)
|
||||
{
|
||||
var brush = new SolidColorBrush(value);
|
||||
brush.Freeze();
|
||||
SharedMaterial.Brush = brush;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CycleColor()
|
||||
{
|
||||
_paletteIndex = (_paletteIndex + 1) % Palette.Length;
|
||||
Color = Palette[_paletteIndex];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// 三點對齊剛體變換數學驗證:
|
||||
// (1) 已知變換還原 (2) Matrix3D ↔ ModOp 兩種表示一致 (3) 共線拒絕
|
||||
using System.Windows.Media.Media3D;
|
||||
using STPViewer.Services;
|
||||
|
||||
namespace SmokeTest;
|
||||
|
||||
internal static class AlignTest
|
||||
{
|
||||
public static int Run()
|
||||
{
|
||||
int failures = 0;
|
||||
|
||||
// ── (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); // 不共線、不在同平面軸上
|
||||
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("三點求解成功", ok, true);
|
||||
|
||||
// 三個對應點要精確貼合
|
||||
failures += CheckPoint("p1→q1", m.Transform(p1), q1);
|
||||
failures += CheckPoint("p2→q2", m.Transform(p2), q2);
|
||||
failures += CheckPoint("p3→q3", m.Transform(p3), q3);
|
||||
|
||||
// 剛體變換唯一性:任意第 4 點也應該跟著正確變換
|
||||
var r = new Point3D(-4, 12, 8);
|
||||
failures += CheckPoint("任意第4點", m.Transform(r), truth.Transform(r));
|
||||
|
||||
// ── (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) })
|
||||
{
|
||||
CADability.GeoPoint gp = op * new CADability.GeoPoint(p.X, p.Y, p.Z);
|
||||
Point3D wpf = m.Transform(p);
|
||||
bool same = Dist(new Point3D(gp.x, gp.y, gp.z), wpf) < 1e-9;
|
||||
if (!same)
|
||||
{
|
||||
System.Console.WriteLine($" FAIL: ModOp/Matrix3D 不一致 @ ({p.X},{p.Y},{p.Z})");
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
System.Console.WriteLine($" {(failures == 0 ? "PASS" : "----")}: ModOp ↔ Matrix3D 一致(5 個測試點)");
|
||||
|
||||
// ── (3) 共線拒絕 ──
|
||||
bool collinearRejected = !RigidAlign.TryRigidTransform(
|
||||
new Point3D(0, 0, 0), new Point3D(1, 0, 0), new Point3D(2, 0, 0), // 共線
|
||||
q1, q2, q3, out _);
|
||||
failures += Check("來源共線 → 拒絕", collinearRejected, true);
|
||||
failures += Check("Collinear 判定", RigidAlign.Collinear(
|
||||
new Point3D(0, 0, 0), new Point3D(1, 1, 1), new Point3D(3, 3, 3)), true);
|
||||
|
||||
System.Console.WriteLine(failures == 0 ? "AlignTest: 全部通過" : $"AlignTest: {failures} 項失敗");
|
||||
return failures;
|
||||
}
|
||||
|
||||
private static int Check(string name, bool actual, bool expected)
|
||||
{
|
||||
bool ok = actual == expected;
|
||||
System.Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name}({actual})");
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
|
||||
private static int CheckPoint(string name, Point3D actual, Point3D expected)
|
||||
{
|
||||
double d = Dist(actual, expected);
|
||||
bool ok = d < 1e-9;
|
||||
System.Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name} 誤差 {d:E2}");
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
|
||||
private static double Dist(Point3D a, Point3D b) => (b - a).Length;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// 剖面裁切數學驗證:對單位幾何裁切,檢查保留側與面積守恆
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Media3D;
|
||||
using STPViewer.Services;
|
||||
|
||||
namespace SmokeTest;
|
||||
|
||||
internal static class ClipTest
|
||||
{
|
||||
public static int Run()
|
||||
{
|
||||
int failures = 0;
|
||||
|
||||
// 單一三角形 (0,0,0)(10,0,0)(0,10,0),面積 50
|
||||
var mesh = new MeshGeometry3D
|
||||
{
|
||||
Positions = new Point3DCollection
|
||||
{
|
||||
new(0, 0, 0), new(10, 0, 0), new(0, 10, 0),
|
||||
},
|
||||
TriangleIndices = new Int32Collection { 0, 1, 2 },
|
||||
};
|
||||
mesh.Freeze();
|
||||
|
||||
// case 1: 平面 x=20,全保留
|
||||
var m1 = SectionService.ClipMesh(mesh, new Point3D(20, 0, 0), new Vector3D(1, 0, 0));
|
||||
failures += Check("全保留", Area(m1), 50.0);
|
||||
|
||||
// case 2: 平面 x=-1,全裁掉
|
||||
var m2 = SectionService.ClipMesh(mesh, new Point3D(-1, 0, 0), new Vector3D(1, 0, 0));
|
||||
failures += Check("全裁掉", Area(m2), 0.0);
|
||||
|
||||
// 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("半裁(x≤5)", Area(m3), 37.5);
|
||||
foreach (Point3D p in m3.Positions)
|
||||
if (p.X > 5 + 1e-9) { Console.WriteLine($" FAIL: 頂點 x={p.X} 超出保留側"); failures++; }
|
||||
|
||||
// case 4: 反向(保留 x≥5)→ 12.5
|
||||
var m4 = SectionService.ClipMesh(mesh, new Point3D(5, 0, 0), new Vector3D(-1, 0, 0));
|
||||
failures += Check("半裁(x≥5)", Area(m4), 12.5);
|
||||
|
||||
// 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;
|
||||
failures += Check("線段裁切長度", segLen, 4.0);
|
||||
|
||||
Console.WriteLine(failures == 0 ? "ClipTest: 全部通過" : $"ClipTest: {failures} 項失敗");
|
||||
return failures;
|
||||
}
|
||||
|
||||
private static int Check(string name, double actual, double expected)
|
||||
{
|
||||
bool ok = Math.Abs(actual - expected) < 1e-9;
|
||||
Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name} = {actual}(期望 {expected})");
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
|
||||
private static double Area(MeshGeometry3D m)
|
||||
{
|
||||
double a = 0;
|
||||
for (int i = 0; i + 2 < m.TriangleIndices.Count; i += 3)
|
||||
{
|
||||
Vector3D u = m.Positions[m.TriangleIndices[i + 1]] - m.Positions[m.TriangleIndices[i]];
|
||||
Vector3D v = m.Positions[m.TriangleIndices[i + 2]] - m.Positions[m.TriangleIndices[i]];
|
||||
a += Vector3D.CrossProduct(u, v).Length / 2;
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// 干涉檢查數學驗證:相交 / 分離 / 貼合三種情境
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Media3D;
|
||||
using STPViewer.Services;
|
||||
|
||||
namespace SmokeTest;
|
||||
|
||||
internal static class InterferenceTest
|
||||
{
|
||||
public static int Run()
|
||||
{
|
||||
int failures = 0;
|
||||
|
||||
// 兩個互穿的四面體(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("互穿 → 相交", r1.Intersects, true);
|
||||
failures += Check("互穿 → 有交線段", r1.Segments.Count > 0, true);
|
||||
|
||||
// 分離(B 平移 +30,gap 應 ≈ 30 - 10 = 20)
|
||||
MeshGeometry3D c = Tetra(new Vector3D(30, 0, 0));
|
||||
InterferenceResult r2 = InterferenceService.Check(new[] { a }, new[] { c });
|
||||
failures += Check("分離 → 不相交", r2.Intersects, false);
|
||||
bool gapOk = Math.Abs(r2.GapDistance - 20.0) < 0.01;
|
||||
Console.WriteLine($" {(gapOk ? "PASS" : "FAIL")}: 分離 gap = {r2.GapDistance:F4}(期望 ≈ 20)");
|
||||
if (!gapOk) failures++;
|
||||
|
||||
// 貼合(B 平移 +10,頂點剛好接觸 → 不算穿透、gap ≈ 0)
|
||||
MeshGeometry3D d = Tetra(new Vector3D(10, 0, 0));
|
||||
InterferenceResult r3 = InterferenceService.Check(new[] { a }, new[] { d });
|
||||
failures += Check("貼合 → 不算穿透", r3.Intersects, false);
|
||||
bool touchOk = r3.GapDistance < 0.01;
|
||||
Console.WriteLine($" {(touchOk ? "PASS" : "FAIL")}: 貼合 gap = {r3.GapDistance:F6}(期望 ≈ 0)");
|
||||
if (!touchOk) failures++;
|
||||
|
||||
Console.WriteLine(failures == 0 ? "InterferenceTest: 全部通過" : $"InterferenceTest: {failures} 項失敗");
|
||||
return failures;
|
||||
}
|
||||
|
||||
private static int Check(string name, bool actual, bool expected)
|
||||
{
|
||||
bool ok = actual == expected;
|
||||
Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name}({actual})");
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
|
||||
/// <summary>邊長 10 的四面體,offset 平移</summary>
|
||||
private static MeshGeometry3D Tetra(Vector3D offset)
|
||||
{
|
||||
Point3D P(double x, double y, double z) => new Point3D(x, y, z) + offset;
|
||||
var m = new MeshGeometry3D
|
||||
{
|
||||
Positions = new Point3DCollection
|
||||
{
|
||||
P(0, 0, 0), P(10, 0, 0), P(0, 10, 0), P(0, 0, 10),
|
||||
},
|
||||
TriangleIndices = new Int32Collection
|
||||
{
|
||||
0, 1, 2, 0, 1, 3, 0, 2, 3, 1, 2, 3,
|
||||
},
|
||||
};
|
||||
m.Freeze();
|
||||
return m;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// 測試輔助:用 CADability 內嵌的 netDxf 產生標準 DXF 測試檔
|
||||
using netDxf;
|
||||
using netDxf.Entities;
|
||||
|
||||
namespace SmokeTest;
|
||||
|
||||
internal static class MakeDxf
|
||||
{
|
||||
public static void Run(string path)
|
||||
{
|
||||
var doc = new DxfDocument();
|
||||
doc.Entities.Add(new Line(new Vector3(0, 0, 0), new Vector3(100, 50, 0)));
|
||||
doc.Entities.Add(new Circle(new Vector3(50, 25, 0), 10));
|
||||
doc.Save(path);
|
||||
Console.WriteLine($"已產生 {path}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// 無 UI smoke test:驗證 CAD 檔解析 → 三角化 → 裝配樹 整條匯入管線
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using STPViewer.Services;
|
||||
|
||||
Console.OutputEncoding = System.Text.Encoding.UTF8;
|
||||
|
||||
if (args.Length == 0)
|
||||
{
|
||||
Console.WriteLine("用法: SmokeTest [--tree] <file.stp|.stl|.dxf> [...]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (args[0] == "--tree")
|
||||
{
|
||||
foreach (string p in args.Skip(1)) SmokeTest.TreeDump.Run(p);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args[0] == "--make-dxf")
|
||||
{
|
||||
SmokeTest.MakeDxf.Run(args[1]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args[0] == "--clip-test")
|
||||
return SmokeTest.ClipTest.Run() == 0 ? 0 : 2;
|
||||
|
||||
if (args[0] == "--interference-test")
|
||||
return SmokeTest.InterferenceTest.Run() == 0 ? 0 : 2;
|
||||
|
||||
if (args[0] == "--align-test")
|
||||
return SmokeTest.AlignTest.Run() == 0 ? 0 : 2;
|
||||
|
||||
var service = new StepImportService { Progress = s => Console.WriteLine($" [階段] {s}") };
|
||||
int failed = 0;
|
||||
|
||||
foreach (string path in args)
|
||||
{
|
||||
Console.WriteLine($"=== {Path.GetFileName(path)} ===");
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
ImportedFileData data = service.Import(path);
|
||||
sw.Stop();
|
||||
ImportedNode r = data.Root;
|
||||
Console.WriteLine($" Solids : {r.SolidCount}");
|
||||
Console.WriteLine($" Faces : {r.FaceCount}");
|
||||
Console.WriteLine($" Triangles : {r.TriangleCount:N0}");
|
||||
Console.WriteLine($" Bounds : {r.Bounds.SizeX:F2} x {r.Bounds.SizeY:F2} x {r.Bounds.SizeZ:F2} mm");
|
||||
Console.WriteLine($" Time : {sw.ElapsedMilliseconds} ms");
|
||||
Console.WriteLine(" --- 裝配樹 ---");
|
||||
PrintNode(r, 1);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
failed++;
|
||||
Console.WriteLine($" FAILED ({sw.ElapsedMilliseconds} ms): {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return failed == 0 ? 0 : 2;
|
||||
|
||||
static void PrintNode(ImportedNode n, int indent)
|
||||
{
|
||||
string seg = n.EdgeSegments is null ? "" : $", edgeSegs={n.EdgeSegments.Count / 2:N0}";
|
||||
Console.WriteLine($"{new string(' ', indent * 2)}{n.Name} " +
|
||||
$"[solids={n.SolidCount}, faces={n.FaceCount}, tris={n.TriangleCount:N0}{seg}{(n.HasBrep ? "" : ", 無B-rep")}]");
|
||||
foreach (ImportedNode c in n.Children) PrintNode(c, indent + 1);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<UseWPF>true</UseWPF>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\STPViewer\STPViewer.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,32 @@
|
||||
// 實驗工具:印出 STEP 匯入後的物件階層(HierarchyToBlocks=true)
|
||||
using CADability;
|
||||
using CADability.GeoObject;
|
||||
|
||||
namespace SmokeTest;
|
||||
|
||||
internal static class TreeDump
|
||||
{
|
||||
public static void Run(string path)
|
||||
{
|
||||
var imp = new ImportStep { HierarchyToBlocks = true };
|
||||
GeoObjectList list = imp.Read(path);
|
||||
Console.WriteLine($"top-level objects: {list?.Count}");
|
||||
if (list is null) return;
|
||||
foreach (IGeoObject go in list) Dump(go, 0);
|
||||
}
|
||||
|
||||
private static void Dump(IGeoObject go, int indent)
|
||||
{
|
||||
string name = go switch
|
||||
{
|
||||
Block b => b.Name,
|
||||
Solid s => s.NameOrEmpty,
|
||||
Shell sh => sh.NameOrEmpty,
|
||||
_ => "",
|
||||
};
|
||||
Console.WriteLine($"{new string(' ', indent * 2)}{go.GetType().Name} '{name}' children={go.NumChildren}");
|
||||
if (indent > 6) return;
|
||||
for (int i = 0; i < go.NumChildren; i++)
|
||||
if (go.Child(i) is IGeoObject c) Dump(c, indent + 1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user