Author SHA1 Message Date
etwenandClaude Opus 4.8 89ed1270a8 docs: add v0.7.1 release notes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XgHf5MW39U3Xo8y2uA229K
2026-07-14 14:20:36 +08:00
etwenandClaude Opus 4.8 060ca4ecd0 feat(perf): direct STL parsing for near-instant large-file load (v0.7.1)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XgHf5MW39U3Xo8y2uA229K
2026-07-14 14:20:36 +08:00
etwenandClaude Fable 5 45b27d51cb feat(ui): Add About dialog with user-facing changelog
- Help menu (Help > About STPViewer) opens a dialog styled after
  ETTerms AboutView: app card (name/version/description), developer
  card, tech stack card on the left; scrollable version history on
  the right, written for end users in plain language (v0.1.0-v0.7.0)
- Version read from assembly (single source: csproj <Version>)
- Note in CLAUDE.md: top-level WPF MenuItem with a Click handler does
  not fire (verified with real mouse input) - dialog entries must
  live in a submenu

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 09:17:06 +08:00
etwenandClaude Fable 5 3d124782d5 feat(ui): Add menu bar and customizable quick toolbar (v0.7.0)
- Menu bar groups all functions: File (import/MRU/exports), View
  (zoom/standard views/ortho/unit), Measure (7 checkable modes with
  hotkey hints), Transform (align/drag/gizmo/rotate), Tools
  (interference/section), QuickBar (customize/reset)
- QuickBarViewModel registry: per-button visibility via XAML indexer
  binding QuickBar[Key].IsChecked; buttons keep static bindings
- Common-user defaults visible (import/recent/zoom/iso/ortho/point/
  distance/circle/angle/interference/section/clear/unit); the rest
  live in menus; selection persists in settings.json QuickBarKeys
- Section parameter bar is its own row, shown only while section is
  enabled (with close button); no longer occupies space permanently
- Bump version to 0.7.0; update ARCHITECTURE/CLAUDE docs (Phase 16)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 08:52:55 +08:00
etwen e4485667e2 merge: feature/v0.6.0-stl-export - STL export with parameter dialog 2026-07-04 08:44:43 +08:00
etwenandClaude Fable 5 f5ccc13e9c feat(export): Add STL export with parameter dialog (v0.6.0)
- StlExportService: binary/ASCII STL writer (degenerate triangle
  filtering, per-triangle normals) + B-rep re-tessellation
- StlExportDialog + StlExportViewModel: scope (merged/per-file),
  format, unit scale (mm/inch/custom), mesh quality (current/fine),
  live triangle count + size estimate; confirm before export
- Background export with UI-thread mesh snapshot (GeometryModel3D is
  DispatcherObject; only frozen MeshGeometry3D crosses threads)
- Fine quality = 0.15x import precision: CADability GetTriangulation
  returns cached mesh for coarser requests (no coarse option) and
  triangle count is non-monotonic in precision (0.4x < 1x)
- SmokeTest --stl-export-test: binary round-trip incl. inch scaling,
  ASCII structure, degenerate filtering, real-file fine re-tessellation
- Bump version to 0.6.0; update ARCHITECTURE/CLAUDE docs (Phase 15)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 08:44:34 +08:00
etwenandClaude Fable 5 38eb6d92d9 docs: Refresh ARCHITECTURE/README (EN+zh-TW) for v0.5.0 feature set
- READMEs: version badge 0.5.0, hotkey column in measure table,
  circle-center snap / standard views / ortho / tree search & isolate /
  volume / 3-pt section / multi-file interference / STEP export /
  settings & MRU; version history adds v0.4.0 and v0.5.0
- ARCHITECTURE: overview bullets, data models (ModelNodeViewModel,
  MeasureMode.Volume) brought up to date

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 09:00:56 +08:00
etwenandClaude Fable 5 0093e92b26 docs: Add v0.5.0 release notes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 08:46:06 +08:00
etwenandClaude Fable 5 9b40d4c36d docs: Record Phase 14 (v0.5.0 feature round), update feature table and conventions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 08:34:21 +08:00
etwenandClaude Fable 5 c657417d6b feat: v0.5.0 feature round - measurement, view, tree, section, export upgrades
Measurement / interaction:
- Circle-center snap in Snap(): click near a circular edge to snap to its
  center (hole-to-hole pitch measuring); nearest of vertex vs circle wins
- Esc cancels pending multi-step measurement, then exits mode, then gizmo;
  hotkeys P/D/E/F/C/A/M toggle measure modes (ignored while typing)
- Volume/centroid via mesh signed volume (open-shell detection rejects
  unreliable results); tree context menu adds it as a measurement

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 08:34:20 +08:00
etwenandClaude Fable 5 e355cb4363 docs: Record Phase 13 (v0.4.0 perf/stability) and prioritized feature backlog
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 14:39:09 +08:00
etwenandClaude Fable 5 96e15013c3 perf: Background parallel section clip, parallel transform bake, faster gap refine (v0.4.0)
- ApplySection: snapshot on UI thread, clip whole scene with Task.Run +
  Parallel.For, swap once; _sectionApplying/_sectionReapply guard reruns
  with latest params when settings change mid-clip
- TransformRoot: per-face parallel mesh transform (frozen in/out);
  B-rep Modify and visual-tree assignment stay sequential
- InterferenceService.ApproxMinDistance: AABB squared-distance early
  reject + Parallel.For refine with thread-local best
- Stability: global exception handlers in App.xaml.cs (dispatcher/task/
  appdomain -> %LOCALAPPDATA%/STPViewer/error.log); IsBusy CanExecute
  guards on Import/Rotate/Interference; whole-batch busy + reentrancy
  guard in ImportFilesAsync
- Refactor: split MainViewModel into partial classes (Drag/Gizmo/
  Section/Interference/Export); dedupe merged-mesh build and
  ClosestPointOnTriangle
