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:
2026-06-13 00:16:10 +08:00
co-authored by Claude Opus 4.8
commit 43b93dabef
33 changed files with 4155 additions and 0 deletions
+15
View File
@@ -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
View File
@@ -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 + MVVMCommunityToolkit.Mvvm | |
| 3D 渲染 / 拾取 | **HelixToolkit.Wpf** | Viewport3D 封裝、相機操作、HitTest |
| STEP 解析 / 幾何核心 | **CADability**(純 C#netstandard2.0 | STEP B-rep 匯入、Face 三角化、邊/面幾何查詢 |
| 量測幾何運算 | CADability(B-rep 精確值)+ 網格近似(面積) | |
| 打包 | `dotnet publish -c Release` | 免安裝、單資料夾 |
> 技術選型備註:OpenCASCADE 的 .NET wrapperMacad.Occt 等)不在 NuGet 上,
> 自建 C++/CLI wrapper 成本過高;CADability 是純 C# 的 CAD kernelMIT),
> 內建 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/ # 🚫 gitignoredREADME.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 CSV2x 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] 量測結果匯出 CSVUTF-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 報告
- 兩邊最短距離、邊到面距離
- 視圖狀態(相機、圖層、量測)存檔/還原
+73
View File
@@ -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`)、MVVMCommunityToolkit.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/Servicecode-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 無 readerSTL 無 B-repFaceInfo.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。
+69
View File
@@ -0,0 +1,69 @@
# STPViewer
STP/STEP 3D 檢視器(Windows 桌面程式,C# .NET 8 WPF)— 多檔匯入、圖層管理、點/距離/邊/面/圓量測。
![.NET](https://img.shields.io/badge/.NET-8.0-512BD4.svg) ![Platform](https://img.shields.io/badge/platform-Windows-0078D6.svg) ![UI](https://img.shields.io/badge/UI-WPF-blueviolet.svg)
## 功能
- **多檔匯入**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)。
+56
View File
@@ -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
+24
View File
@@ -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
```
+31
View File
@@ -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
}
+25
View File
@@ -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
+9
View File
@@ -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>
+13
View File
@@ -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
{
}
+10
View File
@@ -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;
}
+218
View File
@@ -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 視圖截圖存 PNG2x 解析度)">📷 截圖</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>
+58
View File
@@ -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);
}
+17
View File
@@ -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; }
}
+17
View File
@@ -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, // 干涉檢查結果(非點擊模式,僅作為結果類型)
}
+43
View File
@@ -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);
}
}
+31
View File
@@ -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)})";
}
+17
View File
@@ -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&lt;UnitSystem,string&gt; 延後產生,支援 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 };
}
}
+74
View File
@@ -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;
}
}
+102
View File
@@ -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;
}
}
+502
View File
@@ -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 / .dxfCADability 無 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 → 折疊成 leafSTEP 常見的 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];
}
}
+79
View File
@@ -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) ToModOpCADability 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;
}
+71
View File
@@ -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;
}
}
+66
View File
@@ -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 平移 +30gap 應 ≈ 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;
}
}
+17
View File
@@ -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}");
}
}
+71
View File
@@ -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);
}
+15
View File
@@ -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>
+32
View File
@@ -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);
}
}