- Bump version to 0.4.0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 14:39:08 +08:00
etwen d5a0f5ef57 docs: Add v0.3.2 release notes 2026-06-18 17:00:00 +08:00
etwenandClaude Opus 4.8 3556e25e96 chore: Add --bbox SmokeTest diagnostic and v0.3.1 release notes
BBoxDump prints per-leaf world-space bounding boxes (to work out each
connector's mating-face axis); wired as `SmokeTest --bbox <file>`. Also tracks
the previously untracked v0.3.1 release notes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:49:07 +08:00
etwenandClaude Opus 4.8 e27fd17eb8 perf: Render merged mesh in measure mode to fix large-assembly orbit lag (v0.3.2)
Measurement modes used to render per-face content (one GeometryModel3D per
B-rep face). On a large assembly (64k faces measured) that is 64k draw calls,
and WPF re-walks the whole visual tree every frame, so orbiting in a
measurement tool was severely laggy while plain browse mode (which renders the
merged mesh, one model per file) stayed smooth.

Render the merged mesh whenever not sectioning (browse AND measure), and
resolve the picked B-rep face from the hit triangle's vertex index via
_mergedFaceRanges + ResolveMergedFace (binary search over per-face vertex-start
boundaries recorded at import — BuildMergedMesh appends faces in order with no
vertex welding, so the ranges are exact and stay valid across translation).
Per-face FacesContent is now rendered only in section mode (needs cut
geometry). WPF 3D hit testing is geometric and does not cull back faces, so
single-sided merged meshes still pick hole inner walls.

Also subscribe camera-interaction suspension to HelixViewport3D.CameraChanged
(control-level routed event) in addition to Camera.Changed, so it can't be
orphaned if Helix replaces the camera instance.

Verified: orbit in measure mode is now as smooth as browse; circle/face/edge/
angle/face-distance and section measurements unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:48:42 +08:00
etwenandClaude Opus 4.8 34afaa6941 chore: Ignore Publish/ (publish-winapp output)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:21:27 +08:00
etwenandClaude Opus 4.8 d00b4e3316 chore: Single-source version from csproj <Version>
Add <Version>0.3.1</Version> to STPViewer.csproj and read the assembly
version at runtime for the window title, instead of hardcoding it in
XAML. Now bumping <Version> alone updates the title and the
publish-winapp output folder / exe name in one place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:11:04 +08:00
etwenandClaude Opus 4.8 ac08c129bd chore: Remove unused secret/ scaffolding
The project has no runtime or compile-time secrets (offline desktop
tool, no DB/API keys), so the secret/ folder held only example launch
scripts that were never used. Remove it and the related .gitignore
rules; trim references in ARCHITECTURE.md and CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 21:56:56 +08:00
etwenandClaude Opus 4.8 eaa72eac1e feat(ui): Always-on-top gizmo overlay; remove customer STEP files (v0.3.1)
- Gizmo now renders in a separate transparent Viewport3D overlaid on
  the main view, so the manipulator is never occluded inside solid
  parts and is always grabbable from any angle
- Overlay camera follows the main camera (position/dir/FOV/near-far)
  on every change; raw Viewport3D lets empty-area clicks pass through
  so orbit / zoom / measurement still work; IsHitTestVisible bound to
  GizmoEnabled. Mouse-up handled on the overlay (handledEventsToo),
  with _gizmoBakePending guarding against double-bake
- Window title shows version (STPViewer V0.3.1)
- Remove the two test STEP models from the repo (customer part
  numbers) -> moved to For_AI/ (gitignored); add *.stp/*.step to
  .gitignore so CAD files never get committed again
- Docs updated (ARCHITECTURE Phase 11, CLAUDE.md, README)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 21:50:00 +08:00
etwenandClaude Opus 4.8 ae79222bf4 feat(ui): Add gizmo manipulator for axis move/rotate (v0.3.0)
- New "操作器" toggle: select a file in the tree and a 3-axis gizmo
  appears at its center -- XYZ arrows (TranslateManipulator) to move
  along an axis regardless of view angle, and rotation rings
  (RotateManipulator) to rotate any angle about an axis
- Manipulators bind to a proxy ModelVisual3D; the proxy's transform
  is mirrored to all target BodyVisuals during drag (GPU-side, temp)
- On release the accumulated transform is baked into B-rep via
  TransformRoot (deferred with Dispatcher.BeginInvoke to avoid
  reentrancy with the manipulator's own mouse-up handling); the
  gizmo re-centers on the new bounds
- Temporary transforms are cleared with Transform3D.Identity, never
  null (HelixToolkit GetTransform crashes on null child.Transform)
- Selecting another file retargets the gizmo; removing the target
  hides it
- Docs updated (ARCHITECTURE Phase 10, CLAUDE.md, README)

Known limitation: gizmo handles are occluded inside solid parts
(WPF single depth buffer). Next: always-on-top overlay viewport.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 18:17:19 +08:00
etwenandClaude Opus 4.8 9072842bba fix(ui): Use Transform3D.Identity instead of null after drag (v0.2.1)
Drag mode set BodyVisual.Transform = null when clearing the temporary
offset on release. HelixToolkit Viewport3DHelper.GetTransform adds
child.Transform to a GeneralTransform3DGroup without a null check, so
the next FindHits after any drag threw ArgumentException and crashed.

Clear the temporary transform with Transform3D.Identity instead.
Root cause confirmed by decompiling HelixToolkit.Wpf GetTransform.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 13:55:58 +08:00
etwenandClaude Opus 4.8 dd15a83986 feat(ui): Add drag mode for direct part manipulation (v0.2.0)
- New Drag mode (hand cursor): left-press a part and drag to move
  its whole file along the screen plane; release to commit
- During drag only a temporary TranslateTransform3D is applied
  (GPU-side, smooth even on 430k-triangle assemblies); on release
  the offset is baked into B-rep + meshes + edges via TranslateRoot,
  so measurement accuracy is unaffected
- Merged-mesh hit-testing via _mergedMap (browse rendering active
  in drag mode); edges suspended while dragging
- Mouse capture keeps drag alive outside the window; lost capture
  commits at current position
- Right-button rotate / wheel zoom / Shift+left pan unchanged
- Rename small test model to test.stp; docs updated
  (ARCHITECTURE Phase 9, CLAUDE.md conventions, README)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 09:41:45 +08:00
43 changed files with 4280 additions and 881 deletions
+5 -6
View File
@@ -2,14 +2,13 @@
bin/
obj/
publish/
Publish/ # publish-winapp 打包產物(大小寫明確,Linux/CI 也忽略)
*.user
.vs/
# secret/ 全部忽略,但保留 README 與範本
secret/*
!secret/README.md
!secret/*.example
!secret/.gitkeep
# AI 協作素材不入 git
For_AI/
# 客戶 CAD 料號檔不入 git(避免外流);測試模型放 For_AI/
*.stp
*.step
+224 -45
View File
@@ -12,12 +12,16 @@ STEP`.stp` / `.step`)檔案做確認與量測,不需要安裝 SolidWorks
核心價值:
- **多檔匯入**:一次載入多個 CAD 檔(STEP / STL / DXF),每個檔案自成一棵樹
- **裝配樹**STEP product structure 還原成樹狀節點(組件→零件),逐節點 顯示/隱藏、換色、Zoom-to
- **裝配樹**STEP product structure 還原成樹狀節點(組件→零件),逐節點 顯示/隱藏、換色、Zoom-to
名稱搜尋過濾、隔離顯示(只顯示此節點/反轉/全顯)、tooltip 外形尺寸
- **量測**:點座標、兩點距離、邊長、面(面積/類型/法向量)、圓(心/半徑/直徑/周長)、
兩面/兩邊夾角、面到面最短距離;單位 mm ⇄ inch 即時切換
- **剖面**:X/Y/Z 軸向剖切,位置滑桿 + 反向,CPU 網格裁切(量測不受影響)
- **匯出**量測結果 CSV、3D 視圖 PNG 截圖(2x
- 單機離線執行,無網路相依
兩面/兩邊夾角、面到面最短距離、體積/質心;吸附含**圓心**(量孔對孔 pitch);
快速鍵 P/D/E/F/C/A/M + Esc;單位 mm ⇄ inch 即時切換
- **剖面**X/Y/Z 軸向 + **3點任意平面**,位置滑桿/數值輸入 + 反向,CPU 網格裁切(量測不受影響
- **視圖**:標準視圖(等角/前/上/右)+ 正交⇄透視投影
- **匯出**:量測結果 CSV、3D 視圖 PNG 截圖(2x)、**目前對齊位置寫成新 STEP 檔**、
**STL 網格檔**(參數對話框:合併/每檔、binary/ASCII、mm/inch/自訂縮放、精度,確認再匯出)
- 單機離線執行,無網路相依;視窗/單位/最近檔案自動保存(settings.json
使用者:單一桌面使用者(無登入 / 角色系統)。
@@ -65,8 +69,11 @@ STEP`.stp` / `.step`)檔案做確認與量測,不需要安裝 SolidWorks
資料流:`*.stp → CADability B-rep → 三角網格(渲染) + B-rep 參照(量測) → Helix Viewport`
每個 Face 對應一個 `GeometryModel3D`,並登錄到 `Dictionary<GeometryModel3D, FaceInfo>`
HitTest 命中後可反查回 B-rep Face / Edge 做精確量測。
每個 Face 對應一個 `GeometryModel3D``FacesContent`,剖面模式渲染用),並登錄到 `_faceMap` 反查 B-rep Face / Edge。
效能:非剖面(瀏覽+量測)渲染**整零件合併網格**(`MergedContent`,每檔 1 個 model),量測 HitTest 改打合併網格、
用命中三角形的頂點 index 經 `_mergedFaceRanges` 二分搜尋(`ResolveMergedFace`)反查回是哪個面 —
量測模式不再掛數萬個逐面 model,大組件量測下轉動視角與瀏覽同樣流暢(v0.3.2)。
---
@@ -78,43 +85,48 @@ STPViewer/
├── CLAUDE.md # 專案記憶 & 給 Claude 的指令
├── README.md # 快速上手
├── ARCHITECTURE.md # 本文件
├── .gitignore # 含 secret/ 與 For_AI/ 規則
├── .gitignore # For_AI/ + *.stp/*.step(客戶料號)規則
├── docs/
│ ├── decisions/ # 技術決策紀錄(ADR)
│ └── runbooks/ # 操作手冊
├── secret/ # 🚫 gitignoredREADME.md / *.example 除外
│ ├── README.md
│ ├── run-STPViewer.ps1.example
│ └── run-STPViewer.sh.example
├── For_AI/ # 🚫 gitignored — AI 協作素材
├── For_AI/ # 🚫 gitignored — AI 協作素材 + 測試模型 *.stp(客戶料號不入 git
├── STPViewer.sln
├── *.stp # 根目錄現有測試模型(Amphenol connector
└── src/
└── STPViewer/
├── STPViewer.csproj # net8.0-windows, UseWPF
├── App.xaml / App.xaml.cs
├── App.xaml / App.xaml.cs # 全域例外處理(log + 訊息框存活;%LOCALAPPDATA%\STPViewer\error.log
├── 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
│ ├── MeasureMode.cs # enum: None/Point/Distance/Edge/Face/Circle/Angle/FaceDistance/Align/Align3/Drag/Interference/Volume
│ ├── MeasurementResult.cs # 量測結果(雙單位 lambda、3D 標籤同步)
│ └── UnitSystem.cs # mm/inch + Units 格式化
├── StlExportDialog.xaml / .cs # STL 匯出參數對話框(範圍/格式/單位/精度 + 預估,確認再匯出)
├── AboutDialog.xaml / .cs # 關於視窗(程式/開發者/技術棧 + 使用者版 changelog
├── Services/
│ ├── StepImportService.cs # STEP/STL/DXF 讀檔 + 三角化 + 裝配樹
│ ├── MeasurementService.cs # 點/線/面/圓/角度/面距 幾何計算
│ ├── MeasurementService.cs # 點/線/面/圓/角度/面距/體積質心 幾何計算(吸附含圓心)
│ ├── InterferenceService.cs # 干涉檢查:三角形-三角形相交(區間法)+均勻網格加速;無干涉時近似最小間隙
│ ├── RigidAlign.cs # 三點對齊/旋轉的剛體變換數學(Matrix3D列向量 ↔ ModOp行向量 轉換)
── SectionService.cs # 剖面:網格/線段半空間裁切
── SectionService.cs # 剖面:網格/線段半空間裁切
│ ├── SettingsService.cs # 使用者設定 settings.json(視窗/單位/MRU%LOCALAPPDATA%\STPViewer
│ └── StlExportService.cs # STL 匯出:binary/ASCII 寫檔 + B-rep 重新三角化(精細)
└── ViewModels/
├── MainViewModel.cs # 樹集合、量測集合、剖面、單位、匯出
└── ViewModels/ # MainViewModel 為 partial class,依職責分檔
├── MainViewModel.cs # 核心:匯入、裝配樹、量測、剛體變換、邊線、單位
├── MainViewModel.Drag.cs # 拖曳模式
├── MainViewModel.Gizmo.cs # 三軸操作器 + 疊圖層相機
├── MainViewModel.Section.cs # 剖面(v0.4.0 起背景平行裁切)
├── MainViewModel.Interference.cs # 干涉檢查指令
├── MainViewModel.Export.cs # CSV / 截圖 / STEP / STL 匯出
├── StlExportViewModel.cs # STL 匯出對話框 VM(選項 + 即時摘要)
└── ModelNodeViewModel.cs # 裝配樹節點(可見性/顏色 cascade)
```
@@ -125,29 +137,30 @@ STPViewer/
桌面程式無資料庫;核心為記憶體內模型:
```csharp
// 一個匯入檔 = 一個圖層
class LayerItemViewModel
// 裝配樹節點:root = 匯入檔、group = STEP 裝配、leaf = 零件幾何(可見性/顏色向下 cascade)
class ModelNodeViewModel
{
string Name; // 檔名(不含路徑)
string FilePath;
bool IsVisible; // 切換 viewport 中的 ModelVisual3D
Color Color; // 圖層色(換色重建材質
string Name; // root = 檔名;group/leaf = STEP product 名
string? FilePath; // root 才有值
bool IsVisible; // 切換 viewport 中的 ModelVisual3Dcascade
bool IsFilterVisible; // 樹搜尋過濾結果(只影響樹面板顯示
Color Color; // 節點色(換色只改共用 Brush)
int SolidCount, FaceCount, TriangleCount;
ModelVisual3D BodyVisual; // 面網格
ModelVisual3D EdgeVisual; // 輪廓線
Rect3D Bounds; // Zoom-to
ModelVisual3D BodyVisual; // leaf:面網格容器(合併/逐面兩種內容切換)
LinesVisual3D EdgeVisual; // root:整檔合併輪廓線
Rect3D Bounds; // Zoom-to / 外形尺寸 / 旋轉中心
}
// HitTest 反查:渲染物件 → B-rep
class FaceInfo
{
object Face; // CADability Face(量測用 B-rep
LayerItemViewModel Owner;
Face? BrepFace; // CADability Face(量測用 B-repSTL 為 null
ModelNodeViewModel Owner;
MeshGeometry3D Mesh; // 面積近似 / 頂點吸附
}
enum MeasureMode { None, Point, Distance, Edge, Face, Circle,
Angle, FaceDistance, Align, Interference }
Angle, FaceDistance, Align, Align3, Drag, Interference, Volume }
class MeasurementResult
{
@@ -178,11 +191,17 @@ class MeasurementResult
| 對齊 | 模式「對齊」+ 點「要移動零件」上一點、再點目標點 | 純平移該檔案使點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 | |
| 拖曳 | 模式「🖐 拖曳」(手形游標)+ 左鍵按住零件拖 | 沿螢幕平面移動該檔案,放開烘進 B-rep(拖曳中僅暫時 Transform,不卡 |
| 操作器 | 「⊹ 操作器」+ 樹面板選檔案 | XYZ 箭頭沿軸移動、旋轉環繞軸轉任意角度(與視角無關);每次放開烘進 B-rep |
| 干涉 | 工具列「🧩 干涉」(≥2 個可見檔案,兩兩配對檢查) | 每組配對:相交→紅色交線+相交三角形對數;無相交→最小間隙 gap(≈0 即配合 match) |
| 剖面 | 工具列「✂ 剖面」+ 軸向(X/Y/Z/**3點任意平面**)/位置(滑桿+數值輸入)/反向 | CPU 裁切渲染網格(原始幾何保留,量測仍精確);3點=在模型上點 3 點定義平面 |
| 單位 | 工具列 mm ⇄ in(隨設定保存) | 既有量測(清單+3D 標籤)即時換算 |
| 匯出 | 💾 CSV / 📷 截圖 / 📤 STEP / 📐 STL | UTF-8 BOM CSV2x PNG;可見檔案目前位置寫成**新** STEP 檔(對齊結果交接 CAD);STL 先開參數對話框(範圍/格式/單位/精度 + 三角形數與大小預估)確認再匯出 |
| 視圖 | 滑鼠右鍵旋轉/滾輪縮放/中鍵平移(Helix 預設)、ViewCube、工具列 等角/前/上/右、正交⇄透視 | |
| 快速鍵 | P 點 / D 距離 / E 邊 / F 面 / C 圓 / A 角度 / M 面距;同鍵再按=退出;Esc=取消進行中量測→退出模式 | |
| 樹操作 | 搜尋框過濾節點名稱;右鍵:只顯示此節點 / 反轉顯示 / 全部顯示 / 量體積質心 | tooltip 含外形尺寸 L×W×H |
| 體積/質心 | 樹節點右鍵「⚖ 量體積/質心」 | signed volume(封閉實體)+ 質心標記 + AABB 外形尺寸 |
| 設定/MRU | 自動記住視窗位置大小、單位;「▾」最近 10 個檔案 | %LOCALAPPDATA%\STPViewer\settings.json |
支援格式:`.stp` / `.step`B-rep + 裝配樹)、`.stl`(純網格,僅點/距離/角度/面距量測)、
`.dxf`(線架構檢視)。**IGES 不支援**CADability 無 IGES reader)。
@@ -202,12 +221,9 @@ class MeasurementResult
## 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
- 純離線桌面工具,無網路、無帳號、無資料庫、無任何 runtime / compile-time secret(故未建 `secret/` 資料夾)
- `For_AI/` 收 AI 協作素材(截圖、筆記)+ 測試模型 `*.stp`(客戶料號),整夾 gitignore
- `*.stp` / `*.step` gitignore:客戶 CAD 料號檔不入 git,避免外流
---
@@ -314,11 +330,174 @@ NuGet 相依(自動還原):`CADability`、`HelixToolkit.Wpf`、`CommunityT
**驗收:** AlignTest 8 項全過;公母連接器可旋轉擺正後三點對齊插合,再用干涉檢查驗證配合
### Phase 9 — 拖曳模式(工作量:M)
- [x] 🖐 拖曳模式(`MeasureMode.Drag`)— 手形游標,左鍵按住零件沿螢幕平面拖動(Helix `UnProject` 投影到過錨點、面向相機的平面)
- [x] 拖曳中僅掛暫時 `TranslateTransform3D`(GPU 端,大組件不卡),放開一次性 `TranslateRoot` 烘進 B-rep(量測精度不受影響)
- [x] `_mergedMap`(合併網格 → leaf 反查)支援瀏覽渲染下的 hit-test;拖曳中邊線暫停
- [x] 滑鼠捕捉(拖出視窗持續)、意外中斷以當前位置定格
**驗收:** 大組件手形拖曳流暢;放開後干涉/量測結果與拖曳位置一致;右鍵視角操作不受影響
> v0.2.1 修正:拖曳放開時清除暫時位移誤用 `Transform = null`,導致下次 `FindHits` 命中該檔案時
> HelixToolkit `GetTransform` 對 null transform `Add` 而 crash。改用 `Transform3D.Identity`。
### Phase 10 — Gizmo 三軸操作器(工作量:M)
- [x] 「⊹ 操作器」toggle — 對樹面板選取的檔案顯示 Helix `TranslateManipulator` ×3XYZ 紅綠藍箭頭)+ `RotateManipulator` ×3(旋轉環),尺寸隨檔案 Bounds 自適應
- [x] 操作器綁定代理 `ModelVisual3D`,拖動中代理 Transform 即時套到目標所有 BodyVisual(暫時、GPU 端);邊線暫停
- [x] 放開滑鼠(`handledEventsToo` 捕捉)→ `Dispatcher.BeginInvoke` 延後 `TransformRoot` 烘進 B-rep、操作器歸零並移到新中心
- [x] 換樹選取自動換目標;目標被移除自動收掉;暫時 Transform 清除用 `Identity`(非 null
**驗收:** 拖 X 箭頭零件僅沿世界 X 移動(與視角無關);拖環任意角度旋轉;放開後量測/干涉與顯示位置一致
### Phase 11 — Gizmo always-on-top 疊圖層(工作量:M
- [x] 操作器移到獨立透明 `Viewport3D``gizmoOverlay`)疊在主視窗上 → 不在主場景,**永不被實體遮擋、永遠可抓**
- [x] `_overlayCamera` 在主相機 `Changed` 時同步(Position/方向/FOV/near-far);同尺寸 → 投影一致、操作器精準貼合
- [x] raw Viewport3D 空白處不吃滑鼠 → 穿透回主視窗(orbit/縮放/量測不受影響);`IsHitTestVisible``GizmoEnabled`
- [x] 放開事件改掛 overlay`handledEventsToo`);`_gizmoBakePending` 防同次重複烘焙
**驗收:** 操作器從任何視角都浮在零件上可見可抓;開啟操作器時右鍵 orbit / 量測仍正常(穿透)
---
## Development Phases — 第四輪(效能,全部完成)
### Phase 12 — 大組件量測流暢度(工作量:M)
**問題:** 大檔(39MB / 64k 面)瀏覽轉動順,但一進量測模式轉動就嚴重卡頓。
**根因:** 量測模式渲染逐面(每個 B-rep 面 1 個 `GeometryModel3D`),64k 面 = 64k draw call
WPF retained-mode 每幀重走 visual tree → frame rate 崩。瀏覽模式因渲染合併網格(每檔 1 個 model)所以順。
- [x] 量測拾取改打**合併網格** + 三角形頂點 index 反查面(`_mergedFaceRanges` + `ResolveMergedFace` 二分搜尋)
- [x] `ApplyRenderMode()` 改為:非剖面(瀏覽+量測)→ 合併網格;剖面 → 逐面。量測模式不再掛數萬個逐面 model
- [x] `BuildMergedMesh` 依面序串接(無焊接共用頂點),匯入時同步記錄每面頂點起始邊界 + `FaceInfo`
- [x] 相機暫停事件改訂 `HelixViewport3D.CameraChanged`(控制項層級)+ `Camera.Changed` 保底,避免相機實例被換掉時訂閱孤兒化
- [x] 驗證:WPF 3D hit-test 純幾何不剔背面 → 打單面合併網格仍命中孔內壁;圓/面/邊/角度/面距/剖面量測數值與逐面一致
**驗收:** 大組件量測模式下轉動視角與瀏覽同樣流暢;圓孔/面/邊/角度/面距量測精度不變;剖面模式量測仍正常
---
## Development Phases — 第五輪(效能 + 穩定性 v0.4.0,全部完成)
### Phase 13 — 效能 + 穩定性(工作量:M)
**效能:**
- [x] 剖面裁切背景化 + 平行化 — `ApplySection` 改 asyncUI 執行緒快照 (model, frozen mesh)、
`Task.Run` + `Parallel.For` 全場景裁切、回 UI 一次換上。`_sectionApplying`/`_sectionReapply` guard
裁切期間的新變更(拉滑桿/換軸/零件平移)→ 完成後用最新參數重跑一輪,最終狀態必為最新。
大檔(64k 面)拉剖面滑桿不再凍結 UI
- [x] `TransformRoot` 網格變換面級平行化 — 來源/輸出 mesh 皆 frozen、各面獨立所以安全;
B-rep `Modify` 與視覺樹賦值維持循序(CADability 非執行緒安全 / WPF 執行緒親和)。
大組件拖曳/操作器放開的烘焙時間大減
- [x] 干涉間隙精修加速 — `ApproxMinDistance` 精修改為 AABB 平方距離下界快速拒絕 + `Parallel.For`
分段掃描(thread-local bestlock 只在合併時),大檔全三角形掃描成本大減
- [x] 合併網格邏輯去重複 — `BuildMergedMesh`/`RebuildMerged` 共用 `MergeMeshes`(面序不變,
`_mergedFaceRanges` 頂點邊界維持有效);`MeasurementService.ClosestPointOnTriangle`
改用 `InterferenceService` 的同一實作
**穩定性:**
- [x] 全域例外處理(App.xaml.cs)— `DispatcherUnhandledException`(記 log + 訊息框 + Handled 讓程式存活)、
`TaskScheduler.UnobservedTaskException`SetObserved)、`AppDomain.UnhandledException`(致命前留記錄);
log 位於 `%LOCALAPPDATA%\STPViewer\error.log`
- [x] `IsBusy` 指令防護 — 匯入 / 旋轉 90° / 干涉檢查在背景運算期間停用(`CanExecute`),
避免干涉檢查中零件被轉走造成結果錯位;`ImportFilesAsync` 整批維持 busy、
重入(拖放/命令列)直接擋掉(StepImportService 有共享狀態、非重入安全)
- [x] `MainViewModel`(原 1,476 行)拆 partial class — Drag / Gizmo / Section / Interference / Export 各自成檔
**驗收:** ClipTest 5 項、AlignTest 8 項、InterferenceTest 3 情境全過;test.stp 匯入 smoke test 通過;建置 0 警告
---
## Development Phases — 第六輪(Future Extensions 實作 v0.5.0,全部完成)
### Phase 14 — 功能擴充(工作量:L)
**量測/操作:**
- [x] 圓心吸附 — `Snap` 點到圓形邊(容差內)吸附圓心,與頂點比誰離命中點近;距離模式可直接量兩孔 pitch
- [x] Esc 取消 — 先清進行中的多段量測(保留模式)→ 退回瀏覽 → 關操作器;量測快速鍵 P/D/E/F/C/A/M
(同鍵再按=退出;焦點在輸入框時不攔截)
- [x] 體積/質心 — `MeasurementService.MeshVolume` signed volume + 加權質心;有向體積抵銷比例過高
(開放殼)判為不可靠並拒算;樹右鍵「⚖ 量體積/質心」→ 量測結果 + 質心標記
**視圖/UI**
- [x] 標準視圖(等角/前/上/右,Z 向上機構慣例)+ 正交⇄透視(`HelixViewport3D.Orthographic`);
gizmo 疊圖層相機「型別」跟隨主相機切換(透視/正交投影一致,操作器不錯位)
- [x] GridSplitter ×2 — 樹面板/量測面板可調寬(MinWidth 防拖到消失)
- [x] 節點外形尺寸 — `Bounds` setter 通知 `Stats`/`ToolTipText`tooltip 顯示 L×W×H(變換後自動更新)
- [x] 樹搜尋/過濾 — 名稱過濾(符合者+祖先+子樹顯示、自動展開);只影響樹面板,不影響 3D;新匯入檔套用現行過濾
- [x] 隔離顯示 — 樹右鍵:只顯示此節點/反轉顯示/全部顯示;`SetVisibleRecursive` 顯式遞迴
(不能只設 rootsetter 同值不觸發 cascade,會留下混合狀態)
- [x] 記住設定 + MRU — `SettingsService`settings.json):視窗位置大小(含虛擬桌面範圍檢查)、
mm/inch、最近 10 檔;工具列「▾」下拉
**檔案/剖面:**
- [x] 干涉 ≥2 可見檔 — 兩兩配對逐組檢查(網格先快照),每組一筆結果,狀態列總結相交組數
- [x] 匯出 STEP — `CADability.ExportStep.WriteToFile` + `Project.CreateSimpleProject`;可見檔案的
Solid/Shell(目前對齊位置)寫成新檔;SmokeTest `--export-test` 往返驗證(8 實體出→回讀 8 實體一致)
- [x] 3點任意剖面 — 軸向下拉加「3點」:模型上點 3 點定義平面(共線防呆、Esc 取消、換軸重置);
平面位置改為「場景 AABB 8 角投影到法向」內插(軸向=舊行為、任意法向也成立);位置加數值輸入框(0–100 防呆)
**驗收:** ClipTest / AlignTest / InterferenceTest / ExportTest 全過;test.stp 匯入正常;建置 0 警告
---
## Development Phases — 第七輪(STL 匯出 v0.6.0,全部完成)
### Phase 15 — STL 匯出(工作量:M)
- [x] `StlExportService` — binary80B header + placeholder 補三角形數)/ ASCII 寫檔,
退化(零面積)三角形濾除、逐三角形自算法向量;`Tessellate` 由 B-rep 重新三角化
- [x] 參數對話框(`StlExportDialog` + `StlExportViewModel`)— 範圍(合併單檔/每檔一個)、
格式(binary/ASCII)、單位縮放(mm / inch ÷25.4 / 自訂)、網格精度(目前網格/精細)、
即時摘要(檔案數/三角形數/預估大小),**確認才選路徑匯出**
- [x] 背景執行緒寫檔 — UI 先快照 frozen mesh`GeometryModel3D` 是 DispatcherObject
不可跨執行緒,v0.6.0 修正),背景只碰 frozen `MeshGeometry3D` + CADability B-rep
- [x] SmokeTest `--stl-export-test [file.stp]`binary 往返(含 inch 縮放)、ASCII 結構、
退化三角形濾除、真檔精細重算三角形數 > 匯入預設
**CADability 三角化兩個實測特性(設計依據):**
1. `GetTriangulation` 對「比快取粗」的精度要求直接回傳既有較細快取 → 「較粗」選項無效,誠實不提供
2. 三角形數對精度**非單調**(0.4× 重算反而比 1× 少 — 重算網格較有效率),
要明顯更細需 ≤0.15×(test.stp1× = 30,784 → 0.15× = 35,228)→ 精細係數定 0.15
**驗收:** StlExportTest 5 項全過;既有 ClipTest/AlignTest/InterferenceTest 回歸全過;建置 0 警告
---
## Development Phases — 第八輪(UI 重整 v0.7.0
### Phase 16 — 選單列 + 可自訂快速列(工作量:M)
**問題:** 功能累積後單一工具列 30+ 顆按鈕擠在一排,視窗不夠寬就溢出到「▾」,難找難按。
- [x] **選單列**(Menu):全部功能分類下拉 — 檔案(匯入/MRU/四種匯出)、視圖(全覽/標準視圖/正交/單位)、
量測(7 種模式 checkable + 快速鍵提示 + 清除)、變換(對齊/三點/拖曳/操作器/旋轉XYZ)、
工具(干涉/剖面)、快速列(自訂按鈕勾選 + 恢復預設)
- [x] **快速工具列**`QuickBarViewModel`):只顯示使用者勾選的常用按鈕;
註冊表定義全部按鈕(key/選單名/預設值),XAML 以索引子綁定
`Visibility="{Binding QuickBar[Key].IsChecked, Converter=BoolToVis}"`(按鈕本體與綁定保持靜態)
- [x] 預設常用:匯入、最近、全覽、等角、正交、點、距離、圓、角度、干涉、剖面、清除、mm⇄in;
其餘(邊/面/面距/對齊/三點/旋轉/拖曳/操作器/四種匯出/前上右視圖)預設收進選單
- [x] 勾選清單隨 settings.json`QuickBarKeys`)保存;null/新按鈕用註冊表預設值
- [x] **剖面參數列**獨立成第三列,只在 `SectionEnabled` 時顯示(含「✕ 關閉」);
平常整條隱藏,不再常駐佔位
- [x] **關於視窗**`AboutDialog`,選單「說明 → 關於 STPViewer」)— 程式資訊/開發者/技術棧 +
給一般使用者看的版本紀錄(樣式參考 ETTerms AboutView);發新版在 Changelog 陣列頂端加一筆
**驗收:** 選單所有功能可用(checkable 與工具列 toggle 同步);快速列勾選即時增減、重開保留;
既有快速鍵 P/D/E/F/C/A/M + Esc 不受影響
---
## Future Extensions(下一輪)
- 剖切面封口(cap)填實(目前剖開處可見內部背面材質)
- **大檔重開快取** — 匯入成功後把三角網格 + 裝配樹序列化成 sidecar 快取檔(以來源檔 hash 驗證),
重開同檔免等 CADability 解析(39MB 實測 276 秒)。取捨:B-rep 無法快取 → 量測退化成網格模式
或背景補載;是否值得取決於「重複開同一大檔」的頻率(v0.5.0 決議暫緩)
### 既有清單(維持)
- 剖切面封口(cap)填實(目前剖開處可見內部背面材質;需對裁切輪廓三角化補面,難度最高,建議最後做)
- 樹節點三態 checkbox(部分子節點隱藏時顯示中間態)
- IGES 支援(需引入其他幾何核心或自寫 reader)
- 量測結果匯出含截圖的 PDF 報告
+87 -18
View File
@@ -2,9 +2,10 @@
## 專案簡介
CAD 3D 檢視器(Windows 桌面 WPF, .NET 8):STEP/STL/DXF 匯入、STEP 裝配樹、
點/距離/邊/面/圓/角度/面距量測、兩點對齊(平移)、三點對齊(旋轉+平移)、軸向旋轉 90°、
干涉檢查、剖面、mm⇄inch、CSV/截圖匯出。
CAD 3D 檢視器(Windows 桌面 WPF, .NET 8):STEP/STL/DXF 匯入、STEP 裝配樹(搜尋/隔離顯示)
點/距離/邊/面/圓/角度/面距/體積質心量測(吸附含圓心)、兩點對齊(平移)、三點對齊(旋轉+平移)、
軸向旋轉 90°、拖曳模式、干涉檢查(≥2 檔兩兩配對)、剖面(X/Y/Z + 3點任意平面)、
標準視圖+正交投影、量測快速鍵+Esc、mm⇄inch、CSV/截圖/STEP/STL 匯出(STL 有參數對話框)、設定與 MRU 保存。
詳細設計見 [ARCHITECTURE.md](ARCHITECTURE.md)。
## 技術棧
@@ -21,26 +22,84 @@ dotnet run --project src/STPViewer
dotnet publish src/STPViewer -c Release -o publish/STPViewer
```
測試模型:根目錄 `Amphenol RA PHD GPD20-50075_RevB_DC (for Cable).stp`
測試模型`For_AI/`gitignored**客戶料號不入 git**):`For_AI/test.stp`Amphenol RA PHD 座端,小檔)與
`For_AI/Amphenol PHD to PHD Cable 10201248 1.stp`39MB 大組件,效能測試用)。`*.stp`/`*.step` 已全域 gitignore
## 開發慣例
- 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 為準(圓半徑、邊長、角度),面積/面距用網格近似
- `MainViewModel`**partial class 分檔**(v0.4.0):主檔(匯入/樹/量測/變換/邊線)+
`.Drag` / `.Gizmo` / `.Section` / `.Interference` / `.Export`。加新功能先看職責放哪個檔,勿全塞回主檔
- 全域例外處理在 `App.xaml.cs`:UI 執行緒未攔截例外 → 記 log(`%LOCALAPPDATA%\STPViewer\error.log`+
訊息框 + `Handled=true` 存活;背景 Task 例外記 log。個別功能仍應自行 try/catch 給出有意義的狀態列訊息
- 一個 B-rep Face = 一個 `GeometryModel3D`,用 `Dictionary<Model3D, FaceInfo>` (`_faceMap`) 反查(剖面模式逐面拾取靠這個,**不可移除逐面結構**
- 渲染雙模式(降 draw call):每個 leaf 同時持有 `FacesContent`(逐面,剖面用)與 `MergedContent`(整零件合併成 1 個 `GeometryModel3D`)。
`ApplyRenderMode()` 依狀態切 `BodyVisual.Content`:**非剖面(瀏覽+量測皆是)→ 合併網格**;**剖面 → 逐面**(要顯示各面裁切後幾何)。
合併網格只代表「未剖切、目前位置」幾何;平移後呼叫 `RebuildMerged(leaf)` 同步。剖面/干涉/`RebuildMerged` 一律走 `FacesContent`,與目前顯示哪種內容無關
-**量測拾取打合併網格、用三角形頂點 index 反查面**`_mergedFaceRanges`:合併 Model → (每面頂點起始邊界, FaceInfo[]))。
`BuildMergedMesh` 依面序串接(`baseIdx += positions.Count`,無焊接共用頂點),故命中 `RayHit.VertexIndex1` 可二分搜尋(`ResolveMergedFace`)回是哪個面。
**這是 v0.3.2 修大檔量測卡頓的關鍵**:量測模式不再掛數萬個逐面 `GeometryModel3D`64k 面實測 = 64k draw call,轉動/停下重繪爆量)→ 改成每檔 1 個 model,量測模式 orbit 與瀏覽同樣順。
邊界由面頂點數決定、平移不改 → 永久有效,`RebuildMerged` 不需重算。**不要因為「量測要逐面」而把渲染改回逐面**
- 合併網格的 `BackMaterial`:封閉實體(`SolidCount≥1``HasBrep`)**不設**(WPF 兩面渲染成本砍半);開放殼/STL 才設。
**拾取不受材質影響**WPF 3D hit-test 純幾何、不剔背面),故打合併網格仍命中孔內壁等背向面。逐面 `FacesContent` 一律保留 BackMaterial(剖切要看內部)
- 量測值以 B-rep 為準(圓半徑、邊長、角度),面積/面距/體積/質心用網格近似
- `Snap` 吸附順序(v0.5.0):頂點與「圓形邊」都在容差內比誰離命中點近,圓邊勝出時回傳**圓心**
(量孔對孔 pitch 靠這個)。不要把圓心吸附拿掉或改成永遠優先頂點
- 鍵盤快速鍵在 `MainWindow.Window_PreviewKeyDown`**焦點在 TextBoxBase/ComboBox 時直接 return**
(樹搜尋框、剖面數值框要能打字),新增輸入控件不用再各自處理
- UI 三層(v0.7.0):**選單列**(全部功能,分類下拉)+ **快速列**`QuickBarViewModel` 註冊表,
使用者勾選常用按鈕,XAML 索引子綁定 `QuickBar[Key].IsChecked` 控 Visibility,按鈕本體維持靜態綁定)+
**剖面參數列**(只在 SectionEnabled 顯示)。新增功能要三處都接:選單分類、快速列註冊表
(含預設值)+ 對應 XAML 按鈕;勾選清單存 settings.json `QuickBarKeys`null = 預設)
- 使用者設定 `SettingsService`%LOCALAPPDATA%\STPViewer\settings.json):視窗(MainWindow 管)+
單位/MRUVM `LoadSettings`/`SaveSettingsInto`);壞檔回預設、儲存失敗靜默
- 匯出 STEP 走 `CADability.ExportStep.WriteToFile(file, Project.CreateSimpleProject()+Model.Add)`
只收 Solid/ShellSTL/DXF 無 B-rep 進不了);改動要跑 `SmokeTest --export-test` 往返驗證
- 匯出 STL`StlExportService` + 參數對話框):**背景寫檔前必須在 UI 執行緒快照 mesh** —
`GeometryModel3D`/`Model3DGroup` 是 DispatcherObject 跨執行緒會炸(v0.6.0 踩過),
背景只能碰 frozen `MeshGeometry3D` 與 CADability B-rep。精度只有「目前網格/精細」兩檔:
CADability `GetTriangulation` 對比快取粗的精度回傳既有快取(粗化無效),且三角形數對精度
**非單調**(0.4× 反而比 1× 少),精細 = 0.15×(匯入 clamp 後再乘,保證嚴格更細)。
改動要跑 `SmokeTest --stl-export-test For_AI/test.stp`
- 3點剖面(SectionAxisIndex==3):`_customNormal` null = 拾取中(`HandleSectionPlanePick`
OnViewportClick 最前面攔點擊);換軸/關剖面/Esc 會重置。平面位置一律用「AABB 8 角投影到法向」內插,
軸向與任意法向共用同一段程式,不要改回逐軸特化
- 量測文字一律 `Func<UnitSystem,string>` 延後產生(mm⇄inch 即時切換);內部數值永遠存 mm
- 裝配樹節點(`ModelNodeViewModel`)的可見性/邊線/顏色向下 cascade
- 剖面只換 `GeometryModel3D.Geometry``FaceInfo.Mesh` 保留原始 frozen mesh 供還原與量測)
- ⚡ 剖面裁切是**背景平行**(v0.4.0):`ApplySection` 在 UI 快照 (model, frozen mesh) → `Task.Run`+`Parallel.For`
裁切 → 回 UI 一次換上;`_sectionApplying`/`_sectionReapply` guard 保證裁切中的新變更(滑桿/換軸/`TransformRoot`
完成後用最新參數重跑。**不要改回同步呼叫 ClipMesh**(64k 面整場景裁切會凍結 UI 數秒);
await 之後一定要檢查 `SectionEnabled`(期間可能被關掉,還原分支已處理)
- 剛體變換(兩點對齊/旋轉 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 與顯示網格會悄悄分家
`ToModOp` 負責轉置轉換,改動務必跑 `SmokeTest --align-test` 驗證兩種表示一致,否則 B-rep 與顯示網格會悄悄分家
`TransformRoot` 的網格變換是**面級平行**(v0.4.0;來源/輸出 mesh 皆 frozen 所以安全);
B-rep `Modify` 與視覺樹賦值必須維持循序(CADability 非執行緒安全 / WPF 執行緒親和)
- IsBusy 期間匯入/旋轉 90°/干涉檢查指令停用(`CanExecute = nameof(IsIdle)` + isBusy 的
`NotifyCanExecuteChangedFor`);`ImportFilesAsync` 整批維持 busy 且開頭擋重入
(拖放/命令列會繞過 CanExecute)。新增「會改動幾何的指令」時記得比照辦理
- 拖曳模式(`MeasureMode.Drag`):拖曳中只掛**暫時 `TranslateTransform3D`**GPU 免費),放開才一次性 `TranslateRoot` 烘進 B-rep —
**不要改成拖曳中逐幀 TransformRoot**(大檔每幀重建網格會卡死)。2D→3D 用 Helix `UnProject`(過錨點、法向=相機 LookDirection 的平面)。
合併網格的 hit-test 走 `_mergedMap`(合併 Model → leaf);拖曳中邊線用 `_edgesSuspended` 暫停
- ⚠️ **`Visual3D.Transform` 永遠不要設成 `null`,清除要用 `Transform3D.Identity`**。HelixToolkit `Viewport3DHelper.GetTransform`
`child.Transform` 沒做 null 檢查(`GeneralTransform3DGroup.Children.Add(null)` → 拋「無法新增空值到集合中」),
之後任何 `FindHits` 都會 crash。v0.2.1 修的就是拖曳放開時把 BodyVisual.Transform 設 null(拖過一次後再點擊就炸)。
Gizmo 的暫時 Transform 清除同理一律用 Identity
- Gizmo 操作器:Helix `TranslateManipulator`/`RotateManipulator` ×6 `Bind` 到代理 `ModelVisual3D`
代理 Transform 變更(`DependencyPropertyDescriptor.AddValueChanged`)即時套到目標 BodyVisual(暫時);
**放開滑鼠才 `TransformRoot` 烘焙** — MouseUp 被 manipulator 標 handledMainWindow 用 `AddHandler(..., handledEventsToo: true)` 才收得到。
烘焙用 `Dispatcher.BeginInvoke` 延後到 manipulator 自身事件處理完,避免 reentrancy;`_gizmoBaking` 旗標防 Transform 歸零的回呼重入
- Gizmo always-on-topv0.3.1):操作器放在獨立透明 `Viewport3D``gizmoOverlay`)疊在主視窗上,**不在主場景所以永不被實體遮擋**。
`_overlayCamera` 在主 `Camera.Changed` 時同步主相機(Position/方向/FOV/near-far);raw Viewport3D 空白處不吃滑鼠 → 穿透回主視窗(orbit/量測正常);
`IsHitTestVisible``GizmoEnabled`。Manipulator 是 `UIElement3D`、用 `GetViewport3D()` 抓所在層相機,故在疊圖層用同步相機運作。
放開事件靠 overlay 的 `AddHandler(MouseLeftButtonUp, handledEventsToo:true)`manipulator 會標 handled),`_gizmoBakePending` 防同次重複烘焙
- 干涉/面距/對齊等運算在背景執行緒;`Freeze()` 幾何後才跨執行緒
- 匯入在背景執行緒;`Freeze()` 幾何後才跨執行緒
- 發新版:升 csproj `<Version>` 之外,記得在 `AboutDialog.xaml.cs``Changelog` 陣列**頂端**加一筆
(口吻寫給一般使用者:「你會感覺到什麼」,不是技術 changelog)。
⚠️ WPF **頂層 MenuItem 直接掛 Click 不會觸發**(實測滑鼠/鍵盤都沒反應),要開視窗的入口一律放子選單(如 說明 → 關於)
- Commit 格式:Conventional Commits`feat:` / `fix:` / `docs:` …)
## 注意事項 / 已知限制
@@ -56,18 +115,28 @@ dotnet publish src/STPViewer -c Release -o publish/STPViewer
- 邊線採「**一檔一條合併 `LinesVisual3D`**」(掛在 root `EdgeVisual`,由 `RefreshRootEdges` 收集各 leaf `OriginalEdgePoints` 重建)。
**不要改回逐 leaf 一條** — 裝配樹零件多時,N 條線每幀重建會嚴重卡頓(實測主因)。leaf 只保留邊線「資料」,渲染統一在 root;
可見性/ShowEdges/剖面/平移變更時呼叫 `RefreshRootEdges(root)` 重組合併線
- 互動中暫停邊線:`Attach``Camera.Changed``OnCameraMoved` 隱藏邊線、`_interactionTimer`(180ms) 停下後 `ResumeEdges` 顯示;
`_edgesSuspended` 為真時 `RefreshRootEdges` 不把線掛回。轉動/縮放/平移時不付邊線重建成本
- 互動中暫停邊線:`Attach` **`HelixViewport3D.CameraChanged`(控制項層級 routed event)+ `Camera.Changed`(保底)**`OnCameraMoved` 隱藏邊線、`_interactionTimer`(180ms) 停下後 `ResumeEdges` 顯示;
`_edgesSuspended` 為真時 `RefreshRootEdges` 不把線掛回。轉動/縮放/平移時不付邊線重建成本
**只訂 `Camera.Changed` 不夠**`Attach` 在建構式呼叫,相機實例若被 Helix 換掉訂閱會孤兒化 → 暫停永不觸發;故加訂控制項層級事件保底(重複觸發 `OnCameraMoved` 無害,有 guard
- CADability `ImportStep` 對少數 AP242 檔案支援不完整;匯入失敗要 catch 顯示訊息,不可閃退
-**STL 直接解析(`StlMeshReader`v0.7.1),不經過 CADability `ImportSTL`**。CADability 會為 STL 每個三角形
建一個 B-rep Face(含 Edge/Vertex 拓樸)→ 百萬三角形要數十分鐘且吃光記憶體(實測 100k 三角形:CADability ~41 s
vs 直接解析 ~11 ms75 MB/150 萬三角形整條匯入管線 ~0.77 s)。binary/ASCII 皆自解;binary 判定用
**檔案大小 == 84 + 50×count**header 常也以 "solid" 開頭,不能只看字串)。頂點不焊接(STL 原生就無共用頂點)。
**後果**STL leaf **沒有 `SourceGeos`CADability B-rep** → (1) 不進 STEP 匯出(STL 本就非 B-rep 幾何,符合原設計意圖;
v0.7.0 以前會把 STL shell 當 Shell 混進 STEP,現移除);(2) 剛體變換照常 — `TransformRoot` 的 SourceGeos 迴圈空轉、
網格層照樣變換;(3) 量測走網格近似不受影響。**不要為了「STL 也要進 STEP」而改回 CADability ImportSTL**(會退回龜速)
- 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`(產測試檔)
`--align-test`(三點對齊剛體變換 + ModOp↔Matrix3D 一致性)、`--make-dxf`(產測試檔)
`--export-test <in.stp> <out.stp>`(STEP 匯出往返:寫出→回讀比對實體數)、
`--stl-export-test [file.stp]`STL binary/ASCII 往返 + 退化濾除;給 stp 加測 B-rep 精細重算)、
`--make-stl <out.stl> [triCount]`(產測試用 binary STL,預設 150 萬三角形,量 STL 匯入效能用)
- **絕不要用 PowerShell regex/Set-Content 改 .cs 檔** — Windows PowerShell 5.1 預設編碼會把 UTF-8 中文弄成亂碼(已踩過,靠反編譯 DLL 救回)。文字取代一律用 Edit 工具
## secret/ 與 For_AI/
## For_AI/
- `secret/`:本機敏感資料集中地,`secret/*` gitignored`README.md``*.example` 除外)
本專案無 runtime secret,僅有啟動腳本範本
- `For_AI/`:AI 協作素材(截圖、草稿),整夾 gitignored。
- `For_AI/`:AI 協作素材(截圖、草稿)+ 測試模型 `*.stp`(客戶料號),整夾 gitignored
- 本專案無 runtime secret(離線桌面工具,無 DB/ApiKey),故未保留 `secret/` 資料夾
+190 -46
View File
@@ -1,69 +1,213 @@
# STPViewer
STP/STEP 3D 檢視器(Windows 桌面程式,C# .NET 8 WPF)— 多檔匯入、圖層管理、點/距離/邊/面/圓量測。
[![English](https://img.shields.io/badge/English-2ea043.svg)](README.md) [![繁體中文](https://img.shields.io/badge/%E7%B9%81%E9%AB%94%E4%B8%AD%E6%96%87-lightgrey.svg)](README.zh-TW.md)
![.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)
> A Windows desktop 3D viewer for STP/STEP CAD files — multi-file import, assembly tree, and point / distance / edge / face / circle measurement. Built with C# .NET 8 WPF.
## 功能
![version](https://img.shields.io/badge/version-0.5.0-blue.svg) ![platform](https://img.shields.io/badge/platform-Windows-0078D6.svg) ![.NET](https://img.shields.io/badge/.NET-8.0-512BD4.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
---
## 快速開始
## 📖 Table of Contents
- [✨ Features](#-features)
- [💻 System Requirements](#-system-requirements)
- [📥 Installation](#-installation)
- [🚀 Quick Start](#-quick-start)
- [📚 Usage Guide](#-usage-guide)
- [🔨 Building from Source](#-building-from-source)
- [📁 Project Structure](#-project-structure)
- [⚠️ Known Limitations](#-known-limitations)
- [🤝 Contributing](#-contributing)
- [📜 Version History](#-version-history)
- [🙏 Acknowledgments](#-acknowledgments)
---
## ✨ Features
Open mechanical STEP parts for quick review and measurement without a heavyweight CAD suite (SolidWorks / Creo).
- **Multi-file import** — STEP / STL / DXF, via the toolbar (multi-select), drag-and-drop, command line (`STPViewer.exe a.stp b.stl`), or the recent-files dropdown (▾, last 10 files)
- **Assembly tree** — STEP product structure restored as a tree (assembly → part); per-node show/hide, recolor (cascades to children), zoom-to; file-level remove and outline-edge toggle; **name search filter**; right-click **Isolate / Invert visibility / Show all / Volume & centroid**; tooltips show each part's L×W×H
- **Measurement** (toolbar mode toggle or hotkey, then click the model):
| Mode | Hotkey | Output |
|---|---|---|
| 📍 Point | P | XYZ coordinate (auto-snaps to nearby B-rep vertex **or circle center**) |
| 📏 Distance | D | Straight-line distance + ΔX/ΔY/ΔZ — clicking two hole rims snaps to their **centers** = pin pitch |
| 📐 Edge | E | Line length / curve length / arc length + radius |
| ⬛ Face | F | Area (mesh approximation) + surface type (plane normal, cylinder radius/axis) |
| ⭕ Circle | C | Center / radius / diameter / circumference |
| ∠ Angle | A | Angle between two faces (normals) or two straight edges + supplement |
| ⇔ Face distance | M | Shortest face-to-face distance (mesh approximation) + closest point pair |
| ⚖ Volume | (tree right-click) | Mesh signed volume + centroid marker + bounding size (closed solids) |
| ⤚ Align (2-pt) | | Pick a point on the moving part + a target point → pure translation so the two points coincide |
| 🎯 Align (3-pt) | | 3 source points + 3 target points → rotation + translation in one shot |
Press the same hotkey again to exit; **Esc** cancels an in-progress pick, then exits the mode.
- **Rotate** — select a file in the tree, then ↻X / ↻Y / ↻Z to rotate +90° about its center (for re-orienting; repeat to accumulate)
- **Drag** 🖐 — hand-cursor mode; hold the left button to drag a part along the screen plane, release to place (right-button view orbit unaffected)
- **Gizmo** ⊹ — select a file → XYZ tri-color arrows + rotation rings (Fusion 360 style); drag an arrow to move along that axis (view-independent), drag a ring to rotate. Always floats on top, never occluded
- **Interference check** 🧩 — with 2+ visible files, checks **every pair**: red intersection curves + intersecting triangle-pair count, or the minimum gap (gap ≈ 0 means a fit/match; coplanar contact is not interference)
- **Section plane** ✂ — X/Y/Z axis **or an arbitrary plane picked from 3 points on the model**; position slider + numeric input + flip; CPU mesh clipping (parallel, in the background), original geometry preserved (measurement stays exact)
- **Units** — one-click mm ⇄ inch; existing measurements (list + 3D labels) convert live
- **Export** — measurement results to CSV (UTF-8 BOM, no mojibake in Excel), a 2× PNG screenshot, and 📤 **STEP export**: writes all visible parts at their current aligned positions into a new STEP file (originals untouched)
- **View** — right-button orbit, wheel zoom, middle-button pan, ViewCube; one-click **Iso / Front / Top / Right** views and an **orthographic ⇄ perspective** toggle
- **Remembers your setup** — window size/position, unit choice and recent files persist across sessions; side panels are resizable
Measurement principle: edge length, circle radius and angles use **exact B-rep values**; area is a triangle-mesh sum approximation (triangulation precision adapts to model size, 0.020.5 mm).
---
## 💻 System Requirements
| Item | Requirement |
|------|-------------|
| OS | Windows 10 / 11 (x64) |
| Runtime | .NET 8 Desktop Runtime (framework-dependent build) — or none for the portable build |
| Build SDK | .NET 8 SDK (only to build from source) |
---
## 📥 Installation
Download a release build and run it — no install required.
- **Framework-dependent** (smaller): requires the .NET 8 Desktop Runtime. Run `STPViewer v0.5.0.exe`.
- **Portable** (self-contained): runtime bundled, no install / admin. Run `STPViewer v0.5.0.exe`.
Or build from source (see below).
```bash
git clone https://github.com/ETWen/STPViewer.git
cd STPViewer
dotnet build STPViewer.sln
```
---
## 🚀 Quick Start
```bash
# Build and run
dotnet build STPViewer.sln
dotnet run --project src/STPViewer
# 發佈免安裝資料夾
# Publish a no-install folder
dotnet publish src/STPViewer -c Release -o publish/STPViewer
```
無 UI 匯入管線與幾何數學驗證:
Then import a `.stp` file (toolbar **Import**, drag-and-drop, or command-line argument), pick a measurement mode, and click the model.
---
## 📚 Usage Guide
1. **Import** one or more CAD files. Each file becomes a root in the assembly tree and the view zooms to fit.
2. **Navigate** the tree — search by name, toggle visibility (or right-click → Isolate), recolor, zoom to a node, or remove a file.
3. **Measure** — pick a mode on the toolbar or by hotkey (P/D/E/F/C/A/M), then click the model. Clicking near a hole rim snaps to the circle center — two clicks measure a pin pitch. Esc cancels. Results appear in the right-hand panel; delete individually or clear all.
4. **Assemble** — use Align (2-pt / 3-pt), Rotate, Drag, or the Gizmo to position parts; then run the Interference check (any number of visible files, all pairs) to verify fit.
5. **Section** — toggle ✂, choose an axis or pick 3 points for an arbitrary plane, and slide (or type a %) to cut through the model; measurement stays exact on the original geometry.
6. **Export** — save measurements to CSV, capture a 2× PNG, or write the aligned scene to a new STEP file for hand-off to CAD.
Headless import-pipeline and geometry-math verification (no 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 # 三點對齊剛體變換數學
dotnet run --project tools/SmokeTest -- "path\to\model.stp" # import + assembly tree
dotnet run --project tools/SmokeTest -- --clip-test # section clipping math
dotnet run --project tools/SmokeTest -- --interference-test # interference: intersect / separate / contact
dotnet run --project tools/SmokeTest -- --align-test # 3-point rigid-transform math
dotnet run --project tools/SmokeTest -- --export-test in.stp out.stp # STEP export round-trip
```
## 技術棧
---
| 元件 | 用途 |
|---|---|
| [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 |
## 🔨 Building from Source
量測原則:邊長、圓半徑等取 **B-rep 精確值**;面積為三角網格加總近似(三角化精度依模型尺寸自適應 0.02–0.5 mm)。
```bash
dotnet build STPViewer.sln -c Debug
dotnet run --project src/STPViewer
dotnet publish src/STPViewer -c Release -o publish/STPViewer
```
## 已知限制
NuGet dependencies (restored automatically): `CADability`, `HelixToolkit.Wpf`, `CommunityToolkit.Mvvm`.
- 大型 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)。
## 📁 Project Structure
```
STPViewer/
├── ARCHITECTURE.md # Design, data flow, development phases
├── CLAUDE.md # Project memory & engineering conventions
├── STPViewer.sln
├── src/STPViewer/
│ ├── STPViewer.csproj # net8.0-windows, UseWPF, single-source <Version>
│ ├── MainWindow.xaml / .cs # Layout + mouse-pick forwarding
│ ├── Models/ # FaceInfo, MeasureMode, MeasurementResult, UnitSystem
│ ├── Services/ # StepImport, Measurement, Interference, Section, RigidAlign, Settings
│ └── ViewModels/ # MainViewModel (partial: Drag/Gizmo/Section/Interference/Export), ModelNodeViewModel
└── tools/SmokeTest/ # Headless import + geometry-math verification
```
---
## ⚠️ Known Limitations
- Large STEP files (thousands of faces) take tens of seconds to import (CADability parse cost); a progress indicator keeps the UI responsive.
- Files with more than 30,000 outline segments disable edges by default (WPF `LinesVisual3D` cost while orbiting); re-enable per file in the tree.
- **IGES is not supported** (CADability has no IGES reader). STL has no B-rep (point / distance / angle / face-distance only). DXF is wireframe view.
- Section cuts have no cap fill — the opened face shows the interior back material (dark gray).
- STEP export writes the solids/shells at their current positions as a flat list — the original assembly hierarchy and product names are not preserved. STL meshes and DXF wireframes are not exported (no B-rep).
- Area, face-distance and volume/centroid are mesh approximations; edge length / circle radius / angle are exact B-rep values.
- A few AP242 files are incompletely supported by CADability; failed imports show a message (no crash).
- Read-only viewer — never writes to or modifies the source file.
---
## 🤝 Contributing
1. Fork and create a feature branch: `git checkout -b feature/your-feature`
2. Follow [Conventional Commits](https://www.conventionalcommits.org/): `feat(scope): summary`
3. Push and open a Pull Request
---
## 📜 Version History
### v0.5.0
- **Feature round:** circle-center snap (measure hole-to-hole pitch in two clicks), measurement hotkeys (P/D/E/F/C/A/M) + Esc, standard views (Iso/Front/Top/Right) + orthographic projection, assembly-tree search / isolate / invert, volume & centroid, 3-point arbitrary section plane + numeric position input, interference check across 3+ files (all pairs), STEP export of the aligned scene, resizable panels, and persisted window / unit / recent-files settings.
### v0.4.0
- **Perf:** section clipping now runs parallel in the background (no more UI freeze while dragging the slider on large files); part-transform bake (drag/gizmo release) parallelized; interference gap refinement accelerated with AABB early rejection.
- **Stability:** global exception handling (log to `%LOCALAPPDATA%\STPViewer\error.log` + message box instead of crashing); geometry-mutating commands disabled while a background computation runs; import reentrancy guarded.
### v0.3.2
- **Perf:** large-assembly measurement no longer lags while orbiting. Measurement modes now render the merged mesh (one model per file) and resolve the picked face from the hit triangle's vertex index, instead of rendering tens of thousands of per-face models. Per-face rendering is kept only for section mode.
- Camera-interaction suspension now also subscribes to `HelixViewport3D.CameraChanged` so it can't be orphaned if the camera instance is replaced.
### v0.3.1
- Gizmo always-on-top overlay (manipulator floats above parts, never occluded).
### v0.3.0
- Rotation alignment: axis rotate (↻X/↻Y/↻Z), 3-point align, and the unified `TransformRoot` rigid-transform path.
### v0.2.x
- Drag mode, 2-point align, interference check, section plane, angle / face-distance measurement, assembly tree, STL / DXF support, mm ⇄ inch.
---
## 🙏 Acknowledgments
- [CADability](https://github.com/SOFAgh/CADability) — pure-C# CAD kernel: STEP import, B-rep geometry, face triangulation
- [HelixToolkit.Wpf](https://github.com/helix-toolkit/helix-toolkit) — 3D viewport, camera control, hit testing
- [CommunityToolkit.Mvvm](https://github.com/CommunityToolkit/dotnet) — MVVM
See [ARCHITECTURE.md](ARCHITECTURE.md) for the full design and development phases.
+213
View File
@@ -0,0 +1,213 @@
# STPViewer
[![English](https://img.shields.io/badge/English-lightgrey.svg)](README.md) [![繁體中文](https://img.shields.io/badge/%E7%B9%81%E9%AB%94%E4%B8%AD%E6%96%87-2ea043.svg)](README.zh-TW.md)
> STP/STEP 3D 檢視器(Windows 桌面程式,C# .NET 8 WPF)— 多檔匯入、裝配樹、點/距離/邊/面/圓量測。
![version](https://img.shields.io/badge/version-0.5.0-blue.svg) ![platform](https://img.shields.io/badge/platform-Windows-0078D6.svg) ![.NET](https://img.shields.io/badge/.NET-8.0-512BD4.svg) ![UI](https://img.shields.io/badge/UI-WPF-blueviolet.svg)
---
## 📖 目錄
- [✨ 功能特色](#-功能特色)
- [💻 系統需求](#-系統需求)
- [📥 安裝](#-安裝)
- [🚀 快速開始](#-快速開始)
- [📚 使用指南](#-使用指南)
- [🔨 從原始碼建置](#-從原始碼建置)
- [📁 專案結構](#-專案結構)
- [⚠️ 已知限制](#-已知限制)
- [🤝 貢獻](#-貢獻)
- [📜 版本紀錄](#-版本紀錄)
- [🙏 致謝](#-致謝)
---
## ✨ 功能特色
快速開啟機構件 STEP 檔做確認與量測,不需安裝 SolidWorks / Creo 等重量級 CAD。
- **多檔匯入** — STEP / STL / DXF,工具列匯入(可複選)、拖放到視窗、命令列 `STPViewer.exe a.stp b.stl`,或最近檔案下拉(▾,最近 10 筆)
- **裝配樹** — STEP product structure 還原成樹(組件→零件);逐節點 顯示/隱藏、換色(cascade)、Zoom-to;檔案層級可移除、開關輪廓邊線;**名稱搜尋過濾**;右鍵 **只顯示此節點 / 反轉顯示 / 全部顯示 / 量體積質心**;tooltip 顯示外形尺寸 L×W×H
- **量測**(工具列切換模式或快速鍵,點擊模型):
| 模式 | 快速鍵 | 輸出 |
|---|---|---|
| 📍 點 | P | XYZ 座標(自動吸附鄰近 B-rep 頂點**或圓心** |
| 📏 距離 | D | 兩點直線距離 + ΔX/ΔY/ΔZ — 點兩個孔緣即吸附**圓心**=直接量 pin pitch |
| 📐 邊 | E | 直線長 / 曲線長 / 圓弧長 + 半徑 |
| ⬛ 面 | F | 面積(網格近似)+ 曲面類型(平面法向量、圓柱半徑/軸向) |
| ⭕ 圓 | C | 圓心 / 半徑 / 直徑 / 周長 |
| ∠ 角度 | A | 兩面(法向量)/ 兩直線邊 夾角 + 補角 |
| ⇔ 面距 | M | 面到面最短距離(網格近似)+ 最近點對 |
| ⚖ 體積 | (樹右鍵) | 網格 signed volume + 質心標記 + 外形尺寸(封閉實體) |
| ⤚ 對齊(兩點) | | 點「要移動零件」一點 + 目標點 → 純平移使兩點貼合 |
| 🎯 對齊(三點) | | 來源檔 3 點 + 目標檔 3 對應點 → 旋轉+平移一次貼合 |
同鍵再按 = 退出模式;**Esc** 先取消進行中的點選、再按退出模式。
- **旋轉** — 樹面板選檔案 + 工具列 ↻X/↻Y/↻Z 繞中心 +90°(擺正方向用,連按累加)
- **拖曳** 🖐 — 手形游標模式,左鍵按住零件沿螢幕平面拖動、放開定位(右鍵轉視角不受影響)
- **操作器** ⊹ — 樹面板選檔案 → XYZ 三色箭頭 + 旋轉環(Fusion 360 風格);拖箭頭沿該軸移動(與視角無關)、拖環繞軸轉。永遠浮在最上層、不被實體遮擋
- **干涉檢查** 🧩 — 2 個以上可見檔案**兩兩配對**檢查:相交時顯示紅色干涉交線 + 相交三角形對數;無相交時回報最小間隙 gap(gap≈0 即為配合 match,共面貼合不算干涉)
- **剖面** ✂ — X/Y/Z 軸或**在模型上點 3 點定義任意平面**;位置滑桿 + 數值輸入 + 反向;CPU 網格裁切(背景平行運算),原始幾何保留(量測仍精確)
- **單位** — mm ⇄ inch 一鍵切換,既有量測(清單與 3D 標籤)即時換算
- **匯出** — 量測結果 CSVUTF-8 BOMExcel 中文不亂碼)、3D 視圖 2× PNG 截圖、📤 **STEP 匯出**:可見零件以目前對齊位置寫成新 STEP 檔(原始檔不動)
- **視角** — 右鍵旋轉、滾輪縮放、中鍵平移、ViewCube;一鍵 **等角/前/上/右** 標準視圖與 **正交⇄透視** 切換
- **記住設定** — 視窗位置大小、單位、最近檔案自動保存;左右面板可拖曳調寬
量測原則:邊長、圓半徑、角度取 **B-rep 精確值**;面積為三角網格加總近似(三角化精度依模型尺寸自適應 0.02–0.5 mm)。
---
## 💻 系統需求
| 項目 | 需求 |
|------|------|
| 作業系統 | Windows 10 / 11x64 |
| 執行階段 | .NET 8 Desktop Runtime(框架相依版)— Portable 版則免裝 |
| 建置 SDK | .NET 8 SDK(僅從原始碼建置時需要) |
---
## 📥 安裝
下載發佈版直接執行,免安裝。
- **框架相依版**(檔案較小):需 .NET 8 Desktop Runtime,執行 `STPViewer v0.5.0.exe`
- **Portable 版**self-contained):內含 runtime,免安裝 / 免系統管理員,執行 `STPViewer v0.5.0.exe`
或從原始碼建置(見下)。
```bash
git clone https://github.com/ETWen/STPViewer.git
cd STPViewer
dotnet build STPViewer.sln
```
---
## 🚀 快速開始
```bash
# 建置並執行
dotnet build STPViewer.sln
dotnet run --project src/STPViewer
# 發佈免安裝資料夾
dotnet publish src/STPViewer -c Release -o publish/STPViewer
```
接著匯入 `.stp` 檔(工具列「匯入」、拖放、或命令列帶檔),選一個量測模式,點擊模型即可。
---
## 📚 使用指南
1. **匯入** 一個或多個 CAD 檔,每個檔案成為裝配樹的一個 root,視角自動 Zoom 到全景。
2. **操作樹** — 名稱搜尋、顯示/隱藏(或右鍵隔離顯示)、換色、Zoom-to 某節點,或移除檔案。
3. **量測** — 工具列選模式或按快速鍵(P/D/E/F/C/A/M),點擊模型;點孔緣自動吸圓心,兩下就是 pin pitch;Esc 取消。結果顯示於右側面板,可單筆刪除或全部清除。
4. **裝配** — 用 對齊(兩點/三點)、旋轉、拖曳、操作器擺位,再用干涉檢查(可見檔全部兩兩檢查)驗證配合。
5. **剖面** — 開啟 ✂、選軸向或點 3 點定義任意平面、滑動(或輸入 %)剖切;量測仍以原始幾何精確計算。
6. **匯出** — 量測結果存 CSV、截 2× PNG,或把對齊後的場景寫成新 STEP 檔交接給 CAD。
無 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 # 三點對齊剛體變換數學
dotnet run --project tools/SmokeTest -- --export-test in.stp out.stp # STEP 匯出往返驗證
```
---
## 🔨 從原始碼建置
```bash
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`
---
## 📁 專案結構
```
STPViewer/
├── ARCHITECTURE.md # 設計、資料流、開發 Phase
├── CLAUDE.md # 專案記憶 & 開發慣例
├── STPViewer.sln
├── src/STPViewer/
│ ├── STPViewer.csproj # net8.0-windows、UseWPF、單一版號來源 <Version>
│ ├── MainWindow.xaml / .cs # 版面 + 滑鼠拾取轉發
│ ├── Models/ # FaceInfo、MeasureMode、MeasurementResult、UnitSystem
│ ├── Services/ # StepImport、Measurement、Interference、Section、RigidAlign、Settings
│ └── ViewModels/ # MainViewModelpartialDrag/Gizmo/Section/Interference/Export)、ModelNodeViewModel
└── tools/SmokeTest/ # 無 UI 匯入 + 幾何數學驗證
```
---
## ⚠️ 已知限制
- 大型 STEP(數千面)匯入需數十秒(CADability 解析成本),匯入期間有進度提示、UI 不凍結。
- 輪廓邊線超過 30,000 線段的檔案預設關閉邊線(WPF `LinesVisual3D` 轉動視角時效能限制),可在樹面板手動開啟。
- **IGES 不支援**CADability 無 IGES reader);STL 無 B-rep,僅支援 點/距離/角度/面距 量測;DXF 為線架構檢視。
- 剖切面無封口(cap),剖開處顯示內部背面材質(深灰)。
- STEP 匯出為「目前位置的 Solid/Shell 平面清單」,不保留原始裝配階層與 product 名稱;STL 網格、DXF 線架構無 B-rep 不匯出。
- 面積、面距、體積/質心為三角網格近似值;邊長/圓半徑/角度為 B-rep 精確值。
- 少數 AP242 檔案 CADability 支援不完整,匯入失敗會提示訊息(不閃退)。
- 唯讀檢視器,不寫入/修改原始檔案。
---
## 🤝 貢獻
1. Fork 並建立 feature 分支:`git checkout -b feature/your-feature`
2. 遵循 [Conventional Commits](https://www.conventionalcommits.org/)`feat(scope): summary`
3. Push 後開 Pull Request
---
## 📜 版本紀錄
### v0.5.0
- **功能擴充:** 圓心吸附(兩下量孔對孔 pitch)、量測快速鍵(P/D/E/F/C/A/M+ Esc、標準視圖(等角/前/上/右)+ 正交投影、裝配樹搜尋/隔離顯示/反轉、體積與質心、3點任意剖切平面 + 位置數值輸入、干涉檢查支援 3+ 檔(兩兩配對)、對齊後場景匯出 STEP、面板可調寬、視窗/單位/最近檔案自動保存。
### v0.4.0
- **效能:** 剖面裁切改背景平行運算(大檔拉滑桿不再凍結 UI);零件變換烘焙(拖曳/操作器放開)平行化;干涉間隙精修以 AABB 快速拒絕加速。
- **穩定性:** 全域例外處理(記 log 至 `%LOCALAPPDATA%\STPViewer\error.log` + 訊息框,不閃退);背景運算期間停用會改動幾何的指令;匯入防重入。
### v0.3.2
- **效能:** 大組件量測模式下轉動視角不再卡頓。量測模式改為渲染合併網格(每檔 1 個 model),並用命中三角形的頂點 index 反查回是哪個面,不再掛數萬個逐面 model;逐面渲染只保留給剖面模式。
- 相機互動暫停事件加訂 `HelixViewport3D.CameraChanged`,避免相機實例被換掉時訂閱失效。
### v0.3.1
- 操作器 always-on-top 疊圖層(永遠浮在零件上、不被實體遮擋)。
### v0.3.0
- 旋轉對齊:軸向旋轉(↻X/↻Y/↻Z)、三點對齊,以及統一的 `TransformRoot` 剛體變換路徑。
### v0.2.x
- 拖曳模式、兩點對齊、干涉檢查、剖面、角度/面距量測、裝配樹、STL/DXF 支援、mm ⇄ inch。
---
## 🙏 致謝
- [CADability](https://github.com/SOFAgh/CADability) — 純 C# CAD kernelSTEP 匯入、B-rep 幾何、面三角化
- [HelixToolkit.Wpf](https://github.com/helix-toolkit/helix-toolkit) — 3D viewport、相機操作、HitTest
- [CommunityToolkit.Mvvm](https://github.com/CommunityToolkit/dotnet) — MVVM
詳細設計與開發 Phase 見 [ARCHITECTURE.md](ARCHITECTURE.md)。
+56
View File
@@ -0,0 +1,56 @@
# v0.3.1 — First public release: 3D STEP viewer with measurement & assembly fit-check
A lightweight Windows desktop viewer for STEP / STL / DXF — open a part, measure it,
and check how two parts fit together, without firing up a heavyweight CAD suite.
## ✨ New features
**View & assembly tree**
* Import multiple CAD files at once (STEP / STL / DXF) via the toolbar, drag-and-drop,
or the command line (`STPViewer.exe a.stp b.stp`).
* STEP product structure is rebuilt into an **assembly tree** — toggle visibility, recolor
(cascades to children), and zoom-to any node; whole-file remove and outline-edge toggle.
* Smooth orbit / zoom / pan even on large assemblies (hundreds of thousands of triangles).
**Measurement** (pick a mode, click the model)
* Point (snaps to nearby vertices), two-point distance + ΔX/ΔY/ΔZ, edge length
(line / curve / arc with radius), face area + surface type, circle center / radius /
diameter / circumference, angle between two faces or edges, and face-to-face minimum gap.
* Edge length, radius and angle use **exact B-rep values**; area and gap are mesh-based.
* Switch units **mm ⇄ inch** anytime — existing measurements and 3D labels convert live.
**Assembly fit-check** (mate a male/female pair and verify)
* **Two-point align** — click a point on the part to move, then the target point, to slide
the whole file into place.
* **Three-point align** — pick 3 feature points on each file to solve a full rotate+translate
in one shot, for parts that aren't already oriented the same way.
* **Axis rotate** — rotate the selected file ±90° about X / Y / Z to square it up.
* **Drag mode** (hand cursor) — left-press a part and drag it along the screen plane.
* **Gizmo** — XYZ arrows to move along an axis (independent of view angle, ideal for pushing
along a mating axis) and rings to rotate any angle. The gizmo always renders on top, so it
is never hidden inside a solid part.
* **Interference check** — for two visible files, shows red intersection lines where they
collide, or the minimum gap when they don't (gap ≈ 0 means a clean fit).
**Section & export**
* Section view along X / Y / Z with a position slider and flip, to inspect mating internals.
* Export measurements to CSV (opens cleanly in Excel) and save a 2× PNG screenshot of the view.
## 📦 Downloads
Two builds are produced:
| Build | Needs .NET 8 Desktop Runtime? | Notes |
|-------|-------------------------------|-------|
| **Standard** (`STPViewer_v0.3.1`) | ✅ Yes | Smaller; for machines that already have the runtime |
| **Portable** (`STPViewer_v0.3.1_portable`) | ❌ No | Runtime bundled — unzip and run, no install / admin needed |
Run `STPViewer v0.3.1.exe`.
## 🔗 Links
* Overview & usage:
[README.md](https://github.com/ETWen/STPViewer/blob/main/README.md)
* Architecture & design:
[ARCHITECTURE.md](https://github.com/ETWen/STPViewer/blob/main/ARCHITECTURE.md)
**Full changelog:** first public release — [commit history](https://github.com/ETWen/STPViewer/commits/v0.3.1)
+26
View File
@@ -0,0 +1,26 @@
# v0.3.2 — Measuring large assemblies no longer lags when you orbit
## 🐛 Bug fixes
**Smooth orbit while measuring large models**
* Fixed: with a large assembly loaded, switching to any measurement tool
(Distance, Edge, Face, Circle, Angle, Face-distance) and then rotating the
view used to lag badly — it hitched every time the view came to rest. Plain
browsing stayed smooth; only measuring was affected.
* Measuring now rotates just as smoothly as browsing, at any model size.
* Picking accuracy is unchanged — circles/holes, faces, edges, angles and
face-distance all read exactly as before, including inner hole walls.
* Section-mode measurement is unaffected.
## 📦 Downloads
Two builds are produced:
| Build | Needs .NET 8 Desktop Runtime? | Notes |
|-------|-------------------------------|-------|
| **Standard** (`STPViewer_v0.3.2`) | ✅ Yes | Smaller; for machines that already have the runtime |
| **Portable** (`STPViewer_v0.3.2_portable`) | ❌ No | Runtime bundled — unzip and run, no install / admin needed |
Run `STPViewer v0.3.2.exe`.
**Full changelog:** [v0.3.1...v0.3.2](https://github.com/ETWen/STPViewer/compare/v0.3.1...v0.3.2)
+58
View File
@@ -0,0 +1,58 @@
# v0.5.0 — Hole-pitch measuring, 3-point section planes and STEP export
## ✨ New features
**Measure hole-to-hole pitch in two clicks — snap to circle centers**
* Point / Distance / Align picks now **snap to the center of a circular edge** when you click near
a hole or a round boss — measure connector pin pitch by simply clicking two hole rims.
* Vertex snapping still works; whichever feature is closest to your click wins.
**Keyboard shortcuts and Esc**
* Measure modes now have hotkeys: **P** point, **D** distance, **E** edge, **F** face, **C** circle,
**A** angle, **M** face-to-face. Press the same key again to go back to browsing.
* **Esc** cancels an in-progress multi-step measurement first (keeping the mode), then exits the
mode, then closes the manipulator — no more re-clicking toolbar buttons after a mis-pick.
**Standard views and orthographic projection**
* One-click **Iso / Front / Top / Right** view buttons (Z-up, mechanical convention).
* New **orthographic ⇄ perspective** toggle — check dimensions without perspective distortion.
The 3-axis manipulator stays perfectly aligned in both projections.
**Assembly tree: search, isolate, volume**
* A **search box** above the tree filters nodes by name (ancestors and children of a match stay
visible, branches auto-expand) — find one part in a hundred instantly.
* Right-click any node: **Isolate** (show only this node), **Invert visibility**, **Show all**.
* Right-click **Volume / centroid**: closed solids get mesh-based volume, a centroid marker in the
view, and overall L×W×H. Node tooltips now show each part's bounding size too.
**Cut along any plane — 3-point section + numeric position**
* The section axis picker gains a **"3-pt"** option: click three points on the model to define an
arbitrary cutting plane, then sweep it with the position slider as usual.
* The position value is now also an **editable number field** (0100 %).
**Interference check across 3+ files**
* The check now accepts **two or more** visible files and tests **every pair**, reporting each
pair's result (intersection lines or minimum gap) plus a summary of how many pairs collide.
**Hand off your alignment — export the scene as STEP**
* New **📤 STEP** button writes all visible parts **at their current aligned positions** into a
brand-new STEP file — mate connectors in STPViewer, then hand the assembly to any CAD.
Original files are never modified.
**Quality of life**
* Window size/position, mm ⇄ inch choice and the **last 10 opened files** (▾ next to Import) are
remembered between sessions.
* The tree and measurement panels are now **resizable** with drag splitters.
## 📦 Downloads
Two builds are produced:
| Build | Needs .NET 8 Desktop Runtime? | Notes |
|-------|-------------------------------|-------|
| **Standard** (`STPViewer_v0.5.0`) | ✅ Yes | Smaller; for machines that already have the runtime |
| **Portable** (`STPViewer_v0.5.0_portable`) | ❌ No | Runtime bundled — unzip and run, no install / admin needed |
Run `STPViewer v0.5.0.exe`.
**Full changelog:** [v0.4.0...v0.5.0](https://github.com/ETWen/STPViewer/compare/v0.4.0...v0.5.0)
+34
View File
@@ -0,0 +1,34 @@
# v0.7.1 — Large STL files open in a blink
## ✨ New features
**Large STL files open in a blink**
* Opening big STL meshes (tens of MB, millions of triangles) is now near-instant instead of a multi-minute wait. A 75 MB / 1.5-million-triangle STL that used to take ages now shows in **under a second**.
* STL is now parsed directly instead of going through the CAD kernel's importer, which used to build a full B-rep face per triangle (100k triangles: **~41 s → ~11 ms**). Both binary and ASCII STL are detected and read automatically.
**Customizable menu bar & quick toolbar**
* A full menu bar groups every feature into categorized dropdowns, and a quick toolbar lets you tick just the buttons you use most — your selection is remembered between sessions.
**About dialog with a changelog**
* Help → About now shows a per-version, plain-language changelog so you can see what each release brought.
## 🐛 Bug fixes
**STL geometry no longer leaks into STEP export**
* STL is pure mesh (no B-rep), so STL parts are now correctly excluded from STEP export — earlier versions wrongly mixed STL shells into the exported STEP. Rigid transforms and measurement on STL parts are unaffected.
## 📦 Downloads
| Build | Needs .NET Runtime? | Notes |
|---|---|---|
| **STPViewer_v0.7.1** (Standard) | ✅ Requires .NET 8 Desktop Runtime | Smaller download |
| **STPViewer_v0.7.1_portable** (Portable) | ❌ Runtime included | Unzip and run anywhere, no admin |
Run `STPViewer v0.7.1.exe`.
## 🔗 Links
* [Architecture](https://github.com/ETWen/STPViewer/blob/main/ARCHITECTURE.md)
* [README](https://github.com/ETWen/STPViewer/blob/main/README.md)
**Full changelog:** [v0.6.0...v0.7.1](https://github.com/ETWen/STPViewer/compare/v0.6.0...v0.7.1)
-24
View File
@@ -1,24 +0,0 @@
# 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
@@ -1,31 +0,0 @@
# ══════════════════════════════════════════════════════════════════
# 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
@@ -1,25 +0,0 @@
#!/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
+118
View File
@@ -0,0 +1,118 @@
<Window x:Class="STPViewer.AboutDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="關於 STPViewer"
Width="860" Height="600"
WindowStartupLocation="CenterOwner"
ResizeMode="CanResize" MinWidth="700" MinHeight="420"
ShowInTaskbar="False">
<Window.Resources>
<Style x:Key="Card" TargetType="Border">
<Setter Property="Background" Value="#F0F4F8" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="Padding" Value="14,10" />
<Setter Property="Margin" Value="0,0,0,10" />
</Style>
<Style x:Key="CardTitle" TargetType="TextBlock">
<Setter Property="FontWeight" Value="Bold" />
<Setter Property="FontSize" Value="13" />
<Setter Property="Margin" Value="0,0,0,6" />
</Style>
<Style x:Key="DimText" TargetType="TextBlock">
<Setter Property="Foreground" Value="#667788" />
<Setter Property="FontSize" Value="12" />
</Style>
</Window.Resources>
<Grid Margin="14">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="300" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- ─── 左:程式 / 開發者 / 技術棧 ─── -->
<StackPanel Grid.Column="0" Grid.Row="0" Margin="0,0,12,0">
<Border Style="{StaticResource Card}">
<StackPanel HorizontalAlignment="Center">
<TextBlock Text="STPViewer" FontSize="22" FontWeight="Bold"
Foreground="#1565C0" HorizontalAlignment="Center" />
<TextBlock x:Name="VersionText" Text="v0.0.0" Style="{StaticResource DimText}"
HorizontalAlignment="Center" Margin="0,2,0,8" />
<TextBlock TextAlignment="Center" FontSize="12" LineHeight="18">
STP / STEP 3D 檢視器<LineBreak />
免開重量級 CAD,快速開檔、量測、<LineBreak />
對齊插合、干涉檢查、剖面、匯出
</TextBlock>
</StackPanel>
</Border>
<Border Style="{StaticResource Card}">
<StackPanel>
<TextBlock Text="👤 開發者" Style="{StaticResource CardTitle}" />
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="60" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition /><RowDefinition /><RowDefinition />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Name" Style="{StaticResource DimText}" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="ET Wen" FontSize="12" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Email" Style="{StaticResource DimText}" />
<TextBlock Grid.Row="1" Grid.Column="1" Text="eric441151893@gmail.com" FontSize="12" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="GitHub" Style="{StaticResource DimText}" />
<TextBlock Grid.Row="2" Grid.Column="1" Text="github.com/ETWen" FontSize="12" />
</Grid>
</StackPanel>
</Border>
<Border Style="{StaticResource Card}">
<StackPanel>
<TextBlock Text="🔧 技術棧" Style="{StaticResource CardTitle}" />
<TextBlock Style="{StaticResource DimText}" LineHeight="18">
.NET 8 · WPF · C#<LineBreak />
CADabilitySTEP / B-rep 幾何核心)<LineBreak />
HelixToolkit3D 顯示與相機)
</TextBlock>
</StackPanel>
</Border>
</StackPanel>
<!-- ─── 右:Change log ─── -->
<Border Grid.Column="1" Grid.Row="0" Background="#FAFBFC" CornerRadius="6" Padding="4">
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="📋 版本紀錄" FontWeight="Bold" FontSize="14"
Margin="12,8,0,4" />
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="12,0,12,8">
<ItemsControl x:Name="ChangelogList">
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Margin="0,8,0,4">
<TextBlock FontWeight="Bold" Foreground="#1565C0" FontSize="13">
<Run Text="●" /> <Run Text="{Binding Header, Mode=OneWay}" />
</TextBlock>
<ItemsControl ItemsSource="{Binding Changes}" Margin="16,3,0,0">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" TextWrapping="Wrap" FontSize="12"
LineHeight="17" Margin="0,1,0,1" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</Border>
<Button Grid.Column="1" Grid.Row="1" Content="關閉" Width="90" Margin="0,10,0,0"
HorizontalAlignment="Right" IsCancel="True" IsDefault="True" />
</Grid>
</Window>
+97
View File
@@ -0,0 +1,97 @@
using System;
using System.Linq;
using System.Reflection;
using System.Windows;
namespace STPViewer;
/// <summary>
/// 關於視窗:左 = 程式資訊 / 開發者 / 技術棧,右 = 版本紀錄(寫給一般使用者看,非技術 changelog)。
/// 樣式參考 ETTerms 的 AboutView。發新版時在 Changelog 陣列最上面加一筆。
/// </summary>
public partial class AboutDialog : Window
{
public AboutDialog()
{
InitializeComponent();
Version v = Assembly.GetExecutingAssembly().GetName().Version!;
VersionText.Text = $"v{v.Major}.{v.Minor}.{v.Build}";
ChangelogList.ItemsSource = Changelog.Select(e => new
{
Header = $"v{e.Version} · {e.Title} ({e.Date:yyyy-MM-dd})",
e.Changes,
}).ToList();
}
private record ChangelogEntry(string Version, DateOnly Date, string Title, string[] Changes);
// ── 版本紀錄(新版加最上面;口吻:一般使用者看得懂,講「你會感覺到什麼」而非實作細節)──
private static readonly ChangelogEntry[] Changelog =
{
new("0.7.1", new DateOnly(2026, 7, 12), "大型 STL 秒開",
new[]
{
"• 開啟大型 STL 網格檔(數十 MB、上百萬三角形)現在幾乎瞬間完成,不用再等好幾分鐘。",
"• 例如一個 75 MB、150 萬個三角形的 STL,以前要等到天荒地老,現在不到 1 秒就顯示出來。",
}),
new("0.7.0", new DateOnly(2026, 7, 4), "介面大整理 — 選單列+可自訂快速列",
new[]
{
"• 上方新增選單列,全部功能依「檔案/視圖/量測/變換/工具」分類,不用再在一長排按鈕裡找。",
"• 原本的工具列變成「快速列」:只放常用按鈕。想放哪些自己決定 — 到「快速列 → 自訂按鈕」勾選,下次開啟會記住。",
"• 剖面的軸向/位置控制列改成只在開啟剖面時出現,平常不佔空間。",
"• 新增「關於」視窗(就是這個畫面),可以看版本紀錄。",
}),
new("0.6.0", new DateOnly(2026, 7, 4), "匯出 STL",
new[]
{
"• 新增匯出 STL:把畫面上的零件(含對齊、旋轉後的位置)存成 STL 網格檔,可拿去 3D 列印或給其他軟體用。",
"• 匯出前會先跳出設定視窗:全部合併成一個檔或每個檔案各存一個、Binary 或文字格式、mm 或 inch、網格精細度 — 確認後才匯出。",
"• 設定視窗會即時顯示三角形數量與預估檔案大小。",
}),
new("0.5.0", new DateOnly(2026, 7, 3), "量測、視圖、樹狀面板大升級",
new[]
{
"• 量距離時點到圓孔邊緣會自動吸到圓心 — 量兩孔中心距(pitch)超方便。",
"• 量測快速鍵:P 點、D 距離、E 邊、F 面、C 圓、A 角度、M 面距;Esc 取消。",
"• 樹狀面板可搜尋零件名稱;右鍵可「只顯示此節點」隔離觀察,也可以量體積與質心。",
"• 新增標準視圖(等角/前/上/右)與正交投影,量尺寸不受透視變形。",
"• 剖面新增「3點」模式:在模型上點 3 個點,就能沿任意斜面剖開。",
"• 新增匯出 STEP:把對齊好的零件位置存成新 STEP 檔,交接給正式 CAD。",
"• 會記住視窗大小、單位設定與最近開啟的 10 個檔案。",
}),
new("0.4.0", new DateOnly(2026, 7, 2), "更快、更穩",
new[]
{
"• 大檔案拉剖面滑桿不再卡住畫面(改到背景計算)。",
"• 拖曳/操作器放開零件後的定位計算大幅加速。",
"• 程式遇到錯誤會提示訊息並繼續運作,不再整個閃退。",
}),
new("0.3.2", new DateOnly(2026, 6, 18), "大組件量測不再卡",
new[]
{
"• 修正大型組件(數萬個面)進入量測模式後,轉動視角嚴重卡頓的問題 — 現在量測跟瀏覽一樣順。",
}),
new("0.3.1", new DateOnly(2026, 6, 13), "操作器永遠可見",
new[]
{
"• 三軸操作器改成永遠浮在畫面最上層,不會被零件擋住,任何角度都抓得到。",
}),
new("0.3.0", new DateOnly(2026, 6, 13), "三軸操作器",
new[]
{
"• 新增操作器:選取檔案後出現 XYZ 三色箭頭與旋轉環,箭頭沿軸精確移動、旋轉環繞軸轉任意角度。",
}),
new("0.2.0", new DateOnly(2026, 6, 13), "拖曳模式",
new[]
{
"• 新增拖曳模式:左鍵按住零件直接拖到想要的位置,放開就定位。",
"• 0.2.1 修正拖曳過的零件再點擊會當掉的問題。",
}),
new("0.1.0", new DateOnly(2026, 6, 13), "首次釋出",
new[]
{
"• STEPSTL/DXF 匯入與裝配樹、點/距離/邊/面/圓/角度/面距量測、兩點與三點對齊、干涉檢查、剖面、mm ⇄ inch、CSV 與截圖匯出。",
}),
};
}
+51 -4
View File
@@ -1,13 +1,60 @@
using System.Configuration;
using System.Data;
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
namespace STPViewer;
/// <summary>
/// Interaction logic for App.xaml
/// 全域例外處理:UI 執行緒未攔截例外 → 記 log + 訊息框後盡量存活(不閃退);
/// 背景 Task / 非 UI 執行緒例外 → 記 log。log 位於 %LOCALAPPDATA%\STPViewer\error.log。
/// </summary>
public partial class App : Application
{
}
private static readonly string LogPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"STPViewer", "error.log");
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
// UI 執行緒(含 async void 回拋)的未攔截例外:記 log、提示、標記 Handled 讓程式存活。
// 若後續狀態已壞使用者可自行重啟;至少量測結果 / 已載模型不會直接蒸發。
DispatcherUnhandledException += (_, args) =>
{
LogException("DispatcherUnhandledException", args.Exception);
MessageBox.Show(
$"發生未預期的錯誤,操作已中止(程式將嘗試繼續運作)。\n\n{args.Exception.Message}\n\n" +
$"詳細記錄:{LogPath}",
"STPViewer 錯誤", MessageBoxButton.OK, MessageBoxImage.Warning);
args.Handled = true;
};
// 沒被 await 的背景 Task 例外(finalizer 階段才浮出):記 log 並標記已觀察,避免行程終止
TaskScheduler.UnobservedTaskException += (_, args) =>
{
LogException("UnobservedTaskException", args.Exception);
args.SetObserved();
};
// 其他非 UI 執行緒的致命例外:無法阻止終止,但至少留下記錄
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
LogException("AppDomainUnhandledException", args.ExceptionObject as Exception);
}
private static void LogException(string source, Exception? ex)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(LogPath)!);
var sb = new StringBuilder();
sb.AppendLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {source}");
sb.AppendLine(ex?.ToString() ?? "(null exception)");
sb.AppendLine(new string('-', 80));
File.AppendAllText(LogPath, sb.ToString(), Encoding.UTF8);
}
catch { /* logging 本身失敗不能再拋 */ }
}
}
@@ -0,0 +1,15 @@
using System;
using System.Globalization;
using System.Windows.Data;
namespace STPViewer.Converters;
/// <summary>完整路徑 → 檔名(選單「最近檔案」顯示用;tooltip 仍給完整路徑)</summary>
public class FileNameConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is string s ? System.IO.Path.GetFileName(s) : "";
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
throw new NotSupportedException();
}
+268 -47
View File
@@ -7,89 +7,258 @@
Title="STPViewer — STP/STEP 3D 檢視器"
Height="820" Width="1320"
WindowStartupLocation="CenterScreen"
AllowDrop="True" Drop="Window_Drop" DragOver="Window_DragOver">
AllowDrop="True" Drop="Window_Drop" DragOver="Window_DragOver"
PreviewKeyDown="Window_PreviewKeyDown">
<Window.Resources>
<conv:EnumToBoolConverter x:Key="ModeConv" />
<conv:FileNameConverter x:Key="FileNameConv" />
<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>
<Style x:Key="QuickButton" TargetType="Button">
<Setter Property="Padding" Value="8,4" />
<Setter Property="Margin" Value="2,0" />
<Setter Property="FontSize" Value="13" />
</Style>
</Window.Resources>
<DockPanel>
<!-- ─── 工具列 ─── -->
<!-- ─── 選單列(全部功能,分類下拉)─── -->
<Menu DockPanel.Dock="Top">
<MenuItem Header="檔案(_F)">
<MenuItem Header="📂 匯入…" Command="{Binding ImportCommand}"
ToolTip="匯入 STP/STEP/STL/DXF(可複選,也可直接拖放到視窗)" />
<MenuItem Header="最近檔案" ItemsSource="{Binding RecentFiles}">
<MenuItem.ItemContainerStyle>
<Style TargetType="MenuItem">
<Setter Property="Header" Value="{Binding Converter={StaticResource FileNameConv}}" />
<Setter Property="ToolTip" Value="{Binding}" />
<Setter Property="Command"
Value="{Binding DataContext.ImportRecentCommand, RelativeSource={RelativeSource AncestorType=Menu}}" />
<Setter Property="CommandParameter" Value="{Binding}" />
</Style>
</MenuItem.ItemContainerStyle>
</MenuItem>
<Separator />
<MenuItem Header="💾 匯出量測 CSV…" Command="{Binding ExportCsvCommand}" />
<MenuItem Header="📷 視圖截圖 PNG…" Command="{Binding SaveScreenshotCommand}"
ToolTip="3D 視圖截圖存 PNG2x 解析度)" />
<MenuItem Header="📤 匯出 STEP…" Command="{Binding ExportStepFileCommand}"
ToolTip="把可見檔案(目前對齊位置)匯出成新 STEP 檔;不動原始檔" />
<MenuItem Header="📐 匯出 STL…" Command="{Binding ExportStlFileCommand}"
ToolTip="先設定範圍/格式/單位/精度,確認再匯出" />
</MenuItem>
<MenuItem Header="視圖(_V)">
<MenuItem Header="🔍 縮放至全部" Command="{Binding ZoomAllCommand}" />
<Separator />
<MenuItem Header="等角視圖" Command="{Binding SetStandardViewCommand}" CommandParameter="Iso" />
<MenuItem Header="前視圖" Command="{Binding SetStandardViewCommand}" CommandParameter="Front"
ToolTip="-Y 方向看,Z 向上" />
<MenuItem Header="上視圖" Command="{Binding SetStandardViewCommand}" CommandParameter="Top"
ToolTip="+Z 往下看" />
<MenuItem Header="右視圖" Command="{Binding SetStandardViewCommand}" CommandParameter="Right"
ToolTip="+X 方向看,Z 向上" />
<Separator />
<MenuItem Header="正交投影" IsCheckable="True" IsChecked="{Binding OrthographicView}"
ToolTip="平行投影,量尺寸不受透視變形;取消勾選切回透視" />
<MenuItem Header="單位顯示 inchmm ⇄ in" IsCheckable="True" IsChecked="{Binding UseInch}"
ToolTip="既有量測即時換算;內部一律存 mm" />
</MenuItem>
<MenuItem Header="量測(_M)">
<MenuItem Header="📍 點座標" IsCheckable="True" InputGestureText="P"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Point}"
ToolTip="量測點座標(吸附頂點/圓心)" />
<MenuItem Header="📏 兩點距離" IsCheckable="True" InputGestureText="D"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Distance}"
ToolTip="兩點距離 + ΔXYZ(點到圓邊會吸圓心 → 可量孔對孔 pitch" />
<MenuItem Header="📐 邊長" IsCheckable="True" InputGestureText="E"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Edge}"
ToolTip="邊長(直線/曲線/圓弧)" />
<MenuItem Header="⬛ 面" IsCheckable="True" InputGestureText="F"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Face}"
ToolTip="面積 + 曲面類型/法向量" />
<MenuItem Header="⭕ 圓" IsCheckable="True" InputGestureText="C"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Circle}"
ToolTip="圓心/半徑/直徑/周長" />
<MenuItem Header="∠ 角度" IsCheckable="True" InputGestureText="A"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Angle}"
ToolTip="兩面/兩邊夾角(點兩個面或靠近直線邊)" />
<MenuItem Header="⇔ 面距" IsCheckable="True" InputGestureText="M"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=FaceDistance}"
ToolTip="面到面最短距離(網格近似)" />
<Separator />
<MenuItem Header="🧹 清除全部量測" Command="{Binding ClearMeasurementsCommand}"
InputGestureText="Esc" ToolTip="Esc = 取消進行中量測/退出模式" />
</MenuItem>
<MenuItem Header="變換(_T)">
<MenuItem Header="🎯 兩點對齊(平移)" IsCheckable="True"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Align}"
ToolTip="先點要移動零件上的點,再點目標點" />
<MenuItem Header="🎯 三點對齊(旋轉+平移)" IsCheckable="True"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Align3}"
ToolTip="先在要移動的檔案點 3 個特徵點,再到目標檔案點 3 個對應點(順序對應)" />
<MenuItem Header="🖐 拖曳模式" IsCheckable="True"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Drag}"
ToolTip="左鍵按住零件沿螢幕平面拖動,放開定位" />
<MenuItem Header="⊹ 三軸操作器" IsCheckable="True" IsChecked="{Binding GizmoEnabled}"
ToolTip="對樹面板選取的檔案顯示 XYZ 箭頭與旋轉環,每次放開即定位" />
<Separator />
<MenuItem Header="↻ 繞 X 軸 +90°" Command="{Binding RotateRootCommand}" CommandParameter="X"
ToolTip="對樹面板選取的檔案,繞其中心旋轉(先在樹面板點選檔案)" />
<MenuItem Header="↻ 繞 Y 軸 +90°" Command="{Binding RotateRootCommand}" CommandParameter="Y" />
<MenuItem Header="↻ 繞 Z 軸 +90°" Command="{Binding RotateRootCommand}" CommandParameter="Z" />
</MenuItem>
<MenuItem Header="工具(_L)">
<MenuItem Header="🧩 干涉檢查" Command="{Binding CheckInterferenceCommand}"
ToolTip="2 個以上可見檔案兩兩檢查是否相交;無相交時回報最小間隙" />
<MenuItem Header="✂ 剖面檢視" IsCheckable="True" IsChecked="{Binding SectionEnabled}"
ToolTip="開啟後下方出現剖面參數列(軸向/位置/反向)" />
</MenuItem>
<MenuItem Header="快速列(_Q)">
<MenuItem Header="自訂按鈕" ItemsSource="{Binding QuickBar.Items}"
ToolTip="勾選要顯示在快速列的常用按鈕(隨設定保存)">
<MenuItem.ItemContainerStyle>
<Style TargetType="MenuItem">
<Setter Property="Header" Value="{Binding Label}" />
<Setter Property="IsCheckable" Value="True" />
<Setter Property="IsChecked" Value="{Binding IsChecked}" />
<Setter Property="StaysOpenOnClick" Value="True" />
</Style>
</MenuItem.ItemContainerStyle>
</MenuItem>
<Separator />
<MenuItem Header="恢復預設按鈕" Command="{Binding QuickBar.ResetDefaultsCommand}" />
</MenuItem>
<MenuItem Header="說明(_H)">
<MenuItem Header=" 關於 STPViewer…" Click="About_Click"
ToolTip="程式資訊與版本紀錄" />
</MenuItem>
</Menu>
<!-- ─── 快速工具列(可自訂:選單「快速列 → 自訂按鈕」勾選)─── -->
<ToolBarTray DockPanel.Dock="Top">
<ToolBar>
<Button Command="{Binding ImportCommand}" Padding="8,4" FontSize="13"
<Button Style="{StaticResource QuickButton}" Command="{Binding ImportCommand}"
Visibility="{Binding QuickBar[Import].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="匯入 STP/STEP/STL/DXF(可複選,也可直接拖放到視窗)">📂 匯入</Button>
<Button Command="{Binding ZoomAllCommand}" Padding="8,4" FontSize="13"
<Button Style="{StaticResource QuickButton}" Click="Recent_Click" Padding="4,4"
Visibility="{Binding QuickBar[Recent].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="最近開啟的檔案">▾</Button>
<Button Style="{StaticResource QuickButton}" Command="{Binding ZoomAllCommand}"
Visibility="{Binding QuickBar[ZoomAll].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="縮放至全部模型">🔍 全覽</Button>
<Separator />
<TextBlock Text="量測:" VerticalAlignment="Center" Margin="4,0,2,0" />
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="量測點座標(吸附頂點)"
<Button Style="{StaticResource QuickButton}" Command="{Binding SetStandardViewCommand}" CommandParameter="Iso"
Visibility="{Binding QuickBar[ViewIso].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="等角視圖">等角</Button>
<Button Style="{StaticResource QuickButton}" Command="{Binding SetStandardViewCommand}" CommandParameter="Front"
Visibility="{Binding QuickBar[ViewFront].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="前視圖(-Y 方向看,Z 向上)">前</Button>
<Button Style="{StaticResource QuickButton}" Command="{Binding SetStandardViewCommand}" CommandParameter="Top"
Visibility="{Binding QuickBar[ViewTop].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="上視圖(+Z 往下看)">上</Button>
<Button Style="{StaticResource QuickButton}" Command="{Binding SetStandardViewCommand}" CommandParameter="Right"
Visibility="{Binding QuickBar[ViewRight].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="右視圖(+X 方向看,Z 向上)">右</Button>
<ToggleButton Style="{StaticResource ModeButton}" IsChecked="{Binding OrthographicView}"
Visibility="{Binding QuickBar[Ortho].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="正交投影(平行投影,量尺寸不受透視變形;再按切回透視)">正交</ToggleButton>
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="量測點座標(吸附頂點/圓心)— 快速鍵 P"
Visibility="{Binding QuickBar[Point].IsChecked, Converter={StaticResource BoolToVis}}"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Point}">📍 點</ToggleButton>
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="兩點距離 + ΔXYZ"
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="兩點距離 + ΔXYZ(點到圓邊會吸圓心 → 可量孔對孔 pitch)— 快速鍵 D"
Visibility="{Binding QuickBar[Distance].IsChecked, Converter={StaticResource BoolToVis}}"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Distance}">📏 距離</ToggleButton>
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="邊長(直線/曲線/圓弧)"
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="邊長(直線/曲線/圓弧)— 快速鍵 E"
Visibility="{Binding QuickBar[Edge].IsChecked, Converter={StaticResource BoolToVis}}"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Edge}">📐 邊</ToggleButton>
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="面積 + 曲面類型/法向量"
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="面積 + 曲面類型/法向量 — 快速鍵 F"
Visibility="{Binding QuickBar[Face].IsChecked, Converter={StaticResource BoolToVis}}"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Face}">⬛ 面</ToggleButton>
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="圓心/半徑/直徑/周長"
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="圓心/半徑/直徑/周長 — 快速鍵 C"
Visibility="{Binding QuickBar[Circle].IsChecked, Converter={StaticResource BoolToVis}}"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Circle}">⭕ 圓</ToggleButton>
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="兩面/兩邊夾角(點兩個面或靠近直線邊)"
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="兩面/兩邊夾角(點兩個面或靠近直線邊)— 快速鍵 A"
Visibility="{Binding QuickBar[Angle].IsChecked, Converter={StaticResource BoolToVis}}"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Angle}">∠ 角度</ToggleButton>
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="面到面最短距離(網格近似)"
<ToggleButton Style="{StaticResource ModeButton}" ToolTip="面到面最短距離(網格近似)— 快速鍵 M"
Visibility="{Binding QuickBar[FaceDistance].IsChecked, Converter={StaticResource BoolToVis}}"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=FaceDistance}">⇔ 面距</ToggleButton>
<Separator />
<ToggleButton Style="{StaticResource ModeButton}"
ToolTip="兩點對齊(平移零件):先點要移動零件上的點,再點目標點"
Visibility="{Binding QuickBar[Align].IsChecked, Converter={StaticResource BoolToVis}}"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Align}">🎯 對齊</ToggleButton>
<ToggleButton Style="{StaticResource ModeButton}"
ToolTip="三點對齊(旋轉+平移):先在要移動的檔案點 3 個特徵點,再到目標檔案點 3 個對應點(順序對應)"
Visibility="{Binding QuickBar[Align3].IsChecked, Converter={StaticResource BoolToVis}}"
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"
<Button Style="{StaticResource QuickButton}" Command="{Binding RotateRootCommand}" CommandParameter="X"
Visibility="{Binding QuickBar[RotateX].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="選取的檔案繞 X 軸 +90°(先在樹面板點選檔案)">↻X</Button>
<Button Command="{Binding RotateRootCommand}" CommandParameter="Y" Padding="6,4" FontSize="13"
<Button Style="{StaticResource QuickButton}" Command="{Binding RotateRootCommand}" CommandParameter="Y"
Visibility="{Binding QuickBar[RotateY].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="選取的檔案繞 Y 軸 +90°">↻Y</Button>
<Button Command="{Binding RotateRootCommand}" CommandParameter="Z" Padding="6,4" FontSize="13"
<Button Style="{StaticResource QuickButton}" Command="{Binding RotateRootCommand}" CommandParameter="Z"
Visibility="{Binding QuickBar[RotateZ].IsChecked, Converter={StaticResource BoolToVis}}"
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"
<ToggleButton Style="{StaticResource ModeButton}"
ToolTip="拖曳模式:左鍵按住零件沿螢幕平面拖動,放開定位(深度方向先轉視角再拖)"
Visibility="{Binding QuickBar[Drag].IsChecked, Converter={StaticResource BoolToVis}}"
IsChecked="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Drag}">🖐 拖曳</ToggleButton>
<ToggleButton Style="{StaticResource ModeButton}" IsChecked="{Binding GizmoEnabled}"
Visibility="{Binding QuickBar[Gizmo].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="三軸操作器:對樹面板選取的檔案顯示 XYZ 箭頭(沿軸移動)與旋轉環(繞軸轉任意角度),每次放開即定位">⊹ 操作器</ToggleButton>
<Button Style="{StaticResource QuickButton}" Command="{Binding CheckInterferenceCommand}"
Visibility="{Binding QuickBar[Interference].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="干涉檢查:2 個以上可見檔案兩兩檢查是否相交(match 判定);無相交時回報最小間隙">🧩 干涉</Button>
<ToggleButton Style="{StaticResource ModeButton}" IsChecked="{Binding SectionEnabled}"
Visibility="{Binding QuickBar[Section].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="開啟/關閉剖面檢視(開啟後下方出現參數列)">✂ 剖面</ToggleButton>
<Button Style="{StaticResource QuickButton}" Command="{Binding ClearMeasurementsCommand}"
Visibility="{Binding QuickBar[Clear].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="清除全部量測與標記(Esc = 取消進行中量測/退出模式)">🧹 清除</Button>
<ToggleButton Style="{StaticResource ModeButton}" IsChecked="{Binding UseInch}"
Visibility="{Binding QuickBar[Unit].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="切換顯示單位 mm ⇄ inch(既有量測即時換算)">mm ⇄ in</ToggleButton>
<Separator />
<Button Command="{Binding ExportCsvCommand}" Padding="8,4" FontSize="13"
<Button Style="{StaticResource QuickButton}" Command="{Binding ExportCsvCommand}"
Visibility="{Binding QuickBar[ExportCsv].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="量測結果匯出 CSV">💾 CSV</Button>
<Button Command="{Binding SaveScreenshotCommand}" Padding="8,4" FontSize="13"
<Button Style="{StaticResource QuickButton}" Command="{Binding SaveScreenshotCommand}"
Visibility="{Binding QuickBar[Screenshot].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="3D 視圖截圖存 PNG2x 解析度)">📷 截圖</Button>
<Button Style="{StaticResource QuickButton}" Command="{Binding ExportStepFileCommand}"
Visibility="{Binding QuickBar[ExportStep].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="把可見檔案(目前對齊位置)匯出成新 STEP 檔 — 對齊插合結果可交接 CAD;不動原始檔">📤 STEP</Button>
<Button Style="{StaticResource QuickButton}" Command="{Binding ExportStlFileCommand}"
Visibility="{Binding QuickBar[ExportStl].IsChecked, Converter={StaticResource BoolToVis}}"
ToolTip="把可見檔案(目前位置)匯出成 STL 網格檔 — 先設定範圍/格式/單位/精度,確認再匯出">📐 STL</Button>
</ToolBar>
</ToolBarTray>
<!-- ─── 剖面參數列(只在剖面開啟時出現)─── -->
<ToolBarTray DockPanel.Dock="Top"
Visibility="{Binding SectionEnabled, Converter={StaticResource BoolToVis}}">
<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}">
<TextBlock Text="✂ 剖面:" VerticalAlignment="Center" Margin="4,0,2,0" />
<ComboBox SelectedIndex="{Binding SectionAxisIndex}" Width="60" Margin="4,0"
VerticalAlignment="Center" ToolTip="剖切軸向(3點 = 在模型上點 3 點定義任意平面)">
<ComboBoxItem>X</ComboBoxItem>
<ComboBoxItem>Y</ComboBoxItem>
<ComboBoxItem>Z</ComboBoxItem>
<ComboBoxItem>3點</ComboBoxItem>
</ComboBox>
<Slider Value="{Binding SectionPosition}" Minimum="0" Maximum="100" Width="180"
VerticalAlignment="Center" Margin="4,0" ToolTip="剖面位置(沿 %"
IsEnabled="{Binding SectionEnabled}" />
<TextBlock Text="{Binding SectionPosition, StringFormat={}{0:F0}%}" Width="36"
VerticalAlignment="Center" />
VerticalAlignment="Center" Margin="4,0" ToolTip="剖面位置(沿法向 %" />
<TextBox Text="{Binding SectionPosition, StringFormat={}{0:F0}}" Width="34"
VerticalAlignment="Center" ToolTip="剖面位置 %(0–100,可直接輸入,離開欄位生效)" />
<TextBlock Text="%" VerticalAlignment="Center" Margin="1,0,0,0" />
<CheckBox IsChecked="{Binding SectionFlip}" Content="反向" VerticalAlignment="Center"
Margin="4,0" ToolTip="切換保留側" IsEnabled="{Binding SectionEnabled}" />
Margin="4,0" ToolTip="切換保留側" />
<Button Style="{StaticResource QuickButton}" Command="{Binding CloseSectionCommand}"
ToolTip="關閉剖面檢視">✕ 關閉</Button>
</ToolBar>
</ToolBarTray>
@@ -106,13 +275,19 @@
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="280" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="300" />
<ColumnDefinition Width="280" MinWidth="160" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" MinWidth="300" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="300" MinWidth="160" />
</Grid.ColumnDefinitions>
<!-- ─── 裝配樹面板 ─── -->
<GroupBox Grid.Column="0" Header="模型 / 裝配樹" Margin="4">
<DockPanel>
<TextBox DockPanel.Dock="Top" Margin="0,2,0,4"
Text="{Binding TreeFilter, UpdateSourceTrigger=PropertyChanged}"
ToolTip="輸入名稱過濾裝配樹(只影響樹面板顯示,不影響 3D 可見性;清空 = 全顯)" />
<TreeView ItemsSource="{Binding Roots}"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
VirtualizingPanel.IsVirtualizing="True"
@@ -120,13 +295,32 @@
<TreeView.ItemContainerStyle>
<Style TargetType="TreeViewItem">
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
<Setter Property="Visibility"
Value="{Binding IsFilterVisible, Converter={StaticResource BoolToVis}}" />
</Style>
</TreeView.ItemContainerStyle>
<TreeView.ItemTemplate>
<HierarchicalDataTemplate DataType="{x:Type vm:ModelNodeViewModel}"
ItemsSource="{Binding Children}">
<StackPanel Orientation="Horizontal" Margin="0,1"
ToolTip="{Binding ToolTipText}">
Background="Transparent"
ToolTip="{Binding ToolTipText}"
Tag="{Binding DataContext, RelativeSource={RelativeSource AncestorType=Window}}">
<StackPanel.ContextMenu>
<ContextMenu>
<MenuItem Header="👁 只顯示此節點"
Command="{Binding PlacementTarget.Tag.IsolateNodeCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
CommandParameter="{Binding}" />
<MenuItem Header="🔄 反轉顯示"
Command="{Binding PlacementTarget.Tag.InvertVisibilityCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
<MenuItem Header="☀ 全部顯示"
Command="{Binding PlacementTarget.Tag.ShowAllCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
<Separator />
<MenuItem Header="⚖ 量體積 / 質心"
Command="{Binding PlacementTarget.Tag.MeasureVolumeCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
CommandParameter="{Binding}" />
</ContextMenu>
</StackPanel.ContextMenu>
<CheckBox IsChecked="{Binding IsVisible}" VerticalAlignment="Center"
ToolTip="顯示 / 隱藏(含子節點)" />
<Button Width="14" Height="14" Margin="3,0"
@@ -171,14 +365,32 @@
</HierarchicalDataTemplate>
</TreeView.ItemTemplate>
</TreeView>
</DockPanel>
</GroupBox>
<GridSplitter Grid.Column="1" Width="5" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" Background="#FFD8D8D8"
ToolTip="拖曳調整面板寬度" />
<!-- ─── 3D 視圖 ─── -->
<h:HelixViewport3D x:Name="viewport" Grid.Column="1"
<h:HelixViewport3D x:Name="viewport" Grid.Column="2"
ShowViewCube="True" ShowCoordinateSystem="True"
ShowFrameRate="False" ZoomExtentsWhenLoaded="True"
PanGesture="MiddleClick" PanGesture2="Shift+LeftClick"
MouseLeftButtonDown="Viewport_MouseLeftButtonDown">
MouseLeftButtonDown="Viewport_MouseLeftButtonDown"
MouseMove="Viewport_MouseMove"
MouseLeftButtonUp="Viewport_MouseLeftButtonUp"
LostMouseCapture="Viewport_LostMouseCapture">
<h:HelixViewport3D.Style>
<Style TargetType="h:HelixViewport3D">
<Style.Triggers>
<DataTrigger Value="True"
Binding="{Binding CurrentMode, Converter={StaticResource ModeConv}, ConverterParameter=Drag}">
<Setter Property="Cursor" Value="Hand" />
</DataTrigger>
</Style.Triggers>
</Style>
</h:HelixViewport3D.Style>
<h:HelixViewport3D.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#FFEAF2F8" Offset="0" />
@@ -188,8 +400,17 @@
<h:DefaultLights />
</h:HelixViewport3D>
<!-- 操作器疊圖層:透明 Viewport3D 疊在主視窗上、永遠最上層。
只在操作器開啟時 IsHitTestVisible(否則空白處滑鼠才會穿透回主視窗) -->
<Viewport3D x:Name="gizmoOverlay" Grid.Column="2"
IsHitTestVisible="{Binding GizmoEnabled}" />
<GridSplitter Grid.Column="3" Width="5" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" Background="#FFD8D8D8"
ToolTip="拖曳調整面板寬度" />
<!-- ─── 量測結果面板 ─── -->
<GroupBox Grid.Column="2" Header="量測結果" Margin="4">
<GroupBox Grid.Column="4" Header="量測結果" Margin="4">
<ListBox ItemsSource="{Binding Measurements}" HorizontalContentAlignment="Stretch"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListBox.ItemTemplate>
+125 -1
View File
@@ -1,5 +1,9 @@
using System.ComponentModel;
using System.Linq;
using System.Reflection;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using STPViewer.Services;
using STPViewer.ViewModels;
@@ -9,13 +13,64 @@ namespace STPViewer;
public partial class MainWindow : Window
{
private readonly MainViewModel _vm = new();
private readonly AppSettings _settings;
public MainWindow()
{
InitializeComponent();
// 版號單一來源 = csproj <Version>;標題執行時讀,避免寫死
var v = Assembly.GetExecutingAssembly().GetName().Version!;
Title = $"STPViewer V{v.Major}.{v.Minor}.{v.Build} — STP/STEP 3D 檢視器";
DataContext = _vm;
_vm.Attach(viewport);
_vm.AttachOverlay(gizmoOverlay);
Loaded += MainWindow_Loaded;
Closing += MainWindow_Closing;
// 使用者設定:視窗位置大小(View 職責)+ 單位/MRU(VM 職責)
_settings = SettingsService.Load();
ApplyWindowSettings();
_vm.LoadSettings(_settings);
// Gizmo 操作器放在疊圖層;manipulator 會把 MouseUp 標 handled,需 handledEventsToo 才收得到
gizmoOverlay.AddHandler(MouseLeftButtonUpEvent,
new MouseButtonEventHandler((_, _) => _vm.OnGizmoMouseUp()), handledEventsToo: true);
}
private void ApplyWindowSettings()
{
if (_settings.WindowWidth >= 400 && _settings.WindowHeight >= 300)
{
Width = _settings.WindowWidth;
Height = _settings.WindowHeight;
}
// 位置要落在目前虛擬桌面內才還原(避免拔掉外接螢幕後視窗開在螢幕外)
if (!double.IsNaN(_settings.WindowLeft) && !double.IsNaN(_settings.WindowTop) &&
_settings.WindowLeft >= SystemParameters.VirtualScreenLeft - 50 &&
_settings.WindowTop >= SystemParameters.VirtualScreenTop - 50 &&
_settings.WindowLeft <= SystemParameters.VirtualScreenLeft + SystemParameters.VirtualScreenWidth - 200 &&
_settings.WindowTop <= SystemParameters.VirtualScreenTop + SystemParameters.VirtualScreenHeight - 200)
{
WindowStartupLocation = WindowStartupLocation.Manual;
Left = _settings.WindowLeft;
Top = _settings.WindowTop;
}
if (_settings.WindowMaximized)
WindowState = WindowState.Maximized;
}
private void MainWindow_Closing(object? sender, CancelEventArgs e)
{
Rect r = WindowState == WindowState.Normal
? new Rect(Left, Top, Width, Height)
: RestoreBounds;
_settings.WindowLeft = r.Left;
_settings.WindowTop = r.Top;
_settings.WindowWidth = r.Width;
_settings.WindowHeight = r.Height;
_settings.WindowMaximized = WindowState == WindowState.Maximized;
_vm.SaveSettingsInto(_settings);
SettingsService.Save(_settings);
}
/// <summary>支援命令列帶檔開啟:STPViewer.exe a.stp b.stl …</summary>
@@ -27,16 +82,85 @@ public partial class MainWindow : Window
await _vm.ImportFilesAsync(files);
}
/// <summary>Esc / 量測模式快速鍵(焦點在輸入框時不攔截,樹搜尋、剖面數值可正常打字)</summary>
private void Window_PreviewKeyDown(object sender, KeyEventArgs e)
{
if (Keyboard.FocusedElement is TextBoxBase or ComboBox) return;
if (e.Key == Key.Escape)
{
_vm.OnEscape();
e.Handled = true;
return;
}
if (Keyboard.Modifiers != ModifierKeys.None) return;
if (_vm.OnHotKey(e.Key))
e.Handled = true;
}
/// <summary>最近檔案下拉(▾):即時從 VM 的 MRU 清單組選單</summary>
private void Recent_Click(object sender, RoutedEventArgs e)
{
var menu = new ContextMenu
{
PlacementTarget = (UIElement)sender,
Placement = PlacementMode.Bottom,
};
if (_vm.RecentFiles.Count == 0)
{
menu.Items.Add(new MenuItem { Header = "(沒有最近開啟的檔案)", IsEnabled = false });
}
else
{
foreach (string f in _vm.RecentFiles)
menu.Items.Add(new MenuItem
{
Header = System.IO.Path.GetFileName(f),
ToolTip = f,
Command = _vm.ImportRecentCommand,
CommandParameter = f,
});
}
menu.IsOpen = true;
}
private void About_Click(object sender, RoutedEventArgs e) =>
new AboutDialog { Owner = this }.ShowDialog();
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),不觸發量測
// Shift+左鍵 = 平移(PanGesture2),不觸發量測/拖曳
if (Keyboard.Modifiers.HasFlag(ModifierKeys.Shift)) return;
if (_vm.CurrentMode == STPViewer.Models.MeasureMode.Drag)
{
if (_vm.OnDragStart(e.GetPosition(viewport.Viewport)))
{
viewport.CaptureMouse(); // 拖出視窗外也持續收到 Move/Up
e.Handled = true;
}
return;
}
_vm.OnViewportClick(e.GetPosition(viewport.Viewport));
}
private void Viewport_MouseMove(object sender, MouseEventArgs e)
{
if (e.LeftButton == MouseButtonState.Pressed)
_vm.OnDragMove(e.GetPosition(viewport.Viewport));
}
private void Viewport_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (_vm.OnDragEnd())
viewport.ReleaseMouseCapture();
}
private void Viewport_LostMouseCapture(object sender, MouseEventArgs e) =>
_vm.OnDragEnd(); // 捕捉意外中斷(Alt+Tab 等)→ 以目前位置定格,不留懸空 Transform
private void Window_DragOver(object sender, DragEventArgs e)
{
e.Effects = HasSupportedFiles(e) ? DragDropEffects.Copy : DragDropEffects.None;
+2
View File
@@ -13,5 +13,7 @@ public enum MeasureMode
FaceDistance, // 面到面最短距離(網格近似)
Align, // 兩點對齊:平移零件使點 1 貼到點 2
Align3, // 三點對齊:旋轉+平移(來源 3 點 → 目標 3 點)
Drag, // 拖曳模式:左鍵按住零件沿螢幕平面拖動(放開才烘進 B-rep)
Interference, // 干涉檢查結果(非點擊模式,僅作為結果類型)
Volume, // 體積/質心(樹右鍵選單觸發,非點擊模式,僅作為結果類型)
}
+4
View File
@@ -21,6 +21,10 @@ public static class Units
public static string A(double mm2, UnitSystem u) =>
u == UnitSystem.Millimeter ? $"{mm2:N3} mm²" : $"{mm2 / (MmPerInch * MmPerInch):N4} in²";
/// <summary>體積</summary>
public static string V(double mm3, UnitSystem u) =>
u == UnitSystem.Millimeter ? $"{mm3:N2} mm³" : $"{mm3 / (MmPerInch * MmPerInch * MmPerInch):N4} in³";
/// <summary>座標分量(無單位字尾)</summary>
public static string C(double mm, UnitSystem u) =>
u == UnitSystem.Millimeter ? $"{mm:F3}" : $"{mm / MmPerInch:F4}";
+1
View File
@@ -6,6 +6,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<Version>0.7.1</Version> <!-- 單一版號來源:標題與 publish-winapp 打包資料夾都讀這個 -->
</PropertyGroup>
<ItemGroup>
+33 -11
View File
@@ -237,21 +237,43 @@ public static class InterferenceService
if (d < best) { best = d; pa = trisA[i].A; pb = trisB[j].A; }
}
// 2) 以最佳點為中心,精修:點 → 對方所有三角形
Point3D RefineAgainst(Point3D p, Triangle[] tris, ref double bestD, Point3D current)
// 2) 以最佳點為中心,精修:點 → 對方所有三角形(AABB 平方距離快速拒絕 + 平行分段,
// 初值來自步驟 1 已相當接近 → 絕大多數三角形被 AABB 直接刷掉)
(pb, best) = ClosestOnTriangles(pa, trisB, pb, best);
(pa, best) = ClosestOnTriangles(pb, trisA, pa, best);
return (pa, pb, best);
}
/// <summary>點 p 到三角形集的最近點(帶初值;回傳更新後的最近點與距離)</summary>
private static (Point3D pt, double d) ClosestOnTriangles(Point3D p, Triangle[] tris, Point3D seedPt, double seedD)
{
Point3D bestPt = current;
foreach (Triangle t in tris)
object gate = new();
Point3D bestPt = seedPt;
double bestD = seedD;
System.Threading.Tasks.Parallel.For(0, tris.Length,
() => (D: seedD, Pt: seedPt, Found: false),
(i, _, local) =>
{
Triangle t = tris[i];
// AABB 平方距離下界 ≥ 目前最佳 → 不可能更近,略過(絕大多數三角形在此被刷掉)
double dx = Math.Max(0, Math.Max(t.Min.X - p.X, p.X - t.Max.X));
double dy = Math.Max(0, Math.Max(t.Min.Y - p.Y, p.Y - t.Max.Y));
double dz = Math.Max(0, Math.Max(t.Min.Z - p.Z, p.Z - t.Max.Z));
if (dx * dx + dy * dy + dz * dz >= local.D * local.D) return local;
Point3D q = ClosestPointOnTriangle(p, t.A, t.B, t.C);
double d = (q - p).Length;
if (d < bestD) { bestD = d; bestPt = q; }
}
return bestPt;
}
pb = RefineAgainst(pa, trisB, ref best, pb);
pa = RefineAgainst(pb, trisA, ref best, pa);
return (pa, pb, best);
return d < local.D ? (d, q, true) : local;
},
local =>
{
if (!local.Found) return;
lock (gate)
if (local.D < bestD) { bestD = local.D; bestPt = local.Pt; }
});
return (bestPt, bestD);
}
/// <summary>點到三角形最近點(Ericson, Real-Time Collision Detection</summary>
+63 -33
View File
@@ -31,7 +31,12 @@ public class MeasurementService
// ─── 拾取輔助 ────────────────────────────────────────────────
/// <summary>命中點吸附到最近的 B-rep 頂點(容差內),否則回傳原始表面點</summary>
/// <summary>
/// 命中點吸附:點到圓形邊附近(容差內)→ 吸附**圓心**(量兩孔 pitch 的關鍵,v0.5.0);
/// 否則吸附最近的 B-rep 頂點(容差內);都沒有 → 回傳原始表面點。
/// 圓心與頂點都在容差內時,比「命中點到圓形邊」與「命中點到頂點」誰近(圓弧端點常與圓重疊,
/// 點在圓邊上時圓心優先勝出才符合直覺)。
/// </summary>
public Point3D Snap(FaceInfo fi, Point3D hit, double tolerance)
{
if (fi.BrepFace is null) return hit;
@@ -47,6 +52,22 @@ public class MeasurementService
}
}
catch { /* 頂點存取失敗則不吸附 */ }
// 圓形邊:以「命中點到邊曲線」的距離參與比較,勝出時吸附到圓心
try
{
foreach (Edge e in fi.BrepFace.AllEdges)
{
if (e.Curve3D is not Ellipse { IsCircle: true } el) continue;
double pos;
try { pos = el.PositionOf(ToGeo(hit)); } catch { continue; }
if (double.IsNaN(pos)) continue;
Point3D onCurve = ToP3(el.PointAt(Math.Clamp(pos, 0, 1)));
double d = (onCurve - hit).Length;
if (d < bestD) { bestD = d; best = ToP3(el.Center); }
}
}
catch { /* 邊存取失敗則維持頂點吸附結果 */ }
return best;
}
@@ -390,7 +411,7 @@ public class MeasurementService
Point3D v = vp[i];
for (int t = 0; t + 2 < ti.Count; t += 3)
{
Point3D q = ClosestPointOnTriangle(v, tp[ti[t]], tp[ti[t + 1]], tp[ti[t + 2]]);
Point3D q = InterferenceService.ClosestPointOnTriangle(v, tp[ti[t]], tp[ti[t + 1]], tp[ti[t + 2]]);
double d = (q - v).Length;
if (d < best)
{
@@ -406,39 +427,48 @@ public class MeasurementService
return (pa, pb);
}
/// <summary>點到三角形最近點(Ericson, Real-Time Collision Detection</summary>
private static Point3D ClosestPointOnTriangle(Point3D p, Point3D a, Point3D b, Point3D c)
// ─── 體積 / 質心(封閉網格 signed volume)────────────────────
/// <summary>
/// 網格體積與質心:各三角形與原點構成的四面體有向體積加總(divergence theorem)。
/// 只對「封閉」網格有意義(開放殼會得到不可靠的值 → IsClosedReliable=false 供呼叫端提示)。
/// 多個 mesh(多實體)直接加總。
/// </summary>
public static (double VolumeMm3, Point3D Centroid, bool Reliable) MeshVolume(
System.Collections.Generic.IEnumerable<MeshGeometry3D> meshes)
{
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);
double vol6 = 0; // Σ 6×有向體積
double absVol6 = 0; // Σ |6×有向體積|(封閉性 sanity check 用)
double cx = 0, cy = 0, cz = 0; // Σ 四面體質心 × 6×有向體積
foreach (MeshGeometry3D mesh in meshes)
{
Point3DCollection pos = mesh.Positions;
Int32Collection idx = mesh.TriangleIndices;
for (int i = 0; i + 2 < idx.Count; i += 3)
{
Point3D a = pos[idx[i]], b = pos[idx[i + 1]], c = pos[idx[i + 2]];
// 6V = a · (b × c)
double v = a.X * (b.Y * c.Z - b.Z * c.Y)
+ a.Y * (b.Z * c.X - b.X * c.Z)
+ a.Z * (b.X * c.Y - b.Y * c.X);
vol6 += v;
absVol6 += Math.Abs(v);
// 四面體(a,b,c,原點)質心 = (a+b+c)/4
cx += (a.X + b.X + c.X) / 4 * v;
cy += (a.Y + b.Y + c.Y) / 4 * v;
cz += (a.Z + b.Z + c.Z) / 4 * v;
}
}
double volume = Math.Abs(vol6) / 6;
// 有向體積相對正負抵銷比例過高 → 可能是開放殼/法向不一致,數值不可靠
bool reliable = absVol6 > 0 && Math.Abs(vol6) / absVol6 > 0.01 && volume > 1e-9;
Point3D centroid = Math.Abs(vol6) > 1e-12
? new Point3D(cx / vol6, cy / vol6, cz / vol6)
: default;
return (volume, centroid, reliable);
}
// 點到三角形最近點:共用 InterferenceService.ClosestPointOnTrianglev0.4.0 去重複)
// ─── overlay helpers ─────────────────────────────────────────
+53
View File
@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
namespace STPViewer.Services;
/// <summary>使用者設定(%LOCALAPPDATA%\STPViewer\settings.json</summary>
public class AppSettings
{
public double WindowWidth { get; set; } = double.NaN;
public double WindowHeight { get; set; } = double.NaN;
public double WindowLeft { get; set; } = double.NaN;
public double WindowTop { get; set; } = double.NaN;
public bool WindowMaximized { get; set; }
public bool UseInch { get; set; }
public List<string> RecentFiles { get; set; } = new();
/// <summary>快速工具列顯示的按鈕 key 清單;null = 用預設(QuickBarViewModel.Registry</summary>
public List<string>? QuickBarKeys { get; set; }
}
/// <summary>設定讀寫:壞檔/缺檔一律回預設值,儲存失敗靜默(設定不值得讓程式閃退)</summary>
public static class SettingsService
{
public const int MaxRecentFiles = 10;
private static readonly string SettingsPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"STPViewer", "settings.json");
public static AppSettings Load()
{
try
{
if (File.Exists(SettingsPath))
return JsonSerializer.Deserialize<AppSettings>(File.ReadAllText(SettingsPath)) ?? new AppSettings();
}
catch { /* 壞檔回預設 */ }
return new AppSettings();
}
public static void Save(AppSettings settings)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(SettingsPath)!);
File.WriteAllText(SettingsPath,
JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true }));
}
catch { /* 儲存失敗靜默 */ }
}
}
+22 -26
View File
@@ -123,46 +123,42 @@ public class StepImportService
BuildChildren(root, EnumerateGeo(list));
}
// ─── STL(純網格,無 B-rep;整個 shell 合併成單一 mesh 避免模型數爆炸)──
// ─── STL(純網格,無 B-rep)──
// 直接解析三角形(StlMeshReader),**不經過 CADability**。CADability 的 ImportSTL 會為每個
// 三角形建一個 B-rep Face(含 Edge/Vertex 拓樸),百萬三角形需數十分鐘且吃光記憶體
// (實測 100k 三角形:CADability ~41 s,直接解析 ~11 ms)。STL 本就無 B-rep,讀三角形即可。
// ⚠️ 因此 STL leaf 沒有 SourceGeosCADability B-rep)→ 不進 STEP 匯出(本就非 STEP B-rep 幾何);
// 剛體變換仍走網格層(TransformRoot 的 SourceGeos 迴圈空轉、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;
Progress?.Invoke("解析 STL(直接讀三角形)…");
var sw = System.Diagnostics.Stopwatch.StartNew();
List<StlMeshReader.MeshGroup> groups = StlMeshReader.Read(path);
if (groups.Count == 0)
throw new InvalidDataException("STL 檔內沒有三角形");
var mesh = new MeshGeometry3D { Positions = positions, TriangleIndices = indices };
foreach (StlMeshReader.MeshGroup g in groups)
{
if (g.Positions.Count == 0) continue;
g.Positions.Freeze();
g.Indices.Freeze();
var mesh = new MeshGeometry3D { Positions = g.Positions, TriangleIndices = g.Indices };
mesh.Freeze();
int tris = g.Indices.Count / 3;
var leaf = new ImportedNode
{
Name = shells.Length == 1 ? "網格" : $"網格 {i}",
Name = g.Name,
HasBrep = false,
SolidCount = 1,
FaceCount = sh.Faces.Length,
TriangleCount = indices.Count / 3,
FaceCount = tris, // STL 無 B-rep 面 → 以三角形數代表
TriangleCount = tris,
Bounds = BoundsOfMesh(mesh),
};
leaf.SourceGeos.Add(sh);
leaf.Faces.Add(new ImportedFace(null, mesh));
root.Children.Add(leaf);
i++;
}
Progress?.Invoke($"STL 解析 {sw.ElapsedMilliseconds:N0} ms{root.Children.Count} 個網格)");
}
// ─── DXF(多為線架構;Solid/Face 走同一套,曲線取樣成線段)──
+209
View File
@@ -0,0 +1,209 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using System.Windows.Media;
using System.Windows.Media.Media3D;
using CADability;
using CADability.GeoObject;
namespace STPViewer.Services;
/// <summary>
/// 網格精度:Current = 目前顯示網格;Fine = 由 B-rep 以較細精度重新三角化(無 B-rep 的來源維持現有網格)。
/// 不提供「較粗」:CADability 的 GetTriangulation 對「比快取粗」的精度要求會直接回傳既有較細快取,
/// 匯入時已三角化過 → 要求粗化實際上拿到同一份網格(實測驗證),故誠實不提供。
/// </summary>
public enum StlMeshQuality { Current, Fine }
/// <summary>
/// STL 匯出:WPF 三角網格 → binary / ASCII STL。
/// STL 本身無單位,約定輸出 mmscale=1);inch 交接用 scale = 1/25.4。
/// </summary>
public static class StlExportService
{
/// <summary>
/// 三角化精度係數(相對匯入預設):Fine 較細(三角形多、曲面平滑)。
/// 實測 CADability 三角形數對精度非單調(0.4× 重算反而比 1× 少 — 重算網格較有效率),
/// 要明顯更細需 ≤0.15×(test.stp1× = 30,784 → 0.1× = 42,220 三角形)。
/// </summary>
public static double PrecisionFactor(StlMeshQuality q) =>
q == StlMeshQuality.Fine ? 0.15 : 1.0;
/// <summary>預估檔案大小:binary = 84 + 50×nASCII 每三角形約 230 bytes</summary>
public static long EstimateBytes(long triangles, bool ascii) =>
ascii ? 20 + triangles * 230 : 84 + triangles * 50;
public static string FormatBytes(long bytes) => bytes switch
{
>= 1L << 30 => $"{bytes / (double)(1L << 30):F1} GB",
>= 1L << 20 => $"{bytes / (double)(1L << 20):F1} MB",
>= 1L << 10 => $"{bytes / (double)(1L << 10):F1} KB",
_ => $"{bytes} B",
};
/// <summary>
/// 把多個網格寫成一個 STL 檔。退化(零面積)三角形濾除。
/// 回傳實際寫出的三角形數。
/// </summary>
public static int Write(string path, IReadOnlyList<MeshGeometry3D> meshes, bool ascii, double scale) =>
ascii ? WriteAscii(path, meshes, scale) : WriteBinary(path, meshes, scale);
private static int WriteBinary(string path, IReadOnlyList<MeshGeometry3D> meshes, double scale)
{
using var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, 1 << 16);
using var bw = new BinaryWriter(fs);
// 80-byte header(不可以 "solid" 開頭,部分讀取器會誤判為 ASCII)
var header = new byte[80];
Encoding.ASCII.GetBytes("STPViewer binary STL (unit: mm x scale)").CopyTo(header, 0);
bw.Write(header);
bw.Write(0u); // 三角形數 placeholder,寫完回頭補
int count = 0;
foreach (MeshGeometry3D mesh in meshes)
{
var pos = mesh.Positions;
var idx = mesh.TriangleIndices;
for (int i = 0; i + 2 < idx.Count; i += 3)
{
Point3D a = pos[idx[i]], b = pos[idx[i + 1]], c = pos[idx[i + 2]];
if (!TryNormal(a, b, c, out Vector3D n)) continue; // 退化三角形
bw.Write((float)n.X); bw.Write((float)n.Y); bw.Write((float)n.Z);
WriteVertex(bw, a, scale);
WriteVertex(bw, b, scale);
WriteVertex(bw, c, scale);
bw.Write((ushort)0); // attribute byte count
count++;
}
}
fs.Seek(80, SeekOrigin.Begin);
bw.Write((uint)count);
return count;
}
private static void WriteVertex(BinaryWriter bw, Point3D p, double scale)
{
bw.Write((float)(p.X * scale));
bw.Write((float)(p.Y * scale));
bw.Write((float)(p.Z * scale));
}
private static int WriteAscii(string path, IReadOnlyList<MeshGeometry3D> meshes, double scale)
{
var inv = CultureInfo.InvariantCulture;
using var sw = new StreamWriter(path, append: false, Encoding.ASCII, 1 << 16);
sw.WriteLine("solid STPViewer");
int count = 0;
foreach (MeshGeometry3D mesh in meshes)
{
var pos = mesh.Positions;
var idx = mesh.TriangleIndices;
for (int i = 0; i + 2 < idx.Count; i += 3)
{
Point3D a = pos[idx[i]], b = pos[idx[i + 1]], c = pos[idx[i + 2]];
if (!TryNormal(a, b, c, out Vector3D n)) continue;
sw.WriteLine(string.Format(inv, " facet normal {0:e6} {1:e6} {2:e6}", n.X, n.Y, n.Z));
sw.WriteLine(" outer loop");
WriteAsciiVertex(sw, a, scale, inv);
WriteAsciiVertex(sw, b, scale, inv);
WriteAsciiVertex(sw, c, scale, inv);
sw.WriteLine(" endloop");
sw.WriteLine(" endfacet");
count++;
}
}
sw.WriteLine("endsolid STPViewer");
return count;
}
private static void WriteAsciiVertex(StreamWriter sw, Point3D p, double scale, CultureInfo inv) =>
sw.WriteLine(string.Format(inv, " vertex {0:e6} {1:e6} {2:e6}",
p.X * scale, p.Y * scale, p.Z * scale));
/// <returns>false = 退化三角形(面積 ≈ 0</returns>
private static bool TryNormal(Point3D a, Point3D b, Point3D c, out Vector3D n)
{
n = Vector3D.CrossProduct(b - a, c - a);
double len = n.Length;
if (len < 1e-12) return false;
n /= len;
return true;
}
// ─── B-rep 重新三角化(精細/較粗匯出用)────────────────────────
/// <summary>
/// 由 B-rep 幾何以指定精度係數重新三角化,回傳每面一個 frozen mesh。
/// CADability 非執行緒安全 → 呼叫端維持循序(可在單一背景執行緒)。
/// 個別面三角化失敗時跳過(與匯入一致的容錯策略)。
/// </summary>
public static List<MeshGeometry3D> Tessellate(IEnumerable<IGeoObject> geos, double precisionFactor)
{
var meshes = new List<MeshGeometry3D>();
foreach (IGeoObject g in geos)
{
switch (g)
{
case Solid solid:
foreach (Shell sh in solid.Shells) TessellateShell(sh, precisionFactor, meshes);
break;
case Shell shell:
TessellateShell(shell, precisionFactor, meshes);
break;
case Face face:
AddFaceMesh(face, PrecisionFor(SafeBounds(face), precisionFactor), meshes);
break;
}
}
return meshes;
}
private static void TessellateShell(Shell shell, double factor, List<MeshGeometry3D> meshes)
{
double precision = PrecisionFor(SafeBounds(shell), factor);
foreach (Face f in shell.Faces) AddFaceMesh(f, precision, meshes);
}
/// <summary>
/// 匯入預設精度(與 StepImportService.PrecisionFor 同款 clamp)再乘 factor —
/// 先 clamp 再乘,保證 factor&lt;1 時嚴格比匯入快取細(否則大殼在匯入被 0.5 上限截住,
/// 直接乘在 clamp 前會算出比快取粗的值 → CADability 回傳快取、精細無效)。
/// </summary>
private static double PrecisionFor(BoundingCube bc, double factor)
{
double diag;
try { diag = bc.DiagonalLength; }
catch { diag = 100; }
if (diag <= 0 || double.IsNaN(diag)) diag = 100;
double importDefault = Math.Clamp(diag * 0.0015, 0.02, 0.5);
return Math.Max(importDefault * factor, 0.005);
}
private static BoundingCube SafeBounds(IGeoObject g)
{
try { return g.GetBoundingCube(); }
catch { return new BoundingCube(); }
}
private static void AddFaceMesh(Face face, double precision, List<MeshGeometry3D> meshes)
{
GeoPoint[] pts; int[] ind;
try { face.GetTriangulation(precision, out pts, out _, out ind, out _); }
catch { return; }
if (pts is null || ind is null || ind.Length < 3) return;
var positions = new Point3DCollection(pts.Length);
foreach (GeoPoint p in pts) positions.Add(new Point3D(p.x, p.y, p.z));
var indices = new Int32Collection(ind.Length);
foreach (int i in ind) indices.Add(i);
var mesh = new MeshGeometry3D { Positions = positions, TriangleIndices = indices };
mesh.Freeze();
meshes.Add(mesh);
}
}
+130
View File
@@ -0,0 +1,130 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Windows.Media;
using System.Windows.Media.Media3D;
namespace STPViewer.Services;
/// <summary>
/// 直接解析 STL 三角網格(binary / ASCII)→ WPF mesh**不經過 CADability**。
/// CADability 的 ImportSTL 會為每個三角形建一個 B-rep Face(含 Edge/Vertex 拓樸),
/// 大檔(百萬三角形)需數十分鐘且吃光記憶體;STL 本就無 B-rep,直接讀三角形即可。
/// 實測 100k 三角形:CADability 約 41,000 ms,直接解析約 11 ms。
/// </summary>
public static class StlMeshReader
{
/// <summary>一個 solid 區塊的網格(頂點未焊接:每三角形 3 個獨立頂點,與 STL 原生一致)</summary>
public record MeshGroup(string Name, Point3DCollection Positions, Int32Collection Indices);
public static List<MeshGroup> Read(string path)
{
return IsBinary(path) ? ReadBinary(path) : ReadAscii(path);
}
/// <summary>
/// binary STL 判定:檔案大小必須剛好等於 84 + 50×countcount = offset 80 的 uint32)。
/// 這是最可靠的判別法 — binary 的 80-byte header 常也以 "solid" 開頭,不能只看開頭字串。
/// </summary>
private static bool IsBinary(string path)
{
var fi = new FileInfo(path);
if (fi.Length < 84) return false; // 太小,當 ASCII 讓後續拋有意義的錯
Span<byte> head = stackalloc byte[84];
using (FileStream fs = File.OpenRead(path))
{
int read = 0;
while (read < 84)
{
int n = fs.Read(head[read..]);
if (n <= 0) break;
read += n;
}
if (read < 84) return false;
}
uint count = BitConverter.ToUInt32(head[80..]);
return fi.Length == 84L + 50L * count;
}
private static List<MeshGroup> ReadBinary(string path)
{
byte[] bytes = File.ReadAllBytes(path);
uint count = BitConverter.ToUInt32(bytes, 80);
var positions = new Point3DCollection((int)Math.Min(count * 3, int.MaxValue));
var indices = new Int32Collection((int)Math.Min(count * 3, int.MaxValue));
var span = bytes.AsSpan();
int off = 84, vi = 0;
for (uint t = 0; t < count; t++)
{
if (off + 50 > bytes.Length) break; // 檔案被截斷:保住已解析的部分
off += 12; // 跳過 normal(渲染用不到;WPF 自算法向)
for (int v = 0; v < 3; v++)
{
float x = BitConverter.ToSingle(span[off..]);
float y = BitConverter.ToSingle(span[(off + 4)..]);
float z = BitConverter.ToSingle(span[(off + 8)..]);
positions.Add(new Point3D(x, y, z));
indices.Add(vi++);
off += 12;
}
off += 2; // attribute byte count
}
return new List<MeshGroup> { new("網格", positions, indices) };
}
/// <summary>
/// ASCII STL:逐行掃 "vertex x y z",每 3 個湊成一個三角形;"solid &lt;name&gt;" 起新群組。
/// ASCII 大檔罕見(同樣三角數約為 binary 的 5 倍體積),故以正確性為主。
/// </summary>
private static List<MeshGroup> ReadAscii(string path)
{
var groups = new List<MeshGroup>();
Point3DCollection? pos = null;
Int32Collection? idx = null;
int vi = 0;
void FlushGroup()
{
if (pos is { Count: > 0 } && idx is not null)
groups.Add(new MeshGroup(groups.Count == 0 ? "網格" : $"網格 {groups.Count + 1}", pos, idx));
pos = null; idx = null; vi = 0;
}
char[] sep = { ' ', '\t' };
foreach (string raw in File.ReadLines(path))
{
string line = raw.Trim();
if (line.Length == 0) continue;
if (line.StartsWith("solid", StringComparison.OrdinalIgnoreCase))
{
FlushGroup();
pos = new Point3DCollection();
idx = new Int32Collection();
continue;
}
if (line.StartsWith("endsolid", StringComparison.OrdinalIgnoreCase))
{
FlushGroup();
continue;
}
if (!line.StartsWith("vertex", StringComparison.OrdinalIgnoreCase)) continue;
pos ??= new Point3DCollection();
idx ??= new Int32Collection();
string[] tok = line.Split(sep, StringSplitOptions.RemoveEmptyEntries);
if (tok.Length < 4) continue;
if (double.TryParse(tok[1], NumberStyles.Float, CultureInfo.InvariantCulture, out double x) &&
double.TryParse(tok[2], NumberStyles.Float, CultureInfo.InvariantCulture, out double y) &&
double.TryParse(tok[3], NumberStyles.Float, CultureInfo.InvariantCulture, out double z))
{
pos.Add(new Point3D(x, y, z));
idx.Add(vi++);
}
}
FlushGroup();
return groups;
}
}
+86
View File
@@ -0,0 +1,86 @@
<Window x:Class="STPViewer.StlExportDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:STPViewer.ViewModels"
Title="匯出 STL — 參數設定"
Width="440" SizeToContent="Height"
WindowStartupLocation="CenterOwner"
ResizeMode="NoResize" ShowInTaskbar="False"
d:DataContext="{d:DesignInstance Type=vm:StlExportViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Window.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVis" />
</Window.Resources>
<Grid Margin="16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.Resources>
<Style TargetType="TextBlock">
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Margin" Value="0,0,12,10" />
</Style>
<Style TargetType="ComboBox">
<Setter Property="Margin" Value="0,0,0,10" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
</Grid.Resources>
<TextBlock Grid.Row="0" Grid.Column="0" Text="匯出範圍" />
<ComboBox Grid.Row="0" Grid.Column="1" SelectedIndex="{Binding ScopeIndex}"
ToolTip="STL 無裝配結構;合併 = 所有可見零件進同一檔">
<ComboBoxItem Content="全部可見檔案 → 合併成單一 STL" />
<ComboBoxItem Content="每個可見檔案 → 各一個 STL" />
</ComboBox>
<TextBlock Grid.Row="1" Grid.Column="0" Text="格式" />
<ComboBox Grid.Row="1" Grid.Column="1" SelectedIndex="{Binding FormatIndex}">
<ComboBoxItem Content="Binary(建議,檔案小)" />
<ComboBoxItem Content="ASCII(文字可讀,檔案約 4 倍大)" />
</ComboBox>
<TextBlock Grid.Row="2" Grid.Column="0" Text="單位縮放" />
<ComboBox Grid.Row="2" Grid.Column="1" SelectedIndex="{Binding UnitIndex}"
ToolTip="STL 檔本身無單位;內部幾何為 mm,選 inch 會把座標 ÷25.4">
<ComboBoxItem Content="mm1:1,預設)" />
<ComboBoxItem Content="inch(座標 ÷ 25.4" />
<ComboBoxItem Content="自訂縮放係數…" />
</ComboBox>
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding CustomScaleText, UpdateSourceTrigger=PropertyChanged}"
Margin="0,0,0,10" Padding="4,2"
Visibility="{Binding IsCustomScale, Converter={StaticResource BoolToVis}}"
ToolTip="座標乘上此係數(例:0.001 = mm→m" />
<TextBlock Grid.Row="4" Grid.Column="0" Text="網格精度" />
<ComboBox Grid.Row="4" Grid.Column="1" SelectedIndex="{Binding QualityIndex}"
IsEnabled="{Binding HasBrep}"
ToolTip="精細會由 B-rep 重新三角化(僅 STEP 來源;STL/DXF 維持現有網格),大組件需要時間">
<ComboBoxItem Content="目前顯示網格(快,與畫面一致)" />
<ComboBoxItem Content="精細 — 重新三角化,曲面更平滑(慢、檔案大)" />
</ComboBox>
<Border Grid.Row="5" Grid.ColumnSpan="2" Background="#F0F4F8" CornerRadius="4"
Padding="10,8" Margin="0,2,0,12">
<TextBlock Text="{Binding Summary}" TextWrapping="Wrap" Margin="0" FontSize="12" />
</Border>
<StackPanel Grid.Row="6" Grid.ColumnSpan="2" Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="取消" Width="80" Margin="0,0,8,0" IsCancel="True" />
<Button Content="匯出…" Width="96" IsDefault="True"
IsEnabled="{Binding CanConfirm}" Click="Confirm_Click" />
</StackPanel>
</Grid>
</Window>
+22
View File
@@ -0,0 +1,22 @@
using System.Windows;
using STPViewer.ViewModels;
namespace STPViewer;
/// <summary>STL 匯出參數對話框 — 選項邏輯在 StlExportViewModel,這裡只做確認/取消</summary>
public partial class StlExportDialog : Window
{
public StlExportViewModel ViewModel { get; }
public StlExportDialog(StlExportViewModel vm)
{
InitializeComponent();
ViewModel = vm;
DataContext = vm;
}
private void Confirm_Click(object sender, RoutedEventArgs e)
{
DialogResult = true;
}
}
@@ -0,0 +1,90 @@
using System.Windows;
using System.Windows.Media.Media3D;
using HelixToolkit.Wpf;
using STPViewer.Models;
namespace STPViewer.ViewModels;
// ─── 拖曳模式(左鍵按住零件沿螢幕平面拖;放開才烘進 B-rep)──────────
public partial class MainViewModel
{
// 拖曳模式:暫時 Transform 跟著滑鼠,放開才一次性烘進 B-repTranslateRoot
private ModelNodeViewModel? _dragRoot;
private Point3D _dragAnchor; // 命中點(世界座標),拖曳平面的錨點
private Vector3D _dragApplied; // 目前累計位移
private TranslateTransform3D? _dragTransform;
/// <summary>左鍵按下:命中零件則開始拖曳(回傳 true 表示要捕捉滑鼠)</summary>
public bool OnDragStart(Point position)
{
if (_viewport is null || IsBusy || CurrentMode != MeasureMode.Drag || _dragRoot is not null)
return false;
// 兩種渲染內容都可能在場:合併網格查 _mergedMap、逐面(剖面時)查 _faceMap
ModelNodeViewModel? leaf = null;
Point3D anchor = default;
foreach (var h in _viewport.Viewport.FindHits(position))
{
if (h.Model is null) continue;
if (_faceMap.TryGetValue(h.Model, out FaceInfo? fi)) { leaf = fi.Owner; anchor = h.Position; break; }
if (_mergedMap.TryGetValue(h.Model, out ModelNodeViewModel? ml)) { leaf = ml; anchor = h.Position; break; }
}
if (leaf is null) return false;
ModelNodeViewModel? root = FindRootOf(leaf) ?? RootContaining(leaf);
if (root is null) return false;
_dragRoot = root;
_dragAnchor = anchor;
_dragApplied = default;
_dragTransform = new TranslateTransform3D();
foreach (ModelNodeViewModel l in root.Leaves())
if (l.BodyVisual is not null)
l.BodyVisual.Transform = _dragTransform;
// 拖曳中隱藏邊線(LinesVisual3D 隨 Transform 變更逐幀重建會卡)
_edgesSuspended = true;
SetEdgesActive(false);
StatusText = $"拖曳「{root.Name}」中…(放開定位)";
return true;
}
/// <summary>滑鼠移動:把 2D 位移投影到「過錨點、面向相機」的平面上 → 暫時 Transform</summary>
public void OnDragMove(Point position)
{
if (_viewport?.Camera is not System.Windows.Media.Media3D.ProjectionCamera cam ||
_dragRoot is null || _dragTransform is null) return;
Point3D? p = _viewport.Viewport.UnProject(position, _dragAnchor, cam.LookDirection);
if (p is null) return;
_dragApplied = p.Value - _dragAnchor;
_dragTransform.OffsetX = _dragApplied.X;
_dragTransform.OffsetY = _dragApplied.Y;
_dragTransform.OffsetZ = _dragApplied.Z;
}
/// <summary>放開:移除暫時 Transform,一次性烘進 B-rep(量測精度不受拖曳影響)</summary>
public bool OnDragEnd()
{
if (_dragRoot is null) return false;
ModelNodeViewModel root = _dragRoot;
Vector3D delta = _dragApplied;
// 清除暫時位移:必須用 Identity,不可用 null —— HelixToolkit GetTransform 對 child.Transform
// 不做 null 檢查(Children.Add(null) 會拋「無法新增空值到集合中」),下次 FindHits 即 crash
foreach (ModelNodeViewModel l in root.Leaves())
if (l.BodyVisual is not null)
l.BodyVisual.Transform = Transform3D.Identity;
_dragRoot = null;
_dragTransform = null;
_dragApplied = default;
_edgesSuspended = false;
SetEdgesActive(true); // 恢復所有檔案的邊線(被拖檔案的點隨後由 TranslateRoot 更新,期間不會渲染)
if (delta.LengthSquared > 1e-12)
{
TranslateRoot(root, delta); // 烘進 B-rep + 網格 + 邊線 + 邊界(量測清空)
StatusText = $"已拖曳「{root.Name}」 Δ({delta.X:F2}, {delta.Y:F2}, {delta.Z:F2}) mm(可用 🧩干涉 驗證)";
}
return true;
}
}
@@ -0,0 +1,241 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Media.Media3D;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Win32;
using STPViewer.Models;
using STPViewer.Services;
namespace STPViewer.ViewModels;
// ─── 匯出(量測 CSV / 視圖 PNG 截圖 / STEP / STL)────────────────────
public partial class MainViewModel
{
[RelayCommand]
private void ExportCsv()
{
if (Measurements.Count == 0)
{
StatusText = "沒有量測結果可匯出";
return;
}
var dlg = new SaveFileDialog
{
Title = "匯出量測結果",
Filter = "CSV 檔案 (*.csv)|*.csv",
FileName = $"measurements_{DateTime.Now:yyyyMMdd_HHmmss}.csv",
};
if (dlg.ShowDialog() != true) return;
var sb = new StringBuilder();
sb.AppendLine("標籤,類型,明細");
foreach (MeasurementResult m in Measurements)
{
static string Esc(string s) => $"\"{s.Replace("\"", "\"\"")}\"";
sb.AppendLine($"{Esc(m.Title)},{m.Kind},{Esc(m.Detail.Replace("\n", " | "))}");
}
File.WriteAllText(dlg.FileName, sb.ToString(), new UTF8Encoding(true)); // BOMExcel 中文不亂碼
StatusText = $"已匯出 {Measurements.Count} 筆量測 → {Path.GetFileName(dlg.FileName)}";
}
/// <summary>
/// 把可見檔案(目前位置,含對齊/旋轉/拖曳結果)匯出成「新的」STEP 檔 —
/// 對齊插合後的配合結果可交接給 CAD;不動任何原始檔(唯讀原則不變)。
/// 只含 B-rep Solid/ShellSTL 網格、DXF 線架構無法進 STEP B-rep)。
/// </summary>
[RelayCommand(CanExecute = nameof(IsIdle))]
private void ExportStepFile()
{
var geos = Roots.Where(r => r.IsVisible)
.SelectMany(r => r.Leaves()).Where(l => l.IsVisible)
.SelectMany(l => l.SourceGeos)
.Where(g => g is CADability.GeoObject.Solid or CADability.GeoObject.Shell)
.ToList();
if (geos.Count == 0)
{
StatusText = "沒有可匯出的 B-rep 幾何(STEP 匯出僅含 Solid/Shell;請勾選要匯出的 STEP 檔案)";
return;
}
var dlg = new SaveFileDialog
{
Title = "匯出目前場景為 STEP(零件在目前對齊位置)",
Filter = "STEP 檔案 (*.stp)|*.stp|STEP 檔案 (*.step)|*.step",
FileName = $"assembly_{DateTime.Now:yyyyMMdd_HHmmss}.stp",
};
if (dlg.ShowDialog() != true) return;
try
{
// B-rep 已含目前位置(TransformRoot 對 Solid/Shell 整體 Modify)→ 直接寫出
CADability.Project project = CADability.Project.CreateSimpleProject();
CADability.Model model = project.GetActiveModel();
foreach (CADability.GeoObject.IGeoObject g in geos) model.Add(g);
new CADability.ExportStep().WriteToFile(dlg.FileName, project);
StatusText = $"已匯出 STEP{geos.Count} 個實體,目前位置)→ {Path.GetFileName(dlg.FileName)}";
}
catch (Exception ex)
{
StatusText = $"STEP 匯出失敗:{ex.Message}";
}
}
/// <summary>
/// 匯出可見檔案(目前位置)為 STL 網格檔 — 先開參數對話框(範圍/格式/單位/精度 + 三角形數與大小預估)
/// 確認後才選路徑寫檔。精細/較粗 = 由 B-rep 重新三角化(背景執行緒);STL/DXF 來源一律用現有網格。
/// </summary>
[RelayCommand(CanExecute = nameof(IsIdle))]
private async Task ExportStlFile()
{
// 可匯出 = 可見 leaf 且有網格(DXF 純線架構無網格 → 排除)
var groups = Roots.Where(r => r.IsVisible)
.Select(r => (Root: r, Leaves: r.Leaves()
.Where(l => l.IsVisible && GetMergedMesh(l) is not null).ToList()))
.Where(g => g.Leaves.Count > 0)
.ToList();
if (groups.Count == 0)
{
StatusText = "沒有可匯出的網格(請勾選至少一個含實體網格的檔案)";
return;
}
long triangles = groups.SelectMany(g => g.Leaves)
.Sum(l => GetMergedMesh(l)!.TriangleIndices.Count / 3L);
bool hasBrep = groups.SelectMany(g => g.Leaves).Any(l => l.HasBrep && l.SourceGeos.Count > 0);
// 參數對話框(確認再匯出)
var vm = new StlExportViewModel(groups.Count, triangles, hasBrep);
var dialog = new StlExportDialog(vm) { Owner = Application.Current.MainWindow };
if (dialog.ShowDialog() != true || vm.Scale is not double scale) return;
var dlg = new SaveFileDialog
{
Title = vm.PerFile ? "匯出 STL(每個檔案各一個,以此為基底檔名)" : "匯出 STL(合併單一檔)",
Filter = "STL 檔案 (*.stl)|*.stl",
FileName = vm.PerFile
? $"stl_{DateTime.Now:yyyyMMdd_HHmmss}.stl"
: $"assembly_{DateTime.Now:yyyyMMdd_HHmmss}.stl",
};
if (dlg.ShowDialog() != true) return;
// UI 執行緒先快照:GeometryModel3D/Model3DGroup 是 DispatcherObject 不可跨執行緒,
// 背景只能碰 frozen MeshGeometry3D 與 CADability B-repSourceGeos
var snapshot = groups
.Select(g => (g.Root.Name, Leaves: g.Leaves
.Select(l => (Mesh: GetMergedMesh(l)!, l.HasBrep, l.SourceGeos))
.ToList()))
.ToList();
IsBusy = true;
StatusText = vm.Quality == StlMeshQuality.Current
? "匯出 STL 中…"
: "重新三角化 + 匯出 STL 中…(大組件需要時間)";
try
{
double factor = StlExportService.PrecisionFactor(vm.Quality);
var written = await Task.Run(() =>
{
var results = new List<(string File, int Triangles)>();
if (vm.PerFile)
{
string dir = Path.GetDirectoryName(dlg.FileName)!;
string baseName = Path.GetFileNameWithoutExtension(dlg.FileName);
foreach ((string rootName, var leaves) in snapshot)
{
string file = Path.Combine(dir, $"{baseName}_{SafeFileName(rootName)}.stl");
int n = StlExportService.Write(file, CollectMeshes(leaves, vm.Quality, factor), vm.Ascii, scale);
results.Add((file, n));
}
}
else
{
var meshes = CollectMeshes(snapshot.SelectMany(g => g.Leaves).ToList(), vm.Quality, factor);
int n = StlExportService.Write(dlg.FileName, meshes, vm.Ascii, scale);
results.Add((dlg.FileName, n));
}
return results;
});
long total = written.Sum(w => (long)w.Triangles);
StatusText = written.Count == 1
? $"已匯出 STL{total:N0} 三角形)→ {Path.GetFileName(written[0].File)}"
: $"已匯出 {written.Count} 個 STL(共 {total:N0} 三角形)→ {Path.GetDirectoryName(dlg.FileName)}";
}
catch (Exception ex)
{
StatusText = $"STL 匯出失敗:{ex.Message}";
}
finally
{
IsBusy = false;
}
}
/// <summary>leaf 的合併網格(frozen、未剖切、目前位置)— STL 匯出的「目前顯示網格」來源</summary>
private static MeshGeometry3D? GetMergedMesh(ModelNodeViewModel leaf) =>
leaf.MergedContent?.Children.Count > 0
? (leaf.MergedContent.Children[0] as GeometryModel3D)?.Geometry as MeshGeometry3D
: null;
/// <summary>
/// 收集要寫進 STL 的網格(背景執行緒;輸入是 UI 快照):
/// 精細且有 B-rep → 重新三角化(循序,CADability 非執行緒安全);
/// 其餘(目前網格 / STL 來源)→ 快照的合併網格(frozen,可安全跨執行緒讀取)。
/// </summary>
private static List<MeshGeometry3D> CollectMeshes(
IReadOnlyList<(MeshGeometry3D Mesh, bool HasBrep, List<CADability.GeoObject.IGeoObject> SourceGeos)> leaves,
StlMeshQuality quality, double precisionFactor)
{
var meshes = new List<MeshGeometry3D>();
foreach ((MeshGeometry3D mesh, bool hasBrep, var sourceGeos) in leaves)
{
if (quality != StlMeshQuality.Current && hasBrep && sourceGeos.Count > 0)
{
List<MeshGeometry3D> tess = StlExportService.Tessellate(sourceGeos, precisionFactor);
if (tess.Count > 0) { meshes.AddRange(tess); continue; }
// 重新三角化全數失敗 → 退回現有網格,不讓零件憑空消失
}
meshes.Add(mesh);
}
return meshes;
}
private static string SafeFileName(string name)
{
string stem = Path.GetFileNameWithoutExtension(name);
if (string.IsNullOrWhiteSpace(stem)) stem = name;
foreach (char c in Path.GetInvalidFileNameChars()) stem = stem.Replace(c, '_');
return stem;
}
[RelayCommand]
private void SaveScreenshot()
{
if (_viewport is null || _viewport.ActualWidth < 1) return;
var dlg = new SaveFileDialog
{
Title = "儲存視圖截圖",
Filter = "PNG 圖片 (*.png)|*.png",
FileName = $"stpviewer_{DateTime.Now:yyyyMMdd_HHmmss}.png",
};
if (dlg.ShowDialog() != true) return;
const double scale = 2.0; // 2x 解析度
var rtb = new RenderTargetBitmap(
(int)(_viewport.ActualWidth * scale), (int)(_viewport.ActualHeight * scale),
96 * scale, 96 * scale, PixelFormats.Pbgra32);
rtb.Render(_viewport);
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(rtb));
using FileStream fs = File.Create(dlg.FileName);
encoder.Save(fs);
StatusText = $"已儲存截圖 → {Path.GetFileName(dlg.FileName)}";
}
}
@@ -0,0 +1,216 @@
using System;
using System.Collections.Generic;
using System.Windows.Media;
using System.Windows.Media.Media3D;
using CommunityToolkit.Mvvm.ComponentModel;
using HelixToolkit.Wpf;
namespace STPViewer.ViewModels;
// ─── Gizmo 三軸操作器(XYZ 箭頭 + 旋轉環;每次放開滑鼠烘進 B-rep)──────
public partial class MainViewModel
{
[ObservableProperty]
private bool gizmoEnabled;
private readonly List<HelixToolkit.Wpf.Manipulator> _gizmoParts = new();
private ModelVisual3D? _gizmoProxy; // 操作器綁定的代理 visual(其 Transform = 使用者拖出的變換)
private ModelNodeViewModel? _gizmoTarget;
private bool _gizmoDragActive; // 拖動中(邊線已暫停)
private bool _gizmoBaking; // 烘焙中,忽略 Transform 變更回呼
private bool _gizmoBakePending; // 已排程烘焙,防同一次放開重複觸發
// 操作器疊圖層:另一個透明 Viewport3D 疊在主視窗上、相機同步、只放操作器 →
// 操作器不在主場景,永不被實體遮擋(always-on-top);空白處滑鼠穿透回主視窗
private System.Windows.Controls.Viewport3D? _overlayViewport;
private ProjectionCamera? _overlayCamera; // 型別跟隨主相機(透視/正交切換,v0.5.0)
/// <summary>注入操作器疊圖層(透明 Viewport3D,疊在主視窗上、永遠最上層)</summary>
public void AttachOverlay(System.Windows.Controls.Viewport3D overlay)
{
_overlayViewport = overlay;
_overlayCamera = new PerspectiveCamera();
overlay.Camera = _overlayCamera;
// 操作器材質需打光(manipulator 用 DiffuseMaterial
overlay.Children.Add(new ModelVisual3D { Content = new AmbientLight(Color.FromRgb(0x80, 0x80, 0x80)) });
overlay.Children.Add(new ModelVisual3D { Content = new DirectionalLight(Colors.White, new Vector3D(-1, -1, -3)) });
SyncOverlayCamera();
}
/// <summary>
/// 疊圖層相機跟隨主相機(每次主相機變更時呼叫,讓操作器疊在正確螢幕位置)。
/// 主相機可能在透視/正交間切換(Orthographic 屬性)→ 疊圖層相機「型別」也要跟著換,投影才一致。
/// </summary>
private void SyncOverlayCamera()
{
if (_overlayViewport is null || _viewport?.Camera is not ProjectionCamera src) return;
if (src is PerspectiveCamera p)
{
if (_overlayCamera is not PerspectiveCamera op)
{
op = new PerspectiveCamera();
_overlayCamera = op;
_overlayViewport.Camera = op;
}
op.FieldOfView = p.FieldOfView;
}
else if (src is OrthographicCamera o)
{
if (_overlayCamera is not OrthographicCamera oo)
{
oo = new OrthographicCamera();
_overlayCamera = oo;
_overlayViewport.Camera = oo;
}
oo.Width = o.Width;
}
if (_overlayCamera is null) return;
_overlayCamera.Position = src.Position;
_overlayCamera.LookDirection = src.LookDirection;
_overlayCamera.UpDirection = src.UpDirection;
_overlayCamera.NearPlaneDistance = src.NearPlaneDistance;
_overlayCamera.FarPlaneDistance = src.FarPlaneDistance;
}
partial void OnGizmoEnabledChanged(bool value) => UpdateGizmo();
partial void OnSelectedNodeChanged(ModelNodeViewModel? value)
{
if (GizmoEnabled) UpdateGizmo(); // 換選取 → 操作器跟著換目標
}
private void UpdateGizmo()
{
RemoveGizmo();
if (!GizmoEnabled || _viewport is null || _overlayViewport is null) return;
ModelNodeViewModel? root = SelectedNode is not null ? RootContaining(SelectedNode)
: Roots.Count == 1 ? Roots[0] : null;
if (root is null || root.Bounds.IsEmpty)
{
StatusText = "操作器:請先在樹面板點選要操作的檔案";
GizmoEnabled = false;
return;
}
_gizmoTarget = root;
SyncOverlayCamera(); // 操作器出現前先對齊相機
Rect3D b = root.Bounds;
var center = new Point3D(b.X + b.SizeX / 2, b.Y + b.SizeY / 2, b.Z + b.SizeZ / 2);
double diag = new Vector3D(b.SizeX, b.SizeY, b.SizeZ).Length;
_gizmoProxy = new ModelVisual3D();
_overlayViewport.Children.Add(_gizmoProxy);
System.ComponentModel.DependencyPropertyDescriptor
.FromProperty(Visual3D.TransformProperty, typeof(Visual3D))
.AddValueChanged(_gizmoProxy, GizmoTransformChanged);
void AddPart(HelixToolkit.Wpf.Manipulator man)
{
man.Position = center;
man.Bind(_gizmoProxy);
_gizmoParts.Add(man);
_overlayViewport!.Children.Add(man); // 放疊圖層 → 永不被實體遮擋
}
// 平移箭頭(X 紅 / Y 綠 / Z 藍 — 業界慣例)
AddPart(new TranslateManipulator { Direction = new Vector3D(1, 0, 0), Color = Colors.Red, Length = diag * 0.22, Diameter = diag * 0.016 });
AddPart(new TranslateManipulator { Direction = new Vector3D(0, 1, 0), Color = Colors.Green, Length = diag * 0.22, Diameter = diag * 0.016 });
AddPart(new TranslateManipulator { Direction = new Vector3D(0, 0, 1), Color = Colors.Blue, Length = diag * 0.22, Diameter = diag * 0.016 });
// 旋轉環
AddPart(new RotateManipulator { Axis = new Vector3D(1, 0, 0), Color = Colors.Red, Diameter = diag * 0.34, InnerDiameter = diag * 0.30, Length = diag * 0.012 });
AddPart(new RotateManipulator { Axis = new Vector3D(0, 1, 0), Color = Colors.Green, Diameter = diag * 0.34, InnerDiameter = diag * 0.30, Length = diag * 0.012 });
AddPart(new RotateManipulator { Axis = new Vector3D(0, 0, 1), Color = Colors.Blue, Diameter = diag * 0.34, InnerDiameter = diag * 0.30, Length = diag * 0.012 });
StatusText = $"操作器:拖箭頭沿軸移動「{root.Name}」、拖環繞軸旋轉;放開即定位";
}
private void RemoveGizmo()
{
if (_gizmoProxy is not null)
{
System.ComponentModel.DependencyPropertyDescriptor
.FromProperty(Visual3D.TransformProperty, typeof(Visual3D))
.RemoveValueChanged(_gizmoProxy, GizmoTransformChanged);
_overlayViewport?.Children.Remove(_gizmoProxy);
}
foreach (HelixToolkit.Wpf.Manipulator man in _gizmoParts)
{
man.UnBind();
_overlayViewport?.Children.Remove(man);
}
_gizmoParts.Clear();
_gizmoProxy = null;
if (_gizmoTarget is not null) // 清掉殘留的暫時 Transform(用 Identity,不可 null → 否則 FindHits crash
foreach (ModelNodeViewModel l in _gizmoTarget.Leaves())
if (l.BodyVisual is not null)
l.BodyVisual.Transform = Transform3D.Identity;
_gizmoTarget = null;
if (_gizmoDragActive)
{
_gizmoDragActive = false;
_edgesSuspended = false;
SetEdgesActive(true);
}
}
/// <summary>操作器拖動中:把代理的 Transform 套到目標檔案的所有 BodyVisual(暫時、GPU 端)</summary>
private void GizmoTransformChanged(object? sender, EventArgs e)
{
if (_gizmoBaking || _gizmoTarget is null || _gizmoProxy is null) return;
Transform3D t = _gizmoProxy.Transform;
if (!_gizmoDragActive && t is not null && !t.Value.IsIdentity)
{
_gizmoDragActive = true;
_edgesSuspended = true;
SetEdgesActive(false); // 邊線逐幀重建會卡,拖動中暫停
}
foreach (ModelNodeViewModel l in _gizmoTarget.Leaves())
if (l.BodyVisual is not null)
l.BodyVisual.Transform = t;
}
/// <summary>滑鼠放開(MainWindow 轉發,handledEventsToo):把累積變換烘進 B-rep 並重置操作器</summary>
public void OnGizmoMouseUp()
{
if (_gizmoTarget is null || _gizmoProxy is null || _gizmoBakePending) return;
Matrix3D m = _gizmoProxy.Transform?.Value ?? Matrix3D.Identity;
if (m.IsIdentity) return; // 只是點一下、沒拖操作器 → 不烘焙
// 延後到 manipulator 自身的 mouse-up 處理(釋放捕捉等)完成後再烘焙,避免在其事件中改動視覺樹造成 reentrancy
_gizmoBakePending = true;
_viewport?.Dispatcher.BeginInvoke(new Action(() => BakeGizmo(m)),
System.Windows.Threading.DispatcherPriority.Background);
}
private void BakeGizmo(Matrix3D m)
{
_gizmoBakePending = false;
if (_gizmoTarget is null) return;
ModelNodeViewModel root = _gizmoTarget;
_gizmoBaking = true; // 防 proxy 歸零觸發 GizmoTransformChanged 重入
foreach (ModelNodeViewModel l in root.Leaves())
if (l.BodyVisual is not null)
l.BodyVisual.Transform = Transform3D.Identity; // 清暫時位移(用 Identity,不可 null
if (_gizmoProxy is not null) _gizmoProxy.Transform = Transform3D.Identity; // 綁定 → 操作器歸零
_gizmoBaking = false;
_gizmoDragActive = false;
_edgesSuspended = false;
SetEdgesActive(true);
TransformRoot(root, Services.RigidAlign.ToModOp(m), m); // 烘進 B-rep(量測精度不受影響)
// 操作器移到變換後的新中心
Rect3D b = root.Bounds;
if (!b.IsEmpty)
{
var center = new Point3D(b.X + b.SizeX / 2, b.Y + b.SizeY / 2, b.Z + b.SizeZ / 2);
foreach (HelixToolkit.Wpf.Manipulator man in _gizmoParts)
man.Position = center;
}
StatusText = $"已套用操作器變換「{root.Name}」(可用 🧩干涉 驗證)";
}
}
@@ -0,0 +1,136 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Media3D;
using CommunityToolkit.Mvvm.Input;
using HelixToolkit.Wpf;
using STPViewer.Models;
using STPViewer.Services;
namespace STPViewer.ViewModels;
// ─── 干涉檢查 ────────────────────────────────────────────────────────
public partial class MainViewModel
{
[RelayCommand(CanExecute = nameof(IsIdle))]
private async Task CheckInterferenceAsync()
{
if (_viewport is null) return;
var visibleRoots = Roots.Where(r => r.IsVisible).ToList();
if (visibleRoots.Count < 2)
{
StatusText = $"干涉檢查需要至少 2 個可見檔案(目前 {visibleRoots.Count} 個)— 用樹面板勾選";
return;
}
List<MeshGeometry3D> MeshesOf(ModelNodeViewModel root) =>
root.Leaves().Where(l => l.IsVisible && l.FacesContent is not null)
.SelectMany(l => l.FacesContent!.Children)
.Where(m => m is GeometryModel3D gm && _faceMap.ContainsKey(gm))
.Select(m => _faceMap[(GeometryModel3D)m].Mesh)
.ToList();
// 網格快照(開跑後零件被改走也不會半途換料;v0.4.0 起運算期間指令已鎖,這裡是雙保險)
var meshes = visibleRoots.ToDictionary(r => r, MeshesOf);
IsBusy = true;
try
{
int pairs = 0, intersecting = 0;
for (int i = 0; i < visibleRoots.Count; i++)
for (int j = i + 1; j < visibleRoots.Count; j++)
{
pairs++;
string nameA = visibleRoots[i].Name, nameB = visibleRoots[j].Name;
StatusText = $"干涉檢查中(第 {pairs} 組):{nameA} ⟷ {nameB} …";
var a = meshes[visibleRoots[i]];
var b = meshes[visibleRoots[j]];
InterferenceResult result = await Task.Run(() => InterferenceService.Check(a, b));
AddInterferenceResult(result, nameA, nameB);
if (result.Intersects) intersecting++;
}
if (pairs > 1)
StatusText = $"干涉檢查完成:{pairs} 組配對、{intersecting} 組相交";
}
catch (Exception ex)
{
StatusText = $"干涉檢查失敗:{ex.Message}";
}
finally
{
IsBusy = false;
}
}
private void AddInterferenceResult(InterferenceResult result, string nameA, string nameB)
{
double diag = SceneDiagonal();
double markerR = Math.Clamp(diag * 0.004, 0.01, 5.0);
MeasurementResult m;
if (result.Intersects)
{
int pairs = result.PairCount;
int segCount = result.Segments.Count;
m = new MeasurementResult
{
Kind = MeasureMode.Interference,
TitleFor = _ => $"🧩 干涉!{nameA} ⟷ {nameB}",
DetailFor = _ => $"兩零件相交(不 match\n相交三角形對 = {pairs:N0}\n" +
$"紅色線 = 干涉交線({segCount:N0} 段)\n(共面貼合不會被算為干涉)",
};
var pts = new Point3DCollection(Math.Min(result.Segments.Count, 20000) * 2);
foreach ((Point3D s0, Point3D s1) in result.Segments) { pts.Add(s0); pts.Add(s1); }
m.Overlays.Add(new LinesVisual3D { Points = pts, Color = Colors.Red, Thickness = 3 });
if (result.Segments.Count > 0)
{
Point3D at = result.Segments[0].A;
m.Overlays.Add(new BillboardTextVisual3D
{
Position = at + new Vector3D(0, 0, markerR * 3),
Text = "干涉",
Foreground = Brushes.White,
Background = Brushes.Red,
Padding = new Thickness(5, 2, 5, 2),
FontSize = 14,
});
}
}
else
{
Point3D ga = result.GapA, gb = result.GapB;
Vector3D d = gb - ga;
double gap = result.GapDistance;
m = new MeasurementResult
{
Kind = MeasureMode.Interference,
TitleFor = u => $"🧩 無干涉 gap ≈ {Units.L(gap, u)}",
DetailFor = u => $"{nameA} ⟷ {nameB} 無相交\n最小間隙 ≈ {Units.L(gap, u)}(網格近似)\n" +
$"gap ≈ 0 即為貼合(match\n點 1 {Units.P(ga, u)}\n點 2 {Units.P(gb, u)}",
};
m.Overlays.Add(new SphereVisual3D { Center = ga, Radius = markerR, Fill = Brushes.OrangeRed });
m.Overlays.Add(new SphereVisual3D { Center = gb, Radius = markerR, Fill = Brushes.OrangeRed });
m.Overlays.Add(new LinesVisual3D
{
Points = new Point3DCollection { ga, gb },
Color = Colors.OrangeRed,
Thickness = 2,
});
var label = new BillboardTextVisual3D
{
Position = ga + d / 2 + new Vector3D(0, 0, markerR * 2),
Text = $"gap {Units.L(gap, UnitSystem.Millimeter)}",
Foreground = Brushes.Black,
Background = new SolidColorBrush(Color.FromArgb(200, 255, 255, 210)),
Padding = new Thickness(4, 2, 4, 2),
FontSize = 14,
};
m.Overlays.Add(label);
m.DynamicLabels.Add((label, u => $"gap {Units.L(gap, u)}"));
}
AddMeasurement(m);
}
}
@@ -0,0 +1,306 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Windows.Media;
using System.Windows.Media.Media3D;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using HelixToolkit.Wpf;
using STPViewer.Models;
using STPViewer.Services;
namespace STPViewer.ViewModels;
// ─── 剖面(CPU 網格裁切;v0.4.0 起裁切在背景執行緒平行計算,UI 不凍結)──
public partial class MainViewModel
{
private readonly DispatcherTimer _sectionTimer;
private RectangleVisual3D? _sectionPlaneVisual;
private Point3D _sectionPlanePoint; // SectionEnabled 時的剖切平面(合併邊線重建用)
private Vector3D _sectionNormal;
// 背景裁切 guard:進行中又有新變更(拉滑桿/換軸/零件平移)→ 完成後用最新參數重跑一輪。
// 不要改回同步呼叫 ClipMesh —— 大檔(64k 面)整場景裁切會凍結 UI 數秒
private bool _sectionApplying;
private bool _sectionReapply;
[ObservableProperty]
private bool sectionEnabled;
/// <summary>0=X 1=Y 2=Z</summary>
[ObservableProperty]
private int sectionAxisIndex;
/// <summary>剖面位置(沿軸 0~100%</summary>
[ObservableProperty]
private double sectionPosition = 50;
[ObservableProperty]
private bool sectionFlip;
/// <summary>剖面參數列的「✕ 關閉」按鈕(參數列只在剖面開啟時顯示)</summary>
[RelayCommand]
private void CloseSection() => SectionEnabled = false;
// ── 3點自訂剖切平面(SectionAxisIndex == 3v0.5.0)──
private const int CustomAxisIndex = 3;
private Vector3D? _customNormal; // null = 尚未定義(等使用者點 3 點)
private readonly List<Point3D> _sectionPicks = new();
private readonly List<Visual3D> _sectionPickOverlays = new();
partial void OnSectionEnabledChanged(bool value)
{
if (!value) CancelSectionPickIfActive(quiet: true);
ScheduleSection();
}
partial void OnSectionAxisIndexChanged(int value)
{
if (value == CustomAxisIndex)
{
if (_customNormal is null)
StatusText = "3點剖面:請在模型上點 3 個點定義剖切平面(Esc 取消)";
}
else
{
// 離開自訂平面 → 丟棄舊平面(再選「3點」即重新定義)
_customNormal = null;
ClearSectionPicks();
}
ScheduleSection();
}
partial void OnSectionPositionChanged(double value)
{
if (value < 0) { SectionPosition = 0; return; } // 位置數值輸入框防呆
if (value > 100) { SectionPosition = 100; return; }
ScheduleSection();
}
partial void OnSectionFlipChanged(bool value) => ScheduleSection();
/// <summary>3點剖面定義模式中的點擊(OnViewportClick 轉入)。回傳 true = 已消費此點擊。</summary>
public bool HandleSectionPlanePick(System.Windows.Point position)
{
if (!SectionEnabled || SectionAxisIndex != CustomAxisIndex || _customNormal is not null) return false;
if (_viewport is null) return true;
Point3D? hit = null;
foreach (var h in _viewport.Viewport.FindHits(position))
{
if (h.Model is null) continue;
if (_faceMap.ContainsKey(h.Model) || _mergedFaceRanges.ContainsKey(h.Model))
{
hit = h.Position;
break;
}
}
if (hit is null)
{
StatusText = $"3點剖面:未命中模型表面(已選 {_sectionPicks.Count}/3 點,Esc 取消)";
return true;
}
if (_sectionPicks.Count == 2 &&
Vector3D.CrossProduct(_sectionPicks[1] - _sectionPicks[0], hit.Value - _sectionPicks[0]).LengthSquared < 1e-12)
{
StatusText = "3點剖面:三點共線,請改選不共線的第 3 點";
return true;
}
_sectionPicks.Add(hit.Value);
double markerR = Math.Clamp(SceneDiagonal() * 0.004, 0.01, 5.0);
var marker = new SphereVisual3D { Center = hit.Value, Radius = markerR, Fill = System.Windows.Media.Brushes.DodgerBlue };
_sectionPickOverlays.Add(marker);
_viewport.Children.Add(marker);
if (_sectionPicks.Count < 3)
{
StatusText = $"3點剖面:已選 {_sectionPicks.Count}/3 點";
return true;
}
Vector3D n = Vector3D.CrossProduct(_sectionPicks[1] - _sectionPicks[0], _sectionPicks[2] - _sectionPicks[0]);
ClearSectionPicks();
if (n.LengthSquared < 1e-12)
{
StatusText = "3點剖面:三點退化(共線),請重新點選";
return true;
}
n.Normalize();
_customNormal = n;
StatusText = $"3點剖面:平面已定義(法向 {n.X:F2}, {n.Y:F2}, {n.Z:F2})— 用位置滑桿掃剖面";
ApplySection();
return true;
}
/// <summary>取消 3 點平面定義(Esc / 關剖面)。回傳 true = 有取消動作。</summary>
private bool CancelSectionPickIfActive(bool quiet = false)
{
bool picking = SectionEnabled && SectionAxisIndex == CustomAxisIndex && _customNormal is null;
if (_sectionPicks.Count == 0 && !picking) return false;
ClearSectionPicks();
if (SectionAxisIndex == CustomAxisIndex && _customNormal is null)
SectionAxisIndex = 0; // 回 X 軸
if (!quiet) StatusText = "已取消 3 點剖面定義";
return true;
}
private void ClearSectionPicks()
{
foreach (Visual3D v in _sectionPickOverlays) _viewport?.Children.Remove(v);
_sectionPickOverlays.Clear();
_sectionPicks.Clear();
}
private void ScheduleSection()
{
_sectionTimer.Stop();
_sectionTimer.Start();
}
private async void ApplySection()
{
if (_viewport is null) return;
if (!SectionEnabled)
{
// 還原原始幾何(換回 fi.Mesh 參照,便宜、同步即可)。
// 若背景裁切還在跑,其 await 之後會看到 SectionEnabled=false 而放棄套用
foreach ((Model3D model, FaceInfo fi) in _faceMap)
if (model is GeometryModel3D gm && !ReferenceEquals(gm.Geometry, fi.Mesh))
gm.Geometry = fi.Mesh;
foreach (ModelNodeViewModel root in Roots) RefreshRootEdges(root);
if (_sectionPlaneVisual is not null)
{
_viewport.Children.Remove(_sectionPlaneVisual);
_sectionPlaneVisual = null;
}
ApplyRenderMode(); // 剖面關閉 → 瀏覽模式可切回合併網格
StatusText = "剖面已關閉";
return;
}
if (_sectionApplying) { _sectionReapply = true; return; }
_sectionApplying = true;
try
{
do
{
_sectionReapply = false;
Rect3D bounds = UnionBounds();
if (bounds.IsEmpty) return;
Vector3D axis;
if (SectionAxisIndex == CustomAxisIndex)
{
if (_customNormal is null) return; // 平面尚未定義(等使用者點 3 點)
axis = _customNormal.Value;
}
else
{
axis = SectionAxisIndex switch
{
0 => new Vector3D(1, 0, 0),
1 => new Vector3D(0, 1, 0),
_ => new Vector3D(0, 0, 1),
};
}
double t = SectionPosition / 100.0;
Point3D min = bounds.Location;
var size = new Vector3D(bounds.SizeX, bounds.SizeY, bounds.SizeZ);
Point3D center = min + size / 2;
// 平面位置:場景 AABB 8 個角投影到法向的 [dmin, dmax],滑桿 t 內插
//(軸向法向 = 與舊版逐軸計算等價;任意法向也成立)
double dmin = double.MaxValue, dmax = double.MinValue;
for (int ci = 0; ci < 8; ci++)
{
var corner = new Point3D(
(ci & 1) == 0 ? min.X : min.X + size.X,
(ci & 2) == 0 ? min.Y : min.Y + size.Y,
(ci & 4) == 0 ? min.Z : min.Z + size.Z);
double d = Vector3D.DotProduct((Vector3D)corner, axis);
if (d < dmin) dmin = d;
if (d > dmax) dmax = d;
}
double dStar = dmin + (dmax - dmin) * t;
Point3D planePoint = center + axis * (dStar - Vector3D.DotProduct((Vector3D)center, axis));
Vector3D normal = SectionFlip ? -axis : axis;
// UI 執行緒快照(原始 mesh 皆已 Freeze,可跨執行緒),背景平行裁切,回 UI 一次換上。
// 裁切期間若參數又變 / 零件被平移(TransformRoot 會呼叫 ApplySection)→
// _sectionReapply 讓迴圈用最新的 fi.Mesh 與平面重跑,最終狀態必為最新
var models = new List<GeometryModel3D>(_faceMap.Count);
var sources = new List<MeshGeometry3D>(_faceMap.Count);
foreach ((Model3D model, FaceInfo fi) in _faceMap)
if (model is GeometryModel3D gm)
{
models.Add(gm);
sources.Add(fi.Mesh);
}
MeshGeometry3D[] clipped = await Task.Run(() =>
{
var result = new MeshGeometry3D[sources.Count];
Parallel.For(0, sources.Count, i =>
result[i] = SectionService.ClipMesh(sources[i], planePoint, normal));
return result;
});
if (!SectionEnabled) return; // 裁切期間被關掉 → 還原分支已處理,丟棄結果
for (int i = 0; i < models.Count; i++)
models[i].Geometry = clipped[i]; // 已移除檔案的殘留 model 賦值無害(不在視覺樹上)
_sectionPlanePoint = planePoint;
_sectionNormal = normal;
foreach (ModelNodeViewModel root in Roots) RefreshRootEdges(root);
ApplyRenderMode(); // 剖面開啟 → 改用逐面(裁切後幾何)
UpdateSectionPlaneVisual(planePoint, axis, size);
string axisName = SectionAxisIndex == CustomAxisIndex ? "自訂平面" : $"{"XYZ"[SectionAxisIndex]} 軸";
StatusText = $"剖面:{axisName} {SectionPosition:F0}%" + (SectionFlip ? "(反向)" : "");
} while (_sectionReapply);
}
finally
{
_sectionApplying = false;
}
}
private void UpdateSectionPlaneVisual(Point3D planePoint, Vector3D axis, Vector3D size)
{
if (_sectionPlaneVisual is not null)
_viewport!.Children.Remove(_sectionPlaneVisual);
(Vector3D lenDir, double len, double wid) = SectionAxisIndex switch
{
0 => (new Vector3D(0, 1, 0), size.Y, size.Z),
1 => (new Vector3D(1, 0, 0), size.X, size.Z),
2 => (new Vector3D(1, 0, 0), size.X, size.Y),
_ => (AnyPerpendicular(axis), size.Length * 0.8, size.Length * 0.8), // 自訂平面
};
lenDir.Normalize();
_sectionPlaneVisual = new RectangleVisual3D
{
Origin = planePoint,
Normal = axis,
LengthDirection = lenDir,
Length = len * 1.05,
Width = wid * 1.05,
Fill = new SolidColorBrush(Color.FromArgb(40, 30, 120, 255)),
};
_viewport!.Children.Add(_sectionPlaneVisual);
}
/// <summary>取任一與 n 垂直的單位向量(自訂平面的指示矩形方向用)</summary>
private static Vector3D AnyPerpendicular(Vector3D n)
{
Vector3D v = Math.Abs(n.Z) < 0.9
? Vector3D.CrossProduct(n, new Vector3D(0, 0, 1))
: Vector3D.CrossProduct(n, new Vector3D(1, 0, 0));
v.Normalize();
return v;
}
}
+350 -336
View File
@@ -3,11 +3,9 @@ using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Media.Media3D;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -27,6 +25,14 @@ public partial class MainViewModel : ObservableObject
/// <summary>渲染物件 → B-rep Face 反查表(HitTest 用;overlay 不在表內所以自然被忽略)</summary>
private readonly Dictionary<Model3D, FaceInfo> _faceMap = new();
/// <summary>合併網格 → leaf 反查表(拖曳模式在瀏覽渲染下 hit-test 用)</summary>
private readonly Dictionary<Model3D, ModelNodeViewModel> _mergedMap = new();
/// <summary>合併網格 → (每面頂點起始邊界, 對應 FaceInfo)。命中合併網格時用三角形頂點 index 二分搜尋反查回是哪個面,
/// 讓量測模式也能 render 合併網格(62 個 model)而非逐面(數萬個 model)。starts 長度 = faces+1,最後一格為總頂點數。
/// 邊界由匯入時依面序串接而成,與 BuildMergedMesh 串接順序一致;平移不改各面頂點數,故邊界永久有效。</summary>
private readonly Dictionary<Model3D, (int[] Starts, FaceInfo[] Faces)> _mergedFaceRanges = new();
private HelixViewport3D? _viewport;
private int _paletteIndex;
private readonly Dictionary<MeasureMode, int> _counters = new();
@@ -43,12 +49,6 @@ public partial class MainViewModel : ObservableObject
[ObservableProperty]
private ModelNodeViewModel? selectedNode;
// 剖面
private readonly DispatcherTimer _sectionTimer;
private RectangleVisual3D? _sectionPlaneVisual;
private Point3D _sectionPlanePoint; // SectionEnabled 時的剖切平面(合併邊線重建用)
private Vector3D _sectionNormal;
// 互動中暫停邊線:轉動/縮放/平移時隱藏 LinesVisual3D,停下再顯示 → 大組件流暢
private readonly DispatcherTimer _interactionTimer;
private bool _edgesSuspended;
@@ -60,28 +60,62 @@ public partial class MainViewModel : ObservableObject
private string statusText = "就緒 — 匯入 STP/STEP/STL/DXF(拖放亦可)。右鍵旋轉、滾輪縮放、中鍵或 Shift+左鍵平移";
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ImportCommand))]
[NotifyCanExecuteChangedFor(nameof(ImportRecentCommand))]
[NotifyCanExecuteChangedFor(nameof(RotateRootCommand))]
[NotifyCanExecuteChangedFor(nameof(CheckInterferenceCommand))]
[NotifyCanExecuteChangedFor(nameof(ExportStepFileCommand))]
[NotifyCanExecuteChangedFor(nameof(ExportStlFileCommand))]
private bool isBusy;
[ObservableProperty]
private bool useInch;
/// <summary>樹面板名稱過濾字串(只影響樹顯示,不影響 3D 可見性)</summary>
[ObservableProperty]
private bool sectionEnabled;
private string? treeFilter;
/// <summary>0=X 1=Y 2=Z</summary>
[ObservableProperty]
private int sectionAxisIndex;
partial void OnTreeFilterChanged(string? value)
{
foreach (ModelNodeViewModel root in Roots) root.ApplyFilter(value);
}
/// <summary>剖面位置(沿軸 0~100%</summary>
[ObservableProperty]
private double sectionPosition = 50;
[ObservableProperty]
private bool sectionFlip;
/// <summary>最近開啟檔案(MRU,最多 10 筆;隨 settings.json 保存</summary>
public ObservableCollection<string> RecentFiles { get; } = new();
public ObservableCollection<ModelNodeViewModel> Roots { get; } = new();
public ObservableCollection<MeasurementResult> Measurements { get; } = new();
/// <summary>快速工具列自訂(選單列有全部功能,快速列只放勾選的常用按鈕)</summary>
public QuickBarViewModel QuickBar { get; } = new();
// ─── 使用者設定(視窗由 MainWindow 處理;VM 管單位 + MRU)─────
public void LoadSettings(AppSettings s)
{
UseInch = s.UseInch;
RecentFiles.Clear();
foreach (string f in s.RecentFiles.Where(File.Exists).Take(SettingsService.MaxRecentFiles))
RecentFiles.Add(f);
QuickBar.Load(s.QuickBarKeys);
}
public void SaveSettingsInto(AppSettings s)
{
s.UseInch = UseInch;
s.RecentFiles = RecentFiles.ToList();
s.QuickBarKeys = QuickBar.ToKeys();
}
private void TouchRecent(string path)
{
string full = Path.GetFullPath(path);
RecentFiles.Remove(full);
RecentFiles.Insert(0, full);
while (RecentFiles.Count > SettingsService.MaxRecentFiles)
RecentFiles.RemoveAt(RecentFiles.Count - 1);
}
public MainViewModel()
{
_sectionTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(100) };
@@ -98,13 +132,20 @@ public partial class MainViewModel : ObservableObject
public void Attach(HelixViewport3D viewport)
{
_viewport = viewport;
// 相機一動就暫停邊線(轉動/縮放/平移皆觸發),停下 180ms 再恢復
// 相機一動就暫停邊線/逐面(轉動/縮放/平移皆觸發),停下 180ms 再恢復
// 兩個事件都訂閱,互補保底:
// 1) viewport.CameraChanged(控制項層級 routed event)— 相機實例若被 Helix 內部換掉也接得住;
// 2) Camera.ChangedFreezable,相機 Position/方向一被改就觸發)— 確保每次移動都收到。
// 舊寫法只訂 Camera.Changed 且包在 null 檢查裡,萬一相機實例被換就永久失聯 → 暫停機制全失效。
// 重複觸發 OnCameraMoved 無害(idempotent,內部有 _edgesSuspended guard)。
viewport.CameraChanged += (_, _) => OnCameraMoved();
if (viewport.Camera is not null)
viewport.Camera.Changed += (_, _) => OnCameraMoved();
}
private void OnCameraMoved()
{
SyncOverlayCamera(); // 操作器疊圖層跟著主相機
if (!_edgesSuspended) { _edgesSuspended = true; SetEdgesActive(false); }
_interactionTimer.Stop();
_interactionTimer.Start();
@@ -125,9 +166,12 @@ public partial class MainViewModel : ObservableObject
SyncVisual(root.EdgeVisual, active && root.EdgeVisual.Points.Count > 0);
}
/// <summary>IsBusy 時停用會改動幾何/場景的指令(匯入、旋轉、干涉),避免背景運算期間狀態被改走造成結果錯位</summary>
private bool IsIdle() => !IsBusy;
// ─── 匯入 ────────────────────────────────────────────────────
[RelayCommand]
[RelayCommand(CanExecute = nameof(IsIdle))]
private async Task ImportAsync()
{
var dlg = new OpenFileDialog
@@ -141,14 +185,23 @@ public partial class MainViewModel : ObservableObject
await ImportFilesAsync(dlg.FileNames);
}
/// <summary>MRU 下拉點選(檔案可能已被移走 → ImportFilesAsync 會報匯入失敗)</summary>
[RelayCommand(CanExecute = nameof(IsIdle))]
private Task ImportRecentAsync(string path) => ImportFilesAsync(new[] { path });
public async Task ImportFilesAsync(IEnumerable<string> paths)
{
if (_viewport is null) return;
// 拖放/命令列可繞過指令 CanExecute 直接進來 → 匯入或背景運算期間直接擋掉,
// 避免兩批匯入交錯(StepImportService 有共享狀態,非重入安全)
if (IsBusy) { StatusText = "忙碌中(匯入或運算進行中),請稍候再匯入"; return; }
int ok = 0, fail = 0;
IsBusy = true; // 整批匯入期間維持 busy(v0.4.0:不再逐檔開關,杜絕檔案之間的重入空窗)
try
{
foreach (string path in paths)
{
string name = Path.GetFileNameWithoutExtension(path);
IsBusy = true;
StatusText = $"匯入中:{name} …";
// 匯入階段回報(從背景執行緒來 → 切回 UI 執行緒)
_importService.Progress = msg =>
@@ -158,6 +211,7 @@ public partial class MainViewModel : ObservableObject
{
ImportedFileData data = await Task.Run(() => _importService.Import(path));
BuildRoot(data);
TouchRecent(path);
ok++;
}
catch (Exception ex)
@@ -167,11 +221,12 @@ public partial class MainViewModel : ObservableObject
MessageBox.Show($"{path}\n\n{ex.Message}", "匯入失敗",
MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
}
finally
{
IsBusy = false;
}
}
if (ok > 0)
{
if (SectionEnabled) ApplySection();
@@ -195,6 +250,7 @@ public partial class MainViewModel : ObservableObject
root.ShowEdges = false;
Roots.Add(root);
if (!string.IsNullOrWhiteSpace(TreeFilter)) root.ApplyFilter(TreeFilter); // 新檔套用目前過濾
foreach (ModelNodeViewModel leaf in root.Leaves())
{
leaf.VisualStateChanged += _ => SyncLeafVisuals(root);
@@ -240,28 +296,42 @@ public partial class MainViewModel : ObservableObject
backMat.Freeze();
vm.SharedMaterial = diffuse;
// 一個 B-rep Face = 一個 GeometryModel3D,登錄反查表(量測拾取用)
// 一個 B-rep Face = 一個 GeometryModel3D,登錄反查表(剖面模式逐面拾取用)
// 同時依面序記錄每面頂點起始邊界 + 對應 FaceInfo,供「命中合併網格反查面」(量測模式拾取用)。
var group = new Model3DGroup();
foreach (ImportedFace f in n.Faces)
var faceInfos = new FaceInfo[n.Faces.Count];
var vertStarts = new int[n.Faces.Count + 1];
int vacc = 0;
for (int i = 0; i < n.Faces.Count; i++)
{
ImportedFace f = n.Faces[i];
var gm = new GeometryModel3D(f.Mesh, matGroup) { BackMaterial = backMat };
group.Children.Add(gm);
_faceMap[gm] = new FaceInfo { BrepFace = f.BrepFace, Mesh = f.Mesh, Owner = vm };
var info = new FaceInfo { BrepFace = f.BrepFace, Mesh = f.Mesh, Owner = vm };
_faceMap[gm] = info;
faceInfos[i] = info;
vertStarts[i] = vacc;
vacc += f.Mesh.Positions.Count;
}
vertStarts[n.Faces.Count] = vacc;
vm.FacesContent = group;
// 合併網格:整零件所有面併成 1 個 GeometryModel3D瀏覽時只 1 個 draw call
// 合併網格:整零件所有面併成 1 個 GeometryModel3Ddraw call 從每面 1 個降為每檔 1 個)。
// BuildMergedMesh 依 n.Faces 同序串接(baseIdx += positions.Count),與上面 vertStarts 對齊 → 命中頂點 index 可反查回面。
var mergedModel = new GeometryModel3D(BuildMergedMesh(n.Faces), matGroup);
// 封閉實體背面不可見 → 不設 BackMaterial(WPF 兩面渲染成本砍半);開放殼/STL 仍需背面
// 封閉實體背面不可見 → 不設 BackMaterial(WPF 兩面渲染成本砍半);開放殼/STL 仍需背面
// 拾取不受材質影響(WPF 3D hit-test 純幾何、不剔背面),故孔內壁仍可命中。
if (n.SolidCount == 0 || !n.HasBrep)
mergedModel.BackMaterial = backMat;
vm.MergedContent = new Model3DGroup();
vm.MergedContent.Children.Add(mergedModel);
_mergedMap[mergedModel] = vm;
_mergedFaceRanges[mergedModel] = (vertStarts, faceInfos);
// 預設瀏覽模式 → 顯示合併網格(量測模式時由 ApplyRenderMode 換回逐面
// 非剖面一律顯示合併網格(瀏覽/量測都是,量測靠 _mergedFaceRanges 反查面);只有剖面才逐面(裁切後幾何
vm.BodyVisual = new ModelVisual3D
{
Content = (CurrentMode == MeasureMode.None && !SectionEnabled) ? vm.MergedContent : group,
Content = SectionEnabled ? group : vm.MergedContent,
};
}
@@ -314,20 +384,19 @@ public partial class MainViewModel : ObservableObject
// ─── 合併網格(瀏覽模式降 draw call)────────────────────────
/// <summary>把一個零件的所有面網格併成單一 MeshGeometry3D(索引位移後串接)</summary>
private static MeshGeometry3D BuildMergedMesh(IReadOnlyList<ImportedFace> faces)
/// <summary>把多個面網格依序併成單一 MeshGeometry3D(索引位移後串接;順序 = 呼叫端給的面序</summary>
private static MeshGeometry3D MergeMeshes(IReadOnlyList<MeshGeometry3D> meshes)
{
int nv = 0, nt = 0;
foreach (ImportedFace f in faces) { nv += f.Mesh.Positions.Count; nt += f.Mesh.TriangleIndices.Count; }
foreach (MeshGeometry3D m in meshes) { nv += m.Positions.Count; nt += m.TriangleIndices.Count; }
var pos = new Point3DCollection(nv);
var nrm = new Vector3DCollection(nv);
var idx = new Int32Collection(nt);
int baseIdx = 0;
bool hasNormals = true;
foreach (ImportedFace f in faces)
foreach (MeshGeometry3D m in meshes)
{
MeshGeometry3D m = f.Mesh;
foreach (Point3D p in m.Positions) pos.Add(p);
if (m.Normals.Count == m.Positions.Count)
foreach (Vector3D v in m.Normals) nrm.Add(v);
@@ -342,46 +411,39 @@ public partial class MainViewModel : ObservableObject
return mesh;
}
/// <summary>建合併網格(位置變更後,如零件平移)。由 leaf 逐面 fi.Mesh(未剖切原始)串接。</summary>
/// <summary>匯入時建合併網格(依 faces 面序串接,與 _mergedFaceRanges 的頂點邊界對齊)</summary>
private static MeshGeometry3D BuildMergedMesh(IReadOnlyList<ImportedFace> faces)
{
var meshes = new List<MeshGeometry3D>(faces.Count);
foreach (ImportedFace f in faces) meshes.Add(f.Mesh);
return MergeMeshes(meshes);
}
/// <summary>重建合併網格(位置變更後,如零件平移)。由 leaf 逐面 fi.Mesh(未剖切原始)依 FacesContent 子序串接,
/// 與匯入時 BuildMergedMesh 的面序一致 → _mergedFaceRanges 頂點邊界維持有效。</summary>
private void RebuildMerged(ModelNodeViewModel leaf)
{
if (leaf.MergedContent is null || leaf.FacesContent is null) return;
int nv = 0, nt = 0;
var meshes = new List<MeshGeometry3D>(leaf.FacesContent.Children.Count);
foreach (Model3D mm in leaf.FacesContent.Children)
if (mm is GeometryModel3D g && _faceMap.TryGetValue(g, out FaceInfo? fi))
{ nv += fi.Mesh.Positions.Count; nt += fi.Mesh.TriangleIndices.Count; }
var pos = new Point3DCollection(nv);
var nrm = new Vector3DCollection(nv);
var idx = new Int32Collection(nt);
int baseIdx = 0; bool hasNormals = true;
foreach (Model3D mm in leaf.FacesContent.Children)
{
if (mm is not GeometryModel3D g || !_faceMap.TryGetValue(g, out FaceInfo? fi)) continue;
MeshGeometry3D m = fi.Mesh;
foreach (Point3D p in m.Positions) pos.Add(p);
if (m.Normals.Count == m.Positions.Count) foreach (Vector3D v in m.Normals) nrm.Add(v);
else hasNormals = false;
foreach (int ti in m.TriangleIndices) idx.Add(ti + baseIdx);
baseIdx += m.Positions.Count;
}
var mesh = new MeshGeometry3D { Positions = pos, TriangleIndices = idx };
if (hasNormals && nrm.Count == pos.Count) mesh.Normals = nrm;
mesh.Freeze();
((GeometryModel3D)leaf.MergedContent.Children[0]).Geometry = mesh;
meshes.Add(fi.Mesh);
((GeometryModel3D)leaf.MergedContent.Children[0]).Geometry = MergeMeshes(meshes);
}
/// <summary>
/// 依目前狀態切換每個 leaf 的渲染內容:
/// 瀏覽(None)且未剖面 → 合併網格(快);量測模式或剖面 → 逐面(拾取/裁切需要)。
/// 非剖面(瀏覽 + 量測皆是)→ 合併網格(每檔 1 個 model,快);量測拾取靠命中合併網格反查面(_mergedFaceRanges)。
/// 剖面 → 逐面(要顯示各面裁切後的幾何)。
/// </summary>
private void ApplyRenderMode()
{
bool browse = CurrentMode == MeasureMode.None && !SectionEnabled;
// 量測不再需要逐面渲染(拾取打合併網格反查面)→ 只有剖面才逐面。永不在量測模式掛數萬個 GeometryModel3D。
bool merged = !SectionEnabled;
foreach (ModelNodeViewModel leaf in Roots.SelectMany(r => r.Leaves()))
{
if (leaf.BodyVisual is null) continue;
Model3DGroup? target = browse ? leaf.MergedContent : leaf.FacesContent;
Model3DGroup? target = merged ? leaf.MergedContent : leaf.FacesContent;
if (target is not null && !ReferenceEquals(leaf.BodyVisual.Content, target))
leaf.BodyVisual.Content = target;
}
@@ -399,11 +461,51 @@ public partial class MainViewModel : ObservableObject
[RelayCommand]
private void ZoomAll() => _viewport?.ZoomExtents(400);
/// <summary>標準視圖(機構慣例 Z 向上):Iso / Front / Back / Top / Bottom / Left / Right</summary>
[RelayCommand]
private void SetStandardView(string name)
{
if (_viewport?.Camera is not ProjectionCamera cam) return;
(Vector3D dir, Vector3D up) = name switch
{
"Front" => (new Vector3D(0, 1, 0), new Vector3D(0, 0, 1)), // 從 -Y 往 +Y 看
"Back" => (new Vector3D(0, -1, 0), new Vector3D(0, 0, 1)),
"Top" => (new Vector3D(0, 0, -1), new Vector3D(0, 1, 0)),
"Bottom" => (new Vector3D(0, 0, 1), new Vector3D(0, -1, 0)),
"Right" => (new Vector3D(-1, 0, 0), new Vector3D(0, 0, 1)),
"Left" => (new Vector3D(1, 0, 0), new Vector3D(0, 0, 1)),
_ => (new Vector3D(-1, -1, -1), new Vector3D(0, 0, 1)), // Iso
};
dir.Normalize();
Rect3D b = UnionBounds();
Point3D center = b.IsEmpty ? new Point3D()
: new Point3D(b.X + b.SizeX / 2, b.Y + b.SizeY / 2, b.Z + b.SizeZ / 2);
double dist = Math.Max(SceneDiagonal() * 1.8, 10);
cam.Position = center - dir * dist;
cam.LookDirection = dir * dist; // 目標點 = Position + LookDirection = 場景中心
cam.UpDirection = up;
_viewport.ZoomExtents(400);
StatusText = $"視圖:{name}";
}
/// <summary>正交投影切換(平行投影,量尺寸不受透視變形;Helix 保持視角換相機型別)</summary>
[ObservableProperty]
private bool orthographicView;
partial void OnOrthographicViewChanged(bool value)
{
if (_viewport is null) return;
_viewport.Orthographic = value;
StatusText = value ? "正交投影(平行投影,適合尺寸確認)" : "透視投影";
}
[RelayCommand]
private void RemoveRoot(ModelNodeViewModel root)
{
if (_viewport is null || !root.IsRoot) return;
if (ReferenceEquals(root, _gizmoTarget)) GizmoEnabled = false; // 目標被移除 → 收掉操作器
// 量測 overlay 可能標在此檔模型上 → 一併清除(簡化:全清)
if (Measurements.Count > 0) ClearMeasurements();
@@ -414,6 +516,12 @@ public partial class MainViewModel : ObservableObject
if (leaf.FacesContent is not null) // 逐面群組才在 _faceMap(與目前顯示哪種內容無關)
foreach (Model3D m in leaf.FacesContent.Children)
_faceMap.Remove(m);
if (leaf.MergedContent is not null)
foreach (Model3D m in leaf.MergedContent.Children)
{
_mergedMap.Remove(m);
_mergedFaceRanges.Remove(m);
}
}
if (root.EdgeVisual is not null)
_viewport.Children.Remove(root.EdgeVisual);
@@ -421,12 +529,98 @@ public partial class MainViewModel : ObservableObject
StatusText = $"已移除:{root.Name}";
}
// ─── 顯示控制 / 體積(樹右鍵選單)────────────────────────────
/// <summary>只顯示此節點(其餘全部隱藏;「全部顯示」復原)</summary>
[RelayCommand]
private void IsolateNode(ModelNodeViewModel node)
{
foreach (ModelNodeViewModel r in Roots) SetVisibleRecursive(r, false);
SetVisibleRecursive(node, true);
StatusText = $"只顯示「{node.Name}」(樹右鍵 → 全部顯示 復原)";
}
[RelayCommand]
private void InvertVisibility()
{
foreach (ModelNodeViewModel l in Roots.SelectMany(r => r.Leaves()))
l.IsVisible = !l.IsVisible;
StatusText = "已反轉顯示";
}
[RelayCommand]
private void ShowAll()
{
foreach (ModelNodeViewModel r in Roots) SetVisibleRecursive(r, true);
StatusText = "已全部顯示";
}
/// <summary>逐節點顯式遞迴設定可見性。不能只設 root(setter 同值不觸發 cascade
/// 之前的隔離操作可能留下「父關子開」的混合狀態)</summary>
private static void SetVisibleRecursive(ModelNodeViewModel node, bool visible)
{
node.IsVisible = visible;
foreach (ModelNodeViewModel c in node.Children) SetVisibleRecursive(c, visible);
}
/// <summary>體積/質心(網格 signed volume,封閉實體才可靠)→ 加進量測結果 + 質心標記</summary>
[RelayCommand]
private void MeasureVolume(ModelNodeViewModel node)
{
var meshes = node.Leaves()
.Where(l => l.FacesContent is not null)
.SelectMany(l => l.FacesContent!.Children)
.OfType<GeometryModel3D>()
.Where(g => _faceMap.ContainsKey(g))
.Select(g => _faceMap[g].Mesh)
.ToList();
if (meshes.Count == 0)
{
StatusText = $"「{node.Name}」沒有可計算的面網格(線架構無體積)";
return;
}
(double vol, Point3D centroid, bool reliable) = MeasurementService.MeshVolume(meshes);
if (!reliable)
{
StatusText = $"「{node.Name}」非封閉網格(開放殼),無法可靠計算體積";
return;
}
double diag = SceneDiagonal();
double markerR = Math.Clamp(diag * 0.004, 0.01, 5.0);
string label = NextLabel(MeasureMode.Volume, "V");
string name = node.Name;
Rect3D b = node.Bounds;
var m = new MeasurementResult
{
Kind = MeasureMode.Volume,
TitleFor = u => $"{label} {name} V ≈ {Units.V(vol, u)}",
DetailFor = u => $"體積 ≈ {Units.V(vol, u)}(網格近似)\n質心 {Units.P(centroid, u)}\n" +
$"外形(AABB{Units.C(b.SizeX, u)} × {Units.C(b.SizeY, u)} × {Units.C(b.SizeZ, u)} " +
(u == UnitSystem.Millimeter ? "mm" : "in"),
};
m.Overlays.Add(new SphereVisual3D { Center = centroid, Radius = markerR, Fill = Brushes.OrangeRed });
var volLabel = new BillboardTextVisual3D
{
Position = centroid + new Vector3D(0, 0, markerR * 2),
Text = $"{label} {Units.V(vol, UnitSystem.Millimeter)}",
Foreground = Brushes.Black,
Background = new SolidColorBrush(Color.FromArgb(200, 255, 255, 210)),
Padding = new Thickness(4, 2, 4, 2),
FontSize = 14,
};
m.Overlays.Add(volLabel);
m.DynamicLabels.Add((volLabel, u => $"{label} {Units.V(vol, u)}"));
AddMeasurement(m);
}
// ─── 量測 ────────────────────────────────────────────────────
partial void OnCurrentModeChanged(MeasureMode value)
{
CancelPending();
ApplyRenderMode(); // None → 合併網格(快);量測模式 → 逐面(可拾取)
ApplyRenderMode(); // 非剖面一律合併網格(量測拾取靠反查面);不再因切量測模式而掛逐面
StatusText = value switch
{
MeasureMode.None => "瀏覽模式(右鍵旋轉、滾輪縮放、中鍵或 Shift+左鍵拖曳平移)",
@@ -439,6 +633,7 @@ public partial class MainViewModel : ObservableObject
MeasureMode.FaceDistance => "面距量測:點選第 1 個面",
MeasureMode.Align => "兩點對齊:先點「要移動零件」上的點,再點目標點(純平移,量測會清空)",
MeasureMode.Align3 => "三點對齊:先在「要移動的檔案」點 3 個特徵點,再到目標檔案點 3 個對應點(旋轉+平移)",
MeasureMode.Drag => "拖曳模式:左鍵按住零件拖動(沿螢幕平面),放開定位;深度方向先轉視角再拖。右鍵仍可轉視角",
_ => StatusText,
};
}
@@ -450,10 +645,52 @@ public partial class MainViewModel : ObservableObject
StatusText = value ? "顯示單位:inch" : "顯示單位:mm";
}
/// <summary>EscMainWindow 轉發):先取消進行中的多段量測(保留模式),再按退回瀏覽模式;再退操作器</summary>
public void OnEscape()
{
if (CancelSectionPickIfActive()) return; // 3點剖面定義中 → 先取消定義
bool hadPending = _pendingPoint is not null || _pendingDirection is not null ||
_pendingFace is not null || _pendingAlign is not null || _align3.Count > 0;
if (hadPending)
{
CancelPending();
StatusText = "已取消目前量測起點(再按 Esc 退出量測模式)";
}
else if (CurrentMode != MeasureMode.None)
{
CurrentMode = MeasureMode.None;
}
else if (GizmoEnabled)
{
GizmoEnabled = false;
}
}
/// <summary>量測模式快速鍵(MainWindow 轉發;無修飾鍵)。同鍵再按 = 退回瀏覽。回傳 false 表示非快速鍵。</summary>
public bool OnHotKey(System.Windows.Input.Key key)
{
MeasureMode? m = key switch
{
System.Windows.Input.Key.P => MeasureMode.Point,
System.Windows.Input.Key.D => MeasureMode.Distance,
System.Windows.Input.Key.E => MeasureMode.Edge,
System.Windows.Input.Key.F => MeasureMode.Face,
System.Windows.Input.Key.C => MeasureMode.Circle,
System.Windows.Input.Key.A => MeasureMode.Angle,
System.Windows.Input.Key.M => MeasureMode.FaceDistance,
_ => null,
};
if (m is null) return false;
CurrentMode = CurrentMode == m ? MeasureMode.None : m.Value;
return true;
}
/// <summary>MainWindow 滑鼠左鍵點擊轉發進來</summary>
public void OnViewportClick(Point position)
{
if (_viewport is null || IsBusy || CurrentMode == MeasureMode.None) return;
if (_viewport is null || IsBusy) return;
if (HandleSectionPlanePick(position)) return; // 3點剖面定義模式優先吃點擊(v0.5.0)
if (CurrentMode is MeasureMode.None or MeasureMode.Drag) return;
var hits = _viewport.Viewport.FindHits(position);
FaceInfo? fi = null;
@@ -461,13 +698,23 @@ public partial class MainViewModel : ObservableObject
Vector3D hitNormal = default;
foreach (var h in hits)
{
if (h.Model is not null && _faceMap.TryGetValue(h.Model, out FaceInfo? f))
if (h.Model is null) continue;
// 剖面模式:渲染逐面 → 直接查 _faceMap
if (_faceMap.TryGetValue(h.Model, out FaceInfo? f))
{
fi = f;
hitPoint = h.Position;
hitNormal = h.Normal;
fi = f; hitPoint = h.Position; hitNormal = h.Normal;
break;
}
// 量測/瀏覽模式:渲染合併網格 → 用命中三角形頂點 index 反查回是哪個面
if (h.RayHit is not null && _mergedFaceRanges.TryGetValue(h.Model, out var range))
{
FaceInfo? mf = ResolveMergedFace(range, h.RayHit.VertexIndex1);
if (mf is not null)
{
fi = mf; hitPoint = h.Position; hitNormal = h.Normal;
break;
}
}
}
if (fi is null)
{
@@ -485,6 +732,23 @@ public partial class MainViewModel : ObservableObject
}
}
/// <summary>命中合併網格的某頂點 index → 反查回是哪個面(依面序的頂點起始邊界二分搜尋)。</summary>
private static FaceInfo? ResolveMergedFace((int[] Starts, FaceInfo[] Faces) range, int vertexIndex)
{
int[] starts = range.Starts;
FaceInfo[] faces = range.Faces;
if (vertexIndex < 0 || faces.Length == 0 || vertexIndex >= starts[faces.Length]) return null;
// 找最大的 k 使 starts[k] <= vertexIndexface k 佔頂點 [starts[k], starts[k+1])
int lo = 0, hi = faces.Length - 1, ans = 0;
while (lo <= hi)
{
int mid = (lo + hi) / 2;
if (starts[mid] <= vertexIndex) { ans = mid; lo = mid + 1; }
else hi = mid - 1;
}
return faces[ans];
}
private void HandleMeasureClick(FaceInfo fi, Point3D hit, Vector3D hitNormal)
{
double diag = SceneDiagonal();
@@ -752,27 +1016,37 @@ public partial class MainViewModel : ObservableObject
if (_viewport is null) return;
ClearMeasurements(); // 量測標記位置已失效
foreach (ModelNodeViewModel leaf in root.Leaves())
{
// B-rep:對 Solid/Shell 整體 Modify(避免共用邊被逐面重複變換)
var leaves = root.Leaves().ToList();
// B-rep:對 Solid/Shell 整體 Modify(避免共用邊被逐面重複變換)
// CADability 物件非執行緒安全 → 維持循序
foreach (ModelNodeViewModel leaf in leaves)
foreach (CADability.GeoObject.IGeoObject g in leaf.SourceGeos)
{
try { g.Modify(op); }
catch { /* 個別物件變換失敗不中斷 */ }
}
// 渲染網格(逐面,與目前顯示哪種內容無關
// 渲染網格:面級平行變換(來源/輸出皆 frozen、各面獨立 → 安全;大檔 64k 面吃滿多核
var faceItems = new List<(GeometryModel3D Gm, FaceInfo Fi)>();
foreach (ModelNodeViewModel leaf in leaves)
if (leaf.FacesContent is not null)
{
foreach (Model3D mm in leaf.FacesContent.Children)
if (mm is GeometryModel3D gm && _faceMap.TryGetValue(gm, out FaceInfo? fi))
faceItems.Add((gm, fi));
var moved = new MeshGeometry3D[faceItems.Count];
Parallel.For(0, faceItems.Count, i => moved[i] = TransformMesh(faceItems[i].Fi.Mesh, m));
for (int i = 0; i < faceItems.Count; i++) // 視覺樹賦值回 UI 執行緒循序做
{
if (mm is not GeometryModel3D gm || !_faceMap.TryGetValue(gm, out FaceInfo? fi)) continue;
MeshGeometry3D moved = TransformMesh(fi.Mesh, m);
fi.Mesh = moved;
gm.Geometry = moved;
faceItems[i].Fi.Mesh = moved[i];
faceItems[i].Gm.Geometry = moved[i];
}
foreach (ModelNodeViewModel leaf in leaves)
{
if (leaf.FacesContent is not null)
RebuildMerged(leaf); // 合併網格同步
}
// 邊線端點(資料);實際合併線於迴圈後重建
if (leaf.OriginalEdgePoints is not null)
@@ -829,7 +1103,7 @@ public partial class MainViewModel : ObservableObject
// ─── 旋轉 90°(樹面板選取的檔案,繞其中心)──────────────────
[RelayCommand]
[RelayCommand(CanExecute = nameof(IsIdle))]
private void RotateRoot(string axisName)
{
ModelNodeViewModel? root = SelectedNode is not null ? RootContaining(SelectedNode)
@@ -863,266 +1137,6 @@ public partial class MainViewModel : ObservableObject
// 三點對齊的剛體變換數學在 Services/RigidAlign.csSmokeTest --align-test 驗證)
// ─── 干涉檢查 ────────────────────────────────────────────────
[RelayCommand]
private async Task CheckInterferenceAsync()
{
if (_viewport is null) return;
var visibleRoots = Roots.Where(r => r.IsVisible).ToList();
if (visibleRoots.Count != 2)
{
StatusText = $"干涉檢查需要剛好 2 個可見檔案(目前 {visibleRoots.Count} 個)— 用樹面板勾選";
return;
}
List<MeshGeometry3D> MeshesOf(ModelNodeViewModel root) =>
root.Leaves().Where(l => l.IsVisible && l.FacesContent is not null)
.SelectMany(l => l.FacesContent!.Children)
.Where(m => m is GeometryModel3D gm && _faceMap.ContainsKey(gm))
.Select(m => _faceMap[(GeometryModel3D)m].Mesh)
.ToList();
var a = MeshesOf(visibleRoots[0]);
var b = MeshesOf(visibleRoots[1]);
string nameA = visibleRoots[0].Name, nameB = visibleRoots[1].Name;
IsBusy = true;
StatusText = $"干涉檢查中:{nameA} ⟷ {nameB} …";
try
{
InterferenceResult result = await Task.Run(() => InterferenceService.Check(a, b));
AddInterferenceResult(result, nameA, nameB);
}
catch (Exception ex)
{
StatusText = $"干涉檢查失敗:{ex.Message}";
}
finally
{
IsBusy = false;
}
}
private void AddInterferenceResult(InterferenceResult result, string nameA, string nameB)
{
double diag = SceneDiagonal();
double markerR = Math.Clamp(diag * 0.004, 0.01, 5.0);
MeasurementResult m;
if (result.Intersects)
{
int pairs = result.PairCount;
int segCount = result.Segments.Count;
m = new MeasurementResult
{
Kind = MeasureMode.Interference,
TitleFor = _ => $"🧩 干涉!{nameA} ⟷ {nameB}",
DetailFor = _ => $"兩零件相交(不 match\n相交三角形對 = {pairs:N0}\n" +
$"紅色線 = 干涉交線({segCount:N0} 段)\n(共面貼合不會被算為干涉)",
};
var pts = new Point3DCollection(Math.Min(result.Segments.Count, 20000) * 2);
foreach ((Point3D s0, Point3D s1) in result.Segments) { pts.Add(s0); pts.Add(s1); }
m.Overlays.Add(new LinesVisual3D { Points = pts, Color = Colors.Red, Thickness = 3 });
if (result.Segments.Count > 0)
{
Point3D at = result.Segments[0].A;
m.Overlays.Add(new BillboardTextVisual3D
{
Position = at + new Vector3D(0, 0, markerR * 3),
Text = "干涉",
Foreground = Brushes.White,
Background = Brushes.Red,
Padding = new Thickness(5, 2, 5, 2),
FontSize = 14,
});
}
}
else
{
Point3D ga = result.GapA, gb = result.GapB;
Vector3D d = gb - ga;
double gap = result.GapDistance;
m = new MeasurementResult
{
Kind = MeasureMode.Interference,
TitleFor = u => $"🧩 無干涉 gap ≈ {Units.L(gap, u)}",
DetailFor = u => $"{nameA} ⟷ {nameB} 無相交\n最小間隙 ≈ {Units.L(gap, u)}(網格近似)\n" +
$"gap ≈ 0 即為貼合(match\n點 1 {Units.P(ga, u)}\n點 2 {Units.P(gb, u)}",
};
m.Overlays.Add(new SphereVisual3D { Center = ga, Radius = markerR, Fill = Brushes.OrangeRed });
m.Overlays.Add(new SphereVisual3D { Center = gb, Radius = markerR, Fill = Brushes.OrangeRed });
m.Overlays.Add(new LinesVisual3D
{
Points = new Point3DCollection { ga, gb },
Color = Colors.OrangeRed,
Thickness = 2,
});
var label = new BillboardTextVisual3D
{
Position = ga + d / 2 + new Vector3D(0, 0, markerR * 2),
Text = $"gap {Units.L(gap, UnitSystem.Millimeter)}",
Foreground = Brushes.Black,
Background = new SolidColorBrush(Color.FromArgb(200, 255, 255, 210)),
Padding = new Thickness(4, 2, 4, 2),
FontSize = 14,
};
m.Overlays.Add(label);
m.DynamicLabels.Add((label, u => $"gap {Units.L(gap, u)}"));
}
AddMeasurement(m);
}
// ─── 剖面 ────────────────────────────────────────────────────
partial void OnSectionEnabledChanged(bool value) => ScheduleSection();
partial void OnSectionAxisIndexChanged(int value) => ScheduleSection();
partial void OnSectionPositionChanged(double value) => ScheduleSection();
partial void OnSectionFlipChanged(bool value) => ScheduleSection();
private void ScheduleSection()
{
_sectionTimer.Stop();
_sectionTimer.Start();
}
private void ApplySection()
{
if (_viewport is null) return;
if (!SectionEnabled)
{
// 還原原始幾何
foreach ((Model3D model, FaceInfo fi) in _faceMap)
if (model is GeometryModel3D gm && !ReferenceEquals(gm.Geometry, fi.Mesh))
gm.Geometry = fi.Mesh;
foreach (ModelNodeViewModel root in Roots) RefreshRootEdges(root);
if (_sectionPlaneVisual is not null)
{
_viewport.Children.Remove(_sectionPlaneVisual);
_sectionPlaneVisual = null;
}
ApplyRenderMode(); // 剖面關閉 → 瀏覽模式可切回合併網格
StatusText = "剖面已關閉";
return;
}
Rect3D bounds = UnionBounds();
if (bounds.IsEmpty) return;
Vector3D axis = SectionAxisIndex switch
{
0 => new Vector3D(1, 0, 0),
1 => new Vector3D(0, 1, 0),
_ => new Vector3D(0, 0, 1),
};
double t = SectionPosition / 100.0;
Point3D min = bounds.Location;
var size = new Vector3D(bounds.SizeX, bounds.SizeY, bounds.SizeZ);
double planeCoord = SectionAxisIndex switch
{
0 => min.X + size.X * t,
1 => min.Y + size.Y * t,
_ => min.Z + size.Z * t,
};
Point3D center = min + size / 2;
Point3D planePoint = SectionAxisIndex switch
{
0 => new Point3D(planeCoord, center.Y, center.Z),
1 => new Point3D(center.X, planeCoord, center.Z),
_ => new Point3D(center.X, center.Y, planeCoord),
};
Vector3D normal = SectionFlip ? -axis : axis;
foreach ((Model3D model, FaceInfo fi) in _faceMap)
if (model is GeometryModel3D gm)
gm.Geometry = SectionService.ClipMesh(fi.Mesh, planePoint, normal);
_sectionPlanePoint = planePoint;
_sectionNormal = normal;
foreach (ModelNodeViewModel root in Roots) RefreshRootEdges(root);
ApplyRenderMode(); // 剖面開啟 → 改用逐面(裁切後幾何)
UpdateSectionPlaneVisual(planePoint, axis, size);
StatusText = $"剖面:{"XYZ"[SectionAxisIndex]} 軸 {SectionPosition:F0}%" + (SectionFlip ? "(反向)" : "");
}
private void UpdateSectionPlaneVisual(Point3D planePoint, Vector3D axis, Vector3D size)
{
if (_sectionPlaneVisual is not null)
_viewport!.Children.Remove(_sectionPlaneVisual);
(Vector3D lenDir, double len, double wid) = SectionAxisIndex switch
{
0 => (new Vector3D(0, 1, 0), size.Y, size.Z),
1 => (new Vector3D(1, 0, 0), size.X, size.Z),
_ => (new Vector3D(1, 0, 0), size.X, size.Y),
};
_sectionPlaneVisual = new RectangleVisual3D
{
Origin = planePoint,
Normal = axis,
LengthDirection = lenDir,
Length = len * 1.05,
Width = wid * 1.05,
Fill = new SolidColorBrush(Color.FromArgb(40, 30, 120, 255)),
};
_viewport!.Children.Add(_sectionPlaneVisual);
}
// ─── 匯出 ────────────────────────────────────────────────────
[RelayCommand]
private void ExportCsv()
{
if (Measurements.Count == 0)
{
StatusText = "沒有量測結果可匯出";
return;
}
var dlg = new SaveFileDialog
{
Title = "匯出量測結果",
Filter = "CSV 檔案 (*.csv)|*.csv",
FileName = $"measurements_{DateTime.Now:yyyyMMdd_HHmmss}.csv",
};
if (dlg.ShowDialog() != true) return;
var sb = new StringBuilder();
sb.AppendLine("標籤,類型,明細");
foreach (MeasurementResult m in Measurements)
{
static string Esc(string s) => $"\"{s.Replace("\"", "\"\"")}\"";
sb.AppendLine($"{Esc(m.Title)},{m.Kind},{Esc(m.Detail.Replace("\n", " | "))}");
}
File.WriteAllText(dlg.FileName, sb.ToString(), new UTF8Encoding(true)); // BOMExcel 中文不亂碼
StatusText = $"已匯出 {Measurements.Count} 筆量測 → {Path.GetFileName(dlg.FileName)}";
}
[RelayCommand]
private void SaveScreenshot()
{
if (_viewport is null || _viewport.ActualWidth < 1) return;
var dlg = new SaveFileDialog
{
Title = "儲存視圖截圖",
Filter = "PNG 圖片 (*.png)|*.png",
FileName = $"stpviewer_{DateTime.Now:yyyyMMdd_HHmmss}.png",
};
if (dlg.ShowDialog() != true) return;
const double scale = 2.0; // 2x 解析度
var rtb = new RenderTargetBitmap(
(int)(_viewport.ActualWidth * scale), (int)(_viewport.ActualHeight * scale),
96 * scale, 96 * scale, PixelFormats.Pbgra32);
rtb.Render(_viewport);
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(rtb));
using FileStream fs = File.Create(dlg.FileName);
encoder.Save(fs);
StatusText = $"已儲存截圖 → {Path.GetFileName(dlg.FileName)}";
}
// ─── 共用 ────────────────────────────────────────────────────
private Rect3D UnionBounds()
+41 -3
View File
@@ -57,8 +57,24 @@ public partial class ModelNodeViewModel : ObservableObject
public int FaceCount { get; init; }
public int TriangleCount { get; init; }
/// <summary>世界座標邊界(零件平移後同步位移</summary>
public Rect3D Bounds { get; set; }
/// <summary>世界座標邊界(零件平移/旋轉後由 RecomputeBounds 更新;連動 Stats/Tooltip 的外形尺寸</summary>
public Rect3D Bounds
{
get => _bounds;
set
{
if (_bounds == value) return;
_bounds = value;
OnPropertyChanged(nameof(Stats));
OnPropertyChanged(nameof(ToolTipText));
}
}
private Rect3D _bounds = Rect3D.Empty;
/// <summary>外形尺寸 L×W×HAABBmm</summary>
public string SizeText => Bounds.IsEmpty
? ""
: $"{Bounds.SizeX:F1} × {Bounds.SizeY:F1} × {Bounds.SizeZ:F1} mm";
/// <summary>來源 B-rep 物件(零件平移時整體 Modify,避免共用邊被重複位移)</summary>
public List<CADability.GeoObject.IGeoObject> SourceGeos { get; } = new();
@@ -67,10 +83,32 @@ public partial class ModelNodeViewModel : ObservableObject
public bool HasBrep { get; init; } = true;
public string Stats =>
$"{SolidCount} 實體 · {FaceCount} 面 · {TriangleCount:N0} △" + (HasBrep ? "" : " · 無B-rep");
$"{SolidCount} 實體 · {FaceCount} 面 · {TriangleCount:N0} △" + (HasBrep ? "" : " · 無B-rep") +
(Bounds.IsEmpty ? "" : $"\n外形 {SizeText}");
public string ToolTipText => (FilePath is not null ? FilePath + "\n" : "") + Stats;
// ── 樹搜尋/過濾(只影響樹面板顯示,不影響 3D 可見性)──
[ObservableProperty]
private bool isFilterVisible = true;
/// <summary>
/// 套用名稱過濾:符合者 + 其祖先 + 其子樹 顯示。空字串 = 全顯。
/// 過濾中有符合的子孫時自動展開。回傳此節點是否顯示。
/// </summary>
public bool ApplyFilter(string? filter, bool ancestorMatched = false)
{
bool empty = string.IsNullOrWhiteSpace(filter);
bool selfMatch = !empty && Name.Contains(filter!, StringComparison.OrdinalIgnoreCase);
bool anyChild = false;
foreach (ModelNodeViewModel c in Children)
anyChild |= c.ApplyFilter(filter, ancestorMatched || selfMatch);
IsFilterVisible = empty || selfMatch || ancestorMatched || anyChild;
if (!empty && anyChild) IsExpanded = true;
return IsFilterVisible;
}
// ── 視覺物件 ──
public ModelVisual3D? BodyVisual { get; set; } // leaf:面網格容器(內容在兩種模式間切換)
public Model3DGroup? FacesContent { get; set; } // leaf:逐面 GeometryModel3D(量測拾取用)
@@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace STPViewer.ViewModels;
/// <summary>快速列單一按鈕的顯示開關(選單「快速列 → 自訂按鈕」的 checkbox 項)</summary>
public partial class QuickBarItemViewModel : ObservableObject
{
public string Key { get; }
public string Label { get; }
public bool Default { get; }
[ObservableProperty]
private bool isChecked;
public QuickBarItemViewModel(string key, string label, bool @default)
{
Key = key;
Label = label;
Default = @default;
isChecked = @default;
}
}
/// <summary>
/// 快速工具列自訂:所有功能都在選單列,快速列只放使用者勾選的常用按鈕。
/// XAML 以索引子綁定:Visibility="{Binding QuickBar[Point].IsChecked, Converter=…}"。
/// 勾選狀態隨 settings.jsonQuickBarKeys)保存;null = 用預設。
/// </summary>
public partial class QuickBarViewModel : ObservableObject
{
/// <summary>註冊表 = 快速列順序 + 選單顯示名 + 是否預設顯示(一般 user 常用)</summary>
private static readonly (string Key, string Label, bool Default)[] Registry =
{
("Import", "📂 匯入", true),
("Recent", "▾ 最近檔案", true),
("ZoomAll", "🔍 全覽", true),
("ViewIso", "視圖:等角", true),
("ViewFront", "視圖:前", false),
("ViewTop", "視圖:上", false),
("ViewRight", "視圖:右", false),
("Ortho", "正交投影", true),
("Point", "📍 點", true),
("Distance", "📏 距離", true),
("Edge", "📐 邊", false),
("Face", "⬛ 面", false),
("Circle", "⭕ 圓", true),
("Angle", "∠ 角度", true),
("FaceDistance", "⇔ 面距", false),
("Align", "🎯 兩點對齊", false),
("Align3", "🎯 三點對齊", false),
("RotateX", "旋轉 ↻X", false),
("RotateY", "旋轉 ↻Y", false),
("RotateZ", "旋轉 ↻Z", false),
("Drag", "🖐 拖曳", false),
("Gizmo", "⊹ 操作器", false),
("Interference", "🧩 干涉", true),
("Section", "✂ 剖面", true),
("Clear", "🧹 清除量測", true),
("Unit", "mm ⇄ in", true),
("ExportCsv", "💾 匯出 CSV", false),
("Screenshot", "📷 截圖", false),
("ExportStep", "📤 匯出 STEP", false),
("ExportStl", "📐 匯出 STL", false),
};
private readonly Dictionary<string, QuickBarItemViewModel> _byKey;
/// <summary>依快速列順序的全部項目(選單自訂清單用)</summary>
public IReadOnlyList<QuickBarItemViewModel> Items { get; }
public QuickBarViewModel()
{
Items = Registry.Select(r => new QuickBarItemViewModel(r.Key, r.Label, r.Default)).ToList();
_byKey = Items.ToDictionary(i => i.Key);
}
/// <summary>XAML 索引子綁定入口:QuickBar[Point].IsChecked</summary>
public QuickBarItemViewModel this[string key] => _byKey[key];
/// <summary>套用保存的勾選清單;null = 維持預設(含未知 key 容錯:新版本新增按鈕用預設值)</summary>
public void Load(List<string>? visibleKeys)
{
if (visibleKeys is null) return;
var set = new HashSet<string>(visibleKeys, StringComparer.Ordinal);
foreach (QuickBarItemViewModel item in Items)
item.IsChecked = set.Contains(item.Key);
}
/// <summary>目前勾選清單(存 settings.json</summary>
public List<string> ToKeys() =>
Items.Where(i => i.IsChecked).Select(i => i.Key).ToList();
[RelayCommand]
private void ResetDefaults()
{
foreach (QuickBarItemViewModel item in Items)
item.IsChecked = item.Default;
}
}
@@ -0,0 +1,85 @@
using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using STPViewer.Services;
namespace STPViewer.ViewModels;
/// <summary>
/// STL 匯出參數對話框的 ViewModel:範圍 / 格式 / 單位縮放 / 網格精度 + 即時摘要(三角形數、預估大小)。
/// 對話框按「匯出」後由 MainViewModel.Export 讀取選項執行。
/// </summary>
public partial class StlExportViewModel : ObservableObject
{
/// <summary>可見(可匯出)檔案數</summary>
public int FileCount { get; }
/// <summary>目前顯示網格的三角形總數(精細/較粗會重新三角化,實際數量以結果為準)</summary>
public long TriangleCount { get; }
/// <summary>是否有 B-rep 來源(全 STL/DXF 時精度選項無意義 → 停用)</summary>
public bool HasBrep { get; }
public StlExportViewModel(int fileCount, long triangleCount, bool hasBrep)
{
FileCount = fileCount;
TriangleCount = triangleCount;
HasBrep = hasBrep;
}
/// <summary>0 = 全部可見合併成單一 STL;1 = 每個可見檔案各一個 STL</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Summary))]
private int scopeIndex;
/// <summary>0 = Binary(建議,檔案小);1 = ASCII(文字可讀,檔案大)</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Summary))]
private int formatIndex;
/// <summary>0 = mm1:1);1 = inch(÷25.4);2 = 自訂縮放</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Summary))]
[NotifyPropertyChangedFor(nameof(IsCustomScale))]
[NotifyPropertyChangedFor(nameof(CanConfirm))]
private int unitIndex;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Summary))]
[NotifyPropertyChangedFor(nameof(CanConfirm))]
private string customScaleText = "1.0";
/// <summary>0 = 目前顯示網格;1 = 精細(B-rep 重新三角化)</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Summary))]
private int qualityIndex;
public bool IsCustomScale => UnitIndex == 2;
public bool Ascii => FormatIndex == 1;
public bool PerFile => ScopeIndex == 1;
public StlMeshQuality Quality => (StlMeshQuality)QualityIndex;
/// <summary>座標縮放;自訂值無效時回 null(擋確認)</summary>
public double? Scale => UnitIndex switch
{
0 => 1.0,
1 => 1.0 / 25.4,
_ => double.TryParse(CustomScaleText, NumberStyles.Float, CultureInfo.InvariantCulture, out double s)
&& s > 0 && double.IsFinite(s) ? s : null,
};
public bool CanConfirm => Scale is not null;
public string Summary
{
get
{
long est = StlExportService.EstimateBytes(TriangleCount, Ascii);
string size = StlExportService.FormatBytes(est);
string basis = QualityIndex == 0
? $"預估 {size}"
: $"以目前網格預估 {size}(實際依重新三角化結果)";
return $"{FileCount} 個可見檔案 · {TriangleCount:N0} 三角形 · {basis}";
}
}
}
+28
View File
@@ -0,0 +1,28 @@
// One-shot diagnostic: print per-leaf world-space bounding boxes so we can
// figure out each connector's mating-face normal (axis the guide posts / holes point).
using STPViewer.Services;
namespace SmokeTest;
internal static class BBoxDump
{
public static int Run(string path)
{
var svc = new StepImportService();
ImportedFileData data = svc.Import(path);
System.Console.WriteLine($"=== {System.IO.Path.GetFileName(path)} ===");
Print(data.Root, 0);
return 0;
}
private static void Print(ImportedNode n, int depth)
{
var b = n.Bounds;
string box = b.IsEmpty
? "(empty)"
: $"X[{b.X,8:F1}..{b.X + b.SizeX,8:F1}] Y[{b.Y,8:F1}..{b.Y + b.SizeY,8:F1}] Z[{b.Z,8:F1}..{b.Z + b.SizeZ,8:F1}] " +
$"size {b.SizeX,7:F1} x {b.SizeY,7:F1} x {b.SizeZ,7:F1}";
System.Console.WriteLine($"{new string(' ', depth * 2)}{n.Name,-28} {box}");
foreach (ImportedNode c in n.Children) Print(c, depth + 1);
}
}
+53
View File
@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using CADability;
using CADability.GeoObject;
using STPViewer.Services;
using IOPath = System.IO.Path; // CADability.GeoObject.Path(曲線)撞名
namespace SmokeTest;
/// <summary>
/// STEP 匯出往返驗證(v0.5.0 匯出 STEP 功能的無 UI 驗證):
/// 匯入來源 STEP → 走與 MainViewModel.ExportStepFile 相同路徑寫出新檔 → 重新匯入比對實體數。
/// </summary>
public static class ExportTest
{
public static int Run(string sourcePath, string outPath)
{
Console.WriteLine($"=== ExportTest: {IOPath.GetFileName(sourcePath)} → {IOPath.GetFileName(outPath)} ===");
ImportedFileData data = new StepImportService().Import(sourcePath);
var geos = Collect(data.Root).Where(g => g is Solid or Shell).ToList();
Console.WriteLine($" 來源 Solid/Shell 物件 = {geos.Count}Solids = {data.Root.SolidCount}");
Project project = Project.CreateSimpleProject();
Model model = project.GetActiveModel();
foreach (IGeoObject g in geos) model.Add(g);
new ExportStep().WriteToFile(outPath, project);
var fi = new FileInfo(outPath);
Console.WriteLine($" 已寫出({fi.Length:N0} bytes");
if (fi.Length < 100)
{
Console.WriteLine("ExportTest: FAIL(輸出檔近乎空白)");
return 1;
}
ImportedFileData back = new StepImportService().Import(outPath);
Console.WriteLine($" 回讀 Solids = {back.Root.SolidCount}, Faces = {back.Root.FaceCount}");
bool ok = back.Root.SolidCount == data.Root.SolidCount && back.Root.FaceCount > 0;
Console.WriteLine(ok
? "ExportTest: PASS(實體數一致)"
: $"ExportTest: FAIL(來源 Solids {data.Root.SolidCount} ↔ 回讀 {back.Root.SolidCount}");
return ok ? 0 : 1;
}
private static IEnumerable<IGeoObject> Collect(ImportedNode n)
{
foreach (IGeoObject g in n.SourceGeos) yield return g;
foreach (ImportedNode c in n.Children)
foreach (IGeoObject g in Collect(c))
yield return g;
}
}
+41
View File
@@ -0,0 +1,41 @@
using System.IO;
namespace SmokeTest;
/// <summary>產生測試用 binary STL:一片 gridN×gridN 的網格,每格 2 個三角形(波浪起伏,避免全共面)。</summary>
internal static class MakeStl
{
public static void Run(string path, int triCount)
{
// 每格 2 三角,格數 = triCount/2;邊長 grid = ceil(sqrt(cells))
int cells = System.Math.Max(1, triCount / 2);
int grid = (int)System.Math.Ceiling(System.Math.Sqrt(cells));
int tris = grid * grid * 2;
using var fs = File.Create(path);
using var bw = new BinaryWriter(fs);
bw.Write(new byte[80]); // header
bw.Write((uint)tris); // triangle count
static void WriteTri(BinaryWriter w, float ax, float ay, float az,
float bx, float by, float bz, float cx, float cy, float cz)
{
for (int i = 0; i < 3; i++) w.Write(0f); // normal (0,0,0)
w.Write(ax); w.Write(ay); w.Write(az);
w.Write(bx); w.Write(by); w.Write(bz);
w.Write(cx); w.Write(cy); w.Write(cz);
w.Write((ushort)0); // attribute byte count
}
float Z(int x, int y) => (float)(System.Math.Sin(x * 0.1) * System.Math.Cos(y * 0.1) * 5.0);
for (int y = 0; y < grid; y++)
for (int x = 0; x < grid; x++)
{
float x0 = x, x1 = x + 1, y0 = y, y1 = y + 1;
WriteTri(bw, x0, y0, Z(x, y), x1, y0, Z(x + 1, y), x1, y1, Z(x + 1, y + 1));
WriteTri(bw, x0, y0, Z(x, y), x1, y1, Z(x + 1, y + 1), x0, y1, Z(x, y + 1));
}
Console.WriteLine($"寫出 {tris:N0} 三角形 → {path} ({new FileInfo(path).Length:N0} bytes)");
}
}
+15
View File
@@ -23,6 +23,12 @@ if (args[0] == "--make-dxf")
return 0;
}
if (args[0] == "--make-stl")
{
SmokeTest.MakeStl.Run(args[1], args.Length > 2 ? int.Parse(args[2]) : 1_500_000);
return 0;
}
if (args[0] == "--clip-test")
return SmokeTest.ClipTest.Run() == 0 ? 0 : 2;
@@ -32,6 +38,15 @@ if (args[0] == "--interference-test")
if (args[0] == "--align-test")
return SmokeTest.AlignTest.Run() == 0 ? 0 : 2;
if (args[0] == "--bbox")
return SmokeTest.BBoxDump.Run(args[1]);
if (args[0] == "--export-test")
return SmokeTest.ExportTest.Run(args[1], args[2]) == 0 ? 0 : 2;
if (args[0] == "--stl-export-test")
return SmokeTest.StlExportTest.Run(args.Length > 1 ? args[1] : null) == 0 ? 0 : 2;
var service = new StepImportService { Progress = s => Console.WriteLine($" [階段] {s}") };
int failed = 0;
+158
View File
@@ -0,0 +1,158 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Windows.Media;
using System.Windows.Media.Media3D;
using STPViewer.Services;
using IOPath = System.IO.Path; // CADability.GeoObject.Path(曲線)撞名
namespace SmokeTest;
/// <summary>
/// STL 匯出驗證(無 UI):
/// 1) binary — 已知立方體網格寫出 → 解析 header/三角形數/座標範圍比對(含縮放)
/// 2) ASCII — facet 數與 solid/endsolid 結構
/// 3) 退化三角形(零面積)自動濾除
/// </summary>
public static class StlExportTest
{
public static int Run(string? stepPath = null)
{
Console.WriteLine("=== StlExportTest ===");
int failed = 0;
string dir = IOPath.Combine(IOPath.GetTempPath(), "stpviewer_stl_test");
Directory.CreateDirectory(dir);
MeshGeometry3D cube = BuildCube(10); // 10×10×10 mm、12 三角形
// 1) binary:三角形數 + 縮放後座標範圍
{
string f = IOPath.Combine(dir, "cube_bin.stl");
double scale = 1.0 / 25.4; // mm → inch
int written = StlExportService.Write(f, new[] { cube }, ascii: false, scale);
(uint count, float min, float max) = ReadBinary(f);
bool ok = written == 12 && count == 12
&& Math.Abs(min - 0f) < 1e-5 && Math.Abs(max - 10f / 25.4f) < 1e-4;
Report(ref failed, ok, $"binary 寫出/回讀 12 三角形、inch 縮放範圍 [{min:F4}, {max:F4}]");
}
// 2) ASCIIfacet 數 + 結構
{
string f = IOPath.Combine(dir, "cube_ascii.stl");
int written = StlExportService.Write(f, new[] { cube }, ascii: true, scale: 1.0);
string text = File.ReadAllText(f);
int facets = CountOccurrences(text, "facet normal");
bool ok = written == 12 && facets == 12
&& text.StartsWith("solid ", StringComparison.Ordinal)
&& text.Contains("endsolid");
Report(ref failed, ok, $"ASCII 結構完整、facet = {facets}");
}
// 3) 退化三角形濾除:加一個零面積三角形 → 仍是 12
{
var degenerate = new MeshGeometry3D();
degenerate.Positions.Add(new Point3D(0, 0, 0));
degenerate.Positions.Add(new Point3D(1, 1, 1));
degenerate.Positions.Add(new Point3D(2, 2, 2)); // 共線 → 面積 0
degenerate.TriangleIndices.Add(0);
degenerate.TriangleIndices.Add(1);
degenerate.TriangleIndices.Add(2);
degenerate.Freeze();
string f = IOPath.Combine(dir, "cube_degen.stl");
int written = StlExportService.Write(f, new[] { cube, degenerate }, ascii: false, scale: 1.0);
(uint count, _, _) = ReadBinary(f);
bool ok = written == 12 && count == 12;
Report(ref failed, ok, $"退化三角形濾除(寫出 {written}");
}
// 4)(可選)真實 STEP:B-rep 精細重新三角化 → 寫 binary STL 驗證
//(不測「較粗」:CADability GetTriangulation 對比快取粗的精度回傳既有快取,粗化無效 → 功能已移除)
if (stepPath is not null)
{
var data = new StepImportService().Import(stepPath);
var geos = Collect(data.Root).ToList();
int importTris = data.Root.TriangleCount;
Console.WriteLine($" 來源 {IOPath.GetFileName(stepPath)}{geos.Count} 個 B-rep 物件、匯入網格 {importTris:N0} 三角形");
var meshes = StlExportService.Tessellate(
geos, StlExportService.PrecisionFactor(StlMeshQuality.Fine));
string f = IOPath.Combine(dir, "step_fine.stl");
int written = StlExportService.Write(f, meshes, ascii: false, scale: 1.0);
(uint count, _, _) = ReadBinary(f);
Report(ref failed, meshes.Count > 0 && written > 0 && count == written,
$"精細重新三角化 {meshes.Count} 面 → {written:N0} 三角形");
// CADability 三角形數對精度非單調(0.4× 反而比 1× 少),Fine=0.15× 實測明顯更細
Report(ref failed, written > importTris,
$"精細({written:N0}> 匯入預設({importTris:N0}");
}
Console.WriteLine(failed == 0 ? "StlExportTest: PASS" : $"StlExportTest: FAIL{failed} 項)");
return failed;
}
private static IEnumerable<CADability.GeoObject.IGeoObject> Collect(ImportedNode n)
{
foreach (CADability.GeoObject.IGeoObject g in n.SourceGeos) yield return g;
foreach (ImportedNode c in n.Children)
foreach (CADability.GeoObject.IGeoObject g in Collect(c))
yield return g;
}
private static void Report(ref int failed, bool ok, string what)
{
Console.WriteLine($" [{(ok ? "PASS" : "FAIL")}] {what}");
if (!ok) failed++;
}
/// <summary>0..size 的軸對齊立方體,12 個三角形(法向不講究,STL 寫出時自算)</summary>
private static MeshGeometry3D BuildCube(double s)
{
var m = new MeshGeometry3D();
Point3D[] v =
{
new(0, 0, 0), new(s, 0, 0), new(s, s, 0), new(0, s, 0),
new(0, 0, s), new(s, 0, s), new(s, s, s), new(0, s, s),
};
foreach (Point3D p in v) m.Positions.Add(p);
int[] quads = { 0,3,2,1, 4,5,6,7, 0,1,5,4, 1,2,6,5, 2,3,7,6, 3,0,4,7 };
for (int q = 0; q < quads.Length; q += 4)
{
m.TriangleIndices.Add(quads[q]); m.TriangleIndices.Add(quads[q + 1]); m.TriangleIndices.Add(quads[q + 2]);
m.TriangleIndices.Add(quads[q]); m.TriangleIndices.Add(quads[q + 2]); m.TriangleIndices.Add(quads[q + 3]);
}
m.Freeze();
return m;
}
private static (uint Count, float Min, float Max) ReadBinary(string path)
{
using var br = new BinaryReader(File.OpenRead(path));
byte[] header = br.ReadBytes(80);
if (Encoding.ASCII.GetString(header, 0, 5) == "solid")
throw new InvalidDataException("binary STL header 不可以 solid 開頭");
uint count = br.ReadUInt32();
float min = float.MaxValue, max = float.MinValue;
for (uint t = 0; t < count; t++)
{
br.ReadBytes(12); // normal
for (int k = 0; k < 9; k++)
{
float f = br.ReadSingle();
min = Math.Min(min, f);
max = Math.Max(max, f);
}
br.ReadUInt16(); // attribute
}
return (count, min, max);
}
private static int CountOccurrences(string text, string token)
{
int n = 0;
for (int i = text.IndexOf(token, StringComparison.Ordinal); i >= 0;
i = text.IndexOf(token, i + token.Length, StringComparison.Ordinal)) n++;
return n;
}
}